poc4 wit rust

This commit is contained in:
Mateusz Gruszczyński
2026-08-16 15:34:53 +02:00
parent e5d344622e
commit 40474cdc59
29 changed files with 2692 additions and 159 deletions
+21 -2
View File
@@ -1,4 +1,16 @@
ARG BASE_IMAGE=debian:trixie-slim ARG BASE_IMAGE=debian:trixie-slim
FROM ${BASE_IMAGE} AS rust-builder
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends cargo rustc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build/tzsp-receiver
COPY rust/tzsp-receiver/Cargo.toml ./Cargo.toml
COPY rust/tzsp-receiver/src ./src
RUN cargo test --release \
&& cargo build --release
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -42,12 +54,13 @@ RUN printf '%s\n' \
WORKDIR /opt/ids WORKDIR /opt/ids
COPY --from=rust-builder /build/tzsp-receiver/target/release/mikrosuricata-tzsp /usr/local/bin/mikrosuricata-tzsp
COPY app /opt/ids/app COPY app /opt/ids/app
COPY scripts /opt/ids/scripts COPY scripts /opt/ids/scripts
COPY suricata /opt/ids/suricata COPY suricata /opt/ids/suricata
RUN chmod +x /opt/ids/scripts/*.sh \ RUN chmod +x /opt/ids/scripts/*.sh \
&& mkdir -p /data /var/log/suricata /var/lib/suricata/rules /run/suricata /tmp/suricata-build-test \ && mkdir -p /data /var/log/suricata /var/lib/suricata/rules /run/suricata /run/mikrosuricata /tmp/suricata-build-test \
&& suricata -T \ && suricata -T \
-c /etc/suricata/suricata.yaml \ -c /etc/suricata/suricata.yaml \
--include /opt/ids/suricata/ids-output.yaml \ --include /opt/ids/suricata/ids-output.yaml \
@@ -63,6 +76,12 @@ RUN chmod +x /opt/ids/scripts/*.sh \
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
TZSP_BIND=0.0.0.0 \ TZSP_BIND=0.0.0.0 \
TZSP_PORT=37008 \ TZSP_PORT=37008 \
TZSP_RECEIVER_BIN=/usr/local/bin/mikrosuricata-tzsp \
TZSP_TELEMETRY_SOCKET=/run/mikrosuricata/tzsp-telemetry.sock \
TZSP_RCVBUF_BYTES=33554432 \
TZSP_BATCH_SIZE=256 \
TZSP_QUEUE_MB=64 \
TZSP_DATAGRAM_BYTES=12288 \
TAP_NAME=suritap0 \ TAP_NAME=suritap0 \
TAP_MTU=9000 \ TAP_MTU=9000 \
WEB_BIND=0.0.0.0 \ WEB_BIND=0.0.0.0 \
@@ -70,7 +89,7 @@ ENV PYTHONUNBUFFERED=1 \
DB_PATH=/data/ids.db \ DB_PATH=/data/ids.db \
EVE_PATH=/data/logs/suricata/eve.json \ EVE_PATH=/data/logs/suricata/eve.json \
SURICATA_LOG_MAX_MB=512 \ SURICATA_LOG_MAX_MB=512 \
FORENSIC_PCAP_MODE=blocks \ FORENSIC_PCAP_MODE=alerts \
FORENSIC_PCAP_WINDOW_SECONDS=60 \ FORENSIC_PCAP_WINDOW_SECONDS=60 \
FORENSIC_PCAP_MEMORY_MB=64 \ FORENSIC_PCAP_MEMORY_MB=64 \
FORENSIC_PCAP_MAX_FILES=32 \ FORENSIC_PCAP_MAX_FILES=32 \
+124 -41
View File
@@ -1,6 +1,37 @@
# MikroSuricata # MikroSuricata
Project version: `0.9.1` Project version: `0.11.2`
## What changed in 0.11.2
- Replaced full-traffic RouterOS Packet Sniffer capture with a **hybrid TZSP path**: routed IPv4 uses `/ip firewall mangle action=sniff-tzsp`, while Packet Sniffer streams only `filter-mac-protocol=!ip` traffic such as ARP, IPv6, LLDP and PPPoE.
- The capture deploy no longer assumes a production bridge, VLAN or interface name. `TZSP_L2_INTERFACE=` defaults to RouterOS `filter-interface=all`; set it only when a site intentionally wants to constrain the low-volume non-IPv4 stream.
- Packet Sniffer TZSP destination now tries the current `streaming-server=IP:PORT` syntax first and automatically falls back to legacy split `streaming-server`/`streaming-port` syntax.
- The project-owned IPv4 mangle rule is identified by the exact comment `MikroSuricata TZSP IPv4`; deployment replaces only that rule and leaves unrelated mangle rules untouched. RouterOS `sniff-tzsp` continues processing subsequent mangle rules after cloning the packet.
- Added `scripts/configure-routeros-tzsp-hybrid.sh` to migrate an existing installation without rebuilding the container, including `DRY_RUN=true` preview mode.
- Replaced the VLAN-specific RouterOS template with `routeros/02-tzsp-hybrid.rsc` and added detailed capture coverage, limitations and migration documentation in `docs/routeros-tzsp-capture.md`.
## What changed in 0.11.0
- Reworked the Rust TZSP data-plane for RouterOS containers that cannot raise kernel `rmem_max`: UDP receive and TZSP decode/TAP injection now run on **separate threads**.
- The RX thread does only blocking `recvmmsg(MSG_WAITFORONE)` into reusable preallocated batches. It never waits for TAP, Suricata, Python, Redis or the UI.
- Added a bounded **userspace packet queue** (`TZSP_QUEUE_MB`, default 64 MiB). With the default 256-packet batch and 12 KiB slots this provides roughly half a second of burst absorption at 1 Gbit/s even when the kernel socket is limited to ~416 KiB.
- If downstream processing falls behind after the userspace queue fills, the RX thread keeps draining UDP into a scratch batch and records explicit `queue_dropped_datagrams` instead of silently turning all loss into kernel UDP drops.
- Increased the default receive batch from 64 to **256** and made the per-datagram buffer configurable (`TZSP_DATAGRAM_BYTES`, default 12288; up to 65535). Oversized/truncated datagrams are counted explicitly.
- Added live capture diagnostics: TZSP ingress rate, inspected rate, capture efficiency, userspace queue fill, kernel drops/s, queue drops/s, truncated packets and RX/TAP worker health.
- The overview now labels the main value as **Inspected throughput** and separately shows TZSP ingress, so a RouterOS mirror limitation can be distinguished from a Suricata/TAP bottleneck immediately.
- Prometheus exports the new Rust queue/capture-health gauges and drop counters.
## What changed in 0.10.0
- Moved the complete TZSP packet hot-path out of Python into a dedicated **Rust data-plane process**. Rust now owns batched UDP receive (`recvmmsg`), TZSP decoding, direction accounting and TAP injection; packet bytes no longer cross into Python.
- Added configurable UDP receive buffering and batched receive. In 0.11.0 the hot path was further split into dedicated RX/TAP threads with a userspace queue.
- Added 1 Hz Unix-socket telemetry from Rust to the Python control plane. The dashboard now receives live throughput independently from retained Redis history, so **Throughput now** is current instead of reusing a stale historical payload.
- Added receiver observability to the UI/health/Prometheus path: actual receive-buffer size, Rust PID, telemetry age and kernel UDP drop counter.
- Fixed the overview refresh path so throughput history is refreshed during periodic UI refreshes, not only at initial load/range changes.
- The old `FORENSIC_PCAP_MODE=blocks` pre-event ring required Python to process every frame. With the isolated Rust data-plane it is treated as a legacy setting and safely falls back to Suricata alert PCAP capture. The new default is `alerts`.
- Docker now compiles and tests the Rust receiver in a dedicated build stage and copies only the stripped receiver binary into the runtime image. The existing RouterOS amd64/arm64/armv7 image workflow is preserved.
## What changed in 0.9.1 ## What changed in 0.9.1
@@ -70,7 +101,7 @@ A lightweight IDS stack designed to run as a **single container on MikroTik Rout
- Security analytics add anomalies, NXDOMAIN counts, encrypted/cleartext session counts, local/remote endpoint inventory, passive IP/MAC asset observations, top signatures/severities and JA4/JA3/HASSH fingerprint inventory. - Security analytics add anomalies, NXDOMAIN counts, encrypted/cleartext session counts, local/remote endpoint inventory, passive IP/MAC asset observations, top signatures/severities and JA4/JA3/HASSH fingerprint inventory.
- The built-in local ruleset adds NXDOMAIN/DNS-rate signals, outbound SMB/SMTP/FTP policy detections, exposed database-service probes and an internal administrative/lateral-movement burst detector. - The built-in local ruleset adds NXDOMAIN/DNS-rate signals, outbound SMB/SMTP/FTP policy detections, exposed database-service probes and an internal administrative/lateral-movement burst detector.
RouterOS mirrors selected traffic with TZSP, the container decodes the frames into a TAP interface, Suricata analyzes them, and the Python service stores EVE alerts in SQLite and exposes a small web dashboard. RouterOS mirrors traffic with a hybrid TZSP path: routed IPv4 is cloned by an IPv4 mangle `sniff-tzsp` rule, while Packet Sniffer handles non-IPv4 Ethernet traffic. A dedicated Rust process decodes and injects the frames into TAP, Suricata analyzes them, and the Python control plane handles EVE/SQLite/NDR/API/UI without sitting in the packet hot-path.
## NDR / MikroTik-specific options ## NDR / MikroTik-specific options
@@ -93,28 +124,39 @@ All NDR state, IOC data, Redis persistence, Suricata logs/rules and forensic PCA
## Architecture ## Architecture
```text ```text
VLAN / RouterOS traffic RouterOS routed IPv4 ----> mangle sniff-tzsp ---------+
|
v
RouterOS Packet Sniffer
|
| TZSP UDP/37008 | TZSP UDP/37008
RouterOS non-IPv4 -----> Packet Sniffer (!ip) --------+
v v
single RouterOS container single RouterOS container
Debian slim Debian slim
+ Python TZSP receiver + Rust TZSP data-plane (recvmmsg -> TZSP -> TAP)
+ TAP suritap0 + TAP suritap0
+ Suricata IDS + Suricata IDS
+ EVE JSON watcher + Python control plane / EVE JSON watcher
+ SQLite alerts / assets / NDR incidents / sessions + SQLite alerts / assets / NDR incidents / sessions
+ MikroSuricata behavior + correlation engine + MikroSuricata behavior + correlation engine
+ local IOC datasets (IP/domain/SHA256/JA3/JA4/HASSH) + local IOC datasets (IP/domain/SHA256/JA3/JA4/HASSH)
+ Redis/RAM bounded traffic history + Redis traffic history
+ WebSocket live stream + Rust -> Python 1 Hz Unix telemetry
+ WebSocket live throughput / event stream
+ Web UI :8080 + Web UI :8080
+ optional RouterOS REST blocking + optional RouterOS REST blocking
``` ```
The capture path is intentionally independent from the UI/control plane:
```text
RouterOS TZSP UDP
|
v
Rust receiver -- recvmmsg() --> TZSP decode --> TAP write --> Suricata
|
+-- 1 Hz counters only --> Unix datagram --> Python --> Redis / WebSocket / Prometheus
```
If Redis, the browser or an analytics request is slow, it cannot block UDP receive/TAP injection.
The RouterOS deployment workflow is: The RouterOS deployment workflow is:
```text ```text
@@ -342,7 +384,7 @@ Every feed update is transactional at the merged-rules level: the existing `suri
```dotenv ```dotenv
UPDATE_RULES_ON_START=false UPDATE_RULES_ON_START=false
RULE_UPDATE_INTERVAL_HOURS=24 RULE_UPDATE_INTERVAL_HOURS=24
FORENSIC_PCAP_MODE=blocks FORENSIC_PCAP_MODE=alerts
FORENSIC_PCAP_WINDOW_SECONDS=60 FORENSIC_PCAP_WINDOW_SECONDS=60
FORENSIC_PCAP_MEMORY_MB=64 FORENSIC_PCAP_MEMORY_MB=64
FORENSIC_PCAP_MAX_FILES=32 FORENSIC_PCAP_MAX_FILES=32
@@ -584,15 +626,15 @@ Then deploy by giving the **RouterOS-side TAR path** directly:
./scripts/deploy-routeros.sh routeros-suricata-tzsp-arm64.tar ./scripts/deploy-routeros.sh routeros-suricata-tzsp-arm64.tar
``` ```
The deployer no longer builds, detects image architecture, renames, or re-uploads the image. For project version `0.9.0` it creates: The deployer no longer builds, detects image architecture, renames, or re-uploads the image. For project version `0.11.2` it creates:
```text ```text
name=suricata_0.9.0 name=suricata_0.11.2
file=routeros-suricata-tzsp-arm64.tar file=routeros-suricata-tzsp-arm64.tar
root-dir=/containers/suricata_0.9.0/root root-dir=/containers/suricata_0.11.2/root
``` ```
The remaining deployment work is unchanged: bridge/VETH/NAT, environment, persistent mounts, optional RouterOS REST user/firewall integration, TZSP sniffer configuration, image extraction wait, container start, and final status. Existing containers are not removed. Re-running deployment for the same version stops with `Container suricata_0.9.0 already exists`. The remaining deployment work is unchanged: private container bridge/VETH/NAT, environment, persistent mounts, optional RouterOS REST user/firewall integration, hybrid TZSP capture configuration, image extraction wait, container start, and final status. Existing containers are not removed. Re-running deployment for the same version stops with `Container suricata_0.11.2 already exists`.
For SSH key authentication set: For SSH key authentication set:
@@ -644,7 +686,7 @@ RouterOS templates are located in `routeros/`:
```text ```text
01-container-network.rsc 01-container-network.rsc
02-sniffer-vlan100.rsc 02-tzsp-hybrid.rsc
03-rest-and-firewall.rsc 03-rest-and-firewall.rsc
04-container-import-amd64.rsc 04-container-import-amd64.rsc
04-container-import-arm64.rsc 04-container-import-arm64.rsc
@@ -686,26 +728,50 @@ Replacing or restarting the application container does not remove any of these f
## TZSP capture ## TZSP capture
The default deployment configuration can configure RouterOS Packet Sniffer to stream VLAN traffic to the container address on UDP port `37008`. The default RouterOS deployment uses a hybrid capture model:
Relevant settings in `deploy-routeros.env`: ```text
routed IPv4 -> /ip firewall mangle action=sniff-tzsp
```dotenv non-IPv4 L2 -> /tool sniffer filter-mac-protocol=!ip
CONFIGURE_SNIFFER=true \_______________________________/
START_SNIFFER=true TZSP UDP/37008
VLAN_ID=100
``` ```
If the router already uses Packet Sniffer for another purpose, disable automatic sniffer configuration: Relevant `deploy-routeros.env` settings:
```dotenv ```dotenv
CONFIGURE_SNIFFER=false TZSP_PORT=37008
START_SNIFFER=false CONFIGURE_TZSP_CAPTURE=true
CONFIGURE_IPV4_MANGLE=true
CONFIGURE_L2_SNIFFER=true
START_L2_SNIFFER=true
TZSP_L2_INTERFACE=
TZSP_L2_MAC_PROTOCOL=!ip
``` ```
Then configure the capture manually. No production bridge/VLAN name is assumed. Empty `TZSP_L2_INTERFACE` maps to RouterOS `filter-interface=all`. To intentionally constrain non-IPv4 capture, set an exact interface name, for example `TZSP_L2_INTERFACE=bridge-core`.
Hardware-offloaded bridge traffic may require additional verification on the specific RouterOS device because some switched traffic can bypass software capture paths. The IPv4 rule is added to `chain=forward` and is owned by the comment `MikroSuricata TZSP IPv4`. Only that project-owned rule is replaced; unrelated mangle rules are left intact. `sniff-tzsp` clones the packet and then continues to subsequent mangle rules.
The Packet Sniffer complement uses `filter-mac-protocol=!ip`. RouterOS defines `ip` as IPv4 EtherType `0x0800` and `ipv6` separately as `0x86DD`, so this stream includes ARP, IPv6, LLDP, PPPoE and other non-IPv4 Ethernet protocols. `filter-stream=yes` prevents the sniffer's own TZSP stream from being recaptured.
Current RouterOS builds may expose the target as `streaming-server=IP:PORT`; older builds/documentation may expose `streaming-server` plus `streaming-port`. Deployment tries the combined form first and falls back automatically.
To migrate an already running installation without changing the container image:
```bash
./scripts/configure-routeros-tzsp-hybrid.sh
```
Preview without modifying RouterOS:
```bash
DRY_RUN=true ./scripts/configure-routeros-tzsp-hybrid.sh
```
Packet Sniffer is global RouterOS state. If another administrative workflow owns `/tool/sniffer`, use `CONFIGURE_L2_SNIFFER=false` and manage the low-volume L2 complement separately. Hardware-offloaded bridge-only traffic may also be invisible to Packet Sniffer on some devices.
Full coverage details, caveats, migration behavior and verification commands are in [`docs/routeros-tzsp-capture.md`](docs/routeros-tzsp-capture.md).
--- ---
@@ -721,15 +787,29 @@ The pipeline is:
```text ```text
TZSP datagram TZSP datagram
-> Python decoder -> Rust batched UDP receiver
-> Rust TZSP decoder
-> Ethernet frame -> Ethernet frame
-> TAP suritap0 -> TAP suritap0
-> Suricata -> Suricata
-> eve.json -> eve.json
-> Python EVE watcher -> Python control plane / EVE watcher
-> SQLite / Web UI -> SQLite / Web UI
``` ```
The Rust receiver emits only a compact 1 Hz telemetry sample to Python. That sample drives the live throughput card and is persisted asynchronously for historical throughput charts.
Useful receive-path tuning:
```dotenv
TZSP_RCVBUF_BYTES=33554432
TZSP_BATCH_SIZE=256
TZSP_QUEUE_MB=64
TZSP_DATAGRAM_BYTES=12288
```
On RouterOS the kernel may cap the actual socket buffer around a few hundred KiB regardless of `TZSP_RCVBUF_BYTES`. The 0.11.0 receiver therefore does not rely on sysctl: a dedicated RX thread drains UDP into the preallocated userspace queue. The UI exposes both kernel drops and userspace queue drops, plus TZSP ingress versus traffic actually injected into Suricata.
If the RouterOS container cannot create the TAP interface, the application will fail early with an error related to `/dev/net/tun`, `TUNSETIFF`, or permissions. This is the main platform-specific capability to validate on the target router. If the RouterOS container cannot create the TAP interface, the application will fail early with an error related to `/dev/net/tun`, `TUNSETIFF`, or permissions. This is the main platform-specific capability to validate on the target router.
--- ---
@@ -778,11 +858,14 @@ Full RouterOS/container application entry point
app/dev_web.py app/dev_web.py
Local web-only entry point Local web-only entry point
app/tzsp.py rust/tzsp-receiver/
TZSP receiver and decoder Production Rust TZSP receiver: batched UDP receive, decoder, counters and TAP injection
app/tap.py app/tzsp_rust.py
TAP interface handling Rust process supervision plus 1 Hz telemetry bridge; no packet bytes enter Python
app/tzsp.py / app/tap.py
Legacy decoder/TAP helpers retained for focused tests; not used by the production packet path
app/eve.py app/eve.py
Suricata EVE JSON watcher Suricata EVE JSON watcher
@@ -862,7 +945,7 @@ The default configuration is observation-oriented:
AUTO_BLOCK=false AUTO_BLOCK=false
ALERT_MAX_SEVERITY=2 ALERT_MAX_SEVERITY=2
UPDATE_RULES_ON_START=false UPDATE_RULES_ON_START=false
FORENSIC_PCAP_MODE=blocks FORENSIC_PCAP_MODE=alerts
ROUTEROS_PASSWORD=CHANGE_ME ROUTEROS_PASSWORD=CHANGE_ME
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD= ADMIN_PASSWORD=
@@ -875,7 +958,7 @@ Keep automatic firewall actions disabled until the capture path and alert qualit
## Image-only upgrade on an already configured RouterOS ## Image-only upgrade on an already configured RouterOS
After the first deployment, when `veth-ids`, bridge/NAT, TZSP sniffer, `IDS_ENV` and `IDS_MOUNTS` already exist, do not run the full deploy just to change the image. After the first deployment, when the VETH/private bridge/NAT, hybrid TZSP capture, `IDS_ENV` and `IDS_MOUNTS` already exist, do not run the full deploy just to change the image.
1. Upload the ready TAR only: 1. Upload the ready TAR only:
@@ -889,18 +972,18 @@ After the first deployment, when `veth-ids`, bridge/NAT, TZSP sniffer, `IDS_ENV`
./scripts/upgrade-routeros-container.sh routeros-suricata-tzsp-arm64.tar ./scripts/upgrade-routeros-container.sh routeros-suricata-tzsp-arm64.tar
``` ```
For version `0.9.0` the second command creates: For version `0.11.2` the second command creates:
```text ```text
name=suricata_0.9.0 name=suricata_0.11.2
file=routeros-suricata-tzsp-arm64.tar file=routeros-suricata-tzsp-arm64.tar
root-dir=/containers/suricata_0.9.0/root root-dir=/containers/suricata_0.11.2/root
interface=veth-ids interface=veth-ids
envlist=IDS_ENV envlist=IDS_ENV
mountlists=IDS_MOUNTS mountlists=IDS_MOUNTS
``` ```
The upgrade helper does not modify the bridge, IP addresses, NAT, veth, TZSP/sniffer, firewall, REST user or envlist definitions. It stops older `suricata_*` containers, normalizes `IDS_MOUNTS` to the single `<disk>/containers/suricata-data -> /data` mapping, creates the new versioned container, waits for image extraction and starts it. Older containers are kept stopped for rollback. The upgrade helper does not modify the bridge, IP addresses, NAT, VETH, hybrid TZSP capture, firewall, REST user or envlist definitions. It stops older `suricata_*` containers, normalizes `IDS_MOUNTS` to the single `<disk>/containers/suricata-data -> /data` mapping, creates the new versioned container, waits for image extraction and starts it. Older containers are kept stopped for rollback.
All new mutable state is written below `/data`, so subsequent image upgrades need only that one persistent mount. All new mutable state is written below `/data`, so subsequent image upgrades need only that one persistent mount.
+1 -1
View File
@@ -1 +1 @@
0.9.8 0.11.2
+18 -1
View File
@@ -37,6 +37,12 @@ class Config:
tzsp_port: int tzsp_port: int
tap_name: str tap_name: str
tap_mtu: int tap_mtu: int
tzsp_receiver_bin: str
tzsp_telemetry_socket: str
tzsp_rcvbuf_bytes: int
tzsp_batch_size: int
tzsp_queue_mb: int
tzsp_datagram_bytes: int
suricata_config: str suricata_config: str
suricata_output_config: str suricata_output_config: str
suricata_home_net: str suricata_home_net: str
@@ -111,6 +117,12 @@ class Config:
tzsp_port=_int("TZSP_PORT", 37008), tzsp_port=_int("TZSP_PORT", 37008),
tap_name=os.getenv("TAP_NAME", "suritap0"), tap_name=os.getenv("TAP_NAME", "suritap0"),
tap_mtu=_int("TAP_MTU", 9000), tap_mtu=_int("TAP_MTU", 9000),
tzsp_receiver_bin=os.getenv("TZSP_RECEIVER_BIN", "/usr/local/bin/mikrosuricata-tzsp"),
tzsp_telemetry_socket=os.getenv("TZSP_TELEMETRY_SOCKET", "/run/mikrosuricata/tzsp-telemetry.sock"),
tzsp_rcvbuf_bytes=max(1024 * 1024, _int("TZSP_RCVBUF_BYTES", 32 * 1024 * 1024)),
tzsp_batch_size=max(1, min(1024, _int("TZSP_BATCH_SIZE", 256))),
tzsp_queue_mb=max(8, min(512, _int("TZSP_QUEUE_MB", 64))),
tzsp_datagram_bytes=max(2048, min(65535, _int("TZSP_DATAGRAM_BYTES", 12288))),
suricata_config=os.getenv("SURICATA_CONFIG", "/etc/suricata/suricata.yaml"), suricata_config=os.getenv("SURICATA_CONFIG", "/etc/suricata/suricata.yaml"),
suricata_output_config=os.getenv( suricata_output_config=os.getenv(
"SURICATA_OUTPUT_CONFIG", "/opt/ids/suricata/ids-output.yaml" "SURICATA_OUTPUT_CONFIG", "/opt/ids/suricata/ids-output.yaml"
@@ -150,7 +162,7 @@ class Config:
db_path=os.getenv("DB_PATH", "/data/ids.db"), db_path=os.getenv("DB_PATH", "/data/ids.db"),
eve_path=os.getenv("EVE_PATH", "/data/logs/suricata/eve.json"), eve_path=os.getenv("EVE_PATH", "/data/logs/suricata/eve.json"),
suricata_log_max_mb=_int("SURICATA_LOG_MAX_MB", 512), suricata_log_max_mb=_int("SURICATA_LOG_MAX_MB", 512),
forensic_pcap_mode=_choice("FORENSIC_PCAP_MODE", "blocks", {"blocks", "alerts", "all", "off"}), forensic_pcap_mode=_choice("FORENSIC_PCAP_MODE", "alerts", {"blocks", "alerts", "all", "off"}),
forensic_pcap_window_seconds=max(5, _int("FORENSIC_PCAP_WINDOW_SECONDS", 60)), forensic_pcap_window_seconds=max(5, _int("FORENSIC_PCAP_WINDOW_SECONDS", 60)),
forensic_pcap_memory_mb=max(1, _int("FORENSIC_PCAP_MEMORY_MB", 64)), forensic_pcap_memory_mb=max(1, _int("FORENSIC_PCAP_MEMORY_MB", 64)),
forensic_pcap_max_files=max(1, _int("FORENSIC_PCAP_MAX_FILES", 32)), forensic_pcap_max_files=max(1, _int("FORENSIC_PCAP_MAX_FILES", 32)),
@@ -213,6 +225,11 @@ class Config:
"tzsp_port": self.tzsp_port, "tzsp_port": self.tzsp_port,
"tap_name": self.tap_name, "tap_name": self.tap_name,
"tap_mtu": self.tap_mtu, "tap_mtu": self.tap_mtu,
"tzsp_receiver_engine": "rust",
"tzsp_rcvbuf_bytes": self.tzsp_rcvbuf_bytes,
"tzsp_batch_size": self.tzsp_batch_size,
"tzsp_queue_mb": self.tzsp_queue_mb,
"tzsp_datagram_bytes": self.tzsp_datagram_bytes,
"suricata_home_net": self.suricata_home_net, "suricata_home_net": self.suricata_home_net,
"suricata_extra_rules_glob": self.suricata_extra_rules_glob, "suricata_extra_rules_glob": self.suricata_extra_rules_glob,
"web_port": self.web_port, "web_port": self.web_port,
+64 -30
View File
@@ -17,7 +17,6 @@ from .analytics_cache import AnalyticsSnapshotCache
from .backup import BackupManager from .backup import BackupManager
from .config import Config from .config import Config
from .eve import EVEWatcher from .eve import EVEWatcher
from .flow_tracker import FlowTracker
from .forensics import ForensicPcapRing from .forensics import ForensicPcapRing
from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer
from .maintenance import clear_suricata_logs, storage_info from .maintenance import clear_suricata_logs, storage_info
@@ -30,9 +29,8 @@ from .routeros import RouterOSClient
from .rules import RuleManager from .rules import RuleManager
from .state import RuntimeStats from .state import RuntimeStats
from .store import AlertStore from .store import AlertStore
from .tap import TapDevice
from .tuning import AlertTuner from .tuning import AlertTuner
from .tzsp import TZSPReceiver from .tzsp_rust import RustTZSPReceiver
from .webui import WebServer from .webui import WebServer
@@ -50,6 +48,18 @@ def _ensure_suricata_state(cfg: Config) -> None:
def _prepare_suricata_output_config(cfg: Config) -> Config: def _prepare_suricata_output_config(cfg: Config) -> Config:
# The legacy "blocks" mode depended on Python seeing every TZSP frame to
# maintain a pre-event RAM ring. The Rust data-plane intentionally removes
# Python from that packet path. Keep forensic evidence without reintroducing
# the bottleneck by falling back to Suricata's alert-associated PCAP output.
if cfg.forensic_pcap_mode == "blocks":
print(
"[forensics] FORENSIC_PCAP_MODE=blocks is not used with the Rust data-plane; "
"using Suricata alert PCAP capture instead",
flush=True,
)
cfg = replace(cfg, forensic_pcap_mode="alerts")
source = Path(cfg.suricata_output_config) source = Path(cfg.suricata_output_config)
text = source.read_text(encoding="utf-8") text = source.read_text(encoding="utf-8")
match = re.search(r"(?ms)^ - pcap-log:\n.*?(?=^ - |\Z)", text) match = re.search(r"(?ms)^ - pcap-log:\n.*?(?=^ - |\Z)", text)
@@ -120,16 +130,24 @@ def main() -> int:
if purged: if purged:
print(f"[db] purged {purged} old alerts", flush=True) print(f"[db] purged {purged} old alerts", flush=True)
tap = TapDevice(cfg.tap_name, cfg.tap_mtu) receiver = RustTZSPReceiver(
binary=cfg.tzsp_receiver_bin,
telemetry_socket=cfg.tzsp_telemetry_socket,
stats=stats,
stop_event=stop_event,
)
try: try:
tap.open() receiver.start()
if not receiver.wait_ready(timeout=8.0):
raise RuntimeError("Rust receiver did not report ready state")
except Exception as exc: except Exception as exc:
print(f"[fatal] cannot create TAP {cfg.tap_name}: {exc}", file=sys.stderr, flush=True) print(f"[fatal] cannot start Rust TZSP data-plane: {exc}", file=sys.stderr, flush=True)
print("[fatal] container needs /dev/net/tun and NET_ADMIN capability", file=sys.stderr, flush=True) print("[fatal] container needs /dev/net/tun and NET_ADMIN capability", file=sys.stderr, flush=True)
receiver.close()
store.close() store.close()
return 2 return 2
print(f"[tap] {cfg.tap_name} is up, mtu={cfg.tap_mtu}", flush=True) print(f"[tap] {cfg.tap_name} is owned by Rust TZSP receiver, mtu={cfg.tap_mtu}", flush=True)
log_dir = os.path.dirname(cfg.eve_path) or "/var/log/suricata" log_dir = os.path.dirname(cfg.eve_path) or "/var/log/suricata"
suricata_cmd = [ suricata_cmd = [
@@ -156,7 +174,7 @@ def main() -> int:
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
tap.close() receiver.close()
store.close() store.close()
return test.returncode or 3 return test.returncode or 3
@@ -235,15 +253,9 @@ def main() -> int:
) )
live_pipeline = LiveEventPipeline(event_bus, traffic_history) live_pipeline = LiveEventPipeline(event_bus, traffic_history)
normalizer = TrafficNormalizer(cfg.monitored_networks) normalizer = TrafficNormalizer(cfg.monitored_networks)
flow_tracker = FlowTracker(normalizer, live_pipeline, update_interval_seconds=cfg.live_flow_update_seconds) # The Rust data-plane emits one compact rate sample per second. Persisting it
# is asynchronous and never sits in the packet receive/injection path.
def observe_frame(frame: bytes) -> None: receiver.set_throughput_sink(live_pipeline.publish_throughput)
forensic_pcap.observe(frame)
flow_tracker.observe(frame)
receiver = TZSPReceiver(
cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event, frame_observer=observe_frame
)
watcher = EVEWatcher( watcher = EVEWatcher(
cfg.eve_path, cfg.eve_path,
store, store,
@@ -268,22 +280,35 @@ def main() -> int:
def health() -> dict: def health() -> dict:
suricata_up = suricata.poll() is None suricata_up = suricata.poll() is None
tzsp_up = receiver.is_alive() and receiver.sock is not None tzsp_up = receiver.is_alive()
tap_up = tap.fd is not None and os.path.exists(f"/sys/class/net/{cfg.tap_name}") tap_up = os.path.exists(f"/sys/class/net/{cfg.tap_name}")
eve_up = watcher.is_alive() eve_up = watcher.is_alive()
routeros_status = "configured" if routeros.configured else "disabled" routeros_status = "configured" if routeros.configured else "disabled"
db = store.database_info() db = store.database_info()
storage = storage_info(cfg.db_path, cfg.eve_path) storage = storage_info(cfg.db_path, cfg.eve_path)
rules = rule_manager.status() rules = rule_manager.status()
runtime = stats.snapshot() runtime = stats.snapshot()
receiver_status = receiver.status()
redis_status = redis_supervisor.status() redis_status = redis_supervisor.status()
suri_stats = runtime.get("suricata") or {} suri_stats = runtime.get("suricata") or {}
kernel_packets = int(suri_stats.get("capture.kernel_packets", 0) or 0) kernel_packets = int(suri_stats.get("capture.kernel_packets", 0) or 0)
kernel_drops = int(suri_stats.get("capture.kernel_drops", 0) or 0) kernel_drops = int(suri_stats.get("capture.kernel_drops", 0) or 0)
alert_overflow = int(suri_stats.get("detect.alert_queue_overflow", 0) or 0) alert_overflow = int(suri_stats.get("detect.alert_queue_overflow", 0) or 0)
inject_errors = int(runtime.get("inject_errors", 0) or 0) inject_errors = int(runtime.get("inject_errors", 0) or 0)
tzsp_udp_drops = int(receiver_status.get("kernel_udp_drops", 0) or 0)
tzsp_udp_drops_now = int(receiver_status.get("kernel_udp_drops_interval", 0) or 0)
tzsp_queue_drops = int(receiver_status.get("queue_dropped_datagrams", 0) or 0)
tzsp_queue_drops_now = int(receiver_status.get("queue_drops_interval", 0) or 0)
tzsp_truncated_now = int(receiver_status.get("truncated_interval", 0) or 0)
drop_pct = round((kernel_drops / kernel_packets) * 100.0, 3) if kernel_packets else 0.0 drop_pct = round((kernel_drops / kernel_packets) * 100.0, 3) if kernel_packets else 0.0
sensor_degraded = (kernel_packets >= 1000 and drop_pct >= 1.0) or alert_overflow > 0 or inject_errors > 0 sensor_degraded = (
(kernel_packets >= 1000 and drop_pct >= 1.0)
or alert_overflow > 0
or inject_errors > 0
or tzsp_udp_drops_now > 0
or tzsp_queue_drops_now > 0
or tzsp_truncated_now > 0
)
core_up = suricata_up and tzsp_up and tap_up and eve_up and db["ok"] core_up = suricata_up and tzsp_up and tap_up and eve_up and db["ok"]
routeros_required_ok = (not cfg.auto_block) or routeros.configured routeros_required_ok = (not cfg.auto_block) or routeros.configured
operational = core_up and routeros_required_ok operational = core_up and routeros_required_ok
@@ -313,7 +338,13 @@ def main() -> int:
"tzsp": { "tzsp": {
"name": "TZSP receiver", "name": "TZSP receiver",
"status": "up" if tzsp_up else "down", "status": "up" if tzsp_up else "down",
"details": f"Listening on UDP {cfg.tzsp_bind}:{cfg.tzsp_port}", "details": (
f"Rust PID {receiver.pid or ''} · UDP {cfg.tzsp_bind}:{cfg.tzsp_port} · "
f"socket={int(receiver_status.get('rcvbuf_bytes', 0) or 0)} B · "
f"queue={int(receiver_status.get('queue_depth_batches', 0) or 0)}/"
f"{int(receiver_status.get('queue_capacity_batches', 0) or 0)} batches · "
f"kernel drops={tzsp_udp_drops} · queue drops={tzsp_queue_drops}"
),
}, },
"tap": { "tap": {
"name": "TAP interface", "name": "TAP interface",
@@ -328,7 +359,7 @@ def main() -> int:
"sensor_quality": { "sensor_quality": {
"name": "Sensor quality / packet loss", "name": "Sensor quality / packet loss",
"status": "degraded" if sensor_degraded else "up", "status": "degraded" if sensor_degraded else "up",
"details": f"capture packets={kernel_packets}; kernel drops={kernel_drops} ({drop_pct}%); alert queue overflow={alert_overflow}; inject errors={inject_errors}", "details": f"Suricata packets={kernel_packets}; Suricata kernel drops={kernel_drops} ({drop_pct}%); TZSP UDP drops={tzsp_udp_drops}; alert queue overflow={alert_overflow}; TAP inject errors={inject_errors}",
}, },
"eve": { "eve": {
"name": "EVE JSON watcher", "name": "EVE JSON watcher",
@@ -346,9 +377,9 @@ def main() -> int:
"details": f"{storage['path']}; {storage['used_percent']}% used", "details": f"{storage['path']}; {storage['used_percent']}% used",
}, },
"live_flows": { "live_flows": {
"name": "Immediate TZSP sessions", "name": "Live session stream",
"status": "up" if tzsp_up else "down", "status": "up" if eve_up else "down",
"details": f"{flow_tracker.status()['active_flows']} active; non-persistent {flow_tracker.status()['update_interval_seconds']:g}s updates", "details": "Suricata EVE sessions; packet capture is isolated in the Rust data-plane",
}, },
"traffic_history": { "traffic_history": {
"name": "Live traffic history", "name": "Live traffic history",
@@ -433,8 +464,8 @@ def main() -> int:
return { return {
"components": { "components": {
"web": True, "web": True,
"tzsp": receiver.is_alive() and receiver.sock is not None, "tzsp": receiver.is_alive(),
"tap": tap.fd is not None, "tap": os.path.exists(f"/sys/class/net/{cfg.tap_name}"),
"suricata": suricata.poll() is None, "suricata": suricata.poll() is None,
"eve": watcher.is_alive(), "eve": watcher.is_alive(),
}, },
@@ -454,7 +485,7 @@ def main() -> int:
mode="full", mode="full",
started_at=started_at, started_at=started_at,
state_provider=metrics_state, state_provider=metrics_state,
flow_tracker=flow_tracker, flow_tracker=receiver,
event_bus=event_bus, event_bus=event_bus,
live_pipeline=live_pipeline, live_pipeline=live_pipeline,
ndr_analyzer=ndr_analyzer, ndr_analyzer=ndr_analyzer,
@@ -477,6 +508,7 @@ def main() -> int:
ndr_analyzer=ndr_analyzer, ndr_analyzer=ndr_analyzer,
backup_manager=backup_manager, backup_manager=backup_manager,
forensic_pcap=forensic_pcap, forensic_pcap=forensic_pcap,
traffic_source=receiver,
metrics_provider=prometheus_metrics.render, metrics_provider=prometheus_metrics.render,
) )
@@ -535,7 +567,6 @@ def main() -> int:
analytics_cache.start() analytics_cache.start()
notifier.start() notifier.start()
ndr_analyzer.start() ndr_analyzer.start()
receiver.start()
watcher.start() watcher.start()
housekeeping_thread.start() housekeeping_thread.start()
web.start() web.start()
@@ -548,6 +579,10 @@ def main() -> int:
print(f"[fatal] Suricata exited with rc={suricata_rc}", file=sys.stderr, flush=True) print(f"[fatal] Suricata exited with rc={suricata_rc}", file=sys.stderr, flush=True)
rc = suricata_rc or 4 rc = suricata_rc or 4
break break
if not receiver.is_alive():
print("[fatal] Rust TZSP data-plane exited", file=sys.stderr, flush=True)
rc = 5
break
time.sleep(0.5) time.sleep(0.5)
finally: finally:
stop_event.set() stop_event.set()
@@ -567,7 +602,6 @@ def main() -> int:
os.remove("/run/suricata.pid") os.remove("/run/suricata.pid")
except FileNotFoundError: except FileNotFoundError:
pass pass
tap.close()
live_pipeline.stop() live_pipeline.stop()
analytics_cache.stop() analytics_cache.stop()
ndr_analyzer.stop() ndr_analyzer.stop()
+49
View File
@@ -238,6 +238,55 @@ class PrometheusMetrics:
if self.flow_tracker is None: if self.flow_tracker is None:
return return
status = self.flow_tracker.status() status = self.flow_tracker.status()
is_rust = str(status.get("engine") or "").lower() == "rust"
if is_rust:
self._emit(
lines,
"mikrosuricata_tzsp_receiver_info",
1,
metric_type="gauge",
help_text="TZSP packet data-plane implementation.",
labels={"engine": "rust"},
)
for key in (
"rcvbuf_bytes",
"batch_size",
"datagram_bytes",
"queue_capacity_batches",
"queue_capacity_bytes",
"queue_depth_batches",
"queue_high_water_batches",
"capture_efficiency_pct",
"rx_thread_alive",
"worker_thread_alive",
"telemetry_age_ms",
"process_alive",
"ready",
):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_tzsp_receiver_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
for key in (
"kernel_udp_drops",
"queue_dropped_datagrams",
"truncated_datagrams",
"tzsp_datagrams",
"tzsp_rx_bytes",
"rx_batches",
"telemetry_errors",
):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_tzsp_receiver_{_metric_name(key)}_total",
status[key],
metric_type="counter",
)
else:
for key in ("active_flows", "max_flows", "update_interval_seconds"): for key in ("active_flows", "max_flows", "update_interval_seconds"):
if key in status and _number(status[key]) is not None: if key in status and _number(status[key]) is not None:
self._emit( self._emit(
+14
View File
@@ -20,6 +20,9 @@ class RuntimeStats:
"tzsp_unsupported": 0, "tzsp_unsupported": 0,
"frames_injected": 0, "frames_injected": 0,
"inject_errors": 0, "inject_errors": 0,
"tzsp_kernel_udp_drops": 0,
"tzsp_queue_drops": 0,
"tzsp_truncated_datagrams": 0,
"eve_events": 0, "eve_events": 0,
"eve_alerts": 0, "eve_alerts": 0,
"eve_parse_errors": 0, "eve_parse_errors": 0,
@@ -44,6 +47,17 @@ class RuntimeStats:
with self._lock: with self._lock:
self._data[key] = datetime.now(timezone.utc).isoformat() self._data[key] = datetime.now(timezone.utc).isoformat()
def update_tzsp_receiver(self, values: dict[str, Any]) -> None:
"""Replace Rust receiver counters from its compact telemetry sample."""
with self._lock:
for key, value in values.items():
if key == "last_packet_at":
if value:
self._data[key] = value
continue
if isinstance(value, (int, float)) and not isinstance(value, bool):
self._data[key] = value
def update_suricata(self, stats: dict[str, Any], timestamp: str | None = None) -> None: def update_suricata(self, stats: dict[str, Any], timestamp: str | None = None) -> None:
flattened: dict[str, int | float] = {} flattened: dict[str, int | float] = {}
_flatten_numeric("", stats, flattened, 240) _flatten_numeric("", stats, flattened, 240)
+2
View File
@@ -68,3 +68,5 @@ html,body{min-height:100%;background:var(--bg);color:var(--text)}body{overflow-x
/* Compact secondary navigation inside data-heavy primary views. */ /* Compact secondary navigation inside data-heavy primary views. */
.subtabs{display:flex;align-items:center;gap:5px;margin:0 0 12px;padding:5px;border:1px solid var(--line-soft);border-radius:9px;background:#0e0f11;overflow-x:auto;scrollbar-width:thin}.subtab-button{flex:0 0 auto;border:1px solid transparent;border-radius:6px;background:transparent;color:#777880;padding:7px 11px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap;transition:.14s ease}.subtab-button:hover{color:#d6d6db;background:#141517}.subtab-button.active{color:#f4f4f5;background:#191a1d;border-color:#2b2c30;box-shadow:0 1px 2px rgba(0,0,0,.18)}.subtab-button.active::before{content:'';display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:999px;background:var(--green);vertical-align:1px}.subtab-panel{display:none}.subtab-panel.active{display:block}.panel-stack{display:grid;gap:12px;align-content:start;min-width:0} .subtabs{display:flex;align-items:center;gap:5px;margin:0 0 12px;padding:5px;border:1px solid var(--line-soft);border-radius:9px;background:#0e0f11;overflow-x:auto;scrollbar-width:thin}.subtab-button{flex:0 0 auto;border:1px solid transparent;border-radius:6px;background:transparent;color:#777880;padding:7px 11px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap;transition:.14s ease}.subtab-button:hover{color:#d6d6db;background:#141517}.subtab-button.active{color:#f4f4f5;background:#191a1d;border-color:#2b2c30;box-shadow:0 1px 2px rgba(0,0,0,.18)}.subtab-button.active::before{content:'';display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:999px;background:var(--green);vertical-align:1px}.subtab-panel{display:none}.subtab-panel.active{display:block}.panel-stack{display:grid;gap:12px;align-content:start;min-width:0}
@media(max-width:780px){.subtabs{margin-bottom:10px}.subtab-button{padding:7px 10px}.panel-stack{width:100%}} @media(max-width:780px){.subtabs{margin-bottom:10px}.subtab-button{padding:7px 10px}.panel-stack{width:100%}}
.metric-sub.metric-sub-bad{color:#d99a9a}
+36 -14
View File
@@ -14,7 +14,7 @@
liveEnabled: false, paused: false, live: [], liveById: new Map(), liveSequence: 0, liveEnabled: false, paused: false, live: [], liveById: new Map(), liveSequence: 0,
liveRenderTimer: null, liveFilterTimer: null, historyLoaded: false, snapshot: [], liveRenderTimer: null, liveFilterTimer: null, historyLoaded: false, snapshot: [],
batchTimes: [], uiDropped: 0, serverDropped: 0, batchTimes: [], uiDropped: 0, serverDropped: 0,
incidents: [], analytics: null, analyticsWindow: 0, throughput: null, throughputWindow: 0, status: null, config: null, ruleSources: [], ruleSourcesLoaded: false, incidents: [], analytics: null, analyticsWindow: 0, throughput: null, throughputWindow: 0, currentThroughput: null, currentThroughputWindow: 0, status: null, config: null, ruleSources: [], ruleSourcesLoaded: false,
selectedRuleSources: new Set(), sourceQueue: null, sourceQueueTimer: null, selectedRuleSources: new Set(), sourceQueue: null, sourceQueueTimer: null,
ndrIncidents: [], assets: [], iocs: [], pcaps: [], pcapMode: 'blocks', ndrSummary: {}, ndrIncidents: [], assets: [], iocs: [], pcaps: [], pcapMode: 'blocks', ndrSummary: {},
ruleIntelligence: [], ruleSnapshots: [], mergedRulesOffset: 0, mergedRulesQuery: '', backups: [], audit: [], ruleIntelligence: [], ruleSnapshots: [], mergedRulesOffset: 0, mergedRulesQuery: '', backups: [], audit: [],
@@ -374,16 +374,40 @@
state.analyticsPollTimer=setTimeout(()=>{if(Number(windowSec)===selectedWindow())loadAnalytics(windowSec,true);},delay); state.analyticsPollTimer=setTimeout(()=>{if(Number(windowSec)===selectedWindow())loadAnalytics(windowSec,true);},delay);
} }
function renderCurrentThroughput(t) {
const windowSec=Number(t?.window_seconds||selectedWindow());
if(windowSec && windowSec!==selectedWindow())return;
state.currentThroughput=t||{}; state.currentThroughputWindow=selectedWindow();
const total=Math.max(0,Number(t?.current_bps||0)), inbound=Math.max(0,Number(t?.current_in_bps||0)), outbound=Math.max(0,Number(t?.current_out_bps||0));
const ingress=Math.max(0,Number(t?.current_ingress_bps||0));
const other=Math.max(0,Number(t?.current_other_bps ?? (total-inbound-outbound))), pps=Math.max(0,Number(t?.current_pps||0));
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(total);
if($('metricThroughputSplit'))$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''} · ${Math.round(pps).toLocaleString()} pps`;
const quality=$('metricThroughputQuality');
if(quality){
const age=t?.current_sample_age_ms;
const ageText=Number.isFinite(Number(age))?`${(Number(age)/1000).toFixed(1)}s`:'waiting';
const capture=Math.max(0,Math.min(100,Number(t?.capture_efficiency_pct ?? 0)));
const queue=Math.max(0,Math.min(100,Number(t?.queue_fill_pct||0)));
const loss=Math.max(0,Number(t?.loss_pps||0));
const inspectRatio=Math.max(0,Math.min(100,Number(t?.inspection_ratio_pct ?? 100)));
const kernelDrops=Math.max(0,Number(t?.kernel_udp_drops||0));
const queueDrops=Math.max(0,Number(t?.queue_dropped_datagrams||0));
const truncated=Math.max(0,Number(t?.truncated_datagrams||0));
const buffer=Number(t?.rcvbuf_bytes||0), queueBytes=Number(t?.queue_capacity_bytes||0);
quality.textContent=`TZSP RX ${fmtBits(ingress)} · capture ${capture.toFixed(1)}% · queue ${queue.toFixed(0)}% · loss ${loss.toFixed(loss<10?1:0)}/s`;
quality.title=`sample ${ageText} old · inspected/TZSP ${inspectRatio.toFixed(1)}% · socket ${fmtBytes(buffer)} · userspace queue ${fmtBytes(queueBytes)} · kernel drops ${kernelDrops.toLocaleString()} · queue drops ${queueDrops.toLocaleString()} · truncated ${truncated.toLocaleString()}`;
const pipelineBehind=ingress>10_000_000 && inspectRatio<90;
const unhealthy=loss>0||queue>=80||pipelineBehind||t?.current_sample_fresh===false||t?.rx_thread_alive===false||t?.worker_thread_alive===false;
quality.classList.toggle('metric-sub-bad',unhealthy);
}
}
function renderThroughput(t) { function renderThroughput(t) {
const windowSec=Number(t?.window_seconds||selectedWindow()); const windowSec=Number(t?.window_seconds||selectedWindow());
if(windowSec!==selectedWindow())return; if(windowSec!==selectedWindow())return;
state.throughput=t; state.throughputWindow=windowSec; state.throughput=t; state.throughputWindow=windowSec;
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(t.current_bps||0); renderCurrentThroughput(t);
if($('metricThroughputSplit')){
const total=Math.max(0,Number(t.current_bps||0)), inbound=Math.max(0,Number(t.current_in_bps||0)), outbound=Math.max(0,Number(t.current_out_bps||0));
const other=Math.max(0,Number(t.current_other_bps ?? (total-inbound-outbound)));
$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''}`;
}
if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(t.peak_bps||0); if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(t.peak_bps||0);
if($('metricBytes'))$('metricBytes').textContent=fmtBytes(t.bytes||0); if($('metricBytes'))$('metricBytes').textContent=fmtBytes(t.bytes||0);
const charts=window.MikroSuricataCharts; if(charts?.drawThroughput)charts.drawThroughput($('throughputChart'),t.timeline||[]); const charts=window.MikroSuricataCharts; if(charts?.drawThroughput)charts.drawThroughput($('throughputChart'),t.timeline||[]);
@@ -420,12 +444,8 @@
if(!state.throughput || state.throughputWindow!==windowSec){state.throughput=a;state.throughputWindow=windowSec;} if(!state.throughput || state.throughputWindow!==windowSec){state.throughput=a;state.throughputWindow=windowSec;}
$('metricEvents').textContent = Number(a.events||0).toLocaleString(); $('metricEvents').textContent = Number(a.events||0).toLocaleString();
const traffic=(state.throughput && state.throughputWindow===windowSec)?state.throughput:a; const traffic=(state.throughput && state.throughputWindow===windowSec)?state.throughput:a;
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(traffic.current_bps||0); const live=(state.currentThroughput && state.currentThroughputWindow===windowSec)?state.currentThroughput:a;
if($('metricThroughputSplit')){ renderCurrentThroughput(live);
const total=Math.max(0,Number(traffic.current_bps||0)), inbound=Math.max(0,Number(traffic.current_in_bps||0)), outbound=Math.max(0,Number(traffic.current_out_bps||0));
const other=Math.max(0,Number(traffic.current_other_bps ?? (total-inbound-outbound)));
$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''}`;
}
if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(traffic.peak_bps||0); if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(traffic.peak_bps||0);
$('metricBytes').textContent = fmtBytes(traffic.bytes||0); $('metricAlerts').textContent = Number(a.alerts||0).toLocaleString(); $('metricBlocked').textContent = Number(a.blocked||0).toLocaleString(); $('metricBytes').textContent = fmtBytes(traffic.bytes||0); $('metricAlerts').textContent = Number(a.alerts||0).toLocaleString(); $('metricBlocked').textContent = Number(a.blocked||0).toLocaleString();
$('metricEventRate').textContent = `${Math.round(Number(a.events||0)/(Number(a.window_seconds||3600)/60)).toLocaleString()} / min`; $('metricEventRate').textContent = `${Math.round(Number(a.events||0)/(Number(a.window_seconds||3600)/60)).toLocaleString()} / min`;
@@ -542,7 +562,7 @@
$('ndrIncidentRows').innerHTML=state.ndrIncidents.length?state.ndrIncidents.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td>${fmtTime(x.last_seen)}</td><td class="mono">${esc(x.subject_ip||'—')}</td><td class="break stages-col">${esc((x.stages||[]).join(' → ')||'detection')}</td><td>${renderAttack(x.mitre)}</td><td class="details-cell" title="${esc(x.summary||x.title||'')}">${esc(x.summary||x.title||'—')}</td><td>${Number(x.event_count||0).toLocaleString()}${x.blocked?' · blocked':''}</td><td><span class="status-chip ${x.status==='open'?'bad':''}">${esc(x.status||'open')}</span></td><td><button class="link-btn" data-ndr-incident="${Number(x.id)}">evidence</button> · <button class="link-btn" data-ndr-status="${Number(x.id)}" data-status="${x.status==='closed'?'open':'closed'}">${x.status==='closed'?'reopen':'close'}</button></td></tr>`).join(''):'<tr><td colspan="9" class="empty">No correlated NDR incidents yet.</td></tr>'; $('ndrIncidentRows').innerHTML=state.ndrIncidents.length?state.ndrIncidents.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td>${fmtTime(x.last_seen)}</td><td class="mono">${esc(x.subject_ip||'—')}</td><td class="break stages-col">${esc((x.stages||[]).join(' → ')||'detection')}</td><td>${renderAttack(x.mitre)}</td><td class="details-cell" title="${esc(x.summary||x.title||'')}">${esc(x.summary||x.title||'—')}</td><td>${Number(x.event_count||0).toLocaleString()}${x.blocked?' · blocked':''}</td><td><span class="status-chip ${x.status==='open'?'bad':''}">${esc(x.status||'open')}</span></td><td><button class="link-btn" data-ndr-incident="${Number(x.id)}">evidence</button> · <button class="link-btn" data-ndr-status="${Number(x.id)}" data-status="${x.status==='closed'?'open':'closed'}">${x.status==='closed'?'reopen':'close'}</button></td></tr>`).join(''):'<tr><td colspan="9" class="empty">No correlated NDR incidents yet.</td></tr>';
$('assetRows').innerHTML=state.assets.length?state.assets.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td class="mono">${esc(x.ip)}</td><td><strong>${esc(x.hostname||'—')}</strong><div class="muted mono">${esc(x.mac||x.identity_source||'—')}</div></td><td class="break">${esc((x.protocols||[]).slice(0,8).join(', ')||'—')}</td><td class="break">${esc((x.ports||[]).slice(0,12).join(', ')||'—')}</td><td>${Number(x.alert_count||0).toLocaleString()}</td><td>${fmtTime(x.last_seen)}</td></tr>`).join(''):'<tr><td colspan="7" class="empty">Assets appear after traffic or RouterOS inventory sync.</td></tr>'; $('assetRows').innerHTML=state.assets.length?state.assets.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td class="mono">${esc(x.ip)}</td><td><strong>${esc(x.hostname||'—')}</strong><div class="muted mono">${esc(x.mac||x.identity_source||'—')}</div></td><td class="break">${esc((x.protocols||[]).slice(0,8).join(', ')||'—')}</td><td class="break">${esc((x.ports||[]).slice(0,12).join(', ')||'—')}</td><td>${Number(x.alert_count||0).toLocaleString()}</td><td>${fmtTime(x.last_seen)}</td></tr>`).join(''):'<tr><td colspan="7" class="empty">Assets appear after traffic or RouterOS inventory sync.</td></tr>';
$('iocRows').innerHTML=state.iocs.length?state.iocs.map(x=>`<tr><td><span class="status-chip">${esc(x.indicator_type)}</span></td><td class="mono break">${esc(x.indicator)}</td><td>${Number(x.confidence||0)}%</td><td>S${esc(x.severity||'—')}</td><td>${esc(x.source||'—')}</td><td>${Number(x.hit_count||0).toLocaleString()}</td><td>${fmtTime(x.last_hit_at)}</td><td><button class="link-btn danger-link" data-delete-ioc="${Number(x.id)}">delete</button></td></tr>`).join(''):'<tr><td colspan="8" class="empty">No local IOCs configured.</td></tr>'; $('iocRows').innerHTML=state.iocs.length?state.iocs.map(x=>`<tr><td><span class="status-chip">${esc(x.indicator_type)}</span></td><td class="mono break">${esc(x.indicator)}</td><td>${Number(x.confidence||0)}%</td><td>S${esc(x.severity||'—')}</td><td>${esc(x.source||'—')}</td><td>${Number(x.hit_count||0).toLocaleString()}</td><td>${fmtTime(x.last_hit_at)}</td><td><button class="link-btn danger-link" data-delete-ioc="${Number(x.id)}">delete</button></td></tr>`).join(''):'<tr><td colspan="8" class="empty">No local IOCs configured.</td></tr>';
const pcapDescriptions={blocks:'Mode: blocks · PCAP is persisted only after a successful RouterOS block; recent packets come from the bounded RAM ring.',alerts:'Mode: alerts · Suricata persists packets associated with alerts.',all:'Mode: all · Suricata persists all observed packets into the rotating PCAP log.',off:'Mode: off · forensic PCAP persistence is disabled.'}; const pcapDescriptions={blocks:'Mode: blocks (legacy) · with the Rust data-plane this is converted to Suricata alert capture so Python never processes every packet.',alerts:'Mode: alerts · Suricata persists packets associated with alerts.',all:'Mode: all · Suricata persists all observed packets into the rotating PCAP log.',off:'Mode: off · forensic PCAP persistence is disabled.'};
if($('pcapMeta'))$('pcapMeta').textContent=pcapDescriptions[state.pcapMode]||`Mode: ${state.pcapMode}`; if($('pcapMeta'))$('pcapMeta').textContent=pcapDescriptions[state.pcapMode]||`Mode: ${state.pcapMode}`;
$('pcapRows').innerHTML=state.pcaps.length?state.pcaps.map(x=>{const url=`/api/forensics/pcap?name=${encodeURIComponent(x.name)}`;return `<tr><td class="mono">${esc(x.name)}</td><td>${fmtBytes(x.size_bytes)}</td><td>${fmtTime(Number(x.modified_at||0)*1000)}</td><td><a class="link-btn" href="${url}" data-download-url="${url}">download</a></td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">No forensic PCAP files yet.</td></tr>'; $('pcapRows').innerHTML=state.pcaps.length?state.pcaps.map(x=>{const url=`/api/forensics/pcap?name=${encodeURIComponent(x.name)}`;return `<tr><td class="mono">${esc(x.name)}</td><td>${fmtBytes(x.size_bytes)}</td><td>${fmtTime(Number(x.modified_at||0)*1000)}</td><td><a class="link-btn" href="${url}" data-download-url="${url}">download</a></td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">No forensic PCAP files yet.</td></tr>';
} }
@@ -708,6 +728,7 @@
if(msg.data?.status)renderStatus(msg.data.status); if(msg.data?.status)renderStatus(msg.data.status);
if(msg.data?.analytics)renderAnalytics(msg.data.analytics); if(msg.data?.analytics)renderAnalytics(msg.data.analytics);
} else if(msg.type==='status')renderStatus(msg.data||{}); } else if(msg.type==='status')renderStatus(msg.data||{});
else if(msg.type==='throughput')renderCurrentThroughput(msg.data||{});
else if(msg.type==='analytics')renderAnalytics(msg.data||{}); else if(msg.type==='analytics')renderAnalytics(msg.data||{});
}; };
ws.onclose=()=>{ if (!state.authEnabled || state.authenticated) scheduleReconnect(); }; ws.onclose=()=>{ if (!state.authEnabled || state.authenticated) scheduleReconnect(); };
@@ -790,6 +811,7 @@
api('/api/stats').then(renderStats), api('/api/stats').then(renderStats),
api('/api/alerts?limit=250').then(alerts=>{state.incidents=alerts.alerts||[];renderIncidents();}), api('/api/alerts?limit=250').then(alerts=>{state.incidents=alerts.alerts||[];renderIncidents();}),
loadOverviewSnapshot(windowSec,true), loadOverviewSnapshot(windowSec,true),
loadThroughput(windowSec,true),
loadAnalytics(windowSec,true), loadAnalytics(windowSec,true),
state.view==='intelligence'?loadIntelligence(true):Promise.resolve(), state.view==='intelligence'?loadIntelligence(true):Promise.resolve(),
]); ]);
+4 -4
View File
@@ -51,14 +51,14 @@
<section id="view-overview" class="view active"> <section id="view-overview" class="view active">
<div class="metric-grid overview-metrics"> <div class="metric-grid overview-metrics">
<article class="metric-card"><div class="metric-label">Events</div><div id="metricEvents" class="metric-value">0</div><div id="metricEventRate" class="metric-sub">0 / min</div></article> <article class="metric-card"><div class="metric-label">Events</div><div id="metricEvents" class="metric-value">0</div><div id="metricEventRate" class="metric-sub">0 / min</div></article>
<article class="metric-card"><div class="metric-label">Throughput now</div><div id="metricThroughput" class="metric-value">0 bps</div><div id="metricThroughputSplit" class="metric-sub">IN 0 bps · OUT 0 bps</div></article> <article class="metric-card"><div class="metric-label">Inspected throughput</div><div id="metricThroughput" class="metric-value">0 bps</div><div id="metricThroughputSplit" class="metric-sub">IN 0 bps · OUT 0 bps · 0 pps</div><div id="metricThroughputQuality" class="metric-sub">Rust TZSP · waiting for sample</div></article>
<article class="metric-card"><div class="metric-label">Observed traffic</div><div id="metricBytes" class="metric-value">0 B</div><div class="metric-sub">TZSP bytes in selected range</div></article> <article class="metric-card"><div class="metric-label">Observed traffic</div><div id="metricBytes" class="metric-value">0 B</div><div class="metric-sub">Ethernet frame bytes delivered to Suricata in selected range</div></article>
<article class="metric-card"><div class="metric-label">Peak throughput</div><div id="metricPeakThroughput" class="metric-value">0 bps</div><div class="metric-sub">Selected time range</div></article> <article class="metric-card"><div class="metric-label">Peak throughput</div><div id="metricPeakThroughput" class="metric-value">0 bps</div><div class="metric-sub">Selected time range</div></article>
<article class="metric-card"><div class="metric-label">Threats</div><div id="metricAlerts" class="metric-value">0</div><div id="metricIncidents" class="metric-sub">0 incidents</div></article> <article class="metric-card"><div class="metric-label">Threats</div><div id="metricAlerts" class="metric-value">0</div><div id="metricIncidents" class="metric-sub">0 incidents</div></article>
<article class="metric-card"><div class="metric-label">Blocked</div><div id="metricBlocked" class="metric-value">0</div><div id="metricBlockRate" class="metric-sub">Policy actions</div></article> <article class="metric-card"><div class="metric-label">Blocked</div><div id="metricBlocked" class="metric-value">0</div><div id="metricBlockRate" class="metric-sub">Policy actions</div></article>
</div> </div>
<div class="grid-main"> <div class="grid-main">
<article class="panel chart-panel span-2"><div class="panel-head"><div><h2>Traffic throughput</h2><p>Total, inbound and outbound network speed sampled from TZSP traffic and retained in Redis.</p></div><div class="chart-head-meta"><span id="snapshotMeta" class="status-chip">loading</span><div class="legend"><span><i class="legend-amber"></i>Total</span><span><i class="legend-blue"></i>Inbound</span><span><i class="legend-green"></i>Outbound</span></div></div></div><canvas id="throughputChart" height="230"></canvas></article> <article class="panel chart-panel span-2"><div class="panel-head"><div><h2>Traffic throughput</h2><p>Total, inbound and outbound speed from the Rust TZSP data-plane; 1 s samples are retained in Redis.</p></div><div class="chart-head-meta"><span id="snapshotMeta" class="status-chip">loading</span><div class="legend"><span><i class="legend-amber"></i>Total</span><span><i class="legend-blue"></i>Inbound</span><span><i class="legend-green"></i>Outbound</span></div></div></div><canvas id="throughputChart" height="230"></canvas></article>
<article class="panel donut-panel"><div class="panel-head"><div><h2>Traffic direction</h2><p>Inbound / outbound / internal</p></div></div><canvas id="directionDonut" height="230"></canvas></article> <article class="panel donut-panel"><div class="panel-head"><div><h2>Traffic direction</h2><p>Inbound / outbound / internal</p></div></div><canvas id="directionDonut" height="230"></canvas></article>
<article class="panel chart-panel span-2"><div class="panel-head"><div><h2>Events &amp; alerts</h2><p>Complete retained event history for the selected time range.</p></div><div class="legend"><span><i class="legend-green"></i>Events</span><span><i class="legend-red"></i>Alerts</span></div></div><canvas id="trafficChart" height="220"></canvas></article> <article class="panel chart-panel span-2"><div class="panel-head"><div><h2>Events &amp; alerts</h2><p>Complete retained event history for the selected time range.</p></div><div class="legend"><span><i class="legend-green"></i>Events</span><span><i class="legend-red"></i>Alerts</span></div></div><canvas id="trafficChart" height="220"></canvas></article>
<article class="panel donut-panel"><div class="panel-head"><div><h2>Event mix</h2><p>Flow, DNS, TLS, HTTP and alerts</p></div></div><canvas id="eventTypeDonut" height="220"></canvas></article> <article class="panel donut-panel"><div class="panel-head"><div><h2>Event mix</h2><p>Flow, DNS, TLS, HTTP and alerts</p></div></div><canvas id="eventTypeDonut" height="220"></canvas></article>
@@ -152,7 +152,7 @@
<article class="panel mt-4"><div class="panel-head"><div><h2>Threat intelligence repository</h2><p>IOC hits increase incident risk and remain persistent in SQLite.</p></div></div><div class="table-wrap"><table><thead><tr><th>Type</th><th>Indicator</th><th>Confidence</th><th>Severity</th><th>Source</th><th>Hits</th><th>Last hit</th><th></th></tr></thead><tbody id="iocRows"></tbody></table></div></article> <article class="panel mt-4"><div class="panel-head"><div><h2>Threat intelligence repository</h2><p>IOC hits increase incident risk and remain persistent in SQLite.</p></div></div><div class="table-wrap"><table><thead><tr><th>Type</th><th>Indicator</th><th>Confidence</th><th>Severity</th><th>Source</th><th>Hits</th><th>Last hit</th><th></th></tr></thead><tbody id="iocRows"></tbody></table></div></article>
</div> </div>
<div class="subtab-panel" data-subtab-panel="intelligence:forensics"> <div class="subtab-panel" data-subtab-panel="intelligence:forensics">
<article class="panel"><div class="panel-head"><div><h2>Forensic PCAP ring</h2><p id="pcapMeta">Persistent evidence mode is loading…</p></div></div><div class="table-wrap"><table><thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead><tbody id="pcapRows"></tbody></table></div></article> <article class="panel"><div class="panel-head"><div><h2>Forensic PCAP</h2><p id="pcapMeta">Persistent evidence mode is loading…</p></div></div><div class="table-wrap"><table><thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead><tbody id="pcapRows"></tbody></table></div></article>
</div> </div>
</section> </section>
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import json
import os
import socket
import subprocess
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from .state import RuntimeStats
class RustTZSPReceiver:
"""Supervise the Rust TZSP data-plane and ingest its 1 Hz telemetry.
Packet bytes never cross into Python. The Rust process owns UDP reception,
TZSP decoding and TAP injection. Python receives only compact telemetry over
a Unix datagram socket, so UI/Redis work cannot back-pressure packet capture.
"""
def __init__(
self,
*,
binary: str,
telemetry_socket: str,
stats: RuntimeStats,
stop_event: threading.Event,
throughput_sink: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.binary = str(binary)
self.telemetry_socket = str(telemetry_socket)
self.stats = stats
self.stop_event = stop_event
self._throughput_sink = throughput_sink
self._process: subprocess.Popen | None = None
self._socket: socket.socket | None = None
self._thread = threading.Thread(target=self._run_telemetry, name="tzsp-rust-telemetry", daemon=True)
self._lock = threading.RLock()
self._ready = threading.Event()
self._last: dict[str, Any] = {}
self._samples = 0
self._telemetry_errors = 0
self._started_at = time.monotonic()
def start(self) -> None:
if self._process is not None:
return
binary = Path(self.binary)
if not binary.is_file():
raise RuntimeError(f"Rust TZSP receiver binary not found: {self.binary}")
path = Path(self.telemetry_socket)
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.unlink()
except FileNotFoundError:
pass
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
sock.bind(self.telemetry_socket)
sock.settimeout(0.5)
self._socket = sock
self._thread.start()
env = os.environ.copy()
env["TZSP_TELEMETRY_SOCKET"] = self.telemetry_socket
self._process = subprocess.Popen([self.binary], env=env)
print(f"[tzsp] Rust data-plane started, pid={self._process.pid}", flush=True)
def wait_ready(self, timeout: float = 8.0) -> bool:
deadline = time.monotonic() + max(0.1, float(timeout))
while time.monotonic() < deadline:
process = self._process
if process is not None and process.poll() is not None:
return False
if self._ready.wait(timeout=min(0.1, max(0.0, deadline - time.monotonic()))):
return True
return False
def set_throughput_sink(self, sink: Callable[[dict[str, Any]], None] | None) -> None:
with self._lock:
self._throughput_sink = sink
def is_alive(self) -> bool:
process = self._process
return bool(process is not None and process.poll() is None and self._thread.is_alive())
@property
def pid(self) -> int | None:
process = self._process
return process.pid if process is not None and process.poll() is None else None
def close(self, timeout: float = 3.0) -> None:
process = self._process
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
sock = self._socket
self._socket = None
if sock is not None:
try:
sock.close()
except OSError:
pass
if self._thread.is_alive():
self._thread.join(timeout=1.0)
try:
Path(self.telemetry_socket).unlink()
except FileNotFoundError:
pass
self._process = None
def status(self) -> dict[str, Any]:
with self._lock:
data = dict(self._last)
now_ms = int(time.time() * 1000)
ts_ms = int(data.get("ts_ms") or 0)
data.update(
{
"engine": "rust",
"process_alive": self.is_alive(),
"pid": self.pid or data.get("pid"),
"ready": bool(self._ready.is_set() and self.is_alive()),
"telemetry_age_ms": max(0, now_ms - ts_ms) if ts_ms else None,
"throughput_samples": self._samples,
"telemetry_errors": self._telemetry_errors,
}
)
return data
def current_throughput(self, window_seconds: int | None = None) -> dict[str, Any]:
with self._lock:
sample = dict(self._last)
now_ms = int(time.time() * 1000)
ts_ms = int(sample.get("ts_ms") or 0)
interval = max(float(sample.get("interval_ms") or 1000) / 1000.0, 0.001)
age_ms = max(0, now_ms - ts_ms) if ts_ms else 10**9
fresh = bool(ts_ms and age_ms <= max(3000, int(interval * 3000)))
if fresh:
total = round(max(int(sample.get("bytes_total") or 0), 0) * 8 / interval)
inbound = round(max(int(sample.get("bytes_in") or 0), 0) * 8 / interval)
outbound = round(max(int(sample.get("bytes_out") or 0), 0) * 8 / interval)
pps = round(max(int(sample.get("packets_total") or 0), 0) / interval, 2)
ingress_bps = round(max(int(sample.get("rx_bytes_interval") or 0), 0) * 8 / interval)
ingress_pps = round(max(int(sample.get("rx_datagrams_interval") or 0), 0) / interval, 2)
else:
total = inbound = outbound = ingress_bps = 0
pps = ingress_pps = 0.0
queue_depth = max(int(sample.get("queue_depth_batches") or 0), 0)
queue_capacity = max(int(sample.get("queue_capacity_batches") or 0), 0)
queue_fill_pct = round((queue_depth / queue_capacity) * 100.0, 1) if queue_capacity else 0.0
inspection_ratio_pct = (
round(min(100.0, (total / ingress_bps) * 100.0), 1)
if ingress_bps > 0
else (100.0 if total == 0 else 0.0)
)
loss_per_second = round(
(
max(int(sample.get("kernel_udp_drops_interval") or 0), 0)
+ max(int(sample.get("queue_drops_interval") or 0), 0)
+ max(int(sample.get("truncated_interval") or 0), 0)
)
/ interval,
2,
) if fresh else 0.0
return {
"window_seconds": int(window_seconds or 0),
"current_bps": total,
"current_in_bps": inbound,
"current_out_bps": outbound,
"current_other_bps": max(0, total - inbound - outbound),
"current_pps": pps,
"current_ingress_bps": ingress_bps,
"current_ingress_pps": ingress_pps,
"inspection_ratio_pct": inspection_ratio_pct,
"capture_efficiency_pct": float(sample.get("capture_efficiency_pct") or 0.0),
"loss_pps": loss_per_second,
"current_sample_ts_ms": ts_ms,
"current_sample_age_ms": age_ms if ts_ms else None,
"current_sample_fresh": fresh,
"receiver_engine": "rust",
"receiver_pid": self.pid,
"kernel_udp_drops": int(sample.get("kernel_udp_drops") or 0),
"kernel_udp_drops_interval": int(sample.get("kernel_udp_drops_interval") or 0),
"queue_dropped_datagrams": int(sample.get("queue_dropped_datagrams") or 0),
"queue_drops_interval": int(sample.get("queue_drops_interval") or 0),
"truncated_datagrams": int(sample.get("truncated_datagrams") or 0),
"truncated_interval": int(sample.get("truncated_interval") or 0),
"queue_depth_batches": queue_depth,
"queue_capacity_batches": queue_capacity,
"queue_capacity_bytes": int(sample.get("queue_capacity_bytes") or 0),
"queue_high_water_batches": int(sample.get("queue_high_water_batches") or 0),
"queue_fill_pct": queue_fill_pct,
"rx_thread_alive": bool(sample.get("rx_thread_alive", False)),
"worker_thread_alive": bool(sample.get("worker_thread_alive", False)),
"rcvbuf_bytes": int(sample.get("rcvbuf_bytes") or 0),
"batch_size": int(sample.get("batch_size") or 0),
"datagram_bytes": int(sample.get("datagram_bytes") or 0),
}
def overlay_current(self, payload: dict[str, Any], window_seconds: int | None = None) -> dict[str, Any]:
result = dict(payload)
result.update(self.current_throughput(window_seconds or int(result.get("window_seconds") or 0)))
return result
def _run_telemetry(self) -> None:
while not self.stop_event.is_set():
sock = self._socket
if sock is None:
break
try:
raw = sock.recv(64 * 1024)
except socket.timeout:
continue
except OSError:
if self.stop_event.is_set() or self._socket is None:
break
self._telemetry_errors += 1
continue
try:
message = json.loads(raw.decode("utf-8"))
if not isinstance(message, dict) or message.get("type") != "tzsp_sample":
continue
self._ingest(message)
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError):
self._telemetry_errors += 1
def _ingest(self, message: dict[str, Any]) -> None:
with self._lock:
self._last = dict(message)
sink = self._throughput_sink
if message.get("ready"):
self._ready.set()
last_packet_ms = int(message.get("last_packet_ms") or 0)
last_packet_at = None
if last_packet_ms:
last_packet_at = datetime.fromtimestamp(last_packet_ms / 1000.0, tz=timezone.utc).isoformat()
self.stats.update_tzsp_receiver(
{
"tzsp_datagrams": int(message.get("tzsp_datagrams") or 0),
"tzsp_decode_errors": int(message.get("tzsp_decode_errors") or 0),
"tzsp_unsupported": int(message.get("tzsp_unsupported") or 0),
"frames_injected": int(message.get("frames_injected") or 0),
"inject_errors": int(message.get("inject_errors") or 0),
"tzsp_kernel_udp_drops": int(message.get("kernel_udp_drops") or 0),
"tzsp_queue_drops": int(message.get("queue_dropped_datagrams") or 0),
"tzsp_truncated_datagrams": int(message.get("truncated_datagrams") or 0),
"last_packet_at": last_packet_at,
}
)
sample = {
key: int(message.get(key) or 0)
for key in (
"ts_ms",
"interval_ms",
"bytes_total",
"bytes_in",
"bytes_out",
"bytes_internal",
"bytes_external",
"packets_total",
"packets_in",
"packets_out",
"packets_internal",
"packets_external",
)
}
self._samples += 1
if sink is not None and sample["interval_ms"] > 0:
try:
sink(sample)
except Exception:
# Telemetry persistence is best-effort and is deliberately never
# allowed to affect the independent Rust packet data-plane.
self._telemetry_errors += 1
+33 -6
View File
@@ -20,7 +20,7 @@ import urllib.parse
from collections import defaultdict, deque from collections import defaultdict, deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Any, Callable
from .config import Config from .config import Config
from .auth import SessionAuth from .auth import SessionAuth
@@ -122,6 +122,7 @@ class WebServer:
ndr_analyzer: NDRAnalyzer | None = None, ndr_analyzer: NDRAnalyzer | None = None,
backup_manager: BackupManager | None = None, backup_manager: BackupManager | None = None,
forensic_pcap: ForensicPcapRing | None = None, forensic_pcap: ForensicPcapRing | None = None,
traffic_source: Any | None = None,
metrics_provider: Callable[[], str] | None = None, metrics_provider: Callable[[], str] | None = None,
) -> None: ) -> None:
self.config = config self.config = config
@@ -137,6 +138,7 @@ class WebServer:
self.threat_intel = threat_intel self.threat_intel = threat_intel
self.ndr_analyzer = ndr_analyzer self.ndr_analyzer = ndr_analyzer
self.forensic_pcap = forensic_pcap self.forensic_pcap = forensic_pcap
self.traffic_source = traffic_source
self.metrics_provider = metrics_provider self.metrics_provider = metrics_provider
self.metrics_access = MetricsAccessControl(config) if metrics_provider is not None else None self.metrics_access = MetricsAccessControl(config) if metrics_provider is not None else None
self.backup_manager = backup_manager or BackupManager(config.db_path, os.path.dirname(config.db_path) or ".") self.backup_manager = backup_manager or BackupManager(config.db_path, os.path.dirname(config.db_path) or ".")
@@ -162,10 +164,30 @@ class WebServer:
def _analytics_payload(self, window_seconds: int) -> dict: def _analytics_payload(self, window_seconds: int) -> dict:
if self.analytics_cache is not None: if self.analytics_cache is not None:
return self.analytics_cache.get(window_seconds) payload = self.analytics_cache.get(window_seconds)
if self.traffic_history is not None: elif self.traffic_history is not None:
return self.traffic_history.analytics(window_seconds) payload = self.traffic_history.analytics(window_seconds)
return {"window_seconds": window_seconds, "events": 0, "timeline": []} else:
payload = {"window_seconds": window_seconds, "events": 0, "timeline": []}
return self._overlay_current_throughput(payload, window_seconds)
def _overlay_current_throughput(self, payload: dict, window_seconds: int) -> dict:
source = self.traffic_source
if source is None or not hasattr(source, "overlay_current"):
return payload
try:
return source.overlay_current(payload, window_seconds)
except Exception:
return payload
def _current_throughput_payload(self, window_seconds: int) -> dict:
source = self.traffic_source
if source is None or not hasattr(source, "current_throughput"):
return {"window_seconds": window_seconds, "current_bps": 0}
try:
return source.current_throughput(window_seconds)
except Exception:
return {"window_seconds": window_seconds, "current_bps": 0}
def _login_allowed(self, client_ip: str) -> bool: def _login_allowed(self, client_ip: str) -> bool:
now = time.monotonic() now = time.monotonic()
@@ -297,7 +319,8 @@ class WebServer:
query = urllib.parse.parse_qs(parsed.query) query = urllib.parse.parse_qs(parsed.query)
window = self._query_int(query, "window", 3600, 60, config.traffic_retention_hours * 3600) window = self._query_int(query, "window", 3600, 60, config.traffic_retention_hours * 3600)
try: try:
self._json(traffic_history.throughput_analytics(window)) payload = traffic_history.throughput_analytics(window)
self._json(outer._overlay_current_throughput(payload, window))
except RedisUnavailableError as exc: except RedisUnavailableError as exc:
self._json({"error": f"Redis throughput history unavailable: {exc}"}, status=503) self._json({"error": f"Redis throughput history unavailable: {exc}"}, status=503)
return return
@@ -778,6 +801,7 @@ class WebServer:
self._ws_send_json(bootstrap) self._ws_send_json(bootstrap)
last_status = time.monotonic() last_status = time.monotonic()
last_analytics = last_status last_analytics = last_status
last_throughput = 0.0
while True: while True:
if not self._ws_client_control(): if not self._ws_client_control():
return return
@@ -819,6 +843,9 @@ class WebServer:
time.sleep(0.25) time.sleep(0.25)
now = time.monotonic() now = time.monotonic()
if now - last_throughput >= 1:
self._ws_send_json({"type": "throughput", "data": outer._current_throughput_payload(window)})
last_throughput = now
if now - last_status >= 5: if now - last_status >= 5:
self._ws_send_json({"type": "status", "data": outer._status_payload()}) self._ws_send_json({"type": "status", "data": outer._status_payload()})
last_status = now last_status = now
+22 -6
View File
@@ -17,17 +17,33 @@ CONTAINER_SUBNET=172.31.255.0/30
CONTAINER_BRIDGE=br-ids CONTAINER_BRIDGE=br-ids
CONTAINER_VETH=veth-ids CONTAINER_VETH=veth-ids
# Packet Sniffer -> TZSP # RouterOS -> TZSP hybrid capture.
VLAN_ID=100 # Routed IPv4 uses /ip firewall mangle action=sniff-tzsp for high throughput.
# Packet Sniffer is kept only for non-IPv4 Ethernet frames (ARP, IPv6, LLDP,
# PPPoE discovery, etc.) using filter-mac-protocol=!ip.
TZSP_PORT=37008 TZSP_PORT=37008
CONFIGURE_SNIFFER=true CONFIGURE_TZSP_CAPTURE=true
START_SNIFFER=true CONFIGURE_IPV4_MANGLE=true
CONFIGURE_L2_SNIFFER=true
START_L2_SNIFFER=true
# Empty means all RouterOS interfaces. Set a real interface/bridge name only
# when you intentionally want to constrain the low-volume non-IPv4 stream.
TZSP_L2_INTERFACE=
TZSP_L2_MAC_PROTOCOL=!ip
# Rust receive-path tuning. RouterOS may cap the kernel socket buffer; the
# userspace batch queue is the primary burst buffer for high-throughput mirrors.
TZSP_RCVBUF_BYTES=33554432
TZSP_BATCH_SIZE=256
TZSP_QUEUE_MB=64
TZSP_DATAGRAM_BYTES=12288
# Suricata/app # Suricata/app
SURICATA_HOME_NET=[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12] SURICATA_HOME_NET=[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]
SURICATA_LOG_MAX_MB=512 SURICATA_LOG_MAX_MB=512
# Forensic PCAP: blocks (default), alerts, all, off # Forensic PCAP: alerts (default), all, off. Legacy "blocks" is accepted but
FORENSIC_PCAP_MODE=blocks # falls back to alert capture so Python never returns to the per-packet path.
FORENSIC_PCAP_MODE=alerts
FORENSIC_PCAP_WINDOW_SECONDS=60 FORENSIC_PCAP_WINDOW_SECONDS=60
FORENSIC_PCAP_MEMORY_MB=64 FORENSIC_PCAP_MEMORY_MB=64
FORENSIC_PCAP_MAX_FILES=32 FORENSIC_PCAP_MAX_FILES=32
+276
View File
@@ -0,0 +1,276 @@
# RouterOS TZSP capture architecture
MikroSuricata 0.11.2 uses a **hybrid RouterOS capture path**. The goal is to keep high-volume routed IPv4 off `/tool/sniffer` while still preserving visibility into ARP and other non-IPv4 Ethernet traffic.
## Default topology
```text
routed IPv4
RouterOS forwarding --------------------> /ip firewall mangle
action=sniff-tzsp
|
| TZSP UDP/37008
v
MikroSuricata Rust
|
v
TAP -> Suricata
non-IPv4 Ethernet
RouterOS interfaces --------------------> /tool/sniffer
filter-mac-protocol=!ip
filter-stream=yes
|
+---- TZSP UDP/37008 ----+
```
Both paths use the same TZSP receiver in the container.
## Why hybrid capture
A full `/tool/sniffer` TZSP stream can become the limiting stage on some RouterOS systems long before the Rust receiver, TAP or Suricata are saturated. In testing on the system that motivated this change, full Packet Sniffer streaming plateaued around 150-270 Mbit/s while an IPv4 `mangle action=sniff-tzsp` rule delivered roughly 1.2 Gbit/s of encapsulated traffic to the container VETH.
This is not treated as a universal RouterOS speed guarantee. The deployer simply chooses the path that proved suitable for high-volume routed IPv4 and keeps Packet Sniffer for the much smaller non-IPv4 stream.
## Routed IPv4
The deployer manages one rule:
```routeros
/ip/firewall/mangle
add chain=forward \
action=sniff-tzsp \
sniff-target=172.31.255.2 \
sniff-target-port=37008 \
comment="MikroSuricata TZSP IPv4"
```
The target IP and port come from `CONTAINER_IP` and `TZSP_PORT`; the values above are examples.
`sniff-tzsp` clones the matching packet to the TZSP receiver and then continues processing the next mangle rule. MikroSuricata places its rule at the beginning of the IPv4 `forward` mangle chain so later existing mangle rules still run normally.
The deployer removes/replaces **only** the rule whose comment is exactly:
```text
MikroSuricata TZSP IPv4
```
It does not delete, disable or reorder unrelated mangle rules.
### What this IPv4 rule covers
`chain=forward` covers IPv4 routed **through** RouterOS. This is the main IDS path for client-to-Internet and inter-subnet traffic.
It intentionally does not capture:
- IPv4 traffic terminating on RouterOS itself (`input`),
- IPv4 traffic generated by RouterOS itself (`output`),
- pure L2 IPv4 switching that never enters the routed IPv4 `forward` chain.
The first two exclusions also avoid any risk of recursively observing RouterOS-generated TZSP traffic. If router-local IPv4 inspection is required later, it should be added as a separate, explicitly designed path rather than by broadening the default rule blindly.
## ARP, IPv6 and other non-IPv4 Ethernet traffic
Packet Sniffer is configured as the complement:
```routeros
/tool/sniffer/set \
streaming-enabled=yes \
filter-stream=yes \
filter-interface=all \
filter-mac-protocol=!ip \
filter-direction=any
```
RouterOS names EtherType `0x0800` as `ip` and EtherType `0x86DD` as `ipv6`. Therefore `filter-mac-protocol=!ip` means **everything except IPv4 EtherType**, not "everything except all IP".
With the default filter the stream can include, among other protocols:
- ARP (`0x0806`),
- IPv6 (`0x86DD`),
- LLDP,
- 802.1X/EAPoL,
- LACP,
- PPPoE session/discovery,
- MPLS EtherTypes,
- RARP,
- VLAN/service-VLAN frames when visible to the sniffer.
`filter-stream=yes` is kept enabled so packets destined for the configured sniffer server are ignored by Packet Sniffer rather than captured again.
### IPv6 performance note
IPv6 is deliberately left in the Packet Sniffer complement in 0.11.2 because the requested design is "mangle for IPv4, stream everything except IPv4". If the network later carries sustained high-volume IPv6, the Packet Sniffer performance ceiling can reappear for that traffic. At that point IPv6 should be split into its own high-throughput capture path and the L2 sniffer narrowed to explicit non-IP EtherTypes.
## No bridge or interface name is assumed
The deployer does **not** assume names such as `bridge`, `bridge-trunk`, `br0`, `LAN`, or any site-specific VLAN interface.
Default:
```dotenv
TZSP_L2_INTERFACE=
```
An empty value is translated to RouterOS:
```text
filter-interface=all
```
If an operator intentionally wants to constrain non-IPv4 capture, set the exact RouterOS interface name:
```dotenv
TZSP_L2_INTERFACE=bridge-core
```
The deployer verifies that a non-empty interface name exists before applying the sniffer configuration.
`CONTAINER_BRIDGE=br-ids` is different: that is the **new private bridge created for the IDS container**, not the bridge from which production traffic is assumed to arrive. It remains configurable through `deploy-routeros.env`.
## RouterOS sniffer endpoint syntax
Newer RouterOS CLI builds can expose the TZSP destination as a composite value:
```routeros
streaming-server=172.31.255.2:37008
```
Some older documentation/builds expose the destination as separate properties:
```routeros
streaming-server=172.31.255.2
streaming-port=37008
```
The deploy and migration helper try the composite `IP:PORT` form first and automatically fall back to the split form if RouterOS rejects it.
## Deployment variables
Default capture settings:
```dotenv
TZSP_PORT=37008
CONFIGURE_TZSP_CAPTURE=true
CONFIGURE_IPV4_MANGLE=true
CONFIGURE_L2_SNIFFER=true
START_L2_SNIFFER=true
TZSP_L2_INTERFACE=
TZSP_L2_MAC_PROTOCOL=!ip
```
Meaning:
| Variable | Meaning |
|---|---|
| `CONFIGURE_TZSP_CAPTURE` | Master switch for capture changes made by full deploy |
| `CONFIGURE_IPV4_MANGLE` | Manage the project-owned IPv4 `forward` `sniff-tzsp` rule |
| `CONFIGURE_L2_SNIFFER` | Reconfigure global Packet Sniffer as the non-IPv4 complement |
| `START_L2_SNIFFER` | Start Packet Sniffer after configuring it |
| `TZSP_L2_INTERFACE` | Empty = all interfaces; otherwise exact interface/bridge name |
| `TZSP_L2_MAC_PROTOCOL` | Default `!ip`; normally should not be changed |
| `TZSP_PORT` | UDP port used by Rust TZSP receiver |
For backward compatibility, an old `deploy-routeros.env` containing `CONFIGURE_SNIFFER` and `START_SNIFFER` is still accepted as an alias for the new master/start settings. `VLAN_ID` is no longer used by the capture deploy.
## Full deployment
Prepare configuration:
```bash
cp deploy-routeros.env.example deploy-routeros.env
```
Build/upload the image as usual, then deploy:
```bash
./scripts/deploy-routeros.sh routeros-suricata-tzsp-arm64.tar
```
The deployment:
1. creates/updates the IDS container infrastructure,
2. starts the new container,
3. replaces only the MikroSuricata IPv4 mangle capture rule,
4. stops the global Packet Sniffer,
5. clears stale Packet Sniffer filters,
6. configures Packet Sniffer for `!ip`,
7. uses all RouterOS interfaces unless `TZSP_L2_INTERFACE` is explicitly set,
8. starts the L2 sniffer when `START_L2_SNIFFER=true`.
## Switch an existing installation without rebuilding the container
The capture migration is independent from the container image:
```bash
./scripts/configure-routeros-tzsp-hybrid.sh
```
It reads the same `deploy-routeros.env` file.
Preview the exact generated RouterOS script without making changes:
```bash
DRY_RUN=true ./scripts/configure-routeros-tzsp-hybrid.sh
```
The helper only manages the project-owned IPv4 mangle rule. Packet Sniffer itself is a **global RouterOS facility**, so enabling `CONFIGURE_L2_SNIFFER=true` necessarily replaces its current global filters/settings. If the router already uses `/tool/sniffer` for another administrative purpose, either coordinate that use or set:
```dotenv
CONFIGURE_L2_SNIFFER=false
```
and manage non-IPv4 capture manually.
## Manual RouterOS template
A standalone template is included:
```text
routeros/02-tzsp-hybrid.rsc
```
Edit the local variables at the top before importing if the default container address differs.
## Verification
Check the RouterOS capture configuration:
```routeros
/ip/firewall/mangle/print stats where comment="MikroSuricata TZSP IPv4"
/tool/sniffer/print
/interface/monitor-traffic veth-ids
```
Or from the project host:
```bash
./scripts/routeros-status.sh
```
During a large IPv4 download, expect the mangle rule packet/byte counters and the container VETH TX counters to increase rapidly. In the MikroSuricata UI compare:
```text
TZSP RX
Inspected throughput
capture %
queue %
loss/s
```
If `TZSP RX` is close to the expected traffic and capture remains close to 100%, RouterOS -> Rust and Rust -> TAP are keeping up.
## Coverage and caveats
- Packet Sniffer cannot necessarily see traffic switched entirely in hardware by a hardware-offloaded bridge. Broadcast/multicast behavior can differ by platform.
- The default mangle rule covers routed IPv4 only, not bridge-only IPv4 switching.
- IPv6 remains on Packet Sniffer in 0.11.2 and can therefore inherit Packet Sniffer throughput limits under sustained high-rate IPv6 traffic.
- `filter-interface=all` can expose the same low-level broadcast/L2 frame on more than one logical/physical observation point on some topologies. If this is noisy, set `TZSP_L2_INTERFACE` explicitly.
- The global Packet Sniffer has no per-consumer instance. MikroSuricata therefore cannot preserve a second independent sniffer configuration while also owning the non-IPv4 TZSP stream.
- Do not run an additional full-traffic Packet Sniffer stream to the same TZSP destination at the same time; duplicate packets will inflate traffic and Suricata processing.
## Why existing mangle rules continue to work
RouterOS defines `sniff-tzsp` as an action that sends a copy to a TZSP target and then passes the matched packet to the next mangle rule, similar to `passthrough`. The MikroSuricata rule therefore observes traffic without accepting, dropping, marking or rerouting the original packet.
This is why the rule can safely sit at the beginning of `chain=forward`: later QoS, marking, policy-routing and other mangle rules remain in the processing path.
+11 -2
View File
@@ -8,8 +8,8 @@ already available in-memory counters and the last Suricata EVE stats snapshot.
A scrape does not query SQLite, Redis, RouterOS, the Suricata control socket, A scrape does not query SQLite, Redis, RouterOS, the Suricata control socket,
or analytics endpoints. or analytics endpoints.
The dashboard also uses monotonic TZSP traffic counters exported directly from The dashboard also uses monotonic TZSP traffic counters exported from the Rust
the in-memory flow tracker: receiver's 1 Hz telemetry bridge:
```text ```text
mikrosuricata_traffic_bytes_total{direction="total|inbound|outbound|internal|external"} mikrosuricata_traffic_bytes_total{direction="total|inbound|outbound|internal|external"}
@@ -21,6 +21,11 @@ bits/s. The top of the dashboard therefore shows current total, inbound and
outbound throughput, packet rate, capture drops, a large throughput chart and outbound throughput, packet rate, capture drops, a large throughput chart and
traffic volume by direction for the selected time range. traffic volume by direction for the selected time range.
Rust receive-path quality is exported separately. In particular,
`mikrosuricata_tzsp_receiver_kernel_udp_drops_total` is the Linux UDP socket
drop counter and should stay at zero during high-rate capture tests. The
dashboard includes it in **Sensor loss & decode errors**.
`/metrics` is protected by an IP/CIDR ACL configured through environment variables. `/metrics` is protected by an IP/CIDR ACL configured through environment variables.
The default allows loopback only: The default allows loopback only:
@@ -73,3 +78,7 @@ scrape_configs:
Import `mikrosuricata-prometheus.json` in Grafana and select the Prometheus Import `mikrosuricata-prometheus.json` in Grafana and select the Prometheus
datasource from the dashboard variable. Rate, percentage and ratio panels are datasource from the dashboard variable. Rate, percentage and ratio panels are
calculated in PromQL, not by MikroSuricata. calculated in PromQL, not by MikroSuricata.
## Rust TZSP data-plane health (0.11.0+)
The receiver now exports both kernel and userspace back-pressure signals. In addition to `mikrosuricata_tzsp_receiver_kernel_udp_drops_total`, watch `mikrosuricata_tzsp_receiver_queue_dropped_datagrams_total`, `mikrosuricata_tzsp_receiver_queue_depth_batches`, `mikrosuricata_tzsp_receiver_queue_capacity_batches` and `mikrosuricata_tzsp_receiver_capture_efficiency_pct`. A small RouterOS kernel socket buffer is expected; loss should be judged by the drop counters, not by buffer size alone.
+26 -15
View File
@@ -648,7 +648,7 @@
{ {
"id": 25, "id": 25,
"type": "bargauge", "type": "bargauge",
"title": "Traffic mix \u00b7 selected range", "title": "Traffic mix · selected range",
"datasource": { "datasource": {
"type": "prometheus", "type": "prometheus",
"uid": "${datasource}" "uid": "${datasource}"
@@ -873,8 +873,8 @@
{ {
"id": 26, "id": 26,
"type": "stat", "type": "stat",
"title": "Active flows", "title": "TZSP RX buffer",
"description": "Currently tracked live L3/L4 flows.", "description": "Actual Linux UDP receive buffer allocated to the Rust TZSP receiver.",
"datasource": { "datasource": {
"type": "prometheus", "type": "prometheus",
"uid": "${datasource}" "uid": "${datasource}"
@@ -887,7 +887,7 @@
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"unit": "short", "unit": "bytes",
"thresholds": { "thresholds": {
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
@@ -926,7 +926,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "max(mikrosuricata_flow_tracker_active_flows{instance=~\"$instance\"})", "expr": "max(mikrosuricata_tzsp_receiver_rcvbuf_bytes{instance=~\"$instance\"})",
"legendFormat": "", "legendFormat": "",
"range": true, "range": true,
"refId": "A" "refId": "A"
@@ -1322,7 +1322,7 @@
"id": 8, "id": 8,
"type": "timeseries", "type": "timeseries",
"title": "Sensor loss & decode errors", "title": "Sensor loss & decode errors",
"description": "Rates that should stay near zero: kernel drops, injection errors, TZSP decode errors and Suricata alert queue overflow.", "description": "Rates that should stay near zero: Rust UDP socket drops, Suricata kernel drops, TAP injection errors, TZSP decode errors and alert queue overflow.",
"datasource": { "datasource": {
"type": "prometheus", "type": "prometheus",
"uid": "${datasource}" "uid": "${datasource}"
@@ -1438,6 +1438,17 @@
"legendFormat": "Alert queue overflow", "legendFormat": "Alert queue overflow",
"range": true, "range": true,
"refId": "D" "refId": "D"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(mikrosuricata_tzsp_receiver_kernel_udp_drops_total{instance=~\"$instance\"}[$__rate_interval]))",
"legendFormat": "TZSP UDP drops",
"range": true,
"refId": "E"
} }
] ]
}, },
@@ -2048,8 +2059,8 @@
{ {
"id": 14, "id": 14,
"type": "timeseries", "type": "timeseries",
"title": "Live sessions", "title": "Rust dataplane packet path",
"description": "", "description": "Inspected packet rate versus kernel UDP loss and userspace queue loss in the Rust TZSP data-plane.",
"datasource": { "datasource": {
"type": "prometheus", "type": "prometheus",
"uid": "${datasource}" "uid": "${datasource}"
@@ -2062,7 +2073,7 @@
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"unit": "short", "unit": "pps",
"custom": { "custom": {
"drawStyle": "line", "drawStyle": "line",
"lineInterpolation": "smooth", "lineInterpolation": "smooth",
@@ -2128,8 +2139,8 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "max(mikrosuricata_flow_tracker_active_flows{instance=~\"$instance\"})", "expr": "sum(rate(mikrosuricata_traffic_packets_total{instance=~\"$instance\",direction=\"total\"}[$__rate_interval]))",
"legendFormat": "Active flows", "legendFormat": "Inspected packets/s",
"range": true, "range": true,
"refId": "A" "refId": "A"
}, },
@@ -2139,8 +2150,8 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "max(mikrosuricata_event_bus_subscribers{instance=~\"$instance\"})", "expr": "sum(rate(mikrosuricata_tzsp_receiver_kernel_udp_drops_total{instance=~\"$instance\"}[$__rate_interval]))",
"legendFormat": "WebSocket subscribers", "legendFormat": "Kernel UDP drops/s",
"range": true, "range": true,
"refId": "B" "refId": "B"
}, },
@@ -2150,8 +2161,8 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rate(mikrosuricata_flow_tracker_evicted_flows_total{instance=~\"$instance\"}[$__rate_interval]))", "expr": "sum(rate(mikrosuricata_tzsp_receiver_queue_dropped_datagrams_total{instance=~\"$instance\"}[$__rate_interval]))",
"legendFormat": "Evictions/s", "legendFormat": "Userspace queue drops/s",
"range": true, "range": true,
"refId": "C" "refId": "C"
} }
+40
View File
@@ -0,0 +1,40 @@
# MikroSuricata hybrid TZSP capture template.
# Review the target IP/port before importing. l2Interface="all" means all interfaces;
# set it to a real RouterOS interface/bridge only if you intentionally want to
# constrain the low-volume non-IPv4 stream.
:local target "172.31.255.2"
:local port 37008
:local l2Interface "all"
:local l2MacProtocol "!ip"
# Routed IPv4 uses mangle sniff-tzsp. The action passes processing to the next
# mangle rule after cloning the packet, so existing mangle rules remain active.
/ip/firewall/mangle/remove [find where comment="MikroSuricata TZSP IPv4"]
:if ([:len [/ip/firewall/mangle/find]] > 0) do={
/ip/firewall/mangle/add chain=forward action=sniff-tzsp sniff-target=$target sniff-target-port=$port comment="MikroSuricata TZSP IPv4" place-before=0
} else={
/ip/firewall/mangle/add chain=forward action=sniff-tzsp sniff-target=$target sniff-target-port=$port comment="MikroSuricata TZSP IPv4"
}
# Everything except IPv4 EtherType is streamed by Packet Sniffer. This includes
# ARP, IPv6, LLDP, PPPoE discovery and other non-IPv4 Ethernet protocols.
/tool/sniffer/stop
:if ($l2Interface != "all") do={
:if ([:len [/interface/find where name=$l2Interface]] = 0) do={
:error ("L2 capture interface not found: " . $l2Interface)
}
}
/tool/sniffer/set only-headers=no max-packet-size=2048 streaming-enabled=yes filter-stream=yes filter-interface=$l2Interface filter-mac-address="" filter-src-mac-address="" filter-dst-mac-address="" filter-mac-protocol=$l2MacProtocol filter-ip-address="" filter-src-ip-address="" filter-dst-ip-address="" filter-ipv6-address="" filter-src-ipv6-address="" filter-dst-ipv6-address="" filter-ip-protocol="" filter-port="" filter-src-port="" filter-dst-port="" filter-vlan="" filter-cpu="" filter-size="" filter-direction=any filter-operator-between-entries=or
# Current RouterOS uses streaming-server=IP:PORT. Fallback supports older builds
# that expose streaming-port separately.
:do {
/tool/sniffer/set streaming-server=($target . ":" . $port)
} on-error={
/tool/sniffer/set streaming-server=$target streaming-port=$port
}
/tool/sniffer/start
/ip/firewall/mangle/print stats where comment="MikroSuricata TZSP IPv4"
/tool/sniffer/print
+1
View File
@@ -2,6 +2,7 @@
# directories on the external disk. Review before importing. # directories on the external disk. Review before importing.
/tool/sniffer/stop /tool/sniffer/stop
/ip/firewall/mangle/remove [find where comment="MikroSuricata TZSP IPv4"]
:if ([:len [/container/find where name="suricata-ids"]] > 0) do={ :if ([:len [/container/find where name="suricata-ids"]] > 0) do={
:local cid [/container/find where name="suricata-ids"] :local cid [/container/find where name="suricata-ids"]
:if ([/container/get $cid status] = "running") do={ :if ([/container/get $cid status] = "running") do={
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "mikrosuricata-tzsp"
version = "0.11.2"
edition = "2021"
publish = false
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
strip = true
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
CONFIG_FILE="${DEPLOY_CONFIG:-deploy-routeros.env}"
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
case "$CONFIG_FILE" in
/*) . "$CONFIG_FILE" ;;
*) . "./$CONFIG_FILE" ;;
esac
fi
: "${ROUTER_HOST:=192.168.88.1}"
: "${ROUTER_USER:=admin}"
: "${ROUTER_PORT:=22}"
: "${ROUTER_IDENTITY_FILE:=}"
: "${ROUTER_SCP_DIR:=/}"
: "${CONTAINER_IP:=172.31.255.2/30}"
: "${TZSP_PORT:=37008}"
: "${CONFIGURE_IPV4_MANGLE:=true}"
: "${CONFIGURE_L2_SNIFFER:=true}"
: "${START_L2_SNIFFER:=true}"
: "${TZSP_L2_INTERFACE:=}"
: "${TZSP_L2_MAC_PROTOCOL:=!ip}"
TZSP_L2_FILTER_INTERFACE="${TZSP_L2_INTERFACE:-all}"
: "${KEEP_REMOTE_RSC:=false}"
: "${DRY_RUN:=false}"
case "$ROUTER_PORT" in *[!0-9]*|'') echo "ROUTER_PORT must be numeric" >&2; exit 2 ;; esac
case "$TZSP_PORT" in *[!0-9]*|'') echo "TZSP_PORT must be numeric" >&2; exit 2 ;; esac
for bool_pair in \
"CONFIGURE_IPV4_MANGLE=$CONFIGURE_IPV4_MANGLE" \
"CONFIGURE_L2_SNIFFER=$CONFIGURE_L2_SNIFFER" \
"START_L2_SNIFFER=$START_L2_SNIFFER" \
"DRY_RUN=$DRY_RUN"
do
case "${bool_pair#*=}" in
true|false) ;;
*) echo "${bool_pair%%=*} must be true or false" >&2; exit 2 ;;
esac
done
check_ros_value() {
label="$1"
value="$2"
case "$value" in
*'"'*|*'\\'*|*'$'*|*';'*|*'`'*)
echo "$label contains a character not supported by this helper: $value" >&2
exit 3
;;
esac
}
check_ros_value CONTAINER_IP "$CONTAINER_IP"
check_ros_value TZSP_L2_INTERFACE "$TZSP_L2_INTERFACE"
check_ros_value TZSP_L2_MAC_PROTOCOL "$TZSP_L2_MAC_PROTOCOL"
TARGET="${ROUTER_USER}@${ROUTER_HOST}"
ssh_run() {
if [ -n "$ROUTER_IDENTITY_FILE" ]; then
ssh -i "$ROUTER_IDENTITY_FILE" -p "$ROUTER_PORT" "$TARGET" "$1"
else
ssh -p "$ROUTER_PORT" "$TARGET" "$1"
fi
}
scp_put() {
src="$1"
dst="$2"
if [ -n "$ROUTER_IDENTITY_FILE" ]; then
scp -i "$ROUTER_IDENTITY_FILE" -P "$ROUTER_PORT" "$src" "${TARGET}:$dst"
else
scp -P "$ROUTER_PORT" "$src" "${TARGET}:$dst"
fi
}
CONTAINER_IP_ONLY="${CONTAINER_IP%/*}"
ID="$(date -u +%Y%m%d%H%M%S)"
mkdir -p build
LOCAL_RSC="build/configure-tzsp-hybrid-${ID}.rsc"
REMOTE_NAME="configure-tzsp-hybrid-${ID}.rsc"
REMOTE_PATH="${ROUTER_SCP_DIR%/}/${REMOTE_NAME}"
[ "$ROUTER_SCP_DIR" = "/" ] && REMOTE_PATH="/${REMOTE_NAME}"
cat > "$LOCAL_RSC" <<RSC
# MikroSuricata hybrid TZSP capture.
# Generated by scripts/configure-routeros-tzsp-hybrid.sh at ${ID} UTC.
RSC
if [ "$CONFIGURE_IPV4_MANGLE" = "true" ]; then
cat >> "$LOCAL_RSC" <<RSC
# Routed IPv4: high-throughput path. Only the MikroSuricata-owned rule is replaced.
/ip/firewall/mangle/remove [find where comment="MikroSuricata TZSP IPv4"]
:if ([:len [/ip/firewall/mangle/find]] > 0) do={
/ip/firewall/mangle/add chain=forward action=sniff-tzsp sniff-target="${CONTAINER_IP_ONLY}" sniff-target-port=${TZSP_PORT} comment="MikroSuricata TZSP IPv4" place-before=0
} else={
/ip/firewall/mangle/add chain=forward action=sniff-tzsp sniff-target="${CONTAINER_IP_ONLY}" sniff-target-port=${TZSP_PORT} comment="MikroSuricata TZSP IPv4"
}
RSC
fi
if [ "$CONFIGURE_L2_SNIFFER" = "true" ]; then
cat >> "$LOCAL_RSC" <<RSC
# Non-IPv4 Ethernet: low-volume Packet Sniffer complement.
# Empty TZSP_L2_INTERFACE intentionally means all interfaces; no bridge name is assumed.
/tool/sniffer/stop
:if ("${TZSP_L2_INTERFACE}" != "") do={
:if ([:len [/interface/find where name="${TZSP_L2_INTERFACE}"]] = 0) do={
:error "TZSP_L2_INTERFACE not found: ${TZSP_L2_INTERFACE}"
}
}
/tool/sniffer/set only-headers=no max-packet-size=2048 streaming-enabled=yes filter-stream=yes filter-interface="${TZSP_L2_FILTER_INTERFACE}" filter-mac-address="" filter-src-mac-address="" filter-dst-mac-address="" filter-mac-protocol=${TZSP_L2_MAC_PROTOCOL} filter-ip-address="" filter-src-ip-address="" filter-dst-ip-address="" filter-ipv6-address="" filter-src-ipv6-address="" filter-dst-ipv6-address="" filter-ip-protocol="" filter-port="" filter-src-port="" filter-dst-port="" filter-vlan="" filter-cpu="" filter-size="" filter-direction=any filter-operator-between-entries=or
:do {
/tool/sniffer/set streaming-server=${CONTAINER_IP_ONLY}:${TZSP_PORT}
} on-error={
/tool/sniffer/set streaming-server=${CONTAINER_IP_ONLY} streaming-port=${TZSP_PORT}
}
RSC
if [ "$START_L2_SNIFFER" = "true" ]; then
cat >> "$LOCAL_RSC" <<'RSC'
/tool/sniffer/start
RSC
fi
fi
cat >> "$LOCAL_RSC" <<'RSC'
/ip/firewall/mangle/print stats where comment="MikroSuricata TZSP IPv4"
/tool/sniffer/print
RSC
if [ "$DRY_RUN" = "true" ]; then
cat "$LOCAL_RSC"
echo "[capture] dry run only; RouterOS was not modified"
exit 0
fi
command -v ssh >/dev/null 2>&1 || { echo "ssh is required" >&2; exit 2; }
command -v scp >/dev/null 2>&1 || { echo "scp is required" >&2; exit 2; }
echo "[capture] preflight ${TARGET}"
ssh_run '/ip/firewall/mangle/print' >/dev/null
if [ "$CONFIGURE_L2_SNIFFER" = "true" ]; then
ssh_run '/tool/sniffer/print' >/dev/null
fi
echo "[capture] uploading $REMOTE_PATH"
scp_put "$LOCAL_RSC" "$REMOTE_PATH"
echo "[capture] applying hybrid capture"
ssh_run "/import file-name=\"${REMOTE_NAME}\""
if [ "$KEEP_REMOTE_RSC" != "true" ]; then
ssh_run "/file/remove [find where name=\"${REMOTE_NAME}\"]" || true
fi
echo "[capture] done"
echo "[capture] IPv4: mangle sniff-tzsp -> ${CONTAINER_IP_ONLY}:${TZSP_PORT}"
if [ "$CONFIGURE_L2_SNIFFER" = "true" ]; then
if [ -n "$TZSP_L2_INTERFACE" ]; then
echo "[capture] non-IPv4: Packet Sniffer on ${TZSP_L2_INTERFACE} -> ${CONTAINER_IP_ONLY}:${TZSP_PORT}"
else
echo "[capture] non-IPv4: Packet Sniffer on all interfaces -> ${CONTAINER_IP_ONLY}:${TZSP_PORT}"
fi
fi
+84 -12
View File
@@ -21,6 +21,14 @@ esac
CONTAINER_NAME="suricata_${VERSION}" CONTAINER_NAME="suricata_${VERSION}"
ROOT_DIR="/containers/${CONTAINER_NAME}/root" ROOT_DIR="/containers/${CONTAINER_NAME}/root"
# Backward-compatible aliases for pre-0.11.2 deploy-routeros.env files.
if [ "${CONFIGURE_TZSP_CAPTURE+x}" != x ] && [ "${CONFIGURE_SNIFFER+x}" = x ]; then
CONFIGURE_TZSP_CAPTURE="$CONFIGURE_SNIFFER"
fi
if [ "${START_L2_SNIFFER+x}" != x ] && [ "${START_SNIFFER+x}" = x ]; then
START_L2_SNIFFER="$START_SNIFFER"
fi
: "${ROUTER_HOST:=192.168.88.1}" : "${ROUTER_HOST:=192.168.88.1}"
: "${ROUTER_USER:=admin}" : "${ROUTER_USER:=admin}"
: "${ROUTER_PORT:=22}" : "${ROUTER_PORT:=22}"
@@ -32,14 +40,22 @@ ROOT_DIR="/containers/${CONTAINER_NAME}/root"
: "${CONTAINER_SUBNET:=172.31.255.0/30}" : "${CONTAINER_SUBNET:=172.31.255.0/30}"
: "${CONTAINER_BRIDGE:=br-ids}" : "${CONTAINER_BRIDGE:=br-ids}"
: "${CONTAINER_VETH:=veth-ids}" : "${CONTAINER_VETH:=veth-ids}"
: "${VLAN_ID:=100}"
: "${TZSP_PORT:=37008}" : "${TZSP_PORT:=37008}"
: "${CONFIGURE_SNIFFER:=true}" : "${CONFIGURE_TZSP_CAPTURE:=true}"
: "${START_SNIFFER:=true}" : "${CONFIGURE_IPV4_MANGLE:=true}"
: "${CONFIGURE_L2_SNIFFER:=true}"
: "${START_L2_SNIFFER:=true}"
: "${TZSP_L2_INTERFACE:=}"
: "${TZSP_L2_MAC_PROTOCOL:=!ip}"
TZSP_L2_FILTER_INTERFACE="${TZSP_L2_INTERFACE:-all}"
: "${TZSP_RCVBUF_BYTES:=33554432}"
: "${TZSP_BATCH_SIZE:=256}"
: "${TZSP_QUEUE_MB:=64}"
: "${TZSP_DATAGRAM_BYTES:=12288}"
: "${SURICATA_HOME_NET:=[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]}" : "${SURICATA_HOME_NET:=[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]}"
: "${MONITORED_NETWORKS:=192.168.0.0/16,10.0.0.0/8,172.16.0.0/12}" : "${MONITORED_NETWORKS:=192.168.0.0/16,10.0.0.0/8,172.16.0.0/12}"
: "${SURICATA_LOG_MAX_MB:=512}" : "${SURICATA_LOG_MAX_MB:=512}"
: "${FORENSIC_PCAP_MODE:=blocks}" : "${FORENSIC_PCAP_MODE:=alerts}"
: "${FORENSIC_PCAP_WINDOW_SECONDS:=60}" : "${FORENSIC_PCAP_WINDOW_SECONDS:=60}"
: "${FORENSIC_PCAP_MEMORY_MB:=64}" : "${FORENSIC_PCAP_MEMORY_MB:=64}"
: "${FORENSIC_PCAP_MAX_FILES:=32}" : "${FORENSIC_PCAP_MAX_FILES:=32}"
@@ -126,9 +142,6 @@ need scp
case "$ROUTER_PORT" in case "$ROUTER_PORT" in
*[!0-9]*|'') echo "ROUTER_PORT must be numeric" >&2; exit 2 ;; *[!0-9]*|'') echo "ROUTER_PORT must be numeric" >&2; exit 2 ;;
esac esac
case "$VLAN_ID" in
*[!0-9]*|'') echo "VLAN_ID must be numeric" >&2; exit 2 ;;
esac
case "$TZSP_PORT" in case "$TZSP_PORT" in
*[!0-9]*|'') echo "TZSP_PORT must be numeric" >&2; exit 2 ;; *[!0-9]*|'') echo "TZSP_PORT must be numeric" >&2; exit 2 ;;
esac esac
@@ -136,6 +149,17 @@ case "$FORENSIC_PCAP_MODE" in
blocks|alerts|all|off) ;; blocks|alerts|all|off) ;;
*) echo "FORENSIC_PCAP_MODE must be one of: blocks, alerts, all, off" >&2; exit 2 ;; *) echo "FORENSIC_PCAP_MODE must be one of: blocks, alerts, all, off" >&2; exit 2 ;;
esac esac
for bool_pair in \
"CONFIGURE_TZSP_CAPTURE=$CONFIGURE_TZSP_CAPTURE" \
"CONFIGURE_IPV4_MANGLE=$CONFIGURE_IPV4_MANGLE" \
"CONFIGURE_L2_SNIFFER=$CONFIGURE_L2_SNIFFER" \
"START_L2_SNIFFER=$START_L2_SNIFFER"
do
case "${bool_pair#*=}" in
true|false) ;;
*) echo "${bool_pair%%=*} must be true or false" >&2; exit 2 ;;
esac
done
if { [ -n "$METRICS_BASIC_AUTH_USERNAME" ] && [ -z "$METRICS_BASIC_AUTH_PASSWORD" ]; } || \ if { [ -n "$METRICS_BASIC_AUTH_USERNAME" ] && [ -z "$METRICS_BASIC_AUTH_PASSWORD" ]; } || \
{ [ -z "$METRICS_BASIC_AUTH_USERNAME" ] && [ -n "$METRICS_BASIC_AUTH_PASSWORD" ]; }; then { [ -z "$METRICS_BASIC_AUTH_USERNAME" ] && [ -n "$METRICS_BASIC_AUTH_PASSWORD" ]; }; then
echo "METRICS_BASIC_AUTH_USERNAME and METRICS_BASIC_AUTH_PASSWORD must both be set or both be empty" >&2 echo "METRICS_BASIC_AUTH_USERNAME and METRICS_BASIC_AUTH_PASSWORD must both be set or both be empty" >&2
@@ -143,6 +167,10 @@ if { [ -n "$METRICS_BASIC_AUTH_USERNAME" ] && [ -z "$METRICS_BASIC_AUTH_PASSWORD
fi fi
for numeric_pair in \ for numeric_pair in \
"REDIS_PORT=$REDIS_PORT" \ "REDIS_PORT=$REDIS_PORT" \
"TZSP_RCVBUF_BYTES=$TZSP_RCVBUF_BYTES" \
"TZSP_BATCH_SIZE=$TZSP_BATCH_SIZE" \
"TZSP_QUEUE_MB=$TZSP_QUEUE_MB" \
"TZSP_DATAGRAM_BYTES=$TZSP_DATAGRAM_BYTES" \
"SURICATA_LOG_MAX_MB=$SURICATA_LOG_MAX_MB" \ "SURICATA_LOG_MAX_MB=$SURICATA_LOG_MAX_MB" \
"FORENSIC_PCAP_WINDOW_SECONDS=$FORENSIC_PCAP_WINDOW_SECONDS" \ "FORENSIC_PCAP_WINDOW_SECONDS=$FORENSIC_PCAP_WINDOW_SECONDS" \
"FORENSIC_PCAP_MEMORY_MB=$FORENSIC_PCAP_MEMORY_MB" \ "FORENSIC_PCAP_MEMORY_MB=$FORENSIC_PCAP_MEMORY_MB" \
@@ -193,6 +221,8 @@ for pair in \
"CONTAINER_SUBNET=$CONTAINER_SUBNET" \ "CONTAINER_SUBNET=$CONTAINER_SUBNET" \
"CONTAINER_BRIDGE=$CONTAINER_BRIDGE" \ "CONTAINER_BRIDGE=$CONTAINER_BRIDGE" \
"CONTAINER_VETH=$CONTAINER_VETH" \ "CONTAINER_VETH=$CONTAINER_VETH" \
"TZSP_L2_INTERFACE=$TZSP_L2_INTERFACE" \
"TZSP_L2_MAC_PROTOCOL=$TZSP_L2_MAC_PROTOCOL" \
"SURICATA_HOME_NET=$SURICATA_HOME_NET" \ "SURICATA_HOME_NET=$SURICATA_HOME_NET" \
"MONITORED_NETWORKS=$MONITORED_NETWORKS" \ "MONITORED_NETWORKS=$MONITORED_NETWORKS" \
"BLOCK_TIMEOUT=$BLOCK_TIMEOUT" \ "BLOCK_TIMEOUT=$BLOCK_TIMEOUT" \
@@ -248,11 +278,19 @@ if ! ssh_run "/file/print without-paging where name=\"${IMAGE_TAR_ROS}\"" | grep
echo "Upload it first with scripts/upload-routeros-image.sh." >&2 echo "Upload it first with scripts/upload-routeros-image.sh." >&2
exit 5 exit 5
fi fi
if [ "$CONFIGURE_SNIFFER" = "true" ]; then if [ "$CONFIGURE_TZSP_CAPTURE" = "true" ]; then
if [ "$CONFIGURE_IPV4_MANGLE" = "true" ]; then
if ! ssh_run '/ip/firewall/mangle/print' >/dev/null; then
echo "RouterOS IPv4 mangle is unavailable." >&2
exit 4
fi
fi
if [ "$CONFIGURE_L2_SNIFFER" = "true" ]; then
if ! ssh_run '/tool/sniffer/print' >/dev/null; then if ! ssh_run '/tool/sniffer/print' >/dev/null; then
echo "RouterOS sniffer is unavailable." >&2 echo "RouterOS sniffer is unavailable." >&2
exit 4 exit 4
fi fi
fi
fi fi
if [ "$CREATE_REST_USER" = "true" ] && [ "$ROUTEROS_REST_PASSWORD" = "CHANGE_ME" ]; then if [ "$CREATE_REST_USER" = "true" ] && [ "$ROUTEROS_REST_PASSWORD" = "CHANGE_ME" ]; then
@@ -298,6 +336,10 @@ cat > "$LOCAL_RSC" <<RSC
/container/envs/remove [find where list="IDS_ENV"] /container/envs/remove [find where list="IDS_ENV"]
/container/envs/add list=IDS_ENV key=TZSP_BIND value="0.0.0.0" /container/envs/add list=IDS_ENV key=TZSP_BIND value="0.0.0.0"
/container/envs/add list=IDS_ENV key=TZSP_PORT value="${TZSP_PORT}" /container/envs/add list=IDS_ENV key=TZSP_PORT value="${TZSP_PORT}"
/container/envs/add list=IDS_ENV key=TZSP_RCVBUF_BYTES value="${TZSP_RCVBUF_BYTES}"
/container/envs/add list=IDS_ENV key=TZSP_BATCH_SIZE value="${TZSP_BATCH_SIZE}"
/container/envs/add list=IDS_ENV key=TZSP_QUEUE_MB value="${TZSP_QUEUE_MB}"
/container/envs/add list=IDS_ENV key=TZSP_DATAGRAM_BYTES value="${TZSP_DATAGRAM_BYTES}"
/container/envs/add list=IDS_ENV key=TAP_NAME value="suritap0" /container/envs/add list=IDS_ENV key=TAP_NAME value="suritap0"
/container/envs/add list=IDS_ENV key=TAP_MTU value="9000" /container/envs/add list=IDS_ENV key=TAP_MTU value="9000"
/container/envs/add list=IDS_ENV key=SURICATA_HOME_NET value="${SURICATA_HOME_NET}" /container/envs/add list=IDS_ENV key=SURICATA_HOME_NET value="${SURICATA_HOME_NET}"
@@ -410,17 +452,47 @@ cat >> "$LOCAL_RSC" <<RSC
:delay 5s :delay 5s
RSC RSC
if [ "$CONFIGURE_SNIFFER" = "true" ]; then if [ "$CONFIGURE_TZSP_CAPTURE" = "true" ]; then
if [ "$CONFIGURE_IPV4_MANGLE" = "true" ]; then
cat >> "$LOCAL_RSC" <<RSC cat >> "$LOCAL_RSC" <<RSC
/tool/sniffer/stop # High-throughput routed IPv4 capture. sniff-tzsp continues to the next mangle
/tool/sniffer/set filter-vlan=${VLAN_ID} filter-direction=any filter-stream=yes only-headers=no streaming-enabled=yes streaming-server="${CONTAINER_IP_ONLY}" streaming-port=${TZSP_PORT} # rule after cloning the packet, so existing mangle processing remains active.
/ip/firewall/mangle/remove [find where comment="MikroSuricata TZSP IPv4"]
:if ([:len [/ip/firewall/mangle/find]] > 0) do={
/ip/firewall/mangle/add chain=forward action=sniff-tzsp sniff-target="${CONTAINER_IP_ONLY}" sniff-target-port=${TZSP_PORT} comment="MikroSuricata TZSP IPv4" place-before=0
} else={
/ip/firewall/mangle/add chain=forward action=sniff-tzsp sniff-target="${CONTAINER_IP_ONLY}" sniff-target-port=${TZSP_PORT} comment="MikroSuricata TZSP IPv4"
}
RSC RSC
if [ "$START_SNIFFER" = "true" ]; then fi
if [ "$CONFIGURE_L2_SNIFFER" = "true" ]; then
cat >> "$LOCAL_RSC" <<RSC
# Low-volume complement for everything that is not IPv4 EtherType 0x0800.
# No bridge/interface name is assumed. Empty TZSP_L2_INTERFACE maps to RouterOS "all".
/tool/sniffer/stop
:if ("${TZSP_L2_INTERFACE}" != "") do={
:if ([:len [/interface/find where name="${TZSP_L2_INTERFACE}"]] = 0) do={
:error "TZSP_L2_INTERFACE not found: ${TZSP_L2_INTERFACE}"
}
}
/tool/sniffer/set only-headers=no max-packet-size=2048 streaming-enabled=yes filter-stream=yes filter-interface="${TZSP_L2_FILTER_INTERFACE}" filter-mac-address="" filter-src-mac-address="" filter-dst-mac-address="" filter-mac-protocol=${TZSP_L2_MAC_PROTOCOL} filter-ip-address="" filter-src-ip-address="" filter-dst-ip-address="" filter-ipv6-address="" filter-src-ipv6-address="" filter-dst-ipv6-address="" filter-ip-protocol="" filter-port="" filter-src-port="" filter-dst-port="" filter-vlan="" filter-cpu="" filter-size="" filter-direction=any filter-operator-between-entries=or
# RouterOS current syntax uses streaming-server=IP:PORT. Older releases used a
# separate streaming-port property, so retain a fallback without assuming either.
:do {
/tool/sniffer/set streaming-server=${CONTAINER_IP_ONLY}:${TZSP_PORT}
} on-error={
/tool/sniffer/set streaming-server=${CONTAINER_IP_ONLY} streaming-port=${TZSP_PORT}
}
RSC
if [ "$START_L2_SNIFFER" = "true" ]; then
cat >> "$LOCAL_RSC" <<'RSC' cat >> "$LOCAL_RSC" <<'RSC'
/tool/sniffer/start /tool/sniffer/start
RSC RSC
fi fi
fi
fi fi
cat >> "$LOCAL_RSC" <<RSC cat >> "$LOCAL_RSC" <<RSC
+2 -1
View File
@@ -11,7 +11,8 @@ mkdir -p \
"$PERSIST_LOG_DIR" \ "$PERSIST_LOG_DIR" \
"$PERSIST_LIB_DIR/rules" \ "$PERSIST_LIB_DIR/rules" \
"$PERSIST_STATE_DIR" \ "$PERSIST_STATE_DIR" \
/run/suricata /run/suricata \
/run/mikrosuricata
if ! id -u suricata >/dev/null 2>&1 || ! getent group suricata >/dev/null 2>&1; then if ! id -u suricata >/dev/null 2>&1 || ! getent group suricata >/dev/null 2>&1; then
echo "[entrypoint] FATAL: missing suricata user/group in the image; rebuild the image from the current Dockerfile" >&2 echo "[entrypoint] FATAL: missing suricata user/group in the image; rebuild the image from the current Dockerfile" >&2
+1
View File
@@ -18,5 +18,6 @@ run() {
fi fi
} }
run "/container/print detail where name=\"${CONTAINER_NAME}\"" run "/container/print detail where name=\"${CONTAINER_NAME}\""
run "/ip/firewall/mangle/print stats where comment=\"MikroSuricata TZSP IPv4\""
run "/tool/sniffer/print" run "/tool/sniffer/print"
run "/log/print without-paging where message~\"suricata|TZSP|IDS\"" run "/log/print without-paging where message~\"suricata|TZSP|IDS\""
+40
View File
@@ -68,3 +68,43 @@ def test_routeros_deploy_forwards_ndr_and_persistence_controls():
"METRICS_BASIC_AUTH_PASSWORD", "METRICS_BASIC_AUTH_PASSWORD",
): ):
assert f"key={key}" in script assert f"key={key}" in script
def test_routeros_deploy_uses_hybrid_tzsp_capture_without_site_bridge_assumption():
script = (ROOT / "scripts" / "deploy-routeros.sh").read_text()
env_example = (ROOT / "deploy-routeros.env.example").read_text()
assert 'action=sniff-tzsp' in script
assert 'chain=forward' in script
assert 'comment="MikroSuricata TZSP IPv4"' in script
assert '/ip/firewall/mangle/remove [find where comment="MikroSuricata TZSP IPv4"]' in script
assert 'filter-mac-protocol=${TZSP_L2_MAC_PROTOCOL}' in script
assert 'TZSP_L2_FILTER_INTERFACE="${TZSP_L2_INTERFACE:-all}"' in script
assert 'filter-interface="${TZSP_L2_FILTER_INTERFACE}"' in script
assert 'streaming-server=${CONTAINER_IP_ONLY}:${TZSP_PORT}' in script
assert 'streaming-port=${TZSP_PORT}' in script # compatibility fallback only
assert 'VLAN_ID' not in env_example
assert 'TZSP_L2_INTERFACE=' in env_example
assert 'TZSP_L2_MAC_PROTOCOL=!ip' in env_example
assert 'bridge-trunk' not in script
def test_hybrid_capture_migration_helper_supports_dry_run_and_same_owned_rule():
script = (ROOT / "scripts" / "configure-routeros-tzsp-hybrid.sh").read_text()
assert 'DRY_RUN' in script
assert 'action=sniff-tzsp' in script
assert 'comment="MikroSuricata TZSP IPv4"' in script
assert 'filter-mac-protocol=${TZSP_L2_MAC_PROTOCOL}' in script
assert 'TZSP_L2_FILTER_INTERFACE="${TZSP_L2_INTERFACE:-all}"' in script
assert 'streaming-server=${CONTAINER_IP_ONLY}:${TZSP_PORT}' in script
def test_routeros_manual_capture_template_is_hybrid_and_not_vlan_specific():
hybrid = ROOT / "routeros" / "02-tzsp-hybrid.rsc"
assert hybrid.exists()
assert not (ROOT / "routeros" / "02-sniffer-vlan100.rsc").exists()
text = hybrid.read_text()
assert 'action=sniff-tzsp' in text
assert 'filter-mac-protocol=$l2MacProtocol' in text
assert ':local l2Interface "all"' in text
assert 'filter-vlan=100' not in text
+44
View File
@@ -27,6 +27,50 @@ class _Status:
class PrometheusMetricsTests(unittest.TestCase): class PrometheusMetricsTests(unittest.TestCase):
def test_render_exports_rust_receiver_quality_metrics(self):
stats = RuntimeStats()
metrics = PrometheusMetrics(
stats,
version="0.11.0",
mode="full",
started_at=datetime(2026, 8, 16, 6, 0, tzinfo=timezone.utc),
state_provider=lambda: {"components": {}, "features": {}},
flow_tracker=_Status(
engine="rust",
rcvbuf_bytes=67_108_864,
batch_size=256,
datagram_bytes=12288,
queue_capacity_batches=24,
queue_capacity_bytes=67_108_864,
queue_depth_batches=2,
queue_high_water_batches=9,
capture_efficiency_pct=99.9,
rx_thread_alive=True,
worker_thread_alive=True,
telemetry_age_ms=125,
process_alive=True,
ready=True,
kernel_udp_drops=7,
queue_dropped_datagrams=11,
truncated_datagrams=0,
telemetry_errors=0,
traffic_counters={
"bytes_total": 125_000_000,
"packets_total": 80_000,
},
),
)
rendered = metrics.render()
self.assertIn('mikrosuricata_tzsp_receiver_info{engine="rust"} 1', rendered)
self.assertIn("mikrosuricata_tzsp_receiver_rcvbuf_bytes 67108864", rendered)
self.assertIn("mikrosuricata_tzsp_receiver_batch_size 256", rendered)
self.assertIn("mikrosuricata_tzsp_receiver_queue_depth_batches 2", rendered)
self.assertIn("mikrosuricata_tzsp_receiver_capture_efficiency_pct 99.9", rendered)
self.assertIn("mikrosuricata_tzsp_receiver_kernel_udp_drops_total 7", rendered)
self.assertIn("mikrosuricata_tzsp_receiver_queue_dropped_datagrams_total 11", rendered)
self.assertIn('mikrosuricata_traffic_bytes_total{direction="total"} 125000000', rendered)
self.assertNotIn("mikrosuricata_flow_tracker_", rendered)
def test_render_exports_raw_runtime_suricata_and_in_memory_component_state(self): def test_render_exports_raw_runtime_suricata_and_in_memory_component_state(self):
stats = RuntimeStats() stats = RuntimeStats()
stats.inc("tzsp_datagrams", 5) stats.inc("tzsp_datagrams", 5)
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_production_main_uses_rust_receiver_not_python_packet_receiver():
main = (ROOT / "app" / "main.py").read_text()
assert "from .tzsp_rust import RustTZSPReceiver" in main
assert "from .tzsp import TZSPReceiver" not in main
assert "TapDevice(" not in main
assert "flow_tracker.observe(" not in main
def test_docker_builds_and_tests_rust_receiver_before_runtime_copy():
dockerfile = (ROOT / "Dockerfile").read_text()
assert "AS rust-builder" in dockerfile
assert "cargo test --release" in dockerfile
assert "cargo build --release" in dockerfile
assert "COPY --from=rust-builder" in dockerfile
assert "/usr/local/bin/mikrosuricata-tzsp" in dockerfile
def test_rust_receiver_is_dependency_free_and_uses_batched_linux_receive():
cargo = (ROOT / "rust" / "tzsp-receiver" / "Cargo.toml").read_text()
source = (ROOT / "rust" / "tzsp-receiver" / "src" / "main.rs").read_text()
assert "[dependencies]" not in cargo
assert "recvmmsg(" in source
assert "MSG_WAITFORONE" in source
assert "sync_channel::<BatchBuffer>" in source
assert 'name("tzsp-udp-rx"' in source
assert 'name("tzsp-tap-worker"' in source
assert "queue_dropped_datagrams" in source
assert "SO_RCVBUFFORCE" in source
assert "TUNSETIFF" in source
assert "tap.write(frame)" in source
assert "write_all(frame)" not in source
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import threading
import time
import unittest
from app.state import RuntimeStats
from app.tzsp_rust import RustTZSPReceiver
class RustTZSPReceiverTelemetryTests(unittest.TestCase):
def _receiver(self, sink=None):
return RustTZSPReceiver(
binary="/does/not/start-in-unit-tests",
telemetry_socket="/tmp/unused-tzsp-test.sock",
stats=RuntimeStats(),
stop_event=threading.Event(),
throughput_sink=sink,
)
def test_telemetry_sample_drives_live_rate_without_packet_bytes_in_python(self):
written = []
receiver = self._receiver(written.append)
now_ms = int(time.time() * 1000)
receiver._ingest(
{
"type": "tzsp_sample",
"engine": "rust",
"ready": True,
"ts_ms": now_ms,
"interval_ms": 1000,
"last_packet_ms": now_ms,
"bytes_total": 125_000_000,
"bytes_in": 100_000_000,
"bytes_out": 25_000_000,
"packets_total": 80_000,
"traffic_counters": {"bytes_total": 125_000_000, "packets_total": 80_000},
"kernel_udp_drops": 3,
"kernel_udp_drops_interval": 0,
"queue_dropped_datagrams": 0,
"queue_drops_interval": 0,
"truncated_datagrams": 0,
"truncated_interval": 0,
"queue_depth_batches": 1,
"queue_capacity_batches": 24,
"queue_capacity_bytes": 64 * 1024 * 1024,
"capture_efficiency_pct": 100.0,
"rx_datagrams_interval": 80_000,
"rx_bytes_interval": 126_000_000,
"rx_thread_alive": True,
"worker_thread_alive": True,
"rcvbuf_bytes": 425_984,
"batch_size": 256,
"datagram_bytes": 12_288,
}
)
current = receiver.current_throughput(3600)
self.assertEqual(current["current_bps"], 1_000_000_000)
self.assertEqual(current["current_in_bps"], 800_000_000)
self.assertEqual(current["current_out_bps"], 200_000_000)
self.assertEqual(current["current_pps"], 80_000)
self.assertEqual(current["kernel_udp_drops"], 3)
self.assertEqual(current["current_ingress_bps"], 1_008_000_000)
self.assertEqual(current["capture_efficiency_pct"], 100.0)
self.assertEqual(current["queue_fill_pct"], 4.2)
self.assertEqual(current["loss_pps"], 0)
self.assertTrue(current["rx_thread_alive"])
self.assertTrue(current["worker_thread_alive"])
self.assertEqual(current["receiver_engine"], "rust")
self.assertEqual(len(written), 1)
self.assertEqual(written[0]["bytes_total"], 125_000_000)
def test_ingress_and_inspection_are_reported_separately_when_pipeline_is_behind(self):
receiver = self._receiver()
now_ms = int(time.time() * 1000)
receiver._last = {
"ts_ms": now_ms,
"interval_ms": 1000,
"bytes_total": 31_250_000,
"packets_total": 24_000,
"rx_bytes_interval": 125_000_000,
"rx_datagrams_interval": 80_000,
"kernel_udp_drops_interval": 10,
"queue_drops_interval": 90,
"truncated_interval": 0,
"queue_depth_batches": 20,
"queue_capacity_batches": 22,
"capture_efficiency_pct": 99.875,
}
current = receiver.current_throughput(900)
self.assertEqual(current["current_bps"], 250_000_000)
self.assertEqual(current["current_ingress_bps"], 1_000_000_000)
self.assertEqual(current["inspection_ratio_pct"], 25.0)
self.assertEqual(current["queue_fill_pct"], 90.9)
self.assertEqual(current["loss_pps"], 100.0)
def test_stale_sample_reports_zero_current_rate(self):
receiver = self._receiver()
receiver._last = {
"ts_ms": int(time.time() * 1000) - 10_000,
"interval_ms": 1000,
"bytes_total": 125_000_000,
"packets_total": 80_000,
}
current = receiver.current_throughput(900)
self.assertFalse(current["current_sample_fresh"])
self.assertEqual(current["current_bps"], 0)
self.assertEqual(current["current_pps"], 0)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -65,7 +65,7 @@ class WebUITests(unittest.TestCase):
self.assertIn(f'data-subtab-panel="intelligence:{tab}"', DASHBOARD) self.assertIn(f'data-subtab-panel="intelligence:{tab}"', DASHBOARD)
self.assertIn("Asset intelligence", DASHBOARD) self.assertIn("Asset intelligence", DASHBOARD)
self.assertIn("Threat intelligence repository", DASHBOARD) self.assertIn("Threat intelligence repository", DASHBOARD)
self.assertIn("Forensic PCAP ring", DASHBOARD) self.assertIn("Forensic PCAP", DASHBOARD)
def test_system_has_dedicated_redis_status_panel(self): def test_system_has_dedicated_redis_status_panel(self):
self.assertIn('<h2>Redis</h2>', DASHBOARD) self.assertIn('<h2>Redis</h2>', DASHBOARD)