config.rs
490 lignes · 18657 octets
//! Runtime configuration for ActivityPub identity federation (RFC 0001). //! //! It loads federation parameters from environment variables at startup and //! is consumed by the federation router builder to wire //! the federation router and outbound worker into the Axum application. //! //! See [`docs/rfcs/0001-fediverse-config-and-builder.md`](https://github.com/EBO/rustwarden/blob/main/docs/rfcs/0001-fediverse-config-and-builder.md) //! for the full specification. use std::time::Duration; use url::Url; /// Runtime configuration for ActivityPub identity federation. /// /// Built from environment variables via [`FediverseConfig::from_env`]. Validated /// at construction: missing or placeholder domains, invalid URLs, and HTTP base /// URLs in production are rejected eagerly. /// /// # Example /// /// ```rust,ignore /// use rustwarden_core::config::FediverseConfig; /// /// // SAFETY: doctest fixture — values are stable test constants and the /// // doctest binary serialises env access through cargo's test harness. /// unsafe { /// std::env::set_var("FEDIVERSE_DOMAIN", "example.org"); /// } /// /// let config = FediverseConfig::from_env().expect("valid config"); /// assert_eq!(config.domain(), "example.org"); /// assert_eq!(config.base_url().as_str(), "https://example.org/"); /// ``` #[derive(Debug, Clone)] pub struct FediverseConfig { domain: String, base_url: Url, user_agent: String, authorized_fetch: bool, outbound_workers: usize, outbound_max_attempts: u32, outbound_request_timeout: Duration, fetch_actor_cache_ttl: Duration, auth_challenge_ttl: Duration, debug_local_federation: bool, } /// Errors that can occur while loading or validating [`FediverseConfig`]. #[derive(Debug, thiserror::Error)] pub enum FediverseConfigError { /// `FEDIVERSE_DOMAIN` environment variable is missing or empty. #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")] MissingDomain, /// `FEDIVERSE_DOMAIN` value is a known placeholder /// (e.g. `example.com`, `localhost.localdomain`, `your.tld`). /// Refused regardless of debug mode to prevent accidental production use. #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")] PlaceholderDomain(String), /// Computed base URL is not HTTPS while `debug_local_federation` is off. #[error( "base URL `{0}` must use HTTPS in production (set FEDIVERSE_DEBUG_LOCAL_FEDERATION=true for local tests)" )] InsecureBaseUrl(String), /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host. #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")] InvalidDomain(String, String), /// A numeric environment variable could not be parsed. #[error("environment variable `{name}` has an invalid value `{value}`: {reason}")] InvalidNumeric { /// Name of the offending environment variable. name: &'static str, /// Raw value that failed to parse. value: String, /// Underlying parse error description. reason: String, }, } impl FediverseConfig { /// Load and validate a [`FediverseConfig`] from environment variables. /// /// # Required variables /// /// - `FEDIVERSE_DOMAIN` — federated domain, no scheme, no path /// (e.g. `example.org`). /// /// # Optional variables (defaults shown) /// /// | Variable | Default | /// |---|---| /// | `FEDIVERSE_AUTHORIZED_FETCH` | `false` | /// | `FEDIVERSE_OUTBOUND_WORKERS` | `4` | /// | `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | `8` | /// | `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | `30` | /// | `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | `86400` (24 h) | /// | `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | `300` (5 min) | /// | `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | `false` | /// /// When `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`, HTTP base URLs are /// accepted (for local 2-instance integration tests). In production this /// flag must remain `false` and the resulting base URL must be HTTPS. /// /// # Errors /// /// See [`FediverseConfigError`] for the full failure taxonomy. pub fn from_env() -> Result<Self, FediverseConfigError> { let raw_domain = std::env::var("FEDIVERSE_DOMAIN") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .ok_or(FediverseConfigError::MissingDomain)?; if is_placeholder_domain(&raw_domain) { return Err(FediverseConfigError::PlaceholderDomain(raw_domain)); } let debug_local_federation = read_bool_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", false)?; // Accept either a bare domain ("example.org") or a fully-qualified URL // ("http://localhost:3000"). The bare form always becomes HTTPS; the // explicit form is only honoured under debug mode. let (scheme, host_part) = if let Some(rest) = raw_domain.strip_prefix("https://") { ("https", rest.trim_end_matches('/').to_string()) } else if let Some(rest) = raw_domain.strip_prefix("http://") { ("http", rest.trim_end_matches('/').to_string()) } else { ("https", raw_domain.trim_end_matches('/').to_string()) }; if scheme == "http" && !debug_local_federation { return Err(FediverseConfigError::InsecureBaseUrl(format!( "http://{host_part}" ))); } let base_url_str = format!("{scheme}://{host_part}/"); let base_url = Url::parse(&base_url_str) .map_err(|e| FediverseConfigError::InvalidDomain(host_part.clone(), e.to_string()))?; let authorized_fetch = read_bool_env("FEDIVERSE_AUTHORIZED_FETCH", false)?; let outbound_workers = read_numeric_env("FEDIVERSE_OUTBOUND_WORKERS", 4)?; let outbound_max_attempts = read_numeric_env("FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", 8)?; let outbound_request_timeout = Duration::from_secs(read_numeric_env( "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", 30, )?); let fetch_actor_cache_ttl = Duration::from_secs(read_numeric_env( "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS", 86_400, )?); let auth_challenge_ttl = Duration::from_secs(read_numeric_env("FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", 300)?); let user_agent = format!("rustwarden/{} (+{})", env!("CARGO_PKG_VERSION"), base_url); Ok(Self { domain: host_part, base_url, user_agent, authorized_fetch, outbound_workers, outbound_max_attempts, outbound_request_timeout, fetch_actor_cache_ttl, auth_challenge_ttl, debug_local_federation, }) } /// Federated domain (no scheme, no trailing slash). Example: `example.org`. pub fn domain(&self) -> &str { &self.domain } /// Canonical base URL with trailing slash. Example: `https://example.org/`. pub fn base_url(&self) -> &Url { &self.base_url } /// HTTP `User-Agent` to send on outbound federation requests. pub fn user_agent(&self) -> &str { &self.user_agent } /// Whether incoming `GET` requests on actor/object endpoints must be /// signed (Mastodon "secure mode" / Authorized Fetch). pub fn authorized_fetch(&self) -> bool { self.authorized_fetch } /// Number of concurrent worker tasks delivering outbound activities. pub fn outbound_workers(&self) -> usize { self.outbound_workers } /// Maximum delivery attempts before a job is marked `dead`. pub fn outbound_max_attempts(&self) -> u32 { self.outbound_max_attempts } /// Per-request HTTP timeout for outbound deliveries. pub fn outbound_request_timeout(&self) -> Duration { self.outbound_request_timeout } /// TTL for cached remote actor documents. pub fn fetch_actor_cache_ttl(&self) -> Duration { self.fetch_actor_cache_ttl } /// TTL for federated authentication challenges (cf. Phase 2). pub fn auth_challenge_ttl(&self) -> Duration { self.auth_challenge_ttl } /// When `true`, HTTP base URLs are allowed (local 2-instance tests). pub fn debug_local_federation(&self) -> bool { self.debug_local_federation } } /// Hostnames that almost certainly indicate the operator forgot to override /// the placeholder (CLI wizard templates, canonical RFC examples). Refused /// regardless of debug mode. /// /// `example.org` is intentionally **not** included: it is a real IANA-owned /// domain commonly used in tests, doctests, and demos for this framework. const PLACEHOLDER_DOMAINS: &[&str] = &[ "example.com", "example.net", "localhost.localdomain", "your.tld", "your-domain.tld", "domain.tld", "change-me.tld", ]; fn is_placeholder_domain(raw: &str) -> bool { let lowered = raw.to_ascii_lowercase(); let stripped = lowered .trim_start_matches("http://") .trim_start_matches("https://") .trim_end_matches('/'); PLACEHOLDER_DOMAINS.contains(&stripped) } fn read_bool_env(key: &'static str, default: bool) -> Result<bool, FediverseConfigError> { match std::env::var(key) { Ok(value) => match value.trim().to_ascii_lowercase().as_str() { "" => Ok(default), "true" | "1" | "yes" | "on" => Ok(true), "false" | "0" | "no" | "off" => Ok(false), other => Err(FediverseConfigError::InvalidNumeric { name: key, value: other.to_string(), reason: "expected boolean (true/false/1/0/yes/no/on/off)".into(), }), }, Err(_) => Ok(default), } } fn read_numeric_env<T>(key: &'static str, default: T) -> Result<T, FediverseConfigError> where T: std::str::FromStr, T::Err: std::fmt::Display, { match std::env::var(key) { Ok(value) => { let trimmed = value.trim(); if trimmed.is_empty() { return Ok(default); } trimmed .parse::<T>() .map_err(|e| FediverseConfigError::InvalidNumeric { name: key, value: trimmed.to_string(), reason: e.to_string(), }) } Err(_) => Ok(default), } } #[cfg(test)] impl FediverseConfig { /// Build a deterministic config for unit tests, bypassing env-var loading. /// Always HTTPS (`https://{domain}/`), all numeric defaults applied, /// `debug_local_federation = false`. pub(crate) fn for_tests(domain: &str) -> Self { let base_url = Url::parse(&format!("https://{domain}/")) .expect("test domain must produce a valid URL"); Self { domain: domain.to_string(), user_agent: format!("rustwarden/test (+{base_url})"), base_url, authorized_fetch: false, outbound_workers: 4, outbound_max_attempts: 8, outbound_request_timeout: Duration::from_secs(30), fetch_actor_cache_ttl: Duration::from_secs(86_400), auth_challenge_ttl: Duration::from_secs(300), debug_local_federation: false, } } } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; /// Serialises env-var mutations across tests in this module. Each test /// holds the lock for its full duration and clears the FEDIVERSE_* keyspace /// before setting fixtures, making tests order-independent. static ENV_LOCK: Mutex<()> = Mutex::new(()); /// Reset every FEDIVERSE_* environment variable so the next test starts /// from a known empty state. fn clear_fediverse_env() { for key in [ "FEDIVERSE_DOMAIN", "FEDIVERSE_AUTHORIZED_FETCH", "FEDIVERSE_OUTBOUND_WORKERS", "FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS", "FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", "FEDIVERSE_DEBUG_LOCAL_FEDERATION", ] { // SAFETY: tests in this module are serialised through ENV_LOCK, // and the helper runs before any code that reads these vars in the // same test. No other thread observes the partial state. unsafe { std::env::remove_var(key); } } } /// Set an environment variable for the duration of the surrounding test. fn set_env(key: &str, value: &str) { // SAFETY: same rationale as `clear_fediverse_env` — serialised via // ENV_LOCK in the calling test. unsafe { std::env::set_var(key, value); } } // ─── Test 1 — Happy path ──────────────────────────────────────────── #[test] fn from_env_returns_valid_config_with_required_var_only() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); clear_fediverse_env(); set_env("FEDIVERSE_DOMAIN", "example.org"); let config = FediverseConfig::from_env().expect("valid config"); assert_eq!(config.domain(), "example.org"); assert_eq!(config.base_url().as_str(), "https://example.org/"); assert!(!config.authorized_fetch()); assert!(!config.debug_local_federation()); } // ─── Test 2 — Missing domain ──────────────────────────────────────── #[test] fn from_env_returns_missing_domain_when_var_unset() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); clear_fediverse_env(); let err = FediverseConfig::from_env().expect_err("must fail"); assert!( matches!(err, FediverseConfigError::MissingDomain), "got {err:?}" ); } // ─── Test 3 — Placeholder domains ─────────────────────────────────── #[test] fn from_env_returns_placeholder_error_for_known_placeholders() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); for placeholder in ["example.com", "localhost.localdomain", "your.tld"] { clear_fediverse_env(); set_env("FEDIVERSE_DOMAIN", placeholder); let err = FediverseConfig::from_env().expect_err("must fail"); assert!( matches!(err, FediverseConfigError::PlaceholderDomain(ref d) if d == placeholder), "domain={placeholder} got {err:?}" ); } } // ─── Test 4 — Insecure base URL in production ─────────────────────── #[test] fn from_env_rejects_http_base_url_in_production() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); clear_fediverse_env(); // Domain that yields http:// only in non-debug mode is conceptual; // we trigger the path by feeding a hostname that the parser maps // to HTTPS but with debug=false (default). To force HTTP, the // implementation interprets a leading `http://` scheme in the env // value as opt-in to plain HTTP — only valid under debug mode. set_env("FEDIVERSE_DOMAIN", "http://example.org"); let err = FediverseConfig::from_env().expect_err("must fail"); assert!( matches!(err, FediverseConfigError::InsecureBaseUrl(_)), "got {err:?}" ); } // ─── Test 5 — Debug mode allows HTTP ──────────────────────────────── #[test] fn from_env_allows_http_when_debug_local_federation() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); clear_fediverse_env(); set_env("FEDIVERSE_DOMAIN", "http://localhost:3000"); set_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true"); let config = FediverseConfig::from_env().expect("debug HTTP allowed"); assert!(config.debug_local_federation()); assert_eq!(config.base_url().scheme(), "http"); assert_eq!(config.domain(), "localhost:3000"); } // ─── Test 6 — Numeric var invalid ─────────────────────────────────── #[test] fn from_env_returns_invalid_numeric_for_bad_workers_value() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); clear_fediverse_env(); set_env("FEDIVERSE_DOMAIN", "example.org"); set_env("FEDIVERSE_OUTBOUND_WORKERS", "not-a-number"); let err = FediverseConfig::from_env().expect_err("must fail"); assert!( matches!( err, FediverseConfigError::InvalidNumeric { name, .. } if name == "FEDIVERSE_OUTBOUND_WORKERS" ), "got {err:?}" ); } // ─── Test 7 — Defaults ────────────────────────────────────────────── #[test] fn defaults_match_documented_values() { let _guard = ENV_LOCK.lock().expect("env lock poisoned"); clear_fediverse_env(); set_env("FEDIVERSE_DOMAIN", "example.org"); let config = FediverseConfig::from_env().expect("valid config"); assert_eq!(config.outbound_workers(), 4); assert_eq!(config.outbound_max_attempts(), 8); assert_eq!( config.outbound_request_timeout(), Duration::from_secs(30), "outbound_request_timeout default" ); assert_eq!( config.fetch_actor_cache_ttl(), Duration::from_secs(86_400), "fetch_actor_cache_ttl default (24 h)" ); assert_eq!( config.auth_challenge_ttl(), Duration::from_secs(300), "auth_challenge_ttl default (5 min)" ); assert!(!config.authorized_fetch()); assert!(!config.debug_local_federation()); assert!( config.user_agent().contains("rustwarden") || config.user_agent().contains("Rustwarden"), "user_agent={}", config.user_agent() ); } }
GitRust