Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 102 additions & 7 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Running-as-a-Service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
37 changes: 37 additions & 0 deletions docs/wiki/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions packaging/sendspin-cli.service.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down