first commit

This commit is contained in:
Mateusz Gruszczyński
2026-08-13 15:58:52 +02:00
commit adfdb0b86c
100 changed files with 6216 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""RouterOS TZSP -> TAP -> Suricata integration package."""
+95
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+78
View File
@@ -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
+95
View File
@@ -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"))
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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()