diff --git a/src/camera.rs b/src/camera.rs index ce750d9..6b63f73 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -55,11 +55,16 @@ 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 first tick fires immediately, before any printer has reported, so it +/// always writes a config with no streams. The one that matters is the next, +/// once the first reports have arrived - and until it runs, telemetry already +/// advertises a camera that go2rtc cannot serve, so a viewer in that window +/// gets "stream not found". At 30s that window was half a minute after every +/// start (found by scripts/e2e-camera.sh, which asked at +7s). +/// +/// Cheap to poll: it reads in-memory reports and compares the rendered +/// config byte for byte, and go2rtc is only restarted when that changes. +const REBUILD_INTERVAL: Duration = Duration::from_secs(5); /// 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 @@ -162,8 +167,8 @@ impl ApiAuth { /// 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. + /// changed per call would restart it - and drop every live viewer - on + /// every tick, 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 @@ -369,8 +374,23 @@ pub async fn run( /// 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<()> { + use tokio::io::AsyncWriteExt; + let temp = format!("{path}.tmp"); - tokio::fs::write(&temp, contents).await?; + // `tokio::fs::write` creates with 0666 minus the umask, so 0644: any local + // user could read the printer's access code and go2rtc's API password out + // of this file. Created owner-only instead, and created fresh - a temp file + // left behind by a crash would keep whatever mode it was given, because the + // mode applies only when the file is created. + let _ = tokio::fs::remove_file(&temp).await; + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(&temp).await?; + file.write_all(contents.as_bytes()).await?; + file.flush().await?; + drop(file); tokio::fs::rename(&temp, path).await } @@ -538,4 +558,29 @@ mod tests { "got:\n{config}" ); } + + /// The file holds a printer's access code and go2rtc's API password, so it + /// must not be readable by other local users. The second write matters: a + /// temp file left by a crash, created 0644, must not poison the next one. + #[cfg(unix)] + #[tokio::test] + async fn the_go2rtc_config_is_written_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("continuum-camera-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("go2rtc.yaml"); + let path_str = path.to_str().unwrap(); + + std::fs::write(format!("{path_str}.tmp"), "stale").unwrap(); + std::fs::set_permissions(format!("{path_str}.tmp"), std::fs::Permissions::from_mode(0o644)).unwrap(); + + write_atomically(path_str, "secret: yes\n").await.unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "secret: yes\n"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + + write_atomically(path_str, "secret: again\n").await.unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src/main.rs b/src/main.rs index 55fd162..ab23b47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,7 +107,7 @@ async fn main() -> anyhow::Result<()> { 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 + // re-rendered every few seconds 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()?; @@ -124,6 +124,16 @@ async fn main() -> anyhow::Result<()> { let (outbound, inbox) = uplink::channel(); tokio::select! { + // Ends the process through a normal return, so the supervisors below + // are dropped and go2rtc's `kill_on_drop` actually fires. Without this + // arm SIGTERM - what `kill`, `docker stop` and a service manager all + // send - ends the process on the spot with no destructor run, and + // go2rtc is orphaned: it keeps :1984 and :8555 bound, so the restarted + // proxy cannot start its own, and it keeps a printer's access code in + // memory. Found when a test run left one behind. + _ = shutdown_requested() => { + info!("stop requested; exiting so go2rtc is stopped with us"); + } _ = uplink::run(config.clone(), fleet.clone(), outbound.clone(), inbox) => {} _ = continuum_proxy::discovery::report::run( config.discovery.clone(), @@ -157,6 +167,23 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Resolves when the process is asked to stop: SIGTERM or SIGINT on Unix, +/// Ctrl-C elsewhere. +async fn shutdown_requested() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + if let Ok(mut terminate) = signal(SignalKind::terminate()) { + tokio::select! { + _ = terminate.recv() => {} + _ = tokio::signal::ctrl_c() => {} + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} + /// Connects every configured printer concurrently (not one at a time - /// Bambu's auto-detect fallback alone can take several seconds per /// printer if it has to time out on MQTT v5 before trying v3.1.1;