Skip to main content

seer_core/
watchlist.rs

1//! Domain watchlist for monitoring expiration and health.
2//!
3//! Loads a list of domains from `~/.seer/watchlist.toml` and checks their
4//! SSL certificates, domain expiration, and HTTP status.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::error::Result;
10use crate::status::StatusClient;
11
12/// Persistent list of domains to monitor.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct Watchlist {
15    #[serde(default)]
16    pub domains: Vec<String>,
17}
18
19/// Status result for a single watched domain.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WatchResult {
22    pub domain: String,
23    pub ssl_days_remaining: Option<i64>,
24    pub domain_days_remaining: Option<i64>,
25    pub registrar: Option<String>,
26    pub http_status: Option<u16>,
27    pub issues: Vec<String>,
28}
29
30/// Aggregated report from checking all watched domains.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct WatchReport {
33    pub checked_at: DateTime<Utc>,
34    pub results: Vec<WatchResult>,
35    pub total: usize,
36    /// Results with issues that are not critical.
37    pub warnings: usize,
38    /// Critical results (expiry under 30 days, invalid certificate, failed
39    /// check); never also counted in `warnings`.
40    pub critical: usize,
41}
42
43crate::fsutil::persisted_store!(Watchlist, "watchlist.toml", toml, "watchlist");
44
45impl Watchlist {
46    /// Adds a domain to the watchlist. Returns `Ok(true)` if the domain was newly added.
47    ///
48    /// Entries are keyed by [`normalize_host`](crate::validation::normalize_host),
49    /// which keeps `www.`: a watch runs per-host probes (TLS certificate, HTTP)
50    /// and `www` routinely serves a different site and certificate than the
51    /// apex. Entries saved by older versions (always without `www.`) load and
52    /// match unchanged.
53    pub fn add(&mut self, domain: &str) -> Result<bool> {
54        let domain = crate::validation::normalize_host(domain)?;
55        if self.domains.contains(&domain) {
56            return Ok(false);
57        }
58        self.domains.push(domain);
59        self.domains.sort();
60        Ok(true)
61    }
62
63    /// Removes a domain from the watchlist. Returns `true` if the domain was present.
64    pub fn remove(&mut self, domain: &str) -> bool {
65        let domain =
66            crate::validation::normalize_host(domain).unwrap_or_else(|_| domain.to_lowercase());
67        let len_before = self.domains.len();
68        self.domains.retain(|d| d != &domain);
69        self.domains.len() < len_before
70    }
71}
72
73/// SSL or domain-registration expiry within this many days is *critical*.
74const EXPIRY_CRITICAL_DAYS: i64 = 30;
75/// Domain-registration expiry within this many days surfaces an informational
76/// (warning-band) issue even before it becomes critical. SSL uses only the
77/// critical band. Defined as named constants so the issue-push thresholds and
78/// the critical tally are a single source of truth (issue #57).
79const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
80/// Exact issue strings this module emits, so the critical predicate can match
81/// them structurally rather than scanning free text for "invalid"/"failed".
82const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
83const CHECK_FAILED_PREFIX: &str = "Check failed:";
84
85/// Returns true if a checked result is *critical* (vs merely a warning): an SSL
86/// or registration expiry within [`EXPIRY_CRITICAL_DAYS`], an invalid SSL
87/// certificate, or a failed check. Uses the numeric day fields and the exact
88/// issue markers this module emits — not a locale/text-fragile substring scan
89/// for "invalid"/"failed" (issue #57).
90fn result_is_critical(r: &WatchResult) -> bool {
91    let bad_ssl = r
92        .ssl_days_remaining
93        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
94    let bad_domain = r
95        .domain_days_remaining
96        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
97    // Match the exact markers this module emits, not arbitrary free text, so a
98    // benign issue line that happens to contain "failed"/"invalid" can't be
99    // miscounted as critical.
100    let bad_issue = r
101        .issues
102        .iter()
103        .any(|i| i == SSL_INVALID_ISSUE || i.starts_with(CHECK_FAILED_PREFIX));
104    bad_ssl || bad_domain || bad_issue
105}
106
107/// Turns one domain's status check into a [`WatchResult`]. Pure, so every
108/// issue rule is unit-testable without the network.
109///
110/// `StatusClient::check` only returns `Err` for an invalid domain: a site that
111/// is down or no longer resolves comes back `Ok` with its failures recorded in
112/// `StatusResponse::errors`. Those must surface here, or a dead domain reads
113/// as healthy and `watch --fail-on` exits 0.
114fn assess(domain: String, outcome: Result<crate::status::StatusResponse>) -> WatchResult {
115    let mut watch_result = WatchResult {
116        domain,
117        ssl_days_remaining: None,
118        domain_days_remaining: None,
119        registrar: None,
120        http_status: None,
121        issues: vec![],
122    };
123
124    let status = match outcome {
125        Ok(status) => status,
126        Err(e) => {
127            watch_result
128                .issues
129                .push(format!("{} {}", CHECK_FAILED_PREFIX, e));
130            return watch_result;
131        }
132    };
133
134    watch_result.http_status = status.http_status;
135
136    // A domain that no longer resolves (lapsed delegation, hijack, deleted
137    // records) is the most severe thing a watch can see.
138    if status.dns_resolution.as_ref().is_some_and(|d| !d.resolves) {
139        watch_result.issues.push(format!(
140            "{} dns: domain does not resolve",
141            CHECK_FAILED_PREFIX
142        ));
143    }
144    for err in &status.errors {
145        match err.check.as_str() {
146            "dns" => watch_result
147                .issues
148                .push(format!("{} dns: {}", CHECK_FAILED_PREFIX, err.message)),
149            // Unreachable web/TLS endpoints are reported, but as warnings:
150            // a watched domain may legitimately serve no website (mail-only).
151            "http" => watch_result
152                .issues
153                .push(format!("HTTP check failed: {}", err.message)),
154            "ssl" => watch_result
155                .issues
156                .push(format!("SSL check failed: {}", err.message)),
157            _ => {}
158        }
159    }
160
161    if let Some(ref cert) = status.certificate {
162        watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
163        if cert.days_until_expiry < EXPIRY_CRITICAL_DAYS {
164            watch_result
165                .issues
166                .push(format!("SSL expires in {} days", cert.days_until_expiry));
167        }
168        if !cert.is_valid {
169            watch_result.issues.push(SSL_INVALID_ISSUE.to_string());
170        }
171    }
172
173    if let Some(ref exp) = status.domain_expiration {
174        watch_result.domain_days_remaining = Some(exp.days_until_expiry);
175        watch_result.registrar = exp.registrar.clone();
176        if exp.days_until_expiry < DOMAIN_EXPIRY_WARN_DAYS {
177            watch_result
178                .issues
179                .push(format!("Domain expires in {} days", exp.days_until_expiry));
180        }
181    }
182
183    if let Some(status_code) = status.http_status {
184        if !(200..300).contains(&status_code) {
185            watch_result
186                .issues
187                .push(format!("HTTP status {}", status_code));
188        }
189    }
190
191    watch_result
192}
193
194/// Counts `(warnings, critical)` results in exclusive bands: a critical
195/// result (see [`result_is_critical`] and the `EXPIRY_*` constants) is not
196/// also a warning, so `total` ≥ `warnings + critical` and the tally lines up
197/// with the issue lines — a registration expiry in the 30..90-day band is a
198/// warning, under 30 days critical.
199fn tally(results: &[WatchResult]) -> (usize, usize) {
200    let critical = results.iter().filter(|r| result_is_critical(r)).count();
201    let warnings = results
202        .iter()
203        .filter(|r| !r.issues.is_empty() && !result_is_critical(r))
204        .count();
205    (warnings, critical)
206}
207
208/// Checks all given domains concurrently and produces a [`WatchReport`],
209/// honoring the config file's per-protocol timeouts (via
210/// [`StatusClient::from_config`]) and `bulk.concurrency`.
211pub async fn check_watchlist_with_config(
212    domains: &[String],
213    config: &crate::config::SeerConfig,
214) -> WatchReport {
215    check_watchlist_with(
216        domains,
217        StatusClient::from_config(config),
218        config.bulk.concurrency,
219    )
220    .await
221}
222
223/// Checks all given domains with `client`, at most `concurrency` at a time.
224pub async fn check_watchlist_with(
225    domains: &[String],
226    client: StatusClient,
227    concurrency: usize,
228) -> WatchReport {
229    use futures::stream::{self, StreamExt};
230
231    // Each per-domain future owns its `client` (via `Arc`) and `domain`
232    // (owned `String`) so the `buffer_unordered` futures are `Send + 'static`
233    // and the whole `check_watchlist_with` future can be used from `tokio::spawn`
234    // (e.g. the TUI). Borrowing `&client`/`&String` here makes the closure fail
235    // the higher-ranked `FnOnce` bound `tokio::spawn` requires.
236    let client = std::sync::Arc::new(client);
237
238    let results: Vec<WatchResult> = stream::iter(domains.iter().cloned())
239        .map(|domain| {
240            let client = client.clone();
241            async move {
242                let outcome = client.check(&domain).await;
243                assess(domain, outcome)
244            }
245        })
246        .buffer_unordered(concurrency.max(1))
247        .collect()
248        .await;
249
250    let total = results.len();
251    let (warnings, critical) = tally(&results);
252
253    WatchReport {
254        checked_at: Utc::now(),
255        results,
256        total,
257        warnings,
258        critical,
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::path::PathBuf;
266
267    fn status_with(
268        dns: Option<crate::status::DnsResolution>,
269        errors: &[(&str, &str)],
270    ) -> crate::status::StatusResponse {
271        let mut status = crate::status::StatusResponse::new("example.com".to_string());
272        status.dns_resolution = dns;
273        status.errors = errors
274            .iter()
275            .map(|(check, message)| crate::status::StatusError {
276                check: check.to_string(),
277                message: message.to_string(),
278            })
279            .collect();
280        status
281    }
282
283    fn resolution(resolves: bool) -> crate::status::DnsResolution {
284        crate::status::DnsResolution {
285            a_records: vec![],
286            aaaa_records: vec![],
287            cname_target: None,
288            nameservers: vec![],
289            resolves,
290        }
291    }
292
293    #[test]
294    fn a_domain_that_stopped_resolving_is_critical() {
295        // Previously every sub-check failure was swallowed into
296        // `StatusResponse::errors`, so this read as a healthy green domain.
297        let r = assess(
298            "example.com".to_string(),
299            Ok(status_with(Some(resolution(false)), &[])),
300        );
301        assert!(result_is_critical(&r), "issues: {:?}", r.issues);
302        let r = assess(
303            "example.com".to_string(),
304            Ok(status_with(None, &[("dns", "DNS lookup timed out")])),
305        );
306        assert!(result_is_critical(&r), "issues: {:?}", r.issues);
307    }
308
309    #[test]
310    fn unreachable_web_endpoints_are_warnings() {
311        let r = assess(
312            "example.com".to_string(),
313            Ok(status_with(
314                Some(resolution(true)),
315                &[("http", "connection refused"), ("ssl", "handshake failed")],
316            )),
317        );
318        assert_eq!(r.issues.len(), 2, "issues: {:?}", r.issues);
319        assert!(!r.issues.is_empty());
320        // Mail-only domains legitimately serve no website: warn, not critical.
321        assert!(!result_is_critical(&r));
322    }
323
324    #[test]
325    fn a_healthy_domain_has_no_issues() {
326        let r = assess(
327            "example.com".to_string(),
328            Ok(status_with(Some(resolution(true)), &[])),
329        );
330        assert!(r.issues.is_empty(), "issues: {:?}", r.issues);
331    }
332
333    #[test]
334    fn test_watchlist_default() {
335        let wl = Watchlist::default();
336        assert!(wl.domains.is_empty());
337    }
338
339    #[test]
340    fn test_watchlist_add_remove() {
341        let mut wl = Watchlist::default();
342        assert!(wl.add("example.com").unwrap());
343        assert!(!wl.add("example.com").unwrap()); // duplicate
344        assert_eq!(wl.domains.len(), 1);
345
346        assert!(wl.add("test.org").unwrap());
347        assert_eq!(wl.domains.len(), 2);
348        // Should be sorted
349        assert_eq!(wl.domains[0], "example.com");
350        assert_eq!(wl.domains[1], "test.org");
351
352        assert!(wl.remove("example.com"));
353        assert!(!wl.remove("example.com")); // already removed
354        assert_eq!(wl.domains.len(), 1);
355    }
356
357    #[test]
358    fn test_watchlist_add_normalizes_case() {
359        let mut wl = Watchlist::default();
360        wl.add("EXAMPLE.COM").unwrap();
361        assert_eq!(wl.domains[0], "example.com");
362    }
363
364    #[test]
365    fn watchlist_keeps_www_hosts_distinct() {
366        // `watch` probes the host's certificate and site; `www` is its own
367        // host, so it must not collapse into the apex entry.
368        let mut wl = Watchlist::default();
369        assert!(wl.add("https://WWW.example.com/").unwrap());
370        assert!(wl.add("example.com").unwrap());
371        assert_eq!(wl.domains, vec!["example.com", "www.example.com"]);
372        assert!(wl.remove("www.example.com"));
373        assert_eq!(wl.domains, vec!["example.com"]);
374    }
375
376    #[test]
377    fn test_watchlist_serialization() {
378        let mut wl = Watchlist::default();
379        wl.add("a.com").unwrap();
380        wl.add("b.org").unwrap();
381        let toml_str = toml::to_string_pretty(&wl).unwrap();
382        assert!(toml_str.contains("a.com"));
383        assert!(toml_str.contains("b.org"));
384
385        let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
386        assert_eq!(parsed.domains.len(), 2);
387    }
388
389    /// Creates a unique temporary file path for a load-from-disk test.
390    fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
391        let mut dir = std::env::temp_dir();
392        dir.push(format!(
393            "seer-watchlist-test-{}-{}",
394            tag,
395            std::process::id()
396        ));
397        let _ = std::fs::create_dir_all(&dir);
398        dir.push("watchlist.toml");
399        dir
400    }
401
402    #[test]
403    fn load_from_path_returns_default_and_backs_up_corrupt_file() {
404        let path = unique_temp_watchlist_path("corrupt");
405        let backup = path.with_extension("corrupt");
406
407        let _ = std::fs::remove_file(&path);
408        let _ = std::fs::remove_file(&backup);
409
410        // TOML parsers reject stray garbage on the value side of `=`.
411        std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
412
413        let loaded = Watchlist::load_from_path(&path).expect("load");
414        assert!(
415            loaded.domains.is_empty(),
416            "corrupt watchlist must load as empty default"
417        );
418        assert!(
419            !path.exists(),
420            "original corrupt file should have been renamed away"
421        );
422        assert!(
423            backup.exists(),
424            "backup .corrupt file should exist at {}",
425            backup.display()
426        );
427
428        let _ = std::fs::remove_file(&backup);
429        if let Some(parent) = path.parent() {
430            let _ = std::fs::remove_dir_all(parent);
431        }
432    }
433
434    #[test]
435    fn load_from_path_returns_default_when_missing() {
436        let path = unique_temp_watchlist_path("missing");
437        let _ = std::fs::remove_file(&path);
438
439        let loaded = Watchlist::load_from_path(&path).expect("load");
440        assert!(loaded.domains.is_empty());
441
442        if let Some(parent) = path.parent() {
443            let _ = std::fs::remove_dir_all(parent);
444        }
445    }
446
447    #[test]
448    fn concurrent_saves_do_not_corrupt_the_file() {
449        // Two same-process writers saving to the same target concurrently:
450        // with a shared (PID-only) temp path one writer truncates the other's
451        // finished bytes and the loser's rename fails (or publishes a torn
452        // file). With per-call temp paths every save succeeds and the last
453        // rename wins with a complete file. Mirrors the history.rs test.
454        let path = unique_temp_watchlist_path("concurrent");
455        let _ = std::fs::remove_file(&path);
456
457        let mut a = Watchlist::default();
458        a.add("a.example").unwrap();
459        let mut b = Watchlist::default();
460        for i in 0..100 {
461            b.add(&format!("b{i}.example")).unwrap();
462        }
463
464        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
465        let spawn_saver =
466            |wl: Watchlist, path: PathBuf, barrier: std::sync::Arc<std::sync::Barrier>| {
467                std::thread::spawn(move || {
468                    for _ in 0..50 {
469                        barrier.wait();
470                        wl.save_to_path(&path).expect("concurrent save failed");
471                    }
472                })
473            };
474        let ta = spawn_saver(a, path.clone(), barrier.clone());
475        let tb = spawn_saver(b, path.clone(), barrier);
476        ta.join().expect("thread A panicked");
477        tb.join().expect("thread B panicked");
478
479        // Whichever writer won the last rename, the file must be complete.
480        let content = std::fs::read_to_string(&path).expect("saved file exists");
481        toml::from_str::<Watchlist>(&content)
482            .expect("concurrently saved watchlist must parse (no torn rename)");
483
484        if let Some(parent) = path.parent() {
485            let _ = std::fs::remove_dir_all(parent);
486        }
487    }
488
489    fn result_with(ssl: Option<i64>, domain: Option<i64>, issues: &[&str]) -> WatchResult {
490        WatchResult {
491            domain: "x.test".to_string(),
492            ssl_days_remaining: ssl,
493            domain_days_remaining: domain,
494            registrar: None,
495            http_status: Some(200),
496            issues: issues.iter().map(|s| s.to_string()).collect(),
497        }
498    }
499
500    #[test]
501    fn critical_uses_explicit_expiry_bands() {
502        // Registration expiry in the 30..90-day warning band is NOT critical,
503        // even though it surfaces an issue line; < 30 days IS critical (#57).
504        assert!(!result_is_critical(&result_with(
505            None,
506            Some(60),
507            &["Domain expires in 60 days"]
508        )));
509        assert!(result_is_critical(&result_with(
510            None,
511            Some(20),
512            &["Domain expires in 20 days"]
513        )));
514        // SSL critical band and invalid cert.
515        assert!(result_is_critical(&result_with(Some(10), None, &[])));
516        assert!(result_is_critical(&result_with(
517            Some(200),
518            None,
519            &["SSL certificate invalid"]
520        )));
521        // Failed check is critical.
522        assert!(result_is_critical(&result_with(
523            None,
524            None,
525            &["Check failed: connection refused"]
526        )));
527    }
528
529    #[test]
530    fn warnings_and_critical_are_exclusive_bands() {
531        // A critical result used to be counted as a warning too.
532        let results = [
533            result_with(Some(10), None, &["SSL expires in 10 days"]), // critical
534            result_with(None, Some(60), &["Domain expires in 60 days"]), // warning
535            result_with(Some(200), Some(200), &[]),                   // healthy
536        ];
537        assert_eq!(tally(&results), (1, 1));
538    }
539
540    #[test]
541    fn critical_predicate_is_structured_not_freetext() {
542        // A healthy domain whose issue text merely contains the word "failed"
543        // (or "invalid") must NOT be counted critical — the old predicate
544        // scanned free text and was locale/wording-fragile (issue #57).
545        let r = result_with(
546            Some(200),
547            Some(200),
548            &["Note: a prior validation failed last week"],
549        );
550        assert!(
551            !result_is_critical(&r),
552            "free-text 'failed' must not trip the critical predicate"
553        );
554    }
555
556    #[test]
557    fn test_watch_result_serialization() {
558        let result = WatchResult {
559            domain: "example.com".to_string(),
560            ssl_days_remaining: Some(45),
561            domain_days_remaining: Some(120),
562            registrar: Some("Test Registrar".to_string()),
563            http_status: Some(200),
564            issues: vec![],
565        };
566        let json = serde_json::to_string(&result).unwrap();
567        assert!(json.contains("example.com"));
568        assert!(json.contains("45"));
569    }
570}