diff --git a/config.toml b/config.toml index 01884f0..44186ae 100644 --- a/config.toml +++ b/config.toml @@ -40,5 +40,17 @@ ml_cpu = 7 ae_threshold_method = "94" -# Suricata daemon mode. Remove or comment out to disable the rule engine. -suricata_name = "suricata.yaml" \ No newline at end of file +# Suricata rule engine. Remove this entire section to disable. +[Config.suricata] +home_net = "140.130.34.0/24" +worker_cpu_set = [4, 6] +management_cpu = 0 +af_packet_threads = "auto" +af_packet_ring_size = 2048 +af_packet_block_size = 131072 + +# Suppress known false positives — paste Suricata suppress lines directly. +# suppress = [ +# "suppress gen_id 1, sig_id 2001234", +# "suppress gen_id 1, sig_id 2001234, track by_src, ip 192.168.1.0/24", +# ] \ No newline at end of file diff --git a/mantis/src/core/ebpf/mod.rs b/mantis/src/core/ebpf/mod.rs index 15e9573..f3bccaa 100644 --- a/mantis/src/core/ebpf/mod.rs +++ b/mantis/src/core/ebpf/mod.rs @@ -51,12 +51,12 @@ impl EbpfServices { pub async fn run( self: Arc, ml_engine: Arc, - suricata_engine: Arc, + suricata_engine: Option>, ) -> Result<(), Error> { let xsk_manager = self.xsk_manager.clone(); let statistics = self.statistics.clone(); - xsk_manager.run(Some(ml_engine), Some(suricata_engine), &self.shutdowns)?; + xsk_manager.run(Some(ml_engine), suricata_engine, &self.shutdowns)?; let statistics_shutdown = statistics.run().await; self.shutdowns.push(statistics_shutdown); diff --git a/mantis/src/core/infrastructure/mod.rs b/mantis/src/core/infrastructure/mod.rs index b7ab6cd..eea9b0a 100644 --- a/mantis/src/core/infrastructure/mod.rs +++ b/mantis/src/core/infrastructure/mod.rs @@ -33,7 +33,7 @@ pub struct AppServices { pub fusion_engine: Arc, pub ml_models: Arc, pub ml_engine: Arc, - pub suricata_engine: Arc, + pub suricata_engine: Option>, shutdowns: SegQueue>, } @@ -77,14 +77,13 @@ impl AppServices { app_config.ml_cpu, )); - let rule_path = PathBuf::from(env!("RULE_PATH")).join(&app_config.suricata_name); - let eve_socket = PathBuf::from(env!("RULE_EVE_PATH")); - - let suricata_engine = SuricataEngine::start( - rule_path, - eve_socket, - fusion_engine.clone(), - )?; + let suricata_engine = if let Some(ref sc) = app_config.suricata { + let rule_path = PathBuf::from(env!("RULE_PATH")); + let eve_socket = PathBuf::from(env!("RULE_EVE_PATH")); + Some(SuricataEngine::start(sc, &rule_path, &eve_socket, fusion_engine.clone())?) + } else { + None + }; Ok(Self { health: Arc::new(health), diff --git a/mantis/src/detection/suricata/engine.rs b/mantis/src/detection/suricata/engine.rs index b158b8b..e704022 100644 --- a/mantis/src/detection/suricata/engine.rs +++ b/mantis/src/detection/suricata/engine.rs @@ -1,20 +1,24 @@ +use std::io::{BufRead, BufReader}; use std::mem; -use std::path::PathBuf; -use std::process::{Child, Command}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::thread; +use std::time::Duration; use crossbeam::channel::{bounded, Sender}; use macros::log; use crate::detection::fusion::FusionEngine; +use crate::model::config::SuricataConfig; use crate::model::error::suricata::SuricataError; use crate::model::log::suricata::SuricataLog; use super::output; const MIRROR_IFACE: &str = "mantis-mirror"; -const MIRROR_PEER: &str = "mantis-mirror-peer"; +const MIRROR_PEER: &str = "mantis-peer"; +const SURICATA_LOG: &str = "/tmp/suricata.log"; const CHANNEL_CAP: usize = 4096; @@ -25,8 +29,9 @@ pub struct SuricataEngine { impl SuricataEngine { pub fn start( - yaml_path: PathBuf, - eve_socket: PathBuf, + config: &SuricataConfig, + rule_path: &Path, + eve_socket: &Path, fusion: Arc, ) -> Result, SuricataError> { Self::setup_veth()?; @@ -37,14 +42,90 @@ impl SuricataEngine { output::start_eve_reader(path, fusion); } - let yaml = yaml_path.to_str() - .ok_or_else(|| SuricataError::InvalidPath { path: yaml_path.display().to_string() })?; + let rule_path_str = rule_path.to_str() + .ok_or_else(|| SuricataError::InvalidPath { path: rule_path.display().to_string() })?; + let eve_socket_str = eve_socket.to_str() + .ok_or_else(|| SuricataError::InvalidPath { path: eve_socket.display().to_string() })?; + + let suppress = Self::generate_suppress(&config.suppress); + let suppress_fd = Self::yaml_to_memfd(&suppress)?; + let suppress_path = format!("/proc/self/fd/{}", suppress_fd); + + let yaml = Self::generate_yaml(config, rule_path_str, eve_socket_str, &suppress_path); + let config_fd = Self::yaml_to_memfd(&yaml)?; + let config_path = format!("/proc/self/fd/{}", config_fd); + + let _ = std::fs::remove_file(SURICATA_LOG); let child = Command::new("suricata") - .args(["-c", yaml, "-i", MIRROR_PEER, "--runmode=workers"]) + .args(["-c", &config_path, "-i", MIRROR_PEER, "--runmode=workers"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .map_err(|e| SuricataError::ProcessSpawnFailed { reason: e.to_string() })?; + // Child has inherited both fds; close our copies. + unsafe { libc::close(suppress_fd) }; + unsafe { libc::close(config_fd) }; + + thread::Builder::new() + .name("suricata-log".into()) + .spawn(|| { + let log_path = Path::new(SURICATA_LOG); + for _ in 0..100 { + if log_path.exists() { break; } + thread::sleep(Duration::from_millis(100)); + } + let file = match std::fs::File::open(log_path) { + Ok(f) => f, + Err(_) => return, + }; + + let inotify_fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) }; + if inotify_fd >= 0 { + let path_cstr = std::ffi::CString::new(SURICATA_LOG).unwrap(); + unsafe { libc::inotify_add_watch(inotify_fd, path_cstr.as_ptr(), libc::IN_MODIFY) }; + } + + let mut reader = BufReader::new(file); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + if inotify_fd >= 0 { + // Block until Suricata writes more data. + let mut buf = [0u8; 64]; + let n = unsafe { + libc::read(inotify_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) + }; + if n <= 0 { break; } + } else { + thread::sleep(Duration::from_millis(50)); + } + } + Ok(_) => { + let trimmed = line.trim_end().to_string(); + if trimmed.is_empty() { continue; } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("error") || lower.starts_with("critical") { + log!(SuricataLog::ProcessError { line: trimmed }); + } else if lower.starts_with("warn") || lower.starts_with("notice") { + log!(SuricataLog::ProcessWarn { line: trimmed }); + } else { + log!(SuricataLog::ProcessInfo { line: trimmed }); + } + } + Err(_) => break, + } + } + + if inotify_fd >= 0 { + unsafe { libc::close(inotify_fd) }; + } + }) + .map_err(|e| SuricataError::ProcessSpawnFailed { reason: e.to_string() })?; + let (tx, rx) = bounded::>(CHANNEL_CAP); thread::Builder::new() @@ -99,6 +180,162 @@ impl SuricataEngine { } } + fn generate_suppress(entries: &[String]) -> String { + entries.join("\n") + "\n" + } + + fn generate_yaml(config: &SuricataConfig, rule_path: &str, eve_socket: &str, suppress_path: &str) -> String { + let threading = match (config.worker_cpu_set, config.management_cpu) { + (None, None) => r#"threading: + set-cpu-affinity: no"#.to_string(), + (worker, mgmt) => { + let mgmt_cpu = mgmt.unwrap_or(0); + let worker_block = match worker { + Some([start, end]) => format!(r#" - worker-cpu-set: + cpu: [ "{start}-{end}" ] + mode: "balanced""#), + None => String::new(), + }; + format!(r#"threading: + set-cpu-affinity: yes + cpu-affinity: + - management-cpu-set: + cpu: [ {mgmt_cpu} ] +{worker_block}"#) + } + }; + + format!(r#"%YAML 1.1 +--- +vars: + address-groups: + HOME_NET: "[{home_net}]" + EXTERNAL_NET: "!$HOME_NET" + HTTP_SERVERS: "$HOME_NET" + SMTP_SERVERS: "$HOME_NET" + SQL_SERVERS: "$HOME_NET" + DNS_SERVERS: "$HOME_NET" + TELNET_SERVERS: "$HOME_NET" + AIM_SERVERS: "$EXTERNAL_NET" + DC_SERVERS: "$HOME_NET" + DNP3_SERVER: "$HOME_NET" + DNP3_CLIENT: "$HOME_NET" + MODBUS_CLIENT: "$HOME_NET" + MODBUS_SERVER: "$HOME_NET" + ENIP_CLIENT: "$HOME_NET" + ENIP_SERVER: "$HOME_NET" + port-groups: + HTTP_PORTS: "80" + SHELLCODE_PORTS: "!80" + ORACLE_PORTS: 1521 + SSH_PORTS: 22 + DNP3_PORTS: 20000 + MODBUS_PORTS: 502 + FILE_DATA_PORTS: "[$HTTP_PORTS,110,143]" + FTP_PORTS: 21 + VXLAN_PORTS: 4789 + TEREDO_PORTS: 3544 + +default-rule-path: {rule_path} + +rule-files: + - "*.rules" + +threshold-file: {suppress_path} + +logging: + default-log-level: notice + outputs: + - console: + enabled: no + - file: + enabled: yes + level: info + filename: {log_path} + +outputs: + - eve-log: + enabled: yes + filetype: unix_stream + filename: {eve_socket} + types: + - alert: + payload: no + packet: no + metadata: no + http-body: no + tagged-packets: no + - fast: + enabled: no + - stats: + enabled: no + +app-layer: + protocols: + tls: + enabled: yes + http: + enabled: yes + dns: + enabled: yes + smtp: + enabled: yes + ssh: + enabled: yes + +af-packet: + - interface: {iface} + threads: {threads} + use-mmap: yes + tpacket-v3: yes + ring-size: {ring_size} + block-size: {block_size} + +{threading} + +legacy: + uricontent: enabled + +host-mode: sniffer-only +"#, + home_net = config.home_net, + rule_path = rule_path, + eve_socket = eve_socket, + suppress_path = suppress_path, + log_path = SURICATA_LOG, + iface = MIRROR_PEER, + threads = config.af_packet_threads, + ring_size = config.af_packet_ring_size, + block_size = config.af_packet_block_size, + threading = threading, + ) + } + + fn yaml_to_memfd(yaml: &str) -> Result { + let fd = unsafe { + libc::memfd_create(b"suricata-config\0".as_ptr() as *const libc::c_char, 0) + }; + if fd < 0 { + let errno = unsafe { *libc::__errno_location() }; + return Err(SuricataError::MirrorSetupFailed { + reason: format!("memfd_create: errno {errno}"), + }); + } + let bytes = yaml.as_bytes(); + let written = unsafe { + libc::write(fd, bytes.as_ptr() as *const libc::c_void, bytes.len()) + }; + if written < 0 { + unsafe { libc::close(fd) }; + let errno = unsafe { *libc::__errno_location() }; + return Err(SuricataError::MirrorSetupFailed { + reason: format!("memfd write: errno {errno}"), + }); + } + unsafe { libc::lseek(fd, 0, libc::SEEK_SET) }; + Ok(fd) + } + fn setup_veth() -> Result<(), SuricataError> { let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output(); @@ -153,4 +390,4 @@ impl Drop for SuricataEngine { } let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output(); } -} +} \ No newline at end of file diff --git a/mantis/src/model/config.rs b/mantis/src/model/config.rs index def318e..fc489bf 100644 --- a/mantis/src/model/config.rs +++ b/mantis/src/model/config.rs @@ -6,6 +6,25 @@ pub struct ConfigTable { pub config: Config, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SuricataConfig { + pub home_net: String, + pub worker_cpu_set: Option<[u32; 2]>, + pub management_cpu: Option, + #[serde(default = "default_af_threads")] + pub af_packet_threads: String, + #[serde(default = "default_af_ring_size")] + pub af_packet_ring_size: u32, + #[serde(default = "default_af_block_size")] + pub af_packet_block_size: u32, + #[serde(default)] + pub suppress: Vec, +} + +fn default_af_threads() -> String { "auto".to_string() } +fn default_af_ring_size() -> u32 { 2048 } +fn default_af_block_size() -> u32 { 131072 } + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Config { pub ingress_ifname: String, @@ -30,35 +49,18 @@ pub struct Config { pub flow_timeout_us: u64, pub traffic_logging_mode: bool, pub traffic_log_csv_path: String, - /// Optional path to an NSS key log file (SSLKEYLOGFILE) for TLS decryption. - /// Only useful in [external]->Mantis->[internal] deployments where the - /// internal server can be configured to write TLS session keys. pub tls_keylog_path: Option, - /// CPU core range [start, end] (inclusive) for XSK packet threads. - /// Threads are distributed round-robin: core = start + (queue_id % (end - start + 1)). - /// Example: [0, 3] with 8 queues spreads 16 threads across cores 0-3 (4 threads each). - /// If absent, no affinity is set. pub xsk_cpu_set: Option<[u32; 2]>, - /// CPU core pinned to the ML inference spawn_blocking thread. - /// If absent, defaults to the last available core. pub ml_cpu: Option, - /// Alert fusion mode: "or" (alert when either source fires) or "and" (require both). - /// Defaults to "or" when absent. #[serde(default = "default_fusion_mode")] pub fusion_mode: String, - /// Seconds within which both ML and Rule must fire to be correlated as Fusion. - /// Only used in "or" (corroboration window) and "and" modes. Defaults to 10. #[serde(default = "default_fusion_window_secs")] pub fusion_window_secs: u64, - /// Key into ae_thresholds in inference_config.json that selects the active - /// anomaly detection threshold. Valid values: "90".."99", "mean+2std", - /// "mean+1std", "Q3+1.5IQR", "Q3+3.0IQR". Defaults to "95" when absent. #[serde(default = "default_ae_threshold_method")] pub ae_threshold_method: String, - /// Path to suricata.yaml. If absent, Suricata rule engine is disabled. - pub suricata_name: String, - + /// Suricata rule engine config. If absent, the rule engine is disabled. + pub suricata: Option, } fn default_fusion_mode() -> String { @@ -71,4 +73,4 @@ fn default_fusion_window_secs() -> u64 { fn default_ae_threshold_method() -> String { "95".to_string() -} +} \ No newline at end of file diff --git a/mantis/src/model/log/suricata.rs b/mantis/src/model/log/suricata.rs index 1f468b6..ebbd808 100644 --- a/mantis/src/model/log/suricata.rs +++ b/mantis/src/model/log/suricata.rs @@ -23,5 +23,14 @@ loggable! { #[error("Suricata mirror channel full — packet dropped")] ChannelFull => tracing::Level::WARN, + + #[error("[suricata] {line}")] + ProcessInfo { line: String } => tracing::Level::INFO, + + #[error("[suricata] {line}")] + ProcessWarn { line: String } => tracing::Level::WARN, + + #[error("[suricata] {line}")] + ProcessError { line: String } => tracing::Level::ERROR, } -} +} \ No newline at end of file diff --git a/mantis/static/rules/suricata.yaml b/mantis/static/rules/suricata.yaml index d1717ee..475cd66 100644 --- a/mantis/static/rules/suricata.yaml +++ b/mantis/static/rules/suricata.yaml @@ -37,6 +37,16 @@ rule-files: threshold-file: ./mantis/static/rules/suppress.conf +logging: + default-log-level: notice + outputs: + - console: + enabled: no + - file: + enabled: yes + level: info + filename: /tmp/suricata.log + outputs: - eve-log: enabled: yes @@ -68,14 +78,20 @@ app-layer: enabled: yes af-packet: - - interface: mantis-mirror-peer + - interface: mantis-peer use-mmap: yes tpacket-v3: yes ring-size: 2048 block-size: 131072 threading: - set-cpu-affinity: no + set-cpu-affinity: yes + cpu-affinity: + - management-cpu-set: + cpu: [ 0 ] + - worker-cpu-set: + cpu: [ "4-6" ] + mode: "balanced" legacy: uricontent: enabled \ No newline at end of file