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

Merged
razvandimescu merged 11 commits from feat/linux-drop-privileges into main 2026-04-19 03:04:54 +08:00
razvandimescu commented 2026-04-18 12:57:14 +08:00 (Migrated from github.com)

Summary

  • Drops the Linux daemon from root to an unprivileged transient UID via DynamicUser=yes + CAP_NET_BIND_SERVICE. A DNS-parser RCE now stays contained to what the service account can read/write, not the whole box.
  • Adds a systemd sandboxing block: ProtectSystem=strict, PrivateTmp, PrivateDevices, ProtectKernel*, ProtectControlGroups, NoNewPrivileges, RestrictSUIDSGID, RestrictRealtime, RestrictAddressFamilies (AF_INET/INET6/UNIX/NETLINK). SystemCallFilter left off for now — layered in a follow-up after isolated testing.
  • install_service_binary_linux() stages the binary to /usr/local/bin/numa when current_exe() sits on a path the transient UID can't traverse (e.g. /home/<user>/… mode 0700, ~/.cargo/bin). Linuxbrew's 0755 paths are kept in place. Atomic copy + rename avoids ETXTBSY on re-install.
  • Reinstall uses systemctl restart (not start) so upgrades pick up the new binary while the old one is running.

Why it's safe

Runtime doesn't need root on Linux — network_watch_loop (src/serve.rs:535) only reads /etc/resolv.conf, and all system-DNS mutation stays in the installer (install_linux / uninstall_linux), which continues to run as root via sudo.

Why DynamicUser instead of a static numa user

No PKGBUILD/sysusers plumbing, no useradd idempotency, no manual chown for legacy installs — systemd remaps StateDirectory ownership to the transient UID on each launch, including root-owned trees from pre-drop installs.

Behavior change

Custom configs at /root/.config/numa/numa.toml won't be loaded after upgrade — under DynamicUser, $HOME is not /root, so suggested_config_path() falls back to /var/lib/numa/numa.toml. Daemon falls back to defaults — safe degradation. Move custom configs to /var/lib/numa/numa.toml to preserve.

Follow-ups (separate PRs)

  • Wire ConfigurationDirectory=/etc/numa into load_config search paths (declared in the unit but currently unused).
  • Layer in SystemCallFilter=@system-service + LockPersonality + ProcSubset=pid once tested.
  • Windows: obj= LocalSystemLocalService, pull netsh out of the runtime.
  • macOS: launchd UserName + sandbox_init.
  • Process split (DNS vs web) — drafts sitting in packaging/systemd/ on a future branch.

Test plan

Covered by integration-linux CI:

  • Fresh install + dig on port 53
  • Re-install while service is running (ETXTBSY / atomic rename path)
  • Uninstall cycle
  • Assert MainPID of numa does not run as root (the invariant this PR establishes)

Manual verification:

  • Upgrade from pre-drop install (root-owned /var/lib/numa) — systemd migrates state
  • Binary staging fallback: install from ~/build/numa with ~ mode 0700 → binary copied to /usr/local/bin/numa
  • DoT (853), DoH (443), HTTP redirect (80) all bind successfully
## Summary - Drops the Linux daemon from root to an unprivileged transient UID via `DynamicUser=yes` + `CAP_NET_BIND_SERVICE`. A DNS-parser RCE now stays contained to what the service account can read/write, not the whole box. - Adds a systemd sandboxing block: `ProtectSystem=strict`, `PrivateTmp`, `PrivateDevices`, `ProtectKernel*`, `ProtectControlGroups`, `NoNewPrivileges`, `RestrictSUIDSGID`, `RestrictRealtime`, `RestrictAddressFamilies` (AF_INET/INET6/UNIX/NETLINK). `SystemCallFilter` left off for now — layered in a follow-up after isolated testing. - `install_service_binary_linux()` stages the binary to `/usr/local/bin/numa` when `current_exe()` sits on a path the transient UID can't traverse (e.g. `/home/<user>/…` mode 0700, `~/.cargo/bin`). Linuxbrew's 0755 paths are kept in place. Atomic `copy + rename` avoids ETXTBSY on re-install. - Reinstall uses `systemctl restart` (not `start`) so upgrades pick up the new binary while the old one is running. ## Why it's safe Runtime doesn't need root on Linux — `network_watch_loop` (`src/serve.rs:535`) only reads `/etc/resolv.conf`, and all system-DNS mutation stays in the installer (`install_linux` / `uninstall_linux`), which continues to run as root via sudo. ## Why `DynamicUser` instead of a static `numa` user No PKGBUILD/sysusers plumbing, no `useradd` idempotency, no manual chown for legacy installs — systemd remaps `StateDirectory` ownership to the transient UID on each launch, including root-owned trees from pre-drop installs. ## Behavior change Custom configs at `/root/.config/numa/numa.toml` won't be loaded after upgrade — under `DynamicUser`, `$HOME` is not `/root`, so `suggested_config_path()` falls back to `/var/lib/numa/numa.toml`. Daemon falls back to defaults — safe degradation. Move custom configs to `/var/lib/numa/numa.toml` to preserve. ## Follow-ups (separate PRs) - Wire `ConfigurationDirectory=/etc/numa` into `load_config` search paths (declared in the unit but currently unused). - Layer in `SystemCallFilter=@system-service` + `LockPersonality` + `ProcSubset=pid` once tested. - Windows: `obj= LocalSystem` → `LocalService`, pull `netsh` out of the runtime. - macOS: launchd `UserName` + `sandbox_init`. - Process split (DNS vs web) — drafts sitting in `packaging/systemd/` on a future branch. ## Test plan Covered by `integration-linux` CI: - [x] Fresh install + `dig` on port 53 - [x] Re-install while service is running (ETXTBSY / atomic rename path) - [x] Uninstall cycle - [x] Assert `MainPID` of `numa` does not run as `root` (the invariant this PR establishes) Manual verification: - [x] Upgrade from pre-drop install (root-owned `/var/lib/numa`) — systemd migrates state - [x] Binary staging fallback: install from `~/build/numa` with `~` mode 0700 → binary copied to `/usr/local/bin/numa` - [x] DoT (853), DoH (443), HTTP redirect (80) all bind successfully
Sign in to join this conversation.