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:
@@ -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<string>;
|
||||
}>();
|
||||
|
||||
// `status` is open-ended and null for a printer that has never reported.
|
||||
@@ -73,9 +83,11 @@ const lastSeen = computed(() => {
|
||||
</p>
|
||||
|
||||
<WebRTCStreamView
|
||||
v-if="stream"
|
||||
:stream-base-url="stream.streamUrl"
|
||||
v-if="stream && signal"
|
||||
:camera-id="stream.cameraId"
|
||||
:ice-servers="stream.iceServers"
|
||||
:turn-configured="stream.turnConfigured"
|
||||
:signal="signal"
|
||||
/>
|
||||
|
||||
<TemperatureGraph
|
||||
|
||||
@@ -2,8 +2,25 @@
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
streamBaseUrl: string;
|
||||
/** The go2rtc stream name to negotiate for, from `GET /printers/:id/stream`. */
|
||||
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);
|
||||
@@ -12,39 +29,47 @@ const status = ref<"connecting" | "connected" | "failed">("connecting");
|
||||
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
|
||||
* 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<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
|
||||
// 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 },
|
||||
|
||||
Reference in New Issue
Block a user