From 695a8b963c045fb5f7147b24a8610e3e5cac694f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 07:56:59 +0300 Subject: [PATCH 01/11] 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) From 41aea1dd12b85382b40e4e345ace504153ad0948 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:10:04 +0300 Subject: [PATCH 02/11] fix(linux): drop risky sandbox directives that break Rust network daemons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration test failed with exit 7 on curl to /health after a successful install — service started but never listened. The likely culprits are MemoryDenyWriteExecute (breaks jemalloc/some crypto), SystemCallFilter ~@privileged @resources (blocks setrlimit and friends tokio may use), and RestrictNamespaces/LockPersonality (occasional foot-guns). Pull them and keep a conservative hardening set that's well-tested with Rust network services: no-new-privs, protect-system/home, private tmp and devices, protect-kernel-*, restrict-realtime/suid/address-families. Layer the aggressive bits back in follow-up PRs once tested individually. --- numa.service | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/numa.service b/numa.service index 6894078..44e90c5 100644 --- a/numa.service +++ b/numa.service @@ -22,7 +22,9 @@ StateDirectoryMode=0750 ConfigurationDirectory=numa ConfigurationDirectoryMode=0755 -# Sandboxing +# Sandboxing — conservative set known to work with Rust network daemons. +# Aggressive hardening (MemoryDenyWriteExecute, SystemCallFilter, seccomp +# allow-lists) can be layered on once tested in isolation. NoNewPrivileges=true ProtectSystem=strict ProtectHome=true @@ -31,14 +33,8 @@ 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 From 4f6159d9616bf485af38bacaf08ed98a5afe0aa5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:20:07 +0300 Subject: [PATCH 03/11] refactor(linux): switch to DynamicUser=yes, drop install-time user creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUR installs never call `numa install` — PKGBUILD drops the unit straight into /usr/lib/systemd/system and the user runs `systemctl enable numa`. With User=numa the Rust installer's useradd code never fires there, breaking Arch out of the box. DynamicUser=yes sidesteps packaging entirely — systemd allocates a transient UID per start and remaps StateDirectory ownership (including legacy root-owned trees) automatically. Works on any modern systemd. Drops the ensure_numa_user_linux/chown helpers plus NUMA_USER; the unit file alone now captures the privilege-drop story. --- numa.service | 8 +++---- src/system_dns.rs | 60 ----------------------------------------------- 2 files changed, 4 insertions(+), 64 deletions(-) diff --git a/numa.service b/numa.service index 44e90c5..5380b83 100644 --- a/numa.service +++ b/numa.service @@ -9,14 +9,14 @@ ExecStart={{exe_path}} Restart=always RestartSec=2 -User=numa -Group=numa +# Transient system user per start; no PKGBUILD/sysusers setup required. +# systemd remaps the StateDirectory ownership to the dynamic UID on each +# launch, including legacy root-owned trees from pre-drop installs. +DynamicUser=yes 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 diff --git a/src/system_dns.rs b/src/system_dns.rs index 7b4de42..b70b9d9 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1664,68 +1664,8 @@ 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) From dfeca53e21f1012da4a5cc1183dae85b54f796ad Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:48:53 +0300 Subject: [PATCH 04/11] ci: dump journalctl + systemctl status on integration-linux failure --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e116744..4bce7c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,17 @@ jobs: sudo ./target/release/numa uninstall sleep 1 ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + - name: diagnostics on failure + if: failure() + run: | + echo "=== systemctl status numa ===" + sudo systemctl status numa --no-pager -l || true + echo "=== journalctl -u numa (last 200) ===" + sudo journalctl -u numa --no-pager -n 200 || true + echo "=== ss -tulnp on 53/80/443/853/5380 ===" + sudo ss -tulnp 2>/dev/null | grep -E ':(53|80|443|853|5380)\b' || true + echo "=== systemctl is-active systemd-resolved ===" + systemctl is-active systemd-resolved || true - name: cleanup if: always() run: | From 7b9db9e889915cbdcf6394bb077d51d8bbf02ba5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:54:34 +0300 Subject: [PATCH 05/11] =?UTF-8?q?fix(linux):=20drop=20ProtectHome=3Dtrue?= =?UTF-8?q?=20=E2=80=94=20blocks=20exec=20when=20binary=20lives=20under=20?= =?UTF-8?q?/home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration-linux journalctl showed status=203/EXEC: systemd couldn't exec /home/runner/work/numa/numa/target/release/numa because ProtectHome=yes makes /home invisible to the sandboxed process. My local Docker test passed because the binary was at /workspace, not /home. DynamicUser=yes already implies ProtectHome=read-only, which preserves exec access to binaries living under /home (cargo install, source builds, CI) while blocking writes to user $HOMEs. Keep that default rather than over-restricting. Follow-up worth tracking: install_service_linux could copy the binary to /usr/local/bin/numa the way Windows does at windows_service_exe_path, making the unit's ExecStart independent of where `numa install` was invoked from — then we could set ProtectHome=yes again. --- numa.service | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/numa.service b/numa.service index 5380b83..4794033 100644 --- a/numa.service +++ b/numa.service @@ -27,7 +27,10 @@ ConfigurationDirectoryMode=0755 # allow-lists) can be layered on once tested in isolation. NoNewPrivileges=true ProtectSystem=strict -ProtectHome=true +# DynamicUser= sets ProtectHome=read-only by default — leaves /home +# readable so systemd can exec binaries installed under it (cargo install, +# source builds), while blocking writes to user $HOMEs. Don't set =yes: +# that hides /home entirely and fails with status=203/EXEC. PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true From 3970a9f45c23d4c751a5ed1ff849610e51eee075 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 11:51:32 +0300 Subject: [PATCH 06/11] fix(linux): copy binary to /usr/local/bin when source path isn't world-traversable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DynamicUser=yes' transient account can only traverse world-x directories. The CI binary at /home/runner/work/numa/numa/target/release/numa fails exec with EACCES because /home/runner is mode 0700; same applies to a build under /home//, ~/.cargo/bin, or any private $HOME tree. install_service_binary_linux now walks the binary's path. If every ancestor grants world-execute (Linuxbrew /home/linuxbrew is 0755, /usr/local/bin is fine, install.sh layout works), keep the source path so brew/distro upgrades propagate in place. Otherwise copy to /usr/local/bin/numa and reference that in the unit. Locally verified both branches in an Ubuntu 24.04 systemd container: - CI-like /home/runner (0700) → copies + service binds 5380 - Brew-like /home/linuxbrew (0755) → keeps source path + service binds 5380 --- src/system_dns.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index b70b9d9..726cc1a 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1664,10 +1664,65 @@ fn uninstall_linux() -> Result<(), String> { Ok(()) } +/// Fallback install location when current_exe() sits on a path the +/// dynamic user cannot traverse (e.g. `/home//` mode 0700). +#[cfg(target_os = "linux")] +fn linux_service_exe_path() -> std::path::PathBuf { + std::path::PathBuf::from("/usr/local/bin/numa") +} + +/// True iff every ancestor of `p` (excluding `/`) grants world-execute — +/// i.e. the `DynamicUser=yes` service account can traverse the path and +/// exec the binary without being in any group. Linuxbrew's +/// `/home/linuxbrew` is 0755 (traversable, keep brew's path, upgrades +/// via `brew` propagate). A build tree under `/home//` (0700) or +/// `~/.cargo/bin/` is not (copy to /usr/local/bin so systemd can reach it). +#[cfg(target_os = "linux")] +fn path_world_traversable_linux(p: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + let mut current = p; + while let Some(parent) = current.parent() { + if parent.as_os_str().is_empty() || parent == std::path::Path::new("/") { + break; + } + match std::fs::metadata(parent) { + Ok(m) if m.permissions().mode() & 0o001 != 0 => {} + _ => return false, + } + current = parent; + } + true +} + +#[cfg(target_os = "linux")] +fn install_service_binary_linux() -> Result { + let src = std::env::current_exe().map_err(|e| format!("current_exe(): {}", e))?; + if path_world_traversable_linux(&src) { + return Ok(src); + } + let dst = linux_service_exe_path(); + if src == dst { + return Ok(dst); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + std::fs::copy(&src, &dst).map_err(|e| { + format!( + "failed to copy {} -> {}: {}", + src.display(), + dst.display(), + e + ) + })?; + Ok(dst) +} + #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { - let unit = include_str!("../numa.service"); - let unit = replace_exe_path(unit)?; + let exe = install_service_binary_linux()?; + let unit = include_str!("../numa.service").replace("{{exe_path}}", &exe.to_string_lossy()); std::fs::write(SYSTEMD_UNIT, unit) .map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?; From e19505aa952d9ff78d5ecd7e8edc52428401b292 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 11:57:54 +0300 Subject: [PATCH 07/11] fix(linux): narrow replace_exe_path cfg to macos after Linux inlined the substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux install_service_linux now does the {{exe_path}} substitution inline because it uses the (potentially copied) binary path returned by install_service_binary_linux, not current_exe(). The shared replace_exe_path helper is dead on Linux — clippy -D warnings caught it. Narrow the function to macos and split the placeholder test: keep the "both templates contain {{exe_path}}" assertion as a cross-platform test (catches placeholder removal on either file), keep the substitution test gated to macos where the function lives. --- src/system_dns.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 726cc1a..60701e3 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1416,7 +1416,7 @@ pub fn service_status() -> Result<(), String> { } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn replace_exe_path(service: &str) -> Result { let exe_path = std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?; @@ -2050,22 +2050,25 @@ Wireless LAN adapter Wi-Fi: } #[test] - #[cfg(any(target_os = "macos", target_os = "linux"))] - fn replace_exe_path_substitutes_template() { + fn install_templates_contain_exe_path_placeholder() { + // Both files are substituted at install time — plist via + // replace_exe_path on macOS, numa.service via inline .replace + // in install_service_linux. Catch placeholder removal early. let plist = include_str!("../com.numa.dns.plist"); let unit = include_str!("../numa.service"); - assert!(plist.contains("{{exe_path}}"), "plist missing placeholder"); assert!( unit.contains("{{exe_path}}"), "unit file missing placeholder" ); + } + #[test] + #[cfg(target_os = "macos")] + fn replace_exe_path_substitutes_template() { + let plist = include_str!("../com.numa.dns.plist"); let result = replace_exe_path(plist).expect("replace_exe_path failed for plist"); assert!(!result.contains("{{exe_path}}")); - - let result = replace_exe_path(unit).expect("replace_exe_path failed for unit"); - assert!(!result.contains("{{exe_path}}")); } #[test] From 067195f2abd9444c34e1e85bed9104d03f0a0d42 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 12:12:11 +0300 Subject: [PATCH 08/11] fix(linux): atomic binary copy + restart instead of start on re-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-install failed with ETXTBSY (Text file busy) because std::fs::copy can't overwrite a binary that's currently being executed by the running service. Switch to copy-then-rename: write the new binary to /usr/local/bin/numa.new, then rename over /usr/local/bin/numa. Rename swaps the path while the running process keeps the old inode alive, so DNS keeps serving from the previous binary until restart. Bump systemctl start to restart so the new binary actually loads on re-install (start is a no-op when the unit is already active, which would silently leave the old binary running). Locally verified the full CI sequence: install → curl → reinstall → curl → uninstall → curl-fails. All three assertions pass. --- src/system_dns.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 60701e3..5a7b999 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1708,10 +1708,18 @@ fn install_service_binary_linux() -> Result { std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; } - std::fs::copy(&src, &dst).map_err(|e| { + // Atomic replace via temp + rename. Plain copy fails with ETXTBSY when + // re-installing while the service is running the previous binary — + // rename swaps the path while the running process keeps the old inode. + let tmp = dst.with_extension("new"); + std::fs::copy(&src, &tmp).map_err(|e| { + format!("failed to copy {} -> {}: {}", src.display(), tmp.display(), e) + })?; + std::fs::rename(&tmp, &dst).map_err(|e| { + let _ = std::fs::remove_file(&tmp); format!( - "failed to copy {} -> {}: {}", - src.display(), + "failed to rename {} -> {}: {}", + tmp.display(), dst.display(), e ) @@ -1734,7 +1742,9 @@ fn install_service_linux() -> Result<(), String> { eprintln!(" warning: failed to configure system DNS: {}", e); } - run_systemctl(&["start", "numa"])?; + // restart, not start: on re-install the service is already running + // the previous binary; restart picks up the new one. + run_systemctl(&["restart", "numa"])?; eprintln!(" Service installed and started."); eprintln!(" Numa will auto-start on boot and restart if killed."); From 763131478f21dd56708f680b72b1b96acc7acb23 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 12:15:44 +0300 Subject: [PATCH 09/11] fmt: rustfmt format! macro split --- src/system_dns.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 5a7b999..fd16e8b 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1713,7 +1713,12 @@ fn install_service_binary_linux() -> Result { // rename swaps the path while the running process keeps the old inode. let tmp = dst.with_extension("new"); std::fs::copy(&src, &tmp).map_err(|e| { - format!("failed to copy {} -> {}: {}", src.display(), tmp.display(), e) + format!( + "failed to copy {} -> {}: {}", + src.display(), + tmp.display(), + e + ) })?; std::fs::rename(&tmp, &dst).map_err(|e| { let _ = std::fs::remove_file(&tmp); From b02b607fb908781c6584665bbe3e6c477afb2058 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 20:07:24 +0300 Subject: [PATCH 10/11] ci(linux): assert numa daemon does not run as root Locks in the invariant this branch establishes: a regression that reverts to User=root would otherwise ship green. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bce7c2..1e015ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,9 @@ jobs: sleep 2 curl -sf http://127.0.0.1:5380/health dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.' + user=$(ps -o user= -p "$(systemctl show -p MainPID --value numa)" | tr -d ' ') + echo "numa running as: $user" + test "$user" != "root" sudo ./target/release/numa install sleep 2 curl -sf http://127.0.0.1:5380/health From fb41a6f8b59b846d2413812ab823a40735b38130 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 22:00:54 +0300 Subject: [PATCH 11/11] test(linux): systemd service install verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scenarios CI cannot run: every advertised port is functional (DNS resolves, TLS chain validates against numa's CA, HTTP/API respond), CA fingerprint survives upgrade from pre-drop layout, binary staging fallback from a 0700 source dir. Self-bootstraps a privileged systemd-as-PID1 container — no dependency on long-lived test containers. MainPID user assertion retries until comm=numa to avoid a race where systemctl reports active while MainPID still points at a transitional process. --- tests/docker/install-systemd.sh | 288 ++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100755 tests/docker/install-systemd.sh diff --git a/tests/docker/install-systemd.sh b/tests/docker/install-systemd.sh new file mode 100755 index 0000000..aa9c31a --- /dev/null +++ b/tests/docker/install-systemd.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# +# Systemd service install verification for the DynamicUser-based Linux +# service unit. Stands up a privileged ubuntu:24.04 container with systemd +# as PID 1, builds numa inside, runs three scenarios that CI does not: +# +# A. Fresh install — every advertised port is not just bound but +# functional (DNS resolves on :53, TLS handshake validates against +# numa's CA on :853/:443, HTTP responds on :80, API on :5380). +# B. Upgrade from pre-drop layout (root-owned /var/lib/numa) preserves +# the CA fingerprint — users' browser-installed CA trust survives. +# C. Install from a 0700 source directory stages the binary under +# /usr/local/bin/numa and the service starts from there. +# +# First run is slow (~5-10 min): image pull + apt + cold cargo build. +# Subsequent runs reuse cached docker volumes for cargo + target (~30s). +# +# Requirements: docker +# Usage: ./tests/docker/install-systemd.sh + +set -u +set -o pipefail + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +pass() { printf " ${GREEN}PASS${RESET}: %s\n" "$*"; } +fail() { printf " ${RED}FAIL${RESET}: %s\n" "$*"; FAIL=1; } + +# ============================================================ +# Mode B: running inside the systemd container — run scenarios +# ============================================================ +if [ "${NUMA_INSIDE:-}" = "1" ]; then + set +e # assertions report pass/fail, don't abort + FAIL=0 + NUMA=/work/target/release/numa + + reset_state() { + "$NUMA" uninstall >/dev/null 2>&1 || true + systemctl reset-failed numa 2>/dev/null || true + rm -rf /var/lib/numa /var/lib/private/numa /etc/numa /home/builder /usr/local/bin/numa + systemctl daemon-reload 2>/dev/null || true + } + + main_pid_user() { + local pid + pid=$(systemctl show -p MainPID --value numa) + [ "$pid" != "0" ] || { echo ""; return; } + ps -o user= -p "$pid" 2>/dev/null | tr -d ' ' + } + + # MainPID + user briefly stabilize after a fresh restart. Retry so we + # don't race the moment systemd flips the service to "active" vs when + # the forked numa process actually owns MainPID. + assert_nonroot() { + local pid user comm n=0 + while [ $n -lt 20 ]; do + pid=$(systemctl show -p MainPID --value numa) + if [ "$pid" != "0" ]; then + comm=$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ') + user=$(ps -o user= -p "$pid" 2>/dev/null | tr -d ' ') + if [ "$comm" = "numa" ]; then + if [ "$user" = "root" ]; then + fail "daemon runs as root (expected transient UID)" + else + pass "daemon runs as $user (non-root)" + fi + return + fi + fi + sleep 0.2 + n=$((n + 1)) + done + fail "numa MainPID did not settle (last: pid=${pid:-?} comm=${comm:-?} user=${user:-?})" + } + + # Functional DNS check: just "port 53 bound" isn't enough — systemd-resolved + # listens on 127.0.0.53 and would satisfy a bind test. Retries for ~15s + # to tolerate cold-start upstream / blocklist warmup. + assert_dns_works() { + local n=0 + while [ $n -lt 15 ]; do + if dig @127.0.0.1 -p 53 example.com +short +timeout=2 +tries=1 2>/dev/null \ + | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + pass "DNS resolves on :53 (A record returned)" + return + fi + sleep 1 + n=$((n + 1)) + done + fail "DNS did not return an A record on :53 within 15s" + } + + # TLS handshake: cert must validate against numa's CA when connecting + # to a .numa SNI. Catches port-not-bound, wrong cert, missing CA file. + assert_tls_handshake() { + local port=$1 sni=${2:-numa.numa} out + if out=$(openssl s_client -connect "127.0.0.1:${port}" \ + -servername "$sni" \ + -CAfile /var/lib/numa/ca.pem \ + -verify_return_error &1); then + if echo "$out" | grep -q 'Verify return code: 0 (ok)'; then + pass "TLS handshake + cert chain verified on :${port}" + else + fail "TLS handshake on :${port} did not report 'Verify return code: 0'" + fi + else + fail "openssl s_client failed connecting to :${port}" + fi + } + + assert_http_responds() { + local code + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1/ || echo 000) + if [ "$code" != "000" ]; then + pass "HTTP responds on :80 (status $code)" + else + fail "HTTP :80 connection failed" + fi + } + + assert_api_healthy() { + if curl -sf --max-time 3 http://127.0.0.1:5380/health >/dev/null; then + pass "API /health OK on :5380" + else + fail "API /health failed on :5380" + fi + } + + ca_fingerprint() { + openssl x509 -in /var/lib/numa/ca.pem -noout -fingerprint -sha256 2>/dev/null \ + | sed 's/.*=//' + } + + wait_active() { + local n=0 + while [ $n -lt 20 ]; do + systemctl is-active --quiet numa && return 0 + sleep 0.5 + n=$((n + 1)) + done + fail "service did not become active within 10s" + systemctl status numa --no-pager -l 2>&1 | head -20 || true + return 1 + } + + # ---- Scenario A ---- + printf "\n=== Scenario A: fresh install — every advertised port is functional ===\n" + reset_state + "$NUMA" install >/tmp/installA.log 2>&1 || { fail "install failed"; tail -20 /tmp/installA.log; } + wait_active || true + assert_nonroot + assert_dns_works + assert_tls_handshake 853 + assert_tls_handshake 443 + assert_http_responds + assert_api_healthy + + # ---- Scenario B ---- + # Pre-drop installs left /var/lib/numa as a plain root-owned tree. + # Flattening the current DynamicUser layout back into that shape + # simulates the upgrade path without needing an actual old binary. + printf "\n=== Scenario B: CA fingerprint survives upgrade from pre-drop layout ===\n" + fp_before=$(ca_fingerprint) + if [ -z "$fp_before" ]; then + fail "could not read initial CA fingerprint (skipping scenario B)" + else + echo " CA fingerprint before: $fp_before" + "$NUMA" uninstall >/dev/null 2>&1 || true + tmp=$(mktemp -d) + cp -a /var/lib/private/numa/. "$tmp"/ 2>/dev/null || true + rm -rf /var/lib/numa /var/lib/private/numa + mv "$tmp" /var/lib/numa + chown -R root:root /var/lib/numa + chmod 755 /var/lib/numa + [ -f /var/lib/numa/ca.pem ] || fail "ca.pem missing from seeded legacy tree" + + "$NUMA" install >/tmp/installB.log 2>&1 || { fail "upgrade install failed"; tail -20 /tmp/installB.log; } + wait_active || true + assert_nonroot + fp_after=$(ca_fingerprint) + if [ -z "$fp_after" ]; then + fail "could not read CA fingerprint after upgrade" + elif [ "$fp_before" = "$fp_after" ]; then + pass "CA fingerprint preserved across upgrade" + else + fail "CA fingerprint changed: before=$fp_before after=$fp_after" + fi + assert_dns_works + fi + + # ---- Scenario C ---- + printf "\n=== Scenario C: install from unreachable source stages binary to /usr/local/bin ===\n" + reset_state + mkdir -p /home/builder + chmod 700 /home/builder + cp "$NUMA" /home/builder/numa + chmod 755 /home/builder/numa + /home/builder/numa install >/tmp/installC.log 2>&1 || { fail "install failed"; tail -20 /tmp/installC.log; } + wait_active || true + if [ -x /usr/local/bin/numa ]; then + pass "binary staged to /usr/local/bin/numa" + else + fail "/usr/local/bin/numa missing after install from 0700 source" + fi + exec_line=$(grep '^ExecStart=' /etc/systemd/system/numa.service 2>/dev/null || echo "ExecStart=") + if echo "$exec_line" | grep -q '/usr/local/bin/numa'; then + pass "unit ExecStart points to staged path" + else + fail "unit ExecStart wrong: $exec_line" + fi + assert_nonroot + assert_dns_works + + reset_state + rm -rf /home/builder + echo + if [ "$FAIL" -eq 0 ]; then + printf "${GREEN}── all scenarios passed ──${RESET}\n" + exit 0 + else + printf "${RED}── some scenarios failed ──${RESET}\n" + exit 1 + fi +fi + +# ============================================================ +# Mode A: host-side bootstrap +# ============================================================ +set -e +cd "$(dirname "$0")/../.." + +IMAGE=numa-install-systemd:local +CONTAINER="numa-install-systemd-$$" +trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT + +echo "── building systemd-in-container image (cached after first run) ──" +docker build --quiet -t "$IMAGE" -f - . <<'DOCKERFILE' >/dev/null +FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -qq && apt-get install -y -qq \ + systemd systemd-sysv systemd-resolved \ + ca-certificates curl build-essential \ + pkg-config libssl-dev cmake make perl \ + dnsutils iproute2 openssl \ + && rm -rf /var/lib/apt/lists/* \ + && for u in dev-hugepages.mount sys-fs-fuse-connections.mount \ + systemd-logind.service getty.target console-getty.service; do \ + systemctl mask $u; \ + done +STOPSIGNAL SIGRTMIN+3 +CMD ["/lib/systemd/systemd"] +DOCKERFILE + +echo "── starting systemd container ──" +docker run -d --name "$CONTAINER" \ + --privileged --cgroupns=host \ + --tmpfs /run --tmpfs /run/lock --tmpfs /tmp:exec \ + -v "$PWD:/src:ro" \ + -v numa-install-systemd-cargo:/root/.cargo \ + -v numa-install-systemd-work:/work \ + "$IMAGE" >/dev/null + +# Wait for systemd to be up +for _ in $(seq 1 30); do + state=$(docker exec "$CONTAINER" systemctl is-system-running 2>&1 || true) + case "$state" in running|degraded) break ;; esac + sleep 0.5 +done + +echo "── copying source into /work (writable) ──" +docker exec "$CONTAINER" bash -c ' +mkdir -p /work +tar -C /src --exclude=./target --exclude=./.git --exclude=./.claude -cf - . | tar -C /work -xf - +' + +echo "── rustup + cargo build --release --locked ──" +docker exec "$CONTAINER" bash -c ' +set -e +if ! command -v cargo &>/dev/null; then + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet +fi +. "$HOME/.cargo/env" +cd /work +cargo build --release --locked 2>&1 | tail -5 +' + +echo "── running scenarios ──" +docker exec -e NUMA_INSIDE=1 "$CONTAINER" bash /src/tests/docker/install-systemd.sh