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
+20 -8
View File
@@ -256,15 +256,27 @@ pub fn render_config(api_listen: &str, sources: &[CameraSource], auth: &ApiAuth)
// listener above. An empty `listen` is how go2rtc is told not to bind.
out.push_str("rtsp:\n listen: \"\"\n");
// `webrtc:` is deliberately left alone, so go2rtc keeps its default
// media port. It is not a second open stream server - a WebRTC session
// only exists after an SDP offer through the API - and the browser needs
// those candidates to receive anything.
// `webrtc:` is deliberately left alone, and with TURN in the picture that
// is now a decision worth stating rather than an omission.
//
// The API listener itself is unauthenticated, which is a real limit
// rather than an oversight: the browser reaches it directly, there is no
// relay, and the frontend is handed only a URL and a stream name. So
// anyone who can reach the gateway's LAN can watch. See PLAN.md.
// go2rtc keeps its default media port and its default candidate
// gathering, which produces a host candidate and - measured against
// 1.9.14 - a server-reflexive one it discovers by STUN on its own. That
// is everything this end needs. No `ice_servers` here, and in particular
// **no TURN credentials on the gateway**: the viewer's browser is pinned
// to relay-only, so the browser allocates the relay and go2rtc simply
// sends to it from its own candidate. Only one side of a WebRTC session
// has to hold a relay for the media to traverse one.
//
// That asymmetry is what makes this tractable at all, because go2rtc
// reads its config exactly once at startup: TURN credentials are
// short-lived, so rotating them here would mean restarting go2rtc and
// dropping every viewer on a timer. Keeping them browser-side moves the
// rotation to a request that is already per-viewer.
//
// This is not a second open stream server either way: a WebRTC session
// exists only after an SDP offer through the API, and that API now
// requires a password.
out
}