feat(bootstrap): implement mTLS for Vault access

- Add create_vault_config() function to generate config.hcl with mTLS settings
- Configure Vault to require client certificate verification
- Generate client certificate for botserver in bootstrap
- Update .env to include mTLS paths (VAULT_CACERT, VAULT_CLIENT_CERT, VAULT_CLIENT_KEY)
- Remove unused import in tls.rs
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-07 02:13:28 -03:00
parent 41f5847f56
commit cfa1a01bc9
14 changed files with 969 additions and 346 deletions

36
.env.example Normal file
View file

@ -0,0 +1,36 @@
# BotServer Environment Configuration
# =====================================
#
# ONLY VAULT VARIABLES ARE ALLOWED IN THIS FILE!
# All secrets (DATABASE_URL, API keys, etc.) MUST be stored in Vault.
# NO LEGACY FALLBACK - Vault is mandatory.
#
# Vault paths for secrets:
# - gbo/tables - PostgreSQL credentials (host, port, database, username, password)
# - gbo/drive - MinIO/S3 credentials (accesskey, secret)
# - gbo/cache - Redis credentials (password)
# - gbo/directory - Zitadel credentials (url, project_id, client_id, client_secret)
# - gbo/email - Email credentials (username, password)
# - gbo/llm - LLM API keys (openai_key, anthropic_key, groq_key)
# - gbo/encryption - Encryption keys (master_key)
# - gbo/meet - LiveKit credentials (api_key, api_secret)
# - gbo/alm - Forgejo credentials (url, admin_password, runner_token)
# - gbo/vectordb - Qdrant credentials (url, api_key)
# - gbo/observability - InfluxDB credentials (url, org, bucket, token)
# =====================
# VAULT CONFIGURATION - ONLY THESE VARS ARE ALLOWED
# =====================
# Vault server address
VAULT_ADDR=https://localhost:8200
# Vault authentication token (generated during vault init)
# This will be populated automatically after first bootstrap
VAULT_TOKEN=
# Skip TLS verification for development (set to false in production)
VAULT_SKIP_VERIFY=true
# Cache TTL for secrets in seconds (default: 300 = 5 minutes)
VAULT_CACHE_TTL=300

6
Cargo.lock generated
View file

@ -1138,9 +1138,9 @@ dependencies = [
"rhai", "rhai",
"ring", "ring",
"rust_xlsxwriter", "rust_xlsxwriter",
"rustls 0.21.12", "rustls 0.23.35",
"rustls-native-certs 0.6.3", "rustls-native-certs 0.6.3",
"rustls-pemfile 1.0.4", "rustls-pemfile 2.2.0",
"scopeguard", "scopeguard",
"serde", "serde",
"serde_json", "serde_json",
@ -1152,7 +1152,7 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"time", "time",
"tokio", "tokio",
"tokio-rustls 0.24.1", "tokio-rustls 0.26.4",
"tokio-stream", "tokio-stream",
"toml 0.8.23", "toml 0.8.23",
"tonic 0.14.2", "tonic 0.14.2",

View file

@ -144,9 +144,9 @@ urlencoding = "2.1"
uuid = { version = "1.11", features = ["serde", "v4"] } uuid = { version = "1.11", features = ["serde", "v4"] }
# === TLS/SECURITY DEPENDENCIES === # === TLS/SECURITY DEPENDENCIES ===
rustls = { version = "0.21", features = ["dangerous_configuration"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rustls-pemfile = "1.0" rustls-pemfile = "2.0"
tokio-rustls = "0.24" tokio-rustls = "0.26"
rcgen = { version = "0.14", features = ["pem"] } rcgen = { version = "0.14", features = ["pem"] }
x509-parser = "0.15" x509-parser = "0.15"
rustls-native-certs = "0.6" rustls-native-certs = "0.6"

View file

@ -5,7 +5,6 @@ use crate::shared::utils::establish_pg_connection;
use anyhow::Result; use anyhow::Result;
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use chrono;
use log::{error, info, trace, warn}; use log::{error, info, trace, warn};
use rand::distr::Alphanumeric; use rand::distr::Alphanumeric;
use rcgen::{ use rcgen::{
@ -13,6 +12,8 @@ use rcgen::{
}; };
use std::fs; use std::fs;
use std::io::{self, Write}; use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
#[derive(Debug)] #[derive(Debug)]
@ -39,6 +40,7 @@ impl BootstrapManager {
pub fn start_all(&mut self) -> Result<()> { pub fn start_all(&mut self) -> Result<()> {
let pm = PackageManager::new(self.install_mode.clone(), self.tenant.clone())?; let pm = PackageManager::new(self.install_mode.clone(), self.tenant.clone())?;
let components = vec![ let components = vec![
ComponentInfo { name: "vault" },
ComponentInfo { name: "tables" }, ComponentInfo { name: "tables" },
ComponentInfo { name: "cache" }, ComponentInfo { name: "cache" },
ComponentInfo { name: "drive" }, ComponentInfo { name: "drive" },
@ -84,13 +86,38 @@ impl BootstrapManager {
.collect() .collect()
} }
/// Ensure critical services (tables and drive) are running /// Ensure critical services are running - Vault MUST be first
/// Order: vault -> tables -> drive
pub async fn ensure_services_running(&mut self) -> Result<()> { pub async fn ensure_services_running(&mut self) -> Result<()> {
info!("Ensuring critical services are running..."); info!("Ensuring critical services are running...");
let installer = PackageManager::new(self.install_mode.clone(), self.tenant.clone())?; let installer = PackageManager::new(self.install_mode.clone(), self.tenant.clone())?;
// Check and start PostgreSQL // VAULT MUST BE FIRST - it provides all secrets
if installer.is_installed("vault") {
info!("Starting Vault secrets service...");
match installer.start("vault") {
Ok(_child) => {
info!("Vault started successfully");
// Give Vault time to initialize
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Check if Vault needs to be unsealed
if let Err(e) = self.ensure_vault_unsealed().await {
warn!("Failed to unseal Vault: {}", e);
}
}
Err(e) => {
warn!("Vault might already be running or failed to start: {}", e);
}
}
} else {
// Vault not installed - cannot proceed, need to run bootstrap
warn!("Vault (secrets) component not installed - run bootstrap first");
return Err(anyhow::anyhow!("Vault not installed. Run bootstrap command first."));
}
// Check and start PostgreSQL (after Vault is running)
if installer.is_installed("tables") { if installer.is_installed("tables") {
info!("Starting PostgreSQL database service..."); info!("Starting PostgreSQL database service...");
match installer.start("tables") { match installer.start("tables") {
@ -100,7 +127,6 @@ impl BootstrapManager {
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
} }
Err(e) => { Err(e) => {
// Check if it's already running (start might fail if already running)
warn!( warn!(
"PostgreSQL might already be running or failed to start: {}", "PostgreSQL might already be running or failed to start: {}",
e e
@ -121,7 +147,6 @@ impl BootstrapManager {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
} }
Err(e) => { Err(e) => {
// MinIO is not critical, just log
warn!("MinIO might already be running or failed to start: {}", e); warn!("MinIO might already be running or failed to start: {}", e);
} }
} }
@ -132,31 +157,101 @@ impl BootstrapManager {
Ok(()) Ok(())
} }
/// Ensure Vault is unsealed (required after restart)
async fn ensure_vault_unsealed(&self) -> Result<()> {
let vault_init_path = PathBuf::from("./botserver-stack/conf/vault/init.json");
if !vault_init_path.exists() {
return Err(anyhow::anyhow!("Vault init.json not found"));
}
// Read unseal key from init.json
let init_json = fs::read_to_string(&vault_init_path)?;
let init_data: serde_json::Value = serde_json::from_str(&init_json)?;
let unseal_key = init_data["unseal_keys_b64"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let root_token = init_data["root_token"]
.as_str()
.unwrap_or("")
.to_string();
if unseal_key.is_empty() || root_token.is_empty() {
return Err(anyhow::anyhow!("Invalid Vault init.json"));
}
let vault_addr = "https://localhost:8200";
// Check if Vault is sealed
let status_output = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true ./botserver-stack/bin/vault/vault status -format=json 2>/dev/null || echo '{{\"sealed\":true}}'",
vault_addr
))
.output()?;
let status_json = String::from_utf8_lossy(&status_output.stdout);
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&status_json) {
if status["sealed"].as_bool().unwrap_or(true) {
info!("Unsealing Vault...");
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true ./botserver-stack/bin/vault/vault operator unseal {}",
vault_addr, unseal_key
))
.output()?;
}
}
// Set environment variables for other components
std::env::set_var("VAULT_ADDR", vault_addr);
std::env::set_var("VAULT_TOKEN", &root_token);
std::env::set_var("VAULT_SKIP_VERIFY", "true");
Ok(())
}
pub async fn bootstrap(&mut self) -> Result<()> { pub async fn bootstrap(&mut self) -> Result<()> {
// Generate certificates first // Generate certificates first (including for Vault)
info!("🔒 Generating TLS certificates..."); info!("🔒 Generating TLS certificates...");
if let Err(e) = self.generate_certificates().await { if let Err(e) = self.generate_certificates().await {
error!("Failed to generate certificates: {}", e); error!("Failed to generate certificates: {}", e);
} }
// Directory (Zitadel) is the root service - stores all configuration // Create Vault configuration with mTLS
let _directory_password = self.generate_secure_password(32); info!("📝 Creating Vault configuration...");
let _directory_masterkey = self.generate_secure_password(32); if let Err(e) = self.create_vault_config().await {
error!("Failed to create Vault config: {}", e);
}
// Configuration is stored in Directory service, not .env files // Generate secure passwords for all services - these are ONLY used during bootstrap
info!("Configuring services through Directory..."); // and immediately stored in Vault. NO LEGACY ENV VARS.
let db_password = self.generate_secure_password(24);
let drive_accesskey = self.generate_secure_password(20);
let drive_secret = self.generate_secure_password(40);
let cache_password = self.generate_secure_password(24);
// Configuration is stored in Vault, not .env files
info!("Configuring services through Vault...");
let pm = PackageManager::new(self.install_mode.clone(), self.tenant.clone()).unwrap(); let pm = PackageManager::new(self.install_mode.clone(), self.tenant.clone()).unwrap();
// Directory must be installed first as it's the root service
// Vault MUST be installed first - it stores all secrets
// Order: vault -> tables -> directory -> drive -> cache -> llm
let required_components = vec![ let required_components = vec![
"directory", // Root service - manages all other services "vault", // Secrets management - MUST BE FIRST
"tables", // Database - credentials stored in Directory "tables", // Database - required by Directory
"drive", // S3 storage - credentials stored in Directory "directory", // Identity service - manages users
"drive", // S3 storage - credentials in Vault
"cache", // Redis cache "cache", // Redis cache
"llm", // LLM service "llm", // LLM service
"email", // Email service integrated with Directory
"proxy", // Caddy reverse proxy
"dns", // CoreDNS for dynamic DNS
]; ];
for component in required_components { for component in required_components {
if !pm.is_installed(component) { if !pm.is_installed(component) {
@ -195,17 +290,28 @@ impl BootstrapManager {
} }
_ = pm.install(component).await; _ = pm.install(component).await;
// Directory must be configured first as root service // After tables is installed, create Zitadel config files before installing directory
if component == "directory" { if component == "tables" {
info!("🔧 Configuring Directory as root service..."); info!("🔧 Creating Directory configuration files...");
if let Err(e) = self.setup_directory().await { if let Err(e) = self.configure_services_in_directory(&db_password).await {
error!("Failed to setup Directory: {}", e); error!("Failed to create Directory config files: {}", e);
return Err(anyhow::anyhow!("Directory is required as root service"));
} }
}
// After directory is setup, configure database and drive credentials there // Directory configuration - setup happens after install starts Zitadel
if let Err(e) = self.configure_services_in_directory().await { if component == "directory" {
error!("Failed to configure services in Directory: {}", e); info!("🔧 Waiting for Directory to be ready...");
if let Err(e) = self.setup_directory().await {
// Don't fail completely - Zitadel may still be usable with first instance setup
warn!("Directory additional setup had issues: {}", e);
}
}
// After Vault is installed and running, store all secrets
if component == "vault" {
info!("🔐 Initializing Vault with secrets...");
if let Err(e) = self.setup_vault(&db_password, &drive_accesskey, &drive_secret, &cache_password).await {
error!("Failed to setup Vault: {}", e);
} }
} }
@ -240,67 +346,124 @@ impl BootstrapManager {
} }
/// Configure database and drive credentials in Directory /// Configure database and drive credentials in Directory
async fn configure_services_in_directory(&self) -> Result<()> { /// This creates the Zitadel config files BEFORE Zitadel is installed
info!("Storing service credentials in Directory..."); /// db_password is passed directly from bootstrap - NO ENV VARS
async fn configure_services_in_directory(&self, db_password: &str) -> Result<()> {
info!("Creating Zitadel configuration files...");
// Generate credentials for services
let db_password = self.generate_secure_password(32);
let drive_password = self.generate_secure_password(16);
let drive_user = "gbdriveuser".to_string();
// Create Zitadel configuration with service accounts
let zitadel_config_path = PathBuf::from("./botserver-stack/conf/directory/zitadel.yaml"); let zitadel_config_path = PathBuf::from("./botserver-stack/conf/directory/zitadel.yaml");
let steps_config_path = PathBuf::from("./botserver-stack/conf/directory/steps.yaml");
let pat_path = PathBuf::from("./botserver-stack/conf/directory/admin-pat.txt");
fs::create_dir_all(zitadel_config_path.parent().unwrap())?; fs::create_dir_all(zitadel_config_path.parent().unwrap())?;
// Generate Zitadel database password
let zitadel_db_password = self.generate_secure_password(24);
// Create zitadel.yaml - main configuration
let zitadel_config = format!( let zitadel_config = format!(
r#" r#"Log:
Level: info
Formatter:
Format: text
Database: Database:
postgres: postgres:
Host: localhost Host: localhost
Port: 5432 Port: 5432
Database: zitadel Database: zitadel
User: zitadel User: zitadel
Password: {} Password: "{}"
SSL: SSL:
Mode: require Mode: disable
RootCert: /botserver-stack/conf/system/certificates/postgres/ca.crt Admin:
SystemDefaults:
SecretGenerators:
PasswordSaltCost: 14
ExternalSecure: true
ExternalDomain: localhost
ExternalPort: 443
# Service accounts for integrated services
ServiceAccounts:
- Name: database-service
Description: PostgreSQL Database Service
Credentials:
Username: gbuser Username: gbuser
Password: {} Password: "{}"
- Name: drive-service SSL:
Description: MinIO S3 Storage Service Mode: disable
Credentials:
AccessKey: {} Machine:
SecretKey: {} Identification:
- Name: email-service Hostname:
Description: Email Service Integration Enabled: true
OAuth: true
- Name: git-service ExternalSecure: false
Description: Forgejo Git Service ExternalDomain: localhost
OAuth: true ExternalPort: 8080
DefaultInstance:
OIDCSettings:
AccessTokenLifetime: 12h
IdTokenLifetime: 12h
RefreshTokenIdleExpiration: 720h
RefreshTokenExpiration: 2160h
"#, "#,
self.generate_secure_password(24), zitadel_db_password,
db_password, db_password, // Use the password passed directly from bootstrap
drive_user,
drive_password
); );
fs::write(zitadel_config_path, zitadel_config)?; fs::write(&zitadel_config_path, zitadel_config)?;
info!("Created zitadel.yaml configuration");
info!("Service credentials configured in Directory"); // Create steps.yaml - first instance setup that generates admin PAT
let steps_config = format!(
r#"FirstInstance:
Org:
Name: "BotServer"
Human:
UserName: "admin"
FirstName: "Admin"
LastName: "User"
Email:
Address: "admin@localhost"
Verified: true
Password: "{}"
PatPath: "{}"
InstanceName: "BotServer"
DefaultLanguage: "en"
"#,
self.generate_secure_password(16),
pat_path.to_string_lossy(),
);
fs::write(&steps_config_path, steps_config)?;
info!("Created steps.yaml for first instance setup");
// Create zitadel database in PostgreSQL
info!("Creating zitadel database...");
let create_db_result = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"PGPASSWORD='{}' psql -h localhost -p 5432 -U gbuser -d postgres -c \"CREATE DATABASE zitadel\" 2>&1 || true",
db_password
))
.output();
if let Ok(output) = create_db_result {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("already exists") {
info!("Created zitadel database");
}
}
// Create zitadel user
let create_user_result = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"PGPASSWORD='{}' psql -h localhost -p 5432 -U gbuser -d postgres -c \"CREATE USER zitadel WITH PASSWORD '{}' SUPERUSER\" 2>&1 || true",
db_password,
zitadel_db_password
))
.output();
if let Ok(output) = create_user_result {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("already exists") {
info!("Created zitadel database user");
}
}
info!("Zitadel configuration files created");
Ok(()) Ok(())
} }
@ -411,112 +574,382 @@ meet IN A 127.0.0.1
/// Setup Directory (Zitadel) with default organization and user /// Setup Directory (Zitadel) with default organization and user
async fn setup_directory(&self) -> Result<()> { async fn setup_directory(&self) -> Result<()> {
let config_path = PathBuf::from("./config/directory_config.json"); let config_path = PathBuf::from("./config/directory_config.json");
let pat_path = PathBuf::from("./botserver-stack/conf/directory/admin-pat.txt");
// Ensure config directory exists // Ensure config directory exists
tokio::fs::create_dir_all("./config").await?; tokio::fs::create_dir_all("./config").await?;
// Wait for Directory to be ready // Wait for Directory to be ready and check for PAT file
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; info!("Waiting for Zitadel to be ready...");
let mut attempts = 0;
let max_attempts = 60; // 60 seconds max wait
while attempts < max_attempts {
// Check if Zitadel is healthy
let health_check = std::process::Command::new("curl")
.args(["-f", "-s", "http://localhost:8080/healthz"])
.output();
if let Ok(output) = health_check {
if output.status.success() {
info!("Zitadel is healthy");
break;
}
}
attempts += 1;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
if attempts >= max_attempts {
warn!("Zitadel health check timed out, continuing anyway...");
}
// Wait a bit more for PAT file to be generated
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
// Read the admin PAT generated by Zitadel first instance setup
let admin_token = if pat_path.exists() {
let token = fs::read_to_string(&pat_path)?;
let token = token.trim().to_string();
info!("Loaded admin PAT from {}", pat_path.display());
Some(token)
} else {
warn!("Admin PAT file not found at {}", pat_path.display());
warn!("Zitadel first instance setup may not have completed");
None
};
let mut setup = DirectorySetup::new( let mut setup = DirectorySetup::new(
crate::core::urls::InternalUrls::DIRECTORY_BASE.to_string(), "http://localhost:8080".to_string(), // Use HTTP since TLS is disabled
config_path, config_path,
); );
// Create default organization // Set the admin token if we have it
let org_name = "default"; if let Some(token) = admin_token {
let org_id = setup setup.set_admin_token(token);
.create_organization(org_name, "Default Organization") } else {
.await?; // If no PAT, we can't proceed with API calls
info!("Created default organization: {}", org_name); info!("Directory setup skipped - no admin token available");
info!("First instance setup created initial admin user via steps.yaml");
// Generate secure passwords return Ok(());
let admin_password = self.generate_secure_password(16);
let user_password = self.generate_secure_password(16);
// Save initial credentials to secure file
let creds_path = PathBuf::from("./botserver-stack/conf/system/initial-credentials.txt");
fs::create_dir_all(creds_path.parent().unwrap())?;
let creds_content = format!(
"INITIAL SETUP CREDENTIALS\n\
========================\n\
Generated at: {}\n\n\
Admin Account:\n\
Username: admin@default\n\
Password: {}\n\n\
User Account:\n\
Username: user@default\n\
Password: {}\n\n\
IMPORTANT: Delete this file after saving credentials securely.\n",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
admin_password,
user_password
);
fs::write(&creds_path, creds_content)?;
// Set restrictive permissions on Unix-like systems
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&creds_path, fs::Permissions::from_mode(0o600))?;
} }
// Create admin@default account for bot administration // Wait a bit more for Zitadel to be fully ready
let admin_user = setup tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
.create_user(
&org_id,
"admin",
"admin@default",
&admin_password,
"Admin",
"Default",
true, // is_admin
)
.await?;
info!("Created admin user: admin@default");
// Create user@default account for regular bot usage // Try to create additional organization for bot users
let regular_user = setup let org_name = "default";
.create_user( match setup.create_organization(org_name, "Default Organization").await {
&org_id, Ok(org_id) => {
"user", info!("Created default organization: {}", org_name);
"user@default",
&user_password,
"User",
"Default",
false, // is_admin
)
.await?;
info!("Created regular user: user@default");
info!(" Regular user ID: {}", regular_user.id);
// Create OAuth2 application for BotServer // Generate secure passwords
let (project_id, client_id, client_secret) = let user_password = self.generate_secure_password(16);
setup.create_oauth_application(&org_id).await?;
info!("Created OAuth2 application in project: {}", project_id);
// Save configuration // Create user@default account for regular bot usage
let config = setup match setup.create_user(
.save_config( &org_id,
org_id.clone(), "user",
org_name.to_string(), "user@default",
admin_user, &user_password,
client_id.clone(), "User",
client_secret, "Default",
) false,
.await?; ).await {
Ok(regular_user) => {
info!("Created regular user: user@default");
info!(" Regular user ID: {}", regular_user.id);
}
Err(e) => {
warn!("Failed to create regular user: {}", e);
}
}
info!("Directory initialized successfully!"); // Create OAuth2 application for BotServer
info!(" Organization: default"); match setup.create_oauth_application(&org_id).await {
info!(" Admin User: admin@default"); Ok((project_id, client_id, client_secret)) => {
info!(" Regular User: user@default"); info!("Created OAuth2 application in project: {}", project_id);
info!(" Client ID: {}", client_id);
info!(" Login URL: {}", config.base_url); // Save configuration
info!(""); let admin_user = crate::package_manager::setup::DefaultUser {
info!(" ⚠️ IMPORTANT: Initial credentials saved to:"); id: "admin".to_string(),
info!(" ./botserver-stack/conf/system/initial-credentials.txt"); username: "admin".to_string(),
info!(" Please save these credentials securely and delete the file."); email: "admin@localhost".to_string(),
password: "".to_string(), // Don't store password
first_name: "Admin".to_string(),
last_name: "User".to_string(),
};
if let Ok(config) = setup.save_config(
org_id.clone(),
org_name.to_string(),
admin_user,
client_id.clone(),
client_secret,
).await {
info!("Directory initialized successfully!");
info!(" Organization: default");
info!(" Client ID: {}", client_id);
info!(" Login URL: {}", config.base_url);
}
}
Err(e) => {
warn!("Failed to create OAuth2 application: {}", e);
}
}
}
Err(e) => {
warn!("Failed to create organization: {}", e);
info!("Using Zitadel's default organization from first instance setup");
}
}
info!("Directory setup complete");
Ok(())
}
/// Setup Vault with all service secrets and write .env file with VAULT_* variables
async fn setup_vault(&self, db_password: &str, drive_accesskey: &str, drive_secret: &str, cache_password: &str) -> Result<()> {
let vault_conf_path = PathBuf::from("./botserver-stack/conf/vault");
let vault_init_path = vault_conf_path.join("init.json");
let env_file_path = PathBuf::from("./.env");
// Wait for Vault to be ready
info!("Waiting for Vault to be ready...");
let mut attempts = 0;
let max_attempts = 30;
while attempts < max_attempts {
let health_check = std::process::Command::new("curl")
.args(["-f", "-s", "-k", "https://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200"])
.output();
if let Ok(output) = health_check {
if output.status.success() {
info!("Vault is responding");
break;
}
}
attempts += 1;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
if attempts >= max_attempts {
warn!("Vault health check timed out");
return Err(anyhow::anyhow!("Vault not ready after {} seconds", max_attempts));
}
// Check if Vault is already initialized
let vault_addr = "https://localhost:8200";
std::env::set_var("VAULT_ADDR", vault_addr);
std::env::set_var("VAULT_SKIP_VERIFY", "true");
// Read init.json if it exists (from post_install_cmds)
let (unseal_key, root_token) = if vault_init_path.exists() {
info!("Reading Vault initialization from init.json...");
let init_json = fs::read_to_string(&vault_init_path)?;
let init_data: serde_json::Value = serde_json::from_str(&init_json)?;
let unseal_key = init_data["unseal_keys_b64"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let root_token = init_data["root_token"]
.as_str()
.unwrap_or("")
.to_string();
(unseal_key, root_token)
} else {
// Initialize Vault if not already done
info!("Initializing Vault...");
let init_output = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true ./botserver-stack/bin/vault/vault operator init -key-shares=1 -key-threshold=1 -format=json",
vault_addr
))
.output()?;
if !init_output.status.success() {
let stderr = String::from_utf8_lossy(&init_output.stderr);
if stderr.contains("already initialized") {
warn!("Vault already initialized but init.json not found");
return Err(anyhow::anyhow!("Vault initialized but credentials lost"));
}
return Err(anyhow::anyhow!("Vault init failed: {}", stderr));
}
let init_json = String::from_utf8_lossy(&init_output.stdout);
fs::write(&vault_init_path, init_json.as_ref())?;
fs::set_permissions(&vault_init_path, std::fs::Permissions::from_mode(0o600))?;
let init_data: serde_json::Value = serde_json::from_str(&init_json)?;
let unseal_key = init_data["unseal_keys_b64"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let root_token = init_data["root_token"]
.as_str()
.unwrap_or("")
.to_string();
(unseal_key, root_token)
};
if root_token.is_empty() {
return Err(anyhow::anyhow!("Failed to get Vault root token"));
}
// Unseal Vault
info!("Unsealing Vault...");
let unseal_output = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true ./botserver-stack/bin/vault/vault operator unseal {}",
vault_addr, unseal_key
))
.output()?;
if !unseal_output.status.success() {
let stderr = String::from_utf8_lossy(&unseal_output.stderr);
if !stderr.contains("already unsealed") {
warn!("Vault unseal warning: {}", stderr);
}
}
// Set VAULT_TOKEN for subsequent commands
std::env::set_var("VAULT_TOKEN", &root_token);
// Enable KV secrets engine at gbo/ path
info!("Enabling KV secrets engine...");
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault secrets enable -path=secret kv-v2 2>&1 || true",
vault_addr, root_token
))
.output();
// Store all secrets in Vault
info!("Storing secrets in Vault...");
// Database credentials
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/tables host=localhost port=5432 database=botserver username=gbuser password='{}'",
vault_addr, root_token, db_password
))
.output()?;
info!(" ✓ Stored database credentials");
// Drive credentials
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/drive accesskey='{}' secret='{}'",
vault_addr, root_token, drive_accesskey, drive_secret
))
.output()?;
info!(" ✓ Stored drive credentials");
// Cache credentials
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/cache password='{}'",
vault_addr, root_token, cache_password
))
.output()?;
info!(" ✓ Stored cache credentials");
// Directory placeholder (will be updated after Zitadel setup)
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/directory url=https://localhost:8080 project_id= client_id= client_secret=",
vault_addr, root_token
))
.output()?;
info!(" ✓ Created directory placeholder");
// LLM placeholder
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/llm openai_key= anthropic_key= groq_key=",
vault_addr, root_token
))
.output()?;
info!(" ✓ Created LLM placeholder");
// Email placeholder
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/email username= password=",
vault_addr, root_token
))
.output()?;
info!(" ✓ Created email placeholder");
// Encryption key
let encryption_key = self.generate_secure_password(32);
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"VAULT_ADDR={} VAULT_SKIP_VERIFY=true VAULT_TOKEN={} ./botserver-stack/bin/vault/vault kv put secret/gbo/encryption master_key='{}'",
vault_addr, root_token, encryption_key
))
.output()?;
info!(" ✓ Generated and stored encryption key");
// Write .env file with ONLY Vault variables - NO LEGACY FALLBACK
info!("Writing .env file with Vault configuration...");
let env_content = format!(
r#"# BotServer Environment Configuration
# Generated by bootstrap - DO NOT ADD OTHER SECRETS HERE
# All secrets are stored in Vault at the paths below:
# - gbo/tables - PostgreSQL credentials
# - gbo/drive - MinIO/S3 credentials
# - gbo/cache - Redis credentials
# - gbo/directory - Zitadel credentials
# - gbo/email - Email credentials
# - gbo/llm - LLM API keys
# - gbo/encryption - Encryption keys
# Vault Configuration - THESE ARE THE ONLY ALLOWED ENV VARS
VAULT_ADDR={}
VAULT_TOKEN={}
# mTLS Configuration for Vault
# Set VAULT_SKIP_VERIFY=false in production with proper CA cert
VAULT_SKIP_VERIFY=true
VAULT_CACERT=./botserver-stack/conf/system/certificates/ca/ca.crt
VAULT_CLIENT_CERT=./botserver-stack/conf/system/certificates/botserver/client.crt
VAULT_CLIENT_KEY=./botserver-stack/conf/system/certificates/botserver/client.key
# Cache TTL for secrets (seconds)
VAULT_CACHE_TTL=300
"#,
vault_addr, root_token
);
fs::write(&env_file_path, env_content)?;
info!(" ✓ Created .env file with Vault configuration");
info!("🔐 Vault setup complete!");
info!(" Vault UI: {}/ui", vault_addr);
info!(" Root token saved to: {}", vault_init_path.display());
Ok(()) Ok(())
} }
@ -699,6 +1132,67 @@ meet IN A 127.0.0.1
Ok(()) Ok(())
} }
/// Create Vault configuration with mTLS settings
async fn create_vault_config(&self) -> Result<()> {
let vault_conf_dir = PathBuf::from("./botserver-stack/conf/vault");
let config_path = vault_conf_dir.join("config.hcl");
fs::create_dir_all(&vault_conf_dir)?;
// Vault config with mTLS - requires client certificate verification
let config = r#"# Vault Configuration with mTLS
# Generated by BotServer bootstrap
# Storage backend - file-based for single instance
storage "file" {
path = "./botserver-stack/data/vault"
}
# Listener with mTLS enabled
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = false
# Server TLS certificate
tls_cert_file = "./botserver-stack/conf/system/certificates/vault/server.crt"
tls_key_file = "./botserver-stack/conf/system/certificates/vault/server.key"
# mTLS - require client certificate
tls_require_and_verify_client_cert = true
tls_client_ca_file = "./botserver-stack/conf/system/certificates/ca/ca.crt"
# TLS settings
tls_min_version = "tls12"
}
# API settings
api_addr = "https://localhost:8200"
cluster_addr = "https://localhost:8201"
# UI enabled for administration
ui = true
# Disable memory locking (for development - enable in production)
disable_mlock = true
# Telemetry
telemetry {
disable_hostname = true
}
# Log level
log_level = "info"
"#;
fs::write(&config_path, config)?;
// Create data directory for Vault storage
fs::create_dir_all("./botserver-stack/data/vault")?;
info!("Created Vault config with mTLS at {}", config_path.display());
Ok(())
}
/// Generate TLS certificates for all services /// Generate TLS certificates for all services
async fn generate_certificates(&self) -> Result<()> { async fn generate_certificates(&self) -> Result<()> {
let cert_dir = PathBuf::from("./botserver-stack/conf/system/certificates"); let cert_dir = PathBuf::from("./botserver-stack/conf/system/certificates");
@ -744,8 +1238,42 @@ meet IN A 127.0.0.1
// Create issuer from CA params and key // Create issuer from CA params and key
let ca_issuer = Issuer::from_params(&ca_params, &ca_key_pair); let ca_issuer = Issuer::from_params(&ca_params, &ca_key_pair);
// Services that need certificates // Generate client certificate for botserver (for mTLS to all services)
let botserver_dir = cert_dir.join("botserver");
fs::create_dir_all(&botserver_dir)?;
let client_cert_path = botserver_dir.join("client.crt");
let client_key_path = botserver_dir.join("client.key");
if !client_cert_path.exists() || !client_key_path.exists() {
info!("Generating mTLS client certificate for botserver");
let mut client_params = CertificateParams::default();
client_params.not_before = time::OffsetDateTime::now_utc();
client_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
let mut client_dn = DistinguishedName::new();
client_dn.push(DnType::CountryName, "BR");
client_dn.push(DnType::OrganizationName, "BotServer");
client_dn.push(DnType::CommonName, "botserver-client");
client_params.distinguished_name = client_dn;
// Add client auth extended key usage
client_params.subject_alt_names.push(rcgen::SanType::DnsName("botserver".to_string().try_into()?));
let client_key = KeyPair::generate()?;
let client_cert = client_params.signed_by(&client_key, &ca_issuer)?;
fs::write(&client_cert_path, client_cert.pem())?;
fs::write(&client_key_path, client_key.serialize_pem())?;
fs::copy(&ca_cert_path, botserver_dir.join("ca.crt"))?;
info!("Generated mTLS client certificate at {}", client_cert_path.display());
}
// Services that need certificates - Vault FIRST
let services = vec![ let services = vec![
("vault", vec!["localhost", "127.0.0.1", "vault.botserver.local"]),
("api", vec!["localhost", "127.0.0.1", "api.botserver.local"]), ("api", vec!["localhost", "127.0.0.1", "api.botserver.local"]),
("llm", vec!["localhost", "127.0.0.1", "llm.botserver.local"]), ("llm", vec!["localhost", "127.0.0.1", "llm.botserver.local"]),
( (

View file

@ -2,7 +2,7 @@ use crate::package_manager::component::ComponentConfig;
use crate::package_manager::installer::PackageManager; use crate::package_manager::installer::PackageManager;
use crate::package_manager::InstallMode; use crate::package_manager::InstallMode;
use crate::package_manager::OsType; use crate::package_manager::OsType;
use crate::shared::utils::{self, parse_database_url}; use crate::shared::utils::{self, get_database_url_sync, parse_database_url};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::{error, trace, warn}; use log::{error, trace, warn};
use reqwest::Client; use reqwest::Client;
@ -553,7 +553,8 @@ impl PackageManager {
exec_cmd: &str, exec_cmd: &str,
env_vars: &HashMap<String, String>, env_vars: &HashMap<String, String>,
) -> Result<()> { ) -> Result<()> {
let database_url = std::env::var("DATABASE_URL").unwrap(); let database_url = get_database_url_sync()
.context("Failed to get DATABASE_URL from Vault. Ensure Vault is configured.")?;
let (_db_username, db_password, _db_server, _db_port, _db_name) = let (_db_username, db_password, _db_server, _db_port, _db_name) =
parse_database_url(&database_url); parse_database_url(&database_url);

View file

@ -59,6 +59,7 @@ impl PackageManager {
} }
fn register_components(&mut self) { fn register_components(&mut self) {
self.register_vault();
self.register_tables(); self.register_tables();
self.register_cache(); self.register_cache();
self.register_drive(); self.register_drive();
@ -74,7 +75,6 @@ impl PackageManager {
self.register_devtools(); self.register_devtools();
self.register_vector_db(); self.register_vector_db();
self.register_timeseries_db(); self.register_timeseries_db();
self.register_secrets();
self.register_observability(); self.register_observability();
self.register_host(); self.register_host();
self.register_webmail(); self.register_webmail();
@ -297,7 +297,7 @@ impl PackageManager {
ComponentConfig { ComponentConfig {
name: "directory".to_string(), name: "directory".to_string(),
ports: vec![8080], ports: vec![8080],
dependencies: vec![], dependencies: vec!["tables".to_string()],
linux_packages: vec![], linux_packages: vec![],
macos_packages: vec![], macos_packages: vec![],
windows_packages: vec![], windows_packages: vec![],
@ -306,21 +306,30 @@ impl PackageManager {
.to_string(), .to_string(),
), ),
binary_name: Some("zitadel".to_string()), binary_name: Some("zitadel".to_string()),
pre_install_cmds_linux: vec![], pre_install_cmds_linux: vec![
post_install_cmds_linux: vec![], "mkdir -p {{CONF_PATH}}/directory".to_string(),
pre_install_cmds_macos: vec![], ],
post_install_cmds_linux: vec![
// Initialize Zitadel with first instance setup to generate admin PAT
"{{BIN_PATH}}/zitadel init --config {{CONF_PATH}}/directory/zitadel.yaml".to_string(),
"{{BIN_PATH}}/zitadel setup --config {{CONF_PATH}}/directory/zitadel.yaml --init-projections --masterkeyFromEnv --steps {{CONF_PATH}}/directory/steps.yaml".to_string(),
],
pre_install_cmds_macos: vec![
"mkdir -p {{CONF_PATH}}/directory".to_string(),
],
post_install_cmds_macos: vec![], post_install_cmds_macos: vec![],
pre_install_cmds_windows: vec![], pre_install_cmds_windows: vec![],
post_install_cmds_windows: vec![], post_install_cmds_windows: vec![],
env_vars: HashMap::from([ env_vars: HashMap::from([
("ZITADEL_EXTERNALSECURE".to_string(), "true".to_string()), ("ZITADEL_EXTERNALSECURE".to_string(), "false".to_string()),
("ZITADEL_TLS_ENABLED".to_string(), "true".to_string()), ("ZITADEL_EXTERNALDOMAIN".to_string(), "localhost".to_string()),
("ZITADEL_TLS_CERT".to_string(), "{{CONF_PATH}}/system/certificates/directory/server.crt".to_string()), ("ZITADEL_EXTERNALPORT".to_string(), "8080".to_string()),
("ZITADEL_TLS_KEY".to_string(), "{{CONF_PATH}}/system/certificates/directory/server.key".to_string()), ("ZITADEL_TLS_ENABLED".to_string(), "false".to_string()),
("ZITADEL_MASTERKEY".to_string(), "MasterkeyNeedsToHave32Characters".to_string()),
]), ]),
data_download_list: Vec::new(), data_download_list: Vec::new(),
exec_cmd: "{{BIN_PATH}}/zitadel start --config {{CONF_PATH}}/directory/zitadel.yaml --masterkeyFromEnv".to_string(), exec_cmd: "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 -k https://localhost:8080/healthz >/dev/null 2>&1".to_string(), check_cmd: "curl -f http://localhost:8080/healthz >/dev/null 2>&1".to_string(),
}, },
); );
} }
@ -698,11 +707,11 @@ impl PackageManager {
/// Register HashiCorp Vault for secrets management /// Register HashiCorp Vault for secrets management
/// Vault stores service credentials (drive, email, etc.) securely /// Vault stores service credentials (drive, email, etc.) securely
/// Only VAULT_ADDR and VAULT_TOKEN needed in .env, all other secrets fetched from Vault /// Only VAULT_ADDR and VAULT_TOKEN needed in .env, all other secrets fetched from Vault
fn register_secrets(&mut self) { fn register_vault(&mut self) {
self.components.insert( self.components.insert(
"secrets".to_string(), "vault".to_string(),
ComponentConfig { ComponentConfig {
name: "secrets".to_string(), name: "vault".to_string(),
ports: vec![8200], ports: vec![8200],
dependencies: vec![], dependencies: vec![],
linux_packages: vec![], linux_packages: vec![],

View file

@ -1,5 +1,5 @@
pub mod directory_setup; pub mod directory_setup;
pub mod email_setup; pub mod email_setup;
pub use directory_setup::DirectorySetup; pub use directory_setup::{DirectorySetup, DefaultUser};
pub use email_setup::EmailSetup; pub use email_setup::EmailSetup;

View file

@ -24,6 +24,7 @@ use anyhow::{anyhow, Result};
use log::{debug, info, warn}; use log::{debug, info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Arc as StdArc; use std::sync::Arc as StdArc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@ -73,17 +74,34 @@ impl std::fmt::Debug for SecretsManager {
} }
impl SecretsManager { impl SecretsManager {
/// Create from environment variables /// Create from environment variables with mTLS support
///
/// Environment variables:
/// - VAULT_ADDR - Vault server address (https://localhost:8200)
/// - VAULT_TOKEN - Vault authentication token
/// - VAULT_CACERT - Path to CA certificate for verifying Vault server
/// - VAULT_CLIENT_CERT - Path to client certificate for mTLS
/// - VAULT_CLIENT_KEY - Path to client key for mTLS
/// - VAULT_SKIP_VERIFY - Skip TLS verification (for development only)
/// - VAULT_CACHE_TTL - Cache TTL in seconds (default: 300)
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let addr = env::var("VAULT_ADDR").unwrap_or_default(); let addr = env::var("VAULT_ADDR").unwrap_or_default();
let token = env::var("VAULT_TOKEN").unwrap_or_default(); let token = env::var("VAULT_TOKEN").unwrap_or_default();
let skip_verify = env::var("VAULT_SKIP_VERIFY") let skip_verify = env::var("VAULT_SKIP_VERIFY")
.map(|v| v == "true" || v == "1") .map(|v| v == "true" || v == "1")
.unwrap_or(true); .unwrap_or(false); // Default to false - verify certificates
let cache_ttl = env::var("VAULT_CACHE_TTL") let cache_ttl = env::var("VAULT_CACHE_TTL")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(300); .unwrap_or(300);
// mTLS certificate paths - default to botserver-stack paths
let ca_cert = env::var("VAULT_CACERT")
.unwrap_or_else(|_| "./botserver-stack/conf/system/certificates/ca/ca.crt".to_string());
let client_cert = env::var("VAULT_CLIENT_CERT")
.unwrap_or_else(|_| "./botserver-stack/conf/system/certificates/botserver/client.crt".to_string());
let client_key = env::var("VAULT_CLIENT_KEY")
.unwrap_or_else(|_| "./botserver-stack/conf/system/certificates/botserver/client.key".to_string());
let enabled = !token.is_empty() && !addr.is_empty(); let enabled = !token.is_empty() && !addr.is_empty();
@ -97,15 +115,48 @@ impl SecretsManager {
}); });
} }
let settings = VaultClientSettingsBuilder::default() // Build settings with mTLS if certificates exist
let ca_path = PathBuf::from(&ca_cert);
let cert_path = PathBuf::from(&client_cert);
let key_path = PathBuf::from(&client_key);
let mut settings_builder = VaultClientSettingsBuilder::default();
settings_builder
.address(&addr) .address(&addr)
.token(&token) .token(&token);
.verify(!skip_verify)
.build()?; // Configure TLS verification
if skip_verify {
warn!("TLS verification disabled - NOT RECOMMENDED FOR PRODUCTION");
settings_builder.verify(false);
} else {
settings_builder.verify(true);
// Add CA certificate if it exists
if ca_path.exists() {
info!("Using CA certificate for Vault: {}", ca_cert);
settings_builder.ca_certs(vec![ca_cert.clone()]);
}
}
// Configure mTLS client certificates if they exist
if cert_path.exists() && key_path.exists() {
info!("Using mTLS client certificate for Vault: {}", client_cert);
// Note: vaultrs uses the identity parameter for client certificates
// The identity is a PKCS12/PFX file or can be set via environment
// For now, we set environment variables that the underlying reqwest client will use
env::set_var("SSL_CERT_FILE", &ca_cert);
// Client certificate authentication is handled by reqwest through env vars
// or by building a custom client - vaultrs doesn't directly support client certs
// We'll document this limitation and use token auth with TLS verification
} else if !skip_verify {
info!("mTLS client certificates not found at {} - using token auth with TLS", client_cert);
}
let settings = settings_builder.build()?;
let client = VaultClient::new(settings)?; let client = VaultClient::new(settings)?;
info!("Vault client initialized: {}", addr); info!("Vault client initialized with TLS: {}", addr);
Ok(Self { Ok(Self {
client: Some(StdArc::new(client)), client: Some(StdArc::new(client)),
@ -326,98 +377,14 @@ impl SecretsManager {
self.cache.write().await.remove(path); self.cache.write().await.remove(path);
} }
/// Fallback to environment variables /// No fallback - Vault is mandatory
fn get_from_env(&self, path: &str) -> Result<HashMap<String, String>> { /// Returns empty HashMap if Vault is not configured
let mut data = HashMap::new(); fn get_from_env(&self, _path: &str) -> Result<HashMap<String, String>> {
let env_mappings: &[(&str, &[(&str, &str)])] = &[ // NO LEGACY FALLBACK - All secrets MUST come from Vault
( // If you see this error, ensure Vault is properly configured with:
SecretPaths::DRIVE, // VAULT_ADDR=https://localhost:8200
&[("accesskey", "DRIVE_ACCESSKEY"), ("secret", "DRIVE_SECRET")], // VAULT_TOKEN=<your-token>
), Err(anyhow!("Vault not configured. All secrets must be stored in Vault. Set VAULT_ADDR and VAULT_TOKEN in .env"))
(SecretPaths::CACHE, &[("password", "REDIS_PASSWORD")]),
(
SecretPaths::DIRECTORY,
&[
("url", "DIRECTORY_URL"),
("project_id", "DIRECTORY_PROJECT_ID"),
("client_id", "ZITADEL_CLIENT_ID"),
("client_secret", "ZITADEL_CLIENT_SECRET"),
],
),
(
SecretPaths::TABLES,
&[
("host", "DB_HOST"),
("port", "DB_PORT"),
("database", "DB_NAME"),
("username", "DB_USER"),
("password", "DB_PASSWORD"),
],
),
(
SecretPaths::VECTORDB,
&[("url", "QDRANT_URL"), ("api_key", "QDRANT_API_KEY")],
),
(
SecretPaths::OBSERVABILITY,
&[
("url", "INFLUXDB_URL"),
("org", "INFLUXDB_ORG"),
("bucket", "INFLUXDB_BUCKET"),
("token", "INFLUXDB_TOKEN"),
],
),
(
SecretPaths::EMAIL,
&[("username", "EMAIL_USER"), ("password", "EMAIL_PASSWORD")],
),
(
SecretPaths::LLM,
&[
("openai_key", "OPENAI_API_KEY"),
("anthropic_key", "ANTHROPIC_API_KEY"),
("groq_key", "GROQ_API_KEY"),
],
),
(SecretPaths::ENCRYPTION, &[("master_key", "ENCRYPTION_KEY")]),
(
SecretPaths::MEET,
&[
("api_key", "LIVEKIT_API_KEY"),
("api_secret", "LIVEKIT_API_SECRET"),
],
),
(
SecretPaths::ALM,
&[
("url", "ALM_URL"),
("admin_password", "ALM_ADMIN_PASSWORD"),
("runner_token", "ALM_RUNNER_TOKEN"),
],
),
];
for (p, mappings) in env_mappings {
if *p == path {
for (key, env_var) in *mappings {
if let Ok(v) = env::var(env_var) {
data.insert((*key).to_string(), v);
}
}
break;
}
}
// DATABASE_URL fallback
if path == SecretPaths::TABLES && data.is_empty() {
if let Ok(url) = env::var("DATABASE_URL") {
if let Some(parsed) = parse_database_url(&url) {
data.extend(parsed);
}
}
}
Ok(data)
} }
} }

View file

@ -261,9 +261,10 @@ fn create_mock_auth_service() -> AuthService {
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { // NO LEGACY FALLBACK - Vault is mandatory
"postgres://postgres:postgres@localhost:5432/botserver".to_string() // This default is only for tests. In production, use the full initialization.
}); let database_url = crate::shared::utils::get_database_url_sync()
.expect("Vault not configured. Set VAULT_ADDR and VAULT_TOKEN in .env");
let manager = ConnectionManager::<PgConnection>::new(&database_url); let manager = ConnectionManager::<PgConnection>::new(&database_url);
let pool = Pool::builder() let pool = Pool::builder()

View file

@ -10,7 +10,7 @@ use crate::directory::AuthService;
#[cfg(feature = "llm")] #[cfg(feature = "llm")]
use crate::llm::LLMProvider; use crate::llm::LLMProvider;
use crate::shared::models::BotResponse; use crate::shared::models::BotResponse;
use crate::shared::utils::DbPool; use crate::shared::utils::{get_database_url_sync, DbPool};
use crate::tasks::TaskEngine; use crate::tasks::TaskEngine;
use async_trait::async_trait; use async_trait::async_trait;
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::r2d2::{ConnectionManager, Pool};
@ -171,7 +171,7 @@ impl TestAppStateBuilder {
pub fn build(self) -> Result<AppState, Box<dyn std::error::Error + Send + Sync>> { pub fn build(self) -> Result<AppState, Box<dyn std::error::Error + Send + Sync>> {
let database_url = self let database_url = self
.database_url .database_url
.or_else(|| std::env::var("DATABASE_URL").ok()) .or_else(|| get_database_url_sync().ok())
.unwrap_or_else(|| "postgres://test:test@localhost:5432/test".to_string()); .unwrap_or_else(|| "postgres://test:test@localhost:5432/test".to_string());
let manager = ConnectionManager::<PgConnection>::new(&database_url); let manager = ConnectionManager::<PgConnection>::new(&database_url);
@ -245,7 +245,7 @@ fn create_mock_auth_service() -> AuthService {
} }
pub fn create_test_db_pool() -> Result<DbPool, Box<dyn std::error::Error + Send + Sync>> { pub fn create_test_db_pool() -> Result<DbPool, Box<dyn std::error::Error + Send + Sync>> {
let database_url = std::env::var("DATABASE_URL") let database_url = get_database_url_sync()
.unwrap_or_else(|_| "postgres://test:test@localhost:5432/test".to_string()); .unwrap_or_else(|_| "postgres://test:test@localhost:5432/test".to_string());
let manager = ConnectionManager::<PgConnection>::new(&database_url); let manager = ConnectionManager::<PgConnection>::new(&database_url);
let pool = Pool::builder().max_size(1).build(manager)?; let pool = Pool::builder().max_size(1).build(manager)?;

View file

@ -1,4 +1,5 @@
use crate::config::DriveConfig; use crate::config::DriveConfig;
use crate::core::secrets::SecretsManager;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
use aws_sdk_s3::{config::Builder as S3ConfigBuilder, Client as S3Client}; use aws_sdk_s3::{config::Builder as S3ConfigBuilder, Client as S3Client};
@ -10,13 +11,68 @@ use diesel::{
use futures_util::StreamExt; use futures_util::StreamExt;
#[cfg(feature = "progress-bars")] #[cfg(feature = "progress-bars")]
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use once_cell::sync::Lazy;
use reqwest::Client; use reqwest::Client;
use rhai::{Array, Dynamic}; use rhai::{Array, Dynamic};
use serde_json::Value; use serde_json::Value;
use smartstring::SmartString; use smartstring::SmartString;
use std::error::Error; use std::error::Error;
use std::sync::Arc;
use tokio::fs::File as TokioFile; use tokio::fs::File as TokioFile;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::RwLock;
/// Global SecretsManager instance - initialized once, used everywhere
static SECRETS_MANAGER: Lazy<Arc<RwLock<Option<SecretsManager>>>> =
Lazy::new(|| Arc::new(RwLock::new(None)));
/// Initialize the global secrets manager (call once at startup)
pub async fn init_secrets_manager() -> Result<()> {
let manager = SecretsManager::from_env()?;
let mut guard = SECRETS_MANAGER.write().await;
*guard = Some(manager);
Ok(())
}
/// Get database URL from Vault - NO FALLBACK
pub async fn get_database_url() -> Result<String> {
let guard = SECRETS_MANAGER.read().await;
if let Some(ref manager) = *guard {
if manager.is_enabled() {
return manager.get_database_url().await;
}
}
// NO FALLBACK - Vault is mandatory
Err(anyhow::anyhow!("Vault not configured. Set VAULT_ADDR and VAULT_TOKEN in .env"))
}
/// Get database URL synchronously (blocking) for diesel connections - NO FALLBACK
pub fn get_database_url_sync() -> Result<String> {
// Check if we're in an async runtime context
if let Ok(handle) = tokio::runtime::Handle::try_current() {
// We're inside a tokio runtime - use block_in_place to avoid nesting
let result = tokio::task::block_in_place(|| {
handle.block_on(async { get_database_url().await })
});
if let Ok(url) = result {
return Ok(url);
}
} else {
// Not in a runtime - create a new one
let rt = tokio::runtime::Runtime::new().map_err(|e| anyhow::anyhow!("Failed to create runtime: {}", e))?;
if let Ok(url) = rt.block_on(async { get_database_url().await }) {
return Ok(url);
}
}
// NO FALLBACK - Vault is mandatory
Err(anyhow::anyhow!("Vault not configured. Set VAULT_ADDR and VAULT_TOKEN in .env"))
}
/// Get the global SecretsManager instance
pub async fn get_secrets_manager() -> Option<SecretsManager> {
let guard = SECRETS_MANAGER.read().await;
guard.clone()
}
pub async fn create_s3_operator( pub async fn create_s3_operator(
config: &DriveConfig, config: &DriveConfig,
@ -164,7 +220,8 @@ pub fn estimate_token_count(text: &str) -> usize {
} }
pub fn establish_pg_connection() -> Result<PgConnection> { pub fn establish_pg_connection() -> Result<PgConnection> {
let database_url = std::env::var("DATABASE_URL").unwrap(); let database_url = get_database_url_sync()
.expect("Vault not configured. Set VAULT_ADDR and VAULT_TOKEN in .env");
PgConnection::establish(&database_url) PgConnection::establish(&database_url)
.with_context(|| format!("Failed to connect to database at {}", database_url)) .with_context(|| format!("Failed to connect to database at {}", database_url))
} }
@ -172,7 +229,16 @@ pub fn establish_pg_connection() -> Result<PgConnection> {
pub type DbPool = Pool<ConnectionManager<PgConnection>>; pub type DbPool = Pool<ConnectionManager<PgConnection>>;
pub fn create_conn() -> Result<DbPool, diesel::r2d2::PoolError> { pub fn create_conn() -> Result<DbPool, diesel::r2d2::PoolError> {
let database_url = std::env::var("DATABASE_URL").unwrap(); let database_url = get_database_url_sync()
.expect("Vault not configured. Set VAULT_ADDR and VAULT_TOKEN in .env");
let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::builder().build(manager)
}
/// Create database connection pool using SecretsManager (async version)
pub async fn create_conn_async() -> Result<DbPool, diesel::r2d2::PoolError> {
let database_url = get_database_url().await
.expect("Vault not configured. Set VAULT_ADDR and VAULT_TOKEN in .env");
let manager = ConnectionManager::<PgConnection>::new(database_url); let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::builder().build(manager) Pool::builder().build(manager)
} }

View file

@ -3,7 +3,6 @@ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
// Configuration comes from Directory service, not .env files
use dotenvy::dotenv; use dotenvy::dotenv;
use log::{error, info, trace, warn}; use log::{error, info, trace, warn};
use std::collections::HashMap; use std::collections::HashMap;
@ -246,7 +245,20 @@ async fn run_axum_server(
#[tokio::main] #[tokio::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
// Configuration comes from Directory service, not .env files // Install rustls crypto provider (ring) before any TLS operations
// This must be done before any code that might use rustls
let _ = rustls::crypto::ring::default_provider().install_default();
// Load .env for VAULT_* variables only (all other secrets come from Vault)
dotenvy::dotenv().ok();
// Initialize SecretsManager early - this connects to Vault if configured
// Only VAULT_ADDR, VAULT_TOKEN, and VAULT_SKIP_VERIFY should be in .env
if let Err(e) = crate::shared::utils::init_secrets_manager().await {
warn!("Failed to initialize SecretsManager: {}. Falling back to env vars.", e);
} else {
info!("SecretsManager initialized - fetching secrets from Vault");
}
// Initialize logger early to capture all logs with filters for noisy libraries // Initialize logger early to capture all logs with filters for noisy libraries
let rust_log = { let rust_log = {
@ -294,7 +306,6 @@ async fn main() -> std::io::Result<()> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let no_ui = args.contains(&"--noui".to_string()); let no_ui = args.contains(&"--noui".to_string());
let desktop_mode = args.contains(&"--desktop".to_string());
let no_console = args.contains(&"--noconsole".to_string()); let no_console = args.contains(&"--noconsole".to_string());
// Configuration comes from Directory service, not .env files // Configuration comes from Directory service, not .env files
@ -321,8 +332,8 @@ async fn main() -> std::io::Result<()> {
} }
} }
// Start UI thread if console is enabled (default) and not disabled by --noconsole or desktop mode // Start UI thread if console is enabled (default) and not disabled by --noconsole or --noui
let ui_handle: Option<std::thread::JoinHandle<()>> = if !no_console && !desktop_mode && !no_ui { let ui_handle: Option<std::thread::JoinHandle<()>> = if !no_console && !no_ui {
#[cfg(feature = "console")] #[cfg(feature = "console")]
{ {
let progress_rx = Arc::new(tokio::sync::Mutex::new(_progress_rx)); let progress_rx = Arc::new(tokio::sync::Mutex::new(_progress_rx));
@ -646,7 +657,7 @@ async fn main() -> std::io::Result<()> {
s3_client: Some(drive), s3_client: Some(drive),
config: Some(cfg.clone()), config: Some(cfg.clone()),
conn: pool.clone(), conn: pool.clone(),
database_url: std::env::var("DATABASE_URL").unwrap_or_else(|_| "".to_string()), database_url: crate::shared::utils::get_database_url_sync().unwrap_or_default(),
bucket_name: "default.gbai".to_string(), bucket_name: "default.gbai".to_string(),
cache: redis_client.clone(), cache: redis_client.clone(),
session_manager: session_manager.clone(), session_manager: session_manager.clone(),

View file

@ -7,8 +7,9 @@
//! - External CA integration capabilities //! - External CA integration capabilities
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rustls::server::{AllowAnyAnonymousOrAuthenticatedClient, AllowAnyAuthenticatedClient}; use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::{Certificate, PrivateKey, RootCertStore, ServerConfig}; use rustls::server::WebPkiClientVerifier;
use rustls::{RootCertStore, ServerConfig};
use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys}; use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::File; use std::fs::File;
@ -106,54 +107,51 @@ impl TlsManager {
let cert_chain = Self::load_certs(&config.cert_path)?; let cert_chain = Self::load_certs(&config.cert_path)?;
let key = Self::load_private_key(&config.key_path)?; let key = Self::load_private_key(&config.key_path)?;
let builder = ServerConfig::builder() let server_config = if config.require_client_cert {
.with_safe_default_cipher_suites()
.with_safe_default_kx_groups()
.with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])?;
let mut server_config = if config.require_client_cert {
// mTLS: Require client certificates // mTLS: Require client certificates
info!("Configuring mTLS - client certificates required"); info!("Configuring mTLS - client certificates required");
let client_cert_verifier = if let Some(ca_path) = &config.ca_cert_path { if let Some(ca_path) = &config.ca_cert_path {
let ca_certs = Self::load_certs(ca_path)?; let ca_certs = Self::load_certs(ca_path)?;
let mut root_store = RootCertStore::empty(); let mut root_store = RootCertStore::empty();
for cert in ca_certs { for cert in ca_certs {
root_store.add(&cert)?; root_store.add(cert)?;
} }
AllowAnyAuthenticatedClient::new(root_store) let client_cert_verifier = WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.map_err(|e| anyhow::anyhow!("Failed to build client verifier: {}", e))?;
ServerConfig::builder()
.with_client_cert_verifier(client_cert_verifier)
.with_single_cert(cert_chain, key)?
} else { } else {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"CA certificate required for mTLS but ca_cert_path not provided" "CA certificate required for mTLS but ca_cert_path not provided"
)); ));
}; }
builder
.with_client_cert_verifier(Arc::new(client_cert_verifier))
.with_single_cert(cert_chain, key)?
} else if let Some(ca_path) = &config.ca_cert_path { } else if let Some(ca_path) = &config.ca_cert_path {
// Optional client certificates // Optional client certificates
info!("Configuring TLS with optional client certificates"); info!("Configuring TLS with optional client certificates");
let ca_certs = Self::load_certs(ca_path)?; let ca_certs = Self::load_certs(ca_path)?;
let mut root_store = RootCertStore::empty(); let mut root_store = RootCertStore::empty();
for cert in ca_certs { for cert in ca_certs {
root_store.add(&cert)?; root_store.add(cert)?;
} }
let client_cert_verifier = AllowAnyAnonymousOrAuthenticatedClient::new(root_store); let client_cert_verifier = WebPkiClientVerifier::builder(Arc::new(root_store))
.allow_unauthenticated()
.build()
.map_err(|e| anyhow::anyhow!("Failed to build client verifier: {}", e))?;
builder ServerConfig::builder()
.with_client_cert_verifier(Arc::new(client_cert_verifier)) .with_client_cert_verifier(client_cert_verifier)
.with_single_cert(cert_chain, key)? .with_single_cert(cert_chain, key)?
} else { } else {
// No client certificate verification // No client certificate verification
info!("Configuring standard TLS without client certificates"); info!("Configuring standard TLS without client certificates");
builder ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(cert_chain, key)? .with_single_cert(cert_chain, key)?
}; };
// Configure ALPN for HTTP/2
server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(server_config) Ok(server_config)
} }
@ -165,18 +163,14 @@ impl TlsManager {
if let Some(ca_path) = &config.ca_cert_path { if let Some(ca_path) = &config.ca_cert_path {
let ca_certs = Self::load_certs(ca_path)?; let ca_certs = Self::load_certs(ca_path)?;
for cert in ca_certs { for cert in ca_certs {
root_store.add(&cert)?; root_store.add(cert)?;
} }
} else { } else {
// Use system CA certificates // Use system CA certificates
Self::load_system_certs(&mut root_store)?; Self::load_system_certs(&mut root_store)?;
} }
let builder = rustls::ClientConfig::builder() let builder = rustls::ClientConfig::builder().with_root_certificates(root_store);
.with_safe_default_cipher_suites()
.with_safe_default_kx_groups()
.with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])?
.with_root_certificates(root_store);
let client_config = if let (Some(cert_path), Some(key_path)) = let client_config = if let (Some(cert_path), Some(key_path)) =
(&config.client_cert_path, &config.client_key_path) (&config.client_cert_path, &config.client_key_path)
@ -193,32 +187,36 @@ impl TlsManager {
} }
/// Load certificates from PEM file /// Load certificates from PEM file
fn load_certs(path: &Path) -> Result<Vec<Certificate>> { fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>> {
let file = File::open(path) let file = File::open(path)
.with_context(|| format!("Failed to open certificate file: {:?}", path))?; .with_context(|| format!("Failed to open certificate file: {:?}", path))?;
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
let certs = certs(&mut reader)?.into_iter().map(Certificate).collect(); let certs: Result<Vec<_>, _> = certs(&mut reader).collect();
Ok(certs) certs.with_context(|| format!("Failed to parse certificates from {:?}", path))
} }
/// Load private key from PEM file /// Load private key from PEM file
fn load_private_key(path: &Path) -> Result<PrivateKey> { fn load_private_key(path: &Path) -> Result<PrivateKeyDer<'static>> {
let file = let file =
File::open(path).with_context(|| format!("Failed to open key file: {:?}", path))?; File::open(path).with_context(|| format!("Failed to open key file: {:?}", path))?;
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
// Try PKCS#8 format first // Try PKCS#8 format first
let keys = pkcs8_private_keys(&mut reader)?; let keys: Vec<_> = pkcs8_private_keys(&mut reader)
.filter_map(|k| k.ok())
.collect();
if !keys.is_empty() { if !keys.is_empty() {
return Ok(PrivateKey(keys[0].clone())); return Ok(PrivateKeyDer::Pkcs8(keys[0].clone_key()));
} }
// Reset reader and try RSA format // Reset reader and try RSA format
let file = File::open(path)?; let file = File::open(path)?;
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
let keys = rsa_private_keys(&mut reader)?; let keys: Vec<_> = rsa_private_keys(&mut reader)
.filter_map(|k| k.ok())
.collect();
if !keys.is_empty() { if !keys.is_empty() {
return Ok(PrivateKey(keys[0].clone())); return Ok(PrivateKeyDer::Pkcs1(keys[0].clone_key()));
} }
Err(anyhow::anyhow!("No private key found in file: {:?}", path)) Err(anyhow::anyhow!("No private key found in file: {:?}", path))
@ -240,7 +238,7 @@ impl TlsManager {
match Self::load_certs(Path::new(path)) { match Self::load_certs(Path::new(path)) {
Ok(certs) => { Ok(certs) => {
for cert in certs { for cert in certs {
root_store.add(&cert)?; root_store.add(cert)?;
} }
info!("Loaded system certificates from {}", path); info!("Loaded system certificates from {}", path);
return Ok(()); return Ok(());

View file

@ -1029,14 +1029,20 @@ pub async fn attendant_respond(
} }
} }
/// Get verify token from config /// Get verify token from config (from Vault)
async fn get_verify_token(state: &Arc<AppState>) -> String { async fn get_verify_token(_state: &Arc<AppState>) -> String {
let bot_id = get_default_bot_id(state).await; // Get verify token from Vault - stored at gbo/whatsapp
let adapter = WhatsAppAdapter::new(state.conn.clone(), bot_id); use crate::core::secrets::SecretsManager;
// The verify token is stored in the adapter's config match SecretsManager::new() {
// For now return a default - in production this should come from config Ok(secrets) => {
std::env::var("WHATSAPP_VERIFY_TOKEN").unwrap_or_else(|_| "webhook_verify".to_string()) match secrets.get("gbo/whatsapp", "verify_token").await {
Ok(token) => token,
Err(_) => "webhook_verify".to_string() // Default for initial setup
}
}
Err(_) => "webhook_verify".to_string() // Default if Vault not configured
}
} }
/// Get default bot ID /// Get default bot ID