pub mod channels;
pub mod email;
pub mod pagerduty;
pub mod slack;
pub mod teams;
pub mod webhook;
use crate::Result;
use crate::monitor::config::MonitorConfig;
use crate::monitor::detector::{ChangeEvent, ChangeSeverity};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
pub use channels::AlertChannel;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AlertType {
CertificateChange { changes: Vec<ChangeEvent> },
ExpiryWarning { days_remaining: i64 },
ValidationFailure { reason: String },
ScanFailure { error: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertDetails {
pub certificate_serial: Option<String>,
pub certificate_issuer: Option<String>,
pub certificate_expiry: Option<String>,
pub previous_serial: Option<String>,
pub scan_time: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub hostname: String,
pub alert_type: AlertType,
pub severity: ChangeSeverity,
pub message: String,
pub details: AlertDetails,
pub timestamp: DateTime<Utc>,
}
impl Alert {
pub fn certificate_change(
hostname: String,
changes: Vec<ChangeEvent>,
details: AlertDetails,
) -> Self {
let severity = changes
.iter()
.map(|c| c.severity)
.max()
.unwrap_or(ChangeSeverity::Info);
let message = format!(
"Certificate changed for {} ({} changes detected)",
hostname,
changes.len()
);
Self {
hostname,
alert_type: AlertType::CertificateChange { changes },
severity,
message,
details,
timestamp: Utc::now(),
}
}
pub fn expiry_warning(hostname: String, days_remaining: i64, details: AlertDetails) -> Self {
let severity = if days_remaining <= 1 {
ChangeSeverity::Critical
} else if days_remaining <= 7 {
ChangeSeverity::High
} else if days_remaining <= 14 {
ChangeSeverity::Medium
} else {
ChangeSeverity::Low
};
let message = format!(
"Certificate for {} expires in {} days",
hostname, days_remaining
);
Self {
hostname,
alert_type: AlertType::ExpiryWarning { days_remaining },
severity,
message,
details,
timestamp: Utc::now(),
}
}
pub fn validation_failure(hostname: String, reason: String, details: AlertDetails) -> Self {
let message = format!("Certificate validation failed for {}: {}", hostname, reason);
Self {
hostname,
alert_type: AlertType::ValidationFailure { reason },
severity: ChangeSeverity::High,
message,
details,
timestamp: Utc::now(),
}
}
pub fn scan_failure(hostname: String, error: String) -> Self {
let message = format!("Failed to scan {}: {}", hostname, error);
Self {
hostname,
alert_type: AlertType::ScanFailure { error },
severity: ChangeSeverity::Medium,
message,
details: AlertDetails {
certificate_serial: None,
certificate_issuer: None,
certificate_expiry: None,
previous_serial: None,
scan_time: Utc::now(),
},
timestamp: Utc::now(),
}
}
pub fn dedup_key(&self) -> String {
match &self.alert_type {
AlertType::CertificateChange { .. } => {
format!("change:{}", self.hostname)
}
AlertType::ExpiryWarning { days_remaining } => {
format!("expiry:{}:{}", self.hostname, days_remaining)
}
AlertType::ValidationFailure { reason } => {
format!("validation:{}:{}", self.hostname, reason)
}
AlertType::ScanFailure { .. } => {
format!("scan:{}", self.hostname)
}
}
}
}
pub struct AlertManager {
channels: Vec<Box<dyn AlertChannel>>,
recent_alerts: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
dedup_window: Duration,
}
impl AlertManager {
pub fn new(dedup_window_hours: u64) -> Self {
Self {
channels: Vec::new(),
recent_alerts: Arc::new(Mutex::new(HashMap::new())),
dedup_window: Duration::hours(dedup_window_hours as i64),
}
}
pub async fn from_config(config: &MonitorConfig) -> Result<Self> {
let mut manager = Self::new(config.monitor.deduplication.window_hours);
if let Some(ref email_config) = config.monitor.alerts.email
&& email_config.enabled
{
let channel = email::EmailChannel::new(email_config.clone())?;
manager.add_channel(Box::new(channel));
}
if let Some(ref slack_config) = config.monitor.alerts.slack
&& slack_config.enabled
{
let channel = slack::SlackChannel::new(slack_config.clone());
manager.add_channel(Box::new(channel));
}
if let Some(ref teams_config) = config.monitor.alerts.teams
&& teams_config.enabled
{
let channel = teams::TeamsChannel::new(teams_config.clone());
manager.add_channel(Box::new(channel));
}
if let Some(ref pd_config) = config.monitor.alerts.pagerduty
&& pd_config.enabled
{
let channel = pagerduty::PagerDutyChannel::new(pd_config.clone());
manager.add_channel(Box::new(channel));
}
if let Some(ref webhook_config) = config.monitor.alerts.webhook
&& webhook_config.enabled
{
let channel = webhook::WebhookChannel::new(webhook_config.clone());
manager.add_channel(Box::new(channel));
}
Ok(manager)
}
pub fn add_channel(&mut self, channel: Box<dyn AlertChannel>) {
self.channels.push(channel);
}
pub async fn send_alert(&self, alert: &Alert) -> Result<()> {
if self.is_duplicate(alert).await {
tracing::debug!("Alert deduplicated: {}", alert.dedup_key());
return Ok(());
}
self.record_alert(alert).await;
let mut success_count = 0;
for channel in &self.channels {
let channel_name = channel.channel_name();
match channel.send_alert(alert).await {
Ok(_) => {
tracing::info!("Alert sent via {}: {}", channel_name, alert.message);
success_count += 1;
}
Err(e) => {
tracing::error!("Failed to send alert via {}: {}", channel_name, e);
}
}
}
if success_count == 0 && !self.channels.is_empty() {
return Err(anyhow::anyhow!("All alert channels failed").into());
}
Ok(())
}
async fn is_duplicate(&self, alert: &Alert) -> bool {
let recent = self.recent_alerts.lock().await;
let key = alert.dedup_key();
if let Some(last_sent) = recent.get(&key) {
let elapsed = Utc::now() - *last_sent;
elapsed < self.dedup_window
} else {
false
}
}
async fn record_alert(&self, alert: &Alert) {
let mut recent = self.recent_alerts.lock().await;
recent.insert(alert.dedup_key(), alert.timestamp);
let cutoff = Utc::now() - self.dedup_window;
recent.retain(|_, &mut time| time > cutoff);
}
pub fn channel_count(&self) -> usize {
self.channels.len()
}
pub async fn test_channels(&self) -> Vec<(String, Result<()>)> {
let mut results = Vec::new();
for channel in &self.channels {
let test_alert = Alert {
hostname: "test.example.com".to_string(),
alert_type: AlertType::ScanFailure {
error: "This is a test alert".to_string(),
},
severity: ChangeSeverity::Info,
message: "Test alert from CipherRun monitoring".to_string(),
details: AlertDetails {
certificate_serial: None,
certificate_issuer: None,
certificate_expiry: None,
previous_serial: None,
scan_time: Utc::now(),
},
timestamp: Utc::now(),
};
let result = channel.send_alert(&test_alert).await;
results.push((channel.channel_name().to_string(), result));
}
results
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alert_dedup_key() {
let alert =
Alert::scan_failure("example.com".to_string(), "Connection refused".to_string());
assert_eq!(alert.dedup_key(), "scan:example.com");
}
#[test]
fn test_expiry_warning_severity() {
let details = AlertDetails {
certificate_serial: None,
certificate_issuer: None,
certificate_expiry: None,
previous_serial: None,
scan_time: Utc::now(),
};
let alert1 = Alert::expiry_warning("example.com".to_string(), 1, details.clone());
assert_eq!(alert1.severity, ChangeSeverity::Critical);
let alert7 = Alert::expiry_warning("example.com".to_string(), 7, details.clone());
assert_eq!(alert7.severity, ChangeSeverity::High);
let alert14 = Alert::expiry_warning("example.com".to_string(), 14, details.clone());
assert_eq!(alert14.severity, ChangeSeverity::Medium);
let alert30 = Alert::expiry_warning("example.com".to_string(), 30, details);
assert_eq!(alert30.severity, ChangeSeverity::Low);
}
#[tokio::test]
async fn test_alert_manager_deduplication() {
let manager = AlertManager::new(24);
let alert =
Alert::scan_failure("example.com".to_string(), "Connection refused".to_string());
assert!(!manager.is_duplicate(&alert).await);
manager.record_alert(&alert).await;
assert!(manager.is_duplicate(&alert).await);
}
#[test]
fn test_alert_manager_new() {
let manager = AlertManager::new(24);
assert_eq!(manager.channel_count(), 0);
}
}