QuickDID is a high-performance AT Protocol identity resolution service written in Rust. It provides handle-to-DID resolution with Redis-backed caching and queue processing.
at main 469 lines 18 kB view raw
1//! Configuration management for QuickDID service 2//! 3//! This module handles all configuration parsing, validation, and error handling 4//! for the QuickDID AT Protocol identity resolution service. 5//! 6//! ## Configuration Sources 7//! 8//! Configuration is provided exclusively through environment variables following 9//! the 12-factor app methodology. 10//! 11//! ## Example 12//! 13//! ```bash 14//! # Minimal configuration 15//! HTTP_EXTERNAL=quickdid.example.com \ 16//! quickdid 17//! 18//! # Full configuration with Redis and custom settings 19//! HTTP_EXTERNAL=quickdid.example.com \ 20//! HTTP_PORT=3000 \ 21//! REDIS_URL=redis://localhost:6379 \ 22//! CACHE_TTL_MEMORY=300 \ 23//! CACHE_TTL_REDIS=86400 \ 24//! QUEUE_ADAPTER=redis \ 25//! QUEUE_REDIS_TIMEOUT=10 \ 26//! quickdid 27//! ``` 28 29use std::env; 30use thiserror::Error; 31 32/// Configuration-specific errors following the QuickDID error format 33/// 34/// All errors follow the pattern: `error-quickdid-config-{number} {message}: {details}` 35#[derive(Debug, Error)] 36pub enum ConfigError { 37 /// Missing required environment variable or command-line argument 38 /// 39 /// Example: When HTTP_EXTERNAL is not provided 40 #[error("error-quickdid-config-1 Missing required environment variable: {0}")] 41 MissingRequired(String), 42 43 /// Invalid configuration value that doesn't meet expected format or constraints 44 /// 45 /// Example: Invalid QUEUE_ADAPTER value (must be 'mpsc', 'redis', 'sqlite', 'noop', or 'none') 46 #[error("error-quickdid-config-2 Invalid configuration value: {0}")] 47 InvalidValue(String), 48 49 /// Invalid TTL (Time To Live) value 50 /// 51 /// TTL values must be positive integers representing seconds 52 #[error("error-quickdid-config-3 Invalid TTL value (must be positive): {0}")] 53 InvalidTtl(String), 54 55 /// Invalid timeout value 56 /// 57 /// Timeout values must be positive integers representing seconds 58 #[error("error-quickdid-config-4 Invalid timeout value (must be positive): {0}")] 59 InvalidTimeout(String), 60} 61 62/// Helper function to get an environment variable with an optional default 63fn get_env_or_default(key: &str, default: Option<&str>) -> Option<String> { 64 match env::var(key) { 65 Ok(val) if !val.is_empty() => Some(val), 66 _ => default.map(String::from), 67 } 68} 69 70/// Helper function to parse an environment variable as a specific type 71fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, ConfigError> 72where 73 T::Err: std::fmt::Display, 74{ 75 match env::var(key) { 76 Ok(val) if !val.is_empty() => val 77 .parse::<T>() 78 .map_err(|e| ConfigError::InvalidValue(format!("{}: {}", key, e))), 79 _ => Ok(default), 80 } 81} 82 83/// Validated configuration for QuickDID service 84/// 85/// This struct contains all configuration after validation and processing. 86/// Use `Config::from_env()` to create from environment variables. 87/// 88/// ## Example 89/// 90/// ```rust,no_run 91/// use quickdid::config::Config; 92/// 93/// # fn main() -> Result<(), Box<dyn std::error::Error>> { 94/// let config = Config::from_env()?; 95/// config.validate()?; 96/// 97/// println!("Service running at: {}", config.http_external); 98/// # Ok(()) 99/// # } 100/// ``` 101#[derive(Clone)] 102pub struct Config { 103 /// HTTP server port (e.g., "8080", "3000") 104 pub http_port: String, 105 106 /// PLC directory hostname (e.g., "plc.directory") 107 pub plc_hostname: String, 108 109 /// External hostname for service endpoints (e.g., "quickdid.example.com") 110 pub http_external: String, 111 112 /// HTTP User-Agent for outgoing requests (e.g., "quickdid/1.0.0 (+https://...)") 113 pub user_agent: String, 114 115 /// Custom DNS nameservers, comma-separated (e.g., "8.8.8.8,8.8.4.4") 116 pub dns_nameservers: Option<String>, 117 118 /// Additional CA certificate bundles, comma-separated paths 119 pub certificate_bundles: Option<String>, 120 121 /// Redis URL for caching (e.g., "redis://localhost:6379/0") 122 pub redis_url: Option<String>, 123 124 /// SQLite database URL for caching (e.g., "sqlite:./quickdid.db") 125 pub sqlite_url: Option<String>, 126 127 /// Queue adapter type: "mpsc", "redis", "sqlite", or "noop" 128 pub queue_adapter: String, 129 130 /// Redis URL for queue operations (falls back to redis_url) 131 pub queue_redis_url: Option<String>, 132 133 /// Redis key prefix for queues (e.g., "queue:handleresolver:") 134 pub queue_redis_prefix: String, 135 136 /// Worker ID for queue operations (defaults to "worker1") 137 pub queue_worker_id: String, 138 139 /// Buffer size for MPSC queue (e.g., 1000) 140 pub queue_buffer_size: usize, 141 142 /// TTL for in-memory cache in seconds (e.g., 600 = 10 minutes) 143 pub cache_ttl_memory: u64, 144 145 /// TTL for Redis cache in seconds (e.g., 7776000 = 90 days) 146 pub cache_ttl_redis: u64, 147 148 /// TTL for SQLite cache in seconds (e.g., 7776000 = 90 days) 149 pub cache_ttl_sqlite: u64, 150 151 /// Redis blocking timeout for queue operations in seconds (e.g., 5) 152 pub queue_redis_timeout: u64, 153 154 /// Enable deduplication for Redis queue to prevent duplicate handles 155 /// Default: false 156 pub queue_redis_dedup_enabled: bool, 157 158 /// TTL for Redis queue deduplication keys in seconds 159 /// Default: 60 (1 minute) 160 pub queue_redis_dedup_ttl: u64, 161 162 /// Maximum queue size for SQLite adapter work shedding (e.g., 10000) 163 /// When exceeded, oldest entries are deleted to maintain this limit. 164 /// Set to 0 to disable work shedding (unlimited queue size). 165 pub queue_sqlite_max_size: u64, 166 167 /// Maximum concurrent handle resolutions allowed (rate limiting). 168 /// When set to > 0, enables rate limiting using a semaphore. 169 /// Default: 0 (disabled) 170 pub resolver_max_concurrent: usize, 171 172 /// Timeout for acquiring rate limit permit in milliseconds. 173 /// When set to > 0, requests will timeout if they can't acquire a permit within this time. 174 /// Default: 0 (no timeout) 175 pub resolver_max_concurrent_timeout_ms: u64, 176 177 /// Seed value for ETAG generation to allow cache invalidation. 178 /// This value is incorporated into ETAG checksums, allowing server admins 179 /// to invalidate client-cached responses after major changes. 180 /// Default: application version 181 pub etag_seed: String, 182 183 /// Maximum age for HTTP cache control in seconds. 184 /// When set to 0, Cache-Control header is disabled. 185 /// Default: 86400 (24 hours) 186 pub cache_max_age: u64, 187 188 /// Stale-if-error directive for Cache-Control in seconds. 189 /// Allows stale content to be served if backend errors occur. 190 /// Default: 172800 (48 hours) 191 pub cache_stale_if_error: u64, 192 193 /// Stale-while-revalidate directive for Cache-Control in seconds. 194 /// Allows stale content to be served while fetching fresh content. 195 /// Default: 86400 (24 hours) 196 pub cache_stale_while_revalidate: u64, 197 198 /// Max-stale directive for Cache-Control in seconds. 199 /// Maximum time client will accept stale responses. 200 /// Default: 172800 (48 hours) 201 pub cache_max_stale: u64, 202 203 /// Min-fresh directive for Cache-Control in seconds. 204 /// Minimum time response must remain fresh. 205 /// Default: 3600 (1 hour) 206 pub cache_min_fresh: u64, 207 208 /// Pre-calculated Cache-Control header value. 209 /// Calculated at startup for efficiency. 210 /// None if cache_max_age is 0 (disabled). 211 pub cache_control_header: Option<String>, 212 213 /// Metrics adapter type: "noop" or "statsd" 214 /// Default: "noop" (no metrics collection) 215 pub metrics_adapter: String, 216 217 /// StatsD host for metrics collection (e.g., "localhost:8125") 218 /// Required when metrics_adapter is "statsd" 219 pub metrics_statsd_host: Option<String>, 220 221 /// Bind address for StatsD UDP socket (e.g., "0.0.0.0:0" for IPv4 or "[::]:0" for IPv6) 222 /// Default: "[::]:0" (IPv6 any address, random port) 223 pub metrics_statsd_bind: String, 224 225 /// Metrics prefix for all metrics (e.g., "quickdid") 226 /// Default: "quickdid" 227 pub metrics_prefix: String, 228 229 /// Default tags for all metrics (comma-separated key:value pairs) 230 /// Example: "env:production,service:quickdid" 231 pub metrics_tags: Option<String>, 232 233 /// Enable proactive cache refresh for frequently accessed handles. 234 /// When enabled, cache entries that have reached the refresh threshold 235 /// will be queued for background refresh to keep the cache warm. 236 /// Default: false 237 pub proactive_refresh_enabled: bool, 238 239 /// Threshold as a percentage (0.0-1.0) of cache TTL when to trigger proactive refresh. 240 /// For example, 0.8 means refresh when an entry has lived for 80% of its TTL. 241 /// Default: 0.8 (80%) 242 pub proactive_refresh_threshold: f64, 243 244 /// Directory path for serving static files. 245 /// When set, the root handler will serve files from this directory. 246 /// Default: "www" (relative to working directory) 247 pub static_files_dir: String, 248 249 /// Enable Jetstream consumer for AT Protocol events. 250 /// When enabled, the service will consume Account and Identity events 251 /// to maintain cache consistency. 252 /// Default: false 253 pub jetstream_enabled: bool, 254 255 /// Jetstream WebSocket hostname for consuming AT Protocol events. 256 /// Example: "jetstream.atproto.tools" or "jetstream1.us-west.bsky.network" 257 /// Default: "jetstream.atproto.tools" 258 pub jetstream_hostname: String, 259} 260 261impl Config { 262 /// Create a validated Config from environment variables 263 /// 264 /// This method: 265 /// 1. Reads configuration from environment variables 266 /// 2. Validates required fields (HTTP_EXTERNAL) 267 /// 3. Applies defaults where appropriate 268 /// 269 /// ## Example 270 /// 271 /// ```rust,no_run 272 /// use quickdid::config::Config; 273 /// 274 /// # fn main() -> Result<(), Box<dyn std::error::Error>> { 275 /// // Parse from environment variables 276 /// let config = Config::from_env()?; 277 /// 278 /// # Ok(()) 279 /// # } 280 /// ``` 281 /// 282 /// ## Errors 283 /// 284 /// Returns `ConfigError::MissingRequired` if: 285 /// - HTTP_EXTERNAL is not provided 286 pub fn from_env() -> Result<Self, ConfigError> { 287 // Required fields 288 let http_external = env::var("HTTP_EXTERNAL") 289 .ok() 290 .filter(|s| !s.is_empty()) 291 .ok_or_else(|| ConfigError::MissingRequired("HTTP_EXTERNAL".to_string()))?; 292 293 // Generate default user agent 294 let default_user_agent = format!( 295 "quickdid/{} (+https://github.com/smokesignal.events/quickdid)", 296 env!("CARGO_PKG_VERSION") 297 ); 298 299 let mut config = Config { 300 http_port: get_env_or_default("HTTP_PORT", Some("8080")).unwrap(), 301 plc_hostname: get_env_or_default("PLC_HOSTNAME", Some("plc.directory")).unwrap(), 302 http_external, 303 user_agent: get_env_or_default("USER_AGENT", None).unwrap_or(default_user_agent), 304 dns_nameservers: get_env_or_default("DNS_NAMESERVERS", None), 305 certificate_bundles: get_env_or_default("CERTIFICATE_BUNDLES", None), 306 redis_url: get_env_or_default("REDIS_URL", None), 307 sqlite_url: get_env_or_default("SQLITE_URL", None), 308 queue_adapter: get_env_or_default("QUEUE_ADAPTER", Some("mpsc")).unwrap(), 309 queue_redis_url: get_env_or_default("QUEUE_REDIS_URL", None), 310 queue_redis_prefix: get_env_or_default( 311 "QUEUE_REDIS_PREFIX", 312 Some("queue:handleresolver:"), 313 ) 314 .unwrap(), 315 queue_worker_id: get_env_or_default("QUEUE_WORKER_ID", Some("worker1")).unwrap(), 316 queue_buffer_size: parse_env("QUEUE_BUFFER_SIZE", 1000)?, 317 cache_ttl_memory: parse_env("CACHE_TTL_MEMORY", 600)?, 318 cache_ttl_redis: parse_env("CACHE_TTL_REDIS", 7776000)?, 319 cache_ttl_sqlite: parse_env("CACHE_TTL_SQLITE", 7776000)?, 320 queue_redis_timeout: parse_env("QUEUE_REDIS_TIMEOUT", 5)?, 321 queue_redis_dedup_enabled: parse_env("QUEUE_REDIS_DEDUP_ENABLED", false)?, 322 queue_redis_dedup_ttl: parse_env("QUEUE_REDIS_DEDUP_TTL", 60)?, 323 queue_sqlite_max_size: parse_env("QUEUE_SQLITE_MAX_SIZE", 10000)?, 324 resolver_max_concurrent: parse_env("RESOLVER_MAX_CONCURRENT", 0)?, 325 resolver_max_concurrent_timeout_ms: parse_env("RESOLVER_MAX_CONCURRENT_TIMEOUT_MS", 0)?, 326 etag_seed: get_env_or_default("ETAG_SEED", Some(env!("CARGO_PKG_VERSION"))).unwrap(), 327 cache_max_age: parse_env("CACHE_MAX_AGE", 86400)?, // 24 hours 328 cache_stale_if_error: parse_env("CACHE_STALE_IF_ERROR", 172800)?, // 48 hours 329 cache_stale_while_revalidate: parse_env("CACHE_STALE_WHILE_REVALIDATE", 86400)?, // 24 hours 330 cache_max_stale: parse_env("CACHE_MAX_STALE", 172800)?, // 48 hours 331 cache_min_fresh: parse_env("CACHE_MIN_FRESH", 3600)?, // 1 hour 332 cache_control_header: None, // Will be calculated below 333 metrics_adapter: get_env_or_default("METRICS_ADAPTER", Some("noop")).unwrap(), 334 metrics_statsd_host: get_env_or_default("METRICS_STATSD_HOST", None), 335 metrics_statsd_bind: get_env_or_default("METRICS_STATSD_BIND", Some("[::]:0")).unwrap(), 336 metrics_prefix: get_env_or_default("METRICS_PREFIX", Some("quickdid")).unwrap(), 337 metrics_tags: get_env_or_default("METRICS_TAGS", None), 338 proactive_refresh_enabled: parse_env("PROACTIVE_REFRESH_ENABLED", false)?, 339 proactive_refresh_threshold: parse_env("PROACTIVE_REFRESH_THRESHOLD", 0.8)?, 340 static_files_dir: get_env_or_default("STATIC_FILES_DIR", Some("www")).unwrap(), 341 jetstream_enabled: parse_env("JETSTREAM_ENABLED", false)?, 342 jetstream_hostname: get_env_or_default( 343 "JETSTREAM_HOSTNAME", 344 Some("jetstream.atproto.tools"), 345 ) 346 .unwrap(), 347 }; 348 349 // Calculate the Cache-Control header value if enabled 350 config.cache_control_header = config.calculate_cache_control_header(); 351 352 Ok(config) 353 } 354 355 /// Calculate the Cache-Control header value based on configuration. 356 /// Returns None if cache_max_age is 0 (disabled). 357 fn calculate_cache_control_header(&self) -> Option<String> { 358 if self.cache_max_age == 0 { 359 return None; 360 } 361 362 Some(format!( 363 "public, max-age={}, stale-while-revalidate={}, stale-if-error={}, max-stale={}, min-fresh={}", 364 self.cache_max_age, 365 self.cache_stale_while_revalidate, 366 self.cache_stale_if_error, 367 self.cache_max_stale, 368 self.cache_min_fresh 369 )) 370 } 371 372 /// Validate the configuration for correctness and consistency 373 /// 374 /// Checks: 375 /// - Cache TTL values are positive (> 0) 376 /// - Queue timeout is positive (> 0) 377 /// - Queue adapter is a valid value ('mpsc', 'redis', 'sqlite', 'noop', 'none') 378 /// 379 /// ## Example 380 /// 381 /// ```rust,no_run 382 /// # use quickdid::config::Config; 383 /// # fn main() -> Result<(), Box<dyn std::error::Error>> { 384 /// let config = Config::from_env()?; 385 /// config.validate()?; // Ensures all values are valid 386 /// # Ok(()) 387 /// # } 388 /// ``` 389 /// 390 /// ## Errors 391 /// 392 /// Returns `ConfigError::InvalidTtl` if TTL values are 0 or negative 393 /// Returns `ConfigError::InvalidTimeout` if timeout values are 0 or negative 394 /// Returns `ConfigError::InvalidValue` if queue adapter is invalid 395 pub fn validate(&self) -> Result<(), ConfigError> { 396 if self.cache_ttl_memory == 0 { 397 return Err(ConfigError::InvalidTtl( 398 "CACHE_TTL_MEMORY must be > 0".to_string(), 399 )); 400 } 401 if self.cache_ttl_redis == 0 { 402 return Err(ConfigError::InvalidTtl( 403 "CACHE_TTL_REDIS must be > 0".to_string(), 404 )); 405 } 406 if self.cache_ttl_sqlite == 0 { 407 return Err(ConfigError::InvalidTtl( 408 "CACHE_TTL_SQLITE must be > 0".to_string(), 409 )); 410 } 411 if self.queue_redis_timeout == 0 { 412 return Err(ConfigError::InvalidTimeout( 413 "QUEUE_REDIS_TIMEOUT must be > 0".to_string(), 414 )); 415 } 416 if self.queue_redis_dedup_enabled && self.queue_redis_dedup_ttl == 0 { 417 return Err(ConfigError::InvalidTtl( 418 "QUEUE_REDIS_DEDUP_TTL must be > 0 when deduplication is enabled".to_string(), 419 )); 420 } 421 match self.queue_adapter.as_str() { 422 "mpsc" | "redis" | "sqlite" | "noop" | "none" => {} 423 _ => { 424 return Err(ConfigError::InvalidValue(format!( 425 "Invalid QUEUE_ADAPTER '{}', must be 'mpsc', 'redis', 'sqlite', 'noop', or 'none'", 426 self.queue_adapter 427 ))); 428 } 429 } 430 if self.resolver_max_concurrent > 10000 { 431 return Err(ConfigError::InvalidValue( 432 "RESOLVER_MAX_CONCURRENT must be between 0 and 10000".to_string(), 433 )); 434 } 435 if self.resolver_max_concurrent_timeout_ms > 60000 { 436 return Err(ConfigError::InvalidTimeout( 437 "RESOLVER_MAX_CONCURRENT_TIMEOUT_MS must be <= 60000 (60 seconds)".to_string(), 438 )); 439 } 440 441 // Validate metrics configuration 442 match self.metrics_adapter.as_str() { 443 "noop" | "statsd" => {} 444 _ => { 445 return Err(ConfigError::InvalidValue(format!( 446 "Invalid METRICS_ADAPTER '{}', must be 'noop' or 'statsd'", 447 self.metrics_adapter 448 ))); 449 } 450 } 451 452 // If statsd is configured, ensure host is provided 453 if self.metrics_adapter == "statsd" && self.metrics_statsd_host.is_none() { 454 return Err(ConfigError::MissingRequired( 455 "METRICS_STATSD_HOST is required when METRICS_ADAPTER is 'statsd'".to_string(), 456 )); 457 } 458 459 // Validate proactive refresh threshold 460 if self.proactive_refresh_threshold < 0.0 || self.proactive_refresh_threshold > 1.0 { 461 return Err(ConfigError::InvalidValue(format!( 462 "PROACTIVE_REFRESH_THRESHOLD must be between 0.0 and 1.0, got {}", 463 self.proactive_refresh_threshold 464 ))); 465 } 466 467 Ok(()) 468 } 469}