Watch
0
0
Fork
You've already forked hyperhive
0

render squid's users file from this hive's proxy credentials

This commit is contained in:
atlas 2026-10-28 13:02:25 +01:00 • committed by mara
commit 52d532ff5a
12 changed files with 562 additions and 6 deletions

30
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -36,6 +36,8 @@ the rest of the cell says how.
| `swarm/agents/<agent>/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/<hive>/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/<hive>/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/<agent>/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/<hive>/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/<hive>/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/<clientId>/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/<id>.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/<id>.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/<hive>/forge/token`, for the `hive-<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`): `<user>:<hash>` 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

View file

@ -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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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 `<user>:<hash>` 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<bool>) {
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<String> = 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<proxy::Credential> = 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<F, Fut>(hive: &str, agents: &[String], file: &Path, read: F) -> Result<()>
where
F: Fn(String) -> Fut,
Fut: Future<Output = Result<Option<String>>>,
{
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(&current, &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 `<user>:<hash>` 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<String> {
let mut lines: Vec<String> = 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$<salt>$<hash>`.
fn hash_secret(secret: &str) -> Result<String> {
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<String, String> {
entries
.iter()
.map(|(p, v)| ((*p).to_owned(), (*v).to_owned()))
.collect()
}
fn names(names: &[&str]) -> Vec<String> {
names.iter().map(|n| (*n).to_owned()).collect()
}
async fn sync(held: &HashMap<String, String>, 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<String, String> {
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<String> = 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<String> = 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:?}");
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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 `<user>:<hash>` 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";

View file

@ -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 =