diff --git a/src/camera.rs b/src/camera.rs index a10ae20..e8bfebd 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -31,6 +31,18 @@ //! from the printer's IP: the fact that a printer has a camera and the fact //! that the camera has an RTSP endpoint are different facts, and only the //! second one is useful. +//! +//! ## What is confirmed to work +//! +//! Measured end to end against a live H2C with go2rtc 1.9.14 (2026-10-18): +//! this module's generated config is enough for go2rtc to connect to +//! `rtsps://bblp:@:322/streaming/live/1` and pull video - +//! 984,604 bytes over `rtsp+tcp`, no certificate complaint and no +//! authentication failure. The printer runs LIVE555 and sends **H.264 High +//! profile, level 4.1**, which a browser plays natively over WebRTC, so no +//! transcoding step (and no ffmpeg on the gateway) is needed. The +//! `rtspx://` prefix that go2rtc documents for skipping TLS verification is +//! not required here. use std::collections::BTreeMap; use std::time::Duration; @@ -152,10 +164,28 @@ pub fn render_config(api_listen: &str, sources: &[CameraSource]) -> String { } out.push_str(&format!("api:\n listen: \"{api_listen}\"\n")); - // No `rtsp:` or `webrtc:` block: the API listener is what serves WebRTC - // to a browser, which is the only consumer here. Opening go2rtc's own - // RTSP server would publish every printer's camera on the LAN - // unauthenticated, which is not something to do by default. + + // Closes go2rtc's own RTSP server, and this has to be explicit. + // + // Leaving the key out does NOT leave the server off - go2rtc defaults to + // :8554. Measured against go2rtc 1.9.14 with a config that omitted it: + // `rtsp://:8554/` answered a DESCRIBE carrying a real + // video SDP, with no credentials sent at all. Every printer's camera was + // republished to anything that could reach the port. + // + // Nothing needs it: a browser is served over WebRTC through the API + // 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. + // + // 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. out } @@ -199,7 +229,11 @@ pub async fn run( loop { ticker.tick().await; - let desired = render_config(&api_listen, &sources(&fleet).await); + // Collected once and kept, so the count logged below describes the + // config actually written rather than a second, later reading of the + // fleet that could disagree with it. + let found = sources(&fleet).await; + let desired = render_config(&api_listen, &found); if last_written.as_deref() == Some(desired.as_str()) { continue; } @@ -218,7 +252,13 @@ pub async fn run( match write_atomically(&config_path, &desired).await { Ok(()) => { - let count = desired.matches("\n ").count().saturating_sub(1); + // Counted from the sources, not from the rendered text. It + // used to be `matches("\n ").count() - 1`, which reported + // one stream for a config with none - the `{}` of an empty + // mapping indents exactly like a stream does. A log line + // that says a camera is being served when none is makes the + // one thing this task is for impossible to check. + let count = found.len(); // The content is never logged: it holds access codes. info!(path = %config_path, streams = count, "wrote go2rtc config, restarting it"); last_written = Some(desired); @@ -316,14 +356,28 @@ mod tests { ); } - /// Opening go2rtc's own RTSP or WebRTC listener would publish every - /// printer's camera on the LAN unauthenticated. The absence is the - /// decision, so it is worth a test that fails if someone adds one. + /// The config must *actively* close go2rtc's RTSP server. + /// + /// This test used to assert the opposite thing - that the rendered + /// config contained no `rtsp:` key - and concluded from that that no + /// RTSP server was opened. It passed, and it was worthless: go2rtc + /// defaults to :8554 when the key is absent. Measured against go2rtc + /// 1.9.14, a config with no `rtsp:` block answered an unauthenticated + /// DESCRIBE on `rtsp://:8554/h2c` with a real video SDP. + /// + /// A test that asserts a key is missing cannot tell "off" from + /// "defaulted on". This one asserts the value that turns it off. #[test] - fn the_generated_config_opens_no_server_but_the_api() { + fn the_generated_config_closes_the_rtsp_server() { let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() }; let config = render_config(":1984", &[source]); - assert!(!config.contains("rtsp:"), "got:\n{config}"); - assert!(!config.contains("webrtc:"), "got:\n{config}"); + assert!(config.contains("rtsp:\n listen: \"\"\n"), "got:\n{config}"); + } + + /// An empty fleet must still close it - a gateway with no cameras is the + /// common case, and it is the config most likely to be left running. + #[test] + fn an_empty_config_closes_the_rtsp_server_too() { + assert!(render_config(":1984", &[]).contains("rtsp:\n listen: \"\"\n")); } }