Some checks failed
BotServer CI/CD / build (push) Failing after 1m27s
- bootstrap_utils.rs: Change Vec<(&'static str,...)> to Vec<(String,...)> to avoid dangling references - bootstrap_manager.rs: Use name.as_str() for safe_pkill - setup.rs: Use PathBuf instead of Path::new with format! - directory/bootstrap.rs: Use PathBuf for pat_dir - main.rs: Use PathBuf for vault_init_path_early
420 lines
14 KiB
Rust
420 lines
14 KiB
Rust
use anyhow::Result;
|
||
use log::{error, info, warn};
|
||
use rand::Rng;
|
||
use std::fs;
|
||
use std::os::unix::fs::PermissionsExt;
|
||
use crate::core::shared::utils::get_stack_path;
|
||
use uuid::Uuid;
|
||
|
||
use super::client::ZitadelClient;
|
||
|
||
const ADMIN_USERNAME: &str = "admin";
|
||
const DEFAULT_ORG_NAME: &str = "General Bots";
|
||
|
||
pub struct BootstrapResult {
|
||
pub user_id: String,
|
||
pub organization_id: Option<String>,
|
||
pub username: String,
|
||
pub email: String,
|
||
pub initial_password: String,
|
||
pub setup_url: String,
|
||
}
|
||
|
||
pub async fn check_and_bootstrap_admin(client: &ZitadelClient) -> Result<Option<BootstrapResult>> {
|
||
info!("Checking if bootstrap is needed...");
|
||
|
||
match client.list_users(10, 0).await {
|
||
Ok(users) => {
|
||
if !users.is_empty() {
|
||
let has_admin = users.iter().any(|u| {
|
||
let username = u
|
||
.get("userName")
|
||
.or_else(|| u.get("username"))
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
|
||
let has_admin_role = u
|
||
.get("roles")
|
||
.and_then(|r| r.as_array())
|
||
.map(|roles| {
|
||
roles.iter().any(|r| {
|
||
r.as_str()
|
||
.map(|s| s.to_lowercase().contains("admin"))
|
||
.unwrap_or(false)
|
||
})
|
||
})
|
||
.unwrap_or(false);
|
||
|
||
username == ADMIN_USERNAME || has_admin_role
|
||
});
|
||
|
||
if has_admin {
|
||
info!("Admin user already exists, skipping bootstrap");
|
||
return Ok(None);
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
warn!(
|
||
"Could not check existing users (may be first run): {}",
|
||
e
|
||
);
|
||
}
|
||
}
|
||
|
||
info!("No admin user found, bootstrapping initial admin account...");
|
||
|
||
let result = create_bootstrap_admin(client).await?;
|
||
|
||
print_bootstrap_credentials(&result);
|
||
|
||
Ok(Some(result))
|
||
}
|
||
|
||
fn generate_secure_password() -> String {
|
||
let mut rng = rand::rng();
|
||
|
||
let lowercase: Vec<char> = (b'a'..=b'z').map(|c| c as char).collect();
|
||
let uppercase: Vec<char> = (b'A'..=b'Z').map(|c| c as char).collect();
|
||
let digits: Vec<char> = (b'0'..=b'9').map(|c| c as char).collect();
|
||
let special: Vec<char> = "!@#$%&*".chars().collect();
|
||
|
||
let mut password = Vec::with_capacity(16);
|
||
|
||
password.push(lowercase[rng.random_range(0..lowercase.len())]);
|
||
password.push(uppercase[rng.random_range(0..uppercase.len())]);
|
||
password.push(digits[rng.random_range(0..digits.len())]);
|
||
password.push(special[rng.random_range(0..special.len())]);
|
||
|
||
let all_chars: Vec<char> = lowercase
|
||
.iter()
|
||
.chain(uppercase.iter())
|
||
.chain(digits.iter())
|
||
.chain(special.iter())
|
||
.copied()
|
||
.collect();
|
||
|
||
for _ in 0..12 {
|
||
password.push(all_chars[rng.random_range(0..all_chars.len())]);
|
||
}
|
||
|
||
for i in (1..password.len()).rev() {
|
||
let j = rng.random_range(0..=i);
|
||
password.swap(i, j);
|
||
}
|
||
|
||
password.into_iter().collect()
|
||
}
|
||
|
||
async fn create_bootstrap_admin(client: &ZitadelClient) -> Result<BootstrapResult> {
|
||
let email = format!("{}@localhost", ADMIN_USERNAME);
|
||
|
||
let user_id = client
|
||
.create_user(&email, "System", "Administrator", Some(ADMIN_USERNAME))
|
||
.await
|
||
.map_err(|e| anyhow::anyhow!("Failed to create admin user: {}", e))?;
|
||
|
||
info!("Created admin user with ID: {}", user_id);
|
||
|
||
let initial_password = generate_secure_password();
|
||
|
||
if let Err(e) = client.set_user_password(&user_id, &initial_password, false).await {
|
||
warn!("Failed to set initial password via API: {}. User may need to use password reset flow.", e);
|
||
} else {
|
||
info!("Initial password set for admin user");
|
||
}
|
||
|
||
let org_id = match create_default_organization(client).await {
|
||
Ok(id) => {
|
||
info!("Created default organization with ID: {}", id);
|
||
|
||
let admin_roles = vec![
|
||
"admin".to_string(),
|
||
"org_owner".to_string(),
|
||
"user_manager".to_string(),
|
||
];
|
||
if let Err(e) = client.add_org_member(&id, &user_id, admin_roles).await {
|
||
warn!("Failed to add admin to organization: {}", e);
|
||
}
|
||
|
||
Some(id)
|
||
}
|
||
Err(e) => {
|
||
warn!("Failed to create default organization: {}", e);
|
||
None
|
||
}
|
||
};
|
||
|
||
// Create a PAT token for the admin user for API access
|
||
// Note: This requires OAuth client credentials. If not available, PAT creation
|
||
// will be skipped, but admin user can still login with username/password.
|
||
info!("Creating admin PAT token for API access...");
|
||
match client.create_pat(&user_id, "Admin API Token", None).await {
|
||
Ok(pat_token) => {
|
||
info!("Admin PAT token created successfully");
|
||
save_admin_pat_token(&pat_token);
|
||
}
|
||
Err(e) => {
|
||
// PAT creation failure is not critical - user can still login
|
||
// This happens when Zitadel doesn't have OAuth client configured yet
|
||
warn!("Failed to create admin PAT token (non-critical): {}", e);
|
||
info!("Admin user can still login with username/password. PAT can be created later via UI.");
|
||
}
|
||
}
|
||
|
||
let base_url = client.api_url();
|
||
let setup_url = format!("{}/ui/login", base_url);
|
||
|
||
let result = BootstrapResult {
|
||
user_id: user_id.clone(),
|
||
organization_id: org_id,
|
||
username: ADMIN_USERNAME.to_string(),
|
||
email: email.clone(),
|
||
initial_password: initial_password.clone(),
|
||
setup_url: setup_url.clone(),
|
||
};
|
||
|
||
save_setup_credentials(&result);
|
||
|
||
create_password_change_reminder(&user_id);
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
fn create_password_change_reminder(user_id: &str) {
|
||
let reminder_file = dirs::home_dir()
|
||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||
.join(".gb-admin-reminders.json");
|
||
|
||
let reminder = serde_json::json!({
|
||
"id": Uuid::new_v4().to_string(),
|
||
"type": "security",
|
||
"priority": "high",
|
||
"title": "Change initial admin password",
|
||
"description": "The admin account was created with an auto-generated password. Please change it to a secure password of your choice.",
|
||
"user_id": user_id,
|
||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||
"status": "pending",
|
||
"action_url": "/settings/security"
|
||
});
|
||
|
||
let reminders = if reminder_file.exists() {
|
||
let content = fs::read_to_string(&reminder_file).unwrap_or_default();
|
||
let mut arr: Vec<serde_json::Value> = serde_json::from_str(&content).unwrap_or_default();
|
||
arr.push(reminder);
|
||
arr
|
||
} else {
|
||
vec![reminder]
|
||
};
|
||
|
||
if let Err(e) = fs::write(&reminder_file, serde_json::to_string_pretty(&reminders).unwrap_or_default()) {
|
||
warn!("Failed to save password change reminder: {}", e);
|
||
} else {
|
||
info!("Created security reminder: Change initial admin password");
|
||
}
|
||
}
|
||
|
||
async fn create_default_organization(client: &ZitadelClient) -> Result<String> {
|
||
let url = format!("{}/v2/organizations", client.api_url());
|
||
|
||
let body = serde_json::json!({
|
||
"name": DEFAULT_ORG_NAME
|
||
});
|
||
|
||
let response = client
|
||
.http_post(url)
|
||
.await
|
||
.json(&body)
|
||
.send()
|
||
.await
|
||
.map_err(|e| anyhow::anyhow!("Failed to create organization: {}", e))?;
|
||
|
||
if response.status().is_success() {
|
||
let data: serde_json::Value = response
|
||
.json()
|
||
.await
|
||
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?;
|
||
|
||
let org_id = data
|
||
.get("organizationId")
|
||
.or_else(|| data.get("id"))
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| anyhow::anyhow!("No organization ID in response"))?
|
||
.to_string();
|
||
|
||
Ok(org_id)
|
||
} else {
|
||
let error_text = response.text().await.unwrap_or_default();
|
||
Err(anyhow::anyhow!(
|
||
"Failed to create organization: {}",
|
||
error_text
|
||
))
|
||
}
|
||
}
|
||
|
||
fn save_setup_credentials(result: &BootstrapResult) {
|
||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||
let creds_path = format!("{}/.gb-setup-credentials", home);
|
||
|
||
let content = format!(
|
||
r#"# General Bots Initial Setup Credentials
|
||
# Created: {}
|
||
|
||
╔════════════════════════════════════════════════════════════╗
|
||
║ 🔐 ADMIN LOGIN - READY TO USE ║
|
||
╠════════════════════════════════════════════════════════════╣
|
||
║ ║
|
||
║ Username: {:<46}║
|
||
║ Password: {:<46}║
|
||
║ Email: {:<46}║
|
||
║ ║
|
||
║ 🌐 LOGIN NOW: http://localhost:8080/suite/login ║
|
||
║ ║
|
||
╚════════════════════════════════════════════════════════════╝
|
||
|
||
✅ Login directly - no password change required on first access
|
||
⚠️ SECURITY: Change this password after login (Settings > Security)
|
||
🗑️ Delete this file after saving your new password
|
||
"#,
|
||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
|
||
result.username,
|
||
result.initial_password,
|
||
result.email
|
||
);
|
||
|
||
match fs::write(&creds_path, &content) {
|
||
Ok(_) => {
|
||
#[cfg(unix)]
|
||
{
|
||
if let Err(e) = fs::set_permissions(&creds_path, fs::Permissions::from_mode(0o600)) {
|
||
warn!("Failed to set file permissions: {}", e);
|
||
}
|
||
}
|
||
info!("Setup credentials saved to: {}", creds_path);
|
||
}
|
||
Err(e) => {
|
||
error!("Failed to save setup credentials: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn save_admin_pat_token(pat_token: &str) {
|
||
// Create directory if it doesn't exist
|
||
let stack = get_stack_path();
|
||
let pat_dir = std::path::PathBuf::from(format!("{}/conf/directory", stack));
|
||
if let Err(e) = fs::create_dir_all(&pat_dir) {
|
||
error!("Failed to create PAT directory: {}", e);
|
||
return;
|
||
}
|
||
|
||
let pat_path = pat_dir.join("admin-pat.txt");
|
||
|
||
match fs::write(&pat_path, pat_token) {
|
||
Ok(_) => {
|
||
#[cfg(unix)]
|
||
{
|
||
if let Err(e) = fs::set_permissions(&pat_path, fs::Permissions::from_mode(0o600)) {
|
||
warn!("Failed to set PAT file permissions: {}", e);
|
||
}
|
||
}
|
||
info!("Admin PAT token saved to: {}", pat_path.display());
|
||
}
|
||
Err(e) => {
|
||
error!("Failed to save admin PAT token: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn print_bootstrap_credentials(result: &BootstrapResult) {
|
||
let separator = "═".repeat(60);
|
||
|
||
println!();
|
||
println!("╔{}╗", separator);
|
||
println!("║{:^60}║", "");
|
||
println!("║{:^60}║", "🤖 GENERAL BOTS - INITIAL SETUP");
|
||
println!("║{:^60}║", "");
|
||
println!("╠{}╣", separator);
|
||
println!("║{:^60}║", "");
|
||
println!("║ {:56}║", "Administrator account created!");
|
||
println!("║{:^60}║", "");
|
||
println!("╠{}╣", separator);
|
||
println!("║{:^60}║", "");
|
||
println!("║{:^60}║", "🔐 LOGIN CREDENTIALS (READY TO USE):");
|
||
println!("║{:^60}║", "");
|
||
println!("║ {:<58}║", format!("Username: {}", result.username));
|
||
println!("║ {:<58}║", format!("Password: {}", result.initial_password));
|
||
println!("║ {:<58}║", format!("Email: {}", result.email));
|
||
println!("║{:^60}║", "");
|
||
|
||
if let Some(ref org_id) = result.organization_id {
|
||
println!(
|
||
"║ {:<58}║",
|
||
format!("Organization: {} ({})", DEFAULT_ORG_NAME, &org_id[..8.min(org_id.len())])
|
||
);
|
||
println!("║{:^60}║", "");
|
||
}
|
||
|
||
println!("╠{}╣", separator);
|
||
println!("║{:^60}║", "");
|
||
println!("║ {:56}║", "🌐 LOGIN NOW:");
|
||
println!("║{:^60}║", "");
|
||
println!("║ {:56}║", "http://localhost:8080/suite/login");
|
||
println!("║{:^60}║", "");
|
||
println!("╠{}╣", separator);
|
||
println!("║{:^60}║", "");
|
||
println!("║ ✅ {:<54}║", "Login directly - no password change required");
|
||
println!("║{:^60}║", "");
|
||
println!("║ {:<56}║", "⚠️ Change password after login (Settings > Security)");
|
||
println!("║ {:<56}║", "📁 Credentials saved to: ~/.gb-setup-credentials");
|
||
println!("║{:^60}║", "");
|
||
println!("╚{}╝", separator);
|
||
println!();
|
||
|
||
info!(
|
||
"Bootstrap complete: admin user '{}' created - ready for direct login",
|
||
result.username
|
||
);
|
||
}
|
||
|
||
pub fn print_existing_admin_notice() {
|
||
println!();
|
||
println!("ℹ️ Admin user already exists. Skipping bootstrap.");
|
||
println!(" If you forgot your password, use Zitadel console to reset it.");
|
||
println!();
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_generate_secure_password() {
|
||
let password = generate_secure_password();
|
||
|
||
assert!(password.len() >= 14);
|
||
|
||
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
|
||
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
|
||
let has_digit = password.chars().any(|c| c.is_ascii_digit());
|
||
let has_special = password.chars().any(|c| "!@#$%&*".contains(c));
|
||
|
||
assert!(has_lower, "Password should contain lowercase");
|
||
assert!(has_upper, "Password should contain uppercase");
|
||
assert!(has_digit, "Password should contain digits");
|
||
assert!(has_special, "Password should contain special chars");
|
||
}
|
||
|
||
#[test]
|
||
fn test_password_uniqueness() {
|
||
let passwords: Vec<String> = (0..10).map(|_| generate_secure_password()).collect();
|
||
|
||
for i in 0..passwords.len() {
|
||
for j in (i + 1)..passwords.len() {
|
||
assert_ne!(
|
||
passwords[i], passwords[j],
|
||
"Generated passwords should be unique"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|