feat(linux): run systemd service as unprivileged numa user

- 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.
This commit is contained in:
Razvan Dimescu
2026-04-18 07:56:59 +03:00
parent 34e2182ae4
commit 695a8b963c
2 changed files with 94 additions and 0 deletions

View File

@@ -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

View File

@@ -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)