This commit is contained in:
Mateusz Gruszczyński
2026-08-17 10:07:19 +02:00
parent 074d17be89
commit cc3c446c8e
29 changed files with 627 additions and 105 deletions
+5 -5
View File
@@ -110,7 +110,7 @@ ENV PYTHONUNBUFFERED=1 \
METRICS_BASIC_AUTH_PASSWORD= \ METRICS_BASIC_AUTH_PASSWORD= \
SESSION_HOURS=168 \ SESSION_HOURS=168 \
SESSION_COOKIE_SECURE=false \ SESSION_COOKIE_SECURE=false \
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 \ ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=120 \
AUTO_BLOCK=false \ AUTO_BLOCK=false \
UPDATE_RULES_ON_START=false \ UPDATE_RULES_ON_START=false \
RULE_UPDATE_INTERVAL_HOURS=24 \ RULE_UPDATE_INTERVAL_HOURS=24 \
@@ -118,11 +118,11 @@ ENV PYTHONUNBUFFERED=1 \
REDIS_MANAGED=true \ REDIS_MANAGED=true \
REDIS_DATA_DIR=/data/redis \ REDIS_DATA_DIR=/data/redis \
REDIS_PORT=6379 \ REDIS_PORT=6379 \
REDIS_MAXMEMORY_MB=0 \ REDIS_MAXMEMORY_MB=128 \
REDIS_SNAPSHOT_SECONDS=1800 \ REDIS_SNAPSHOT_SECONDS=0 \
REDIS_AOF=true \ REDIS_AOF=false \
TRAFFIC_RETENTION_HOURS=24 \ TRAFFIC_RETENTION_HOURS=24 \
TRAFFIC_MAX_EVENTS=0 \ TRAFFIC_MAX_EVENTS=50000 \
TRAFFIC_MEMORY_EVENTS=0 \ TRAFFIC_MEMORY_EVENTS=0 \
WEBSOCKET_QUEUE_SIZE=512 \ WEBSOCKET_QUEUE_SIZE=512 \
LIVE_FLOW_UPDATE_SECONDS=2.0 \ LIVE_FLOW_UPDATE_SECONDS=2.0 \
+15 -4
View File
@@ -1,6 +1,17 @@
# MikroSuricata # MikroSuricata
Project version: `0.11.3` Project version: `0.11.4`
## What changed in 0.11.4
- Bounded NDR beacon/cooldown/runtime state and added a bounded baseline LRU so long-running sensors no longer retain one Python object per remote peer indefinitely.
- Bounded high-cardinality analytics counters used while scanning SQLite history, preventing large 24h snapshots from permanently inflating Python RSS.
- Dashboard snapshots now continue from the committed SQLite prefix while the Redis archive has backlog; partial snapshots are marked incomplete instead of leaving charts frozen for hours.
- EVE watcher now recovers from downstream processing exceptions instead of silently terminating while the Rust TZSP process remains alive.
- `TRAFFIC_MAX_EVENTS` is now honored. New deployments use a 50,000-event Redis queue cap and trim it every second in addition to the 128 MiB Redis memory cap.
- `/api/status` now exposes per-process RSS for Python, Rust, Suricata and Redis plus cgroup memory usage to make future memory regressions attributable to a specific component.
- Removed the obsolete VLAN100 sniffer template that conflicted with the hybrid capture deployment tests.
## What changed in 0.11.3 ## What changed in 0.11.3
@@ -10,7 +21,7 @@ Project version: `0.11.3`
- Added an exact **5 hour (`18000` seconds)** dashboard range. Requested ranges are no longer rounded to the nearest predefined analytics window. - Added an exact **5 hour (`18000` seconds)** dashboard range. Requested ranges are no longer rounded to the nearest predefined analytics window.
- Added bounded-memory analytics for long windows: endpoint/application cardinality is aggregated inside SQLite and a separate snapshot worker processes one requested window at a time instead of retaining large Python flow/IP sets. Archiving continues while a long 24h snapshot is being calculated. - Added bounded-memory analytics for long windows: endpoint/application cardinality is aggregated inside SQLite and a separate snapshot worker processes one requested window at a time instead of retaining large Python flow/IP sets. Archiving continues while a long 24h snapshot is being calculated.
- Managed Redis now defaults to a **128 MiB hard memory limit**, `noeviction`, no RDB snapshots and no AOF. The defaults prevent Redis from growing until the RouterOS container is killed by OOM; durable traffic history lives in `/data/ids.db`. - Managed Redis now defaults to a **128 MiB hard memory limit**, `noeviction`, no RDB snapshots and no AOF. The defaults prevent Redis from growing until the RouterOS container is killed by OOM; durable traffic history lives in `/data/ids.db`.
- Added `TRAFFIC_ARCHIVE_INTERVAL_SECONDS`, `TRAFFIC_ARCHIVE_LAG_SECONDS` and `TRAFFIC_ARCHIVE_BATCH_SIZE` controls. Defaults move committed data out of Redis every 5 seconds with a 10 second live-buffer lag. - Added `TRAFFIC_ARCHIVE_INTERVAL_SECONDS`, `TRAFFIC_ARCHIVE_LAG_SECONDS` and `TRAFFIC_ARCHIVE_BATCH_SIZE` controls. Defaults move committed data out of Redis every 10 seconds with a 10 second live-buffer lag.
- Upgrades with a large legacy Redis backlog drain it in bounded batches before publishing fresh SQLite snapshots, preventing partially migrated history from appearing in charts. - Upgrades with a large legacy Redis backlog drain it in bounded batches before publishing fresh SQLite snapshots, preventing partially migrated history from appearing in charts.
@@ -130,7 +141,7 @@ NOTIFY_MIN_RISK=80
REDIS_MAXMEMORY_MB=128 REDIS_MAXMEMORY_MB=128
REDIS_SNAPSHOT_SECONDS=0 REDIS_SNAPSHOT_SECONDS=0
REDIS_AOF=false REDIS_AOF=false
TRAFFIC_ARCHIVE_INTERVAL_SECONDS=5 TRAFFIC_ARCHIVE_INTERVAL_SECONDS=10
TRAFFIC_ARCHIVE_LAG_SECONDS=10 TRAFFIC_ARCHIVE_LAG_SECONDS=10
TRAFFIC_ARCHIVE_BATCH_SIZE=1000 TRAFFIC_ARCHIVE_BATCH_SIZE=1000
``` ```
@@ -247,7 +258,7 @@ ALERT_DEDUP_WINDOW_SECONDS=300
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=<long-unique-password> ADMIN_PASSWORD=<long-unique-password>
SESSION_HOURS=168 SESSION_HOURS=168
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=120
AUTO_BLOCK=false AUTO_BLOCK=false
``` ```
+1 -1
View File
@@ -1 +1 @@
0.11.3 0.11.4
+29 -9
View File
@@ -55,11 +55,12 @@ class AnalyticsSnapshotCache:
self._archive_wake = threading.Event() self._archive_wake = threading.Event()
self._snapshot_wake = threading.Event() self._snapshot_wake = threading.Event()
self._archive_ready = threading.Event() self._archive_ready = threading.Event()
self._archive_started = threading.Event()
self._stopping = threading.Event() self._stopping = threading.Event()
self._lock = threading.RLock() self._lock = threading.RLock()
now = time.monotonic() now = time.monotonic()
# Standard UI ranges are kept warm. Arbitrary API ranges are added on demand. # Standard UI ranges are kept warm. Arbitrary API ranges are added on demand.
self._requested_at: dict[int, float] = {window: now for window in SUMMARY_WINDOWS} self._requested_at: dict[int, float] = {}
self._last_refresh: dict[int, float] = {} self._last_refresh: dict[int, float] = {}
self._errors = 0 self._errors = 0
self._refreshes = 0 self._refreshes = 0
@@ -68,7 +69,7 @@ class AnalyticsSnapshotCache:
self._archive_moved_throughput = 0 self._archive_moved_throughput = 0
self._last_archive_run = 0.0 self._last_archive_run = 0.0
self._last_purge = 0.0 self._last_purge = 0.0
self._active_ttl_seconds = max(300, self.interval_seconds * 10) self._active_ttl_seconds = max(120, self.interval_seconds * 3)
def start(self) -> None: def start(self) -> None:
self._stopping.clear() self._stopping.clear()
@@ -148,8 +149,12 @@ class AnalyticsSnapshotCache:
return return
try: try:
snapshots = self.history.analytics_many(normalized) snapshots = self.history.analytics_many(normalized)
archive_backlog = not self._archive_ready.is_set()
now_wall = time.time() now_wall = time.time()
for window in normalized: for window in normalized:
snapshots[window]["archive_backlog"] = archive_backlog
if archive_backlog:
snapshots[window]["analytics_complete"] = False
self.store.save_traffic_snapshot(window, snapshots[window]) self.store.save_traffic_snapshot(window, snapshots[window])
with self._lock: with self._lock:
self._last_refresh[window] = now_wall self._last_refresh[window] = now_wall
@@ -179,6 +184,8 @@ class AnalyticsSnapshotCache:
"backend": "sqlite-archive", "backend": "sqlite-archive",
"archive_worker_running": self._archive_thread.is_alive(), "archive_worker_running": self._archive_thread.is_alive(),
"snapshot_worker_running": self._snapshot_thread.is_alive(), "snapshot_worker_running": self._snapshot_thread.is_alive(),
"archive_started": self._archive_started.is_set(),
"archive_backlog": not self._archive_ready.is_set(),
"interval_seconds": self.interval_seconds, "interval_seconds": self.interval_seconds,
"archive_interval_seconds": self.archive_interval_seconds, "archive_interval_seconds": self.archive_interval_seconds,
"archive_lag_seconds": self.archive_lag_seconds, "archive_lag_seconds": self.archive_lag_seconds,
@@ -201,6 +208,7 @@ class AnalyticsSnapshotCache:
if self.stop_event.is_set() or self._stopping.is_set(): if self.stop_event.is_set() or self._stopping.is_set():
break break
backlog = self._archive_once() backlog = self._archive_once()
self._archive_started.set()
if backlog: if backlog:
self._archive_ready.clear() self._archive_ready.clear()
elif not self._archive_ready.is_set(): elif not self._archive_ready.is_set():
@@ -208,7 +216,7 @@ class AnalyticsSnapshotCache:
self._snapshot_wake.set() self._snapshot_wake.set()
now = time.monotonic() now = time.monotonic()
if now - self._last_purge >= 60: if now - self._last_purge >= 900:
try: try:
self.history.purge_archive() self.history.purge_archive()
except Exception: except Exception:
@@ -218,8 +226,10 @@ class AnalyticsSnapshotCache:
if backlog: if backlog:
# Continue draining old installations immediately instead of # Continue draining old installations immediately instead of
# sleeping while Redis still holds a large backlog. Do not # sleeping while Redis still holds a large backlog. Yield a
# publish a partial SQLite snapshot while migration is pending. # little CPU so EVE/NDR/web threads remain responsive.
if self.stop_event.wait(0.02):
break
self._archive_wake.set() self._archive_wake.set()
def _run_snapshots(self) -> None: def _run_snapshots(self) -> None:
@@ -229,14 +239,19 @@ class AnalyticsSnapshotCache:
self._snapshot_wake.clear() self._snapshot_wake.clear()
if self.stop_event.is_set() or self._stopping.is_set(): if self.stop_event.is_set() or self._stopping.is_set():
break break
# On startup/upgrades wait until the bounded archive worker has # Do not race the very first archive pass. After that, snapshots may
# drained any legacy Redis backlog. Existing persisted snapshots # use the committed SQLite prefix even while Redis still has a
# remain available to the UI while this happens. # backlog. This keeps historical charts moving instead of freezing
if not self._archive_ready.is_set(): # for hours; such snapshots are explicitly marked incomplete.
if not self._archive_started.is_set():
continue continue
try:
due = self._due_windows() due = self._due_windows()
if due: if due:
self.refresh_windows(due) self.refresh_windows(due)
except Exception:
with self._lock:
self._errors += 1
def _archive_once(self) -> bool: def _archive_once(self) -> bool:
cutoff_ms = int((time.time() - self.archive_lag_seconds) * 1000) cutoff_ms = int((time.time() - self.archive_lag_seconds) * 1000)
@@ -268,6 +283,11 @@ class AnalyticsSnapshotCache:
with self._lock: with self._lock:
requests = dict(self._requested_at) requests = dict(self._requested_at)
last_refresh = dict(self._last_refresh) last_refresh = dict(self._last_refresh)
# When nobody requested analytics, do not touch SQLite just to keep
# dashboard ranges warm. Current throughput is delivered independently
# from the Rust telemetry path, so background chart work can stay idle.
if not requests:
return []
persisted = { persisted = {
int(row["window_seconds"]): row.get("generated_at") int(row["window_seconds"]): row.get("generated_at")
for row in self.store.traffic_snapshot_status().get("windows", []) for row in self.store.traffic_snapshot_status().get("windows", [])
+7 -4
View File
@@ -195,9 +195,9 @@ class Config:
session_hours=max(1, _int("SESSION_HOURS", 168)), session_hours=max(1, _int("SESSION_HOURS", 168)),
session_cookie_secure=_bool("SESSION_COOKIE_SECURE", False), session_cookie_secure=_bool("SESSION_COOKIE_SECURE", False),
analytics_snapshot_interval_seconds=max( analytics_snapshot_interval_seconds=max(
15, _int("ANALYTICS_SNAPSHOT_INTERVAL_SECONDS", 60) 15, _int("ANALYTICS_SNAPSHOT_INTERVAL_SECONDS", 120)
), ),
traffic_archive_interval_seconds=max(1, _int("TRAFFIC_ARCHIVE_INTERVAL_SECONDS", 5)), traffic_archive_interval_seconds=max(1, _int("TRAFFIC_ARCHIVE_INTERVAL_SECONDS", 10)),
traffic_archive_lag_seconds=max(2, _int("TRAFFIC_ARCHIVE_LAG_SECONDS", 10)), traffic_archive_lag_seconds=max(2, _int("TRAFFIC_ARCHIVE_LAG_SECONDS", 10)),
traffic_archive_batch_size=max(100, min(5000, _int("TRAFFIC_ARCHIVE_BATCH_SIZE", 1000))), traffic_archive_batch_size=max(100, min(5000, _int("TRAFFIC_ARCHIVE_BATCH_SIZE", 1000))),
redis_url=os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0"), redis_url=os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0"),
@@ -212,8 +212,11 @@ class Config:
redis_snapshot_seconds=max(0, _int("REDIS_SNAPSHOT_SECONDS", 0)), redis_snapshot_seconds=max(0, _int("REDIS_SNAPSHOT_SECONDS", 0)),
redis_aof=_bool("REDIS_AOF", False), redis_aof=_bool("REDIS_AOF", False),
traffic_retention_hours=_int("TRAFFIC_RETENTION_HOURS", 24), traffic_retention_hours=_int("TRAFFIC_RETENTION_HOURS", 24),
traffic_max_events=0, # Redis is only a transient queue. Bound the number of normalized
traffic_memory_events=0, # EVE records as well as bytes so a stalled SQLite archive cannot
# accumulate an arbitrarily large sorted set.
traffic_max_events=max(0, _int("TRAFFIC_MAX_EVENTS", 50000)),
traffic_memory_events=max(0, _int("TRAFFIC_MEMORY_EVENTS", 0)),
websocket_queue_size=_int("WEBSOCKET_QUEUE_SIZE", 512), websocket_queue_size=_int("WEBSOCKET_QUEUE_SIZE", 512),
live_flow_update_seconds=_float("LIVE_FLOW_UPDATE_SECONDS", 2.0), live_flow_update_seconds=_float("LIVE_FLOW_UPDATE_SECONDS", 2.0),
ndr_enabled=_bool("NDR_ENABLED", True), ndr_enabled=_bool("NDR_ENABLED", True),
+1
View File
@@ -270,6 +270,7 @@ def main() -> int:
live_pipeline=live_pipeline, live_pipeline=live_pipeline,
analytics_cache=analytics_cache, analytics_cache=analytics_cache,
metrics_provider=prometheus_metrics.render, metrics_provider=prometheus_metrics.render,
healthcheck_provider=lambda: {"status": "ok" if store.ping() else "degraded", "operational": store.ping()},
) )
def request_stop(_signum=None, _frame=None) -> None: def request_stop(_signum=None, _frame=None) -> None:
+7
View File
@@ -59,6 +59,13 @@ class EVEWatcher(threading.Thread):
except OSError as exc: except OSError as exc:
print(f"[eve] file error: {exc}", flush=True) print(f"[eve] file error: {exc}", flush=True)
time.sleep(1.0) time.sleep(1.0)
except Exception as exc:
# A transient downstream failure (SQLite/Redis/NDR/etc.) must
# not permanently kill the EVE watcher while the container and
# Rust data-plane continue to look healthy.
self.stats.inc("eve_runtime_errors")
print(f"[eve] processing error: {exc}", flush=True)
time.sleep(0.5)
def _follow_file(self) -> None: def _follow_file(self) -> None:
with open(self.path, "r", encoding="utf-8", errors="replace") as handle: with open(self.path, "r", encoding="utf-8", errors="replace") as handle:
+24 -8
View File
@@ -21,6 +21,7 @@ SUPPORTED_EVENT_TYPES = {
"ssh", "rdp", "smb", "quic", "dhcp", "arp", "ike", "mqtt", "ftp", "ftp_data", "smtp", "ssh", "rdp", "smb", "quic", "dhcp", "arp", "ike", "mqtt", "ftp", "ftp_data", "smtp",
"websocket", "nfs", "tftp", "dcerpc", "krb5", "snmp", "rfb", "sip", "ldap", "pop3", "websocket", "nfs", "tftp", "dcerpc", "krb5", "snmp", "rfb", "sip", "ldap", "pop3",
} }
MAX_ANALYTICS_DIMENSION_KEYS = 4096
def _utc_now() -> str: def _utc_now() -> str:
@@ -52,6 +53,20 @@ def _text(value: Any, max_len: int = 512) -> str:
return str(value)[:max_len] return str(value)[:max_len]
def _bounded_counter_add(
counter: collections.Counter[str],
key: str,
amount: int = 1,
*,
max_keys: int = MAX_ANALYTICS_DIMENSION_KEYS,
) -> None:
"""Update a dashboard Counter without retaining unbounded unique values."""
if not key:
return
if key in counter or len(counter) < max_keys:
counter[key] += amount
def _parse_networks(value: str) -> list[ipaddress._BaseNetwork]: def _parse_networks(value: str) -> list[ipaddress._BaseNetwork]:
result: list[ipaddress._BaseNetwork] = [] result: list[ipaddress._BaseNetwork] = []
for raw in (value or "").split(","): for raw in (value or "").split(","):
@@ -1215,7 +1230,8 @@ class TrafficHistory:
def _trim_if_due(self, redis: RedisConnection) -> None: def _trim_if_due(self, redis: RedisConnection) -> None:
now = time.monotonic() now = time.monotonic()
if now - self._last_trim > 15: interval = 1.0 if self.max_events > 0 else 15.0
if now - self._last_trim > interval:
self._trim(redis) self._trim(redis)
self._last_trim = now self._last_trim = now
@@ -1437,7 +1453,7 @@ class _AnalyticsAccumulator:
self.alerts += 1 self.alerts += 1
signature = _text(item.get("signature"), 160) signature = _text(item.get("signature"), 160)
if signature: if signature:
self.signatures[signature] += 1 _bounded_counter_add(self.signatures, signature)
severity = item.get("severity") severity = item.get("severity")
if severity not in (None, ""): if severity not in (None, ""):
self.severities[f"S{severity}"] += 1 self.severities[f"S{severity}"] += 1
@@ -1451,26 +1467,26 @@ class _AnalyticsAccumulator:
self.files += 1 self.files += 1
filename = _text(item.get("filename"), 180) or "unnamed file" filename = _text(item.get("filename"), 180) or "unnamed file"
digest = _text(item.get("file_sha256") or item.get("file_sha1") or item.get("file_md5"), 32) digest = _text(item.get("file_sha256") or item.get("file_sha1") or item.get("file_md5"), 32)
self.file_activity[f"{filename}{' · ' + digest if digest else ''}"] += 1 _bounded_counter_add(self.file_activity, f"{filename}{' · ' + digest if digest else ''}")
direction = _text(item.get("direction"), 24) or "unknown" direction = _text(item.get("direction"), 24) or "unknown"
src_ip = _text(item.get("src_ip"), 64) src_ip = _text(item.get("src_ip"), 64)
dest_ip = _text(item.get("dest_ip"), 64) dest_ip = _text(item.get("dest_ip"), 64)
ether_src = _text(item.get("ether_src"), 32) ether_src = _text(item.get("ether_src"), 32)
ether_dest = _text(item.get("ether_dest"), 32) ether_dest = _text(item.get("ether_dest"), 32)
if direction in {"outbound", "internal"} and src_ip and ether_src: if direction in {"outbound", "internal"} and src_ip and ether_src:
self.assets[f"{src_ip} · {ether_src}"] += 1 _bounded_counter_add(self.assets, f"{src_ip} · {ether_src}")
if direction in {"inbound", "internal"} and dest_ip and ether_dest: if direction in {"inbound", "internal"} and dest_ip and ether_dest:
self.assets[f"{dest_ip} · {ether_dest}"] += 1 _bounded_counter_add(self.assets, f"{dest_ip} · {ether_dest}")
if item.get("type") == "dhcp": if item.get("type") == "dhcp":
asset_ip = _text(item.get("dhcp_assigned_ip") or item.get("src_ip"), 64) asset_ip = _text(item.get("dhcp_assigned_ip") or item.get("src_ip"), 64)
identity = _text(item.get("dhcp_hostname") or item.get("dhcp_client_mac"), 160) identity = _text(item.get("dhcp_hostname") or item.get("dhcp_client_mac"), 160)
if asset_ip or identity: if asset_ip or identity:
self.assets[f"{asset_ip}{' · ' if asset_ip and identity else ''}{identity}"] += 1 _bounded_counter_add(self.assets, f"{asset_ip}{' · ' if asset_ip and identity else ''}{identity}")
elif item.get("type") == "arp": elif item.get("type") == "arp":
asset_ip = _text(item.get("arp_src_ip") or item.get("src_ip"), 64) asset_ip = _text(item.get("arp_src_ip") or item.get("src_ip"), 64)
mac = _text(item.get("arp_src_mac"), 32) mac = _text(item.get("arp_src_mac"), 32)
if asset_ip or mac: if asset_ip or mac:
self.assets[f"{asset_ip}{' · ' if asset_ip and mac else ''}{mac}"] += 1 _bounded_counter_add(self.assets, f"{asset_ip}{' · ' if asset_ip and mac else ''}{mac}")
app_proto = _valid_app_proto(item.get("app_proto")) app_proto = _valid_app_proto(item.get("app_proto"))
if item.get("type") in {"tls", "quic", "ssh"} or app_proto in {"tls", "quic", "ssh"}: if item.get("type") in {"tls", "quic", "ssh"} or app_proto in {"tls", "quic", "ssh"}:
self.encrypted += 1 self.encrypted += 1
@@ -1484,7 +1500,7 @@ class _AnalyticsAccumulator:
): ):
value = _text(item.get(key), 160) value = _text(item.get(key), 160)
if value: if value:
self.fingerprints[f"{label} {value}"] += 1 _bounded_counter_add(self.fingerprints, f"{label} {value}")
if item.get("type") in {"http", "ftp", "smtp"} or app_proto in {"http", "ftp", "smtp", "telnet"}: if item.get("type") in {"http", "ftp", "smtp"} or app_proto in {"http", "ftp", "smtp", "telnet"}:
self.cleartext += 1 self.cleartext += 1
if is_flow: if is_flow:
+77 -9
View File
@@ -34,6 +34,40 @@ from .tzsp_rust import RustTZSPReceiver
from .webui import WebServer from .webui import WebServer
def _process_rss_mib(pid: int | None) -> float | None:
if not pid:
return None
try:
for line in Path(f"/proc/{int(pid)}/status").read_text(encoding="ascii", errors="replace").splitlines():
if line.startswith("VmRSS:"):
parts = line.split()
return round(int(parts[1]) / 1024.0, 1)
except (OSError, ValueError, IndexError):
pass
return None
def _cgroup_memory_mib() -> dict[str, float | None]:
def read_number(path: str) -> int | None:
try:
raw = Path(path).read_text(encoding="ascii").strip()
if not raw or raw == "max":
return None
return int(raw)
except (OSError, ValueError):
return None
current = read_number("/sys/fs/cgroup/memory.current")
limit = read_number("/sys/fs/cgroup/memory.max")
if current is None:
current = read_number("/sys/fs/cgroup/memory/memory.usage_in_bytes")
limit = read_number("/sys/fs/cgroup/memory/memory.limit_in_bytes")
return {
"current_mib": round(current / (1024 * 1024), 1) if current is not None else None,
"limit_mib": round(limit / (1024 * 1024), 1) if limit is not None else None,
}
def _routeros_target(cfg: Config) -> tuple[str, int]: def _routeros_target(cfg: Config) -> tuple[str, int]:
parsed = urlparse(cfg.routeros_url) parsed = urlparse(cfg.routeros_url)
host = parsed.hostname or cfg.routeros_url host = parsed.hostname or cfg.routeros_url
@@ -240,8 +274,8 @@ def main() -> int:
traffic_history = TrafficHistory( traffic_history = TrafficHistory(
cfg.redis_url, cfg.redis_url,
cfg.traffic_retention_hours, cfg.traffic_retention_hours,
0, cfg.traffic_max_events,
0, cfg.traffic_memory_events,
require_redis=True, require_redis=True,
allow_memory_fallback=False, allow_memory_fallback=False,
archive_store=store, archive_store=store,
@@ -282,6 +316,26 @@ def main() -> int:
) )
routeros_host, routeros_port = _routeros_target(cfg) routeros_host, routeros_port = _routeros_target(cfg)
def healthcheck() -> dict:
# Docker calls this frequently. Keep it strictly O(1): no rule-file
# scans, archive counts, directory walks or dashboard aggregates.
suricata_up = suricata.poll() is None
tzsp_up = receiver.is_alive()
tap_up = os.path.exists(f"/sys/class/net/{cfg.tap_name}")
eve_up = watcher.is_alive()
db_up = store.ping()
routeros_required_ok = (not cfg.auto_block) or routeros.configured
operational = suricata_up and tzsp_up and tap_up and eve_up and db_up and routeros_required_ok
return {
"status": "ok" if operational else "degraded",
"operational": operational,
"suricata_running": suricata_up,
"tzsp_running": tzsp_up,
"tap_up": tap_up,
"eve_running": eve_up,
"database_ok": db_up,
}
def health() -> dict: def health() -> dict:
suricata_up = suricata.poll() is None suricata_up = suricata.poll() is None
tzsp_up = receiver.is_alive() tzsp_up = receiver.is_alive()
@@ -294,6 +348,10 @@ def main() -> int:
runtime = stats.snapshot() runtime = stats.snapshot()
receiver_status = receiver.status() receiver_status = receiver.status()
redis_status = redis_supervisor.status() redis_status = redis_supervisor.status()
history_status = traffic_history.status()
ndr_status = ndr_analyzer.status()
ndr_summary = store.ndr_summary()
notifier_status = notifier.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)
@@ -316,6 +374,13 @@ def main() -> int:
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
process_memory = {
"python_rss_mib": _process_rss_mib(os.getpid()),
"rust_rss_mib": _process_rss_mib(receiver.pid),
"suricata_rss_mib": _process_rss_mib(suricata.pid if suricata_up else None),
"redis_rss_mib": _process_rss_mib(redis_status.get("pid")),
"cgroup": _cgroup_memory_mib(),
}
return { return {
"status": "ok" if operational else "degraded", "status": "ok" if operational else "degraded",
@@ -328,11 +393,13 @@ def main() -> int:
"suricata_pid": suricata.pid, "suricata_pid": suricata.pid,
"auto_block": cfg.auto_block, "auto_block": cfg.auto_block,
"routeros_configured": routeros.configured, "routeros_configured": routeros.configured,
"ndr": {**ndr_analyzer.status(), **store.ndr_summary()}, "ndr": {**ndr_status, **ndr_summary},
"database": db, "database": db,
"storage": storage, "storage": storage,
"rules": rules, "rules": rules,
"redis": redis_status, "redis": redis_status,
"memory": process_memory,
"traffic_history": history_status,
"services": { "services": {
"web": { "web": {
"name": "Web UI / API", "name": "Web UI / API",
@@ -387,7 +454,7 @@ def main() -> int:
}, },
"traffic_history": { "traffic_history": {
"name": "Live traffic history", "name": "Live traffic history",
"status": "up" if traffic_history.status().get("redis_ok") else "degraded", "status": "up" if history_status.get("redis_ok") else "degraded",
"details": f"Redis ingest buffer -> SQLite archive; retention={cfg.traffic_retention_hours}h", "details": f"Redis ingest buffer -> SQLite archive; retention={cfg.traffic_retention_hours}h",
}, },
"analytics_cache": { "analytics_cache": {
@@ -397,13 +464,13 @@ def main() -> int:
}, },
"ndr": { "ndr": {
"name": "MikroSuricata NDR correlation", "name": "MikroSuricata NDR correlation",
"status": "up" if ndr_analyzer.status().get("running") else "disabled" if not cfg.ndr_enabled else "degraded", "status": "up" if ndr_status.get("running") else "disabled" if not cfg.ndr_enabled else "degraded",
"details": f"assets={store.ndr_summary()['assets']}; incidents={store.ndr_summary()['incidents']}; IOC={store.ndr_summary()['enabled_iocs']}; queue={ndr_analyzer.status()['queue']}", "details": f"assets={ndr_summary['assets']}; incidents={ndr_summary['incidents']}; IOC={ndr_summary['enabled_iocs']}; queue={ndr_status['queue']}",
}, },
"notifications": { "notifications": {
"name": "High-risk webhook notifications", "name": "High-risk webhook notifications",
"status": "up" if notifier.status().get("running") else "disabled" if not notifier.enabled else "degraded", "status": "up" if notifier_status.get("running") else "disabled" if not notifier.enabled else "degraded",
"details": f"min risk={cfg.notify_min_risk}; sent={notifier.status()['sent']}; failed={notifier.status()['failed']}; queue={notifier.status()['queue']}", "details": f"min risk={cfg.notify_min_risk}; sent={notifier_status['sent']}; failed={notifier_status['failed']}; queue={notifier_status['queue']}",
}, },
"redis": { "redis": {
"name": "Managed Redis", "name": "Managed Redis",
@@ -455,7 +522,7 @@ def main() -> int:
"status": routeros_status, "status": routeros_status,
}, },
], ],
"runtime": stats.snapshot(), "runtime": runtime,
} }
version_path = Path(__file__).resolve().parents[1] / "VERSION" version_path = Path(__file__).resolve().parents[1] / "VERSION"
@@ -514,6 +581,7 @@ def main() -> int:
forensic_pcap=forensic_pcap, forensic_pcap=forensic_pcap,
traffic_source=receiver, traffic_source=receiver,
metrics_provider=prometheus_metrics.render, metrics_provider=prometheus_metrics.render,
healthcheck_provider=healthcheck,
) )
def housekeeping() -> None: def housekeeping() -> None:
+95 -4
View File
@@ -18,6 +18,12 @@ from .store import AlertStore
SENSITIVE_PORTS = {21, 22, 23, 25, 110, 135, 139, 445, 1433, 3306, 3389, 5432, 6379, 8291, 9200, 27017} SENSITIVE_PORTS = {21, 22, 23, 25, 110, 135, 139, 445, 1433, 3306, 3389, 5432, 6379, 8291, 9200, 27017}
NDR_BEACON_MAX_KEYS = 32768
NDR_STATE_MAX_SUBJECTS = 8192
NDR_BASELINE_CACHE_MAX = 50000
NDR_BLOCK_ATTEMPT_MAX = 10000
NDR_STATE_IDLE_SECONDS = 7200
NDR_BLOCK_ATTEMPT_TTL_SECONDS = 86400
LOCAL_STAGE_BY_SID = { LOCAL_STAGE_BY_SID = {
1000101: "credential-access", 1000102: "credential-access", 1000103: "credential-access", 1000101: "credential-access", 1000102: "credential-access", 1000103: "credential-access",
1000104: "recon", 1000105: "recon", 1000106: "initial-access", 1000104: "recon", 1000105: "recon", 1000106: "initial-access",
@@ -211,7 +217,11 @@ class NDRAnalyzer:
self._signals = 0 self._signals = 0
self._ioc_hits = 0 self._ioc_hits = 0
self._behavior_hits = 0 self._behavior_hits = 0
self._beacon: dict[tuple[str, str], collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=12)) # Remote destinations are high-cardinality on real networks (CDNs,
# crawlers, cloud APIs). Keep all runtime correlation state explicitly
# bounded so a long-running sensor cannot retain one object per peer
# forever.
self._beacon: collections.OrderedDict[tuple[str, str], collections.deque[float]] = collections.OrderedDict()
self._scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128)) self._scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
self._out_scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128)) self._out_scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
self._egress: dict[str, collections.deque[tuple[float, int, str]]] = collections.defaultdict(lambda: collections.deque(maxlen=512)) self._egress: dict[str, collections.deque[tuple[float, int, str]]] = collections.defaultdict(lambda: collections.deque(maxlen=512))
@@ -220,7 +230,13 @@ class NDRAnalyzer:
self._dns_tunnel: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=96)) self._dns_tunnel: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=96))
self._identity_changes: dict[str, collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=8)) self._identity_changes: dict[str, collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=8))
self._cooldown: dict[tuple[str, str], float] = {} self._cooldown: dict[tuple[str, str], float] = {}
self._block_attempted: set[int] = set() self._block_attempted: collections.OrderedDict[int, float] = collections.OrderedDict()
# baseline_touch() used to perform a SELECT/UPDATE/COMMIT for every EVE
# record, even if an asset had already used the same app/port/domain.
# A bounded process-local LRU preserves first-seen detection while
# removing that repeated SQLite write amplification.
self._baseline_seen: collections.OrderedDict[tuple[str, str, str], None] = collections.OrderedDict()
self._last_state_cleanup = 0.0
self._routeros_inventory_syncs = 0 self._routeros_inventory_syncs = 0
self._routeros_inventory_assets = 0 self._routeros_inventory_assets = 0
self._routeros_inventory_last_at = "" self._routeros_inventory_last_at = ""
@@ -249,6 +265,19 @@ class NDRAnalyzer:
"routeros_inventory_syncs": self._routeros_inventory_syncs, "routeros_inventory_syncs": self._routeros_inventory_syncs,
"routeros_inventory_assets": self._routeros_inventory_assets, "routeros_inventory_assets": self._routeros_inventory_assets,
"routeros_inventory_last_at": self._routeros_inventory_last_at, "routeros_inventory_last_at": self._routeros_inventory_last_at,
"state_entries": {
"beacon": len(self._beacon),
"scan": len(self._scan),
"out_scan": len(self._out_scan),
"egress": len(self._egress),
"dga": len(self._dga),
"nxdomain": len(self._nxdomain),
"dns_tunnel": len(self._dns_tunnel),
"identity_changes": len(self._identity_changes),
"cooldown": len(self._cooldown),
"baseline_lru": len(self._baseline_seen),
"block_attempted": len(self._block_attempted),
},
} }
def sync_routeros_inventory(self) -> dict[str, int]: def sync_routeros_inventory(self) -> dict[str, int]:
@@ -301,6 +330,7 @@ class NDRAnalyzer:
self._queue.task_done() self._queue.task_done()
def _process(self, record: dict[str, Any], alert_id: int | None) -> None: def _process(self, record: dict[str, Any], alert_id: int | None) -> None:
self._prune_state(_dt(record.get("timestamp")))
subject = self._subject(record) subject = self._subject(record)
if subject: if subject:
asset = self.store.observe_asset(record) asset = self.store.observe_asset(record)
@@ -357,7 +387,15 @@ class NDRAnalyzer:
if fingerprint: if fingerprint:
values.append(("client-fingerprint", fingerprint[:160], 30)) values.append(("client-fingerprint", fingerprint[:160], 30))
for kind, value, risk in values: for kind, value, risk in values:
baseline_key = (subject, kind, value)
if baseline_key in self._baseline_seen:
self._baseline_seen.move_to_end(baseline_key)
continue
is_new, _ = self.store.baseline_touch(subject, kind, value, str(record.get("timestamp") or "")) is_new, _ = self.store.baseline_touch(subject, kind, value, str(record.get("timestamp") or ""))
self._baseline_seen[baseline_key] = None
self._baseline_seen.move_to_end(baseline_key)
while len(self._baseline_seen) > NDR_BASELINE_CACHE_MAX:
self._baseline_seen.popitem(last=False)
if is_new and observations >= self.behavior_min_observations: if is_new and observations >= self.behavior_min_observations:
self._behavior_hits += 1 self._behavior_hits += 1
self._emit(record, alert_id, subject, "behavior", "behavior-change", risk, f"New {kind} for established asset: {value}") self._emit(record, alert_id, subject, "behavior", "behavior-change", risk, f"New {kind} for established asset: {value}")
@@ -369,7 +407,15 @@ class NDRAnalyzer:
port = int(record.get("dest_port") or 0) port = int(record.get("dest_port") or 0)
if record.get("direction") == "outbound" and dest: if record.get("direction") == "outbound" and dest:
key = (subject, dest) key = (subject, dest)
dq = self._beacon[key]; dq.append(now) dq = self._beacon.get(key)
if dq is None:
dq = collections.deque(maxlen=12)
self._beacon[key] = dq
else:
self._beacon.move_to_end(key)
dq.append(now)
while len(self._beacon) > NDR_BEACON_MAX_KEYS:
self._beacon.popitem(last=False)
if port in SENSITIVE_PORTS: if port in SENSITIVE_PORTS:
scan = self._out_scan[subject]; scan.append((now, dest, port)) scan = self._out_scan[subject]; scan.append((now, dest, port))
while scan and scan[0][0] < now - 60: scan.popleft() while scan and scan[0][0] < now - 60: scan.popleft()
@@ -465,7 +511,10 @@ class NDRAnalyzer:
if self.auto_block and combined_risk >= self.auto_block_risk and incident_id not in self._block_attempted: if self.auto_block and combined_risk >= self.auto_block_risk and incident_id not in self._block_attempted:
target = self._remote_target(record, subject) target = self._remote_target(record, subject)
if target and self.routeros.configured: if target and self.routeros.configured:
self._block_attempted.add(incident_id) self._block_attempted[incident_id] = _dt(record.get("timestamp"))
self._block_attempted.move_to_end(incident_id)
while len(self._block_attempted) > NDR_BLOCK_ATTEMPT_MAX:
self._block_attempted.popitem(last=False)
result = self.routeros.block_ip(target, self.block_timeout, f"MikroSuricata NDR risk {combined_risk}: {summary}"[:220]) result = self.routeros.block_ip(target, self.block_timeout, f"MikroSuricata NDR risk {combined_risk}: {summary}"[:220])
if result.success: if result.success:
self.store.mark_incident_blocked(incident_id, target) self.store.mark_incident_blocked(incident_id, target)
@@ -475,6 +524,48 @@ class NDRAnalyzer:
except Exception as exc: except Exception as exc:
print(f"[ndr] forensic PCAP capture failed: {exc}", flush=True) print(f"[ndr] forensic PCAP capture failed: {exc}", flush=True)
def _prune_state(self, now: float) -> None:
current = time.monotonic()
if current - self._last_state_cleanup < 60:
return
self._last_state_cleanup = current
stale_before = now - NDR_STATE_IDLE_SECONDS
for key in list(self._beacon):
values = self._beacon.get(key)
if not values or values[-1] < stale_before:
self._beacon.pop(key, None)
keyed_deques = (
self._scan,
self._out_scan,
self._egress,
self._dga,
self._nxdomain,
self._dns_tunnel,
self._identity_changes,
)
for mapping in keyed_deques:
for key, values in list(mapping.items()):
if not values:
mapping.pop(key, None)
continue
latest = values[-1]
latest_ts = latest if isinstance(latest, (int, float)) else latest[0]
if float(latest_ts) < stale_before:
mapping.pop(key, None)
while len(mapping) > NDR_STATE_MAX_SUBJECTS:
mapping.pop(next(iter(mapping)), None)
for key, expires_at in list(self._cooldown.items()):
if expires_at <= now:
self._cooldown.pop(key, None)
block_before = now - NDR_BLOCK_ATTEMPT_TTL_SECONDS
for incident_id, attempted_at in list(self._block_attempted.items()):
if attempted_at < block_before:
self._block_attempted.pop(incident_id, None)
def _subject(self, record: dict[str, Any]) -> str: def _subject(self, record: dict[str, Any]) -> str:
src = str(record.get("src_ip") or record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "") src = str(record.get("src_ip") or record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")
dst = str(record.get("dest_ip") or "") dst = str(record.get("dest_ip") or "")
+29 -3
View File
@@ -61,6 +61,8 @@ class RuleManager:
"items": [], "items": [],
} }
self._last_result = "not changed" self._last_result = "not changed"
self._vendor_rule_cache_key: tuple[int, int, int] | None = None
self._vendor_rule_cache_count = 0
self._snapshot_dir = Path(self.config.suricata_custom_rules).parent / "rule-snapshots" self._snapshot_dir = Path(self.config.suricata_custom_rules).parent / "rule-snapshots"
self._snapshot_dir.mkdir(parents=True, exist_ok=True) self._snapshot_dir.mkdir(parents=True, exist_ok=True)
self._ensure_files() self._ensure_files()
@@ -78,6 +80,7 @@ class RuleManager:
last_result = self._last_result last_result = self._last_result
vendor_root = self.config.suricata_persist_lib_dir vendor_root = self.config.suricata_persist_lib_dir
vendor_rules = os.path.join(vendor_root, "rules", "suricata.rules") vendor_rules = os.path.join(vendor_root, "rules", "suricata.rules")
vendor_rules_size, vendor_rules_updated_at, vendor_rule_count = self._vendor_rule_metadata(vendor_rules)
source_index = _first_existing_path( source_index = _first_existing_path(
os.path.join(vendor_root, "rules", ".cache", "index.yaml"), os.path.join(vendor_root, "rules", ".cache", "index.yaml"),
os.path.join(vendor_root, "update", "cache", "index.yaml"), os.path.join(vendor_root, "update", "cache", "index.yaml"),
@@ -94,15 +97,38 @@ class RuleManager:
"threshold_entry_count": _count_config_entries(threshold), "threshold_entry_count": _count_config_entries(threshold),
"suppressed_sids": _suppressed_sids(threshold), "suppressed_sids": _suppressed_sids(threshold),
"vendor_rules_path": vendor_rules, "vendor_rules_path": vendor_rules,
"vendor_rules_size_bytes": _file_size(vendor_rules), "vendor_rules_size_bytes": vendor_rules_size,
"vendor_rule_count": _count_rule_file(vendor_rules), "vendor_rule_count": vendor_rule_count,
"vendor_rules_updated_at": _file_mtime_iso(vendor_rules), "vendor_rules_updated_at": vendor_rules_updated_at,
"source_index_updated_at": _file_mtime_iso(source_index) if source_index else None, "source_index_updated_at": _file_mtime_iso(source_index) if source_index else None,
"source_index_url": self.SOURCE_INDEX_URL, "source_index_url": self.SOURCE_INDEX_URL,
"last_result": last_result, "last_result": last_result,
"snapshots": len(self.list_snapshots()), "snapshots": len(self.list_snapshots()),
} }
def _vendor_rule_metadata(self, path: str) -> tuple[int, str | None, int]:
try:
stat = os.stat(path)
except OSError:
with self._lock:
self._vendor_rule_cache_key = None
self._vendor_rule_cache_count = 0
return 0, None, 0
cache_key = (int(stat.st_ino), int(stat.st_mtime_ns), int(stat.st_size))
with self._lock:
if cache_key == self._vendor_rule_cache_key:
count = self._vendor_rule_cache_count
else:
count = -1
if count < 0:
count = _count_rule_file(path)
with self._lock:
self._vendor_rule_cache_key = cache_key
self._vendor_rule_cache_count = count
updated_at = datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()
return int(stat.st_size), updated_at, count
def content(self) -> dict: def content(self) -> dict:
return { return {
"custom_rules": self._read(self.config.suricata_custom_rules), "custom_rules": self._read(self.config.suricata_custom_rules),
+3 -1
View File
@@ -905,7 +905,9 @@
if (!state.appStarted) state.appStarted=true; if (!state.appStarted) state.appStarted=true;
await initialLoad(); await initialLoad();
connectWebSocket(); connectWebSocket();
if (!state.refreshTimer) state.refreshTimer=setInterval(refreshStats,30000); // Live throughput already arrives over WebSocket. Aggregate SQLite-backed
// cards/charts do not need to be recomputed every 30 seconds.
if (!state.refreshTimer) state.refreshTimer=setInterval(refreshStats,60000);
if ('ResizeObserver' in window && !startApplication.observer) { if ('ResizeObserver' in window && !startApplication.observer) {
startApplication.observer=new ResizeObserver(()=>scheduleChartRender()); startApplication.observer=new ResizeObserver(()=>scheduleChartRender());
document.querySelectorAll('.view,.chart-panel,.donut-panel').forEach(el=>startApplication.observer.observe(el)); document.querySelectorAll('.view,.chart-panel,.donut-panel').forEach(el=>startApplication.observer.observe(el));
+98 -22
View File
@@ -22,6 +22,12 @@ class AlertStore:
self._conn = sqlite3.connect(path, check_same_thread=False) self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row self._conn.row_factory = sqlite3.Row
self._init_schema() self._init_schema()
# Keep exact archive counters/bounds in memory. A full COUNT(*) over a
# multi-million-row traffic archive on every /api/status request was a
# major source of periodic CPU spikes. The archive is only mutated by
# this process, so incremental metadata stays exact after one startup
# scan.
self._traffic_archive_meta = self._load_traffic_archive_meta()
# WAL permits a dedicated read connection to run long dashboard # WAL permits a dedicated read connection to run long dashboard
# aggregates while the archive worker keeps committing new batches. # aggregates while the archive worker keeps committing new batches.
# This avoids coupling Redis draining to a 24h GROUP BY query. # This avoids coupling Redis draining to a 24h GROUP BY query.
@@ -258,6 +264,50 @@ class AlertStore:
if name not in columns: if name not in columns:
self._conn.execute(f"ALTER TABLE traffic_events ADD COLUMN {name} {ddl}") self._conn.execute(f"ALTER TABLE traffic_events ADD COLUMN {name} {ddl}")
def _load_traffic_archive_meta(self) -> dict[str, int]:
with self._lock:
events = self._conn.execute(
"SELECT COUNT(*) AS count, MIN(ts_ms) AS oldest, MAX(ts_ms) AS newest FROM traffic_events"
).fetchone()
throughput = self._conn.execute(
"SELECT COUNT(*) AS count, MIN(ts_ms) AS oldest, MAX(ts_ms) AS newest FROM traffic_throughput"
).fetchone()
return {
"events": int(events["count"] or 0),
"throughput_samples": int(throughput["count"] or 0),
"oldest_event_ms": int(events["oldest"] or 0),
"newest_event_ms": int(events["newest"] or 0),
"oldest_throughput_ms": int(throughput["oldest"] or 0),
"newest_throughput_ms": int(throughput["newest"] or 0),
}
def _traffic_meta_insert_locked(self, kind: str, ts_ms: int) -> None:
ts_ms = int(ts_ms)
if kind == "event":
count_key, oldest_key, newest_key = "events", "oldest_event_ms", "newest_event_ms"
else:
count_key, oldest_key, newest_key = "throughput_samples", "oldest_throughput_ms", "newest_throughput_ms"
previous_count = int(self._traffic_archive_meta[count_key])
self._traffic_archive_meta[count_key] = previous_count + 1
if previous_count == 0:
self._traffic_archive_meta[oldest_key] = ts_ms
self._traffic_archive_meta[newest_key] = ts_ms
return
self._traffic_archive_meta[oldest_key] = min(int(self._traffic_archive_meta[oldest_key]), ts_ms)
self._traffic_archive_meta[newest_key] = max(int(self._traffic_archive_meta[newest_key]), ts_ms)
def _oldest_ts_locked(self, table: str) -> int:
row = self._conn.execute(f"SELECT ts_ms FROM {table} ORDER BY ts_ms ASC LIMIT 1").fetchone()
return int(row[0] or 0) if row is not None else 0
def ping(self) -> bool:
try:
with self._lock:
self._conn.execute("SELECT 1").fetchone()
return True
except sqlite3.Error:
return False
def save_traffic_snapshot(self, window_seconds: int, payload: dict[str, Any]) -> None: def save_traffic_snapshot(self, window_seconds: int, payload: dict[str, Any]) -> None:
window_seconds = int(window_seconds) window_seconds = int(window_seconds)
if window_seconds <= 0: if window_seconds <= 0:
@@ -321,6 +371,7 @@ class AlertStore:
if not records: if not records:
return 0 return 0
inserted = 0 inserted = 0
inserted_ts: list[int] = []
with self._lock: with self._lock:
for event_key, payload in records: for event_key, payload in records:
if ( if (
@@ -353,14 +404,20 @@ class AlertStore:
raw, raw,
), ),
) )
inserted += max(0, int(cursor.rowcount or 0)) added = max(0, int(cursor.rowcount or 0))
inserted += added
if added:
inserted_ts.append(int(payload.get("ts_ms") or 0))
self._conn.commit() self._conn.commit()
for ts_ms in inserted_ts:
self._traffic_meta_insert_locked("event", ts_ms)
return inserted return inserted
def archive_traffic_throughput(self, records: list[tuple[str, dict[str, Any]]]) -> int: def archive_traffic_throughput(self, records: list[tuple[str, dict[str, Any]]]) -> int:
if not records: if not records:
return 0 return 0
inserted = 0 inserted = 0
inserted_ts: list[int] = []
with self._lock: with self._lock:
for sample_key, payload in records: for sample_key, payload in records:
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
@@ -368,8 +425,13 @@ class AlertStore:
"INSERT OR IGNORE INTO traffic_throughput(sample_key, ts_ms, payload_json) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO traffic_throughput(sample_key, ts_ms, payload_json) VALUES (?, ?, ?)",
(str(sample_key), int(payload.get("ts_ms") or 0), raw), (str(sample_key), int(payload.get("ts_ms") or 0), raw),
) )
inserted += max(0, int(cursor.rowcount or 0)) added = max(0, int(cursor.rowcount or 0))
inserted += added
if added:
inserted_ts.append(int(payload.get("ts_ms") or 0))
self._conn.commit() self._conn.commit()
for ts_ms in inserted_ts:
self._traffic_meta_insert_locked("throughput", ts_ms)
return inserted return inserted
def traffic_event_page( def traffic_event_page(
@@ -586,38 +648,52 @@ class AlertStore:
def traffic_archive_status(self) -> dict[str, Any]: def traffic_archive_status(self) -> dict[str, Any]:
with self._lock: with self._lock:
events = self._conn.execute( return dict(self._traffic_archive_meta)
"SELECT COUNT(*) AS count, MIN(ts_ms) AS oldest, MAX(ts_ms) AS newest FROM traffic_events"
).fetchone()
throughput = self._conn.execute(
"SELECT COUNT(*) AS count, MIN(ts_ms) AS oldest, MAX(ts_ms) AS newest FROM traffic_throughput"
).fetchone()
return {
"events": int(events["count"] or 0),
"throughput_samples": int(throughput["count"] or 0),
"oldest_event_ms": int(events["oldest"] or 0),
"newest_event_ms": int(events["newest"] or 0),
"oldest_throughput_ms": int(throughput["oldest"] or 0),
"newest_throughput_ms": int(throughput["newest"] or 0),
}
def purge_traffic_archive_before(self, cutoff_ms: int) -> dict[str, int]: def purge_traffic_archive_before(self, cutoff_ms: int) -> dict[str, int]:
with self._lock: with self._lock:
events = self._conn.execute("DELETE FROM traffic_events WHERE ts_ms<?", (int(cutoff_ms),)) events_cursor = self._conn.execute("DELETE FROM traffic_events WHERE ts_ms<?", (int(cutoff_ms),))
throughput = self._conn.execute("DELETE FROM traffic_throughput WHERE ts_ms<?", (int(cutoff_ms),)) throughput_cursor = self._conn.execute("DELETE FROM traffic_throughput WHERE ts_ms<?", (int(cutoff_ms),))
removed_events = max(0, int(events_cursor.rowcount or 0))
removed_throughput = max(0, int(throughput_cursor.rowcount or 0))
self._conn.commit() self._conn.commit()
if removed_events:
remaining = max(0, int(self._traffic_archive_meta["events"]) - removed_events)
self._traffic_archive_meta["events"] = remaining
if remaining:
self._traffic_archive_meta["oldest_event_ms"] = self._oldest_ts_locked("traffic_events")
else:
self._traffic_archive_meta["oldest_event_ms"] = 0
self._traffic_archive_meta["newest_event_ms"] = 0
if removed_throughput:
remaining = max(0, int(self._traffic_archive_meta["throughput_samples"]) - removed_throughput)
self._traffic_archive_meta["throughput_samples"] = remaining
if remaining:
self._traffic_archive_meta["oldest_throughput_ms"] = self._oldest_ts_locked("traffic_throughput")
else:
self._traffic_archive_meta["oldest_throughput_ms"] = 0
self._traffic_archive_meta["newest_throughput_ms"] = 0
return { return {
"events": max(0, int(events.rowcount or 0)), "events": removed_events,
"throughput_samples": max(0, int(throughput.rowcount or 0)), "throughput_samples": removed_throughput,
} }
def clear_traffic_archive(self) -> dict[str, int]: def clear_traffic_archive(self) -> dict[str, int]:
with self._lock: with self._lock:
events = int(self._conn.execute("SELECT COUNT(*) FROM traffic_events").fetchone()[0]) events = int(self._traffic_archive_meta["events"])
throughput = int(self._conn.execute("SELECT COUNT(*) FROM traffic_throughput").fetchone()[0]) throughput = int(self._traffic_archive_meta["throughput_samples"])
self._conn.execute("DELETE FROM traffic_events") self._conn.execute("DELETE FROM traffic_events")
self._conn.execute("DELETE FROM traffic_throughput") self._conn.execute("DELETE FROM traffic_throughput")
self._conn.commit() self._conn.commit()
self._traffic_archive_meta.update({
"events": 0,
"throughput_samples": 0,
"oldest_event_ms": 0,
"newest_event_ms": 0,
"oldest_throughput_ms": 0,
"newest_throughput_ms": 0,
})
return {"events": events, "throughput_samples": throughput} return {"events": events, "throughput_samples": throughput}
def create_web_session( def create_web_session(
+53 -4
View File
@@ -124,6 +124,7 @@ class WebServer:
forensic_pcap: ForensicPcapRing | None = None, forensic_pcap: ForensicPcapRing | None = None,
traffic_source: Any | None = None, traffic_source: Any | None = None,
metrics_provider: Callable[[], str] | None = None, metrics_provider: Callable[[], str] | None = None,
healthcheck_provider: Callable[[], dict] | None = None,
) -> None: ) -> None:
self.config = config self.config = config
self.store = store self.store = store
@@ -140,8 +141,17 @@ class WebServer:
self.forensic_pcap = forensic_pcap self.forensic_pcap = forensic_pcap
self.traffic_source = traffic_source self.traffic_source = traffic_source
self.metrics_provider = metrics_provider self.metrics_provider = metrics_provider
self.healthcheck_provider = healthcheck_provider or health_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 ".")
self._status_cache_lock = threading.Lock()
self._status_cache: dict[str, Any] | None = None
self._status_cache_at = 0.0
self._status_cache_seconds = 30.0
self._stats_cache_lock = threading.Lock()
self._stats_cache: dict[str, Any] | None = None
self._stats_cache_at = 0.0
self._stats_cache_seconds = 60.0
self.auth = SessionAuth(config, store) self.auth = SessionAuth(config, store)
self._login_lock = threading.Lock() self._login_lock = threading.Lock()
self._login_attempts: dict[str, deque[float]] = defaultdict(deque) self._login_attempts: dict[str, deque[float]] = defaultdict(deque)
@@ -149,10 +159,17 @@ class WebServer:
self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True) self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True)
def _status_payload(self) -> dict: def _status_payload(self) -> dict:
now = time.monotonic()
with self._status_cache_lock:
if self._status_cache is not None and now - self._status_cache_at < self._status_cache_seconds:
return dict(self._status_cache)
payload = dict(self.health_provider()) payload = dict(self.health_provider())
payload["summary"] = self.store.summary() payload["summary"] = self.store.summary()
if self.traffic_history is not None: if self.traffic_history is not None:
history = self.traffic_history.status() # main.health() already obtains this once for the service table. Reuse
# it instead of issuing another Redis + archive status query.
history = dict(payload.get("traffic_history") or self.traffic_history.status())
if self.live_pipeline is not None: if self.live_pipeline is not None:
history.update(self.live_pipeline.status()) history.update(self.live_pipeline.status())
if self.event_bus is not None: if self.event_bus is not None:
@@ -160,6 +177,30 @@ class WebServer:
payload["traffic_history"] = history payload["traffic_history"] = history
if self.analytics_cache is not None: if self.analytics_cache is not None:
payload["analytics_snapshots"] = self.analytics_cache.status() payload["analytics_snapshots"] = self.analytics_cache.status()
with self._status_cache_lock:
self._status_cache = dict(payload)
self._status_cache_at = time.monotonic()
return payload
def _healthcheck_payload(self) -> dict:
return dict(self.healthcheck_provider())
def _stats_payload(self) -> dict:
"""Cache aggregate SQLite statistics shared by all dashboard clients."""
now = time.monotonic()
with self._stats_cache_lock:
if self._stats_cache is not None and now - self._stats_cache_at < self._stats_cache_seconds:
return dict(self._stats_cache)
payload = {
"summary": self.store.summary(),
"analytics": self.store.analytics(),
"ndr": self.store.ndr_summary(),
}
with self._stats_cache_lock:
self._stats_cache = dict(payload)
self._stats_cache_at = time.monotonic()
return payload return payload
def _analytics_payload(self, window_seconds: int) -> dict: def _analytics_payload(self, window_seconds: int) -> dict:
@@ -192,6 +233,14 @@ class WebServer:
def _login_allowed(self, client_ip: str) -> bool: def _login_allowed(self, client_ip: str) -> bool:
now = time.monotonic() now = time.monotonic()
with self._login_lock: with self._login_lock:
if len(self._login_attempts) >= 4096 and client_ip not in self._login_attempts:
for key, values in list(self._login_attempts.items()):
while values and values[0] < now - 300:
values.popleft()
if not values:
self._login_attempts.pop(key, None)
while len(self._login_attempts) >= 4096:
self._login_attempts.pop(next(iter(self._login_attempts)), None)
attempts = self._login_attempts[client_ip] attempts = self._login_attempts[client_ip]
while attempts and attempts[0] < now - 300: while attempts and attempts[0] < now - 300:
attempts.popleft() attempts.popleft()
@@ -267,7 +316,7 @@ class WebServer:
self._static(parsed.path) self._static(parsed.path)
return return
if parsed.path == "/api/health": if parsed.path == "/api/health":
self._json(outer._status_payload()) self._json(outer._healthcheck_payload())
return return
if parsed.path == "/api/auth/session": if parsed.path == "/api/auth/session":
self._auth_session() self._auth_session()
@@ -281,7 +330,7 @@ class WebServer:
self._json(store.summary()) self._json(store.summary())
return return
if parsed.path == "/api/stats": if parsed.path == "/api/stats":
self._json({"summary": store.summary(), "analytics": store.analytics(), "ndr": store.ndr_summary()}) self._json(outer._stats_payload())
return return
if parsed.path == "/api/config": if parsed.path == "/api/config":
self._json(config.public_dict()) self._json(config.public_dict())
@@ -845,7 +894,7 @@ class WebServer:
if now - last_throughput >= 1: if now - last_throughput >= 1:
self._ws_send_json({"type": "throughput", "data": outer._current_throughput_payload(window)}) self._ws_send_json({"type": "throughput", "data": outer._current_throughput_payload(window)})
last_throughput = now last_throughput = now
if now - last_status >= 5: if now - last_status >= 30:
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
if now - last_analytics >= 10: if now - last_analytics >= 10:
+2 -2
View File
@@ -65,7 +65,7 @@ REDIS_MAXMEMORY_MB=128
REDIS_SNAPSHOT_SECONDS=0 REDIS_SNAPSHOT_SECONDS=0
REDIS_AOF=false REDIS_AOF=false
TRAFFIC_RETENTION_HOURS=24 TRAFFIC_RETENTION_HOURS=24
TRAFFIC_ARCHIVE_INTERVAL_SECONDS=5 TRAFFIC_ARCHIVE_INTERVAL_SECONDS=10
TRAFFIC_ARCHIVE_LAG_SECONDS=10 TRAFFIC_ARCHIVE_LAG_SECONDS=10
TRAFFIC_ARCHIVE_BATCH_SIZE=1000 TRAFFIC_ARCHIVE_BATCH_SIZE=1000
TRAFFIC_MAX_EVENTS=0 TRAFFIC_MAX_EVENTS=0
@@ -86,7 +86,7 @@ METRICS_BASIC_AUTH_USERNAME=
METRICS_BASIC_AUTH_PASSWORD= METRICS_BASIC_AUTH_PASSWORD=
SESSION_HOURS=168 SESSION_HOURS=168
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=120
# MikroSuricata NDR / correlation. Keep NDR_AUTO_BLOCK=false while baselining. # MikroSuricata NDR / correlation. Keep NDR_AUTO_BLOCK=false while baselining.
NDR_ENABLED=true NDR_ENABLED=true
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- ids-data:/data - ids-data:/data
healthcheck: healthcheck:
test: ["CMD", "python3", "/opt/ids/scripts/healthcheck.py"] test: ["CMD", "python3", "/opt/ids/scripts/healthcheck.py"]
interval: 15s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 20s start_period: 20s
+3 -3
View File
@@ -1,6 +1,6 @@
# RouterOS TZSP capture architecture # RouterOS TZSP capture architecture
MikroSuricata 0.11.3 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. MikroSuricata 0.11.4 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 ## Default topology
@@ -100,7 +100,7 @@ With the default filter the stream can include, among other protocols:
### IPv6 performance note ### IPv6 performance note
IPv6 is deliberately left in the Packet Sniffer complement in 0.11.3 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. IPv6 is deliberately left in the Packet Sniffer complement in 0.11.4 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 ## No bridge or interface name is assumed
@@ -264,7 +264,7 @@ If `TZSP RX` is close to the expected traffic and capture remains close to 100%,
- Packet Sniffer cannot necessarily see traffic switched entirely in hardware by a hardware-offloaded bridge. Broadcast/multicast behavior can differ by platform. - 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. - The default mangle rule covers routed IPv4 only, not bridge-only IPv4 switching.
- IPv6 remains on Packet Sniffer in 0.11.3 and can therefore inherit Packet Sniffer throughput limits under sustained high-rate IPv6 traffic. - IPv6 remains on Packet Sniffer in 0.11.4 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. - `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. - 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. - 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.
+4 -4
View File
@@ -28,10 +28,10 @@
/container/envs/add list=IDS_ENV key=REDIS_MANAGED value=true /container/envs/add list=IDS_ENV key=REDIS_MANAGED value=true
/container/envs/add list=IDS_ENV key=REDIS_DATA_DIR value=/data/redis /container/envs/add list=IDS_ENV key=REDIS_DATA_DIR value=/data/redis
/container/envs/add list=IDS_ENV key=REDIS_PORT value=6379 /container/envs/add list=IDS_ENV key=REDIS_PORT value=6379
/container/envs/add list=IDS_ENV key=REDIS_MAXMEMORY_MB value=0 /container/envs/add list=IDS_ENV key=REDIS_MAXMEMORY_MB value=128
/container/envs/add list=IDS_ENV key=REDIS_SNAPSHOT_SECONDS value=1800 /container/envs/add list=IDS_ENV key=REDIS_SNAPSHOT_SECONDS value=0
/container/envs/add list=IDS_ENV key=TRAFFIC_RETENTION_HOURS value=24 /container/envs/add list=IDS_ENV key=TRAFFIC_RETENTION_HOURS value=24
/container/envs/add list=IDS_ENV key=TRAFFIC_MAX_EVENTS value=0 /container/envs/add list=IDS_ENV key=TRAFFIC_MAX_EVENTS value=50000
/container/envs/add list=IDS_ENV key=TRAFFIC_MEMORY_EVENTS value=0 /container/envs/add list=IDS_ENV key=TRAFFIC_MEMORY_EVENTS value=0
/container/envs/add list=IDS_ENV key=WEBSOCKET_QUEUE_SIZE value=512 /container/envs/add list=IDS_ENV key=WEBSOCKET_QUEUE_SIZE value=512
/container/envs/add list=IDS_ENV key=LIVE_FLOW_UPDATE_SECONDS value=2.0 /container/envs/add list=IDS_ENV key=LIVE_FLOW_UPDATE_SECONDS value=2.0
@@ -48,7 +48,7 @@
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value="" /container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value=""
/container/envs/add list=IDS_ENV key=SESSION_HOURS value=168 /container/envs/add list=IDS_ENV key=SESSION_HOURS value=168
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60 /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=120
/container/mounts/remove [find where list="IDS_MOUNTS"] /container/mounts/remove [find where list="IDS_MOUNTS"]
/container/mounts/add list=IDS_MOUNTS src=disk1/containers/suricata-data dst=/data /container/mounts/add list=IDS_MOUNTS src=disk1/containers/suricata-data dst=/data
+4 -4
View File
@@ -28,10 +28,10 @@
/container/envs/add list=IDS_ENV key=REDIS_MANAGED value=true /container/envs/add list=IDS_ENV key=REDIS_MANAGED value=true
/container/envs/add list=IDS_ENV key=REDIS_DATA_DIR value=/data/redis /container/envs/add list=IDS_ENV key=REDIS_DATA_DIR value=/data/redis
/container/envs/add list=IDS_ENV key=REDIS_PORT value=6379 /container/envs/add list=IDS_ENV key=REDIS_PORT value=6379
/container/envs/add list=IDS_ENV key=REDIS_MAXMEMORY_MB value=0 /container/envs/add list=IDS_ENV key=REDIS_MAXMEMORY_MB value=128
/container/envs/add list=IDS_ENV key=REDIS_SNAPSHOT_SECONDS value=1800 /container/envs/add list=IDS_ENV key=REDIS_SNAPSHOT_SECONDS value=0
/container/envs/add list=IDS_ENV key=TRAFFIC_RETENTION_HOURS value=24 /container/envs/add list=IDS_ENV key=TRAFFIC_RETENTION_HOURS value=24
/container/envs/add list=IDS_ENV key=TRAFFIC_MAX_EVENTS value=0 /container/envs/add list=IDS_ENV key=TRAFFIC_MAX_EVENTS value=50000
/container/envs/add list=IDS_ENV key=TRAFFIC_MEMORY_EVENTS value=0 /container/envs/add list=IDS_ENV key=TRAFFIC_MEMORY_EVENTS value=0
/container/envs/add list=IDS_ENV key=WEBSOCKET_QUEUE_SIZE value=512 /container/envs/add list=IDS_ENV key=WEBSOCKET_QUEUE_SIZE value=512
/container/envs/add list=IDS_ENV key=LIVE_FLOW_UPDATE_SECONDS value=2.0 /container/envs/add list=IDS_ENV key=LIVE_FLOW_UPDATE_SECONDS value=2.0
@@ -48,7 +48,7 @@
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value="" /container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value=""
/container/envs/add list=IDS_ENV key=SESSION_HOURS value=168 /container/envs/add list=IDS_ENV key=SESSION_HOURS value=168
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60 /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=120
/container/mounts/remove [find where list="IDS_MOUNTS"] /container/mounts/remove [find where list="IDS_MOUNTS"]
/container/mounts/add list=IDS_MOUNTS src=disk1/containers/suricata-data dst=/data /container/mounts/add list=IDS_MOUNTS src=disk1/containers/suricata-data dst=/data
+4 -4
View File
@@ -29,10 +29,10 @@
/container/envs/add list=IDS_ENV key=REDIS_MANAGED value=true /container/envs/add list=IDS_ENV key=REDIS_MANAGED value=true
/container/envs/add list=IDS_ENV key=REDIS_DATA_DIR value=/data/redis /container/envs/add list=IDS_ENV key=REDIS_DATA_DIR value=/data/redis
/container/envs/add list=IDS_ENV key=REDIS_PORT value=6379 /container/envs/add list=IDS_ENV key=REDIS_PORT value=6379
/container/envs/add list=IDS_ENV key=REDIS_MAXMEMORY_MB value=0 /container/envs/add list=IDS_ENV key=REDIS_MAXMEMORY_MB value=128
/container/envs/add list=IDS_ENV key=REDIS_SNAPSHOT_SECONDS value=1800 /container/envs/add list=IDS_ENV key=REDIS_SNAPSHOT_SECONDS value=0
/container/envs/add list=IDS_ENV key=TRAFFIC_RETENTION_HOURS value=24 /container/envs/add list=IDS_ENV key=TRAFFIC_RETENTION_HOURS value=24
/container/envs/add list=IDS_ENV key=TRAFFIC_MAX_EVENTS value=0 /container/envs/add list=IDS_ENV key=TRAFFIC_MAX_EVENTS value=50000
/container/envs/add list=IDS_ENV key=TRAFFIC_MEMORY_EVENTS value=0 /container/envs/add list=IDS_ENV key=TRAFFIC_MEMORY_EVENTS value=0
/container/envs/add list=IDS_ENV key=WEBSOCKET_QUEUE_SIZE value=512 /container/envs/add list=IDS_ENV key=WEBSOCKET_QUEUE_SIZE value=512
/container/envs/add list=IDS_ENV key=LIVE_FLOW_UPDATE_SECONDS value=2.0 /container/envs/add list=IDS_ENV key=LIVE_FLOW_UPDATE_SECONDS value=2.0
@@ -49,7 +49,7 @@
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value="" /container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value=""
/container/envs/add list=IDS_ENV key=SESSION_HOURS value=168 /container/envs/add list=IDS_ENV key=SESSION_HOURS value=168
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60 /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=120
/container/mounts/remove [find where list="IDS_MOUNTS"] /container/mounts/remove [find where list="IDS_MOUNTS"]
/container/mounts/add list=IDS_MOUNTS src=disk1/containers/suricata-data dst=/data /container/mounts/add list=IDS_MOUNTS src=disk1/containers/suricata-data dst=/data
+5 -4
View File
@@ -33,10 +33,11 @@ services:
REDIS_MANAGED: "true" REDIS_MANAGED: "true"
REDIS_DATA_DIR: /data/redis REDIS_DATA_DIR: /data/redis
REDIS_PORT: "6379" REDIS_PORT: "6379"
REDIS_MAXMEMORY_MB: "0" REDIS_MAXMEMORY_MB: "128"
REDIS_SNAPSHOT_SECONDS: "1800" REDIS_SNAPSHOT_SECONDS: "0"
REDIS_AOF: "false"
TRAFFIC_RETENTION_HOURS: "24" TRAFFIC_RETENTION_HOURS: "24"
TRAFFIC_MAX_EVENTS: "0" TRAFFIC_MAX_EVENTS: "50000"
TRAFFIC_MEMORY_EVENTS: "0" TRAFFIC_MEMORY_EVENTS: "0"
WEBSOCKET_QUEUE_SIZE: "512" WEBSOCKET_QUEUE_SIZE: "512"
LIVE_FLOW_UPDATE_SECONDS: "2.0" LIVE_FLOW_UPDATE_SECONDS: "2.0"
@@ -47,4 +48,4 @@ services:
METRICS_BASIC_AUTH_PASSWORD: "" METRICS_BASIC_AUTH_PASSWORD: ""
SESSION_HOURS: "168" SESSION_HOURS: "168"
SESSION_COOKIE_SECURE: "false" SESSION_COOKIE_SECURE: "false"
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS: "60" ANALYTICS_SNAPSHOT_INTERVAL_SECONDS: "120"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mikrosuricata-tzsp" name = "mikrosuricata-tzsp"
version = "0.11.3" version = "0.11.4"
edition = "2021" edition = "2021"
publish = false publish = false
+2 -2
View File
@@ -74,7 +74,7 @@ TZSP_L2_FILTER_INTERFACE="${TZSP_L2_INTERFACE:-all}"
: "${REDIS_SNAPSHOT_SECONDS:=0}" : "${REDIS_SNAPSHOT_SECONDS:=0}"
: "${REDIS_AOF:=false}" : "${REDIS_AOF:=false}"
: "${TRAFFIC_RETENTION_HOURS:=24}" : "${TRAFFIC_RETENTION_HOURS:=24}"
: "${TRAFFIC_ARCHIVE_INTERVAL_SECONDS:=5}" : "${TRAFFIC_ARCHIVE_INTERVAL_SECONDS:=10}"
: "${TRAFFIC_ARCHIVE_LAG_SECONDS:=10}" : "${TRAFFIC_ARCHIVE_LAG_SECONDS:=10}"
: "${TRAFFIC_ARCHIVE_BATCH_SIZE:=1000}" : "${TRAFFIC_ARCHIVE_BATCH_SIZE:=1000}"
: "${TRAFFIC_MAX_EVENTS:=0}" : "${TRAFFIC_MAX_EVENTS:=0}"
@@ -92,7 +92,7 @@ TZSP_L2_FILTER_INTERFACE="${TZSP_L2_INTERFACE:-all}"
: "${METRICS_BASIC_AUTH_PASSWORD:=}" : "${METRICS_BASIC_AUTH_PASSWORD:=}"
: "${SESSION_HOURS:=168}" : "${SESSION_HOURS:=168}"
: "${SESSION_COOKIE_SECURE:=false}" : "${SESSION_COOKIE_SECURE:=false}"
: "${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS:=60}" : "${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS:=120}"
: "${NDR_ENABLED:=true}" : "${NDR_ENABLED:=true}"
: "${NDR_CORRELATION_WINDOW_SECONDS:=1800}" : "${NDR_CORRELATION_WINDOW_SECONDS:=1800}"
: "${BEHAVIOR_MIN_OBSERVATIONS:=50}" : "${BEHAVIOR_MIN_OBSERVATIONS:=50}"
+1 -1
View File
@@ -6,7 +6,7 @@ import urllib.request
try: try:
port = int(os.getenv("WEB_PORT", "8080")) port = int(os.getenv("WEB_PORT", "8080"))
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/status", timeout=3) as response: with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=3) as response:
data = json.load(response) data = json.load(response)
raise SystemExit(0 if data.get("operational") else 1) raise SystemExit(0 if data.get("operational") else 1)
except Exception as exc: except Exception as exc:
+10
View File
@@ -30,6 +30,16 @@ class AnalyticsCacheTests(unittest.TestCase):
self.assertFalse(cache._snapshot_thread.is_alive()) self.assertFalse(cache._snapshot_thread.is_alive())
store.close() store.close()
def test_background_worker_has_no_dashboard_windows_until_requested(self):
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
history = TrafficHistory("", retention_hours=24, max_events=1000, memory_events=1000)
cache = AnalyticsSnapshotCache(store, history, threading.Event(), interval_seconds=60)
self.assertEqual(cache._due_windows(), [])
cache.get(900)
self.assertEqual(cache._due_windows(), [900])
store.close()
def test_refresh_persists_all_dashboard_windows_in_sqlite_cache(self): def test_refresh_persists_all_dashboard_windows_in_sqlite_cache(self):
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db")) store = AlertStore(os.path.join(td, "ids.db"))
+9 -1
View File
@@ -43,9 +43,17 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(cfg.redis_maxmemory_mb, 128) self.assertEqual(cfg.redis_maxmemory_mb, 128)
self.assertEqual(cfg.redis_snapshot_seconds, 0) self.assertEqual(cfg.redis_snapshot_seconds, 0)
self.assertFalse(cfg.redis_aof) self.assertFalse(cfg.redis_aof)
self.assertEqual(cfg.traffic_archive_interval_seconds, 5) self.assertEqual(cfg.traffic_archive_interval_seconds, 10)
self.assertEqual(cfg.analytics_snapshot_interval_seconds, 120)
self.assertEqual(cfg.traffic_archive_lag_seconds, 10) self.assertEqual(cfg.traffic_archive_lag_seconds, 10)
self.assertEqual(cfg.traffic_archive_batch_size, 1000) self.assertEqual(cfg.traffic_archive_batch_size, 1000)
self.assertEqual(cfg.traffic_max_events, 50000)
def test_traffic_max_events_can_be_overridden_or_disabled(self):
with patch.dict(os.environ, {"TRAFFIC_MAX_EVENTS": "12345"}, clear=True):
self.assertEqual(Config.from_env().traffic_max_events, 12345)
with patch.dict(os.environ, {"TRAFFIC_MAX_EVENTS": "0"}, clear=True):
self.assertEqual(Config.from_env().traffic_max_events, 0)
if __name__ == "__main__": if __name__ == "__main__":
+15
View File
@@ -8,11 +8,13 @@ from datetime import datetime, timezone
from app.store import AlertStore from app.store import AlertStore
from app.live import ( from app.live import (
MAX_ANALYTICS_DIMENSION_KEYS,
EventBus, EventBus,
LiveEventPipeline, LiveEventPipeline,
RedisUnavailableError, RedisUnavailableError,
TrafficHistory, TrafficHistory,
TrafficNormalizer, TrafficNormalizer,
_AnalyticsAccumulator,
event_matches, event_matches,
) )
@@ -410,6 +412,19 @@ class LiveTests(unittest.TestCase):
self.assertEqual(store.traffic_archive_status()["events"], 0) self.assertEqual(store.traffic_archive_status()["events"], 0)
store.close() store.close()
def test_sqlite_analytics_high_cardinality_counters_are_bounded(self):
now = int(time.time() * 1000)
acc = _AnalyticsAccumulator(now - 60_000, now, 60, track_high_cardinality=False)
for idx in range(MAX_ANALYTICS_DIMENSION_KEYS + 100):
acc.add_event({
"ts_ms": now,
"type": "fileinfo",
"filename": f"unique-{idx}.bin",
"file_sha256": f"{idx:064x}"[-64:],
"direction": "outbound",
})
self.assertEqual(len(acc.file_activity), MAX_ANALYTICS_DIMENSION_KEYS)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+68
View File
@@ -175,6 +175,74 @@ class PrometheusMetricsTests(unittest.TestCase):
store.close() store.close()
def test_health_endpoint_uses_lightweight_provider(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(Config.from_env(), web_bind="127.0.0.1", web_port=0, db_path=db_path)
store = AlertStore(db_path)
full_calls = []
health_calls = []
def full_status():
full_calls.append(True)
raise AssertionError("lightweight health endpoint must not build full status")
web = WebServer(
cfg,
store,
full_status,
healthcheck_provider=lambda: health_calls.append(True) or {"status": "ok", "operational": True},
)
try:
with contextlib.redirect_stdout(io.StringIO()):
web.start()
port = web.server.server_address[1]
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=2) as response:
body = response.read().decode("utf-8")
self.assertEqual(200, response.status)
self.assertIn('"operational":true', body.replace(" ", ""))
self.assertEqual([], full_calls)
self.assertEqual([True], health_calls)
finally:
web.stop()
store.close()
def test_status_payload_is_cached_for_short_poll_bursts(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(Config.from_env(), web_bind="127.0.0.1", web_port=0, db_path=db_path)
store = AlertStore(db_path)
calls = []
web = WebServer(cfg, store, lambda: calls.append(True) or {"operational": True})
try:
first = web._status_payload()
second = web._status_payload()
self.assertTrue(first["operational"])
self.assertTrue(second["operational"])
self.assertEqual([True], calls)
finally:
web.server.server_close()
store.close()
def test_stats_payload_caches_expensive_sqlite_aggregates(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(Config.from_env(), web_bind="127.0.0.1", web_port=0, db_path=db_path)
store = AlertStore(db_path)
calls = {"summary": 0, "analytics": 0, "ndr": 0}
store.summary = lambda: calls.__setitem__("summary", calls["summary"] + 1) or {"alerts": 0}
store.analytics = lambda: calls.__setitem__("analytics", calls["analytics"] + 1) or {"alerts_24h": 0}
store.ndr_summary = lambda: calls.__setitem__("ndr", calls["ndr"] + 1) or {"open_incidents": 0}
web = WebServer(cfg, store, lambda: {"operational": True})
try:
first = web._stats_payload()
second = web._stats_payload()
self.assertEqual(first, second)
self.assertEqual({"summary": 1, "analytics": 1, "ndr": 1}, calls)
finally:
web.server.server_close()
store.close()
def test_metrics_ip_acl_denies_before_rendering_metrics(self): def test_metrics_ip_acl_denies_before_rendering_metrics(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db") db_path = str(Path(tmp) / "ids.db")
+50
View File
@@ -1,6 +1,7 @@
import base64 import base64
import os import os
import tempfile import tempfile
from unittest.mock import patch
from app.ndr import NDRAnalyzer, ThreatIntelManager from app.ndr import NDRAnalyzer, ThreatIntelManager
from app.store import AlertStore from app.store import AlertStore
@@ -142,3 +143,52 @@ def test_repeated_ip_mac_changes_escalate_to_network_spoofing_and_anomalies_are_
events = store.ndr_incident_events(int(anomaly_incident["id"]), 20) events = store.ndr_incident_events(int(anomaly_incident["id"]), 20)
assert sum(1 for event in events if event["stage"] == "protocol-anomaly") == 1 assert sum(1 for event in events if event["stage"] == "protocol-anomaly") == 1
store.close() store.close()
def test_baseline_touch_is_cached_for_repeated_values():
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
ti = ThreatIntelManager(store, os.path.join(td, "suricata"))
analyzer = NDRAnalyzer(
store, ti, DummyRouterOS(), "192.168.88.0/24", "", "1h",
enabled=True, auto_block=False,
)
calls = 0
original = store.baseline_touch
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
store.baseline_touch = counted
record = {
"timestamp": "2026-08-15T08:20:00+00:00",
"type": "flow", "direction": "outbound", "src_ip": "192.168.88.50",
"dest_ip": "203.0.113.20", "dest_port": 443, "app_proto": "tls",
}
for _ in range(10):
analyzer._process(dict(record), None)
assert calls == 2 # app + outbound port, only on first sight in this process
assert analyzer.status()["state_entries"]["baseline_lru"] == 2
store.close()
def test_beacon_state_is_bounded():
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
ti = ThreatIntelManager(store, os.path.join(td, "suricata"))
analyzer = NDRAnalyzer(
store, ti, DummyRouterOS(), "192.168.88.0/24", "", "1h",
enabled=True, auto_block=False,
)
with patch("app.ndr.NDR_BEACON_MAX_KEYS", 32):
for idx in range(64):
analyzer._behavior({
"timestamp": "2026-08-15T08:30:00+00:00",
"type": "flow", "direction": "outbound",
"src_ip": "192.168.88.60", "dest_ip": f"203.0.113.{idx}",
"dest_port": 443,
}, None, "192.168.88.60")
assert len(analyzer._beacon) == 32
store.close()