feat: Suricata integration stabilization and config unification

Fix a series of bugs in the Suricata daemon integration and unify
configuration so users interact only with config.toml.
This commit is contained in:
ParrotXray 2026-05-22 10:39:50 +00:00
parent f90a80efa0
commit 45fcd7a1bb
7 changed files with 320 additions and 45 deletions

View File

@ -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"
# 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",
# ]

View File

@ -51,12 +51,12 @@ impl EbpfServices {
pub async fn run(
self: Arc<Self>,
ml_engine: Arc<Engine>,
suricata_engine: Arc<SuricataEngine>,
suricata_engine: Option<Arc<SuricataEngine>>,
) -> 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);

View File

@ -33,7 +33,7 @@ pub struct AppServices {
pub fusion_engine: Arc<FusionEngine>,
pub ml_models: Arc<MLModels>,
pub ml_engine: Arc<Engine>,
pub suricata_engine: Arc<SuricataEngine>,
pub suricata_engine: Option<Arc<SuricataEngine>>,
shutdowns: SegQueue<oneshot::Sender<()>>,
}
@ -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),

View File

@ -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<FusionEngine>,
) -> Result<Arc<Self>, 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::<Vec<u8>>(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<i32, SuricataError> {
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();
}
}
}

View File

@ -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<u32>,
#[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<String>,
}
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<String>,
/// 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<u32>,
/// 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<SuricataConfig>,
}
fn default_fusion_mode() -> String {
@ -71,4 +73,4 @@ fn default_fusion_window_secs() -> u64 {
fn default_ae_threshold_method() -> String {
"95".to_string()
}
}

View File

@ -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,
}
}
}

View File

@ -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