hivectl: wireguard mesh setup verbs (#1756)
One-time-setup convenience for the inter-hive WireGuard mesh (services.hyperhive.swarm) so nobody has to remember the wg dance: - hivectl wg init [--address X] — generate (if absent) the hive's private key at /etc/wireguard/hive.key (0400, never clobbered), derive + print the public key, and print the swarm.wireguard nix snippet to enable the mesh. - hivectl wg peer <domain> --pubkey --address [--endpoint] — print the swarm.peers.<domain> nix snippet to add a remote hive. - hivectl wg status — wrap wg show wg-hive. Hybrid model per the design: the verb owns the imperative state (the key file), the operator pastes the printed nix into host config (kept in git) — nothing mutates declarative config behind their back. hivectl-only (root host ops, like the gateway htpasswd verbs); no priv/wire/c0re changes. flake: wrap hivectl with wireguard-tools on PATH so wg resolves even before the mesh config (which would otherwise pull it in) exists — wg init is the first setup step. Add clippy.toml doc-valid-idents for the WireGuard proper noun. Regenerate hivectl-cli.md.
This commit is contained in:
parent
c7612dcf2b
commit
5336be7813
4 changed files with 252 additions and 4 deletions
|
|
@ -76,6 +76,19 @@ enum Cmd {
|
|||
#[command(subcommand)]
|
||||
cmd: AgentsCmd,
|
||||
},
|
||||
/// WireGuard inter-hive mesh setup helpers (`services.hyperhive.swarm`).
|
||||
///
|
||||
/// One-time-setup convenience so nobody has to remember the `wg` dance:
|
||||
/// `wg init` generates + stores this hive's private key and prints the
|
||||
/// public key plus the nix snippet to enable the mesh; `wg peer` prints
|
||||
/// the snippet to add a remote hive; `wg status` wraps `wg show`. The
|
||||
/// verbs own the imperative state (the key file); the printed nix goes
|
||||
/// into the operator's host config (kept in git), so nothing here mutates
|
||||
/// declarative config behind the operator's back.
|
||||
Wg {
|
||||
#[command(subcommand)]
|
||||
cmd: WgCmd,
|
||||
},
|
||||
/// Open an interactive Claude session inside an agent container.
|
||||
///
|
||||
/// Replaces the current process with `machinectl shell
|
||||
|
|
@ -381,6 +394,40 @@ enum GatewayCmd {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum WgCmd {
|
||||
/// Generate (if absent) this hive's WireGuard private key, print its
|
||||
/// public key, and print the nix snippet to enable the mesh. Idempotent:
|
||||
/// an existing key is reused, never clobbered (clobbering would break a
|
||||
/// live mesh). Share the printed public key with peer hives.
|
||||
Init {
|
||||
/// This hive's mesh address (e.g. `10.42.0.1/32`) to bake into the
|
||||
/// printed snippet. Omit to get a placeholder you fill in.
|
||||
#[arg(long)]
|
||||
address: Option<String>,
|
||||
},
|
||||
/// Print the nix snippet to add a peer hive to the mesh. Pure output —
|
||||
/// paste it into this hive's config. Get `<pubkey>` from the peer's
|
||||
/// `hivectl wg init`.
|
||||
Peer {
|
||||
/// Peer hive's DNS domain (the `swarm.peers` attrset key).
|
||||
domain: String,
|
||||
/// Peer's WireGuard public key (from its `hivectl wg init`).
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
/// Peer's mesh address (e.g. `10.42.0.2/32`).
|
||||
#[arg(long)]
|
||||
address: String,
|
||||
/// Peer's `host:port` endpoint (omit for a peer that only dials out,
|
||||
/// e.g. one behind NAT — it must set an endpoint pointing back here).
|
||||
#[arg(long)]
|
||||
endpoint: Option<String>,
|
||||
},
|
||||
/// Show the live mesh interface state (`wg show wg-hive`). Requires the
|
||||
/// mesh to be enabled + up.
|
||||
Status,
|
||||
}
|
||||
|
||||
/// Default host admin socket path. Must match `hive-c0re`'s default in
|
||||
/// `main.rs` (`/run/hyperhive/host.sock`) — the daemon binds there and
|
||||
/// `hivectl agents` connects to it.
|
||||
|
|
@ -445,6 +492,19 @@ async fn main() -> Result<()> {
|
|||
AgentsCmd::Restart { name } => agents_restart(&socket, &name).await,
|
||||
AgentsCmd::RestartAll => agents_restart_all(&socket).await,
|
||||
},
|
||||
Cmd::Wg { cmd } => match cmd {
|
||||
WgCmd::Init { address } => wg_init(address.as_deref()),
|
||||
WgCmd::Peer {
|
||||
domain,
|
||||
pubkey,
|
||||
address,
|
||||
endpoint,
|
||||
} => {
|
||||
wg_peer(&domain, &pubkey, &address, endpoint.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
WgCmd::Status => wg_status(),
|
||||
},
|
||||
Cmd::Stop { scope, graceful } => stop(&socket, scope.to_scope(), graceful).await,
|
||||
Cmd::Start { scope } => start(&socket, scope.to_scope()).await,
|
||||
Cmd::Restart { scope, graceful } => restart(&socket, scope.to_scope(), graceful).await,
|
||||
|
|
@ -469,6 +529,118 @@ fn generate_completions(shell: clap_complete::Shell) {
|
|||
clap_complete::generate(shell, &mut cmd, "hivectl", &mut std::io::stdout());
|
||||
}
|
||||
|
||||
/// 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";
|
||||
|
||||
/// `wg init` — generate (if absent) the hive's WireGuard key, print its
|
||||
/// public key + the nix snippet to enable the mesh.
|
||||
fn wg_init(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!(" }};");
|
||||
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.
|
||||
fn wg_peer(domain: &str, pubkey: &str, address: &str, endpoint: Option<&str>) {
|
||||
println!("Add to this hive's NixOS config:");
|
||||
println!(" services.hyperhive.swarm.peers.\"{domain}\" = {{");
|
||||
println!(" wireguardPublicKey = \"{pubkey}\";");
|
||||
println!(" wireguardAddress = \"{address}\";");
|
||||
if let Some(ep) = endpoint {
|
||||
println!(" wireguardEndpoint = \"{ep}\";");
|
||||
}
|
||||
println!(" }};");
|
||||
}
|
||||
|
||||
/// `wg status` — show the live mesh interface (`wg show wg-hive`),
|
||||
/// inheriting stdout so the operator sees it directly.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// True when `name` matches an existing hyperhive agent — i.e. it has a
|
||||
/// persistent state dir under `/var/lib/hyperhive/agents/`. We use the
|
||||
/// state dir (not the live container list) so kept-state tombstones
|
||||
|
|
|
|||
Loading…
Reference in a new issue