This commit is contained in:
Mateusz Gruszczyński
2026-08-15 23:43:58 +02:00
parent 71b6c0d86f
commit e8e5515e24
27 changed files with 861 additions and 64 deletions
+37 -3
View File
@@ -26,6 +26,7 @@ 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
@@ -71,6 +72,7 @@ class WebServer:
threat_intel: ThreatIntelManager | None = None,
ndr_analyzer: NDRAnalyzer | None = None,
backup_manager: BackupManager | None = None,
forensic_pcap: ForensicPcapRing | None = None,
) -> None:
self.config = config
self.store = store
@@ -84,6 +86,7 @@ class WebServer:
self.analytics_cache = analytics_cache
self.threat_intel = threat_intel
self.ndr_analyzer = ndr_analyzer
self.forensic_pcap = forensic_pcap
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()
@@ -139,6 +142,7 @@ class WebServer:
threat_intel = self.threat_intel
ndr_analyzer = self.ndr_analyzer
backup_manager = self.backup_manager
forensic_pcap = self.forensic_pcap
auth = self.auth
class Handler(BaseHTTPRequestHandler):
@@ -254,10 +258,11 @@ class WebServer:
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
], "max_bytes": 8 * 64 * 1024 * 1024})
], "mode": config.forensic_pcap_mode, "max_bytes": max_bytes})
return
if parsed.path == "/api/forensics/pcap":
query = urllib.parse.parse_qs(parsed.query)
@@ -307,6 +312,18 @@ class WebServer:
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
@@ -526,6 +543,14 @@ class WebServer:
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")
@@ -567,6 +592,11 @@ class WebServer:
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:
@@ -802,10 +832,14 @@ class WebServer:
def _pcap_files(self) -> list[Path]:
root = Path(config.eve_path).resolve().parent
try:
files = [p for p in root.glob("alert*.pcap*") if p.is_file() and p.resolve().parent == root]
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)[:32]
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: