use anyhow::Result; use sando_daemon::{config, db, events, git, reconcile, routes, runs, state, sync, topology}; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; /// CF2 fail-closed startup guard: refuse to start when the deploy mutators would /// be exposed unauthenticated (no token) on a non-loopback bind. A loopback bind /// without a token is allowed (local-only); any bind with a token is allowed. fn refuse_unauthenticated_bind(addr: &SocketAddr, has_token: bool) -> bool { !has_token && !addr.ip().is_loopback() } fn main() -> Result<()> { // `--check-config`: prove THIS binary can load and parse its live daemon // config + topology, then exit — no runtime, no DB, no network, no bind. // This is the exact parse a stale self-update build once failed: a sandod // missing the `node_health` Gate variant could not parse the node_health // topology and crash-looped on every restart (postmortem 2026-07-09, #6). // The self-update runs this against the live config *before* swapping the // new binary in, so a binary that can't understand its config never gets // installed. Pure by design: a green here means "this build understands its // config", isolated from any environmental (DB/host) failure. if std::env::args().skip(1).any(|a| a == "--check-config") { return check_config(); } run() } /// Load + validate the live config chain the daemon would boot against, without /// starting anything. Mirrors the config/topology steps of `run` (below) so a /// pass here guarantees `run` gets past them. Errors carry anyhow's context /// chain and exit non-zero via `main`'s `Result`. fn check_config() -> Result<()> { let (_daemon, apps) = config::DaemonConfig::load()?; // Every product, not just the first. A daemon that can parse one config and // not another still crash-loops, and the whole point of this check is that a // binary which cannot understand what it is about to run never gets // installed. for (id, cfg) in &apps { let topo = validate_loaded(cfg)?; println!( "sandod --check-config: OK — app `{id}`, {} tier(s), topology {}", topo.tiers.len(), cfg.topology_path.display() ); } Ok(()) } /// The parse + invariant work `check_config` does once the daemon config is in /// hand. Split out so it can be exercised against a fixture config without /// touching process env (`AppConfig::load` reads `SANDO_CONFIG`/CWD). fn validate_loaded(cfg: &config::AppConfig) -> Result { cfg.validate()?; let topo = topology::Topology::load(&cfg.topology_path)?; if let Some(h) = cfg.build_host.as_deref() { topo.ensure_build_host_not_serving(h)?; } topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?; topo.ensure_node_companions_are_built(&cfg.companions)?; topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?; Ok(topo) } #[tokio::main] async fn run() -> Result<()> { tracing_subscriber::fmt() // stdout is block-buffered under systemd (no TTY) so events never // reach journald until the buffer fills or the process exits. stderr // is line-buffered, which is what we want for a long-running service. .with_writer(std::io::stderr) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() // Modules live under the library crate `sando_daemon` (since // the step-5 lib/bin split). `sandod` is kept for any // top-level events that originate in main.rs itself. .unwrap_or_else(|_| "sando_daemon=info,sandod=info,tower_http=info".into()), ) .init(); let (daemon, app_cfgs) = config::DaemonConfig::load()?; // Bring every product up before binding: its topology parsed, its // cross-file invariants checked, its directories made, its bare repo // present. A product that cannot start is a startup failure, not a surprise // at its first /rebuild. let mut apps: state::AppMap = std::collections::BTreeMap::new(); for (id, cfg) in app_cfgs { let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?); if let Some(h) = cfg.build_host.as_deref() { topo.ensure_build_host_not_serving(h)?; } topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?; topo.ensure_node_companions_are_built(&cfg.companions)?; topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?; tokio::fs::create_dir_all(&cfg.workdir).await?; tokio::fs::create_dir_all(&cfg.release_root).await?; // An intake-only product has no bare repo on this host to create. if let Some(repo) = topo.repo.as_ref() { git::ensure_bare_repo(Path::new(&repo.bare_path)).await?; } let executors = Arc::new(state::build_executors(&topo)); tracing::info!( app = %id, tiers = topo.tiers.len(), bare = topo.repo.as_ref().map_or("(intake-only)", |r| r.bare_path.as_str()), "app loaded" ); apps.insert( id, Arc::new(state::App { cfg, topo, executors, }), ); } let apps = Arc::new(apps); // The product a request that names none is about. First in declared order, // which for a legacy config is the only one there is. let default_app = apps .keys() .next() .cloned() .expect("DaemonConfig::load refuses an empty app set"); let default = apps[&default_app].clone(); let (cfg, topo) = (default.cfg.clone(), default.topo.clone()); let pool = db::connect(&daemon.db_path).await?; db::migrate(&pool).await?; // Reconcile any build_runs left 'building' by a previous daemon that died // mid-build, so a restart can't leave a run wedged 'building' forever. match runs::recover_orphaned_running(&pool).await { Ok(0) => {} Ok(n) => tracing::warn!( reconciled = n, "settled orphaned 'building' run(s) from a prior daemon" ), Err(e) => { tracing::error!(error = %e, "failed to reconcile orphaned 'building' runs at startup"); } } for (id, app) in apps.iter() { sync::sync(&pool, id, &app.topo).await?; tracing::debug!(app = %id, "topology synced"); } // Reconcile deploys against tier_state (after sync, so every tier_state row // exists): catch a promote that landed on the nodes but died before advancing // tier_state, so a stale-green tier on the wrong version is flagged partial // instead of misleading /state and a later /rollback (audit // sando-h5-crash-mid-promote). match reconcile::recover_unrecorded_deploys(&pool).await { Ok(0) => {} Ok(n) => tracing::error!( flagged = n, "flagged tier(s) with an unrecorded deploy from a prior daemon; see /state partial_reason" ), Err(e) => { tracing::error!(error = %e, "failed to reconcile deploys against tier_state at startup"); } } // Fail closed on the scratch cluster's privileges before any gate can hit // them, rather than letting the first migration_dry_run discover it as an // opaque "permission denied". Unset scratch_db_url is already a per-gate // Blocked outcome, so there is nothing to assert. if let Some(scratch_url) = cfg.scratch_db_url.as_deref() { sando_daemon::gates::preflight_scratch_privileges(scratch_url).await?; } let addr: SocketAddr = daemon.listen.parse()?; // Deploy-API auth (CF2). Token comes from SANDO_API_TOKEN (systemd // EnvironmentFile). Refuse to expose the mutators unauthenticated on a // non-loopback bind — that exact posture (tailnet bind, no auth) was the // finding. let api_token: Option> = std::env::var("SANDO_API_TOKEN") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(|s| Arc::from(s.as_str())); if refuse_unauthenticated_bind(&addr, api_token.is_some()) { anyhow::bail!( "SANDO_API_TOKEN is unset but listen={addr} is not loopback; refusing to expose deploy \ controls unauthenticated. Set SANDO_API_TOKEN (via EnvironmentFile) or bind 127.0.0.1." ); } match &api_token { Some(_) => tracing::info!("deploy endpoints require a bearer token"), None => { tracing::warn!(%addr, "SANDO_API_TOKEN unset; deploy endpoints are UNAUTHENTICATED (loopback bind)"); } } let executors = default.executors.clone(); let app_state = state::AppState { pool, apps, default_app, topo, cfg, active_build: Arc::new(tokio::sync::Mutex::new(None)), deploy_lock: Arc::new(tokio::sync::Mutex::new(())), events: events::channel(), executors, api_token, }; let app = routes::router_for_apps(app_state); tracing::info!(%addr, "sando daemon listening"); let listener = tokio::net::TcpListener::bind(addr).await?; // `into_make_service_with_connect_info` surfaces the peer `SocketAddr` in // request extensions so the bearer middleware can log who issued a mutation. axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await?; Ok(()) } #[cfg(test)] mod tests { use super::{refuse_unauthenticated_bind, validate_loaded}; use std::net::SocketAddr; fn addr(s: &str) -> SocketAddr { s.parse().unwrap() } #[test] fn check_config_accepts_the_shipped_topology() { // The `--check-config` path a self-update runs before installing a new // binary must pass against the topology this build ships with — the // regression guard for the 2026-07-09 brick, where a binary that could // not parse the live node_health config was installed and crash-looped. // Both halves of the pair are the shipped ones. This used to hand-roll a // minimal daemon config, which meant the production daemon config was // never validated by anything and the cross-file checks had nothing real // to check: the fixture declared no companions while the shipped // topology installs two, so `ensure_node_companions_are_built` would // have passed on a fixture and failed on the box. // // Only `topology_path` is overridden, because the shipped value is the // installed location (/etc/sando/sando.toml) and the repo copy is what // this build ships. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let raw = std::fs::read_to_string(root.join("../deploy/sando-daemon.toml.example")) .expect("reading the shipped production daemon config"); let mut cfg: super::config::AppConfig = toml::from_str(&raw).expect("shipped daemon config must parse"); cfg.topology_path = root.join("../sando.toml"); validate_loaded(&cfg).expect("shipped config must pass --check-config"); } #[test] fn non_loopback_without_token_is_refused() { // The exact CF2 posture: tailnet bind, no token. assert!(refuse_unauthenticated_bind( &addr("100.103.89.95:7766"), false )); assert!(refuse_unauthenticated_bind(&addr("0.0.0.0:7766"), false)); } #[test] fn loopback_without_token_is_allowed() { assert!(!refuse_unauthenticated_bind(&addr("127.0.0.1:7766"), false)); assert!(!refuse_unauthenticated_bind(&addr("[::1]:7766"), false)); } #[test] fn any_bind_with_token_is_allowed() { assert!(!refuse_unauthenticated_bind( &addr("100.103.89.95:7766"), true )); assert!(!refuse_unauthenticated_bind(&addr("127.0.0.1:7766"), true)); } }