//! `RealiseClosure`: download the closure the swarm built for an agent from //! the swarm's binary cache into the store, without ever building. //! //! What this accepts is decided by root-owned sources alone: the cache's //! address is the host's configuration, handed to the unit in //! [`CACHE_URL_ENV`], and the cache's public key and the pull token's netrc are //! files a root unit keeps in [`ROOT_FACTS_DIR`]. Nothing in the request, and //! nothing hive-c0re (unprivileged) writes, chooses a cache or a key. They go to //! nix's daemon as per-call settings, which the daemon applies for a trusted //! client such as this one (root), so the host's nix configuration is untouched. use std::os::unix::fs::MetadataExt as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_priv_sock::{SIGNATURE_REFUSED, valid_closure_path}; use crate::container::{BoundedRun, run_bounded}; use crate::shared::validate_agent_name; /// The unit's environment variable naming the cache: `https:///` /// from the host's `services.hyperhive.swarm.attic` options. pub(crate) const CACHE_URL_ENV: &str = "HIVE_PRIV_CACHE_URL"; /// Where the root unit `hive-priv-cache-facts` keeps `public-key` and `netrc`. /// Not `/run/hyperhive/attic`, which hive-c0re writes. const ROOT_FACTS_DIR: &str = "/run/hyperhive-priv/attic"; /// Wall-clock bound on one download. A stalled transfer is bounded by nix's /// own `stalled-download-timeout`; this covers the rest. const REALISE_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(30); /// The cache this host trusts. #[derive(Debug, PartialEq, Eq)] struct Cache { substituter: String, public_key: String, netrc: PathBuf, } /// Whether `path` is a regular file or directory owned by `owner` that nobody /// else can read or write. fn private_to(path: &Path, owner: u32, directory: bool) -> Result<()> { let meta = std::fs::symlink_metadata(path) .with_context(|| format!("{} is missing", path.display()))?; let kind_ok = if directory { meta.is_dir() } else { meta.is_file() }; if !kind_ok { bail!( "{} is not a plain {}", path.display(), if directory { "directory" } else { "file" } ); } if meta.uid() != owner { bail!("{} is not owned by uid {owner}", path.display()); } if meta.mode() & 0o077 != 0 { bail!("{} is readable or writable by others", path.display()); } Ok(()) } impl Cache { /// The cache at `url`, trusting the key in `dir`. /// /// # Errors /// No `url`, one that is not a plain `https` address (the token would go /// out in clear), a `dir` or file that is missing, not owned by `owner` or /// open to others, or a key that is not `:`. fn read(url: Option<&str>, dir: &Path, owner: u32) -> Result { let url = url .map(str::trim) .filter(|u| !u.is_empty()) .context("this host names no cache")?; if !url.starts_with("https://") || url.contains(char::is_whitespace) { bail!("the cache address is not an https URL"); } private_to(dir, owner, true)?; let key_file = dir.join("public-key"); private_to(&key_file, owner, false)?; let netrc = dir.join("netrc"); private_to(&netrc, owner, false)?; let key = std::fs::read_to_string(&key_file) .with_context(|| format!("reading {}", key_file.display()))?; let key = key.trim(); if key.contains(char::is_whitespace) || key .split_once(':') .is_none_or(|(name, key)| name.is_empty() || key.is_empty()) { bail!("the cache's public key is not :"); } Ok(Self { substituter: url.to_owned(), public_key: key.to_owned(), netrc, }) } } /// The `nix-store` arguments that fetch `store_path` and nothing else. /// /// - `--max-jobs 0` forbids a local build, so a path the cache lacks fails. /// - `substituters` and `trusted-public-keys` are replaced, not extended: the /// swarm cache is the only source and its key the only one that counts. /// - `require-sigs true` makes nix refuse a path no trusted key signed. fn realise_args(store_path: &str, cache: &Cache) -> Vec { let mut args: Vec = ["--realise", store_path, "--max-jobs", "0"] .map(str::to_owned) .into(); let netrc = cache.netrc.display().to_string(); for (name, value) in [ ("substituters", cache.substituter.as_str()), ("trusted-public-keys", cache.public_key.as_str()), ("require-sigs", "true"), ("netrc-file", netrc.as_str()), ] { args.extend(["--option".to_owned(), name.to_owned(), value.to_owned()]); } args } /// Why nix did not realise the path, from its last stderr line. A refusal for /// want of a signature is named, so the caller can tell it from a cache that /// is down. fn reason(stderr: &str) -> String { let last = stderr.lines().last().unwrap_or("").trim(); if last.to_lowercase().contains("signature") { format!("{SIGNATURE_REFUSED}: {last}") } else { last.to_owned() } } /// `RealiseClosure`. /// /// # Errors /// Bad arguments, or `not available: ` for anything that left the path /// out of the store: no cache or key on this host, nix failing or timing out. pub(crate) async fn realise_closure(agent: &str, store_path: &str) -> Result<(String, String)> { let url = std::env::var(CACHE_URL_ENV).ok(); realise_with( url.as_deref(), Path::new(ROOT_FACTS_DIR), 0, agent, store_path, ) .await } /// [`realise_closure`] with the cache's address and the key's directory and /// owner named, so a test can stand in for the host. async fn realise_with( url: Option<&str>, dir: &Path, owner: u32, agent: &str, store_path: &str, ) -> Result<(String, String)> { validate_agent_name(agent)?; if !valid_closure_path(store_path) { bail!("{store_path:?} is not a store path"); } let outcome = async { let cache = Cache::read(url, dir, owner)?; let mut cmd = tokio::process::Command::new("nix-store"); cmd.args(realise_args(store_path, &cache)); match run_bounded(cmd, REALISE_TIMEOUT, None) .await .context("running nix-store")? { BoundedRun::Exited { status, .. } if status.success() => Ok(()), BoundedRun::Exited { stderr, .. } => bail!("{}", reason(&stderr)), BoundedRun::TimedOut => bail!( "nix-store timed out after {}s; killed it", REALISE_TIMEOUT.as_secs() ), } } .await; match outcome { Ok(()) => { tracing::info!(%agent, %store_path, "substituted the agent's closure"); Ok(("substituted".to_owned(), String::new())) } Err(e) => { let reason = format!("{e:#}"); if reason.contains(SIGNATURE_REFUSED) { tracing::error!(%agent, %store_path, %reason, "the cache's closure was refused"); } bail!("not available: {reason}") } } } #[cfg(test)] mod tests { use std::os::unix::fs::PermissionsExt as _; use super::*; use crate::shared::test_support::scratch; const PATH: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas"; const URL: &str = "https://cache.example/swarm"; fn cache() -> Cache { Cache { substituter: URL.to_owned(), public_key: "swarm:AAAA".to_owned(), netrc: PathBuf::from("/run/hyperhive-priv/attic/netrc"), } } fn option<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.windows(3) .find(|w| w[0] == "--option" && w[1] == name) .map(|w| w[2].as_str()) } #[test] fn the_command_never_builds() { let args = realise_args(PATH, &cache()); assert_eq!(args[..2], ["--realise", PATH]); let jobs = args.iter().position(|a| a == "--max-jobs").expect("flag"); assert_eq!(args[jobs + 1], "0"); } #[test] fn only_the_swarm_cache_and_its_key_count_and_signatures_are_required() { let args = realise_args(PATH, &cache()); assert_eq!(option(&args, "substituters"), Some(URL)); assert_eq!(option(&args, "trusted-public-keys"), Some("swarm:AAAA")); assert_eq!(option(&args, "require-sigs"), Some("true")); assert_eq!( option(&args, "netrc-file"), Some("/run/hyperhive-priv/attic/netrc") ); // The settings that would add to the defaults are not used. assert!(!args.iter().any(|a| a.starts_with("extra-")), "{args:?}"); } /// The uid the test runs as, which stands in for root. fn me() -> u32 { std::fs::metadata(scratch()).expect("stat").uid() } fn put(path: &Path, text: &str, mode: u32) { std::fs::write(path, text).expect("write"); std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod"); } /// A facts directory as the root unit leaves it. fn facts(key: &str) -> PathBuf { let dir = scratch(); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("chmod"); put(&dir.join("public-key"), &format!("{key}\n"), 0o600); put( &dir.join("netrc"), "machine cache.example password t\n", 0o600, ); dir } #[test] fn the_cache_is_the_hosts_address_and_the_root_files_key() { let dir = facts("swarm:AAAA"); let cache = Cache::read(Some(URL), &dir, me()).expect("read"); assert_eq!(cache.substituter, URL); assert_eq!(cache.public_key, "swarm:AAAA"); assert_eq!(cache.netrc, dir.join("netrc")); } /// A `substituter` or `public-key` file beside the facts, as hive-c0re /// writes them, decides nothing: the address is the argument and the key is /// the root file's. #[test] fn the_address_is_never_read_from_a_file_and_the_key_only_from_the_root_files() { let dir = facts("swarm:ROOT"); put( &dir.join("substituter"), "https://evil.example/cache\n", 0o600, ); let cache = Cache::read(Some(URL), &dir, me()).expect("read"); assert_eq!(cache.substituter, URL); assert_eq!(cache.public_key, "swarm:ROOT"); // The directory hive-c0re writes is not the one this reads. assert!(!ROOT_FACTS_DIR.starts_with("/run/hyperhive/")); assert_eq!(ROOT_FACTS_DIR, "/run/hyperhive-priv/attic"); // And without an address from the host there is no cache, whatever the // directory holds. for none in [None, Some(""), Some(" ")] { assert!(Cache::read(none, &dir, me()).is_err(), "{none:?}"); } } #[test] fn a_file_that_is_not_the_roots_or_is_open_to_others_is_refused() { let me = me(); // Control: the facts as the unit leaves them are accepted. assert!(Cache::read(Some(URL), &facts("swarm:AAAA"), me).is_ok()); // Another owner than the one trusted. assert!(Cache::read(Some(URL), &facts("swarm:AAAA"), me + 1).is_err()); // The key or the netrc readable by others, or the directory open. for (name, mode) in [("public-key", 0o644), ("netrc", 0o644), ("netrc", 0o660)] { let dir = facts("swarm:AAAA"); std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(mode)) .expect("chmod"); assert!(Cache::read(Some(URL), &dir, me).is_err(), "{name} {mode:o}"); } let dir = facts("swarm:AAAA"); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("chmod"); assert!(Cache::read(Some(URL), &dir, me).is_err()); // A symlink in place of the key. let dir = facts("swarm:AAAA"); std::fs::remove_file(dir.join("public-key")).expect("rm"); std::os::unix::fs::symlink(dir.join("netrc"), dir.join("public-key")).expect("link"); assert!(Cache::read(Some(URL), &dir, me).is_err()); } #[test] fn missing_files_and_unusable_values_are_refused() { let me = me(); for url in ["http://cache.example/swarm", "https://cache.example/a b"] { assert!( Cache::read(Some(url), &facts("swarm:AAAA"), me).is_err(), "{url}" ); } for key in ["nokeyname", "swarm: AAAA", ":AAAA", "swarm:"] { assert!(Cache::read(Some(URL), &facts(key), me).is_err(), "{key:?}"); } let dir = facts("swarm:AAAA"); std::fs::remove_file(dir.join("netrc")).expect("rm"); assert!(Cache::read(Some(URL), &dir, me).is_err()); assert!(Cache::read(Some(URL), &dir.join("nowhere"), me).is_err()); } /// Missing facts reach the caller as `not available`, which its prebuild /// answers by building. #[tokio::test] async fn without_the_root_files_the_answer_is_not_available() { let nowhere = scratch().join("nowhere"); let err = realise_with(Some(URL), &nowhere, 0, "atlas", PATH) .await .expect_err("no facts"); assert!(format!("{err:#}").starts_with("not available: "), "{err:#}"); let err = realise_with(None, &facts("swarm:AAAA"), me(), "atlas", PATH) .await .expect_err("no address"); assert!(format!("{err:#}").starts_with("not available: "), "{err:#}"); } /// The request carries an agent and a path and nothing that chooses a /// cache: fields beyond those are dropped when it is read. #[test] fn the_request_cannot_carry_an_address_or_a_key() { let line = r#"{"op":"realise_closure","agent":"atlas","store_path":"/nix/store/x", "substituter":"https://evil.example/c","public_key":"evil:AAAA","netrc":"/tmp/x"}"#; let hive_priv_sock::PrivRequest::RealiseClosure { agent, store_path } = serde_json::from_str(line).expect("reads") else { panic!("not a RealiseClosure"); }; assert_eq!( (agent.as_str(), store_path.as_str()), ("atlas", "/nix/store/x") ); } #[test] fn a_signature_refusal_is_named_and_another_failure_is_not() { let refused = reason("error: cannot add path 'x' because it lacks a signature by a trusted key"); assert!(refused.starts_with(SIGNATURE_REFUSED), "{refused}"); let down = reason("warning: x\nerror: unable to download 'https://c/x.narinfo': Couldn't connect"); assert!(!down.contains(SIGNATURE_REFUSED), "{down}"); assert!(down.contains("Couldn't connect"), "{down}"); } #[tokio::test] async fn bad_arguments_are_refused_before_anything_runs() { for (agent, path) in [ ("Bad Agent", PATH), ("", PATH), ("atlas", "/nix/store/x"), ("atlas", "/etc/passwd"), ("atlas", &format!("{PATH}/bin/sh")), ] { let err = realise_closure(agent, path).await.expect_err("refused"); assert!(!format!("{err:#}").contains("not available"), "{err:#}"); } } }