route gateway htpasswd management through a daemon wire command (#2504)
This commit is contained in:
parent
614e8c6d5a
commit
f1812335d1
9 changed files with 181 additions and 160 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1673,7 +1673,6 @@ name = "hivectl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
|
||||||
"clap",
|
"clap",
|
||||||
"clap-markdown",
|
"clap-markdown",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
|
|
||||||
|
|
@ -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
|
`/run/hive-state/gateway.htpasswd` via the existing gateway state
|
||||||
bind-mount.
|
bind-mount.
|
||||||
|
|
||||||
Manage users with `hivectl gateway` (defaults to the standard path — no
|
Manage users with `hivectl gateway`. `hivectl` sends the request over the
|
||||||
`--file` flag needed for the common case):
|
host admin socket and the `hive-c0re` daemon performs the write at its
|
||||||
|
canonical path — no path is exposed to the CLI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Add or update a user (prompted for password):
|
# Add or update a user (prompted for password):
|
||||||
|
|
@ -502,10 +503,9 @@ hivectl gateway delete-user bob
|
||||||
hivectl gateway list-users
|
hivectl gateway list-users
|
||||||
```
|
```
|
||||||
|
|
||||||
`hivectl gateway create-user` hashes passwords with BCrypt (cost 12) and
|
The daemon hashes passwords with BCrypt (cost 12) and writes
|
||||||
writes `$2y$`-prefixed hashes that nginx accepts natively. No external
|
`$2y$`-prefixed hashes that nginx accepts natively. No external
|
||||||
`htpasswd` binary is required. Pass `--file <path>` to target a
|
`htpasswd` binary is required.
|
||||||
non-default file.
|
|
||||||
|
|
||||||
**What is not gated:** per-agent UI routes emitted into `agents.conf`
|
**What is not gated:** per-agent UI routes emitted into `agents.conf`
|
||||||
(served under `/agent/<name>/`) inherit no auth from `/` — nginx
|
(served under `/agent/<name>/`) inherit no auth from `/` — nginx
|
||||||
|
|
|
||||||
|
|
@ -249,17 +249,17 @@ Gateway htpasswd user management. Add, remove, or list users in an htpasswd file
|
||||||
|
|
||||||
###### **Subcommands:**
|
###### **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
|
* `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`
|
## `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] <USERNAME>`
|
**Usage:** `hivectl gateway create-user [OPTIONS] <USERNAME>`
|
||||||
|
|
||||||
|
|
@ -271,9 +271,6 @@ Pass `--password-stdin` when scripting or when you don't want the password visib
|
||||||
|
|
||||||
* `--password <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 <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`
|
* `--password-stdin` — Read the password from stdin (single line, trailing newline stripped). Mutually exclusive with `--password`
|
||||||
* `-f`, `--file <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
|
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] <USERNAME>`
|
**Usage:** `hivectl gateway delete-user <USERNAME>`
|
||||||
|
|
||||||
###### **Arguments:**
|
###### **Arguments:**
|
||||||
|
|
||||||
* `<USERNAME>` — Username to remove
|
* `<USERNAME>` — Username to remove
|
||||||
|
|
||||||
###### **Options:**
|
|
||||||
|
|
||||||
* `-f`, `--file <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`
|
## `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]`
|
**Usage:** `hivectl gateway list-users`
|
||||||
|
|
||||||
###### **Options:**
|
|
||||||
|
|
||||||
* `-f`, `--file <FILE>` — Path to the htpasswd file. Defaults to the standard gateway credential store
|
|
||||||
|
|
||||||
Default value: `/var/lib/hyperhive/gateway/gateway.htpasswd`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,10 @@ hivectl github set-token damocles --token <pat> # inline (visible in shell hi
|
||||||
## Gateway
|
## Gateway
|
||||||
|
|
||||||
Manage users in the gateway's HTTP Basic auth htpasswd file
|
Manage users in the gateway's HTTP Basic auth htpasswd file
|
||||||
(`services.hyperhive.gateway.auth`). All commands default to
|
(`services.hyperhive.gateway.auth`). `hivectl` sends the request over the
|
||||||
`/var/lib/hyperhive/gateway/gateway.htpasswd`; pass `--file` to target
|
host admin socket; the `hive-c0re` daemon owns the htpasswd file at its
|
||||||
a different path.
|
canonical path (`/var/lib/hyperhive/gateway/gateway.htpasswd`) and
|
||||||
|
performs the write.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hivectl gateway create-user alice --password-stdin # add (or update) user; read password from stdin
|
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
|
hivectl gateway list-users # list all usernames, one per line
|
||||||
```
|
```
|
||||||
|
|
||||||
Passwords are hashed with BCrypt (cost 12). The file is created if it
|
Passwords are hashed with BCrypt (cost 12) by the daemon. The file is
|
||||||
does not exist. Re-running `create-user` with the same username updates
|
created if it does not exist. Re-running `create-user` with the same
|
||||||
the password hash in place.
|
username updates the password hash in place.
|
||||||
|
|
||||||
## Agents
|
## Agents
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<Vec<String>> {
|
||||||
|
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 (`<path>.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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,15 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
HostRequest::ForgeCreateUser { name, password } => {
|
HostRequest::ForgeCreateUser { name, password } => {
|
||||||
handle_forge_create_user(name, password.as_deref()).await?
|
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 } => {
|
HostRequest::SetAgentGithubToken { agent, token } => {
|
||||||
handle_set_agent_github_token(agent, token).await?
|
handle_set_agent_github_token(agent, token).await?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,21 @@ pub enum HostRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// 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
|
/// Write (or overwrite) an agent's GitHub PAT under its state dir, via
|
||||||
/// the privileged helper. Daemon-side equivalent of `hivectl github
|
/// the privileged helper. Daemon-side equivalent of `hivectl github
|
||||||
/// set-token`. `token` is resolved + non-empty-validated client-side
|
/// set-token`. `token` is resolved + non-empty-validated client-side
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bcrypt.workspace = true
|
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
clap_complete.workspace = true
|
clap_complete.workspace = true
|
||||||
clap-markdown = "0.1"
|
clap-markdown = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[derive(Subcommand)]
|
||||||
enum GatewayCmd {
|
enum GatewayCmd {
|
||||||
/// Add a new user or update the password of an existing user in the
|
/// 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
|
/// Pass `--password-stdin` when scripting or when you don't want the
|
||||||
/// password visible in shell history. The file is created if it does
|
/// password visible in shell history.
|
||||||
/// not exist; its parent directory must already exist.
|
|
||||||
CreateUser {
|
CreateUser {
|
||||||
/// Username to add or update.
|
/// Username to add or update.
|
||||||
username: String,
|
username: String,
|
||||||
|
|
@ -487,28 +482,15 @@ enum GatewayCmd {
|
||||||
/// stripped). Mutually exclusive with `--password`.
|
/// stripped). Mutually exclusive with `--password`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
password_stdin: bool,
|
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
|
/// 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.
|
/// when the user is not found so callers can detect the no-op case.
|
||||||
DeleteUser {
|
DeleteUser {
|
||||||
/// Username to remove.
|
/// Username to remove.
|
||||||
username: String,
|
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)]
|
#[derive(Subcommand)]
|
||||||
|
|
@ -771,13 +753,12 @@ async fn main() -> Result<()> {
|
||||||
},
|
},
|
||||||
Cmd::Gateway { cmd } => match cmd {
|
Cmd::Gateway { cmd } => match cmd {
|
||||||
GatewayCmd::CreateUser {
|
GatewayCmd::CreateUser {
|
||||||
file,
|
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
password_stdin,
|
password_stdin,
|
||||||
} => gateway_create_user(&file, &username, password.as_deref(), password_stdin),
|
} => gateway_create_user(&socket, &username, password.as_deref(), password_stdin).await,
|
||||||
GatewayCmd::DeleteUser { file, username } => gateway_delete_user(&file, &username),
|
GatewayCmd::DeleteUser { username } => gateway_delete_user(&socket, &username).await,
|
||||||
GatewayCmd::ListUsers { file } => gateway_list_users(&file),
|
GatewayCmd::ListUsers => gateway_list_users(&socket).await,
|
||||||
},
|
},
|
||||||
Cmd::Agents { cmd } => run_agents(&socket, cmd).await,
|
Cmd::Agents { cmd } => run_agents(&socket, cmd).await,
|
||||||
Cmd::Approvals { cmd } => run_approvals(&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
|
async fn gateway_create_user(
|
||||||
/// if the file does not exist yet.
|
socket: &Path,
|
||||||
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,
|
username: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
password_stdin: bool,
|
password_stdin: bool,
|
||||||
|
|
@ -1520,57 +1472,35 @@ fn gateway_create_user(
|
||||||
let pw = resolve_password(password, password_stdin)?.ok_or_else(|| {
|
let pw = resolve_password(password, password_stdin)?.ok_or_else(|| {
|
||||||
anyhow::anyhow!("a password is required — pass --password or --password-stdin")
|
anyhow::anyhow!("a password is required — pass --password or --password-stdin")
|
||||||
})?;
|
})?;
|
||||||
validate_htpasswd_username(username)?;
|
daemon_request(
|
||||||
let raw_hash = bcrypt::hash(&pw, 12).context("bcrypt hash")?;
|
socket,
|
||||||
// nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The two
|
hive_host_sock::HostRequest::GatewayCreateUser {
|
||||||
// prefixes are algorithmically identical; remap so nginx accepts the hash.
|
username: username.to_owned(),
|
||||||
let hash = raw_hash.replacen("$2b$", "$2y$", 1);
|
password: pw,
|
||||||
let entry = format!("{username}:{hash}");
|
},
|
||||||
let mut lines = htpasswd_read(file)?;
|
"gateway",
|
||||||
let prefix = format!("{username}:");
|
)
|
||||||
if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) {
|
.await
|
||||||
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
|
async fn gateway_delete_user(socket: &Path, username: &str) -> Result<()> {
|
||||||
/// not present so callers can detect the no-op case.
|
daemon_request(
|
||||||
fn gateway_delete_user(file: &Path, username: &str) -> Result<()> {
|
socket,
|
||||||
let mut lines = htpasswd_read(file)?;
|
hive_host_sock::HostRequest::GatewayDeleteUser {
|
||||||
let prefix = format!("{username}:");
|
username: username.to_owned(),
|
||||||
let before = lines.len();
|
},
|
||||||
lines.retain(|l| !l.starts_with(&prefix));
|
"gateway",
|
||||||
if lines.len() == before {
|
)
|
||||||
bail!("gateway: user '{username}' not found in {}", file.display());
|
.await
|
||||||
}
|
|
||||||
htpasswd_write(file, &lines)?;
|
|
||||||
println!("gateway: removed user '{username}' from {}", file.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print one username per line from the htpasswd file.
|
async fn gateway_list_users(socket: &Path) -> Result<()> {
|
||||||
fn gateway_list_users(file: &Path) -> Result<()> {
|
daemon_request(
|
||||||
let lines = htpasswd_read(file)?;
|
socket,
|
||||||
for line in &lines {
|
hive_host_sock::HostRequest::GatewayListUsers,
|
||||||
// Skip blank lines and comments.
|
"gateway",
|
||||||
if line.is_empty() || line.starts_with('#') {
|
)
|
||||||
continue;
|
.await
|
||||||
}
|
|
||||||
if let Some((name, _)) = line.split_once(':') {
|
|
||||||
println!("{name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -2046,18 +1976,3 @@ fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<(
|
||||||
}
|
}
|
||||||
Ok(())
|
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(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue