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:
@@ -0,0 +1,137 @@
|
||||
//! WebRTC signalling, relayed from the cloud to this gateway's own go2rtc.
|
||||
//!
|
||||
//! Only the signalling. The video never touches this process or the cloud:
|
||||
//! once the browser has go2rtc's answer, the two negotiate media directly
|
||||
//! over ICE. On a workshop LAN that means the stream never leaves the
|
||||
//! building and costs the cloud nothing, which is the entire reason the
|
||||
//! relay carries SDP rather than bytes.
|
||||
//!
|
||||
//! ## Why the cloud has to be in the middle at all
|
||||
//!
|
||||
//! A browser cannot signal to go2rtc itself. Measured against go2rtc 1.9.14:
|
||||
//! it returns no `Access-Control-Allow-Origin` on any response, and it checks
|
||||
//! its API password *before* CORS, so the unauthenticated preflight a browser
|
||||
//! always sends gets a 401. A gateway is also behind a workshop NAT, so the
|
||||
//! cloud cannot dial in either - the only way through is the connection this
|
||||
//! gateway already opened outbound.
|
||||
//!
|
||||
//! ## Why no credential appears here
|
||||
//!
|
||||
//! The request goes to `127.0.0.1`, and go2rtc exempts loopback from its API
|
||||
//! password. That is a measured property, not an assumption, and it is also
|
||||
//! why a probe from the gateway itself cannot be used to check that the
|
||||
//! password is working - test from another host for that.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// go2rtc answers an offer after gathering candidates, which includes a STUN
|
||||
/// round trip to discover its public address, so this is not instant. Still
|
||||
/// bounded: a viewer waiting on a dead go2rtc should be told, and the cloud
|
||||
/// is holding a request open for this.
|
||||
const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// Turns go2rtc's *bind* address into a loopback URL we can call it on.
|
||||
///
|
||||
/// `CONTINUUM_GO2RTC_API_LISTEN` is a listen address - `:1984`,
|
||||
/// `0.0.0.0:1984` - and neither is something to send a request to. Only the
|
||||
/// port is portable, and loopback is the address we want regardless: it is
|
||||
/// the one go2rtc exempts from its password.
|
||||
fn local_api_base(api_listen: &str) -> anyhow::Result<String> {
|
||||
let port = api_listen
|
||||
.rsplit_once(':')
|
||||
.map(|(_, port)| port)
|
||||
.unwrap_or(api_listen)
|
||||
.trim();
|
||||
|
||||
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
|
||||
anyhow::bail!("cannot tell which port go2rtc's API is on from '{api_listen}'");
|
||||
}
|
||||
Ok(format!("http://127.0.0.1:{port}"))
|
||||
}
|
||||
|
||||
/// Rejects a stream name that could not be a go2rtc stream name.
|
||||
///
|
||||
/// It goes into a query string, and this is cheaper and clearer than
|
||||
/// percent-encoding: a real name is a printer id from `printers.toml`, so
|
||||
/// anything outside this set is a bug or an attempt at one, and refusing it
|
||||
/// says so instead of forwarding something odd to go2rtc.
|
||||
fn checked_stream_name(name: &str) -> anyhow::Result<&str> {
|
||||
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) {
|
||||
anyhow::bail!("'{name}' is not a usable go2rtc stream name");
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Posts the browser's offer to go2rtc and returns its answer SDP.
|
||||
pub async fn negotiate(api_listen: &str, camera_stream: &str, offer_sdp: &str) -> anyhow::Result<String> {
|
||||
let base = local_api_base(api_listen)?;
|
||||
let stream = checked_stream_name(camera_stream)?;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/webrtc?src={stream}"))
|
||||
.header("Content-Type", "application/sdp")
|
||||
.body(offer_sdp.to_string())
|
||||
.timeout(NEGOTIATION_TIMEOUT)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// go2rtc answers 201 rather than 200 here, so this checks the class and
|
||||
// not a single code.
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Its body is the useful half - "streams: wrong url" for a camera it
|
||||
// does not have, say - and a bare status would send whoever is
|
||||
// reading the logs looking at the network instead.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let detail = body.trim();
|
||||
anyhow::bail!(
|
||||
"go2rtc refused the offer for '{stream}' (HTTP {}){}",
|
||||
status.as_u16(),
|
||||
if detail.is_empty() { String::new() } else { format!(": {detail}") }
|
||||
);
|
||||
}
|
||||
|
||||
let answer = response.text().await?;
|
||||
if answer.trim().is_empty() {
|
||||
anyhow::bail!("go2rtc accepted the offer for '{stream}' but returned an empty answer");
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bind_address_becomes_a_loopback_url() {
|
||||
// The three shapes an operator actually writes, all of which have to
|
||||
// end up at loopback - that is the address go2rtc does not demand a
|
||||
// password on.
|
||||
for listen in [":1984", "0.0.0.0:1984", "127.0.0.1:1984"] {
|
||||
assert_eq!(local_api_base(listen).unwrap(), "http://127.0.0.1:1984");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bind_address_with_no_port_is_refused_rather_than_guessed() {
|
||||
// Guessing 1984 would produce a gateway that silently signals to the
|
||||
// wrong place when someone has moved the port.
|
||||
for listen in ["", "localhost", ":", "1984a", ":http"] {
|
||||
assert!(local_api_base(listen).is_err(), "{listen:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_name_that_could_break_out_of_the_query_is_refused() {
|
||||
for name in ["", "h2c&src=other", "h2c?x", "h2c/../y", "h2c one"] {
|
||||
assert!(checked_stream_name(name).is_err(), "{name:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_printer_id_is_accepted() {
|
||||
for name in ["h2c", "p1p", "printer-2", "bay_3", "x1.c"] {
|
||||
assert_eq!(checked_stream_name(name).unwrap(), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user