From f1812335d1efd85c06140d3b49b7c19a9b9a4ce6 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 15 Jul 2026 23:18:11 +0200 Subject: [PATCH] route gateway htpasswd management through a daemon wire command (#2504) --- Cargo.lock | 1 - docs/gateway.md | 12 +-- docs/tools/hivectl-cli.md | 29 ++---- docs/tools/hivectl.md | 13 +-- hive-c0re/src/gateway_nginx.rs | 98 ++++++++++++++++++++ hive-c0re/src/server.rs | 9 ++ hive-host-sock/src/lib.rs | 15 +++ hivectl/Cargo.toml | 1 - hivectl/src/main.rs | 163 ++++++++------------------------- 9 files changed, 181 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6fdc375..9a2d7ea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1673,7 +1673,6 @@ name = "hivectl" version = "0.1.0" dependencies = [ "anyhow", - "bcrypt", "clap", "clap-markdown", "clap_complete", diff --git a/docs/gateway.md b/docs/gateway.md index ae795d62..7b488e50 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -485,8 +485,9 @@ is required. The file is exposed inside the gateway container at `/run/hive-state/gateway.htpasswd` via the existing gateway state bind-mount. -Manage users with `hivectl gateway` (defaults to the standard path — no -`--file` flag needed for the common case): +Manage users with `hivectl gateway`. `hivectl` sends the request over the +host admin socket and the `hive-c0re` daemon performs the write at its +canonical path — no path is exposed to the CLI: ```sh # Add or update a user (prompted for password): @@ -502,10 +503,9 @@ hivectl gateway delete-user bob hivectl gateway list-users ``` -`hivectl gateway create-user` hashes passwords with BCrypt (cost 12) and -writes `$2y$`-prefixed hashes that nginx accepts natively. No external -`htpasswd` binary is required. Pass `--file ` to target a -non-default file. +The daemon hashes passwords with BCrypt (cost 12) and writes +`$2y$`-prefixed hashes that nginx accepts natively. No external +`htpasswd` binary is required. **What is not gated:** per-agent UI routes emitted into `agents.conf` (served under `/agent//`) inherit no auth from `/` — nginx diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 155de994..507960f1 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -249,17 +249,17 @@ Gateway htpasswd user management. Add, remove, or list users in an htpasswd file ###### **Subcommands:** -* `create-user` — 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) +* `create-user` — Add a new user or update the password of an existing user in the gateway htpasswd file. The daemon bcrypt-hashes the password (cost 12) and writes the credential store — hivectl relays over the host socket and never touches the file * `delete-user` — 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 -* `list-users` — List all usernames in the gateway htpasswd file, one per line +* `list-users` — List all gateway htpasswd usernames, one per line ## `hivectl gateway create-user` -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). +Add a new user or update the password of an existing user in the gateway htpasswd file. The daemon bcrypt-hashes the password (cost 12) and writes the credential store — hivectl relays over the host socket and never touches the file. -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. +Pass `--password-stdin` when scripting or when you don't want the password visible in shell history. **Usage:** `hivectl gateway create-user [OPTIONS] ` @@ -271,9 +271,6 @@ Pass `--password-stdin` when scripting or when you don't want the password visib * `--password ` — Set the password inline. WARNING: visible in shell history and process listings — prefer `--password-stdin` for sensitive input. Mutually exclusive with `--password-stdin` * `--password-stdin` — Read the password from stdin (single line, trailing newline stripped). Mutually exclusive with `--password` -* `-f`, `--file ` — Path to the htpasswd file. Defaults to the standard gateway credential store at `/var/lib/hyperhive/gateway/gateway.htpasswd` - - Default value: `/var/lib/hyperhive/gateway/gateway.htpasswd` @@ -281,31 +278,19 @@ Pass `--password-stdin` when scripting or when you don't want the password visib 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 -**Usage:** `hivectl gateway delete-user [OPTIONS] ` +**Usage:** `hivectl gateway delete-user ` ###### **Arguments:** * `` — Username to remove -###### **Options:** - -* `-f`, `--file ` — Path to the htpasswd file. Defaults to the standard gateway credential store - - Default value: `/var/lib/hyperhive/gateway/gateway.htpasswd` - ## `hivectl gateway list-users` -List all usernames in the gateway htpasswd file, one per line +List all gateway htpasswd usernames, one per line -**Usage:** `hivectl gateway list-users [OPTIONS]` - -###### **Options:** - -* `-f`, `--file ` — Path to the htpasswd file. Defaults to the standard gateway credential store - - Default value: `/var/lib/hyperhive/gateway/gateway.htpasswd` +**Usage:** `hivectl gateway list-users` diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 04c208cc..7a5bd643 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -98,9 +98,10 @@ hivectl github set-token damocles --token # inline (visible in shell hi ## Gateway Manage users in the gateway's HTTP Basic auth htpasswd file -(`services.hyperhive.gateway.auth`). All commands default to -`/var/lib/hyperhive/gateway/gateway.htpasswd`; pass `--file` to target -a different path. +(`services.hyperhive.gateway.auth`). `hivectl` sends the request over the +host admin socket; the `hive-c0re` daemon owns the htpasswd file at its +canonical path (`/var/lib/hyperhive/gateway/gateway.htpasswd`) and +performs the write. ```bash hivectl gateway create-user alice --password-stdin # add (or update) user; read password from stdin @@ -109,9 +110,9 @@ hivectl gateway delete-user bob # remove user hivectl gateway list-users # list all usernames, one per line ``` -Passwords are hashed with BCrypt (cost 12). The file is created if it -does not exist. Re-running `create-user` with the same username updates -the password hash in place. +Passwords are hashed with BCrypt (cost 12) by the daemon. The file is +created if it does not exist. Re-running `create-user` with the same +username updates the password hash in place. ## Agents diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index 53c96fcc..85b0432f 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -252,6 +252,104 @@ async fn reload_gateway_nginx() { } } +// ── Gateway HTTP-Basic (htpasswd) user management ────────────────────────── +// The daemon owns the write (hivectl drives it over the host socket via the +// `Gateway*User` requests, so hivectl never touches the credential file). +// Keyed on the canonical `paths::GATEWAY_HTPASSWD` — a socket client doesn't +// pick the path. + +fn htpasswd_path() -> std::path::PathBuf { + std::path::PathBuf::from(crate::paths::GATEWAY_HTPASSWD) +} + +/// Read the htpasswd file into lines, or an empty list if it doesn't exist. +fn htpasswd_read() -> Result> { + let path = htpasswd_path(); + 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 atomically (`.tmp` then rename), with a trailing +/// newline. +fn htpasswd_write(lines: &[String]) -> Result<()> { + let path = htpasswd_path(); + 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(()) +} + +fn validate_htpasswd_username(username: &str) -> Result<()> { + if username.is_empty() { + anyhow::bail!("username must not be empty"); + } + if username.contains(':') { + anyhow::bail!("username must not contain ':' (htpasswd field separator)"); + } + if username.chars().any(char::is_control) { + anyhow::bail!("username must not contain control characters"); + } + Ok(()) +} + +/// Add or update a gateway HTTP-Basic user, bcrypt-hashing `password` +/// (cost 12). Returns the confirmation line for the operator. +pub fn create_user(username: &str, password: &str) -> Result { + validate_htpasswd_username(username)?; + let raw_hash = bcrypt::hash(password, 12).context("bcrypt hash")?; + // nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The 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()?; + let prefix = format!("{username}:"); + let msg = if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) { + lines[pos] = entry; + format!("gateway: updated password for '{username}'") + } else { + lines.push(entry); + format!("gateway: added user '{username}'") + }; + htpasswd_write(&lines)?; + Ok(msg) +} + +/// Remove a gateway HTTP-Basic user. Errors when the user isn't present so a +/// no-op is detectable. +pub fn delete_user(username: &str) -> Result { + let mut lines = htpasswd_read()?; + let prefix = format!("{username}:"); + let before = lines.len(); + lines.retain(|l| !l.starts_with(&prefix)); + if lines.len() == before { + anyhow::bail!("gateway: user '{username}' not found"); + } + htpasswd_write(&lines)?; + Ok(format!("gateway: removed user '{username}'")) +} + +/// List the gateway HTTP-Basic usernames (skips blank / comment lines). +pub fn list_users() -> Result> { + Ok(htpasswd_read()? + .iter() + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .filter_map(|l| l.split_once(':').map(|(name, _)| name.to_owned())) + .collect()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index ac2e35e5..4b3c716e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -196,6 +196,15 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostRequest::ForgeCreateUser { name, password } => { handle_forge_create_user(name, password.as_deref()).await? } + HostRequest::GatewayCreateUser { username, password } => { + HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?]) + } + HostRequest::GatewayDeleteUser { username } => { + HostResponse::messages(vec![crate::gateway_nginx::delete_user(username)?]) + } + HostRequest::GatewayListUsers => { + HostResponse::messages(crate::gateway_nginx::list_users()?) + } HostRequest::SetAgentGithubToken { agent, token } => { handle_set_agent_github_token(agent, token).await? } diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index b4c9a15a..0c8b6340 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -200,6 +200,21 @@ pub enum HostRequest { #[serde(default)] password: Option, }, + /// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file + /// (`paths::GATEWAY_HTPASSWD`). Daemon-side equivalent of `hivectl gateway + /// create-user`: the daemon bcrypt-hashes `password` (cost 12, remapped to + /// the `$2y$` prefix nginx accepts) and writes the entry, so hivectl never + /// touches the file. `password` is read client-side (inline flag or stdin). + /// Returns a confirmation line in [`HostResponse::messages`]. + GatewayCreateUser { username: String, password: String }, + /// Remove a gateway HTTP-Basic user from the daemon's htpasswd file. + /// Daemon-side equivalent of `hivectl gateway delete-user`. Errors if the + /// user isn't present so a no-op is detectable. + GatewayDeleteUser { username: String }, + /// List the gateway HTTP-Basic usernames in the daemon's htpasswd file. + /// Daemon-side equivalent of `hivectl gateway list-users`; the usernames + /// come back in [`HostResponse::messages`], one per line. + GatewayListUsers, /// Write (or overwrite) an agent's GitHub PAT under its state dir, via /// the privileged helper. Daemon-side equivalent of `hivectl github /// set-token`. `token` is resolved + non-empty-validated client-side diff --git a/hivectl/Cargo.toml b/hivectl/Cargo.toml index 0bc1094f..5107a6e3 100644 --- a/hivectl/Cargo.toml +++ b/hivectl/Cargo.toml @@ -12,7 +12,6 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true -bcrypt.workspace = true clap.workspace = true clap_complete.workspace = true clap-markdown = "0.1" diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index 940996d3..9f661739 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -461,20 +461,15 @@ enum GithubCmd { }, } -// 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`. Literal lives in -// `hive_host_sock`. -use hive_host_sock::GATEWAY_HTPASSWD as DEFAULT_HTPASSWD_FILE; - #[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). + /// gateway htpasswd file. The daemon bcrypt-hashes the password (cost 12) + /// and writes the credential store — hivectl relays over the host socket + /// and never touches the file. /// /// 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. + /// password visible in shell history. CreateUser { /// Username to add or update. username: String, @@ -487,28 +482,15 @@ enum GatewayCmd { /// 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, }, + /// List all gateway htpasswd usernames, one per line. + ListUsers, } #[derive(Subcommand)] @@ -771,13 +753,12 @@ async fn main() -> Result<()> { }, 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), + } => gateway_create_user(&socket, &username, password.as_deref(), password_stdin).await, + GatewayCmd::DeleteUser { username } => gateway_delete_user(&socket, &username).await, + GatewayCmd::ListUsers => gateway_list_users(&socket).await, }, Cmd::Agents { cmd } => run_agents(&socket, cmd).await, Cmd::Approvals { cmd } => run_approvals(&socket, cmd).await, @@ -1477,42 +1458,13 @@ async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { } // --------------------------------------------------------------------------- -// Gateway htpasswd helpers +// Gateway htpasswd helpers (require daemon via host admin socket) // --------------------------------------------------------------------------- +// The daemon owns the bcrypt hash + htpasswd write (see the `Gateway*User` +// HostRequests); hivectl resolves the password client-side and relays. -/// 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, +async fn gateway_create_user( + socket: &Path, username: &str, password: Option<&str>, password_stdin: bool, @@ -1520,57 +1472,35 @@ fn gateway_create_user( 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(()) + daemon_request( + socket, + hive_host_sock::HostRequest::GatewayCreateUser { + username: username.to_owned(), + password: pw, + }, + "gateway", + ) + .await } -/// 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(()) +async fn gateway_delete_user(socket: &Path, username: &str) -> Result<()> { + daemon_request( + socket, + hive_host_sock::HostRequest::GatewayDeleteUser { + username: username.to_owned(), + }, + "gateway", + ) + .await } -/// 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(()) +async fn gateway_list_users(socket: &Path) -> Result<()> { + daemon_request( + socket, + hive_host_sock::HostRequest::GatewayListUsers, + "gateway", + ) + .await } // --------------------------------------------------------------------------- @@ -2046,18 +1976,3 @@ fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<( } 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(()) -}