From 1e668f376101981fa5e4b214d47dc68c649d7b0d Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 19 Aug 2026 17:42:17 -0500 Subject: [PATCH] Run the systemd unit as a dedicated hardened user The unit ran the player as root, its network-facing WebSocket server included, with no hardening block. The three places that recorded this as debt said a dedicated user and a hardening block were owed together, so both ship here. The account is declared in packaging/sendspin-cli.sysusers.conf, installed to lib/sysusers.d beside the unit. Its two lines are owed together as well: the user, and its membership of audio, because a uid without audio is a player that starts and cannot open /dev/snd. A tarball has no postinst, so `systemd-sysusers` is a command README, BUILD-INFO.txt and the release notes all now tell an operator to run once; skipping it is 217/USER rather than a quiet degradation. An existing root-owned /var/lib/sendspin-cli needs nothing done to it -- StateDirectory= chowns a directory it finds as well as one it creates. Every directive in the hardening block was run against a real systemd, one at a time and then together, and the four that gate what the ALSA backend reaches are named as future work instead of shipped: they all pass under `-o null`, which is exactly why passing proves nothing about them. The Linux CI legs now start the unit as the account rather than only parsing it: a planted root-owned state file read back through the new uid, the control socket, the state file's mode and owner, restart-after-SIGKILL, a clean stop, and -- where a real avahi-daemon is running -- the mDNS advertisement, scoped to the invocation so an earlier run cannot satisfy it. --- .github/workflows/build.yml | 157 ++++++++++++++++++++++++++- .github/workflows/release.yml | 5 + CMakeLists.txt | 13 +++ README.md | 114 ++++++++++++++----- docs/ROADMAP.md | 112 ++++++++++++++----- packaging/sendspin-cli.service.in | 105 ++++++++++++++++-- packaging/sendspin-cli.sysusers.conf | 20 ++++ 7 files changed, 455 insertions(+), 71 deletions(-) create mode 100644 packaging/sendspin-cli.sysusers.conf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2334ead..6ed10b6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -369,13 +369,20 @@ jobs: cat >>"stage/$name/BUILD-INFO.txt" <<'LINUX' The systemd unit is at usr/local/lib/systemd/system/sendspin-cli.service, which is - on systemd's own search path, so installing as above needs no copying: + on systemd's own search path, so installing as above needs no copying. The one + command a tarball cannot run for itself creates the account the unit runs as, + declared beside it in usr/local/lib/sysusers.d/sendspin-cli.conf: + sudo systemd-sysusers sudo systemctl daemon-reload sudo systemctl enable --now sendspin-cli - It runs the player as root, in the foreground, with its state in - /var/lib/sendspin-cli and its control socket in /run/sendspin-cli. Configure it in + Skip the first line and the unit will not start: systemctl reports 217/USER. + + It runs the player as an unprivileged sendspin-cli user, in the foreground, with its + state in /var/lib/sendspin-cli and its control socket in /run/sendspin-cli. An + existing /var/lib/sendspin-cli left behind by an earlier root-run version needs + nothing done to it -- systemd chowns it. Configure it in /etc/sendspin-cli.conf; there is an annotated example beside the installed README, under usr/local/share/doc/sendspin-cli. Its ExecStart names /usr/local/bin/sendspin-cli absolutely, so a binary put anywhere else needs the unit changed to match. @@ -406,8 +413,10 @@ jobs: echo usr/local/share/doc/sendspin-cli/LICENSE echo usr/local/share/doc/sendspin-cli/README.md echo usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example - # Installed on Linux alone: a systemd unit on a Mac is a file that can never run. + # Installed on Linux alone: a systemd unit, and the account declaration it needs, + # are both files a Mac can only ignore. if [ "$RUNNER_OS" = 'Linux' ]; then + echo usr/local/lib/sysusers.d/sendspin-cli.conf echo usr/local/lib/systemd/system/sendspin-cli.service fi } | sort >"$expected" @@ -454,8 +463,9 @@ jobs: # below are the same hazard's other half and sit after Package for the same reason, # though `installer` writes to /usr/local rather than into the build tree. # - # No daemon is started and nothing is enabled: verify is a parse, and a runner has no - # sound card to play through anyway. + # `verify` is only a parse, so the step below runs the thing as well. What it cannot cover + # is the audio path: a runner has no sound card, which is why `output = null` goes in the + # config and why the unit ships no directive that gates /dev/snd. - name: Install at the real prefix and verify the systemd unit if: runner.os == 'Linux' run: | @@ -469,6 +479,141 @@ jobs: cat "$unit" systemd-analyze verify "$unit" + # The unit run as the account it names, with the hardening block in place. A runner is a + # real machine with systemd as PID 1 -- the Avahi step above already leans on that -- so + # every claim the unit's comment makes short of `/dev/snd` can be made here rather than + # reasoned about, and a directive that boots on a maintainer's laptop and not on a stock + # 24.04 fails a leg instead of an operator's install. + # + # `systemd-sysusers` with no argument is the same command README tells an operator to run, + # reading the fragment the install rules just put in /usr/local/lib/sysusers.d -- so this + # covers the search path holding for /usr/local as well as the fragment's own content. + - name: Run the unit as its own user, with the hardening block + if: runner.os == 'Linux' + env: + AVAHI: ${{ matrix.avahi }} + run: | + # Defined first so that everything below, `systemctl start` included, reports the + # journal rather than dying on `set -e` with nothing but a non-zero exit to show. + fail() { + echo "::error::$1" + systemctl status sendspin-cli --no-pager --full || true + journalctl -u sendspin-cli --no-pager | tail -40 || true + exit 1 + } + + sudo systemd-sysusers + id sendspin-cli + # A case match rather than a pipe into grep, which is the shape every check in this + # step uses: `defaults.run.shell` is `bash -eo pipefail`, and `grep -q` exits the + # instant it matches, so the writer on the left takes SIGPIPE and pipefail then reads + # a *successful* match as a failed pipeline. + case " $(id -nG sendspin-cli) " in + *' audio '*) ;; + *) + echo '::error::the sysusers fragment did not put sendspin-cli in the audio group' + exit 1 + ;; + esac + + # A runner has no card, and the unit deliberately carries no -o: the config file is + # where an operator picks one, so it is where this picks one too. + printf '%s\n' 'output = null' | sudo tee /etc/sendspin-cli.conf >/dev/null + + # The upgrade path, asserted rather than described: a root-owned state directory from + # an earlier root-run version, which StateDirectory= has to take over on its own. + sock=/run/sendspin-cli/control.sock + sudo rm -rf /var/lib/sendspin-cli + sudo install -d -m 0700 /var/lib/sendspin-cli + printf '%s\n' '# Written by sendspin-cli. Edits are overwritten.' \ + 'static-delay-ms = 175' | sudo tee /var/lib/sendspin-cli/state >/dev/null + sudo chmod 0600 /var/lib/sendspin-cli/state + + sudo systemctl daemon-reload + sudo systemctl start sendspin-cli || fail 'systemctl start refused the unit' + for _ in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.2; done + + [ "$(systemctl is-active sendspin-cli)" = active ] || fail 'the unit did not start' + + # Numeric uids on both sides, the way scripts/smoke_test.sh already compares them: + # `ps -o user=` prints the uid instead of the name once the name outgrows the column, + # so a name comparison is one rename away from being a comparison of nothing. + main=$(systemctl show -p MainPID --value sendspin-cli) + [ "$main" != 0 ] || fail 'the unit reports no main process' + owner=$(ps -o uid= -p "$main" | tr -d ' ') || fail "no process with pid $main" + [ "$owner" = "$(id -u sendspin-cli)" ] || + fail "the player runs as uid $owner, not sendspin-cli ($(id -u sendspin-cli))" + + status=$(sudo sendspin-cli status --control-socket "$sock") || + fail 'the control socket did not answer status' + printf '%s\n' "$status" + + # Read back rather than merely chowned: 175 came from the root-owned file planted + # above, so seeing it proves systemd handed the whole directory over intact. + case "$status" in + *'static delay: 175 ms'*) ;; + *) fail 'the state left by a root-run player did not survive the ownership change' ;; + esac + + sudo sendspin-cli delay 250 --control-socket "$sock" || fail 'delay was refused' + for _ in $(seq 1 50); do + [ "$(stat -c '%U %a' /var/lib/sendspin-cli/state 2>/dev/null)" = 'sendspin-cli 600' ] && + break + sleep 0.2 + done + state=$(stat -c '%U %a' /var/lib/sendspin-cli/state 2>/dev/null) + [ "$state" = 'sendspin-cli 600' ] || fail "the state file is '$state', not 'sendspin-cli 600'" + + # Only on the leg that started a real avahi-daemon above, and the one claim the rest of + # this step cannot make: that RestrictAddressFamilies= without AF_NETLINK still reaches + # the daemon over AF_UNIX. Registration is asynchronous, so it is waited for. + if [ "$AVAHI" = 'true' ]; then + # Scoped to this invocation rather than `-u sendspin-cli`, which prints every start + # the unit has ever had: a run that failed to advertise would otherwise match the + # line an earlier one logged and pass. A fail-open gate is worse than no gate. + invocation=$(systemctl show -p InvocationID --value sendspin-cli) + [ -n "$invocation" ] || fail 'the unit reports no invocation id to scope the journal to' + for _ in $(seq 1 100); do + journalctl "_SYSTEMD_INVOCATION_ID=$invocation" --no-pager >journal.log + if grep -q 'mdns: advertising _sendspin\._tcp' journal.log; then break; fi + sleep 0.2 + done + grep -q 'mdns: advertising _sendspin\._tcp' journal.log || + fail 'the mDNS advertisement never reached avahi-daemon under the hardening block' + fi + + sudo systemctl restart sendspin-cli || fail 'the unit would not restart' + for _ in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.2; done + status=$(sudo sendspin-cli status --control-socket "$sock") || + fail 'the control socket did not answer status after a restart' + case "$status" in + *'static delay: 250 ms'*) ;; + *) fail 'the 250 ms set over the socket did not survive a restart' ;; + esac + + # Restart=on-failure covering the case it exists for. RestartSec is 5s, so the poll + # below has to outlast it -- and a new MainPID is what says it really came back rather + # than the old one still being reported. + main=$(systemctl show -p MainPID --value sendspin-cli) + sudo kill -9 "$main" + for _ in $(seq 1 100); do + now=$(systemctl show -p MainPID --value sendspin-cli) + [ "$now" != "$main" ] && [ "$now" != 0 ] && + [ "$(systemctl is-active sendspin-cli)" = active ] && break + sleep 0.2 + done + [ "$(systemctl is-active sendspin-cli)" = active ] && + [ "$(systemctl show -p MainPID --value sendspin-cli)" != "$main" ] || + fail 'the unit did not come back after SIGKILL' + + sudo systemctl stop sendspin-cli + result=$(systemctl show -p Result --value sendspin-cli) + [ "$result" = success ] || fail "stopping the unit left Result=$result" + + # Printed rather than asserted: a score is evidence for a reader, and pinning a number + # would make an unrelated systemd release fail this leg for saying so differently. + systemd-analyze security sendspin-cli --no-pager || true + # The macOS half of the same idea: the payload staged above, wrapped into the installer # anybody on a Mac would rather double-click than untar. `scripts/build_macos_pkg.sh` owns # how, and is a script rather than lines here so a developer can build the same .pkg from diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db332a8..e548bc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,9 +183,14 @@ jobs: ```bash sudo tar -xzf sendspin-cli-@VERSION@-linux-x86_64.tar.gz --strip-components=1 -C / \ sendspin-cli-@VERSION@-linux-x86_64/usr + sudo systemd-sysusers sudo systemctl daemon-reload ``` + `systemd-sysusers` creates the unprivileged account the unit runs as, from the + declaration the payload installs alongside it; a tarball has no `postinst` to run it + for you, and without it `systemctl start` reports `217/USER`. + Naming the `usr` member is what leaves `BUILD-INFO.txt` in the archive rather than unpacking it at `/`. Each archive carries its own `BUILD-INFO.txt` at the root with that command already filled in for its platform, the runtime packages it needs, and diff --git a/CMakeLists.txt b/CMakeLists.txt index fc12567..78f4cdd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -377,6 +377,19 @@ if(SENDSPIN_CLI_IS_TOP_LEVEL) DESTINATION lib/systemd/system COMPONENT ${SENDSPIN_CLI_COMPONENT} ) + + # The account the unit's User= names. lib/sysusers.d for the same reason as + # lib/systemd/system above -- the fragment is architecture-independent and + # systemd-sysusers reads /lib/sysusers.d, /usr/local included -- and copied + # rather than generated, since nothing in it depends on the configure line. + # + # RENAME because the source name says which of this project's files it is and the + # installed name is what sysusers.d wants: one .conf per package, named after it. + install(FILES packaging/sendspin-cli.sysusers.conf + DESTINATION lib/sysusers.d + RENAME sendspin-cli.conf + COMPONENT ${SENDSPIN_CLI_COMPONENT} + ) endif() endif() diff --git a/README.md b/README.md index 15247d0..72cbc2b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ sudo cmake --install build --component sendspin-cli ``` /usr/local/bin/sendspin-cli /usr/local/lib/systemd/system/sendspin-cli.service # Linux only +/usr/local/lib/sysusers.d/sendspin-cli.conf # Linux only /usr/local/share/doc/sendspin-cli/README.md /usr/local/share/doc/sendspin-cli/LICENSE /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example @@ -115,7 +116,10 @@ unit that still points at the old one. Reconfigure rather than redirect the inst The unit goes in `lib/systemd/system` and not in a multiarch `libdir`: a unit file is architecture-independent, and systemd reads `/usr/lib/systemd/system` and `/usr/local/lib/systemd/system` — never `lib/x86_64-linux-gnu/systemd/system`. That is -also why the default prefix needs nothing copied by hand. +also why the default prefix needs nothing copied by hand. The account declaration +beside it is in `lib/sysusers.d` for the same two reasons: `systemd-sysusers` searches +`/usr/local/lib/sysusers.d` alongside `/usr/lib/sysusers.d`, and a list of users has no +architecture either. To stage the same payload elsewhere — a tarball, a container image, an installer — give `cmake --install` a `DESTDIR` instead of a different prefix, and every path @@ -135,18 +139,21 @@ is the order CI uses for exactly this reason. ### The systemd unit ```bash +sudo systemd-sysusers sudo systemctl daemon-reload sudo systemctl enable --now sendspin-cli systemctl status sendspin-cli journalctl -u sendspin-cli -f ``` -One system unit, with sane defaults and nothing to fill in. It runs the player in the -**foreground** under `Type=simple`, so the log goes to the journal rather than to a -file something has to rotate — `-z` and `-f` would both be working around the -supervisor. `Type=forking` with `PIDFile=` pointing at `-P` is the shape for a -supervisor with no journal, and `Type=notify` is not available at all: `sd_notify` is -not wired up. +One system unit, with sane defaults and nothing to fill in. **`systemd-sysusers` is not +optional** — it creates the account the unit runs as, and the unit does not start +without it; the end of this section says what that account is and what it may reach. +It runs the player in the **foreground** under `Type=simple`, so the log goes to the +journal rather than to a file something has to rotate — `-z` and `-f` would both be +working around the supervisor. `Type=forking` with `PIDFile=` pointing at `-P` is the +shape for a supervisor with no journal, and `Type=notify` is not available at all: +`sd_notify` is not wired up. Two flags are on the `ExecStart` line, and both are there because a system unit has neither of the environment variables a default path would come from: @@ -171,37 +178,89 @@ and `control-socket` in a config file are *silently ignored* under this unit, be the unit passes both. Setting `control-socket` to the same path the unit uses is still worth doing — it is what lets a subcommand find the socket with no flags. -**The subcommands need `sudo` here.** The control socket is mode `0600` and the -service is root's, so an unprivileged shell cannot connect to it: +**The subcommands need `sudo` here.** The control socket is mode `0600` and belongs to +the service account, so an unprivileged shell cannot connect to it — while root can, +because root is not subject to the mode: ```console $ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock ``` -**It runs as root, and that is a decision rather than an oversight.** There is no -`User=` and no hardening block, so the player runs as root — its WebSocket server, -which listens on the network, included. A tarball has no `postinst` to create a -dedicated user with, and root already has the sound card (`/dev/snd` is `root:audio` -mode `0660`), the mDNS daemon and `/run` with nothing to arrange first. A drop-in is -where to change it: +**It runs as `sendspin-cli`, an unprivileged account, and creating it is the one +step installing cannot do for you.** The unit names `User=sendspin-cli` — no home, +no shell — and a tarball has no `postinst`, so the declaration ships beside the unit as +`lib/sysusers.d/sendspin-cli.conf` and one command turns it into an account: -```bash -sudo systemctl edit sendspin-cli +```console +$ sudo systemd-sysusers +Creating group 'sendspin-cli' with GID 997. +Creating user 'sendspin-cli' (Sendspin audio player) with UID 997 and GID 997. +``` + +It is idempotent, so running it twice is free. Skip it and the unit does not start at +all — `systemctl status` says `status=217/USER`, which names the cause: + +``` +sendspin-cli.service: Main process exited, code=exited, status=217/USER ``` +The fragment carries two lines, and they are owed **together**: the account, and its +membership of `audio`. A `sendspin-cli` in no `audio` group is a player that starts and +cannot open a device, because `/dev/snd` is `root:audio` mode `0660`. That is the whole +reason this is a shipped declaration rather than a `useradd` line in this file — one +artifact, both halves, or neither. If you manage accounts with your own tooling, the +equivalent is `useradd --system --no-create-home -G audio sendspin-cli`. + +`DynamicUser=` looks like it would avoid all of this and does not: it hands the player a +uid in no supplementary group at all, which deafens the ALSA backend. + +**Upgrading from a version that ran as root needs nothing done to +`/var/lib/sendspin-cli`.** `StateDirectory=` chowns the directory it finds as well as +the one it creates, recursively, so a root-owned state file from an earlier install +becomes the new account's on the first start and the remembered volume, mute and static +delay carry over. systemd has documented that since v235 — the same release this unit +needs anyway — and CI plants a root-owned `/var/lib/sendspin-cli` on every Linux leg and +reads the value back out of it afterwards. + +**What is hardened, and what is not.** The unit carries a hardening block — +`ProtectSystem=strict`, `NoNewPrivileges=`, an empty `CapabilityBoundingSet=`, +`RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallFilter=@system-service` +and the `Protect*=` family — and every directive in it was run rather than copied from a +list. Read the unit: each is commented where it sits. On systemd 255 `systemd-analyze +security` puts the result at **1.8 OK**, against **9.6 UNSAFE** for the root unit this +replaces; the Linux CI legs print the score on every run rather than asserting it, since +pinning a number would make an unrelated systemd release fail a build for rewording. + +**One configuration stops working, and it is worth checking before you upgrade.** +Under `ProtectSystem=strict` a `logfile` or `pidfile` in `/etc/sendspin-cli.conf` +pointing outside `/run/sendspin-cli` and `/var/lib/sendspin-cli` is refused — `cannot +open logfile /var/log/sendspin-cli.log: Read-only file system`, on every restart, +rather than a player that logs nowhere in silence. Neither key is the shape for this +unit anyway, since journald already has stderr and `-z`/`-f` are for a supervisor +without one. If you want a logfile regardless, a drop-in is the way back: + ```ini [Service] -User=sendspin -SupplementaryGroups=audio +ReadWritePaths=/var/log ``` -Both lines together, and neither alone: a user with no membership of `audio` is a -player that starts and cannot open a device. Create the user first (`useradd ---system --no-create-home sendspin`), and chown an existing `/var/lib/sendspin-cli` -to it once — systemd owns the directory it creates, not one a root-run player left -behind. This project has not run it that way, which is also why no hardening -directive ships: a dedicated user and a hardening block are owed together, and no task -is open for them yet. +The block wants systemd **247** in full — `ProtectProc=` is its newest directive — +while the unit itself still starts on 236. Below 247 the shortfall is one line and a +log message: systemd warns `Unknown key name 'ProtectProc' … ignoring` and runs the +unit with the rest, which was checked on 245. + +Four directives are deliberately *absent*, because they gate what the ALSA backend +reaches and a machine with no sound card cannot tell you whether they break it: +`PrivateDevices=`, `DeviceAllow=`, `ProcSubset=pid` and `RestrictRealtime=`. Each of +them passes every check CI makes, which is exactly why passing proves nothing about +them. They are tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md) item 10. + +**To change any of it, use a drop-in** rather than editing the installed unit, which an +upgrade overwrites: + +```bash +sudo systemctl edit sendspin-cli +``` An `ExecStart=` in a drop-in has to be cleared first (`ExecStart=` on its own line, then the replacement), which is systemd's rule for every list-valued directive rather @@ -1178,7 +1237,8 @@ Files that `installer` puts on disk are not quarantined either way, so the insta unpacked it in Finder. That is convenience, not identity. There is no uninstaller. Four files and the receipt undo it completely — the systemd -unit in the [Install](#install) list is Linux-only and never in this package: +unit and the sysusers fragment in the [Install](#install) list are Linux-only and never +in this package: ```bash sudo rm -f /usr/local/bin/sendspin-cli diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dca53f8..2b48caf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -902,7 +902,7 @@ Deliberately **not** here, and each one names its owner rather than being left i Image and compose file. ALSA with `/dev/snd` passed through for real output, and the null sink for device-less containers and CI. -### 10. Packaging — *shipped (install rules, the unit, the CI payload, the tagged release, the macOS `.pkg`; signing still owed)* +### 10. Packaging — *shipped (install rules, the unit, the CI payload, the tagged release, the macOS `.pkg`, the unit's own user and hardening; signing still owed)* Nothing in this tree could be installed. `cmake --build` left the binary where it built it, and `.github/workflows/ci.yml` hand-rolled a tarball around it with three `cp` paths inside a @@ -911,7 +911,8 @@ everything a unit file needs and then stopped at *documenting* it, so `README.md operator to choose between `Type=simple` and `Type=forking` and write the unit themselves. **Shipped** in `CMakeLists.txt`, `packaging/sendspin-cli.service.in`, -`packaging/sendspin-cli.conf.example`, `scripts/build_macos_pkg.sh`, +`packaging/sendspin-cli.sysusers.conf`, `packaging/sendspin-cli.conf.example`, +`scripts/build_macos_pkg.sh`, `.github/workflows/{build,ci,release}.yml` and `README.md`: - **A staged `cmake --install` plus an explicit `tar`, and no CPack.** The per-leg archive name @@ -923,8 +924,8 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t - **Every install rule names one component, and the payload is asked for by name:** `cmake --install build --component sendspin-cli`. That is load-bearing rather than tidy. The dependencies fetched at configure time declare install rules of their own, and a bare - `cmake --install` into an empty prefix stages **148 files on Linux** where the component stages - five: the other 143 are every ArduinoJson header and its three CMake export files. + `cmake --install` into an empty prefix stages **149 files on Linux** where the component stages + six: the other 143 are every ArduinoJson header and its three CMake export files. `IXWEBSOCKET_INSTALL` is now `OFF` beside the existing `INSTALL_GTEST OFF`, since otherwise IXWebSocket adds its archive, its headers, a `.pc` and an `install(EXPORT)` as well; but ArduinoJson declares its rules with no option at all, and patching a fetched subproject to get @@ -934,9 +935,13 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t Naming a component is CMake's own answer to "which of these rules are mine", and unlike a list of upstream options it stays true across a `SENDSPIN_GIT_TAG` bump that adds a dependency. -- **The install set is deliberately five files:** `bin/sendspin-cli`, - `lib/systemd/system/sendspin-cli.service` on Linux only, and `share/doc/sendspin-cli/` - holding `README.md`, `LICENSE` and `sendspin-cli.conf.example`. `sendspin-cli-core` is absent +- **The install set is deliberately six files:** `bin/sendspin-cli`, + `lib/systemd/system/sendspin-cli.service` and `lib/sysusers.d/sendspin-cli.conf` on Linux + only, and `share/doc/sendspin-cli/` + holding `README.md`, `LICENSE` and `sendspin-cli.conf.example`. The count is a claim that + every member is argued for rather than a cap — the sysusers fragment is the unit's `User=` + made installable, and belongs to the hardening slice below — and CI's payload diff is what + stops a seventh arriving unnoticed. `sendspin-cli-core` is absent on purpose — it exists so the tests can link the parser without a process entry point, and nothing outside this build wants a static archive of it. The doc directory is named after the binary rather than taken from `CMAKE_INSTALL_DOCDIR`, which is built from the *project* name: @@ -990,18 +995,54 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t parse error naming its file and line in the journal each time. That is the wanted end of it: an operator who fixes the file gets a player back without also having to `reset-failed` a unit that had given up. -- **It runs as root: no `User=`, no `DynamicUser=`, and no hardening block.** Named as a - decision because it is one. A tarball has no `postinst` to create a dedicated user with, and - root already has the sound card (`/dev/snd` is `root:audio` mode `0660`), the mDNS daemon and - `/run` with nothing to arrange first — while `DynamicUser=` would hand the player a uid in no - supplementary group and so deafen the ALSA backend that is the whole point of this unit on - Linux. An untested hardening directive is worse than none, and this branch could not test one: - `ProtectSystem=`, `PrivateDevices=` and friends each have to be tried against a real card, a - real mDNS daemon and a real socket. What ships instead is the `systemctl edit` recipe in - `README.md`, naming what a drop-in owes *together* — a user that exists, membership of - `audio`, and ownership of `/var/lib/sendspin-cli` — because two of the three alone is a player - that starts and cannot play. This unit's WebSocket server therefore listens on the network as - root, which is said out loud here rather than left for a reader to notice. +- **It runs as `sendspin-cli` with a hardening block, and neither shipped without the other.** + The account is declared in `lib/sysusers.d/sendspin-cli.conf` beside the unit, carrying two + lines that are owed together — the user, and its membership of `audio` — because a uid with + no `audio` is a player that starts and cannot open `/dev/snd` (`root:audio` mode `0660`). + That is also what rules out `DynamicUser=`, which hands the player a uid in no supplementary + group at all. A tarball still has no `postinst`, so `systemd-sysusers` is a command + `README.md` and `BUILD-INFO.txt` both tell an operator to run once; skipping it is not a + quiet degradation but `217/USER` in `systemctl status`. Nothing has to be done to an existing + `/var/lib/sendspin-cli` left root-owned by the version before this one: `StateDirectory=` + chowns a directory it finds as well as one it creates, recursively, documented since systemd + 235 and checked against 255 — the state a root-run player wrote is read back by the new + account. The whole of it, the hardening block included, is exercised on every Linux CI leg + rather than reasoned about: the unit starts as the account, the control socket answers + `status`, a planted root-owned state file survives the ownership change, `delay` lands 0600, + it comes back from `SIGKILL`, and `systemctl stop` leaves `Result=success`. +- **The hardening block is what was tried, not what a list suggests.** `ProtectSystem=strict`, + `ProtectHome=`, `PrivateTmp=`, `NoNewPrivileges=`, an empty `CapabilityBoundingSet=`, + `RestrictSUIDSGID=`, the `Protect*=` kernel family, `ProtectProc=invisible`, + `RestrictNamespaces=`, `LockPersonality=`, `MemoryDenyWriteExecute=`, + `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallArchitectures=native` and + `SystemCallFilter=@system-service`. Two of those needed more than "it booted". `AF_NETLINK` + is left out because glibc's interface probe falls back when it cannot open one, which was + settled by running browse, resolve, the A-record query behind a `ws://` URL and a dial by + hostname that really connected — all under the restriction. And `@system-service` covers + every syscall `libasound` imports, `ioctl`, `mmap`, `mlock` and the SysV IPC calls `dmix` + uses included, read off the shipped library's own import table rather than assumed, which is + what keeps the audio path from being the thing that directive is gambling on. + One existing configuration stops working, and it is the whole of what an upgrade breaks: + `ProtectSystem=strict` refuses a `logfile` or `pidfile` in `/etc/sendspin-cli.conf` that + points outside the unit's own two directories. It fails the way this project prefers — the + error names the path and the reason on every retry, rather than a player logging into + nowhere — and `ReadWritePaths=` in a drop-in is the documented way back. Neither key was ever + the shape for this unit, `Type=simple` having handed stderr to journald already. + The block also moves the unit's *effective* floor, which is worth stating rather than + discovering: `ProtectProc=` is from 247 and four more land between 242 and 245, against the + 236 the unit needs to start at all. Below 247 systemd logs `Unknown key name … ignoring` and + runs it anyway — checked on 245 — so the degradation is one directive and a warning rather + than a unit that will not load, and 247 is Debian 11. + `systemd-analyze security` scores the result 1.8 against the root unit's 9.6 on systemd 255; + CI prints it rather than asserting it, since a number is evidence and not a target, and the + one remaining `✗` is `UMask=`, which buys nothing here — the player creates its socket and + its state file `0600` itself. +- **Four directives are deliberately absent, and that is the item's own rule applied to + itself.** `PrivateDevices=`, `DeviceAllow=`, `ProcSubset=pid` and `RestrictRealtime=` each + gate what the ALSA backend reaches — `/dev/snd`, `/proc/asound`, `SCHED_FIFO` — and every one + of them *passes* the checks above under `-o null`, which is exactly why passing proves + nothing about them. An untested hardening directive is worse than none, so they wait for a + run against a real card; see the open work below. - **CI stages the payload with `DESTDIR`, not `--prefix`,** so every path under the archive's `usr/` is the path the file installs to and the whole thing goes in with `sudo tar -xzf … --strip-components=1 -C / /usr`. The member is named rather than @@ -1245,11 +1286,13 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t `hostArchitectures` one. Same class of failure as the arch gate — an install that reports success and then cannot run — and the same declarative remedy, but it wants the deployment target pinned first, which is a build decision rather than a packaging one. -- **A dedicated user and a hardening block** → owed together, with **no task open for them yet**; - `README.md` says the same. Mostly *verifiable in CI* rather than needing hardware: a runner - boots the whole daemon under `-o null` today, so every directive except the ones that gate - `/dev/snd` can be tried there. Kept separate because it is a behaviour change to a unit that - currently works. +- **The four `/dev/snd`-gating directives**, with **no task open for them yet**: `PrivateDevices=`, + `DeviceAllow=`, `ProcSubset=pid` and `RestrictRealtime=`, all named in the unit where a reader + meets them. What is missing is not analysis but a machine — every one passes CI under `-o null` + and would keep passing while deafening a real card, so the check that settles them is a player + on hardware with a device configured, listened to. Worth doing as one sitting: the four share a + fixture, and `DeviceAllow=char-alsa rw` with `DevicePolicy=closed` is what the first three are + really reaching for. - **A drift guard on the version strings in `README.md`,** with **no task open for it yet**, and worth more now than when it was first named. Several places spell an artifact name out in full — `sendspin-cli-0.1.0-linux-x86_64`, its macOS twin, and the `.pkg` and @@ -1298,10 +1341,23 @@ deliberately broken `/etc/sendspin-cli.conf` was left in place for a minute and shipped example config was checked the same way, every commented line uncommented: all eight keys and values are accepted, the run gets as far as the sound card the container does not have. -**Two things that were not exercised and are worth knowing.** No hardening directive was tried, -which is why none ships. And no audio came out of a unit — the container has no `/dev/snd`, so -`Type=simple` under systemd is proven to *start*, log, serve its socket and stop cleanly, but the -ALSA path under it is inference from a foreground run. +**The hardening slice was proven the same way, one directive at a time.** On the same +`ubuntu:24.04` (aarch64, systemd 255) with `avahi-daemon` running, every candidate was applied on +its own and put through the whole round — unit active, control socket answering `status`, +WebSocket port accepting a connection, the `_sendspin._tcp` advertisement reaching Avahi, `delay` +landing in a `0600` state file, that value surviving a restart, a `SIGKILL` recovered from, and +`systemctl stop` leaving `Result=success` — then the shipped block was put through it whole, and +then the real installed unit with no drop-in at all. `systemd-sysusers` creating the account from +`/usr/local/lib/sysusers.d` was run from a host that had no such user, `217/USER` was seen before +it and the account with its `audio` membership after, and a root-owned `/var/lib/sendspin-cli` +holding a `175 ms` delay was planted and read back through the new uid. `PrivateDevices=` and +`ProcSubset=pid` were run too, and passed — which is the evidence for excluding them rather than +against it. + +**One thing that was not exercised and is worth knowing:** no audio came out of a unit. The +container has no `/dev/snd`, so `Type=simple` under systemd is proven to *start*, log, serve its +socket and stop cleanly, but the ALSA path under it is inference from a foreground run — which is +also why the four device-gating directives above are named rather than shipped. **A third, for the release slice: `release.yml` has never run.** Everything asserted about it above is reasoning plus a local dry run, not a release. What *was* exercised, on macOS against diff --git a/packaging/sendspin-cli.service.in b/packaging/sendspin-cli.service.in index 54e6efb..5656d7e 100644 --- a/packaging/sendspin-cli.service.in +++ b/packaging/sendspin-cli.service.in @@ -1,8 +1,11 @@ # sendspin-cli as a system service. # # `cmake --install` puts this in /lib/systemd/system, which systemd already searches -# for both /usr and the default /usr/local prefix, so there is nothing to copy by hand: +# for both /usr and the default /usr/local prefix, so there is nothing to copy by hand. The one +# thing a tarball cannot do for itself is create the account User= names, since it has no +# postinst to run sysusers with: # +# systemd-sysusers # systemctl daemon-reload # systemctl enable --now sendspin-cli # @@ -18,12 +21,22 @@ # Setting `control-socket` in the config to the same path is still worth doing: that is what a # subcommand reads to find this socket, which is why the example file suggests it. # -# `sendspin-cli status` and the other subcommands need `sudo` here: the control socket is -# mode 0600 and this service is root's, so an unprivileged shell cannot connect to it. +# `sendspin-cli status` and the other subcommands need `sudo` here: the control socket is mode +# 0600 and belongs to the service account, so an unprivileged shell cannot connect to it. Root +# still can, because root is not subject to the mode -- which is why the recipe is `sudo` rather +# than joining a group. # -# Needs systemd 235 or newer, which is where StateDirectory= and the %S specifier arrived -# (RuntimeDirectory= and %t are older). Debian 10 shipped 241, so every distribution this +# Needs systemd 236 or newer: StateDirectory= is 235, and the %S that names it is 236 -- the two +# are a release apart, and it is the specifier that sets the floor (RuntimeDirectory= and %t are +# both far older). 235 is where the recursive chown the upgrade note below relies on was first +# documented, so that is covered either way. Debian 10 shipped 241, so every distribution this # player plausibly runs on is well past it. +# +# The hardening block wants 247 for all of it -- ProtectProc= is the newest directive here, and +# RestrictSUIDSGID=, ProtectHostname=, ProtectKernelLogs= and ProtectClock= arrive between 242 +# and 245. Nothing breaks below that: an unknown key is a "Unknown key name ... ignoring" +# warning and the unit runs with one directive fewer, checked against 245. So the floor for +# *starting* is 236 and the floor for the whole block is 247, which is Debian 11. [Unit] Description=Sendspin audio player @@ -45,11 +58,83 @@ After=avahi-daemon.service # pointing at -P is the alternative for a supervisor that has no journal. Type=simple -# No User= and no hardening block, so this runs as root -- its WebSocket server included, and -# that listens on the network. A deliberate default rather than an oversight: a tarball has no -# postinst to create a dedicated user with, and root already has the sound card (/dev/snd is -# root:audio 0660), the mDNS daemon and /run without anything having to be arranged. A -# `systemctl edit sendspin-cli` drop-in is where to change it; README says what one owes. +# The account declared in lib/sysusers.d/sendspin-cli.conf, installed beside this file. It is +# not created for you -- run `systemd-sysusers` once, as above -- and until it exists this unit +# does not start at all: `systemctl status` reports 217/USER, which names the cause rather than +# hiding it. No SupplementaryGroups=audio here even though the player needs it for /dev/snd +# (root:audio 0660): the fragment's `m` line carries the membership, and the account and what it +# may reach belong in one file rather than half in each. DynamicUser= is not the shortcut it +# looks like -- it hands the player a uid in no supplementary group at all, which deafens the +# ALSA backend that is the point of this unit on Linux. +# +# An existing /var/lib/sendspin-cli left root-owned by an earlier root-run version needs nothing +# done to it: StateDirectory= chowns a directory it finds as well as one it creates, recursively, +# which is documented from systemd 235 and was checked here against 255. +User=sendspin-cli + +# Hardening. Every directive below was run rather than copied: with it in place the unit starts, +# the control socket answers `status`, the WebSocket port accepts a connection, the mDNS +# advertisement reaches avahi-daemon, `delay` lands in a 0600 state file that survives a restart, +# the unit comes back after SIGKILL, and `systemctl stop` leaves Result=success. What could not +# be tried is named at the end of this block instead of guessed at. +# +# No privilege to gain, none to keep, and none to hand on: an unprivileged service starts with +# an empty capability set anyway, and these make that the kernel's rule rather than a +# consequence of who it happens to be running as. +NoNewPrivileges=yes +CapabilityBoundingSet= +RestrictSUIDSGID=yes + +# The whole hierarchy read-only, with the two directories below as the exception systemd makes +# itself -- RuntimeDirectory= and StateDirectory= stay writable under ProtectSystem=strict, which +# is what leaves the socket and the state file somewhere to be. /etc/sendspin-cli.conf is only +# ever read, so nothing here wants /etc writable. +# +# The one thing this takes away: `logfile` or `pidfile` in the config, pointing anywhere but +# those two directories, now fails -- "cannot open logfile ...: Read-only file system" on every +# attempt rather than a player that quietly logs nowhere. Neither is the shape for this unit +# anyway, since journald already has stderr, and a drop-in with ReadWritePaths= is the way back +# for an operator who wants one regardless. +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes + +# Kernel surface this player has no use for. ProtectProc= hides other users' processes and not +# /proc/asound, which is the distinction that matters: ProcSubset=pid would hide that too and is +# in the unproven list below for it. MemoryDenyWriteExecute= is the one here that touches the +# audio path at all, and only through dlopen: alsa-lib loads its PCM plugins as shared objects, +# which map executable-not-writable like any library and so are untouched by it -- the mDNS +# compatibility layer this run does dlopen is the same mechanism, proven rather than argued. +ProtectProc=invisible +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes + +# AF_UNIX for the control socket and for the avahi socket the mDNS compatibility layer dials, +# AF_INET and AF_INET6 for the WebSocket server and for an -s dial. AF_NETLINK is deliberately +# absent: glibc probes the interface list over it and falls back cleanly when it cannot, which +# was checked rather than assumed -- browse, resolve, the A-record query behind a ws:// URL, and +# a dial by hostname that really connected all work without it. +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +# @system-service covers every syscall libasound imports, ioctl, mmap, mlock and the SysV IPC +# calls dmix uses included -- checked against the shipped library's own import table, so the +# audio path is not what this is gambling on. Native ABI only, since nothing here is 32-bit. +SystemCallArchitectures=native +SystemCallFilter=@system-service + +# What is NOT here, and why: PrivateDevices=, DeviceAllow= and ProcSubset=pid each gate what the +# ALSA backend reaches -- /dev/snd and /proc/asound -- and every one of them passes the checks +# above with `-o null`, which is exactly why passing proves nothing. They want a real card. +# RestrictRealtime= is the same shape from the other side: an unprivileged player cannot take +# SCHED_FIFO anyway, so the directive would be free here and untested where it counts. +# docs/ROADMAP.md item 10 carries all four as future work. # %t and %S are the two directories the lines below create -- /run and /var/lib for a system # unit -- and each flag is here because a system unit has neither of the environment variables diff --git a/packaging/sendspin-cli.sysusers.conf b/packaging/sendspin-cli.sysusers.conf new file mode 100644 index 0000000..e68e8f2 --- /dev/null +++ b/packaging/sendspin-cli.sysusers.conf @@ -0,0 +1,20 @@ +# The account sendspin-cli.service runs as, installed to /lib/sysusers.d. +# +# A declaration rather than a README line of `useradd`, because the two lines below are owed +# *together*: an account with no membership of `audio` is a player that starts and cannot open +# /dev/snd, which is root:audio mode 0660. As one file both halves arrive or neither does, and +# `systemd-sysusers` is idempotent, so the recipe survives being run twice. +# +# A tarball has no postinst, so nothing runs this for you -- README.md says to invoke +# `systemd-sysusers` once after installing. systemd-sysusers.service does run it at boot, but +# only when its ConditionNeedsUpdate=|/etc fires, so it is not what the install should rest on. +# Without the account the unit does not start at all: `systemctl status` reports 217/USER. +# +# The uid, home and shell are left as `-` on purpose. A fixed uid needs a registry nobody here +# keeps, and a spelled-out /usr/sbin/nologin is wrong on the distributions that ship +# /sbin/nologin instead -- systemd picks a free system uid and the host's own nologin. +# +# `m` creates the audio group if the host has none, which is the harmless end of it: a host with +# no sound card has no /dev/snd for the membership to reach either way. +u sendspin-cli - "Sendspin audio player" - - +m sendspin-cli audio