forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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}