hyperhive/hive-c0re/src/bin/hivectl.rs

793 lines
33 KiB
Rust

//! `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.
#[cfg(unix)]
use std::os::unix::process::CommandExt as _;
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,
},
/// Open an interactive Claude session inside an agent container.
///
/// Replaces the current process with `machinectl shell
/// <name>@h-<name>` running `claude --continue` from the agent's
/// state dir — drops the operator straight into the agent's live
/// Claude session with its full loaded context and persona. Requires
/// root (same as all machinectl shell operations) and the container
/// must be running.
///
/// The session is entered **as the agent user** (not root): claude
/// reads its OAuth credentials + settings from the agent's
/// `/home/<name>/.claude`, and the working directory is set to the
/// agent's state dir (`/agents/<name>/state`) so `--continue`
/// resumes the same per-project session the harness runs. Entering
/// as root (the `machinectl shell` default) is what loses both.
///
/// Pass `--fresh` to start a new Claude session instead of continuing
/// the most recent one.
Choom {
/// Agent name (e.g. `damocles`, `iris`).
name: String,
/// Start a fresh Claude session instead of continuing the most
/// recent one. Without this flag `--continue` is passed to Claude
/// so the operator joins the agent's ongoing session context.
#[arg(long)]
fresh: bool,
},
/// Emit the full CLI reference as `CommonMark` to stdout.
///
/// Hidden tooling command (not part of day-to-day operator admin):
/// walks this binary's own clap command tree and renders every verb,
/// flag, and help string as markdown. Used by the docs build to keep
/// the published `hivectl` reference in lockstep with the code — no
/// hand-maintained command list to drift out of date.
#[command(hide = true)]
MarkdownDocs,
}
#[derive(Subcommand)]
enum ForgeCmd {
/// Create or refresh the Forgejo account + token for `<name>`.
///
/// When `<name>` matches an existing agent (i.e. it has a state
/// dir under `/var/lib/hyperhive/agents/`), persists the token to
/// `<state>/forge-token` (idempotent: re-mints + rewrites every
/// call so the on-disk scope matches the current
/// `forge::TOKEN_SCOPES`).
///
/// When `<name>` 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
/// (`<n>` in `h-<n>`; 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<String>,
/// 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 `<name>`.
///
/// When `<name>` matches an existing agent (i.e. it has a state
/// dir under `/var/lib/hyperhive/agents/`), persists the token to
/// `<state>/matrix-token`. Skips registration when the file is
/// already populated; delete it to force re-registration.
///
/// When `<name>` 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<String>,
/// 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:<server>`). 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/<name>-password`
/// so the next `ensure_user_for` (or `create-user`) can re-login.
///
/// After this command succeeds, run `hivectl matrix create-user
/// <name>` to mint a fresh access token for the agent.
ResetPassword {
/// Matrix localpart of the account to reset (e.g. `argus`).
name: String,
},
/// Invite a matrix user to the hive Space (default) or a specific
/// room. Uses the hive admin token; the admin account must be a
/// member of the target room with invite power (it owns the hive
/// Space). Idempotent — already-member / already-invited is a no-op.
Invite {
/// User to invite: a full id (`@mara:server`) or a bare
/// localpart (qualified with the homeserver's `server_name`).
user: String,
/// Target room id (`!abc:server`) or alias (`#name:server`).
/// Omit to invite to the hive Space.
#[arg(long)]
room: Option<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<String>,
/// 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. Must match `hive-c0re`'s default in
/// `main.rs` (`/run/hyperhive/host.sock`) — the daemon binds there and
/// `hivectl agents` connects to it.
const DEFAULT_HOST_SOCKET: &str = "/run/hyperhive/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,
MatrixCmd::Invite { user, room } => matrix_invite(&user, room.as_deref()).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,
},
Cmd::Choom { name, fresh } => choom(&name, fresh),
Cmd::MarkdownDocs => {
print!("{}", clap_markdown::help_markdown::<Cli>());
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
/// 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()
}
/// Drop into an interactive Claude session in the agent container.
///
/// Replaces the current process (exec) with `machinectl shell
/// <name>@h-<name> /bin/sh -lc 'cd /agents/<name>/state && exec claude
/// [--continue]'`. Two things matter here, both of which the naive
/// `machinectl shell h-<name> claude` got wrong (issue: choom missing
/// creds + settings):
///
/// 1. **Run as the agent user.** `machinectl shell` defaults to root in
/// the container, so claude would read `/root/.claude` (empty)
/// instead of the agent's `/home/<name>/.claude` where the OAuth
/// credentials + settings live. Prefixing the machine with
/// `<name>@` enters the session as the agent user (the meta-flake
/// sets `hyperhive.user.name` to the agent label, so the unix user
/// name matches the agent name).
/// 2. **Start in the agent's state dir.** `claude --continue` resumes
/// the most recent session *for the current project directory*. The
/// harness runs claude from `/agents/<name>/state`, so choom has to
/// `cd` there or `--continue` finds no session (it would look under
/// the user's home instead).
///
/// `machinectl shell` inherits the caller's PTY, so the session is
/// fully interactive. Requires root and a running container.
fn choom(name: &str, fresh: bool) -> Result<()> {
if !is_agent(name) {
bail!("no such agent: '{name}' (no state dir under /var/lib/hyperhive/agents/)");
}
let container = hive_c0re::lifecycle::container_name(name);
// The agent's unix user name matches its agent name (meta-flake
// sets `hyperhive.user.name = <label>`); enter the session as that
// user so claude sees the right `$HOME/.claude`.
let target = format!("{name}@{container}");
let claude = "/run/current-system/sw/bin/claude";
// In-container state dir (host `/var/lib/hyperhive/agents/<name>/state`
// is bind-mounted here); matches `hive-ag3nt::paths::state_dir()`.
let state_dir = format!("/agents/{name}/state");
let continue_flag = if fresh { "" } else { " --continue" };
// `cd` so `--continue` resolves the harness's per-project session;
// `exec` replaces the shell with claude so it owns the PTY directly.
// `name` is constrained to `[a-z0-9._-]` so there's nothing to quote.
let inner = format!("cd {state_dir} && exec {claude}{continue_flag}");
let mut cmd = std::process::Command::new("machinectl");
cmd.arg("shell")
.arg(&target)
.arg("/bin/sh")
.arg("-lc")
.arg(&inner);
// exec() replaces the current process — we inherit stdin/stdout/stderr
// (the caller's PTY) so the Claude session is fully interactive.
// This call only returns on error.
let err = cmd.exec();
Err(anyhow::anyhow!("exec machinectl: {err}"))
}
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 <PW>` 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<Option<String>> {
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, &register_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,
&register_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, &register_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_invite(user: &str, room: Option<&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")?;
let room_id = hive_c0re::matrix::invite_user(&client, &admin_token, user, room, &server_name)
.await
.with_context(|| format!("matrix invite {user}"))?;
let target = if user.starts_with('@') {
user.to_owned()
} else {
format!("@{user}:{server_name}")
};
println!("matrix: invited {target} to {room_id}");
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<Vec<String>> {
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 `<path>.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(char::is_control) {
bail!("username must not contain control characters");
}
Ok(())
}