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.
244 lines
11 KiB
Rust
244 lines
11 KiB
Rust
//! `hivectl wg` / `peer-config` — WireGuard mesh helpers: generate this
|
|
//! hive's key + the nix snippets to enable the mesh, add a peer, print the
|
|
//! block a peer pastes to federate with us, and show the live interface.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
|
|
use crate::util::query_hive_urls;
|
|
|
|
/// Host path of this hive's WireGuard private key (matches the
|
|
/// `privateKeyFile` example in the swarm.wireguard nix options).
|
|
const WG_KEY_PATH: &str = "/etc/wireguard/hive.key";
|
|
/// The mesh interface name hive-c0re's nix module brings up.
|
|
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 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
|
|
/// against (openssl will not stop at a trusted non-self-signed cert).
|
|
/// The bundle is whatever this hive is currently rooted at — the CA
|
|
/// alone on a hive that predates the swarm root — so this stays a single
|
|
/// 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
|
|
/// unset — for the callers that treat both as "skip the optional block".
|
|
/// Use [`require_hive_domain`] where the distinction matters: it keeps the
|
|
/// connect error (and its hint) instead of flattening it away.
|
|
async fn query_hive_domain(socket: &Path) -> Option<String> {
|
|
query_hive_urls(socket).await.ok().flatten()?.domain
|
|
}
|
|
|
|
/// Require this hive's domain from the daemon for snippet generation.
|
|
/// Errors with a clear hint when it can't be resolved, so `peer-config`
|
|
/// never silently emits a wrong key. An unreachable daemon propagates its
|
|
/// own (classified) connect error; only a *reachable* daemon with no domain
|
|
/// gets the config hint.
|
|
pub(crate) async fn require_hive_domain(socket: &Path) -> Result<String> {
|
|
query_hive_urls(socket)
|
|
.await
|
|
.context("could not determine this hive's domain from the daemon")?
|
|
.and_then(|u| u.domain)
|
|
.context("the daemon reported no domain — set `services.hyperhive.domain`")
|
|
}
|
|
|
|
/// `wg init` — generate (if absent) the hive's WireGuard key, print its
|
|
/// public key + the nix snippet to enable the mesh, then (best-effort)
|
|
/// the `peer-config` block peers paste to federate with this hive, so a
|
|
/// fresh setup is one command. The domain comes from the daemon; if it
|
|
/// can't be resolved, the peer block is skipped (init still succeeds —
|
|
/// its core job is enabling the mesh locally).
|
|
pub(crate) async fn wg_init(socket: &Path, address: Option<&str>) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let key_path = Path::new(WG_KEY_PATH);
|
|
if key_path.exists() {
|
|
println!(
|
|
"WireGuard private key already present at {WG_KEY_PATH} — reusing (not regenerating)."
|
|
);
|
|
} else {
|
|
if let Some(parent) = key_path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("create {}", parent.display()))?;
|
|
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).ok();
|
|
}
|
|
let out = std::process::Command::new("wg")
|
|
.arg("genkey")
|
|
.output()
|
|
.context("run `wg genkey` (is wireguard-tools installed?)")?;
|
|
if !out.status.success() {
|
|
bail!(
|
|
"wg genkey failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
std::fs::write(key_path, &out.stdout)
|
|
.with_context(|| format!("write {}", key_path.display()))?;
|
|
std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o400))
|
|
.with_context(|| format!("chmod 400 {}", key_path.display()))?;
|
|
println!("Generated WireGuard private key at {WG_KEY_PATH} (0400).");
|
|
}
|
|
|
|
let privkey =
|
|
std::fs::read(key_path).with_context(|| format!("read {}", key_path.display()))?;
|
|
let pubkey = wg_pubkey(&privkey)?;
|
|
let addr = address.unwrap_or("<MESH_ADDRESS e.g. 10.42.0.1/32>");
|
|
println!("\nPublic key (share this with peer hives — they pass it to `hivectl wg peer`):");
|
|
println!(" {pubkey}");
|
|
println!("\nAdd to this hive's NixOS config:");
|
|
println!(" services.hyperhive.swarm.wireguard = {{");
|
|
println!(" enable = true;");
|
|
println!(" privateKeyFile = \"{WG_KEY_PATH}\";");
|
|
println!(" address = \"{addr}\";");
|
|
println!(" # listenPort = 51820; # default");
|
|
println!(" }};");
|
|
|
|
// Also print the block a peer pastes to federate with us (CA + this
|
|
// mesh key) — one-stop setup. Domain comes from the daemon;
|
|
// best-effort, so init still succeeds when it can't be resolved.
|
|
if let Some(d) = query_hive_domain(socket).await {
|
|
println!();
|
|
peer_config(&d, address, None);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Derive a WireGuard public key from a private key by piping it through
|
|
/// `wg pubkey`.
|
|
fn wg_pubkey(privkey: &[u8]) -> Result<String> {
|
|
use std::io::Write as _;
|
|
use std::process::{Command, Stdio};
|
|
let mut child = Command::new("wg")
|
|
.arg("pubkey")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.context("run `wg pubkey` (is wireguard-tools installed?)")?;
|
|
child
|
|
.stdin
|
|
.take()
|
|
.context("wg pubkey: stdin unavailable")?
|
|
.write_all(privkey)
|
|
.context("write private key to `wg pubkey`")?;
|
|
let out = child.wait_with_output().context("wait for `wg pubkey`")?;
|
|
if !out.status.success() {
|
|
bail!(
|
|
"wg pubkey failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
}
|
|
|
|
/// `wg peer` — print the nix snippet to add a peer hive to the mesh.
|
|
/// 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!(" # `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.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();
|
|
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)
|
|
.ok()
|
|
.and_then(|k| wg_pubkey(&k).ok());
|
|
|
|
if self_signed {
|
|
// 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.hives.\"{key}\" = {{");
|
|
println!(" domain = \"{domain}\";");
|
|
if let Some(pk) = &wg_pub {
|
|
println!(" wireguardPublicKey = \"{pk}\";");
|
|
}
|
|
if let Some(addr) = wg_address {
|
|
println!(" wireguardAddress = \"{addr}\";");
|
|
}
|
|
if let Some(ep) = wg_endpoint {
|
|
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 — nothing to install; \
|
|
it's trusted by the default bundle.)"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `wg status` — show the live mesh interface (`wg show wg-hive`),
|
|
/// inheriting stdout so the operator sees it directly.
|
|
pub(crate) fn wg_status() -> Result<()> {
|
|
let status = std::process::Command::new("wg")
|
|
.args(["show", WG_INTERFACE])
|
|
.status()
|
|
.context("run `wg show` (is wireguard-tools installed?)")?;
|
|
if !status.success() {
|
|
bail!(
|
|
"`wg show {WG_INTERFACE}` failed — is the mesh enabled + up? \
|
|
(services.hyperhive.swarm.wireguard.enable = true, then deploy)"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|