//! Configuration management for QuickDID service //! //! This module handles all configuration parsing, validation, and error handling //! for the QuickDID AT Protocol identity resolution service. //! //! ## Configuration Sources //! //! Configuration is provided exclusively through environment variables following //! the 12-factor app methodology. //! //! ## Example //! //! ```bash //! # Minimal configuration //! HTTP_EXTERNAL=quickdid.example.com \ //! quickdid //! //! # Full configuration with Redis and custom settings //! HTTP_EXTERNAL=quickdid.example.com \ //! HTTP_PORT=3000 \ //! REDIS_URL=redis://localhost:6379 \ //! CACHE_TTL_MEMORY=300 \ //! CACHE_TTL_REDIS=86400 \ //! QUEUE_ADAPTER=redis \ //! QUEUE_REDIS_TIMEOUT=10 \ //! quickdid //! ``` use std::env; use thiserror::Error; /// Configuration-specific errors following the QuickDID error format /// /// All errors follow the pattern: `error-quickdid-config-{number} {message}: {details}` #[derive(Debug, Error)] pub enum ConfigError { /// Missing required environment variable or command-line argument /// /// Example: When HTTP_EXTERNAL is not provided #[error("error-quickdid-config-1 Missing required environment variable: {0}")] MissingRequired(String), /// Invalid configuration value that doesn't meet expected format or constraints /// /// Example: Invalid QUEUE_ADAPTER value (must be 'mpsc', 'redis', 'sqlite', 'noop', or 'none') #[error("error-quickdid-config-2 Invalid configuration value: {0}")] InvalidValue(String), /// Invalid TTL (Time To Live) value /// /// TTL values must be positive integers representing seconds #[error("error-quickdid-config-3 Invalid TTL value (must be positive): {0}")] InvalidTtl(String), /// Invalid timeout value /// /// Timeout values must be positive integers representing seconds #[error("error-quickdid-config-4 Invalid timeout value (must be positive): {0}")] InvalidTimeout(String), } /// Helper function to get an environment variable with an optional default fn get_env_or_default(key: &str, default: Option<&str>) -> Option { match env::var(key) { Ok(val) if !val.is_empty() => Some(val), _ => default.map(String::from), } } /// Helper function to parse an environment variable as a specific type fn parse_env(key: &str, default: T) -> Result where T::Err: std::fmt::Display, { match env::var(key) { Ok(val) if !val.is_empty() => val .parse::() .map_err(|e| ConfigError::InvalidValue(format!("{}: {}", key, e))), _ => Ok(default), } } /// Validated configuration for QuickDID service /// /// This struct contains all configuration after validation and processing. /// Use `Config::from_env()` to create from environment variables. /// /// ## Example /// /// ```rust,no_run /// use quickdid::config::Config; /// /// # fn main() -> Result<(), Box> { /// let config = Config::from_env()?; /// config.validate()?; /// /// println!("Service running at: {}", config.http_external); /// # Ok(()) /// # } /// ``` #[derive(Clone)] pub struct Config { /// HTTP server port (e.g., "8080", "3000") pub http_port: String, /// PLC directory hostname (e.g., "plc.directory") pub plc_hostname: String, /// External hostname for service endpoints (e.g., "quickdid.example.com") pub http_external: String, /// HTTP User-Agent for outgoing requests (e.g., "quickdid/1.0.0 (+https://...)") pub user_agent: String, /// Custom DNS nameservers, comma-separated (e.g., "8.8.8.8,8.8.4.4") pub dns_nameservers: Option, /// Additional CA certificate bundles, comma-separated paths pub certificate_bundles: Option, /// Redis URL for caching (e.g., "redis://localhost:6379/0") pub redis_url: Option, /// SQLite database URL for caching (e.g., "sqlite:./quickdid.db") pub sqlite_url: Option, /// Queue adapter type: "mpsc", "redis", "sqlite", or "noop" pub queue_adapter: String, /// Redis URL for queue operations (falls back to redis_url) pub queue_redis_url: Option, /// Redis key prefix for queues (e.g., "queue:handleresolver:") pub queue_redis_prefix: String, /// Worker ID for queue operations (defaults to "worker1") pub queue_worker_id: String, /// Buffer size for MPSC queue (e.g., 1000) pub queue_buffer_size: usize, /// TTL for in-memory cache in seconds (e.g., 600 = 10 minutes) pub cache_ttl_memory: u64, /// TTL for Redis cache in seconds (e.g., 7776000 = 90 days) pub cache_ttl_redis: u64, /// TTL for SQLite cache in seconds (e.g., 7776000 = 90 days) pub cache_ttl_sqlite: u64, /// Redis blocking timeout for queue operations in seconds (e.g., 5) pub queue_redis_timeout: u64, /// Enable deduplication for Redis queue to prevent duplicate handles /// Default: false pub queue_redis_dedup_enabled: bool, /// TTL for Redis queue deduplication keys in seconds /// Default: 60 (1 minute) pub queue_redis_dedup_ttl: u64, /// Maximum queue size for SQLite adapter work shedding (e.g., 10000) /// When exceeded, oldest entries are deleted to maintain this limit. /// Set to 0 to disable work shedding (unlimited queue size). pub queue_sqlite_max_size: u64, /// Maximum concurrent handle resolutions allowed (rate limiting). /// When set to > 0, enables rate limiting using a semaphore. /// Default: 0 (disabled) pub resolver_max_concurrent: usize, /// Timeout for acquiring rate limit permit in milliseconds. /// When set to > 0, requests will timeout if they can't acquire a permit within this time. /// Default: 0 (no timeout) pub resolver_max_concurrent_timeout_ms: u64, /// Seed value for ETAG generation to allow cache invalidation. /// This value is incorporated into ETAG checksums, allowing server admins /// to invalidate client-cached responses after major changes. /// Default: application version pub etag_seed: String, /// Maximum age for HTTP cache control in seconds. /// When set to 0, Cache-Control header is disabled. /// Default: 86400 (24 hours) pub cache_max_age: u64, /// Stale-if-error directive for Cache-Control in seconds. /// Allows stale content to be served if backend errors occur. /// Default: 172800 (48 hours) pub cache_stale_if_error: u64, /// Stale-while-revalidate directive for Cache-Control in seconds. /// Allows stale content to be served while fetching fresh content. /// Default: 86400 (24 hours) pub cache_stale_while_revalidate: u64, /// Max-stale directive for Cache-Control in seconds. /// Maximum time client will accept stale responses. /// Default: 172800 (48 hours) pub cache_max_stale: u64, /// Min-fresh directive for Cache-Control in seconds. /// Minimum time response must remain fresh. /// Default: 3600 (1 hour) pub cache_min_fresh: u64, /// Pre-calculated Cache-Control header value. /// Calculated at startup for efficiency. /// None if cache_max_age is 0 (disabled). pub cache_control_header: Option, /// Metrics adapter type: "noop" or "statsd" /// Default: "noop" (no metrics collection) pub metrics_adapter: String, /// StatsD host for metrics collection (e.g., "localhost:8125") /// Required when metrics_adapter is "statsd" pub metrics_statsd_host: Option, /// Bind address for StatsD UDP socket (e.g., "0.0.0.0:0" for IPv4 or "[::]:0" for IPv6) /// Default: "[::]:0" (IPv6 any address, random port) pub metrics_statsd_bind: String, /// Metrics prefix for all metrics (e.g., "quickdid") /// Default: "quickdid" pub metrics_prefix: String, /// Default tags for all metrics (comma-separated key:value pairs) /// Example: "env:production,service:quickdid" pub metrics_tags: Option, /// Enable proactive cache refresh for frequently accessed handles. /// When enabled, cache entries that have reached the refresh threshold /// will be queued for background refresh to keep the cache warm. /// Default: false pub proactive_refresh_enabled: bool, /// Threshold as a percentage (0.0-1.0) of cache TTL when to trigger proactive refresh. /// For example, 0.8 means refresh when an entry has lived for 80% of its TTL. /// Default: 0.8 (80%) pub proactive_refresh_threshold: f64, /// Directory path for serving static files. /// When set, the root handler will serve files from this directory. /// Default: "www" (relative to working directory) pub static_files_dir: String, /// Enable Jetstream consumer for AT Protocol events. /// When enabled, the service will consume Account and Identity events /// to maintain cache consistency. /// Default: false pub jetstream_enabled: bool, /// Jetstream WebSocket hostname for consuming AT Protocol events. /// Example: "jetstream.atproto.tools" or "jetstream1.us-west.bsky.network" /// Default: "jetstream.atproto.tools" pub jetstream_hostname: String, } impl Config { /// Create a validated Config from environment variables /// /// This method: /// 1. Reads configuration from environment variables /// 2. Validates required fields (HTTP_EXTERNAL) /// 3. Applies defaults where appropriate /// /// ## Example /// /// ```rust,no_run /// use quickdid::config::Config; /// /// # fn main() -> Result<(), Box> { /// // Parse from environment variables /// let config = Config::from_env()?; /// /// # Ok(()) /// # } /// ``` /// /// ## Errors /// /// Returns `ConfigError::MissingRequired` if: /// - HTTP_EXTERNAL is not provided pub fn from_env() -> Result { // Required fields let http_external = env::var("HTTP_EXTERNAL") .ok() .filter(|s| !s.is_empty()) .ok_or_else(|| ConfigError::MissingRequired("HTTP_EXTERNAL".to_string()))?; // Generate default user agent let default_user_agent = format!( "quickdid/{} (+https://github.com/smokesignal.events/quickdid)", env!("CARGO_PKG_VERSION") ); let mut config = Config { http_port: get_env_or_default("HTTP_PORT", Some("8080")).unwrap(), plc_hostname: get_env_or_default("PLC_HOSTNAME", Some("plc.directory")).unwrap(), http_external, user_agent: get_env_or_default("USER_AGENT", None).unwrap_or(default_user_agent), dns_nameservers: get_env_or_default("DNS_NAMESERVERS", None), certificate_bundles: get_env_or_default("CERTIFICATE_BUNDLES", None), redis_url: get_env_or_default("REDIS_URL", None), sqlite_url: get_env_or_default("SQLITE_URL", None), queue_adapter: get_env_or_default("QUEUE_ADAPTER", Some("mpsc")).unwrap(), queue_redis_url: get_env_or_default("QUEUE_REDIS_URL", None), queue_redis_prefix: get_env_or_default( "QUEUE_REDIS_PREFIX", Some("queue:handleresolver:"), ) .unwrap(), queue_worker_id: get_env_or_default("QUEUE_WORKER_ID", Some("worker1")).unwrap(), queue_buffer_size: parse_env("QUEUE_BUFFER_SIZE", 1000)?, cache_ttl_memory: parse_env("CACHE_TTL_MEMORY", 600)?, cache_ttl_redis: parse_env("CACHE_TTL_REDIS", 7776000)?, cache_ttl_sqlite: parse_env("CACHE_TTL_SQLITE", 7776000)?, queue_redis_timeout: parse_env("QUEUE_REDIS_TIMEOUT", 5)?, queue_redis_dedup_enabled: parse_env("QUEUE_REDIS_DEDUP_ENABLED", false)?, queue_redis_dedup_ttl: parse_env("QUEUE_REDIS_DEDUP_TTL", 60)?, queue_sqlite_max_size: parse_env("QUEUE_SQLITE_MAX_SIZE", 10000)?, resolver_max_concurrent: parse_env("RESOLVER_MAX_CONCURRENT", 0)?, resolver_max_concurrent_timeout_ms: parse_env("RESOLVER_MAX_CONCURRENT_TIMEOUT_MS", 0)?, etag_seed: get_env_or_default("ETAG_SEED", Some(env!("CARGO_PKG_VERSION"))).unwrap(), cache_max_age: parse_env("CACHE_MAX_AGE", 86400)?, // 24 hours cache_stale_if_error: parse_env("CACHE_STALE_IF_ERROR", 172800)?, // 48 hours cache_stale_while_revalidate: parse_env("CACHE_STALE_WHILE_REVALIDATE", 86400)?, // 24 hours cache_max_stale: parse_env("CACHE_MAX_STALE", 172800)?, // 48 hours cache_min_fresh: parse_env("CACHE_MIN_FRESH", 3600)?, // 1 hour cache_control_header: None, // Will be calculated below metrics_adapter: get_env_or_default("METRICS_ADAPTER", Some("noop")).unwrap(), metrics_statsd_host: get_env_or_default("METRICS_STATSD_HOST", None), metrics_statsd_bind: get_env_or_default("METRICS_STATSD_BIND", Some("[::]:0")).unwrap(), metrics_prefix: get_env_or_default("METRICS_PREFIX", Some("quickdid")).unwrap(), metrics_tags: get_env_or_default("METRICS_TAGS", None), proactive_refresh_enabled: parse_env("PROACTIVE_REFRESH_ENABLED", false)?, proactive_refresh_threshold: parse_env("PROACTIVE_REFRESH_THRESHOLD", 0.8)?, static_files_dir: get_env_or_default("STATIC_FILES_DIR", Some("www")).unwrap(), jetstream_enabled: parse_env("JETSTREAM_ENABLED", false)?, jetstream_hostname: get_env_or_default( "JETSTREAM_HOSTNAME", Some("jetstream.atproto.tools"), ) .unwrap(), }; // Calculate the Cache-Control header value if enabled config.cache_control_header = config.calculate_cache_control_header(); Ok(config) } /// Calculate the Cache-Control header value based on configuration. /// Returns None if cache_max_age is 0 (disabled). fn calculate_cache_control_header(&self) -> Option { if self.cache_max_age == 0 { return None; } Some(format!( "public, max-age={}, stale-while-revalidate={}, stale-if-error={}, max-stale={}, min-fresh={}", self.cache_max_age, self.cache_stale_while_revalidate, self.cache_stale_if_error, self.cache_max_stale, self.cache_min_fresh )) } /// Validate the configuration for correctness and consistency /// /// Checks: /// - Cache TTL values are positive (> 0) /// - Queue timeout is positive (> 0) /// - Queue adapter is a valid value ('mpsc', 'redis', 'sqlite', 'noop', 'none') /// /// ## Example /// /// ```rust,no_run /// # use quickdid::config::Config; /// # fn main() -> Result<(), Box> { /// let config = Config::from_env()?; /// config.validate()?; // Ensures all values are valid /// # Ok(()) /// # } /// ``` /// /// ## Errors /// /// Returns `ConfigError::InvalidTtl` if TTL values are 0 or negative /// Returns `ConfigError::InvalidTimeout` if timeout values are 0 or negative /// Returns `ConfigError::InvalidValue` if queue adapter is invalid pub fn validate(&self) -> Result<(), ConfigError> { if self.cache_ttl_memory == 0 { return Err(ConfigError::InvalidTtl( "CACHE_TTL_MEMORY must be > 0".to_string(), )); } if self.cache_ttl_redis == 0 { return Err(ConfigError::InvalidTtl( "CACHE_TTL_REDIS must be > 0".to_string(), )); } if self.cache_ttl_sqlite == 0 { return Err(ConfigError::InvalidTtl( "CACHE_TTL_SQLITE must be > 0".to_string(), )); } if self.queue_redis_timeout == 0 { return Err(ConfigError::InvalidTimeout( "QUEUE_REDIS_TIMEOUT must be > 0".to_string(), )); } if self.queue_redis_dedup_enabled && self.queue_redis_dedup_ttl == 0 { return Err(ConfigError::InvalidTtl( "QUEUE_REDIS_DEDUP_TTL must be > 0 when deduplication is enabled".to_string(), )); } match self.queue_adapter.as_str() { "mpsc" | "redis" | "sqlite" | "noop" | "none" => {} _ => { return Err(ConfigError::InvalidValue(format!( "Invalid QUEUE_ADAPTER '{}', must be 'mpsc', 'redis', 'sqlite', 'noop', or 'none'", self.queue_adapter ))); } } if self.resolver_max_concurrent > 10000 { return Err(ConfigError::InvalidValue( "RESOLVER_MAX_CONCURRENT must be between 0 and 10000".to_string(), )); } if self.resolver_max_concurrent_timeout_ms > 60000 { return Err(ConfigError::InvalidTimeout( "RESOLVER_MAX_CONCURRENT_TIMEOUT_MS must be <= 60000 (60 seconds)".to_string(), )); } // Validate metrics configuration match self.metrics_adapter.as_str() { "noop" | "statsd" => {} _ => { return Err(ConfigError::InvalidValue(format!( "Invalid METRICS_ADAPTER '{}', must be 'noop' or 'statsd'", self.metrics_adapter ))); } } // If statsd is configured, ensure host is provided if self.metrics_adapter == "statsd" && self.metrics_statsd_host.is_none() { return Err(ConfigError::MissingRequired( "METRICS_STATSD_HOST is required when METRICS_ADAPTER is 'statsd'".to_string(), )); } // Validate proactive refresh threshold if self.proactive_refresh_threshold < 0.0 || self.proactive_refresh_threshold > 1.0 { return Err(ConfigError::InvalidValue(format!( "PROACTIVE_REFRESH_THRESHOLD must be between 0.0 and 1.0, got {}", self.proactive_refresh_threshold ))); } Ok(()) } }