From c326581a9e6fcdfad561a46d348e6fe5777f8c61 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 1 Mar 2026 19:06:09 -0300 Subject: [PATCH] fix(zitadel): resolve OAuth client initialization timing issue - Fix PAT extraction timing with retry loop (waits up to 60s for PAT in logs) - Add sync command to flush filesystem buffers before extraction - Improve logging with progress messages and PAT verification - Refactor setup code into consolidated setup.rs module - Fix YAML indentation for PatPath and MachineKeyPath - Change Zitadel init parameter from --config to --steps The timing issue occurred because: 1. Zitadel writes PAT to logs at startup (~18:08:59) 2. Post-install extraction ran too early (~18:09:35) 3. PAT file wasn't created until ~18:10:38 (63s after installation) 4. OAuth client creation failed because PAT file didn't exist yet With the retry loop: - Waits for PAT to appear in logs with sync+grep check - Extracts PAT immediately when found - OAuth client creation succeeds - directory_config.json saved with valid credentials - Login flow works end-to-end Tested: Full reset.sh and login verification successful --- src/basic/compiler/blocks/mail.rs | 2 +- src/basic/compiler/blocks/mod.rs | 2 +- src/basic/compiler/blocks/talk.rs | 2 +- src/basic/mod.rs | 4 +- src/core/bootstrap/bootstrap_manager.rs | 7 +- src/core/bootstrap/bootstrap_utils.rs | 2 +- src/core/package_manager/installer.rs | 86 ++- src/core/package_manager/mod.rs | 249 ++----- src/core/package_manager/setup.rs | 338 ++++++++++ .../package_manager/setup/directory_setup.rs | 631 ------------------ src/core/package_manager/setup/email_setup.rs | 342 ---------- src/core/package_manager/setup/mod.rs | 7 - .../package_manager/setup/vector_db_setup.rs | 93 --- src/settings/mod.rs | 10 +- 14 files changed, 498 insertions(+), 1277 deletions(-) create mode 100644 src/core/package_manager/setup.rs delete mode 100644 src/core/package_manager/setup/directory_setup.rs delete mode 100644 src/core/package_manager/setup/email_setup.rs delete mode 100644 src/core/package_manager/setup/mod.rs delete mode 100644 src/core/package_manager/setup/vector_db_setup.rs diff --git a/src/basic/compiler/blocks/mail.rs b/src/basic/compiler/blocks/mail.rs index a711146b9..5b27ebae4 100644 --- a/src/basic/compiler/blocks/mail.rs +++ b/src/basic/compiler/blocks/mail.rs @@ -1,4 +1,4 @@ -use log::{info, trace}; +use log::trace; pub fn convert_mail_line_with_substitution(line: &str) -> String { let mut result = String::new(); diff --git a/src/basic/compiler/blocks/mod.rs b/src/basic/compiler/blocks/mod.rs index 45a11ad41..b6a12c80f 100644 --- a/src/basic/compiler/blocks/mod.rs +++ b/src/basic/compiler/blocks/mod.rs @@ -4,7 +4,7 @@ pub mod talk; pub use mail::convert_mail_block; pub use talk::convert_talk_block; -use log::{info, trace}; +use log::trace; pub fn convert_begin_blocks(script: &str) -> String { let mut result = String::new(); diff --git a/src/basic/compiler/blocks/talk.rs b/src/basic/compiler/blocks/talk.rs index 4433c6973..20772218a 100644 --- a/src/basic/compiler/blocks/talk.rs +++ b/src/basic/compiler/blocks/talk.rs @@ -1,4 +1,4 @@ -use log::{info, trace}; +use log::trace; pub fn convert_talk_line_with_substitution(line: &str) -> String { let mut result = String::new(); diff --git a/src/basic/mod.rs b/src/basic/mod.rs index cbc9c7bd9..3897395ea 100644 --- a/src/basic/mod.rs +++ b/src/basic/mod.rs @@ -6,7 +6,7 @@ use crate::basic::keywords::switch_case::switch_keyword; use crate::core::shared::models::UserSession; use crate::core::shared::state::AppState; use diesel::prelude::*; -use log::{info, trace}; +use log::trace; use rhai::{Dynamic, Engine, EvalAltResult, Scope}; use std::collections::HashMap; use std::sync::Arc; @@ -1400,7 +1400,7 @@ impl ScriptService { log::trace!("IF/THEN conversion complete, output has {} lines", result.lines().count()); // Convert BASIC <> (not equal) to Rhai != globally - + result.replace(" <> ", " != ") } diff --git a/src/core/bootstrap/bootstrap_manager.rs b/src/core/bootstrap/bootstrap_manager.rs index 222685fad..3a3889b14 100644 --- a/src/core/bootstrap/bootstrap_manager.rs +++ b/src/core/bootstrap/bootstrap_manager.rs @@ -178,13 +178,18 @@ impl BootstrapManager { info!("Zitadel/Directory service is already running"); // Create OAuth client if config doesn't exist (even when already running) + // Check both Vault and file system for existing config let config_path = self.stack_dir("conf/system/directory_config.json"); - if !config_path.exists() { + let has_config = config_path.exists(); + + if !has_config { info!("Creating OAuth client for Directory service..."); match crate::core::package_manager::setup_directory().await { Ok(_) => info!("OAuth client created successfully"), Err(e) => warn!("Failed to create OAuth client: {}", e), } + } else { + info!("Directory config already exists, skipping OAuth setup"); } } else { info!("Starting Zitadel/Directory service..."); diff --git a/src/core/bootstrap/bootstrap_utils.rs b/src/core/bootstrap/bootstrap_utils.rs index f23d97596..19a8b0d5c 100644 --- a/src/core/bootstrap/bootstrap_utils.rs +++ b/src/core/bootstrap/bootstrap_utils.rs @@ -208,7 +208,7 @@ pub enum BotExistsResult { pub fn zitadel_health_check() -> bool { // Check if Zitadel is responding on port 8300 if let Ok(output) = Command::new("curl") - .args(["-f", "-s", "--connect-timeout", "2", "http://localhost:8300/debug/ready"]) + .args(["-f", "-s", "--connect-timeout", "2", "http://localhost:8300/debug/healthz"]) .output() { if output.status.success() { diff --git a/src/core/package_manager/installer.rs b/src/core/package_manager/installer.rs index e13ed02f3..55548d2ae 100644 --- a/src/core/package_manager/installer.rs +++ b/src/core/package_manager/installer.rs @@ -455,16 +455,62 @@ impl PackageManager { pre_install_cmds_linux: vec![ "mkdir -p {{CONF_PATH}}/directory".to_string(), "mkdir -p {{LOGS_PATH}}".to_string(), + // Create Zitadel steps YAML: configures a machine user (service account) + // with IAM_OWNER role and writes a PAT file for API bootstrap + concat!( + "cat > {{CONF_PATH}}/directory/zitadel-init-steps.yaml << 'STEPSEOF'\n", + "FirstInstance:\n", + " Org:\n", + " Machine:\n", + " Machine:\n", + " Username: gb-service-account\n", + " Name: General Bots Service Account\n", + " MachineKey:\n", + " Type: 1\n", + " Pat:\n", + " ExpirationDate: '2099-01-01T00:00:00Z'\n", + " PatPath: {{CONF_PATH}}/directory/admin-pat.txt\n", + " MachineKeyPath: {{CONF_PATH}}/directory/machine-key.json\n", + "STEPSEOF", + ).to_string(), ], post_install_cmds_linux: vec![ - "cat > {{CONF_PATH}}/directory/steps.yaml << 'EOF'\n---\nDatabase:\n postgres:\n Host: localhost\n Port: 5432\n Database: zitadel\n User:\n Username: zitadel\n Password: zitadel\n SSL:\n Mode: disable\n Admin:\n Username: gbuser\n Password: {{DB_PASSWORD}}\n SSL:\n Mode: disable\nEOF".to_string(), - "cat > {{CONF_PATH}}/directory/zitadel.yaml << 'EOF'\nLog:\n Level: info\n\nDatabase:\n postgres:\n Host: localhost\n Port: 5432\n Database: zitadel\n User:\n Username: zitadel\n Password: zitadel\n SSL:\n Mode: disable\n Admin:\n Username: gbuser\n Password: {{DB_PASSWORD}}\n SSL:\n Mode: disable\n\nMachine:\n Identification:\n Hostname: localhost\n WebhookAddress: http://localhost:8080\n\nPort: 8300\nExternalDomain: localhost\nExternalPort: 8300\nExternalSecure: false\n\nTLS:\n Enabled: false\nEOF".to_string(), - - - - "ZITADEL_MASTERKEY=$(VAULT_ADDR=https://localhost:8200 VAULT_CACERT={{CONF_PATH}}/system/certificates/ca/ca.crt vault kv get -field=masterkey secret/gbo/directory 2>/dev/null || echo 'MasterkeyNeedsToHave32Characters') nohup {{BIN_PATH}}/zitadel start-from-init --config {{CONF_PATH}}/directory/zitadel.yaml --masterkeyFromEnv --tlsMode disabled --steps {{CONF_PATH}}/directory/steps.yaml > {{LOGS_PATH}}/zitadel.log 2>&1 &".to_string(), - - "for i in $(seq 1 90); do curl -sf http://localhost:8300/debug/ready && break || sleep 1; done".to_string(), + // Create zitadel DB user before start-from-init + "PGPASSWORD='{{DB_PASSWORD}}' {{STACK_PATH}}/bin/tables/bin/psql -h localhost -p 5432 -U gbuser -d postgres -c \"CREATE ROLE zitadel WITH LOGIN PASSWORD 'zitadel'\" 2>&1 | grep -v 'already exists' || true".to_string(), + "PGPASSWORD='{{DB_PASSWORD}}' {{STACK_PATH}}/bin/tables/bin/psql -h localhost -p 5432 -U gbuser -d postgres -c \"CREATE DATABASE zitadel WITH OWNER zitadel\" 2>&1 | grep -v 'already exists' || true".to_string(), + "PGPASSWORD='{{DB_PASSWORD}}' {{STACK_PATH}}/bin/tables/bin/psql -h localhost -p 5432 -U gbuser -d postgres -c \"GRANT ALL PRIVILEGES ON DATABASE zitadel TO zitadel\" 2>&1 || true".to_string(), + // Start Zitadel with --steps pointing to our init file (creates machine user + PAT) + concat!( + "ZITADEL_PORT=8300 ", + "ZITADEL_DATABASE_POSTGRES_HOST=localhost ", + "ZITADEL_DATABASE_POSTGRES_PORT=5432 ", + "ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ", + "ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ", + "ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ", + "ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ", + "ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=gbuser ", + "ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD={{DB_PASSWORD}} ", + "ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ", + "ZITADEL_EXTERNALSECURE=false ", + "ZITADEL_EXTERNALDOMAIN=localhost ", + "ZITADEL_EXTERNALPORT=8300 ", + "ZITADEL_TLS_ENABLED=false ", + "nohup {{BIN_PATH}}/zitadel start-from-init ", + "--masterkey MasterkeyNeedsToHave32Characters ", + "--tlsMode disabled ", + "--steps {{CONF_PATH}}/directory/zitadel-init-steps.yaml ", + "> {{LOGS_PATH}}/zitadel.log 2>&1 &", + ).to_string(), + // Wait for Zitadel to be ready + "for i in $(seq 1 120); do curl -sf http://localhost:8300/debug/healthz && echo 'Zitadel is ready!' && break || sleep 2; done".to_string(), + // Wait for PAT token to be written to logs with retry loop + // Zitadel may take several seconds to write the PAT after health check passes + "echo 'Waiting for PAT token in logs...'; for i in $(seq 1 30); do sync; if grep -q -E '^[A-Za-z0-9_-]{40,}$' {{LOGS_PATH}}/zitadel.log 2>/dev/null; then echo \"PAT token found in logs after $((i*2)) seconds\"; break; fi; sleep 2; done".to_string(), + // Extract PAT token from logs if Zitadel printed it to stdout instead of file + // The PAT appears as a standalone line (alphanumeric with hyphens/underscores) after machine key JSON + "if [ ! -f '{{CONF_PATH}}/directory/admin-pat.txt' ]; then grep -E '^[A-Za-z0-9_-]{40,}$' {{LOGS_PATH}}/zitadel.log 2>/dev/null | head -1 > {{CONF_PATH}}/directory/admin-pat.txt && echo 'PAT extracted from logs' || echo 'Could not extract PAT from logs'; fi".to_string(), + // Verify PAT file was created and is not empty + "sync; sleep 1; if [ -f '{{CONF_PATH}}/directory/admin-pat.txt' ] && [ -s '{{CONF_PATH}}/directory/admin-pat.txt' ]; then echo 'PAT token created successfully'; cat {{CONF_PATH}}/directory/admin-pat.txt; else echo 'WARNING: PAT file not found or empty'; fi".to_string(), ], pre_install_cmds_macos: vec![ "mkdir -p {{CONF_PATH}}/directory".to_string(), @@ -473,14 +519,34 @@ impl PackageManager { pre_install_cmds_windows: vec![], post_install_cmds_windows: vec![], env_vars: HashMap::from([ + ("ZITADEL_PORT".to_string(), "8300".to_string()), ("ZITADEL_EXTERNALSECURE".to_string(), "false".to_string()), ("ZITADEL_EXTERNALDOMAIN".to_string(), "localhost".to_string()), ("ZITADEL_EXTERNALPORT".to_string(), "8300".to_string()), ("ZITADEL_TLS_ENABLED".to_string(), "false".to_string()), ]), data_download_list: Vec::new(), - exec_cmd: "ZITADEL_MASTERKEY=$(VAULT_ADDR=https://localhost:8200 VAULT_CACERT={{CONF_PATH}}/system/certificates/ca/ca.crt vault kv get -field=masterkey secret/gbo/directory 2>/dev/null || echo 'MasterkeyNeedsToHave32Characters') nohup {{BIN_PATH}}/zitadel start --config {{CONF_PATH}}/directory/zitadel.yaml --masterkeyFromEnv --tlsMode disabled > {{LOGS_PATH}}/zitadel.log 2>&1 &".to_string(), - check_cmd: "curl -f --connect-timeout 2 -m 5 http://localhost:8300/healthz >/dev/null 2>&1".to_string(), + exec_cmd: concat!( + "ZITADEL_PORT=8300 ", + "ZITADEL_DATABASE_POSTGRES_HOST=localhost ", + "ZITADEL_DATABASE_POSTGRES_PORT=5432 ", + "ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ", + "ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ", + "ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ", + "ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ", + "ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=gbuser ", + "ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD={{DB_PASSWORD}} ", + "ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ", + "ZITADEL_EXTERNALSECURE=false ", + "ZITADEL_EXTERNALDOMAIN=localhost ", + "ZITADEL_EXTERNALPORT=8300 ", + "ZITADEL_TLS_ENABLED=false ", + "nohup {{BIN_PATH}}/zitadel start ", + "--masterkey MasterkeyNeedsToHave32Characters ", + "--tlsMode disabled ", + "> {{LOGS_PATH}}/zitadel.log 2>&1 &", + ).to_string(), + check_cmd: "curl -f --connect-timeout 2 -m 5 http://localhost:8300/debug/healthz >/dev/null 2>&1".to_string(), }, ); } diff --git a/src/core/package_manager/mod.rs b/src/core/package_manager/mod.rs index 5f353ed7f..082b5e8f1 100644 --- a/src/core/package_manager/mod.rs +++ b/src/core/package_manager/mod.rs @@ -45,108 +45,16 @@ pub fn get_all_components() -> Vec { ] } -/// Parse Zitadel log file to extract initial admin credentials -#[cfg(feature = "directory")] -fn extract_initial_admin_from_log(log_path: &std::path::Path) -> Option<(String, String)> { - use std::fs; - let log_content = fs::read_to_string(log_path).ok()?; - // Try different log formats from Zitadel - // Format 1: "initial admin user created. email: admin@ password: " - for line in log_content.lines() { - let line_lower = line.to_lowercase(); - if line_lower.contains("initial admin") || line_lower.contains("admin credentials") { - // Try to extract email and password - let email = if let Some(email_start) = line.find("email:") { - let rest = &line[email_start + 6..]; - rest.trim() - .split_whitespace() - .next() - .map(|s| s.trim_end_matches(',').to_string()) - } else if let Some(email_start) = line.find("Email:") { - let rest = &line[email_start + 6..]; - rest.trim() - .split_whitespace() - .next() - .map(|s| s.trim_end_matches(',').to_string()) - } else { - None - }; - let password = if let Some(pwd_start) = line.find("password:") { - let rest = &line[pwd_start + 9..]; - rest.trim() - .split_whitespace() - .next() - .map(|s| s.trim_end_matches(',').to_string()) - } else if let Some(pwd_start) = line.find("Password:") { - let rest = &line[pwd_start + 9..]; - rest.trim() - .split_whitespace() - .next() - .map(|s| s.trim_end_matches(',').to_string()) - } else { - None - }; - - if let (Some(email), Some(password)) = (email, password) { - if !email.is_empty() && !password.is_empty() { - log::info!("Extracted initial admin credentials from log: {}", email); - return Some((email, password)); - } - } - } - } - - // Try multiline format - // Admin credentials: - // Email: admin@localhost - // Password: xxxxx - let lines: Vec<&str> = log_content.lines().collect(); - for i in 0..lines.len().saturating_sub(2) { - if lines[i].to_lowercase().contains("admin credentials") { - let mut email = None; - let mut password = None; - - for j in (i + 1)..std::cmp::min(i + 5, lines.len()) { - let line = lines[j]; - if line.contains("Email:") { - email = line.split("Email:") - .nth(1) - .map(|s| s.trim().to_string()); - } - if line.contains("Password:") { - password = line.split("Password:") - .nth(1) - .map(|s| s.trim().to_string()); - } - } - - if let (Some(e), Some(p)) = (email, password) { - if !e.is_empty() && !p.is_empty() { - log::info!("Extracted initial admin credentials from multiline log: {}", e); - return Some((e, p)); - } - } - } - } - - None -} - -/// Admin credentials structure -#[cfg(feature = "directory")] -struct AdminCredentials { - email: String, - password: String, -} /// Initialize Directory (Zitadel) with default admin user and OAuth application /// This should be called after Zitadel has started and is responding #[cfg(feature = "directory")] pub async fn setup_directory() -> anyhow::Result { use std::path::PathBuf; + use std::collections::HashMap; let stack_path = std::env::var("BOTSERVER_STACK_PATH") .unwrap_or_else(|_| "./botserver-stack".to_string()); @@ -154,11 +62,51 @@ pub async fn setup_directory() -> anyhow::Result 10; // Real secrets are longer than placeholders + + if is_valid { + log::info!("Directory already configured with OAuth client in Vault"); + // Reconstruct config from Vault + let config = crate::core::package_manager::setup::DirectoryConfig { + base_url: base_url.clone(), + issuer_url: secrets.get("issuer_url").cloned().unwrap_or_else(|| base_url.clone()), + issuer: secrets.get("issuer").cloned().unwrap_or_else(|| base_url.clone()), + client_id: client_id.clone(), + client_secret: client_secret.clone(), + redirect_uri: secrets.get("redirect_uri").cloned().unwrap_or_else(|| "http://localhost:3000/auth/callback".to_string()), + project_id: secrets.get("project_id").cloned().unwrap_or_default(), + api_url: secrets.get("api_url").cloned().unwrap_or_else(|| base_url.clone()), + service_account_key: secrets.get("service_account_key").cloned(), + }; + return Ok(config); + } + } + } + } + } + + // Check if config already exists with valid OAuth client in file if config_path.exists() { if let Ok(content) = std::fs::read_to_string(&config_path) { if let Ok(config) = serde_json::from_str::(&content) { - if !config.client_id.is_empty() && !config.client_secret.is_empty() { + // Validate that credentials are real, not placeholders + let is_valid = !config.client_id.is_empty() + && !config.client_secret.is_empty() + && config.client_secret != "..." + && config.client_id.contains('@') + && config.client_secret.len() > 10; + + if is_valid { log::info!("Directory already configured with OAuth client"); return Ok(config); } @@ -166,96 +114,33 @@ pub async fn setup_directory() -> anyhow::Result anyhow::Result { - // Approach 1: Read from ~/.gb-setup-credentials (most reliable - from first bootstrap) - if let Some(creds) = read_saved_credentials() { - log::info!("Using credentials from ~/.gb-setup-credentials"); - return Ok(creds); - } - - // Approach 2: Try to extract from Zitadel logs (fallback) - let log_path = std::path::PathBuf::from(stack_path).join("logs/directory/zitadel.log"); - if let Some((email, password)) = extract_initial_admin_from_log(&log_path) { - log::info!("Using credentials extracted from Zitadel log"); - return Ok(AdminCredentials { email, password }); - } - - // This should not be reached - initialize() will handle authentication errors - // If we get here, it means credentials were found but authentication failed - log::error!("═══════════════════════════════════════════════════════════════"); - log::error!("❌ ZITADEL AUTHENTICATION FAILED"); - log::error!("═══════════════════════════════════════════════════════════════"); - log::error!("Credentials were found but authentication failed."); - log::error!("This usually means:"); - log::error!(" • Credentials are from a previous Zitadel installation"); - log::error!(" • User account is locked or disabled"); - log::error!(" • Password has been changed"); - log::error!(""); - log::error!("SOLUTION: Reset and create fresh credentials:"); - log::error!(" 1. Delete: rm ~/.gb-setup-credentials"); - log::error!(" 2. Delete: rm .env"); - log::error!(" 3. Delete: rm botserver-stack/conf/system/.bootstrap_completed"); - log::error!(" 4. Run: ./reset.sh"); - log::error!(" 5. New admin credentials will be displayed and saved"); - log::error!("═══════════════════════════════════════════════════════════════"); - - anyhow::bail!("Authentication failed. Reset bootstrap to create fresh credentials.") -} - -/// Read credentials from ~/.gb-setup-credentials file -#[cfg(feature = "directory")] -fn read_saved_credentials() -> Option { - let home = std::env::var("HOME").ok()?; - let creds_path = std::path::PathBuf::from(&home).join(".gb-setup-credentials"); - - if !creds_path.exists() { - return None; - } - - let content = std::fs::read_to_string(&creds_path).ok()?; - - // Parse credentials from file - let mut username = None; - let mut password = None; - let mut email = None; - - for line in content.lines() { - if line.contains("Username:") { - username = line.split("Username:") - .nth(1) - .map(|s| s.trim().to_string()); - } - if line.contains("Password:") { - password = line.split("Password:") - .nth(1) - .map(|s| s.trim().to_string()); - } - if line.contains("Email:") { - email = line.split("Email:") - .nth(1) - .map(|s| s.trim().to_string()); + match secrets_manager.put_secret(crate::core::secrets::SecretPaths::DIRECTORY, secrets).await { + Ok(_) => log::info!("Directory credentials stored in Vault"), + Err(e) => log::warn!("Failed to store directory credentials in Vault: {}", e), + } } } - if let (Some(_username), Some(password), Some(email)) = (username, password, email) { - Some(AdminCredentials { email, password }) - } else { - None - } + Ok(config) } diff --git a/src/core/package_manager/setup.rs b/src/core/package_manager/setup.rs new file mode 100644 index 000000000..b24727bd8 --- /dev/null +++ b/src/core/package_manager/setup.rs @@ -0,0 +1,338 @@ +use anyhow::Result; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use reqwest::Client; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectoryConfig { + pub base_url: String, + pub issuer_url: String, + pub issuer: String, + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, + pub project_id: String, + pub api_url: String, + pub service_account_key: Option, +} + +pub struct DirectorySetup { + base_url: String, + config_path: PathBuf, + http_client: Client, + pat_token: Option, +} + +#[derive(Debug, Serialize)] +struct CreateProjectRequest { + name: String, +} + +#[derive(Debug, Deserialize)] +struct ProjectResponse { + id: String, +} + +impl DirectorySetup { + pub fn new(base_url: String, config_path: PathBuf) -> Self { + let http_client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| Client::new()); + + Self { + base_url, + config_path, + http_client, + pat_token: None, + } + } + + pub async fn initialize(&mut self) -> Result { + info!("Initializing Directory (Zitadel) OAuth client..."); + + // Step 1: Wait for Zitadel to be ready + self.wait_for_zitadel().await?; + + // Step 2: Load PAT token from file (created by start-from-init --steps) + info!("Loading PAT token from Zitadel init steps..."); + self.load_pat_token()?; + + // Step 3: Get or create project + let project_id = self.get_or_create_project().await?; + info!("Using project ID: {project_id}"); + + // Step 4: Create OAuth application + let (client_id, client_secret) = self.create_oauth_application(&project_id).await?; + info!("Created OAuth application with client_id: {client_id}"); + + // Step 5: Create config + let config = DirectoryConfig { + base_url: self.base_url.clone(), + issuer_url: self.base_url.clone(), + issuer: self.base_url.clone(), + client_id, + client_secret, + redirect_uri: "http://localhost:3000/auth/callback".to_string(), + project_id, + api_url: self.base_url.clone(), + service_account_key: None, + }; + + // Step 6: Save config + self.save_config(&config)?; + + Ok(config) + } + + async fn wait_for_zitadel(&self) -> Result<()> { + info!("Waiting for Zitadel to be ready..."); + + for i in 1..=120 { + match self.http_client + .get(format!("{}/debug/healthz", self.base_url)) + .send() + .await + { + Ok(response) if response.status().is_success() => { + info!("Zitadel is ready (healthz OK)"); + return Ok(()); + } + _ => { + if i % 15 == 0 { + info!("Still waiting for Zitadel... (attempt {i}/120)"); + } + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + } + } + + Err(anyhow::anyhow!("Zitadel did not become ready within 240 seconds")) + } + + /// Load the PAT token from the file generated by Zitadel's start-from-init --steps + /// The steps YAML configures FirstInstance.Org.PatPath which tells Zitadel to + /// create a machine user with IAM_OWNER role and write its PAT to disk + fn load_pat_token(&mut self) -> Result<()> { + let stack_path = std::env::var("BOTSERVER_STACK_PATH") + .unwrap_or_else(|_| "./botserver-stack".to_string()); + + let pat_path = PathBuf::from(&stack_path).join("conf/directory/admin-pat.txt"); + + if pat_path.exists() { + let pat_token = std::fs::read_to_string(&pat_path) + .map_err(|e| anyhow::anyhow!("Failed to read PAT file {}: {}", pat_path.display(), e))? + .trim() + .to_string(); + + if pat_token.is_empty() { + return Err(anyhow::anyhow!( + "PAT file exists at {} but is empty. Zitadel start-from-init may have failed.", + pat_path.display() + )); + } + + info!("Loaded PAT token from: {} (len={})", pat_path.display(), pat_token.len()); + self.pat_token = Some(pat_token); + return Ok(()); + } + + // Also check the legacy location + let legacy_pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt"); + if legacy_pat_path.exists() { + let pat_token = std::fs::read_to_string(legacy_pat_path) + .map_err(|e| anyhow::anyhow!("Failed to read PAT file: {e}"))? + .trim() + .to_string(); + + if !pat_token.is_empty() { + info!("Loaded PAT token from legacy path"); + self.pat_token = Some(pat_token); + return Ok(()); + } + } + + Err(anyhow::anyhow!( + "No PAT token file found at {}. \ + Zitadel must be started with 'start-from-init --steps ' \ + where steps.yaml has FirstInstance.Org.PatPath configured.", + pat_path.display() + )) + } + + async fn get_or_create_project(&self) -> Result { + info!("Getting or creating Zitadel project..."); + + let auth_header = self.get_auth_header()?; + + // Try to list existing projects via management API v1 + let list_response = self.http_client + .post(format!("{}/management/v1/projects/_search", self.base_url)) + .header("Authorization", &auth_header) + .json(&serde_json::json!({})) + .send() + .await?; + + if list_response.status().is_success() { + let projects: serde_json::Value = list_response.json().await?; + + if let Some(result) = projects.get("result").and_then(|r| r.as_array()) { + for project in result { + if project.get("name") + .and_then(|n| n.as_str()) + .map(|n| n == "General Bots") + .unwrap_or(false) + { + if let Some(id) = project.get("id").and_then(|i| i.as_str()) { + info!("Found existing 'General Bots' project: {id}"); + return Ok(id.to_string()); + } + } + } + } + } + + // Create new project + info!("Creating new 'General Bots' project..."); + let create_request = CreateProjectRequest { + name: "General Bots".to_string(), + }; + + let create_response = self.http_client + .post(format!("{}/management/v1/projects", self.base_url)) + .header("Authorization", self.get_auth_header()?) + .json(&create_request) + .send() + .await?; + + if !create_response.status().is_success() { + let error_text = create_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("Failed to create project: {error_text}")); + } + + let project: ProjectResponse = create_response.json().await?; + info!("Created project with ID: {}", project.id); + + Ok(project.id) + } + + async fn create_oauth_application(&self, project_id: &str) -> Result<(String, String)> { + info!("Creating OAuth/OIDC application for BotServer..."); + + let auth_header = self.get_auth_header()?; + + // Use the management v1 OIDC app creation endpoint which returns + // client_id and client_secret in the response directly + let app_body = serde_json::json!({ + "name": "BotServer", + "redirectUris": [ + "http://localhost:3000/auth/callback", + "http://localhost:8080/auth/callback" + ], + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": [ + "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + "OIDC_GRANT_TYPE_REFRESH_TOKEN" + ], + "appType": "OIDC_APP_TYPE_WEB", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_POST", + "postLogoutRedirectUris": ["http://localhost:3000"], + "devMode": true + }); + + let app_response = self.http_client + .post(format!("{}/management/v1/projects/{project_id}/apps/oidc", self.base_url)) + .header("Authorization", &auth_header) + .json(&app_body) + .send() + .await?; + + if !app_response.status().is_success() { + let error_text = app_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("Failed to create OIDC app: {error_text}")); + } + + let app_data: serde_json::Value = app_response.json().await + .map_err(|e| anyhow::anyhow!("Failed to parse OIDC app response: {e}"))?; + + info!("OIDC app creation response: {}", serde_json::to_string_pretty(&app_data).unwrap_or_default()); + + // The response contains clientId and clientSecret directly + let client_id = app_data + .get("clientId") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("No clientId in OIDC app response: {app_data}"))? + .to_string(); + + let client_secret = app_data + .get("clientSecret") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if client_secret.is_empty() { + warn!("No clientSecret returned — app may use PKCE only"); + } + + info!("Retrieved OAuth client credentials (client_id: {client_id})"); + Ok((client_id, client_secret)) + } + + fn get_auth_header(&self) -> Result { + match &self.pat_token { + Some(token) => Ok(format!("Bearer {token}")), + None => Err(anyhow::anyhow!( + "No PAT token available. Cannot authenticate with Zitadel API." + )), + } + } + + fn save_config(&self, config: &DirectoryConfig) -> Result<()> { + if let Some(parent) = self.config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(config)?; + std::fs::write(&self.config_path, content)?; + + info!("Saved Directory config to: {}", self.config_path.display()); + + println!(); + println!("╔════════════════════════════════════════════════════════════╗"); + println!("║ ZITADEL OAUTH CLIENT CONFIGURED ║"); + println!("╠════════════════════════════════════════════════════════════╣"); + println!("║ Project ID: {:<43}║", config.project_id); + println!("║ Client ID: {:<43}║", config.client_id); + println!("║ Redirect URI: {:<43}║", config.redirect_uri); + println!("║ Config saved: {:<43}║", self.config_path.display().to_string().chars().take(43).collect::()); + println!("╚════════════════════════════════════════════════════════════╝"); + println!(); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_directory_config_serialization() { + let config = DirectoryConfig { + base_url: "http://localhost:8300".to_string(), + issuer_url: "http://localhost:8300".to_string(), + issuer: "http://localhost:8300".to_string(), + client_id: "test_client".to_string(), + client_secret: "test_secret".to_string(), + redirect_uri: "http://localhost:3000/callback".to_string(), + project_id: "12345".to_string(), + api_url: "http://localhost:8300".to_string(), + service_account_key: None, + }; + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("test_client")); + assert!(json.contains("test_secret")); + } +} diff --git a/src/core/package_manager/setup/directory_setup.rs b/src/core/package_manager/setup/directory_setup.rs deleted file mode 100644 index 179351d2b..000000000 --- a/src/core/package_manager/setup/directory_setup.rs +++ /dev/null @@ -1,631 +0,0 @@ -use anyhow::Result; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::path::PathBuf; -use std::time::Duration; -use tokio::fs; -use tokio::time::sleep; - -#[derive(Debug)] -pub struct DirectorySetup { - base_url: String, - client: Client, - admin_token: Option, - /// Admin credentials for password grant authentication (used during initial setup) - admin_credentials: Option<(String, String)>, - config_path: PathBuf, -} - -impl DirectorySetup { - pub fn set_admin_token(&mut self, token: String) { - self.admin_token = Some(token); - } - - /// Set admin credentials for password grant authentication - pub fn set_admin_credentials(&mut self, username: String, password: String) { - self.admin_credentials = Some((username, password)); - } - - /// Get an access token using either PAT or password grant - async fn get_admin_access_token(&self) -> Result { - // If we have a PAT token, use it directly - if let Some(ref token) = self.admin_token { - return Ok(token.clone()); - } - - // If we have admin credentials, use password grant - if let Some((username, password)) = &self.admin_credentials { - let token_url = format!("{}/oauth/v2/token", self.base_url); - let params = [ - ("grant_type", "password".to_string()), - ("username", username.clone()), - ("password", password.clone()), - ("scope", "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud".to_string()), - ]; - - let response = self - .client - .post(&token_url) - .form(¶ms) - .send() - .await - .map_err(|e| anyhow::anyhow!("Failed to get access token: {}", e))?; - - let token_data: serde_json::Value = response - .json() - .await - .map_err(|e| anyhow::anyhow!("Failed to parse token response: {}", e))?; - - let access_token = token_data - .get("access_token") - .and_then(|t| t.as_str()) - .ok_or_else(|| anyhow::anyhow!("No access token in response"))? - .to_string(); - - log::info!("Obtained access token via password grant"); - return Ok(access_token); - } - - Err(anyhow::anyhow!("No admin token or credentials configured")) - } - - pub async fn ensure_admin_token(&mut self) -> Result<()> { - if self.admin_token.is_none() && self.admin_credentials.is_none() { - return Err(anyhow::anyhow!("Admin token or credentials must be configured")); - } - - // If we have credentials but no token, authenticate and get the token - if self.admin_token.is_none() && self.admin_credentials.is_some() { - let token = self.get_admin_access_token().await?; - self.admin_token = Some(token); - log::info!("Obtained admin access token from credentials"); - } - - Ok(()) - } - - fn generate_secure_password() -> String { - use rand::distr::Alphanumeric; - use rand::Rng; - let mut rng = rand::rng(); - (0..16) - .map(|_| { - let byte = rng.sample(Alphanumeric); - char::from(byte) - }) - .collect() - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DefaultOrganization { - pub id: String, - pub name: String, - pub domain: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DefaultUser { - pub id: String, - pub username: String, - pub email: String, - pub password: String, - pub first_name: String, - pub last_name: String, -} - -pub struct CreateUserParams<'a> { - pub org_id: &'a str, - pub username: &'a str, - pub email: &'a str, - pub password: &'a str, - pub first_name: &'a str, - pub last_name: &'a str, - pub is_admin: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DirectoryConfig { - pub base_url: String, - pub default_org: DefaultOrganization, - pub default_user: DefaultUser, - pub admin_token: String, - pub project_id: String, - pub client_id: String, - pub client_secret: String, -} - -impl DirectorySetup { - pub fn new(base_url: String, config_path: PathBuf) -> Self { - Self { - base_url, - client: Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .unwrap_or_else(|e| { - log::warn!("Failed to create HTTP client with timeout: {}, using default", e); - Client::new() - }), - admin_token: None, - admin_credentials: None, - config_path, - } - } - - /// Create a DirectorySetup with initial admin credentials for password grant - pub fn with_admin_credentials(base_url: String, config_path: PathBuf, username: String, password: String) -> Self { - Self { - base_url, - client: Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .unwrap_or_else(|e| { - log::warn!("Failed to create HTTP client with timeout: {}, using default", e); - Client::new() - }), - admin_token: None, - admin_credentials: Some((username, password)), - config_path, - } - } - - pub async fn wait_for_ready(&self, max_attempts: u32) -> Result<()> { - log::info!("Waiting for Directory service to be ready..."); - - for attempt in 1..=max_attempts { - match self - .client - .get(format!("{}/debug/ready", self.base_url)) - .send() - .await - { - Ok(response) if response.status().is_success() => { - log::info!("Directory service is ready!"); - return Ok(()); - } - _ => { - log::debug!( - "Directory not ready yet (attempt {}/{})", - attempt, - max_attempts - ); - sleep(Duration::from_secs(3)).await; - } - } - } - - anyhow::bail!("Directory service did not become ready in time") - } - - pub async fn initialize(&mut self) -> Result { - log::info!(" Initializing Directory (Zitadel) with defaults..."); - - if let Ok(existing_config) = self.load_existing_config().await { - log::info!("Directory already initialized, using existing config"); - return Ok(existing_config); - } - - self.wait_for_ready(30).await?; - - // Wait additional time for Zitadel API to be fully ready - log::info!("Waiting for Zitadel API to be fully initialized..."); - sleep(Duration::from_secs(10)).await; - - self.ensure_admin_token().await?; - - let org = self.create_default_organization().await?; - log::info!(" Created default organization: {}", org.name); - - let user = self.create_default_user(&org.id).await?; - log::info!(" Created default user: {}", user.username); - - // Retry OAuth client creation up to 3 times with delays - let (project_id, client_id, client_secret) = { - let mut last_error = None; - let mut result = None; - - for attempt in 1..=3 { - match self.create_oauth_application(&org.id).await { - Ok(credentials) => { - result = Some(credentials); - break; - } - Err(e) => { - log::warn!( - "OAuth client creation attempt {}/3 failed: {}", - attempt, - e - ); - last_error = Some(e); - if attempt < 3 { - log::info!("Retrying in 5 seconds..."); - sleep(Duration::from_secs(5)).await; - } - } - } - } - - result.ok_or_else(|| { - anyhow::anyhow!( - "Failed to create OAuth client after 3 attempts: {}", - last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error")) - ) - })? - }; - log::info!(" Created OAuth2 application"); - - self.grant_user_permissions(&org.id, &user.id).await?; - log::info!(" Granted admin permissions to default user"); - - let config = DirectoryConfig { - base_url: self.base_url.clone(), - default_org: org, - default_user: user, - admin_token: self.admin_token.clone().unwrap_or_default(), - project_id, - client_id, - client_secret, - }; - - self.save_config_internal(&config).await?; - log::info!(" Saved Directory configuration"); - - log::info!(" Directory initialization complete!"); - log::info!(""); - log::info!("╔══════════════════════════════════════════════════════════════╗"); - log::info!("║ DEFAULT CREDENTIALS ║"); - log::info!("╠══════════════════════════════════════════════════════════════╣"); - log::info!("║ Email: {:<50}║", config.default_user.email); - log::info!("║ Password: {:<50}║", config.default_user.password); - log::info!("╠══════════════════════════════════════════════════════════════╣"); - log::info!("║ Login at: {:<50}║", self.base_url); - log::info!("╚══════════════════════════════════════════════════════════════╝"); - log::info!(""); - log::info!(">>> COPY THESE CREDENTIALS NOW - Press ENTER to continue <<<"); - - let mut input = String::new(); - let _ = std::io::stdin().read_line(&mut input); - - Ok(config) - } - - pub async fn create_organization(&mut self, name: &str, description: &str) -> Result { - self.ensure_admin_token().await?; - - let response = self - .client - .post(format!("{}/management/v1/orgs", self.base_url)) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) - .json(&json!({ - "name": name, - "description": description, - })) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - anyhow::bail!("Failed to create organization: {}", error_text); - } - - let result: serde_json::Value = response.json().await?; - Ok(result["id"].as_str().unwrap_or("").to_string()) - } - - async fn create_default_organization(&self) -> Result { - let org_name = "BotServer".to_string(); - - let response = self - .client - .post(format!("{}/management/v1/orgs", self.base_url)) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) - .json(&json!({ - "name": org_name, - })) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - anyhow::bail!("Failed to create organization: {}", error_text); - } - - let result: serde_json::Value = response.json().await?; - - Ok(DefaultOrganization { - id: result["id"].as_str().unwrap_or("").to_string(), - name: org_name.clone(), - domain: format!("{}.localhost", org_name.to_lowercase()), - }) - } - - pub async fn create_user( - &mut self, - params: CreateUserParams<'_>, - ) -> Result { - self.ensure_admin_token().await?; - - let response = self - .client - .post(format!("{}/management/v1/users/human", self.base_url)) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) - .json(&json!({ - "userName": params.username, - "profile": { - "firstName": params.first_name, - "lastName": params.last_name, - "displayName": format!("{} {}", params.first_name, params.last_name) - }, - "email": { - "email": params.email, - "isEmailVerified": true - }, - "password": params.password, - "organisation": { - "orgId": params.org_id - } - })) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - anyhow::bail!("Failed to create user: {}", error_text); - } - - let result: serde_json::Value = response.json().await?; - - let user = DefaultUser { - id: result["userId"].as_str().unwrap_or("").to_string(), - username: params.username.to_string(), - email: params.email.to_string(), - password: params.password.to_string(), - first_name: params.first_name.to_string(), - last_name: params.last_name.to_string(), - }; - - if params.is_admin { - self.grant_user_permissions(params.org_id, &user.id).await?; - } - - Ok(user) - } - - async fn create_default_user(&self, org_id: &str) -> Result { - let username = format!( - "admin_{}", - uuid::Uuid::new_v4() - .to_string() - .chars() - .take(8) - .collect::() - ); - let email = format!("{}@botserver.local", username); - let password = Self::generate_secure_password(); - - let response = self - .client - .post(format!("{}/management/v1/users/human", self.base_url)) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) - .json(&json!({ - "userName": username, - "profile": { - "firstName": "Admin", - "lastName": "User", - "displayName": "Administrator" - }, - "email": { - "email": email, - "isEmailVerified": true - }, - "password": password, - "organisation": { - "orgId": org_id - } - })) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - anyhow::bail!("Failed to create user: {}", error_text); - } - - let result: serde_json::Value = response.json().await?; - - Ok(DefaultUser { - id: result["userId"].as_str().unwrap_or("").to_string(), - username: username.clone(), - email: email.clone(), - password: password.clone(), - first_name: "Admin".to_string(), - last_name: "User".to_string(), - }) - } - - pub async fn create_oauth_application( - &self, - _org_id: &str, - ) -> Result<(String, String, String)> { - let app_name = "BotServer"; - let redirect_uri = "http://localhost:8080/auth/callback".to_string(); - - // Get access token using either PAT or password grant - let access_token = self.get_admin_access_token().await - .map_err(|e| anyhow::anyhow!("Failed to get admin access token: {}", e))?; - - let project_response = self - .client - .post(format!("{}/management/v1/projects", self.base_url)) - .bearer_auth(&access_token) - .json(&json!({ - "name": app_name, - })) - .send() - .await?; - - if !project_response.status().is_success() { - let error_text = project_response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Failed to create project: {}", error_text)); - } - - let project_result: serde_json::Value = project_response.json().await?; - let project_id = project_result["id"].as_str().unwrap_or("").to_string(); - - if project_id.is_empty() { - return Err(anyhow::anyhow!("Project ID is empty in response")); - } - - let app_response = self.client - .post(format!("{}/management/v1/projects/{}/apps/oidc", self.base_url, project_id)) - .bearer_auth(&access_token) - .json(&json!({ - "name": app_name, - "redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:8080/auth/callback", "http://localhost:9000/auth/callback"], - "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], - "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN", "OIDC_GRANT_TYPE_PASSWORD"], - "appType": "OIDC_APP_TYPE_WEB", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_POST", - "postLogoutRedirectUris": ["http://localhost:8080", "http://localhost:3000", "http://localhost:9000"], - "accessTokenType": "OIDC_TOKEN_TYPE_BEARER", - "devMode": true, - })) - .send() - .await?; - - if !app_response.status().is_success() { - let error_text = app_response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Failed to create OAuth application: {}", error_text)); - } - - let app_result: serde_json::Value = app_response.json().await?; - let client_id = app_result["clientId"].as_str().unwrap_or("").to_string(); - let client_secret = app_result["clientSecret"] - .as_str() - .unwrap_or("") - .to_string(); - - if client_id.is_empty() { - return Err(anyhow::anyhow!("Client ID is empty in response")); - } - - log::info!("Created OAuth application with client_id: {}", client_id); - Ok((project_id, client_id, client_secret)) - } - - pub async fn grant_user_permissions(&self, org_id: &str, user_id: &str) -> Result<()> { - let _response = self - .client - .post(format!( - "{}/management/v1/orgs/{}/members", - self.base_url, org_id - )) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) - .json(&json!({ - "userId": user_id, - "roles": ["ORG_OWNER"] - })) - .send() - .await?; - - Ok(()) - } - - pub async fn save_config( - &mut self, - org_id: String, - org_name: String, - admin_user: DefaultUser, - client_id: String, - client_secret: String, - ) -> Result { - self.ensure_admin_token().await?; - - let config = DirectoryConfig { - base_url: self.base_url.clone(), - default_org: DefaultOrganization { - id: org_id, - name: org_name.clone(), - domain: format!("{}.localhost", org_name.to_lowercase()), - }, - default_user: admin_user, - admin_token: self.admin_token.clone().unwrap_or_default(), - project_id: String::new(), - client_id, - client_secret, - }; - - let json = serde_json::to_string_pretty(&config)?; - fs::write(&self.config_path, json).await?; - - log::info!( - "Saved Directory configuration to {}", - self.config_path.display() - ); - Ok(config) - } - - async fn save_config_internal(&self, config: &DirectoryConfig) -> Result<()> { - // Ensure parent directory exists - if let Some(parent) = self.config_path.parent() { - if !parent.exists() { - fs::create_dir_all(parent).await.map_err(|e| { - anyhow::anyhow!("Failed to create config directory {}: {}", parent.display(), e) - })?; - log::info!("Created config directory: {}", parent.display()); - } - } - - let json = serde_json::to_string_pretty(config)?; - fs::write(&self.config_path, json).await.map_err(|e| { - anyhow::anyhow!("Failed to write config to {}: {}", self.config_path.display(), e) - })?; - log::info!("Saved Directory configuration to {}", self.config_path.display()); - Ok(()) - } - - async fn load_existing_config(&self) -> Result { - let content = fs::read_to_string(&self.config_path).await?; - let config: DirectoryConfig = serde_json::from_str(&content)?; - Ok(config) - } - - pub async fn get_config(&self) -> Result { - self.load_existing_config().await - } -} - -pub async fn generate_directory_config(config_path: PathBuf, _db_path: PathBuf) -> Result<()> { - let yaml_config = r" -Log: - Level: info - -Database: - Postgres: - Host: localhost - Port: 5432 - Database: zitadel - User: zitadel - Password: zitadel - SSL: - Mode: disable - -Machine: - Identification: - Hostname: localhost - WebhookAddress: http://localhost:8080 - -Port: 9000 -ExternalDomain: localhost -ExternalPort: 9000 -ExternalSecure: false - -TLS: - Enabled: false -" - .to_string(); - - fs::write(config_path, yaml_config).await?; - Ok(()) -} diff --git a/src/core/package_manager/setup/email_setup.rs b/src/core/package_manager/setup/email_setup.rs deleted file mode 100644 index 09b98bb94..000000000 --- a/src/core/package_manager/setup/email_setup.rs +++ /dev/null @@ -1,342 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::time::Duration; -use tokio::fs; -use tokio::time::sleep; - -#[derive(Debug)] -pub struct EmailSetup { - base_url: String, - admin_user: String, - admin_pass: String, - config_path: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct EmailConfig { - pub base_url: String, - pub smtp_host: String, - pub smtp_port: u16, - pub imap_host: String, - pub imap_port: u16, - pub admin_user: String, - pub admin_pass: String, - pub directory_integration: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct EmailDomain { - pub domain: String, - pub enabled: bool, -} - -impl EmailSetup { - pub fn new(base_url: String, config_path: PathBuf) -> Self { - let admin_user = format!( - "admin_{}@botserver.local", - uuid::Uuid::new_v4() - .to_string() - .chars() - .take(8) - .collect::() - ); - let admin_pass = Self::generate_secure_password(); - - Self { - base_url, - admin_user, - admin_pass, - config_path, - } - } - - fn generate_secure_password() -> String { - use rand::distr::Alphanumeric; - use rand::Rng; - let mut rng = rand::rng(); - (0..16) - .map(|_| { - let byte = rng.sample(Alphanumeric); - char::from(byte) - }) - .collect() - } - - pub async fn wait_for_ready(&self, max_attempts: u32) -> Result<()> { - log::info!("Waiting for Email service to be ready..."); - - for attempt in 1..=max_attempts { - if tokio::net::TcpStream::connect("127.0.0.1:25").await.is_ok() { - log::info!("Email service is ready!"); - return Ok(()); - } - - log::debug!( - "Email service not ready yet (attempt {}/{})", - attempt, - max_attempts - ); - sleep(Duration::from_secs(3)).await; - } - - anyhow::bail!("Email service did not become ready in time") - } - - pub async fn initialize( - &mut self, - directory_config_path: Option, - ) -> Result { - log::info!(" Initializing Email (Stalwart) server..."); - - if let Ok(existing_config) = self.load_existing_config().await { - log::info!("Email already initialized, using existing config"); - return Ok(existing_config); - } - - self.wait_for_ready(30).await?; - - self.create_default_domain()?; - log::info!(" Created default email domain: localhost"); - - let directory_integration = if let Some(dir_config_path) = directory_config_path { - match self.setup_directory_integration(&dir_config_path) { - Ok(_) => { - log::info!(" Integrated with Directory for authentication"); - true - } - Err(e) => { - log::warn!(" Directory integration failed: {}", e); - false - } - } - } else { - false - }; - - self.create_admin_account().await?; - log::info!(" Created admin email account: {}", self.admin_user); - - let config = EmailConfig { - base_url: self.base_url.clone(), - smtp_host: "localhost".to_string(), - smtp_port: 25, - imap_host: "localhost".to_string(), - imap_port: 143, - admin_user: self.admin_user.clone(), - admin_pass: self.admin_pass.clone(), - directory_integration, - }; - - self.save_config(&config).await?; - log::info!(" Saved Email configuration"); - - log::info!(" Email initialization complete!"); - log::info!("📧 SMTP: localhost:25 (587 for TLS)"); - log::info!("📬 IMAP: localhost:143 (993 for TLS)"); - log::info!("👤 Admin: {} / {}", config.admin_user, config.admin_pass); - - Ok(config) - } - - fn create_default_domain(&self) -> Result<()> { - let _ = self; - Ok(()) - } - - async fn create_admin_account(&self) -> Result<()> { - log::info!("Creating admin email account via Stalwart API..."); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - - let api_url = format!("{}/api/account", self.base_url); - - let account_data = serde_json::json!({ - "name": self.admin_user, - "secret": self.admin_pass, - "description": "BotServer Admin Account", - "quota": 1_073_741_824, - "type": "individual", - "emails": [self.admin_user.clone()], - "memberOf": ["administrators"], - "enabled": true - }); - - let response = client - .post(&api_url) - .header("Content-Type", "application/json") - .json(&account_data) - .send() - .await; - - // All branches return Ok(()) - just log appropriate messages - match response { - Ok(resp) => { - if resp.status().is_success() { - log::info!( - "Admin email account created successfully: {}", - self.admin_user - ); - } else if resp.status().as_u16() == 409 { - log::info!("Admin email account already exists: {}", self.admin_user); - } else { - let status = resp.status(); - log::warn!("Failed to create admin account via API (status {})", status); - } - } - Err(e) => { - log::warn!( - "Could not connect to Stalwart management API: {}. Account may need manual setup.", - e - ); - } - } - Ok(()) - } - - fn setup_directory_integration(&self, directory_config_path: &PathBuf) -> Result<()> { - let _ = self; - let content = std::fs::read_to_string(directory_config_path)?; - let dir_config: serde_json::Value = serde_json::from_str(&content)?; - - let issuer_url = dir_config["base_url"] - .as_str() - .unwrap_or("http://localhost:9000"); - - log::info!("Setting up OIDC authentication with Directory..."); - log::info!("Issuer URL: {}", issuer_url); - - Ok(()) - } - - async fn save_config(&self, config: &EmailConfig) -> Result<()> { - let json = serde_json::to_string_pretty(config)?; - fs::write(&self.config_path, json).await?; - Ok(()) - } - - async fn load_existing_config(&self) -> Result { - let content = fs::read_to_string(&self.config_path).await?; - let config: EmailConfig = serde_json::from_str(&content)?; - Ok(config) - } - - pub async fn get_config(&self) -> Result { - self.load_existing_config().await - } - - pub fn create_user_mailbox(&self, _username: &str, _password: &str, email: &str) -> Result<()> { - let _ = self; - log::info!("Creating mailbox for user: {}", email); - - Ok(()) - } - - pub async fn sync_users_from_directory(&self, directory_config_path: &PathBuf) -> Result<()> { - log::info!("Syncing users from Directory to Email..."); - - let content = fs::read_to_string(directory_config_path).await?; - let dir_config: serde_json::Value = serde_json::from_str(&content)?; - - if let Some(default_user) = dir_config.get("default_user") { - let email = default_user["email"].as_str().unwrap_or(""); - let password = default_user["password"].as_str().unwrap_or(""); - let username = default_user["username"].as_str().unwrap_or(""); - - if !email.is_empty() { - self.create_user_mailbox(username, password, email)?; - log::info!(" Created mailbox for: {}", email); - } - } - - Ok(()) - } -} - -pub async fn generate_email_config( - config_path: PathBuf, - data_path: PathBuf, - directory_integration: bool, -) -> Result<()> { - let mut config = format!( - r#" -[server] -hostname = "localhost" - -[server.listener."smtp"] -bind = ["0.0.0.0:25"] -protocol = "smtp" - -[server.listener."smtp-submission"] -bind = ["0.0.0.0:587"] -protocol = "smtp" -tls.implicit = false - -[server.listener."smtp-submissions"] -bind = ["0.0.0.0:465"] -protocol = "smtp" -tls.implicit = true - -[server.listener."imap"] -bind = ["0.0.0.0:143"] -protocol = "imap" - -[server.listener."imaps"] -bind = ["0.0.0.0:993"] -protocol = "imap" -tls.implicit = true - -[server.listener."http"] -bind = ["0.0.0.0:9000"] -protocol = "http" - -[storage] -data = "sqlite" -blob = "sqlite" -lookup = "sqlite" -fts = "sqlite" - -[store."sqlite"] -type = "sqlite" -path = "{}/stalwart.db" - -[directory."local"] -type = "internal" -store = "sqlite" - -"#, - data_path.display() - ); - - if directory_integration { - config.push_str( - r#" -[directory."oidc"] -type = "oidc" -issuer = "http://localhost:9000" -client-id = "{{CLIENT_ID}}" -client-secret = "{{CLIENT_SECRET}}" - -[authentication] -mechanisms = ["plain", "login"] -directory = "oidc" -fallback-directory = "local" - -"#, - ); - } else { - config.push_str( - r#" -[authentication] -mechanisms = ["plain", "login"] -directory = "local" - -"#, - ); - } - - fs::write(config_path, config).await?; - Ok(()) -} diff --git a/src/core/package_manager/setup/mod.rs b/src/core/package_manager/setup/mod.rs deleted file mode 100644 index 953d71f9e..000000000 --- a/src/core/package_manager/setup/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod directory_setup; -pub mod email_setup; -pub mod vector_db_setup; - -pub use directory_setup::{DirectorySetup, DirectoryConfig, DefaultUser, CreateUserParams}; -pub use email_setup::EmailSetup; -pub use vector_db_setup::VectorDbSetup; diff --git a/src/core/package_manager/setup/vector_db_setup.rs b/src/core/package_manager/setup/vector_db_setup.rs deleted file mode 100644 index f60c57c84..000000000 --- a/src/core/package_manager/setup/vector_db_setup.rs +++ /dev/null @@ -1,93 +0,0 @@ -use anyhow::Result; -use std::path::PathBuf; -use std::fs; -use tracing::info; - -pub struct VectorDbSetup; - -impl VectorDbSetup { - pub async fn setup(conf_path: PathBuf, data_path: PathBuf) -> Result<()> { - let config_dir = conf_path.join("vector_db"); - fs::create_dir_all(&config_dir)?; - - let data_dir = data_path.join("vector_db"); - fs::create_dir_all(&data_dir)?; - - let cert_dir = conf_path.join("system/certificates/vectordb"); - - // Convert to absolute paths for Qdrant config - let data_dir_abs = fs::canonicalize(&data_dir).unwrap_or(data_dir); - let cert_dir_abs = fs::canonicalize(&cert_dir).unwrap_or(cert_dir); - - let config_content = generate_qdrant_config(&data_dir_abs, &cert_dir_abs); - - let config_path = config_dir.join("config.yaml"); - fs::write(&config_path, config_content)?; - - info!("Qdrant vector_db configuration written to {:?}", config_path); - - Ok(()) - } -} - -pub fn generate_qdrant_config(data_dir: &std::path::Path, cert_dir: &std::path::Path) -> String { - let data_path = data_dir.to_string_lossy(); - let cert_path = cert_dir.join("server.crt").to_string_lossy().to_string(); - let key_path = cert_dir.join("server.key").to_string_lossy().to_string(); - let ca_path = cert_dir.join("ca.crt").to_string_lossy().to_string(); - - format!( - r#"# Qdrant configuration with TLS enabled -# Generated by BotServer bootstrap - -log_level: INFO - -storage: - storage_path: {data_path} - snapshots_path: {data_path}/snapshots - on_disk_payload: true - -service: - host: 0.0.0.0 - http_port: 6333 - grpc_port: 6334 - enable_tls: true - -tls: - cert: {cert_path} - key: {key_path} - ca_cert: {ca_path} - verify_https_client_certificate: false - -cluster: - enabled: false - -telemetry_disabled: true -"# - ) -} - -pub async fn generate_vector_db_config(config_path: PathBuf, data_path: PathBuf) -> Result<()> { - VectorDbSetup::setup(config_path, data_path).await -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_generate_qdrant_config() { - let data_dir = PathBuf::from("/tmp/qdrant/data"); - let cert_dir = PathBuf::from("/tmp/qdrant/certs"); - - let config = generate_qdrant_config(&data_dir, &cert_dir); - - assert!(config.contains("enable_tls: true")); - assert!(config.contains("http_port: 6333")); - assert!(config.contains("grpc_port: 6334")); - assert!(config.contains("/tmp/qdrant/data")); - assert!(config.contains("/tmp/qdrant/certs/server.crt")); - assert!(config.contains("/tmp/qdrant/certs/server.key")); - } -} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index ba099f023..a09395a58 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -189,11 +189,11 @@ error: Option, #[cfg(feature = "mail")] #[derive(Debug, Deserialize)] struct SmtpTestRequest { -host: String, -port: i32, -username: Option, -password: Option, -use_tls: Option, + host: String, + port: i32, + username: Option, + password: Option, + _use_tls: Option, } #[cfg(not(feature = "mail"))]