use crate::Result;
use crate::plugins::audit::{AuditEvent, event_bus};
use crate::web::config::{AlertCondition, AlertConfig, AlertRule, AlertSeverity, WebhookConfig};
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tracing::{debug, error, info, trace, warn};
#[derive(Debug, Clone, Serialize)]
pub struct Alert {
pub id: String,
pub rule_name: String,
pub severity: AlertSeverity,
pub message: String,
pub timestamp: u64,
pub last_updated: u64,
pub occurrence_count: u64,
pub acknowledged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<HashMap<String, String>>,
}
impl Alert {
pub fn new(rule_name: &str, severity: AlertSeverity, message: String) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
id: uuid::Uuid::new_v4().to_string(),
rule_name: rule_name.to_string(),
severity,
message,
timestamp: now,
last_updated: now,
occurrence_count: 1,
acknowledged: false,
context: None,
}
}
pub fn with_context(mut self, key: &str, value: &str) -> Self {
self.context
.get_or_insert_with(HashMap::new)
.insert(key.to_string(), value.to_string());
self
}
pub fn increment_occurrence(&mut self) {
self.occurrence_count += 1;
self.last_updated = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
pub fn matches(&self, other: &Alert) -> bool {
self.rule_name == other.rule_name && self.message == other.message
}
}
pub struct AlertEngine {
config: AlertConfig,
alerts: RwLock<VecDeque<Alert>>,
dedup_cache: RwLock<HashMap<String, Instant>>,
alert_counter: AtomicU64,
http_client: reqwest::Client,
}
impl AlertEngine {
pub fn new(config: &AlertConfig) -> Result<Self> {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| crate::Error::Config(format!("Failed to create HTTP client: {}", e)))?;
Ok(Self {
config: config.clone(),
alerts: RwLock::new(VecDeque::with_capacity(config.max_alerts)),
dedup_cache: RwLock::new(HashMap::new()),
alert_counter: AtomicU64::new(0),
http_client,
})
}
pub async fn run(&self) -> Result<()> {
if !self.config.enabled {
info!("Alert engine disabled");
return Ok(());
}
let bus = match event_bus() {
Some(bus) => bus,
None => {
info!("Event bus not initialized, alert engine not starting");
return Ok(());
}
};
let mut subscriber = bus.subscribe_security();
info!(rules = self.config.rules.len(), "Alert engine started");
loop {
match subscriber.recv().await {
Some(event) => {
trace!("Processing security event for alerts");
self.process_event(&event).await;
}
None => {
info!("Event bus closed, alert engine stopping");
break;
}
}
}
Ok(())
}
async fn process_event(&self, event: &AuditEvent) {
for rule in &self.config.rules {
if self.matches_rule(rule, event) {
if self.should_deduplicate(&rule.name) {
trace!(rule = %rule.name, "Alert deduplicated");
continue;
}
let message = self.format_message(rule, event);
let alert = Alert::new(&rule.name, rule.severity, message);
debug!(
rule = %rule.name,
severity = ?rule.severity,
"Triggering alert"
);
self.add_alert(alert.clone());
if let Some(ref webhook) = self.config.webhook {
self.send_webhook(webhook, &alert).await;
}
}
}
}
fn matches_rule(&self, rule: &AlertRule, event: &AuditEvent) -> bool {
match &rule.condition {
AlertCondition::SecurityEvent { event_type } => {
if let AuditEvent::Security { event_type: et, .. } = event {
et.as_str() == event_type
} else {
false
}
}
AlertCondition::RateThreshold { .. } => {
false
}
AlertCondition::UpstreamHealth { .. } => {
false
}
AlertCondition::ErrorRate { .. } => {
false
}
}
}
fn should_deduplicate(&self, rule_name: &str) -> bool {
let dedup_window = Duration::from_secs(self.config.dedup_window_secs);
let mut cache = self.dedup_cache.write();
if let Some(last_trigger) = cache.get(rule_name)
&& last_trigger.elapsed() < dedup_window
{
return true;
}
cache.insert(rule_name.to_string(), Instant::now());
false
}
fn format_message(&self, rule: &AlertRule, event: &AuditEvent) -> String {
if let Some(ref template) = rule.message {
let mut msg = template.clone();
if let AuditEvent::Security {
event_type,
message,
client_ip,
qname,
..
} = event
{
msg = msg.replace("{event_type}", event_type.as_str());
msg = msg.replace("{message}", message);
if let Some(ip) = client_ip {
msg = msg.replace("{client_ip}", &ip.to_string());
}
if let Some(domain) = qname {
msg = msg.replace("{domain}", domain);
}
}
msg
} else {
match event {
AuditEvent::Security { message, .. } => message.clone(),
AuditEvent::Query(_) => "Query alert".to_string(),
}
}
}
fn add_alert(&self, alert: Alert) {
let mut alerts = self.alerts.write();
for existing in alerts.iter_mut().take(10) {
if existing.matches(&alert) && !existing.acknowledged {
existing.increment_occurrence();
debug!(
rule = %alert.rule_name,
occurrences = existing.occurrence_count,
"Alert aggregated"
);
return;
}
}
while alerts.len() >= self.config.max_alerts {
alerts.pop_back();
}
alerts.push_front(alert);
self.alert_counter.fetch_add(1, Ordering::Relaxed);
}
async fn send_webhook(&self, webhook: &WebhookConfig, alert: &Alert) {
let payload = serde_json::json!({
"alert": alert,
"source": "lazydns",
});
let mut request = self
.http_client
.post(&webhook.url)
.json(&payload)
.timeout(Duration::from_secs(webhook.timeout_secs));
if let Some(ref auth) = webhook.auth_header {
request = request.header("Authorization", auth);
}
let mut retries = 0;
loop {
match request.try_clone() {
Some(req) => match req.send().await {
Ok(response) => {
if response.status().is_success() {
debug!(url = %webhook.url, "Webhook sent successfully");
return;
} else {
warn!(
url = %webhook.url,
status = %response.status(),
"Webhook returned error status"
);
}
}
Err(e) => {
warn!(
url = %webhook.url,
error = %e,
retry = retries,
"Webhook request failed"
);
}
},
None => {
error!("Failed to clone webhook request");
return;
}
}
retries += 1;
if retries >= webhook.retries {
error!(
url = %webhook.url,
"Webhook failed after {} retries",
webhook.retries
);
return;
}
tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(retries))).await;
}
}
pub fn recent_alerts(&self, limit: usize) -> Vec<Alert> {
self.alerts.read().iter().take(limit).cloned().collect()
}
pub fn recent_alert_count(&self) -> usize {
self.alerts.read().len()
}
pub fn total_alerts(&self) -> u64 {
self.alert_counter.load(Ordering::Relaxed)
}
pub fn acknowledge(&self, alert_id: &str) -> bool {
let mut alerts = self.alerts.write();
for alert in alerts.iter_mut() {
if alert.id == alert_id {
alert.acknowledged = true;
return true;
}
}
false
}
pub fn acknowledge_all(&self) {
let mut alerts = self.alerts.write();
for alert in alerts.iter_mut() {
alert.acknowledged = true;
}
}
pub fn clear(&self) {
self.alerts.write().clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> AlertConfig {
AlertConfig {
enabled: true,
rules: vec![AlertRule {
name: "test_rule".to_string(),
condition: AlertCondition::SecurityEvent {
event_type: "rate_limit_exceeded".to_string(),
},
severity: AlertSeverity::Warning,
message: Some("Rate limit exceeded for {client_ip}".to_string()),
}],
dedup_window_secs: 60,
max_alerts: 100,
webhook: None,
}
}
#[test]
fn test_alert_creation() {
let alert = Alert::new("test", AlertSeverity::Warning, "Test message".to_string());
assert!(!alert.id.is_empty());
assert!(!alert.acknowledged);
}
#[test]
fn test_engine_creation() {
let config = sample_config();
let engine = AlertEngine::new(&config).unwrap();
assert_eq!(engine.recent_alert_count(), 0);
}
#[test]
fn test_add_alert() {
let config = sample_config();
let engine = AlertEngine::new(&config).unwrap();
let alert = Alert::new("test", AlertSeverity::Warning, "Test".to_string());
engine.add_alert(alert);
assert_eq!(engine.recent_alert_count(), 1);
}
#[test]
fn test_alert_aggregation() {
let config = sample_config();
let engine = AlertEngine::new(&config).unwrap();
let alert1 = Alert::new("test", AlertSeverity::Warning, "Same alert".to_string());
let alert2 = Alert::new("test", AlertSeverity::Warning, "Same alert".to_string());
let alert3 = Alert::new("test", AlertSeverity::Warning, "Same alert".to_string());
engine.add_alert(alert1);
engine.add_alert(alert2);
engine.add_alert(alert3);
assert_eq!(engine.recent_alert_count(), 1);
let alerts = engine.recent_alerts(1);
assert_eq!(alerts[0].occurrence_count, 3);
}
#[test]
fn test_alert_different_messages() {
let config = sample_config();
let engine = AlertEngine::new(&config).unwrap();
let alert1 = Alert::new("test", AlertSeverity::Warning, "Alert A".to_string());
let alert2 = Alert::new("test", AlertSeverity::Warning, "Alert B".to_string());
engine.add_alert(alert1);
engine.add_alert(alert2);
assert_eq!(engine.recent_alert_count(), 2);
}
#[test]
fn test_acknowledge() {
let config = sample_config();
let engine = AlertEngine::new(&config).unwrap();
let alert = Alert::new("test", AlertSeverity::Warning, "Test".to_string());
let id = alert.id.clone();
engine.add_alert(alert);
assert!(engine.acknowledge(&id));
let alerts = engine.recent_alerts(1);
assert!(alerts[0].acknowledged);
}
#[test]
fn test_deduplication() {
let mut config = sample_config();
config.dedup_window_secs = 60;
let engine = AlertEngine::new(&config).unwrap();
assert!(!engine.should_deduplicate("test_rule"));
assert!(engine.should_deduplicate("test_rule"));
assert!(!engine.should_deduplicate("other_rule"));
}
}