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:
UltradesuandClaude Opus 4.8 committed 2026-10-02 14:41:58 +01:00
1 parent 8c812e6874
commit de8e48bee7
7 files changed
+161 -25

No files matched your search

+5
View File
@@ -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
+51 -2
View File
@@ -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)