Answer relayed WebRTC offers from the gateway's own go2rtc

Adds src/uplink/webrtc.rs: on a webrtc_offer from the cloud, POST the
offer to go2rtc on 127.0.0.1 and send the answer back as webrtc_answer,
or the reason as webrtc_error.

No credential is involved, because the request goes to loopback and
go2rtc exempts loopback from its API password. That is measured, and it
is also why a probe from the gateway cannot be used to check the
password is working - test from another host for that.

Spawned rather than awaited in the select! loop: go2rtc gathers ICE
candidates before answering, which includes a STUN round trip, so
awaiting inline would stall telemetry and heartbeats for a whole farm
while one person opened one camera. It rides the outbound queue added
earlier for exactly this kind of out-of-band reply.

The bind address is turned into a loopback URL by taking its port and
refusing anything it cannot read a port out of - guessing 1984 would
produce a gateway that silently signals to the wrong place when someone
has moved it. Stream names are validated rather than percent-encoded,
since a real one is a printer id and anything else is a bug or an
attempt at one.

camera.rs now states why there are no ice_servers and no TURN
credentials on this side: the viewer's browser is pinned to relay-only,
so it allocates the relay and go2rtc just sends to it. Only one side of
a session needs one. That asymmetry is load-bearing, not tidy - go2rtc
reads its config once at startup, so rotating short-lived credentials
here would mean restarting it and dropping every viewer on a timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 00:32:57 +00:00
co-authored by Claude Opus 5
parent a8f8dec955
commit ee737f2e47
4 changed files with 226 additions and 8 deletions
+24
View File
@@ -47,6 +47,15 @@ pub enum ClientMessage {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// go2rtc's answer SDP for a `WebrtcOffer`, passed through verbatim.
WebrtcAnswer { session_id: String, sdp: String },
/// No answer was obtainable: no such camera here, go2rtc not running, or
/// it refused the offer.
///
/// Its own variant rather than an empty answer, because the cloud is
/// waiting on this and a viewer otherwise watches a video element that
/// never loads with nothing anywhere saying why.
WebrtcError { session_id: String, message: String },
}
/// A flattened snapshot of one printer, in the terms an operator console
@@ -149,6 +158,21 @@ pub enum ServerMessage {
/// Do something to one printer. `printer_id` is our own id from
/// printers.toml.
Command { command_id: String, printer_id: String, command: PrinterCommand },
/// Relay a browser's WebRTC offer to this gateway's go2rtc and answer
/// with the SDP it returns. **Signalling only - no video on this
/// socket.**
///
/// Media goes browser-to-go2rtc directly over ICE, which is the whole
/// reason only the signalling is relayed: on a workshop LAN the video
/// never leaves the building, and it costs the cloud no bandwidth at
/// all. go2rtc's answer carries a server-reflexive candidate alongside
/// its host ones, so even a remote viewer can often reach the gateway
/// without a relay.
///
/// The browser cannot do this part itself. go2rtc sends no CORS headers
/// and checks its password before CORS, so the unauthenticated preflight
/// a browser always sends is refused - measured, not assumed.
WebrtcOffer { session_id: String, camera_stream: String, sdp: String },
}
/// The closed set of things the cloud is allowed to ask a printer to do.