max / makenotwork
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" |