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
Generated
+1
@@ -4245,6 +4245,7 @@ dependencies = [
|
||||
"clap",
|
||||
"iroh",
|
||||
"netwatch",
|
||||
"nix 0.31.3",
|
||||
"simple-dns",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::ipc::{ControlSocket, request_status};
|
||||
use tsunagi::ipc::{ControlSocket, ControlSocketAccess, request_status};
|
||||
use tsunagi::ipc::{StatusReport, control_socket_path};
|
||||
use tsunagi::overlay::MemoryTunFactory;
|
||||
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
|
||||
@@ -130,9 +130,13 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
|
||||
// A short path: a Unix socket address is limited to about 100 bytes.
|
||||
let socket_path = dir_a.path().join("agent.sock");
|
||||
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
source(agent.clone(), plugin.clone()),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = wait_until("the overlay is reported as up", || {
|
||||
let socket_path = socket_path.clone();
|
||||
@@ -193,13 +197,13 @@ async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
|
||||
|
||||
// A file with nobody listening is a leftover from a crash.
|
||||
std::fs::write(&path, b"stale").unwrap();
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty))
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(request_status(&path).await.is_ok());
|
||||
|
||||
// A live socket is not stolen from the agent that owns it.
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty)).await;
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private).await;
|
||||
assert!(
|
||||
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
|
||||
"a second agent must not take over a live control socket"
|
||||
@@ -321,9 +325,13 @@ async fn a_client_can_leave_a_network_through_the_running_agent() {
|
||||
wait_for_peers(&agent, network_id, 1).await;
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
Arc::new(Control(agent.clone())),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
||||
.await
|
||||
@@ -361,9 +369,13 @@ async fn a_client_can_add_a_network_to_a_running_agent() {
|
||||
agent.join_network(&first, &first_secret).await.unwrap();
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
Arc::new(Control(agent.clone())),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::join_network(
|
||||
&socket_path,
|
||||
|
||||
@@ -44,6 +44,21 @@ use windows as transport;
|
||||
#[cfg(any(unix, windows))]
|
||||
pub use transport::ControlSocket;
|
||||
|
||||
/// Who may open the local control socket.
|
||||
///
|
||||
/// The default is owner-only. A system service shares its socket with a GUI or
|
||||
/// CLI running as another user by granting a group; the agent sets that on the
|
||||
/// socket when it binds it, so the access is correct from creation rather than
|
||||
/// patched afterwards.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ControlSocketAccess {
|
||||
/// Owner only — mode `0600`. The per-user agent's default.
|
||||
Private,
|
||||
/// The owner and members of this group — the socket's group is set to this
|
||||
/// gid and the mode to `0660`. The caller resolves a group name to its gid.
|
||||
Group(u32),
|
||||
}
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::error::{Error, Result};
|
||||
// The transport-agnostic surface, re-exported so this module is a complete
|
||||
// view of the local control interface on its own.
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
@@ -39,11 +39,15 @@ impl ControlSocket {
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)?;
|
||||
create_parent(parent, &access)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
@@ -61,7 +65,7 @@ impl ControlSocket {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict(&path)?;
|
||||
apply_access(&path, &access)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
@@ -94,9 +98,48 @@ impl Drop for ControlSocket {
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict(path: &Path) -> Result<()> {
|
||||
/// Creates the socket's parent directory.
|
||||
///
|
||||
/// A group-shared socket must sit in a directory the group can traverse, so it
|
||||
/// is `0755`; a private socket keeps the owner-only state-directory default.
|
||||
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => crate::storage::create_dir(parent),
|
||||
ControlSocketAccess::Group(_) => {
|
||||
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
set_mode(parent, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the access policy to the bound socket.
|
||||
///
|
||||
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
|
||||
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
|
||||
/// say — it falls back to owner-only rather than leaving the group wrong, so
|
||||
/// the socket is never more open than intended.
|
||||
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => set_mode(path, 0o600),
|
||||
ControlSocketAccess::Group(gid) => {
|
||||
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
|
||||
tracing::warn!(
|
||||
path = %path.display(), gid, %source,
|
||||
"cannot set the control socket group; keeping it owner-only"
|
||||
);
|
||||
return set_mode(path, 0o600);
|
||||
}
|
||||
set_mode(path, 0o660)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
@@ -170,7 +213,9 @@ mod tests {
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
let control = ControlSocket::bind(&path, source, ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
|
||||
@@ -40,8 +40,8 @@ use tokio::task::JoinHandle;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||
@@ -149,7 +149,16 @@ impl ControlSocket {
|
||||
/// Claiming the first instance of the name is what detects a second agent:
|
||||
/// if the name already exists, the create is refused and that is reported
|
||||
/// as the state being locked, so two live agents never share one pipe.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
///
|
||||
/// `_access` is accepted for parity with the Unix socket; granting a group
|
||||
/// here means a pipe security descriptor and is a separate follow-up, so for
|
||||
/// now the pipe keeps its default (creator plus the built-in access the
|
||||
/// `PipeSecurityAttributes` set).
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
_access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
|
||||
Reference in new issue
Block a user