Let the agent grant the control socket to a group (0660)
For a GUI (or a CLI) running as a different user to reach the agent, the control
socket needs to be group-accessible. This is set by the agent when it binds the
socket — correct from creation, not patched afterwards.
- ipc::ControlSocketAccess { Private, Group(gid) }. Private is the existing
owner-only 0600. Group sets the socket's group to the gid and mode 0660, and
creates the socket's parent directory 0755 so the group can traverse to it; if
the group cannot be set it falls back to owner-only rather than leaving it
wrong. The chown uses std::os::unix::fs::chown — no unsafe, no new lib
dependency.
- ControlSocket::bind takes the access policy (Windows accepts it and keeps its
pipe default for now; a pipe security descriptor is a later follow-up).
- CLI: `tsunagi up --control-group <name>` (env TSUNAGI_CONTROL_GROUP) resolves
the group to its gid via nix (safe wrapper, unix-only dep) and passes
Group(gid); without it the socket stays Private. A name that does not resolve
is reported, and the socket stays owner-only.
- control_socket() now serves the system path (default_control_socket_path) on
macOS too, not just Windows: /var/run/tsunagi/agent.sock, a runtime directory
the GUI can reach, rather than the owner-only state directory. A custom
--state-dir still keeps its own per-user socket.
So the packaged/daemon agent runs e.g. `tsunagi up --control-group tsunagi` and a
user in that group talks to it; a plain per-user agent is unchanged (0600).
Checks: fmt, clippy --workspace --all-targets -D warnings, tsunagi lib tests and
the wg-quic local_control tests pass on macOS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c812e6874
commit
de8e48bee7
7 files changed
+161
-25
No files matched your search
@@ -33,6 +33,11 @@ anstyle = "1.0"
|
||||
# Local interface addresses, for the diagnostics in `status`.
|
||||
netwatch = "0.19.3"
|
||||
|
||||
# Resolving a group name to its gid for `up --control-group`, through a safe
|
||||
# wrapper (no unsafe of our own). Unix only.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["user"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -482,6 +482,17 @@ struct UpArgs {
|
||||
/// Control socket to serve. Derived from the state directory by default.
|
||||
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
|
||||
control_socket: Option<PathBuf>,
|
||||
|
||||
/// Group whose members may use the control socket (Unix, mode 0660), so a
|
||||
/// GUI or CLI running as another user can reach the agent. Without it the
|
||||
/// socket is owner-only (0600). The group must already exist.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "GROUP",
|
||||
env = "TSUNAGI_CONTROL_GROUP",
|
||||
help_heading = "System"
|
||||
)]
|
||||
control_group: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads the shared secret from an argument or a file.
|
||||
@@ -748,6 +759,31 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the control-socket access policy from an optional group name.
|
||||
///
|
||||
/// The group must already exist (packaging/post-install creates it); a name
|
||||
/// that cannot be resolved is an error the caller reports rather than a silent
|
||||
/// fallback that would leave a GUI unable to connect without explanation.
|
||||
#[cfg(unix)]
|
||||
fn control_socket_access(group: Option<&str>) -> Result<tsunagi::ipc::ControlSocketAccess, String> {
|
||||
match group {
|
||||
None => Ok(tsunagi::ipc::ControlSocketAccess::Private),
|
||||
Some(name) => {
|
||||
let group = nix::unistd::Group::from_name(name)
|
||||
.map_err(|err| format!("cannot look up group `{name}`: {err}"))?
|
||||
.ok_or_else(|| format!("group `{name}` does not exist; create it first"))?;
|
||||
Ok(tsunagi::ipc::ControlSocketAccess::Group(group.gid.as_raw()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn control_socket_access(
|
||||
_group: Option<&str>,
|
||||
) -> Result<tsunagi::ipc::ControlSocketAccess, String> {
|
||||
Ok(tsunagi::ipc::ControlSocketAccess::Private)
|
||||
}
|
||||
|
||||
/// Path of the local control socket for a state directory.
|
||||
fn control_socket(
|
||||
paths: &StoragePaths,
|
||||
@@ -757,7 +793,10 @@ fn control_socket(
|
||||
if let Some(path) = override_path {
|
||||
return path.clone();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
// On Windows and macOS the agent runs as a system service and serves at a
|
||||
// fixed system path (a runtime directory the GUI can reach), not inside the
|
||||
// owner-only state directory.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
if !_is_custom_state_dir {
|
||||
return tsunagi::ipc::default_control_socket_path();
|
||||
}
|
||||
@@ -3859,7 +3898,17 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let path = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
match tsunagi::ipc::ControlSocket::bind(path, source).await {
|
||||
let access = match control_socket_access(args.control_group.as_deref()) {
|
||||
Ok(access) => access,
|
||||
Err(reason) => {
|
||||
eprintln!(
|
||||
"warning: cannot grant the control socket to a group ({reason}); \
|
||||
it will be owner-only"
|
||||
);
|
||||
tsunagi::ipc::ControlSocketAccess::Private
|
||||
}
|
||||
};
|
||||
match tsunagi::ipc::ControlSocket::bind(path, source, access).await {
|
||||
Ok(socket) => {
|
||||
println!(" control {}", socket.path().display());
|
||||
Some(socket)
|
||||
|
||||
Reference in new issue
Block a user