Relay camera signalling and mint TURN credentials

Makes the printer cameras playable from a hosted console, which they
were not: GET /printers/:id/stream handed out the gateway's own LAN
address, and a browser cannot use it. go2rtc sends no CORS headers and
checks its API password before CORS, so a browser's unauthenticated
preflight gets a 401 (measured against 1.9.14), and a gateway is behind
a workshop NAT anyway. That endpoint could only ever have worked for an
operator standing on the same network, and offered everyone else a
player that silently never loaded.

So /:id/stream now returns a camera name and short-lived ICE servers,
and POST /:id/webrtc relays the offer to the gateway over the socket it
already holds open and returns go2rtc's answer. Media travels
browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so
neither this process nor Cloudflare can read it - which is why this is
signalling rather than proxying fMP4 through the API. That alternative
also needed a credential in the URL, because <video> cannot send an
Authorization header.

Only the browser gets TURN credentials. It is pinned to relay-only, so
it allocates the relay and go2rtc sends to it; one side is enough. That
keeps rotation on a per-viewer request instead of in go2rtc's config,
which is read once at startup - rotating there would restart it and drop
every viewer on a timer.

Unconfigured TURN is supported rather than fatal, unlike R2: a viewer on
the gateway's LAN connects on host candidates. turnConfigured is
returned alongside, because empty iceServers means "not set up" in one
case and "minting failed" in the other, and those need different
answers.

Camera negotiations are failed immediately when a gateway disconnects,
rather than waiting out the 12s timeout - a browser is holding a request
open on each one.

getStreamBaseUrl is kept but no longer served to anyone: gateways still
report the field and it is the only record of where one thinks it is
reachable, which is useful when diagnosing one.

e2e-edge.sh asserts the new shape, that no streamUrl comes back, and
drives a real offer through the relay - asserting the refusal came from
go2rtc rather than from a timeout, since that is what proves the round
trip happened.
This commit is contained in:
2026-10-19 00:33:18 +00:00
parent 0d87c8cbce
commit f02c9897c9
7 changed files with 411 additions and 40 deletions
+30 -4
View File
@@ -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