diff --git a/scripts/e2e-camera-peer.ts b/scripts/e2e-camera-peer.ts index ce3dcb7..4674be5 100644 --- a/scripts/e2e-camera-peer.ts +++ b/scripts/e2e-camera-peer.ts @@ -19,6 +19,13 @@ * * Environment: BASE, PRINTER_UUID, ACCESS_TOKEN. The token never reaches a * log line. + * + * ICE_MODE=relay runs it the way the real browser is configured: pinned to + * `iceTransportPolicy: "relay"` with the short-lived TURN servers that + * `GET /printers/:id/stream` hands out, so media can only travel through + * Cloudflare. That is the product's actual path, and the one go2rtc's issue + * tracker warns can strand ICE in `checking` when only one side holds a relay. + * The TURN credentials are used and never printed. */ import { RTCPeerConnection, RTCRtpCodecParameters } from "werift"; @@ -39,8 +46,22 @@ const say = (good: boolean, label: string, detail = "") => { // matched: video:H264 => video:VP8" - correctly. Every browser offers H.264, // so offering it here is what a real viewer does, not a concession to the test. // The parameters are Chrome's: packetization-mode=1, constrained baseline. +const relayOnly = process.env.ICE_MODE === "relay"; +let iceServers: { urls: string | string[]; username?: string; credential?: string }[] = []; +if (relayOnly) { + const info = await fetch(`${base}/printers/${printer}/stream`, { + headers: { authorization: `Bearer ${token}` }, + }); + const parsed = (await info.json()) as { iceServers?: typeof iceServers }; + iceServers = parsed.iceServers ?? []; + say(info.ok && iceServers.length > 0, "the backend hands out ICE servers for a relay-only viewer", + `${iceServers.length} entr(ies)`); + if (iceServers.length === 0) process.exit(1); +} + const pc = new RTCPeerConnection({ - iceServers: [], + iceServers, + iceTransportPolicy: relayOnly ? "relay" : "all", codecs: { video: [ new RTCRtpCodecParameters({ @@ -69,6 +90,16 @@ const offerSdp = pc.localDescription!.sdp; say(/^m=video /m.test(offerSdp) && /^a=ice-ufrag:/m.test(offerSdp), "the offer has a video section and an ice-ufrag, unlike the stub it replaces"); +if (relayOnly) { + // Asserted rather than assumed: if the viewer also offered host or srflx + // candidates, a pass below could mean "connected directly" and prove nothing + // about TURN. + const kinds = [...offerSdp.matchAll(/^a=candidate:.* typ (\w+)/gm)].map((m) => m[1]); + say(kinds.length > 0 && kinds.every((k) => k === "relay"), + "the viewer offers only relay candidates, as the browser does", + `candidates: ${kinds.length ? [...new Set(kinds)].join(",") : "none - the TURN allocation failed"}`); +} + // A player that gets "stream not found" just asks again, and so does this: the // gateway advertises a camera as soon as the printer reports its URL, a moment // before go2rtc has been restarted with the stream in its config. Bounded, and diff --git a/scripts/e2e-camera.sh b/scripts/e2e-camera.sh index a925ea5..ac8cae6 100755 --- a/scripts/e2e-camera.sh +++ b/scripts/e2e-camera.sh @@ -9,13 +9,21 @@ # more than it was because a stray go2rtc left over from an earlier run # answered the stub with an HTTP 500 and the test still printed "ok". # -# This one runs the real thing end to end: +# This one runs the real thing end to end, twice - once over direct candidates +# and once pinned to relay-only through Cloudflare TURN, which is how the real +# browser is configured: # # werift peer (stands in for the browser) # -> POST /printers/:id/webrtc on the real backend # -> /ws/edge/v1 -> the real continuum-proxy -> its real go2rtc # -> RTSPS to the printer's camera -# and the RTP that comes back is counted. +# and the RTP that comes back is counted. The relay phase needs +# CLOUDFLARE_TURN_KEY_ID / CLOUDFLARE_TURN_API_TOKEN and is skipped, with a +# message saying the TURN path went unverified, when they are absent. +# +# Not covered: the viewer and the gateway share this host, so what is proven is +# that ICE, DTLS and media work via TURN, not that they survive two distinct +# NATs; and a real browser has still not rendered a frame. # # SAFETY: read-only against the printers. It sends no printer command of any # kind; the proxy's MQTT subscription is passive and the camera is a pull of @@ -205,25 +213,47 @@ ok "the generated go2rtc config is owner-only (mode $mode)" # --- 4. the viewer --------------------------------------------------------- -echo "==> a real WebRTC peer plays the camera through the relay" -RESULTS="$(BASE="$BASE" PRINTER_UUID="$CAM_UUID" ACCESS_TOKEN="$USER_TOKEN" bun scripts/e2e-camera-peer.ts 2>&1 || true)" -SEEN=0; BAD=0 -while IFS=$'\t' read -r verdict label; do - case "$verdict" in - ok) ok "$label"; SEEN=$((SEEN + 1)) ;; - FAILED) echo " FAILED: $label" >&2; SEEN=$((SEEN + 1)); BAD=$((BAD + 1)) ;; - esac -done < <(printf '%s\n' "$RESULTS" | grep -E "^(ok|FAILED)$(printf '\t')") -# Fewer results than the peer's five checks means it died before finishing, -# and a silent peer must not read as a pass. -if [ "$SEEN" -lt 5 ] && [ "$BAD" -eq 0 ]; then - BAD=1; echo " FAILED: the peer produced only $SEEN of 5 results" >&2 -fi -if [ "$BAD" -ne 0 ]; then - printf '%s\n' "$RESULTS" | sed 's/^/ | /' >&2 - fail "the camera did not deliver video" +# Runs the peer once and reads its `oklabel` / `FAILEDlabel` lines. +# $1 names the phase, $2 is how many results a complete run produces: fewer +# means the peer died before finishing, and a silent peer must not read as a +# pass. +run_viewer() { + local phase="$1" expected="$2" results seen=0 bad=0 verdict label + shift 2 + results="$(env BASE="$BASE" PRINTER_UUID="$CAM_UUID" ACCESS_TOKEN="$USER_TOKEN" "$@" \ + bun scripts/e2e-camera-peer.ts 2>&1 || true)" + while IFS=$'\t' read -r verdict label; do + case "$verdict" in + ok) ok "$label"; seen=$((seen + 1)) ;; + FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;; + esac + done < <(printf '%s\n' "$results" | grep -E "^(ok|FAILED)$(printf '\t')") + if [ "$seen" -lt "$expected" ] && [ "$bad" -eq 0 ]; then + bad=1; echo " FAILED: the $phase peer produced only $seen of $expected results" >&2 + fi + if [ "$bad" -ne 0 ]; then + printf '%s\n' "$results" | sed 's/^/ | /' >&2 + fail "the camera did not deliver video ($phase)" + fi +} + +echo "==> a real WebRTC peer plays the camera through the relay (direct candidates)" +run_viewer "direct" 5 + +# The path the product actually uses: the browser is pinned to relay-only, so +# media goes through Cloudflare TURN. Needs real credentials, which come from +# the environment, so it is skipped - loudly - rather than failed without them. +if [ -n "${CLOUDFLARE_TURN_KEY_ID:-}" ] && [ -n "${CLOUDFLARE_TURN_API_TOKEN:-}" ]; then + echo "==> the same camera, viewer pinned to relay-only through Cloudflare TURN" + run_viewer "relay-only" 7 ICE_MODE=relay +else + echo " SKIPPED: relay-only phase - CLOUDFLARE_TURN_KEY_ID / CLOUDFLARE_TURN_API_TOKEN not set." + echo " The TURN-relayed path, which the browser is pinned to, is NOT verified by this run." fi echo echo "PASS - a real WebRTC peer received video from the printer's camera" echo " through the backend relay, the uplink, the proxy and go2rtc." +if [ -n "${CLOUDFLARE_TURN_KEY_ID:-}" ] && [ -n "${CLOUDFLARE_TURN_API_TOKEN:-}" ]; then + echo " Both directly and pinned to relay-only through Cloudflare TURN." +fi