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,
38 pub critical: usize,
41}
42
43crate::fsutil::persisted_store!(Watchlist, "watchlist.toml", toml, "watchlist");
44
45impl Watchlist {
46 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 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
73const EXPIRY_CRITICAL_DAYS: i64 = 30;
75const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
80const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
83const CHECK_FAILED_PREFIX: &str = "Check failed:";
84
85fn 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 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
107fn 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 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 "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
194fn 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
208pub 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
223pub async fn check_watchlist_with(
225 domains: &[String],
226 client: StatusClient,
227 concurrency: usize,
228) -> WatchReport {
229 use futures::stream::{self, StreamExt};
230
231 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 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 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()); assert_eq!(wl.domains.len(), 1);
345
346 assert!(wl.add("test.org").unwrap());
347 assert_eq!(wl.domains.len(), 2);
348 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")); 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 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 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 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 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 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 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 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 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 let results = [
533 result_with(Some(10), None, &["SSL expires in 10 days"]), result_with(None, Some(60), &["Domain expires in 60 days"]), result_with(Some(200), Some(200), &[]), ];
537 assert_eq!(tally(&results), (1, 1));
538 }
539
540 #[test]
541 fn critical_predicate_is_structured_not_freetext() {
542 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}