first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""RouterOS TZSP -> TAP -> Suricata integration package."""
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _bool(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _int(name: str, default: int) -> int:
|
||||
value = os.getenv(name)
|
||||
if value is None or not value.strip():
|
||||
return default
|
||||
return int(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
tzsp_bind: str
|
||||
tzsp_port: int
|
||||
tap_name: str
|
||||
tap_mtu: int
|
||||
suricata_config: str
|
||||
suricata_home_net: str
|
||||
update_rules_on_start: bool
|
||||
web_bind: str
|
||||
web_port: int
|
||||
db_path: str
|
||||
eve_path: str
|
||||
alert_retention_days: int
|
||||
auto_block: bool
|
||||
auto_block_max_severity: int
|
||||
monitored_networks: str
|
||||
never_block: str
|
||||
block_timeout: str
|
||||
routeros_url: str
|
||||
routeros_user: str
|
||||
routeros_password: str
|
||||
routeros_verify_tls: bool
|
||||
routeros_address_list: str
|
||||
routeros_http_timeout: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
return cls(
|
||||
tzsp_bind=os.getenv("TZSP_BIND", "0.0.0.0"),
|
||||
tzsp_port=_int("TZSP_PORT", 37008),
|
||||
tap_name=os.getenv("TAP_NAME", "suritap0"),
|
||||
tap_mtu=_int("TAP_MTU", 9000),
|
||||
suricata_config=os.getenv("SURICATA_CONFIG", "/etc/suricata/suricata.yaml"),
|
||||
suricata_home_net=os.getenv(
|
||||
"SURICATA_HOME_NET",
|
||||
"[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]",
|
||||
),
|
||||
update_rules_on_start=_bool("UPDATE_RULES_ON_START", False),
|
||||
web_bind=os.getenv("WEB_BIND", "0.0.0.0"),
|
||||
web_port=_int("WEB_PORT", 8080),
|
||||
db_path=os.getenv("DB_PATH", "/data/ids.db"),
|
||||
eve_path=os.getenv("EVE_PATH", "/var/log/suricata/eve.json"),
|
||||
alert_retention_days=_int("ALERT_RETENTION_DAYS", 14),
|
||||
auto_block=_bool("AUTO_BLOCK", False),
|
||||
auto_block_max_severity=_int("AUTO_BLOCK_MAX_SEVERITY", 1),
|
||||
monitored_networks=os.getenv("MONITORED_NETWORKS", "192.168.100.0/24"),
|
||||
never_block=os.getenv("NEVER_BLOCK", ""),
|
||||
block_timeout=os.getenv("BLOCK_TIMEOUT", "1h"),
|
||||
routeros_url=os.getenv("ROUTEROS_URL", "https://172.31.255.1").rstrip("/"),
|
||||
routeros_user=os.getenv("ROUTEROS_USER", "suricata-api"),
|
||||
routeros_password=os.getenv("ROUTEROS_PASSWORD", "CHANGE_ME"),
|
||||
routeros_verify_tls=_bool("ROUTEROS_VERIFY_TLS", False),
|
||||
routeros_address_list=os.getenv("ROUTEROS_ADDRESS_LIST", "IDS-BLOCK"),
|
||||
routeros_http_timeout=_int("ROUTEROS_HTTP_TIMEOUT", 5),
|
||||
)
|
||||
|
||||
def public_dict(self) -> dict:
|
||||
return {
|
||||
"tzsp_bind": self.tzsp_bind,
|
||||
"tzsp_port": self.tzsp_port,
|
||||
"tap_name": self.tap_name,
|
||||
"tap_mtu": self.tap_mtu,
|
||||
"suricata_home_net": self.suricata_home_net,
|
||||
"web_port": self.web_port,
|
||||
"auto_block": self.auto_block,
|
||||
"auto_block_max_severity": self.auto_block_max_severity,
|
||||
"monitored_networks": self.monitored_networks,
|
||||
"never_block": self.never_block,
|
||||
"block_timeout": self.block_timeout,
|
||||
"routeros_url": self.routeros_url,
|
||||
"routeros_user": self.routeros_user,
|
||||
"routeros_verify_tls": self.routeros_verify_tls,
|
||||
"routeros_address_list": self.routeros_address_list,
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .config import Config
|
||||
from .state import RuntimeStats
|
||||
from .store import AlertStore
|
||||
from .webui import WebServer
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _seed_demo_alert(store: AlertStore) -> None:
|
||||
if store.summary()["total_alerts"]:
|
||||
return
|
||||
|
||||
event = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"flow_id": "dev-demo",
|
||||
"src_ip": "192.168.100.10",
|
||||
"src_port": 51515,
|
||||
"dest_ip": "203.0.113.10",
|
||||
"dest_port": 443,
|
||||
"proto": "TCP",
|
||||
"alert": {
|
||||
"signature_id": 1000001,
|
||||
"signature": "DEV MODE SAMPLE ALERT",
|
||||
"category": "Development/Test",
|
||||
"severity": 2,
|
||||
"action": "allowed",
|
||||
},
|
||||
}
|
||||
store.insert_alert(event, False, None, "development sample")
|
||||
|
||||
|
||||
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.db_path) or ".", exist_ok=True)
|
||||
store = AlertStore(cfg.db_path)
|
||||
|
||||
if _bool_env("DEV_SEED_DATA", False):
|
||||
_seed_demo_alert(store)
|
||||
|
||||
routeros_host, routeros_port = _routeros_target(cfg)
|
||||
|
||||
def health() -> dict:
|
||||
return {
|
||||
"status": "development",
|
||||
"mode": "web-only-development",
|
||||
"dev_mode": True,
|
||||
"operational": True,
|
||||
"started_at": started_at.isoformat(),
|
||||
"uptime_seconds": round(time.monotonic() - started_monotonic, 1),
|
||||
"suricata_running": False,
|
||||
"suricata_pid": None,
|
||||
"auto_block": False,
|
||||
"routeros_configured": False,
|
||||
"services": {
|
||||
"web": {
|
||||
"name": "Web UI / API",
|
||||
"status": "up",
|
||||
"details": f"Development server listening on {cfg.web_bind}:{cfg.web_port}",
|
||||
},
|
||||
"tzsp": {
|
||||
"name": "TZSP receiver",
|
||||
"status": "disabled",
|
||||
"details": "Disabled in web-only development mode",
|
||||
},
|
||||
"tap": {
|
||||
"name": "TAP interface",
|
||||
"status": "disabled",
|
||||
"details": f"{cfg.tap_name} is not created in development mode",
|
||||
},
|
||||
"suricata": {
|
||||
"name": "Suricata IDS",
|
||||
"status": "disabled",
|
||||
"details": "Suricata is not started in web-only development mode",
|
||||
},
|
||||
"eve": {
|
||||
"name": "EVE JSON watcher",
|
||||
"status": "disabled",
|
||||
"details": "EVE watcher is not started in web-only development mode",
|
||||
},
|
||||
"routeros": {
|
||||
"name": "RouterOS REST integration",
|
||||
"status": "disabled",
|
||||
"details": "RouterOS integration and auto-blocking are disabled in development mode",
|
||||
},
|
||||
},
|
||||
"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": "disabled",
|
||||
},
|
||||
{
|
||||
"name": "RouterOS REST API",
|
||||
"direction": "outbound",
|
||||
"protocol": "TCP",
|
||||
"address": routeros_host,
|
||||
"port": routeros_port,
|
||||
"status": "disabled",
|
||||
},
|
||||
],
|
||||
"runtime": stats.snapshot(),
|
||||
}
|
||||
|
||||
web = WebServer(cfg, store, health)
|
||||
|
||||
def request_stop(_signum=None, _frame=None) -> None:
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
|
||||
web.start()
|
||||
print(
|
||||
f"[dev] web-only mode active at http://{cfg.web_bind}:{cfg.web_port}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
time.sleep(0.25)
|
||||
except KeyboardInterrupt:
|
||||
stop_event.set()
|
||||
finally:
|
||||
try:
|
||||
web.stop()
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .policy import PolicyEngine
|
||||
from .routeros import RouterOSClient
|
||||
from .state import RuntimeStats
|
||||
from .store import AlertStore
|
||||
|
||||
|
||||
class EVEWatcher(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
store: AlertStore,
|
||||
policy: PolicyEngine,
|
||||
routeros: RouterOSClient,
|
||||
block_timeout: str,
|
||||
stats: RuntimeStats,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
super().__init__(name="eve-watcher", daemon=True)
|
||||
self.path = path
|
||||
self.store = store
|
||||
self.policy = policy
|
||||
self.routeros = routeros
|
||||
self.block_timeout = block_timeout
|
||||
self.stats = stats
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
if not os.path.exists(self.path):
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
try:
|
||||
self._follow_file()
|
||||
except OSError as exc:
|
||||
print(f"[eve] file error: {exc}", flush=True)
|
||||
time.sleep(1.0)
|
||||
|
||||
def _follow_file(self) -> None:
|
||||
with open(self.path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
handle.seek(0, os.SEEK_END)
|
||||
inode = os.fstat(handle.fileno()).st_ino
|
||||
print(f"[eve] following {self.path}", flush=True)
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
line = handle.readline()
|
||||
if line:
|
||||
self._process_line(line)
|
||||
continue
|
||||
|
||||
try:
|
||||
stat = os.stat(self.path)
|
||||
if stat.st_ino != inode or stat.st_size < handle.tell():
|
||||
return
|
||||
except FileNotFoundError:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
def _process_line(self, line: str) -> None:
|
||||
try:
|
||||
event: dict[str, Any] = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
self.stats.inc("eve_parse_errors")
|
||||
return
|
||||
|
||||
self.stats.inc("eve_events")
|
||||
if event.get("event_type") != "alert":
|
||||
return
|
||||
|
||||
self.stats.inc("eve_alerts")
|
||||
self.stats.stamp("last_alert_at")
|
||||
decision = self.policy.evaluate(event)
|
||||
blocked = False
|
||||
reason = decision.reason
|
||||
|
||||
if decision.should_block and decision.target:
|
||||
self.stats.inc("block_attempts")
|
||||
alert = event.get("alert") or {}
|
||||
sid = alert.get("signature_id", "unknown")
|
||||
signature = str(alert.get("signature", "Suricata alert"))
|
||||
result = self.routeros.block_ip(
|
||||
decision.target,
|
||||
self.block_timeout,
|
||||
f"Suricata SID {sid}: {signature}",
|
||||
)
|
||||
blocked = result.success
|
||||
reason = result.message if result.success else f"{decision.reason}; {result.message}"
|
||||
self.stats.inc("block_success" if result.success else "block_errors")
|
||||
|
||||
self.store.insert_alert(event, blocked, decision.target, reason)
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
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())
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
should_block: bool
|
||||
target: str | None
|
||||
reason: str
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
def __init__(
|
||||
self,
|
||||
auto_block: bool,
|
||||
max_severity: int,
|
||||
monitored_networks: str,
|
||||
never_block: str,
|
||||
) -> None:
|
||||
self.auto_block = auto_block
|
||||
self.max_severity = max_severity
|
||||
self.monitored = _parse_networks(monitored_networks)
|
||||
self.never_block = _parse_networks(never_block)
|
||||
|
||||
def evaluate(self, event: dict[str, Any]) -> Decision:
|
||||
alert = event.get("alert") or {}
|
||||
try:
|
||||
severity = int(alert.get("severity", 999))
|
||||
except (TypeError, ValueError):
|
||||
return Decision(False, None, "missing or invalid severity")
|
||||
|
||||
if severity > self.max_severity:
|
||||
return Decision(False, None, f"severity {severity} is below block threshold")
|
||||
|
||||
src = _ip(event.get("src_ip"))
|
||||
dst = _ip(event.get("dest_ip"))
|
||||
if src is None or dst is None:
|
||||
return Decision(False, None, "alert has no usable IPv4/IPv6 endpoints")
|
||||
|
||||
src_local = self._is_monitored(src)
|
||||
dst_local = self._is_monitored(dst)
|
||||
if src_local == dst_local:
|
||||
return Decision(False, None, "cannot identify one remote endpoint")
|
||||
|
||||
target = dst if src_local else src
|
||||
if not target.is_global:
|
||||
return Decision(False, str(target), "remote endpoint is not globally routable")
|
||||
if self._is_never_block(target):
|
||||
return Decision(False, str(target), "remote endpoint is on NEVER_BLOCK list")
|
||||
if not self.auto_block:
|
||||
return Decision(False, str(target), "observation mode: AUTO_BLOCK=false")
|
||||
return Decision(True, str(target), f"severity {severity} matched automatic block policy")
|
||||
|
||||
def _is_monitored(self, address: ipaddress._BaseAddress) -> bool:
|
||||
return any(address in network for network in self.monitored if network.version == address.version)
|
||||
|
||||
def _is_never_block(self, address: ipaddress._BaseAddress) -> bool:
|
||||
return any(address in network for network in self.never_block if network.version == address.version)
|
||||
|
||||
|
||||
def _parse_networks(value: str) -> list[ipaddress._BaseNetwork]:
|
||||
result = []
|
||||
for item in (value or "").split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
result.append(ipaddress.ip_network(item, strict=False))
|
||||
return result
|
||||
|
||||
|
||||
def _ip(value: Any) -> ipaddress._BaseAddress | None:
|
||||
try:
|
||||
return ipaddress.ip_address(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BlockResult:
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class RouterOSClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
verify_tls: bool,
|
||||
address_list: str,
|
||||
timeout: int = 5,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.verify_tls = verify_tls
|
||||
self.address_list = address_list
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(
|
||||
self.base_url
|
||||
and self.username
|
||||
and self.password
|
||||
and self.password != "CHANGE_ME"
|
||||
)
|
||||
|
||||
def block_ip(self, address: str, timeout_value: str, comment: str) -> BlockResult:
|
||||
if not self.configured:
|
||||
return BlockResult(False, "RouterOS credentials are not configured")
|
||||
try:
|
||||
existing = self._request(
|
||||
"GET",
|
||||
"/rest/ip/firewall/address-list",
|
||||
query={"list": self.address_list, "address": address},
|
||||
)
|
||||
if isinstance(existing, list) and existing:
|
||||
return BlockResult(True, "address already present in RouterOS address-list")
|
||||
|
||||
body = {
|
||||
"list": self.address_list,
|
||||
"address": address,
|
||||
"timeout": timeout_value,
|
||||
"comment": comment[:220],
|
||||
}
|
||||
self._request("PUT", "/rest/ip/firewall/address-list", body=body)
|
||||
return BlockResult(True, "address added to RouterOS address-list")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
||||
return BlockResult(False, f"RouterOS REST error: {exc}")
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
query: dict | None = None,
|
||||
):
|
||||
url = self.base_url + path
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(query)
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
|
||||
context = None
|
||||
if url.lower().startswith("https://") and not self.verify_tls:
|
||||
context = ssl._create_unverified_context()
|
||||
|
||||
with urllib.request.urlopen(request, timeout=self.timeout, context=context) as response:
|
||||
raw = response.read()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class RuntimeStats:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._data = {
|
||||
"tzsp_datagrams": 0,
|
||||
"tzsp_decode_errors": 0,
|
||||
"tzsp_unsupported": 0,
|
||||
"frames_injected": 0,
|
||||
"inject_errors": 0,
|
||||
"eve_events": 0,
|
||||
"eve_alerts": 0,
|
||||
"eve_parse_errors": 0,
|
||||
"block_attempts": 0,
|
||||
"block_success": 0,
|
||||
"block_errors": 0,
|
||||
"last_packet_at": None,
|
||||
"last_alert_at": None,
|
||||
}
|
||||
|
||||
def inc(self, key: str, amount: int = 1) -> None:
|
||||
with self._lock:
|
||||
self._data[key] = int(self._data.get(key, 0)) + amount
|
||||
|
||||
def stamp(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._data[key] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return dict(self._data)
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AlertStore:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.executescript(
|
||||
"""
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
flow_id TEXT,
|
||||
src_ip TEXT,
|
||||
src_port INTEGER,
|
||||
dest_ip TEXT,
|
||||
dest_port INTEGER,
|
||||
proto TEXT,
|
||||
signature_id INTEGER,
|
||||
signature TEXT,
|
||||
category TEXT,
|
||||
severity INTEGER,
|
||||
action TEXT,
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
block_target TEXT,
|
||||
block_reason TEXT,
|
||||
raw_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_signature_id ON alerts(signature_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_blocked ON alerts(blocked);
|
||||
"""
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def insert_alert(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
blocked: bool,
|
||||
block_target: str | None,
|
||||
block_reason: str,
|
||||
) -> int:
|
||||
alert = event.get("alert") or {}
|
||||
values = (
|
||||
str(event.get("timestamp") or datetime.now(timezone.utc).isoformat()),
|
||||
str(event.get("flow_id") or ""),
|
||||
event.get("src_ip"),
|
||||
event.get("src_port"),
|
||||
event.get("dest_ip"),
|
||||
event.get("dest_port"),
|
||||
event.get("proto"),
|
||||
_as_int(alert.get("signature_id")),
|
||||
alert.get("signature"),
|
||||
alert.get("category"),
|
||||
_as_int(alert.get("severity")),
|
||||
alert.get("action"),
|
||||
1 if blocked else 0,
|
||||
block_target,
|
||||
block_reason,
|
||||
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
||||
)
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO alerts (
|
||||
timestamp, flow_id, src_ip, src_port, dest_ip, dest_port, proto,
|
||||
signature_id, signature, category, severity, action,
|
||||
blocked, block_target, block_reason, raw_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values,
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def recent(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
limit = min(max(int(limit), 1), 500)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT id, timestamp, src_ip, src_port, dest_ip, dest_port, proto,
|
||||
signature_id, signature, category, severity, action,
|
||||
blocked, block_target, block_reason
|
||||
FROM alerts ORDER BY id DESC LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["blocked"] = bool(item["blocked"])
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM alerts").fetchone()[0]
|
||||
blocked = self._conn.execute("SELECT COUNT(*) FROM alerts WHERE blocked=1").fetchone()[0]
|
||||
sev = self._conn.execute(
|
||||
"SELECT severity, COUNT(*) AS count FROM alerts GROUP BY severity ORDER BY severity"
|
||||
).fetchall()
|
||||
return {
|
||||
"total_alerts": int(total),
|
||||
"blocked_alerts": int(blocked),
|
||||
"by_severity": {str(row["severity"]): int(row["count"]) for row in sev},
|
||||
}
|
||||
|
||||
def purge_older_than(self, days: int) -> int:
|
||||
if days <= 0:
|
||||
return 0
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
with self._lock:
|
||||
cursor = self._conn.execute("DELETE FROM alerts WHERE timestamp < ?", (cutoff,))
|
||||
self._conn.commit()
|
||||
return int(cursor.rowcount)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
TUNSETIFF = 0x400454CA
|
||||
IFF_TAP = 0x0002
|
||||
IFF_NO_PI = 0x1000
|
||||
|
||||
|
||||
class TapDevice:
|
||||
def __init__(self, name: str, mtu: int = 9000) -> None:
|
||||
self.name = name
|
||||
self.mtu = mtu
|
||||
self.fd: int | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def open(self) -> None:
|
||||
if self.fd is not None:
|
||||
return
|
||||
fd = os.open("/dev/net/tun", os.O_RDWR)
|
||||
ifreq = struct.pack("16sH22x", self.name.encode("ascii"), IFF_TAP | IFF_NO_PI)
|
||||
fcntl.ioctl(fd, TUNSETIFF, ifreq)
|
||||
subprocess.run(["ip", "link", "set", "dev", self.name, "mtu", str(self.mtu)], check=True)
|
||||
subprocess.run(["ip", "link", "set", "dev", self.name, "up"], check=True)
|
||||
self.fd = fd
|
||||
|
||||
def write(self, frame: bytes) -> int:
|
||||
if self.fd is None:
|
||||
raise RuntimeError("TAP is not open")
|
||||
with self._lock:
|
||||
return os.write(self.fd, frame)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.fd is not None:
|
||||
try:
|
||||
os.close(self.fd)
|
||||
finally:
|
||||
self.fd = None
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .state import RuntimeStats
|
||||
|
||||
TZSP_VERSION = 1
|
||||
TZSP_TYPE_RECEIVED = 0
|
||||
TZSP_TYPE_TRANSMIT = 1
|
||||
TZSP_ENCAP_ETHERNET = 1
|
||||
TAG_PADDING = 0
|
||||
TAG_END = 1
|
||||
|
||||
|
||||
class TZSPError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TZSPPacket:
|
||||
packet_type: int
|
||||
encapsulation: int
|
||||
frame: bytes
|
||||
|
||||
|
||||
def decode_tzsp(data: bytes) -> TZSPPacket:
|
||||
if len(data) < 5:
|
||||
raise TZSPError("datagram too short")
|
||||
|
||||
version = data[0]
|
||||
packet_type = data[1]
|
||||
encapsulation = int.from_bytes(data[2:4], "big")
|
||||
|
||||
if version != TZSP_VERSION:
|
||||
raise TZSPError(f"unsupported TZSP version {version}")
|
||||
if packet_type not in {TZSP_TYPE_RECEIVED, TZSP_TYPE_TRANSMIT}:
|
||||
raise TZSPError(f"TZSP packet type {packet_type} has no packet payload")
|
||||
|
||||
offset = 4
|
||||
found_end = False
|
||||
while offset < len(data):
|
||||
tag_type = data[offset]
|
||||
offset += 1
|
||||
if tag_type == TAG_PADDING:
|
||||
continue
|
||||
if tag_type == TAG_END:
|
||||
found_end = True
|
||||
break
|
||||
if offset >= len(data):
|
||||
raise TZSPError("truncated TZSP tag length")
|
||||
tag_len = data[offset]
|
||||
offset += 1
|
||||
if offset + tag_len > len(data):
|
||||
raise TZSPError("truncated TZSP tag value")
|
||||
offset += tag_len
|
||||
|
||||
if not found_end:
|
||||
raise TZSPError("missing TZSP END tag")
|
||||
if offset >= len(data):
|
||||
raise TZSPError("TZSP datagram contains no encapsulated frame")
|
||||
|
||||
return TZSPPacket(packet_type=packet_type, encapsulation=encapsulation, frame=data[offset:])
|
||||
|
||||
|
||||
class TZSPReceiver(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
bind_host: str,
|
||||
port: int,
|
||||
frame_writer: Callable[[bytes], int],
|
||||
stats: RuntimeStats,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
super().__init__(name="tzsp-receiver", daemon=True)
|
||||
self.bind_host = bind_host
|
||||
self.port = port
|
||||
self.frame_writer = frame_writer
|
||||
self.stats = stats
|
||||
self.stop_event = stop_event
|
||||
self.sock: socket.socket | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((self.bind_host, self.port))
|
||||
sock.settimeout(1.0)
|
||||
self.sock = sock
|
||||
print(f"[tzsp] listening on udp://{self.bind_host}:{self.port}", flush=True)
|
||||
|
||||
try:
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
data, _addr = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
raise
|
||||
|
||||
self.stats.inc("tzsp_datagrams")
|
||||
self.stats.stamp("last_packet_at")
|
||||
try:
|
||||
packet = decode_tzsp(data)
|
||||
except TZSPError:
|
||||
self.stats.inc("tzsp_decode_errors")
|
||||
continue
|
||||
|
||||
if packet.encapsulation != TZSP_ENCAP_ETHERNET:
|
||||
self.stats.inc("tzsp_unsupported")
|
||||
continue
|
||||
|
||||
try:
|
||||
self.frame_writer(packet.frame)
|
||||
self.stats.inc("frames_injected")
|
||||
except OSError:
|
||||
self.stats.inc("inject_errors")
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self.sock is not None:
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Callable
|
||||
|
||||
from .config import Config
|
||||
from .store import AlertStore
|
||||
|
||||
DASHBOARD = r'''<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>RouterOS Suricata TZSP</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:#111827;color:#e5e7eb}
|
||||
main{max-width:1200px;margin:auto;padding:24px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px}
|
||||
.card{background:#1f2937;border:1px solid #374151;border-radius:10px;padding:14px}.value{font-size:28px;font-weight:700}.muted{color:#9ca3af;font-size:13px}
|
||||
table{width:100%;border-collapse:collapse;background:#1f2937;border-radius:10px;overflow:hidden;margin-bottom:22px}th,td{padding:10px;border-bottom:1px solid #374151;text-align:left;font-size:13px}th{color:#9ca3af}.ok{color:#34d399}.bad{color:#f87171}.warn{color:#fbbf24}.off{color:#9ca3af}
|
||||
code{background:#111827;padding:2px 5px;border-radius:4px}.top{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.section-title{margin-top:24px}
|
||||
.badge{display:inline-block;border:1px solid #4b5563;border-radius:999px;padding:3px 8px;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<div class="top"><div><h1>RouterOS Suricata TZSP</h1><div class="muted">TZSP → TAP → Suricata → EVE JSON → SQLite</div></div><div id="status">Loading…</div></div>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="muted">TZSP datagrams</div><div id="tzsp" class="value">0</div></div>
|
||||
<div class="card"><div class="muted">Frames injected into TAP</div><div id="frames" class="value">0</div></div>
|
||||
<div class="card"><div class="muted">Suricata alerts</div><div id="alerts" class="value">0</div></div>
|
||||
<div class="card"><div class="muted">RouterOS blocks</div><div id="blocked" class="value">0</div></div>
|
||||
</div>
|
||||
<h2 class="section-title">System status</h2>
|
||||
<table><thead><tr><th>Component</th><th>Status</th><th>Details</th></tr></thead><tbody id="serviceRows"></tbody></table>
|
||||
<h2 class="section-title">Ports</h2>
|
||||
<table><thead><tr><th>Service</th><th>Direction</th><th>Protocol</th><th>Address</th><th>Port</th><th>Status</th></tr></thead><tbody id="portRows"></tbody></table>
|
||||
<h2 class="section-title">Recent alerts</h2>
|
||||
<table><thead><tr><th>Time</th><th>Severity</th><th>Signature</th><th>Source</th><th>Destination</th><th>Action</th></tr></thead><tbody id="rows"></tbody></table>
|
||||
<script>
|
||||
function valueOrDash(v){return (v===null||v===undefined||v==='')?'-':String(v)}
|
||||
function esc(v){return valueOrDash(v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function stateClass(v){v=String(v||'').toLowerCase();if(v==='up'||v==='ok'||v==='running'||v==='configured')return'ok';if(v==='down'||v==='error'||v==='degraded')return'bad';if(v==='disabled'||v==='not configured'||v==='development')return'off';return'warn'}
|
||||
function stateBadge(v){return `<span class="badge ${stateClass(v)}">${esc(v)}</span>`}
|
||||
async function refresh(){
|
||||
try{
|
||||
const [statusData,summary,alertsData]=await Promise.all([fetch('/api/status').then(r=>r.json()),fetch('/api/summary').then(r=>r.json()),fetch('/api/alerts?limit=50').then(r=>r.json())]);
|
||||
if(statusData.dev_mode){
|
||||
document.getElementById('status').innerHTML='<span class="warn">Development mode: Web UI only</span>';
|
||||
}else if(statusData.status==='ok'){
|
||||
document.getElementById('status').innerHTML='<span class="ok">System operational</span>';
|
||||
}else{
|
||||
document.getElementById('status').innerHTML='<span class="bad">System degraded</span>';
|
||||
}
|
||||
document.getElementById('tzsp').textContent=valueOrDash(statusData.runtime?.tzsp_datagrams);
|
||||
document.getElementById('frames').textContent=valueOrDash(statusData.runtime?.frames_injected);
|
||||
document.getElementById('alerts').textContent=valueOrDash(summary.total_alerts);
|
||||
document.getElementById('blocked').textContent=valueOrDash(summary.blocked_alerts);
|
||||
|
||||
const serviceRows=Object.entries(statusData.services||{}).map(([name,item])=>`<tr><td>${esc(item.name||name)}</td><td>${stateBadge(item.status)}</td><td>${esc(item.details)}</td></tr>`).join('');
|
||||
document.getElementById('serviceRows').innerHTML=serviceRows||'<tr><td colspan="3" class="muted">No service status data available.</td></tr>';
|
||||
|
||||
const portRows=(statusData.ports||[]).map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.direction)}</td><td>${esc(item.protocol)}</td><td>${esc(item.address)}</td><td>${esc(item.port)}</td><td>${stateBadge(item.status)}</td></tr>`).join('');
|
||||
document.getElementById('portRows').innerHTML=portRows||'<tr><td colspan="6" class="muted">No port status data available.</td></tr>';
|
||||
|
||||
const rows=(alertsData.alerts||[]).map(x=>`<tr><td>${esc(x.timestamp)}</td><td>${esc(x.severity)}</td><td>${esc(x.signature)}<br><span class="muted">SID ${esc(x.signature_id)}</span></td><td>${esc(x.src_ip)}:${esc(x.src_port)}</td><td>${esc(x.dest_ip)}:${esc(x.dest_port)}</td><td>${x.blocked?'<span class="bad">BLOCK '+esc(x.block_target)+'</span>':'<span class="muted">'+esc(x.block_reason)+'</span>'}</td></tr>`).join('');
|
||||
document.getElementById('rows').innerHTML=rows||'<tr><td colspan="6" class="muted">No alerts yet. Run scripts/selftest.sh for a full-stack test or start dev mode with DEV_SEED_DATA=true.</td></tr>';
|
||||
}catch(err){document.getElementById('status').innerHTML='<span class="bad">Application unavailable</span>'}
|
||||
}
|
||||
refresh();setInterval(refresh,2500);
|
||||
</script>
|
||||
</main></body></html>'''
|
||||
|
||||
|
||||
class WebServer:
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
store: AlertStore,
|
||||
health_provider: Callable[[], dict],
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.store = store
|
||||
self.health_provider = health_provider
|
||||
self.server = ThreadingHTTPServer((config.web_bind, config.web_port), self._handler())
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True)
|
||||
|
||||
def _handler(self):
|
||||
store = self.store
|
||||
config = self.config
|
||||
health_provider = self.health_provider
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path == "/":
|
||||
self._send(200, DASHBOARD.encode("utf-8"), "text/html; charset=utf-8")
|
||||
return
|
||||
if parsed.path in {"/api/health", "/api/status"}:
|
||||
self._json(health_provider())
|
||||
return
|
||||
if parsed.path == "/api/summary":
|
||||
self._json(store.summary())
|
||||
return
|
||||
if parsed.path == "/api/config":
|
||||
self._json(config.public_dict())
|
||||
return
|
||||
if parsed.path == "/api/alerts":
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
try:
|
||||
limit = int(query.get("limit", ["100"])[0])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
self._json({"alerts": store.recent(limit)})
|
||||
return
|
||||
self._json({"error": "not found"}, status=404)
|
||||
|
||||
def _json(self, obj, status: int = 200):
|
||||
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self._send(status, data, "application/json; charset=utf-8")
|
||||
|
||||
def _send(self, status: int, data: bytes, content_type: str):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
return Handler
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
print(f"[web] dashboard on http://{self.config.web_bind}:{self.config.web_port}", flush=True)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
Reference in New Issue
Block a user