@@ -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
0 commit comments