//! Daemon-local config (`bento-daemon.toml`) — paths and listen address that //! belong to the machine bentod runs on, not to the build matrix (which lives //! in the separate topology file, see [`crate::topology`]). use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; #[derive(Debug, Clone, Deserialize)] pub struct Config { pub listen: String, pub db_path: PathBuf, pub topology_path: PathBuf, /// Root of the Syncthing private layer (`~/Code/_private`); the `secret()` /// host function reads credential files relative to here. Never logged. pub secrets_root: PathBuf, /// Where collected artifacts land on the daemon's own box /// (`////`). pub dist_root: PathBuf, /// Where every build's finished artifacts are deposited, whichever host /// produced them (see [`Archive`]). Unset = artifacts stay on whichever box /// bentod happens to run on, which is what "where is the AppImage for /// goingson 1.4.0" had no single answer to. #[serde(default)] pub archive: Option, /// Which apps hand their finished artifacts to a Sando, keyed by the app id /// this daemon knows it by (see [`Handoff`]). An app with no entry builds /// and archives exactly as before; there is no fleet-wide default, because /// handing an artifact to a deploy controller is a per-product decision. #[serde(default)] pub handoff: HashMap, /// Root for per-step run logs /// (`////..log`). #[serde(default = "default_logs_root")] pub logs_root: PathBuf, /// Override the per-step wall-clock budget (in seconds) for EVERY step, /// replacing the per-kind defaults in `engine::step_budget`. Unset (the /// default) uses those. Mainly an escape hatch for a constrained host or a /// test that needs a short deadline. #[serde(default)] pub step_timeout_secs: Option, /// Seconds to wait between `notarize` retry attempts. Unset (the default) /// uses the 15-second production backoff; a test drives the retry loop with /// `Some(0)` so it doesn't actually sleep. Same test-seam shape as /// `step_timeout_secs`. #[serde(default)] pub notarize_backoff_secs: Option, /// Pin every build host to the release tag `v` and verify they all /// report the same commit BEFORE any target builds — so `mbp`/`astra`/`fw13` /// can't each build whatever `main` happened to be at pull time. On by /// default; turn off only to build from an untagged commit (or in tests /// whose repos aren't git checkouts). #[serde(default = "default_true")] pub pin_release_sha: bool, /// The command that installs a service binary and restarts its unit, run on /// the service host with ` ` appended. /// /// Bento never installs or restarts anything itself. It stages bytes and /// calls this, and what it calls is a root script whose arguments are /// re-checked on the far side — so the sudoers grant on a production box is /// ONE auditable script rather than a broad `install` + `systemctl` grant. /// Same shape as Sando's `install-companion.sh`. /// /// Configurable because the sudo path differs per host fleet, and because a /// test needs to point it somewhere that is not root. It is read from the /// same trusted daemon config that already names the secrets root, so this /// adds no trust boundary that was not already there. #[serde(default = "default_deploy_installer")] pub deploy_installer: String, } /// The one place a finished artifact ends up, named once for the whole fleet. /// /// `pull_root` is the *collection* root the sync gate fences on each build host; /// it says what may be pulled off that box, not where the result belongs. So /// before this, a release's bytes ended up wherever bentod was running, and the /// answer to "where is the AppImage for goingson 1.4.0" depended on which host /// built it. /// /// Deposited by the daemon after each target's `collect`, so it holds every /// target of every app — including the macOS and Windows ones bentod does not /// run on — under one versioned path. #[derive(Debug, Clone, Deserialize)] pub struct Archive { /// SSH destination of the archive host (a tailnet alias like `astra`, or /// `user@host`); `local` deposits on the daemon's own filesystem. /// /// astra in production: always on, on the tailnet, and already the aarch64 /// build host and git mirror, so it is the one box every other build host /// can reliably reach. pub host: String, /// Absolute root ON THE ARCHIVE HOST, e.g. `/var/lib/bento/artifacts`. /// Never tilde-expanded — it names a location on the far side, not here. pub root: PathBuf, } /// Where one app's finished artifacts are handed to Sando, and how to tell it /// they arrived. /// /// The Sando/Bento boundary is "Bento builds and packages, Sando decides whether /// a thing advances a stage", and deciding requires being handed bytes. Sando's /// `POST /intake` takes a bundle that is *already* under its staging directory — /// it owns proving the bytes, and the producer owns getting them there. This is /// the producer half. /// /// Push and not pull, for a reason worth recording: Sando would have to be told /// a version exists either way (the same POST), and it would then be reaching /// into the build daemon's tree as a different user to fetch what it was just /// told about. Pull is push with an extra hop and a second credential. /// /// The transfer excludes the artifact record. Evidence names the digest of the /// bytes it vouches for, and the digest covers every file in the bundle, so a /// record copied in among the artifacts would change the digest it names and /// Sando would refuse the bundle — correctly. The record travels in the request /// body instead. #[derive(Debug, Clone, Deserialize)] pub struct Handoff { /// SSH destination of the host sandod runs on (a tailnet alias like `fw13`, /// or `sando@fw13`); `local` stages on this daemon's own filesystem. /// /// Usually the same machine bentod is on, and still worth going through ssh: /// sandod runs as `sando` and bentod as a user unit, so `sando@fw13` lands /// the bytes owned by the process that has to rename them, without a group /// or an ACL on the staging directory. pub host: String, /// Absolute path of Sando's staging directory ON THAT HOST — its /// `release_root` plus `staging`. Sando refuses a bundle staged anywhere /// else, because publishing is an atomic rename and a staging dir on another /// filesystem would silently become a copy. pub staging_root: PathBuf, /// Base URL of the sandod that will take the intake, e.g. /// `http://100.103.89.95:7766`. Tailnet address, never a public one. pub url: String, /// The app id SANDO knows this product by, when it differs from Bento's. /// Absent means the daemon's default product, which is the unprefixed mount; /// present routes to `/apps//intake`. #[serde(default)] pub sando_app: Option, /// File holding sandod's bearer token, as a relative path under /// `secrets_root` — the same private layer the `secret()` host function /// reads, so the token is not a second secrets mechanism. Absent sends no /// `Authorization` header, which only works against a loopback sandod that /// configured none. #[serde(default)] pub token_file: Option, } fn default_true() -> bool { true } fn default_deploy_installer() -> String { "sudo /usr/local/lib/bento/install-service.sh".into() } fn default_logs_root() -> PathBuf { PathBuf::from("/srv/bento/logs") } impl Config { pub fn load() -> Result { let path = std::env::var("BENTO_CONFIG").unwrap_or_else(|_| "bento-daemon.toml".into()); let raw = std::fs::read_to_string(&path) .with_context(|| format!("reading daemon config at {path}"))?; let cfg: Self = toml::from_str(&raw)?; cfg.validate() .with_context(|| format!("daemon config at {path}"))?; Ok(cfg) } /// Check what would otherwise only fail mid-release. The archive root is /// interpolated into an rsync destination on another machine, so a relative /// path or a `..` would deposit a signed release somewhere other than where /// the config appears to say — the same rule, and the same reason, as /// `topology::validate_deploy`'s check on `install_path`. fn validate(&self) -> Result<()> { if let Some(a) = &self.archive { anyhow::ensure!( !a.host.trim().is_empty(), "[archive] host is empty; set it to an ssh destination or `local`" ); anyhow::ensure!( a.root.is_absolute() && !a .root .components() .any(|c| c == std::path::Component::ParentDir), "[archive] root `{}` must be an absolute path with no `..`", a.root.display() ); } for (app, h) in &self.handoff { anyhow::ensure!( !h.host.trim().is_empty(), "[handoff.{app}] host is empty; set it to an ssh destination or `local`" ); // Same rule and the same reason as the archive root, with more at // stake: this destination is rsynced with `--delete`, so a relative // path or a `..` prunes a directory other than the one the config // reads as naming. anyhow::ensure!( h.staging_root.is_absolute() && !h .staging_root .components() .any(|c| c == std::path::Component::ParentDir), "[handoff.{app}] staging_root `{}` must be an absolute path with no `..`", h.staging_root.display() ); anyhow::ensure!( h.url.starts_with("http://") || h.url.starts_with("https://"), "[handoff.{app}] url `{}` must be an http(s) URL for sandod", h.url ); // The token is read relative to `secrets_root`, so the same // traversal guard the `secret()` host function applies belongs here: // a config that could name `../../etc/shadow` would turn a path into // a read primitive. if let Some(t) = &h.token_file { anyhow::ensure!( t.is_relative() && !t.components().any(|c| c == std::path::Component::ParentDir), "[handoff.{app}] token_file `{}` must be a relative path under secrets_root", t.display() ); } } Ok(()) } #[cfg(test)] pub fn for_tests(root: &std::path::Path) -> Self { Self { listen: "127.0.0.1:0".into(), db_path: root.join("bento.db"), topology_path: root.join("bento.toml"), secrets_root: root.join("secrets"), dist_root: root.join("dist"), // Off by default in tests: the archive is a second machine, and the // tests that exercise it point it at a local directory themselves. archive: None, // Off for the same reason as the archive: a handoff is another // daemon, and the tests that exercise it stand one up themselves. handoff: HashMap::new(), logs_root: root.join("logs"), step_timeout_secs: None, notarize_backoff_secs: None, // Test repos are plain dirs, not git checkouts; the barrier is // exercised by its own tests that build a real tagged repo. pin_release_sha: false, deploy_installer: default_deploy_installer(), } } } #[cfg(test)] mod tests { use super::*; /// Parse a whole daemon config with `body` appended, through the same /// validation `load()` runs. fn parse(body: &str) -> Result { let base = r#" listen = "127.0.0.1:8765" db_path = "/var/lib/bento/bento.db" topology_path = "/etc/bento/bento.toml" secrets_root = "/home/max/Code/_private" dist_root = "/home/max/Dist" "#; let cfg: Config = toml::from_str(&format!("{base}{body}"))?; cfg.validate()?; Ok(cfg) } /// The table is optional: every config written before the archive existed /// must keep loading, and an operator who has not named an archive host /// still gets releases. #[test] fn the_archive_table_is_optional() { assert!(parse("").unwrap().archive.is_none()); } #[test] fn an_archive_host_and_root_parse() { let cfg = parse("[archive]\nhost = \"astra\"\nroot = \"/var/lib/bento/artifacts\"\n").unwrap(); let a = cfg.archive.expect("archive configured"); assert_eq!(a.host, "astra"); assert_eq!(a.root, PathBuf::from("/var/lib/bento/artifacts")); } /// The root reaches an rsync destination on another machine. A relative path /// lands in the ssh user's home and a `..` walks out of the declared tree — /// both put a signed release somewhere other than where the config reads as /// saying, which is worth failing at startup rather than mid-release. #[test] fn a_relative_or_dot_dot_archive_root_is_rejected() { assert!(parse("[archive]\nhost = \"astra\"\nroot = \"artifacts\"\n").is_err()); assert!(parse("[archive]\nhost = \"astra\"\nroot = \"/var/../etc/bento\"\n").is_err()); } #[test] fn an_empty_archive_host_is_rejected() { assert!(parse("[archive]\nhost = \"\"\nroot = \"/var/lib/bento/artifacts\"\n").is_err()); } /// No handoff table at all is the ordinary case — every app but the ones /// Sando deploys builds and archives and stops there. #[test] fn handoff_is_per_app_and_absent_by_default() { assert!(parse("").unwrap().handoff.is_empty()); } #[test] fn a_handoff_parses_with_its_optional_fields_absent() { let cfg = parse( r#" [handoff.pom] host = "sando@fw13" staging_root = "/srv/sando/staging" url = "http://100.103.89.95:7766" "#, ) .unwrap(); let h = cfg.handoff.get("pom").expect("pom hands off"); assert_eq!(h.host, "sando@fw13"); assert_eq!(h.staging_root, PathBuf::from("/srv/sando/staging")); // Absent means Sando's default product and no bearer header, which is // the shape a single-product sandod on loopback wants. assert!(h.sando_app.is_none()); assert!(h.token_file.is_none()); } /// The destination is rsynced with `--delete`. A relative path lands in the /// ssh user's home and a `..` walks out of the declared tree, and either one /// would prune a directory the config does not appear to name. #[test] fn a_relative_or_dot_dot_staging_root_is_rejected() { let with = |root: &str| { format!( "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"{root}\"\nurl = \"http://x:1\"\n" ) }; assert!(parse(&with("staging")).is_err()); assert!(parse(&with("/srv/../etc/sando")).is_err()); assert!(parse(&with("/srv/sando/staging")).is_ok()); } /// `token_file` is resolved under `secrets_root`. Left unguarded it would be /// a read primitive for any file the daemon user can open. #[test] fn a_token_file_that_escapes_secrets_root_is_rejected() { let with = |token: &str| { format!( "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\n\ url = \"http://x:1\"\ntoken_file = \"{token}\"\n" ) }; assert!(parse(&with("../../etc/shadow")).is_err()); assert!(parse(&with("/etc/shadow")).is_err()); assert!(parse(&with("sando/api-token")).is_ok()); } /// The url is interpolated into a request. A bare host:port would be sent /// as a relative URL and fail at the first release rather than at startup. #[test] fn a_handoff_url_must_name_a_scheme() { let with = |url: &str| { format!( "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\nurl = \"{url}\"\n" ) }; assert!(parse(&with("100.103.89.95:7766")).is_err()); assert!(parse(&with("http://100.103.89.95:7766")).is_ok()); } }