| 53 |
53 |
|
wam_url: Option<String>,
|
| 54 |
54 |
|
}
|
| 55 |
55 |
|
|
|
56 |
+ |
/// The record-context an alert carries: what to write to the `alerts` ledger on
|
|
57 |
+ |
/// successful delivery, and what to persist for retry on failure.
|
|
58 |
+ |
struct AlertMeta<'a> {
|
|
59 |
+ |
key: &'a str,
|
|
60 |
+ |
category: AlertCategory,
|
|
61 |
+ |
from: Option<&'a str>,
|
|
62 |
+ |
to: Option<&'a str>,
|
|
63 |
+ |
error: Option<&'a str>,
|
|
64 |
+ |
}
|
|
65 |
+ |
|
|
66 |
+ |
/// `true` if `sent_at` (rfc3339) is within `cooldown_secs` of now. An unparseable
|
|
67 |
+ |
/// timestamp is treated as expired (not in cooldown) so a bad row can never wedge
|
|
68 |
+ |
/// alerting shut.
|
|
69 |
+ |
fn within_cooldown_secs(sent_at: &str, cooldown_secs: u64) -> bool {
|
|
70 |
+ |
match chrono::DateTime::parse_from_rfc3339(sent_at) {
|
|
71 |
+ |
Ok(dt) => {
|
|
72 |
+ |
chrono::Utc::now().signed_duration_since(dt).num_seconds() < cooldown_secs as i64
|
|
73 |
+ |
}
|
|
74 |
+ |
Err(_) => false,
|
|
75 |
+ |
}
|
|
76 |
+ |
}
|
|
77 |
+ |
|
| 56 |
78 |
|
impl Alerter {
|
| 57 |
79 |
|
pub fn new(config: AlertConfig, pool: SqlitePool, instance_name: String) -> Self {
|
| 58 |
80 |
|
let client = reqwest::Client::builder()
|
| 99 |
121 |
|
body.push_str("\n- PoM");
|
| 100 |
122 |
|
|
| 101 |
123 |
|
let priority = health_status_priority(to_status);
|
| 102 |
|
- |
self.wam_ticket(&subject, &body, priority, "pom-health", Some(target)).await;
|
| 103 |
|
- |
self.record_alert(&alert_key, AlertCategory::Health, Some(from_status), Some(to_status), error).await;
|
|
124 |
+ |
self.dispatch_wam(&subject, &body, priority, "pom-health", Some(target),
|
|
125 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::Health, from: Some(from_status), to: Some(to_status), error }).await;
|
| 104 |
126 |
|
}
|
| 105 |
127 |
|
|
| 106 |
128 |
|
#[instrument(skip_all)]
|
| 111 |
133 |
|
from_status: &str,
|
| 112 |
134 |
|
) {
|
| 113 |
135 |
|
let alert_key = format!("health:{target}");
|
| 114 |
|
- |
// No cooldown on recovery — always send
|
|
136 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
137 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
138 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
139 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
140 |
+ |
return;
|
|
141 |
+ |
}
|
| 115 |
142 |
|
let subject = format!("[PoM] {target}: recovered");
|
| 116 |
143 |
|
let body = format!(
|
| 117 |
144 |
|
"Target: {label} ({target})\n\
|
| 123 |
150 |
|
chrono::Utc::now().to_rfc3339(),
|
| 124 |
151 |
|
);
|
| 125 |
152 |
|
|
| 126 |
|
- |
self.send_email(&subject, &body).await;
|
| 127 |
|
- |
self.record_alert(&alert_key, AlertCategory::Recovery, Some(from_status), Some("operational"), None).await;
|
|
153 |
+ |
self.dispatch_email(&subject, &body,
|
|
154 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::Recovery, from: Some(from_status), to: Some("operational"), error: None }).await;
|
| 128 |
155 |
|
}
|
| 129 |
156 |
|
|
| 130 |
157 |
|
#[instrument(skip_all)]
|
| 155 |
182 |
|
);
|
| 156 |
183 |
|
|
| 157 |
184 |
|
let priority = tls_expiry_priority(days_remaining);
|
| 158 |
|
- |
self.wam_ticket(&subject, &body, priority, "pom-tls", Some(&format!("{target}:{host}"))).await;
|
| 159 |
|
- |
self.record_alert(&alert_key, AlertCategory::TlsExpiry, None, None, None).await;
|
|
185 |
+ |
self.dispatch_wam(&subject, &body, priority, "pom-tls", Some(&format!("{target}:{host}")),
|
|
186 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::TlsExpiry, from: None, to: None, error: None }).await;
|
| 160 |
187 |
|
}
|
| 161 |
188 |
|
|
| 162 |
189 |
|
#[instrument(skip_all)]
|
| 184 |
211 |
|
chrono::Utc::now().to_rfc3339(),
|
| 185 |
212 |
|
);
|
| 186 |
213 |
|
|
| 187 |
|
- |
self.wam_ticket(&subject, &body, "high", "pom-tls", Some(&format!("{target}:{host}"))).await;
|
| 188 |
|
- |
self.record_alert(&alert_key, AlertCategory::TlsError, None, None, Some(error)).await;
|
|
214 |
+ |
self.dispatch_wam(&subject, &body, "high", "pom-tls", Some(&format!("{target}:{host}")),
|
|
215 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::TlsError, from: None, to: None, error: Some(error) }).await;
|
| 189 |
216 |
|
}
|
| 190 |
217 |
|
|
| 191 |
218 |
|
#[instrument(skip_all)]
|
| 196 |
223 |
|
days_remaining: i64,
|
| 197 |
224 |
|
) {
|
| 198 |
225 |
|
let alert_key = format!("tls:{target}");
|
| 199 |
|
- |
// No cooldown on recovery — always send
|
|
226 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
227 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
228 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
229 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
230 |
+ |
return;
|
|
231 |
+ |
}
|
| 200 |
232 |
|
let subject = format!("[PoM] {target}: TLS cert renewed");
|
| 201 |
233 |
|
let body = format!(
|
| 202 |
234 |
|
"Target: {label} ({target})\n\
|
| 208 |
240 |
|
chrono::Utc::now().to_rfc3339(),
|
| 209 |
241 |
|
);
|
| 210 |
242 |
|
|
| 211 |
|
- |
self.send_email(&subject, &body).await;
|
| 212 |
|
- |
self.record_alert(&alert_key, AlertCategory::TlsRecovery, None, None, None).await;
|
|
243 |
+ |
self.dispatch_email(&subject, &body,
|
|
244 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::TlsRecovery, from: None, to: None, error: None }).await;
|
| 213 |
245 |
|
}
|
| 214 |
246 |
|
|
| 215 |
247 |
|
#[instrument(skip_all)]
|
| 237 |
269 |
|
chrono::Utc::now().to_rfc3339(),
|
| 238 |
270 |
|
);
|
| 239 |
271 |
|
|
| 240 |
|
- |
self.wam_ticket(&subject, &body, "high", "pom-peer", Some(peer_name)).await;
|
| 241 |
|
- |
self.record_alert(&alert_key, AlertCategory::PeerMissing, None, None, None).await;
|
|
272 |
+ |
self.dispatch_wam(&subject, &body, "high", "pom-peer", Some(peer_name),
|
|
273 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::PeerMissing, from: None, to: None, error: None }).await;
|
| 242 |
274 |
|
}
|
| 243 |
275 |
|
|
| 244 |
276 |
|
#[instrument(skip_all)]
|
| 259 |
291 |
|
);
|
| 260 |
292 |
|
|
| 261 |
293 |
|
let alert_key = format!("peer:{peer_name}");
|
| 262 |
|
- |
self.send_email(&subject, &body).await;
|
| 263 |
|
- |
self.record_alert(&alert_key, AlertCategory::PeerRecovery, None, None, None).await;
|
|
294 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
295 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
296 |
+ |
return;
|
|
297 |
+ |
}
|
|
298 |
+ |
self.dispatch_email(&subject, &body,
|
|
299 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::PeerRecovery, from: None, to: None, error: None }).await;
|
| 264 |
300 |
|
}
|
| 265 |
301 |
|
|
| 266 |
302 |
|
#[instrument(skip_all)]
|
| 289 |
325 |
|
chrono::Utc::now().to_rfc3339(),
|
| 290 |
326 |
|
);
|
| 291 |
327 |
|
|
| 292 |
|
- |
self.wam_ticket(&subject, &body, "high", "pom-routes", Some(target)).await;
|
| 293 |
|
- |
self.record_alert(&alert_key, AlertCategory::RouteFailure, None, None, None).await;
|
|
328 |
+ |
self.dispatch_wam(&subject, &body, "high", "pom-routes", Some(target),
|
|
329 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::RouteFailure, from: None, to: None, error: None }).await;
|
| 294 |
330 |
|
}
|
| 295 |
331 |
|
|
| 296 |
332 |
|
#[instrument(skip_all)]
|
| 300 |
336 |
|
label: &str,
|
| 301 |
337 |
|
recovered_paths: &[String],
|
| 302 |
338 |
|
) {
|
| 303 |
|
- |
// No cooldown on recovery — always send
|
| 304 |
339 |
|
let alert_key = format!("route:{target}");
|
|
340 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
341 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
342 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
343 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
344 |
+ |
return;
|
|
345 |
+ |
}
|
| 305 |
346 |
|
let subject = format!("[PoM] {label}: routes recovered");
|
| 306 |
347 |
|
let body = format!(
|
| 307 |
348 |
|
"Target: {label} ({target})\n\
|
| 314 |
355 |
|
chrono::Utc::now().to_rfc3339(),
|
| 315 |
356 |
|
);
|
| 316 |
357 |
|
|
| 317 |
|
- |
self.send_email(&subject, &body).await;
|
| 318 |
|
- |
self.record_alert(&alert_key, AlertCategory::RouteRecovery, None, None, None).await;
|
|
358 |
+ |
self.dispatch_email(&subject, &body,
|
|
359 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::RouteRecovery, from: None, to: None, error: None }).await;
|
| 319 |
360 |
|
}
|
| 320 |
361 |
|
|
| 321 |
362 |
|
#[instrument(skip_all)]
|
| 357 |
398 |
|
chrono::Utc::now().to_rfc3339(),
|
| 358 |
399 |
|
);
|
| 359 |
400 |
|
|
| 360 |
|
- |
self.wam_ticket(&subject, &body, "high", "pom-dns", Some(target)).await;
|
| 361 |
|
- |
self.record_alert(&alert_key, AlertCategory::DnsMismatch, None, None, None).await;
|
|
401 |
+ |
self.dispatch_wam(&subject, &body, "high", "pom-dns", Some(target),
|
|
402 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::DnsMismatch, from: None, to: None, error: None }).await;
|
| 362 |
403 |
|
}
|
| 363 |
404 |
|
|
| 364 |
405 |
|
#[instrument(skip_all)]
|
| 367 |
408 |
|
target: &str,
|
| 368 |
409 |
|
label: &str,
|
| 369 |
410 |
|
) {
|
| 370 |
|
- |
// No cooldown on recovery — always send
|
| 371 |
411 |
|
let alert_key = format!("dns:{target}");
|
|
412 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
413 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
414 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
415 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
416 |
+ |
return;
|
|
417 |
+ |
}
|
| 372 |
418 |
|
let subject = format!("[PoM] {label}: DNS records recovered");
|
| 373 |
419 |
|
let body = format!(
|
| 374 |
420 |
|
"Target: {label} ({target})\n\
|
| 380 |
426 |
|
chrono::Utc::now().to_rfc3339(),
|
| 381 |
427 |
|
);
|
| 382 |
428 |
|
|
| 383 |
|
- |
self.send_email(&subject, &body).await;
|
| 384 |
|
- |
self.record_alert(&alert_key, AlertCategory::DnsRecovery, None, None, None).await;
|
|
429 |
+ |
self.dispatch_email(&subject, &body,
|
|
430 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::DnsRecovery, from: None, to: None, error: None }).await;
|
| 385 |
431 |
|
}
|
| 386 |
432 |
|
|
| 387 |
433 |
|
#[instrument(skip_all)]
|
| 411 |
457 |
|
);
|
| 412 |
458 |
|
|
| 413 |
459 |
|
let priority = whois_expiry_priority(days_remaining);
|
| 414 |
|
- |
self.wam_ticket(&subject, &body, priority, "pom-whois", Some(&format!("{target}:{domain}"))).await;
|
| 415 |
|
- |
self.record_alert(&alert_key, AlertCategory::WhoisExpiry, None, None, None).await;
|
|
460 |
+ |
self.dispatch_wam(&subject, &body, priority, "pom-whois", Some(&format!("{target}:{domain}")),
|
|
461 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::WhoisExpiry, from: None, to: None, error: None }).await;
|
| 416 |
462 |
|
}
|
| 417 |
463 |
|
|
| 418 |
464 |
|
#[instrument(skip_all)]
|
| 441 |
487 |
|
chrono::Utc::now().to_rfc3339(),
|
| 442 |
488 |
|
);
|
| 443 |
489 |
|
|
| 444 |
|
- |
self.wam_ticket(&subject, &body, "high", "pom-whois", Some(&format!("{target}:{domain}"))).await;
|
| 445 |
|
- |
self.record_alert(&alert_key, AlertCategory::WhoisError, None, None, Some(error)).await;
|
|
490 |
+ |
self.dispatch_wam(&subject, &body, "high", "pom-whois", Some(&format!("{target}:{domain}")),
|
|
491 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::WhoisError, from: None, to: None, error: Some(error) }).await;
|
| 446 |
492 |
|
}
|
| 447 |
493 |
|
|
| 448 |
494 |
|
#[instrument(skip_all)]
|
| 482 |
528 |
|
chrono::Utc::now().to_rfc3339(),
|
| 483 |
529 |
|
);
|
| 484 |
530 |
|
|
| 485 |
|
- |
self.wam_ticket(&subject, &body, "high", "pom-cors", Some(target)).await;
|
| 486 |
|
- |
self.record_alert(&alert_key, AlertCategory::CorsFailure, None, None, None).await;
|
|
531 |
+ |
self.dispatch_wam(&subject, &body, "high", "pom-cors", Some(target),
|
|
532 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::CorsFailure, from: None, to: None, error: None }).await;
|
| 487 |
533 |
|
}
|
| 488 |
534 |
|
|
| 489 |
535 |
|
#[instrument(skip_all)]
|
| 492 |
538 |
|
target: &str,
|
| 493 |
539 |
|
label: &str,
|
| 494 |
540 |
|
) {
|
| 495 |
|
- |
// No cooldown on recovery — always send
|
| 496 |
541 |
|
let alert_key = format!("cors:{target}");
|
|
542 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
543 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
544 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
545 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
546 |
+ |
return;
|
|
547 |
+ |
}
|
| 497 |
548 |
|
let subject = format!("[PoM] {label}: CORS preflights recovered");
|
| 498 |
549 |
|
let body = format!(
|
| 499 |
550 |
|
"Target: {label} ({target})\n\
|
| 505 |
556 |
|
chrono::Utc::now().to_rfc3339(),
|
| 506 |
557 |
|
);
|
| 507 |
558 |
|
|
| 508 |
|
- |
self.send_email(&subject, &body).await;
|
| 509 |
|
- |
self.record_alert(&alert_key, AlertCategory::CorsRecovery, None, None, None).await;
|
|
559 |
+ |
self.dispatch_email(&subject, &body,
|
|
560 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::CorsRecovery, from: None, to: None, error: None }).await;
|
| 510 |
561 |
|
}
|
| 511 |
562 |
|
|
| 512 |
563 |
|
#[instrument(skip_all)]
|
| 533 |
584 |
|
chrono::Utc::now().to_rfc3339(),
|
| 534 |
585 |
|
);
|
| 535 |
586 |
|
|
| 536 |
|
- |
self.wam_ticket(&subject, &body, "medium", "pom-latency", Some(target)).await;
|
| 537 |
|
- |
self.record_alert(&alert_key, AlertCategory::LatencyDrift, None, None, Some(drift_message)).await;
|
|
587 |
+ |
self.dispatch_wam(&subject, &body, "medium", "pom-latency", Some(target),
|
|
588 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::LatencyDrift, from: None, to: None, error: Some(drift_message) }).await;
|
| 538 |
589 |
|
}
|
| 539 |
590 |
|
|
| 540 |
591 |
|
#[instrument(skip_all)]
|
| 543 |
594 |
|
target: &str,
|
| 544 |
595 |
|
label: &str,
|
| 545 |
596 |
|
) {
|
| 546 |
|
- |
// No cooldown on recovery — always send
|
| 547 |
597 |
|
let alert_key = format!("latency:{target}");
|
|
598 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
599 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
600 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
601 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
602 |
+ |
return;
|
|
603 |
+ |
}
|
| 548 |
604 |
|
let subject = format!("[PoM] {target}: latency recovered");
|
| 549 |
605 |
|
let body = format!(
|
| 550 |
606 |
|
"Target: {label} ({target})\n\
|
| 556 |
612 |
|
chrono::Utc::now().to_rfc3339(),
|
| 557 |
613 |
|
);
|
| 558 |
614 |
|
|
| 559 |
|
- |
self.send_email(&subject, &body).await;
|
| 560 |
|
- |
self.record_alert(&alert_key, AlertCategory::LatencyRecovery, None, None, None).await;
|
|
615 |
+ |
self.dispatch_email(&subject, &body,
|
|
616 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::LatencyRecovery, from: None, to: None, error: None }).await;
|
| 561 |
617 |
|
}
|
| 562 |
618 |
|
|
| 563 |
619 |
|
#[instrument(skip_all)]
|
| 584 |
640 |
|
chrono::Utc::now().to_rfc3339(),
|
| 585 |
641 |
|
);
|
| 586 |
642 |
|
|
| 587 |
|
- |
self.wam_ticket(&subject, &body, "medium", "pom-test-duration", Some(target)).await;
|
| 588 |
|
- |
self.record_alert(&alert_key, AlertCategory::TestDurationDrift, None, None, Some(drift_message)).await;
|
|
643 |
+ |
self.dispatch_wam(&subject, &body, "medium", "pom-test-duration", Some(target),
|
|
644 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::TestDurationDrift, from: None, to: None, error: Some(drift_message) }).await;
|
| 589 |
645 |
|
}
|
| 590 |
646 |
|
|
| 591 |
647 |
|
#[instrument(skip_all)]
|
| 619 |
675 |
|
);
|
| 620 |
676 |
|
|
| 621 |
677 |
|
let priority = backup_status_priority(status);
|
| 622 |
|
- |
self.wam_ticket(&subject, &body, priority, "pom-backup", Some(&format!("{target}:{database}"))).await;
|
| 623 |
|
- |
self.record_alert(&alert_key, AlertCategory::BackupStale, None, Some(status), None).await;
|
|
678 |
+ |
self.dispatch_wam(&subject, &body, priority, "pom-backup", Some(&format!("{target}:{database}")),
|
|
679 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::BackupStale, from: None, to: Some(status), error: None }).await;
|
| 624 |
680 |
|
}
|
| 625 |
681 |
|
|
| 626 |
682 |
|
#[instrument(skip_all)]
|
| 630 |
686 |
|
label: &str,
|
| 631 |
687 |
|
database: &str,
|
| 632 |
688 |
|
) {
|
| 633 |
|
- |
// No cooldown on recovery — always send
|
| 634 |
689 |
|
let alert_key = format!("backup:{target}:{database}");
|
|
690 |
+ |
// Recoveries are throttled by their own cooldown so a flapping target
|
|
691 |
+ |
// can't spam one recovery email per flap (fuzz-2026-07-06).
|
|
692 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
693 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
694 |
+ |
return;
|
|
695 |
+ |
}
|
| 635 |
696 |
|
let subject = format!("[PoM] {label}: {database} backup recovered");
|
| 636 |
697 |
|
let body = format!(
|
| 637 |
698 |
|
"Target: {label} ({target})\n\
|
| 644 |
705 |
|
chrono::Utc::now().to_rfc3339(),
|
| 645 |
706 |
|
);
|
| 646 |
707 |
|
|
| 647 |
|
- |
self.send_email(&subject, &body).await;
|
| 648 |
|
- |
self.record_alert(&alert_key, AlertCategory::BackupRecovery, None, Some("ok"), None).await;
|
|
708 |
+ |
self.dispatch_email(&subject, &body,
|
|
709 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::BackupRecovery, from: None, to: Some("ok"), error: None }).await;
|
| 649 |
710 |
|
}
|
| 650 |
711 |
|
|
| 651 |
712 |
|
/// Fire when the scan pipeline transitions from operational → degraded or
|
| 680 |
741 |
|
);
|
| 681 |
742 |
|
|
| 682 |
743 |
|
let priority = if status == "unreachable" { "high" } else { "medium" };
|
| 683 |
|
- |
self.wam_ticket(&subject, &body, priority, "pom-scan-pipeline", Some(target)).await;
|
| 684 |
|
- |
self.record_alert(&alert_key, AlertCategory::ScanPipelineDegraded, None, Some(status), None).await;
|
|
744 |
+ |
self.dispatch_wam(&subject, &body, priority, "pom-scan-pipeline", Some(target),
|
|
745 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::ScanPipelineDegraded, from: None, to: Some(status), error: None }).await;
|
| 685 |
746 |
|
}
|
| 686 |
747 |
|
|
| 687 |
748 |
|
/// Fire on recovery from a degraded / unreachable scan-pipeline state.
|
| 688 |
749 |
|
#[instrument(skip_all)]
|
| 689 |
750 |
|
pub async fn send_scan_pipeline_recovery(&self, target: &str, label: &str) {
|
| 690 |
751 |
|
let alert_key = format!("scan_pipeline:{target}");
|
|
752 |
+ |
if self.is_recovery_within_cooldown(&alert_key).await {
|
|
753 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
754 |
+ |
return;
|
|
755 |
+ |
}
|
| 691 |
756 |
|
let subject = format!("[PoM] {label}: scan pipeline recovered");
|
| 692 |
757 |
|
let body = format!(
|
| 693 |
758 |
|
"Target: {label} ({target})\n\
|
| 699 |
764 |
|
chrono::Utc::now().to_rfc3339(),
|
| 700 |
765 |
|
);
|
| 701 |
766 |
|
|
| 702 |
|
- |
self.send_email(&subject, &body).await;
|
| 703 |
|
- |
self.record_alert(&alert_key, AlertCategory::ScanPipelineRecovery, None, Some("operational"), None).await;
|
|
767 |
+ |
self.dispatch_email(&subject, &body,
|
|
768 |
+ |
AlertMeta { key: &alert_key, category: AlertCategory::ScanPipelineRecovery, from: None, to: Some("operational"), error: None }).await;
|
| 704 |
769 |
|
}
|
| 705 |
770 |
|
|
| 706 |
771 |
|
/// All monitored targets are unreachable — likely a network issue with PoM itself.
|
| 724 |
789 |
|
chrono::Utc::now().to_rfc3339(),
|
| 725 |
790 |
|
);
|
| 726 |
791 |
|
|
| 727 |
|
- |
self.wam_ticket(&subject, &body, "critical", "pom-monitoring", Some("self")).await;
|
| 728 |
|
- |
self.record_alert(alert_key, AlertCategory::MonitoringOffline, None, None, None).await;
|
|
792 |
+ |
self.dispatch_wam(&subject, &body, "critical", "pom-monitoring", Some("self"),
|
|
793 |
+ |
AlertMeta { key: alert_key, category: AlertCategory::MonitoringOffline, from: None, to: None, error: None }).await;
|
| 729 |
794 |
|
}
|
| 730 |
795 |
|
|
| 731 |
796 |
|
/// At least one target is reachable again after a monitoring-offline event.
|
| 732 |
797 |
|
#[instrument(skip_all)]
|
| 733 |
798 |
|
pub async fn send_monitoring_recovery(&self) {
|
| 734 |
799 |
|
let alert_key = "monitoring:self";
|
|
800 |
+ |
if self.is_recovery_within_cooldown(alert_key).await {
|
|
801 |
+ |
info!("recovery cooldown active for {alert_key}, skipping");
|
|
802 |
+ |
return;
|
|
803 |
+ |
}
|
| 735 |
804 |
|
let subject = "[PoM] monitoring recovered".to_string();
|
| 736 |
805 |
|
let body = format!(
|
| 737 |
806 |
|
"At least one target is reachable again.\n\
|
| 742 |
811 |
|
chrono::Utc::now().to_rfc3339(),
|
| 743 |
812 |
|
);
|
| 744 |
813 |
|
|
| 745 |
|
- |
self.send_email(&subject, &body).await;
|
| 746 |
|
- |
self.record_alert(alert_key, AlertCategory::MonitoringRecovery, None, None, None).await;
|
|
814 |
+ |
self.dispatch_email(&subject, &body,
|
|
815 |
+ |
AlertMeta { key: alert_key, category: AlertCategory::MonitoringRecovery, from: None, to: None, error: None }).await;
|
| 747 |
816 |
|
}
|
| 748 |
817 |
|
|
|
818 |
+ |
/// Whether a FAILURE alert for `target` is within its cooldown.
|
|
819 |
+ |
///
|
|
820 |
+ |
/// A recorded recovery *newer* than the last failure clears the cooldown: the
|
|
821 |
+ |
/// target came back, so a fresh failure is a genuine new outage and must not
|
|
822 |
+ |
/// be suppressed by the prior failure's window (fuzz-2026-07-06 fail→recover→
|
|
823 |
+ |
/// fail suppression).
|
| 749 |
824 |
|
async fn is_within_cooldown(&self, target: &str) -> bool {
|
| 750 |
|
- |
let latest = match db::get_latest_alert_for_target(&self.pool, target).await {
|
| 751 |
|
- |
Ok(Some(row)) => row,
|
| 752 |
|
- |
_ => return false,
|
|
825 |
+ |
let Ok(Some(latest_fail)) = db::get_latest_alert_for_target(&self.pool, target).await else {
|
|
826 |
+ |
return false; // never alerted → not in cooldown
|
| 753 |
827 |
|
};
|
|
828 |
+ |
// A recovery after the last failure voids the cooldown.
|
|
829 |
+ |
if let Ok(Some(rec)) =
|
|
830 |
+ |
db::get_latest_alert_matching(&self.pool, target, "%recovery%").await
|
|
831 |
+ |
&& rec.id > latest_fail.id
|
|
832 |
+ |
{
|
|
833 |
+ |
return false;
|
|
834 |
+ |
}
|
|
835 |
+ |
within_cooldown_secs(&latest_fail.sent_at, self.config.cooldown_secs)
|
|
836 |
+ |
}
|
| 754 |
837 |
|
|
| 755 |
|
- |
let sent_at = match chrono::DateTime::parse_from_rfc3339(&latest.sent_at) {
|
| 756 |
|
- |
Ok(dt) => dt,
|
| 757 |
|
- |
Err(_) => return false,
|
|
838 |
+ |
/// Whether a RECOVERY for `target` is within its own cooldown — throttles a
|
|
839 |
+ |
/// flapping target so it can't emit one recovery email per flap
|
|
840 |
+ |
/// (fuzz-2026-07-06 un-throttled recoveries). A failure newer than the last
|
|
841 |
+ |
/// recovery clears it, so a genuine recover-after-a-new-outage still sends.
|
|
842 |
+ |
async fn is_recovery_within_cooldown(&self, target: &str) -> bool {
|
|
843 |
+ |
let Ok(Some(latest_rec)) =
|
|
844 |
+ |
db::get_latest_alert_matching(&self.pool, target, "%recovery%").await
|
|
845 |
+ |
else {
|
|
846 |
+ |
return false;
|
| 758 |
847 |
|
};
|
| 759 |
|
- |
|
| 760 |
|
- |
let elapsed = chrono::Utc::now().signed_duration_since(sent_at);
|
| 761 |
|
- |
elapsed.num_seconds() < self.config.cooldown_secs as i64
|
|
848 |
+ |
if let Ok(Some(fail)) = db::get_latest_alert_for_target(&self.pool, target).await
|
|
849 |
+ |
&& fail.id > latest_rec.id
|
|
850 |
+ |
{
|
|
851 |
+ |
return false;
|
|
852 |
+ |
}
|
|
853 |
+ |
within_cooldown_secs(&latest_rec.sent_at, self.config.cooldown_secs)
|
| 762 |
854 |
|
}
|
| 763 |
855 |
|
|
| 764 |
856 |
|
/// Send an email via Postmark. Returns `true` if the message was accepted
|
| 818 |
910 |
|
to_status: Option<&str>,
|
| 819 |
911 |
|
error: Option<&str>,
|
| 820 |
912 |
|
) {
|
| 821 |
|
- |
let alert_type_str = alert_type.to_string();
|
| 822 |
|
- |
if let Err(e) = db::insert_alert(&self.pool, target, &alert_type_str, from_status, to_status, error).await {
|
|
913 |
+ |
self.record_alert_str(target, &alert_type.to_string(), from_status, to_status, error).await;
|
|
914 |
+ |
}
|
|
915 |
+ |
|
|
916 |
+ |
/// String-typed variant so the retry task can record a queued alert straight
|
|
917 |
+ |
/// from its stored category without round-tripping through the enum.
|
|
918 |
+ |
async fn record_alert_str(
|
|
919 |
+ |
&self,
|
|
920 |
+ |
target: &str,
|
|
921 |
+ |
alert_type: &str,
|
|
922 |
+ |
from_status: Option<&str>,
|
|
923 |
+ |
to_status: Option<&str>,
|
|
924 |
+ |
error: Option<&str>,
|
|
925 |
+ |
) {
|
|
926 |
+ |
if let Err(e) = db::insert_alert(&self.pool, target, alert_type, from_status, to_status, error).await {
|
| 823 |
927 |
|
warn!("failed to record alert: {e}");
|
| 824 |
928 |
|
}
|
| 825 |
929 |
|
}
|
| 826 |
930 |
|
|
|
931 |
+ |
/// Dispatch a WAM failure alert: attempt delivery (WAM, falling back to
|
|
932 |
+ |
/// email), record it on success, or persist it for retry on failure. Gating
|
|
933 |
+ |
/// the ledger write on delivery — and queueing the miss — is what stops a
|
|
934 |
+ |
/// transient send failure at a status transition from silencing the whole
|
|
935 |
+ |
/// outage: the transition fires once, but the queued alert is retried until it
|
|
936 |
+ |
/// lands (fuzz-2026-07-06 CRITICAL #1).
|
|
937 |
+ |
async fn dispatch_wam(
|
|
938 |
+ |
&self,
|
|
939 |
+ |
subject: &str,
|
|
940 |
+ |
body: &str,
|
|
941 |
+ |
priority: &str,
|
|
942 |
+ |
source: &str,
|
|
943 |
+ |
source_ref: Option<&str>,
|
|
944 |
+ |
meta: AlertMeta<'_>,
|
|
945 |
+ |
) {
|
|
946 |
+ |
if self.wam_ticket(subject, body, priority, source, source_ref).await {
|
|
947 |
+ |
self.record_alert(meta.key, meta.category, meta.from, meta.to, meta.error).await;
|
|
948 |
+ |
} else {
|
|
949 |
+ |
self.enqueue_undelivered(subject, body, "wam", Some(priority), Some(source), source_ref, &meta).await;
|
|
950 |
+ |
}
|
|
951 |
+ |
}
|
|
952 |
+ |
|
|
953 |
+ |
/// Dispatch an email alert (recoveries, and failure alerts that already chose
|
|
954 |
+ |
/// email): record on success, persist for retry on failure.
|
|
955 |
+ |
async fn dispatch_email(&self, subject: &str, body: &str, meta: AlertMeta<'_>) {
|
|
956 |
+ |
if self.send_email(subject, body).await {
|
|
957 |
+ |
self.record_alert(meta.key, meta.category, meta.from, meta.to, meta.error).await;
|
|
958 |
+ |
} else {
|
|
959 |
+ |
self.enqueue_undelivered(subject, body, "email", None, None, None, &meta).await;
|
|
960 |
+ |
}
|
|
961 |
+ |
}
|
|
962 |
+ |
|
|
963 |
+ |
#[allow(clippy::too_many_arguments)]
|
|
964 |
+ |
async fn enqueue_undelivered(
|
|
965 |
+ |
&self,
|
|
966 |
+ |
subject: &str,
|
|
967 |
+ |
body: &str,
|
|
968 |
+ |
channel: &str,
|
|
969 |
+ |
priority: Option<&str>,
|
|
970 |
+ |
source: Option<&str>,
|
|
971 |
+ |
source_ref: Option<&str>,
|
|
972 |
+ |
meta: &AlertMeta<'_>,
|