From 005b8a3337eb6b2d8b1d84174c68f098d45b3b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sun, 18 Oct 2026 22:00:51 +0000 Subject: [PATCH] Require a password on go2rtc's API The API listener is what serves WebRTC, and it defaults to :1984 on every interface, so anyone who could reach the gateway could watch any printer with a camera. The RTSP server was closed on 2026-10-18; this was the other half and the one that actually carries video. Measured against go2rtc 1.9.14, feeding it this module's own rendered config: from a non-loopback address /api/webrtc, /api/ws, /api/streams and the web UI all answer 401 without credentials and 200 with them, while :8554 stays closed. go2rtc exempts loopback, which is worth knowing twice - it is why a probe from the gateway still works without credentials, and why such a probe cannot be used to confirm auth is on. The password is generated once per run from /dev/urandom, or taken from CONTINUUM_GO2RTC_PASSWORD. Once per run matters: the config is re-rendered every 30s and compared byte-for-byte to decide whether to restart go2rtc, so a per-render credential would restart it and drop every viewer every half minute, with no symptom but dying video. A test pins that. It is never logged - it goes into the config file, which already holds every printer's access code and is already documented as a secret, so there is one place to protect rather than two. An operator-supplied password is escaped for YAML, because a config go2rtc cannot parse means a camera that silently never appears. No opt-out flag. The alternative is an unauthenticated video feed of someone's workshop, which is not a thing to leave behind a setting, and nothing in this crate calls go2rtc's API so nothing internal breaks. Note this does NOT let a browser play a stream directly - see the next commit's notes and PLAN.md. go2rtc sends no CORS headers and 401s the unauthenticated preflight, so the relay is required, not optional. Co-Authored-By: Claude Opus 5 --- .env.example | 10 ++++ src/camera.rs | 160 +++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 13 ++++ 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 333aee1..11a55f4 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,16 @@ CONTINUUM_GO2RTC_BIN=/usr/local/bin/go2rtc CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml CONTINUUM_GO2RTC_API_LISTEN=:1984 +# The go2rtc API serves WebRTC, and the listen address above is every +# interface, so it is always password-protected: leave this unset and the +# daemon generates a random password each run and writes it into the config +# file above, which is already a secret. Set it only if you need a stable one +# (to open go2rtc's own web UI, say). The username is always "continuum". +# +# go2rtc exempts loopback from this, so a check from the gateway itself always +# succeeds - test from another machine if you want to confirm it is on. +# CONTINUUM_GO2RTC_PASSWORD= + # What a browser can reach the streams at, reported to the cloud. A bind # address is not one, which is why this is separate from the listen address # above; only you know this gateway's reachable name. Note this is a LAN diff --git a/src/camera.rs b/src/camera.rs index e8bfebd..444dc8b 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -137,7 +137,75 @@ pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option String { +/// 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\ @@ -164,6 +232,17 @@ pub fn render_config(api_listen: &str, sources: &[CameraSource]) -> String { } 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. // @@ -221,6 +300,7 @@ 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); @@ -233,7 +313,7 @@ pub async fn run( // 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); + let desired = render_config(&api_listen, &found, &auth); if last_written.as_deref() == Some(desired.as_str()) { continue; } @@ -300,6 +380,13 @@ mod tests { } /// The URL shape confirmed from a real H2C's `ipcam` block. + + /// 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() } + } #[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")) @@ -338,7 +425,7 @@ mod tests { /// 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", &[]); + let config = render_config(":1984", &[], &test_auth()); assert!(config.contains("streams:\n {}\n"), "got:\n{config}"); assert!(config.contains("listen: \":1984\""), "got:\n{config}"); } @@ -351,8 +438,8 @@ mod tests { 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()]), - render_config(":1984", &[b, a]), + render_config(":1984", &[a.clone(), b.clone()], &test_auth()), + render_config(":1984", &[b, a], &test_auth()), ); } @@ -370,7 +457,7 @@ mod tests { #[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]); + let config = render_config(":1984", &[source], &test_auth()); assert!(config.contains("rtsp:\n listen: \"\"\n"), "got:\n{config}"); } @@ -378,6 +465,65 @@ mod tests { /// 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")); + 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", &[source.clone()], &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}" + ); } } diff --git a/src/main.rs b/src/main.rs index ccf7703..88c0bc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ // about the library's actual public API. One copy, no false warnings. mod go2rtc; +use tracing::info; use tracing_subscriber::EnvFilter; use continuum_proxy::config::Config; @@ -59,6 +60,17 @@ async fn main() -> anyhow::Result<()> { // so in practice this runs until the process is killed. let restart_go2rtc = std::sync::Arc::new(tokio::sync::Notify::new()); + // Built once, here, rather than inside the rebuild loop: the config is + // re-rendered every 30s and compared byte-for-byte to decide whether to + // restart go2rtc, so a credential that changed per render would restart + // it - and drop every viewer - on every tick. + let camera_auth = camera::ApiAuth::new()?; + info!( + user = %camera_auth.username, + config = %config.go2rtc_config, + "go2rtc's API requires basic auth; the password is in its config file" + ); + tokio::select! { _ = uplink::run(config.clone(), fleet.clone()) => {} _ = go2rtc::watchdog( @@ -70,6 +82,7 @@ async fn main() -> anyhow::Result<()> { fleet.clone(), config.go2rtc_config.clone(), config.go2rtc_api_listen.clone(), + camera_auth, restart_go2rtc.clone(), ) => {} _ = completion::run(fleet.clone()) => {}