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
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue