diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..17565808 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +# Proper nouns / product names that clippy's `doc_markdown` lint would +# otherwise flag as un-backticked identifiers in doc-comments. `".."` +# keeps clippy's built-in default list (GitHub, OAuth, …) and extends it. +doc-valid-idents = ["WireGuard", ".."] diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index db24e9a9..f64c6e62 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -20,6 +20,10 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agents`↴](#hivectl-agents) * [`hivectl agents restart`↴](#hivectl-agents-restart) * [`hivectl agents restart-all`↴](#hivectl-agents-restart-all) +* [`hivectl wg`↴](#hivectl-wg) +* [`hivectl wg init`↴](#hivectl-wg-init) +* [`hivectl wg peer`↴](#hivectl-wg-peer) +* [`hivectl wg status`↴](#hivectl-wg-status) * [`hivectl choom`↴](#hivectl-choom) * [`hivectl stop`↴](#hivectl-stop) * [`hivectl start`↴](#hivectl-start) @@ -38,6 +42,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `matrix` — matrix-tuwunel user provisioning. Manual entry point to the same idempotent flow c0re runs automatically at boot (`matrix::ensure_all`) — useful when the boot-time sweep skipped an agent (e.g. matrix container wasn't up yet) or to re-register after wiping a token file * `gateway` — Gateway htpasswd user management. Add, remove, or list users in an htpasswd file used by the gateway's HTTP Basic auth (`services.hyperhive.gateway.auth`). Credentials are stored as `BCrypt` hashes — no extra service or PAM required * `agents` — Agent container management. Requires the hive-c0re daemon to be running (connects to the host admin socket) +* `wg` — WireGuard inter-hive mesh setup helpers (`services.hyperhive.swarm`) * `choom` — Open an interactive Claude session inside an agent container * `stop` — Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request * `start` — Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon @@ -277,6 +282,60 @@ Stop and restart ALL managed agent containers in sequence. Iterates the live con +## `hivectl wg` + +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. + +**Usage:** `hivectl wg ` + +###### **Subcommands:** + +* `init` — 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 +* `peer` — Print the nix snippet to add a peer hive to the mesh. Pure output — paste it into this hive's config. Get `` from the peer's `hivectl wg init` +* `status` — Show the live mesh interface state (`wg show wg-hive`). Requires the mesh to be enabled + up + + + +## `hivectl wg init` + +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 + +**Usage:** `hivectl wg init [OPTIONS]` + +###### **Options:** + +* `--address
` — 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 + + + +## `hivectl wg peer` + +Print the nix snippet to add a peer hive to the mesh. Pure output — paste it into this hive's config. Get `` from the peer's `hivectl wg init` + +**Usage:** `hivectl wg peer [OPTIONS] --pubkey --address
` + +###### **Arguments:** + +* `` — Peer hive's DNS domain (the `swarm.peers` attrset key) + +###### **Options:** + +* `--pubkey ` — Peer's WireGuard public key (from its `hivectl wg init`) +* `--address
` — Peer's mesh address (e.g. `10.42.0.2/32`) +* `--endpoint ` — 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) + + + +## `hivectl wg status` + +Show the live mesh interface state (`wg show wg-hive`). Requires the mesh to be enabled + up + +**Usage:** `hivectl wg status` + + + ## `hivectl choom` Open an interactive Claude session inside an agent container. diff --git a/flake.nix b/flake.nix index e511c5b0..d9d3fbee 100644 --- a/flake.nix +++ b/flake.nix @@ -164,10 +164,14 @@ default = craneLib.buildPackage { src = cleanSrc; inherit cargoArtifacts; - # `installShellFiles` provides `installShellCompletion` for the - # postInstall below; appended (not in the shared set) so it's a - # build input only of this binary derivation. - nativeBuildInputs = nativeBuildInputs ++ [ pkgs.installShellFiles ]; + # `installShellFiles` provides `installShellCompletion` and + # `makeWrapper` provides `wrapProgram` for the postInstall below; + # appended (not in the shared set) so they're build inputs only of + # this binary derivation. + nativeBuildInputs = nativeBuildInputs ++ [ + pkgs.installShellFiles + pkgs.makeWrapper + ]; pname = "hyperhive-workspace"; version = "0.1.0"; meta.description = "hyperhive workspace (hive-c0re, hive-ag3nt, hive-root)"; @@ -178,11 +182,20 @@ # `$out/share/{zsh/site-functions,bash-completion,fish}/…`; an # operator gets working completion as soon as hivectl is in their # system/user profile with the shell's completion enabled. + # + # Then wrap hivectl with `wireguard-tools` on PATH so its `wg` + # subcommands (`wg init`/`peer`/`status`) work even before the + # WireGuard mesh is configured — `wg init` is the *first* setup + # step, run before `swarm.wireguard.enable` (which would otherwise + # be what pulls wireguard-tools onto the system). Completion + # generation runs first since wrapProgram renames the real binary. postInstall = '' installShellCompletion --cmd hivectl \ --bash <("$out/bin/hivectl" completions bash) \ --zsh <("$out/bin/hivectl" completions zsh) \ --fish <("$out/bin/hivectl" completions fish) + wrapProgram "$out/bin/hivectl" \ + --prefix PATH : ${pkgs.wireguard-tools}/bin ''; }; # Bundled browser assets — see ./nix/frontend.nix. Output is diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 38c6c893..aabf5cc6 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -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, + }, + /// Print the nix snippet to add a peer hive to the mesh. Pure output — + /// paste it into this hive's config. Get `` 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, + }, + /// 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(""); + 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 { + 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