Skip to main content

max / pom

Add dashboard UI, DNS/WHOIS monitoring, alert enhancements (v0.2.5) Dashboard: opt-in HTML page at GET / with inline CSS/JS matching MNW warm beige theme. Shows target cards, uptime, latency, TLS/DNS/WHOIS status, incidents, peer mesh. Auto-refreshes every 30s via existing API endpoints. Enabled via `dashboard = true` in [serve] config. Also includes DNS record verification, WHOIS domain expiry monitoring, and per-type alert cooldown keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-16 02:35 UTC
Commit: c0f3d590055ac96bdbb27cd536ffc17ca9e07727
Parent: a90d9a1
18 files changed, +2422 insertions, -98 deletions
M Cargo.lock +175 -4
@@ -150,6 +150,17 @@ dependencies = [
150 150 ]
151 151
152 152 [[package]]
153 + name = "async-trait"
154 + version = "0.1.89"
155 + source = "registry+https://github.com/rust-lang/crates.io-index"
156 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
157 + dependencies = [
158 + "proc-macro2",
159 + "quote",
160 + "syn",
161 + ]
162 +
163 + [[package]]
153 164 name = "atoi"
154 165 version = "2.0.0"
155 166 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -435,6 +446,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
435 446 checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
436 447
437 448 [[package]]
449 + name = "critical-section"
450 + version = "1.2.0"
451 + source = "registry+https://github.com/rust-lang/crates.io-index"
452 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
453 +
454 + [[package]]
455 + name = "crossbeam-channel"
456 + version = "0.5.15"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
459 + dependencies = [
460 + "crossbeam-utils",
461 + ]
462 +
463 + [[package]]
464 + name = "crossbeam-epoch"
465 + version = "0.9.18"
466 + source = "registry+https://github.com/rust-lang/crates.io-index"
467 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
468 + dependencies = [
469 + "crossbeam-utils",
470 + ]
471 +
472 + [[package]]
438 473 name = "crossbeam-queue"
439 474 version = "0.3.12"
440 475 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -585,6 +620,18 @@ dependencies = [
585 620 ]
586 621
587 622 [[package]]
623 + name = "enum-as-inner"
624 + version = "0.6.1"
625 + source = "registry+https://github.com/rust-lang/crates.io-index"
626 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
627 + dependencies = [
628 + "heck",
629 + "proc-macro2",
630 + "quote",
631 + "syn",
632 + ]
633 +
634 + [[package]]
588 635 name = "equivalent"
589 636 version = "1.0.2"
590 637 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -848,6 +895,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
848 895 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
849 896
850 897 [[package]]
898 + name = "hickory-proto"
899 + version = "0.25.2"
900 + source = "registry+https://github.com/rust-lang/crates.io-index"
901 + checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
902 + dependencies = [
903 + "async-trait",
904 + "cfg-if",
905 + "data-encoding",
906 + "enum-as-inner",
907 + "futures-channel",
908 + "futures-io",
909 + "futures-util",
910 + "idna",
911 + "ipnet",
912 + "once_cell",
913 + "rand 0.9.2",
914 + "ring",
915 + "thiserror 2.0.18",
916 + "tinyvec",
917 + "tokio",
918 + "tracing",
919 + "url",
920 + ]
921 +
922 + [[package]]
923 + name = "hickory-resolver"
924 + version = "0.25.2"
925 + source = "registry+https://github.com/rust-lang/crates.io-index"
926 + checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
927 + dependencies = [
928 + "cfg-if",
929 + "futures-util",
930 + "hickory-proto",
931 + "ipconfig",
932 + "moka",
933 + "once_cell",
934 + "parking_lot",
935 + "rand 0.9.2",
936 + "resolv-conf",
937 + "smallvec",
938 + "thiserror 2.0.18",
939 + "tokio",
940 + "tracing",
941 + ]
942 +
943 + [[package]]
851 944 name = "hkdf"
852 945 version = "0.12.4"
853 946 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -986,7 +1079,7 @@ dependencies = [
986 1079 "libc",
987 1080 "percent-encoding",
988 1081 "pin-project-lite",
989 - "socket2",
1082 + "socket2 0.6.3",
990 1083 "tokio",
991 1084 "tower-service",
992 1085 "tracing",
@@ -1137,6 +1230,18 @@ dependencies = [
1137 1230 ]
1138 1231
1139 1232 [[package]]
1233 + name = "ipconfig"
1234 + version = "0.3.2"
1235 + source = "registry+https://github.com/rust-lang/crates.io-index"
1236 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
1237 + dependencies = [
1238 + "socket2 0.5.10",
1239 + "widestring",
1240 + "windows-sys 0.48.0",
1241 + "winreg",
1242 + ]
1243 +
1244 + [[package]]
1140 1245 name = "ipnet"
1141 1246 version = "2.12.0"
1142 1247 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1316,6 +1421,23 @@ dependencies = [
1316 1421 ]
1317 1422
1318 1423 [[package]]
1424 + name = "moka"
1425 + version = "0.12.14"
1426 + source = "registry+https://github.com/rust-lang/crates.io-index"
1427 + checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
1428 + dependencies = [
1429 + "crossbeam-channel",
1430 + "crossbeam-epoch",
1431 + "crossbeam-utils",
1432 + "equivalent",
1433 + "parking_lot",
1434 + "portable-atomic",
1435 + "smallvec",
1436 + "tagptr",
1437 + "uuid",
1438 + ]
1439 +
1440 + [[package]]
1319 1441 name = "nom"
1320 1442 version = "7.1.3"
1321 1443 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1419,6 +1541,10 @@ name = "once_cell"
1419 1541 version = "1.21.3"
1420 1542 source = "registry+https://github.com/rust-lang/crates.io-index"
1421 1543 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
1544 + dependencies = [
1545 + "critical-section",
1546 + "portable-atomic",
1547 + ]
1422 1548
1423 1549 [[package]]
1424 1550 name = "once_cell_polyfill"
@@ -1545,6 +1671,7 @@ dependencies = [
1545 1671 "chrono",
1546 1672 "clap",
1547 1673 "dirs",
1674 + "hickory-resolver",
1548 1675 "hostname",
1549 1676 "http-body-util",
1550 1677 "rcgen",
@@ -1569,6 +1696,12 @@ dependencies = [
1569 1696 ]
1570 1697
1571 1698 [[package]]
1699 + name = "portable-atomic"
1700 + version = "1.13.1"
1701 + source = "registry+https://github.com/rust-lang/crates.io-index"
1702 + checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
1703 +
1704 + [[package]]
1572 1705 name = "potential_utf"
1573 1706 version = "0.1.4"
1574 1707 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1624,7 +1757,7 @@ dependencies = [
1624 1757 "quinn-udp",
1625 1758 "rustc-hash",
1626 1759 "rustls",
1627 - "socket2",
1760 + "socket2 0.6.3",
1628 1761 "thiserror 2.0.18",
1629 1762 "tokio",
1630 1763 "tracing",
@@ -1661,7 +1794,7 @@ dependencies = [
1661 1794 "cfg_aliases",
1662 1795 "libc",
1663 1796 "once_cell",
1664 - "socket2",
1797 + "socket2 0.6.3",
1665 1798 "tracing",
1666 1799 "windows-sys 0.52.0",
1667 1800 ]
@@ -1845,6 +1978,12 @@ dependencies = [
1845 1978 ]
1846 1979
1847 1980 [[package]]
1981 + name = "resolv-conf"
1982 + version = "0.7.6"
1983 + source = "registry+https://github.com/rust-lang/crates.io-index"
1984 + checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
1985 +
1986 + [[package]]
1848 1987 name = "ring"
1849 1988 version = "0.17.14"
1850 1989 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2171,6 +2310,16 @@ dependencies = [
2171 2310
2172 2311 [[package]]
2173 2312 name = "socket2"
2313 + version = "0.5.10"
2314 + source = "registry+https://github.com/rust-lang/crates.io-index"
2315 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
2316 + dependencies = [
2317 + "libc",
2318 + "windows-sys 0.52.0",
2319 + ]
2320 +
2321 + [[package]]
2322 + name = "socket2"
2174 2323 version = "0.6.3"
2175 2324 source = "registry+https://github.com/rust-lang/crates.io-index"
2176 2325 checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
@@ -2447,6 +2596,12 @@ dependencies = [
2447 2596 ]
2448 2597
2449 2598 [[package]]
2599 + name = "tagptr"
2600 + version = "0.2.0"
2601 + source = "registry+https://github.com/rust-lang/crates.io-index"
2602 + checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
2603 +
2604 + [[package]]
2450 2605 name = "thiserror"
2451 2606 version = "1.0.69"
2452 2607 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2562,7 +2717,7 @@ dependencies = [
2562 2717 "mio",
2563 2718 "pin-project-lite",
2564 2719 "signal-hook-registry",
2565 - "socket2",
2720 + "socket2 0.6.3",
2566 2721 "tokio-macros",
2567 2722 "windows-sys 0.61.2",
2568 2723 ]
@@ -3037,6 +3192,12 @@ dependencies = [
3037 3192 ]
3038 3193
3039 3194 [[package]]
3195 + name = "widestring"
3196 + version = "1.2.1"
3197 + source = "registry+https://github.com/rust-lang/crates.io-index"
3198 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
3199 +
3200 + [[package]]
3040 3201 name = "windows-core"
3041 3202 version = "0.62.2"
3042 3203 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3253,6 +3414,16 @@ dependencies = [
3253 3414 ]
3254 3415
3255 3416 [[package]]
3417 + name = "winreg"
3418 + version = "0.50.0"
3419 + source = "registry+https://github.com/rust-lang/crates.io-index"
3420 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
3421 + dependencies = [
3422 + "cfg-if",
3423 + "windows-sys 0.48.0",
3424 + ]
3425 +
3426 + [[package]]
3256 3427 name = "wit-bindgen"
3257 3428 version = "0.51.0"
3258 3429 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +4 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "pom"
3 - version = "0.2.4"
3 + version = "0.2.5"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -52,6 +52,9 @@ dirs = "6"
52 52 uuid = { version = "1", features = ["v4"] }
53 53 hostname = "0.4"
54 54
55 + # DNS resolution
56 + hickory-resolver = "0.25"
57 +
55 58 # TLS certificate checking
56 59 x509-parser = "0.16"
57 60 tokio-rustls = "0.26"
@@ -4,6 +4,7 @@ prune_days = 30
4 4 listen = "0.0.0.0:9100"
5 5 peer_heartbeat_secs = 60
6 6 route_check_interval_secs = 300
7 + dashboard = true
7 8 # api_token loaded from POM_API_TOKEN env var
8 9
9 10 [instance]
@@ -13,6 +14,25 @@ name = "astra"
13 14 label = "Makenotwork Production"
14 15 expected_routes = ["/", "/discover", "/login", "/docs", "/health"]
15 16
17 + [[targets.mnw.dns]]
18 + name = "makenot.work"
19 + record_type = "A"
20 + expected = ["5.78.144.244"]
21 +
22 + [[targets.mnw.dns]]
23 + name = "forums.makenot.work"
24 + record_type = "A"
25 + expected = ["5.78.144.244"]
26 +
27 + [[targets.mnw.dns]]
28 + name = "git.makenot.work"
29 + record_type = "A"
30 + expected = ["5.78.144.244"]
31 +
32 + [targets.mnw.whois]
33 + domain = "makenot.work"
34 + warn_days = 30
35 +
16 36 [targets.mnw.health]
17 37 url = "https://makenot.work/api/health"
18 38 timeout_secs = 10
@@ -52,6 +72,15 @@ host = "forums.makenot.work"
52 72 [targets.htpy]
53 73 label = "htpy.app"
54 74
75 + [[targets.htpy.dns]]
76 + name = "htpy.app"
77 + record_type = "A"
78 + expected = ["5.78.135.189"]
79 +
80 + [targets.htpy.whois]
81 + domain = "htpy.app"
82 + warn_days = 30
83 +
55 84 [targets.htpy.health]
56 85 url = "http://100.99.153.68:8080/archive/S_2"
57 86 timeout_secs = 10
@@ -4,6 +4,7 @@ prune_days = 30
4 4 listen = "0.0.0.0:9100"
5 5 peer_heartbeat_secs = 60
6 6 route_check_interval_secs = 300
7 + dashboard = false
7 8 # api_token loaded from POM_API_TOKEN env var
8 9
9 10 [instance]
@@ -13,6 +14,25 @@ name = "hetzner"
13 14 label = "Makenotwork Production"
14 15 expected_routes = ["/", "/discover", "/login", "/docs", "/health"]
15 16
17 + [[targets.mnw.dns]]
18 + name = "makenot.work"
19 + record_type = "A"
20 + expected = ["5.78.144.244"]
21 +
22 + [[targets.mnw.dns]]
23 + name = "forums.makenot.work"
24 + record_type = "A"
25 + expected = ["5.78.144.244"]
26 +
27 + [[targets.mnw.dns]]
28 + name = "git.makenot.work"
29 + record_type = "A"
30 + expected = ["5.78.144.244"]
31 +
32 + [targets.mnw.whois]
33 + domain = "makenot.work"
34 + warn_days = 30
35 +
16 36 [targets.mnw.health]
17 37 url = "https://makenot.work/api/health"
18 38 timeout_secs = 10
@@ -52,6 +72,15 @@ host = "forums.makenot.work"
52 72 [targets.htpy]
53 73 label = "htpy.app"
54 74
75 + [[targets.htpy.dns]]
76 + name = "htpy.app"
77 + record_type = "A"
78 + expected = ["5.78.135.189"]
79 +
80 + [targets.htpy.whois]
81 + domain = "htpy.app"
82 + warn_days = 30
83 +
55 84 [targets.htpy.health]
56 85 url = "http://100.99.153.68:8080/archive/S_2"
57 86 timeout_secs = 10
M src/alerts.rs +162
@@ -274,6 +274,132 @@ impl Alerter {
274 274 }
275 275
276 276 #[instrument(skip_all)]
277 + pub async fn send_dns_mismatch_alert(
278 + &self,
279 + target: &str,
280 + label: &str,
281 + mismatches: &[crate::types::DnsCheckResult],
282 + ) {
283 + let alert_key = format!("dns:{target}");
284 + if self.is_within_cooldown(&alert_key).await {
285 + info!("alert cooldown active for {alert_key}, skipping");
286 + return;
287 + }
288 +
289 + let n = mismatches.len();
290 + let subject = format!("[PoM] {label}: {n} DNS record(s) mismatched");
291 + let details: Vec<String> = mismatches
292 + .iter()
293 + .map(|m| {
294 + if let Some(ref err) = m.error {
295 + format!(" - {} {}: {err}", m.name, m.record_type)
296 + } else {
297 + format!(
298 + " - {} {}: expected {:?}, got {:?}",
299 + m.name, m.record_type, m.expected, m.actual
300 + )
301 + }
302 + })
303 + .collect();
304 + let body = format!(
305 + "Target: {label} ({target})\n\
306 + DNS mismatches:\n{}\n\
307 + Instance: {}\n\
308 + Time: {}\n\n\
309 + - PoM",
310 + details.join("\n"),
311 + self.instance_name,
312 + chrono::Utc::now().to_rfc3339(),
313 + );
314 +
315 + self.send_email(&subject, &body).await;
316 + self.record_alert(&alert_key, "dns_mismatch", None, None, None).await;
317 + }
318 +
319 + #[instrument(skip_all)]
320 + pub async fn send_dns_recovery_alert(
321 + &self,
322 + target: &str,
323 + label: &str,
324 + ) {
325 + // No cooldown on recovery — always send
326 + let alert_key = format!("dns:{target}");
327 + let subject = format!("[PoM] {label}: DNS records recovered");
328 + let body = format!(
329 + "Target: {label} ({target})\n\
330 + All DNS records now match expected values.\n\
331 + Instance: {}\n\
332 + Time: {}\n\n\
333 + - PoM",
334 + self.instance_name,
335 + chrono::Utc::now().to_rfc3339(),
336 + );
337 +
338 + self.send_email(&subject, &body).await;
339 + self.record_alert(&alert_key, "dns_recovery", None, None, None).await;
340 + }
341 +
342 + #[instrument(skip_all)]
343 + pub async fn send_whois_expiry_alert(
344 + &self,
345 + target: &str,
346 + label: &str,
347 + domain: &str,
348 + days_remaining: i64,
349 + ) {
350 + let alert_key = format!("whois:{target}");
351 + if self.is_within_cooldown(&alert_key).await {
352 + info!("alert cooldown active for {alert_key}, skipping");
353 + return;
354 + }
355 +
356 + let subject = format!("[PoM] {label}: domain {domain} expires in {days_remaining} days");
357 + let body = format!(
358 + "Target: {label} ({target})\n\
359 + Domain: {domain}\n\
360 + Days remaining: {days_remaining}\n\
361 + Instance: {}\n\
362 + Time: {}\n\n\
363 + - PoM",
364 + self.instance_name,
365 + chrono::Utc::now().to_rfc3339(),
366 + );
367 +
368 + self.send_email(&subject, &body).await;
369 + self.record_alert(&alert_key, "whois_expiry", None, None, None).await;
370 + }
371 +
372 + #[instrument(skip_all)]
373 + pub async fn send_whois_error_alert(
374 + &self,
375 + target: &str,
376 + label: &str,
377 + domain: &str,
378 + error: &str,
379 + ) {
380 + let alert_key = format!("whois:{target}");
381 + if self.is_within_cooldown(&alert_key).await {
382 + info!("alert cooldown active for {alert_key}, skipping");
383 + return;
384 + }
385 +
386 + let subject = format!("[PoM] {label}: WHOIS check failed for {domain}");
387 + let body = format!(
388 + "Target: {label} ({target})\n\
389 + Domain: {domain}\n\
390 + Error: {error}\n\
391 + Instance: {}\n\
392 + Time: {}\n\n\
393 + - PoM",
394 + self.instance_name,
395 + chrono::Utc::now().to_rfc3339(),
396 + );
397 +
398 + self.send_email(&subject, &body).await;
399 + self.record_alert(&alert_key, "whois_error", None, None, Some(error)).await;
400 + }
401 +
402 + #[instrument(skip_all)]
277 403 pub async fn send_latency_drift_alert(
278 404 &self,
279 405 target: &str,
@@ -484,6 +610,42 @@ mod tests {
484 610 }
485 611
486 612 #[tokio::test]
613 + async fn dns_alert_cooldown_key() {
614 + let pool = db::connect_in_memory().await.unwrap();
615 + let alerter = test_alerter(pool.clone());
616 +
617 + assert!(!alerter.is_within_cooldown("dns:mnw").await);
618 +
619 + let mismatches = vec![crate::types::DnsCheckResult {
620 + target: "mnw".to_string(),
621 + name: "makenot.work".to_string(),
622 + record_type: "A".to_string(),
623 + expected: vec!["1.2.3.4".to_string()],
624 + actual: vec!["5.6.7.8".to_string()],
625 + matches: false,
626 + checked_at: chrono::Utc::now().to_rfc3339(),
627 + error: None,
628 + }];
629 + alerter.send_dns_mismatch_alert("mnw", "MakeNotWork", &mismatches).await;
630 +
631 + assert!(alerter.is_within_cooldown("dns:mnw").await);
632 + assert!(!alerter.is_within_cooldown("dns:other").await);
633 + }
634 +
635 + #[tokio::test]
636 + async fn whois_alert_cooldown_key() {
637 + let pool = db::connect_in_memory().await.unwrap();
638 + let alerter = test_alerter(pool.clone());
639 +
640 + assert!(!alerter.is_within_cooldown("whois:mnw").await);
641 +
642 + alerter.send_whois_expiry_alert("mnw", "MakeNotWork", "makenot.work", 15).await;
643 +
644 + assert!(alerter.is_within_cooldown("whois:mnw").await);
645 + assert!(!alerter.is_within_cooldown("whois:other").await);
646 + }
647 +
648 + #[tokio::test]
487 649 async fn health_alert_cooldown_key_matches_record_key() {
488 650 let pool = db::connect_in_memory().await.unwrap();
489 651 let alerter = test_alerter(pool.clone());
M src/api.rs +44 -1
@@ -139,7 +139,13 @@ pub fn router(pool: sqlx::SqlitePool, config: Config, mesh: Option<SharedMeshSta
139 139 let public = Router::new()
140 140 .route("/api/health", get(self_health));
141 141
142 - public.merge(authenticated).with_state(state)
142 + let mut app = public.merge(authenticated);
143 +
144 + if state.config.serve.dashboard {
145 + app = app.route("/", get(crate::dashboard::dashboard_handler));
146 + }
147 +
148 + app.with_state(state)
143 149 }
144 150
145 151 // --- Response types ---
@@ -180,6 +186,22 @@ struct TargetStatus {
180 186 /// Latest route check results per path. Omitted if empty.
181 187 #[serde(skip_serializing_if = "Vec::is_empty")]
182 188 route_status: Vec<RouteStatusJson>,
189 + /// Latest DNS check results. Omitted if empty.
190 + #[serde(skip_serializing_if = "Vec::is_empty")]
191 + dns_status: Vec<DnsStatusJson>,
192 + /// Latest WHOIS check result. Omitted if no WHOIS monitoring is configured.
193 + #[serde(skip_serializing_if = "Option::is_none")]
194 + whois: Option<db::WhoisCheckRow>,
195 + }
196 +
197 + #[derive(Serialize)]
198 + struct DnsStatusJson {
199 + name: String,
200 + record_type: String,
201 + expected: Vec<String>,
202 + actual: Vec<String>,
203 + matches: bool,
204 + checked_at: String,
183 205 }
184 206
185 207 #[derive(Serialize)]
@@ -310,6 +332,25 @@ async fn build_target_status(
310 332 })
311 333 .collect();
312 334
335 + let dns_checks = db::get_latest_dns_checks(pool, name)
336 + .await
337 + .unwrap_or_default();
338 + let dns_status: Vec<DnsStatusJson> = dns_checks
339 + .into_iter()
340 + .map(|r| DnsStatusJson {
341 + name: r.name,
342 + record_type: r.record_type,
343 + expected: serde_json::from_str(&r.expected).unwrap_or_default(),
344 + actual: serde_json::from_str(&r.actual).unwrap_or_default(),
345 + matches: r.matches,
346 + checked_at: r.checked_at,
347 + })
348 + .collect();
349 +
350 + let whois = db::get_latest_whois_check(pool, name)
351 + .await
352 + .unwrap_or(None);
353 +
313 354 TargetStatus {
314 355 label: label.to_string(),
315 356 latest,
@@ -322,6 +363,8 @@ async fn build_target_status(
322 363 current_incident,
323 364 incidents,
324 365 route_status,
366 + dns_status,
367 + whois,
325 368 }
326 369 }
327 370
@@ -0,0 +1,213 @@
1 + //! DNS record verification — resolves hostnames and compares against expected values.
2 +
3 + use std::collections::HashSet;
4 +
5 + use hickory_resolver::TokioResolver;
6 + use tracing::instrument;
7 +
8 + use crate::config::DnsRecord;
9 + use crate::types::DnsCheckResult;
10 +
11 + /// Resolve DNS records and compare against expected values.
12 + /// Returns one `DnsCheckResult` per `DnsRecord` in the input.
13 + #[instrument(skip_all)]
14 + pub async fn check_dns(target: &str, records: &[DnsRecord]) -> Vec<DnsCheckResult> {
15 + let resolver = match TokioResolver::builder_tokio() {
16 + Ok(builder) => builder.build(),
17 + Err(e) => {
18 + return records
19 + .iter()
20 + .map(|r| DnsCheckResult {
21 + target: target.to_string(),
22 + name: r.name.clone(),
23 + record_type: r.record_type.clone(),
24 + expected: r.expected.clone(),
25 + actual: vec![],
26 + matches: false,
27 + checked_at: chrono::Utc::now().to_rfc3339(),
28 + error: Some(format!("failed to create resolver: {e}")),
29 + })
30 + .collect();
31 + }
32 + };
33 +
34 + let mut results = Vec::with_capacity(records.len());
35 + for record in records {
36 + let result = resolve_record(target, &resolver, record).await;
37 + results.push(result);
38 + }
39 + results
40 + }
41 +
42 + async fn resolve_record(
43 + target: &str,
44 + resolver: &TokioResolver,
45 + record: &DnsRecord,
46 + ) -> DnsCheckResult {
47 + let now = chrono::Utc::now().to_rfc3339();
48 +
49 + let actual = match record.record_type.as_str() {
50 + "A" => resolve_a(resolver, &record.name).await,
51 + "AAAA" => resolve_aaaa(resolver, &record.name).await,
52 + "CNAME" => resolve_cname(resolver, &record.name).await,
53 + "MX" => resolve_mx(resolver, &record.name).await,
54 + "TXT" => resolve_txt(resolver, &record.name).await,
55 + other => Err(format!("unsupported record type: {other}")),
56 + };
57 +
58 + match actual {
59 + Ok(actual_values) => {
60 + let matches = check_match(&record.expected, &actual_values);
61 + DnsCheckResult {
62 + target: target.to_string(),
63 + name: record.name.clone(),
64 + record_type: record.record_type.clone(),
65 + expected: record.expected.clone(),
66 + actual: actual_values,
67 + matches,
68 + checked_at: now,
69 + error: None,
70 + }
71 + }
72 + Err(e) => DnsCheckResult {
73 + target: target.to_string(),
74 + name: record.name.clone(),
75 + record_type: record.record_type.clone(),
76 + expected: record.expected.clone(),
77 + actual: vec![],
78 + matches: false,
79 + checked_at: now,
80 + error: Some(e),
81 + },
82 + }
83 + }
84 +
85 + /// Check if all expected values are found in actual (expected ⊆ actual).
86 + pub fn check_match(expected: &[String], actual: &[String]) -> bool {
87 + let actual_set: HashSet<&str> = actual.iter().map(|s| s.as_str()).collect();
88 + expected.iter().all(|e| actual_set.contains(e.as_str()))
89 + }
90 +
91 + async fn resolve_a(
92 + resolver: &TokioResolver,
93 + name: &str,
94 + ) -> Result<Vec<String>, String> {
95 + let response = resolver
96 + .ipv4_lookup(name)
97 + .await
98 + .map_err(|e| format!("A lookup failed for {name}: {e}"))?;
99 + Ok(response.iter().map(|ip| ip.to_string()).collect())
100 + }
101 +
102 + async fn resolve_aaaa(
103 + resolver: &TokioResolver,
104 + name: &str,
105 + ) -> Result<Vec<String>, String> {
106 + let response = resolver
107 + .ipv6_lookup(name)
108 + .await
109 + .map_err(|e| format!("AAAA lookup failed for {name}: {e}"))?;
110 + Ok(response.iter().map(|ip| ip.to_string()).collect())
111 + }
112 +
113 + async fn resolve_cname(
114 + resolver: &TokioResolver,
115 + name: &str,
116 + ) -> Result<Vec<String>, String> {
117 + let response = resolver
118 + .lookup(name, hickory_resolver::proto::rr::RecordType::CNAME)
119 + .await
120 + .map_err(|e| format!("CNAME lookup failed for {name}: {e}"))?;
121 + Ok(response
122 + .iter()
123 + .filter_map(|r| r.as_cname().map(|c| c.0.to_string().trim_end_matches('.').to_string()))
124 + .collect())
125 + }
126 +
127 + async fn resolve_mx(
128 + resolver: &TokioResolver,
129 + name: &str,
130 + ) -> Result<Vec<String>, String> {
131 + let response = resolver
132 + .mx_lookup(name)
133 + .await
134 + .map_err(|e| format!("MX lookup failed for {name}: {e}"))?;
135 + Ok(response
136 + .iter()
137 + .map(|mx| mx.exchange().to_string().trim_end_matches('.').to_string())
138 + .collect())
139 + }
140 +
141 + async fn resolve_txt(
142 + resolver: &TokioResolver,
143 + name: &str,
144 + ) -> Result<Vec<String>, String> {
145 + let response = resolver
146 + .txt_lookup(name)
147 + .await
148 + .map_err(|e| format!("TXT lookup failed for {name}: {e}"))?;
149 + Ok(response.iter().map(|txt| txt.to_string()).collect())
150 + }
151 +
152 + #[cfg(test)]
153 + mod tests {
154 + use super::*;
155 +
156 + #[test]
157 + fn check_match_exact() {
158 + assert!(check_match(
159 + &["1.2.3.4".to_string()],
160 + &["1.2.3.4".to_string()],
161 + ));
162 + }
163 +
164 + #[test]
165 + fn check_match_subset() {
166 + assert!(check_match(
167 + &["1.2.3.4".to_string()],
168 + &["1.2.3.4".to_string(), "5.6.7.8".to_string()],
169 + ));
170 + }
171 +
172 + #[test]
173 + fn check_match_mismatch() {
174 + assert!(!check_match(
175 + &["1.2.3.4".to_string()],
176 + &["5.6.7.8".to_string()],
177 + ));
178 + }
179 +
180 + #[test]
181 + fn check_match_empty_expected() {
182 + assert!(check_match(&[], &["1.2.3.4".to_string()]));
183 + }
184 +
185 + #[test]
186 + fn check_match_empty_actual() {
187 + assert!(!check_match(&["1.2.3.4".to_string()], &[]));
188 + }
189 +
190 + #[test]
191 + fn check_match_order_independent() {
192 + assert!(check_match(
193 + &["b".to_string(), "a".to_string()],
194 + &["a".to_string(), "b".to_string(), "c".to_string()],
195 + ));
196 + }
197 +
198 + #[test]
199 + fn check_match_multiple_expected_all_present() {
200 + assert!(check_match(
201 + &["1.2.3.4".to_string(), "5.6.7.8".to_string()],
202 + &["5.6.7.8".to_string(), "1.2.3.4".to_string()],
203 + ));
204 + }
205 +
206 + #[test]
207 + fn check_match_multiple_expected_one_missing() {
208 + assert!(!check_match(
209 + &["1.2.3.4".to_string(), "5.6.7.8".to_string()],
210 + &["1.2.3.4".to_string()],
211 + ));
212 + }
213 + }
@@ -1,5 +1,7 @@
1 + pub mod dns;
1 2 pub mod http;
2 3 pub mod parse;
3 4 pub mod routes;
4 5 pub mod ssh;
5 6 pub mod tls;
7 + pub mod whois;
@@ -0,0 +1,305 @@
1 + //! WHOIS domain expiry checking — raw TCP to WHOIS servers.
2 +
3 + use tokio::io::{AsyncReadExt, AsyncWriteExt};
4 + use tokio::net::TcpStream;
5 + use tracing::instrument;
6 +
7 + use crate::config::WhoisConfig;
8 + use crate::types::WhoisResult;
9 +
10 + /// Query WHOIS for domain registration info.
11 + #[instrument(skip_all)]
12 + pub async fn check_whois(target: &str, config: &WhoisConfig) -> WhoisResult {
13 + let now = chrono::Utc::now().to_rfc3339();
14 +
15 + let Some(server) = whois_server_for_tld(&config.domain) else {
16 + return WhoisResult {
17 + target: target.to_string(),
18 + domain: config.domain.clone(),
19 + registrar: None,
20 + expiry_date: None,
21 + days_remaining: None,
22 + nameservers: vec![],
23 + checked_at: now,
24 + error: Some(format!("no WHOIS server known for TLD of {}", config.domain)),
25 + };
26 + };
27 +
28 + match query_whois(server, &config.domain).await {
29 + Ok(response) => {
30 + let parsed = parse_whois_response(&response);
31 + let days_remaining = parsed.expiry_date.as_deref().and_then(compute_days_remaining);
32 +
33 + WhoisResult {
34 + target: target.to_string(),
35 + domain: config.domain.clone(),
36 + registrar: parsed.registrar,
37 + expiry_date: parsed.expiry_date,
38 + days_remaining,
39 + nameservers: parsed.nameservers,
40 + checked_at: now,
41 + error: None,
42 + }
43 + }
44 + Err(e) => WhoisResult {
45 + target: target.to_string(),
46 + domain: config.domain.clone(),
47 + registrar: None,
48 + expiry_date: None,
49 + days_remaining: None,
50 + nameservers: vec![],
51 + checked_at: now,
52 + error: Some(e),
53 + },
54 + }
55 + }
56 +
57 + /// Determine the WHOIS server for a domain based on its TLD.
58 + pub fn whois_server_for_tld(domain: &str) -> Option<&'static str> {
59 + let tld = domain.rsplit('.').next()?;
60 + match tld {
61 + "com" => Some("whois.verisign-grs.com"),
62 + "net" => Some("whois.verisign-grs.com"),
63 + "org" => Some("whois.pir.org"),
64 + "work" => Some("whois.nic.work"),
65 + "app" => Some("whois.nic.google"),
66 + "dev" => Some("whois.nic.google"),
67 + "io" => Some("whois.nic.io"),
68 + "me" => Some("whois.nic.me"),
69 + "info" => Some("whois.afilias.net"),
70 + _ => None,
71 + }
72 + }
73 +
74 + /// Send a WHOIS query over TCP and return the raw response.
75 + async fn query_whois(server: &str, domain: &str) -> Result<String, String> {
76 + let addr = format!("{server}:43");
77 +
78 + let mut stream = tokio::time::timeout(
79 + std::time::Duration::from_secs(10),
80 + TcpStream::connect(&addr),
81 + )
82 + .await
83 + .map_err(|_| format!("WHOIS connection to {server} timed out"))?
84 + .map_err(|e| format!("WHOIS connection to {server} failed: {e}"))?;
85 +
86 + stream
87 + .write_all(format!("{domain}\r\n").as_bytes())
88 + .await
89 + .map_err(|e| format!("WHOIS write failed: {e}"))?;
90 +
91 + let mut response = Vec::with_capacity(4096);
92 + let mut buf = [0u8; 4096];
93 + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
94 + loop {
95 + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
96 + if remaining.is_zero() {
97 + break;
98 + }
99 + match tokio::time::timeout(remaining, stream.read(&mut buf)).await {
100 + Ok(Ok(0)) => break,
101 + Ok(Ok(n)) => {
102 + response.extend_from_slice(&buf[..n]);
103 + if response.len() > 65536 {
104 + break;
105 + }
106 + }
107 + Ok(Err(e)) => return Err(format!("WHOIS read error: {e}")),
108 + Err(_) => break,
109 + }
110 + }
111 +
112 + String::from_utf8(response).map_err(|e| format!("WHOIS response not UTF-8: {e}"))
113 + }
114 +
115 + /// Parse key fields from a raw WHOIS response.
116 + pub fn parse_whois_response(response: &str) -> ParsedWhoisResult {
117 + let mut registrar = None;
118 + let mut expiry_date = None;
119 + let mut nameservers = Vec::new();
120 +
121 + for line in response.lines() {
122 + let line = line.trim();
123 + let lower = line.to_lowercase();
124 +
125 + // Registrar
126 + if registrar.is_none() && lower.starts_with("registrar:") {
127 + registrar = extract_value(line);
128 + }
129 +
130 + // Expiry date — multiple possible field names
131 + if expiry_date.is_none()
132 + && (lower.starts_with("registry expiry date:")
133 + || lower.starts_with("registrar registration expiration date:")
134 + || lower.starts_with("expiration date:")
135 + || lower.starts_with("paid-till:"))
136 + {
137 + expiry_date = extract_value(line);
138 + }
139 +
140 + // Name servers
141 + if (lower.starts_with("name server:") || lower.starts_with("nserver:"))
142 + && let Some(ns) = extract_value(line)
143 + {
144 + let ns = ns.trim_end_matches('.').to_lowercase();
145 + if !nameservers.contains(&ns) {
146 + nameservers.push(ns);
147 + }
148 + }
149 + }
150 +
151 + ParsedWhoisResult {
152 + registrar,
153 + expiry_date,
154 + nameservers,
155 + }
156 + }
157 +
158 + pub struct ParsedWhoisResult {
159 + pub registrar: Option<String>,
160 + pub expiry_date: Option<String>,
161 + pub nameservers: Vec<String>,
162 + }
163 +
164 + fn extract_value(line: &str) -> Option<String> {
165 + let value = line.split_once(':')?.1.trim();
166 + if value.is_empty() {
167 + None
168 + } else {
169 + Some(value.to_string())
170 + }
171 + }
172 +
173 + /// Compute days remaining from an expiry date string.
174 + /// Tries RFC 3339 first, then common date-only formats.
175 + pub fn compute_days_remaining(expiry_str: &str) -> Option<i64> {
176 + let expiry = chrono::DateTime::parse_from_rfc3339(expiry_str)
177 + .map(|dt| dt.with_timezone(&chrono::Utc))
178 + .or_else(|_| {
179 + chrono::NaiveDate::parse_from_str(expiry_str.trim(), "%Y-%m-%d")
180 + .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
181 + })
182 + .ok()?;
183 +
184 + let now = chrono::Utc::now();
185 + Some(expiry.signed_duration_since(now).num_days())
186 + }
187 +
188 + #[cfg(test)]
189 + mod tests {
190 + use super::*;
191 +
192 + #[test]
193 + fn whois_server_known_tlds() {
194 + assert_eq!(whois_server_for_tld("example.work"), Some("whois.nic.work"));
195 + assert_eq!(whois_server_for_tld("example.app"), Some("whois.nic.google"));
196 + assert_eq!(whois_server_for_tld("example.com"), Some("whois.verisign-grs.com"));
197 + assert_eq!(whois_server_for_tld("example.net"), Some("whois.verisign-grs.com"));
198 + assert_eq!(whois_server_for_tld("example.org"), Some("whois.pir.org"));
199 + }
200 +
201 + #[test]
202 + fn whois_server_unknown_tld() {
203 + assert_eq!(whois_server_for_tld("example.xyz"), None);
204 + }
205 +
206 + #[test]
207 + fn parse_whois_verisign_response() {
208 + let response = r#"
209 + Domain Name: EXAMPLE.COM
210 + Registry Domain ID: 2336799_DOMAIN_COM-VRSN
211 + Registrar WHOIS Server: whois.registrar.com
212 + Registrar URL: http://www.registrar.com
213 + Updated Date: 2024-08-14T07:01:44Z
214 + Creation Date: 1995-08-14T04:00:00Z
215 + Registry Expiry Date: 2025-08-13T04:00:00Z
216 + Registrar: Example Registrar, Inc.
217 + Name Server: A.IANA-SERVERS.NET
218 + Name Server: B.IANA-SERVERS.NET
219 + "#;
220 + let parsed = parse_whois_response(response);
221 + assert_eq!(parsed.registrar.as_deref(), Some("Example Registrar, Inc."));
222 + assert_eq!(parsed.expiry_date.as_deref(), Some("2025-08-13T04:00:00Z"));
223 + assert_eq!(parsed.nameservers.len(), 2);
224 + assert!(parsed.nameservers.contains(&"a.iana-servers.net".to_string()));
225 + assert!(parsed.nameservers.contains(&"b.iana-servers.net".to_string()));
226 + }
227 +
228 + #[test]
229 + fn parse_whois_nic_work_response() {
230 + let response = r#"
231 + Domain Name: makenot.work
232 + Registry Domain ID: abc123
233 + Registrar WHOIS Server: whois.namecheap.com
234 + Registrar URL: http://www.namecheap.com
235 + Updated Date: 2025-03-01T12:00:00Z
236 + Creation Date: 2024-03-01T12:00:00Z
237 + Registry Expiry Date: 2026-12-01T12:00:00Z
238 + Registrar: Namecheap, Inc.
239 + Name Server: dns1.registrar-servers.com
240 + Name Server: dns2.registrar-servers.com
241 + "#;
242 + let parsed = parse_whois_response(response);
243 + assert_eq!(parsed.registrar.as_deref(), Some("Namecheap, Inc."));
244 + assert_eq!(parsed.expiry_date.as_deref(), Some("2026-12-01T12:00:00Z"));
245 + assert_eq!(parsed.nameservers.len(), 2);
246 + }
247 +
248 + #[test]
249 + fn parse_whois_empty_response() {
250 + let parsed = parse_whois_response("");
251 + assert!(parsed.registrar.is_none());
252 + assert!(parsed.expiry_date.is_none());
253 + assert!(parsed.nameservers.is_empty());
254 + }
255 +
256 + #[test]
257 + fn parse_whois_no_matching_fields() {
258 + let parsed = parse_whois_response("Some random text\nAnother line\n");
259 + assert!(parsed.registrar.is_none());
260 + assert!(parsed.expiry_date.is_none());
261 + assert!(parsed.nameservers.is_empty());
262 + }
263 +
264 + #[test]
265 + fn compute_days_remaining_rfc3339() {
266 + let future = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
267 + let days = compute_days_remaining(&future).unwrap();
268 + assert!((29..=30).contains(&days));
269 + }
270 +
271 + #[test]
272 + fn compute_days_remaining_date_only() {
273 + let future = (chrono::Utc::now() + chrono::Duration::days(60))
274 + .format("%Y-%m-%d")
275 + .to_string();
276 + let days = compute_days_remaining(&future).unwrap();
277 + assert!((59..=60).contains(&days));
278 + }
279 +
280 + #[test]
281 + fn compute_days_remaining_expired() {
282 + let past = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
283 + let days = compute_days_remaining(&past).unwrap();
284 + assert!(days < 0);
285 + }
286 +
287 + #[test]
288 + fn compute_days_remaining_invalid() {
289 + assert!(compute_days_remaining("not-a-date").is_none());
290 + }
291 +
292 + #[test]
293 + fn parse_whois_alternative_expiry_field() {
294 + let response = "Expiration Date: 2027-06-15T00:00:00Z\nName Server: ns1.example.com\n";
295 + let parsed = parse_whois_response(response);
296 + assert_eq!(parsed.expiry_date.as_deref(), Some("2027-06-15T00:00:00Z"));
297 + }
298 +
299 + #[test]
300 + fn parse_whois_deduplicates_nameservers() {
301 + let response = "Name Server: ns1.example.com\nName Server: ns1.example.com\n";
302 + let parsed = parse_whois_response(response);
303 + assert_eq!(parsed.nameservers.len(), 1);
304 + }
305 + }
M src/cli.rs +183 -7
@@ -4,7 +4,7 @@ use clap::Subcommand;
4 4 use tracing::info;
5 5
6 6 use pom::alerts::Alerter;
7 - use pom::checks::{http, routes, ssh, tls};
7 + use pom::checks::{dns, http, routes, ssh, tls, whois};
8 8 use pom::config::Config;
9 9 use pom::db;
10 10 use pom::display;
@@ -127,6 +127,8 @@ pub(crate) async fn cmd_status(
127 127 let health = db::get_latest_health(pool, &name).await?;
128 128 let tls_check = db::get_latest_tls_check(pool, &name).await?;
129 129 let route_checks = db::get_latest_route_checks(pool, &name).await?;
130 + let dns_checks = db::get_latest_dns_checks(pool, &name).await?;
131 + let whois_check = db::get_latest_whois_check(pool, &name).await?;
130 132 let test = db::get_latest_test_run(pool, &name).await?;
131 133 let incident = db::get_open_incident(pool, &name).await?;
132 134
@@ -170,6 +172,8 @@ pub(crate) async fn cmd_status(
170 172 "health": health,
171 173 "tls": tls_check,
172 174 "latency_24h": latency_24h,
175 + "dns": dns_checks,
176 + "whois": whois_check,
173 177 "last_test": test.map(|t| serde_json::json!({
174 178 "passed": t.passed,
175 179 "exit_code": t.exit_code,
@@ -182,6 +186,7 @@ pub(crate) async fn cmd_status(
182 186 }));
183 187 } else {
184 188 let route_slice = if route_checks.is_empty() { None } else { Some(route_checks.as_slice()) };
189 + let dns_slice = if dns_checks.is_empty() { None } else { Some(dns_checks.as_slice()) };
185 190 print!(
186 191 "{}",
187 192 display::format_status_target(
@@ -191,6 +196,8 @@ pub(crate) async fn cmd_status(
191 196 latency_24h.as_ref(),
192 197 tls_check.as_ref(),
193 198 route_slice,
199 + dns_slice,
200 + whois_check.as_ref(),
194 201 test.as_ref(),
195 202 staleness.as_ref(),
196 203 incident.as_ref(),
@@ -248,11 +255,67 @@ pub(crate) async fn cmd_prune(
248 255 pool: &sqlx::SqlitePool,
249 256 days: i64,
250 257 ) -> Result<()> {
251 - let (health_pruned, test_pruned, heartbeat_pruned, alerts_pruned, tls_pruned, incidents_pruned, routes_pruned) = db::prune_old_records(pool, days).await?;
252 - print!(
253 - "{}",
254 - display::format_prune(health_pruned, test_pruned, heartbeat_pruned, alerts_pruned, tls_pruned, incidents_pruned, routes_pruned, days),
255 - );
258 + let result = db::prune_old_records(pool, days).await?;
259 + print!("{}", display::format_prune(&result, days));
260 + Ok(())
261 + }
262 +
263 + pub(crate) async fn cmd_dns(
264 + pool: &sqlx::SqlitePool,
265 + config: &Config,
266 + target: Option<&str>,
267 + json: bool,
268 + ) -> Result<()> {
269 + let targets: Vec<String> = match target {
270 + Some(t) => {
271 + if config.get_target(t).is_none() {
272 + eprintln!("Unknown target: {t}");
273 + std::process::exit(1);
274 + }
275 + vec![t.to_string()]
276 + }
277 + None => config.target_names(),
278 + };
279 +
280 + let mut all_dns_results = Vec::new();
281 + let mut all_whois_results = Vec::new();
282 +
283 + for name in &targets {
284 + let target_config = config.get_target(name).unwrap();
285 +
286 + // DNS checks
287 + if !target_config.dns.is_empty() {
288 + let results = dns::check_dns(name, &target_config.dns).await;
289 + for result in &results {
290 + if let Err(e) = db::insert_dns_check(pool, result).await {
291 + tracing::error!("{name}: failed to store DNS check: {e}");
292 + }
293 + }
294 + all_dns_results.extend(results);
295 + }
296 +
297 + // WHOIS check
298 + if let Some(ref whois_config) = target_config.whois {
299 + let result = whois::check_whois(name, whois_config).await;
300 + if let Err(e) = db::insert_whois_check(pool, &result).await {
301 + tracing::error!("{name}: failed to store WHOIS check: {e}");
302 + }
303 + all_whois_results.push(result);
304 + }
305 + }
306 +
307 + if json {
308 + let output = serde_json::json!({
309 + "dns": all_dns_results,
310 + "whois": all_whois_results,
311 + });
312 + println!("{}", serde_json::to_string_pretty(&output)?);
313 + } else if all_dns_results.is_empty() && all_whois_results.is_empty() {
314 + println!("No DNS or WHOIS checks configured for the selected target(s).");
315 + } else {
316 + print!("{}", display::format_dns_results(&all_dns_results, &all_whois_results));
317 + }
318 +
256 319 Ok(())
257 320 }
258 321
@@ -571,6 +634,119 @@ pub(crate) async fn cmd_serve(
571 634 }));
572 635 }
573 636
637 + // Spawn DNS check tasks
638 + let dns_interval_secs = config.serve.dns_check_interval_secs;
639 + for name in config.target_names() {
640 + let target_config = config.get_target(&name).unwrap().clone();
641 + if target_config.dns.is_empty() {
642 + continue;
643 + }
644 + let dns_records = target_config.dns.clone();
645 + let label = target_config.label.clone();
646 + let pool = pool.clone();
647 + let alerter = alerter.clone();
648 + let cancel = token.clone();
649 + let n = dns_records.len();
650 +
651 + info!("{name}: DNS check every {dns_interval_secs}s ({n} records)");
652 +
653 + handles.push(tokio::spawn(async move {
654 + let mut interval = tokio::time::interval(
655 + std::time::Duration::from_secs(dns_interval_secs),
656 + );
657 + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
658 + let mut prev_mismatched: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
659 +
660 + interval.tick().await; // consume immediate first tick
661 + loop {
662 + tokio::select! {
663 + _ = cancel.cancelled() => break,
664 + _ = interval.tick() => {}
665 + }
666 + let results = dns::check_dns(&name, &dns_records).await;
667 +
668 + for result in &results {
669 + if let Err(e) = db::insert_dns_check(&pool, result).await {
670 + tracing::error!("{}: failed to store DNS check for {} {}: {e}", name, result.name, result.record_type);
671 + }
672 + }
673 +
674 + let current_mismatched: std::collections::HashSet<(String, String)> = results
675 + .iter()
676 + .filter(|r| !r.matches)
677 + .map(|r| (r.name.clone(), r.record_type.clone()))
678 + .collect();
679 +
680 + let ok_count = results.iter().filter(|r| r.matches).count();
681 + info!("{name}: DNS {ok_count}/{n} match");
682 +
683 + if let Some(ref alerter) = alerter {
684 + // New mismatches
685 + let new_mismatches: Vec<&pom::types::DnsCheckResult> = results
686 + .iter()
687 + .filter(|r| !r.matches && !prev_mismatched.contains(&(r.name.clone(), r.record_type.clone())))
688 + .collect();
689 + if !new_mismatches.is_empty() {
690 + let owned: Vec<pom::types::DnsCheckResult> = new_mismatches.into_iter().cloned().collect();
691 + alerter.send_dns_mismatch_alert(&name, &label, &owned).await;
692 + }
693 +
694 + // All recovered
695 + if !prev_mismatched.is_empty() && current_mismatched.is_empty() {
696 + alerter.send_dns_recovery_alert(&name, &label).await;
697 + }
698 + }
699 +
700 + prev_mismatched = current_mismatched;
701 + }
702 + }));
703 + }
704 +
705 + // Spawn WHOIS check tasks (reuse TLS check interval)
706 + let whois_interval_secs = config.serve.tls_check_interval_secs;
707 + for name in config.target_names() {
708 + let target_config = config.get_target(&name).unwrap().clone();
709 + let Some(whois_config) = target_config.whois else { continue };
710 + let label = target_config.label.clone();
711 + let pool = pool.clone();
712 + let alerter = alerter.clone();
713 + let cancel = token.clone();
714 + let warn_days = whois_config.warn_days;
715 +
716 + info!("{name}: WHOIS check every {whois_interval_secs}s (domain={})", whois_config.domain);
717 +
718 + handles.push(tokio::spawn(async move {
719 + let mut interval = tokio::time::interval(
720 + std::time::Duration::from_secs(whois_interval_secs),
721 + );
722 + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
723 +
724 + interval.tick().await; // consume immediate first tick
725 + loop {
726 + tokio::select! {
727 + _ = cancel.cancelled() => break,
728 + _ = interval.tick() => {}
729 + }
730 + let result = whois::check_whois(&name, &whois_config).await;
731 + info!("{}: WHOIS {} — {:?} days remaining", name, whois_config.domain, result.days_remaining);
732 +
733 + if let Err(e) = db::insert_whois_check(&pool, &result).await {
734 + tracing::error!("{name}: failed to store WHOIS check: {e}");
735 + }
736 +
737 + if let Some(ref alerter) = alerter {
738 + if let Some(ref error) = result.error {
739 + alerter.send_whois_error_alert(&name, &label, &whois_config.domain, error).await;
740 + } else if let Some(days) = result.days_remaining
741 + && days <= warn_days as i64
742 + {
743 + alerter.send_whois_expiry_alert(&name, &label, &whois_config.domain, days).await;
744 + }
745 + }
746 + }
747 + }));
748 + }
749 +
574 750 // Spawn daily prune task
575 751 let prune_pool = pool.clone();
576 752 let prune_cancel = token.clone();
@@ -585,7 +761,7 @@ pub(crate) async fn cmd_serve(
585 761 _ = interval.tick() => {}
586 762 }
587 763 match db::prune_old_records(&prune_pool, prune_days).await {
588 - Ok((h, t, p, a, tl, inc, rc)) => info!("Pruned {h} health checks, {t} test runs, {p} peer heartbeats, {a} alerts, {tl} TLS checks, {inc} incidents, {rc} route checks"),
764 + Ok(r) => info!("Pruned {} health checks, {} test runs, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks", r.health, r.tests, r.heartbeats, r.alerts, r.tls, r.incidents, r.routes, r.dns, r.whois),
589 765 Err(e) => tracing::error!("Prune failed: {e}"),
590 766 }
591 767 }
M src/config.rs +148
@@ -81,9 +81,15 @@ pub struct ServeConfig {
81 81 /// Seconds between route accessibility checks for all targets.
82 82 #[serde(default = "default_route_check_interval")]
83 83 pub route_check_interval_secs: u64,
84 + /// Seconds between DNS record verification checks.
85 + #[serde(default = "default_dns_check_interval")]
86 + pub dns_check_interval_secs: u64,
84 87 /// Bearer token required for API access. If set, all /api/* requests must
85 88 /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var.
86 89 pub api_token: Option<String>,
90 + /// Enable the HTML dashboard at `GET /`. Disabled by default.
91 + #[serde(default)]
92 + pub dashboard: bool,
87 93 }
88 94
89 95 impl Default for ServeConfig {
@@ -95,7 +101,9 @@ impl Default for ServeConfig {
95 101 peer_heartbeat_secs: 60,
96 102 tls_check_interval_secs: 3600,
97 103 route_check_interval_secs: 300,
104 + dns_check_interval_secs: 3600,
98 105 api_token: None,
106 + dashboard: false,
99 107 }
100 108 }
101 109 }
@@ -115,6 +123,11 @@ fn default_route_check_interval() -> u64 {
115 123 300
116 124 }
117 125
126 + fn default_dns_check_interval() -> u64 {
127 + // 1 hour: DNS records change infrequently, same cadence as TLS checks
128 + 3600
129 + }
130 +
118 131 fn default_serve_interval() -> u64 {
119 132 // 5 minutes: frequent enough to catch outages within an SLA window,
120 133 // infrequent enough to avoid noise
@@ -144,6 +157,34 @@ pub struct TargetConfig {
144 157 /// Requires `health` config for base URL derivation.
145 158 #[serde(default)]
146 159 pub expected_routes: Vec<String>,
160 + /// DNS records to verify. Empty disables DNS checks.
161 + #[serde(default)]
162 + pub dns: Vec<DnsRecord>,
163 + /// WHOIS domain expiry monitoring. `None` disables WHOIS checks.
164 + pub whois: Option<WhoisConfig>,
165 + }
166 +
167 + #[derive(Debug, Clone, Deserialize)]
168 + pub struct DnsRecord {
169 + /// Hostname to resolve (e.g. "makenot.work").
170 + pub name: String,
171 + /// DNS record type: "A", "AAAA", "CNAME", "MX", "TXT".
172 + pub record_type: String,
173 + /// Expected values (order-independent set comparison).
174 + pub expected: Vec<String>,
175 + }
176 +
177 + #[derive(Debug, Clone, Deserialize)]
178 + pub struct WhoisConfig {
179 + /// Domain to check (e.g. "makenot.work").
180 + pub domain: String,
181 + /// Alert when registration expires within this many days. Defaults to 30.
182 + #[serde(default = "default_whois_warn_days")]
183 + pub warn_days: u32,
184 + }
185 +
186 + fn default_whois_warn_days() -> u32 {
187 + 30
147 188 }
148 189
149 190 #[derive(Debug, Clone, Deserialize)]
@@ -694,6 +735,113 @@ route_check_interval_secs = 600
694 735 }
695 736
696 737 #[test]
738 + fn config_dns_check_interval_default() {
739 + let config: Config = toml::from_str("").unwrap();
740 + assert_eq!(config.serve.dns_check_interval_secs, 3600);
741 + }
742 +
743 + #[test]
744 + fn config_dns_check_interval_custom() {
745 + let toml = r#"
746 + [serve]
747 + dns_check_interval_secs = 1800
748 + "#;
749 + let config: Config = toml::from_str(toml).unwrap();
750 + assert_eq!(config.serve.dns_check_interval_secs, 1800);
751 + }
752 +
753 + #[test]
754 + fn config_with_dns_records() {
755 + let toml = r#"
756 + [targets.mnw]
757 + label = "MakeNotWork"
758 +
759 + [[targets.mnw.dns]]
760 + name = "makenot.work"
761 + record_type = "A"
762 + expected = ["5.78.144.244"]
763 +
764 + [[targets.mnw.dns]]
765 + name = "git.makenot.work"
766 + record_type = "A"
767 + expected = ["5.78.144.244"]
768 + "#;
769 + let config: Config = toml::from_str(toml).unwrap();
770 + let mnw = config.get_target("mnw").unwrap();
771 + assert_eq!(mnw.dns.len(), 2);
772 + assert_eq!(mnw.dns[0].name, "makenot.work");
773 + assert_eq!(mnw.dns[0].record_type, "A");
774 + assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
775 + assert_eq!(mnw.dns[1].name, "git.makenot.work");
776 + }
777 +
778 + #[test]
779 + fn config_dns_default_empty() {
780 + let toml = r#"
781 + [targets.mnw]
782 + label = "MakeNotWork"
783 + "#;
784 + let config: Config = toml::from_str(toml).unwrap();
785 + assert!(config.get_target("mnw").unwrap().dns.is_empty());
786 + }
787 +
788 + #[test]
789 + fn config_with_whois() {
790 + let toml = r#"
791 + [targets.mnw]
792 + label = "MakeNotWork"
793 +
794 + [targets.mnw.whois]
795 + domain = "makenot.work"
796 + warn_days = 60
797 + "#;
798 + let config: Config = toml::from_str(toml).unwrap();
799 + let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
800 + assert_eq!(whois.domain, "makenot.work");
801 + assert_eq!(whois.warn_days, 60);
802 + }
803 +
804 + #[test]
805 + fn config_whois_default_warn_days() {
806 + let toml = r#"
807 + [targets.mnw]
808 + label = "MakeNotWork"
809 +
810 + [targets.mnw.whois]
811 + domain = "makenot.work"
812 + "#;
813 + let config: Config = toml::from_str(toml).unwrap();
814 + let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
815 + assert_eq!(whois.warn_days, 30);
816 + }
817 +
818 + #[test]
819 + fn config_without_whois() {
820 + let toml = r#"
821 + [targets.mnw]
822 + label = "MakeNotWork"
823 + "#;
824 + let config: Config = toml::from_str(toml).unwrap();
825 + assert!(config.get_target("mnw").unwrap().whois.is_none());
826 + }
827 +
828 + #[test]
829 + fn config_dashboard_default_false() {
830 + let config: Config = toml::from_str("").unwrap();
831 + assert!(!config.serve.dashboard);
832 + }
833 +
834 + #[test]
835 + fn config_dashboard_enabled() {
836 + let toml = r#"
837 + [serve]
838 + dashboard = true
839 + "#;
840 + let config: Config = toml::from_str(toml).unwrap();
841 + assert!(config.serve.dashboard);
842 + }
843 +
844 + #[test]
697 845 fn config_expected_routes_without_slash_detected() {
698 846 let toml = r#"
699 847 [targets.mnw]
@@ -0,0 +1,406 @@
1 + //! Optional HTML dashboard served at `GET /`.
2 +
3 + use axum::extract::State as AxumState;
4 + use axum::response::{Html, IntoResponse};
5 +
6 + use crate::api::ApiState;
7 +
8 + /// Handler for `GET /` — returns the dashboard HTML page.
9 + pub async fn dashboard_handler(AxumState(state): AxumState<ApiState>) -> impl IntoResponse {
10 + let instance_name = state.config.instance_name();
11 + let version = env!("CARGO_PKG_VERSION");
12 + let api_token = state.config.serve.api_token.as_deref().unwrap_or("");
13 + let has_mesh = state.mesh.is_some();
14 + Html(render_dashboard(&instance_name, version, api_token, has_mesh))
15 + }
16 +
17 + /// Escape a string for safe embedding in a JS string literal.
18 + fn escape_js(s: &str) -> String {
19 + s.replace('\\', "\\\\").replace('"', "\\\"")
20 + }
21 +
22 + fn render_dashboard(instance_name: &str, version: &str, api_token: &str, has_mesh: bool) -> String {
23 + let js = format!(
24 + "const API_TOKEN = \"{token}\";\nconst HAS_MESH = {has_mesh};\n{JS}",
25 + token = escape_js(api_token),
26 + has_mesh = has_mesh,
27 + JS = JS,
28 + );
29 + format!(
30 + r##"<!DOCTYPE html>
31 + <html lang="en">
32 + <head>
33 + <meta charset="utf-8">
34 + <meta name="viewport" content="width=device-width, initial-scale=1">
35 + <title>PoM — {instance_name}</title>
36 + <link rel="preconnect" href="https://fonts.googleapis.com">
37 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
38 + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Lato:wght@400;700&display=swap" rel="stylesheet">
39 + <style>{CSS}</style>
40 + </head>
41 + <body>
42 + <div class="health-container">
43 + <div class="summary-bar">
44 + <div class="summary-left">
45 + <span class="summary-dot" id="global-dot"></span>
46 + <span class="summary-title">PoM</span>
47 + <span class="summary-instance">{instance_name}</span>
48 + </div>
49 + <div class="summary-right">
50 + <span class="summary-version">v{version}</span>
51 + <span class="summary-refresh" id="refresh-timer">Refresh: 30s</span>
52 + <span class="summary-updated" id="last-updated"></span>
53 + </div>
54 + </div>
55 + <div class="health-grid" id="target-grid"></div>
56 + <div id="mesh-section"></div>
57 + <div id="details-section"></div>
58 + </div>
59 + <script>
60 + {js}
61 + </script>
62 + </body>
63 + </html>"##,
64 + instance_name = instance_name,
65 + CSS = CSS,
66 + js = js,
67 + )
68 + }
69 +
70 + const CSS: &str = r##"
71 + :root {
72 + --background: #ede8e1;
73 + --text: #3d3530;
74 + --surface-muted: #ddd7c5;
75 + --light-background: #f4f0eb;
76 + --border: #d0cbb8;
77 + --ok: #22c55e;
78 + --warn: #f59e0b;
79 + --error: #ef4444;
80 + --unknown: #9ca3af;
81 + }
82 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
83 + body {
84 + font-family: 'Lato', sans-serif;
85 + background: var(--background);
86 + color: var(--text);
87 + line-height: 1.5;
88 + }
89 + .health-container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
90 + .summary-bar {
91 + display: flex; justify-content: space-between; align-items: center;
92 + margin-bottom: 1.5rem; padding: 0.75rem 1rem;
93 + background: var(--surface-muted); border-radius: 6px;
94 + }
95 + .summary-left, .summary-right { display: flex; align-items: center; gap: 0.75rem; }
96 + .summary-dot {
97 + width: 10px; height: 10px; border-radius: 50%;
98 + background: var(--unknown); display: inline-block; flex-shrink: 0;
99 + }
100 + .summary-title { font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: 1.1rem; }
101 + .summary-instance { font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; opacity: 0.6; }
102 + .summary-version { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; }
103 + .summary-refresh { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; }
104 + .summary-updated { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; }
105 + .health-grid {
106 + display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
107 + gap: 1.5rem; margin-bottom: 2rem;
108 + }
109 + .health-card {
110 + background: var(--surface-muted); border-radius: 6px; padding: 1.25rem;
111 + }
112 + .health-card.incident { border-left: 3px solid var(--error); }
113 + .health-card.stale { border-left: 3px solid var(--warn); }
114 + .card-header {
115 + display: flex; align-items: center; gap: 0.5rem;
116 + font-family: 'IBM Plex Mono', monospace; font-size: 0.9rem; font-weight: 500;
117 + margin-bottom: 0.75rem;
118 + }
119 + .status-dot {
120 + width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
121 + }
122 + .dot-ok { background: var(--ok); }
123 + .dot-warn { background: var(--warn); }
124 + .dot-error { background: var(--error); }
125 + .dot-unknown { background: var(--unknown); }
126 + dl {
127 + display: grid; grid-template-columns: auto 1fr;
128 + gap: 0.25rem 0.75rem; font-size: 0.85rem;
129 + }
130 + dt { opacity: 0.7; }
131 + dd { text-align: right; font-family: 'IBM Plex Mono', monospace; }
132 + .section-title {
133 + font-family: 'IBM Plex Mono', monospace; font-size: 1rem; font-weight: 500;
134 + border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin: 1.5rem 0 1rem;
135 + }
136 + details { margin-bottom: 0.75rem; }
137 + details summary {
138 + font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem;
139 + cursor: pointer; padding: 0.5rem; background: var(--light-background);
140 + border-radius: 4px; list-style: none;
141 + }
142 + details summary::before { content: '\25b6 '; font-size: 0.7rem; }
143 + details[open] summary::before { content: '\25bc '; }
144 + details .detail-content { padding: 0.5rem; font-size: 0.85rem; }
145 + .incident-line {
146 + display: flex; justify-content: space-between; align-items: center;
147 + padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem;
148 + }
149 + .incident-line:last-child { border-bottom: none; }
150 + .uptime-ok { color: var(--ok); }
151 + .uptime-warn { color: var(--warn); }
152 + .uptime-danger { color: var(--error); }
153 + .alert-bar {
154 + padding: 0.4rem 0.6rem; font-size: 0.8rem; border-radius: 4px;
155 + margin-top: 0.5rem; font-family: 'IBM Plex Mono', monospace;
156 + }
157 + .alert-bar.incident-bar { background: rgba(239,68,68,0.1); border-left: 3px solid var(--error); }
158 + .alert-bar.stale-bar { background: rgba(245,158,11,0.1); border-left: 3px solid var(--warn); }
159 + @media (max-width: 600px) {
160 + .summary-bar { flex-direction: column; gap: 0.5rem; }
161 + .health-grid { grid-template-columns: 1fr; }
162 + }
163 + "##;
164 +
165 + const JS: &str = r##"
166 + let countdown = 30;
167 + let timer = null;
168 +
169 + function headers() {
170 + const h = {};
171 + if (API_TOKEN) h['Authorization'] = 'Bearer ' + API_TOKEN;
172 + return h;
173 + }
174 +
175 + function dotClass(status) {
176 + if (!status) return 'dot-unknown';
177 + const s = status.toLowerCase();
178 + if (s === 'operational') return 'dot-ok';
179 + if (s === 'degraded') return 'dot-warn';
180 + if (s === 'error' || s === 'unreachable') return 'dot-error';
181 + return 'dot-unknown';
182 + }
183 +
184 + function uptimeClass(pct) {
185 + if (pct >= 99) return 'uptime-ok';
186 + if (pct >= 95) return 'uptime-warn';
187 + return 'uptime-danger';
188 + }
189 +
190 + function fmtPct(v) {
191 + return v != null ? v.toFixed(2) + '%' : 'N/A';
192 + }
193 +
194 + function renderCard(name, t) {
195 + const status = t.latest ? t.latest.status : 'unknown';
196 + const dc = dotClass(status);
197 + let cls = 'health-card';
198 + if (t.current_incident) cls += ' incident';
199 + else if (t.test_staleness && t.test_staleness.stale) cls += ' stale';
200 +
201 + let html = '<div class="' + cls + '">';
202 + html += '<div class="card-header"><span class="status-dot ' + dc + '"></span>' + esc(t.label) + '</div>';
203 + html += '<dl>';
204 + html += '<dt>Status</dt><dd>' + esc(status) + '</dd>';
205 + if (t.latest) html += '<dt>Response</dt><dd>' + t.latest.response_time_ms + 'ms</dd>';
206 + if (t.uptime_24h != null) {
207 + const c24 = uptimeClass(t.uptime_24h);
208 + html += '<dt>Uptime 24h</dt><dd class="' + c24 + '">' + fmtPct(t.uptime_24h) + '</dd>';
209 + }
210 + if (t.uptime_7d != null) {
211 + const c7 = uptimeClass(t.uptime_7d);
212 + html += '<dt>Uptime 7d</dt><dd class="' + c7 + '">' + fmtPct(t.uptime_7d) + '</dd>';
213 + }
214 + html += '</dl>';
215 +
216 + if (t.latency_24h) {
217 + html += '<dl>';
218 + html += '<dt>Avg</dt><dd>' + t.latency_24h.avg_ms.toFixed(0) + 'ms</dd>';
219 + html += '<dt>P95</dt><dd>' + t.latency_24h.p95_ms + 'ms</dd>';
220 + html += '<dt>Min/Max</dt><dd>' + t.latency_24h.min_ms + '/' + t.latency_24h.max_ms + 'ms</dd>';
221 + html += '</dl>';
222 + }
223 +
224 + if (t.tls) {
225 + html += '<dl>';
226 + html += '<dt>TLS</dt><dd>' + (t.tls.valid ? 'valid' : 'invalid') + '</dd>';
227 + const dc2 = t.tls.days_remaining > 14 ? 'uptime-ok' : t.tls.days_remaining > 7 ? 'uptime-warn' : 'uptime-danger';
228 + html += '<dt>Expires</dt><dd class="' + dc2 + '">' + t.tls.days_remaining + 'd</dd>';
229 + html += '</dl>';
230 + }
231 +
232 + if (t.whois) {
233 + html += '<dl>';
234 + const wd = t.whois.days_remaining;
235 + const wc = wd > 30 ? 'uptime-ok' : wd > 14 ? 'uptime-warn' : 'uptime-danger';
236 + html += '<dt>Domain</dt><dd class="' + wc + '">' + (wd != null ? wd + 'd' : 'N/A') + '</dd>';
237 + if (t.whois.registrar) html += '<dt>Registrar</dt><dd>' + esc(t.whois.registrar) + '</dd>';
238 + html += '</dl>';
239 + }
240 +
241 + if (t.dns_status && t.dns_status.length > 0) {
242 + const m = t.dns_status.filter(function(d) { return d.matches; }).length;
243 + html += '<dl><dt>DNS</dt><dd>' + m + '/' + t.dns_status.length + ' match</dd></dl>';
244 + }
245 + if (t.route_status && t.route_status.length > 0) {
246 + const ok = t.route_status.filter(function(r) { return r.ok; }).length;
247 + html += '<dl><dt>Routes</dt><dd>' + ok + '/' + t.route_status.length + ' OK</dd></dl>';
248 + }
249 +
250 + if (t.current_incident) {
251 + html += '<div class="alert-bar incident-bar">Incident: ' + esc(t.current_incident.from_status) + ' \u2192 ' + esc(t.current_incident.to_status) + '</div>';
252 + }
253 + if (t.test_staleness && t.test_staleness.stale) {
254 + html += '<div class="alert-bar stale-bar">Tests stale: ' + esc(t.test_staleness.reason) + '</div>';
255 + }
256 +
257 + html += '</div>';
258 + return html;
259 + }
260 +
261 + function esc(s) {
262 + if (!s) return '';
263 + var d = document.createElement('div');
264 + d.textContent = s;
265 + return d.innerHTML;
266 + }
267 +
268 + function renderDetails(targets) {
269 + let html = '';
270 + // Recent incidents
271 + let hasIncidents = false;
272 + let incHtml = '';
273 + for (const name in targets) {
274 + const t = targets[name];
275 + if (t.incidents && t.incidents.length > 0) {
276 + hasIncidents = true;
277 + for (let i = 0; i < t.incidents.length; i++) {
278 + const inc = t.incidents[i];
279 + const dur = inc.duration_secs ? Math.round(inc.duration_secs / 60) + 'm' : 'ongoing';
280 + incHtml += '<div class="incident-line"><span>' + esc(t.label) + ': ' + esc(inc.from_status) + ' \u2192 ' + esc(inc.to_status) + '</span><span>' + dur + '</span></div>';
281 + }
282 + }
283 + }
284 + if (hasIncidents) {
285 + html += '<details><summary>Recent Incidents</summary><div class="detail-content">' + incHtml + '</div></details>';
286 + }
287 +
288 + // DNS details
289 + let hasDns = false;
290 + let dnsHtml = '';
291 + for (const name in targets) {
292 + const t = targets[name];
293 + if (t.dns_status && t.dns_status.length > 0) {
294 + hasDns = true;
295 + for (let i = 0; i < t.dns_status.length; i++) {
296 + const d = t.dns_status[i];
297 + const mc = d.matches ? 'dot-ok' : 'dot-error';
298 + dnsHtml += '<div class="incident-line"><span><span class="status-dot ' + mc + '" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>' + esc(d.name) + ' ' + esc(d.record_type) + '</span><span>' + esc(d.actual.join(', ')) + '</span></div>';
299 + }
300 + }
301 + }
302 + if (hasDns) {
303 + html += '<details><summary>DNS Details</summary><div class="detail-content">' + dnsHtml + '</div></details>';
304 + }
305 +
306 + // Route details
307 + let hasRoutes = false;
308 + let routeHtml = '';
309 + for (const name in targets) {
310 + const t = targets[name];
311 + if (t.route_status && t.route_status.length > 0) {
312 + hasRoutes = true;
313 + for (let i = 0; i < t.route_status.length; i++) {
314 + const r = t.route_status[i];
315 + const rc = r.ok ? 'dot-ok' : 'dot-error';
316 + routeHtml += '<div class="incident-line"><span><span class="status-dot ' + rc + '" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>' + esc(t.label) + ' ' + esc(r.path) + '</span><span>' + r.status_code + ' (' + r.response_time_ms + 'ms)</span></div>';
317 + }
318 + }
319 + }
320 + if (hasRoutes) {
321 + html += '<details><summary>Route Details</summary><div class="detail-content">' + routeHtml + '</div></details>';
322 + }
323 +
324 + return html;
325 + }
326 +
327 + function renderMesh(data) {
328 + if (!data || !data.instances) return '';
329 + let html = '<div class="section-title">Peer Mesh</div>';
330 + html += '<div class="health-grid">';
331 + for (const name in data.instances) {
332 + const inst = data.instances[name];
333 + html += '<div class="health-card"><div class="card-header">' + esc(name) + '</div>';
334 + if (inst.instance) {
335 + html += '<dl><dt>Version</dt><dd>' + esc(inst.instance.version) + '</dd></dl>';
336 + }
337 + if (inst.targets) {
338 + html += '<dl>';
339 + for (const tn in inst.targets) {
340 + const tt = inst.targets[tn];
341 + const dc = dotClass(tt.status);
342 + html += '<dt>' + esc(tt.label || tn) + '</dt><dd><span class="status-dot ' + dc + '" style="display:inline-block;vertical-align:middle"></span></dd>';
343 + }
344 + html += '</dl>';
345 + }
346 + html += '</div>';
347 + }
348 + html += '</div>';
349 + return html;
350 + }
351 +
352 + async function refresh() {
353 + try {
354 + const resp = await fetch('/api/status', { headers: headers() });
355 + if (!resp.ok) return;
356 + const data = await resp.json();
357 + const targets = data.targets || {};
358 +
359 + // Global dot
360 + let worst = 'operational';
361 + for (const name in targets) {
362 + const s = targets[name].latest ? targets[name].latest.status : 'unknown';
363 + if (s === 'error' || s === 'unreachable') worst = 'error';
364 + else if (s === 'degraded' && worst !== 'error') worst = 'degraded';
365 + else if (s === 'unknown' && worst === 'operational') worst = 'unknown';
366 + }
367 + document.getElementById('global-dot').className = 'summary-dot ' + dotClass(worst).replace('dot-', 'dot-');
368 +
369 + // Sort target names
370 + const names = Object.keys(targets).sort();
371 + let gridHtml = '';
372 + for (let i = 0; i < names.length; i++) {
373 + gridHtml += renderCard(names[i], targets[names[i]]);
374 + }
375 + document.getElementById('target-grid').innerHTML = gridHtml;
376 +
377 + // Details
378 + document.getElementById('details-section').innerHTML = renderDetails(targets);
379 +
380 + // Update timestamp
381 + document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
382 +
383 + // Mesh
384 + if (HAS_MESH) {
385 + try {
386 + const mr = await fetch('/api/mesh', { headers: headers() });
387 + if (mr.ok) {
388 + const md = await mr.json();
389 + document.getElementById('mesh-section').innerHTML = renderMesh(md);
390 + }
391 + } catch(e) {}
392 + }
393 + } catch(e) {
394 + console.error('Dashboard refresh failed:', e);
395 + }
396 + }
397 +
398 + function tick() {
399 + countdown--;
400 + if (countdown <= 0) { countdown = 30; refresh(); }
401 + document.getElementById('refresh-timer').textContent = 'Refresh: ' + countdown + 's';
402 + }
403 +
404 + refresh();
405 + timer = setInterval(tick, 1000);
406 + "##;
M src/db.rs +175 -15
@@ -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::{HealthDetails, HealthSnapshot, HealthStatus, TestRun, TestSummary, TlsStatus};
14 + use crate::types::{DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, 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.
@@ -109,6 +109,33 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
109 109 CREATE INDEX idx_route_checks_target_path ON route_checks(target, path, id DESC);
110 110 CREATE INDEX idx_route_checks_target ON route_checks(target, checked_at DESC);
111 111 "#),
112 + (6, "add dns_checks and whois_checks tables", r#"
113 + CREATE TABLE dns_checks (
114 + id INTEGER PRIMARY KEY AUTOINCREMENT,
115 + target TEXT NOT NULL,
116 + name TEXT NOT NULL,
117 + record_type TEXT NOT NULL,
118 + expected TEXT NOT NULL,
119 + actual TEXT NOT NULL,
120 + matches INTEGER NOT NULL,
121 + checked_at TEXT NOT NULL,
122 + error TEXT
123 + );
124 + CREATE INDEX idx_dns_checks_target ON dns_checks(target, name, id DESC);
125 +
126 + CREATE TABLE whois_checks (
127 + id INTEGER PRIMARY KEY AUTOINCREMENT,
128 + target TEXT NOT NULL,
129 + domain TEXT NOT NULL,
130 + registrar TEXT,
131 + expiry_date TEXT,
132 + days_remaining INTEGER,
133 + nameservers TEXT,
134 + checked_at TEXT NOT NULL,
135 + error TEXT
136 + );
137 + CREATE INDEX idx_whois_checks_target ON whois_checks(target, id DESC);
138 + "#),
112 139 ];
113 140
114 141 #[instrument(skip_all)]
@@ -725,22 +752,143 @@ pub async fn get_latest_route_checks(
725 752 .await?)
726 753 }
727 754
755 + // --- DNS check queries ---
756 +
757 + #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
758 + pub struct DnsCheckRow {
759 + pub id: i64,
760 + pub target: String,
761 + pub name: String,
762 + pub record_type: String,
763 + pub expected: String,
764 + pub actual: String,
765 + pub matches: bool,
766 + pub checked_at: String,
767 + pub error: Option<String>,
768 + }
769 +
770 + #[instrument(skip_all)]
771 + pub async fn insert_dns_check(
772 + pool: &SqlitePool,
773 + result: &DnsCheckResult,
774 + ) -> Result<i64> {
775 + let expected = serde_json::to_string(&result.expected).unwrap_or_default();
776 + let actual = serde_json::to_string(&result.actual).unwrap_or_default();
777 +
778 + let row = sqlx::query(
779 + "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at, error)
780 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
781 + )
782 + .bind(&result.target)
783 + .bind(&result.name)
784 + .bind(&result.record_type)
785 + .bind(&expected)
786 + .bind(&actual)
787 + .bind(result.matches)
788 + .bind(&result.checked_at)
789 + .bind(&result.error)
790 + .execute(pool)
791 + .await?;
792 + Ok(row.last_insert_rowid())
793 + }
794 +
795 + /// Get the latest DNS check per (name, record_type) for a target.
796 + #[instrument(skip_all)]
797 + pub async fn get_latest_dns_checks(
798 + pool: &SqlitePool,
799 + target: &str,
800 + ) -> Result<Vec<DnsCheckRow>> {
801 + Ok(sqlx::query_as::<_, DnsCheckRow>(
802 + "SELECT d.id, d.target, d.name, d.record_type, d.expected, d.actual, d.matches, d.checked_at, d.error
803 + FROM dns_checks d
804 + INNER JOIN (SELECT name, record_type, MAX(id) as max_id FROM dns_checks WHERE target = ? GROUP BY name, record_type) latest
805 + ON d.id = latest.max_id
806 + ORDER BY d.name, d.record_type",
807 + )
808 + .bind(target)
809 + .fetch_all(pool)
810 + .await?)
811 + }
812 +
813 + // --- WHOIS check queries ---
814 +
815 + #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
816 + pub struct WhoisCheckRow {
817 + pub id: i64,
818 + pub target: String,
819 + pub domain: String,
820 + pub registrar: Option<String>,
821 + pub expiry_date: Option<String>,
822 + pub days_remaining: Option<i64>,
823 + pub nameservers: Option<String>,
824 + pub checked_at: String,
825 + pub error: Option<String>,
826 + }
827 +
828 + #[instrument(skip_all)]
829 + pub async fn insert_whois_check(
830 + pool: &SqlitePool,
831 + result: &WhoisResult,
832 + ) -> Result<i64> {
833 + let nameservers = serde_json::to_string(&result.nameservers).unwrap_or_default();
834 +
835 + let row = sqlx::query(
836 + "INSERT INTO whois_checks (target, domain, registrar, expiry_date, days_remaining, nameservers, checked_at, error)
837 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
838 + )
839 + .bind(&result.target)
840 + .bind(&result.domain)
841 + .bind(&result.registrar)
842 + .bind(&result.expiry_date)
843 + .bind(result.days_remaining)
844 + .bind(&nameservers)
845 + .bind(&result.checked_at)
846 + .bind(&result.error)
847 + .execute(pool)
848 + .await?;
849 + Ok(row.last_insert_rowid())
850 + }
851 +
852 + #[instrument(skip_all)]
853 + pub async fn get_latest_whois_check(
854 + pool: &SqlitePool,
855 + target: &str,
856 + ) -> Result<Option<WhoisCheckRow>> {
857 + Ok(sqlx::query_as::<_, WhoisCheckRow>(
858 + "SELECT id, target, domain, registrar, expiry_date, days_remaining, nameservers, checked_at, error
859 + FROM whois_checks WHERE target = ? ORDER BY id DESC LIMIT 1",
860 + )
861 + .bind(target)
862 + .fetch_optional(pool)
863 + .await?)
864 + }
865 +
728 866 // --- Maintenance ---
729 867
868 + /// Prune result with counts for each table.
869 + pub struct PruneResult {
870 + pub health: u64,
871 + pub tests: u64,
872 + pub heartbeats: u64,
873 + pub alerts: u64,
874 + pub tls: u64,
875 + pub incidents: u64,
876 + pub routes: u64,
877 + pub dns: u64,
878 + pub whois: u64,
879 + }
880 +
730 881 /// Delete records older than `days` from all tables.
731 - ///
732 - /// Returns a tuple of deleted row counts in this order:
733 - /// (health_checks, test_runs, heartbeats, alerts, tls_checks, incidents, route_checks).
734 882 /// Only closed incidents (with a non-NULL `ended_at`) are pruned.
735 883 #[instrument(skip_all)]
736 884 pub async fn prune_old_records(
737 885 pool: &SqlitePool,
738 886 days: i64,
739 - ) -> Result<(u64, u64, u64, u64, u64, u64, u64)> {
887 + ) -> Result<PruneResult> {
740 888 // Guard: days <= 0 would set cutoff to now (or the future), deleting
741 889 // everything. Treat this as a no-op instead.
742 890 if days <= 0 {
743 - return Ok((0, 0, 0, 0, 0, 0, 0));
891 + return Ok(PruneResult { health: 0, tests: 0, heartbeats: 0, alerts: 0, tls: 0, incidents: 0, routes: 0, dns: 0, whois: 0 });
744 892 }
745 893
746 894 let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
@@ -781,15 +929,27 @@ pub async fn prune_old_records(
781 929 .execute(pool)
782 930 .await?;
783 931
784 - Ok((
785 - health_result.rows_affected(),
786 - test_result.rows_affected(),
787 - peer_hb_result.rows_affected(),
788 - alerts_result.rows_affected(),
789 - tls_result.rows_affected(),
790 - incidents_result.rows_affected(),
791 - routes_result.rows_affected(),
792 - ))
932 + let dns_result = sqlx::query("DELETE FROM dns_checks WHERE checked_at < ?")
933 + .bind(&cutoff_str)
934 + .execute(pool)
935 + .await?;
936 +
937 + let whois_result = sqlx::query("DELETE FROM whois_checks WHERE checked_at < ?")
938 + .bind(&cutoff_str)
939 + .execute(pool)
940 + .await?;
941 +
942 + Ok(PruneResult {
943 + health: health_result.rows_affected(),
944 + tests: test_result.rows_affected(),
945 + heartbeats: peer_hb_result.rows_affected(),
946 + alerts: alerts_result.rows_affected(),
947 + tls: tls_result.rows_affected(),
948 + incidents: incidents_result.rows_affected(),
949 + routes: routes_result.rows_affected(),
950 + dns: dns_result.rows_affected(),
951 + whois: whois_result.rows_affected(),
952 + })
793 953 }
794 954
795 955 // --- Peer identity queries ---
M src/display.rs +114 -35
@@ -5,8 +5,8 @@
5 5
6 6 use std::fmt::Write;
7 7
8 - use crate::db::{IncidentRow, RouteCheckRow, TlsCheckRow};
9 - use crate::types::{HealthSnapshot, LatencyStats, TestRun, TestStaleness};
8 + use crate::db::{DnsCheckRow, IncidentRow, PruneResult, RouteCheckRow, TlsCheckRow, WhoisCheckRow};
9 + use crate::types::{DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, WhoisResult};
10 10
11 11 /// Format a single health snapshot as a human-readable line.
12 12 pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
@@ -58,7 +58,7 @@ pub fn format_test_result(target_name: &str, run: &TestRun) -> String {
58 58 out
59 59 }
60 60
61 - /// Format a single target's status block (health + latency + TLS + routes + tests + staleness + incident) for CLI display.
61 + /// Format a single target's status block for CLI display.
62 62 #[allow(clippy::too_many_arguments)]
63 63 pub fn format_status_target(
64 64 name: &str,
@@ -67,6 +67,8 @@ pub fn format_status_target(
67 67 latency: Option<&LatencyStats>,
68 68 tls: Option<&TlsCheckRow>,
69 69 route_checks: Option<&[RouteCheckRow]>,
70 + dns_checks: Option<&[DnsCheckRow]>,
71 + whois: Option<&WhoisCheckRow>,
70 72 test: Option<&TestRun>,
71 73 staleness: Option<&TestStaleness>,
72 74 incident: Option<&IncidentRow>,
@@ -108,15 +110,42 @@ pub fn format_status_target(
108 110 }
109 111 }
110 112
111 - if let Some(checks) = route_checks {
112 - if !checks.is_empty() {
113 - let total = checks.len();
114 - let ok_count = checks.iter().filter(|c| c.ok).count();
115 - if ok_count == total {
116 - writeln!(out, " Routes: {ok_count}/{total} OK").unwrap();
113 + if let Some(checks) = route_checks
114 + && !checks.is_empty()
115 + {
116 + let total = checks.len();
117 + let ok_count = checks.iter().filter(|c| c.ok).count();
118 + if ok_count == total {
119 + writeln!(out, " Routes: {ok_count}/{total} OK").unwrap();
120 + } else {
121 + let failed: Vec<&str> = checks.iter().filter(|c| !c.ok).map(|c| c.path.as_str()).collect();
122 + writeln!(out, " Routes: {ok_count}/{total} (FAIL: {})", failed.join(", ")).unwrap();
123 + }
124 + }
125 +
126 + if let Some(checks) = dns_checks
127 + && !checks.is_empty()
128 + {
129 + let total = checks.len();
130 + let ok_count = checks.iter().filter(|c| c.matches).count();
131 + if ok_count == total {
132 + writeln!(out, " DNS: {ok_count}/{total} match").unwrap();
133 + } else {
134 + let failed: Vec<String> = checks.iter().filter(|c| !c.matches).map(|c| format!("{} {}", c.name, c.record_type)).collect();
135 + writeln!(out, " DNS: {ok_count}/{total} (MISMATCH: {})", failed.join(", ")).unwrap();
136 + }
137 + }
138 +
139 + if let Some(w) = whois {
140 + if let Some(ref err) = w.error {
141 + writeln!(out, " WHOIS: [ERR] {} \u{2014} {err}", w.domain).unwrap();
142 + } else if let Some(days) = w.days_remaining {
143 + if days <= 0 {
144 + writeln!(out, " WHOIS: [ERR] {} \u{2014} EXPIRED", w.domain).unwrap();
145 + } else if days <= 30 {
146 + writeln!(out, " WHOIS: [WARN] {} \u{2014} {}d remaining", w.domain, days).unwrap();
117 147 } else {
118 - let failed: Vec<&str> = checks.iter().filter(|c| !c.ok).map(|c| c.path.as_str()).collect();
119 - writeln!(out, " Routes: {ok_count}/{total} (FAIL: {})", failed.join(", ")).unwrap();
148 + writeln!(out, " WHOIS: [OK] {} \u{2014} {}d remaining", w.domain, days).unwrap();
120 149 }
121 150 }
122 151 }
@@ -192,9 +221,51 @@ pub fn format_test_history(history: &[TestRun]) -> String {
192 221 out
193 222 }
194 223
224 + /// Format DNS check results and WHOIS results for CLI display.
225 + pub fn format_dns_results(dns_results: &[DnsCheckResult], whois_results: &[WhoisResult]) -> String {
226 + let mut out = String::new();
227 +
228 + if !dns_results.is_empty() {
229 + writeln!(out, "DNS Records:").unwrap();
230 + for r in dns_results {
231 + if let Some(ref err) = r.error {
232 + writeln!(out, " [ERR] {} {} \u{2014} {err}", r.name, r.record_type).unwrap();
233 + } else if r.matches {
234 + writeln!(out, " [OK] {} {} \u{2014} {:?}", r.name, r.record_type, r.actual).unwrap();
235 + } else {
236 + writeln!(out, " [FAIL] {} {} \u{2014} expected {:?}, got {:?}", r.name, r.record_type, r.expected, r.actual).unwrap();
237 + }
238 + }
239 + }
240 +
241 + if !whois_results.is_empty() {
242 + if !dns_results.is_empty() {
243 + writeln!(out).unwrap();
244 + }
245 + writeln!(out, "WHOIS:").unwrap();
246 + for w in whois_results {
247 + if let Some(ref err) = w.error {
248 + writeln!(out, " [ERR] {} \u{2014} {err}", w.domain).unwrap();
249 + } else {
250 + let days_str = w.days_remaining
251 + .map(|d| format!("{d}d remaining"))
252 + .unwrap_or_else(|| "expiry unknown".to_string());
253 + let registrar_str = w.registrar.as_deref().unwrap_or("unknown registrar");
254 + writeln!(out, " [OK] {} \u{2014} {days_str} ({registrar_str})", w.domain).unwrap();
255 + }
256 + }
257 + }
258 +
259 + out
260 + }
261 +
195 262 /// Format prune results for CLI display.
196 - pub fn format_prune(health_pruned: u64, test_pruned: u64, heartbeat_pruned: u64, alerts_pruned: u64, tls_pruned: u64, incidents_pruned: u64, routes_pruned: u64, days: i64) -> String {
197 - format!("Pruned {health_pruned} health checks, {test_pruned} test runs, {heartbeat_pruned} peer heartbeats, {alerts_pruned} alerts, {tls_pruned} TLS checks, {incidents_pruned} incidents, {routes_pruned} route checks older than {days} days.\n")
263 + pub fn format_prune(result: &PruneResult, days: i64) -> String {
264 + format!(
265 + "Pruned {} health checks, {} test runs, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks older than {} days.\n",
266 + result.health, result.tests, result.heartbeats, result.alerts, result.tls,
267 + result.incidents, result.routes, result.dns, result.whois, days
268 + )
198 269 }
199 270
200 271 /// Format mesh data (from JSON) for human-readable CLI display.
@@ -494,7 +565,7 @@ mod tests {
494 565 raw_output: String::new(),
495 566 filter: None,
496 567 };
497 - let out = format_status_target("mnw", "MakeNotWork", Some(&health), None, None, None, Some(&test), None, None);
568 + let out = format_status_target("mnw", "MakeNotWork", Some(&health), None, None, None, None, None, Some(&test), None, None);
498 569 assert!(out.contains("=== mnw (MakeNotWork) ==="));
499 570 assert!(out.contains("Health: [OK] operational (95ms) v2.1.0"));
500 571 assert!(out.contains("Tests: PASSED (60s)"));
@@ -503,7 +574,7 @@ mod tests {
503 574
504 575 #[test]
505 576 fn status_target_no_data() {
506 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None);
577 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
507 578 assert!(out.contains("=== mnw (MakeNotWork) ==="));
508 579 assert!(out.contains("Health: no data"));
509 580 assert!(out.contains("Tests: no data"));
@@ -520,7 +591,7 @@ mod tests {
520 591 details: None,
521 592 error: None,
522 593 };
523 - let out = format_status_target("mnw", "MakeNotWork", Some(&health), None, None, None, None, None, None);
594 + let out = format_status_target("mnw", "MakeNotWork", Some(&health), None, None, None, None, None, None, None, None);
524 595 assert!(out.contains("Health: [WARN] degraded (2000ms)"));
525 596 assert!(out.contains("Tests: no data"));
526 597 }
@@ -543,7 +614,7 @@ mod tests {
543 614 raw_output: String::new(),
544 615 filter: None,
545 616 };
546 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, Some(&test), None, None);
617 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, Some(&test), None, None);
547 618 assert!(out.contains("Tests: FAILED"));
548 619 assert!(out.contains("80 passed, 5 failed"));
549 620 }
@@ -565,7 +636,7 @@ mod tests {
565 636 checked_at: "2026-03-11T00:00:00Z".to_string(),
566 637 error: None,
567 638 };
568 - let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None);
639 + let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None, None, None);
569 640 assert!(out.contains("TLS: [OK] makenot.work"));
570 641 assert!(out.contains("47d remaining"));
571 642 assert!(out.contains("expires 2026-04-27"));
@@ -586,7 +657,7 @@ mod tests {
586 657 checked_at: "2026-03-11T00:00:00Z".to_string(),
587 658 error: None,
588 659 };
589 - let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None);
660 + let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None, None, None);
590 661 assert!(out.contains("TLS: [WARN] makenot.work"));
591 662 assert!(out.contains("12d remaining"));
592 663 }
@@ -606,7 +677,7 @@ mod tests {
606 677 checked_at: "2026-03-11T00:00:00Z".to_string(),
607 678 error: Some("connection refused".to_string()),
608 679 };
609 - let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None);
680 + let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None, None, None);
610 681 assert!(out.contains("TLS: [ERR] makenot.work"));
611 682 assert!(out.contains("connection refused"));
612 683 }
@@ -624,13 +695,13 @@ mod tests {
624 695 from_status: "operational".to_string(),
625 696 to_status: "degraded".to_string(),
626 697 };
627 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, Some(&incident));
698 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, Some(&incident));
628 699 assert!(out.contains("Incident: [ACTIVE] degraded since 2026-03-11T14:30:00Z"));
629 700 }
630 701
631 702 #[test]
632 703 fn status_target_no_incident() {
633 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None);
704 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
634 705 assert!(!out.contains("Incident"));
635 706 }
636 707
@@ -645,13 +716,13 @@ mod tests {
645 716 p95_ms: 180,
646 717 sample_count: 288,
647 718 };
648 - let out = format_status_target("mnw", "MakeNotWork", None, Some(&latency), None, None, None, None, None);
719 + let out = format_status_target("mnw", "MakeNotWork", None, Some(&latency), None, None, None, None, None, None, None);
649 720 assert!(out.contains("Latency (24h): avg 120ms, p95 180ms, range 95-210ms (288 samples)"));
650 721 }
651 722
652 723 #[test]
653 724 fn status_target_without_latency() {
654 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None);
725 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
655 726 assert!(!out.contains("Latency"));
656 727 }
657 728
@@ -771,17 +842,25 @@ mod tests {
771 842
772 843 #[test]
773 844 fn prune_formatting() {
774 - let out = format_prune(5, 3, 10, 2, 1, 4, 0, 30);
845 + let result = PruneResult {
846 + health: 5, tests: 3, heartbeats: 10, alerts: 2, tls: 1,
847 + incidents: 4, routes: 0, dns: 8, whois: 2,
848 + };
849 + let out = format_prune(&result, 30);
775 850 assert_eq!(
776 851 out,
777 - "Pruned 5 health checks, 3 test runs, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks older than 30 days.\n"
852 + "Pruned 5 health checks, 3 test runs, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks, 8 DNS checks, 2 WHOIS checks older than 30 days.\n"
778 853 );
779 854 }
780 855
781 856 #[test]
782 857 fn prune_zero_records() {
783 - let out = format_prune(0, 0, 0, 0, 0, 0, 0, 7);
784 - assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks older than 7 days."));
858 + let result = PruneResult {
859 + health: 0, tests: 0, heartbeats: 0, alerts: 0, tls: 0,
860 + incidents: 0, routes: 0, dns: 0, whois: 0,
861 + };
862 + let out = format_prune(&result, 7);
863 + assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks, 0 DNS checks, 0 WHOIS checks older than 7 days."));
785 864 }
786 865
787 866 // --- format_mesh ---
@@ -927,7 +1006,7 @@ mod tests {
927 1006 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
928 1007 days_since_test: Some(1),
929 1008 };
930 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, Some(&staleness), None);
1009 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, Some(&staleness), None);
931 1010 assert!(out.contains("Tests: STALE"));
932 1011 assert!(out.contains("version changed: 0.1.8 -> 0.1.9"));
933 1012 }
@@ -942,7 +1021,7 @@ mod tests {
942 1021 last_test_at: Some("2026-03-01T00:00:00Z".to_string()),
943 1022 days_since_test: Some(10),
944 1023 };
945 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, Some(&staleness), None);
1024 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, Some(&staleness), None);
946 1025 assert!(out.contains("Tests: STALE"));
947 1026 assert!(out.contains("tests are 10 days old"));
948 1027 }
@@ -957,13 +1036,13 @@ mod tests {
957 1036 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
958 1037 days_since_test: Some(1),
959 1038 };
960 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, Some(&staleness), None);
1039 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, Some(&staleness), None);
961 1040 assert!(!out.contains("STALE"));
962 1041 }
963 1042
964 1043 #[test]
965 1044 fn status_target_no_staleness_data() {
966 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None);
1045 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
967 1046 assert!(!out.contains("STALE"));
968 1047 }
969 1048
@@ -975,7 +1054,7 @@ mod tests {
975 1054 RouteCheckRow { id: 1, target: "mnw".to_string(), path: "/".to_string(), status_code: 200, ok: true, response_time_ms: 50, checked_at: "2026-03-13T00:00:00Z".to_string(), error: None },
976 1055 RouteCheckRow { id: 2, target: "mnw".to_string(), path: "/docs".to_string(), status_code: 200, ok: true, response_time_ms: 60, checked_at: "2026-03-13T00:00:00Z".to_string(), error: None },
977 1056 ];
978 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, Some(&checks), None, None, None);
1057 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, Some(&checks), None, None, None, None, None);
979 1058 assert!(out.contains("Routes: 2/2 OK"));
980 1059 }
981 1060
@@ -986,13 +1065,13 @@ mod tests {
986 1065 RouteCheckRow { id: 2, target: "mnw".to_string(), path: "/docs/faq".to_string(), status_code: 404, ok: false, response_time_ms: 30, checked_at: "2026-03-13T00:00:00Z".to_string(), error: Some("HTTP 404".to_string()) },
987 1066 RouteCheckRow { id: 3, target: "mnw".to_string(), path: "/pricing".to_string(), status_code: 500, ok: false, response_time_ms: 20, checked_at: "2026-03-13T00:00:00Z".to_string(), error: Some("HTTP 500".to_string()) },
988 1067 ];
989 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, Some(&checks), None, None, None);
1068 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, Some(&checks), None, None, None, None, None);
990 1069 assert!(out.contains("Routes: 1/3 (FAIL: /docs/faq, /pricing)"));
991 1070 }
992 1071
993 1072 #[test]
994 1073 fn status_target_no_route_checks() {
995 - let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None);
1074 + let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
996 1075 assert!(!out.contains("Routes"));
997 1076 }
998 1077 }
M src/lib.rs +1
@@ -6,6 +6,7 @@ pub mod alerts;
6 6 pub mod api;
7 7 pub mod checks;
8 8 pub mod config;
9 + pub mod dashboard;
9 10 pub mod db;
10 11 pub mod display;
11 12 pub mod error;
@@ -62,6 +62,14 @@ enum Commands {
62 62 #[arg(long, default_value = "30")]
63 63 days: i64,
64 64 },
65 + /// Run DNS and WHOIS checks
66 + Dns {
67 + /// Target name (omit for all)
68 + target: Option<String>,
69 + /// Output as JSON
70 + #[arg(long)]
71 + json: bool,
72 + },
65 73 /// Run as a daemon, checking health at intervals
66 74 Serve,
67 75 /// Show peer mesh status
@@ -132,6 +140,7 @@ async fn run_cli(
132 140 Commands::Status { json } => cli::cmd_status(&pool, &config, json).await,
133 141 Commands::History { kind } => cli::cmd_history(&pool, kind).await,
134 142 Commands::Prune { days } => cli::cmd_prune(&pool, days).await,
143 + Commands::Dns { target, json } => cli::cmd_dns(&pool, &config, target.as_deref(), json).await,
135 144 Commands::Serve => cli::cmd_serve(&pool, &config).await,
136 145 Commands::Mesh { json } => cli::cmd_mesh(&config, json).await,
137 146 }
M src/types.rs +79
@@ -208,6 +208,46 @@ pub struct TestStaleness {
208 208 pub days_since_test: Option<i64>,
209 209 }
210 210
211 + #[derive(Debug, Clone, Serialize, Deserialize)]
212 + pub struct DnsCheckResult {
213 + /// Config key identifying the monitored target.
214 + pub target: String,
215 + /// Queried hostname (e.g. "makenot.work").
216 + pub name: String,
217 + /// DNS record type (A, AAAA, CNAME, MX, TXT).
218 + pub record_type: String,
219 + /// Expected values from config.
220 + pub expected: Vec<String>,
221 + /// Actually resolved values.
222 + pub actual: Vec<String>,
223 + /// Whether all expected values were found in actual (expected ⊆ actual).
224 + pub matches: bool,
225 + /// When this check was performed, in RFC 3339 format (UTC).
226 + pub checked_at: String,
227 + /// Error message if resolution failed.
228 + pub error: Option<String>,
229 + }
230 +
231 + #[derive(Debug, Clone, Serialize, Deserialize)]
232 + pub struct WhoisResult {
233 + /// Config key identifying the monitored target.
234 + pub target: String,
235 + /// Domain that was queried.
236 + pub domain: String,
237 + /// Domain registrar name, if parsed.
238 + pub registrar: Option<String>,
239 + /// Registration expiry date (RFC 3339 or raw date string).
240 + pub expiry_date: Option<String>,
241 + /// Days until expiry. Negative if already expired.
242 + pub days_remaining: Option<i64>,
243 + /// Nameservers from WHOIS response.
244 + pub nameservers: Vec<String>,
245 + /// When this check was performed, in RFC 3339 format (UTC).
246 + pub checked_at: String,
247 + /// Error message if WHOIS query failed.
248 + pub error: Option<String>,
249 + }
250 +
211 251 impl LatencyStats {
212 252 /// Compute latency statistics from a slice of response times.
213 253 /// Returns `None` if the slice is empty.
@@ -394,6 +434,45 @@ mod tests {
394 434 }
395 435
396 436 #[test]
437 + fn dns_check_result_serde_roundtrip() {
438 + let result = DnsCheckResult {
439 + target: "mnw".to_string(),
440 + name: "makenot.work".to_string(),
441 + record_type: "A".to_string(),
442 + expected: vec!["5.78.144.244".to_string()],
443 + actual: vec!["5.78.144.244".to_string()],
444 + matches: true,
445 + checked_at: "2026-03-15T00:00:00Z".to_string(),
446 + error: None,
447 + };
448 + let json = serde_json::to_string(&result).unwrap();
449 + let parsed: DnsCheckResult = serde_json::from_str(&json).unwrap();
450 + assert_eq!(parsed.target, "mnw");
451 + assert_eq!(parsed.name, "makenot.work");
452 + assert!(parsed.matches);
453 + assert!(parsed.error.is_none());
454 + }
455 +
456 + #[test]
457 + fn whois_result_serde_roundtrip() {
458 + let result = WhoisResult {
459 + target: "mnw".to_string(),
460 + domain: "makenot.work".to_string(),
461 + registrar: Some("Namecheap".to_string()),
462 + expiry_date: Some("2027-01-15T00:00:00Z".to_string()),
463 + days_remaining: Some(306),
464 + nameservers: vec!["ns1.example.com".to_string()],
465 + checked_at: "2026-03-15T00:00:00Z".to_string(),
466 + error: None,
467 + };
468 + let json = serde_json::to_string(&result).unwrap();
469 + let parsed: WhoisResult = serde_json::from_str(&json).unwrap();
470 + assert_eq!(parsed.domain, "makenot.work");
471 + assert_eq!(parsed.registrar.as_deref(), Some("Namecheap"));
472 + assert_eq!(parsed.days_remaining, Some(306));
473 + }
474 +
475 + #[test]
397 476 fn latency_stats_serde_roundtrip() {
398 477 let stats = LatencyStats::from_times(&[50, 100, 150]).unwrap();
399 478 let json = serde_json::to_string(&stats).unwrap();
@@ -175,8 +175,8 @@ async fn prune_removes_old_records() {
175 175 };
176 176 db::insert_health_check(&pool, &recent).await.unwrap();
177 177
178 - let (health_pruned, _, _, _, _, _, _) = db::prune_old_records(&pool, 30).await.unwrap();
179 - assert_eq!(health_pruned, 1);
178 + let result = db::prune_old_records(&pool, 30).await.unwrap();
179 + assert_eq!(result.health, 1);
180 180
181 181 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
182 182 assert_eq!(remaining.len(), 1);
@@ -259,6 +259,18 @@ url = "https://makenot.work/health"
259 259 .unwrap()
260 260 }
261 261
262 + /// GET a path and return (status_code, body_string) — for HTML responses.
263 + async fn get_body(app: &axum::Router, path: &str) -> (u16, String) {
264 + let req = axum::http::Request::builder()
265 + .uri(path)
266 + .body(Body::empty())
267 + .unwrap();
268 + let resp = app.clone().oneshot(req).await.unwrap();
269 + let status = resp.status().as_u16();
270 + let body = resp.into_body().collect().await.unwrap().to_bytes();
271 + (status, String::from_utf8_lossy(&body).into_owned())
272 + }
273 +
262 274 fn test_mesh() -> pom::peer::SharedMeshState {
263 275 let info = pom::peer::InstanceInfo {
264 276 id: "test-uuid".to_string(),
@@ -363,7 +375,7 @@ async fn migration_fresh_db_reaches_latest_version() {
363 375 // A fresh in-memory DB should run all migrations and reach version 5.
364 376 let pool = db::connect_in_memory().await.unwrap();
365 377 let version = db::get_schema_version(&pool).await.unwrap();
366 - assert_eq!(version, 5);
378 + assert_eq!(version, 6);
367 379
368 380 // Verify the schema_version table has entries for each migration
369 381 let rows = sqlx::query_as::<_, (i64, String)>(
@@ -372,7 +384,7 @@ async fn migration_fresh_db_reaches_latest_version() {
372 384 .fetch_all(&pool)
373 385 .await
374 386 .unwrap();
375 - assert_eq!(rows.len(), 5);
387 + assert_eq!(rows.len(), 6);
376 388 assert_eq!(rows[0].0, 1);
377 389 assert_eq!(rows[0].1, "initial schema");
378 390 assert_eq!(rows[1].0, 2);
@@ -383,6 +395,8 @@ async fn migration_fresh_db_reaches_latest_version() {
383 395 assert_eq!(rows[3].1, "add incidents table");
384 396 assert_eq!(rows[4].0, 5);
385 397 assert_eq!(rows[4].1, "add route_checks table");
398 + assert_eq!(rows[5].0, 6);
399 + assert_eq!(rows[5].1, "add dns_checks and whois_checks tables");
386 400
387 401 // Verify actual tables were created by inserting data
388 402 let snapshot = HealthSnapshot {
@@ -402,18 +416,18 @@ async fn migration_fresh_db_reaches_latest_version() {
402 416 async fn migration_already_current_is_idempotent() {
403 417 // Running migrations on an already-migrated DB should be a no-op.
404 418 let pool = db::connect_in_memory().await.unwrap();
405 - assert_eq!(db::get_schema_version(&pool).await.unwrap(), 5);
419 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 6);
406 420
407 421 // Run migrations again
408 422 db::run_migrations(&pool).await.unwrap();
409 - assert_eq!(db::get_schema_version(&pool).await.unwrap(), 5);
423 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 6);
410 424
411 - // schema_version should still have exactly five entries (not duplicated)
425 + // schema_version should still have exactly six entries (not duplicated)
412 426 let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version")
413 427 .fetch_one(&pool)
414 428 .await
415 429 .unwrap();
416 - assert_eq!(count.0, 5);
430 + assert_eq!(count.0, 6);
417 431 }
418 432
419 433 #[tokio::test]
@@ -469,11 +483,11 @@ async fn migration_detects_pre_migration_database() {
469 483 .await
470 484 .unwrap();
471 485
472 - // Now run migrations — should detect existing tables, stamp as v1, then run v2+v3+v4+v5
486 + // Now run migrations — should detect existing tables, stamp as v1, then run v2+v3+v4+v5+v6
473 487 db::run_migrations(&pool).await.unwrap();
474 488
475 - // Version should be 5 (stamped v1 + ran v2 + ran v3 + ran v4 + ran v5)
476 - assert_eq!(db::get_schema_version(&pool).await.unwrap(), 5);
489 + // Version should be 6 (stamped v1 + ran v2 + ran v3 + ran v4 + ran v5 + ran v6)
490 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 6);
477 491
478 492 // Description should indicate pre-existing
479 493 let row = sqlx::query_as::<_, (String,)>(
@@ -773,7 +787,7 @@ async fn tool_run_tests_no_test_config() {
773 787 async fn migration_v2_creates_alerts_table() {
774 788 let pool = db::connect_in_memory().await.unwrap();
775 789 let version = db::get_schema_version(&pool).await.unwrap();
776 - assert_eq!(version, 5);
790 + assert_eq!(version, 6);
777 791
778 792 // Verify alerts table exists by inserting
779 793 let id = db::insert_alert(&pool, "mnw", "health", Some("operational"), Some("error"), None)
@@ -827,8 +841,8 @@ async fn prune_removes_old_alerts() {
827 841 // Insert a recent alert
828 842 db::insert_alert(&pool, "mnw", "health", None, None, None).await.unwrap();
829 843
830 - let (_, _, _, alerts_pruned, _, _, _) = db::prune_old_records(&pool, 30).await.unwrap();
831 - assert_eq!(alerts_pruned, 1);
844 + let result = db::prune_old_records(&pool, 30).await.unwrap();
845 + assert_eq!(result.alerts, 1);
832 846
833 847 // Recent alert should remain
834 848 let latest = db::get_latest_alert_for_target(&pool, "mnw").await.unwrap();
@@ -841,7 +855,7 @@ async fn prune_removes_old_alerts() {
841 855 async fn migration_v3_creates_tls_checks_table() {
842 856 let pool = db::connect_in_memory().await.unwrap();
843 857 let version = db::get_schema_version(&pool).await.unwrap();
844 - assert_eq!(version, 5);
858 + assert_eq!(version, 6);
845 859
846 860 // Verify tls_checks table exists by inserting
847 861 let status = pom::types::TlsStatus {
@@ -949,8 +963,8 @@ async fn prune_removes_old_tls_checks() {
949 963 };
950 964 db::insert_tls_check(&pool, &status).await.unwrap();
951 965
952 - let (_, _, _, _, tls_pruned, _, _) = db::prune_old_records(&pool, 30).await.unwrap();
953 - assert_eq!(tls_pruned, 1);
966 + let result = db::prune_old_records(&pool, 30).await.unwrap();
967 + assert_eq!(result.tls, 1);
954 968
955 969 // Recent check should remain
956 970 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
@@ -1056,7 +1070,7 @@ cooldown_secs = 120
1056 1070 async fn migration_v4_creates_incidents_table() {
1057 1071 let pool = db::connect_in_memory().await.unwrap();
1058 1072 let version = db::get_schema_version(&pool).await.unwrap();
1059 - assert_eq!(version, 5);
1073 + assert_eq!(version, 6);
1060 1074
1061 1075 // Verify incidents table exists by inserting
1062 1076 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
@@ -1140,8 +1154,8 @@ async fn prune_removes_closed_incidents_only() {
1140 1154 .await
1141 1155 .unwrap();
1142 1156
1143 - let (_, _, _, _, _, incidents_pruned, _) = db::prune_old_records(&pool, 30).await.unwrap();
1144 - assert_eq!(incidents_pruned, 1); // only the closed one
1157 + let result = db::prune_old_records(&pool, 30).await.unwrap();
1158 + assert_eq!(result.incidents, 1); // only the closed one
1145 1159
1146 1160 // The open incident should remain
1147 1161 let remaining = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
@@ -1185,7 +1199,7 @@ async fn api_status_no_incidents_omits_fields() {
1185 1199 async fn migration_v5_creates_route_checks_table() {
1186 1200 let pool = db::connect_in_memory().await.unwrap();
1187 1201 let version = db::get_schema_version(&pool).await.unwrap();
1188 - assert_eq!(version, 5);
1202 + assert_eq!(version, 6);
1189 1203
1190 1204 // Verify route_checks table exists by inserting
1191 1205 let result = pom::checks::routes::RouteCheckResult {
@@ -1268,8 +1282,8 @@ async fn route_check_prune() {
1268 1282 };
1269 1283 db::insert_route_check(&pool, &old).await.unwrap();
1270 1284
1271 - let (_, _, _, _, _, _, routes_pruned) = db::prune_old_records(&pool, 30).await.unwrap();
1272 - assert_eq!(routes_pruned, 1);
1285 + let result = db::prune_old_records(&pool, 30).await.unwrap();
1286 + assert_eq!(result.routes, 1);
1273 1287 }
1274 1288
1275 1289 #[tokio::test]
@@ -1765,14 +1779,16 @@ async fn prune_with_days_zero_is_noop() {
1765 1779 db::insert_health_check(&pool, &snapshot).await.unwrap();
1766 1780
1767 1781 // Prune with days=0 should delete nothing
1768 - let (h, t, p, a, tl, inc, rc) = db::prune_old_records(&pool, 0).await.unwrap();
1769 - assert_eq!(h, 0);
1770 - assert_eq!(t, 0);
1771 - assert_eq!(p, 0);
1772 - assert_eq!(a, 0);
1773 - assert_eq!(tl, 0);
1774 - assert_eq!(inc, 0);
1775 - assert_eq!(rc, 0);
1782 + let result = db::prune_old_records(&pool, 0).await.unwrap();
1783 + assert_eq!(result.health, 0);
1784 + assert_eq!(result.tests, 0);
1785 + assert_eq!(result.heartbeats, 0);
1786 + assert_eq!(result.alerts, 0);
1787 + assert_eq!(result.tls, 0);
1788 + assert_eq!(result.incidents, 0);
1789 + assert_eq!(result.routes, 0);
1790 + assert_eq!(result.dns, 0);
1791 + assert_eq!(result.whois, 0);
1776 1792
1777 1793 // Records should still exist
1778 1794 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
@@ -1808,8 +1824,8 @@ async fn prune_with_days_seven_keeps_recent() {
1808 1824 db::insert_health_check(&pool, &old).await.unwrap();
1809 1825
1810 1826 // Prune with days=7 should only delete the 10-day-old record
1811 - let (h, _, _, _, _, _, _) = db::prune_old_records(&pool, 7).await.unwrap();
1812 - assert_eq!(h, 1);
1827 + let result = db::prune_old_records(&pool, 7).await.unwrap();
1828 + assert_eq!(result.health, 1);
1813 1829
1814 1830 // Yesterday's record should remain
1815 1831 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
@@ -1846,8 +1862,8 @@ async fn prune_with_days_one_keeps_today() {
1846 1862 db::insert_health_check(&pool, &old).await.unwrap();
1847 1863
1848 1864 // Prune with days=1 should delete the 2-day-old record, keep today's
1849 - let (h, _, _, _, _, _, _) = db::prune_old_records(&pool, 1).await.unwrap();
1850 - assert_eq!(h, 1);
1865 + let result = db::prune_old_records(&pool, 1).await.unwrap();
1866 + assert_eq!(result.health, 1);
1851 1867
1852 1868 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
1853 1869 assert_eq!(remaining.len(), 1);
@@ -2170,3 +2186,533 @@ async fn peer_uuid_mismatch_updates_db_identity() {
2170 2186 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2171 2187 assert_eq!(stored, Some("new-uuid".to_string()));
2172 2188 }
2189 +
2190 + // --- DNS check tests ---
2191 +
2192 + #[tokio::test]
2193 + async fn migration_v6_creates_dns_and_whois_tables() {
2194 + let pool = db::connect_in_memory().await.unwrap();
2195 + let version = db::get_schema_version(&pool).await.unwrap();
2196 + assert_eq!(version, 6);
2197 +
2198 + // Verify dns_checks table exists
2199 + let dns_result = DnsCheckResult {
2200 + target: "mnw".to_string(),
2201 + name: "makenot.work".to_string(),
2202 + record_type: "A".to_string(),
2203 + expected: vec!["5.78.144.244".to_string()],
2204 + actual: vec!["5.78.144.244".to_string()],
2205 + matches: true,
2206 + checked_at: chrono::Utc::now().to_rfc3339(),
2207 + error: None,
2208 + };
2209 + let id = db::insert_dns_check(&pool, &dns_result).await.unwrap();
2210 + assert!(id > 0);
2211 +
2212 + // Verify whois_checks table exists
2213 + let whois_result = WhoisResult {
2214 + target: "mnw".to_string(),
2215 + domain: "makenot.work".to_string(),
2216 + registrar: Some("Namecheap, Inc.".to_string()),
2217 + expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2218 + days_remaining: Some(261),
2219 + nameservers: vec!["ns1.example.com".to_string()],
2220 + checked_at: chrono::Utc::now().to_rfc3339(),
2221 + error: None,
2222 + };
2223 + let id = db::insert_whois_check(&pool, &whois_result).await.unwrap();
2224 + assert!(id > 0);
2225 + }
2226 +
2227 + #[tokio::test]
2228 + async fn dns_check_insert_and_query() {
2229 + let pool = db::connect_in_memory().await.unwrap();
2230 +
2231 + let result = DnsCheckResult {
2232 + target: "mnw".to_string(),
2233 + name: "makenot.work".to_string(),
2234 + record_type: "A".to_string(),
2235 + expected: vec!["5.78.144.244".to_string()],
2236 + actual: vec!["5.78.144.244".to_string()],
2237 + matches: true,
2238 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2239 + error: None,
2240 + };
2241 + db::insert_dns_check(&pool, &result).await.unwrap();
2242 +
2243 + let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2244 + assert_eq!(latest.len(), 1);
2245 + assert_eq!(latest[0].name, "makenot.work");
2246 + assert_eq!(latest[0].record_type, "A");
2247 + assert!(latest[0].matches);
2248 + }
2249 +
2250 + #[tokio::test]
2251 + async fn dns_check_latest_per_name_and_type() {
2252 + let pool = db::connect_in_memory().await.unwrap();
2253 +
2254 + // Insert two checks for same name/type, different times
2255 + let r1 = DnsCheckResult {
2256 + target: "mnw".to_string(),
2257 + name: "makenot.work".to_string(),
2258 + record_type: "A".to_string(),
2259 + expected: vec!["1.2.3.4".to_string()],
2260 + actual: vec!["5.6.7.8".to_string()],
2261 + matches: false,
2262 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2263 + error: None,
2264 + };
2265 + let r2 = DnsCheckResult {
2266 + target: "mnw".to_string(),
2267 + name: "makenot.work".to_string(),
2268 + record_type: "A".to_string(),
2269 + expected: vec!["5.78.144.244".to_string()],
2270 + actual: vec!["5.78.144.244".to_string()],
2271 + matches: true,
2272 + checked_at: "2026-03-15T01:00:00Z".to_string(),
2273 + error: None,
2274 + };
2275 + db::insert_dns_check(&pool, &r1).await.unwrap();
2276 + db::insert_dns_check(&pool, &r2).await.unwrap();
2277 +
2278 + // Should return only the latest check per name+type
2279 + let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2280 + assert_eq!(latest.len(), 1);
2281 + assert!(latest[0].matches);
2282 + }
2283 +
2284 + #[tokio::test]
2285 + async fn dns_check_multiple_records() {
2286 + let pool = db::connect_in_memory().await.unwrap();
2287 +
2288 + let r1 = DnsCheckResult {
2289 + target: "mnw".to_string(),
2290 + name: "makenot.work".to_string(),
2291 + record_type: "A".to_string(),
2292 + expected: vec!["5.78.144.244".to_string()],
2293 + actual: vec!["5.78.144.244".to_string()],
2294 + matches: true,
2295 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2296 + error: None,
2297 + };
2298 + let r2 = DnsCheckResult {
2299 + target: "mnw".to_string(),
2300 + name: "forums.makenot.work".to_string(),
2301 + record_type: "A".to_string(),
2302 + expected: vec!["5.78.144.244".to_string()],
2303 + actual: vec!["5.78.144.244".to_string()],
2304 + matches: true,
2305 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2306 + error: None,
2307 + };
2308 + db::insert_dns_check(&pool, &r1).await.unwrap();
2309 + db::insert_dns_check(&pool, &r2).await.unwrap();
2310 +
2311 + let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2312 + assert_eq!(latest.len(), 2);
2313 + }
2314 +
2315 + #[tokio::test]
2316 + async fn dns_check_filters_by_target() {
2317 + let pool = db::connect_in_memory().await.unwrap();
2318 +
2319 + let r1 = DnsCheckResult {
2320 + target: "mnw".to_string(),
2321 + name: "makenot.work".to_string(),
2322 + record_type: "A".to_string(),
2323 + expected: vec!["5.78.144.244".to_string()],
2324 + actual: vec!["5.78.144.244".to_string()],
2325 + matches: true,
2326 + checked_at: chrono::Utc::now().to_rfc3339(),
2327 + error: None,
2328 + };
2329 + let r2 = DnsCheckResult {
2330 + target: "htpy".to_string(),
2331 + name: "htpy.app".to_string(),
2332 + record_type: "A".to_string(),
2333 + expected: vec!["5.78.135.189".to_string()],
2334 + actual: vec!["5.78.135.189".to_string()],
2335 + matches: true,
2336 + checked_at: chrono::Utc::now().to_rfc3339(),
2337 + error: None,
2338 + };
2339 + db::insert_dns_check(&pool, &r1).await.unwrap();
2340 + db::insert_dns_check(&pool, &r2).await.unwrap();
2341 +
2342 + let mnw_checks = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2343 + assert_eq!(mnw_checks.len(), 1);
2344 + assert_eq!(mnw_checks[0].name, "makenot.work");
2345 + }
2346 +
2347 + // --- WHOIS check tests ---
2348 +
2349 + #[tokio::test]
2350 + async fn whois_check_insert_and_query() {
2351 + let pool = db::connect_in_memory().await.unwrap();
2352 +
2353 + let result = WhoisResult {
2354 + target: "mnw".to_string(),
2355 + domain: "makenot.work".to_string(),
2356 + registrar: Some("Namecheap, Inc.".to_string()),
2357 + expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2358 + days_remaining: Some(261),
2359 + nameservers: vec!["dns1.registrar-servers.com".to_string()],
2360 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2361 + error: None,
2362 + };
2363 + db::insert_whois_check(&pool, &result).await.unwrap();
2364 +
2365 + let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
2366 + assert!(latest.is_some());
2367 + let row = latest.unwrap();
2368 + assert_eq!(row.domain, "makenot.work");
2369 + assert_eq!(row.registrar.as_deref(), Some("Namecheap, Inc."));
2370 + assert_eq!(row.days_remaining, Some(261));
2371 + }
2372 +
2373 + #[tokio::test]
2374 + async fn whois_check_returns_latest() {
2375 + let pool = db::connect_in_memory().await.unwrap();
2376 +
2377 + let r1 = WhoisResult {
2378 + target: "mnw".to_string(),
2379 + domain: "makenot.work".to_string(),
2380 + registrar: Some("Old Registrar".to_string()),
2381 + expiry_date: Some("2026-06-01T00:00:00Z".to_string()),
2382 + days_remaining: Some(78),
2383 + nameservers: vec![],
2384 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2385 + error: None,
2386 + };
2387 + let r2 = WhoisResult {
2388 + target: "mnw".to_string(),
2389 + domain: "makenot.work".to_string(),
2390 + registrar: Some("New Registrar".to_string()),
2391 + expiry_date: Some("2027-06-01T00:00:00Z".to_string()),
2392 + days_remaining: Some(443),
2393 + nameservers: vec![],
2394 + checked_at: "2026-03-15T01:00:00Z".to_string(),
2395 + error: None,
2396 + };
2397 + db::insert_whois_check(&pool, &r1).await.unwrap();
2398 + db::insert_whois_check(&pool, &r2).await.unwrap();
2399 +
2400 + let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap().unwrap();
2401 + assert_eq!(latest.registrar.as_deref(), Some("New Registrar"));
2402 + assert_eq!(latest.days_remaining, Some(443));
2403 + }
2404 +
2405 + #[tokio::test]
2406 + async fn whois_check_returns_none_for_unknown_target() {
2407 + let pool = db::connect_in_memory().await.unwrap();
2408 +
2409 + let latest = db::get_latest_whois_check(&pool, "nonexistent").await.unwrap();
2410 + assert!(latest.is_none());
2411 + }
2412 +
2413 + #[tokio::test]
2414 + async fn whois_check_error_stored() {
2415 + let pool = db::connect_in_memory().await.unwrap();
2416 +
2417 + let result = WhoisResult {
2418 + target: "mnw".to_string(),
2419 + domain: "makenot.work".to_string(),
2420 + registrar: None,
2421 + expiry_date: None,
2422 + days_remaining: None,
2423 + nameservers: vec![],
2424 + checked_at: chrono::Utc::now().to_rfc3339(),
2425 + error: Some("WHOIS connection timed out".to_string()),
2426 + };
2427 + db::insert_whois_check(&pool, &result).await.unwrap();
2428 +
2429 + let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap().unwrap();
2430 + assert_eq!(latest.error.as_deref(), Some("WHOIS connection timed out"));
2431 + assert!(latest.registrar.is_none());
2432 + }
2433 +
2434 + #[tokio::test]
2435 + async fn prune_removes_old_dns_checks() {
2436 + let pool = db::connect_in_memory().await.unwrap();
2437 +
2438 + let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2439 + sqlx::query(
2440 + "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at)
2441 + VALUES (?, ?, ?, '[]', '[]', 1, ?)",
2442 + )
2443 + .bind("mnw")
2444 + .bind("makenot.work")
2445 + .bind("A")
2446 + .bind(&old_time)
2447 + .execute(&pool)
2448 + .await
2449 + .unwrap();
2450 +
2451 + // Insert recent DNS check
2452 + let recent = DnsCheckResult {
2453 + target: "mnw".to_string(),
2454 + name: "makenot.work".to_string(),
2455 + record_type: "A".to_string(),
2456 + expected: vec![],
2457 + actual: vec![],
2458 + matches: true,
2459 + checked_at: chrono::Utc::now().to_rfc3339(),
2460 + error: None,
2461 + };
2462 + db::insert_dns_check(&pool, &recent).await.unwrap();
2463 +
2464 + let result = db::prune_old_records(&pool, 30).await.unwrap();
2465 + assert_eq!(result.dns, 1);
2466 +
2467 + let remaining = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2468 + assert_eq!(remaining.len(), 1);
2469 + }
2470 +
2471 + #[tokio::test]
2472 + async fn prune_removes_old_whois_checks() {
2473 + let pool = db::connect_in_memory().await.unwrap();
2474 +
2475 + let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2476 + sqlx::query(
2477 + "INSERT INTO whois_checks (target, domain, checked_at) VALUES (?, ?, ?)",
2478 + )
2479 + .bind("mnw")
2480 + .bind("makenot.work")
2481 + .bind(&old_time)
Lines truncated