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