//! # Configuration Module //! //! Application configuration management and validation for the smokesignal event management system. //! //! This module provides a comprehensive configuration system that loads settings from environment //! variables, validates them at startup, and provides type-safe access throughout the application. //! Configuration includes database connections, cryptographic keys, external service endpoints, //! and operational parameters. //! //! ## Architecture //! //! The configuration system is built around several key principles: //! //! ### Type Safety //! All configuration values are wrapped in newtype structs to prevent misuse: //! - **[`HttpPort`]** - Validated HTTP server port number //! - **[`HttpCookieKey`]** - Cryptographically secure cookie signing key //! - **[`SigningKeys`]** - JSON Web Signature (JWS) signing keys for OAuth //! - **[`AdminDIDs`]** - List of administrative DID identifiers //! //! ### Validation //! Configuration values are validated at load time: //! - Port numbers within valid ranges //! - Cryptographic key format validation //! - URL format validation for external services //! - Required environment variables presence checks //! //! ### Security //! Sensitive configuration is handled securely: //! - Keys are loaded from secure sources //! - Sensitive values are not logged or exposed //! - Cryptographic material is properly managed //! - Environment-based configuration isolation //! //! ## Configuration Sources //! //! Configuration is loaded from multiple sources in order of precedence: //! 1. Environment variables (highest priority) //! 2. Configuration files (if specified) //! 3. Default values (lowest priority) //! //! ## Required Environment Variables //! //! The following environment variables must be set: //! //! ### Database and Cache //! - `DATABASE_URL` - PostgreSQL connection string //! - `REDIS_URL` - Redis/Valkey connection string for caching and sessions //! //! ### HTTP Server //! - `HTTP_PORT` - Port number for the web server (default: 3000) //! - `HTTP_COOKIE_KEY` - Base64-encoded key for cookie signing //! - `EXTERNAL_BASE` - Base URL for external links and callbacks //! //! ### AT Protocol //! - `PLC_HOSTNAME` - Personal Data Server hostname //! - `SIGNING_KEYS` - JSON object containing OAuth signing keys //! - `OAUTH_ACTIVE_KEYS` - Comma-separated list of active key IDs //! - `DESTINATION_KEY` - Private key for destination verification //! //! ### Optional Configuration //! - `CERTIFICATE_BUNDLES` - Additional CA certificate bundles //! - `ADMIN_DIDS` - Comma-separated list of admin DID identifiers //! - `DNS_NAMESERVERS` - Custom DNS servers for resolution //! - `USER_AGENT` - Custom User-Agent string for external requests //! //! ## Example Usage //! //! ```rust,no_run //! use smokesignal::config::Config; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { //! // Load configuration from environment //! let config = Config::from_env().await?; //! //! // Access configuration values //! println!("Server will run on port: {}", config.http_port.value()); //! println!("Database URL: {}", config.database_url); //! //! // Configuration is available throughout the application //! start_server(config).await //! } //! //! async fn start_server(config: Config) -> anyhow::Result<()> { //! // Use configuration to set up services //! Ok(()) //! } //! ``` //! //! ## Security Considerations //! //! When deploying smokesignal, ensure that: //! - All required environment variables are set //! - Cryptographic keys are generated securely and kept private //! - Database and Redis connections use appropriate authentication //! - The `EXTERNAL_BASE` URL uses HTTPS in production //! - Admin DIDs are restricted to trusted identities use anyhow::Result; use axum_extra::extract::cookie::Key; use base64::{engine::general_purpose, Engine as _}; use ordermap::OrderMap; use p256::SecretKey; use rand::seq::SliceRandom; use crate::config_errors::ConfigError; use crate::encoding_errors::EncodingError; use crate::jose::jwk::WrappedJsonWebKeySet; #[derive(Clone)] pub struct HttpPort(u16); #[derive(Clone)] pub struct HttpCookieKey(Key); #[derive(Clone)] pub struct CertificateBundles(Vec); #[derive(Clone)] pub struct SigningKeys(OrderMap); #[derive(Clone)] pub struct OAuthActiveKeys(Vec); #[derive(Clone)] pub struct AdminDIDs(Vec); #[derive(Clone)] pub struct DnsNameservers(Vec); #[derive(Clone)] pub struct Config { pub version: String, pub http_port: HttpPort, pub http_cookie_key: HttpCookieKey, pub http_static_path: String, pub external_base: String, pub certificate_bundles: CertificateBundles, pub user_agent: String, pub database_url: String, pub plc_hostname: String, pub signing_keys: SigningKeys, pub oauth_active_keys: OAuthActiveKeys, pub destination_key: SecretKey, pub redis_url: String, pub admin_dids: AdminDIDs, pub dns_nameservers: DnsNameservers, } impl Config { pub fn new() -> Result { let http_port: HttpPort = default_env("HTTP_PORT", "8080").try_into()?; let http_cookie_key: HttpCookieKey = require_env("HTTP_COOKIE_KEY").and_then(|value| value.try_into())?; let http_static_path = default_env("HTTP_STATIC_PATH", "static"); let external_base = require_env("EXTERNAL_BASE")?; let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; let default_user_agent = format!("smokesignal ({}; +https://smokesignal.events/)", version()?); let user_agent = default_env("USER_AGENT", &default_user_agent); let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); let database_url = default_env("DATABASE_URL", "sqlite://development.db"); let signing_keys: SigningKeys = require_env("SIGNING_KEYS").and_then(|value| value.try_into())?; let oauth_active_keys: OAuthActiveKeys = require_env("OAUTH_ACTIVE_KEYS").and_then(|value| value.try_into())?; let destination_key = require_env("DESTINATION_KEY").and_then(|value| { signing_keys .0 .get(&value) .cloned() .ok_or(ConfigError::InvalidDestinationKey.into()) })?; let redis_url = default_env("REDIS_URL", "redis://valkey:6379/0"); let admin_dids: AdminDIDs = optional_env("ADMIN_DIDS").try_into()?; let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; Ok(Self { version: version()?, http_port, http_static_path, external_base, certificate_bundles, user_agent, plc_hostname, database_url, signing_keys, oauth_active_keys, http_cookie_key, destination_key, redis_url, admin_dids, dns_nameservers, }) } pub fn select_oauth_signing_key(&self) -> Result<(String, SecretKey)> { let key_id = self .oauth_active_keys .as_ref() .choose(&mut rand::thread_rng()) .ok_or(ConfigError::SigningKeyNotFound)? .clone(); let signing_key = self .signing_keys .as_ref() .get(&key_id) .ok_or(ConfigError::SigningKeyNotFound)? .clone(); Ok((key_id, signing_key)) } /// Check if a DID is in the admin allow list pub fn is_admin(&self, did: &str) -> bool { self.admin_dids.as_ref().contains(&did.to_string()) } } pub fn require_env(name: &str) -> Result { std::env::var(name).map_err(|_| ConfigError::EnvVarRequired(name.to_string()).into()) } pub fn optional_env(name: &str) -> String { std::env::var(name).unwrap_or("".to_string()) } pub fn default_env(name: &str, default_value: &str) -> String { std::env::var(name).unwrap_or(default_value.to_string()) } pub fn version() -> Result { option_env!("GIT_HASH") .or(option_env!("CARGO_PKG_VERSION")) .map(|val| val.to_string()) .ok_or(ConfigError::VersionNotSet.into()) } impl TryFrom for HttpPort { type Error = anyhow::Error; fn try_from(value: String) -> Result { if value.is_empty() { Ok(Self(80)) } else { value .parse::() .map(Self) .map_err(|err| ConfigError::PortParsingFailed(err).into()) } } } impl AsRef for HttpPort { fn as_ref(&self) -> &u16 { &self.0 } } impl TryFrom for HttpCookieKey { type Error = anyhow::Error; fn try_from(value: String) -> Result { let mut decoded_key: [u8; 66] = [0; 66]; general_purpose::STANDARD_NO_PAD .decode_slice(value, &mut decoded_key) .map_err(|err| anyhow::Error::from(ConfigError::CookieKeyDecodeFailed(err)))?; Key::try_from(&decoded_key[..64]) .map_err(|_| anyhow::Error::from(ConfigError::CookieKeyProcessFailed)) .map(Self) } } impl AsRef for HttpCookieKey { fn as_ref(&self) -> &Key { &self.0 } } impl TryFrom for CertificateBundles { type Error = anyhow::Error; fn try_from(value: String) -> Result { Ok(Self( value .split(';') .filter_map(|s| { if s.is_empty() { None } else { Some(s.to_string()) } }) .collect::>(), )) } } impl AsRef> for CertificateBundles { fn as_ref(&self) -> &Vec { &self.0 } } impl AsRef> for SigningKeys { fn as_ref(&self) -> &OrderMap { &self.0 } } impl TryFrom for SigningKeys { type Error = anyhow::Error; fn try_from(value: String) -> Result { let content = { if value.starts_with("/") { // Verify file exists before reading if !std::path::Path::new(&value).exists() { return Err(ConfigError::SigningKeysFileNotFound(value).into()); } std::fs::read(&value).map_err(ConfigError::ReadSigningKeysFailed)? } else { general_purpose::STANDARD .decode(&value) .map_err(EncodingError::Base64DecodingFailed)? } }; // Validate content is not empty if content.is_empty() { return Err(ConfigError::EmptySigningKeysFile.into()); } // Parse JSON with proper error handling let jwks = serde_json::from_slice::(&content) .map_err(ConfigError::ParseSigningKeysFailed)?; // Validate JWKS contains keys if jwks.keys.is_empty() { return Err(ConfigError::MissingKeysInJWKS.into()); } // Track keys that failed validation for better error reporting let mut validation_errors = Vec::new(); let signing_keys = jwks .keys .iter() .filter_map(|key| { // Validate key has required fields if key.kid.is_none() { validation_errors.push("Missing key ID (kid)".to_string()); return None; } if let (Some(key_id), secret_key) = (key.kid.clone(), key.jwk.clone()) { // Verify the key_id format (should be a valid ULID) if ulid::Ulid::from_string(&key_id).is_err() { validation_errors.push(format!("Invalid key ID format: {}", key_id)); return None; } // Validate the secret key match p256::SecretKey::from_jwk(&secret_key) { Ok(secret_key) => Some((key_id, secret_key)), Err(err) => { validation_errors.push(format!("Invalid key {}: {}", key_id, err)); None } } } else { None } }) .collect::>(); // Check if we have any valid keys if signing_keys.is_empty() { if !validation_errors.is_empty() { return Err(ConfigError::SigningKeysValidationFailed(validation_errors).into()); } return Err(ConfigError::EmptySigningKeys.into()); } Ok(Self(signing_keys)) } } impl AsRef> for OAuthActiveKeys { fn as_ref(&self) -> &Vec { &self.0 } } impl TryFrom for OAuthActiveKeys { type Error = anyhow::Error; fn try_from(value: String) -> Result { let values = value .split(';') .map(|s| s.to_string()) .collect::>(); if values.is_empty() { return Err(ConfigError::EmptyOAuthActiveKeys.into()); } Ok(Self(values)) } } impl AsRef> for AdminDIDs { fn as_ref(&self) -> &Vec { &self.0 } } impl TryFrom for AdminDIDs { type Error = anyhow::Error; fn try_from(value: String) -> Result { // Allow empty value for no admins if value.is_empty() { return Ok(Self(Vec::new())); } let admin_dids = value .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::>(); Ok(Self(admin_dids)) } } impl AsRef> for DnsNameservers { fn as_ref(&self) -> &Vec { &self.0 } } impl TryFrom for DnsNameservers { type Error = anyhow::Error; fn try_from(value: String) -> Result { // Allow empty value for default DNS configuration if value.is_empty() { return Ok(Self(Vec::new())); } let nameservers = value .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| { s.parse::() .map_err(|e| ConfigError::NameserverParsingFailed(s.to_string(), e)) }) .collect::, ConfigError>>()?; Ok(Self(nameservers)) } }