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