max / pom
18 files changed,
+2422 insertions,
-98 deletions
| @@ -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" |
| @@ -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 |
| @@ -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()); |
| @@ -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 | + | } |
| @@ -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 | } |
| @@ -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 | + | "##; |
| @@ -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 --- |
| @@ -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 | } |
| @@ -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 | } |
| @@ -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