1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::error::Result;
10use crate::status::StatusClient;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct Watchlist {
15 #[serde(default)]
16 pub domains: Vec<String>,
17}
18
19#[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#[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 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 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
64const EXPIRY_CRITICAL_DAYS: i64 = 30;
66const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
71const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
74const CHECK_FAILED_PREFIX: &str = "Check failed:";
75
76fn 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 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
98fn 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 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 "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
185pub 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
200pub async fn check_watchlist_with(
202 domains: &[String],
203 client: StatusClient,
204 concurrency: usize,
205) -> WatchReport {
206 use futures::stream::{self, StreamExt};
207
208 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 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 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 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()); assert_eq!(wl.domains.len(), 1);
327
328 assert!(wl.add("test.org").unwrap());
329 assert_eq!(wl.domains.len(), 2);
330 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")); 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 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 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 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 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 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 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 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 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}