diff --git a/Cargo.lock b/Cargo.lock index e7d202fe..d2363cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1777,6 +1777,7 @@ dependencies = [ "ed25519-dalek", "forgejo-api", "futures-util", + "getrandom 0.3.4", "hive-core-agent-sock", "hive-host-sock", "hive-jobq", @@ -1800,6 +1801,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha-crypt", "sha2 0.11.0", "strum", "swarm-matrix-client", @@ -3082,6 +3084,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "mcf" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2044bdbade272ded4a1529324f4f30ca7b0c5b496541f2a9095bbd1c9d03cb06" +dependencies = [ + "base64ct", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3357,6 +3368,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" + [[package]] name = "paste" version = "1.0.15" @@ -4567,6 +4584,19 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-crypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ee31e0190f9dc366d26b032ac36a99328532f40a237a79385a20d2d7f29810" +dependencies = [ + "base64ct", + "ctutils", + "mcf", + "password-hash", + "sha2 0.11.0", +] + [[package]] name = "sha1" version = "0.10.7" diff --git a/Cargo.toml b/Cargo.toml index 5ef450f6..3157d61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,6 +248,9 @@ matrix-sdk = { version = "0.18", default-features = false, features = [ futures-util = "0.3" hmac = "0.13" sha2 = "0.11" +# SHA-512 crypt (`$6$`) for the users file hive-c0re renders; squid's `basic_ncsa_auth` +# hands a `$` hash to the system `crypt()`. +sha-crypt = "0.6" # Verifies the cache signature over a narinfo (hive-c0re). Already in the lock # through nkeys. ed25519-dalek = "2" diff --git a/docs/swarm/credentials-reference.md b/docs/swarm/credentials-reference.md index c516f69f..e87bbcdb 100644 --- a/docs/swarm/credentials-reference.md +++ b/docs/swarm/credentials-reference.md @@ -36,6 +36,8 @@ the rest of the cell says how. | `swarm/agents//claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting | | `swarm/hives//matrix/sender-token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or rejected by the homeserver | ✅ the hive re-reads on every sweep | | `swarm/hives//forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call | +| `swarm/agents//proxy-credential` | `swarm-controller`: 32 random bytes, at start and every 5 minutes for every agent that holds a store identity | the hive (`hive-c0re`); the agent can read it, nothing sends it yet | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` re-reads it every 30 seconds | +| `swarm/hives//squid-selfcheck` | `swarm-controller`, in the same pass as the agents' credentials | the hive: `hive-c0re` and `squid-selfcheck-credential` (root) | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` every 30 seconds, the root unit every 10 minutes | | `swarm/hives//oidc/client` | authelia, first boot; `swarm-secret-publish` copies it in | the hive | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it | | `swarm/services//oidc/client` | authelia | that service | must be stated | must be stated | | `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge, at start and every 5 minutes | `swarm-bao-ci-runner` on the runner's host | ❌ the forge keeps one token until an admin resets it; the controller then writes the new one within 5 minutes | ✅ every 10 minutes; restarts the runner on a change | @@ -154,6 +156,7 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials. | the forge's copy of its OIDC secret | `swarm-bao-forge-oidc.service` reads it out of the swarm secret store under the forge publisher's leaf, wherever authelia runs | `/run/hive-forge-oidc/.secret` on the host (tmpfs), bound into the forge container, handed to `forgejo-sso-source` through `LoadCredential` | | the homeserver's copy of its OIDC secret | `swarm-bao-matrix-oidc.service` reads it out of the swarm secret store under matrix-ctl's leaf, wherever authelia runs | `/run/hive-matrix-oidc/.secret` on the host (tmpfs), bound into the homeserver container, handed to tuwunel through `LoadCredential` | | this hive's forge read token | `swarm-controller`, into the secret store at `swarm/hives//forge/token`, for the `hive-` account; a pass at start and every five minutes re-mints a missing or stale token | `hive-c0re` reads it under the hive's own store identity, and copies it (account on the first line, token on the second) to `/run/hyperhive/forge-hive-token` (tmpfs, `0600` `hive-core`) for nix's `git`, which `hive-priv` also runs. Each `internal/meta` read and each boot rewrites the copy when it differs; where they differ after a rotation, a build that starts before the next read fails to authenticate, and a retry reads the new token | +| squid's users file | `hive-c0re` (`workers::squid_users`), every 30 seconds, from the proxy credentials of its agents and the self-check | `services.hyperhive.squid.usersFile` (`/var/lib/hive-squid/users`, `hive-core:squid`, `0640`): `:` lines, SHA-512 crypt hashes only, renamed into place when it changed. `systemd-tmpfiles` creates it empty so squid's helper never sees a missing file. A hive whose agents have no credential yet (an older controller) has an empty file: every login fails and no agent sends a credential and squid doesn't read the file yet, so no traffic changes | ### Store identities, one per principal diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index b1cb8c29..fa1713df 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -19,6 +19,8 @@ axum.workspace = true backon.workspace = true chrono.workspace = true base64.workspace = true +getrandom.workspace = true +sha-crypt.workspace = true reqwest.workspace = true forgejo-api.workspace = true url.workspace = true diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 11b0b615..13f037ae 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -363,6 +363,8 @@ async fn cmd_serve( // not a part of the above; see swarm_agent_status's module doc for why // the two buckets stay separate. swarm_agent_status::spawn(coord.shutdown_rx()); + // Squid's users file: the hashes of this hive's agents' proxy credentials. + workers::squid_users::spawn(coord.shutdown_rx()); // Per-agent events.sqlite + bash-tasks file cleanup now runs // agent-side in the harness (`hive_agent::vacuum`): the files are // agent-owned, so host-side deletes hit PermissionDenied / readonly-db diff --git a/hive-c0re/src/secret_file.rs b/hive-c0re/src/secret_file.rs index f13f51e0..6f3f8d31 100644 --- a/hive-c0re/src/secret_file.rs +++ b/hive-c0re/src/secret_file.rs @@ -1,7 +1,8 @@ -//! [`write_secret_0600`]: the one way hive-c0re puts a credential on disk. +//! [`write_secret_0600`] and [`write_secret_mode`]: the ways hive-c0re puts a +//! credential, or a hash of one, on disk. use std::io::Write as _; -use std::os::unix::fs::OpenOptionsExt as _; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; use std::path::Path; use anyhow::{Context as _, Result}; @@ -13,6 +14,12 @@ use anyhow::{Context as _, Result}; /// reader (or a crash) never sees half of it. Errors carry paths, never /// `contents`. pub fn write_secret_0600(path: &Path, contents: &[u8]) -> Result<()> { + write_secret_mode(path, contents, 0o600) +} + +/// [`write_secret_0600`] with the file's mode given, for a file another user +/// has to read (the group comes from the directory). +pub fn write_secret_mode(path: &Path, contents: &[u8], mode: u32) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } @@ -27,9 +34,13 @@ pub fn write_secret_0600(path: &Path, contents: &[u8]) -> Result<()> { let mut f = std::fs::OpenOptions::new() .write(true) .create_new(true) - .mode(0o600) + .mode(mode) .open(&tmp) .with_context(|| format!("create {}", tmp.display()))?; + // `open` masks the mode with the process umask; the mode is a property of + // the writer, not of the unit's umask. + f.set_permissions(std::fs::Permissions::from_mode(mode)) + .with_context(|| format!("chmod {}", tmp.display()))?; f.write_all(contents) .and_then(|()| f.sync_all()) .with_context(|| format!("write {}", tmp.display()))?; @@ -38,7 +49,7 @@ pub fn write_secret_0600(path: &Path, contents: &[u8]) -> Result<()> { #[cfg(test)] mod tests { - use super::write_secret_0600; + use super::{write_secret_0600, write_secret_mode}; use std::os::unix::fs::PermissionsExt as _; fn mode(path: &std::path::Path) -> u32 { @@ -76,4 +87,26 @@ mod tests { write_secret_0600(&path, b"new\n").expect("writes"); assert_eq!(std::fs::read(&path).expect("read"), b"new\n"); } + + /// A restrictive umask doesn't change the mode asked for. The umask is + /// process-wide, so the write happens in a child run of this test. + #[test] + fn the_mode_survives_a_restrictive_umask() { + const DIR: &str = "SECRET_FILE_UMASK_TEST_DIR"; + if let Some(dir) = std::env::var_os(DIR) { + write_secret_mode(&std::path::Path::new(&dir).join("shared"), b"x\n", 0o640) + .expect("writes"); + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let status = std::process::Command::new("sh") + .arg("-c") + .arg(r#"umask 077 && exec "$0" --exact secret_file::tests::the_mode_survives_a_restrictive_umask"#) + .arg(std::env::current_exe().expect("test binary")) + .env(DIR, dir.path()) + .status() + .expect("spawns the child"); + assert!(status.success()); + assert_eq!(mode(&dir.path().join("shared")), 0o640); + } } diff --git a/hive-c0re/src/workers/mod.rs b/hive-c0re/src/workers/mod.rs index 118f923b..87da7e4e 100644 --- a/hive-c0re/src/workers/mod.rs +++ b/hive-c0re/src/workers/mod.rs @@ -1,7 +1,7 @@ //! Background tasks and periodic sweeps: crash/login watcher, boot-time //! auto-update reconcile, the agent-sockets.json writer loop, the MCP socket listener //! reconcile loop, knowledge-repo sync, the swarm wanted-state pull, and the -//! swarm resource-cap and permissions reads. +//! swarm resource-cap and permissions reads, and the squid users file. //! Each submodule is re-exported //! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged. @@ -11,6 +11,7 @@ pub mod crash_watch; pub mod knowledge; pub mod mcp_sockets; pub mod permissions_import; +pub mod squid_users; pub mod swarm_limits; pub mod swarm_permissions; pub mod wanted; diff --git a/hive-c0re/src/workers/squid_users.rs b/hive-c0re/src/workers/squid_users.rs new file mode 100644 index 00000000..44737a8f --- /dev/null +++ b/hive-c0re/src/workers/squid_users.rs @@ -0,0 +1,440 @@ +//! Squid's users file: the hashes of the proxy credentials this hive's agents +//! authenticate to its egress proxy with. +//! +//! swarm-controller mints one credential per agent and one for this hive's own +//! squid self-check ([`swarm_secret_client::proxy`]). Every [`INTERVAL`] this +//! reads the credentials of the agents this hive has, plus the self-check's, +//! and renders `:` lines into the file named by +//! [`ENV_USERS_FILE`], which squid's `basic_ncsa_auth` re-reads when its mtime +//! moves. The agents are the container list, not the running containers: a +//! stopped agent stays in the file. +//! +//! Only salted SHA-512 crypt hashes reach the disk. A hash is kept as it is +//! while it still verifies its credential, so the file changes only when a +//! user is added, removed or given a new credential. +//! +//! A failed read leaves the file as it is: dropping a user on a store outage +//! would read exactly like the swarm revoking it. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use sha_crypt::{PasswordHasher as _, PasswordVerifier as _, ShaCrypt}; +use swarm_secret_client::{SecretStore, client, proxy}; + +use crate::stats::sweep_health::{self, SweepHealth}; + +/// How often the file is re-rendered. +pub const INTERVAL: Duration = Duration::from_secs(30); + +/// At [`INTERVAL`] this is ~90 seconds of failing passes. +const FAILURES_BEFORE_WARNING: u32 = 3; + +/// The environment variable naming the file. Set by `nix/host-modules/hive-c0re` +/// from `services.hyperhive.squid.usersFile`; unset means this host runs no squid. +pub const ENV_USERS_FILE: &str = "HIVE_SQUID_USERS_FILE"; + +/// Raw salt bytes: twelve of them encode to the sixteen characters `crypt()` +/// accepts. +const SALT_BYTES: usize = 12; + +/// The file's mode. The group is squid's, from the setgid directory +/// `nix/host-modules/hive-squid.nix` creates. +const FILE_MODE: u32 = 0o640; + +/// One squid user and the credential it logs in with. No `Debug`: `secret` is +/// live. +struct User { + name: String, + secret: String, +} + +/// Start the pass loop, when this host has a squid users file, a store and a +/// hive name. +pub fn spawn(mut shutdown: tokio::sync::watch::Receiver) { + let Some(path) = std::env::var_os(ENV_USERS_FILE) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + else { + return; + }; + let Some(hive) = crate::container_view::hive_swarm_names().0 else { + tracing::info!("squid users: HYPERHIVE_HIVE_NAME is unset; no users file is written"); + return; + }; + if std::env::var_os(client::ENV_ADDR).is_none_or(|v| v.is_empty()) { + tracing::info!( + "squid users: no swarm secret store is configured; no users file is written" + ); + return; + } + tokio::spawn(async move { + let mut health = SweepHealth::new("squid_users_render", "warn", FAILURES_BEFORE_WARNING); + loop { + match pass(&hive, &path).await { + Ok(()) => health.record_ok(), + Err(e) => { + tracing::warn!(error = ?e, "squid users: the pass failed"); + let err = format!("{e:#}"); + health.record_err(|ctx| { + let age = ctx.since_last_ok.map_or_else( + || "no success this session".to_owned(), + |d| format!("last ok {} ago", sweep_health::fmt_age(d)), + ); + format!( + "rendering squid's users file is failing ({} consecutive, {age}) \ + — squid keeps the users last written: {err}", + ctx.consecutive + ) + }); + } + } + tokio::select! { + () = tokio::time::sleep(INTERVAL) => {} + _ = shutdown.changed() => return, + } + } + }); +} + +/// Read this hive's credentials from the store and render them into `file`. +async fn pass(hive: &str, file: &Path) -> Result<()> { + let agents: Vec = crate::lifecycle::agents_for_meta_listing() + .await + .context("enumerating this hive's agents")? + .into_iter() + .map(|spec| spec.name) + .collect(); + let store = SecretStore::from_env(hive) + .await + .context("connecting to the store")?; + sync_with(hive, &agents, file, |path| { + let store = &store; + async move { + let held: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + Ok(held.map(|c| c.value)) + } + }) + .await +} + +/// [`pass`] with the store read and the file given. Nothing is written unless +/// every read succeeds and the rendered file differs from the current one. +async fn sync_with(hive: &str, agents: &[String], file: &Path, read: F) -> Result<()> +where + F: Fn(String) -> Fut, + Fut: Future>>, +{ + let mut wanted = Vec::new(); + for agent in agents { + wanted.push((agent.clone(), proxy::agent_credential_path(agent)?)); + } + wanted.push(( + proxy::SELFCHECK_USER.to_owned(), + proxy::selfcheck_path(hive)?, + )); + let mut users = Vec::new(); + for (name, path) in wanted { + if let Some(secret) = read(path).await? { + users.push(User { name, secret }); + } else { + tracing::debug!(name, "squid users: no proxy credential minted yet"); + } + } + let current = match std::fs::read_to_string(file) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e).with_context(|| format!("reading {}", file.display())), + }; + let rendered = render(¤t, &users)?; + if rendered == current { + return Ok(()); + } + crate::secret_file::write_secret_mode(file, rendered.as_bytes(), FILE_MODE) + .with_context(|| format!("writing {}", file.display()))?; + tracing::info!( + users = users.len(), + agents = users.iter().filter(|u| u.name != proxy::SELFCHECK_USER).count(), + file = %file.display(), + "squid users: wrote the users file" + ); + Ok(()) +} + +/// The users file for `users`, sorted by name, one `:` line each. +/// +/// A user whose line in `current` still verifies against its credential keeps +/// that line. Every other line of `current` is dropped, so a destroyed agent, +/// or one that belongs to another hive, does not outlive the pass. +fn render(current: &str, users: &[User]) -> Result { + let mut lines: Vec = Vec::with_capacity(users.len()); + for user in users { + if user.name.contains([':', '\n']) { + bail!("{:?} cannot be a squid user name", user.name); + } + let kept = current + .lines() + .filter_map(|l| l.split_once(':')) + .find(|(name, hash)| *name == user.name && verifies(&user.secret, hash)) + .map(|(_, hash)| hash.to_owned()); + let hash = match kept { + Some(hash) => hash, + None => hash_secret(&user.secret)?, + }; + lines.push(format!("{}:{hash}\n", user.name)); + } + lines.sort(); + Ok(lines.concat()) +} + +/// A fresh salted SHA-512 crypt hash of `secret`: `$6$rounds=5000$$`. +fn hash_secret(secret: &str) -> Result { + let mut salt = [0u8; SALT_BYTES]; + getrandom::fill(&mut salt).context("drawing a salt from the kernel's CSPRNG")?; + let hash = ShaCrypt::SHA512 + .hash_password_with_salt(secret.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("hashing a proxy credential: {e}"))?; + Ok(hash.as_str().to_owned()) +} + +/// Whether `hash` is `secret`'s SHA-crypt hash. +fn verifies(secret: &str, hash: &str) -> bool { + ShaCrypt::SHA512 + .verify_password(secret.as_bytes(), hash) + .is_ok() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::os::unix::fs::PermissionsExt as _; + + use super::*; + + const HIVE: &str = "h1"; + + /// What the store holds, by path. + fn store(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(p, v)| ((*p).to_owned(), (*v).to_owned())) + .collect() + } + + fn names(names: &[&str]) -> Vec { + names.iter().map(|n| (*n).to_owned()).collect() + } + + async fn sync(held: &HashMap, agents: &[&str], file: &Path) -> Result<()> { + sync_with(HIVE, &names(agents), file, |path| { + let found = held.get(&path).cloned(); + async move { Ok(found) } + }) + .await + } + + fn lines(file: &Path) -> Vec<(String, String)> { + std::fs::read_to_string(file) + .expect("the file is there") + .lines() + .map(|l| { + let (name, hash) = l.split_once(':').expect("a name and a hash"); + (name.to_owned(), hash.to_owned()) + }) + .collect() + } + + fn two_agents_and_a_stranger() -> HashMap { + store(&[ + ("swarm/agents/atlas/proxy-credential", "atlas-secret"), + ("swarm/agents/argus/proxy-credential", "argus-secret"), + ("swarm/agents/other-hive-agent/proxy-credential", "stranger"), + ("swarm/hives/h1/squid-selfcheck", "check-secret"), + ("swarm/hives/h2/squid-selfcheck", "other-check"), + ]) + } + + #[tokio::test] + async fn only_this_hives_agents_and_its_selfcheck_are_rendered() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + sync(&two_agents_and_a_stranger(), &["atlas", "argus"], &file) + .await + .expect("renders"); + let users: Vec = lines(&file).into_iter().map(|(n, _)| n).collect(); + assert_eq!(users, ["argus", "atlas", "selfcheck.squid"]); + } + + #[tokio::test] + async fn each_line_is_a_salted_sha512_crypt_hash_of_its_users_credential() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + sync(&two_agents_and_a_stranger(), &["atlas", "argus"], &file) + .await + .expect("renders"); + let by_name: HashMap<_, _> = lines(&file).into_iter().collect(); + assert!(by_name["atlas"].starts_with("$6$"), "{}", by_name["atlas"]); + assert!(verifies("atlas-secret", &by_name["atlas"])); + assert!(verifies("argus-secret", &by_name["argus"])); + assert!(verifies("check-secret", &by_name["selfcheck.squid"])); + // Control: a hash is of its own user's credential only. + assert!(!verifies("argus-secret", &by_name["atlas"])); + // Salted: the same credential hashes differently twice. + assert_ne!( + hash_secret("atlas-secret").expect("hashes"), + hash_secret("atlas-secret").expect("hashes") + ); + } + + #[tokio::test] + async fn no_credential_is_written_to_the_file_or_its_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + sync(&two_agents_and_a_stranger(), &["atlas", "argus"], &file) + .await + .expect("renders"); + // Control: the search finds a name that is in the file. + let mut found = 0; + for entry in std::fs::read_dir(dir.path()).expect("lists") { + let text = std::fs::read_to_string(entry.expect("entry").path()).expect("text"); + assert!(text.contains("atlas:"), "control: {text}"); + for secret in ["atlas-secret", "argus-secret", "check-secret"] { + assert!(!text.contains(secret), "{secret} reached the disk"); + } + found += 1; + } + assert_eq!(found, 1, "only the users file is left behind"); + } + + #[tokio::test] + async fn the_file_is_group_readable_and_replaced_atomically() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + sync(&two_agents_and_a_stranger(), &["atlas"], &file) + .await + .expect("renders"); + let mode = std::fs::metadata(&file).expect("stat").permissions().mode() & 0o777; + assert_eq!(mode, 0o640); + let inode = std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(&file).expect("stat")); + sync(&two_agents_and_a_stranger(), &["atlas", "argus"], &file) + .await + .expect("renders"); + let after = std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(&file).expect("stat")); + assert_ne!( + inode, after, + "a rename onto the path, not a rewrite in place" + ); + assert!(!dir.path().join("users.tmp").exists()); + } + + #[tokio::test] + async fn an_unchanged_pass_leaves_the_file_alone() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + let held = two_agents_and_a_stranger(); + sync(&held, &["atlas", "argus"], &file) + .await + .expect("renders"); + let first = std::fs::read_to_string(&file).expect("read"); + let inode = std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(&file).expect("stat")); + sync(&held, &["atlas", "argus"], &file) + .await + .expect("renders"); + assert_eq!(std::fs::read_to_string(&file).expect("read"), first); + let again = std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(&file).expect("stat")); + assert_eq!(inode, again, "nothing was written"); + } + + #[tokio::test] + async fn a_changed_credential_gets_a_new_hash_and_the_others_keep_theirs() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + let mut held = two_agents_and_a_stranger(); + sync(&held, &["atlas", "argus"], &file) + .await + .expect("renders"); + let before: HashMap<_, _> = lines(&file).into_iter().collect(); + held.insert( + "swarm/agents/atlas/proxy-credential".to_owned(), + "rotated".to_owned(), + ); + sync(&held, &["atlas", "argus"], &file) + .await + .expect("renders"); + let after: HashMap<_, _> = lines(&file).into_iter().collect(); + assert!(verifies("rotated", &after["atlas"])); + assert_ne!(before["atlas"], after["atlas"]); + assert_eq!(before["argus"], after["argus"]); + } + + #[tokio::test] + async fn a_stopped_agent_stays_and_a_destroyed_one_goes() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + let held = two_agents_and_a_stranger(); + // The agent list is the container list, so an agent whose container + // is down is in it: nothing here looks at whether anything runs. + sync(&held, &["atlas", "argus"], &file) + .await + .expect("renders"); + assert!(lines(&file).iter().any(|(n, _)| n == "argus")); + // Control: once the container is gone the user is dropped, and a line + // for an agent this hive never had does not survive a pass either. + let mut text = std::fs::read_to_string(&file).expect("read"); + text.push_str("stray:$6$rounds=5000$saltsaltsaltsalt$x\n"); + std::fs::write(&file, text).expect("write"); + sync(&held, &["atlas"], &file).await.expect("renders"); + let users: Vec = lines(&file).into_iter().map(|(n, _)| n).collect(); + assert_eq!(users, ["atlas", "selfcheck.squid"]); + } + + #[tokio::test] + async fn an_agent_without_a_credential_yet_is_left_out() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + // The empty file squid's unit creates before its helper starts. + std::fs::write(&file, "").expect("write"); + sync(&store(&[]), &["atlas"], &file).await.expect("renders"); + assert_eq!(std::fs::read_to_string(&file).expect("read"), ""); + } + + #[tokio::test] + async fn a_failed_read_leaves_the_file_as_it_was() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("users"); + sync(&two_agents_and_a_stranger(), &["atlas", "argus"], &file) + .await + .expect("renders"); + let before = std::fs::read_to_string(&file).expect("read"); + let failed = sync_with( + HIVE, + &names(&["atlas", "argus"]), + &file, + |path| async move { + if path.contains("argus") { + bail!("the store is down") + } + Ok(Some("atlas-secret".to_owned())) + }, + ) + .await; + assert!(failed.is_err()); + assert_eq!(std::fs::read_to_string(&file).expect("read"), before); + } + + #[test] + fn a_name_that_would_break_the_line_is_refused() { + for bad in ["a:b", "a\nb"] { + let user = User { + name: bad.to_owned(), + secret: "s".to_owned(), + }; + assert!(render("", &[user]).is_err(), "{bad:?}"); + } + } +} diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index a1d0f02e..6a26ae02 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -347,7 +347,11 @@ in # needs access to the rest of c0re's state — that separation # means c0re itself needs an explicit carve-out to write there. ProtectSystem = "strict"; - ReadWritePaths = [ "/var/lib/hive-gateway/conf" ]; + # `-` so a host without the squid users directory still starts the daemon. + ReadWritePaths = [ + "/var/lib/hive-gateway/conf" + "-${builtins.dirOf config.services.hyperhive.squid.usersFile}" + ]; ProtectKernelTunables = true; # no sysctl writes ProtectKernelLogs = true; # no /dev/kmsg access needed ProtectControlGroups = true; # cgroup writes go through hive-priv, not c0re directly diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 288e5286..e2ef032f 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -271,6 +271,9 @@ in # See the LoadCredential in ./default.nix. BAO_CLIENT_CERT = "%d/bao-client.pem"; BAO_CLIENT_KEY = "%d/bao-client-key.pem"; + # Where `workers::squid_users` renders the hashes of this hive's agents' proxy + # credentials for squid; it reads them from the store under the identity above. + HIVE_SQUID_USERS_FILE = config.services.hyperhive.squid.usersFile; } // lib.optionalAttrs (haveBaoClientIdentity && baoDeploy.serverCaFile != null) { # Absent means the system trust store — right for a deployment with a real diff --git a/nix/host-modules/hive-squid.nix b/nix/host-modules/hive-squid.nix index 41f2556b..c1b92645 100644 --- a/nix/host-modules/hive-squid.nix +++ b/nix/host-modules/hive-squid.nix @@ -21,6 +21,8 @@ let allowed = lib.unique (cfg.allowedDomains ++ [ cfg.selfcheck.canary ]); + usersDir = builtins.dirOf cfg.usersFile; + # Evaluated top to bottom, first match wins. The three destination denies # sit before the only allow: squid resolves names on the host, so a name # that answers 127.0.0.1, 169.254.169.254 or a bridge address would @@ -136,6 +138,17 @@ in ''; }; + usersFile = lib.mkOption { + type = lib.types.path; + default = "/var/lib/hive-squid/users"; + description = '' + The `:` file hive-c0re renders from the credentials in the + swarm secret store: one line per agent of this hive and one for the + self-check, as SHA-512 crypt hashes. Created empty, owned by `hive-core` + and readable by the `squid` group. Squid doesn't read it yet. + ''; + }; + selfcheck.canary = lib.mkOption { type = lib.types.str; default = hyperhiveCfg.swarm.forge.domain; @@ -167,6 +180,13 @@ in configText = squidConf; }; + # The directory is setgid so the file hive-c0re renames into it is the + # squid group's. + systemd.tmpfiles.rules = [ + "d ${usersDir} 2750 hive-core squid - -" + "f ${cfg.usersFile} 0640 hive-core squid - -" + ]; + systemd.services.squid = { serviceConfig = sandbox // { User = "squid"; diff --git a/nix/module-eval/hive-otel.nix b/nix/module-eval/hive-otel.nix index 2e409033..3db59108 100644 --- a/nix/module-eval/hive-otel.nix +++ b/nix/module-eval/hive-otel.nix @@ -505,6 +505,21 @@ let && builtins.elem "cache deny all" lines && lib.last (lib.filter (l: l != "") lines) == "http_access deny all"; } + { + # The file hive-c0re renders exists, owned by it and readable by squid's + # group, from boot; hive-c0re gets its path only on a host with a store + # identity. + name = "squid's users file exists for hive-c0re to write, and hive-c0re is told where it is"; + ok = + let + users = hiveOtel.services.hyperhive.squid.usersFile; + rules = hiveOtel.systemd.tmpfiles.rules; + in + users == "/var/lib/hive-squid/users" + && builtins.elem "d /var/lib/hive-squid 2750 hive-core squid - -" rules + && builtins.elem "f ${users} 0640 hive-core squid - -" rules + && hiveOtelAllLocal.systemd.services.hive-c0re.environment.HIVE_SQUID_USERS_FILE == users; + } { name = "squid's port is open on the bridge interface and on no other"; ok =