#!/usr/bin/env bash # check-image.sh: build the relay's image, run it, and hold it to its checks; then remove the # container (and the image, unless KEEP=1). Nothing is pushed anywhere. # # bash joule-code/joule-code-rs/crates/joule-moq-relay/deploy/check-image.sh (from the repository's root) # # The checks: the image runs as the user wai (uid 10001), not as root, with wai-relay as its # entry point; `wai-relay --version --json` runs in it and names the toolchain of this checkout # (wai/wai-rs/Cargo.toml); and the image, run with a read-only root, every capability dropped and # no privilege to gain, answers a WebSocket opening on /moq for the subprotocol moqt-22 with 101, # that subprotocol, and the relay's first capsule (WT_MAX_STREAMS), from a process of uid 10001. # # ENGINE picks the container engine (docker, or podman), JOBS bounds the build's parallel jobs, PORT # is the host port the WebSocket listener is published on. TAG names the image built (default # wai-relay-check:); IMAGE checks that image instead of building one; KEEP=1 keeps the image # built. VERSION is the toolchain version the image must state (default this checkout's). set -euo pipefail ENGINE=${ENGINE:-docker} JOBS=${JOBS:-8} PORT=${PORT:-18744} here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) root=$(cd "$here/../../../../.." && pwd) VERSION=${VERSION:-$(sed -n 's/^version = "\(.*\)"$/\1/p' "$root/wai/wai-rs/Cargo.toml" | head -1)} name="wai-relay-check-$$" built="" cleanup() { "$ENGINE" rm -f "$name" >/dev/null 2>&1 || true if [ -n "$built" ] && [ "${KEEP:-0}" != 1 ]; then "$ENGINE" image rm -f "$built" >/dev/null 2>&1 || true; fi } trap cleanup EXIT die() { echo "check-image: $*" >&2; exit 1; } if [ -n "${IMAGE:-}" ]; then tag="$IMAGE" else tag="${TAG:-wai-relay-check:$$}" ignore=() [ "$(basename "$ENGINE")" = podman ] && ignore=(--ignorefile "$here/Containerfile.dockerignore") "$ENGINE" build ${ignore[@]+"${ignore[@]}"} --build-arg JOBS="$JOBS" -f "$here/Containerfile" -t "$tag" "$root" built="$tag" fi user=$("$ENGINE" image inspect --format '{{.Config.User}}' "$tag") [ "$user" = wai ] || die "the image runs as '${user:-root}', not as wai" entry=$("$ENGINE" image inspect --format '{{json .Config.Entrypoint}}' "$tag") [ "$entry" = '["/usr/local/bin/wai-relay"]' ] || die "the image's entry point is $entry, not wai-relay" uid=$("$ENGINE" run --rm --entrypoint /usr/bin/id "$tag" -u) || die "id does not run in the image" [ "$uid" = 10001 ] || die "the image's user has uid $uid, not 10001" out=$("$ENGINE" run --rm "$tag" --version --json) || die "wai-relay --version does not run in the image" python3 -I -c ' import json, sys d = json.loads(sys.argv[1])["data"] assert d["program"] == "wai-relay" and d["toolchain"] == sys.argv[2], d print("image: wai-relay %s, of the WAI toolchain %s" % (d["version"], d["toolchain"])) ' "$out" "$VERSION" || die "wai-relay --version --json says $out, not the toolchain $VERSION" "$ENGINE" run -d --name "$name" -p "127.0.0.1:$PORT:4480" \ --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges "$tag" >/dev/null python3 -I - "$PORT" <<'PY' || { "$ENGINE" logs "$name" 2>&1 | tail -20 >&2; die "the relay does not answer a WebSocket opening on /moq"; } import base64, os, socket, sys, time port = int(sys.argv[1]) deadline = time.monotonic() + 30 while True: try: s = socket.create_connection(("127.0.0.1", port), timeout=5) break except OSError: if time.monotonic() > deadline: sys.exit("check-image: nothing listens on the WebSocket port") time.sleep(0.2) key = base64.b64encode(os.urandom(16)).decode() s.sendall((f"GET /moq HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: moqt-22\r\n\r\n").encode()) buf = b"" while b"\r\n\r\n" not in buf: chunk = s.recv(4096) if not chunk: sys.exit(f"check-image: the connection closed after {buf!r}") buf += chunk head, rest = buf.split(b"\r\n\r\n", 1) lines = head.decode("latin-1").split("\r\n") headers = {k.strip().lower(): v.strip() for k, v in (l.split(":", 1) for l in lines[1:] if ":" in l)} if " 101 " not in lines[0] + " " or headers.get("sec-websocket-protocol") != "moqt-22": sys.exit(f"check-image: the opening was answered {lines[0]!r}, subprotocol {headers.get('sec-websocket-protocol')!r}") while len(rest) < 2: rest += s.recv(4096) if rest[0] & 0x0F != 2: sys.exit(f"check-image: the relay's first message has opcode {rest[0] & 0x0F}, not binary") n, off = rest[1] & 0x7F, 2 if n == 126: while len(rest) < 4: rest += s.recv(4096) n, off = int.from_bytes(rest[2:4], "big"), 4 while len(rest) < off + n: rest += s.recv(4096) msg = rest[off:off + n] # WT_MAX_STREAMS (bidirectional), type 0x190B4D3F as a QUIC variable-length integer. if not msg.startswith(bytes.fromhex("990b4d3f")): sys.exit(f"check-image: the relay's first capsule is {msg.hex()}, not WT_MAX_STREAMS") print(f"image: 101 for moqt-22 on /moq, and the relay's first capsule, WT_MAX_STREAMS ({msg.hex()})") PY status=$("$ENGINE" exec "$name" cat /proc/1/status) || die "the relay's process cannot be read" grep -qE '^Uid:[[:space:]]+10001[[:space:]]' <<<"$status" || die "the relay runs as $(grep '^Uid:' <<<"$status"), not uid 10001" echo "image: built, ran read-only with no capabilities as uid 10001, and held to its checks"