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
This commit is contained in:
parent
1bbb94d500
commit
c326581a9e
14 changed files with 498 additions and 1277 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(" <> ", " != ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,108 +45,16 @@ pub fn get_all_components() -> Vec<ComponentInfo> {
|
|||
]
|
||||
}
|
||||
|
||||
/// 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@<domain> password: <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<crate::core::package_manager::setup::DirectoryConfig> {
|
||||
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<crate::core::package_manager::s
|
|||
let base_url = "http://localhost:8300".to_string();
|
||||
let config_path = PathBuf::from(&stack_path).join("conf/system/directory_config.json");
|
||||
|
||||
// Check if config already exists with valid OAuth client
|
||||
// Check if config already exists in Vault first
|
||||
if let Ok(secrets_manager) = crate::core::secrets::SecretsManager::from_env() {
|
||||
if secrets_manager.is_enabled() {
|
||||
if let Ok(secrets) = secrets_manager.get_secret(crate::core::secrets::SecretPaths::DIRECTORY).await {
|
||||
if let (Some(client_id), Some(client_secret)) = (secrets.get("client_id"), secrets.get("client_secret")) {
|
||||
// Validate that credentials are real, not placeholders
|
||||
let is_valid = !client_id.is_empty()
|
||||
&& !client_secret.is_empty()
|
||||
&& client_secret != "..."
|
||||
&& client_id.contains('@') // OAuth client IDs contain @
|
||||
&& client_secret.len() > 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::<crate::core::package_manager::setup::DirectoryConfig>(&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<crate::core::package_manager::s
|
|||
}
|
||||
}
|
||||
|
||||
// Try to get credentials from multiple sources
|
||||
let credentials = get_admin_credentials(&stack_path).await?;
|
||||
// Initialize directory with default credentials
|
||||
let mut directory_setup = crate::core::package_manager::setup::DirectorySetup::new(base_url.clone(), config_path.clone());
|
||||
let config = directory_setup.initialize().await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize directory: {}", e))?;
|
||||
|
||||
let mut directory_setup = crate::core::package_manager::setup::DirectorySetup::with_admin_credentials(
|
||||
base_url,
|
||||
config_path.clone(),
|
||||
credentials.email,
|
||||
credentials.password,
|
||||
);
|
||||
// Store credentials in Vault
|
||||
if let Ok(secrets_manager) = crate::core::secrets::SecretsManager::from_env() {
|
||||
if secrets_manager.is_enabled() {
|
||||
let mut secrets = HashMap::new();
|
||||
secrets.insert("url".to_string(), config.base_url.clone());
|
||||
secrets.insert("issuer_url".to_string(), config.issuer_url.clone());
|
||||
secrets.insert("issuer".to_string(), config.issuer.clone());
|
||||
secrets.insert("client_id".to_string(), config.client_id.clone());
|
||||
secrets.insert("client_secret".to_string(), config.client_secret.clone());
|
||||
secrets.insert("redirect_uri".to_string(), config.redirect_uri.clone());
|
||||
secrets.insert("project_id".to_string(), config.project_id.clone());
|
||||
secrets.insert("api_url".to_string(), config.api_url.clone());
|
||||
if let Some(key) = &config.service_account_key {
|
||||
secrets.insert("service_account_key".to_string(), key.clone());
|
||||
}
|
||||
|
||||
directory_setup.initialize().await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize directory: {}", e))
|
||||
}
|
||||
|
||||
/// Get admin credentials from multiple sources
|
||||
#[cfg(feature = "directory")]
|
||||
async fn get_admin_credentials(stack_path: &str) -> anyhow::Result<AdminCredentials> {
|
||||
// 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<AdminCredentials> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
338
src/core/package_manager/setup.rs
Normal file
338
src/core/package_manager/setup.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
pub struct DirectorySetup {
|
||||
base_url: String,
|
||||
config_path: PathBuf,
|
||||
http_client: Client,
|
||||
pat_token: Option<String>,
|
||||
}
|
||||
|
||||
#[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<DirectoryConfig> {
|
||||
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 <steps.yaml>' \
|
||||
where steps.yaml has FirstInstance.Org.PatPath configured.",
|
||||
pat_path.display()
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_or_create_project(&self) -> Result<String> {
|
||||
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<String> {
|
||||
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::<String>());
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
/// 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<String> {
|
||||
// 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<DirectoryConfig> {
|
||||
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<String> {
|
||||
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<DefaultOrganization> {
|
||||
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<DefaultUser> {
|
||||
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<DefaultUser> {
|
||||
let username = format!(
|
||||
"admin_{}",
|
||||
uuid::Uuid::new_v4()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(8)
|
||||
.collect::<String>()
|
||||
);
|
||||
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<DirectoryConfig> {
|
||||
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<DirectoryConfig> {
|
||||
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<DirectoryConfig> {
|
||||
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(())
|
||||
}
|
||||
|
|
@ -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::<String>()
|
||||
);
|
||||
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<PathBuf>,
|
||||
) -> Result<EmailConfig> {
|
||||
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<EmailConfig> {
|
||||
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<EmailConfig> {
|
||||
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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -189,11 +189,11 @@ error: Option<String>,
|
|||
#[cfg(feature = "mail")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SmtpTestRequest {
|
||||
host: String,
|
||||
port: i32,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
use_tls: Option<bool>,
|
||||
host: String,
|
||||
port: i32,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
_use_tls: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mail"))]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue