From 8e0bcbe580d2e65498e2c29816dd1ad5f890be77 Mon Sep 17 00:00:00 2001 From: Sunil Thakare Date: Wed, 12 Aug 2026 14:13:09 +0530 Subject: [PATCH] feat: add Docker first-start admin bootstrap --- .env.example | 6 ++--- Dockerfile | 6 +++-- docker-compose.yml | 2 ++ docker-entrypoint.sh | 9 +++++++ docs/DOCKER-Deploy.md | 16 ++++++++++- src/cli/init_admin.rs | 53 ++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 6 +++++ src/main.rs | 3 +++ tests/init_admin_tests.rs | 57 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 152 insertions(+), 6 deletions(-) create mode 100755 docker-entrypoint.sh create mode 100644 src/cli/init_admin.rs create mode 100644 tests/init_admin_tests.rs diff --git a/.env.example b/.env.example index 720b00f..63ebc1f 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,10 @@ COOKIE_SECURE=false SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it # Bootstrap Admin Credentials -# Default username: admin -# Default password: admin ADMIN_USERNAME=admin -ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 +# REQUIRED for a fresh deployment. +# Use a strong unique password. +ADMIN_PASSWORD= # Cron & Cleaner Intervals (in minutes) LINK_CHECK_INTERVAL_MINS=60 diff --git a/Dockerfile b/Dockerfile index e32f022..f6fe111 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,7 @@ RUN groupadd --gid 1000 bzod && \ # Application binary COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # Application-owned immutable assets COPY --from=builder /app/templates /app/templates @@ -68,7 +69,8 @@ RUN mkdir -p \ /app/images \ /app/templates \ /app/www \ - /usr/local/bin/bzod + /usr/local/bin/bzod \ + /usr/local/bin/docker-entrypoint.sh # Runtime configuration ENV DATA_DIR=/app/data \ @@ -89,5 +91,5 @@ HEALTHCHECK \ USER bzod -ENTRYPOINT ["/usr/local/bin/bzod"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["serve"] diff --git a/docker-compose.yml b/docker-compose.yml index 5d8320b..d49acaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: environment: - COOKIE_SECURE=false - DATA_DIR=/app/data + - ADMIN_USERNAME=${ADMIN_USERNAME} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} - HOST=0.0.0.0 - PORT=8654 - RUST_LOG=info diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..6da3050 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +if [ "$1" = 'serve' ]; then + /usr/local/bin/bzod init-admin + exec /usr/local/bin/bzod serve +fi + +exec /usr/local/bin/bzod "$@" diff --git a/docs/DOCKER-Deploy.md b/docs/DOCKER-Deploy.md index 9e8065c..e1e4ba0 100644 --- a/docs/DOCKER-Deploy.md +++ b/docs/DOCKER-Deploy.md @@ -45,7 +45,21 @@ cd nx9-url-shortener docker compose up -d --build ``` -## Create Administrator +## Automated Administrator Bootstrap (First Start Only) + +For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically: + +```yaml + environment: + ADMIN_USERNAME: "admin" + ADMIN_PASSWORD: "" +``` + +These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved. + +## Manual Administrator Creation + +Alternatively, if you prefer not to use environment variables, you can create the admin manually: ```bash docker exec -it bzod bzod create-admin diff --git a/src/cli/init_admin.rs b/src/cli/init_admin.rs new file mode 100644 index 0000000..381d649 --- /dev/null +++ b/src/cli/init_admin.rs @@ -0,0 +1,53 @@ +use crate::auth::hash_password; +use crate::config::Config; +use crate::db::Db; +use std::env; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + let conn = db.users.lock().unwrap(); + + let admin_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", + [], + |row| row.get(0), + )?; + + if admin_count > 0 { + info!("Administrator already exists; initialization skipped."); + return Ok(()); + } + + let username = match env::var("ADMIN_USERNAME") { + Ok(u) if !u.trim().is_empty() => u.trim().to_string(), + _ => { + let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization."; + error!("{}", msg); + return Err(msg.into()); + } + }; + + let password = match env::var("ADMIN_PASSWORD") { + Ok(p) if !p.trim().is_empty() => p.trim().to_string(), + _ => { + let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization."; + error!("{}", msg); + return Err(msg.into()); + } + }; + + let hash = hash_password(&password).map_err(|e| e.to_string())?; + let u = crate::db::users::create_admin_user(&conn, &username, &hash)?; + db.init_user_databases(u.id)?; + info!("Administrator initialized successfully."); + + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a30ca92..ab0934c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,7 @@ pub mod disable_user; pub mod doctor; pub mod enable_user; pub mod expand; +pub mod init_admin; pub mod list_users; pub mod migrate; pub mod repair; @@ -86,6 +87,11 @@ pub enum Commands { #[arg(long)] data_dir: Option, }, + /// Initialize the first administrator for automated/container deployments + InitAdmin { + #[arg(long)] + data_dir: Option, + }, /// Run database diagnostics and health checks Doctor { #[arg(long)] diff --git a/src/main.rs b/src/main.rs index e3124ed..a96662f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,9 @@ async fn main() -> Result<(), Box> { Commands::CreateAdmin { username, data_dir } => { bzod::cli::create_admin::run(username, data_dir, config).await?; } + Commands::InitAdmin { data_dir } => { + bzod::cli::init_admin::run(data_dir, config).await?; + } Commands::Doctor { data_dir } => { bzod::cli::doctor::run(data_dir, config).await?; } diff --git a/tests/init_admin_tests.rs b/tests/init_admin_tests.rs new file mode 100644 index 0000000..5f31553 --- /dev/null +++ b/tests/init_admin_tests.rs @@ -0,0 +1,57 @@ +use bzod::config::Config; +use bzod::db::Db; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn create_temp_config(temp_dir: PathBuf) -> Config { + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.clone(); + config.base_url = Some("http://bzo.in".to_string()); + config +} + +#[tokio::test] +async fn test_init_admin() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_init_admin_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + + // Case C/D - missing env vars + env::remove_var("ADMIN_USERNAME"); + env::remove_var("ADMIN_PASSWORD"); + let res = bzod::cli::init_admin::run(None, config.clone()).await; + assert!(res.is_err(), "Should fail without env vars"); + + // Case A - No admin + valid ENV + env::set_var("ADMIN_USERNAME", "admin"); + env::set_var("ADMIN_PASSWORD", "securepass"); + let res = bzod::cli::init_admin::run(None, config.clone()).await; + assert!(res.is_ok(), "Should succeed with valid env vars"); + + let db = Db::init(&config).unwrap(); + { + let conn = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_username(&conn, "admin") + .unwrap() + .unwrap(); + assert_eq!(user.account_type, "admin"); + assert!(bzod::auth::verify_password( + "securepass", + &user.password_hash + )); + } + + // Case B/F - Admin already exists + env::remove_var("ADMIN_USERNAME"); + env::remove_var("ADMIN_PASSWORD"); + let res = bzod::cli::init_admin::run(None, config.clone()).await; + assert!( + res.is_ok(), + "Should skip and succeed if admin exists even without env vars" + ); + + let _ = fs::remove_dir_all(&temp_dir); +}