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:
Rodrigo Rodriguez (Pragmatismo) 2026-03-01 19:06:09 -03:00
parent 1bbb94d500
commit c326581a9e
14 changed files with 498 additions and 1277 deletions

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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(" <> ", " != ")
}

View file

@ -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...");

View file

@ -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() {

View file

@ -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(),
},
);
}

View file

@ -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)
}

View 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"));
}
}

View file

@ -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(&params)
.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(())
}

View file

@ -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(())
}

View file

@ -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;

View file

@ -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"));
}
}

View file

@ -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"))]