max / pom
11 files changed,
+558 insertions,
-13 deletions
| @@ -29,6 +29,11 @@ name = "git.makenot.work" | |||
| 29 | 29 | record_type = "A" | |
| 30 | 30 | expected = [] | |
| 31 | 31 | ||
| 32 | + | [[targets.mnw.cors]] | |
| 33 | + | url = "https://fsn1.your-objectstorage.com/makenotwork-files/cors-probe" | |
| 34 | + | origin = "https://makenot.work" | |
| 35 | + | method = "PUT" | |
| 36 | + | ||
| 32 | 37 | [targets.mnw.whois] | |
| 33 | 38 | domain = "makenot.work" | |
| 34 | 39 | warn_days = 30 |
| @@ -401,6 +401,70 @@ impl Alerter { | |||
| 401 | 401 | } | |
| 402 | 402 | ||
| 403 | 403 | #[instrument(skip_all)] | |
| 404 | + | pub async fn send_cors_failure_alert( | |
| 405 | + | &self, | |
| 406 | + | target: &str, | |
| 407 | + | label: &str, | |
| 408 | + | failures: &[crate::types::CorsCheckResult], | |
| 409 | + | ) { | |
| 410 | + | let alert_key = format!("cors:{target}"); | |
| 411 | + | if self.is_within_cooldown(&alert_key).await { | |
| 412 | + | info!("alert cooldown active for {alert_key}, skipping"); | |
| 413 | + | return; | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | let n = failures.len(); | |
| 417 | + | let subject = format!("[PoM] {label}: {n} CORS preflight(s) failing"); | |
| 418 | + | let details: Vec<String> = failures | |
| 419 | + | .iter() | |
| 420 | + | .map(|f| { | |
| 421 | + | if let Some(ref err) = f.error { | |
| 422 | + | format!(" - {} {} from {}: {err}", f.method, f.url, f.origin) | |
| 423 | + | } else { | |
| 424 | + | format!(" - {} {} from {}: no CORS headers", f.method, f.url, f.origin) | |
| 425 | + | } | |
| 426 | + | }) | |
| 427 | + | .collect(); | |
| 428 | + | let body = format!( | |
| 429 | + | "Target: {label} ({target})\n\ | |
| 430 | + | CORS preflight failures:\n{}\n\ | |
| 431 | + | Instance: {}\n\ | |
| 432 | + | Time: {}\n\n\ | |
| 433 | + | Browser-side uploads will silently fail without CORS.\n\n\ | |
| 434 | + | - PoM", | |
| 435 | + | details.join("\n"), | |
| 436 | + | self.instance_name, | |
| 437 | + | chrono::Utc::now().to_rfc3339(), | |
| 438 | + | ); | |
| 439 | + | ||
| 440 | + | self.send_email(&subject, &body).await; | |
| 441 | + | self.record_alert(&alert_key, AlertCategory::CorsFailure, None, None, None).await; | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | #[instrument(skip_all)] | |
| 445 | + | pub async fn send_cors_recovery_alert( | |
| 446 | + | &self, | |
| 447 | + | target: &str, | |
| 448 | + | label: &str, | |
| 449 | + | ) { | |
| 450 | + | // No cooldown on recovery — always send | |
| 451 | + | let alert_key = format!("cors:{target}"); | |
| 452 | + | let subject = format!("[PoM] {label}: CORS preflights recovered"); | |
| 453 | + | let body = format!( | |
| 454 | + | "Target: {label} ({target})\n\ | |
| 455 | + | All CORS preflight checks passing.\n\ | |
| 456 | + | Instance: {}\n\ | |
| 457 | + | Time: {}\n\n\ | |
| 458 | + | - PoM", | |
| 459 | + | self.instance_name, | |
| 460 | + | chrono::Utc::now().to_rfc3339(), | |
| 461 | + | ); | |
| 462 | + | ||
| 463 | + | self.send_email(&subject, &body).await; | |
| 464 | + | self.record_alert(&alert_key, AlertCategory::CorsRecovery, None, None, None).await; | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | #[instrument(skip_all)] | |
| 404 | 468 | pub async fn send_latency_drift_alert( | |
| 405 | 469 | &self, | |
| 406 | 470 | target: &str, |
| @@ -0,0 +1,144 @@ | |||
| 1 | + | //! CORS preflight verification — sends OPTIONS requests and checks Access-Control headers. | |
| 2 | + | ||
| 3 | + | use tracing::instrument; | |
| 4 | + | ||
| 5 | + | use crate::config::CorsCheck; | |
| 6 | + | use crate::types::CorsCheckResult; | |
| 7 | + | ||
| 8 | + | /// Send a CORS preflight OPTIONS request and verify the response allows the expected origin. | |
| 9 | + | /// Returns one `CorsCheckResult` per `CorsCheck` in the input. | |
| 10 | + | #[instrument(skip_all)] | |
| 11 | + | pub async fn check_cors(target: &str, checks: &[CorsCheck]) -> Vec<CorsCheckResult> { | |
| 12 | + | let client = reqwest::Client::builder() | |
| 13 | + | .timeout(std::time::Duration::from_secs(10)) | |
| 14 | + | .redirect(reqwest::redirect::Policy::none()) | |
| 15 | + | .build() | |
| 16 | + | .unwrap(); | |
| 17 | + | ||
| 18 | + | let mut results = Vec::with_capacity(checks.len()); | |
| 19 | + | for check in checks { | |
| 20 | + | results.push(run_preflight(target, &client, check).await); | |
| 21 | + | } | |
| 22 | + | results | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | async fn run_preflight( | |
| 26 | + | target: &str, | |
| 27 | + | client: &reqwest::Client, | |
| 28 | + | check: &CorsCheck, | |
| 29 | + | ) -> CorsCheckResult { | |
| 30 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 31 | + | ||
| 32 | + | let response = client | |
| 33 | + | .request(reqwest::Method::OPTIONS, &check.url) | |
| 34 | + | .header("Origin", &check.origin) | |
| 35 | + | .header("Access-Control-Request-Method", &check.method) | |
| 36 | + | .send() | |
| 37 | + | .await; | |
| 38 | + | ||
| 39 | + | match response { | |
| 40 | + | Ok(resp) => { | |
| 41 | + | let status = resp.status().as_u16(); | |
| 42 | + | let allow_origin = resp | |
| 43 | + | .headers() | |
| 44 | + | .get("access-control-allow-origin") | |
| 45 | + | .and_then(|v| v.to_str().ok()) | |
| 46 | + | .unwrap_or("") | |
| 47 | + | .to_string(); | |
| 48 | + | let allow_methods = resp | |
| 49 | + | .headers() | |
| 50 | + | .get("access-control-allow-methods") | |
| 51 | + | .and_then(|v| v.to_str().ok()) | |
| 52 | + | .unwrap_or("") | |
| 53 | + | .to_string(); | |
| 54 | + | ||
| 55 | + | let origin_ok = allow_origin == check.origin || allow_origin == "*"; | |
| 56 | + | let method_ok = allow_methods | |
| 57 | + | .split(',') | |
| 58 | + | .any(|m| m.trim().eq_ignore_ascii_case(&check.method)); | |
| 59 | + | ||
| 60 | + | let passes = status < 400 && origin_ok && method_ok; | |
| 61 | + | ||
| 62 | + | let error = if passes { | |
| 63 | + | None | |
| 64 | + | } else { | |
| 65 | + | let mut reasons = Vec::new(); | |
| 66 | + | if status >= 400 { | |
| 67 | + | reasons.push(format!("HTTP {status}")); | |
| 68 | + | } | |
| 69 | + | if !origin_ok { | |
| 70 | + | reasons.push(format!( | |
| 71 | + | "Access-Control-Allow-Origin: {allow_origin:?} (expected {:?} or \"*\")", | |
| 72 | + | check.origin | |
| 73 | + | )); | |
| 74 | + | } | |
| 75 | + | if !method_ok { | |
| 76 | + | reasons.push(format!( | |
| 77 | + | "Access-Control-Allow-Methods: {allow_methods:?} (expected {:?})", | |
| 78 | + | check.method | |
| 79 | + | )); | |
| 80 | + | } | |
| 81 | + | Some(reasons.join("; ")) | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | CorsCheckResult { | |
| 85 | + | target: target.to_string(), | |
| 86 | + | url: check.url.clone(), | |
| 87 | + | origin: check.origin.clone(), | |
| 88 | + | method: check.method.clone(), | |
| 89 | + | passes, | |
| 90 | + | checked_at: now, | |
| 91 | + | error, | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | Err(e) => CorsCheckResult { | |
| 95 | + | target: target.to_string(), | |
| 96 | + | url: check.url.clone(), | |
| 97 | + | origin: check.origin.clone(), | |
| 98 | + | method: check.method.clone(), | |
| 99 | + | passes: false, | |
| 100 | + | checked_at: now, | |
| 101 | + | error: Some(format!("preflight request failed: {e}")), | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[cfg(test)] | |
| 107 | + | mod tests { | |
| 108 | + | use super::*; | |
| 109 | + | ||
| 110 | + | #[test] | |
| 111 | + | fn cors_check_result_serde_roundtrip() { | |
| 112 | + | let result = CorsCheckResult { | |
| 113 | + | target: "mnw".to_string(), | |
| 114 | + | url: "https://s3.example.com/bucket/test".to_string(), | |
| 115 | + | origin: "https://makenot.work".to_string(), | |
| 116 | + | method: "PUT".to_string(), | |
| 117 | + | passes: true, | |
| 118 | + | checked_at: "2026-03-28T00:00:00Z".to_string(), | |
| 119 | + | error: None, | |
| 120 | + | }; | |
| 121 | + | let json = serde_json::to_string(&result).unwrap(); | |
| 122 | + | let parsed: CorsCheckResult = serde_json::from_str(&json).unwrap(); | |
| 123 | + | assert_eq!(parsed.target, "mnw"); | |
| 124 | + | assert!(parsed.passes); | |
| 125 | + | assert!(parsed.error.is_none()); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[test] | |
| 129 | + | fn cors_check_result_with_error() { | |
| 130 | + | let result = CorsCheckResult { | |
| 131 | + | target: "mnw".to_string(), | |
| 132 | + | url: "https://s3.example.com/bucket/test".to_string(), | |
| 133 | + | origin: "https://makenot.work".to_string(), | |
| 134 | + | method: "PUT".to_string(), | |
| 135 | + | passes: false, | |
| 136 | + | checked_at: "2026-03-28T00:00:00Z".to_string(), | |
| 137 | + | error: Some("HTTP 403".to_string()), | |
| 138 | + | }; | |
| 139 | + | let json = serde_json::to_string(&result).unwrap(); | |
| 140 | + | let parsed: CorsCheckResult = serde_json::from_str(&json).unwrap(); | |
| 141 | + | assert!(!parsed.passes); | |
| 142 | + | assert_eq!(parsed.error.as_deref(), Some("HTTP 403")); | |
| 143 | + | } | |
| 144 | + | } |
| @@ -1,3 +1,4 @@ | |||
| 1 | + | pub mod cors; | |
| 1 | 2 | pub mod dns; | |
| 2 | 3 | pub mod http; | |
| 3 | 4 | pub mod parse; |
| @@ -61,6 +61,7 @@ pub(crate) async fn cmd_serve( | |||
| 61 | 61 | handles.extend(tasks::spawn_route_tasks(config, pool, &token, &alerter)); | |
| 62 | 62 | handles.extend(tasks::spawn_dns_tasks(config, pool, &token, &alerter)); | |
| 63 | 63 | handles.extend(tasks::spawn_whois_tasks(config, pool, &token, &alerter)); | |
| 64 | + | handles.extend(tasks::spawn_cors_tasks(config, pool, &token, &alerter)); | |
| 64 | 65 | handles.push(tasks::spawn_prune_task(pool, prune_days, &token)); | |
| 65 | 66 | ||
| 66 | 67 | // Spawn peer heartbeat tasks |
| @@ -0,0 +1,85 @@ | |||
| 1 | + | use tokio::task::JoinHandle; | |
| 2 | + | use tracing::info; | |
| 3 | + | ||
| 4 | + | use pom::alerts::Alerter; | |
| 5 | + | use pom::checks::cors; | |
| 6 | + | use pom::config::Config; | |
| 7 | + | use pom::db; | |
| 8 | + | ||
| 9 | + | pub(crate) fn spawn_cors_tasks( | |
| 10 | + | config: &Config, | |
| 11 | + | pool: &sqlx::SqlitePool, | |
| 12 | + | cancel: &tokio_util::sync::CancellationToken, | |
| 13 | + | alerter: &Option<Alerter>, | |
| 14 | + | ) -> Vec<JoinHandle<()>> { | |
| 15 | + | let interval_secs = config.serve.cors_check_interval_secs; | |
| 16 | + | let mut handles = Vec::new(); | |
| 17 | + | ||
| 18 | + | for name in config.target_names() { | |
| 19 | + | let target_config = config.get_target(&name).unwrap().clone(); | |
| 20 | + | if target_config.cors.is_empty() { | |
| 21 | + | continue; | |
| 22 | + | } | |
| 23 | + | let cors_checks = target_config.cors.clone(); | |
| 24 | + | let label = target_config.label.clone(); | |
| 25 | + | let pool = pool.clone(); | |
| 26 | + | let alerter = alerter.clone(); | |
| 27 | + | let cancel = cancel.clone(); | |
| 28 | + | let n = cors_checks.len(); | |
| 29 | + | ||
| 30 | + | info!("{name}: CORS check every {interval_secs}s ({n} endpoints)"); | |
| 31 | + | ||
| 32 | + | handles.push(tokio::spawn(async move { | |
| 33 | + | let mut interval = tokio::time::interval( | |
| 34 | + | std::time::Duration::from_secs(interval_secs), | |
| 35 | + | ); | |
| 36 | + | interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); | |
| 37 | + | let mut prev_failed: std::collections::HashSet<String> = std::collections::HashSet::new(); | |
| 38 | + | ||
| 39 | + | interval.tick().await; // consume immediate first tick | |
| 40 | + | loop { | |
| 41 | + | tokio::select! { | |
| 42 | + | _ = cancel.cancelled() => break, | |
| 43 | + | _ = interval.tick() => {} | |
| 44 | + | } | |
| 45 | + | let results = cors::check_cors(&name, &cors_checks).await; | |
| 46 | + | ||
| 47 | + | for result in &results { | |
| 48 | + | if let Err(e) = db::insert_cors_check(&pool, result).await { | |
| 49 | + | tracing::error!("{}: failed to store CORS check for {}: {e}", name, result.url); | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | let current_failed: std::collections::HashSet<String> = results | |
| 54 | + | .iter() | |
| 55 | + | .filter(|r| !r.passes) | |
| 56 | + | .map(|r| r.url.clone()) | |
| 57 | + | .collect(); | |
| 58 | + | ||
| 59 | + | let ok_count = results.iter().filter(|r| r.passes).count(); | |
| 60 | + | info!("{name}: CORS {ok_count}/{n} pass"); | |
| 61 | + | ||
| 62 | + | if let Some(ref alerter) = alerter { | |
| 63 | + | // New failures | |
| 64 | + | let new_failures: Vec<&pom::types::CorsCheckResult> = results | |
| 65 | + | .iter() | |
| 66 | + | .filter(|r| !r.passes && !prev_failed.contains(&r.url)) | |
| 67 | + | .collect(); | |
| 68 | + | if !new_failures.is_empty() { | |
| 69 | + | let owned: Vec<pom::types::CorsCheckResult> = new_failures.into_iter().cloned().collect(); | |
| 70 | + | alerter.send_cors_failure_alert(&name, &label, &owned).await; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // All recovered | |
| 74 | + | if !prev_failed.is_empty() && current_failed.is_empty() { | |
| 75 | + | alerter.send_cors_recovery_alert(&name, &label).await; | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | prev_failed = current_failed; | |
| 80 | + | } | |
| 81 | + | })); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | handles | |
| 85 | + | } |
| @@ -1,3 +1,4 @@ | |||
| 1 | + | mod cors; | |
| 1 | 2 | mod dns; | |
| 2 | 3 | mod health; | |
| 3 | 4 | mod meta_alert; | |
| @@ -6,6 +7,7 @@ mod routes; | |||
| 6 | 7 | mod tls; | |
| 7 | 8 | mod whois; | |
| 8 | 9 | ||
| 10 | + | pub(crate) use cors::spawn_cors_tasks; | |
| 9 | 11 | pub(crate) use dns::spawn_dns_tasks; | |
| 10 | 12 | pub(crate) use health::spawn_health_tasks; | |
| 11 | 13 | pub(crate) use meta_alert::spawn_meta_alert_task; |
| @@ -85,6 +85,9 @@ pub struct ServeConfig { | |||
| 85 | 85 | /// Seconds between DNS record verification checks. | |
| 86 | 86 | #[serde(default = "default_dns_check_interval")] | |
| 87 | 87 | pub dns_check_interval_secs: u64, | |
| 88 | + | /// Seconds between CORS preflight verification checks. | |
| 89 | + | #[serde(default = "default_cors_check_interval")] | |
| 90 | + | pub cors_check_interval_secs: u64, | |
| 88 | 91 | /// Bearer token required for API access. If set, all /api/* requests must | |
| 89 | 92 | /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var. | |
| 90 | 93 | pub api_token: Option<String>, | |
| @@ -103,6 +106,7 @@ impl Default for ServeConfig { | |||
| 103 | 106 | tls_check_interval_secs: 3600, | |
| 104 | 107 | route_check_interval_secs: 300, | |
| 105 | 108 | dns_check_interval_secs: 3600, | |
| 109 | + | cors_check_interval_secs: 3600, | |
| 106 | 110 | api_token: None, | |
| 107 | 111 | dashboard: false, | |
| 108 | 112 | } | |
| @@ -129,6 +133,11 @@ fn default_dns_check_interval() -> u64 { | |||
| 129 | 133 | 3600 | |
| 130 | 134 | } | |
| 131 | 135 | ||
| 136 | + | fn default_cors_check_interval() -> u64 { | |
| 137 | + | // 1 hour: CORS policies change infrequently | |
| 138 | + | 3600 | |
| 139 | + | } | |
| 140 | + | ||
| 132 | 141 | fn default_serve_interval() -> u64 { | |
| 133 | 142 | // 5 minutes: frequent enough to catch outages within an SLA window, | |
| 134 | 143 | // infrequent enough to avoid noise | |
| @@ -163,6 +172,9 @@ pub struct TargetConfig { | |||
| 163 | 172 | pub dns: Vec<DnsRecord>, | |
| 164 | 173 | /// WHOIS domain expiry monitoring. `None` disables WHOIS checks. | |
| 165 | 174 | pub whois: Option<WhoisConfig>, | |
| 175 | + | /// CORS preflight checks. Empty disables CORS checks. | |
| 176 | + | #[serde(default)] | |
| 177 | + | pub cors: Vec<CorsCheck>, | |
| 166 | 178 | } | |
| 167 | 179 | ||
| 168 | 180 | #[derive(Debug, Clone, Deserialize)] | |
| @@ -189,6 +201,21 @@ fn default_whois_warn_days() -> u32 { | |||
| 189 | 201 | } | |
| 190 | 202 | ||
| 191 | 203 | #[derive(Debug, Clone, Deserialize)] | |
| 204 | + | pub struct CorsCheck { | |
| 205 | + | /// URL to send the preflight OPTIONS request to. | |
| 206 | + | pub url: String, | |
| 207 | + | /// Expected `Access-Control-Allow-Origin` value. | |
| 208 | + | pub origin: String, | |
| 209 | + | /// HTTP method to include in `Access-Control-Request-Method`. | |
| 210 | + | #[serde(default = "default_cors_method")] | |
| 211 | + | pub method: String, | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | fn default_cors_method() -> String { | |
| 215 | + | "PUT".to_string() | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[derive(Debug, Clone, Deserialize)] | |
| 192 | 219 | pub struct TlsConfig { | |
| 193 | 220 | /// Hostname to connect to for the TLS check. | |
| 194 | 221 | pub host: String, |
| @@ -11,7 +11,7 @@ use std::str::FromStr; | |||
| 11 | 11 | use tracing::{info, instrument}; | |
| 12 | 12 | ||
| 13 | 13 | use crate::error::Result; | |
| 14 | - | use crate::types::{DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestSummary, TlsStatus, WhoisResult}; | |
| 14 | + | use crate::types::{CorsCheckResult, DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestSummary, TlsStatus, WhoisResult}; | |
| 15 | 15 | ||
| 16 | 16 | /// Each migration is a (version, description, SQL) tuple. Versions start at 1. | |
| 17 | 17 | /// The SQL may contain multiple statements separated by semicolons. | |
| @@ -147,6 +147,19 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ | |||
| 147 | 147 | CREATE INDEX IF NOT EXISTS idx_test_details_run_id ON test_details(run_id); | |
| 148 | 148 | CREATE INDEX IF NOT EXISTS idx_test_details_name ON test_details(test_name, run_id DESC); | |
| 149 | 149 | "#), | |
| 150 | + | (8, "add cors_checks table", r#" | |
| 151 | + | CREATE TABLE IF NOT EXISTS cors_checks ( | |
| 152 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 153 | + | target TEXT NOT NULL, | |
| 154 | + | url TEXT NOT NULL, | |
| 155 | + | origin TEXT NOT NULL, | |
| 156 | + | method TEXT NOT NULL, | |
| 157 | + | passes INTEGER NOT NULL, | |
| 158 | + | checked_at TEXT NOT NULL, | |
| 159 | + | error TEXT | |
| 160 | + | ); | |
| 161 | + | CREATE INDEX IF NOT EXISTS idx_cors_target_id ON cors_checks(target, id DESC); | |
| 162 | + | "#), | |
| 150 | 163 | ]; | |
| 151 | 164 | ||
| 152 | 165 | #[instrument(skip_all)] | |
| @@ -966,6 +979,60 @@ pub async fn get_latest_whois_check( | |||
| 966 | 979 | .await?) | |
| 967 | 980 | } | |
| 968 | 981 | ||
| 982 | + | // --- CORS check queries --- | |
| 983 | + | ||
| 984 | + | #[instrument(skip_all)] | |
| 985 | + | pub async fn insert_cors_check( | |
| 986 | + | pool: &SqlitePool, | |
| 987 | + | result: &CorsCheckResult, | |
| 988 | + | ) -> Result<i64> { | |
| 989 | + | let row = sqlx::query( | |
| 990 | + | "INSERT INTO cors_checks (target, url, origin, method, passes, checked_at, error) | |
| 991 | + | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 992 | + | ) | |
| 993 | + | .bind(&result.target) | |
| 994 | + | .bind(&result.url) | |
| 995 | + | .bind(&result.origin) | |
| 996 | + | .bind(&result.method) | |
| 997 | + | .bind(result.passes) | |
| 998 | + | .bind(&result.checked_at) | |
| 999 | + | .bind(&result.error) | |
| 1000 | + | .execute(pool) | |
| 1001 | + | .await?; | |
| 1002 | + | Ok(row.last_insert_rowid()) | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | /// Get the latest CORS check per URL for a target. | |
| 1006 | + | #[instrument(skip_all)] | |
| 1007 | + | pub async fn get_latest_cors_checks( | |
| 1008 | + | pool: &SqlitePool, | |
| 1009 | + | target: &str, | |
| 1010 | + | ) -> Result<Vec<CorsCheckResult>> { | |
| 1011 | + | let rows = sqlx::query_as::<_, (String, String, String, String, bool, String, Option<String>)>( | |
| 1012 | + | "SELECT c.target, c.url, c.origin, c.method, c.passes, c.checked_at, c.error | |
| 1013 | + | FROM cors_checks c | |
| 1014 | + | INNER JOIN (SELECT url, MAX(id) as max_id FROM cors_checks WHERE target = ? GROUP BY url) latest | |
| 1015 | + | ON c.id = latest.max_id | |
| 1016 | + | ORDER BY c.url", | |
| 1017 | + | ) | |
| 1018 | + | .bind(target) | |
| 1019 | + | .fetch_all(pool) | |
| 1020 | + | .await?; | |
| 1021 | + | ||
| 1022 | + | Ok(rows | |
| 1023 | + | .into_iter() | |
| 1024 | + | .map(|(target, url, origin, method, passes, checked_at, error)| CorsCheckResult { | |
| 1025 | + | target, | |
| 1026 | + | url, | |
| 1027 | + | origin, | |
| 1028 | + | method, | |
| 1029 | + | passes, | |
| 1030 | + | checked_at, | |
| 1031 | + | error, | |
| 1032 | + | }) | |
| 1033 | + | .collect()) | |
| 1034 | + | } | |
| 1035 | + | ||
| 969 | 1036 | // --- Stale data cleanup --- | |
| 970 | 1037 | ||
| 971 | 1038 | /// Delete route_checks for paths no longer in the config. |
| @@ -25,6 +25,8 @@ pub enum AlertCategory { | |||
| 25 | 25 | LatencyDrift, | |
| 26 | 26 | LatencyRecovery, | |
| 27 | 27 | TestDurationDrift, | |
| 28 | + | CorsFailure, | |
| 29 | + | CorsRecovery, | |
| 28 | 30 | MonitoringOffline, | |
| 29 | 31 | MonitoringRecovery, | |
| 30 | 32 | } | |
| @@ -48,6 +50,8 @@ impl fmt::Display for AlertCategory { | |||
| 48 | 50 | Self::LatencyDrift => write!(f, "latency_drift"), | |
| 49 | 51 | Self::LatencyRecovery => write!(f, "latency_recovery"), | |
| 50 | 52 | Self::TestDurationDrift => write!(f, "test_duration_drift"), | |
| 53 | + | Self::CorsFailure => write!(f, "cors_failure"), | |
| 54 | + | Self::CorsRecovery => write!(f, "cors_recovery"), | |
| 51 | 55 | Self::MonitoringOffline => write!(f, "monitoring_offline"), | |
| 52 | 56 | Self::MonitoringRecovery => write!(f, "monitoring_recovery"), | |
| 53 | 57 | } | |
| @@ -74,6 +78,8 @@ impl std::str::FromStr for AlertCategory { | |||
| 74 | 78 | "latency_drift" => Ok(Self::LatencyDrift), | |
| 75 | 79 | "latency_recovery" => Ok(Self::LatencyRecovery), | |
| 76 | 80 | "test_duration_drift" => Ok(Self::TestDurationDrift), | |
| 81 | + | "cors_failure" => Ok(Self::CorsFailure), | |
| 82 | + | "cors_recovery" => Ok(Self::CorsRecovery), | |
| 77 | 83 | "monitoring_offline" => Ok(Self::MonitoringOffline), | |
| 78 | 84 | "monitoring_recovery" => Ok(Self::MonitoringRecovery), | |
| 79 | 85 | other => Err(format!("unknown alert category: {other}")), | |
| @@ -379,6 +385,24 @@ pub struct WhoisResult { | |||
| 379 | 385 | pub error: Option<String>, | |
| 380 | 386 | } | |
| 381 | 387 | ||
| 388 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 389 | + | pub struct CorsCheckResult { | |
| 390 | + | /// Config key identifying the monitored target. | |
| 391 | + | pub target: String, | |
| 392 | + | /// URL that was checked. | |
| 393 | + | pub url: String, | |
| 394 | + | /// Origin sent in the preflight request. | |
| 395 | + | pub origin: String, | |
| 396 | + | /// HTTP method sent in `Access-Control-Request-Method`. | |
| 397 | + | pub method: String, | |
| 398 | + | /// Whether the preflight response allows the origin and method. | |
| 399 | + | pub passes: bool, | |
| 400 | + | /// When this check was performed, in RFC 3339 format (UTC). | |
| 401 | + | pub checked_at: String, | |
| 402 | + | /// Error message if the preflight request failed. | |
| 403 | + | pub error: Option<String>, | |
| 404 | + | } | |
| 405 | + | ||
| 382 | 406 | impl LatencyStats { | |
| 383 | 407 | /// Compute latency statistics from a slice of response times. | |
| 384 | 408 | /// Returns `None` if the slice is empty. | |
| @@ -606,6 +630,8 @@ mod tests { | |||
| 606 | 630 | AlertCategory::LatencyDrift, | |
| 607 | 631 | AlertCategory::LatencyRecovery, | |
| 608 | 632 | AlertCategory::TestDurationDrift, | |
| 633 | + | AlertCategory::CorsFailure, | |
| 634 | + | AlertCategory::CorsRecovery, | |
| 609 | 635 | AlertCategory::MonitoringOffline, | |
| 610 | 636 | AlertCategory::MonitoringRecovery, | |
| 611 | 637 | ] { |
| @@ -376,7 +376,7 @@ async fn migration_fresh_db_reaches_latest_version() { | |||
| 376 | 376 | // A fresh in-memory DB should run all migrations and reach the latest version. | |
| 377 | 377 | let pool = db::connect_in_memory().await.unwrap(); | |
| 378 | 378 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 379 | - | assert_eq!(version, 7); | |
| 379 | + | assert_eq!(version, 8); | |
| 380 | 380 | ||
| 381 | 381 | // Verify the schema_version table has entries for each migration | |
| 382 | 382 | let rows = sqlx::query_as::<_, (i64, String)>( | |
| @@ -385,7 +385,7 @@ async fn migration_fresh_db_reaches_latest_version() { | |||
| 385 | 385 | .fetch_all(&pool) | |
| 386 | 386 | .await | |
| 387 | 387 | .unwrap(); | |
| 388 | - | assert_eq!(rows.len(), 7); | |
| 388 | + | assert_eq!(rows.len(), 8); | |
| 389 | 389 | assert_eq!(rows[0].0, 1); | |
| 390 | 390 | assert_eq!(rows[0].1, "initial schema"); | |
| 391 | 391 | assert_eq!(rows[1].0, 2); | |
| @@ -400,6 +400,8 @@ async fn migration_fresh_db_reaches_latest_version() { | |||
| 400 | 400 | assert_eq!(rows[5].1, "add dns_checks and whois_checks tables"); | |
| 401 | 401 | assert_eq!(rows[6].0, 7); | |
| 402 | 402 | assert_eq!(rows[6].1, "add test_details table"); | |
| 403 | + | assert_eq!(rows[7].0, 8); | |
| 404 | + | assert_eq!(rows[7].1, "add cors_checks table"); | |
| 403 | 405 | ||
| 404 | 406 | // Verify actual tables were created by inserting data | |
| 405 | 407 | let snapshot = HealthSnapshot { | |
| @@ -419,18 +421,18 @@ async fn migration_fresh_db_reaches_latest_version() { | |||
| 419 | 421 | async fn migration_already_current_is_idempotent() { | |
| 420 | 422 | // Running migrations on an already-migrated DB should be a no-op. | |
| 421 | 423 | let pool = db::connect_in_memory().await.unwrap(); | |
| 422 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 7); | |
| 424 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 8); | |
| 423 | 425 | ||
| 424 | 426 | // Run migrations again | |
| 425 | 427 | db::run_migrations(&pool).await.unwrap(); | |
| 426 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 7); | |
| 428 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 8); | |
| 427 | 429 | ||
| 428 | - | // schema_version should still have exactly seven entries (not duplicated) | |
| 430 | + | // schema_version should still have exactly eight entries (not duplicated) | |
| 429 | 431 | let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version") | |
| 430 | 432 | .fetch_one(&pool) | |
| 431 | 433 | .await | |
| 432 | 434 | .unwrap(); | |
| 433 | - | assert_eq!(count.0, 7); | |
| 435 | + | assert_eq!(count.0, 8); | |
| 434 | 436 | } | |
| 435 | 437 | ||
| 436 | 438 | #[tokio::test] | |
| @@ -490,7 +492,7 @@ async fn migration_detects_pre_migration_database() { | |||
| 490 | 492 | db::run_migrations(&pool).await.unwrap(); | |
| 491 | 493 | ||
| 492 | 494 | // Version should be 6 (stamped v1 + ran v2 + ran v3 + ran v4 + ran v5 + ran v6) | |
| 493 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 7); | |
| 495 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 8); | |
| 494 | 496 | ||
| 495 | 497 | // Description should indicate pre-existing | |
| 496 | 498 | let row = sqlx::query_as::<_, (String,)>( | |
| @@ -791,7 +793,7 @@ async fn tool_run_tests_no_test_config() { | |||
| 791 | 793 | async fn migration_v2_creates_alerts_table() { | |
| 792 | 794 | let pool = db::connect_in_memory().await.unwrap(); | |
| 793 | 795 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 794 | - | assert_eq!(version, 7); | |
| 796 | + | assert_eq!(version, 8); | |
| 795 | 797 | ||
| 796 | 798 | // Verify alerts table exists by inserting | |
| 797 | 799 | let id = db::insert_alert(&pool, "mnw", "health", Some("operational"), Some("error"), None) | |
| @@ -859,7 +861,7 @@ async fn prune_removes_old_alerts() { | |||
| 859 | 861 | async fn migration_v3_creates_tls_checks_table() { | |
| 860 | 862 | let pool = db::connect_in_memory().await.unwrap(); | |
| 861 | 863 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 862 | - | assert_eq!(version, 7); | |
| 864 | + | assert_eq!(version, 8); | |
| 863 | 865 | ||
| 864 | 866 | // Verify tls_checks table exists by inserting | |
| 865 | 867 | let status = pom::types::TlsStatus { | |
| @@ -1074,7 +1076,7 @@ cooldown_secs = 120 | |||
| 1074 | 1076 | async fn migration_v4_creates_incidents_table() { | |
| 1075 | 1077 | let pool = db::connect_in_memory().await.unwrap(); | |
| 1076 | 1078 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 1077 | - | assert_eq!(version, 7); | |
| 1079 | + | assert_eq!(version, 8); | |
| 1078 | 1080 | ||
| 1079 | 1081 | // Verify incidents table exists by inserting | |
| 1080 | 1082 | let id = db::insert_incident(&pool, "mnw", "operational", "degraded") | |
| @@ -1203,7 +1205,7 @@ async fn api_status_no_incidents_omits_fields() { | |||
| 1203 | 1205 | async fn migration_v5_creates_route_checks_table() { | |
| 1204 | 1206 | let pool = db::connect_in_memory().await.unwrap(); | |
| 1205 | 1207 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 1206 | - | assert_eq!(version, 7); | |
| 1208 | + | assert_eq!(version, 8); | |
| 1207 | 1209 | ||
| 1208 | 1210 | // Verify route_checks table exists by inserting | |
| 1209 | 1211 | let result = pom::checks::routes::RouteCheckResult { | |
| @@ -2203,7 +2205,7 @@ async fn peer_uuid_mismatch_updates_db_identity() { | |||
| 2203 | 2205 | async fn migration_v6_creates_dns_and_whois_tables() { | |
| 2204 | 2206 | let pool = db::connect_in_memory().await.unwrap(); | |
| 2205 | 2207 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 2206 | - | assert_eq!(version, 7); | |
| 2208 | + | assert_eq!(version, 8); | |
| 2207 | 2209 | ||
| 2208 | 2210 | // Verify dns_checks table exists | |
| 2209 | 2211 | let dns_result = DnsCheckResult { | |
| @@ -2442,6 +2444,91 @@ async fn whois_check_error_stored() { | |||
| 2442 | 2444 | } | |
| 2443 | 2445 | ||
| 2444 | 2446 | #[tokio::test] | |
| 2447 | + | async fn cors_check_insert_and_query() { | |
| 2448 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2449 | + | ||
| 2450 | + | let result = CorsCheckResult { | |
| 2451 | + | target: "mnw".to_string(), | |
| 2452 | + | url: "https://storage.example.com/bucket/probe".to_string(), | |
| 2453 | + | origin: "https://makenot.work".to_string(), | |
| 2454 | + | method: "PUT".to_string(), | |
| 2455 | + | passes: true, | |
| 2456 | + | checked_at: chrono::Utc::now().to_rfc3339(), | |
| 2457 | + | error: None, | |
| 2458 | + | }; | |
| 2459 | + | db::insert_cors_check(&pool, &result).await.unwrap(); | |
| 2460 | + | ||
| 2461 | + | let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap(); | |
| 2462 | + | assert_eq!(latest.len(), 1); | |
| 2463 | + | assert!(latest[0].passes); | |
| 2464 | + | assert_eq!(latest[0].url, "https://storage.example.com/bucket/probe"); | |
| 2465 | + | } | |
| 2466 | + | ||
| 2467 | + | #[tokio::test] | |
| 2468 | + | async fn cors_check_latest_per_url() { | |
| 2469 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2470 | + | ||
| 2471 | + | // Insert an old failing check | |
| 2472 | + | let r1 = CorsCheckResult { | |
| 2473 | + | target: "mnw".to_string(), | |
| 2474 | + | url: "https://storage.example.com/bucket/probe".to_string(), | |
| 2475 | + | origin: "https://makenot.work".to_string(), | |
| 2476 | + | method: "PUT".to_string(), | |
| 2477 | + | passes: false, | |
| 2478 | + | checked_at: "2026-03-01T00:00:00Z".to_string(), | |
| 2479 | + | error: Some("Missing Access-Control-Allow-Origin".to_string()), | |
| 2480 | + | }; | |
| 2481 | + | db::insert_cors_check(&pool, &r1).await.unwrap(); | |
| 2482 | + | ||
| 2483 | + | // Insert a newer passing check for same URL | |
| 2484 | + | let r2 = CorsCheckResult { | |
| 2485 | + | target: "mnw".to_string(), | |
| 2486 | + | url: "https://storage.example.com/bucket/probe".to_string(), | |
| 2487 | + | origin: "https://makenot.work".to_string(), | |
| 2488 | + | method: "PUT".to_string(), | |
| 2489 | + | passes: true, | |
| 2490 | + | checked_at: "2026-03-15T00:00:00Z".to_string(), | |
| 2491 | + | error: None, | |
| 2492 | + | }; | |
| 2493 | + | db::insert_cors_check(&pool, &r2).await.unwrap(); | |
| 2494 | + | ||
| 2495 | + | let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap(); | |
| 2496 | + | // Should return only the latest (passing) check per URL | |
| 2497 | + | assert_eq!(latest.len(), 1); | |
| 2498 | + | assert!(latest[0].passes); | |
| 2499 | + | } | |
| 2500 | + | ||
| 2501 | + | #[tokio::test] | |
| 2502 | + | async fn cors_check_filters_by_target() { | |
| 2503 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2504 | + | ||
| 2505 | + | let r1 = CorsCheckResult { | |
| 2506 | + | target: "mnw".to_string(), | |
| 2507 | + | url: "https://storage.example.com/bucket/probe".to_string(), | |
| 2508 | + | origin: "https://makenot.work".to_string(), | |
| 2509 | + | method: "PUT".to_string(), | |
| 2510 | + | passes: true, | |
| 2511 | + | checked_at: chrono::Utc::now().to_rfc3339(), | |
| 2512 | + | error: None, | |
| 2513 | + | }; | |
| 2514 | + | let r2 = CorsCheckResult { | |
| 2515 | + | target: "other".to_string(), | |
| 2516 | + | url: "https://other.example.com/probe".to_string(), | |
| 2517 | + | origin: "https://other.app".to_string(), | |
| 2518 | + | method: "PUT".to_string(), | |
| 2519 | + | passes: false, | |
| 2520 | + | checked_at: chrono::Utc::now().to_rfc3339(), | |
| 2521 | + | error: Some("Failed".to_string()), | |
| 2522 | + | }; | |
| 2523 | + | db::insert_cors_check(&pool, &r1).await.unwrap(); | |
| 2524 | + | db::insert_cors_check(&pool, &r2).await.unwrap(); | |
| 2525 | + | ||
| 2526 | + | let mnw_checks = db::get_latest_cors_checks(&pool, "mnw").await.unwrap(); | |
| 2527 | + | assert_eq!(mnw_checks.len(), 1); | |
| 2528 | + | assert_eq!(mnw_checks[0].target, "mnw"); | |
| 2529 | + | } | |
| 2530 | + | ||
| 2531 | + | #[tokio::test] | |
| 2445 | 2532 | async fn prune_removes_old_dns_checks() { | |
| 2446 | 2533 | let pool = db::connect_in_memory().await.unwrap(); | |
| 2447 | 2534 | ||
| @@ -2657,6 +2744,42 @@ domain = "makenot.work" | |||
| 2657 | 2744 | } | |
| 2658 | 2745 | ||
| 2659 | 2746 | #[test] | |
| 2747 | + | fn config_with_cors_parses() { | |
| 2748 | + | let toml_str = r#" | |
| 2749 | + | [targets.mnw] | |
| 2750 | + | label = "MakeNotWork" | |
| 2751 | + | ||
| 2752 | + | [[targets.mnw.cors]] | |
| 2753 | + | url = "https://example.com/bucket/probe" | |
| 2754 | + | origin = "https://myapp.com" | |
| 2755 | + | method = "PUT" | |
| 2756 | + | ||
| 2757 | + | [[targets.mnw.cors]] | |
| 2758 | + | url = "https://example.com/bucket/probe2" | |
| 2759 | + | origin = "https://myapp.com" | |
| 2760 | + | "#; | |
| 2761 | + | let config: pom::config::Config = toml::from_str(toml_str).unwrap(); | |
| 2762 | + | let mnw = config.get_target("mnw").unwrap(); | |
| 2763 | + | assert_eq!(mnw.cors.len(), 2); | |
| 2764 | + | assert_eq!(mnw.cors[0].url, "https://example.com/bucket/probe"); | |
| 2765 | + | assert_eq!(mnw.cors[0].origin, "https://myapp.com"); | |
| 2766 | + | assert_eq!(mnw.cors[0].method, "PUT"); | |
| 2767 | + | // Second entry uses default method | |
| 2768 | + | assert_eq!(mnw.cors[1].method, "PUT"); | |
| 2769 | + | } | |
| 2770 | + | ||
| 2771 | + | #[test] | |
| 2772 | + | fn config_no_cors_defaults_to_empty() { | |
| 2773 | + | let toml_str = r#" | |
| 2774 | + | [targets.mnw] | |
| 2775 | + | label = "MakeNotWork" | |
| 2776 | + | "#; | |
| 2777 | + | let config: pom::config::Config = toml::from_str(toml_str).unwrap(); | |
| 2778 | + | let mnw = config.get_target("mnw").unwrap(); | |
| 2779 | + | assert!(mnw.cors.is_empty()); | |
| 2780 | + | } | |
| 2781 | + | ||
| 2782 | + | #[test] | |
| 2660 | 2783 | fn config_no_dns_defaults_to_empty() { | |
| 2661 | 2784 | let toml_str = r#" | |
| 2662 | 2785 | [targets.mnw] |