//! TOML configuration loading and types. use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::error::{PomError, Result}; use crate::peer::OnMissing; use crate::types::DnsRecordType; #[derive(Debug, Clone, Deserialize)] pub struct Config { /// Serve-mode settings (intervals, listen address, pruning). #[serde(default)] pub serve: ServeConfig, /// This PoM instance's identity (name, optional fixed ID). #[serde(default)] pub instance: InstanceConfig, /// Monitored targets, keyed by short name (e.g. "mnw", "go"). #[serde(default)] pub targets: HashMap, /// Peer PoM instances for mesh monitoring, keyed by peer name. #[serde(default)] pub peers: HashMap, /// Email alert configuration via Postmark. `None` disables alerting. pub alerts: Option, } #[derive(Debug, Clone, Deserialize)] pub struct AlertConfig { /// Postmark server API token. Can also be set via `POM_POSTMARK_TOKEN` env var. pub postmark_token: Option, /// Recipient email address for alert notifications. pub to: String, /// Sender email address for alert notifications. #[serde(default = "default_alert_from")] pub from: String, /// Minimum seconds between repeated alerts for the same target. #[serde(default = "default_cooldown_secs")] pub cooldown_secs: u64, } #[derive(Debug, Clone, Default, Deserialize)] pub struct InstanceConfig { /// Human-readable instance name. Falls back to OS hostname if unset. pub name: Option, /// Fixed instance UUID. Auto-generated and persisted to disk if unset. pub id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct PeerConfig { /// Network address of the peer (host:port). pub address: String, /// Action to take when the peer is declared missing. #[serde(default)] pub on_missing: OnMissing, /// Number of consecutive heartbeat failures before declaring the peer missing. /// Defaults to 3 at runtime if unset. pub grace_count: Option, /// Bearer token for authenticating with this peer's API. pub token: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ServeConfig { /// Seconds between health check cycles for all targets. #[serde(default = "default_serve_interval")] pub interval_secs: u64, /// Number of days of history to retain before pruning. #[serde(default = "default_prune_days")] pub prune_days: i64, /// Socket address the API server binds to (e.g. "127.0.0.1:9100"). #[serde(default = "default_listen")] pub listen: String, /// Seconds between peer heartbeat probes. #[serde(default = "default_peer_heartbeat")] pub peer_heartbeat_secs: u64, /// Seconds between TLS certificate checks. #[serde(default = "default_tls_check_interval")] pub tls_check_interval_secs: u64, /// Seconds between route accessibility checks for all targets. #[serde(default = "default_route_check_interval")] pub route_check_interval_secs: u64, /// Seconds between DNS record verification checks. #[serde(default = "default_dns_check_interval")] pub dns_check_interval_secs: u64, /// Seconds between CORS preflight verification checks. #[serde(default = "default_cors_check_interval")] pub cors_check_interval_secs: u64, /// Bearer token required for API access. If set, all /api/* requests must /// include `Authorization: Bearer `. Can also be set via POM_API_TOKEN env var. pub api_token: Option, /// Enable the HTML dashboard at `GET /`. Disabled by default. #[serde(default)] pub dashboard: bool, } impl Default for ServeConfig { fn default() -> Self { Self { interval_secs: 300, prune_days: 30, listen: default_listen(), peer_heartbeat_secs: 60, tls_check_interval_secs: 3600, route_check_interval_secs: 300, dns_check_interval_secs: 3600, cors_check_interval_secs: 3600, api_token: None, dashboard: false, } } } fn default_peer_heartbeat() -> u64 { // 1 minute: detects peer failures within the grace period 60 } fn default_tls_check_interval() -> u64 { // 1 hour: certificates change slowly, no need to probe frequently 3600 } fn default_route_check_interval() -> u64 { // 5 minutes: same cadence as health checks, catches broken pages quickly 300 } fn default_dns_check_interval() -> u64 { // 1 hour: DNS records change infrequently, same cadence as TLS checks 3600 } fn default_cors_check_interval() -> u64 { // 1 hour: CORS policies change infrequently 3600 } fn default_serve_interval() -> u64 { // 5 minutes: frequent enough to catch outages within an SLA window, // infrequent enough to avoid noise 300 } fn default_prune_days() -> i64 { // 30 days: enough history for monthly reporting, keeps DB small 30 } fn default_listen() -> String { "127.0.0.1:9100".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct TargetConfig { /// Human-readable display name for this target. pub label: String, /// HTTP health check configuration. `None` disables health monitoring. pub health: Option, /// Remote test runner configuration. `None` disables test execution. pub tests: Option, /// TLS certificate monitoring configuration. `None` disables TLS checks. pub tls: Option, /// Expected routes to check for accessibility. Empty disables route checks. /// Requires `health` config for base URL derivation. #[serde(default)] pub expected_routes: Vec, /// DNS records to verify. Empty disables DNS checks. #[serde(default)] pub dns: Vec, /// WHOIS domain expiry monitoring. `None` disables WHOIS checks. pub whois: Option, /// CORS preflight checks. Empty disables CORS checks. #[serde(default)] pub cors: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct DnsRecord { /// Hostname to resolve (e.g. "makenot.work"). pub name: String, /// DNS record type: A, AAAA, CNAME, MX, TXT. pub record_type: DnsRecordType, /// Expected values (order-independent set comparison). pub expected: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct WhoisConfig { /// Domain to check (e.g. "makenot.work"). pub domain: String, /// Alert when registration expires within this many days. Defaults to 30. #[serde(default = "default_whois_warn_days")] pub warn_days: u32, } fn default_whois_warn_days() -> u32 { 30 } #[derive(Debug, Clone, Deserialize)] pub struct CorsCheck { /// URL to send the preflight OPTIONS request to. pub url: String, /// Expected `Access-Control-Allow-Origin` value. pub origin: String, /// HTTP method to include in `Access-Control-Request-Method`. #[serde(default = "default_cors_method")] pub method: String, } fn default_cors_method() -> String { "PUT".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct TlsConfig { /// Hostname to connect to for the TLS check. pub host: String, /// TCP port for the TLS connection. #[serde(default = "default_tls_port")] pub port: u16, /// Days before expiry at which to start warning. #[serde(default = "default_tls_warn_days")] pub warn_days: u32, } fn default_tls_port() -> u16 { 443 } fn default_tls_warn_days() -> u32 { // 2 weeks: enough lead time to renew before expiry 14 } #[derive(Debug, Clone, Deserialize)] pub struct HealthConfig { /// URL of the health endpoint to check. pub url: String, /// HTTP request timeout in seconds for this health check. #[serde(default = "default_health_timeout")] pub timeout_secs: u64, /// Per-target interval override for serve mode. pub interval_secs: Option, /// Response validation expectations. pub expect: Option, /// Latency trending and drift detection. pub trending: Option, } #[derive(Debug, Clone, Deserialize)] pub struct TrendingConfig { /// Number of hours of history used to compute the baseline average latency. #[serde(default = "default_baseline_window_hours")] pub baseline_window_hours: u64, /// Multiplier over the baseline average that constitutes a latency spike. #[serde(default = "default_spike_threshold")] pub spike_threshold: f64, } fn default_baseline_window_hours() -> u64 { // 7 days: captures weekly traffic patterns for stable baseline 168 } fn default_spike_threshold() -> f64 { // 2x baseline average: significant deviation without false positives // from normal variance 2.0 } #[derive(Debug, Clone, Deserialize, Default)] pub struct HealthExpectation { /// Expected HTTP status code (e.g. 200). `None` accepts any 2xx. pub status_code: Option, /// JSON field paths and their expected string values (e.g. `{"status": "operational"}`). #[serde(default)] pub json_fields: HashMap, /// Substring that must appear in the response body. pub body_contains: Option, } #[derive(Debug, Clone, Deserialize)] pub struct TestsConfig { /// SSH host alias (from ~/.ssh/config) for the test runner machine. pub ssh: String, /// Shell command to execute on the remote host to run tests. pub command: String, /// Maximum seconds to wait for the test command before killing it. #[serde(default = "default_test_timeout")] pub timeout_secs: u64, /// Number of days after which a test run is considered stale. #[serde(default = "default_staleness_days")] pub staleness_days: u64, } fn default_staleness_days() -> u64 { // 1 week: tests older than a week may not reflect current code 7 } fn default_health_timeout() -> u64 { // 10 seconds: generous for most HTTP endpoints, avoids false positives // on slow networks 10 } fn default_test_timeout() -> u64 { // 10 minutes: full CI suites can take time, especially on slow machines 600 } fn default_alert_from() -> String { "PoM Alerts ".to_string() } fn default_cooldown_secs() -> u64 { // 5 minutes: prevents alert storms during sustained outages 300 } impl Config { pub fn load(path: Option<&Path>) -> Result { let config_path = match path { Some(p) => p.to_path_buf(), None => default_config_path()?, }; if !config_path.exists() { return Err(PomError::Config(format!( "Config file not found: {}", config_path.display() ))); } let contents = std::fs::read_to_string(&config_path)?; let mut config: Config = toml::from_str(&contents)?; // Allow postmark_token from environment variable (preferred over config file) if let Some(ref mut alerts) = config.alerts && alerts.postmark_token.is_none() && let Ok(token) = std::env::var("POM_POSTMARK_TOKEN") { alerts.postmark_token = Some(token); } // Allow api_token from environment variable (preferred over config file) if config.serve.api_token.is_none() && let Ok(token) = std::env::var("POM_API_TOKEN") { config.serve.api_token = Some(token); } // Validate no duplicate target labels let mut seen_labels = std::collections::HashSet::new(); for (name, target) in &config.targets { if !seen_labels.insert(target.label.to_lowercase()) { return Err(PomError::Config(format!( "duplicate target label: \"{}\" (on target \"{name}\")", target.label ))); } } // Validate expected_routes paths start with '/' for (name, target) in &config.targets { for route in &target.expected_routes { if !route.starts_with('/') { return Err(PomError::Config(format!( "target {name}: expected_route \"{route}\" must start with '/'" ))); } } } Ok(config) } pub fn get_target(&self, name: &str) -> Option<&TargetConfig> { self.targets.get(name) } pub fn target_names(&self) -> Vec { let mut names: Vec<_> = self.targets.keys().cloned().collect(); names.sort(); names } pub fn instance_name(&self) -> String { self.instance .name .clone() .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|_| "unknown".to_string())) } } pub fn default_config_path() -> Result { let config_dir = dirs::config_dir().ok_or_else(|| PomError::Config("Could not determine config directory".into())); Ok(config_dir?.join("pom").join("pom.toml")) } pub fn db_path() -> Result { let data_dir = dirs::data_local_dir().ok_or_else(|| PomError::Config("Could not determine data directory".into())); let pom_dir = data_dir?.join("pom"); std::fs::create_dir_all(&pom_dir)?; Ok(pom_dir.join("pom.db")) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_full_config() { let toml = r#" [serve] interval_secs = 120 listen = "127.0.0.1:9100" peer_heartbeat_secs = 30 [instance] name = "hetzner" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" timeout_secs = 5 [targets.mnw.tests] ssh = "hetzner" command = "cd /srv/mnw && ./ci.sh" [peers.astra] address = "100.0.0.1:9100" on_missing = "alert" grace_count = 5 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.interval_secs, 120); assert_eq!(config.serve.listen, "127.0.0.1:9100"); assert_eq!(config.serve.peer_heartbeat_secs, 30); assert_eq!(config.instance.name.as_deref(), Some("hetzner")); assert_eq!(config.target_names(), vec!["mnw"]); let mnw = config.get_target("mnw").unwrap(); assert_eq!(mnw.label, "MakeNotWork"); assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5); assert_eq!(mnw.tests.as_ref().unwrap().ssh, "hetzner"); let astra = config.peers.get("astra").unwrap(); assert_eq!(astra.address, "100.0.0.1:9100"); assert_eq!(astra.on_missing, OnMissing::Alert); assert_eq!(astra.grace_count, Some(5)); } #[test] fn empty_config_uses_defaults() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.interval_secs, 300); assert_eq!(config.serve.prune_days, 30); assert_eq!(config.serve.listen, "127.0.0.1:9100"); assert_eq!(config.serve.peer_heartbeat_secs, 60); assert!(config.targets.is_empty()); assert!(config.peers.is_empty()); assert!(config.instance.name.is_none()); } #[test] fn peer_on_missing_defaults_to_log() { let toml = r#" [peers.test] address = "10.0.0.1:9100" "#; let config: Config = toml::from_str(toml).unwrap(); let peer = config.peers.get("test").unwrap(); assert_eq!(peer.on_missing, OnMissing::Log); assert_eq!(peer.grace_count, None); assert!(peer.token.is_none()); } #[test] fn peer_with_token() { let toml = r#" [peers.test] address = "10.0.0.1:9100" token = "peer-secret-123" "#; let config: Config = toml::from_str(toml).unwrap(); let peer = config.peers.get("test").unwrap(); assert_eq!(peer.token.as_deref(), Some("peer-secret-123")); } #[test] fn serve_api_token_from_config() { let toml = r#" [serve] api_token = "my-api-secret" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.api_token.as_deref(), Some("my-api-secret")); } #[test] fn serve_api_token_defaults_to_none() { let config: Config = toml::from_str("").unwrap(); assert!(config.serve.api_token.is_none()); } #[test] fn instance_name_falls_back_to_hostname() { let config: Config = toml::from_str("").unwrap(); let name = config.instance_name(); assert!(!name.is_empty()); } #[test] fn config_without_alerts_section() { let config: Config = toml::from_str("").unwrap(); assert!(config.alerts.is_none()); } #[test] fn config_with_alerts_section() { let toml = r#" [alerts] postmark_token = "test-token" to = "alerts@example.com" "#; let config: Config = toml::from_str(toml).unwrap(); let alerts = config.alerts.unwrap(); assert_eq!(alerts.postmark_token.as_deref(), Some("test-token")); assert_eq!(alerts.to, "alerts@example.com"); assert_eq!(alerts.from, "PoM Alerts "); assert_eq!(alerts.cooldown_secs, 300); } #[test] fn config_with_tls() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tls] host = "makenot.work" port = 8443 warn_days = 30 "#; let config: Config = toml::from_str(toml).unwrap(); let mnw = config.get_target("mnw").unwrap(); let tls = mnw.tls.as_ref().unwrap(); assert_eq!(tls.host, "makenot.work"); assert_eq!(tls.port, 8443); assert_eq!(tls.warn_days, 30); } #[test] fn config_tls_defaults() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tls] host = "makenot.work" "#; let config: Config = toml::from_str(toml).unwrap(); let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap(); assert_eq!(tls.port, 443); assert_eq!(tls.warn_days, 14); } #[test] fn config_without_tls() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().tls.is_none()); } #[test] fn config_tls_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.tls_check_interval_secs, 3600); } #[test] fn config_tls_check_interval_custom() { let toml = r#" [serve] tls_check_interval_secs = 1800 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.tls_check_interval_secs, 1800); } #[test] fn config_with_health_expect() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.expect] status_code = 200 body_contains = "operational" json_fields = { "status" = "operational", "checks.db" = "ok" } "#; let config: Config = toml::from_str(toml).unwrap(); let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap(); assert_eq!(expect.status_code, Some(200)); assert_eq!(expect.body_contains.as_deref(), Some("operational")); assert_eq!(expect.json_fields.get("status").unwrap(), "operational"); assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok"); } #[test] fn config_health_without_expect() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.is_none()); } #[test] fn config_with_trending() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.trending] baseline_window_hours = 48 spike_threshold = 1.5 "#; let config: Config = toml::from_str(toml).unwrap(); let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap(); assert_eq!(trending.baseline_window_hours, 48); assert_eq!(trending.spike_threshold, 1.5); } #[test] fn config_trending_defaults() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.trending] "#; let config: Config = toml::from_str(toml).unwrap(); let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap(); assert_eq!(trending.baseline_window_hours, 168); assert_eq!(trending.spike_threshold, 2.0); } #[test] fn config_without_trending() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.is_none()); } #[test] fn config_health_expect_empty() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.expect] "#; let config: Config = toml::from_str(toml).unwrap(); let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap(); assert_eq!(expect.status_code, None); assert!(expect.json_fields.is_empty()); assert_eq!(expect.body_contains, None); } #[test] fn config_staleness_days_default() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tests] ssh = "host" command = "./ci.sh" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 7); } #[test] fn config_staleness_days_custom() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tests] ssh = "host" command = "./ci.sh" staleness_days = 14 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 14); } #[test] fn config_with_alerts_custom_defaults() { let toml = r#" [alerts] to = "alerts@example.com" from = "Custom " cooldown_secs = 60 "#; let config: Config = toml::from_str(toml).unwrap(); let alerts = config.alerts.unwrap(); assert!(alerts.postmark_token.is_none()); assert_eq!(alerts.from, "Custom "); assert_eq!(alerts.cooldown_secs, 60); } #[test] fn config_expected_routes() { let toml = r#" [targets.mnw] label = "MakeNotWork" expected_routes = ["/", "/discover", "/login", "/docs"] [targets.mnw.health] url = "https://makenot.work/api/health" "#; let config: Config = toml::from_str(toml).unwrap(); let mnw = config.get_target("mnw").unwrap(); assert_eq!(mnw.expected_routes, vec!["/", "/discover", "/login", "/docs"]); } #[test] fn config_expected_routes_default_empty() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().expected_routes.is_empty()); } #[test] fn config_route_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.route_check_interval_secs, 300); } #[test] fn config_route_check_interval_custom() { let toml = r#" [serve] route_check_interval_secs = 600 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.route_check_interval_secs, 600); } #[test] fn config_dns_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.dns_check_interval_secs, 3600); } #[test] fn config_dns_check_interval_custom() { let toml = r#" [serve] dns_check_interval_secs = 1800 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.dns_check_interval_secs, 1800); } #[test] fn config_with_dns_records() { let toml = r#" [targets.mnw] label = "MakeNotWork" [[targets.mnw.dns]] name = "makenot.work" record_type = "A" expected = ["5.78.144.244"] [[targets.mnw.dns]] name = "git.makenot.work" record_type = "A" expected = ["5.78.144.244"] "#; let config: Config = toml::from_str(toml).unwrap(); let mnw = config.get_target("mnw").unwrap(); assert_eq!(mnw.dns.len(), 2); assert_eq!(mnw.dns[0].name, "makenot.work"); assert_eq!(mnw.dns[0].record_type, DnsRecordType::A); assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]); assert_eq!(mnw.dns[1].name, "git.makenot.work"); } #[test] fn config_dns_default_empty() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().dns.is_empty()); } #[test] fn config_with_whois() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.whois] domain = "makenot.work" warn_days = 60 "#; let config: Config = toml::from_str(toml).unwrap(); let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap(); assert_eq!(whois.domain, "makenot.work"); assert_eq!(whois.warn_days, 60); } #[test] fn config_whois_default_warn_days() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.whois] domain = "makenot.work" "#; let config: Config = toml::from_str(toml).unwrap(); let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap(); assert_eq!(whois.warn_days, 30); } #[test] fn config_without_whois() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().whois.is_none()); } #[test] fn config_dashboard_default_false() { let config: Config = toml::from_str("").unwrap(); assert!(!config.serve.dashboard); } #[test] fn config_dashboard_enabled() { let toml = r#" [serve] dashboard = true "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.serve.dashboard); } #[test] fn config_expected_routes_without_slash_detected() { let toml = r#" [targets.mnw] label = "MakeNotWork" expected_routes = ["discover", "/login"] "#; let config: Config = toml::from_str(toml).unwrap(); let bad_routes: Vec<_> = config.get_target("mnw").unwrap() .expected_routes.iter() .filter(|r| !r.starts_with('/')) .collect(); assert_eq!(bad_routes, vec!["discover"]); } }