diff --git a/apps/web/composables/usePrinterStream.ts b/apps/web/composables/usePrinterStream.ts index 7b9734d..5447f24 100644 --- a/apps/web/composables/usePrinterStream.ts +++ b/apps/web/composables/usePrinterStream.ts @@ -2,20 +2,37 @@ * Where a printer's camera can be watched, from `GET /printers/:id/stream`. */ export interface PrinterStream { - streamUrl: string; + /** The go2rtc stream name to negotiate for. */ cameraId: string; + /** + * ICE servers for the player's `RTCPeerConnection`, minted per request and + * short-lived. Empty when the backend has no TURN configured. + */ + iceServers: RTCIceServer[]; + /** + * Whether TURN is configured at all, which is the difference between "no + * relay is needed here" and "the relay is missing". Empty `iceServers` + * with this true would mean minting silently failed. + */ + turnConfigured: boolean; } /** * Asks the backend where a printer's camera can be watched, or `null` if it * cannot. * - * This replaces the build-time `go2rtcStreamBase` that used to live in - * `nuxt.config.ts`. A single compiled-in URL can only ever be correct for a - * one-farm deployment: the stream lives on a gateway box inside a farm's own - * network, each farm has its own, and the gateway reports its address to the - * backend when it connects. So the address has to be asked for at runtime, - * per printer, rather than baked in. + * It returns no URL, and that is the point. A browser cannot reach a gateway + * directly: go2rtc sends no CORS headers and checks its API password before + * CORS, so an unauthenticated preflight gets a 401 - measured against + * 1.9.14 - and a gateway sits behind a workshop NAT anyway. This used to + * return the gateway's LAN address, which could only have worked for an + * operator standing on that same network. + * + * So what comes back is a camera name and the ICE servers to negotiate with. + * The offer goes to the backend, which relays it to the gateway over the + * socket the gateway already holds open; the media then travels + * browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so neither + * the backend nor the relay can read it. * * A plain function and not a `useAsyncData` wrapper despite the file it lives * in: the caller needs one of these per printer, discovered from a list that @@ -30,10 +47,9 @@ export interface PrinterStream { * those, so this answers `null` and the caller shows no player at all - * rendering one that can never load would be worse than rendering nothing. * - * It is also a LAN address with no relay in front of it, so the stream plays - * only for someone on the same network as the printers. That is the right - * trade-off for an operator standing in the workshop, and a real limitation - * for anyone else. + * The credentials are deliberately not cached: they are short-lived, so a + * player that reconnects should ask again rather than retry a session with a + * credential that has since expired. */ export async function fetchPrinterStream( apiFetch: ReturnType, diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index 71e3565..abc297a 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -31,6 +31,22 @@ function current(printer: AppPrinterState): AppPrinterState { // mean one request per machine in the fleet, most of them 404 by design. const streams = ref>({}); +/** + * Relays one printer's WebRTC offer through the backend and returns the + * gateway's answer. + * + * Lives here rather than in `@continuum/ui` for the same reason the stream + * lookup does: that package is plain Vue that knows nothing of the API, so + * the app supplies the one function that talks to it. + */ +async function signalOffer(printerId: string, sdp: string): Promise { + const answer = await apiFetch<{ sdp: string }>(`/printers/${printerId}/webrtc`, { + method: "POST", + body: { sdp }, + }); + return answer.sdp; +} + watch( printers, (list) => { @@ -59,6 +75,7 @@ watch( :key="printer.id" :printer="current(printer)" :stream="streams[printer.id]" + :signal="(sdp: string) => signalOffer(printer.id, sdp)" />

diff --git a/packages/ui/src/components/PrinterCard.vue b/packages/ui/src/components/PrinterCard.vue index 0c38db3..c2d1397 100644 --- a/packages/ui/src/components/PrinterCard.vue +++ b/packages/ui/src/components/PrinterCard.vue @@ -17,8 +17,9 @@ import WebRTCStreamView from "./WebRTCStreamView.vue"; * `usePrinterStream`. The player is simply not rendered. */ interface PrinterStream { - streamUrl: string; cameraId: string; + iceServers: RTCIceServer[]; + turnConfigured: boolean; } const props = defineProps<{ @@ -33,6 +34,15 @@ const props = defineProps<{ */ printer: AppPrinterState; stream?: PrinterStream | null; + /** + * Exchanges an offer SDP for an answer SDP, via the backend's relay. + * + * Passed in rather than called here, like `stream` itself: this package is + * plain Vue with no knowledge of the API or of Nuxt, which is what lets + * these components render from a test or the desktop shell with no server. + * No function means no player, rather than a player that cannot signal. + */ + signal?: (offerSdp: string) => Promise; }>(); // `status` is open-ended and null for a printer that has never reported. @@ -73,9 +83,11 @@ const lastSeen = computed(() => {

Promise; }>(); const videoEl = ref(null); @@ -12,39 +29,47 @@ const status = ref<"connecting" | "connected" | "failed">("connecting"); const error = ref(null); /** - * Opens a WebRTC session against the gateway's go2rtc. + * Opens a WebRTC session with the printer's gateway, signalling through the + * backend. * - * Note what is deliberately absent: no `iceServers`. Both ends are on the - * same LAN - the gateway is a box in the workshop - so host candidates are - * enough and a STUN round-trip would only add latency. + * ## Why the backend is in the middle * - * ## This cannot work from a browser on another origin, and that is measured + * A browser cannot signal to a gateway's go2rtc directly, and that is + * measured against go2rtc 1.9.14 rather than assumed: it sends no + * `Access-Control-Allow-Origin` on any response, the SDP `POST` needs a + * preflight anyway because `application/sdp` is not a CORS-safelisted + * content type, and it checks its API password *before* CORS - so the + * preflight, which a browser always sends without credentials, gets a 401. + * Handing the gateway's password to the frontend could not fix that. A + * gateway is also behind a workshop NAT, so nothing can dial in to it. * - * Not a caveat about reachability - a flat blocker, against go2rtc 1.9.14: + * The backend relays only the SDP exchange, over the socket the gateway + * already holds open. Media never passes through it. * - * - go2rtc sends **no** `Access-Control-Allow-Origin` on any response, with - * or without auth configured. A cross-origin response is unreadable. - * - The `POST` below needs a preflight regardless, because `application/sdp` - * is not a CORS-safelisted `Content-Type`. - * - go2rtc checks basic auth *before* CORS, so the preflight - which a - * browser always sends without credentials - gets a 401. Sending the - * gateway's password from here could not fix it even if the password were - * worth handing to every viewer. + * ## Why relay-only when TURN is configured * - * So this component works when it is same-origin with go2rtc, or through a - * relay that proxies server-to-server. The gateway's API now requires a - * password (continuum-proxy's camera.rs), which closes the hole where - * anything on the workshop LAN could watch - and which also means a browser - * pointed straight at a gateway now gets a 401 rather than a CORS error. - * Both are the same missing piece. The relay design is in PLAN.md; note it - * cannot carry WebRTC, so whatever lands there will replace this transport. + * Pinned to `iceTransportPolicy: "relay"`, so media always travels through + * the TURN relay and never peer-to-peer. That is deliberate: a hosted + * console cannot rely on reaching a customer's gateway directly, and a + * feature that works from the workshop but not from home is worse than one + * that behaves the same everywhere. It is DTLS-SRTP encrypted end to end, so + * the relay forwards packets it cannot decrypt. + * + * With no TURN configured it falls back to `"all"`, which still works for a + * viewer on the gateway's own LAN via host candidates. Relay-only without a + * relay would gather no candidates at all and fail every time, so the + * fallback is what makes an unconfigured deployment degrade instead of + * break. */ async function connect() { status.value = "connecting"; error.value = null; pc.value?.close(); - const connection = new RTCPeerConnection(); + const connection = new RTCPeerConnection({ + iceServers: props.iceServers, + iceTransportPolicy: props.iceServers.length > 0 ? "relay" : "all", + }); pc.value = connection; connection.addTransceiver("video", { direction: "recvonly" }); @@ -53,40 +78,33 @@ async function connect() { }; connection.onconnectionstatechange = () => { if (connection.connectionState === "connected") status.value = "connected"; - if (connection.connectionState === "failed") status.value = "failed"; + if (connection.connectionState === "failed") { + status.value = "failed"; + // ICE failure has one overwhelmingly likely cause when there is no + // relay: the viewer is not on the gateway's network, and host + // candidates were the only ones available. Worth saying, because the + // browser reports nothing more than "failed" and the fix is a + // deployment setting rather than anything the viewer can do. + error.value = props.turnConfigured + ? "could not establish a media connection to the gateway" + : "no TURN relay is configured, so this camera can only be watched from the " + + "printers' own network"; + } }; const offer = await connection.createOffer(); await connection.setLocalDescription(offer); - // go2rtc's WebRTC WHEP-style signaling endpoint. - const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, { - method: "POST", - headers: { "Content-Type": "application/sdp" }, - body: offer.sdp, - }); + // Waits for ICE gathering before signalling, rather than trickling. + // go2rtc's answer comes back through one request/response relay hop, so + // there is no channel to trickle candidates over afterwards - the offer + // has to carry them all. + await gathered(connection); + if (pc.value !== connection) return; - // Checked before the body is used. go2rtc answers a request for a stream - // it does not have with an error page, and handing that to - // setRemoteDescription throws something about SDP parsing - which sends - // whoever is debugging it looking at the wrong layer, when the real - // answer is that this printer is not in go2rtc's config. - if (!response.ok) { - // 401 has one meaning and it is worth naming: go2rtc's API is - // password-protected, and a browser cannot send that password across - // origins (see above). It is not a wrong URL or a missing camera. - if (response.status === 401) { - throw new Error( - "the gateway requires a password for camera access, which a browser cannot " + - "supply across origins - this needs the relay", - ); - } - throw new Error(`the gateway refused the stream request (HTTP ${response.status})`); - } + const answerSdp = await props.signal(connection.localDescription?.sdp ?? offer.sdp ?? ""); - const answerSdp = await response.text(); - - // The props can change again while the request above is in flight, which + // The props can change again while the exchange above is in flight, which // starts a second connect(). Without this check the slower of the two // finishes last and applies its answer to a connection that has been // replaced and closed, leaving a black video element and a "connected" @@ -96,26 +114,39 @@ async function connect() { await connection.setRemoteDescription({ type: "answer", sdp: answerSdp }); } +/** + * Resolves once ICE gathering finishes, or after a short grace period. + * + * Bounded because gathering can hang on a candidate that never resolves, and + * a player that waits forever is worse than one that signals with the + * candidates it already has - which, relay-only, is usually the one that + * matters. + */ +function gathered(connection: RTCPeerConnection): Promise { + if (connection.iceGatheringState === "complete") return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + connection.removeEventListener("icegatheringstatechange", onChange); + resolve(); + }; + const onChange = () => { + if (connection.iceGatheringState === "complete") done(); + }; + const timer = setTimeout(done, 3_000); + connection.addEventListener("icegatheringstatechange", onChange); + }); +} + // `connect` is async inside a watcher, so nothing would otherwise catch a // rejection: the status would sit on "connecting" forever and the only sign // of the failure would be an unhandled rejection in the console. watch( - () => [props.streamBaseUrl, props.cameraId], + () => [props.cameraId, props.iceServers], () => { connect().catch((err: unknown) => { status.value = "failed"; - // A blocked cross-origin request rejects as an opaque TypeError - // ("Failed to fetch") with nothing about CORS in it, which is the - // single most misleading error in this path - it reads as the gateway - // being down. Say the likely cause instead of repeating the browser. - error.value = - err instanceof TypeError - ? "could not reach the gateway's camera API from this origin - either it is " + - "unreachable from here, or the browser blocked the request (see this " + - "component's notes)" - : err instanceof Error - ? err.message - : String(err); + error.value = err instanceof Error ? err.message : String(err); }); }, { immediate: true },