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 on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||||
let initial = !dir.join(".git").exists();
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +98,16 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
||||||
std::fs::write(&flake_path, &new_flake)
|
std::fs::write(&flake_path, &new_flake)
|
||||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
.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
|
// Reconcile topology.json against the live agent set — adds
|
||||||
// entries for newly-spawned agents (default: manager as parent,
|
// entries for newly-spawned agents (default: manager as parent,
|
||||||
// manager itself as root) and drops removed agents. Operator
|
// 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
|
// contain '/flake.nix'". Lock then commit once with both
|
||||||
// flake.nix and flake.lock — single commit per change.
|
// flake.nix and flake.lock — single commit per change.
|
||||||
git(&dir, &["add", "flake.nix"]).await?;
|
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
|
// Stage topology.json on every sync (regenerated by reconcile
|
||||||
// above when the agent set changed). git add is a no-op when the
|
// above when the agent set changed). git add is a no-op when the
|
||||||
// file content is unchanged.
|
// 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() {
|
.filter_map(|f| match f.as_str() {
|
||||||
"flake.nix" => Some("flake"),
|
"flake.nix" => Some("flake"),
|
||||||
"flake.lock" => Some("lock"),
|
"flake.lock" => Some("lock"),
|
||||||
|
"hive-ca.pem" => Some("hive-ca"),
|
||||||
"topology.json" => Some("topology"),
|
"topology.json" => Some("topology"),
|
||||||
"capabilities.json" => Some("capabilities"),
|
"capabilities.json" => Some("capabilities"),
|
||||||
"tool-groups.json" => Some("tool-groups"),
|
"tool-groups.json" => Some("tool-groups"),
|
||||||
|
|
@ -565,6 +590,41 @@ fn forwarded_env_vars() -> Vec<(&'static str, String)> {
|
||||||
.collect()
|
.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
|
/// Read an agent's applied `flake.lock` and return the subset of
|
||||||
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
|
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
|
||||||
/// Returns an empty vec when the lock is missing or unparsable —
|
/// Returns an empty vec when the lock is missing or unparsable —
|
||||||
|
|
@ -700,7 +760,21 @@ where
|
||||||
modules = [
|
modules = [
|
||||||
input.nixosModules.default
|
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`,
|
# non-root unix user named after the agent (`damocles`,
|
||||||
# `iris`, `root`, …). UID auto-assigned by NixOS; the
|
# `iris`, `root`, …). UID auto-assigned by NixOS; the
|
||||||
# per-agent override here is what makes
|
# per-agent override here is what makes
|
||||||
|
|
@ -1138,4 +1212,53 @@ mod tests {
|
||||||
not only the harness service env:\n{out}"
|
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
|
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