worked poc

This commit is contained in:
Mateusz Gruszczyński
2026-08-14 11:33:01 +02:00
parent adfdb0b86c
commit fc3a2944b2
94 changed files with 2931 additions and 3412 deletions
+114 -12
View File
@@ -4,18 +4,23 @@ 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
@@ -27,6 +32,29 @@ def _routeros_target(cfg: Config) -> tuple[str, int]:
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()
@@ -36,8 +64,12 @@ def main() -> int:
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)
@@ -53,21 +85,31 @@ def main() -> int:
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",
"-c", cfg.suricata_config,
*_suricata_common_args(cfg, log_dir),
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}",
"--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",
]
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)
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)
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
@@ -77,6 +119,11 @@ def main() -> int:
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,
@@ -93,7 +140,22 @@ def main() -> int:
)
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)
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:
@@ -102,7 +164,10 @@ def main() -> int:
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
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
@@ -117,6 +182,9 @@ def main() -> int:
"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",
@@ -143,6 +211,21 @@ def main() -> int:
"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,
@@ -178,7 +261,25 @@ def main() -> int:
"runtime": stats.snapshot(),
}
web = WebServer(cfg, store, health)
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()
@@ -188,6 +289,7 @@ def main() -> int:
receiver.start()
watcher.start()
housekeeping_thread.start()
web.start()
rc = 0