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.
208 lines
9.7 KiB
Rust
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"),
|
|
}
|
|
}
|
|
}
|