Allow unelevated Windows processes to access control socket via SDDL and shared path

When running the tsunagi agent as an elevated Administrator or Windows Service,
unprivileged user-space processes (such as a system tray application, user-level
CLI commands, or status monitors) could not interact with the control socket.
This was due to two constraints on Windows:
1. The default control socket path was derived from the per-user %APPDATA%,
   making an administrator's socket invisible or inaccessible to a non-elevated
   standard user profile.
2. Even if pointed to the same named pipe, Windows Mandatory Integrity Control (MIC)
   and default ACLs prevent a Medium-Integrity (unelevated) process from opening
   a High-Integrity (elevated) pipe server for write access unless explicitly allowed.

Key changes:
- In crates/tsunagi/src/ipc/windows.rs, implement PipeSecurityAttributes using
  Win32 ConvertStringSecurityDescriptorToSecurityDescriptorW with SDDL:
  "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)". This grants generic
  read/write access to Everyone (WD), retains full control for Administrators (BA)
  and LocalSystem (SY), and sets the Mandatory Label SACL to Medium Integrity (ME)
  with No-Write-Up (NW).
- In crates/tsunagi/src/ipc/mod.rs, introduce default_control_socket_path() which
  points to %ProgramData%\tsunagi\agent.sock on Windows (and default per-user
  state directory on Unix).
- In crates/tsunagi-cli/src/main.rs, support TSUNAGI_CONTROL_SOCKET environment
  variable across all commands, and use default_control_socket_path() on Windows
  when running against the default state directory. Custom state directories (--state-dir)
  or explicit sockets (--control-socket) continue to take precedence, allowing
  multi-daemon setups to remain strictly isolated.
- In workspace Cargo.toml, set unsafe_code = "deny" so the workspace forbids unsafe
  code while permitting explicit, documented unsafe Win32 API calls in windows.rs.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets --jobs 2 -- --test-threads=2 passed (349 tests passed).
This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-01 00:13:44 +01:00
1 parent 192ab25bbd
commit ac5a1596ef
6 files changed
+176 -38

No files matched your search

+35 -19
View File
@@ -82,7 +82,7 @@ struct DnsArgs {
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long, global = true)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)]
control_socket: Option<PathBuf>,
#[command(subcommand)]
@@ -107,7 +107,7 @@ struct NetworkArgs {
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long, global = true)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)]
control_socket: Option<PathBuf>,
#[command(subcommand)]
@@ -132,7 +132,7 @@ struct JoinArgs {
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
control_socket: Option<PathBuf>,
/// Network name. Must be identical on every participant.
@@ -233,7 +233,7 @@ struct WipeArgs {
paths: PathArgs,
/// Control socket to check for a running agent.
#[arg(long)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
control_socket: Option<PathBuf>,
/// Actually remove it. Without this the command only says what it would.
@@ -247,7 +247,7 @@ struct IdArgs {
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long, global = true)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)]
control_socket: Option<PathBuf>,
#[command(subcommand)]
@@ -284,7 +284,7 @@ struct StatusArgs {
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
control_socket: Option<PathBuf>,
}
@@ -476,7 +476,7 @@ struct UpArgs {
status_interval: u64,
/// Control socket to serve. Derived from the state directory by default.
#[arg(long)]
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
control_socket: Option<PathBuf>,
}
@@ -745,11 +745,19 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
}
/// Path of the local control socket for a state directory.
fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> PathBuf {
match override_path {
Some(path) => path.clone(),
None => tsunagi::ipc::control_socket_path(&paths.state_dir),
fn control_socket(
paths: &StoragePaths,
override_path: Option<&PathBuf>,
_is_custom_state_dir: bool,
) -> PathBuf {
if let Some(path) = override_path {
return path.clone();
}
#[cfg(windows)]
if !_is_custom_state_dir {
return tsunagi::ipc::default_control_socket_path();
}
tsunagi::ipc::control_socket_path(&paths.state_dir)
}
/// What could be learned about this device, and from where.
@@ -1580,7 +1588,8 @@ fn resolve_network<'a>(
/// `tsunagi network`: what this device belongs to, and leaving it.
async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
match args.action {
None => show_networks(&paths, &socket).await,
Some(NetworkAction::Broadcast { network, choice }) => {
@@ -1614,7 +1623,8 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
/// `tsunagi join`: make a network or join one.
async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let name = NetworkName::new(args.network)?;
// A name this device already has means that network; a name nobody
// has means a new one, and no secret is needed to make a network with
@@ -2000,7 +2010,8 @@ fn forget_protocol_state(paths: &StoragePaths, network: tsunagi::NetworkId) {
/// `tsunagi wipe`: back to a device that has never joined anything.
async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
if tsunagi::ipc::is_serving(&socket).await {
return Err(
"stop the agent first: a wipe removes the state it is using, and leaving a \
@@ -2060,7 +2071,8 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
/// `tsunagi dns`: the local resolver, and turning it on or off.
async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let enable = match args.action {
None => return show_dns(&paths, &socket).await,
@@ -2152,7 +2164,8 @@ async fn show_dns(
/// `tsunagi id`: what this device is, and what changes it.
async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
match args.action {
None => show_identity(&paths, &socket).await,
@@ -2343,7 +2356,8 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
use report::{Health, Report, Row, Section};
let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let observed = observe(&paths, &socket).await;
let mut out = Report::new();
@@ -3559,7 +3573,8 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
// agent that is already running, so the lock on its own is an
// answer to a question nobody asked.
Err(tsunagi::Error::StateLocked { path }) => {
let socket = control_socket(&paths, args.control_socket.as_ref());
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
if tsunagi::ipc::is_serving(&socket).await {
return Err(format!(
"an agent is already running for {}, and one state directory is one \
@@ -3645,7 +3660,8 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
dns,
paths: paths.clone(),
});
let path = control_socket(&paths, args.control_socket.as_ref());
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 {
Ok(socket) => {
println!(" control {}", socket.path().display());