Skip to content

Run the systemd unit as a dedicated hardened user - #19

Merged
chrisuthe merged 1 commit into
mainfrom
chrisuthe/task/run-the-systemd-unit-as-a-dedicated-hardened-user
Aug 19, 2026
Merged

Run the systemd unit as a dedicated hardened user#19
chrisuthe merged 1 commit into
mainfrom
chrisuthe/task/run-the-systemd-unit-as-a-dedicated-hardened-user

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

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:

sudo systemd-sysusers

It creates the sendspin-cli account from lib/sysusers.d/sendspin-cli.conf, new in
the payload beside the unit. It is idempotent. Skip it and the unit does not start —
systemctl status reports 217/USER, which names the cause. README, the tarball's
BUILD-INFO.txt and the release notes template all now carry the line.

/var/lib/sendspin-cli needs nothing done to it. StateDirectory= chowns a
directory 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, a logfile or
pidfile in /etc/sendspin-cli.conf pointing outside the unit's own two directories
is refused — cannot open logfile …: Read-only file system, on every retry, rather
than 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.d rather than a useradd line in README, and the argument is not that it
saves 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 audio membership are owed
together: a uid with no audio is a player that starts and cannot open /dev/snd
(root:audio mode 0660). One idempotent artifact carries both halves or neither.
DynamicUser= stays ruled out for the same reason — it grants no supplementary group
at 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 (systemd
255, aarch64) with a real avahi-daemon — checking each time that the unit starts, the
control socket answers status, the WebSocket port accepts a connection, the mDNS
advertisement reaches Avahi, delay lands in a 0600 state file that survives a
restart, the unit recovers from SIGKILL, and systemctl stop leaves
Result=success.

Two needed more than "it booted":

  • RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6AF_NETLINK is left out
    because 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 by
    hostname that really connected, all under the restriction.
  • SystemCallFilter=@system-service — it 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.

Four directives are deliberately absent: PrivateDevices=, DeviceAllow=,
ProcSubset=pid and RestrictRealtime=. Each gates what the ALSA backend reaches, and
each passes every check above under -o null — which is exactly why passing proves
nothing 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, against
the 236 the unit needs to start (StateDirectory= is 235, but the %S that names it is
236). Below 247 systemd logs Unknown key name … ignoring and runs the unit with the
rest — checked on 245 — so the shortfall is one directive and a warning.

systemd-analyze security, as evidence rather than a target

before:  Overall exposure level for sendspin-cli.service: 9.6 UNSAFE :-{
after:   Overall exposure level for sendspin-cli.service: 1.8 OK :-)

The one remaining is UMask=, and it is not worth chasing: the player creates its
socket and its state file 0600 itself. 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-sysusers
from the fragment the install rules just placed under /usr/local (which also proves
the search path), the audio membership, the unit up as the account, a planted
root-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 real avahi-daemon
the mDNS advertisement, scoped to _SYSTEMD_INVOCATION_ID so an earlier run's journal
line cannot satisfy it. That assertion was checked in both directions: it fails with
Avahi masked and passes with it running.

systemd-analyze verify stays clean, including on a host with no such account.
actionlint with shellcheck is clean over all three workflows. The suite is
349/349 and scripts/smoke_test.sh fully green.

The payload grows from five files to six; CI's payload diff is what stops a seventh
arriving unnoticed, and the macOS .pkg stays at four.

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
chrisuthe marked this pull request as ready for review August 19, 2026 23:06
@chrisuthe
chrisuthe merged commit b8d8d6a into main Aug 19, 2026
10 checks passed
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.

1 participant