Files
routeros-suricata-tzsp/app/webui.py
T

1174 lines
61 KiB
Python

from __future__ import annotations
import base64
import errno
import hashlib
import hmac
import ipaddress
import json
import mimetypes
import os
import queue
import re
import select
import sys
import socket
import struct
import threading
import time
import urllib.parse
from collections import defaultdict, deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from .config import Config
from .auth import SessionAuth
from .analytics_cache import AnalyticsSnapshotCache
from .backup import BackupManager
from .forensics import ForensicPcapRing
from .live import EventBus, LiveEventPipeline, RedisUnavailableError, TrafficHistory, event_matches
from .maintenance import clear_suricata_logs
from .ndr import NDRAnalyzer, ThreatIntelManager
from .routeros import RouterOSClient
from .rules import RuleManager
from .state import RuntimeStats
from .store import AlertStore
BASE_DIR = Path(__file__).resolve().parent
TEMPLATE_PATH = BASE_DIR / "templates" / "index.html"
STATIC_DIR = BASE_DIR / "static"
DASHBOARD = TEMPLATE_PATH.read_text(encoding="utf-8")
_TIMEOUT_RE = re.compile(r"^[1-9][0-9]{0,5}[smhdw]$")
class _WebHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def handle_error(self, request, client_address) -> None:
exc = sys.exc_info()[1]
if isinstance(exc, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
return
if isinstance(exc, OSError) and exc.errno in {errno.EPIPE, errno.ECONNRESET, errno.ECONNABORTED}:
return
super().handle_error(request, client_address)
class MetricsAccessControl:
"""Pre-parsed ACL for the lightweight Prometheus endpoint."""
def __init__(self, config: Config) -> None:
networks = []
for item in config.metrics_allowed_ips.split(","):
item = item.strip()
if not item:
continue
try:
networks.append(ipaddress.ip_network(item, strict=False))
except ValueError as exc:
raise ValueError(f"invalid METRICS_ALLOWED_IPS entry: {item}") from exc
self.networks = tuple(networks)
self.username = config.metrics_basic_auth_username
self.password = config.metrics_basic_auth_password
if bool(self.username) != bool(self.password):
raise ValueError(
"METRICS_BASIC_AUTH_USERNAME and METRICS_BASIC_AUTH_PASSWORD "
"must either both be set or both be empty"
)
@property
def basic_auth_enabled(self) -> bool:
return bool(self.username and self.password)
def ip_allowed(self, client_ip: str) -> bool:
try:
address = ipaddress.ip_address(client_ip)
except ValueError:
return False
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
address = address.ipv4_mapped
return any(address in network for network in self.networks if network.version == address.version)
def basic_auth_allowed(self, authorization: str) -> bool:
if not self.basic_auth_enabled:
return True
scheme, separator, encoded = authorization.partition(" ")
if not separator or scheme.lower() != "basic" or not encoded.strip():
return False
try:
supplied = base64.b64decode(encoded.strip().encode("ascii"), validate=True)
except (ValueError, UnicodeEncodeError):
return False
expected = f"{self.username}:{self.password}".encode("utf-8")
return hmac.compare_digest(supplied, expected)
class WebServer:
def __init__(
self,
config: Config,
store: AlertStore,
health_provider: Callable[[], dict],
stats: RuntimeStats | None = None,
rule_manager: RuleManager | None = None,
traffic_history: TrafficHistory | None = None,
event_bus: EventBus | None = None,
live_pipeline: LiveEventPipeline | None = None,
routeros: RouterOSClient | None = None,
analytics_cache: AnalyticsSnapshotCache | None = None,
threat_intel: ThreatIntelManager | None = None,
ndr_analyzer: NDRAnalyzer | None = None,
backup_manager: BackupManager | None = None,
forensic_pcap: ForensicPcapRing | None = None,
traffic_source: Any | None = None,
metrics_provider: Callable[[], str] | None = None,
) -> None:
self.config = config
self.store = store
self.health_provider = health_provider
self.stats = stats
self.rule_manager = rule_manager
self.traffic_history = traffic_history
self.event_bus = event_bus
self.live_pipeline = live_pipeline
self.routeros = routeros
self.analytics_cache = analytics_cache
self.threat_intel = threat_intel
self.ndr_analyzer = ndr_analyzer
self.forensic_pcap = forensic_pcap
self.traffic_source = traffic_source
self.metrics_provider = metrics_provider
self.metrics_access = MetricsAccessControl(config) if metrics_provider is not None else None
self.backup_manager = backup_manager or BackupManager(config.db_path, os.path.dirname(config.db_path) or ".")
self.auth = SessionAuth(config, store)
self._login_lock = threading.Lock()
self._login_attempts: dict[str, deque[float]] = defaultdict(deque)
self.server = _WebHTTPServer((config.web_bind, config.web_port), self._handler())
self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True)
def _status_payload(self) -> dict:
payload = dict(self.health_provider())
payload["summary"] = self.store.summary()
if self.traffic_history is not None:
history = self.traffic_history.status()
if self.live_pipeline is not None:
history.update(self.live_pipeline.status())
if self.event_bus is not None:
history.update(self.event_bus.status())
payload["traffic_history"] = history
if self.analytics_cache is not None:
payload["analytics_snapshots"] = self.analytics_cache.status()
return payload
def _analytics_payload(self, window_seconds: int) -> dict:
if self.analytics_cache is not None:
payload = self.analytics_cache.get(window_seconds)
elif self.traffic_history is not None:
payload = self.traffic_history.analytics(window_seconds)
else:
payload = {"window_seconds": window_seconds, "events": 0, "timeline": []}
return self._overlay_current_throughput(payload, window_seconds)
def _overlay_current_throughput(self, payload: dict, window_seconds: int) -> dict:
source = self.traffic_source
if source is None or not hasattr(source, "overlay_current"):
return payload
try:
return source.overlay_current(payload, window_seconds)
except Exception:
return payload
def _current_throughput_payload(self, window_seconds: int) -> dict:
source = self.traffic_source
if source is None or not hasattr(source, "current_throughput"):
return {"window_seconds": window_seconds, "current_bps": 0}
try:
return source.current_throughput(window_seconds)
except Exception:
return {"window_seconds": window_seconds, "current_bps": 0}
def _login_allowed(self, client_ip: str) -> bool:
now = time.monotonic()
with self._login_lock:
attempts = self._login_attempts[client_ip]
while attempts and attempts[0] < now - 300:
attempts.popleft()
if len(attempts) >= 10:
return False
attempts.append(now)
return True
def _clear_login_attempts(self, client_ip: str) -> None:
with self._login_lock:
self._login_attempts.pop(client_ip, None)
def _handler(self):
outer = self
store = self.store
config = self.config
stats = self.stats
rule_manager = self.rule_manager
traffic_history = self.traffic_history
event_bus = self.event_bus
routeros = self.routeros
threat_intel = self.threat_intel
ndr_analyzer = self.ndr_analyzer
backup_manager = self.backup_manager
forensic_pcap = self.forensic_pcap
metrics_provider = self.metrics_provider
metrics_access = self.metrics_access
auth = self.auth
class Handler(BaseHTTPRequestHandler):
MAX_BODY = 1024 * 1024
server_version = "MikroSuricata/1.0"
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
view_path = parsed.path.rstrip("/") or "/"
if parsed.path == "/ws/live":
self._websocket(parsed)
return
if parsed.path == "/metrics":
if metrics_provider is None or metrics_access is None:
self._send(404, b"metrics unavailable\n", "text/plain; charset=utf-8")
return
client_ip = self.client_address[0] if self.client_address else ""
if not metrics_access.ip_allowed(client_ip):
self._send(403, b"metrics access denied\n", "text/plain; charset=utf-8")
return
if not metrics_access.basic_auth_allowed(self.headers.get("Authorization", "")):
self._send(
401,
b"metrics authentication required\n",
"text/plain; charset=utf-8",
extra_headers={
"WWW-Authenticate": 'Basic realm="metrics", charset="UTF-8"'
},
)
return
try:
payload = metrics_provider().encode("utf-8")
except Exception:
self._send(500, b"metrics scrape failed\n", "text/plain; charset=utf-8")
return
self._send(
200,
payload,
"text/plain; version=0.0.4; charset=utf-8",
)
return
if view_path in {"/", "/live", "/security", "/intelligence", "/blocks", "/reports", "/feeds", "/rules", "/system"}:
self._send(200, DASHBOARD.encode("utf-8"), "text/html; charset=utf-8")
return
if parsed.path.startswith("/static/"):
self._static(parsed.path)
return
if parsed.path == "/api/health":
self._json(outer._status_payload())
return
if parsed.path == "/api/auth/session":
self._auth_session()
return
if parsed.path.startswith("/api/") and not self._require_session():
return
if parsed.path == "/api/status":
self._json(outer._status_payload())
return
if parsed.path == "/api/summary":
self._json(store.summary())
return
if parsed.path == "/api/stats":
self._json({"summary": store.summary(), "analytics": store.analytics(), "ndr": store.ndr_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)
self._json({"alerts": store.recent(self._query_int(query, "limit", 100, 1, 500))})
return
if parsed.path == "/api/traffic":
if traffic_history is None:
self._json({"events": [], "history": {"backend": "disabled"}})
return
query = urllib.parse.parse_qs(parsed.query)
window = self._query_int(query, "window", 3600, 60, config.traffic_retention_hours * 3600)
now_ms = int(time.time() * 1000)
try:
events = traffic_history.search(
limit=self._query_int(query, "limit", 250, 1, 2000),
since_ms=now_ms - window * 1000,
text=self._query_text(query, "q", 256),
event_type=self._query_text(query, "type", 32),
proto=self._query_text(query, "proto", 24),
app_proto=self._query_text(query, "app", 48),
direction=self._query_text(query, "direction", 24),
)
except RedisUnavailableError as exc:
self._json({"error": f"Traffic history unavailable: {exc}"}, status=503)
return
self._json({"events": events, "history": traffic_history.status()})
return
if parsed.path == "/api/traffic/throughput":
if traffic_history is None:
self._json({"window_seconds": 3600, "bytes": 0, "timeline": []})
return
query = urllib.parse.parse_qs(parsed.query)
window = self._query_int(query, "window", 3600, 60, config.traffic_retention_hours * 3600)
try:
payload = traffic_history.throughput_analytics(window)
self._json(outer._overlay_current_throughput(payload, window))
except RedisUnavailableError as exc:
self._json({"error": f"Traffic throughput unavailable: {exc}"}, status=503)
return
if parsed.path == "/api/traffic/analytics":
if traffic_history is None:
self._json({"window_seconds": 3600, "events": 0, "timeline": []})
return
query = urllib.parse.parse_qs(parsed.query)
window = self._query_int(query, "window", 3600, 60, config.traffic_retention_hours * 3600)
try:
payload = outer._analytics_payload(window)
except RedisUnavailableError as exc:
self._json({"error": f"Traffic analytics unavailable: {exc}"}, status=503)
return
self._json(payload)
return
if parsed.path == "/api/ndr/summary":
self._json({"summary": store.ndr_summary(), "analyzer": ndr_analyzer.status() if ndr_analyzer else {"enabled": False}})
return
if parsed.path == "/api/ndr/incidents":
query = urllib.parse.parse_qs(parsed.query)
self._json({"incidents": store.recent_ndr_incidents(self._query_int(query, "limit", 100, 1, 500))})
return
if parsed.path.startswith("/api/ndr/incidents/"):
try:
incident_id = int(parsed.path.rsplit("/", 1)[-1])
except ValueError:
self._json({"error": "invalid incident id"}, status=400); return
rows = [x for x in store.recent_ndr_incidents(500) if int(x.get("id", 0)) == incident_id]
if not rows:
self._json({"error": "incident not found"}, status=404); return
self._json({"incident": rows[0], "events": store.ndr_incident_events(incident_id, 200)})
return
if parsed.path == "/api/assets":
query = urllib.parse.parse_qs(parsed.query)
self._json({"assets": store.assets(self._query_int(query, "limit", 250, 1, 1000))})
return
if parsed.path == "/api/threat-intel":
query = urllib.parse.parse_qs(parsed.query)
self._json({"iocs": store.list_iocs(self._query_int(query, "limit", 1000, 1, 5000)), "summary": store.ndr_summary()})
return
if parsed.path == "/api/forensics/pcaps":
files = self._pcap_files()
max_bytes = config.forensic_pcap_max_total_mb * 1024 * 1024
self._json({"files": [
{"name": path.name, "size_bytes": path.stat().st_size, "modified_at": path.stat().st_mtime}
for path in files
], "mode": config.forensic_pcap_mode, "max_bytes": max_bytes})
return
if parsed.path == "/api/forensics/pcap":
query = urllib.parse.parse_qs(parsed.query)
name = self._query_text(query, "name", 255)
allowed = {path.name: path for path in self._pcap_files()}
path = allowed.get(name)
if path is None:
self._json({"error": "PCAP not found"}, status=404); return
self._send_file(path, "application/vnd.tcpdump.pcap")
return
if parsed.path == "/api/rules/intelligence":
query = urllib.parse.parse_qs(parsed.query)
self._json(store.rule_intelligence(self._query_int(query, "hours", 24, 1, 720), self._query_int(query, "limit", 100, 1, 500)))
return
if parsed.path == "/api/audit":
query = urllib.parse.parse_qs(parsed.query)
self._json({"events": store.audit_events(self._query_int(query, "limit", 200, 1, 1000))})
return
if parsed.path == "/api/system/backups":
self._json({"backups": backup_manager.list()})
return
if parsed.path == "/api/system/backup":
query = urllib.parse.parse_qs(parsed.query)
name = self._query_text(query, "name", 255)
path = backup_manager.path(name)
if path is None:
self._json({"error": "backup not found"}, status=404); return
self._send_file(path, "application/gzip")
return
if parsed.path == "/api/admin/rules/snapshots":
if not self._require_admin():
return
self._json({"snapshots": rule_manager.list_snapshots() if rule_manager else []})
return
if parsed.path == "/api/blocks":
blocks = routeros.list_blocks() if routeros is not None and routeros.configured else []
self._json({
"configured": bool(routeros and routeros.configured),
"address_list": config.routeros_address_list,
"blocks": blocks,
})
return
if parsed.path == "/api/rules/sources":
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
if parsed.path == "/api/rules/merged":
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503)
else:
query = urllib.parse.parse_qs(parsed.query)
payload = rule_manager.merged_rules(
self._query_text(query, "q", 300),
self._query_int(query, "offset", 0, 0, 100000000),
self._query_int(query, "limit", 1000, 1, 5000),
)
self._json(payload, status=200 if payload.get("ok") else 404)
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
if parsed.path == "/api/admin/rules/sources/queue":
if not self._require_admin():
return
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503)
else:
self._json(rule_manager.source_queue_status())
return
self._json({"error": "not found"}, status=404)
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/api/auth/login":
self._login()
return
if parsed.path == "/api/auth/logout":
if not self._require_session(csrf=True):
return
self._audit("auth.logout")
auth.delete_session_from_cookie(self.headers.get("Cookie", ""))
self._json(
{"ok": True},
extra_headers={"Set-Cookie": auth.clear_cookie_header()},
)
return
if not parsed.path.startswith("/api/admin/"):
self._json({"error": "not found"}, status=404)
return
if not self._require_admin(csrf=True):
return
body = self._read_json()
if body is None:
return
if parsed.path == "/api/admin/system/backups/create":
try:
item = backup_manager.create(str(body.get("label") or "manual"))
except Exception as exc:
self._audit("backup.create", result="error", details={"error": str(exc)})
self._json({"error": f"backup failed: {exc}"}, status=500); return
self._audit("backup.create", target=str(item.get("id") or ""), details={"size_bytes": item.get("size_bytes")})
self._json({"ok": True, "backup": item, "message": "Persistent IDS state backup created"})
return
if parsed.path == "/api/admin/system/backups/delete":
name = str(body.get("id") or "")
removed = backup_manager.delete(name)
self._audit("backup.delete", target=name, result="ok" if removed else "not-found")
self._json({"ok": removed, "message": "Backup deleted" if removed else "Backup not found"}, status=200 if removed else 404)
return
if parsed.path == "/api/admin/rules/threshold":
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503); return
try:
sid = int(body.get("sid"))
result = rule_manager.add_threshold(sid, threshold_type=str(body.get("type") or "limit"), track=str(body.get("track") or "by_src"), count=int(body.get("count") or 5), seconds=int(body.get("seconds") or 60))
except (TypeError, ValueError) as exc:
self._json({"error": str(exc)}, status=400); return
self._audit("rules.threshold", target=str(sid), result="ok" if result.ok else "error", details={"message": result.message})
self._json({"ok": result.ok, "message": result.message}, status=200 if result.ok else 400)
return
if parsed.path == "/api/admin/rules/snapshot":
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503); return
result = rule_manager.create_snapshot(str(body.get("reason") or "manual"))
self._audit("rules.snapshot", result="ok" if result.ok else "error", details={"message": result.message})
self._json({"ok": result.ok, "message": result.message}, status=200 if result.ok else 400)
return
if parsed.path == "/api/admin/rules/rollback":
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503); return
snapshot_id = str(body.get("id") or "")
result = rule_manager.rollback_snapshot(snapshot_id)
self._audit("rules.rollback", target=snapshot_id, result="ok" if result.ok else "error", details={"message": result.message})
self._json({"ok": result.ok, "message": result.message}, status=200 if result.ok else 400)
return
if parsed.path == "/api/admin/alerts/clear":
count = store.clear_alerts()
self._audit("alerts.clear", details={"rows": count})
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._audit("database.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._audit("runtime.reset")
self._json({"ok": True, "message": "Runtime counters reset"})
return
if parsed.path == "/api/admin/traffic/clear":
try:
count = traffic_history.clear() if traffic_history is not None else 0
except RedisUnavailableError as exc:
self._json({"error": f"Traffic history unavailable: {exc}"}, status=503)
return
snapshots = store.clear_traffic_snapshots()
self._audit("traffic.clear", details={"events": count, "snapshots": snapshots})
self._json({"ok": True, "message": f"Cleared {count} archived/live traffic events and {snapshots} chart snapshots"})
return
if parsed.path == "/api/admin/ndr/incidents/status":
try:
incident_id = int(body.get("id"))
changed = store.set_ndr_incident_status(incident_id, str(body.get("status") or ""))
except (TypeError, ValueError) as exc:
self._json({"error": str(exc)}, status=400); return
self._audit("incident.status", target=str(incident_id), result="ok" if changed else "not-found", details={"status": str(body.get("status") or "")})
self._json({"ok": changed, "message": "Incident status updated" if changed else "Incident not found"}, status=200 if changed else 404)
return
if parsed.path == "/api/admin/threat-intel/add":
try:
ioc_id = store.add_ioc(
str(body.get("indicator") or ""), str(body.get("type") or ""),
source=str(body.get("source") or "manual"),
confidence=int(body.get("confidence") or 80),
severity=int(body.get("severity") or 1),
note=str(body.get("note") or ""),
expires_at=str(body.get("expires_at") or "") or None,
)
except (ValueError, TypeError) as exc:
self._json({"error": str(exc)}, status=400); return
sync = threat_intel.sync_suricata_datasets() if threat_intel else {}
reload_result = rule_manager.reload() if rule_manager is not None else None
self._audit("ioc.add", target=str(ioc_id), details={"type": str(body.get("type") or ""), "indicator": str(body.get("indicator") or "")[:300]})
self._json({"ok": True, "id": ioc_id, "datasets": sync, "message": "IOC saved and Suricata datasets refreshed" + (f"; {reload_result.message}" if reload_result else "")})
return
if parsed.path == "/api/admin/threat-intel/import":
text = str(body.get("text") or "")
if len(text) > 500000:
self._json({"error": "IOC import is too large"}, status=413); return
added = 0; errors = []
for line_no, raw in enumerate(text.splitlines(), 1):
raw = raw.strip()
if not raw or raw.startswith("#"): continue
parts = [x.strip() for x in re.split(r"[,;\t]", raw, maxsplit=4)]
if len(parts) >= 2 and parts[0].lower() in {"ip","domain","sha256","ja3","ja4","hassh"}:
kind, indicator = parts[0].lower(), parts[1]
confidence = int(parts[2]) if len(parts)>2 and parts[2].isdigit() else 80
source = parts[3] if len(parts)>3 and parts[3] else "import"
note = parts[4] if len(parts)>4 else ""
else:
indicator = parts[0]; kind = self._guess_ioc_type(indicator); confidence=80; source="import"; note=""
try:
store.add_ioc(indicator, kind, source=source, confidence=confidence, note=note); added += 1
except (ValueError, TypeError) as exc:
errors.append(f"line {line_no}: {exc}")
if len(errors) >= 20: break
sync = threat_intel.sync_suricata_datasets() if threat_intel else {}
reload_result = rule_manager.reload() if rule_manager is not None and added else None
self._audit("ioc.import", details={"added": added, "rejected": len(errors)})
self._json({"ok": True, "added": added, "errors": errors, "datasets": sync, "message": f"Imported {added} IOC entries" + (f"; {reload_result.message}" if reload_result else "")})
return
if parsed.path == "/api/admin/threat-intel/delete":
try: ioc_id = int(body.get("id"))
except (TypeError, ValueError): self._json({"error": "valid IOC id is required"}, status=400); return
removed = store.remove_ioc(ioc_id)
sync = threat_intel.sync_suricata_datasets() if threat_intel else {}
reload_result = rule_manager.reload() if rule_manager is not None and removed else None
self._audit("ioc.delete", target=str(ioc_id), result="ok" if removed else "not-found")
self._json({"ok": removed, "datasets": sync, "message": "IOC removed" if removed else "IOC not found"}, status=200 if removed else 404)
return
if parsed.path == "/api/admin/blocks/add":
self._manual_block(body)
return
if parsed.path == "/api/admin/blocks/remove":
self._manual_unblock(body)
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 == "/api/admin/rules/sources/queue":
sources = body.get("sources")
if not isinstance(sources, list):
self._json({"error": "sources must be an array"}, status=400)
return
result = rule_manager.queue_sources([str(item) for item in sources])
elif parsed.path == "/api/admin/rules/sources/add":
result = rule_manager.add_manual_source(
str(body.get("name") or ""),
str(body.get("url") or ""),
body.get("no_checksum", True) is not False,
)
elif parsed.path == "/api/admin/rules/sources/remove":
result = rule_manager.remove_manual_source(str(body.get("source") or ""))
elif parsed.path in {"/api/admin/rules/sources/enable", "/api/admin/rules/sources/disable"}:
result = rule_manager.set_source_enabled(
str(body.get("source") or ""), parsed.path.endswith("/enable")
)
else:
self._json({"error": "not found"}, status=404)
return
self._audit("rules.action", target=parsed.path.removeprefix("/api/admin/rules/"), result="ok" if result.ok else "error", details={"message": result.message})
self._json({"ok": result.ok, "message": result.message}, status=200 if result.ok else 400)
return
self._json({"error": "not found"}, status=404)
@staticmethod
def _guess_ioc_type(value: str) -> str:
text = str(value or "").strip()
try:
ipaddress.ip_address(text); return "ip"
except ValueError:
pass
lower = text.lower()
if len(lower) == 64 and all(ch in "0123456789abcdef" for ch in lower): return "sha256"
if "_" in lower and len(lower) >= 20: return "ja4"
if len(lower) == 32 and all(ch in "0123456789abcdef" for ch in lower): return "ja3"
return "domain"
def _manual_block(self, body: dict) -> None:
if routeros is None or not routeros.configured:
self._json({"error": "RouterOS REST integration is not configured"}, status=503)
return
address = str(body.get("address") or "").strip()
try:
address = str(ipaddress.ip_address(address))
except ValueError:
self._json({"error": "valid IPv4 or IPv6 address is required"}, status=400)
return
timeout_value = str(body.get("timeout") or config.block_timeout).strip().lower()
if not _TIMEOUT_RE.fullmatch(timeout_value):
self._json({"error": "timeout must look like 30m, 1h, 2d or 1w"}, status=400)
return
comment = str(body.get("comment") or "Manual dashboard block").strip()[:180]
result = routeros.block_ip(address, timeout_value, comment)
if result.success and forensic_pcap is not None:
try:
forensic_pcap.capture_target(address, label="manual")
except Exception as exc:
print(f"[forensics] manual block PCAP capture failed: {exc}", flush=True)
self._json({"ok": result.success, "message": result.message}, status=200 if result.success else 502)
def _manual_unblock(self, body: dict) -> None:
if routeros is None or not routeros.configured:
self._json({"error": "RouterOS REST integration is not configured"}, status=503)
return
address = str(body.get("address") or "").strip()
try:
address = str(ipaddress.ip_address(address))
except ValueError:
self._json({"error": "valid IPv4 or IPv6 address is required"}, status=400)
return
result = routeros.unblock_ip(address)
self._json({"ok": result.success, "message": result.message}, status=200 if result.success else 502)
def _websocket(self, parsed: urllib.parse.ParseResult) -> None:
if event_bus is None or traffic_history is None:
self.send_error(503, "live event bus unavailable")
return
if not self._require_session(websocket=True):
return
if self.headers.get("Upgrade", "").lower() != "websocket":
self.send_error(426, "WebSocket upgrade required")
return
if self.headers.get("Sec-WebSocket-Version", "13").strip() != "13":
self.send_response(426, "Unsupported WebSocket version")
self.send_header("Sec-WebSocket-Version", "13")
self.end_headers()
return
origin = self.headers.get("Origin", "").strip()
host = self.headers.get("Host", "").strip().lower()
if origin:
parsed_origin = urllib.parse.urlparse(origin)
if parsed_origin.scheme not in {"http", "https"} or parsed_origin.netloc.lower() != host:
self.send_error(403, "WebSocket origin is not allowed")
return
key = self.headers.get("Sec-WebSocket-Key", "").strip()
if not key:
self.send_error(400, "missing Sec-WebSocket-Key")
return
accept = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()
).decode("ascii")
self.send_response(101, "Switching Protocols")
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
self.wfile.flush()
query = urllib.parse.parse_qs(parsed.query)
window = self._query_int(query, "window", 3600, 60, config.traffic_retention_hours * 3600)
stream = str(query.get("stream", ["0"])[0]).strip().lower() in {"1", "true", "yes", "on"}
filters = {
"text": self._query_text(query, "q", 256),
"event_type": self._query_text(query, "type", 32),
"proto": self._query_text(query, "proto", 24),
"app_proto": self._query_text(query, "app", 48),
"direction": self._query_text(query, "direction", 24),
}
target = event_bus.subscribe() if stream else None
try:
recent = []
if stream:
try:
recent = traffic_history.search(
limit=100,
since_ms=int((time.time() - min(window, config.traffic_retention_hours * 3600)) * 1000),
text=filters["text"],
event_type=filters["event_type"],
proto=filters["proto"],
app_proto=filters["app_proto"],
direction=filters["direction"],
)
recent.reverse()
except RedisUnavailableError:
recent = []
try:
bootstrap_analytics = outer._analytics_payload(window)
except RedisUnavailableError as exc:
bootstrap_analytics = {
"window_seconds": window,
"snapshot_loading": True,
"snapshot_error": f"Redis unavailable: {exc}",
"timeline": [],
}
bootstrap = {
"type": "bootstrap",
"data": {
"events": recent,
"status": outer._status_payload(),
"analytics": bootstrap_analytics,
"stream": stream,
},
}
self._ws_send_json(bootstrap)
last_status = time.monotonic()
last_analytics = last_status
last_throughput = 0.0
while True:
if not self._ws_client_control():
return
if target is not None:
# Render-rate protection: collect a short burst and emit at most
# four event frames per second. Under heavy traffic the bounded
# EventBus queue deliberately drops browser-only updates rather
# than allowing the UI client to back-pressure packet capture.
cycle_started = time.monotonic()
batch = []
try:
batch.append(target.get(timeout=0.25))
except queue.Empty:
pass
while batch and len(batch) < 512:
try:
batch.append(target.get_nowait())
except queue.Empty:
break
if batch:
remaining = 0.25 - (time.monotonic() - cycle_started)
if remaining > 0:
time.sleep(remaining)
latest: dict[str, dict] = {}
for event in batch:
if not event_matches(event, **filters):
continue
event_id = str(event.get("id") or "")
if event_id in latest:
latest.pop(event_id, None)
latest[event_id or f"anon-{len(latest)}"] = event
payload = list(latest.values())[-120:]
if payload:
self._ws_send_json({"type": "events", "data": payload})
else:
time.sleep(0.25)
now = time.monotonic()
if now - last_throughput >= 1:
self._ws_send_json({"type": "throughput", "data": outer._current_throughput_payload(window)})
last_throughput = now
if now - last_status >= 5:
self._ws_send_json({"type": "status", "data": outer._status_payload()})
last_status = now
if now - last_analytics >= 10:
try:
analytics = outer._analytics_payload(window)
except RedisUnavailableError as exc:
analytics = {
"window_seconds": window,
"snapshot_loading": True,
"snapshot_error": f"Redis unavailable: {exc}",
"timeline": [],
}
self._ws_send_json({"type": "analytics", "data": analytics})
last_analytics = now
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
return
finally:
if target is not None:
event_bus.unsubscribe(target)
def _ws_send_json(self, obj: dict) -> None:
payload = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._ws_send_frame(0x1, payload)
def _ws_send_frame(self, opcode: int, payload: bytes = b"") -> None:
header = bytearray([0x80 | (opcode & 0x0F)])
length = len(payload)
if length < 126:
header.append(length)
elif length <= 0xFFFF:
header.append(126)
header.extend(struct.pack("!H", length))
else:
header.append(127)
header.extend(struct.pack("!Q", length))
self.connection.sendall(bytes(header) + payload)
def _ws_client_control(self) -> bool:
readable, _, _ = select.select([self.connection], [], [], 0)
if not readable:
return True
try:
head = self._recv_exact(2)
if not head:
return False
fin = bool(head[0] & 0x80)
rsv = head[0] & 0x70
opcode = head[0] & 0x0F
masked = bool(head[1] & 0x80)
length = head[1] & 0x7F
if rsv or not fin or not masked or opcode not in {0x1, 0x2, 0x8, 0x9, 0xA}:
self._ws_send_frame(0x8, struct.pack("!H", 1002))
return False
if length == 126:
length = struct.unpack("!H", self._recv_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._recv_exact(8))[0]
if opcode >= 0x8 and length > 125:
self._ws_send_frame(0x8, struct.pack("!H", 1002))
return False
if length > 1024 * 1024:
self._ws_send_frame(0x8, struct.pack("!H", 1009))
return False
mask = self._recv_exact(4)
payload = self._recv_exact(length) if length else b""
if payload:
payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
if opcode == 0x8:
self._ws_send_frame(0x8, payload[:125])
return False
if opcode == 0x9:
self._ws_send_frame(0xA, payload[:125])
return True
except (OSError, socket.timeout, struct.error):
return False
def _recv_exact(self, length: int) -> bytes:
if length <= 0:
return b""
previous_timeout = self.connection.gettimeout()
self.connection.settimeout(0.5)
try:
chunks = bytearray()
while len(chunks) < length:
chunk = self.connection.recv(length - len(chunks))
if not chunk:
raise ConnectionResetError("WebSocket peer closed")
chunks.extend(chunk)
return bytes(chunks)
finally:
self.connection.settimeout(previous_timeout)
def _pcap_files(self) -> list[Path]:
root = Path(config.eve_path).resolve().parent
try:
files = [
p for pattern in ("alert*.pcap*", "block-*.pcap")
for p in root.glob(pattern)
if p.is_file() and p.resolve().parent == root
]
except OSError:
return []
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)[:max(32, config.forensic_pcap_max_files)]
def _send_file(self, path: Path, content_type: str) -> None:
try:
size = path.stat().st_size
handle = path.open("rb")
except OSError:
self._json({"error": "file unavailable"}, status=404)
return
try:
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(size))
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
with handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
self.close_connection = True
handle.close()
return
except OSError as exc:
if exc.errno in {errno.EPIPE, errno.ECONNRESET, errno.ECONNABORTED}:
self.close_connection = True
handle.close()
return
handle.close()
raise
def _static(self, request_path: str) -> None:
relative = request_path.removeprefix("/static/")
try:
path = (STATIC_DIR / relative).resolve()
path.relative_to(STATIC_DIR.resolve())
except (ValueError, OSError):
self.send_error(404)
return
if not path.is_file():
self.send_error(404)
return
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
data = path.read_bytes()
self._send(200, data, content_type, cache="public, max-age=3600")
def _auth_session(self) -> None:
session = auth.session_from_cookie(self.headers.get("Cookie", ""), touch=True)
self._json({
"auth_enabled": auth.enabled,
"authenticated": session is not None,
"username": session.get("username") if session else None,
"csrf_token": session.get("csrf_token") if session else None,
"expires_at": session.get("expires_at") if session else None,
"default_username": config.admin_username,
})
def _login(self) -> None:
if not auth.enabled:
self._json(
{"error": "login is not configured; set ADMIN_PASSWORD"},
status=503,
)
return
if not self._same_origin():
self._json({"error": "origin is not allowed"}, status=403)
return
client_ip = self.client_address[0] if self.client_address else "unknown"
if not outer._login_allowed(client_ip):
self._json({"error": "too many login attempts; try again later"}, status=429)
return
body = self._read_json()
if body is None:
return
username = str(body.get("username") or "")[:128]
password = str(body.get("password") or "")[:1024]
if not auth.authenticate(username, password):
store.audit(username, "auth.login", result="denied", remote_ip=client_ip)
time.sleep(0.15)
self._json({"error": "invalid username or password"}, status=401)
return
outer._clear_login_attempts(client_ip)
token, session = auth.create_session(username)
store.audit(username, "auth.login", result="ok", remote_ip=client_ip)
self._json(
{
"ok": True,
"username": session["username"],
"csrf_token": session["csrf_token"],
"expires_at": session["expires_at"],
},
extra_headers={"Set-Cookie": auth.cookie_header(token)},
)
def _require_admin(self, *, csrf: bool = False) -> bool:
if not auth.enabled:
self._json({"error": "admin login is disabled; set ADMIN_PASSWORD"}, status=403)
return False
return self._require_session(csrf=csrf)
def _require_session(self, *, csrf: bool = False, websocket: bool = False) -> bool:
if not auth.enabled:
return True
session = auth.session_from_cookie(self.headers.get("Cookie", ""), touch=True)
if session is None:
if websocket:
self.send_error(401, "authentication required")
else:
self._json({"error": "authentication required"}, status=401)
return False
if csrf:
supplied = self.headers.get("X-CSRF-Token", "")
expected = str(session.get("csrf_token") or "")
if not supplied or not hmac.compare_digest(supplied, expected):
self._json({"error": "invalid CSRF token"}, status=403)
return False
if not self._same_origin():
self._json({"error": "origin is not allowed"}, status=403)
return False
return True
def _audit(self, action: str, *, target: str = "", result: str = "ok", details: dict | None = None) -> None:
try:
session = auth.session_from_cookie(self.headers.get("Cookie", ""), touch=False)
username = str(session.get("username") or "") if session else ""
remote_ip = self.client_address[0] if self.client_address else ""
store.audit(username, action, target=target, result=result, remote_ip=remote_ip, details=details or {})
except Exception:
pass
def _same_origin(self) -> bool:
origin = self.headers.get("Origin", "").strip()
if not origin:
return True
host = self.headers.get("Host", "").strip().lower()
parsed_origin = urllib.parse.urlparse(origin)
return parsed_origin.scheme in {"http", "https"} and parsed_origin.netloc.lower() == host
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
@staticmethod
def _query_text(query: dict, name: str, max_len: int) -> str:
return str(query.get(name, [""])[0])[:max_len]
@staticmethod
def _query_int(query: dict, name: str, default: int, minimum: int, maximum: int) -> int:
try:
value = int(query.get(name, [str(default)])[0])
except (TypeError, ValueError):
value = default
return min(max(value, minimum), maximum)
def _json(self, obj, status: int = 200, extra_headers: dict[str, str] | None = None):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self._send(status, data, "application/json; charset=utf-8", extra_headers=extra_headers)
def _send(
self,
status: int,
data: bytes,
content_type: str,
cache: str = "no-store",
extra_headers: dict[str, str] | None = None,
):
try:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", cache)
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'; style-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'",
)
for name, value in (extra_headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
self.close_connection = True
return
except OSError as exc:
if exc.errno in {errno.EPIPE, errno.ECONNRESET, errno.ECONNABORTED}:
self.close_connection = True
return
raise
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()