Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ builds the GitHub release body from the matching `## <version>` section.

## Unreleased

### Added
- **The systemd `--user` unit is sandboxed.** `rdc service install` writes `NoNewPrivileges`,
a `@system-service` syscall filter minus `@privileged`/`@resources`, `RestrictAddressFamilies`,
`RestrictNamespaces`/`RestrictRealtime`/`RestrictSUIDSGID`/`LockPersonality` and `UMask=0077`
into the unit, and the directives that need user namespaces (`PrivateUsers`, an empty
capability set, the `Protect*` family, `ProtectSystem=strict`, `ProtectHome=read-only`,
`PrivateTmp` with the X11 socket bound back) into a drop-in written only when a real
`systemd-run --user` probe starts a transient unit carrying them, since those would otherwise
stop the unit from starting at all. Paths are quoted and `%` escaped, so a binary or audit
directory containing a space or a percent sign is written correctly. Installing restarts a
running daemon rather than leaving the old one in place, and the unit is `Type=exec`, so
install can wait past `RestartSec` and confirm `NRestarts` is 0 instead of mistaking a restart
loop for success.

## 0.4.0 — 2026-09-11

### Added
Expand Down
63 changes: 63 additions & 0 deletions docs/setup-linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,69 @@ restarts on failure. It runs the binary from the path where you invoked `service

Config lives at `~/.config/rdc/config.toml`; see [Configuration](configuration.md).

### What the unit is allowed to do

`rdc service install` writes a sandboxed unit. The daemon runs as your user inside your session
(it has to, to see the screen and inject input), but systemd fences off everything it does not
need. The directives are split across two files because in a `--user` service they are not all
equally available:

**Always applied**, in `dev.rdc.daemon.service` — these are pure seccomp and prctl settings and
need no namespace:

| Restriction | Effect |
|---|---|
| `NoNewPrivileges`, `RestrictSUIDSGID` | neither rdc nor anything it runs (`hyprctl`, `tailscale`) can gain privileges |
| `SystemCallFilter=@system-service` minus `@privileged @resources`, native ABI only | privileged and resource-limit syscalls return `EPERM` |
| `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` | unix sockets (Wayland, D-Bus, portal, tailscaled) and the HTTP listener; nothing else |
| `RestrictNamespaces`, `RestrictRealtime`, `LockPersonality` | no new namespaces, no realtime scheduling, no personality changes |
| `UMask=0077` | audit log and rotated copies are private to your user |

**Applied when unprivileged user namespaces work**, in
`dev.rdc.daemon.service.d/10-sandbox.conf`:

| Restriction | Effect |
|---|---|
| `PrivateUsers=yes` | every uid but yours maps to `nobody` inside the service |
| empty `CapabilityBoundingSet`/`AmbientCapabilities` | rdc can never hold a capability |
| `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectKernelLogs`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname` | no kernel tunables, modules, logs, cgroups, clock or hostname |
| `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp` | the filesystem is read-only except `$XDG_RUNTIME_DIR`, `~/.local/state/rdc` and the audit log directory |
| `BindReadOnlyPaths="-/tmp/.X11-unix"` | puts the X11 socket back, which `PrivateTmp` would otherwise hide |

Every directive in the second table implies `PrivateUsers=` in a `--user` service (see
`systemd.exec(5)`), so where unprivileged user namespaces are unavailable the unit would refuse
to start at all. `rdc service install` therefore asks systemd itself rather than guessing: it
runs a throwaway transient unit,

```sh
systemd-run --user --wait --quiet -p PrivateUsers=yes -p ProtectSystem=strict -p PrivateTmp=yes /bin/true
```

and writes the drop-in only if that starts. Reading `/proc/sys` is not enough (Ubuntu 23.10+
restricts namespaces through an AppArmor profile the old knobs do not reflect) and neither is
`unshare -Ur`, which can succeed on hosts where systemd's own mount setup still fails. Install
says so when it skips the drop-in, and removes a stale one if an upgrade takes namespaces away.

Paths in the generated files are quoted and any `%` is written `%%`, so a binary or audit
directory containing a space is one path rather than two, and a `%` is not read as a systemd
specifier. A path containing a quote, a backslash or a newline is refused at install time
rather than written out ambiguously.

Installing over a running daemon **restarts** it, so the new settings take effect immediately
instead of at the next login. The unit is `Type=exec`, which means systemd calls it active only
once the binary has actually been reached; install then waits past `RestartSec` and checks that
`NRestarts` is still 0, so a daemon that starts and dies in a loop is reported rather than
mistaken for a healthy one.

`RemoveIPC` is deliberately not set: in a `--user` unit it would delete every shared-memory
object owned by your account when the unit stops, which can take Xwayland (MIT-SHM) and
PipeWire down with it.

Check the result with `systemd-analyze security --user dev.rdc.daemon`. If your desktop needs
something the sandbox blocks (the journal will show `EPERM` or a mount error), override it with
`systemctl --user edit dev.rdc.daemon` rather than editing the generated files, so the change
survives the next `rdc service install`.

## Tailscale

rdc uses the LocalAPI socket at `/var/run/tailscale/tailscaled.sock`. If your user can't read it,
Expand Down
Loading
Loading