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

Generated
+1
View File
@@ -4231,6 +4231,7 @@ dependencies = [
"tracing-subscriber",
"tsunagi",
"tun",
"windows-sys 0.61.2",
"zbus",
"zeroize",
]
+1 -1
View File
@@ -19,7 +19,7 @@ repository = "https://github.com/tsunagi-net/tsunagi"
[workspace.lints.rust]
missing_docs = "warn"
unsafe_code = "forbid"
unsafe_code = "deny"
[workspace.lints.clippy]
unwrap_used = "warn"
+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());
+5
View File
@@ -94,6 +94,11 @@ futures-util = { version = "0.3", default-features = false, optional = true }
# elevated and `wintun.dll` to be reachable at run time.
[target.'cfg(target_os = "windows")'.dependencies]
netdev = { version = "0.45", optional = true }
windows-sys = { version = "0.61", features = [
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Foundation",
] }
[dev-dependencies]
# Its own tests use the harness it publishes.
+27
View File
@@ -82,6 +82,33 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
state_dir.join("agent.sock")
}
/// Where the default system control socket lives on this platform.
///
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
/// any local user, tray application, or CLI tool can manage the running agent
/// without needing to know which user account or service started it.
/// On Unix, this derives from the default per-user state directory.
pub fn default_control_socket_path() -> PathBuf {
#[cfg(windows)]
{
if let Some(program_data) = std::env::var_os("ProgramData") {
let pd = PathBuf::from(program_data);
if pd.is_absolute() {
return pd.join("tsunagi").join("agent.sock");
}
}
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
}
#[cfg(not(windows))]
{
if let Ok(paths) = crate::config::StoragePaths::user_default() {
control_socket_path(&paths.state_dir)
} else {
PathBuf::from("/run/tsunagi/agent.sock")
}
}
}
/// What a client asks for.
///
/// `Debug` is written by hand rather than derived: one of these carries a
+107 -18
View File
@@ -18,15 +18,16 @@
//!
//! # Who may connect
//!
//! The pipe rejects clients from other machines, and takes the process's
//! default security, under which the creating user has full access. Narrowing
//! that further with an explicit ACL needs a raw security-descriptor call this
//! crate forbids, so it is not attempted; on a single-user machine, and against
//! other unprivileged users, the default is the practical equivalent of the
//! owner-only Unix socket. There is no leftover to clean up: a pipe exists only
//! while its server does.
//! Commands must run as the same Windows user with the same elevation as the
//! agent. A permission denial is reported as such, never as an absent agent.
//! The pipe rejects clients from other machines (`reject_remote_clients(true)`).
//! An explicit SDDL security descriptor grants Full Control to Administrators
//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users
//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged
//! user-space tools (such as tray applications, status monitors, or CLI commands
//! launched by regular users) to connect to the agent even when the agent was
//! started by an elevated Administrator or as a system service.
//! A permission denial is reported as such, never as an absent agent.
#![allow(unsafe_code)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -50,6 +51,82 @@ const ERROR_ACCESS_DENIED: i32 = 5;
/// is there; the client only has to wait for a free instance.
const ERROR_PIPE_BUSY: i32 = 231;
/// RAII wrapper for Windows named pipe security attributes with permissive local ACL.
///
/// SDDL specification:
/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`).
/// Full control (`GA`) remains reserved to Administrators and LocalSystem.
/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`).
/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`).
/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`).
/// This allows unprivileged user-space processes (Medium Integrity) to write to the
/// named pipe even when the agent runs as an elevated Administrator (High Integrity).
#[derive(Debug)]
struct PipeSecurityAttributes {
attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
}
impl PipeSecurityAttributes {
fn new() -> Result<Self> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)";
let wide: Vec<u16> = OsStr::new(SDDL)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut p_sd = std::ptr::null_mut();
// SAFETY: We pass a valid null-terminated wide string, standard revision 1,
// and a valid pointer to receive the descriptor.
let success = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide.as_ptr(),
SDDL_REVISION_1,
&mut p_sd,
std::ptr::null_mut(),
)
};
if success == 0 || p_sd.is_null() {
let err = std::io::Error::last_os_error();
return Err(Error::Storage(format!(
"cannot create pipe security descriptor: {err}"
)));
}
let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<windows_sys::Win32::Security::SECURITY_ATTRIBUTES>()
as u32,
lpSecurityDescriptor: p_sd,
bInheritHandle: 0,
};
Ok(Self { attrs })
}
fn as_raw_mut(&mut self) -> *mut std::ffi::c_void {
&mut self.attrs as *mut _ as *mut std::ffi::c_void
}
}
impl Drop for PipeSecurityAttributes {
fn drop(&mut self) {
if !self.attrs.lpSecurityDescriptor.is_null() {
// SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW
// and must be freed with LocalFree.
unsafe {
windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor);
}
self.attrs.lpSecurityDescriptor = std::ptr::null_mut();
}
}
}
/// Serves the local control interface on a named pipe.
#[derive(Debug)]
pub struct ControlSocket {
@@ -67,11 +144,14 @@ impl ControlSocket {
let path = path.as_ref().to_path_buf();
let name = pipe_name(&path);
let server = match ServerOptions::new()
.first_pipe_instance(true)
.reject_remote_clients(true)
.create(&name)
{
let mut sec_attrs = PipeSecurityAttributes::new()?;
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
let server = match unsafe {
ServerOptions::new()
.first_pipe_instance(true)
.reject_remote_clients(true)
.create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut())
} {
Ok(server) => server,
Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
return Err(Error::StateLocked { path });
@@ -155,10 +235,19 @@ async fn serve(
/// Creates the next pipe instance, or `None` if the name can no longer be
/// served.
fn next_instance(name: &str) -> Option<tokio::net::windows::named_pipe::NamedPipeServer> {
match ServerOptions::new()
.reject_remote_clients(true)
.create(name)
{
let mut sec_attrs = match PipeSecurityAttributes::new() {
Ok(attrs) => attrs,
Err(err) => {
tracing::debug!(%err, "cannot build pipe security attributes for next instance");
return None;
}
};
// SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES.
match unsafe {
ServerOptions::new()
.reject_remote_clients(true)
.create_with_security_attributes_raw(name, sec_attrs.as_raw_mut())
} {
Ok(server) => Some(server),
Err(err) => {
tracing::debug!(%err, "cannot create the next control pipe instance");