Skip to main content

max / makenotwork

sando: refuse to build on a non-build host (ultra-fuzz structural, never build on prod) build::run now reads the live kernel hostname (/proc/sys/kernel/hostname) and refuses to compile unless it matches a required build_host config field. The build runs locally, so a sandod misdeployed onto a prod node would otherwise build there; the guard makes 'never build on prod' an invariant rather than a code-path accident (the Sando analogue of Bento's topology::host_for). Startup also rejects a build_host that names a serving-tier node, as defense-in-depth. build_host is required (no safe default); added to the daemon config + example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 01:21 UTC
Commit: c58cbb74f174b4b6c6e54698403f2fb333c94698
Parent: b058fc7
7 files changed, +122 insertions, -1 deletion
@@ -2,6 +2,10 @@
2 2 listen = "127.0.0.1:7766"
3 3 db_path = "./sando.db"
4 4 topology_path = "../sando.toml"
5 + # Hostname (/proc/sys/kernel/hostname) this daemon may build on. build::run
6 + # refuses to compile on any other host, so a misdeployed daemon can't build on
7 + # prod. Required.
8 + build_host = "fw13"
5 9 workdir = "./work"
6 10 release_root = "./releases"
7 11 # Shared cargo target dir across per-sha worktrees — incremental rebuilds reuse
@@ -28,6 +28,32 @@ pub struct BuildArtifact {
28 28 pub binary_paths: Vec<PathBuf>,
29 29 }
30 30
31 + /// The live kernel hostname (`/proc/sys/kernel/hostname`, trimmed). Linux-only,
32 + /// which Sando is. The build-host guard reads this rather than `$HOSTNAME`
33 + /// (not reliably exported) so the check reflects the actual machine.
34 + fn runtime_hostname() -> Result<String> {
35 + let raw = std::fs::read_to_string("/proc/sys/kernel/hostname")
36 + .context("reading /proc/sys/kernel/hostname for the build-host guard")?;
37 + Ok(raw.trim().to_string())
38 + }
39 +
40 + /// Pure half of the build-host guard: fail unless the live host matches the
41 + /// configured build host. Split out from [`enforce_build_host`] so it is unit-
42 + /// testable without depending on the test machine's hostname.
43 + fn check_build_host(actual: &str, expected: &str) -> Result<()> {
44 + anyhow::ensure!(
45 + actual == expected,
46 + "refusing to build on host {actual}: configured build host is {expected} \
47 + (never build on a prod/serving node)",
48 + );
49 + Ok(())
50 + }
51 +
52 + /// Refuse to build unless this daemon is running on the configured build host.
53 + fn enforce_build_host(expected: &str) -> Result<()> {
54 + check_build_host(&runtime_hostname()?, expected)
55 + }
56 +
31 57 pub async fn run(
32 58 pool: SqlitePool,
33 59 cfg: Arc<Config>,
@@ -36,6 +62,13 @@ pub async fn run(
36 62 events: crate::events::EventTx,
37 63 run_id: RunId,
38 64 ) -> Result<BuildArtifact> {
65 + // Build-host guard: refuse to compile anywhere but the configured build
66 + // host. The build runs locally (`cargo build` in cfg.workdir), so a sandod
67 + // misdeployed onto a prod/serving node would otherwise build there — exactly
68 + // the "never build on prod" rule. Enforced before any cargo invocation so a
69 + // wrong-host daemon fails fast with a clear message rather than compiling.
70 + enforce_build_host(&cfg.build_host)?;
71 +
39 72 let worktree = cfg.workdir.join(sha.as_str());
40 73 let bare = PathBuf::from(&topo.repo.bare_path);
41 74
@@ -313,7 +346,27 @@ async fn stage_entry(
313 346
314 347 #[cfg(test)]
315 348 mod tests {
316 - use super::tail;
349 + use super::{check_build_host, runtime_hostname, tail};
350 +
351 + #[test]
352 + fn check_build_host_accepts_matching_host() {
353 + assert!(check_build_host("fw13", "fw13").is_ok());
354 + }
355 +
356 + #[test]
357 + fn check_build_host_refuses_mismatched_host() {
358 + // A daemon misdeployed onto prod (e.g. a Hetzner host) must refuse.
359 + let err = check_build_host("alpha-west-1", "fw13").unwrap_err().to_string();
360 + assert!(err.contains("refusing to build"), "{err}");
361 + assert!(err.contains("alpha-west-1") && err.contains("fw13"), "{err}");
362 + }
363 +
364 + #[test]
365 + fn runtime_hostname_reads_a_nonempty_trimmed_name() {
366 + let h = runtime_hostname().expect("hostname readable on Linux");
367 + assert!(!h.is_empty());
368 + assert_eq!(h, h.trim(), "must be trimmed");
369 + }
317 370
318 371 #[test]
319 372 fn tail_does_not_panic_on_multibyte_boundary() {
@@ -7,6 +7,12 @@ pub struct Config {
7 7 pub listen: String,
8 8 pub db_path: PathBuf,
9 9 pub topology_path: PathBuf,
10 + /// The runtime hostname (`/proc/sys/kernel/hostname`) this daemon is
11 + /// permitted to build on. `build::run` refuses to compile unless the live
12 + /// host matches, so a `sandod` misdeployed onto a prod/serving node (e.g.
13 + /// Hetzner) cannot build there — "never build on prod" becomes an invariant
14 + /// rather than a code-path accident. Required: there is no safe default.
15 + pub build_host: String,
10 16 /// MM-local checkout scratch dir (per-sha worktrees live here).
11 17 pub workdir: PathBuf,
12 18 /// MM-local releases dir (`releases/<version>/` and `current` live here).
@@ -83,6 +89,7 @@ impl Config {
83 89 listen: "127.0.0.1:0".into(),
84 90 db_path: PathBuf::from(":memory:"),
85 91 topology_path: PathBuf::from("/tmp/sando-test-topology.toml"),
92 + build_host: "test-host".into(),
86 93 workdir: PathBuf::from("/tmp/sando-test-workdir"),
87 94 release_root: PathBuf::from("/tmp/sando-test-release-root"),
88 95 scratch_db_url: None,
@@ -102,6 +109,7 @@ mod tests {
102 109 listen = "127.0.0.1:7766"
103 110 db_path = "./sando.db"
104 111 topology_path = "../sando.toml"
112 + build_host = "fw13"
105 113 workdir = "./work"
106 114 release_root = "./releases"
107 115 "#;
@@ -118,4 +126,12 @@ mod tests {
118 126 let cfg: Config = toml::from_str(MINIMAL).unwrap();
119 127 assert!(cfg.cargo_target_dir.is_none(), "omitting it keeps the per-worktree target/");
120 128 }
129 +
130 + #[test]
131 + fn build_host_is_required() {
132 + // No safe default: a config without build_host must not parse, so the
133 + // no-build-on-prod guard can never be silently skipped.
134 + let without = MINIMAL.replace("build_host = \"fw13\"\n", "");
135 + assert!(toml::from_str::<Config>(&without).is_err());
136 + }
121 137 }
@@ -22,6 +22,7 @@ async fn main() -> Result<()> {
22 22
23 23 let cfg = Arc::new(config::Config::load()?);
24 24 let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
25 + topo.ensure_build_host_not_serving(&cfg.build_host)?;
25 26 tokio::fs::create_dir_all(&cfg.workdir).await?;
26 27 tokio::fs::create_dir_all(&cfg.release_root).await?;
27 28 git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?;
@@ -1155,6 +1155,7 @@ mod tests {
1155 1155 listen: "127.0.0.1:0".into(),
1156 1156 db_path: PathBuf::from(":memory:"),
1157 1157 topology_path: PathBuf::from("/tmp/test-sando.toml"),
1158 + build_host: "test-host".into(),
1158 1159 workdir: PathBuf::from("/tmp/sando-work"),
1159 1160 release_root: PathBuf::from("/tmp/sando-releases"),
1160 1161 scratch_db_url: None,
@@ -139,6 +139,32 @@ impl Topology {
139 139 Ok(topo)
140 140 }
141 141
142 + /// Defense-in-depth for the build-host guard: a host that serves a
143 + /// provisioned tier must never be designated the builder. The runtime
144 + /// hostname check in `build::run` is the real guard; this catches a
145 + /// misconfiguration (build_host pointed at a prod node's name/ssh_target) at
146 + /// startup, before the daemon ever accepts a build. Called from `main` once
147 + /// config + topology are both loaded.
148 + pub fn ensure_build_host_not_serving(&self, build_host: &str) -> Result<()> {
149 + for t in &self.tiers {
150 + if !t.provisioned || t.name.as_str() == "host" {
151 + continue;
152 + }
153 + for n in &t.nodes {
154 + if n.name.as_str() == build_host || n.ssh_target == build_host {
155 + anyhow::bail!(
156 + "build_host {build_host:?} is also node {} (ssh {}) in serving tier {} — \
157 + the builder must not be a prod/serving node",
158 + n.name,
159 + n.ssh_target,
160 + t.name
161 + );
162 + }
163 + }
164 + }
165 + Ok(())
166 + }
167 +
142 168 #[cfg(test)]
143 169 pub(crate) fn validate_for_test(&self) -> Result<()> {
144 170 self.validate()
@@ -231,6 +257,21 @@ release_root = "/srv/mnw"
231 257 }
232 258
233 259 #[test]
260 + fn build_host_matching_a_serving_node_is_rejected() {
261 + // prod-1 is a node in the provisioned serving tier built above; naming it
262 + // as the builder must fail closed.
263 + let topo = topo_with_serving_gates(true, r#"{ kind = "boot_smoke" }"#);
264 + let err = topo.ensure_build_host_not_serving("prod-1").unwrap_err().to_string();
265 + assert!(err.contains("must not be a prod/serving node"), "{err}");
266 + }
267 +
268 + #[test]
269 + fn build_host_distinct_from_serving_nodes_is_accepted() {
270 + let topo = topo_with_serving_gates(true, r#"{ kind = "boot_smoke" }"#);
271 + assert!(topo.ensure_build_host_not_serving("fw13").is_ok());
272 + }
273 +
274 + #[test]
234 275 fn real_sando_toml_loads_clean() {
235 276 // The shipped topology must satisfy the invariant — guards against a
236 277 // regression that would lock sandod out of its own config.
@@ -11,6 +11,11 @@
11 11 listen = "100.103.89.95:7766" # fw13 tailnet IP; bind tailnet-only, not 0.0.0.0 — requires SANDO_API_TOKEN
12 12 db_path = "/srv/sando/state/sando.db"
13 13 topology_path = "/etc/sando/sando.toml"
14 + # Hostname (/proc/sys/kernel/hostname) this daemon is permitted to build on.
15 + # build::run refuses to compile on any other host, so a sandod misdeployed onto
16 + # a prod node cannot build there ("never build on prod" as an invariant).
17 + # Required — there is no safe default.
18 + build_host = "fw13"
14 19 workdir = "/srv/sando/work"
15 20 release_root = "/srv/sando"
16 21 scratch_db_url = "postgres:///sando_scratch?host=/var/run/postgresql"