//! `hivectl` — operator-facing host CLI for hyperhive. //! //! Sibling binary to the `hive-c0re` daemon. Where `hive-c0re`'s //! subcommands focus on the broker / approval / topology surface //! (`spawn`, `kill`, `rebuild`, `approve` …), `hivectl` covers //! host-side administration — both manual provisioning operations //! that work without the daemon running (forge, matrix, gateway) AND //! daemon-assisted agent management (agents restart/restart-all) that //! goes through the host admin socket. //! //! Verbs read configuration off the same on-disk paths c0re uses //! (`/var/lib/hyperhive/forge-core-token`, //! `/var/lib/hyperhive/matrix-register-token`, per-agent state //! dirs) and reuse the `forge` / `matrix` modules from the //! `hive-c0re` lib — single source of truth, no duplication. use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use clap::{Parser, Subcommand}; use hive_c0re::coordinator::Coordinator; #[derive(Parser)] #[command( name = "hivectl", about = "hyperhive host CLI — operator-facing administration", long_about = "\ Sibling to the `hive-c0re` daemon binary. Covers host-side admin \ operations that don't go through the broker — manual user \ provisioning on the bundled forge + matrix containers, plus future \ recovery / debugging verbs.\ " )] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// Forgejo user provisioning. Manual entry point to the same /// idempotent flow c0re runs automatically at boot /// (`forge::ensure_all`) — useful for recovery, ad-hoc reprovisioning, /// or single-agent fixes without bouncing the daemon. Forge { #[command(subcommand)] cmd: ForgeCmd, }, /// 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. Matrix { #[command(subcommand)] cmd: MatrixCmd, }, /// 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. Gateway { #[command(subcommand)] cmd: GatewayCmd, }, /// Agent container management. Requires the hive-c0re daemon to be /// running (connects to the host admin socket). Agents { #[command(subcommand)] cmd: AgentsCmd, }, } #[derive(Subcommand)] enum ForgeCmd { /// Create or refresh the Forgejo account + token for ``. /// /// When `` matches an existing agent (i.e. it has a state /// dir under `/var/lib/hyperhive/agents/`), persists the token to /// `/forge-token` (idempotent: re-mints + rewrites every /// call so the on-disk scope matches the current /// `forge::TOKEN_SCOPES`). /// /// When `` is **not** an agent (a human or any other /// non-container account), creates the forgejo user and prints the /// freshly-minted token to stdout — no `/var/lib/hyperhive/agents/` /// directory is created for the user. /// /// Without `--password` / `--password-stdin` a random throwaway is /// used (fine for agents — they auth by token via tea / hive-forge). /// Set a password to log into the forge web UI afterwards. /// `--password` is idempotent: re-running with the same value sets /// the same password (covers password resets on already-created /// accounts since `forgejo admin user create` silently no-ops once /// the user exists). CreateUser { /// Forgejo username. For agents: the container/agent name /// (`` in `h-`; manager uses the literal `manager`). /// For humans: any forgejo username — `mara`, `damocles`, etc. name: String, /// Set the account password to this string instead of a random /// throwaway. Use this for operator accounts that need to log /// into the forge web UI. Mutually exclusive with /// `--password-stdin`. WARNING: the password is visible in /// shell history + process listings; prefer `--password-stdin` /// for anything sensitive. #[arg(long)] password: Option, /// Read the password from stdin (single line, trailing newline /// stripped) instead of an inline flag. Mutually exclusive with /// `--password`. #[arg(long, conflicts_with = "password")] password_stdin: bool, }, } #[derive(Subcommand)] enum MatrixCmd { /// Create or refresh the matrix account + access token for ``. /// /// When `` matches an existing agent (i.e. it has a state /// dir under `/var/lib/hyperhive/agents/`), persists the token to /// `/matrix-token`. Skips registration when the file is /// already populated; delete it to force re-registration. /// /// When `` is **not** an agent (a human or any other /// non-container account), registers the matrix user and prints /// the freshly-minted access token to stdout — no /// `/var/lib/hyperhive/agents/` directory is created for the user. /// /// Without `--password` / `--password-stdin` a random throwaway is /// used (fine for agents — they auth by `access_token`, never by /// password). Set a password to log into a matrix web client /// afterwards. CreateUser { /// Matrix localpart. For agents: the container/agent name. /// For humans: any matrix localpart — `mara`, `damocles`, etc. name: String, /// Set the account password to this string instead of a random /// throwaway. Use this for operator accounts that need to log /// into matrix web clients via `m.login.password`. Mutually /// exclusive with `--password-stdin`. WARNING: the /// password is visible in shell history + process listings; /// prefer `--password-stdin` for anything sensitive. #[arg(long)] password: Option, /// Read the password from stdin (single line, trailing newline /// stripped) instead of an inline flag. Mutually exclusive with /// `--password`. #[arg(long, conflicts_with = "password")] password_stdin: bool, }, /// Provision (or re-provision) the hive system admin matrix account /// (`@hive:`). hive-c0re runs this automatically on startup /// before the agent sweep so the account is the first registered /// user — Conduit/tuwunel grants admin rights to the first user. /// Run manually to recover a missing admin token file. SyncAdmin, /// Promote a matrix user to homeserver admin via the admin API. /// Uses the hive system admin token at /// `/var/lib/hyperhive/matrix-admin-token`. The server_name is /// discovered automatically from the running homeserver. PromoteUser { /// Matrix localpart of the user to promote (e.g. `argus`). name: String, }, /// Reset a matrix user's password via the admin API and persist the /// new password to `/var/lib/hyperhive/matrix-creds/-password` /// so the next `ensure_user_for` (or `create-user`) can re-login. /// /// After this command succeeds, run `hivectl matrix create-user /// ` to mint a fresh access token for the agent. ResetPassword { /// Matrix localpart of the account to reset (e.g. `argus`). name: String, }, } /// Default htpasswd file path — the host-side location of the gateway's /// credential store, pre-created by a tmpfiles rule when /// `services.hyperhive.gateway.auth.enable = true`. const DEFAULT_HTPASSWD_FILE: &str = "/var/lib/hyperhive/gateway/gateway.htpasswd"; #[derive(Subcommand)] enum GatewayCmd { /// Add a new user or update the password of an existing user in the /// gateway htpasswd file. The password is hashed with BCrypt (cost 12). /// /// Pass `--password-stdin` when scripting or when you don't want the /// password visible in shell history. The file is created if it does /// not exist; its parent directory must already exist. CreateUser { /// Username to add or update. username: String, /// Set the password inline. WARNING: visible in shell history and /// process listings — prefer `--password-stdin` for sensitive input. /// Mutually exclusive with `--password-stdin`. #[arg(long, conflicts_with = "password_stdin")] password: Option, /// Read the password from stdin (single line, trailing newline /// stripped). Mutually exclusive with `--password`. #[arg(long)] password_stdin: bool, /// Path to the htpasswd file. Defaults to the standard gateway /// credential store at `/var/lib/hyperhive/gateway/gateway.htpasswd`. #[arg(long, short = 'f', default_value = DEFAULT_HTPASSWD_FILE)] file: PathBuf, }, /// Remove a user from the gateway htpasswd file. Exits with an error /// when the user is not found so callers can detect the no-op case. DeleteUser { /// Username to remove. username: String, /// Path to the htpasswd file. Defaults to the standard gateway /// credential store. #[arg(long, short = 'f', default_value = DEFAULT_HTPASSWD_FILE)] file: PathBuf, }, /// List all usernames in the gateway htpasswd file, one per line. ListUsers { /// Path to the htpasswd file. Defaults to the standard gateway /// credential store. #[arg(long, short = 'f', default_value = DEFAULT_HTPASSWD_FILE)] file: PathBuf, }, } /// Default host admin socket path (same as `hive-c0re`'s default). const DEFAULT_HOST_SOCKET: &str = "/run/hive/host.sock"; #[derive(Subcommand)] enum AgentsCmd { /// Stop and start a single agent container without rebuilding config. /// Useful for "kick the container" when the process is stuck or the /// container needs a clean restart without changing the NixOS config. Restart { /// Agent name (e.g. `damocles`, `ruth`). name: String, /// Path to the hive-c0re host admin socket. #[arg(long, default_value = DEFAULT_HOST_SOCKET)] socket: PathBuf, }, /// Stop and restart ALL managed agent containers in sequence. /// Iterates the live container list and restarts each one. Any per-agent /// failure is reported at the end rather than stopping mid-run, so all /// containers get a restart attempt. RestartAll { /// Path to the hive-c0re host admin socket. #[arg(long, default_value = DEFAULT_HOST_SOCKET)] socket: PathBuf, }, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let cli = Cli::parse(); match cli.cmd { Cmd::Forge { cmd } => match cmd { ForgeCmd::CreateUser { name, password, password_stdin, } => forge_create_user(&name, password.as_deref(), password_stdin).await, }, Cmd::Matrix { cmd } => match cmd { MatrixCmd::CreateUser { name, password, password_stdin, } => matrix_create_user(&name, password.as_deref(), password_stdin).await, MatrixCmd::SyncAdmin => matrix_sync_admin().await, MatrixCmd::PromoteUser { name } => matrix_promote_user(&name).await, MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await, }, Cmd::Gateway { cmd } => match cmd { GatewayCmd::CreateUser { file, username, password, password_stdin, } => gateway_create_user(&file, &username, password.as_deref(), password_stdin), GatewayCmd::DeleteUser { file, username } => gateway_delete_user(&file, &username), GatewayCmd::ListUsers { file } => gateway_list_users(&file), }, Cmd::Agents { cmd } => match cmd { AgentsCmd::Restart { name, socket } => agents_restart(&socket, &name).await, AgentsCmd::RestartAll { socket } => agents_restart_all(&socket).await, }, } } /// 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 /// still resolve as agents — re-provisioning a destroyed-but-kept agent /// should still drop its token in the existing state tree. fn is_agent(name: &str) -> bool { Coordinator::agent_state_root(name).exists() } async fn forge_create_user(name: &str, password: Option<&str>, password_stdin: bool) -> Result<()> { if !hive_c0re::forge::is_present().await { bail!( "hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users" ); } let user_password = resolve_password(password, password_stdin)?; if is_agent(name) { if user_password.is_some() { bail!( "forge create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token" ); } hive_c0re::forge::ensure_user_for(name) .await .with_context(|| format!("forge create-user {name}"))?; let path = Coordinator::agent_notes_dir(name).join("forge-token"); println!("forge: provisioned agent user '{name}'"); println!("token persisted at: {}", path.display()); } else { let token = hive_c0re::forge::provision_user_token(name, user_password.as_deref()) .await .with_context(|| format!("forge create-user {name}"))?; println!("forge: provisioned user '{name}' (not an agent — token not persisted)"); println!("token: {token}"); if password.is_some() || password_stdin { println!("password: set as supplied — use it to log into the forge web UI"); } else { println!( "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)" ); } } Ok(()) } /// Resolve the password the caller asked for, or return `None` to fall /// back to a random throwaway. `--password ` wins outright; /// `--password-stdin` reads one line off stdin (trailing newline /// stripped). Empty stdin is treated as an error so the caller doesn't /// silently provision an empty-password account. fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result> { if let Some(p) = password { return Ok(Some(p.to_owned())); } if password_stdin { use std::io::BufRead as _; let stdin = std::io::stdin(); let mut line = String::new(); stdin .lock() .read_line(&mut line) .context("read password from stdin")?; let trimmed = line.trim_end_matches(['\r', '\n']).to_owned(); if trimmed.is_empty() { bail!("--password-stdin: empty input"); } return Ok(Some(trimmed)); } Ok(None) } async fn matrix_create_user( name: &str, password: Option<&str>, password_stdin: bool, ) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" ); } let register_token = hive_c0re::matrix::ensure_register_token().context("read matrix register token")?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; let user_password = resolve_password(password, password_stdin)?; if is_agent(name) { if user_password.is_some() { // The boot-sweep / approval-time agent provisioning path // doesn't accept a password — agents auth by access_token, // never by password. Refuse rather than silently dropping it. bail!( "matrix create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" ); } hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token) .await .with_context(|| format!("matrix create-user {name}"))?; let path = Coordinator::agent_notes_dir(name).join("matrix-token"); println!("matrix: provisioned agent user '{name}'"); println!("token persisted at: {}", path.display()); } else { let effective_password = match user_password { Some(p) => p, None => { hive_c0re::matrix::random_password().context("generate random matrix password")? } }; let token = hive_c0re::matrix::provision_user_token( &client, name, ®ister_token, &effective_password, ) .await .with_context(|| format!("matrix create-user {name}"))?; println!("matrix: provisioned user '{name}' (not an agent — token not persisted)"); println!("token: {token}"); if password.is_some() || password_stdin { println!("password: set as supplied — use it to log into a matrix web client"); } else { println!( "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)" ); } } Ok(()) } async fn matrix_sync_admin() -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" ); } let register_token = hive_c0re::matrix::ensure_register_token().context("read matrix register token")?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; hive_c0re::matrix::ensure_admin_user(&client, ®ister_token) .await .context("matrix sync-admin")?; let path = hive_c0re::matrix::admin_token_path(); println!( "matrix: hive admin user '@{}' provisioned", hive_c0re::matrix::HIVE_ADMIN_LOCALPART ); println!("token persisted at: {}", path.display()); Ok(()) } async fn matrix_promote_user(name: &str) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" ); } let admin_token = hive_c0re::matrix::read_admin_token()?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; let server_name = hive_c0re::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name) .await .with_context(|| format!("matrix promote-user {name}"))?; println!("matrix: promoted @{name}:{server_name} to admin"); Ok(()) } async fn matrix_reset_password(name: &str) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" ); } let admin_token = hive_c0re::matrix::read_admin_token()?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; let server_name = hive_c0re::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; hive_c0re::matrix::reset_user_password(&client, &admin_token, name, &server_name) .await .with_context(|| format!("matrix reset-password {name}"))?; // Password is persisted by reset_user_password. let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password")); println!("matrix: password for @{name}:{server_name} reset"); println!("password persisted at: {}", pw_path.display()); println!("next: hivectl matrix create-user {name} # mints a fresh access token"); Ok(()) } // --------------------------------------------------------------------------- // Gateway htpasswd helpers // --------------------------------------------------------------------------- /// Read an htpasswd file into a list of lines, or return an empty list /// if the file does not exist yet. fn htpasswd_read(path: &Path) -> Result> { if !path.exists() { return Ok(vec![]); } let content = std::fs::read_to_string(path) .with_context(|| format!("read htpasswd file {}", path.display()))?; Ok(content.lines().map(str::to_owned).collect()) } /// Write lines back to `path` atomically (write to `.tmp`, then /// rename). A trailing newline is always appended to the last line. fn htpasswd_write(path: &Path, lines: &[String]) -> Result<()> { let tmp = path.with_extension("htpasswd.tmp"); let content = if lines.is_empty() { String::new() } else { let mut s = lines.join("\n"); s.push('\n'); s }; std::fs::write(&tmp, &content) .with_context(|| format!("write htpasswd tmp {}", tmp.display()))?; std::fs::rename(&tmp, path) .with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?; Ok(()) } /// Add or update `username` in the htpasswd file at `file`, hashing /// `password` with BCrypt (cost 12). Creates the file when absent. fn gateway_create_user( file: &Path, username: &str, password: Option<&str>, password_stdin: bool, ) -> Result<()> { let pw = resolve_password(password, password_stdin)?.ok_or_else(|| { anyhow::anyhow!("a password is required — pass --password or --password-stdin") })?; validate_htpasswd_username(username)?; let raw_hash = bcrypt::hash(&pw, 12).context("bcrypt hash")?; // nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The two // prefixes are algorithmically identical; remap so nginx accepts the hash. let hash = raw_hash.replacen("$2b$", "$2y$", 1); let entry = format!("{username}:{hash}"); let mut lines = htpasswd_read(file)?; let prefix = format!("{username}:"); if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) { lines[pos] = entry; htpasswd_write(file, &lines)?; println!( "gateway: updated password for '{username}' in {}", file.display() ); } else { lines.push(entry); htpasswd_write(file, &lines)?; println!("gateway: added user '{username}' to {}", file.display()); } Ok(()) } /// Remove `username` from the htpasswd file. Errors when the user is /// not present so callers can detect the no-op case. fn gateway_delete_user(file: &Path, username: &str) -> Result<()> { let mut lines = htpasswd_read(file)?; let prefix = format!("{username}:"); let before = lines.len(); lines.retain(|l| !l.starts_with(&prefix)); if lines.len() == before { bail!("gateway: user '{username}' not found in {}", file.display()); } htpasswd_write(file, &lines)?; println!("gateway: removed user '{username}' from {}", file.display()); Ok(()) } /// Print one username per line from the htpasswd file. fn gateway_list_users(file: &Path) -> Result<()> { let lines = htpasswd_read(file)?; for line in &lines { // Skip blank lines and comments. if line.is_empty() || line.starts_with('#') { continue; } if let Some((name, _)) = line.split_once(':') { println!("{name}"); } } Ok(()) } // --------------------------------------------------------------------------- // Agent management helpers (require daemon via host admin socket) // --------------------------------------------------------------------------- async fn agents_restart(socket: &Path, name: &str) -> Result<()> { let resp = hive_c0re::client::request( socket, hive_sh4re::HostRequest::Restart { name: name.to_owned(), }, ) .await .with_context(|| format!("connect to daemon socket {}", socket.display()))?; if resp.ok { println!("restarted: {name}"); Ok(()) } else { bail!( "restart {name}: {}", resp.error.as_deref().unwrap_or("unknown error") ) } } async fn agents_restart_all(socket: &Path) -> Result<()> { let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::RestartAll) .await .with_context(|| format!("connect to daemon socket {}", socket.display()))?; let agents = resp.agents.as_deref().unwrap_or(&[]); if agents.is_empty() { println!("restart-all: no managed containers found"); } else { for a in agents { println!("restarted: {a}"); } } if !resp.ok { bail!( "restart-all: {}", resp.error.as_deref().unwrap_or("unknown error") ); } Ok(()) } /// Reject usernames containing `:` (field separator) or control chars /// that would corrupt the htpasswd file format. fn validate_htpasswd_username(username: &str) -> Result<()> { if username.is_empty() { bail!("username must not be empty"); } if username.contains(':') { bail!("username must not contain ':' (htpasswd field separator)"); } if username.chars().any(|c| c.is_control()) { bail!("username must not contain control characters"); } Ok(()) }