From a88955e35c3961c441b3cda5b3965156a6783c53 Mon Sep 17 00:00:00 2001 From: mram Date: Mon, 21 Sep 2026 00:11:25 +0200 Subject: [PATCH] Sandbox the systemd unit: DynamicUser, read-only FS, no capabilities The Linux install now mirrors the Windows virtual-account hardening: the unit runs with DynamicUser=yes (transient per-service UID, no login), ProtectSystem=strict with only StateDirectory writable (the install dir, so self-update can rewrite the binary), NoNewPrivileges, empty capability sets, restricted address families and a @system-service syscall filter. Install copies the binary to /var/lib/gpu-turnstile and the config to /etc/gpu-turnstile.env; Remove cleans up the unit and binary but keeps the config. --- README.md | 10 ++- SPEC.md | 22 ++++-- internal/service/service_linux.go | 94 +++++++++++++++++++++++--- internal/service/service_linux_test.go | 10 ++- 4 files changed, 115 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1715c6e..0fad82f 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,13 @@ gpu-turnstile --remove-service The unit (`/etc/systemd/system/gpu-turnstile.service`) is `Type=notify`: `systemctl start` blocks until the listeners are actually bound, a 30 s watchdog restarts the process if it wedges, and logs land in the journal -(`journalctl -u gpu-turnstile -f`) unless `LOG_FILE` is set. Put the -config in a `gpu-turnstile.env` next to the binary (or pass -`-config /path` during install). The notify integration is a no-op in +(`journalctl -u gpu-turnstile -f`) unless `LOG_FILE` is set. Install +copies the binary to `/var/lib/gpu-turnstile/` and the config to +`/etc/gpu-turnstile.env` (edit that one after installing). The service +runs sandboxed with `DynamicUser=yes` — a transient low-privilege UID, +read-only filesystem except its install dir (so self-update keeps +working), no capabilities, syscall-filtered: same least-privilege idea as +the Windows virtual account. The notify integration is a no-op in containers and interactive shells. **Auto-update is on by default**: the binary checks the repo's latest diff --git a/SPEC.md b/SPEC.md index 3eb5bf3..e561765 100644 --- a/SPEC.md +++ b/SPEC.md @@ -195,12 +195,22 @@ install|remove` does the same thing. ### Linux (systemd) -- `--install-service` writes `/etc/systemd/system/gpu-turnstile.service` - with `ExecStart` pointing at the current executable and the `-config` - file, then runs `systemctl daemon-reload` and `enable --now`. The unit - runs as root (it must be able to overwrite its own binary for - self-updates); harden with `ProtectSystem=strict` plus a writable - `ReadWritePaths` if desired. +- `--install-service` copies the binary to `/var/lib/gpu-turnstile/`, + copies the config to `/etc/gpu-turnstile.env` if none exists there yet, + writes `/etc/systemd/system/gpu-turnstile.service`, then runs `systemctl + daemon-reload` and `enable --now`. `--remove-service` removes the unit + and the installed binary; the `/etc` config stays. +- **Sandboxing** mirrors the Windows virtual account: the unit runs with + `DynamicUser=yes` — a transient per-service UID with no login, no home + and no password, managed entirely by systemd. `ProtectSystem=strict` + makes the filesystem read-only except `StateDirectory=gpu-turnstile` + (the install dir, so self-updates can rewrite the binary), plus + `NoNewPrivileges`, `ProtectHome`, `PrivateTmp`, `ProtectKernel*`, + `ProtectControlGroups`, `RestrictNamespaces`, `RestrictSUIDSGID`, + `RestrictRealtime`, `LockPersonality`, `MemoryDenyWriteExecute`, empty + capability sets, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` and + `SystemCallFilter=@system-service`. The proxy needs only outbound + TCP/UDP and the notify socket, so it loses nothing. - The unit is `Type=notify`: the binary sends `READY=1` via `github.com/coreos/go-systemd` only after the listeners are bound, so `systemctl start` blocks until the proxy accepts connections. A 30 s diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index ab099ff..11b4a76 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -2,12 +2,13 @@ // Package service integrates gpu-turnstile with systemd on Linux: running // under a unit with readiness notification and watchdog, plus -// install/remove helpers that manage a system unit. +// install/remove helpers that manage a hardened system unit. package service import ( "context" "fmt" + "io" "os" "os/exec" "os/signal" @@ -21,6 +22,14 @@ const Name = "gpu-turnstile" // unitPath is where Install writes the unit file. const unitPath = "/etc/systemd/system/" + Name + ".service" +// stateDir holds the installed binary (and staged updates); the unit's +// StateDirectory= directive makes systemd own it and grant the dynamic +// user write access. etcConfig is the config file the unit loads. +const ( + stateDir = "/var/lib/" + Name + etcConfig = "/etc/" + Name + ".env" +) + // IsService reports whether the process was started by systemd. func IsService() bool { return os.Getenv("INVOCATION_ID") != "" } @@ -33,10 +42,17 @@ func Run(run func(ctx context.Context) error) error { return run(ctx) } -// renderUnit builds the systemd unit: Type=notify so systemctl start blocks -// until the listeners are bound, a 30s watchdog, and restart-on-failure -// with a 5s delay — which is also what brings up a staged update after the -// updater exits with a non-zero code. +// renderUnit builds the hardened systemd unit: Type=notify so systemctl +// start blocks until the listeners are bound, a 30s watchdog, and +// restart-on-failure with a 5s delay — which is also what brings up a +// staged update after the updater exits with a non-zero code. +// +// Sandboxing mirrors the Windows virtual account: DynamicUser=yes gives +// the service a transient per-service UID with no login and no home, the +// filesystem is read-only except StateDirectory (the install dir, so +// self-updates can rewrite the binary), and the usual no-privilege-escalation +// directives apply. The proxy needs nothing but outbound TCP/UDP and the +// notify socket, so it loses nothing. func renderUnit(exePath, configPath string) string { return fmt.Sprintf(`[Unit] Description=gpu-turnstile GPU arbitration proxy for Ollama and ComfyUI @@ -50,13 +66,55 @@ ExecStart=%q -config %q Restart=on-failure RestartSec=5s +DynamicUser=yes +StateDirectory=%s +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +RestrictNamespaces=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +CapabilityBoundingSet= +AmbientCapabilities= +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + [Install] WantedBy=multi-user.target -`, exePath, configPath) +`, exePath, configPath, Name) } -// Install writes the unit for the current executable and the given config -// file, then enables and starts it. Needs root. +// copyFile copies src to dst, creating dst with the given mode. +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +// Install copies the current executable into /var/lib/gpu-turnstile, makes +// sure /etc/gpu-turnstile.env exists (copied from the given config file if +// provided), writes the hardened unit, then enables and starts it. Needs +// root. func Install(configPath string) error { exe, err := os.Executable() if err != nil { @@ -65,7 +123,21 @@ func Install(configPath string) error { if abs, absErr := filepath.Abs(exe); absErr == nil { exe = abs } - if err := os.WriteFile(unitPath, []byte(renderUnit(exe, configPath)), 0o644); err != nil { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return fmt.Errorf("create %s (run as root): %w", stateDir, err) + } + installedExe := filepath.Join(stateDir, Name) + if exe != installedExe { + if err := copyFile(exe, installedExe, 0o755); err != nil { + return fmt.Errorf("install binary to %s: %w", installedExe, err) + } + } + if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" { + // Missing config is not fatal: the service fails fast with a clear + // "no consumer URL" error until the user writes one. + copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort + } + if err := os.WriteFile(unitPath, []byte(renderUnit(installedExe, etcConfig)), 0o644); err != nil { return fmt.Errorf("write %s (run as root): %w", unitPath, err) } if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { @@ -77,12 +149,14 @@ func Install(configPath string) error { return nil } -// Remove stops and disables the service and deletes the unit file. +// Remove stops and disables the service and deletes the unit file and the +// installed binary. The config file in /etc is left in place (user data). func Remove() error { exec.Command("systemctl", "disable", "--now", Name+".service").Run() // ignore: may not exist if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", unitPath, err) } + os.RemoveAll(stateDir) // installed binary + staged updates; ignore error if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { return fmt.Errorf("systemctl daemon-reload: %w (%s)", err, out) } diff --git a/internal/service/service_linux_test.go b/internal/service/service_linux_test.go index bf18176..2ff4de7 100644 --- a/internal/service/service_linux_test.go +++ b/internal/service/service_linux_test.go @@ -8,13 +8,19 @@ import ( ) func TestRenderUnit(t *testing.T) { - unit := renderUnit("/usr/local/bin/gpu-turnstile", "/etc/gpu-turnstile.env") + unit := renderUnit("/var/lib/gpu-turnstile/gpu-turnstile", "/etc/gpu-turnstile.env") for _, want := range []string{ "Type=notify", "WatchdogSec=30s", - `ExecStart="/usr/local/bin/gpu-turnstile" -config "/etc/gpu-turnstile.env"`, + `ExecStart="/var/lib/gpu-turnstile/gpu-turnstile" -config "/etc/gpu-turnstile.env"`, "Restart=on-failure", "WantedBy=multi-user.target", + "DynamicUser=yes", + "StateDirectory=gpu-turnstile", + "ProtectSystem=strict", + "NoNewPrivileges=yes", + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + "SystemCallFilter=@system-service", } { if !strings.Contains(unit, want) { t.Fatalf("unit missing %q:\n%s", want, unit)