This commit is contained in:
Mateusz Gruszczyński
2026-08-16 11:46:35 +02:00
parent e8e5515e24
commit e5d344622e
22 changed files with 3511 additions and 11 deletions
+83
View File
@@ -56,6 +56,55 @@ class _WebHTTPServer(ThreadingHTTPServer):
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,
@@ -73,6 +122,7 @@ class WebServer:
ndr_analyzer: NDRAnalyzer | None = None,
backup_manager: BackupManager | None = None,
forensic_pcap: ForensicPcapRing | None = None,
metrics_provider: Callable[[], str] | None = None,
) -> None:
self.config = config
self.store = store
@@ -87,6 +137,8 @@ class WebServer:
self.threat_intel = threat_intel
self.ndr_analyzer = ndr_analyzer
self.forensic_pcap = forensic_pcap
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()
@@ -143,6 +195,8 @@ class WebServer:
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):
@@ -155,6 +209,35 @@ class WebServer:
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