/// Configuration management for Charybdis /// /// This module handles loading configuration from a TOML file with support for /// environment variable substitution using ${VAR_NAME} syntax. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; use tracing::{debug, info}; /// Main Charybdis configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// Server configuration pub server: ServerConfig, /// Database configuration pub database: DatabaseConfig, /// Security configuration (mTLS and RBAC) #[serde(default)] pub security: crate::security::config::SecurityConfig, /// OpenTelemetry configuration #[serde(default)] pub telemetry: crate::telemetry::config::TelemetryConfig, /// Plugin configuration #[serde(default)] pub plugins: PluginsConfig, } /// Server configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { /// gRPC server host #[serde(default = "default_grpc_host")] pub grpc_host: String, /// gRPC server port #[serde(default = "default_grpc_port")] pub grpc_port: u16, /// YAML adapter configuration #[serde(default)] pub yaml_adapter: YamlAdapterConfig, } /// YAML adapter configuration for Backstage integration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YamlAdapterConfig { /// Enable YAML adapter #[serde(default = "default_yaml_enabled")] pub enabled: bool, /// YAML adapter host #[serde(default = "default_yaml_host")] pub host: String, /// YAML adapter port #[serde(default = "default_yaml_port")] pub port: u16, } /// Database configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabaseConfig { /// PostgreSQL connection URL /// Supports environment variable substitution: ${DATABASE_URL} pub url: String, /// Maximum number of connections in the pool #[serde(default = "default_max_connections")] pub max_connections: u32, /// Connection timeout in seconds #[serde(default = "default_connection_timeout")] pub connection_timeout_secs: u64, } /// Plugin system configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PluginsConfig { /// DefectDojo plugin configuration #[serde(default)] pub defectdojo: Option>, /// Dependency-Track plugin configuration #[serde(default)] pub dependencytrack: Option>, /// Keycloak plugin configuration #[serde(default)] pub keycloak: Option>, /// Custom plugin configurations #[serde(flatten)] pub custom: HashMap>, } // Default values fn default_grpc_host() -> String { "[::1]".to_string() } fn default_grpc_port() -> u16 { 50051 } fn default_yaml_enabled() -> bool { true } fn default_yaml_host() -> String { "0.0.0.0".to_string() } fn default_yaml_port() -> u16 { 8080 } fn default_max_connections() -> u32 { 10 } fn default_connection_timeout() -> u64 { 30 } impl Default for ServerConfig { fn default() -> Self { Self { grpc_host: default_grpc_host(), grpc_port: default_grpc_port(), yaml_adapter: YamlAdapterConfig::default(), } } } impl Default for YamlAdapterConfig { fn default() -> Self { Self { enabled: default_yaml_enabled(), host: default_yaml_host(), port: default_yaml_port(), } } } impl Config { /// Load configuration from a TOML file /// /// Environment variables in the format ${VAR_NAME} will be substituted /// with their values from the environment. pub fn from_file>(path: P) -> Result { let path = path.as_ref(); info!("Loading configuration from: {}", path.display()); // Read the file let content = fs::read_to_string(path) .with_context(|| format!("Failed to read config file: {}", path.display()))?; // Substitute environment variables let content = Self::substitute_env_vars(&content)?; debug!("Parsing configuration"); // Parse TOML let config: Config = toml::from_str(&content) .with_context(|| format!("Failed to parse config file: {}", path.display()))?; info!("Configuration loaded successfully"); Ok(config) } /// Load configuration from default location /// /// Looks for config in the following order: /// 1. ./config.toml (current directory) /// 2. ./charybdis.toml /// 3. /etc/charybdis/config.toml (Linux/Unix) /// /// Falls back to environment variables if no config file is found. pub fn load() -> Result { let candidates = vec![ "./config.toml", "./charybdis.toml", "/etc/charybdis/config.toml", ]; for path in candidates { if Path::new(path).exists() { return Self::from_file(path); } } info!("No config file found, using environment variables"); Self::from_env() } /// Create configuration from environment variables (legacy support) pub fn from_env() -> Result { use crate::security::config::SecurityConfig; use crate::telemetry::config::TelemetryConfig; let database_url = std::env::var("DATABASE_URL") .context("DATABASE_URL must be set (or provide config.toml)")?; Ok(Config { server: ServerConfig { grpc_host: std::env::var("GRPC_HOST").unwrap_or_else(|_| default_grpc_host()), grpc_port: std::env::var("GRPC_PORT") .ok() .and_then(|s| s.parse().ok()) .unwrap_or_else(default_grpc_port), yaml_adapter: YamlAdapterConfig::default(), }, database: DatabaseConfig { url: database_url, max_connections: default_max_connections(), connection_timeout_secs: default_connection_timeout(), }, security: SecurityConfig::from_env(), telemetry: TelemetryConfig::from_env(), plugins: PluginsConfig::default(), }) } /// Substitute environment variables in the format ${VAR_NAME} or ${VAR_NAME:-default} fn substitute_env_vars(content: &str) -> Result { let mut result = content.to_string(); // Match ${VAR_NAME} or ${VAR_NAME:-default_value} let var_pattern = regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)(?::-((?:[^}])*))?\}").unwrap(); for capture in var_pattern.captures_iter(content) { let full_match = &capture[0]; let var_name = &capture[1]; let default_value = capture.get(2).map(|m| m.as_str()); match std::env::var(var_name) { Ok(value) => { debug!("Substituting ${{{}}}", var_name); result = result.replace(full_match, &value); } Err(_) => { if let Some(default) = default_value { debug!( "Environment variable not set: ${{{}}}, using default: {}", var_name, default ); result = result.replace(full_match, default); } else { debug!("Environment variable not set: ${{{}}}", var_name); } } } } Ok(result) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_env_var_substitution() { // Use PATH which always exists in test environment let path_value = std::env::var("PATH").unwrap(); let input = "some_path = \"${PATH}\""; let result = Config::substitute_env_vars(input).unwrap(); assert!(result.contains(&path_value)); assert!(!result.contains("${PATH}")); } #[test] fn test_env_var_substitution_missing() { // Test that missing env vars are left as-is (allowing optional substitution) let input = "url = \"postgresql://${NONEXISTENT_VAR_12345}:password@localhost\""; let result = Config::substitute_env_vars(input).unwrap(); // Variable should remain unchanged when not found assert!(result.contains("${NONEXISTENT_VAR_12345}")); } #[test] fn test_env_var_substitution_multiple() { // Test multiple variable substitutions let path_value = std::env::var("PATH").unwrap(); let input = "path = \"${PATH}\" and user = \"${USER}\""; let result = Config::substitute_env_vars(input).unwrap(); assert!(result.contains(&path_value)); assert!(!result.contains("${PATH}")); } #[test] fn test_default_values() { let server = ServerConfig::default(); assert_eq!(server.grpc_port, 50051); assert_eq!(server.grpc_host, "[::1]"); } #[test] fn test_env_var_with_default_value() { let input = "url = \"${NONEXISTENT_VAR_99999:-http://localhost:8080}\""; let result = Config::substitute_env_vars(input).unwrap(); assert_eq!(result, "url = \"http://localhost:8080\""); } #[test] fn test_env_var_with_default_value_overridden() { // PATH always exists — use it with a default that should be ignored let path_value = std::env::var("PATH").unwrap(); let input = "url = \"${PATH:-http://fallback:8080}\""; let result = Config::substitute_env_vars(input).unwrap(); assert_eq!(result, format!("url = \"{}\"", path_value)); } }