Files
routeros-suricata-tzsp/app/main.py
T
2026-08-15 23:43:58 +02:00

542 lines
22 KiB
Python

from __future__ import annotations
import os
import signal
import subprocess
import sys
import tempfile
import threading
import re
import time
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from .analytics_cache import AnalyticsSnapshotCache
from .backup import BackupManager
from .config import Config
from .eve import EVEWatcher
from .flow_tracker import FlowTracker
from .forensics import ForensicPcapRing
from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer
from .maintenance import clear_suricata_logs, storage_info
from .ndr import NDRAnalyzer, ThreatIntelManager
from .notifier import WebhookNotifier
from .policy import PolicyEngine
from .redis_service import RedisSupervisor
from .routeros import RouterOSClient
from .rules import RuleManager
from .state import RuntimeStats
from .store import AlertStore
from .tap import TapDevice
from .tuning import AlertTuner
from .tzsp import TZSPReceiver
from .webui import WebServer
def _routeros_target(cfg: Config) -> tuple[str, int]:
parsed = urlparse(cfg.routeros_url)
host = parsed.hostname or cfg.routeros_url
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return host, port
def _ensure_suricata_state(cfg: Config) -> None:
for path in (cfg.suricata_custom_rules, cfg.suricata_threshold_config):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).touch(exist_ok=True)
def _prepare_suricata_output_config(cfg: Config) -> Config:
source = Path(cfg.suricata_output_config)
text = source.read_text(encoding="utf-8")
match = re.search(r"(?ms)^ - pcap-log:\n.*?(?=^ - |\Z)", text)
if match is None:
raise RuntimeError("Suricata output profile has no pcap-log section")
block = match.group(0)
enabled = cfg.forensic_pcap_mode in {"alerts", "all"}
conditional = "all" if cfg.forensic_pcap_mode == "all" else "alerts"
block = re.sub(r"(?m)^ enabled: .*?$", f" enabled: {'yes' if enabled else 'no'}", block)
block = re.sub(r"(?m)^ conditional: .*?$", f" conditional: {conditional}", block)
rendered = text[:match.start()] + block + text[match.end():]
runtime = Path("/run/suricata/ids-output.runtime.yaml")
runtime.parent.mkdir(parents=True, exist_ok=True)
runtime.write_text(rendered, encoding="utf-8")
os.environ["SURICATA_OUTPUT_CONFIG"] = str(runtime)
return replace(cfg, suricata_output_config=str(runtime))
def _suricata_common_args(cfg: Config, log_dir: str) -> list[str]:
return [
"-c",
cfg.suricata_config,
"--include",
cfg.suricata_output_config,
"-l",
log_dir,
# Suricata exposes one additive -s signature path; use its supported
# globbing so all persisted local/custom .rules files are loaded.
"-s",
cfg.suricata_extra_rules_glob,
"--set",
f"vars.address-groups.HOME_NET={cfg.suricata_home_net}",
"--set",
f"threshold-file={cfg.suricata_threshold_config}",
"--set",
f"default-rule-path={cfg.suricata_persist_lib_dir}/rules",
# These fingerprints are useful IDS pivots but remain opt-in in the
# upstream configuration unless a rule explicitly needs them.
"--set",
"app-layer.protocols.tls.ja3-fingerprints=yes",
"--set",
"app-layer.protocols.tls.ja4-fingerprints=yes",
"--set",
"app-layer.protocols.ssh.hassh=yes",
]
def main() -> int:
cfg = _prepare_suricata_output_config(Config.from_env())
stop_event = threading.Event()
stats = RuntimeStats()
started_at = datetime.now(timezone.utc)
started_monotonic = time.monotonic()
os.makedirs(os.path.dirname(cfg.eve_path) or ".", exist_ok=True)
os.makedirs(os.path.dirname(cfg.db_path) or ".", exist_ok=True)
_ensure_suricata_state(cfg)
store = AlertStore(cfg.db_path)
backup_manager = BackupManager(cfg.db_path, os.path.dirname(cfg.db_path) or ".")
threat_intel = ThreatIntelManager(store, os.path.dirname(cfg.suricata_custom_rules))
purged_tests = store.purge_builtin_test_incidents()
if purged_tests:
print(f"[db] removed {purged_tests} legacy pipeline-test incidents", flush=True)
purged = store.purge_older_than(cfg.alert_retention_days)
if purged:
print(f"[db] purged {purged} old alerts", flush=True)
tap = TapDevice(cfg.tap_name, cfg.tap_mtu)
try:
tap.open()
except Exception as exc:
print(f"[fatal] cannot create TAP {cfg.tap_name}: {exc}", file=sys.stderr, flush=True)
print("[fatal] container needs /dev/net/tun and NET_ADMIN capability", file=sys.stderr, flush=True)
store.close()
return 2
print(f"[tap] {cfg.tap_name} is up, mtu={cfg.tap_mtu}", flush=True)
log_dir = os.path.dirname(cfg.eve_path) or "/var/log/suricata"
suricata_cmd = [
"suricata",
*_suricata_common_args(cfg, log_dir),
f"--af-packet={cfg.tap_name}",
"--user",
"suricata",
"--group",
"suricata",
# Debian's default unix-command socket is directly under /var/run,
# which is not writable after Suricata drops privileges.
"--set",
"unix-command.filename=suricata/suricata-command.socket",
]
print("[suricata] validating configuration and managed rules", flush=True)
with tempfile.TemporaryDirectory(prefix="suricata-config-test-") as test_log_dir:
test_cmd = ["suricata", "-T", *_suricata_common_args(cfg, test_log_dir)]
test = subprocess.run(test_cmd, check=False)
if test.returncode != 0:
print(
f"[fatal] suricata configuration test failed with rc={test.returncode}",
file=sys.stderr,
flush=True,
)
tap.close()
store.close()
return test.returncode or 3
print("[suricata] starting IDS process", flush=True)
suricata = subprocess.Popen(suricata_cmd)
with open("/run/suricata.pid", "w", encoding="ascii") as pid_file:
pid_file.write(str(suricata.pid))
tuner = AlertTuner(
cfg.alert_max_severity,
cfg.alert_ignore_sids,
cfg.alert_ignore_categories,
)
policy = PolicyEngine(
cfg.auto_block,
cfg.auto_block_max_severity,
cfg.monitored_networks,
cfg.never_block,
)
routeros = RouterOSClient(
cfg.routeros_url,
cfg.routeros_user,
cfg.routeros_password,
cfg.routeros_verify_tls,
cfg.routeros_address_list,
cfg.routeros_http_timeout,
)
notifier = WebhookNotifier(cfg.notify_webhook_url, cfg.notify_min_risk, cfg.notify_timeout_seconds)
forensic_pcap = ForensicPcapRing(
cfg.forensic_pcap_mode,
log_dir,
window_seconds=cfg.forensic_pcap_window_seconds,
memory_mb=cfg.forensic_pcap_memory_mb,
max_files=cfg.forensic_pcap_max_files,
max_total_mb=cfg.forensic_pcap_max_total_mb,
)
ndr_analyzer = NDRAnalyzer(
store, threat_intel, routeros, cfg.monitored_networks, cfg.never_block, cfg.block_timeout,
enabled=cfg.ndr_enabled,
correlation_window_seconds=cfg.ndr_correlation_window_seconds,
behavior_min_observations=cfg.behavior_min_observations,
auto_block=cfg.ndr_auto_block,
auto_block_risk=cfg.ndr_auto_block_risk,
notifier=notifier,
block_evidence_callback=lambda target, label: forensic_pcap.capture_target(target, label=label),
)
redis_supervisor = RedisSupervisor(
cfg.redis_managed,
cfg.redis_data_dir,
cfg.redis_port,
cfg.redis_maxmemory_mb,
cfg.redis_snapshot_seconds,
cfg.redis_aof,
)
if cfg.redis_managed and not redis_supervisor.start(wait_ready_seconds=15):
raise RuntimeError(
f"managed Redis failed to start: {redis_supervisor.status().get('last_error') or 'unknown error'}"
)
event_bus = EventBus(
history_size=0,
subscriber_queue_size=cfg.websocket_queue_size,
)
traffic_history = TrafficHistory(
cfg.redis_url,
cfg.traffic_retention_hours,
0,
0,
require_redis=True,
allow_memory_fallback=False,
)
analytics_cache = AnalyticsSnapshotCache(
store,
traffic_history,
stop_event,
cfg.analytics_snapshot_interval_seconds,
)
live_pipeline = LiveEventPipeline(event_bus, traffic_history)
normalizer = TrafficNormalizer(cfg.monitored_networks)
flow_tracker = FlowTracker(normalizer, live_pipeline, update_interval_seconds=cfg.live_flow_update_seconds)
def observe_frame(frame: bytes) -> None:
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(
cfg.eve_path,
store,
tuner,
policy,
routeros,
cfg.block_timeout,
cfg.alert_dedup_window_seconds,
stats,
stop_event,
normalizer=normalizer,
live_pipeline=live_pipeline,
ndr_analyzer=ndr_analyzer,
forensic_pcap=forensic_pcap,
)
rule_manager = RuleManager(
cfg,
pid_provider=lambda: suricata.pid if suricata.poll() is None else None,
suricata_available=True,
)
routeros_host, routeros_port = _routeros_target(cfg)
def health() -> dict:
suricata_up = suricata.poll() is None
tzsp_up = receiver.is_alive() and receiver.sock is not None
tap_up = tap.fd is not None and os.path.exists(f"/sys/class/net/{cfg.tap_name}")
eve_up = watcher.is_alive()
routeros_status = "configured" if routeros.configured else "disabled"
db = store.database_info()
storage = storage_info(cfg.db_path, cfg.eve_path)
rules = rule_manager.status()
runtime = stats.snapshot()
redis_status = redis_supervisor.status()
suri_stats = runtime.get("suricata") or {}
kernel_packets = int(suri_stats.get("capture.kernel_packets", 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)
inject_errors = int(runtime.get("inject_errors", 0) or 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
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
operational = core_up and routeros_required_ok
return {
"status": "ok" if operational else "degraded",
"mode": "full",
"dev_mode": False,
"operational": operational,
"started_at": started_at.isoformat(),
"uptime_seconds": round(time.monotonic() - started_monotonic, 1),
"suricata_running": suricata_up,
"suricata_pid": suricata.pid,
"auto_block": cfg.auto_block,
"routeros_configured": routeros.configured,
"ndr": {**ndr_analyzer.status(), **store.ndr_summary()},
"database": db,
"storage": storage,
"rules": rules,
"redis": redis_status,
"services": {
"web": {
"name": "Web UI / API",
"status": "up",
"details": f"Listening on TCP {cfg.web_bind}:{cfg.web_port}",
},
"tzsp": {
"name": "TZSP receiver",
"status": "up" if tzsp_up else "down",
"details": f"Listening on UDP {cfg.tzsp_bind}:{cfg.tzsp_port}",
},
"tap": {
"name": "TAP interface",
"status": "up" if tap_up else "down",
"details": f"{cfg.tap_name}, MTU {cfg.tap_mtu}",
},
"suricata": {
"name": "Suricata IDS",
"status": "up" if suricata_up else "down",
"details": f"PID {suricata.pid}" if suricata_up else f"Process exited with code {suricata.poll()}",
},
"sensor_quality": {
"name": "Sensor quality / packet loss",
"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}",
},
"eve": {
"name": "EVE JSON watcher",
"status": "up" if eve_up else "down",
"details": cfg.eve_path,
},
"database": {
"name": "SQLite database",
"status": "up" if db["ok"] else "down",
"details": f"{db['path']}; {db['rows']} incidents; WAL={db['journal_mode']}",
},
"storage": {
"name": "Persistent storage",
"status": "up" if storage["free_bytes"] > 0 else "down",
"details": f"{storage['path']}; {storage['used_percent']}% used",
},
"live_flows": {
"name": "Immediate TZSP sessions",
"status": "up" if tzsp_up else "down",
"details": f"{flow_tracker.status()['active_flows']} active; non-persistent {flow_tracker.status()['update_interval_seconds']:g}s updates",
},
"traffic_history": {
"name": "Live traffic history",
"status": "up" if traffic_history.status().get("redis_ok") else "degraded",
"details": f"Redis-only persistent history; retention={cfg.traffic_retention_hours}h; no event-count cap",
},
"analytics_cache": {
"name": "Persistent dashboard summaries",
"status": "up",
"details": f"Redis snapshots for 15m/1h/6h/24h every {cfg.analytics_snapshot_interval_seconds}s",
},
"ndr": {
"name": "MikroSuricata NDR correlation",
"status": "up" if ndr_analyzer.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']}",
},
"notifications": {
"name": "High-risk webhook notifications",
"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']}",
},
"redis": {
"name": "Managed Redis",
"status": (
"up" if redis_status.get("ready")
else "disabled" if not cfg.redis_managed
else "degraded"
),
"details": (
f"{cfg.redis_data_dir}; maxmemory=unlimited; persistence={redis_status.get('persistence')}"
if cfg.redis_managed
else "Managed Redis disabled; REDIS_URL may point to an external server"
),
},
"rules": {
"name": "Managed rules",
"status": "up" if rules["available"] else "disabled",
"details": f"{rules.get('builtin_rule_count', 0)} built-in; {rules['custom_rule_count']} custom; {rules['threshold_entry_count']} threshold/suppress entries",
},
"routeros": {
"name": "RouterOS REST integration",
"status": routeros_status,
"details": f"{cfg.routeros_url}; auto-block={'enabled' if cfg.auto_block else 'disabled'}",
},
},
"ports": [
{
"name": "Web UI / API",
"direction": "listen",
"protocol": "TCP",
"address": cfg.web_bind,
"port": cfg.web_port,
"status": "up",
},
{
"name": "TZSP receiver",
"direction": "listen",
"protocol": "UDP",
"address": cfg.tzsp_bind,
"port": cfg.tzsp_port,
"status": "up" if tzsp_up else "down",
},
{
"name": "RouterOS REST API",
"direction": "outbound",
"protocol": "TCP",
"address": routeros_host,
"port": routeros_port,
"status": routeros_status,
},
],
"runtime": stats.snapshot(),
}
web = WebServer(
cfg,
store,
health,
stats=stats,
rule_manager=rule_manager,
traffic_history=traffic_history,
event_bus=event_bus,
live_pipeline=live_pipeline,
routeros=routeros,
analytics_cache=analytics_cache,
threat_intel=threat_intel,
ndr_analyzer=ndr_analyzer,
backup_manager=backup_manager,
forensic_pcap=forensic_pcap,
)
def housekeeping() -> None:
interval_seconds = max(0, cfg.rule_update_interval_hours) * 3600
next_rule_update = time.monotonic() + interval_seconds if interval_seconds else None
next_retention = time.monotonic() + 3600
next_routeros_inventory = time.monotonic() + 10
log_limit_bytes = max(0, cfg.suricata_log_max_mb) * 1024 * 1024
while not stop_event.wait(60):
now = time.monotonic()
if log_limit_bytes:
try:
current_storage = storage_info(cfg.db_path, cfg.eve_path)
if int(current_storage.get("suricata_log_bytes", 0)) > log_limit_bytes:
result = clear_suricata_logs(cfg.eve_path)
stats.inc("log_auto_truncations")
print(
f"[housekeeping] Suricata logs exceeded {cfg.suricata_log_max_mb}MB; "
f"freed {result['bytes_freed']} bytes",
flush=True,
)
except Exception as exc:
print(f"[housekeeping] log cap failed: {exc}", file=sys.stderr, flush=True)
if now >= next_retention:
try:
removed = store.purge_older_than(cfg.alert_retention_days)
if removed:
print(f"[db] purged {removed} expired incidents", flush=True)
except Exception as exc:
print(f"[housekeeping] alert retention failed: {exc}", file=sys.stderr, flush=True)
next_retention = now + 3600
if now >= next_routeros_inventory:
try:
result = ndr_analyzer.sync_routeros_inventory()
if result.get("assets"):
print(f"[ndr] RouterOS inventory: {result['assets']} assets (DHCP={result['dhcp']}, ARP={result['arp']})", flush=True)
except Exception as exc:
print(f"[housekeeping] RouterOS inventory sync failed: {exc}", file=sys.stderr, flush=True)
next_routeros_inventory = now + cfg.routeros_inventory_interval_seconds
if next_rule_update is not None and now >= next_rule_update:
result = rule_manager.update_vendor_rules()
stream = sys.stdout if result.ok else sys.stderr
print(f"[rules] scheduled update: {result.message}", file=stream, flush=True)
next_rule_update = now + interval_seconds
housekeeping_thread = threading.Thread(target=housekeeping, name="housekeeping", daemon=True)
def request_stop(_signum=None, _frame=None):
stop_event.set()
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
live_pipeline.start()
analytics_cache.start()
notifier.start()
ndr_analyzer.start()
receiver.start()
watcher.start()
housekeeping_thread.start()
web.start()
rc = 0
try:
while not stop_event.is_set():
suricata_rc = suricata.poll()
if suricata_rc is not None:
print(f"[fatal] Suricata exited with rc={suricata_rc}", file=sys.stderr, flush=True)
rc = suricata_rc or 4
break
time.sleep(0.5)
finally:
stop_event.set()
receiver.close()
try:
web.stop()
except Exception:
pass
if suricata.poll() is None:
suricata.terminate()
try:
suricata.wait(timeout=8)
except subprocess.TimeoutExpired:
suricata.kill()
suricata.wait(timeout=3)
try:
os.remove("/run/suricata.pid")
except FileNotFoundError:
pass
tap.close()
live_pipeline.stop()
analytics_cache.stop()
ndr_analyzer.stop()
notifier.stop()
redis_supervisor.stop()
store.close()
return rc
if __name__ == "__main__":
raise SystemExit(main())