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.
This commit is contained in:
2026-10-19 00:32:57 +00:00
parent d13284b585
commit 1a0037af36
4 changed files with 226 additions and 8 deletions
+45
View File
@@ -27,6 +27,7 @@
//! buffering.
mod download;
mod webrtc;
mod protocol;
pub use protocol::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
@@ -274,6 +275,21 @@ async fn serve(
tracing::error!(%message, "cloud rejected this gateway's handshake - check CONTINUUM_GATEWAY_TOKEN");
return Ok(());
}
// Spawned, never awaited inline: go2rtc gathers
// ICE candidates before answering, which
// includes a STUN round trip, so this takes
// seconds. Awaiting it here would stall
// telemetry and heartbeats for the whole farm
// while one person opened one camera.
Ok(ServerMessage::WebrtcOffer { session_id, camera_stream, sdp }) => {
spawn_webrtc_negotiation(
config.go2rtc_api_listen.clone(),
session_id,
camera_stream,
sdp,
outbound.clone(),
);
}
Ok(other) => info!(?other, "received from cloud"),
// Loudly. This used to be an `if let Ok(..)` with
// no else, which is the same silent-drop bug that
@@ -424,6 +440,35 @@ fn spawn_long_command(
});
}
/// Answers one WebRTC offer, off this connection's task.
///
/// Always answers, success or failure: the cloud is holding a browser's
/// request open waiting for this, and a silent failure there is a viewer
/// watching a video element that never loads until something times out.
fn spawn_webrtc_negotiation(
api_listen: String,
session_id: String,
camera_stream: String,
offer_sdp: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
) {
tokio::spawn(async move {
let reply = match webrtc::negotiate(&api_listen, &camera_stream, &offer_sdp).await {
Ok(sdp) => ClientMessage::WebrtcAnswer { session_id, sdp },
Err(err) => {
// Logged as well as returned. The cloud gets the reason, but
// this is the copy that survives a gateway whose uplink is
// about to drop.
warn!(%camera_stream, %err, "could not negotiate a camera stream");
ClientMessage::WebrtcError { session_id, message: err.to_string() }
}
};
if outbound.send(reply).await.is_err() {
warn!(%camera_stream, "uplink is shutting down; the viewer gets no answer");
}
});
}
/// Finds the printer by our own id and runs the command. The error is a
/// `String` rather than a typed error because its only destination is a
/// `CommandResult` going back to the cloud as text.