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

228 lines
7.7 KiB
Python

from __future__ import annotations
import os
import signal
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
from .config import Config
from .eve import EVEWatcher
from .policy import PolicyEngine
from .routeros import RouterOSClient
from .state import RuntimeStats
from .store import AlertStore
from .tap import TapDevice
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 main() -> int:
cfg = 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)
store = AlertStore(cfg.db_path)
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)
suricata_cmd = [
"suricata",
"-c", cfg.suricata_config,
f"--af-packet={cfg.tap_name}",
"-l", os.path.dirname(cfg.eve_path) or "/var/log/suricata",
"--user", "suricata",
"--group", "suricata",
"--set", f"vars.address-groups.HOME_NET={cfg.suricata_home_net}",
]
test_cmd = ["suricata", "-T", "-c", cfg.suricata_config, "--set", f"vars.address-groups.HOME_NET={cfg.suricata_home_net}"]
print("[suricata] validating configuration", flush=True)
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))
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,
)
receiver = TZSPReceiver(cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event)
watcher = EVEWatcher(cfg.eve_path, store, policy, routeros, cfg.block_timeout, stats, stop_event)
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"
core_up = suricata_up and tzsp_up and tap_up and eve_up
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,
"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()}",
},
"eve": {
"name": "EVE JSON watcher",
"status": "up" if eve_up else "down",
"details": cfg.eve_path,
},
"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)
def request_stop(_signum=None, _frame=None):
stop_event.set()
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
receiver.start()
watcher.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()
store.close()
return rc
if __name__ == "__main__":
raise SystemExit(main())