route gateway htpasswd management through a daemon wire command (#2504)

This commit is contained in:
damocles 2026-07-15 23:18:11 +02:00 committed by mara
commit f1812335d1
9 changed files with 181 additions and 160 deletions

View file

@ -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)]
mod tests {
use super::*;

View file

@ -196,6 +196,15 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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?
}