Skip to content

Commit c33c0e0

Browse files
authored
Merge pull request #53 from Sendspin/chrisuthe/task/allow-af-netlink-in-the-systemd-unit-so-the-mac
Allow AF_NETLINK in the systemd unit so the MAC-derived client id is detected
2 parents 88f7be0 + 299a3ff commit c33c0e0

5 files changed

Lines changed: 162 additions & 22 deletions

File tree

.github/workflows/build.yml

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -890,15 +890,110 @@ jobs:
890890
state=$(stat -c '%U %a' /var/lib/sendspin-cli/state 2>/dev/null)
891891
[ "$state" = 'sendspin-cli 600' ] || fail "the state file is '$state', not 'sendspin-cli 600'"
892892
893+
# Scoped to this invocation rather than `-u sendspin-cli`, which prints every start the
894+
# unit has ever had: a run that failed would otherwise be judged by the lines an earlier
895+
# one logged. A fail-open gate is worse than no gate.
896+
invocation=$(systemctl show -p InvocationID --value sendspin-cli)
897+
[ -n "$invocation" ] || fail 'the unit reports no invocation id to scope the journal to'
898+
899+
# The identity a server files this player under. With no `id` in the config -- and the
900+
# one above sets none, which is what this depends on -- the library derives client_id
901+
# from the interface MAC, read through getifaddrs(), which needs AF_NETLINK. It does that
902+
# only while building client/hello, and only when a connection arrives, so nothing
903+
# before this point has exercised it: booting, the socket and mDNS all work without the
904+
# family. An inbound connection is sent its hello straight after the upgrade, unasked,
905+
# so a bare WebSocket handshake is enough to get one.
906+
#
907+
# Two gates, because each is blind where the other is not. An empty client_id is what
908+
# Music Assistant refuses, and checking for it survives a library that rewords its log;
909+
# the journal line survives a library that answers a failed detection with some other
910+
# non-empty id, which the first gate would wave through. Port 8928 is the default, and
911+
# the config sets no other.
912+
python3 - <<'EOF' || fail 'the hardened unit did not greet a connection with a non-empty client_id'
913+
import base64, json, os, socket, sys, time
914+
915+
# The control socket appearing says nothing about the WebSocket port, so the connect
916+
# is retried rather than assumed.
917+
deadline = time.monotonic() + 20
918+
while True:
919+
try:
920+
sock = socket.create_connection(("127.0.0.1", 8928), timeout=5)
921+
break
922+
except OSError as err:
923+
if time.monotonic() > deadline:
924+
sys.exit(f"nothing accepted a connection on port 8928: {err}")
925+
time.sleep(0.2)
926+
sock.settimeout(10)
927+
928+
buffered = b""
929+
930+
def read(count):
931+
global buffered
932+
while len(buffered) < count:
933+
chunk = sock.recv(65536)
934+
if not chunk:
935+
sys.exit("the player closed the connection before sending client/hello")
936+
buffered += chunk
937+
out, buffered = buffered[:count], buffered[count:]
938+
return out
939+
940+
key = base64.b64encode(os.urandom(16)).decode()
941+
sock.sendall(
942+
"GET /sendspin HTTP/1.1\r\nHost: 127.0.0.1:8928\r\nUpgrade: websocket\r\n"
943+
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
944+
.encode()
945+
)
946+
response = b""
947+
while not response.endswith(b"\r\n\r\n"):
948+
response += read(1)
949+
status = response.split(b"\r\n", 1)[0].decode(errors="replace")
950+
if status.split()[1:2] != ["101"]:
951+
sys.exit(f"the WebSocket upgrade was refused: {status}")
952+
953+
# The first complete text message. Frames from a server are unmasked; control and
954+
# binary frames are skipped rather than mistaken for it.
955+
message = b""
956+
while True:
957+
first, second = read(2)
958+
length = second & 0x7F
959+
if length == 126:
960+
length = int.from_bytes(read(2), "big")
961+
elif length == 127:
962+
length = int.from_bytes(read(8), "big")
963+
payload = read(length)
964+
opcode = first & 0x0F
965+
if opcode == 0x8:
966+
sys.exit("the player sent a close frame before client/hello")
967+
if opcode in (0x0, 0x1):
968+
message += payload
969+
if first & 0x80:
970+
break
971+
972+
hello = json.loads(message)
973+
if hello.get("type") != "client/hello":
974+
sys.exit(f"the first message was {hello.get('type')!r}, not client/hello")
975+
client_id = hello.get("payload", {}).get("client_id")
976+
if not client_id:
977+
sys.exit("client/hello carried an empty client_id")
978+
print(f"client/hello carried client_id {client_id}")
979+
EOF
980+
981+
# --sync returns only once everything logged before it is in the journal, so a line
982+
# still on its way from the player's stderr cannot slip past the grep. An absence proves
983+
# nothing about a journal that came back empty, so the startup line has to be there
984+
# first.
985+
sudo journalctl --sync
986+
journalctl "_SYSTEMD_INVOCATION_ID=$invocation" --no-pager >journal.log
987+
grep -q 'listening on port 8928' journal.log ||
988+
fail "this invocation's journal is missing the player's startup line, so its silence proves nothing"
989+
if grep -q 'getifaddrs failed' journal.log; then
990+
fail 'getifaddrs() failed under the hardening block, so the client id was not MAC-derived'
991+
fi
992+
893993
# Only on the leg that started a real avahi-daemon above, and the one claim the rest of
894-
# this step cannot make: that RestrictAddressFamilies= without AF_NETLINK still reaches
895-
# the daemon over AF_UNIX. Registration is asynchronous, so it is waited for.
994+
# this step cannot make: that RestrictAddressFamilies= still lets the player reach the
995+
# daemon over AF_UNIX. Registration is asynchronous, so it is waited for.
896996
if [ "$AVAHI" = 'true' ]; then
897-
# Scoped to this invocation rather than `-u sendspin-cli`, which prints every start
898-
# the unit has ever had: a run that failed to advertise would otherwise match the
899-
# line an earlier one logged and pass. A fail-open gate is worse than no gate.
900-
invocation=$(systemctl show -p InvocationID --value sendspin-cli)
901-
[ -n "$invocation" ] || fail 'the unit reports no invocation id to scope the journal to'
902997
for _ in $(seq 1 100); do
903998
journalctl "_SYSTEMD_INVOCATION_ID=$invocation" --no-pager >journal.log
904999
if grep -q 'mdns: advertising _sendspin\._tcp' journal.log; then break; fi

docs/ROADMAP.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,11 +1090,14 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t
10901090
`ProtectHome=`, `PrivateTmp=`, `NoNewPrivileges=`, an empty `CapabilityBoundingSet=`,
10911091
`RestrictSUIDSGID=`, the `Protect*=` kernel family, `ProtectProc=invisible`,
10921092
`RestrictNamespaces=`, `LockPersonality=`, `MemoryDenyWriteExecute=`,
1093-
`RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallArchitectures=native` and
1094-
`SystemCallFilter=@system-service`. Two of those needed more than "it booted". `AF_NETLINK`
1095-
is left out because glibc's interface probe falls back when it cannot open one, which was
1096-
settled by running browse, resolve, the A-record query behind a `ws://` URL and a dial by
1097-
hostname that really connected — all under the restriction. And `@system-service` covers
1093+
`RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK`,
1094+
`SystemCallArchitectures=native` and `SystemCallFilter=@system-service`. Two of those needed
1095+
more than "it booted". `AF_NETLINK` is in the list because glibc's `getifaddrs()` has no way
1096+
to read the interface list without it, and that list is where the library finds the MAC it
1097+
derives the default client id from: without the family a player with no `id` says hello with
1098+
an empty `client_id`, which Music Assistant refuses with `No key provided`. Booting, browse,
1099+
resolve and a dial all succeed either way, so CI asserts the hello itself — a connection to
1100+
the hardened unit has to be greeted with a non-empty `client_id`. And `@system-service` covers
10981101
every syscall `libasound` imports, `ioctl`, `mmap`, `mlock` and the SysV IPC calls `dmix`
10991102
uses included, read off the shipped library's own import table rather than assumed, which is
11001103
what keeps the audio path from being the thing that directive is gambling on.

docs/wiki/Running-as-a-Service.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Two things are worth checking before the upgrade, and both come from the hardeni
165165
### What is hardened
166166

167167
The unit carries `ProtectSystem=strict`, `NoNewPrivileges=`, an empty
168-
`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`,
168+
`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK`,
169169
`SystemCallFilter=@system-service` and the `Protect*=` family, each commented where it sits.
170170
Read the installed unit for the full block. Three operator-visible edges:
171171

docs/wiki/Troubleshooting.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,43 @@ regardless, a drop-in with `ReadWritePaths=/var/log` is the way back.
134134

135135
**A device that will not open.** See above.
136136

137+
## The server finds it and will not take it
138+
139+
Under the system unit, and not when you run the player from your own shell:
140+
141+
```
142+
W sendspin.network_info: getifaddrs failed; cannot auto-detect MAC address
143+
```
144+
145+
with the server failing on the new player in its own log — Music Assistant reports
146+
`No key provided` from `_handle_client_added`.
147+
148+
With no `id` in the config, the player's identity is the MAC address of its network interface,
149+
and glibc reads the interface list over a netlink socket. The unit in 0.1.6 and earlier does not
150+
allow one, so the player says hello with an empty id and the server has nothing to file it
151+
under. Add the family with a drop-in:
152+
153+
```bash
154+
sudo systemctl edit sendspin-cli
155+
```
156+
157+
```ini
158+
[Service]
159+
RestrictAddressFamilies=AF_NETLINK
160+
```
161+
162+
```bash
163+
sudo systemctl restart sendspin-cli
164+
```
165+
166+
A repeated `RestrictAddressFamilies=` adds to the unit's list rather than replacing it, so that
167+
one family is the whole of the drop-in, and it stays harmless once an upgrade's unit carries
168+
the family itself.
169+
170+
Setting `id = living-room` in `/etc/sendspin-cli.conf` also gets the player taken, but as a
171+
different player from the one a run from your shell registers: the server files it under that
172+
id rather than under the MAC.
173+
137174
## Nothing discovers it
138175

139176
### Check it is advertising

packaging/sendspin-cli.service.in

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,11 @@ Type=simple
7373
User=sendspin-cli
7474

7575
# Hardening. Every directive below was run rather than copied: with it in place the unit starts,
76-
# the control socket answers `status`, the WebSocket port accepts a connection, the mDNS
77-
# advertisement reaches avahi-daemon, `delay` lands in a 0600 state file that survives a restart,
78-
# the unit comes back after SIGKILL, and `systemctl stop` leaves Result=success. What could not
79-
# be tried is named at the end of this block instead of guessed at.
76+
# the control socket answers `status`, the WebSocket port greets a connection with a client/hello
77+
# whose client_id is the MAC-derived default, the mDNS advertisement reaches avahi-daemon, `delay`
78+
# lands in a 0600 state file that survives a restart, the unit comes back after SIGKILL, and
79+
# `systemctl stop` leaves Result=success. What could not be tried is named at the end of this
80+
# block instead of guessed at.
8081
#
8182
# No privilege to gain, none to keep, and none to hand on: an unprivileged service starts with
8283
# an empty capability set anyway, and these make that the kernel's rule rather than a
@@ -117,11 +118,15 @@ LockPersonality=yes
117118
MemoryDenyWriteExecute=yes
118119

119120
# AF_UNIX for the control socket and for the avahi socket the mDNS compatibility layer dials,
120-
# AF_INET and AF_INET6 for the WebSocket server and for an -s dial. AF_NETLINK is deliberately
121-
# absent: glibc probes the interface list over it and falls back cleanly when it cannot, which
122-
# was checked rather than assumed -- browse, resolve, the A-record query behind a ws:// URL, and
123-
# a dial by hostname that really connected all work without it.
124-
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
121+
# AF_INET and AF_INET6 for the WebSocket server and for an -s dial. AF_NETLINK because glibc's
122+
# getifaddrs() reads the interface list over it and has no other way to: without it the call
123+
# fails outright, and that list is where the library finds the MAC it derives the default client
124+
# id from. A player with no `id` then says hello with an empty one, which a server has nothing to
125+
# file it under. rtnetlink, the protocol getifaddrs() speaks, refuses every change to an interface
126+
# or a route without CAP_NET_ADMIN, which the empty bounding set above rules out. The family admits
127+
# the other netlink protocols as well -- socket diagnostics and device events among them -- and
128+
# no directive narrows it to NETLINK_ROUTE.
129+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
125130

126131
# @system-service covers every syscall libasound imports, ioctl, mmap, mlock and the SysV IPC
127132
# calls dmix uses included -- checked against the shipped library's own import table, so the

0 commit comments

Comments
 (0)