from __future__ import annotations import os import signal import subprocess import sys import tempfile import threading import time from datetime import datetime, timezone from pathlib import Path from urllib.parse import urlparse from .config import Config from .eve import EVEWatcher from .maintenance import storage_info from .policy import PolicyEngine 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 _suricata_common_args(cfg: Config, log_dir: str) -> list[str]: return [ "-c", cfg.suricata_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}", ] 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) _ensure_suricata_state(cfg) store = AlertStore(cfg.db_path) 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, ) receiver = TZSPReceiver(cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event) watcher = EVEWatcher( cfg.eve_path, store, tuner, policy, routeros, cfg.block_timeout, cfg.alert_dedup_window_seconds, stats, stop_event, ) 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() 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, "database": db, "storage": storage, "rules": rules, "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, }, "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", }, "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) 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 while not stop_event.wait(3600): 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) if next_rule_update is not None and time.monotonic() >= 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 = time.monotonic() + 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) 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() store.close() return rc if __name__ == "__main__": raise SystemExit(main())