1use std::sync::Arc;
28use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
29use std::sync::mpsc;
30use std::thread;
31use std::time::Duration;
32
33use crate::audit::error::{AuditError, Result};
34use crate::audit::model::AuditEvent;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum WebhookFormat {
39 Json,
41 Cef,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DeliveryGuarantee {
48 BestEffort,
50 AtLeastOnce,
53}
54
55#[derive(Debug, Clone)]
57pub struct WebhookConfig {
58 pub url: String,
60 pub format: WebhookFormat,
62 pub buffer_size: usize,
64 pub timeout: Duration,
66 pub max_retries: u32,
68 pub auth_header: Option<String>,
75 pub delivery_guarantee: DeliveryGuarantee,
77 pub overflow_dir: Option<std::path::PathBuf>,
80}
81
82impl Default for WebhookConfig {
83 fn default() -> Self {
84 Self {
85 url: String::new(),
86 format: WebhookFormat::Json,
87 buffer_size: 10_000,
88 timeout: Duration::from_secs(5),
89 max_retries: 3,
90 auth_header: None,
91 delivery_guarantee: DeliveryGuarantee::BestEffort,
92 overflow_dir: None,
93 }
94 }
95}
96
97fn format_cef(event: &AuditEvent) -> String {
101 let severity = match event.decision {
102 crate::audit::model::AuditDecision::Allow => 3,
103 crate::audit::model::AuditDecision::Deny => 7,
104 };
105 let extension = format!(
106 "src={} requestId={} principal={} resource={} decision={}",
107 event.source_ip.as_deref().unwrap_or("-"),
108 event.request_id.as_deref().unwrap_or("-"),
109 event.principal,
110 event.resource,
111 event.decision,
112 );
113 format!(
114 "CEF:0|Chronix|TSDB|1.0|{}|{}|{severity}|{extension}",
115 event.action, event.action,
116 )
117}
118
119fn expand_env_vars(s: &str) -> std::result::Result<String, String> {
126 expand_with(s, |name| std::env::var(name).ok())
127}
128
129fn expand_with<F>(s: &str, lookup: F) -> std::result::Result<String, String>
139where
140 F: Fn(&str) -> Option<String>,
141{
142 let mut result = String::with_capacity(s.len());
143 let mut chars = s.chars().peekable();
144 while let Some(c) = chars.next() {
145 if c == '$' && chars.peek() == Some(&'{') {
146 chars.next(); let mut var_name = String::new();
148 loop {
149 match chars.next() {
150 Some('}') => break,
151 Some(ch) => var_name.push(ch),
152 None => return Err("unclosed ${...} in env var reference".into()),
153 }
154 }
155 match lookup(&var_name) {
156 Some(val) => result.push_str(&val),
157 None => {
158 return Err(format!("environment variable '{var_name}' is not set"));
159 }
160 }
161 } else {
162 result.push(c);
163 }
164 }
165 Ok(result)
166}
167
168struct WebhookWorker {
170 config: WebhookConfig,
171 rx: mpsc::Receiver<AuditEvent>,
172 running: Arc<AtomicBool>,
173 delivered: Arc<AtomicU64>,
174 dropped: Arc<AtomicU64>,
175 overflow: Option<Arc<parking_lot::Mutex<OverflowJournal>>>,
176}
177
178struct OverflowJournal {
183 path: std::path::PathBuf,
184 pending: std::collections::VecDeque<AuditEvent>,
186 loaded: bool,
188}
189
190impl OverflowJournal {
191 fn new(dir: &std::path::Path) -> std::io::Result<Self> {
192 std::fs::create_dir_all(dir)?;
193 let path = dir.join("webhook_overflow.jsonl");
194 Ok(Self {
195 path,
196 pending: std::collections::VecDeque::new(),
197 loaded: false,
198 })
199 }
200
201 fn append(&mut self, event: &AuditEvent) -> std::io::Result<()> {
203 use std::io::Write;
204 let mut file = std::fs::OpenOptions::new()
205 .create(true)
206 .append(true)
207 .open(&self.path)?;
208 let json = serde_json::to_string(event)
209 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
210 writeln!(file, "{json}")?;
211 file.sync_all()?;
212 metrics::counter!("chronix_audit_webhook_overflow_total").increment(1);
213 Ok(())
214 }
215
216 fn load_pending(&mut self) -> std::io::Result<usize> {
219 if self.loaded {
220 return Ok(self.pending.len());
221 }
222 self.loaded = true;
223 if !self.path.exists() {
224 return Ok(0);
225 }
226 let file = std::fs::File::open(&self.path)?;
227 let reader = std::io::BufReader::new(file);
228 use std::io::BufRead;
229 for line in reader.lines() {
230 let line = line?;
231 if line.is_empty() {
232 continue;
233 }
234 match serde_json::from_str::<AuditEvent>(&line) {
235 Ok(event) => self.pending.push_back(event),
236 Err(e) => {
237 tracing::warn!(error = %e, "skipping corrupt overflow journal line");
238 }
239 }
240 }
241 let count = self.pending.len();
242 if count > 0 {
243 tracing::info!(count, "loaded pending events from overflow journal");
244 }
245 Ok(count)
246 }
247
248 fn pop_pending(&mut self) -> Option<AuditEvent> {
250 self.pending.pop_front()
251 }
252
253 fn truncate(&mut self) -> std::io::Result<()> {
255 if self.path.exists() {
256 std::fs::write(&self.path, b"")?;
257 }
258 Ok(())
259 }
260}
261
262impl WebhookWorker {
263 fn run(self) {
264 if let Some(ref overflow) = self.overflow {
266 let mut guard = overflow.lock();
267 if let Err(e) = guard.load_pending() {
268 tracing::error!(error = %e, "failed to load overflow journal");
269 }
270 let mut delivered_from_overflow = 0u64;
271 while let Some(event) = guard.pop_pending() {
272 if !self.running.load(Ordering::Relaxed) {
273 break;
274 }
275 drop(guard);
277 self.send_event(&event);
278 delivered_from_overflow += 1;
279 guard = overflow.lock();
280 }
281 if delivered_from_overflow > 0 {
282 tracing::info!(count = delivered_from_overflow, "drained overflow journal");
283 if let Err(e) = guard.truncate() {
284 tracing::error!(error = %e, "failed to truncate overflow journal");
285 }
286 }
287 }
288
289 while self.running.load(Ordering::Relaxed) {
290 match self.rx.recv_timeout(Duration::from_millis(100)) {
291 Ok(event) => self.send_event(&event),
292 Err(mpsc::RecvTimeoutError::Timeout) => continue,
293 Err(mpsc::RecvTimeoutError::Disconnected) => break,
294 }
295 }
296
297 for event in self.rx.try_iter() {
299 self.send_event(&event);
300 }
301 }
302
303 fn send_event(&self, event: &AuditEvent) {
304 let body = match self.config.format {
305 WebhookFormat::Json => match serde_json::to_string(event) {
306 Ok(json) => json,
307 Err(e) => {
308 tracing::warn!(error = %e, "Failed to serialize audit event");
309 self.dropped.fetch_add(1, Ordering::Relaxed);
310 return;
311 }
312 },
313 WebhookFormat::Cef => format_cef(event),
314 };
315
316 let content_type = match self.config.format {
317 WebhookFormat::Json => "application/json",
318 WebhookFormat::Cef => "text/plain",
319 };
320
321 for attempt in 0..=self.config.max_retries {
322 let req = ureq::post(&self.config.url)
323 .header("Content-Type", content_type)
324 .header("User-Agent", "chronix-audit/1.0");
325
326 let req = if let Some(ref auth) = self.config.auth_header {
327 req.header("Authorization", auth)
328 } else {
329 req
330 };
331
332 let req = req
333 .config()
334 .timeout_global(Some(self.config.timeout))
335 .build();
336
337 match req.send(&body) {
338 Ok(resp) => {
339 if resp.status().is_success() {
340 self.delivered.fetch_add(1, Ordering::Relaxed);
341 metrics::counter!("chronix_audit_webhook_delivered_total").increment(1);
342 return;
343 }
344 let status = resp.status().as_u16();
345 tracing::warn!(
346 status,
347 attempt,
348 url = %self.config.url,
349 "Webhook returned non-2xx status"
350 );
351 }
352 Err(e) => {
353 tracing::warn!(
354 error = %e,
355 attempt,
356 url = %self.config.url,
357 "Webhook delivery failed"
358 );
359 }
360 }
361
362 if attempt < self.config.max_retries {
363 let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
364 thread::sleep(backoff);
365 }
366 }
367
368 self.dropped.fetch_add(1, Ordering::Relaxed);
369 metrics::counter!("chronix_audit_webhook_dropped_total").increment(1);
370 tracing::warn!(
371 url = %self.config.url,
372 retries = self.config.max_retries,
373 "Webhook delivery failed after all retries — event dropped"
374 );
375 }
376}
377
378pub struct WebhookSink {
387 tx: mpsc::SyncSender<AuditEvent>,
388 running: Arc<AtomicBool>,
389 handle: parking_lot::Mutex<Option<thread::JoinHandle<()>>>,
390 delivered: Arc<AtomicU64>,
391 dropped: Arc<AtomicU64>,
392 overflow: Option<Arc<parking_lot::Mutex<OverflowJournal>>>,
393 delivery_guarantee: DeliveryGuarantee,
394}
395
396impl WebhookSink {
397 pub fn new(config: WebhookConfig) -> Self {
402 let mut config = config;
405 if let Some(ref raw) = config.auth_header {
406 match expand_env_vars(raw) {
407 Ok(expanded) => {
408 if expanded.chars().any(char::is_control) {
411 tracing::error!(
412 "webhook auth_header contains control characters after expansion — rejecting"
413 );
414 config.auth_header = None;
415 } else {
416 config.auth_header = Some(expanded);
417 }
418 }
419 Err(e) => {
420 tracing::error!(error = %e, "failed to expand env vars in webhook auth_header");
421 }
422 }
423 }
424
425 let (tx, rx) = mpsc::sync_channel(config.buffer_size);
426 let running = Arc::new(AtomicBool::new(true));
427 let delivered = Arc::new(AtomicU64::new(0));
428 let dropped = Arc::new(AtomicU64::new(0));
429 let delivery_guarantee = config.delivery_guarantee;
430
431 let overflow = if delivery_guarantee == DeliveryGuarantee::AtLeastOnce {
433 let dir = config
434 .overflow_dir
435 .clone()
436 .unwrap_or_else(|| std::env::temp_dir().join("chronix-audit-overflow"));
437 match OverflowJournal::new(&dir) {
438 Ok(journal) => Some(Arc::new(parking_lot::Mutex::new(journal))),
439 Err(e) => {
440 tracing::error!(error = %e, "failed to create overflow journal — falling back to BestEffort");
441 None
442 }
443 }
444 } else {
445 None
446 };
447
448 let worker = WebhookWorker {
449 config,
450 rx,
451 running: Arc::clone(&running),
452 delivered: Arc::clone(&delivered),
453 dropped: Arc::clone(&dropped),
454 overflow: overflow.clone(),
455 };
456
457 let handle = thread::Builder::new()
458 .name("chronix-audit-webhook".into())
459 .spawn(move || worker.run())
460 .expect("failed to spawn webhook worker thread");
461
462 Self {
463 tx,
464 running,
465 handle: parking_lot::Mutex::new(Some(handle)),
466 delivered,
467 dropped,
468 overflow,
469 delivery_guarantee,
470 }
471 }
472
473 #[must_use]
475 pub fn delivered_count(&self) -> u64 {
476 self.delivered.load(Ordering::Relaxed)
477 }
478
479 #[must_use]
481 pub fn dropped_count(&self) -> u64 {
482 self.dropped.load(Ordering::Relaxed)
483 }
484
485 pub fn shutdown(&self) {
487 self.running.store(false, Ordering::Relaxed);
488 if let Some(handle) = self.handle.lock().take() {
489 let _ = handle.join();
490 }
491 }
492}
493
494impl super::AuditSink for WebhookSink {
495 fn emit(&self, event: &AuditEvent) -> Result<()> {
496 match self.tx.try_send(event.clone()) {
497 Ok(()) => Ok(()),
498 Err(mpsc::TrySendError::Full(event)) => {
499 if self.delivery_guarantee == DeliveryGuarantee::AtLeastOnce
501 && let Some(ref overflow) = self.overflow
502 {
503 if let Err(e) = overflow.lock().append(&event) {
504 tracing::error!(error = %e, "failed to write to overflow journal — event dropped");
505 self.dropped.fetch_add(1, Ordering::Relaxed);
506 metrics::counter!("chronix_audit_webhook_dropped_total").increment(1);
507 }
508 return Ok(());
509 }
510 self.dropped.fetch_add(1, Ordering::Relaxed);
512 metrics::counter!("chronix_audit_webhook_channel_full_total").increment(1);
513 tracing::warn!("Webhook audit channel full — event dropped");
514 Ok(())
515 }
516 Err(mpsc::TrySendError::Disconnected(_)) => Err(AuditError::Internal(
517 "webhook worker thread has stopped".into(),
518 )),
519 }
520 }
521
522 fn flush(&self) -> Result<()> {
523 thread::sleep(Duration::from_millis(50));
525 Ok(())
526 }
527}
528
529impl Drop for WebhookSink {
530 fn drop(&mut self) {
531 self.shutdown();
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::audit::model::{AuditAction, AuditDecision};
539 use parking_lot::Mutex;
540 use std::io::{BufRead, BufReader, Read, Write as IoWrite};
541 use std::net::TcpListener;
542
543 fn test_event(principal: &str) -> AuditEvent {
544 AuditEvent {
545 id: 1,
546 timestamp: 1_000_000,
547 principal: principal.to_string(),
548 action: AuditAction::Write,
549 resource: "test_measurement".to_string(),
550 decision: AuditDecision::Allow,
551 source_ip: Some("127.0.0.1".to_string()),
552 request_id: Some("req-1".to_string()),
553 metadata: Default::default(),
554 prev_hash: None,
555 event_hash: None,
556 }
557 }
558
559 fn spawn_test_server() -> (String, Arc<Mutex<Vec<String>>>, TcpListener) {
561 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
562 let addr = listener.local_addr().unwrap();
563 let url = format!("http://127.0.0.1:{}", addr.port());
564 let bodies: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
565 (url, bodies, listener)
566 }
567
568 fn handle_requests(listener: &TcpListener, bodies: &Arc<Mutex<Vec<String>>>, count: usize) {
569 listener.set_nonblocking(false).unwrap();
570 for _ in 0..count {
571 let (mut stream, _) = listener.accept().unwrap();
572 stream
573 .set_read_timeout(Some(Duration::from_secs(2)))
574 .unwrap();
575 let mut reader = BufReader::new(stream.try_clone().unwrap());
576 let mut content_length: Option<usize> = None;
577 let mut is_chunked = false;
578
579 loop {
581 let mut line = String::new();
582 if reader.read_line(&mut line).is_err() {
583 break;
584 }
585 if line.trim().is_empty() {
586 break;
587 }
588 let lower = line.to_ascii_lowercase();
589 if let Some(val) = lower.strip_prefix("content-length:") {
590 content_length = val.trim().parse().ok();
591 }
592 if lower.starts_with("transfer-encoding:") && lower.contains("chunked") {
593 is_chunked = true;
594 }
595 }
596
597 let body_str = if is_chunked {
599 let mut body = Vec::new();
601 loop {
602 let mut size_line = String::new();
603 if reader.read_line(&mut size_line).is_err() {
604 break;
605 }
606 let chunk_size = usize::from_str_radix(size_line.trim(), 16).unwrap_or(0);
607 if chunk_size == 0 {
608 let mut _trail = String::new();
610 let _ = reader.read_line(&mut _trail);
611 break;
612 }
613 let mut chunk = vec![0u8; chunk_size];
614 let _ = reader.read_exact(&mut chunk);
615 body.extend_from_slice(&chunk);
616 let mut _crlf = String::new();
618 let _ = reader.read_line(&mut _crlf);
619 }
620 String::from_utf8_lossy(&body).to_string()
621 } else if let Some(len) = content_length {
622 let mut buf = vec![0u8; len];
623 let _ = reader.read_exact(&mut buf);
624 String::from_utf8_lossy(&buf).to_string()
625 } else {
626 String::new()
627 };
628
629 bodies.lock().push(body_str);
630
631 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
633 let _ = stream.write_all(response.as_bytes());
634 }
635 }
636
637 #[test]
638 fn webhook_delivers_json_event() {
639 let (url, bodies, listener) = spawn_test_server();
640
641 let config = WebhookConfig {
642 url,
643 format: WebhookFormat::Json,
644 buffer_size: 100,
645 timeout: Duration::from_secs(2),
646 max_retries: 0,
647 auth_header: None,
648 ..Default::default()
649 };
650
651 let sink = WebhookSink::new(config);
652 let event = test_event("alice");
653
654 let bodies_clone = Arc::clone(&bodies);
656 let server = thread::spawn(move || {
657 handle_requests(&listener, &bodies_clone, 1);
658 });
659
660 super::super::AuditSink::emit(&sink, &event).unwrap();
661 thread::sleep(Duration::from_millis(200));
662 sink.shutdown();
663 server.join().unwrap();
664
665 let received = bodies.lock();
666 assert_eq!(received.len(), 1);
667 let parsed: serde_json::Value = serde_json::from_str(&received[0]).unwrap();
668 assert_eq!(parsed["principal"], "alice");
669 }
670
671 #[test]
672 fn webhook_delivers_cef_event() {
673 let (url, bodies, listener) = spawn_test_server();
674
675 let config = WebhookConfig {
676 url,
677 format: WebhookFormat::Cef,
678 buffer_size: 100,
679 timeout: Duration::from_secs(2),
680 max_retries: 0,
681 auth_header: None,
682 ..Default::default()
683 };
684
685 let sink = WebhookSink::new(config);
686 let event = test_event("bob");
687
688 let bodies_clone = Arc::clone(&bodies);
689 let server = thread::spawn(move || {
690 handle_requests(&listener, &bodies_clone, 1);
691 });
692
693 super::super::AuditSink::emit(&sink, &event).unwrap();
694 thread::sleep(Duration::from_millis(200));
695 sink.shutdown();
696 server.join().unwrap();
697
698 let received = bodies.lock();
699 assert_eq!(received.len(), 1);
700 assert!(received[0].starts_with("CEF:0|Chronix|TSDB|"));
701 assert!(received[0].contains("principal=bob"));
702 }
703
704 #[test]
705 fn cef_format_deny_severity() {
706 let mut event = test_event("mallory");
707 event.decision = AuditDecision::Deny;
708 let cef = format_cef(&event);
709 assert!(cef.contains("|7|"));
711 }
712
713 #[test]
714 fn webhook_drops_when_channel_full() {
715 let config = WebhookConfig {
716 url: "http://127.0.0.1:1".to_string(), format: WebhookFormat::Json,
718 buffer_size: 2,
719 timeout: Duration::from_millis(50),
720 max_retries: 0,
721 auth_header: None,
722 ..Default::default()
723 };
724
725 let sink = WebhookSink::new(config);
726
727 for i in 0..10 {
729 let event = test_event(&format!("user_{i}"));
730 let _ = super::super::AuditSink::emit(&sink, &event);
731 }
732
733 assert!(sink.dropped_count() > 0);
735 sink.shutdown();
736 }
737
738 #[test]
739 fn webhook_shutdown_drains() {
740 let (url, bodies, listener) = spawn_test_server();
741
742 let config = WebhookConfig {
743 url,
744 format: WebhookFormat::Json,
745 buffer_size: 100,
746 timeout: Duration::from_secs(2),
747 max_retries: 0,
748 auth_header: None,
749 ..Default::default()
750 };
751
752 let sink = WebhookSink::new(config);
753
754 let bodies_clone = Arc::clone(&bodies);
755 let server = thread::spawn(move || {
756 handle_requests(&listener, &bodies_clone, 3);
757 });
758
759 for i in 0..3 {
760 let event = test_event(&format!("user_{i}"));
761 super::super::AuditSink::emit(&sink, &event).unwrap();
762 }
763
764 sink.shutdown();
766 server.join().unwrap();
767
768 let received = bodies.lock();
769 assert_eq!(received.len(), 3);
770 }
771
772 #[test]
773 fn overflow_journal_roundtrip() {
774 let dir = tempfile::tempdir().unwrap();
775 let mut journal = OverflowJournal::new(dir.path()).unwrap();
776 let event = test_event("overflow-user");
777
778 journal.append(&event).unwrap();
780 journal.append(&event).unwrap();
781
782 let count = journal.load_pending().unwrap();
784 assert_eq!(count, 2);
785 let e1 = journal.pop_pending().unwrap();
786 assert_eq!(e1.principal, "overflow-user");
787 let e2 = journal.pop_pending().unwrap();
788 assert_eq!(e2.principal, "overflow-user");
789 assert!(journal.pop_pending().is_none());
790
791 journal.truncate().unwrap();
793 let mut journal2 = OverflowJournal::new(dir.path()).unwrap();
794 let count2 = journal2.load_pending().unwrap();
795 assert_eq!(count2, 0);
796 }
797
798 #[test]
799 fn at_least_once_overflows_to_disk() {
800 let dir = tempfile::tempdir().unwrap();
802 let (url, bodies, listener) = spawn_test_server();
803
804 let config = WebhookConfig {
805 url,
806 buffer_size: 1,
807 delivery_guarantee: DeliveryGuarantee::AtLeastOnce,
808 overflow_dir: Some(dir.path().to_path_buf()),
809 ..Default::default()
810 };
811
812 let sink = WebhookSink::new(config);
813
814 for i in 0..3 {
817 let event = test_event(&format!("user_{i}"));
818 super::super::AuditSink::emit(&sink, &event).unwrap();
819 }
820
821 let overflow_path = dir.path().join("webhook_overflow.jsonl");
823 assert!(overflow_path.exists());
824 let content = std::fs::read_to_string(&overflow_path).unwrap();
825 assert!(!content.is_empty(), "overflow journal should have content");
826
827 let bodies_clone = Arc::clone(&bodies);
829 let server = thread::spawn(move || {
830 handle_requests(&listener, &bodies_clone, 1);
832 });
833
834 sink.shutdown();
836 let _ = server.join();
837
838 let received = bodies.lock();
840 assert!(!received.is_empty());
841 }
842
843 #[test]
844 fn expand_env_vars_replaces_variable() {
845 let result = super::expand_with("Bearer ${TOKEN}", |n| {
846 (n == "TOKEN").then(|| "secret-value".to_string())
847 })
848 .unwrap();
849 assert_eq!(result, "Bearer secret-value");
850 }
851
852 #[test]
853 fn expand_env_vars_passthrough_literal() {
854 let result = super::expand_with("Bearer my-literal-token", |_| None).unwrap();
855 assert_eq!(result, "Bearer my-literal-token");
856 }
857
858 #[test]
859 fn expand_env_vars_missing_var_errors() {
860 let result = super::expand_with("${NOPE}", |_| None);
861 assert!(result.is_err());
862 }
863
864 #[test]
865 fn expand_env_vars_unclosed_brace_errors() {
866 let result = super::expand_with("${UNCLOSED", |_| None);
867 assert!(result.is_err());
868 }
869
870 #[test]
871 fn expand_env_vars_multiple_vars() {
872 let result = super::expand_with("${A} ${B}", |n| match n {
873 "A" => Some("hello".to_string()),
874 "B" => Some("world".to_string()),
875 _ => None,
876 })
877 .unwrap();
878 assert_eq!(result, "hello world");
879 }
880
881 #[test]
882 fn expand_env_vars_reads_the_real_environment() {
883 assert!(super::expand_env_vars("${CHRONIX_NONEXISTENT_VAR_XYZ}").is_err());
886 }
887}