Run the systemd unit as a dedicated hardened user - #19
Merged
chrisuthe merged 1 commit intoAug 19, 2026
Merged
Conversation
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.
chrisuthe
marked this pull request as ready for review
August 19, 2026 23:06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The shipped unit ran the player as root — its network-facing WebSocket server
included — with no hardening block. The unit comment, README's systemd section and
ROADMAP item 10 all recorded that as debt, and all three said a dedicated user and a
hardening block were owed together. Both ship here.
What changes for an existing deployment
The operator owes one command. After upgrading, before starting:
It creates the
sendspin-cliaccount fromlib/sysusers.d/sendspin-cli.conf, new inthe payload beside the unit. It is idempotent. Skip it and the unit does not start —
systemctl statusreports217/USER, which names the cause. README, the tarball'sBUILD-INFO.txtand the release notes template all now carry the line./var/lib/sendspin-clineeds nothing done to it.StateDirectory=chowns adirectory it finds as well as one it creates, recursively — documented since systemd
235, checked here against 255. A root-owned state file from the old unit becomes the
new account's on first start and the remembered volume, mute and static delay carry
over. CI plants one and reads the value back out.
One configuration stops working. Under
ProtectSystem=strict, alogfileorpidfilein/etc/sendspin-cli.confpointing outside the unit's own two directoriesis refused —
cannot open logfile …: Read-only file system, on every retry, ratherthan a player logging into nowhere. Neither key was ever the shape for this unit, and
ReadWritePaths=in a drop-in is the documented way back.The account
sysusers.drather than auseraddline in README, and the argument is not that itsaves a step — a tarball has no postinst either way, so it is one hand-run command
instead of another. It is that the account and its
audiomembership are owedtogether: a uid with no
audiois a player that starts and cannot open/dev/snd(
root:audiomode0660). One idempotent artifact carries both halves or neither.DynamicUser=stays ruled out for the same reason — it grants no supplementary groupat all.
The hardening block
Every directive was run rather than copied. Each was applied on its own, then the whole
block, then the real installed unit with no drop-in, against
ubuntu:24.04(systemd255, aarch64) with a real
avahi-daemon— checking each time that the unit starts, thecontrol socket answers
status, the WebSocket port accepts a connection, the mDNSadvertisement reaches Avahi,
delaylands in a0600state file that survives arestart, the unit recovers from
SIGKILL, andsystemctl stopleavesResult=success.Two needed more than "it booted":
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6—AF_NETLINKis left outbecause glibc's interface probe falls back when it cannot open one. Settled by
running browse, resolve, the A-record query behind a
ws://URL, and a dial byhostname that really connected, all under the restriction.
SystemCallFilter=@system-service— it covers every syscalllibasoundimports,
ioctl,mmap,mlockand the SysV IPC callsdmixuses included, readoff the shipped library's own import table rather than assumed.
Four directives are deliberately absent:
PrivateDevices=,DeviceAllow=,ProcSubset=pidandRestrictRealtime=. Each gates what the ALSA backend reaches, andeach passes every check above under
-o null— which is exactly why passing provesnothing about them. They want a real card, and ROADMAP item 10 carries them.
The block also raises the unit's effective floor:
ProtectProc=is from 247, againstthe 236 the unit needs to start (
StateDirectory=is 235, but the%Sthat names it is236). Below 247 systemd logs
Unknown key name … ignoringand runs the unit with therest — checked on 245 — so the shortfall is one directive and a warning.
systemd-analyze security, as evidence rather than a targetThe one remaining
✗isUMask=, and it is not worth chasing: the player creates itssocket and its state file
0600itself. CI prints the score rather than asserting it,so an unrelated systemd release cannot fail a leg for rewording.
Verification
The Linux CI legs now run the unit rather than only parsing it:
systemd-sysusersfrom the fragment the install rules just placed under
/usr/local(which also provesthe search path), the
audiomembership, the unit up as the account, a plantedroot-owned state file read back through the new uid, the state file's mode and owner,
restart-after-
SIGKILL, a clean stop, and — on the leg with a realavahi-daemon—the mDNS advertisement, scoped to
_SYSTEMD_INVOCATION_IDso an earlier run's journalline cannot satisfy it. That assertion was checked in both directions: it fails with
Avahi masked and passes with it running.
systemd-analyze verifystays clean, including on a host with no such account.actionlintwithshellcheckis clean over all three workflows. The suite is349/349 and
scripts/smoke_test.shfully green.The payload grows from five files to six; CI's payload diff is what stops a seventh
arriving unnoticed, and the macOS
.pkgstays at four.