Close go2rtc's RTSP server, which was open all along

Found by installing go2rtc and running the real daemon against the live
H2C, which is also how the comment this replaces turned out to be wrong.

`camera.rs` said: "No `rtsp:` or `webrtc:` block: ... Opening go2rtc's
own RTSP server would publish every printer's camera on the LAN
unauthenticated, which is not something to do by default." The reasoning
was right and the conclusion was backwards. **Omitting the key does not
leave the server off - go2rtc defaults to :8554.** Measured against
go2rtc 1.9.14 with exactly the config this module generated:
`rtsp://<gateway>:8554/h2c` answered a DESCRIBE with a real video SDP,
with no credentials sent at all. Every printer with a camera was being
republished to anything that could reach the port.

The unit test asserted that the rendered config contained no `rtsp:`
key, and from that concluded no server was opened. It passed throughout.
A test that asserts a key is absent cannot tell "off" from "defaulted
on" - it now asserts the value that actually turns it off, and that an
empty fleet closes it too, since a gateway with no cameras is the config
most likely to be left running.

`webrtc:` is left at its default on purpose: it is not a second stream
server, because a session only exists after an SDP offer through the
API, and the browser needs those candidates. The API listener itself is
unauthenticated and stays that way for now - the browser reaches it
directly, there is no relay, and the frontend is handed only a URL and a
stream name - so anyone on the gateway's LAN can watch. That is a real
limit, now written down here and in PLAN.md rather than implied.

Two smaller things from the same session:

- The logged stream count was `matches("\n  ").count() - 1`, which
  reported one stream for a config with none: the `{}` of an empty
  mapping indents exactly like a stream does. It made the log say a
  camera was being served when none was, and it hid that the two writes
  on startup are genuinely different configs (empty, then the fleet once
  it reports) rather than a pointless rewrite. Counted from the sources
  now, collected once so the number describes the config actually
  written.

- Recorded what is now confirmed: go2rtc connects to
  `rtsps://bblp:<access_code>@<ip>:322/streaming/live/1` and pulls video
  - 984,604 bytes over `rtsp+tcp`, no certificate complaint, no auth
  failure. The printer runs LIVE555 and sends H.264 High profile level
  4.1, which a browser plays natively over WebRTC, so no transcoding and
  no ffmpeg on the gateway. The `rtspx://` prefix go2rtc documents for
  skipping TLS verification is not needed.

Verified after the fix with the real daemon: the generated config now
leaves :8554 closed while :1984 and the WebRTC port stay up. 65 tests,
clippy unchanged, licenses ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 13:43:19 +00:00
co-authored by Claude Opus 5
parent da6260fa9c
commit 2ca81401fc
+66 -12
View File
@@ -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:<access_code>@<ip>: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://<gateway>:8554/<printer>` 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://<gateway>: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"));
}
}