from __future__ import annotations import hmac import json import threading import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Callable from .config import Config from .maintenance import clear_suricata_logs from .rules import RuleManager from .state import RuntimeStats from .store import AlertStore DASHBOARD = r''' RouterOS Suricata TZSP

RouterOS Suricata TZSP

TZSP → TAP → Suricata → EVE JSON → SQLite
Loading…
TZSP datagrams
0
Frames to TAP
0
Alert hits
0
Incidents
0
Alerts / 24h
0
RouterOS blocks
0
Filtered noise
0
Deduplicated
0

Detection profile

Loading tuning configuration…

Rules in the image

Loading rule status…
The reserved self-test SID 1000001 only matches the explicit TZSP test payload and is filtered from the incident database. Production detections use separate SIDs.

Recent incidents

Repeated matches are aggregated into one incident window.
Last seenHitsSeveritySignatureSourceDestinationAction

Extended statistics

Top signatures / 24h
SIDSignatureSeverityHits
Top sources / 24h
SourceHitsLast seen
Top destinations / 24h
DestinationHitsLast seen
Severity distribution
SeverityHits
Sensor counterValue
Suricata counterValue

System status

ComponentStatusDetails

Ports

ServiceDirectionProtocolAddressPortStatus

Database & storage

Loading database/storage state…

Rules & signature feeds

The image contains an ET/Open snapshot plus conservative local production rules. Downloaded feeds and their enabled-source configuration are persisted in /var/lib/suricata. Every downloaded ruleset is validated with suricata -T before it replaces the last known-good rules.

Signature sources

Load the OISF source catalog to manage feeds.
The table is populated by suricata-update list-sources --free from the official OISF source index. ET/Open is the default feed. Other free feeds can be enabled individually; sources requiring parameters are shown but are not enabled blindly from the UI.
SourceVendorLicenseTagsStatusAction
Source catalog not loaded yet.
Custom Suricata signatures

Use SIDs 1001000+ for site-specific detections. Built-in production rules are maintained by the image.

threshold.config / suppressions

Global suppress removes alerts for a SID. Prefer source/destination-scoped suppression or rate limits when only one host is noisy.

Maintenance

Destructive actions require ADMIN_TOKEN. The token is kept only in this browser session.
''' class WebServer: def __init__( self, config: Config, store: AlertStore, health_provider: Callable[[], dict], stats: RuntimeStats | None = None, rule_manager: RuleManager | None = None, ) -> None: self.config = config self.store = store self.health_provider = health_provider self.stats = stats self.rule_manager = rule_manager 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 stats = self.stats rule_manager = self.rule_manager class Handler(BaseHTTPRequestHandler): MAX_BODY = 1024 * 1024 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/stats": self._json({"summary": store.summary(), "analytics": store.analytics()}) 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 if parsed.path == "/api/admin/rules": if not self._require_admin(): return if rule_manager is None: self._json({"error": "rule manager unavailable"}, status=503) else: self._json(rule_manager.content()) return if parsed.path == "/api/admin/rules/sources": if not self._require_admin(): return if rule_manager is None: self._json({"error": "rule manager unavailable"}, status=503) else: payload = rule_manager.source_catalog() self._json(payload, status=200 if payload.get("ok") else 503) return self._json({"error": "not found"}, status=404) def do_POST(self): parsed = urllib.parse.urlparse(self.path) if not parsed.path.startswith("/api/admin/"): self._json({"error": "not found"}, status=404) return if not self._require_admin(): return body = self._read_json() if body is None: return if parsed.path == "/api/admin/alerts/clear": count = store.clear_alerts() self._json({"ok": True, "message": f"Deleted {count} incident rows"}) return if parsed.path == "/api/admin/logs/clear": result = clear_suricata_logs(config.eve_path) self._json({"ok": True, "message": f"Cleared {len(result['files'])} log files; freed {result['bytes_freed']} bytes", **result}) return if parsed.path == "/api/admin/database/vacuum": store.vacuum() self._json({"ok": True, "message": "SQLite VACUUM completed"}) return if parsed.path == "/api/admin/runtime/reset": if stats is None: self._json({"error": "runtime stats unavailable"}, status=503) else: stats.reset() self._json({"ok": True, "message": "Runtime counters reset"}) return if parsed.path.startswith("/api/admin/rules/"): if rule_manager is None: self._json({"error": "rule manager unavailable"}, status=503) return if parsed.path == "/api/admin/rules/custom": result = rule_manager.replace_custom_rules(str(body.get("content", ""))) elif parsed.path == "/api/admin/rules/thresholds": result = rule_manager.replace_threshold_config(str(body.get("content", ""))) elif parsed.path == "/api/admin/rules/suppress": try: sid = int(body.get("sid")) except (TypeError, ValueError): self._json({"error": "valid SID is required"}, status=400) return result = rule_manager.suppress_sid(sid, str(body.get("track") or ""), body.get("ip")) elif parsed.path == "/api/admin/rules/reload": result = rule_manager.reload() elif parsed.path == "/api/admin/rules/update": result = rule_manager.update_vendor_rules() elif parsed.path == "/api/admin/rules/sources/refresh": result = rule_manager.refresh_source_catalog() elif parsed.path in {"/api/admin/rules/sources/enable", "/api/admin/rules/sources/disable"}: source_name = str(body.get("source") or "") result = rule_manager.set_source_enabled( source_name, parsed.path.endswith("/enable"), ) else: self._json({"error": "not found"}, status=404) return self._json( {"ok": result.ok, "message": result.message}, status=200 if result.ok else 400, ) return self._json({"error": "not found"}, status=404) def _require_admin(self) -> bool: if not config.admin_token: self._json( {"error": "admin actions are disabled; set ADMIN_TOKEN in the container environment"}, status=403, ) return False supplied = self.headers.get("X-Admin-Token", "") if not hmac.compare_digest(supplied, config.admin_token): self._json({"error": "invalid admin token"}, status=403) return False return True def _read_json(self): try: length = int(self.headers.get("Content-Length", "0")) except ValueError: length = 0 if length < 0 or length > self.MAX_BODY: self._json({"error": "request body too large"}, status=413) return None raw = self.rfile.read(length) if length else b"{}" try: data = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): self._json({"error": "invalid JSON body"}, status=400) return None if not isinstance(data, dict): self._json({"error": "JSON body must be an object"}, status=400) return None return data 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.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.send_header("Referrer-Policy", "no-referrer") self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'") 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()