654 lines
26 KiB
Rust
654 lines
26 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.
|
|
|
|
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 `<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,
|
|
},
|
|
}
|
|
|
|
/// 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 (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 <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, ®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<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(|c| c.is_control()) {
|
|
bail!("username must not contain control characters");
|
|
}
|
|
Ok(())
|
|
}
|