From 299a3ffccd7b6dd033acfcd6c103eb9edb8053ed Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Mon, 14 Sep 2026 10:18:25 -0500 Subject: [PATCH] Allow AF_NETLINK in the systemd unit so the MAC-derived client id is detected glibc's getifaddrs() reads the interface list over netlink and has no fallback, so under RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 MAC detection fails and a player with no id says client/hello with an empty client_id, which Music Assistant refuses with "No key provided". The hardening step in CI now opens a WebSocket to the unit and requires the hello it is sent to carry a non-empty client_id, and fails if the invocation's journal logs "getifaddrs failed". ROADMAP and the wiki drop the claim that netlink was unneeded, and Troubleshooting gives existing installs a drop-in workaround. --- .github/workflows/build.yml | 109 ++++++++++++++++++++++++++++-- docs/ROADMAP.md | 13 ++-- docs/wiki/Running-as-a-Service.md | 2 +- docs/wiki/Troubleshooting.md | 37 ++++++++++ packaging/sendspin-cli.service.in | 23 ++++--- 5 files changed, 162 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be29e1e..3ff779e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -890,15 +890,110 @@ jobs: 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'" + # Scoped to this invocation rather than `-u sendspin-cli`, which prints every start the + # unit has ever had: a run that failed would otherwise be judged by the lines an earlier + # one logged. 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' + + # The identity a server files this player under. With no `id` in the config -- and the + # one above sets none, which is what this depends on -- the library derives client_id + # from the interface MAC, read through getifaddrs(), which needs AF_NETLINK. It does that + # only while building client/hello, and only when a connection arrives, so nothing + # before this point has exercised it: booting, the socket and mDNS all work without the + # family. An inbound connection is sent its hello straight after the upgrade, unasked, + # so a bare WebSocket handshake is enough to get one. + # + # Two gates, because each is blind where the other is not. An empty client_id is what + # Music Assistant refuses, and checking for it survives a library that rewords its log; + # the journal line survives a library that answers a failed detection with some other + # non-empty id, which the first gate would wave through. Port 8928 is the default, and + # the config sets no other. + python3 - <<'EOF' || fail 'the hardened unit did not greet a connection with a non-empty client_id' + import base64, json, os, socket, sys, time + + # The control socket appearing says nothing about the WebSocket port, so the connect + # is retried rather than assumed. + deadline = time.monotonic() + 20 + while True: + try: + sock = socket.create_connection(("127.0.0.1", 8928), timeout=5) + break + except OSError as err: + if time.monotonic() > deadline: + sys.exit(f"nothing accepted a connection on port 8928: {err}") + time.sleep(0.2) + sock.settimeout(10) + + buffered = b"" + + def read(count): + global buffered + while len(buffered) < count: + chunk = sock.recv(65536) + if not chunk: + sys.exit("the player closed the connection before sending client/hello") + buffered += chunk + out, buffered = buffered[:count], buffered[count:] + return out + + key = base64.b64encode(os.urandom(16)).decode() + sock.sendall( + "GET /sendspin HTTP/1.1\r\nHost: 127.0.0.1:8928\r\nUpgrade: websocket\r\n" + f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" + .encode() + ) + response = b"" + while not response.endswith(b"\r\n\r\n"): + response += read(1) + status = response.split(b"\r\n", 1)[0].decode(errors="replace") + if status.split()[1:2] != ["101"]: + sys.exit(f"the WebSocket upgrade was refused: {status}") + + # The first complete text message. Frames from a server are unmasked; control and + # binary frames are skipped rather than mistaken for it. + message = b"" + while True: + first, second = read(2) + length = second & 0x7F + if length == 126: + length = int.from_bytes(read(2), "big") + elif length == 127: + length = int.from_bytes(read(8), "big") + payload = read(length) + opcode = first & 0x0F + if opcode == 0x8: + sys.exit("the player sent a close frame before client/hello") + if opcode in (0x0, 0x1): + message += payload + if first & 0x80: + break + + hello = json.loads(message) + if hello.get("type") != "client/hello": + sys.exit(f"the first message was {hello.get('type')!r}, not client/hello") + client_id = hello.get("payload", {}).get("client_id") + if not client_id: + sys.exit("client/hello carried an empty client_id") + print(f"client/hello carried client_id {client_id}") + EOF + + # --sync returns only once everything logged before it is in the journal, so a line + # still on its way from the player's stderr cannot slip past the grep. An absence proves + # nothing about a journal that came back empty, so the startup line has to be there + # first. + sudo journalctl --sync + journalctl "_SYSTEMD_INVOCATION_ID=$invocation" --no-pager >journal.log + grep -q 'listening on port 8928' journal.log || + fail "this invocation's journal is missing the player's startup line, so its silence proves nothing" + if grep -q 'getifaddrs failed' journal.log; then + fail 'getifaddrs() failed under the hardening block, so the client id was not MAC-derived' + fi + # 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. + # this step cannot make: that RestrictAddressFamilies= still lets the player reach 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c054ea6..e74919c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1090,11 +1090,14 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t `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 + `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK`, + `SystemCallArchitectures=native` and `SystemCallFilter=@system-service`. Two of those needed + more than "it booted". `AF_NETLINK` is in the list because glibc's `getifaddrs()` has no way + to read the interface list without it, and that list is where the library finds the MAC it + derives the default client id from: without the family a player with no `id` says hello with + an empty `client_id`, which Music Assistant refuses with `No key provided`. Booting, browse, + resolve and a dial all succeed either way, so CI asserts the hello itself — a connection to + the hardened unit has to be greeted with a non-empty `client_id`. 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. diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index b268bd7..96b41e9 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -165,7 +165,7 @@ Two things are worth checking before the upgrade, and both come from the hardeni ### What is hardened The unit carries `ProtectSystem=strict`, `NoNewPrivileges=`, an empty -`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, +`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK`, `SystemCallFilter=@system-service` and the `Protect*=` family, each commented where it sits. Read the installed unit for the full block. Three operator-visible edges: diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 8630b4a..e57d7ee 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -134,6 +134,43 @@ regardless, a drop-in with `ReadWritePaths=/var/log` is the way back. **A device that will not open.** See above. +## The server finds it and will not take it + +Under the system unit, and not when you run the player from your own shell: + +``` +W sendspin.network_info: getifaddrs failed; cannot auto-detect MAC address +``` + +with the server failing on the new player in its own log — Music Assistant reports +`No key provided` from `_handle_client_added`. + +With no `id` in the config, the player's identity is the MAC address of its network interface, +and glibc reads the interface list over a netlink socket. The unit in 0.1.6 and earlier does not +allow one, so the player says hello with an empty id and the server has nothing to file it +under. Add the family with a drop-in: + +```bash +sudo systemctl edit sendspin-cli +``` + +```ini +[Service] +RestrictAddressFamilies=AF_NETLINK +``` + +```bash +sudo systemctl restart sendspin-cli +``` + +A repeated `RestrictAddressFamilies=` adds to the unit's list rather than replacing it, so that +one family is the whole of the drop-in, and it stays harmless once an upgrade's unit carries +the family itself. + +Setting `id = living-room` in `/etc/sendspin-cli.conf` also gets the player taken, but as a +different player from the one a run from your shell registers: the server files it under that +id rather than under the MAC. + ## Nothing discovers it ### Check it is advertising diff --git a/packaging/sendspin-cli.service.in b/packaging/sendspin-cli.service.in index fc94d36..2c0e6f9 100644 --- a/packaging/sendspin-cli.service.in +++ b/packaging/sendspin-cli.service.in @@ -73,10 +73,11 @@ Type=simple 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. +# the control socket answers `status`, the WebSocket port greets a connection with a client/hello +# whose client_id is the MAC-derived default, 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 @@ -117,11 +118,15 @@ 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 +# AF_INET and AF_INET6 for the WebSocket server and for an -s dial. AF_NETLINK because glibc's +# getifaddrs() reads the interface list over it and has no other way to: without it the call +# fails outright, and that list is where the library finds the MAC it derives the default client +# id from. A player with no `id` then says hello with an empty one, which a server has nothing to +# file it under. rtnetlink, the protocol getifaddrs() speaks, refuses every change to an interface +# or a route without CAP_NET_ADMIN, which the empty bounding set above rules out. The family admits +# the other netlink protocols as well -- socket diagnostics and device events among them -- and +# no directive narrows it to NETLINK_ROUTE. +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK # @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