feat(tls): embed hive CA into agent flakes for self-signed trust
Wire agents to trust the gateway's self-signed leaf at build time. When the gateway runs self-signed TLS, hive-tls sets HIVE_TLS_CA_PATH in hive-c0re's service env pointing at the host hive CA cert. The meta flake renderer reads it and, when present, writes the public CA cert next to flake.nix as hive-ca.pem and emits security.pki.certificateFiles so every agent's system trust store includes the hive CA. Build-time embedding (rather than a runtime bind-mount + bundle service) keeps trust robust: the CA travels with the flake source, lands in the standard NixOS trust store, and needs no per-process SSL_CERT_FILE plumbing. Only the public CA certificate is embedded; the CA private key never leaves the host. The cert is re-embedded and re-committed on CA rotation even when the flake is otherwise byte-identical; when self-signed TLS is off the embedded cert is dropped so the flake stays buildable. Covers OpenSSL-based tools (git, curl) directly. A follow-up switches the hive-forge reqwest client to native roots so it picks up the same store.
This commit is contained in:
parent
9f9c1167ae
commit
4f3f6522d2
2 changed files with 133 additions and 2 deletions
|
|
@ -67,7 +67,16 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||
let initial = !dir.join(".git").exists();
|
||||
|
||||
if !initial && on_disk == new_flake {
|
||||
// Hive CA embedding (self-signed TLS): keep `./hive-ca.pem` at the meta
|
||||
// root in lockstep with the host CA so the build-time `certificateFiles`
|
||||
// reference render_flake emits always resolves. `ca_desired` is empty
|
||||
// when self-signed TLS isn't active (cert / ACME mode).
|
||||
let ca_path = dir.join(HIVE_CA_FILE);
|
||||
let (ca_desired, ca_changed) = hive_ca_state(&dir);
|
||||
|
||||
// Skip only when both the flake AND the embedded CA are unchanged — a
|
||||
// CA rotation with an otherwise-identical flake must still re-commit.
|
||||
if !initial && on_disk == new_flake && !ca_changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +98,16 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
std::fs::write(&flake_path, &new_flake)
|
||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
||||
|
||||
// Materialise (or drop) the embedded hive CA next to flake.nix. When
|
||||
// self-signed TLS is off, `ca_desired` is empty and we remove any stale
|
||||
// cert so the flake (which no longer references it) stays buildable.
|
||||
if ca_desired.is_empty() {
|
||||
let _ = std::fs::remove_file(&ca_path);
|
||||
} else if ca_changed {
|
||||
std::fs::write(&ca_path, &ca_desired)
|
||||
.with_context(|| format!("write {}", ca_path.display()))?;
|
||||
}
|
||||
|
||||
// Reconcile topology.json against the live agent set — adds
|
||||
// entries for newly-spawned agents (default: manager as parent,
|
||||
// manager itself as root) and drops removed agents. Operator
|
||||
|
|
@ -143,6 +162,11 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
// contain '/flake.nix'". Lock then commit once with both
|
||||
// flake.nix and flake.lock — single commit per change.
|
||||
git(&dir, &["add", "flake.nix"]).await?;
|
||||
// Stage the embedded hive CA — added/updated when self-signed TLS is on,
|
||||
// or its deletion when it was just removed. `git add <path>` stages a
|
||||
// deletion when the path is tracked and now gone; best-effort so the
|
||||
// never-tracked-and-absent case (pathspec mismatch) is a harmless no-op.
|
||||
let _ = git(&dir, &["add", "--", HIVE_CA_FILE]).await;
|
||||
// Stage topology.json on every sync (regenerated by reconcile
|
||||
// above when the agent set changed). git add is a no-op when the
|
||||
// file content is unchanged.
|
||||
|
|
@ -189,6 +213,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
.filter_map(|f| match f.as_str() {
|
||||
"flake.nix" => Some("flake"),
|
||||
"flake.lock" => Some("lock"),
|
||||
"hive-ca.pem" => Some("hive-ca"),
|
||||
"topology.json" => Some("topology"),
|
||||
"capabilities.json" => Some("capabilities"),
|
||||
"tool-groups.json" => Some("tool-groups"),
|
||||
|
|
@ -565,6 +590,41 @@ fn forwarded_env_vars() -> Vec<(&'static str, String)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Filename the hive CA cert is embedded under at the meta-flake root.
|
||||
/// `sync_agents` writes it and `render_flake` references `./hive-ca.pem`
|
||||
/// in `security.pki.certificateFiles` so every agent trusts it at build
|
||||
/// time.
|
||||
const HIVE_CA_FILE: &str = "hive-ca.pem";
|
||||
|
||||
/// Host path of the hive CA *certificate*, when self-signed TLS is active.
|
||||
/// `hive-tls.nix` sets `HIVE_TLS_CA_PATH` in hive-c0re's service env (to
|
||||
/// `<tls.stateDir>/ca.pem`) whenever the gateway serves a self-signed,
|
||||
/// hive-CA-signed leaf. Returns `Some(path)` only when the var is set AND
|
||||
/// the cert exists on disk — so render + write stay consistent (we never
|
||||
/// emit a `certificateFiles` reference to a file we didn't embed). Only the
|
||||
/// public cert is ever read here; the CA private key never leaves the host.
|
||||
fn hive_ca_source() -> Option<String> {
|
||||
let path = std::env::var("HIVE_TLS_CA_PATH").ok()?;
|
||||
if path.is_empty() || !std::path::Path::new(&path).is_file() {
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Embedded-CA state for the meta repo: `(desired_contents, changed)`.
|
||||
/// `desired_contents` is the host hive CA cert (empty when self-signed TLS
|
||||
/// is inactive); `changed` is true when it differs from what's already
|
||||
/// embedded at `<dir>/hive-ca.pem`, so a CA rotation re-commits even when
|
||||
/// the flake itself is byte-identical.
|
||||
fn hive_ca_state(dir: &std::path::Path) -> (String, bool) {
|
||||
let on_disk = std::fs::read_to_string(dir.join(HIVE_CA_FILE)).unwrap_or_default();
|
||||
let desired = hive_ca_source()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.unwrap_or_default();
|
||||
let changed = desired != on_disk;
|
||||
(desired, changed)
|
||||
}
|
||||
|
||||
/// Read an agent's applied `flake.lock` and return the subset of
|
||||
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
|
||||
/// Returns an empty vec when the lock is missing or unparsable —
|
||||
|
|
@ -700,7 +760,21 @@ where
|
|||
modules = [
|
||||
input.nixosModules.default
|
||||
{
|
||||
# The harness service inside the container runs as a
|
||||
"#,
|
||||
);
|
||||
// Self-signed TLS trust: embed the hive CA so every agent validates the
|
||||
// gateway's self-signed leaf at build time. `security.pki.certificateFiles`
|
||||
// is build-time, so the CA travels with the flake source — `sync_agents`
|
||||
// writes `./hive-ca.pem` next to flake.nix and stages it. Only the public
|
||||
// CA cert is embedded; the private key never leaves the host. Emitted only
|
||||
// when hive-tls.nix signalled a CA (HIVE_TLS_CA_PATH) and the cert exists,
|
||||
// matching the write condition in `sync_agents` so we never reference a
|
||||
// file we didn't embed.
|
||||
if hive_ca_source().is_some() {
|
||||
out.push_str(" security.pki.certificateFiles = [ ./hive-ca.pem ];\n");
|
||||
}
|
||||
out.push_str(
|
||||
r#" # The harness service inside the container runs as a
|
||||
# non-root unix user named after the agent (`damocles`,
|
||||
# `iris`, `root`, …). UID auto-assigned by NixOS; the
|
||||
# per-agent override here is what makes
|
||||
|
|
@ -1138,4 +1212,53 @@ mod tests {
|
|||
not only the harness service env:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_embeds_hive_ca_when_signalled() {
|
||||
// When hive-tls.nix signals a self-signed hive CA via
|
||||
// HIVE_TLS_CA_PATH (and the cert exists), the agent module must
|
||||
// trust it at build time via security.pki.certificateFiles. Absent
|
||||
// the signal, no reference is emitted (so the flake doesn't point at
|
||||
// a file that was never embedded).
|
||||
//
|
||||
// SAFETY: single-threaded mutation of a process env var the other
|
||||
// tests don't assert the absence of; restored before returning.
|
||||
let ca_file = std::env::temp_dir().join(format!("hive-ca-test-{}.pem", std::process::id()));
|
||||
std::fs::write(
|
||||
&ca_file,
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n",
|
||||
)
|
||||
.expect("write temp CA");
|
||||
|
||||
let render = || {
|
||||
render_flake(
|
||||
"github:example/hyperhive",
|
||||
"path:/nix/store/aaaa-nixpkgs-source",
|
||||
"path:/nix/store/bbbb-nixpkgs-unstable-source",
|
||||
8000,
|
||||
"she/her",
|
||||
&std::collections::HashMap::new(),
|
||||
&[sample_spec("alice", false, 9001)],
|
||||
)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("HIVE_TLS_CA_PATH", &ca_file);
|
||||
}
|
||||
let with_ca = render();
|
||||
unsafe {
|
||||
std::env::remove_var("HIVE_TLS_CA_PATH");
|
||||
}
|
||||
let without_ca = render();
|
||||
let _ = std::fs::remove_file(&ca_file);
|
||||
|
||||
assert!(
|
||||
with_ca.contains("security.pki.certificateFiles = [ ./hive-ca.pem ]"),
|
||||
"CA cert must be wired into certificateFiles when signalled:\n{with_ca}"
|
||||
);
|
||||
assert!(
|
||||
!without_ca.contains("security.pki.certificateFiles"),
|
||||
"no certificateFiles reference without the HIVE_TLS_CA_PATH signal:\n{without_ca}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,5 +153,13 @@ in
|
|||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# Signal the hive-c0re lifecycle that a hive CA exists: it bind-mounts
|
||||
# this file (read-only, the CA cert ONLY — never the key) into each
|
||||
# agent container so agents + their tools can trust the gateway's
|
||||
# self-signed leaf, and the meta flake wires the per-agent trust
|
||||
# bundle. Only the `ca.pem` path is exposed; `ca-key.pem` stays on the
|
||||
# host (an agent that could read it could mint trusted certs).
|
||||
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/ca.pem";
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue