diff --git a/.env.example b/.env.example index b07f628..969f9e4 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,25 @@ R2_ENDPOINT= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= +# Cloudflare Realtime TURN, for relaying printer camera video. +# +# Optional, and unset is a supported state rather than a broken one: with no +# TURN the console can still play a camera for a viewer on the gateway's own +# network, over host candidates. Remote viewers need this. +# +# Why a relay at all: a browser cannot reach a gateway directly. go2rtc sends +# no CORS headers and checks its API password before CORS, so a browser's +# unauthenticated preflight gets a 401 - and a gateway is behind a workshop +# NAT besides. Only the SDP exchange is relayed by this backend; the media +# goes browser-to-gateway through TURN, DTLS-SRTP encrypted, so neither this +# process nor Cloudflare can read it. +# +# The browser is pinned to relay-only when these are set, so video never goes +# peer-to-peer and behaves the same on every customer network. Credentials +# are minted per request with a 1 hour TTL (src/camera/ice.ts). +# CLOUDFLARE_TURN_KEY_ID= +# CLOUDFLARE_TURN_API_TOKEN= + # Supabase Auth. Access tokens are verified against this project's published # JWKS (RS256), so no signing secret is needed - or wanted - here. SUPABASE_URL= diff --git a/scripts/e2e-edge.sh b/scripts/e2e-edge.sh index 265185c..6a77f31 100755 --- a/scripts/e2e-edge.sh +++ b/scripts/e2e-edge.sh @@ -325,11 +325,37 @@ else || fail "GET /printers/$CAM_UUID/stream failed for a printer that has a camera" echo "$STREAM" | grep -q "\"cameraId\":\"$WITH_CAMERA\"" \ || fail "wrong cameraId: $STREAM" - # The farm's stream base URL has to survive the hello and come back out of - # an in-memory connection, which is the part with no unit test behind it. - echo "$STREAM" | grep -q "\"streamUrl\":\"$STREAM_BASE_URL\"" \ - || fail "streamUrl did not come back from the gateway's hello: $STREAM" + # Deliberately NOT a URL any more. A browser cannot play from a gateway's + # own address - go2rtc sends no CORS headers and 401s an unauthenticated + # preflight - so serving one offered a player that could never load. If + # this reappears, something has regressed to the pre-relay design. + echo "$STREAM" | grep -q '"streamUrl"' \ + && fail "the stream endpoint is serving a gateway URL again: $STREAM" + # No Cloudflare credentials in this test, so TURN is off and iceServers is + # empty. Both are asserted, because an empty list with turnConfigured true + # would mean minting silently failed. + echo "$STREAM" | grep -q '"turnConfigured":false' \ + || fail "expected TURN to be unconfigured in the test env: $STREAM" + echo "$STREAM" | grep -q '"iceServers":\[\]' \ + || fail "expected no ICE servers when TURN is unconfigured: $STREAM" ok "GET .../stream: $STREAM" + + # The signalling relay, end to end through the real gateway: an offer goes + # cloud -> uplink -> gateway -> its own go2rtc. go2rtc is not running in + # this test (see CONTINUUM_GO2RTC_BIN above), so the honest expectation is + # a 503 whose reason came back *from the gateway* rather than from a + # timeout - that is what proves the round trip happened at all. + OFFER_CODE="$(curl -s -o "$WORK/offer.json" -w '%{http_code}' -X POST \ + -H "$AUTH_HEADER" -H 'Content-Type: application/json' \ + -d '{"sdp":"v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n"}' \ + "$BASE/printers/$CAM_UUID/webrtc")" + if [ "$OFFER_CODE" = "503" ] && grep -q 'go2rtc\|connection refused\|refused the offer' "$WORK/offer.json"; then + ok "the WebRTC offer reached the gateway and its go2rtc answered (as a refusal): $(head -c 160 "$WORK/offer.json")" + elif [ "$OFFER_CODE" = "503" ] && grep -q 'did not answer within' "$WORK/offer.json"; then + fail "the gateway never answered the offer - the relay did not round-trip: $(cat "$WORK/offer.json")" + else + fail "unexpected answer to a WebRTC offer (HTTP $OFFER_CODE): $(cat "$WORK/offer.json")" + fi fi # A printer with no camera must 404, and say which half is missing. This is diff --git a/src/camera/ice.ts b/src/camera/ice.ts new file mode 100644 index 0000000..b090ce8 --- /dev/null +++ b/src/camera/ice.ts @@ -0,0 +1,106 @@ +/** + * ICE servers for a browser about to watch a printer camera. + * + * ## Why the browser needs these at all + * + * A viewer's browser cannot reach a gateway directly. The gateway is behind a + * workshop NAT, and go2rtc will not talk to a browser cross-origin anyway - + * measured against 1.9.14, it sends no CORS headers and checks its API + * password before CORS, so a browser's unauthenticated preflight gets a 401. + * The SDP exchange is therefore relayed through this backend, and the media + * itself goes over a TURN relay. + * + * ## Why only the browser gets credentials + * + * The browser is pinned to `iceTransportPolicy: "relay"`, so it gathers a + * relay candidate and go2rtc simply sends to it from its own candidate. Only + * one side of a WebRTC session has to hold a relay for media to traverse one. + * + * That asymmetry is load-bearing rather than tidy: go2rtc reads its config + * exactly once at startup, so TURN credentials on the gateway would have to + * be rotated by restarting go2rtc and dropping every viewer on a timer. + * Browser-side, rotation lands on a request that is already per-viewer. + * + * ## What this buys over proxying the video + * + * WebRTC media is DTLS-SRTP encrypted between the browser and the gateway, so + * the relay forwards packets it cannot decrypt. The alternative considered - + * tunnelling fMP4 up the gateway's uplink and streaming it from this process - + * would have put plaintext video through the API, and `