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:
1 parent
192ab25bbd
commit
ac5a1596ef
6 files changed
+176
-38
No files matched your search
Generated
+1
@@ -4231,6 +4231,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tsunagi",
|
||||
"tun",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user