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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue