//! Turns the printers' own camera URLs into a go2rtc configuration. //! //! `go2rtc.rs` has supervised a go2rtc process since before this module //! existed, and go2rtc has never had anything to serve: nothing wrote its //! config file. `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, which //! did not exist in the repository or anywhere else. This writes it. //! //! ## Where the URL comes from //! //! Not from `printers.toml`. A printer reports its own camera URL in //! `print.push_status`'s `ipcam` block, so asking an operator to copy it into //! a config file would be asking them to keep a second copy of something the //! machine already tells us - and to update it by hand when the printer's //! address changes. //! //! ## What does not work, which is most printers //! //! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real //! machines: an H2C reports `rtsps://:322/streaming/live/1`, and a P1P //! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is //! reachable only over Bambu's proprietary port-6000 JPEG stream, which this //! crate does not speak, so those printers get no stream here and say so //! rather than advertising one that will not play. //! //! That is not just an inference from the missing field - it is confirmed at //! the network layer (2026-10-15). The H2C accepts TCP on 322; the P1P //! *refuses* 322 and listens on 6000. So a P1P has no RTSP endpoint to //! restream, and no amount of guessing a URL would produce one. //! //! That is also why this module reads the *reported* URL and never builds one //! 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; use tokio::sync::Notify; use tracing::{info, warn}; use crate::printer::{GenericPrinter, PrinterHandle}; use crate::uplink::SharedFleet; /// How often to rebuild the configuration from what the printers report. /// /// A camera URL changes when a printer's address changes, which is a DHCP /// lease event, not something that needs watching closely. The first useful /// rebuild is the one a few seconds after startup, once the first reports /// have arrived - before that the fleet has nothing to say. const REBUILD_INTERVAL: Duration = Duration::from_secs(30); /// The username every Bambu uses for its own services - MQTT, FTPS, and the /// camera. Not a guess: it is what `bambu_mqtt` and `bambu_ftps` already /// authenticate with. const CAMERA_USER: &str = "bblp"; /// One printer's playable stream. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CameraSource { /// Our own printer id, which doubles as the go2rtc stream name. Using it /// for both means the cloud can name a stream without the gateway having /// to report a mapping: `camera_stream` in telemetry is this id. pub stream_name: String, /// The full source URL, credentials included. pub url: String, } /// Builds the source URL for one printer, or `None` if it has no stream. /// /// The credentials go in the URL because that is the only place go2rtc takes /// them for an RTSP source. The consequence is that the printer's access code /// ends up in a file on disk - which is not a new exposure (`printers.toml` /// already holds it, and that file is gitignored for this reason) but it does /// mean the generated config deserves the same treatment, and must never be /// logged. pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option { let PrinterHandle::Bambu(bambu) = printer else { // Nothing else has a camera path here yet. A Prusa's is a separate // product, and a Klipper camera is whatever the user configured. return None; }; let url = report_url?; if url.is_empty() { return None; } // `rtsps://1.2.3.4:322/streaming/live/1` -> `rtsps://bblp:code@1.2.3.4:322/...` let with_credentials = match url.split_once("://") { Some((scheme, rest)) => { // A URL that already carries credentials is left alone rather // than having a second set spliced in. if rest.contains('@') { url.to_string() } else { format!("{scheme}://{CAMERA_USER}:{}@{rest}", bambu.access_code) } } // Not a URL shape this understands. Refused rather than guessed at: // a malformed source makes go2rtc fail to load the whole file, which // would take every *working* stream down with it. None => { warn!( printer = %bambu.display_name(), "the printer reported a camera URL with no scheme; ignoring it" ); return None; } }; Some(CameraSource { stream_name: printer.base().id.clone(), url: with_credentials, }) } /// Renders a go2rtc configuration. /// /// Hand-written YAML rather than a serializer, for the same reason the /// `.3mf` reader is hand-rolled: it is a fixed, tiny shape, and a dependency /// for ten lines of output is a dependency to license and audit. The values /// are quoted and the keys are our own printer ids, which `fleet.rs` /// constrains. /// /// `listen` is explicit rather than left to go2rtc's defaults, because the /// cloud is told this address and a default that changes between go2rtc /// versions would change where the stream lives without anything saying so. /// Basic-auth credentials for go2rtc's API listener. /// /// The API is what serves WebRTC, so without this anyone who can reach /// `http://:1984` can watch any printer with a camera - and the /// listener defaults to `:1984`, every interface. Measured against go2rtc /// 1.9.14, `username`/`password` under `api:` really does enforce it: from a /// non-loopback address, no credentials and wrong credentials both get 401 /// and the correct ones get 200, on `/api/webrtc`, `/api/ws`, /// `/api/frame.jpeg` and the web UI alike. /// /// **Loopback is exempt**, which is a deliberate convenience of go2rtc's and /// worth knowing twice over: it is why a probe from the gateway itself still /// works with no credentials, and it is why a probe from the gateway itself /// cannot be used to check that auth is on. Test from another host. pub struct ApiAuth { pub username: String, pub password: String, } impl ApiAuth { /// The operator's password if they set one, otherwise a fresh random one. /// /// Generated once per run and then held, never re-derived: `render_config` /// is called every `REBUILD_INTERVAL` and its output is compared /// byte-for-byte to decide whether to restart go2rtc, so a password that /// changed per call would restart it - and drop every live viewer - every /// 30 seconds, forever. /// /// A generated password is never logged. It is written into the config /// file, which already holds every printer's access code and is already /// documented as a secret, so that is where an operator reads it from - /// one secret store rather than two. pub fn new() -> anyhow::Result { let password = match std::env::var("CONTINUUM_GO2RTC_PASSWORD") { Ok(set) if !set.is_empty() => set, // `||` semantics, not `??`: an empty value in a deployment // template is "unset", not "the empty password". _ => random_secret()?, }; Ok(Self { username: "continuum".to_string(), password }) } } /// 32 bytes of kernel randomness, base64url so it is safe unquoted in YAML. /// /// Read from `/dev/urandom` with the standard library rather than pulling in /// a crate for it: the gateway is Linux, this is the one place that needs /// randomness, and a new dependency would need a licence review (see /// `continuum-common/docs/LICENSES.md`). fn random_secret() -> anyhow::Result { use base64::Engine; use std::io::Read; let mut bytes = [0u8; 32]; std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } /// Escapes a value for a YAML double-quoted scalar. /// /// Only needed for an operator-supplied password - a generated one is /// base64url and has nothing to escape - but a password containing a quote /// would otherwise produce a config go2rtc cannot parse, and the symptom /// would be a camera that silently never appears. fn yaml_quoted(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } pub fn render_config(api_listen: &str, sources: &[CameraSource], auth: &ApiAuth) -> String { let mut out = String::from( "# Generated by continuum-proxy. Do not edit: it is rewritten from\n\ # what the printers themselves report, so an edit here is lost on\n\ # the next rebuild. Contains printer access codes - treat it as a\n\ # secret and keep it out of version control.\n", ); // Sorted, so an unchanged fleet renders byte-identical output and the // change detection below does not restart go2rtc on every rebuild. let ordered: BTreeMap<&str, &str> = sources .iter() .map(|s| (s.stream_name.as_str(), s.url.as_str())) .collect(); out.push_str("streams:\n"); if ordered.is_empty() { // An empty mapping, written explicitly. `streams:` with nothing // under it is a null in YAML, and go2rtc is entitled to treat that // differently from an empty map. out.push_str(" {}\n"); } for (name, url) in &ordered { out.push_str(&format!(" {name}: \"{url}\"\n")); } out.push_str(&format!("api:\n listen: \"{api_listen}\"\n")); // Not optional, and there is no flag to turn it off. The alternative is // an unauthenticated video feed of someone's workshop reachable by // anything on their network, which is not a thing to leave behind a // setting. Nothing in this crate calls go2rtc's API, so nothing here // breaks; a browser cannot use these credentials directly either way - // see the CORS note in the module docs. out.push_str(&format!( " username: \"{}\"\n password: \"{}\"\n", yaml_quoted(&auth.username), yaml_quoted(&auth.password), )); // 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, and with TURN in the picture that // is now a decision worth stating rather than an omission. // // 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 } /// Collects every printer that currently has a playable stream. pub async fn sources(fleet: &SharedFleet) -> Vec { let fleet = fleet.lock().await; let mut found = Vec::new(); for printer in fleet.iter() { let report_url = printer .print_status() .await .and_then(|report| report.ipcam) .and_then(|ipcam| ipcam.rtsp_url); if let Some(source) = source_for(printer, report_url.as_deref()) { found.push(source); } } found } /// Rewrites the config whenever what the printers report changes, and asks /// the watchdog to restart go2rtc when it does. /// /// Restart rather than reload: go2rtc can reload some of its configuration /// over its own API, but a restart is the behaviour we can be sure of, and /// the watchdog was already built to restart it. A stream interruption at the /// moment a printer's camera URL changes is acceptable; a stream that is /// silently serving the old address is not. pub async fn run( fleet: SharedFleet, config_path: String, api_listen: String, auth: ApiAuth, restart: std::sync::Arc, ) { let mut ticker = tokio::time::interval(REBUILD_INTERVAL); let mut last_written: Option = None; loop { ticker.tick().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, &auth); if last_written.as_deref() == Some(desired.as_str()) { continue; } // Compare against the file too, not just our own memory of it: on // startup `last_written` is `None`, and rewriting an identical file // would restart go2rtc - and therefore drop every live viewer - for // no reason, every time the daemon restarts. if last_written.is_none() && let Ok(existing) = tokio::fs::read_to_string(&config_path).await && existing == desired { last_written = Some(desired); continue; } match write_atomically(&config_path, &desired).await { Ok(()) => { // 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); restart.notify_one(); } Err(err) => warn!(path = %config_path, ?err, "could not write the go2rtc config"), } } } /// Writes through a temporary file and renames. /// /// go2rtc may be reading this file at any moment - the watchdog restarts it /// on a five-second loop - and a partially written config is a config that /// fails to parse, which takes every working stream down. A rename is atomic /// on the same filesystem, so a reader sees either the old file or the new /// one. async fn write_atomically(path: &str, contents: &str) -> std::io::Result<()> { let temp = format!("{path}.tmp"); tokio::fs::write(&temp, contents).await?; tokio::fs::rename(&temp, path).await } #[cfg(test)] mod tests { use super::*; use crate::printer::{BambuPrinter, BambuProtocolPreference, BambuTls}; fn printer(id: &str) -> PrinterHandle { PrinterHandle::Bambu(BambuPrinter::new( id, "Test Printer", "192.168.0.21", "12345678", "SN123", BambuTls::Insecure, BambuProtocolPreference::Auto, )) } /// A fixed credential, so the byte-identical assertions below stay /// meaningful. A real one is 32 random bytes and would make every /// rendered config differ from every other. fn test_auth() -> ApiAuth { ApiAuth { username: "continuum".into(), password: "test-password".into() } } /// The URL shape confirmed from a real H2C's `ipcam` block. #[test] fn a_reported_rtsps_url_gets_the_printer_credentials_spliced_in() { let source = source_for(&printer("h2c"), Some("rtsps://192.168.0.21:322/streaming/live/1")) .expect("an rtsps URL is playable"); assert_eq!(source.stream_name, "h2c"); assert_eq!(source.url, "rtsps://bblp:12345678@192.168.0.21:322/streaming/live/1"); } /// What a P1P reports: an `ipcam` block that says a camera exists without /// giving anything to connect to. Advertising a stream here would make a /// UI offer a player that can never load. #[test] fn a_printer_that_reports_no_url_has_no_stream() { assert_eq!(source_for(&printer("p1p"), None), None); assert_eq!(source_for(&printer("p1p"), Some("")), None); } /// Splicing a second set of credentials into a URL that already has some /// would produce `bblp:code@user:pass@host`, which is not a host. #[test] fn a_url_that_already_carries_credentials_is_left_alone() { let source = source_for(&printer("h2c"), Some("rtsps://someone:else@10.0.0.5:322/live")) .expect("still playable"); assert_eq!(source.url, "rtsps://someone:else@10.0.0.5:322/live"); } /// Refused rather than guessed at, because one bad entry makes go2rtc /// fail to load the whole file and takes every working stream with it. #[test] fn a_url_with_no_scheme_is_refused_rather_than_repaired() { assert_eq!(source_for(&printer("h2c"), Some("192.168.0.21:322/streaming/live/1")), None); } /// `streams:` with nothing under it is YAML for null, not for an empty /// map, and a gateway with no streamable camera is the normal state of a /// P1-only farm - so this is the output that must be valid, not a corner. #[test] fn an_empty_fleet_renders_an_explicit_empty_map() { let config = render_config(":1984", &[], &test_auth()); assert!(config.contains("streams:\n {}\n"), "got:\n{config}"); assert!(config.contains("listen: \":1984\""), "got:\n{config}"); } /// The change detection in `run` compares rendered output, so an /// unchanged fleet in a different order must render identically or /// go2rtc gets restarted - dropping every live viewer - every 30s. #[test] fn the_same_fleet_in_a_different_order_renders_identically() { let a = CameraSource { stream_name: "aaa".into(), url: "rtsps://one".into() }; let b = CameraSource { stream_name: "zzz".into(), url: "rtsps://two".into() }; assert_eq!( render_config(":1984", &[a.clone(), b.clone()], &test_auth()), render_config(":1984", &[b, a], &test_auth()), ); } /// 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_closes_the_rtsp_server() { let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() }; let config = render_config(":1984", &[source], &test_auth()); 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", &[], &test_auth()).contains("rtsp:\n listen: \"\"\n")); } /// The API listener is what serves WebRTC, and it defaults to every /// interface, so a config without this is a video feed of someone's /// workshop open to their whole network. /// /// Asserts the values, for the same reason as the RTSP test above: a /// `username:` key with the wrong content would pass an existence check /// and authenticate nobody. #[test] fn the_generated_config_demands_a_password_on_the_api() { let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() }; let config = render_config(":1984", &[source], &test_auth()); assert!( config.contains(" username: \"continuum\"\n password: \"test-password\"\n"), "got:\n{config}" ); } /// Even with no cameras. A gateway that acquires one later keeps the /// config it was already running, and an unauthenticated listener with /// no streams is one printer away from an unauthenticated listener with /// one. #[test] fn an_empty_config_demands_a_password_too() { assert!(render_config(":1984", &[], &test_auth()).contains(" password: \"test-password\"\n")); } /// Byte-identical output for the same inputs, which is what stops the /// rebuild loop restarting go2rtc. /// /// The credential is the live hazard here: it is generated once per run /// precisely so this holds. Were it re-derived per render, every 30s tick /// would see a changed config, restart go2rtc and drop every viewer - /// forever, and the only symptom would be video that dies every half /// minute. #[test] fn the_same_fleet_and_credential_render_identically() { let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() }; assert_eq!( render_config(":1984", std::slice::from_ref(&source), &test_auth()), render_config(":1984", &[source], &test_auth()), ); } /// An operator-supplied password is not guaranteed to be YAML-safe, and /// a config go2rtc cannot parse means a camera that silently never /// appears - the hardest kind of failure to trace back to a password. #[test] fn an_awkward_operator_password_is_escaped() { let auth = ApiAuth { username: "continuum".into(), password: r#"he said "hi"\ok"#.into(), }; let config = render_config(":1984", &[], &auth); assert!( config.contains(r#" password: "he said \"hi\"\\ok""#), "got:\n{config}" ); } }