Stop orphaning go2rtc, serve cameras sooner, keep its config private
Three defects found by running a real WebRTC viewer through the whole stack for the first time (continuum-backend/scripts/e2e-camera.sh). 1. go2rtc was orphaned on every ordinary stop. `kill_on_drop(true)` was set, but main handled no signals, so SIGTERM - what `kill`, `docker stop` and a service manager send - ended the process on the spot with no destructor run and the child was never dropped. The orphan keeps :1984 and :8555 bound, so the restarted proxy cannot start its own, and it keeps a printer's access code in memory. One had been running for 4.5 days. main now selects on SIGTERM/SIGINT so it returns normally and the supervisor is dropped. A SIGKILL or OOM kill still orphans it; PR_SET_PDEATHSIG would cover that but needs unsafe and a libc dependency, so it is left for a decision. 2. A camera was advertised before go2rtc could serve it. The comment on REBUILD_INTERVAL said the first useful rebuild is "a few seconds after startup", but a tokio interval ticks immediately - with the fleet still empty - and the next tick was 30s away. For that window the cloud offered a camera and go2rtc answered "stream not found". Polling every 5s costs nothing: it reads in-memory reports and only restarts go2rtc when the rendered config changes. 3. The generated go2rtc.yaml was created 0644 by tokio::fs::write. It holds the printer's access code and go2rtc's API password, so any local user could read both. It is now created owner-only and fresh, because a temp file left by a crash would otherwise keep its old mode. The new test pre-creates a stale 0644 temp file; against the old implementation it fails with mode 420 (0o644) where 384 (0o600) is expected. cargo test: 193 lib tests and all integration tests pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+53
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user