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:
@@ -2,20 +2,37 @@
|
|||||||
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
|
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
|
||||||
*/
|
*/
|
||||||
export interface PrinterStream {
|
export interface PrinterStream {
|
||||||
streamUrl: string;
|
/** The go2rtc stream name to negotiate for. */
|
||||||
cameraId: string;
|
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
|
* Asks the backend where a printer's camera can be watched, or `null` if it
|
||||||
* cannot.
|
* cannot.
|
||||||
*
|
*
|
||||||
* This replaces the build-time `go2rtcStreamBase` that used to live in
|
* It returns no URL, and that is the point. A browser cannot reach a gateway
|
||||||
* `nuxt.config.ts`. A single compiled-in URL can only ever be correct for a
|
* directly: go2rtc sends no CORS headers and checks its API password before
|
||||||
* one-farm deployment: the stream lives on a gateway box inside a farm's own
|
* CORS, so an unauthenticated preflight gets a 401 - measured against
|
||||||
* network, each farm has its own, and the gateway reports its address to the
|
* 1.9.14 - and a gateway sits behind a workshop NAT anyway. This used to
|
||||||
* backend when it connects. So the address has to be asked for at runtime,
|
* return the gateway's LAN address, which could only have worked for an
|
||||||
* per printer, rather than baked in.
|
* 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
|
* 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
|
* 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 -
|
* 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.
|
* 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
|
* The credentials are deliberately not cached: they are short-lived, so a
|
||||||
* only for someone on the same network as the printers. That is the right
|
* player that reconnects should ask again rather than retry a session with a
|
||||||
* trade-off for an operator standing in the workshop, and a real limitation
|
* credential that has since expired.
|
||||||
* for anyone else.
|
|
||||||
*/
|
*/
|
||||||
export async function fetchPrinterStream(
|
export async function fetchPrinterStream(
|
||||||
apiFetch: ReturnType<typeof useApiFetch>,
|
apiFetch: ReturnType<typeof useApiFetch>,
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ function current(printer: AppPrinterState): AppPrinterState {
|
|||||||
// mean one request per machine in the fleet, most of them 404 by design.
|
// mean one request per machine in the fleet, most of them 404 by design.
|
||||||
const streams = ref<Record<string, PrinterStream | null>>({});
|
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(
|
watch(
|
||||||
printers,
|
printers,
|
||||||
(list) => {
|
(list) => {
|
||||||
@@ -59,6 +75,7 @@ watch(
|
|||||||
:key="printer.id"
|
:key="printer.id"
|
||||||
:printer="current(printer)"
|
:printer="current(printer)"
|
||||||
:stream="streams[printer.id]"
|
:stream="streams[printer.id]"
|
||||||
|
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!printers?.length">
|
<p v-if="!printers?.length">
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
|
|||||||
* `usePrinterStream`. The player is simply not rendered.
|
* `usePrinterStream`. The player is simply not rendered.
|
||||||
*/
|
*/
|
||||||
interface PrinterStream {
|
interface PrinterStream {
|
||||||
streamUrl: string;
|
|
||||||
cameraId: string;
|
cameraId: string;
|
||||||
|
iceServers: RTCIceServer[];
|
||||||
|
turnConfigured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -33,6 +34,15 @@ const props = defineProps<{
|
|||||||
*/
|
*/
|
||||||
printer: AppPrinterState;
|
printer: AppPrinterState;
|
||||||
stream?: PrinterStream | null;
|
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<string>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// `status` is open-ended and null for a printer that has never reported.
|
// `status` is open-ended and null for a printer that has never reported.
|
||||||
@@ -73,9 +83,11 @@ const lastSeen = computed(() => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<WebRTCStreamView
|
<WebRTCStreamView
|
||||||
v-if="stream"
|
v-if="stream && signal"
|
||||||
:stream-base-url="stream.streamUrl"
|
|
||||||
:camera-id="stream.cameraId"
|
:camera-id="stream.cameraId"
|
||||||
|
:ice-servers="stream.iceServers"
|
||||||
|
:turn-configured="stream.turnConfigured"
|
||||||
|
:signal="signal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemperatureGraph
|
<TemperatureGraph
|
||||||
|
|||||||
@@ -2,8 +2,25 @@
|
|||||||
import { onBeforeUnmount, ref, watch } from "vue";
|
import { onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
streamBaseUrl: string;
|
/** The go2rtc stream name to negotiate for, from `GET /printers/:id/stream`. */
|
||||||
cameraId: string;
|
cameraId: string;
|
||||||
|
/**
|
||||||
|
* ICE servers for this session, minted per request by the backend and
|
||||||
|
* short-lived. Empty means TURN is not configured.
|
||||||
|
*/
|
||||||
|
iceServers: RTCIceServer[];
|
||||||
|
/**
|
||||||
|
* Whether the backend has TURN configured at all. Empty `iceServers` with
|
||||||
|
* this true means minting failed, which is a fault; empty with this false
|
||||||
|
* is a deployment that has not set it up, which is merely a limit.
|
||||||
|
*/
|
||||||
|
turnConfigured: boolean;
|
||||||
|
/**
|
||||||
|
* Exchanges an offer SDP for an answer SDP. Supplied by the app, because
|
||||||
|
* this package knows nothing of the API or of Nuxt - which is what lets it
|
||||||
|
* render from a test or the desktop shell with no server behind it.
|
||||||
|
*/
|
||||||
|
signal: (offerSdp: string) => Promise<string>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const videoEl = ref<HTMLVideoElement | null>(null);
|
const videoEl = ref<HTMLVideoElement | null>(null);
|
||||||
@@ -12,39 +29,47 @@ const status = ref<"connecting" | "connected" | "failed">("connecting");
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(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
|
* ## Why the backend is in the middle
|
||||||
* same LAN - the gateway is a box in the workshop - so host candidates are
|
|
||||||
* enough and a STUN round-trip would only add latency.
|
|
||||||
*
|
*
|
||||||
* ## 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
|
* ## Why relay-only when TURN is configured
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* So this component works when it is same-origin with go2rtc, or through a
|
* Pinned to `iceTransportPolicy: "relay"`, so media always travels through
|
||||||
* relay that proxies server-to-server. The gateway's API now requires a
|
* the TURN relay and never peer-to-peer. That is deliberate: a hosted
|
||||||
* password (continuum-proxy's camera.rs), which closes the hole where
|
* console cannot rely on reaching a customer's gateway directly, and a
|
||||||
* anything on the workshop LAN could watch - and which also means a browser
|
* feature that works from the workshop but not from home is worse than one
|
||||||
* pointed straight at a gateway now gets a 401 rather than a CORS error.
|
* that behaves the same everywhere. It is DTLS-SRTP encrypted end to end, so
|
||||||
* Both are the same missing piece. The relay design is in PLAN.md; note it
|
* the relay forwards packets it cannot decrypt.
|
||||||
* cannot carry WebRTC, so whatever lands there will replace this transport.
|
*
|
||||||
|
* 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() {
|
async function connect() {
|
||||||
status.value = "connecting";
|
status.value = "connecting";
|
||||||
error.value = null;
|
error.value = null;
|
||||||
pc.value?.close();
|
pc.value?.close();
|
||||||
|
|
||||||
const connection = new RTCPeerConnection();
|
const connection = new RTCPeerConnection({
|
||||||
|
iceServers: props.iceServers,
|
||||||
|
iceTransportPolicy: props.iceServers.length > 0 ? "relay" : "all",
|
||||||
|
});
|
||||||
pc.value = connection;
|
pc.value = connection;
|
||||||
|
|
||||||
connection.addTransceiver("video", { direction: "recvonly" });
|
connection.addTransceiver("video", { direction: "recvonly" });
|
||||||
@@ -53,40 +78,33 @@ async function connect() {
|
|||||||
};
|
};
|
||||||
connection.onconnectionstatechange = () => {
|
connection.onconnectionstatechange = () => {
|
||||||
if (connection.connectionState === "connected") status.value = "connected";
|
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();
|
const offer = await connection.createOffer();
|
||||||
await connection.setLocalDescription(offer);
|
await connection.setLocalDescription(offer);
|
||||||
|
|
||||||
// go2rtc's WebRTC WHEP-style signaling endpoint.
|
// Waits for ICE gathering before signalling, rather than trickling.
|
||||||
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
|
// go2rtc's answer comes back through one request/response relay hop, so
|
||||||
method: "POST",
|
// there is no channel to trickle candidates over afterwards - the offer
|
||||||
headers: { "Content-Type": "application/sdp" },
|
// has to carry them all.
|
||||||
body: offer.sdp,
|
await gathered(connection);
|
||||||
});
|
if (pc.value !== connection) return;
|
||||||
|
|
||||||
// Checked before the body is used. go2rtc answers a request for a stream
|
const answerSdp = await props.signal(connection.localDescription?.sdp ?? offer.sdp ?? "");
|
||||||
// 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 response.text();
|
// The props can change again while the exchange above is in flight, which
|
||||||
|
|
||||||
// The props can change again while the request above is in flight, which
|
|
||||||
// starts a second connect(). Without this check the slower of the two
|
// starts a second connect(). Without this check the slower of the two
|
||||||
// finishes last and applies its answer to a connection that has been
|
// finishes last and applies its answer to a connection that has been
|
||||||
// replaced and closed, leaving a black video element and a "connected"
|
// 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 });
|
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<void> {
|
||||||
|
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
|
// `connect` is async inside a watcher, so nothing would otherwise catch a
|
||||||
// rejection: the status would sit on "connecting" forever and the only sign
|
// rejection: the status would sit on "connecting" forever and the only sign
|
||||||
// of the failure would be an unhandled rejection in the console.
|
// of the failure would be an unhandled rejection in the console.
|
||||||
watch(
|
watch(
|
||||||
() => [props.streamBaseUrl, props.cameraId],
|
() => [props.cameraId, props.iceServers],
|
||||||
() => {
|
() => {
|
||||||
connect().catch((err: unknown) => {
|
connect().catch((err: unknown) => {
|
||||||
status.value = "failed";
|
status.value = "failed";
|
||||||
// A blocked cross-origin request rejects as an opaque TypeError
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
// ("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);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user