Play cameras through the relay instead of straight at the gateway

The player posted its SDP offer to the gateway's go2rtc directly, which
cannot work from a browser on another origin: no CORS headers on any
go2rtc response, a preflight required because application/sdp is not
CORS-safelisted, and the password checked before CORS so that preflight
gets a 401. It now hands the offer to a `signal` function the app
supplies, which relays it through the backend.

Pinned to iceTransportPolicy "relay" whenever ICE servers are present,
so media always goes through the TURN relay and never peer-to-peer. A
hosted console cannot rely on reaching a customer's gateway directly,
and a camera that works from the workshop but not from home is worse
than one that behaves the same everywhere.

With no TURN configured it falls back to "all", which still works for a
viewer on the gateway's own network. Relay-only with no relay would
gather no candidates and fail every time, so the fallback is what makes
an unconfigured deployment degrade rather than break - and a failed
connection in that state now says so, since the browser reports nothing
but "failed" and the fix is a deployment setting.

It also waits for ICE gathering before signalling rather than trickling:
the answer comes back through a single request/response relay hop, so
there is no channel to trickle over afterwards and the offer has to
carry its candidates. Bounded at 3s, because gathering can hang on a
candidate that never resolves and signalling with what we have beats
waiting forever.

`signal` is a prop rather than a fetch in the component, like `stream`
already was: @continuum/ui stays plain Vue that knows nothing of the API,
which is what lets it render from a test or the desktop shell.
This commit is contained in:
2026-10-19 00:33:18 +00:00
parent 1874bbe8da
commit 6fab9072d4
4 changed files with 153 additions and 77 deletions
+27 -11
View File
@@ -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<typeof useApiFetch>,
+17
View File
@@ -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<Record<string, PrinterStream | null>>({});
/**
* 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<string> {
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)"
/>
</div>
<p v-if="!printers?.length">