config.rs 213 lignes · 7579 octets
//! Runtime configuration for the fediverse guard.
//!
//! Parsed once from `FEDIVERSE_GUARD_*` env vars at boot via
//! [`GuardConfig::from_env`]. Production deployments override defaults
//! through env vars; tests can build a `GuardConfig` literal directly.

use std::time::Duration;

use thiserror::Error;

/// Errors raised while loading [`GuardConfig`] from the environment.
#[derive(Debug, Error)]
pub enum GuardConfigError {
    /// An env var was set to a value that could not be parsed.
    #[error("invalid value for {name}: {source}")]
    InvalidValue {
        /// Name of the env var that failed to parse.
        name: &'static str,
        /// Underlying parser error.
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },
}

/// Per-detector tunable threshold. A source (domain or actor) is
/// auto-banned when more than `count` qualifying events are observed
/// within `window`.
#[derive(Debug, Clone, Copy)]
pub struct DetectorThreshold {
    /// Maximum events tolerated within the sliding window.
    pub count: u32,
    /// Sliding-window duration over which `count` is enforced.
    pub window: Duration,
}

impl DetectorThreshold {
    /// Build a threshold from explicit values.
    #[must_use]
    pub const fn new(count: u32, window_secs: u64) -> Self {
        Self {
            count,
            window: Duration::from_secs(window_secs),
        }
    }
}

/// Top-level runtime configuration for the fediverse guard.
#[derive(Debug, Clone)]
pub struct GuardConfig {
    /// Master kill-switch. When `false`, no detector runs and
    /// [`crate::BanManager::is_banned`] always returns `false`.
    pub enabled: bool,
    /// When `true`, detectors emit events and tracing logs but no ban
    /// is ever persisted. Use this to validate thresholds in
    /// production before flipping the switch.
    pub dry_run: bool,
    /// HTTP signatures that fail verification, per signing actor's
    /// domain.
    pub signature_failures: DetectorThreshold,
    /// Detected replays (Date drift / duplicate nonce / signature
    /// already seen).
    pub replay_attempts: DetectorThreshold,
    /// Attempts by the local outbound fetcher to talk to a private/
    /// loopback/link-local IP. Surfaces SSRF probing campaigns.
    pub fetch_ssrf: DetectorThreshold,
    /// Inbox payloads that fail size, schema, or activity-type
    /// validation.
    pub inbox_abuse: DetectorThreshold,
    /// AP-native auth challenges initiated without subsequent consume.
    pub auth_spam: DetectorThreshold,
    /// TTL applied to auto-bans. `None` means permanent.
    pub auto_ban_duration: Option<Duration>,
    /// Retention for raw events in `fediverse_guard_events`.
    pub events_retention: Duration,
}

impl GuardConfig {
    /// Load configuration from `FEDIVERSE_GUARD_*` environment
    /// variables, falling back to documented defaults for any var
    /// that is unset.
    ///
    /// # Errors
    ///
    /// Returns [`GuardConfigError::InvalidValue`] when an env var
    /// is set to a value that cannot be parsed as the expected
    /// type.
    pub fn from_env() -> Result<Self, GuardConfigError> {
        Ok(Self {
            enabled: parse_bool_env("FEDIVERSE_GUARD_ENABLED", true)?,
            dry_run: parse_bool_env("FEDIVERSE_GUARD_DRY_RUN", false)?,
            signature_failures: DetectorThreshold {
                count: parse_u32_env("FEDIVERSE_GUARD_SIG_FAILURE_THRESHOLD", 10)?,
                window: Duration::from_secs(parse_u64_env(
                    "FEDIVERSE_GUARD_SIG_FAILURE_WINDOW_SECS",
                    300,
                )?),
            },
            replay_attempts: DetectorThreshold {
                count: parse_u32_env("FEDIVERSE_GUARD_REPLAY_THRESHOLD", 3)?,
                window: Duration::from_secs(parse_u64_env(
                    "FEDIVERSE_GUARD_REPLAY_WINDOW_SECS",
                    600,
                )?),
            },
            fetch_ssrf: DetectorThreshold {
                count: parse_u32_env("FEDIVERSE_GUARD_SSRF_THRESHOLD", 5)?,
                window: Duration::from_secs(parse_u64_env(
                    "FEDIVERSE_GUARD_SSRF_WINDOW_SECS",
                    600,
                )?),
            },
            inbox_abuse: DetectorThreshold {
                count: parse_u32_env("FEDIVERSE_GUARD_INBOX_ABUSE_THRESHOLD", 20)?,
                window: Duration::from_secs(parse_u64_env(
                    "FEDIVERSE_GUARD_INBOX_ABUSE_WINDOW_SECS",
                    300,
                )?),
            },
            auth_spam: DetectorThreshold {
                count: parse_u32_env("FEDIVERSE_GUARD_AUTH_SPAM_THRESHOLD", 30)?,
                window: Duration::from_secs(parse_u64_env(
                    "FEDIVERSE_GUARD_AUTH_SPAM_WINDOW_SECS",
                    300,
                )?),
            },
            auto_ban_duration: {
                let secs = parse_u64_env("FEDIVERSE_GUARD_AUTO_BAN_DURATION_SECS", 3600)?;
                if secs == 0 {
                    None
                } else {
                    Some(Duration::from_secs(secs))
                }
            },
            events_retention: Duration::from_secs(
                parse_u64_env("FEDIVERSE_GUARD_EVENTS_RETENTION_DAYS", 90)? * 24 * 3600,
            ),
        })
    }

    /// Build a permissive `GuardConfig` for tests.
    #[must_use]
    pub fn for_tests() -> Self {
        Self {
            enabled: true,
            dry_run: false,
            signature_failures: DetectorThreshold::new(3, 60),
            replay_attempts: DetectorThreshold::new(2, 60),
            fetch_ssrf: DetectorThreshold::new(3, 60),
            inbox_abuse: DetectorThreshold::new(5, 60),
            auth_spam: DetectorThreshold::new(5, 60),
            auto_ban_duration: Some(Duration::from_secs(60)),
            events_retention: Duration::from_secs(3600),
        }
    }
}

fn parse_bool_env(name: &'static str, default: bool) -> Result<bool, GuardConfigError> {
    match std::env::var(name) {
        Ok(v) => match v.to_ascii_lowercase().as_str() {
            "true" | "1" | "yes" => Ok(true),
            "false" | "0" | "no" => Ok(false),
            other => Err(GuardConfigError::InvalidValue {
                name,
                source: format!("expected true/false, got {other:?}").into(),
            }),
        },
        Err(_) => Ok(default),
    }
}

fn parse_u32_env(name: &'static str, default: u32) -> Result<u32, GuardConfigError> {
    match std::env::var(name) {
        Ok(v) => v
            .parse::<u32>()
            .map_err(|e| GuardConfigError::InvalidValue {
                name,
                source: Box::new(e),
            }),
        Err(_) => Ok(default),
    }
}

fn parse_u64_env(name: &'static str, default: u64) -> Result<u64, GuardConfigError> {
    match std::env::var(name) {
        Ok(v) => v
            .parse::<u64>()
            .map_err(|e| GuardConfigError::InvalidValue {
                name,
                source: Box::new(e),
            }),
        Err(_) => Ok(default),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn for_tests_yields_sane_defaults() {
        let cfg = GuardConfig::for_tests();
        assert!(cfg.enabled);
        assert!(!cfg.dry_run);
        assert_eq!(cfg.signature_failures.count, 3);
    }

    #[test]
    fn detector_threshold_new_converts_secs_to_duration() {
        let t = DetectorThreshold::new(7, 120);
        assert_eq!(t.count, 7);
        assert_eq!(t.window, Duration::from_secs(120));
    }
}