From 695a8b963c045fb5f7147b24a8610e3e5cac694f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 07:56:59 +0300 Subject: [PATCH] feat(linux): run systemd service as unprivileged numa user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numa.service: User=numa + CAP_NET_BIND_SERVICE + sandboxing block (ProtectSystem=strict, PrivateTmp, seccomp @system-service, etc) - install_service_linux: create numa system user + chown data_dir before first start so TLS-cert generation and state writes land on a numa-owned tree Runtime verified root-free on Linux — network_watch_loop only reads /etc/resolv.conf; all system-DNS mutation stays in the installer, which continues to run as root via sudo. --- numa.service | 34 +++++++++++++++++++++++++++ src/system_dns.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/numa.service b/numa.service index 7e67296..6894078 100644 --- a/numa.service +++ b/numa.service @@ -8,6 +8,40 @@ Type=simple ExecStart={{exe_path}} Restart=always RestartSec=2 + +User=numa +Group=numa + +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +# StateDirectory maps to crate::data_dir() default on Linux (/var/lib/numa). +# systemd auto-creates + chowns on every start, fixing legacy root-owned trees. +StateDirectory=numa +StateDirectoryMode=0750 +ConfigurationDirectory=numa +ConfigurationDirectoryMode=0755 + +# Sandboxing +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +# AF_NETLINK for interface enumeration on network changes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK + StandardOutput=journal StandardError=journal SyslogIdentifier=numa diff --git a/src/system_dns.rs b/src/system_dns.rs index b70b9d9..7b4de42 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1664,8 +1664,68 @@ fn uninstall_linux() -> Result<(), String> { Ok(()) } +#[cfg(target_os = "linux")] +const NUMA_USER: &str = "numa"; + +#[cfg(target_os = "linux")] +fn ensure_numa_user_linux() -> Result<(), String> { + let _ = std::process::Command::new("groupadd") + .args(["-f", "-r", NUMA_USER]) + .status(); + + let data_dir = crate::data_dir(); + let status = std::process::Command::new("useradd") + .args([ + "-r", + "-g", + NUMA_USER, + "-d", + &data_dir.to_string_lossy(), + "-s", + "/usr/sbin/nologin", + "-c", + "Numa DNS service", + NUMA_USER, + ]) + .status() + .map_err(|e| format!("failed to run useradd: {}", e))?; + + // useradd exit 9 = "username already in use"; idempotent reinstall. + match status.code() { + Some(0) | Some(9) => Ok(()), + Some(code) => Err(format!("useradd {} failed (exit {})", NUMA_USER, code)), + None => Err(format!("useradd {} killed by signal", NUMA_USER)), + } +} + +#[cfg(target_os = "linux")] +fn chown_data_dir_to_numa_linux() -> Result<(), String> { + let dir = crate::data_dir(); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("failed to create {}: {}", dir.display(), e))?; + let owner = format!("{0}:{0}", NUMA_USER); + let status = std::process::Command::new("chown") + .args(["-R", &owner, &dir.to_string_lossy()]) + .status() + .map_err(|e| format!("failed to run chown: {}", e))?; + if !status.success() { + return Err(format!( + "chown {} failed (exit {})", + dir.display(), + status.code().unwrap_or(-1) + )); + } + Ok(()) +} + #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { + // Create the numa account and hand it ownership of data_dir before the + // first start — TLS-cert generation and state writes happen on the + // unit's first launch and need to land on a numa-owned tree. + ensure_numa_user_linux()?; + chown_data_dir_to_numa_linux()?; + let unit = include_str!("../numa.service"); let unit = replace_exe_path(unit)?; std::fs::write(SYSTEMD_UNIT, unit)