Skip to main content

max / makenotwork

9.2 KB · 221 lines History Blame Raw
1 use anyhow::Result;
2 use sando_daemon::{config, db, events, git, reconcile, routes, runs, state, sync, topology};
3 use std::net::SocketAddr;
4 use std::path::Path;
5 use std::sync::Arc;
6
7 /// CF2 fail-closed startup guard: refuse to start when the deploy mutators would
8 /// be exposed unauthenticated (no token) on a non-loopback bind. A loopback bind
9 /// without a token is allowed (local-only); any bind with a token is allowed.
10 fn refuse_unauthenticated_bind(addr: &SocketAddr, has_token: bool) -> bool {
11 !has_token && !addr.ip().is_loopback()
12 }
13
14 fn main() -> Result<()> {
15 // `--check-config`: prove THIS binary can load and parse its live daemon
16 // config + topology, then exit — no runtime, no DB, no network, no bind.
17 // This is the exact parse a stale self-update build once failed: a sandod
18 // missing the `node_health` Gate variant could not parse the node_health
19 // topology and crash-looped on every restart (postmortem 2026-07-09, #6).
20 // The self-update runs this against the live config *before* swapping the
21 // new binary in, so a binary that can't understand its config never gets
22 // installed. Pure by design: a green here means "this build understands its
23 // config", isolated from any environmental (DB/host) failure.
24 if std::env::args().skip(1).any(|a| a == "--check-config") {
25 return check_config();
26 }
27 run()
28 }
29
30 /// Load + validate the live config chain the daemon would boot against, without
31 /// starting anything. Mirrors the config/topology steps of `run` (below) so a
32 /// pass here guarantees `run` gets past them. Errors carry anyhow's context
33 /// chain and exit non-zero via `main`'s `Result`.
34 fn check_config() -> Result<()> {
35 let cfg = config::Config::load()?;
36 let topo = validate_loaded(&cfg)?;
37 println!(
38 "sandod --check-config: OK — {} tier(s), topology {}",
39 topo.tiers.len(),
40 cfg.topology_path.display()
41 );
42 Ok(())
43 }
44
45 /// The parse + invariant work `check_config` does once the daemon config is in
46 /// hand. Split out so it can be exercised against a fixture config without
47 /// touching process env (`Config::load` reads `SANDO_CONFIG`/CWD).
48 fn validate_loaded(cfg: &config::Config) -> Result<topology::Topology> {
49 cfg.validate()?;
50 let topo = topology::Topology::load(&cfg.topology_path)?;
51 topo.ensure_build_host_not_serving(&cfg.build_host)?;
52 Ok(topo)
53 }
54
55 #[tokio::main]
56 async fn run() -> Result<()> {
57 tracing_subscriber::fmt()
58 // stdout is block-buffered under systemd (no TTY) so events never
59 // reach journald until the buffer fills or the process exits. stderr
60 // is line-buffered, which is what we want for a long-running service.
61 .with_writer(std::io::stderr)
62 .with_env_filter(
63 tracing_subscriber::EnvFilter::try_from_default_env()
64 // Modules live under the library crate `sando_daemon` (since
65 // the step-5 lib/bin split). `sandod` is kept for any
66 // top-level events that originate in main.rs itself.
67 .unwrap_or_else(|_| "sando_daemon=info,sandod=info,tower_http=info".into()),
68 )
69 .init();
70
71 let cfg = Arc::new(config::Config::load()?);
72 let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
73 topo.ensure_build_host_not_serving(&cfg.build_host)?;
74 tokio::fs::create_dir_all(&cfg.workdir).await?;
75 tokio::fs::create_dir_all(&cfg.release_root).await?;
76 git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?;
77 let pool = db::connect(&cfg.db_path).await?;
78 db::migrate(&pool).await?;
79 // Reconcile any build_runs left 'building' by a previous daemon that died
80 // mid-build, so a restart can't leave a run wedged 'building' forever.
81 match runs::recover_orphaned_running(&pool).await {
82 Ok(0) => {}
83 Ok(n) => tracing::warn!(
84 reconciled = n,
85 "settled orphaned 'building' run(s) from a prior daemon"
86 ),
87 Err(e) => {
88 tracing::error!(error = %e, "failed to reconcile orphaned 'building' runs at startup");
89 }
90 }
91 sync::sync(&pool, &topo).await?;
92 tracing::info!(tiers = topo.tiers.len(), bare = %topo.repo.bare_path, "topology synced");
93
94 // Reconcile deploys against tier_state (after sync, so every tier_state row
95 // exists): catch a promote that landed on the nodes but died before advancing
96 // tier_state, so a stale-green tier on the wrong version is flagged partial
97 // instead of misleading /state and a later /rollback (audit
98 // sando-h5-crash-mid-promote).
99 match reconcile::recover_unrecorded_deploys(&pool).await {
100 Ok(0) => {}
101 Ok(n) => tracing::error!(
102 flagged = n,
103 "flagged tier(s) with an unrecorded deploy from a prior daemon; see /state partial_reason"
104 ),
105 Err(e) => {
106 tracing::error!(error = %e, "failed to reconcile deploys against tier_state at startup");
107 }
108 }
109
110 // Fail closed on the scratch cluster's privileges before any gate can hit
111 // them, rather than letting the first migration_dry_run discover it as an
112 // opaque "permission denied". Unset scratch_db_url is already a per-gate
113 // Blocked outcome, so there is nothing to assert.
114 if let Some(scratch_url) = cfg.scratch_db_url.as_deref() {
115 sando_daemon::gates::preflight_scratch_privileges(scratch_url).await?;
116 }
117
118 let addr: SocketAddr = cfg.listen.parse()?;
119
120 // Deploy-API auth (CF2). Token comes from SANDO_API_TOKEN (systemd
121 // EnvironmentFile). Refuse to expose the mutators unauthenticated on a
122 // non-loopback bind — that exact posture (tailnet bind, no auth) was the
123 // finding.
124 let api_token: Option<Arc<str>> = std::env::var("SANDO_API_TOKEN")
125 .ok()
126 .map(|s| s.trim().to_string())
127 .filter(|s| !s.is_empty())
128 .map(|s| Arc::from(s.as_str()));
129 if refuse_unauthenticated_bind(&addr, api_token.is_some()) {
130 anyhow::bail!(
131 "SANDO_API_TOKEN is unset but listen={addr} is not loopback; refusing to expose deploy \
132 controls unauthenticated. Set SANDO_API_TOKEN (via EnvironmentFile) or bind 127.0.0.1."
133 );
134 }
135 match &api_token {
136 Some(_) => tracing::info!("deploy endpoints require a bearer token"),
137 None => {
138 tracing::warn!(%addr, "SANDO_API_TOKEN unset; deploy endpoints are UNAUTHENTICATED (loopback bind)");
139 }
140 }
141
142 let executors = Arc::new(state::build_executors(&topo));
143 let app_state = state::AppState {
144 pool,
145 topo,
146 cfg,
147 active_build: Arc::new(tokio::sync::Mutex::new(None)),
148 deploy_lock: Arc::new(tokio::sync::Mutex::new(())),
149 events: events::channel(),
150 executors,
151 api_token,
152 };
153 let app = routes::router(app_state);
154 tracing::info!(%addr, "sando daemon listening");
155 let listener = tokio::net::TcpListener::bind(addr).await?;
156 // `into_make_service_with_connect_info` surfaces the peer `SocketAddr` in
157 // request extensions so the bearer middleware can log who issued a mutation.
158 axum::serve(
159 listener,
160 app.into_make_service_with_connect_info::<SocketAddr>(),
161 )
162 .await?;
163 Ok(())
164 }
165
166 #[cfg(test)]
167 mod tests {
168 use super::{refuse_unauthenticated_bind, validate_loaded};
169 use std::net::SocketAddr;
170
171 fn addr(s: &str) -> SocketAddr {
172 s.parse().unwrap()
173 }
174
175 #[test]
176 fn check_config_accepts_the_shipped_topology() {
177 // The `--check-config` path a self-update runs before installing a new
178 // binary must pass against the topology this build ships with — the
179 // regression guard for the 2026-07-09 brick, where a binary that could
180 // not parse the live node_health config was installed and crash-looped.
181 // (`Config::for_tests` is lib-test-only and unavailable here, so build
182 // the config by deserializing, pointing it at the shipped sando.toml.)
183 let topo_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sando.toml");
184 let raw = format!(
185 "listen = \"127.0.0.1:7766\"\n\
186 db_path = \"./sando.db\"\n\
187 topology_path = {topo_path:?}\n\
188 build_host = \"fw13\"\n\
189 workdir = \"./work\"\n\
190 release_root = \"./releases\"\n"
191 );
192 let cfg: super::config::Config = toml::from_str(&raw).expect("build fixture daemon config");
193 validate_loaded(&cfg).expect("shipped config must pass --check-config");
194 }
195
196 #[test]
197 fn non_loopback_without_token_is_refused() {
198 // The exact CF2 posture: tailnet bind, no token.
199 assert!(refuse_unauthenticated_bind(
200 &addr("100.103.89.95:7766"),
201 false
202 ));
203 assert!(refuse_unauthenticated_bind(&addr("0.0.0.0:7766"), false));
204 }
205
206 #[test]
207 fn loopback_without_token_is_allowed() {
208 assert!(!refuse_unauthenticated_bind(&addr("127.0.0.1:7766"), false));
209 assert!(!refuse_unauthenticated_bind(&addr("[::1]:7766"), false));
210 }
211
212 #[test]
213 fn any_bind_with_token_is_allowed() {
214 assert!(!refuse_unauthenticated_bind(
215 &addr("100.103.89.95:7766"),
216 true
217 ));
218 assert!(!refuse_unauthenticated_bind(&addr("127.0.0.1:7766"), true));
219 }
220 }
221