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
+106
View File
@@ -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 `<video>` cannot send
* an `Authorization` header, so it would have needed a credential in the URL.
* That is the pattern `/ws/telemetry` deliberately rejected.
*
* ## Unconfigured is a supported state
*
* No Cloudflare credentials means no TURN, and that is not a boot failure the
* way R2 is: a farm with no cameras, or one whose operator is on the same LAN
* as the gateway, works without it. The endpoint says so rather than
* pretending, because "no iceServers" and "TURN is broken" need different
* answers from whoever is looking.
*/
const KEY_ID = process.env.CLOUDFLARE_TURN_KEY_ID;
const API_TOKEN = process.env.CLOUDFLARE_TURN_API_TOKEN;
/**
* Short on purpose. These are handed to a browser, so the window in which a
* leaked one is useful is the window this sets. An hour outlasts setting up a
* session comfortably; a viewer watching longer than that has their relay
* drop and the player renegotiates, which costs a reconnect and gets fresh
* credentials - the right trade against a credential that stays good all day.
*/
const TTL_SECONDS = 3600;
/** Whether TURN is configured at all. */
export function turnConfigured(): boolean {
return Boolean(KEY_ID && API_TOKEN);
}
export type IceServer = {
urls: string | string[];
username?: string;
credential?: string;
};
/**
* Mints a short-lived set of ICE servers, or returns null when TURN is not
* configured.
*
* Throws only when TURN *is* configured and Cloudflare refused: that is a
* real fault and must not be reported as "no TURN here", which would send a
* viewer into a session that cannot carry media.
*/
export async function mintIceServers(): Promise<IceServer[] | null> {
if (!KEY_ID || !API_TOKEN) return null;
const response = await fetch(
`https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(KEY_ID)}/credentials/generate-ice-servers`,
{
method: "POST",
headers: {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ttl: TTL_SECONDS }),
},
);
// 201 on success, per Cloudflare's own docs, so this checks the class.
if (!response.ok) {
// The body carries the reason - a wrong key id, a revoked token - and a
// bare status would send whoever reads this at the network instead.
const detail = await response.text().catch(() => "");
throw new Error(
`Cloudflare TURN refused to mint credentials (HTTP ${response.status})` +
(detail.trim() ? `: ${detail.trim().slice(0, 300)}` : ""),
);
}
const body = (await response.json()) as { iceServers?: IceServer | IceServer[] };
if (!body.iceServers) {
throw new Error("Cloudflare TURN returned no iceServers");
}
// Cloudflare returns a single object; the browser's RTCPeerConnection wants
// a list. Normalised here so every caller does not have to know that.
return Array.isArray(body.iceServers) ? body.iceServers : [body.iceServers];
}