feat: add Docker first-start admin bootstrap
This commit is contained in:
1 parent
25577b4b83
commit
8e0bcbe580
9 files changed
+152
-6
No files matched your search
+3
-3
@@ -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
|
||||
|
||||
+4
-2
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Executable
+9
@@ -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 "$@"
|
||||
+15
-1
@@ -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: "<your-secure-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
|
||||
|
||||
@@ -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<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// Initialize the first administrator for automated/container deployments
|
||||
InitAdmin {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Run database diagnostics and health checks
|
||||
Doctor {
|
||||
#[arg(long)]
|
||||
|
||||
@@ -44,6 +44,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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?;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user