//! `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 deploy.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.deploy.hive-controller.tls.stateDir` default in /// hive-tls.nix). Its existence means the gateway is in self-signed mode, /// serving a leaf the secret store issued, so a federating peer needs an /// anchor for it. Absent = ACME / operator cert, trusted by the default CA /// bundle with nothing to distribute. const HIVE_TLS_TRUST_BUNDLE_PATH: &str = "/var/lib/hive-tls/trust-bundle.pem"; /// Host path of the secret store's `pki` root (hive-tls.nix's /// `$svcroot`, folded into the trust bundle). The anchor a whole swarm /// shares: every hive's gateway leaf and the swarm's service names are /// issued under it, so a peer host installs it once and every present /// *and future* hive validates. const STORE_PKI_ROOT_PATH: &str = "/var/lib/hive-tls/swarm-services-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() ); } write_key_0400(key_path, &out.stdout)?; 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.deploy.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(()) } /// Write `key` to a sibling temp file created `0400` and rename it over /// `path`, so the key is never readable by anyone else and a crash never /// leaves half of it at `path`. Errors carry paths, never the key. fn write_key_0400(path: &Path, key: &[u8]) -> Result<()> { use std::io::Write as _; use std::os::unix::fs::OpenOptionsExt as _; let mut tmp_name = path .file_name() .with_context(|| format!("{} has no file name", path.display()))? .to_owned(); tmp_name.push(".tmp"); let tmp = path.with_file_name(tmp_name); // A temp file left by a crashed earlier write would fail `create_new`. let _ = std::fs::remove_file(&tmp); let mut f = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o400) .open(&tmp) .with_context(|| format!("create {}", tmp.display()))?; f.write_all(key) .and_then(|()| f.sync_all()) .with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, path).with_context(|| format!("rename onto {}", path.display())) } /// 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"); } /// The `peer-config` step carrying the secret store's `pki` root: the /// certificate itself, or where it will appear when this hive has not /// fetched it yet. fn store_root_step(pem: Option<&str>) -> String { const HEAD: &str = "# 1. trust the SECRET STORE's pki root on the peer host (once, not per hive);\n\ # every hive's gateway leaf and the swarm's service names are issued under it:\n"; match pem.map(str::trim_end).filter(|p| !p.is_empty()) { Some(p) => format!( "{HEAD}{p}\n\ # (a self-signed hive in this swarm fetches it itself, into {STORE_PKI_ROOT_PATH})\n" ), None => format!( "{HEAD}# not on this host yet: swarm-services-cert.service writes it to\n\ # {STORE_PKI_ROOT_PATH}; rerun once that unit has succeeded\n" ), } } /// `peer-config` — print the `swarm.hives.""` block a peer /// operator pastes to federate with THIS hive, plus the store-root step /// when this hive serves a self-signed chain. Reads /// local state only (the TLS trust bundle, the store root + 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 { let store_root = std::fs::read_to_string(STORE_PKI_ROOT_PATH).ok(); print!("{}", store_root_step(store_root.as_deref())); 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.deploy.wireguard.enable = true, then deploy)" ); } Ok(()) } #[cfg(test)] mod tests { use super::{STORE_PKI_ROOT_PATH, store_root_step, write_key_0400}; const PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----"; /// The certificate is printed whole, on lines of its own, so a peer /// operator can copy it out of the output as a PEM file. #[test] fn store_root_is_printed_verbatim() { let s = store_root_step(Some(&format!("{PEM}\n"))); assert!(s.contains(&format!("\n{PEM}\n")), "{s}"); } /// A hive that has not fetched the root yet says where it will be, /// rather than printing an empty step. #[test] fn missing_store_root_names_the_path_and_the_unit() { for pem in [None, Some(""), Some("\n")] { let s = store_root_step(pem); assert!(s.contains(STORE_PKI_ROOT_PATH), "{s}"); assert!(s.contains("swarm-services-cert.service"), "{s}"); assert!(!s.contains("BEGIN CERTIFICATE"), "{s}"); } } /// The key reaches its path only by a rename from a sibling temp: with /// the temp path blocked, the write fails and the existing key is left /// byte-for-byte as it was. Once unblocked, the key lands at `0400` with /// its bytes intact and no temp file beside it. #[test] fn key_is_written_through_a_temp_and_lands_0400() { use std::os::unix::fs::PermissionsExt as _; let dir = std::env::temp_dir().join(format!("hivectl-wg-key-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("mkdir"); let path = dir.join("hive.key"); let tmp = dir.join("hive.key.tmp"); std::fs::write(&path, "old-key\n").expect("seed"); std::fs::create_dir(&tmp).expect("block tmp"); let blocked = write_key_0400(&path, b"key-bytes\n"); assert!(blocked.is_err(), "a blocked temp must fail the write"); assert_eq!(std::fs::read(&path).expect("read seed"), b"old-key\n"); std::fs::remove_dir(&tmp).expect("unblock tmp"); write_key_0400(&path, b"key-bytes\n").expect("write"); let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777; let content = std::fs::read(&path).expect("read"); let entries: Vec<_> = std::fs::read_dir(&dir) .expect("readdir") .map(|e| e.expect("entry").file_name()) .collect(); std::fs::remove_dir_all(&dir).ok(); assert_eq!(mode, 0o400); assert_eq!(content, b"key-bytes\n"); assert_eq!(entries, [std::ffi::OsString::from("hive.key")]); } }