From f3eb7ee93ee2544cb5afa6a7ffcf88d580214fb9 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 26 Oct 2026 20:16:41 +0100 Subject: detect broken htpasswd files during startup --- src/api/auth.rs | 33 ++++++++++++++++++++++----------- src/main.rs | 3 ++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index de4220c..c07db74 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use argon2::{Argon2, password_hash::PasswordVerifier}; use axum::extract::State; use axum::{ @@ -21,18 +22,28 @@ use std::{ use tokio::sync::Semaphore; use tokio::time::sleep; -fn load_users(path: &str) -> std::io::Result> { - let file = File::open(path)?; +/// The users of an htpasswd file (`user:hash` lines; empty lines and `#` comments are +/// skipped). A line that is neither, or a file without users, is an error: every request +/// would be refused, which is better told at startup. +fn load_users(path: &str) -> anyhow::Result> { + let file = File::open(path).with_context(|| format!("Opening the htpasswd file {path}"))?; let reader = BufReader::new(file); let mut users = HashMap::new(); - for line in reader.lines() { - let line = line?; - - if let Some((user, hash)) = line.split_once(':') { - users.insert(user.to_owned(), hash.to_owned()); + for (number, line) in reader.lines().enumerate() { + let line = line.with_context(|| format!("Reading the htpasswd file {path}"))?; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; } + let Some((user, hash)) = line.split_once(':') else { + anyhow::bail!("{path}, line {}: not `user:hash`", number + 1); + }; + users.insert(user.to_owned(), hash.to_owned()); + } + if users.is_empty() { + anyhow::bail!("The htpasswd file {path} has no users"); } Ok(users) @@ -49,15 +60,15 @@ pub struct AuthState { } impl AuthState { - pub fn new(path: &str) -> Self { - let users = load_users(path).unwrap_or_default(); + pub fn new(path: &str) -> anyhow::Result { + let users = load_users(path)?; - Self { + Ok(Self { idle_timeout: Duration::from_secs(300), cache: Arc::new(DashMap::new()), users: Arc::new(users), verify_slots: Arc::new(Semaphore::new(2)), - } + }) } } diff --git a/src/main.rs b/src/main.rs index 99ee1b9..dee0a32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,8 @@ async fn main() -> Result<()> { // First thing: until a handler is installed, SIGINT and SIGTERM kill the process on // the spot, and startup (spawning Mycelium, bootstrap) can take minutes. let stop = watch_for_shutdown().context("Installing signal handlers")?; + // A broken htpasswd will abort startup. + let auth_state = AuthState::new(&args.htpasswd)?; let mycelium_setup = { let spawn_config = MyceliumSpawnConfig { @@ -223,7 +225,6 @@ async fn main() -> Result<()> { ); swarm::state_file::update(&state, true).await; - let auth_state = AuthState::new(&args.htpasswd); let app = api::router(state.clone(), auth_state, args.debug_api); let listener = tokio::net::TcpListener::bind(args.bind_addr) -- cgit v1.3.1