`swarm.*` is what a hive needs to be a *client* of the swarm; the mesh is none of it. A peer needs this host's `wireguardEndpoint` -- the roster entry in swarm.nix, which stays -- and nothing about the interface this host brings up. The module already said so: "plain host networking that a machine which runs no hive at all still needs." All five options move, so the namespace relocates rather than splitting. `listenPort` is the one that reads the other way: it is what this host *binds*, while the port a peer *dials* lives inside `wireguardEndpoint`. Declared in swarm-wireguard.nix under the `deploy.*` path, following swarm-victorialogs.nix; deploy.nix carries only the renames, per its own "a single file to delete when the deprecation window closes". Deliberately NOT added to deploy.nix's own options block: every entry there is a swarm service this host deploys, and the mesh is host networking. hivectl/src/wg.rs generates the config snippet an operator pastes, so it moves too -- otherwise the tool's own output trips the deprecation warning. module-eval gains a case that configures a host through the OLD path and asserts the rendered wg-hive interface, because the new path evaluates fine without the shim: dropping it reads as a clean tree.
245 lines
11 KiB
Rust
245 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 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 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.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");
|
|
}
|
|
|
|
/// `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.deploy.wireguard.enable = true, then deploy)"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|