Files
continuum-proxy/src/main.rs
T
octoturge af8bac4bf3 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.
2026-10-23 16:01:34 +00:00

208 lines
9.7 KiB
Rust

// Everything but go2rtc comes from the library crate (src/lib.rs) rather than
// being re-declared here. Declaring `mod printer;` in both places compiles the
// same files twice, once per crate, and the binary copy warned about every
// `pub use` re-export and every method only examples or tests call - "unused"
// is true inside a binary, where nothing is externally reachable, and wrong
// about the library's actual public API. One copy, no false warnings.
mod go2rtc;
use tracing::info;
use tracing_subscriber::EnvFilter;
use continuum_proxy::bootstrap::{self, Bootstrap};
use continuum_proxy::config::Config;
use continuum_proxy::config_import;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{camera, completion, fleet, uplink};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
dotenvy::dotenv().ok();
// Order matters: import first, so a stick present at boot is already
// the installed config by the time it is read. A rejected stick is
// logged inside and does not stop startup - the box runs on what it has.
let bootstrap = Bootstrap::discover()?;
// Plaintext bundles are refused on a Proxy Box and signatures are
// mandatory once a signing key is provisioned; see the config_import
// module docs. An unprovisioned box is allowed to start - it runs on its
// installed config - but says loudly what it cannot check.
//
// Loaded only on a box: on a PC nothing imports from removable media, and
// a stray or malformed file under /etc/continuum must not stop a PC user's
// proxy starting over provisioning it never uses.
let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox {
config_import::load_provisioning()?
} else {
config_import::BundlePolicy::unencrypted_only()
};
if bootstrap.mode == bootstrap::Mode::ProxyBox {
if policy.signing_keys.is_empty() {
tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated");
}
if policy.box_id.is_none() {
tracing::warn!("no box id provisioned: signed config bundles cannot be accepted");
}
}
for outcome in config_import::import_from_pendrives(&bootstrap, &policy) {
config_import::log_outcome(&outcome);
}
let file = bootstrap.load_config_file()?;
let config = Config::from_file_and_env(file.as_ref())?;
info!(mode = ?bootstrap.mode, config_file = ?file.as_ref().map(|_| &bootstrap.config_path), "configuration loaded");
// BOUNDARY: network settings are parsed and validated, and stop here.
// Applying them (static IP, DNS, wifi) is the appliance image's job, not
// this process's - see the module docs of `config`. Logged so that a box
// given a network block does not look like it acted on it.
if let Some(network) = file.as_ref().and_then(|f| f.network.as_ref()) {
tracing::warn!(?network, "network settings are present but this build does NOT apply them");
}
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
// Loaded and connected here, once, at startup - nothing yet re-reads
// printers.toml on a running daemon, so adding or removing a printer
// still means a restart.
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
connect_fleet(&mut fleet).await;
// The fleet is now shared with the uplink rather than just parked here
// keeping connections alive: the uplink reads each printer's status to
// send telemetry up, and looks printers up by id to dispatch commands
// down. `Arc<Mutex<..>>` is how two tasks share one mutable thing -
// see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// Five long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, the
// print-completion watcher that blinks a printer's lights when it
// finishes, and printer discovery. Every printer's MQTT session is a
// sixth thing running concurrently, but implicitly - each one has its own
// background poll task spawned by `connect()`, kept alive by the fleet
// above. More will join these later (the plate changer).
//
// Discovery parks on a future that never completes unless it is switched
// on, which it is not by default - see `discovery::report::run`. So this
// arm is usually a task that costs nothing, and that is the intended
// steady state, not a disabled feature waiting to be finished.
//
// The completion watcher is deliberately its own task rather than part
// of the uplink: it must keep working with the cloud connection down,
// because the person it is for is standing next to the printer. See
// `completion::run`.
//
// The camera builder and the watchdog are two tasks and not one because
// they answer different questions - what should be served, and keeping
// the thing that serves it alive. They meet at one `Notify`: the builder
// rings it after writing a new config, because go2rtc only reads its
// configuration at startup.
//
// `tokio::select!` stops as soon as any returns; all four loop forever,
// 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 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()?;
info!(
user = %camera_auth.username,
config = %config.go2rtc_config,
"go2rtc's API requires basic auth; the password is in its config file"
);
// Created here rather than inside the uplink so discovery can hand it
// messages without the uplink knowing what discovery is. It outlives any
// one connection, so a scan that finishes while the cloud is unreachable
// waits for the next socket instead of being thrown away.
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(),
config.printers_path.clone(),
outbound.clone(),
) => {}
_ = go2rtc::watchdog(
config.go2rtc_bin.clone(),
config.go2rtc_config.clone(),
restart_go2rtc.clone(),
) => {}
_ = camera::run(
fleet.clone(),
config.go2rtc_config.clone(),
config.go2rtc_api_listen.clone(),
camera_auth,
restart_go2rtc.clone(),
) => {}
_ = completion::run(fleet.clone()) => {}
// Returns only after installing a new config from a stick inserted
// while running. Settings are read once at startup, so the process
// ends here and relies on its supervisor (systemd `Restart=always`
// on a box) to start it again on the new config. Not exercised end
// to end - there is no supervisor or real stick in the dev
// environment.
_ = config_import::watch(bootstrap.clone(), policy.clone()) => {
info!("exiting so the supervisor restarts the proxy on the newly installed config");
}
}
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;
/// serially, a fleet of twenty would take twenty times as long to start).
/// Logs success/failure per printer instead of failing startup over one -
/// a farm shouldn't refuse to start because one printer is unplugged.
async fn connect_fleet(fleet: &mut [PrinterHandle]) {
let results =
futures_util::future::join_all(fleet.iter_mut().map(|printer| async {
let name = printer.display_name();
(name, printer.connect().await)
}))
.await;
for (name, result) in results {
match result {
Ok(()) => tracing::info!(printer = %name, "connected"),
Err(err) => tracing::warn!(printer = %name, %err, "failed to connect"),
}
}
}