max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
20 files changed,
+1099 insertions,
-33 deletions
| @@ -9,3 +9,16 @@ | |||
| 9 | 9 | [targets.mnw.health] | |
| 10 | 10 | url = "https://makenot.work/api/health" | |
| 11 | 11 | timeout_secs = 10 | |
| 12 | + | ||
| 13 | + | # Host trust-anchor freshness. Local to whichever machine PoM runs on, like | |
| 14 | + | # [targets.*.systemd] and [targets.*.backups], so it belongs on the instance | |
| 15 | + | # that runs the workloads rather than on a monitoring box watching from outside. | |
| 16 | + | # Every field has a working default; the section only has to exist. | |
| 17 | + | # | |
| 18 | + | # [targets.mnw.ca_bundle] | |
| 19 | + | # package = "ca-certificates" | |
| 20 | + | # bundle_path = "/etc/ssl/certs/ca-certificates.crt" | |
| 21 | + | # min_certs = 80 | |
| 22 | + | # update_stamp = "/var/lib/apt/periodic/update-success-stamp" | |
| 23 | + | # update_stamp_max_age_hours = 48 | |
| 24 | + | # interval_secs = 3600 |
| @@ -17,12 +17,12 @@ | |||
| 17 | 17 | | Module | File | Role | | |
| 18 | 18 | |--------|------|------| | |
| 19 | 19 | | `main` | `src/main.rs` | Entry point: parses CLI args, dispatches to CLI handler or MCP server | | |
| 20 | - | | `cli` | `src/cli/` | CLI command handlers. `mod.rs` dispatch, plus `serve.rs`, `status.rs`, `incident.rs`, `transition.rs`, and `tasks/` (one spawner per background loop: health, tls, dns, whois, routes, cors, backup, systemd, scan_pipeline, synckit_fleet, meta_alert, prune) | | |
| 20 | + | | `cli` | `src/cli/` | CLI command handlers. `mod.rs` dispatch, plus `serve.rs`, `status.rs`, `incident.rs`, `transition.rs`, and `tasks/` (one spawner per background loop: health, tls, dns, whois, routes, cors, backup, systemd, ca_bundle, scan_pipeline, synckit_fleet, meta_alert, prune) | | |
| 21 | 21 | | `config` | `src/config.rs` | TOML config loading, types for targets/peers/alerts/serve settings | | |
| 22 | 22 | | `types` | `src/types.rs` | Shared domain types: HealthSnapshot, TestRun, TlsStatus, LatencyStats, TestStaleness | | |
| 23 | - | | `db` | `src/db/` | SQLite persistence. `migrations.rs` holds the numbered schema migrations and pool construction; the rest is one query module per subject: health, test_runs, alerts, incidents, peers, tls, dns, whois, routes, cors, backup, systemd, scan_pipeline, synckit_fleet, maintenance | | |
| 23 | + | | `db` | `src/db/` | SQLite persistence. `migrations.rs` holds the numbered schema migrations and pool construction; the rest is one query module per subject: health, test_runs, alerts, incidents, peers, tls, dns, whois, routes, cors, backup, systemd, ca_bundle, scan_pipeline, synckit_fleet, maintenance | | |
| 24 | 24 | | `api` | `src/api.rs` | Axum HTTP API: status, trends, peer info, mesh view, bearer token auth middleware | | |
| 25 | - | | `alerts` | `src/alerts/` | Alerter struct: sends emails via Postmark on status transitions, with cooldown tracking. One module per alert kind: health, tls, latency, offline, peer, dns, whois, route, cors, backup, systemd, scan, test_duration | | |
| 25 | + | | `alerts` | `src/alerts/` | Alerter struct: sends emails via Postmark on status transitions, with cooldown tracking. One module per alert kind: health, tls, latency, offline, peer, dns, whois, route, cors, backup, systemd, ca_bundle, scan, test_duration | | |
| 26 | 26 | | `peer` | `src/peer.rs` | Peer mesh: identity management, heartbeat loops, grace period state machine, mesh state | | |
| 27 | 27 | | `status` | `src/status.rs` | PoM's projection onto the shared operator status payload | | |
| 28 | 28 | | `dashboard` | `src/dashboard.rs` | Optional HTML dashboard served at `GET /` | | |
| @@ -42,6 +42,7 @@ | |||
| 42 | 42 | | `checks::cors` | `src/checks/cors.rs` | CORS preflight: sends OPTIONS and checks Access-Control headers | | |
| 43 | 43 | | `checks::backup` | `src/checks/backup.rs` | Local filesystem backup verification: scans for PostgreSQL backup files, checks recency | | |
| 44 | 44 | | `checks::systemd` | `src/checks/systemd.rs` | Local systemd unit health: liveness, crash-loops, optionally any failed unit on the host | | |
| 45 | + | | `checks::ca_bundle` | `src/checks/ca_bundle.rs` | Local CA-bundle freshness: installed vs candidate package version, age of the last package-list update, certificate count against a floor | | |
| 45 | 46 | | `checks::scan_pipeline` | `src/checks/scan_pipeline.rs` | Polls a makenotwork instance's upload scan health and applies the audit thresholds | | |
| 46 | 47 | | `checks::synckit_fleet` | `src/checks/synckit_fleet.rs` | Polls a makenotwork instance for which SyncKit SDK versions are syncing | | |
| 47 | 48 | | `tools` | `src/tools/mod.rs` | MCP server definition (PomServer), tool registration via rmcp | | |
| @@ -137,6 +138,7 @@ | |||
| 137 | 138 | | `pending_alerts` | Alert outbox with retry state | alert_key, category, channel, subject, body, priority, source, attempts, next_retry_at | | |
| 138 | 139 | | `scan_pipeline_checks` | Upload scan queue depth and stuck-job counts | target, status, issues, queue_pending, queue_running, queue_stuck, held_total, checked_at | | |
| 139 | 140 | | `systemd_checks` | systemd unit state on a host | target, status, units, failed_units, issues, checked_at, error | | |
| 141 | + | | `ca_bundle_checks` | Host trust-anchor freshness | target, status, package, installed, candidate, cert_count, lists_age_hours, issues, checked_at, error | | |
| 140 | 142 | | `synckit_fleet_checks` | SyncKit device and version spread over a window | target, window_days, devices, versions, checked_at, error | | |
| 141 | 143 | ||
| 142 | 144 | Pre-migration databases are detected by the presence of the `health_checks` table and stamped as v1 without re-running the initial migration. |
| @@ -583,6 +583,9 @@ | |||
| 583 | 583 | days_remaining: r.days_remaining, | |
| 584 | 584 | checked_at: r.checked_at, | |
| 585 | 585 | error: r.error, | |
| 586 | + | webpki_trusted: r.webpki_trusted, | |
| 587 | + | platform_trusted: r.platform_trusted, | |
| 588 | + | platform_error: r.platform_error, | |
| 586 | 589 | }); | |
| 587 | 590 | ||
| 588 | 591 | let incident = db::get_open_incident(pool, &name) | |
| @@ -642,6 +645,17 @@ | |||
| 642 | 645 | error: s.error, | |
| 643 | 646 | }); | |
| 644 | 647 | ||
| 648 | + | let ca_bundle = db::get_latest_ca_bundle_check(pool, &name) | |
| 649 | + | .await | |
| 650 | + | .ok() | |
| 651 | + | .flatten() | |
| 652 | + | .map(|c| crate::status::CaBundleView { | |
| 653 | + | issues: c.issue_list(), | |
| 654 | + | status: c.status, | |
| 655 | + | checked_at: c.checked_at, | |
| 656 | + | error: c.error, | |
| 657 | + | }); | |
| 658 | + | ||
| 645 | 659 | let synckit_fleet = db::get_latest_synckit_fleet_check(pool, &name) | |
| 646 | 660 | .await | |
| 647 | 661 | .ok() | |
| @@ -742,6 +756,7 @@ | |||
| 742 | 756 | backups, | |
| 743 | 757 | scan_pipeline, | |
| 744 | 758 | systemd, | |
| 759 | + | ca_bundle, | |
| 745 | 760 | synckit_fleet, | |
| 746 | 761 | tests, | |
| 747 | 762 | dns, |
| @@ -279,6 +279,73 @@ | |||
| 279 | 279 | /// Local systemd daemon liveness / crash-loop / failed-unit check. `None` | |
| 280 | 280 | /// disables. Probes the host PoM runs on, not a remote target. | |
| 281 | 281 | pub systemd: Option<SystemdConfig>, | |
| 282 | + | /// Local CA-bundle freshness check. `None` disables. Like `systemd`, this | |
| 283 | + | /// probes the host PoM runs on rather than a remote target. | |
| 284 | + | pub ca_bundle: Option<CaBundleConfig>, | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// Local CA-bundle freshness monitoring for a host target. | |
| 288 | + | /// | |
| 289 | + | /// The trailing indicator for this already exists: the TLS check validates every | |
| 290 | + | /// target against the host trust store as well as the web PKI, so a bundle that | |
| 291 | + | /// has gone thin shows up as a chain the host rejects. This is the leading one. | |
| 292 | + | /// It answers "is the bundle drifting" before anything fails, which matters | |
| 293 | + | /// because multithreaded has no in-binary root fallback on any outbound path and | |
| 294 | + | /// would otherwise report the problem as a login outage. | |
| 295 | + | #[derive(Debug, Clone, Deserialize)] | |
| 296 | + | pub struct CaBundleConfig { | |
| 297 | + | /// Package providing the trust anchors. Defaults to "ca-certificates". | |
| 298 | + | #[serde(default = "default_ca_bundle_package")] | |
| 299 | + | pub package: String, | |
| 300 | + | /// Concatenated PEM bundle to count certificates in. Defaults to | |
| 301 | + | /// "/etc/ssl/certs/ca-certificates.crt". | |
| 302 | + | #[serde(default = "default_ca_bundle_path")] | |
| 303 | + | pub bundle_path: PathBuf, | |
| 304 | + | /// Fewer certificates than this reads as a truncated or emptied bundle. | |
| 305 | + | /// Defaults to 80, comfortably under the ~120 a stock Ubuntu carries and | |
| 306 | + | /// well above anything that would still be a working trust store. | |
| 307 | + | #[serde(default = "default_ca_bundle_min_certs")] | |
| 308 | + | pub min_certs: usize, | |
| 309 | + | /// Stamp file whose mtime records the last successful `apt update`. | |
| 310 | + | /// Defaults to "/var/lib/apt/periodic/update-success-stamp". | |
| 311 | + | #[serde(default = "default_ca_bundle_stamp")] | |
| 312 | + | pub update_stamp: PathBuf, | |
| 313 | + | /// A stamp older than this means the package lists are stale, so | |
| 314 | + | /// installed-equals-candidate proves nothing. Defaults to 48 hours. | |
| 315 | + | #[serde(default = "default_ca_bundle_stamp_max_age_hours")] | |
| 316 | + | pub update_stamp_max_age_hours: i64, | |
| 317 | + | /// Seconds between checks. Defaults to 1 hour. | |
| 318 | + | #[serde(default = "default_ca_bundle_interval")] | |
| 319 | + | pub interval_secs: u64, | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | fn default_ca_bundle_package() -> String { | |
| 323 | + | "ca-certificates".to_string() | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | fn default_ca_bundle_path() -> PathBuf { | |
| 327 | + | PathBuf::from("/etc/ssl/certs/ca-certificates.crt") | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | fn default_ca_bundle_min_certs() -> usize { | |
| 331 | + | // Ubuntu 24.04 ships ~121. The floor is not a freshness signal, it catches a | |
| 332 | + | // truncated or half-written bundle, which a version comparison calls fine. | |
| 333 | + | 80 | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | fn default_ca_bundle_stamp() -> PathBuf { | |
| 337 | + | PathBuf::from("/var/lib/apt/periodic/update-success-stamp") | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | fn default_ca_bundle_stamp_max_age_hours() -> i64 { | |
| 341 | + | // apt-daily.timer runs daily, so 48h allows one missed run before saying so. | |
| 342 | + | 48 | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | fn default_ca_bundle_interval() -> u64 { | |
| 346 | + | // 1 hour. The package moves a few times a year; this is about noticing | |
| 347 | + | // within a day, not within a minute. | |
| 348 | + | 3600 | |
| 282 | 349 | } | |
| 283 | 350 | ||
| 284 | 351 | /// A local git checkout to measure a target's live build against. |
| @@ -345,7 +345,7 @@ | |||
| 345 | 345 | /// Format prune results for CLI display. | |
| 346 | 346 | pub fn format_prune(result: &PruneResult, days: i64) -> String { | |
| 347 | 347 | format!( | |
| 348 | - | "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks, {} systemd checks, {} synckit fleet checks older than {} days.\n", | |
| 348 | + | "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks, {} systemd checks, {} CA bundle checks, {} synckit fleet checks older than {} days.\n", | |
| 349 | 349 | result.health, | |
| 350 | 350 | result.tests, | |
| 351 | 351 | result.test_details, | |
| @@ -358,6 +358,7 @@ | |||
| 358 | 358 | result.whois, | |
| 359 | 359 | result.backups, | |
| 360 | 360 | result.systemd, | |
| 361 | + | result.ca_bundle, | |
| 361 | 362 | result.synckit_fleet, | |
| 362 | 363 | days | |
| 363 | 364 | ) | |
| @@ -924,6 +925,10 @@ | |||
| 924 | 925 | issuer: "CN=Let's Encrypt".to_string(), | |
| 925 | 926 | checked_at: "2026-03-11T00:00:00Z".to_string(), | |
| 926 | 927 | error: None, | |
| 928 | + | webpki_trusted: Some(true), | |
| 929 | + | platform_trusted: Some(true), | |
| 930 | + | webpki_error: None, | |
| 931 | + | platform_error: None, | |
| 927 | 932 | }; | |
| 928 | 933 | let out = format_status_target( | |
| 929 | 934 | "mnw", | |
| @@ -957,6 +962,10 @@ | |||
| 957 | 962 | issuer: "CN=Let's Encrypt".to_string(), | |
| 958 | 963 | checked_at: "2026-03-11T00:00:00Z".to_string(), | |
| 959 | 964 | error: None, | |
| 965 | + | webpki_trusted: Some(true), | |
| 966 | + | platform_trusted: Some(true), | |
| 967 | + | webpki_error: None, | |
| 968 | + | platform_error: None, | |
| 960 | 969 | }; | |
| 961 | 970 | let out = format_status_target( | |
| 962 | 971 | "mnw", | |
| @@ -989,6 +998,10 @@ | |||
| 989 | 998 | issuer: String::new(), | |
| 990 | 999 | checked_at: "2026-03-11T00:00:00Z".to_string(), | |
| 991 | 1000 | error: Some("connection refused".to_string()), | |
| 1001 | + | webpki_trusted: Some(false), | |
| 1002 | + | platform_trusted: Some(false), | |
| 1003 | + | webpki_error: Some("TCP connect failed".to_string()), | |
| 1004 | + | platform_error: Some("TCP connect failed".to_string()), | |
| 992 | 1005 | }; | |
| 993 | 1006 | let out = format_status_target( | |
| 994 | 1007 | "mnw", | |
| @@ -1231,12 +1244,13 @@ | |||
| 1231 | 1244 | whois: 2, | |
| 1232 | 1245 | backups: 1, | |
| 1233 | 1246 | systemd: 6, | |
| 1247 | + | ca_bundle: 2, | |
| 1234 | 1248 | synckit_fleet: 7, | |
| 1235 | 1249 | }; | |
| 1236 | 1250 | let out = format_prune(&result, 30); | |
| 1237 | 1251 | assert_eq!( | |
| 1238 | 1252 | out, | |
| 1239 | - | "Pruned 5 health checks, 3 test runs, 15 test details, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks, 8 DNS checks, 2 WHOIS checks, 1 backup checks, 6 systemd checks, 7 synckit fleet checks older than 30 days.\n" | |
| 1253 | + | "Pruned 5 health checks, 3 test runs, 15 test details, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks, 8 DNS checks, 2 WHOIS checks, 1 backup checks, 6 systemd checks, 2 CA bundle checks, 7 synckit fleet checks older than 30 days.\n" | |
| 1240 | 1254 | ); | |
| 1241 | 1255 | } | |
| 1242 | 1256 | ||
| @@ -1255,10 +1269,11 @@ | |||
| 1255 | 1269 | whois: 0, | |
| 1256 | 1270 | backups: 0, | |
| 1257 | 1271 | systemd: 0, | |
| 1272 | + | ca_bundle: 0, | |
| 1258 | 1273 | synckit_fleet: 0, | |
| 1259 | 1274 | }; | |
| 1260 | 1275 | let out = format_prune(&result, 7); | |
| 1261 | - | assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 test details, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks, 0 DNS checks, 0 WHOIS checks, 0 backup checks, 0 systemd checks, 0 synckit fleet checks older than 7 days.")); | |
| 1276 | + | assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 test details, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks, 0 DNS checks, 0 WHOIS checks, 0 backup checks, 0 systemd checks, 0 CA bundle checks, 0 synckit fleet checks older than 7 days.")); | |
| 1262 | 1277 | } | |
| 1263 | 1278 | ||
| 1264 | 1279 | // format_mesh |
| @@ -90,6 +90,8 @@ | |||
| 90 | 90 | pub scan_pipeline: Option<ScanView>, | |
| 91 | 91 | /// Latest local systemd daemon-health check. `None` if not monitored. | |
| 92 | 92 | pub systemd: Option<SystemdView>, | |
| 93 | + | /// Latest local CA-bundle freshness check. `None` if not monitored. | |
| 94 | + | pub ca_bundle: Option<CaBundleView>, | |
| 93 | 95 | /// Latest SyncKit field-version readout. `None` if not monitored. | |
| 94 | 96 | pub synckit_fleet: Option<SyncKitFleetView>, | |
| 95 | 97 | /// Latest test run and PoM's staleness verdict. `None` if the target has no | |
| @@ -114,6 +116,11 @@ | |||
| 114 | 116 | pub days_remaining: i64, | |
| 115 | 117 | pub checked_at: String, | |
| 116 | 118 | pub error: Option<String>, | |
| 119 | + | /// Per-trust-store results. `None` means the reading predates migration 14, | |
| 120 | + | /// not that the probe failed. See [`tls_condition`]. | |
| 121 | + | pub webpki_trusted: Option<bool>, | |
| 122 | + | pub platform_trusted: Option<bool>, | |
| 123 | + | pub platform_error: Option<String>, | |
| 117 | 124 | } | |
| 118 | 125 | ||
| 119 | 126 | pub(crate) struct IncidentView { | |
| @@ -156,6 +163,15 @@ | |||
| 156 | 163 | pub error: Option<String>, | |
| 157 | 164 | } | |
| 158 | 165 | ||
| 166 | + | pub(crate) struct CaBundleView { | |
| 167 | + | /// One of "ok", "stale", "thin", "unknown", "error". | |
| 168 | + | pub status: String, | |
| 169 | + | /// The why behind a non-ok status, one line per fired signal. | |
| 170 | + | pub issues: Vec<String>, | |
| 171 | + | pub checked_at: String, | |
| 172 | + | pub error: Option<String>, | |
| 173 | + | } | |
| 174 | + | ||
| 159 | 175 | /// A SyncKit field-version readout. No status field: the reading cannot be | |
| 160 | 176 | /// unhealthy, only unavailable. See [`crate::checks::synckit_fleet`]. | |
| 161 | 177 | pub(crate) struct SyncKitFleetView { | |
| @@ -253,6 +269,9 @@ | |||
| 253 | 269 | if let Some(sd) = &target.systemd { | |
| 254 | 270 | conditions.push(systemd_condition(sd)); | |
| 255 | 271 | } | |
| 272 | + | if let Some(ca) = &target.ca_bundle { | |
| 273 | + | conditions.push(ca_bundle_condition(ca)); | |
| 274 | + | } | |
| 256 | 275 | if let Some(fleet) = &target.synckit_fleet { | |
| 257 | 276 | conditions.push(synckit_fleet_condition(fleet)); | |
| 258 | 277 | } | |
| @@ -354,6 +373,24 @@ | |||
| 354 | 373 | Status::Failed, | |
| 355 | 374 | format!("certificate expired {} days ago", -tls.days_remaining), | |
| 356 | 375 | ) | |
| 376 | + | } else if tls.webpki_trusted == Some(true) && tls.platform_trusted == Some(false) { | |
| 377 | + | // The chain is fine by the public web PKI and rejected by this host's | |
| 378 | + | // own trust store, so the fault is the host's trust anchors rather than | |
| 379 | + | // the certificate. That is the CA-bundle failure multithreaded would hit | |
| 380 | + | // on all three of its outbound paths, and it used to arrive as a login | |
| 381 | + | // outage because this reading was computed and thrown away. | |
| 382 | + | // | |
| 383 | + | // Ordered ahead of the expiry warning deliberately: both are degraded, | |
| 384 | + | // and a broken trust store is the rarer and more actionable of the two. | |
| 385 | + | ( | |
| 386 | + | Status::Degraded, | |
| 387 | + | match &tls.platform_error { | |
| 388 | + | Some(error) => { | |
| 389 | + | format!("host trust store rejects a chain the public web PKI accepts: {error}") | |
| 390 | + | } | |
| 391 | + | None => "host trust store rejects a chain the public web PKI accepts".into(), | |
| 392 | + | }, | |
| 393 | + | ) | |
| 357 | 394 | } else if tls.days_remaining <= TLS_EXPIRY_WARN_DAYS { | |
| 358 | 395 | ( | |
| 359 | 396 | Status::Degraded, | |
| @@ -498,6 +535,37 @@ | |||
| 498 | 535 | } | |
| 499 | 536 | } | |
| 500 | 537 | ||
| 538 | + | /// CA-bundle freshness as a condition. | |
| 539 | + | /// | |
| 540 | + | /// `thin` is the only red: a bundle below the certificate floor cannot validate | |
| 541 | + | /// the public web PKI, so outbound TLS on this host is already broken or one | |
| 542 | + | /// handshake from it. `stale` (a newer package is available) and `unknown` (the | |
| 543 | + | /// package lists are too old for the comparison to mean anything) are drift, and | |
| 544 | + | /// drift is yellow. A probe that could not run is yellow for the same reason the | |
| 545 | + | /// SyncKit readout is: PoM has stopped being able to answer the question, which | |
| 546 | + | /// is worth saying out loud but is not itself an outage. | |
| 547 | + | fn ca_bundle_condition(ca: &CaBundleView) -> Condition { | |
| 548 | + | let status = match ca.status.as_str() { | |
| 549 | + | "ok" => Status::Ok, | |
| 550 | + | "thin" => Status::Failed, | |
| 551 | + | "stale" | "unknown" | "error" => Status::Degraded, | |
| 552 | + | _ => Status::Unknown, | |
| 553 | + | }; | |
| 554 | + | let detail = if let Some(error) = &ca.error { | |
| 555 | + | format!("probe error: {error}") | |
| 556 | + | } else if !ca.issues.is_empty() { | |
| 557 | + | ca.issues.join("; ") | |
| 558 | + | } else { | |
| 559 | + | "trust anchors current".into() | |
| 560 | + | }; | |
| 561 | + | Condition { | |
| 562 | + | condition_type: "ca_bundle".into(), | |
| 563 | + | status, | |
| 564 | + | since: parse_instant(&ca.checked_at), | |
| 565 | + | detail: Some(detail), | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | ||
| 501 | 569 | /// The SyncKit field-version readout as a condition. Only two outcomes, and | |
| 502 | 570 | /// neither of them depends on which versions came back: `ok` when the reading was | |
| 503 | 571 | /// taken (the detail is the distribution), `degraded` when it could not be. No | |
| @@ -786,6 +854,7 @@ | |||
| 786 | 854 | backups: Vec::new(), | |
| 787 | 855 | scan_pipeline: None, | |
| 788 | 856 | systemd: None, | |
| 857 | + | ca_bundle: None, | |
| 789 | 858 | synckit_fleet: None, | |
| 790 | 859 | tests: None, | |
| 791 | 860 | dns: None, | |
| @@ -882,6 +951,9 @@ | |||
| 882 | 951 | days_remaining: 9, | |
| 883 | 952 | checked_at: checked_at(), | |
| 884 | 953 | error: None, | |
| 954 | + | webpki_trusted: Some(true), | |
| 955 | + | platform_trusted: Some(true), | |
| 956 | + | platform_error: None, | |
| 885 | 957 | }); | |
| 886 | 958 | let p = payload(&[t], now()); | |
| 887 | 959 | ||
| @@ -904,6 +976,9 @@ | |||
| 904 | 976 | days_remaining: -3, | |
| 905 | 977 | checked_at: checked_at(), | |
| 906 | 978 | error: None, | |
| 979 | + | webpki_trusted: Some(true), | |
| 980 | + | platform_trusted: Some(true), | |
| 981 | + | platform_error: None, | |
| 907 | 982 | }); | |
| 908 | 983 | let p = payload(&[t], now()); | |
| 909 | 984 | ||
| @@ -930,6 +1005,124 @@ | |||
| 930 | 1005 | days_remaining: 60, | |
| 931 | 1006 | checked_at: checked_at(), | |
| 932 | 1007 | error: None, | |
| 1008 | + | webpki_trusted: Some(true), | |
| 1009 | + | platform_trusted: Some(true), | |
| 1010 | + | platform_error: None, | |
| 1011 | + | }); | |
| 1012 | + | let p = payload(&[t], now()); | |
| 1013 | + | ||
| 1014 | + | let n = node(&p, "target:mnw"); | |
| 1015 | + | assert_eq!(n.status, Status::Ok); | |
| 1016 | + | let tls = n | |
| 1017 | + | .conditions | |
| 1018 | + | .iter() | |
| 1019 | + | .find(|c| c.condition_type == "tls") | |
| 1020 | + | .unwrap(); | |
| 1021 | + | assert_eq!(tls.status, Status::Ok); | |
| 1022 | + | assert!(tls.detail.as_deref().unwrap().contains("60 days remaining")); | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | #[test] | |
| 1026 | + | fn a_host_trust_store_that_rejects_a_publicly_valid_chain_degrades_the_target() { | |
| 1027 | + | // multithreaded takes its outbound trust anchors from the host CA | |
| 1028 | + | // bundle on every path it has, with no in-binary fallback. A bundle | |
| 1029 | + | // that goes stale or thin therefore breaks OAuth, link previews and S3 | |
| 1030 | + | // at once while the certificates themselves are perfectly good, which | |
| 1031 | + | // is why the two stores are reported separately rather than folded. | |
| 1032 | + | let mut t = healthy("mnw"); | |
| 1033 | + | t.tls = Some(TlsView { | |
| 1034 | + | valid: true, | |
| 1035 | + | days_remaining: 60, | |
| 1036 | + | checked_at: checked_at(), | |
| 1037 | + | error: None, | |
| 1038 | + | webpki_trusted: Some(true), | |
| 1039 | + | platform_trusted: Some(false), | |
| 1040 | + | platform_error: Some("invalid peer certificate: UnknownIssuer".into()), | |
| 1041 | + | }); | |
| 1042 | + | let p = payload(&[t], now()); | |
| 1043 | + | ||
| 1044 | + | let n = node(&p, "target:mnw"); | |
| 1045 | + | assert_eq!(n.status, Status::Degraded); | |
| 1046 | + | let tls = n | |
| 1047 | + | .conditions | |
| 1048 | + | .iter() | |
| 1049 | + | .find(|c| c.condition_type == "tls") | |
| 1050 | + | .unwrap(); | |
| 1051 | + | assert_eq!(tls.status, Status::Degraded); | |
| 1052 | + | let detail = tls.detail.as_deref().unwrap(); | |
| 1053 | + | assert!(detail.contains("host trust store")); | |
| 1054 | + | assert!(detail.contains("UnknownIssuer")); | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | #[test] | |
| 1058 | + | fn a_thin_ca_bundle_fails_the_target_and_a_stale_one_only_degrades_it() { | |
| 1059 | + | // The severity split is the point: a bundle below the certificate floor | |
| 1060 | + | // means outbound TLS is broken now, while a package one release behind | |
| 1061 | + | // is drift. Collapsing them would either page on drift or bury an outage. | |
| 1062 | + | let mut thin = healthy("mnw"); | |
| 1063 | + | thin.ca_bundle = Some(CaBundleView { | |
| 1064 | + | status: "thin".into(), | |
| 1065 | + | issues: vec!["bundle holds 3 certificates, below the floor of 80".into()], | |
| 1066 | + | checked_at: checked_at(), | |
| 1067 | + | error: None, | |
| 1068 | + | }); | |
| 1069 | + | assert_eq!( | |
| 1070 | + | node(&payload(&[thin], now()), "target:mnw").status, | |
| 1071 | + | Status::Failed | |
| 1072 | + | ); | |
| 1073 | + | ||
| 1074 | + | let mut stale = healthy("mnw"); | |
| 1075 | + | stale.ca_bundle = Some(CaBundleView { | |
| 1076 | + | status: "stale".into(), | |
| 1077 | + | issues: vec!["ca-certificates 20260601 installed, 20261101 available".into()], | |
| 1078 | + | checked_at: checked_at(), | |
| 1079 | + | error: None, | |
| 1080 | + | }); | |
| 1081 | + | let p = payload(&[stale], now()); | |
| 1082 | + | let n = node(&p, "target:mnw"); | |
| 1083 | + | assert_eq!(n.status, Status::Degraded); | |
| 1084 | + | let ca = n | |
| 1085 | + | .conditions | |
| 1086 | + | .iter() | |
| 1087 | + | .find(|c| c.condition_type == "ca_bundle") | |
| 1088 | + | .unwrap(); | |
| 1089 | + | assert!(ca.detail.as_deref().unwrap().contains("20261101 available")); | |
| 1090 | + | } | |
| 1091 | + | ||
| 1092 | + | #[test] | |
| 1093 | + | fn a_ca_bundle_probe_that_could_not_run_says_so_rather_than_reading_green() { | |
| 1094 | + | let mut t = healthy("mnw"); | |
| 1095 | + | t.ca_bundle = Some(CaBundleView { | |
| 1096 | + | status: "error".into(), | |
| 1097 | + | issues: Vec::new(), | |
| 1098 | + | checked_at: checked_at(), | |
| 1099 | + | error: Some("apt-cache policy failed to run: No such file or directory".into()), | |
| 1100 | + | }); | |
| 1101 | + | let p = payload(&[t], now()); | |
| 1102 | + | let n = node(&p, "target:mnw"); | |
| 1103 | + | assert_eq!(n.status, Status::Degraded); | |
| 1104 | + | let ca = n | |
| 1105 | + | .conditions | |
| 1106 | + | .iter() | |
| 1107 | + | .find(|c| c.condition_type == "ca_bundle") | |
| 1108 | + | .unwrap(); | |
| 1109 | + | assert!(ca.detail.as_deref().unwrap().contains("probe error")); | |
| 1110 | + | } | |
| 1111 | + | ||
| 1112 | + | #[test] | |
| 1113 | + | fn a_pre_migration_tls_row_reports_expiry_and_claims_nothing_about_trust() { | |
| 1114 | + | // Rows written before the trust columns existed carry NULL, not false. | |
| 1115 | + | // Reading those as "untrusted" would light up every target on the first | |
| 1116 | + | // run after an upgrade, which trains the eye to ignore the condition. | |
| 1117 | + | let mut t = healthy("mnw"); | |
| 1118 | + | t.tls = Some(TlsView { | |
| 1119 | + | valid: true, | |
| 1120 | + | days_remaining: 60, | |
| 1121 | + | checked_at: checked_at(), | |
| 1122 | + | error: None, | |
| 1123 | + | webpki_trusted: None, | |
| 1124 | + | platform_trusted: None, | |
| 1125 | + | platform_error: None, | |
| 933 | 1126 | }); | |
| 934 | 1127 | let p = payload(&[t], now()); | |
| 935 | 1128 | ||
| @@ -1038,6 +1231,9 @@ | |||
| 1038 | 1231 | days_remaining: -1, | |
| 1039 | 1232 | checked_at: checked_at(), | |
| 1040 | 1233 | error: None, | |
| 1234 | + | webpki_trusted: Some(true), | |
| 1235 | + | platform_trusted: Some(true), | |
| 1236 | + | platform_error: None, | |
| 1041 | 1237 | }); | |
| 1042 | 1238 | let p = payload(&[t], now()); | |
| 1043 | 1239 |
| @@ -33,6 +33,8 @@ | |||
| 33 | 33 | ScanPipelineRecovery, | |
| 34 | 34 | SystemdFailure, | |
| 35 | 35 | SystemdRecovery, | |
| 36 | + | CaBundleStale, | |
| 37 | + | CaBundleRecovery, | |
| 36 | 38 | MonitoringOffline, | |
| 37 | 39 | MonitoringRecovery, | |
| 38 | 40 | } | |
| @@ -64,6 +66,8 @@ | |||
| 64 | 66 | Self::ScanPipelineRecovery => write!(f, "scan_pipeline_recovery"), | |
| 65 | 67 | Self::SystemdFailure => write!(f, "systemd_failure"), | |
| 66 | 68 | Self::SystemdRecovery => write!(f, "systemd_recovery"), | |
| 69 | + | Self::CaBundleStale => write!(f, "ca_bundle_stale"), | |
| 70 | + | Self::CaBundleRecovery => write!(f, "ca_bundle_recovery"), | |
| 67 | 71 | Self::MonitoringOffline => write!(f, "monitoring_offline"), | |
| 68 | 72 | Self::MonitoringRecovery => write!(f, "monitoring_recovery"), | |
| 69 | 73 | } | |
| @@ -98,6 +102,8 @@ | |||
| 98 | 102 | "scan_pipeline_recovery" => Ok(Self::ScanPipelineRecovery), | |
| 99 | 103 | "systemd_failure" => Ok(Self::SystemdFailure), | |
| 100 | 104 | "systemd_recovery" => Ok(Self::SystemdRecovery), | |
| 105 | + | "ca_bundle_stale" => Ok(Self::CaBundleStale), | |
| 106 | + | "ca_bundle_recovery" => Ok(Self::CaBundleRecovery), | |
| 101 | 107 | "monitoring_offline" => Ok(Self::MonitoringOffline), | |
| 102 | 108 | "monitoring_recovery" => Ok(Self::MonitoringRecovery), | |
| 103 | 109 | other => Err(format!("unknown alert category: {other}")), | |
| @@ -579,6 +585,32 @@ | |||
| 579 | 585 | pub error: Option<String>, | |
| 580 | 586 | } | |
| 581 | 587 | ||
| 588 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 589 | + | pub struct CaBundleCheckResult { | |
| 590 | + | /// Config key identifying the monitored host target. | |
| 591 | + | pub target: String, | |
| 592 | + | /// Overall status: "ok", "stale", "thin", "unknown", or "error". | |
| 593 | + | pub status: String, | |
| 594 | + | /// Package providing the trust anchors (e.g. "ca-certificates"). | |
| 595 | + | pub package: String, | |
| 596 | + | /// Version currently installed, as dpkg reports it. | |
| 597 | + | pub installed: Option<String>, | |
| 598 | + | /// Version apt would install now. Equal to `installed` on a current host. | |
| 599 | + | pub candidate: Option<String>, | |
| 600 | + | /// Certificates counted in the concatenated PEM bundle. | |
| 601 | + | pub cert_count: Option<i64>, | |
| 602 | + | /// Age in whole hours of the last successful package-list update. `None` | |
| 603 | + | /// when the stamp file is absent, which is itself reported. | |
| 604 | + | pub lists_age_hours: Option<i64>, | |
| 605 | + | /// One short line per fired condition, in the order they were evaluated. | |
| 606 | + | pub issues: Vec<String>, | |
| 607 | + | /// When this check was performed, in RFC 3339 format (UTC). | |
| 608 | + | pub checked_at: String, | |
| 609 | + | /// Error message if the probe itself could not run. `None` when the probe | |
| 610 | + | /// worked and merely found something wrong (that goes in `issues`). | |
| 611 | + | pub error: Option<String>, | |
| 612 | + | } | |
| 613 | + | ||
| 582 | 614 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 583 | 615 | pub struct BackupCheckResult { | |
| 584 | 616 | /// Config key identifying the monitored target. |
| @@ -413,7 +413,7 @@ | |||
| 413 | 413 | // A fresh in-memory DB should run all migrations and reach the latest version. | |
| 414 | 414 | let pool = db::connect_in_memory().await.unwrap(); | |
| 415 | 415 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 416 | - | assert_eq!(version, 13); | |
| 416 | + | assert_eq!(version, 15); | |
| 417 | 417 | ||
| 418 | 418 | // Verify the schema_version table has entries for each migration | |
| 419 | 419 | let rows = sqlx::query_as::<_, (i64, String)>( | |
| @@ -422,7 +422,7 @@ | |||
| 422 | 422 | .fetch_all(&pool) | |
| 423 | 423 | .await | |
| 424 | 424 | .unwrap(); | |
| 425 | - | assert_eq!(rows.len(), 13); | |
| 425 | + | assert_eq!(rows.len(), 15); | |
| 426 | 426 | assert_eq!(rows[0].0, 1); | |
| 427 | 427 | assert_eq!(rows[0].1, "initial schema"); | |
| 428 | 428 | assert_eq!(rows[1].0, 2); | |
| @@ -449,6 +449,10 @@ | |||
| 449 | 449 | assert_eq!(rows[11].1, "add systemd_checks table"); | |
| 450 | 450 | assert_eq!(rows[12].0, 13); | |
| 451 | 451 | assert_eq!(rows[12].1, "add synckit_fleet_checks table"); | |
| 452 | + | assert_eq!(rows[13].0, 14); | |
| 453 | + | assert_eq!(rows[13].1, "record per-trust-store results on tls_checks"); | |
| 454 | + | assert_eq!(rows[14].0, 15); | |
| 455 | + | assert_eq!(rows[14].1, "add ca_bundle_checks table"); | |
| 452 | 456 | ||
| 453 | 457 | // Verify actual tables were created by inserting data | |
| 454 | 458 | let snapshot = HealthSnapshot { | |
| @@ -468,18 +472,18 @@ | |||
| 468 | 472 | async fn migration_already_current_is_idempotent() { | |
| 469 | 473 | // Running migrations on an already-migrated DB should be a no-op. | |
| 470 | 474 | let pool = db::connect_in_memory().await.unwrap(); | |
| 471 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 13); | |
| 475 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15); | |
| 472 | 476 | ||
| 473 | 477 | // Run migrations again | |
| 474 | 478 | db::run_migrations(&pool).await.unwrap(); | |
| 475 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 13); | |
| 479 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15); | |
| 476 | 480 | ||
| 477 | 481 | // schema_version should still have exactly thirteen entries (not duplicated) | |
| 478 | 482 | let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version") | |
| 479 | 483 | .fetch_one(&pool) | |
| 480 | 484 | .await | |
| 481 | 485 | .unwrap(); | |
| 482 | - | assert_eq!(count.0, 13); | |
| 486 | + | assert_eq!(count.0, 15); | |
| 483 | 487 | } | |
| 484 | 488 | ||
| 485 | 489 | #[tokio::test] | |
| @@ -535,11 +539,11 @@ | |||
| 535 | 539 | .await | |
| 536 | 540 | .unwrap(); | |
| 537 | 541 | ||
| 538 | - | // Now run migrations, should detect existing tables, stamp as v1, then run v2+v3+v4+v5+v6 | |
| 542 | + | // Now run migrations, should detect existing tables, stamp as v1, then run v2..v15 | |
| 539 | 543 | db::run_migrations(&pool).await.unwrap(); | |
| 540 | 544 | ||
| 541 | - | // Version should be 13 (stamped v1 + ran v2..v13) | |
| 542 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 13); | |
| 545 | + | // Version should be 15 (stamped v1 + ran v2..v15) | |
| 546 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15); | |
| 543 | 547 | ||
| 544 | 548 | // Description should indicate pre-existing | |
| 545 | 549 | let row = | |
| @@ -860,7 +864,7 @@ | |||
| 860 | 864 | async fn migration_v2_creates_alerts_table() { | |
| 861 | 865 | let pool = db::connect_in_memory().await.unwrap(); | |
| 862 | 866 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 863 | - | assert_eq!(version, 13); | |
| 867 | + | assert_eq!(version, 15); | |
| 864 | 868 | ||
| 865 | 869 | // Verify alerts table exists by inserting | |
| 866 | 870 | let id = db::insert_alert( | |
| @@ -946,7 +950,7 @@ | |||
| 946 | 950 | async fn migration_v3_creates_tls_checks_table() { | |
| 947 | 951 | let pool = db::connect_in_memory().await.unwrap(); | |
| 948 | 952 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 949 | - | assert_eq!(version, 13); | |
| 953 | + | assert_eq!(version, 15); | |
| 950 | 954 | ||
| 951 | 955 | // Verify tls_checks table exists by inserting | |
| 952 | 956 | let status = pom::types::TlsStatus { | |
| @@ -1184,7 +1188,7 @@ | |||
| 1184 | 1188 | async fn migration_v4_creates_incidents_table() { | |
| 1185 | 1189 | let pool = db::connect_in_memory().await.unwrap(); | |
| 1186 | 1190 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 1187 | - | assert_eq!(version, 13); | |
| 1191 | + | assert_eq!(version, 15); | |
| 1188 | 1192 | ||
| 1189 | 1193 | // Verify incidents table exists by inserting | |
| 1190 | 1194 | let id = db::insert_incident(&pool, "mnw", "operational", "degraded") | |
| @@ -1324,7 +1328,7 @@ | |||
| 1324 | 1328 | async fn migration_v5_creates_route_checks_table() { | |
| 1325 | 1329 | let pool = db::connect_in_memory().await.unwrap(); | |
| 1326 | 1330 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 1327 | - | assert_eq!(version, 13); | |
| 1331 | + | assert_eq!(version, 15); | |
| 1328 | 1332 | ||
| 1329 | 1333 | // Verify route_checks table exists by inserting | |
| 1330 | 1334 | let result = pom::checks::routes::RouteCheckResult { | |
| @@ -2473,7 +2477,7 @@ | |||
| 2473 | 2477 | async fn migration_v6_creates_dns_and_whois_tables() { | |
| 2474 | 2478 | let pool = db::connect_in_memory().await.unwrap(); | |
| 2475 | 2479 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 2476 | - | assert_eq!(version, 13); | |
| 2480 | + | assert_eq!(version, 15); | |
| 2477 | 2481 | ||
| 2478 | 2482 | // Verify dns_checks table exists | |
| 2479 | 2483 | let dns_result = DnsCheckResult { |
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | use crate::types::AlertCategory; | |
| 12 | 12 | ||
| 13 | 13 | mod backup; | |
| 14 | + | mod ca_bundle; | |
| 14 | 15 | mod cors; | |
| 15 | 16 | mod dns; | |
| 16 | 17 | mod health; | |
| @@ -82,15 +83,18 @@ | |||
| 82 | 83 | /// rejects the push with 422). | |
| 83 | 84 | fn mnw_kind(category: AlertCategory) -> &'static str { | |
| 84 | 85 | use AlertCategory::{ | |
| 85 | - | BackupRecovery, BackupStale, CorsFailure, CorsRecovery, DnsMismatch, DnsRecovery, Health, | |
| 86 | - | LatencyDrift, LatencyRecovery, MonitoringOffline, MonitoringRecovery, PeerMissing, | |
| 87 | - | PeerRecovery, Recovery, RouteFailure, RouteRecovery, ScanPipelineDegraded, | |
| 88 | - | ScanPipelineRecovery, SystemdFailure, SystemdRecovery, TestDurationDrift, TlsError, | |
| 89 | - | TlsExpiry, TlsRecovery, WhoisError, WhoisExpiry, | |
| 86 | + | BackupRecovery, BackupStale, CaBundleRecovery, CaBundleStale, CorsFailure, CorsRecovery, | |
| 87 | + | DnsMismatch, DnsRecovery, Health, LatencyDrift, LatencyRecovery, MonitoringOffline, | |
| 88 | + | MonitoringRecovery, PeerMissing, PeerRecovery, Recovery, RouteFailure, RouteRecovery, | |
| 89 | + | ScanPipelineDegraded, ScanPipelineRecovery, SystemdFailure, SystemdRecovery, | |
| 90 | + | TestDurationDrift, TlsError, TlsExpiry, TlsRecovery, WhoisError, WhoisExpiry, | |
| 90 | 91 | }; | |
| 91 | 92 | match category { | |
| 92 | 93 | Health | Recovery => "health", | |
| 93 | - | TlsExpiry | TlsError | TlsRecovery => "tls", | |
| 94 | + | // The host CA bundle folds onto the "tls" domain: it is a trust-anchor | |
| 95 | + | // problem, and every symptom it produces is a failed handshake. Adding a | |
| 96 | + | // domain would need a matching MNW `AlertKind` or the ingest 422s. | |
| 97 | + | TlsExpiry | TlsError | TlsRecovery | CaBundleStale | CaBundleRecovery => "tls", | |
| 94 | 98 | DnsMismatch | DnsRecovery => "dns", | |
| 95 | 99 | WhoisExpiry | WhoisError => "whois", | |
| 96 | 100 | LatencyDrift | LatencyRecovery | TestDurationDrift => "latency", |
| @@ -1,8 +1,9 @@ | |||
| 1 | 1 | //! Check implementations, one module per probe kind: health, TLS, DNS, WHOIS, | |
| 2 | - | //! routes, CORS, backups, SSH, port scans, local systemd units, the SyncKit | |
| 3 | - | //! field-version readout, plus latency-drift analysis. | |
| 2 | + | //! routes, CORS, backups, SSH, port scans, local systemd units, the local CA | |
| 3 | + | //! bundle, the SyncKit field-version readout, plus latency-drift analysis. | |
| 4 | 4 | ||
| 5 | 5 | pub mod backup; | |
| 6 | + | pub mod ca_bundle; | |
| 6 | 7 | pub mod cors; | |
| 7 | 8 | pub mod dns; | |
| 8 | 9 | pub mod drift; |
| @@ -115,6 +115,12 @@ | |||
| 115 | 115 | &token, | |
| 116 | 116 | alerter.as_ref(), | |
| 117 | 117 | )); | |
| 118 | + | handles.extend(tasks::spawn_ca_bundle_tasks( | |
| 119 | + | config, | |
| 120 | + | pool, | |
| 121 | + | &token, | |
| 122 | + | alerter.as_ref(), | |
| 123 | + | )); | |
| 118 | 124 | // No alerter: the fleet readout has nothing to alert on. See the task module. | |
| 119 | 125 | handles.extend(tasks::spawn_synckit_fleet_tasks(config, pool, &token)); | |
| 120 | 126 | handles.push(tasks::spawn_prune_task(pool, prune_days, &token)); |