feat(gateway): hivectl gateway user management + fix htpasswdFile assertion

Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.

Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.

Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
atlas 2026-06-01 23:00:38 +02:00
commit 4bff450343
61 changed files with 1084 additions and 547 deletions

View file

@ -15,6 +15,8 @@
//! 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;
@ -54,6 +56,14 @@ enum Cmd {
#[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,
},
}
#[derive(Subcommand)]
@ -137,6 +147,48 @@ enum MatrixCmd {
},
}
#[derive(Subcommand)]
enum GatewayCmd {
/// Add a new user or update the password of an existing user in an
/// 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 {
/// Path to the htpasswd file (the value of
/// `services.hyperhive.gateway.auth.htpasswdFile`).
#[arg(long, short = 'f')]
file: PathBuf,
/// 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,
},
/// Remove a user from an htpasswd file. Exits with an error when the
/// user is not found so callers can detect the no-op case.
DeleteUser {
/// Path to the htpasswd file.
#[arg(long, short = 'f')]
file: PathBuf,
/// Username to remove.
username: String,
},
/// List all usernames in an htpasswd file, one per line.
ListUsers {
/// Path to the htpasswd file.
#[arg(long, short = 'f')]
file: PathBuf,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
@ -161,6 +213,16 @@ async fn main() -> Result<()> {
password_stdin,
} => matrix_create_user(&name, password.as_deref(), password_stdin).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),
},
}
}
@ -173,11 +235,7 @@ 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<()> {
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"
@ -274,13 +332,18 @@ async fn matrix_create_user(
} else {
let effective_password = match user_password {
Some(p) => p,
None => hive_c0re::matrix::random_password()
.context("generate random matrix password")?,
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}"))?;
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 {
@ -293,3 +356,115 @@ async fn matrix_create_user(
}
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(())
}
/// 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(())
}