//! `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 `.`, 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.` 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 { 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 { 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(""); 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 { 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.""` 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} :{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(()) }