Prove the TURN-relayed camera path, the one the browser actually uses

The first e2e-camera.sh connected over direct host candidates. The real
browser is pinned to iceTransportPolicy "relay", and PLAN.md recorded
go2rtc's known trap for that configuration: a relay candidate on one
side only can leave ICE stuck in `checking`.

The script now runs the peer a second time pinned the same way, with the
iceServers the backend hands out. The peer asserts its offer contains
only `typ relay` candidates, so a pass cannot mean it connected directly,
and never prints the credentials. Measured against the H2C: 937 packets
(~943 KB) arrive through Cloudflare, and the trap did not occur.

The phase is skipped with an explicit message when TURN credentials are
absent, so a run without them cannot be mistaken for one that verified
the relay. Both limits are written into the script header: viewer and
gateway share a host, so this is not two distinct NATs, and no real
browser has rendered a frame.
This commit is contained in:
2026-10-24 08:06:58 +00:00
parent ac484a99ba
commit e4940f9404
2 changed files with 81 additions and 20 deletions
+32 -1
View File
@@ -19,6 +19,13 @@
* *
* Environment: BASE, PRINTER_UUID, ACCESS_TOKEN. The token never reaches a * Environment: BASE, PRINTER_UUID, ACCESS_TOKEN. The token never reaches a
* log line. * 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"; 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, // 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. // 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. // 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({ const pc = new RTCPeerConnection({
iceServers: [], iceServers,
iceTransportPolicy: relayOnly ? "relay" : "all",
codecs: { codecs: {
video: [ video: [
new RTCRtpCodecParameters({ new RTCRtpCodecParameters({
@@ -69,6 +90,16 @@ const offerSdp = pc.localDescription!.sdp;
say(/^m=video /m.test(offerSdp) && /^a=ice-ufrag:/m.test(offerSdp), 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"); "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 // 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 // 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 // before go2rtc has been restarted with the stream in its config. Bounded, and
+45 -15
View File
@@ -9,13 +9,21 @@
# more than it was because a stray go2rtc left over from an earlier run # 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". # 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) # werift peer (stands in for the browser)
# -> POST /printers/:id/webrtc on the real backend # -> POST /printers/:id/webrtc on the real backend
# -> /ws/edge/v1 -> the real continuum-proxy -> its real go2rtc # -> /ws/edge/v1 -> the real continuum-proxy -> its real go2rtc
# -> RTSPS to the printer's camera # -> 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 # 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 # 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 --------------------------------------------------------- # --- 4. the viewer ---------------------------------------------------------
echo "==> a real WebRTC peer plays the camera through the relay" # Runs the peer once and reads its `ok<TAB>label` / `FAILED<TAB>label` lines.
RESULTS="$(BASE="$BASE" PRINTER_UUID="$CAM_UUID" ACCESS_TOKEN="$USER_TOKEN" bun scripts/e2e-camera-peer.ts 2>&1 || true)" # $1 names the phase, $2 is how many results a complete run produces: fewer
SEEN=0; BAD=0 # 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 while IFS=$'\t' read -r verdict label; do
case "$verdict" in case "$verdict" in
ok) ok "$label"; SEEN=$((SEEN + 1)) ;; ok) ok "$label"; seen=$((seen + 1)) ;;
FAILED) echo " FAILED: $label" >&2; SEEN=$((SEEN + 1)); BAD=$((BAD + 1)) ;; FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;;
esac esac
done < <(printf '%s\n' "$RESULTS" | grep -E "^(ok|FAILED)$(printf '\t')") done < <(printf '%s\n' "$results" | grep -E "^(ok|FAILED)$(printf '\t')")
# Fewer results than the peer's five checks means it died before finishing, if [ "$seen" -lt "$expected" ] && [ "$bad" -eq 0 ]; then
# and a silent peer must not read as a pass. bad=1; echo " FAILED: the $phase peer produced only $seen of $expected results" >&2
if [ "$SEEN" -lt 5 ] && [ "$BAD" -eq 0 ]; then
BAD=1; echo " FAILED: the peer produced only $SEEN of 5 results" >&2
fi fi
if [ "$BAD" -ne 0 ]; then if [ "$bad" -ne 0 ]; then
printf '%s\n' "$RESULTS" | sed 's/^/ | /' >&2 printf '%s\n' "$results" | sed 's/^/ | /' >&2
fail "the camera did not deliver video" 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 fi
echo echo
echo "PASS - a real WebRTC peer received video from the printer's camera" echo "PASS - a real WebRTC peer received video from the printer's camera"
echo " through the backend relay, the uplink, the proxy and go2rtc." 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