Skip to content

systemd: sandbox the user unit - #3

Merged
bscott merged 1 commit into
bscott:release/0.5.0from
Mrigbozurike:systemd-hardening
Oct 7, 2026
Merged

bscott merged 1 commit into
bscott:release/0.5.0from
Mrigbozurike:systemd-hardening

Conversation

@Mrigbozurike

@Mrigbozurike Mrigbozurike commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

The generated systemd --user unit granted the daemon everything the account has. rdc needs very little beyond the session it drives: its config, its state directory, the session runtime directory, the tailscaled socket, and one listener.

What

The directives are split across two files, because in a --user service they are not equally available:

  • dev.rdc.daemon.service always carries the pure seccomp/prctl settings: NoNewPrivileges, RestrictSUIDSGID, SystemCallFilter=@system-service minus @privileged @resources, SystemCallArchitectures=native, RestrictAddressFamilies, RestrictNamespaces, RestrictRealtime, LockPersonality, UMask=0077. It is Type=exec.
  • dev.rdc.daemon.service.d/10-sandbox.conf carries everything that needs user namespaces — PrivateUsers=yes, an empty CapabilityBoundingSet/AmbientCapabilities, the Protect* family, ProtectSystem=strict, ProtectHome=read-only, PrivateTmp, with BindReadOnlyPaths="-/tmp/.X11-unix" so X11 sessions keep their socket, and ReadWritePaths for %t, the state directory and the audit directory.

Install asks systemd whether the second set will work (systemd-run --user --wait -p PrivateUsers=yes -p ProtectSystem=strict -p PrivateTmp=yes /bin/true) and writes the drop-in only if it starts, says so when it skips it, and removes a stale drop-in if an upgrade takes namespaces away. Paths are quoted and % escaped; a path containing a quote, backslash or newline is refused at install time. Installing restarts a running daemon, then waits past RestartSec and checks NRestarts is still 0.

RemoveIPC is deliberately not set: in a --user unit it would delete the desktop user's SysV/POSIX IPC on stop and can take Xwayland and PipeWire with it.

Testing

cargo fmt, cargo clippy --all-targets -- -D warnings, cargo test on Linux and Windows. Ten unit tests cover the split, the X11 bind, the writable set, quoting, refused paths, reinstall idempotence, the no-namespace path and uninstall. Not yet run on a live Wayland desktop.

@bscott

bscott commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Thanks, this is a good hardening set and it passes fmt, clippy (Linux, Windows, macOS targets) and tests locally.

Two things before merge:

  1. Please drop RemoveIPC=yes. In a --user unit it removes every SysV and POSIX IPC object owned by the desktop user when the unit stops, which can break the running session (Xwayland MIT-SHM, PipeWire).
  2. Please run it once on a live Wayland session and confirm screenshots and input still work under the syscall filter. If you can't, say so in the PR and I'll test it here before merging.

Also, please sign off the commit (git commit -s --amend), per CONTRIBUTING.md.

@Mrigbozurike

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Both points addressed in 38518fc (force-pushed, signed off):

  • Dropped RemoveIPC=yes from the unit and the docs table, with a short note in setup-linux.md on why it's left out, and a test that fails if it ever comes back. You're right that in a --user unit it would tear down the desktop user's SysV/POSIX IPC on stop.
  • On the live-session test: I don't have a Wayland desktop available, so this has only been exercised on a headless Linux box (unit installs, cargo test covers the generated unit and drop-in, systemd-analyze security scores it) and the code builds on Windows. Please test on your Hyprland machine before merging. If the syscall filter or the mount drop-in blocks something, journalctl --user -u dev.rdc.daemon will show EPERM or a mount error; the drop-in is designed to be the first thing to loosen.

Also rebased on current main.

@bscott

bscott commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Thanks for the update. RemoveIPC is gone, the commit is signed off, and CI is green on all three OSes. Two things still block it, and there is a branch change.

  1. The base unit needs user namespaces too. On a --user service, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname and CapabilityBoundingSet all imply PrivateUsers= (see man systemd.exec), so on a kernel without unprivileged user namespaces the "everything else still applies" unit fails the same way the drop-in was meant to avoid. Please move those into the drop-in with an explicit PrivateUsers=yes, and keep the seccomp/prctl directives (NoNewPrivileges, SystemCallFilter, RestrictAddressFamilies, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, LockPersonality, UMask) in the base unit.
  2. PrivateTmp=yes hides /tmp/.X11-unix, so X11 sessions cannot reach the display. Either drop it or add BindReadOnlyPaths=/tmp/.X11-unix.

Smaller: the userns check reads two sysctls and misses AppArmor's apparmor_restrict_unprivileged_userns (Ubuntu 23.10+); a real probe (unshare -Ur true) plus an is-active check after enable --now would catch a unit stuck in a restart loop. The tests mostly assert that a template contains its own text; a test for the "no userns" path and for uninstall removing the drop-in would meet the bar in CONTRIBUTING. Please also add a CHANGELOG line under Unreleased.

Branch: we moved to release branches (see CONTRIBUTING, "How changes reach a release"). 0.4.0 is closed and waiting on final approval, so this goes into 0.5.0. Please rebase onto release/0.4.0 (this one applies cleanly); I will retarget the PR when the release/0.5.0 branch exists. I will do the live Hyprland test on my machine once the two points above are in.

@bscott
bscott changed the base branch from main to release/0.5.0 September 14, 2026 02:57
@bscott
bscott self-requested a review as a code owner September 14, 2026 02:57
@bscott

bscott commented Sep 14, 2026

Copy link
Copy Markdown
Owner

0.4.0 is released and this PR's base is now release/0.5.0. Please rebase onto it (it is main as of v0.4.0).

@Mrigbozurike

Copy link
Copy Markdown
Contributor Author

Both blockers fixed, rebased onto release/0.5.0, signed off.

  1. Namespace directives moved. You're right that ProtectKernelTunables/Modules/Logs, ProtectControlGroups, ProtectClock, ProtectHostname and CapabilityBoundingSet imply PrivateUsers= — the base unit was not the "always works" file I claimed. The drop-in now carries those plus an explicit PrivateUsers=yes, and the base unit keeps only NoNewPrivileges, SystemCallFilter/SystemCallArchitectures, RestrictAddressFamilies, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, LockPersonality and UMask. A test lists every directive that implies a namespace and fails if one appears in the base unit.
  2. X11 socket. BindReadOnlyPaths=-/tmp/.X11-unix in the drop-in, with the - so it is skipped on Wayland-only systems. Asserted in the drop-in test.

On the smaller points:

  • The userns check is now a real probe: unshare -Ur true, falling back to the sysctls only when unshare is not installed. That covers the AppArmor restriction the knobs miss.
  • enable --now is followed by a polled is-active check. A unit that crash-loops reports activating for a while and then failed, so install now waits for it to settle and, if it never reaches active, prints the last 15 journal lines and tells you which file to loosen.
  • Tests are behavioural rather than template tautologies: install-without-namespaces writes no drop-in and still produces a startable unit; a stale drop-in is removed when namespaces go away; uninstall removes the unit, the drop-in and its directory; and uninstall leaves a co-resident 99-local.conf alone.
  • CHANGELOG line added under Unreleased.

Still only exercised on a headless Linux box — the Hyprland test is yours to do, and the drop-in is the first thing to loosen if something is blocked.

bscott commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thanks, the namespace split and the X11 bind are right, and CI is green. Before this goes into 0.5.0:

Please fix

  1. The "wait until active" check misses a crash loop. With the default Type=simple, the unit is active as soon as it forks, so the first poll passes. Set Type=exec. Then wait longer than RestartSec and check that systemctl --user show -p NRestarts --value dev.rdc.daemon is still 0.
  2. Probe what systemd will actually do. unshare -Ur true can pass on Ubuntu 24.04 while systemd's mounts fail. Probe the user manager directly instead:
    systemd-run --user --wait --quiet -p PrivateUsers=yes -p ProtectSystem=strict -p PrivateTmp=yes /bin/true
  3. Quote paths in the unit files. A home or audit path containing a space splits in two, and % is read as a systemd specifier. Wrap each path in "..." and write % as %%. This applies to ReadWritePaths= and ExecStart=.
  4. Reinstall should restart the unit. enable --now leaves an already-running, unsandboxed daemon in place. Run systemctl --user restart after daemon-reload.

Please also update the PR description: it still mentions RemoveIPC and 10-sandbox-mounts.conf.

Tests to add. Please apply the patch below. It adds two tests:

  • a custom audit directory becomes writable, next to the state dir and %t, and nothing else does;
  • a second install rewrites identical files and writes nothing outside the unit directory.

They pass on your current head. A test for the quoting fix in point 3 would be welcome too.

tests.patch (apply with git apply tests.patch)
diff --git a/src/service/systemd.rs b/src/service/systemd.rs
index 02488db..e191f0f 100644
--- a/src/service/systemd.rs
+++ b/src/service/systemd.rs
@@ -339,6 +339,32 @@ mod tests {
         assert!(d.contains("ReadWritePaths=-/home/a/.local/state/rdc\n"));
     }
 
+    #[test]
+    fn dropin_makes_every_writable_dir_writable_and_nothing_else() {
+        // State dir plus a custom [serve.audit].path directory, as writable_dirs() returns them.
+        let rw = [PathBuf::from("/home/a/.local/state/rdc"), PathBuf::from("/var/log/rdc")];
+        let d = dropin_text(&rw);
+        let writable: Vec<&str> = d.lines().filter_map(|l| l.strip_prefix("ReadWritePaths=")).collect();
+        assert_eq!(writable, ["-%t", "-/home/a/.local/state/rdc", "-/var/log/rdc"]);
+        // Everything else stays read-only.
+        assert!(d.contains("ProtectSystem=strict\n") && d.contains("ProtectHome=read-only\n"));
+    }
+
+    #[test]
+    fn reinstall_rewrites_the_same_files() {
+        let dir = tmp("reinstall");
+        let rw = [PathBuf::from("/home/a/.local/state/rdc")];
+        let first = write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &rw, true).unwrap();
+        let before: Vec<String> = first.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect();
+        let second = write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &rw, true).unwrap();
+        assert_eq!(first, second);
+        let after: Vec<String> = second.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect();
+        assert_eq!(before, after, "a second install must not change or duplicate directives");
+        // Nothing is written outside the unit directory.
+        assert!(second.iter().all(|p| p.starts_with(&dir)), "{second:?}");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
     #[test]
     fn install_without_namespaces_writes_no_dropin() {
         let dir = tmp("no-userns");

After that I'll run it on Hyprland, and check that screenshots on GNOME/KDE Wayland still work with PrivateTmp.


Generated by Claude Code

Signed-off-by: mrigbozurike <drbillionbook@gmail.com>
@Mrigbozurike

Mrigbozurike commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

All four fixed, rebased onto release/0.5.0, signed off. PR description updated too.

  1. Crash loop. Type=exec set. Install now waits past RestartSec and then requires both ActiveState=active and NRestarts=0, so a daemon that starts and dies is caught instead of looking healthy on the first poll. Failure prints the last 15 journal lines and names the drop-in to loosen.

  2. Probe. Replaced unshare -Ur with systemd-run --user --wait --quiet -p PrivateUsers=yes -p ProtectSystem=strict -p PrivateTmp=yes /bin/true. The sysctl check survives only as a fallback for a machine with no systemd-run.

  3. Quoting. ExecStart and every ReadWritePaths/BindReadOnlyPaths entry are quoted, with % written %%. A path containing ", \ or a newline is refused at install time instead — \ is c-unescaped in ExecStart= but retained in ReadWritePaths=, so the same text would mean two different paths and quoting cannot fix that honestly.

    Checked against systemd 255 rather than just my reading of the parser, with a unit generated for /opt/my rdc/rdc and /var/log/100% done/rdc:

    $ systemd-analyze verify dev.rdc.daemon.service     # quoted
    (no output)
    $ systemd-analyze verify unquoted.service
    unquoted.service: Command /opt/my is not executable: No such file or directory
    
  4. Reinstall. enable then restart, so an install over a running daemon replaces it.

Tests: your two applied as given, plus paths_are_quoted_and_percent_is_escaped and paths_a_unit_file_cannot_express_are_refused for point 3. One change to yours — dropin_makes_every_writable_dir_writable_and_nothing_else now expects the quoted forms ("-%t", "-/home/a/.local/state/rdc", "-/var/log/rdc"), since that is what fix 3 produces. Ten systemd tests, all green on Linux and Windows.

@Mrigbozurike Mrigbozurike reopened this Oct 5, 2026
bscott added a commit that referenced this pull request Oct 7, 2026
Merge #4 and #3 into release/0.5.0 (conflicts resolved)
@bscott
bscott merged commit 34b35e3 into bscott:release/0.5.0 Oct 7, 2026
8 checks passed
@bscott bscott mentioned this pull request Oct 7, 2026
4 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants