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