diff --git a/config.example.toml b/config.example.toml index 1244581..8b22024 100644 --- a/config.example.toml +++ b/config.example.toml @@ -61,3 +61,10 @@ # Log filter (sets RUST_LOG). Default: info # log = "kobors=debug,info" + +# The initial admin account is bootstrapped from the ENVIRONMENT only (not this +# file), and only on a fresh database with no users: +# KOBORS_ADMIN_ACCOUNT / KOBORS_ADMIN_ACCOUNT_FILE +# KOBORS_ADMIN_PASSWORD / KOBORS_ADMIN_PASSWORD_FILE +# The *_FILE variants read the value from a file (Docker/systemd secrets). If no +# credentials are set on a fresh database, no account is created. diff --git a/nix/module.nix b/nix/module.nix index 5909296..86a598a 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -154,6 +154,26 @@ in { description = "Value of `RUST_LOG` for the service."; }; + adminAccount = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Username for the initial admin account, created on first boot only when + the database has no users yet. Requires `adminPasswordFile`. No account + is created if unset. + ''; + }; + + adminPasswordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the initial admin account's password, read at + boot. Pair with `adminAccount` to bootstrap the first user. Keep this + outside the Nix store (e.g. an agenix/sops secret). + ''; + }; + user = mkOption { type = types.str; default = "kobors"; @@ -234,6 +254,12 @@ in { } // lib.optionalAttrs (cfg.storeUserId != null) { KOBORS_STORE_USER_ID = cfg.storeUserId; + } + // lib.optionalAttrs (cfg.adminAccount != null) { + KOBORS_ADMIN_ACCOUNT = cfg.adminAccount; + } + // lib.optionalAttrs (cfg.adminPasswordFile != null) { + KOBORS_ADMIN_PASSWORD_FILE = toString cfg.adminPasswordFile; }; serviceConfig = diff --git a/src/main.rs b/src/main.rs index 036ea08..dfa1b1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,8 +71,8 @@ async fn main() -> Result<(), Box> { .await .map_err(|e| format!("Failed to connect to app DB: {e}"))?; - // Seed a default admin user if no users exist - seed_default_user(&app_db).await?; + // Bootstrap the initial admin user from the environment, if configured. + seed_admin_user(&app_db).await?; // Build the unified book catalog over the app DB pool. tokio::fs::create_dir_all(&config.upload_dir) @@ -161,22 +161,59 @@ async fn main() -> Result<(), Box> { Ok(()) } -/// Create a default admin user if no users exist yet. -async fn seed_default_user(app_db: &AppDb) -> Result<(), Box> { +/// Create the initial admin user from the environment when the database has no +/// users yet. Credentials come from `KOBORS_ADMIN_ACCOUNT` and +/// `KOBORS_ADMIN_PASSWORD` (or their `*_FILE` counterparts, which read the value +/// from a file for Docker/systemd secrets). No default account is ever created: +/// on a fresh database with no credentials configured, no user is seeded and +/// login is impossible until they are provided. +async fn seed_admin_user(app_db: &AppDb) -> Result<(), Box> { let has_users = app_db .has_users() .await .map_err(|e| format!("Failed to check users: {e}"))?; + if has_users { + return Ok(()); + } - if !has_users { - let password_hash = password_auth::generate_hash("admin"); - app_db - .create_user("admin", &password_hash) - .await - .map_err(|e| format!("Failed to create default user: {e}"))?; - - warn!("Created default admin user (username: admin, password: admin). Change this immediately!"); + match ( + env_or_file("KOBORS_ADMIN_ACCOUNT"), + env_or_file("KOBORS_ADMIN_PASSWORD"), + ) { + (Some(username), Some(password)) => { + let password_hash = password_auth::generate_hash(&password); + app_db + .create_user(&username, &password_hash) + .await + .map_err(|e| format!("Failed to create admin user: {e}"))?; + info!(username = %username, "Created initial admin user from environment"); + } + _ => { + warn!( + "No users exist and no admin credentials are configured. Set \ + KOBORS_ADMIN_ACCOUNT and KOBORS_ADMIN_PASSWORD (or their *_FILE \ + variants) to bootstrap the first account." + ); + } } Ok(()) } + +/// Resolve a secret from `$NAME`, falling back to the contents of the file named +/// by `$NAME_FILE`. Empty values count as unset; file contents are trimmed. +fn env_or_file(name: &str) -> Option { + if let Some(v) = std::env::var(name).ok().filter(|s| !s.is_empty()) { + return Some(v); + } + let path = std::env::var(format!("{name}_FILE")) + .ok() + .filter(|s| !s.is_empty())?; + match std::fs::read_to_string(&path) { + Ok(contents) => Some(contents.trim().to_string()).filter(|s| !s.is_empty()), + Err(e) => { + warn!(path = %path, "Failed to read {name}_FILE: {e}"); + None + } + } +}