From 0fa0a61ab73a803cabead120b1328e48ee907cb4 Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Sun, 12 Oct 2025 09:11:08 +0200 Subject: [PATCH] feat: add JSON logging for SIEM integration (#9) (#44) Add --json-log flag to output connection events as JSON lines. Logs new_connection and connection_closed events with IPs, ports, protocol, DPI info, and traffic statistics for SIEM tools. --- Cargo.lock | 2 ++ Cargo.toml | 2 ++ src/app.rs | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++-- src/cli.rs | 7 ++++ src/main.rs | 5 +++ 5 files changed, 113 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9d5c0d..2ac0666 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1785,6 +1785,8 @@ dependencies = [ "procfs", "ratatui", "ring", + "serde", + "serde_json", "simple-logging", "simplelog", "windows", diff --git a/Cargo.toml b/Cargo.toml index bfe2118..bbf37f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ chrono = "0.4" ratatui = { version = "0.29", features = ["all-widgets"] } ring = "0.17" aes = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [target.'cfg(target_os = "linux")'.dependencies] procfs = "0.18" diff --git a/src/app.rs b/src/app.rs index 89bada6..71ccd5a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,9 @@ use anyhow::Result; use crossbeam::channel::{self, Receiver, Sender}; use dashmap::DashMap; use log::{debug, error, info, warn}; +use serde_json::json; +use std::fs::OpenOptions; +use std::io::Write; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; @@ -27,6 +30,73 @@ use std::sync::{LazyLock, Mutex}; static QUIC_CONNECTION_MAPPING: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// Helper function to log connection events as JSON +fn log_connection_event(json_log_path: &str, event_type: &str, conn: &Connection, duration_secs: Option) { + // Build JSON object based on event type + let mut event = json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "event": event_type, + "protocol": conn.protocol.to_string(), + "source_ip": conn.local_addr.ip().to_string(), + "source_port": conn.local_addr.port(), + "destination_ip": conn.remote_addr.ip().to_string(), + "destination_port": conn.remote_addr.port(), + }); + + // Add DPI information if available + if let Some(dpi) = &conn.dpi_info { + event["dpi_protocol"] = json!(dpi.application.to_string()); + + // Extract domain/hostname from DPI info + match &dpi.application { + ApplicationProtocol::Dns(info) => { + if let Some(domain) = &info.query_name { + event["dpi_domain"] = json!(domain); + } + } + ApplicationProtocol::Http(info) => { + if let Some(host) = &info.host { + event["dpi_domain"] = json!(host); + } + } + ApplicationProtocol::Https(info) => { + if let Some(tls_info) = &info.tls_info + && let Some(sni) = &tls_info.sni + { + event["dpi_domain"] = json!(sni); + } + } + ApplicationProtocol::Quic(info) => { + if let Some(tls_info) = &info.tls_info + && let Some(sni) = &tls_info.sni + { + event["dpi_domain"] = json!(sni); + } + } + _ => {} + } + } + + // Add connection statistics for closed events + if event_type == "connection_closed" { + event["bytes_sent"] = json!(conn.bytes_sent); + event["bytes_received"] = json!(conn.bytes_received); + if let Some(duration) = duration_secs { + event["duration_secs"] = json!(duration); + } + } + + // Write to file + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(json_log_path) + && let Ok(json_str) = serde_json::to_string(&event) + { + let _ = writeln!(file, "{}", json_str); + } +} + /// Application configuration #[derive(Debug, Clone)] pub struct Config { @@ -40,6 +110,8 @@ pub struct Config { pub enable_dpi: bool, /// BPF filter for packet capture pub bpf_filter: Option, + /// JSON log file path for connection events + pub json_log_file: Option, } impl Default for Config { @@ -50,6 +122,7 @@ impl Default for Config { refresh_interval: 1000, enable_dpi: true, bpf_filter: None, // No filter by default to see all packets + json_log_file: None, } } } @@ -321,6 +394,7 @@ impl App { let should_stop = Arc::clone(&self.should_stop); let stats = Arc::clone(&self.stats); let linktype_storage = Arc::clone(&self.linktype); + let json_log_path = self.config.json_log_file.clone(); let parser_config = ParserConfig { enable_dpi: self.config.enable_dpi, ..Default::default() @@ -361,7 +435,7 @@ impl App { let mut parsed_count = 0; for packet_data in &batch { if let Some(parsed) = parser.parse_packet(packet_data) { - update_connection(&connections, parsed, &stats); + update_connection(&connections, parsed, &stats, &json_log_path); parsed_count += 1; } } @@ -679,6 +753,7 @@ impl App { /// Start cleanup thread to remove old connections fn start_cleanup_thread(&self, connections: Arc>) -> Result<()> { let should_stop = Arc::clone(&self.should_stop); + let json_log_path = self.config.json_log_file.clone(); thread::spawn(move || { info!("Cleanup thread started"); @@ -703,6 +778,18 @@ impl App { if !should_keep { removed += 1; removed_keys.push(key.clone()); + + // Calculate connection duration + let duration_secs = now + .duration_since(conn.created_at) + .map(|d| d.as_secs()) + .ok(); + + // Log connection_closed event if JSON logging is enabled + if let Some(log_path) = &json_log_path { + log_connection_event(log_path, "connection_closed", conn, duration_secs); + } + // Log cleanup reason for debugging let conn_timeout = conn.get_timeout(); let idle_time = now.duration_since(conn.last_activity).unwrap_or_default(); @@ -829,6 +916,7 @@ fn update_connection( connections: &DashMap, parsed: ParsedPacket, _stats: &AppStats, + json_log_path: &Option, ) { let mut key = parsed.connection_key.clone(); let now = SystemTime::now(); @@ -863,7 +951,14 @@ fn update_connection( }) .or_insert_with(|| { debug!("New connection detected: {}", key); - create_connection_from_packet(&parsed, now) + let conn = create_connection_from_packet(&parsed, now); + + // Log new connection event if JSON logging is enabled + if let Some(log_path) = json_log_path { + log_connection_event(log_path, "new_connection", &conn, None); + } + + conn }); } diff --git a/src/cli.rs b/src/cli.rs index 1604be8..76ba240 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,4 +49,11 @@ pub fn build_cli() -> Command { .help("Set the log level (if not provided, no logging will be enabled)") .required(false), ) + .arg( + Arg::new("json-log") + .long("json-log") + .value_name("FILE") + .help("Enable JSON logging of connection events to specified file") + .required(false), + ) } diff --git a/src/main.rs b/src/main.rs index 18940af..340408b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,11 @@ fn main() -> Result<()> { info!("Deep packet inspection disabled"); } + if let Some(json_log_path) = matches.get_one::("json-log") { + config.json_log_file = Some(json_log_path.to_string()); + info!("JSON logging enabled: {}", json_log_path); + } + // Set up terminal let backend = CrosstermBackend::new(io::stdout()); let mut terminal = ui::setup_terminal(backend)?;