i18n+filtering fork - fluent-templates v2
at main 471 lines 15 kB view raw
1//! # Configuration Module 2//! 3//! Application configuration management and validation for the smokesignal event management system. 4//! 5//! This module provides a comprehensive configuration system that loads settings from environment 6//! variables, validates them at startup, and provides type-safe access throughout the application. 7//! Configuration includes database connections, cryptographic keys, external service endpoints, 8//! and operational parameters. 9//! 10//! ## Architecture 11//! 12//! The configuration system is built around several key principles: 13//! 14//! ### Type Safety 15//! All configuration values are wrapped in newtype structs to prevent misuse: 16//! - **[`HttpPort`]** - Validated HTTP server port number 17//! - **[`HttpCookieKey`]** - Cryptographically secure cookie signing key 18//! - **[`SigningKeys`]** - JSON Web Signature (JWS) signing keys for OAuth 19//! - **[`AdminDIDs`]** - List of administrative DID identifiers 20//! 21//! ### Validation 22//! Configuration values are validated at load time: 23//! - Port numbers within valid ranges 24//! - Cryptographic key format validation 25//! - URL format validation for external services 26//! - Required environment variables presence checks 27//! 28//! ### Security 29//! Sensitive configuration is handled securely: 30//! - Keys are loaded from secure sources 31//! - Sensitive values are not logged or exposed 32//! - Cryptographic material is properly managed 33//! - Environment-based configuration isolation 34//! 35//! ## Configuration Sources 36//! 37//! Configuration is loaded from multiple sources in order of precedence: 38//! 1. Environment variables (highest priority) 39//! 2. Configuration files (if specified) 40//! 3. Default values (lowest priority) 41//! 42//! ## Required Environment Variables 43//! 44//! The following environment variables must be set: 45//! 46//! ### Database and Cache 47//! - `DATABASE_URL` - PostgreSQL connection string 48//! - `REDIS_URL` - Redis/Valkey connection string for caching and sessions 49//! 50//! ### HTTP Server 51//! - `HTTP_PORT` - Port number for the web server (default: 3000) 52//! - `HTTP_COOKIE_KEY` - Base64-encoded key for cookie signing 53//! - `EXTERNAL_BASE` - Base URL for external links and callbacks 54//! 55//! ### AT Protocol 56//! - `PLC_HOSTNAME` - Personal Data Server hostname 57//! - `SIGNING_KEYS` - JSON object containing OAuth signing keys 58//! - `OAUTH_ACTIVE_KEYS` - Comma-separated list of active key IDs 59//! - `DESTINATION_KEY` - Private key for destination verification 60//! 61//! ### Optional Configuration 62//! - `CERTIFICATE_BUNDLES` - Additional CA certificate bundles 63//! - `ADMIN_DIDS` - Comma-separated list of admin DID identifiers 64//! - `DNS_NAMESERVERS` - Custom DNS servers for resolution 65//! - `USER_AGENT` - Custom User-Agent string for external requests 66//! 67//! ## Example Usage 68//! 69//! ```rust,no_run 70//! use smokesignal::config::Config; 71//! 72//! #[tokio::main] 73//! async fn main() -> anyhow::Result<()> { 74//! // Load configuration from environment 75//! let config = Config::from_env().await?; 76//! 77//! // Access configuration values 78//! println!("Server will run on port: {}", config.http_port.value()); 79//! println!("Database URL: {}", config.database_url); 80//! 81//! // Configuration is available throughout the application 82//! start_server(config).await 83//! } 84//! 85//! async fn start_server(config: Config) -> anyhow::Result<()> { 86//! // Use configuration to set up services 87//! Ok(()) 88//! } 89//! ``` 90//! 91//! ## Security Considerations 92//! 93//! When deploying smokesignal, ensure that: 94//! - All required environment variables are set 95//! - Cryptographic keys are generated securely and kept private 96//! - Database and Redis connections use appropriate authentication 97//! - The `EXTERNAL_BASE` URL uses HTTPS in production 98//! - Admin DIDs are restricted to trusted identities 99 100use anyhow::Result; 101use axum_extra::extract::cookie::Key; 102use base64::{engine::general_purpose, Engine as _}; 103use ordermap::OrderMap; 104use p256::SecretKey; 105use rand::seq::SliceRandom; 106 107use crate::config_errors::ConfigError; 108use crate::encoding_errors::EncodingError; 109use crate::jose::jwk::WrappedJsonWebKeySet; 110 111#[derive(Clone)] 112pub struct HttpPort(u16); 113 114#[derive(Clone)] 115pub struct HttpCookieKey(Key); 116 117#[derive(Clone)] 118pub struct CertificateBundles(Vec<String>); 119 120#[derive(Clone)] 121pub struct SigningKeys(OrderMap<String, SecretKey>); 122 123#[derive(Clone)] 124pub struct OAuthActiveKeys(Vec<String>); 125 126#[derive(Clone)] 127pub struct AdminDIDs(Vec<String>); 128 129#[derive(Clone)] 130pub struct DnsNameservers(Vec<std::net::IpAddr>); 131 132#[derive(Clone)] 133pub struct Config { 134 pub version: String, 135 pub http_port: HttpPort, 136 pub http_cookie_key: HttpCookieKey, 137 pub http_static_path: String, 138 pub external_base: String, 139 pub certificate_bundles: CertificateBundles, 140 pub user_agent: String, 141 pub database_url: String, 142 pub plc_hostname: String, 143 pub signing_keys: SigningKeys, 144 pub oauth_active_keys: OAuthActiveKeys, 145 pub destination_key: SecretKey, 146 pub redis_url: String, 147 pub admin_dids: AdminDIDs, 148 pub dns_nameservers: DnsNameservers, 149} 150 151impl Config { 152 pub fn new() -> Result<Self> { 153 let http_port: HttpPort = default_env("HTTP_PORT", "8080").try_into()?; 154 155 let http_cookie_key: HttpCookieKey = 156 require_env("HTTP_COOKIE_KEY").and_then(|value| value.try_into())?; 157 158 let http_static_path = default_env("HTTP_STATIC_PATH", "static"); 159 160 let external_base = require_env("EXTERNAL_BASE")?; 161 162 let certificate_bundles: CertificateBundles = 163 optional_env("CERTIFICATE_BUNDLES").try_into()?; 164 165 let default_user_agent = 166 format!("smokesignal ({}; +https://smokesignal.events/)", version()?); 167 168 let user_agent = default_env("USER_AGENT", &default_user_agent); 169 170 let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 171 172 let database_url = default_env("DATABASE_URL", "sqlite://development.db"); 173 174 let signing_keys: SigningKeys = 175 require_env("SIGNING_KEYS").and_then(|value| value.try_into())?; 176 177 let oauth_active_keys: OAuthActiveKeys = 178 require_env("OAUTH_ACTIVE_KEYS").and_then(|value| value.try_into())?; 179 180 let destination_key = require_env("DESTINATION_KEY").and_then(|value| { 181 signing_keys 182 .0 183 .get(&value) 184 .cloned() 185 .ok_or(ConfigError::InvalidDestinationKey.into()) 186 })?; 187 188 let redis_url = default_env("REDIS_URL", "redis://valkey:6379/0"); 189 190 let admin_dids: AdminDIDs = optional_env("ADMIN_DIDS").try_into()?; 191 192 let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 193 194 Ok(Self { 195 version: version()?, 196 http_port, 197 http_static_path, 198 external_base, 199 certificate_bundles, 200 user_agent, 201 plc_hostname, 202 database_url, 203 signing_keys, 204 oauth_active_keys, 205 http_cookie_key, 206 destination_key, 207 redis_url, 208 admin_dids, 209 dns_nameservers, 210 }) 211 } 212 213 pub fn select_oauth_signing_key(&self) -> Result<(String, SecretKey)> { 214 let key_id = self 215 .oauth_active_keys 216 .as_ref() 217 .choose(&mut rand::thread_rng()) 218 .ok_or(ConfigError::SigningKeyNotFound)? 219 .clone(); 220 let signing_key = self 221 .signing_keys 222 .as_ref() 223 .get(&key_id) 224 .ok_or(ConfigError::SigningKeyNotFound)? 225 .clone(); 226 227 Ok((key_id, signing_key)) 228 } 229 230 /// Check if a DID is in the admin allow list 231 pub fn is_admin(&self, did: &str) -> bool { 232 self.admin_dids.as_ref().contains(&did.to_string()) 233 } 234} 235 236pub fn require_env(name: &str) -> Result<String> { 237 std::env::var(name).map_err(|_| ConfigError::EnvVarRequired(name.to_string()).into()) 238} 239 240pub fn optional_env(name: &str) -> String { 241 std::env::var(name).unwrap_or("".to_string()) 242} 243 244pub fn default_env(name: &str, default_value: &str) -> String { 245 std::env::var(name).unwrap_or(default_value.to_string()) 246} 247 248pub fn version() -> Result<String> { 249 option_env!("GIT_HASH") 250 .or(option_env!("CARGO_PKG_VERSION")) 251 .map(|val| val.to_string()) 252 .ok_or(ConfigError::VersionNotSet.into()) 253} 254 255impl TryFrom<String> for HttpPort { 256 type Error = anyhow::Error; 257 fn try_from(value: String) -> Result<Self, Self::Error> { 258 if value.is_empty() { 259 Ok(Self(80)) 260 } else { 261 value 262 .parse::<u16>() 263 .map(Self) 264 .map_err(|err| ConfigError::PortParsingFailed(err).into()) 265 } 266 } 267} 268 269impl AsRef<u16> for HttpPort { 270 fn as_ref(&self) -> &u16 { 271 &self.0 272 } 273} 274 275impl TryFrom<String> for HttpCookieKey { 276 type Error = anyhow::Error; 277 fn try_from(value: String) -> Result<Self, Self::Error> { 278 let mut decoded_key: [u8; 66] = [0; 66]; 279 general_purpose::STANDARD_NO_PAD 280 .decode_slice(value, &mut decoded_key) 281 .map_err(|err| anyhow::Error::from(ConfigError::CookieKeyDecodeFailed(err)))?; 282 Key::try_from(&decoded_key[..64]) 283 .map_err(|_| anyhow::Error::from(ConfigError::CookieKeyProcessFailed)) 284 .map(Self) 285 } 286} 287 288impl AsRef<Key> for HttpCookieKey { 289 fn as_ref(&self) -> &Key { 290 &self.0 291 } 292} 293 294impl TryFrom<String> for CertificateBundles { 295 type Error = anyhow::Error; 296 fn try_from(value: String) -> Result<Self, Self::Error> { 297 Ok(Self( 298 value 299 .split(';') 300 .filter_map(|s| { 301 if s.is_empty() { 302 None 303 } else { 304 Some(s.to_string()) 305 } 306 }) 307 .collect::<Vec<String>>(), 308 )) 309 } 310} 311 312impl AsRef<Vec<String>> for CertificateBundles { 313 fn as_ref(&self) -> &Vec<String> { 314 &self.0 315 } 316} 317 318impl AsRef<OrderMap<String, SecretKey>> for SigningKeys { 319 fn as_ref(&self) -> &OrderMap<String, SecretKey> { 320 &self.0 321 } 322} 323 324impl TryFrom<String> for SigningKeys { 325 type Error = anyhow::Error; 326 fn try_from(value: String) -> Result<Self, Self::Error> { 327 let content = { 328 if value.starts_with("/") { 329 // Verify file exists before reading 330 if !std::path::Path::new(&value).exists() { 331 return Err(ConfigError::SigningKeysFileNotFound(value).into()); 332 } 333 std::fs::read(&value).map_err(ConfigError::ReadSigningKeysFailed)? 334 } else { 335 general_purpose::STANDARD 336 .decode(&value) 337 .map_err(EncodingError::Base64DecodingFailed)? 338 } 339 }; 340 341 // Validate content is not empty 342 if content.is_empty() { 343 return Err(ConfigError::EmptySigningKeysFile.into()); 344 } 345 346 // Parse JSON with proper error handling 347 let jwks = serde_json::from_slice::<WrappedJsonWebKeySet>(&content) 348 .map_err(ConfigError::ParseSigningKeysFailed)?; 349 350 // Validate JWKS contains keys 351 if jwks.keys.is_empty() { 352 return Err(ConfigError::MissingKeysInJWKS.into()); 353 } 354 355 // Track keys that failed validation for better error reporting 356 let mut validation_errors = Vec::new(); 357 358 let signing_keys = jwks 359 .keys 360 .iter() 361 .filter_map(|key| { 362 // Validate key has required fields 363 if key.kid.is_none() { 364 validation_errors.push("Missing key ID (kid)".to_string()); 365 return None; 366 } 367 368 if let (Some(key_id), secret_key) = (key.kid.clone(), key.jwk.clone()) { 369 // Verify the key_id format (should be a valid ULID) 370 if ulid::Ulid::from_string(&key_id).is_err() { 371 validation_errors.push(format!("Invalid key ID format: {}", key_id)); 372 return None; 373 } 374 375 // Validate the secret key 376 match p256::SecretKey::from_jwk(&secret_key) { 377 Ok(secret_key) => Some((key_id, secret_key)), 378 Err(err) => { 379 validation_errors.push(format!("Invalid key {}: {}", key_id, err)); 380 None 381 } 382 } 383 } else { 384 None 385 } 386 }) 387 .collect::<OrderMap<String, SecretKey>>(); 388 389 // Check if we have any valid keys 390 if signing_keys.is_empty() { 391 if !validation_errors.is_empty() { 392 return Err(ConfigError::SigningKeysValidationFailed(validation_errors).into()); 393 } 394 return Err(ConfigError::EmptySigningKeys.into()); 395 } 396 397 Ok(Self(signing_keys)) 398 } 399} 400 401impl AsRef<Vec<String>> for OAuthActiveKeys { 402 fn as_ref(&self) -> &Vec<String> { 403 &self.0 404 } 405} 406 407impl TryFrom<String> for OAuthActiveKeys { 408 type Error = anyhow::Error; 409 fn try_from(value: String) -> Result<Self, Self::Error> { 410 let values = value 411 .split(';') 412 .map(|s| s.to_string()) 413 .collect::<Vec<String>>(); 414 if values.is_empty() { 415 return Err(ConfigError::EmptyOAuthActiveKeys.into()); 416 } 417 Ok(Self(values)) 418 } 419} 420 421impl AsRef<Vec<String>> for AdminDIDs { 422 fn as_ref(&self) -> &Vec<String> { 423 &self.0 424 } 425} 426 427impl TryFrom<String> for AdminDIDs { 428 type Error = anyhow::Error; 429 fn try_from(value: String) -> Result<Self, Self::Error> { 430 // Allow empty value for no admins 431 if value.is_empty() { 432 return Ok(Self(Vec::new())); 433 } 434 435 let admin_dids = value 436 .split(',') 437 .map(|s| s.trim().to_string()) 438 .filter(|s| !s.is_empty()) 439 .collect::<Vec<String>>(); 440 441 Ok(Self(admin_dids)) 442 } 443} 444 445impl AsRef<Vec<std::net::IpAddr>> for DnsNameservers { 446 fn as_ref(&self) -> &Vec<std::net::IpAddr> { 447 &self.0 448 } 449} 450 451impl TryFrom<String> for DnsNameservers { 452 type Error = anyhow::Error; 453 fn try_from(value: String) -> Result<Self, Self::Error> { 454 // Allow empty value for default DNS configuration 455 if value.is_empty() { 456 return Ok(Self(Vec::new())); 457 } 458 459 let nameservers = value 460 .split(',') 461 .map(|s| s.trim()) 462 .filter(|s| !s.is_empty()) 463 .map(|s| { 464 s.parse::<std::net::IpAddr>() 465 .map_err(|e| ConfigError::NameserverParsingFailed(s.to_string(), e)) 466 }) 467 .collect::<Result<Vec<std::net::IpAddr>, ConfigError>>()?; 468 469 Ok(Self(nameservers)) 470 } 471}