Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hivectl/src/wg.rs
atlas 93f1a76ea7 swarm CA: retire the host-held root CA, the store holds the only anchor
The secret store's pki root issues every hive's gateway leaf and the
swarm's service names, so the root swarm-ca.service generated on the
host anchors nothing and its key is a secret outside the store.

- swarm-ca.nix is gone: swarm-ca.service, root.pem and root-key.pem,
  and the swarm.ca.{autoConfigure,stateDir,validityDays} options. A
  definition of any of them fails evaluation with a removal message
  (swarm-ca-removed.nix), and a tmpfiles rule deletes /var/lib/swarm-ca
  on every host. singleHostSwarm no longer sets a swarm.* option.
- hive-tls-ca drops the $root slot: trust-bundle.pem holds the store's
  pki root and a hive CA's public ca.pem/ca-previous.pem only.
- hivectl peer-config no longer prints the scp line for the swarm root;
  the store's pki root is the one step.
- The gateway's cert import no longer stands a hive leaf or a throwaway
  in for a missing swarm-services leaf where it Requires= the issuing
  unit: it fails, naming swarm-services-cert.service. The stand-in stays
  where the gateway does not wait (no swarm name, or the store's UI
  only, whose passthrough must start with the store sealed).

Comments and docs that named swarm-ca.nix, swarm.ca.* or the host root
now name the store's pki root.

Refs #4588
2026-10-05 21:17:10 +02:00

278 lines
12 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 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 `<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.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(())
}
/// 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");
}
/// 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."<name>"` 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};
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}");
}
}
}