refactor(nix): swarm.peers becomes swarm.hives, a directory of every hive

One attrset describing every hive in the swarm including this one,
identical on every host, with hiveName selecting which entry is us.
"My peers" is derived (swarm.peerHives) rather than declared.

Every field in the old per-host peer list was intrinsic to the hive it
described, never to the pair -- so the list was a directory each host
kept its own copy of. Beyond the deduplication it removes a bug class:
two hosts could hold different endpoints for the same third hive with
nothing to detect the disagreement.

Drops the per-hive caCert. Trust inside a swarm derives from the swarm
root, which every hive chains to. What that genuinely removes is
trusting a hive whose root this swarm does not own -- a cross-swarm
problem that wants a mechanism of its own, not a field that happened to
work.

The matrix container's certificateFiles block goes with it and could
NOT be migrated: that list is read at build time and the swarm root is
a runtime file (its key must never enter the store), so there is no
build-time name to put there. caCert being a nix path was precisely
what made it the build-time distribution channel. Agents are unaffected
-- hive-tls folds the root into the hive trust bundle and the meta
renderer embeds that one file. Tracked separately.

Migration is an assertion plus warnings, not a rename: hives is peers
union {self}, and the set gains a member no existing config has written
down. A rename migrates a name and a default can re-root a meaning;
neither can conjure a new member. The warning explains, the self-entry
assertion stops the build.
This commit is contained in:
atlas 2026-08-05 20:18:22 +02:00
commit 433b294099
19 changed files with 484 additions and 293 deletions

View file

@ -425,7 +425,7 @@ pub enum WgCmd {
},
/// Print the nix to add a peer hive to the mesh.
Peer {
/// Peer hive's DNS domain (the `swarm.peers` attrset key).
/// Peer hive's DNS domain (its `swarm.hives` entry's `domain`)
domain: String,
/// Peer's WireGuard public key (from its `hivectl wg init`).
#[arg(long)]

View file

@ -16,9 +16,10 @@ const WG_INTERFACE: &str = "wg-hive";
/// Host path of this hive's TLS trust bundle (matches the
/// `services.hyperhive.tls.stateDir` default in hive-tls.nix). Its
/// existence means the gateway serves a self-signed, hive-CA-signed leaf,
/// so a federating peer needs this via `swarm.peers.<d>.caCert`. Absent
/// = ACME / operator cert (trusted by the default CA bundle, no `caCert`).
/// existence means the gateway serves a self-signed, hive-CA-signed
/// leaf, so a federating peer needs an anchor for it — which is now the
/// swarm root, not a per-hive CA. Absent = ACME / operator cert, trusted
/// by the default CA bundle with nothing to distribute.
///
/// The bundle rather than `ca.pem`: the hive CA is an intermediate under
/// the swarm root, so `ca.pem` alone is not a chain a peer can validate
@ -28,6 +29,26 @@ const WG_INTERFACE: &str = "wg-hive";
/// path with no mode to branch on.
const HIVE_TLS_TRUST_BUNDLE_PATH: &str = "/var/lib/hive-tls/trust-bundle.pem";
/// Host path of the swarm root CA cert (matches the
/// `services.hyperhive.swarm.ca.stateDir` default in swarm-ca.nix). The
/// anchor a whole swarm shares: install it once per host and every
/// present *and future* hive under it validates, which is what replaced
/// the per-hive CA pinning.
const SWARM_CA_ROOT_PATH: &str = "/var/lib/swarm-ca/root.pem";
/// Attrset key for a hive in `services.hyperhive.swarm.hives`, derived
/// from its domain's first DNS label.
///
/// A hive occupies `<hiveName>.<swarm.domain>`, so the first label *is*
/// the hive name in any deployment that hasn't overridden `domain` by
/// hand. Where it has, a wrong key fails loudly rather than quietly: on
/// that hive's own host the `hives.<hiveName>` assertion fires, because
/// the directory is supposed to be the same attrset everywhere. The
/// snippets below say so rather than presenting the guess as fact.
fn hive_key(domain: &str) -> &str {
domain.split('.').next().unwrap_or(domain)
}
/// Best-effort query for this hive's domain from the running daemon
/// (`HostRequest::Urls`, which reads `HYPERHIVE_HIVE_DOMAIN` from c0re's
/// service env). `None` when the daemon is unreachable or the domain is
@ -143,25 +164,29 @@ fn wg_pubkey(privkey: &[u8]) -> Result<String> {
/// Pure output (no fallible work), so it returns `()`; the dispatch arm
/// wraps it in `Ok` to match the sibling verbs.
pub(crate) fn wg_peer(domain: &str, pubkey: &str, address: &str, endpoint: Option<&str>) {
let key = hive_key(domain);
println!("Add to this hive's NixOS config:");
println!(" services.hyperhive.swarm.peers.\"{domain}\" = {{");
println!(" # `hives` describes the whole swarm and is meant to be the same");
println!(" # attrset on every host — add this entry to all of them.");
println!(" services.hyperhive.swarm.hives.\"{key}\" = {{");
println!(" domain = \"{domain}\";");
println!(" wireguardPublicKey = \"{pubkey}\";");
println!(" wireguardAddress = \"{address}\";");
if let Some(ep) = endpoint {
println!(" wireguardEndpoint = \"{ep}\";");
}
println!(" }};");
println!(" # the key must be that hive's services.hyperhive.hiveName");
}
/// `peer-config` — print the `swarm.peers."<domain>"` block a peer
/// operator pastes to federate with THIS hive, plus a `cp` line for the
/// CA when this hive is self-signed. Reads local state only (the TLS CA
/// cert presence + the wg key); prints, never mutates.
/// `peer-config` — print the `swarm.hives."<name>"` block a peer
/// operator pastes to federate with THIS hive, plus the swarm-root
/// install step when this hive serves a self-signed chain. Reads local
/// state only (the TLS trust bundle's presence + the wg key); prints,
/// never mutates.
pub(crate) fn peer_config(domain: &str, wg_address: Option<&str>, wg_endpoint: Option<&str>) {
let self_signed = Path::new(HIVE_TLS_TRUST_BUNDLE_PATH).exists();
// CA filename derived from the first DNS label so multiple peers'
// certs don't collide in the operator's config dir.
let ca_file = format!("{}-ca.pem", domain.split('.').next().unwrap_or("peer"));
let key = hive_key(domain);
// WireGuard public key, when this hive has a mesh key. Best-effort:
// a missing key or absent `wg` binary just omits the mesh lines.
let wg_pub = std::fs::read(WG_KEY_PATH)
@ -169,17 +194,19 @@ pub(crate) fn peer_config(domain: &str, wg_address: Option<&str>, wg_endpoint: O
.and_then(|k| wg_pubkey(&k).ok());
if self_signed {
println!("# 1. copy this hive's CA cert next to the peer's config:");
println!("cp {HIVE_TLS_TRUST_BUNDLE_PATH} ./{ca_file}");
// One anchor for the whole swarm, installed once per host — not
// a file per peer. That is the point of the hierarchy: a hive
// joining later needs no edit on the hives already running.
println!("# 1. install the SWARM ROOT on the peer host (once, not per hive):");
println!("scp {SWARM_CA_ROOT_PATH} <peer-host>:{SWARM_CA_ROOT_PATH}");
println!("# (skip if that host already has the swarm root)");
println!();
println!("# 2. paste into the peer hive's NixOS config:");
} else {
println!("# paste into the peer hive's NixOS config:");
}
println!("services.hyperhive.swarm.peers.\"{domain}\" = {{");
if self_signed {
println!(" caCert = ./{ca_file};");
}
println!("services.hyperhive.swarm.hives.\"{key}\" = {{");
println!(" domain = \"{domain}\";");
if let Some(pk) = &wg_pub {
println!(" wireguardPublicKey = \"{pk}\";");
}
@ -190,9 +217,11 @@ pub(crate) fn peer_config(domain: &str, wg_address: Option<&str>, wg_endpoint: O
println!(" wireguardEndpoint = \"{ep}\";");
}
println!("}};");
println!("# the key must be this hive's services.hyperhive.hiveName, and the");
println!("# same entry belongs in every hive's config — `hives` is the swarm.");
if !self_signed {
println!(
"# (this hive's cert chains to a public CA — no `caCert` needed; \
"# (this hive's cert chains to a public CA — nothing to install; \
it's trusted by the default bundle.)"
);
}