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
+3
View File
@@ -86,6 +86,9 @@ ENV PYTHONUNBUFFERED=1 \
ALERT_IGNORE_SIDS=1000001 \ ALERT_IGNORE_SIDS=1000001 \
ADMIN_USERNAME=admin \ ADMIN_USERNAME=admin \
ADMIN_PASSWORD= \ ADMIN_PASSWORD= \
METRICS_ALLOWED_IPS=127.0.0.1/32,::1/128 \
METRICS_BASIC_AUTH_USERNAME= \
METRICS_BASIC_AUTH_PASSWORD= \
SESSION_HOURS=168 \ SESSION_HOURS=168 \
SESSION_COOKIE_SECURE=false \ SESSION_COOKIE_SECURE=false \
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 \ ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 \
+31
View File
@@ -823,6 +823,37 @@ RouterOS configuration templates
--- ---
## Prometheus `/metrics` ACL
The Prometheus endpoint is restricted independently from the dashboard. The source
IP/CIDR allowlist is always enforced and is parsed once at startup, so rejected
scrapes do not render metrics or trigger any application-side calculations.
```dotenv
# Default: local scrapes only. Exact IPs and CIDRs can be mixed, comma-separated.
METRICS_ALLOWED_IPS=127.0.0.1/32,::1/128
# Leave both empty for IP-only ACL. Set both for IP + Basic Auth.
METRICS_BASIC_AUTH_USERNAME=
METRICS_BASIC_AUTH_PASSWORD=
```
For example, to allow Prometheus at `192.168.88.50` and require Basic Auth:
```dotenv
METRICS_ALLOWED_IPS=192.168.88.50/32
METRICS_BASIC_AUTH_USERNAME=prometheus
METRICS_BASIC_AUTH_PASSWORD=<long-unique-password>
```
A client outside the allowlist receives HTTP `403`. A permitted IP with missing or
invalid Basic Auth receives HTTP `401`. Supplying only one Basic Auth variable is a
configuration error. An empty `METRICS_ALLOWED_IPS` denies all access. The ACL uses
the TCP peer address and deliberately ignores `X-Forwarded-For`; when using a reverse
proxy, allow the proxy address itself.
---
## Safety defaults ## Safety defaults
The default configuration is observation-oriented: The default configuration is observation-oriented:
+1 -1
View File
@@ -1 +1 @@
0.9.6 0.9.8
+12
View File
@@ -49,6 +49,9 @@ class Config:
rule_update_interval_hours: int rule_update_interval_hours: int
web_bind: str web_bind: str
web_port: int web_port: int
metrics_allowed_ips: str
metrics_basic_auth_username: str
metrics_basic_auth_password: str
db_path: str db_path: str
eve_path: str eve_path: str
suricata_log_max_mb: int suricata_log_max_mb: int
@@ -135,6 +138,15 @@ class Config:
rule_update_interval_hours=max(0, _int("RULE_UPDATE_INTERVAL_HOURS", 24)), rule_update_interval_hours=max(0, _int("RULE_UPDATE_INTERVAL_HOURS", 24)),
web_bind=os.getenv("WEB_BIND", "0.0.0.0"), web_bind=os.getenv("WEB_BIND", "0.0.0.0"),
web_port=_int("WEB_PORT", 8080), web_port=_int("WEB_PORT", 8080),
metrics_allowed_ips=os.getenv(
"METRICS_ALLOWED_IPS", "127.0.0.1/32,::1/128"
).strip(),
metrics_basic_auth_username=os.getenv(
"METRICS_BASIC_AUTH_USERNAME", ""
).strip(),
metrics_basic_auth_password=os.getenv(
"METRICS_BASIC_AUTH_PASSWORD", ""
),
db_path=os.getenv("DB_PATH", "/data/ids.db"), db_path=os.getenv("DB_PATH", "/data/ids.db"),
eve_path=os.getenv("EVE_PATH", "/data/logs/suricata/eve.json"), eve_path=os.getenv("EVE_PATH", "/data/logs/suricata/eve.json"),
suricata_log_max_mb=_int("SURICATA_LOG_MAX_MB", 512), suricata_log_max_mb=_int("SURICATA_LOG_MAX_MB", 512),
+29
View File
@@ -5,12 +5,14 @@ import signal
import threading import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from .analytics_cache import AnalyticsSnapshotCache from .analytics_cache import AnalyticsSnapshotCache
from .config import Config from .config import Config
from .live import EventBus, LiveEventPipeline, TrafficHistory from .live import EventBus, LiveEventPipeline, TrafficHistory
from .maintenance import storage_info from .maintenance import storage_info
from .metrics import PrometheusMetrics
from .rules import RuleManager from .rules import RuleManager
from .state import RuntimeStats from .state import RuntimeStats
from .store import AlertStore from .store import AlertStore
@@ -231,6 +233,32 @@ def main() -> int:
"runtime": stats.snapshot(), "runtime": stats.snapshot(),
} }
version_path = Path(__file__).resolve().parents[1] / "VERSION"
try:
app_version = version_path.read_text(encoding="ascii").strip() or "unknown"
except OSError:
app_version = "unknown"
prometheus_metrics = PrometheusMetrics(
stats,
version=app_version,
mode="web-only-development",
started_at=started_at,
state_provider=lambda: {
"components": {"web": True},
"features": {
"auto_block": False,
"ndr": False,
"notifications": False,
"routeros": False,
"redis_managed": False,
"forensic_pcap": False,
},
},
event_bus=event_bus,
live_pipeline=live_pipeline,
)
web = WebServer( web = WebServer(
cfg, cfg,
store, store,
@@ -241,6 +269,7 @@ def main() -> int:
event_bus=event_bus, event_bus=event_bus,
live_pipeline=live_pipeline, live_pipeline=live_pipeline,
analytics_cache=analytics_cache, analytics_cache=analytics_cache,
metrics_provider=prometheus_metrics.render,
) )
def request_stop(_signum=None, _frame=None) -> None: def request_stop(_signum=None, _frame=None) -> None:
+23 -8
View File
@@ -68,7 +68,12 @@ class FlowTracker:
"bytes_total": 0, "bytes_in": 0, "bytes_out": 0, "bytes_total": 0, "bytes_in": 0, "bytes_out": 0,
"bytes_internal": 0, "bytes_external": 0, "bytes_internal": 0, "bytes_external": 0,
"packets_total": 0, "packets_in": 0, "packets_out": 0, "packets_total": 0, "packets_in": 0, "packets_out": 0,
"packets_internal": 0, "packets_external": 0,
} }
# Monotonic counters are kept separately from the one-second sampling
# bucket above. Prometheus can safely apply rate()/increase() to these
# even when its scrape interval differs from the UI sampling interval.
self._traffic_counters = dict.fromkeys(self._rate_counters, 0)
def observe(self, frame: bytes) -> None: def observe(self, frame: bytes) -> None:
parsed = _parse_frame(frame) parsed = _parse_frame(frame)
@@ -138,23 +143,33 @@ class FlowTracker:
"parse_errors": self._parse_errors, "parse_errors": self._parse_errors,
"throughput_samples": self._throughput_samples, "throughput_samples": self._throughput_samples,
"update_interval_seconds": self.update_interval, "update_interval_seconds": self.update_interval,
"traffic_counters": dict(self._traffic_counters),
} }
def _record_throughput(self, src_ip: str, dest_ip: str, frame_bytes: int, now: float) -> None: def _record_throughput(self, src_ip: str, dest_ip: str, frame_bytes: int, now: float) -> None:
direction = self.normalizer._direction(src_ip, dest_ip) direction = self.normalizer._direction(src_ip, dest_ip)
counters = self._rate_counters counters = self._rate_counters
counters["bytes_total"] += frame_bytes totals = self._traffic_counters
counters["packets_total"] += 1
for target in (counters, totals):
target["bytes_total"] += frame_bytes
target["packets_total"] += 1
if direction == "inbound": if direction == "inbound":
counters["bytes_in"] += frame_bytes for target in (counters, totals):
counters["packets_in"] += 1 target["bytes_in"] += frame_bytes
target["packets_in"] += 1
elif direction == "outbound": elif direction == "outbound":
counters["bytes_out"] += frame_bytes for target in (counters, totals):
counters["packets_out"] += 1 target["bytes_out"] += frame_bytes
target["packets_out"] += 1
elif direction == "internal": elif direction == "internal":
counters["bytes_internal"] += frame_bytes for target in (counters, totals):
target["bytes_internal"] += frame_bytes
target["packets_internal"] += 1
else: else:
counters["bytes_external"] += frame_bytes for target in (counters, totals):
target["bytes_external"] += frame_bytes
target["packets_external"] += 1
elapsed = now - self._rate_started elapsed = now - self._rate_started
if elapsed < 1.0: if elapsed < 1.0:
+42 -1
View File
@@ -21,6 +21,7 @@ from .flow_tracker import FlowTracker
from .forensics import ForensicPcapRing from .forensics import ForensicPcapRing
from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer
from .maintenance import clear_suricata_logs, storage_info from .maintenance import clear_suricata_logs, storage_info
from .metrics import PrometheusMetrics
from .ndr import NDRAnalyzer, ThreatIntelManager from .ndr import NDRAnalyzer, ThreatIntelManager
from .notifier import WebhookNotifier from .notifier import WebhookNotifier
from .policy import PolicyEngine from .policy import PolicyEngine
@@ -210,7 +211,7 @@ def main() -> int:
cfg.redis_snapshot_seconds, cfg.redis_snapshot_seconds,
cfg.redis_aof, cfg.redis_aof,
) )
if cfg.redis_managed and not redis_supervisor.start(wait_ready_seconds=15): if cfg.redis_managed and not redis_supervisor.start(wait_ready_seconds=120):
raise RuntimeError( raise RuntimeError(
f"managed Redis failed to start: {redis_supervisor.status().get('last_error') or 'unknown error'}" f"managed Redis failed to start: {redis_supervisor.status().get('last_error') or 'unknown error'}"
) )
@@ -422,6 +423,45 @@ def main() -> int:
"runtime": stats.snapshot(), "runtime": stats.snapshot(),
} }
version_path = Path(__file__).resolve().parents[1] / "VERSION"
try:
app_version = version_path.read_text(encoding="ascii").strip() or "unknown"
except OSError:
app_version = "unknown"
def metrics_state() -> dict:
return {
"components": {
"web": True,
"tzsp": receiver.is_alive() and receiver.sock is not None,
"tap": tap.fd is not None,
"suricata": suricata.poll() is None,
"eve": watcher.is_alive(),
},
"features": {
"auto_block": cfg.auto_block,
"ndr": cfg.ndr_enabled,
"notifications": notifier.enabled,
"routeros": routeros.configured,
"redis_managed": cfg.redis_managed,
"forensic_pcap": cfg.forensic_pcap_mode != "off",
},
}
prometheus_metrics = PrometheusMetrics(
stats,
version=app_version,
mode="full",
started_at=started_at,
state_provider=metrics_state,
flow_tracker=flow_tracker,
event_bus=event_bus,
live_pipeline=live_pipeline,
ndr_analyzer=ndr_analyzer,
notifier=notifier,
forensic_pcap=forensic_pcap,
)
web = WebServer( web = WebServer(
cfg, cfg,
store, store,
@@ -437,6 +477,7 @@ def main() -> int:
ndr_analyzer=ndr_analyzer, ndr_analyzer=ndr_analyzer,
backup_manager=backup_manager, backup_manager=backup_manager,
forensic_pcap=forensic_pcap, forensic_pcap=forensic_pcap,
metrics_provider=prometheus_metrics.render,
) )
def housekeeping() -> None: def housekeeping() -> None:
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
import math
import re
from datetime import datetime
from typing import Any, Callable, Mapping
from .state import RuntimeStats
_METRIC_RE = re.compile(r"[^a-zA-Z0-9_:]")
def _metric_name(value: str) -> str:
name = _METRIC_RE.sub("_", str(value)).strip("_")
return re.sub(r"_+", "_", name)
def _label_value(value: Any) -> str:
return str(value).replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
def _number(value: Any) -> str | None:
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "+Inf" if value > 0 else "-Inf"
return repr(value)
return None
def _timestamp(value: Any) -> float | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
class PrometheusMetrics:
"""Render a scrape from already available in-memory state only.
Collectors registered here must expose cheap in-memory ``status()`` data.
The renderer deliberately does not call the application's health provider,
SQLite, Redis, RouterOS, filesystem scans, or analytics routines.
"""
def __init__(
self,
stats: RuntimeStats,
*,
version: str,
mode: str,
started_at: datetime,
state_provider: Callable[[], Mapping[str, Any]] | None = None,
flow_tracker: Any | None = None,
event_bus: Any | None = None,
live_pipeline: Any | None = None,
ndr_analyzer: Any | None = None,
notifier: Any | None = None,
forensic_pcap: Any | None = None,
) -> None:
self.stats = stats
self.version = str(version or "unknown")
self.mode = str(mode or "unknown")
self.started_at = started_at
self.state_provider = state_provider
self.flow_tracker = flow_tracker
self.event_bus = event_bus
self.live_pipeline = live_pipeline
self.ndr_analyzer = ndr_analyzer
self.notifier = notifier
self.forensic_pcap = forensic_pcap
def render(self) -> str:
lines: list[str] = []
self._emit(
lines,
"mikrosuricata_build_info",
1,
metric_type="gauge",
help_text="MikroSuricata build information.",
labels={"version": self.version, "mode": self.mode},
)
self._emit(
lines,
"mikrosuricata_process_start_time_seconds",
self.started_at.timestamp(),
metric_type="gauge",
help_text="Unix timestamp when the MikroSuricata process started.",
)
runtime = self.stats.metrics_snapshot()
suricata = runtime.pop("suricata", {}) or {}
suricata_stats_at = runtime.pop("suricata_stats_at", None)
for key in sorted(runtime):
value = runtime[key]
if key.endswith("_at"):
timestamp = _timestamp(value)
if timestamp is not None:
self._emit(
lines,
f"mikrosuricata_{_metric_name(key[:-3])}_timestamp_seconds",
timestamp,
metric_type="gauge",
)
continue
if _number(value) is not None:
self._emit(
lines,
f"mikrosuricata_{_metric_name(key)}_total",
value,
metric_type="counter",
)
timestamp = _timestamp(suricata_stats_at)
if timestamp is not None:
self._emit(
lines,
"mikrosuricata_suricata_stats_timestamp_seconds",
timestamp,
metric_type="gauge",
help_text="Unix timestamp of the latest Suricata stats event.",
)
# Suricata already computes these values and publishes them through EVE.
# Exporting the cached numeric snapshot avoids any control-socket request
# or work triggered specifically by a Prometheus scrape.
for key in sorted(suricata):
value = suricata[key]
if _number(value) is None:
continue
self._emit(
lines,
f"mikrosuricata_suricata_{_metric_name(key)}",
value,
)
self._emit_runtime_state(lines)
self._emit_flow_tracker_status(lines)
self._emit_status(
lines,
"event_bus",
self.event_bus,
gauges={"history_events", "subscribers"},
counters={"subscriber_dropped_events"},
)
self._emit_status(
lines,
"live_pipeline",
self.live_pipeline,
gauges={"writer_queue"},
counters={"writer_dropped", "writer_written", "throughput_written", "writer_batches", "writer_redis_errors"},
)
self._emit_status(
lines,
"ndr",
self.ndr_analyzer,
gauges={"enabled", "running", "queue", "auto_block", "auto_block_risk", "routeros_inventory_assets"},
counters={"dropped", "processed", "signals", "ioc_hits", "behavior_hits", "routeros_inventory_syncs"},
)
self._emit_status(
lines,
"notifier",
self.notifier,
gauges={"enabled", "running", "min_risk", "queue"},
counters={"sent", "failed", "dropped"},
)
self._emit_forensic_status(lines)
return "\n".join(lines) + "\n"
def _emit_runtime_state(self, lines: list[str]) -> None:
if self.state_provider is None:
return
state = dict(self.state_provider())
components = state.get("components") or {}
if isinstance(components, Mapping) and components:
lines.append("# HELP mikrosuricata_component_up Whether a core MikroSuricata component is running.")
lines.append("# TYPE mikrosuricata_component_up gauge")
for component, up in sorted(components.items()):
self._emit(
lines,
"mikrosuricata_component_up",
bool(up),
labels={"component": component},
)
features = state.get("features") or {}
if isinstance(features, Mapping) and features:
lines.append("# HELP mikrosuricata_feature_enabled Whether an optional MikroSuricata feature is enabled/configured.")
lines.append("# TYPE mikrosuricata_feature_enabled gauge")
for feature, enabled in sorted(features.items()):
self._emit(
lines,
"mikrosuricata_feature_enabled",
bool(enabled),
labels={"feature": feature},
)
def _emit_status(
self,
lines: list[str],
prefix: str,
source: Any | None,
*,
gauges: set[str],
counters: set[str],
) -> None:
if source is None:
return
status = source.status()
for key in sorted(gauges):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_{prefix}_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
for key in sorted(counters):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_{prefix}_{_metric_name(key)}_total",
status[key],
metric_type="counter",
)
def _emit_flow_tracker_status(self, lines: list[str]) -> None:
if self.flow_tracker is None:
return
status = self.flow_tracker.status()
for key in ("active_flows", "max_flows", "update_interval_seconds"):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_flow_tracker_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
for key in ("published_updates", "evicted_flows", "parse_errors", "throughput_samples"):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_flow_tracker_{_metric_name(key)}_total",
status[key],
metric_type="counter",
)
traffic = status.get("traffic_counters") or {}
if not isinstance(traffic, Mapping):
return
directions = {
"total": ("bytes_total", "packets_total"),
"inbound": ("bytes_in", "packets_in"),
"outbound": ("bytes_out", "packets_out"),
"internal": ("bytes_internal", "packets_internal"),
"external": ("bytes_external", "packets_external"),
}
lines.append("# HELP mikrosuricata_traffic_bytes_total Captured TZSP traffic bytes by direction.")
lines.append("# TYPE mikrosuricata_traffic_bytes_total counter")
lines.append("# HELP mikrosuricata_traffic_packets_total Captured TZSP packets by direction.")
lines.append("# TYPE mikrosuricata_traffic_packets_total counter")
for direction, (bytes_key, packets_key) in directions.items():
if _number(traffic.get(bytes_key)) is not None:
self._emit(
lines,
"mikrosuricata_traffic_bytes_total",
traffic[bytes_key],
labels={"direction": direction},
)
if _number(traffic.get(packets_key)) is not None:
self._emit(
lines,
"mikrosuricata_traffic_packets_total",
traffic[packets_key],
labels={"direction": direction},
)
def _emit_forensic_status(self, lines: list[str]) -> None:
if self.forensic_pcap is None:
return
status = self.forensic_pcap.status()
for key in (
"buffered_frames",
"buffered_bytes",
"window_seconds",
"memory_bytes",
"max_files",
"max_total_bytes",
):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_forensic_pcap_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
mode = status.get("mode")
if mode:
self._emit(
lines,
"mikrosuricata_forensic_pcap_mode_info",
1,
metric_type="gauge",
labels={"mode": mode},
)
@staticmethod
def _emit(
lines: list[str],
name: str,
value: Any,
*,
metric_type: str | None = None,
help_text: str | None = None,
labels: Mapping[str, Any] | None = None,
) -> None:
number = _number(value)
if number is None:
return
if help_text is not None:
lines.append(f"# HELP {name} {help_text}")
if metric_type is not None:
lines.append(f"# TYPE {name} {metric_type}")
if labels:
rendered = ",".join(
f'{_metric_name(str(key))}="{_label_value(label_value)}"'
for key, label_value in sorted(labels.items())
)
lines.append(f"{name}{{{rendered}}} {number}")
else:
lines.append(f"{name} {number}")
+6 -1
View File
@@ -59,11 +59,16 @@ class RuntimeStats:
self._data["last_packet_at"] = last_packet self._data["last_packet_at"] = last_packet
self._data["last_alert_at"] = last_alert self._data["last_alert_at"] = last_alert
def snapshot(self) -> dict: def metrics_snapshot(self) -> dict:
"""Return raw in-memory counters without derived calculations."""
with self._lock: with self._lock:
data = dict(self._data) data = dict(self._data)
data["suricata"] = dict(self._suricata) data["suricata"] = dict(self._suricata)
data["suricata_stats_at"] = self._suricata_timestamp data["suricata_stats_at"] = self._suricata_timestamp
return data
def snapshot(self) -> dict:
data = self.metrics_snapshot()
datagrams = int(data.get("tzsp_datagrams", 0)) datagrams = int(data.get("tzsp_datagrams", 0))
frames = int(data.get("frames_injected", 0)) frames = int(data.get("frames_injected", 0))
data["tzsp_to_tap_loss"] = max(datagrams - frames, 0) data["tzsp_to_tap_loss"] = max(datagrams - frames, 0)
+83
View File
@@ -56,6 +56,55 @@ class _WebHTTPServer(ThreadingHTTPServer):
super().handle_error(request, client_address) 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: class WebServer:
def __init__( def __init__(
self, self,
@@ -73,6 +122,7 @@ class WebServer:
ndr_analyzer: NDRAnalyzer | None = None, ndr_analyzer: NDRAnalyzer | None = None,
backup_manager: BackupManager | None = None, backup_manager: BackupManager | None = None,
forensic_pcap: ForensicPcapRing | None = None, forensic_pcap: ForensicPcapRing | None = None,
metrics_provider: Callable[[], str] | None = None,
) -> None: ) -> None:
self.config = config self.config = config
self.store = store self.store = store
@@ -87,6 +137,8 @@ class WebServer:
self.threat_intel = threat_intel self.threat_intel = threat_intel
self.ndr_analyzer = ndr_analyzer self.ndr_analyzer = ndr_analyzer
self.forensic_pcap = forensic_pcap 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.backup_manager = backup_manager or BackupManager(config.db_path, os.path.dirname(config.db_path) or ".")
self.auth = SessionAuth(config, store) self.auth = SessionAuth(config, store)
self._login_lock = threading.Lock() self._login_lock = threading.Lock()
@@ -143,6 +195,8 @@ class WebServer:
ndr_analyzer = self.ndr_analyzer ndr_analyzer = self.ndr_analyzer
backup_manager = self.backup_manager backup_manager = self.backup_manager
forensic_pcap = self.forensic_pcap forensic_pcap = self.forensic_pcap
metrics_provider = self.metrics_provider
metrics_access = self.metrics_access
auth = self.auth auth = self.auth
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -155,6 +209,35 @@ class WebServer:
if parsed.path == "/ws/live": if parsed.path == "/ws/live":
self._websocket(parsed) self._websocket(parsed)
return 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"}: if view_path in {"/", "/live", "/security", "/intelligence", "/blocks", "/reports", "/feeds", "/rules", "/system"}:
self._send(200, DASHBOARD.encode("utf-8"), "text/html; charset=utf-8") self._send(200, DASHBOARD.encode("utf-8"), "text/html; charset=utf-8")
return return
+4
View File
@@ -61,6 +61,10 @@ ALERT_IGNORE_CATEGORIES=
# Dashboard login. Use a long random password. Empty ADMIN_PASSWORD keeps the UI read-only. # Dashboard login. Use a long random password. Empty ADMIN_PASSWORD keeps the UI read-only.
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Prometheus /metrics ACL. Set the Prometheus host IP/CIDR; Basic Auth is optional.
METRICS_ALLOWED_IPS=127.0.0.1/32,::1/128
METRICS_BASIC_AUTH_USERNAME=
METRICS_BASIC_AUTH_PASSWORD=
SESSION_HOURS=168 SESSION_HOURS=168
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60
+75
View File
@@ -0,0 +1,75 @@
# MikroSuricata Grafana dashboard
`mikrosuricata-prometheus.json` is an importable Grafana dashboard for the
Prometheus metrics exposed by MikroSuricata at `/metrics`.
The exporter is intentionally scrape-only and lightweight: it serializes
already available in-memory counters and the last Suricata EVE stats snapshot.
A scrape does not query SQLite, Redis, RouterOS, the Suricata control socket,
or analytics endpoints.
The dashboard also uses monotonic TZSP traffic counters exported directly from
the in-memory flow tracker:
```text
mikrosuricata_traffic_bytes_total{direction="total|inbound|outbound|internal|external"}
mikrosuricata_traffic_packets_total{direction="total|inbound|outbound|internal|external"}
```
Grafana calculates bandwidth with PromQL `rate()` and converts bytes/s to
bits/s. The top of the dashboard therefore shows current total, inbound and
outbound throughput, packet rate, capture drops, a large throughput chart and
traffic volume by direction for the selected time range.
`/metrics` is protected by an IP/CIDR ACL configured through environment variables.
The default allows loopback only:
```dotenv
METRICS_ALLOWED_IPS=127.0.0.1/32,::1/128
METRICS_BASIC_AUTH_USERNAME=
METRICS_BASIC_AUTH_PASSWORD=
```
For a remote Prometheus, set `METRICS_ALLOWED_IPS` to its source IP or subnet.
Multiple entries are comma-separated. Leaving both Basic Auth values empty enables
IP-only mode. Setting both enables IP + Basic Auth. Setting only one credential is
invalid and prevents the web server from starting. An empty `METRICS_ALLOWED_IPS`
denies all scrapes. The ACL uses the actual TCP peer address and does not trust
`X-Forwarded-For`.
Example IP-only Prometheus scrape configuration:
```yaml
scrape_configs:
- job_name: mikrosuricata
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets: ["mikrosuricata:8080"]
```
Example IP + Basic Auth configuration in the application:
```dotenv
METRICS_ALLOWED_IPS=10.20.30.40/32
METRICS_BASIC_AUTH_USERNAME=prometheus
METRICS_BASIC_AUTH_PASSWORD=change-this-secret
```
and in Prometheus:
```yaml
scrape_configs:
- job_name: mikrosuricata
scrape_interval: 30s
metrics_path: /metrics
basic_auth:
username: prometheus
password: change-this-secret
static_configs:
- targets: ["mikrosuricata:8080"]
```
Import `mikrosuricata-prometheus.json` in Grafana and select the Prometheus
datasource from the dashboard variable. Rate, percentage and ratio panels are
calculated in PromQL, not by MikroSuricata.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -42,6 +42,10 @@
# Set a long ADMIN_PASSWORD to enable authenticated maintenance/rule-management. # Set a long ADMIN_PASSWORD to enable authenticated maintenance/rule-management.
/container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="admin" /container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="admin"
/container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value="" /container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value=""
# /metrics is IP-restricted by default. Replace/add the Prometheus source IP or CIDR.
/container/envs/add list=IDS_ENV key=METRICS_ALLOWED_IPS value="127.0.0.1/32,::1/128"
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_USERNAME value=""
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value=""
/container/envs/add list=IDS_ENV key=SESSION_HOURS value=168 /container/envs/add list=IDS_ENV key=SESSION_HOURS value=168
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60 /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60
+4
View File
@@ -42,6 +42,10 @@
# Set a long ADMIN_PASSWORD to enable authenticated maintenance/rule-management. # Set a long ADMIN_PASSWORD to enable authenticated maintenance/rule-management.
/container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="admin" /container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="admin"
/container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value="" /container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value=""
# /metrics is IP-restricted by default. Replace/add the Prometheus source IP or CIDR.
/container/envs/add list=IDS_ENV key=METRICS_ALLOWED_IPS value="127.0.0.1/32,::1/128"
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_USERNAME value=""
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value=""
/container/envs/add list=IDS_ENV key=SESSION_HOURS value=168 /container/envs/add list=IDS_ENV key=SESSION_HOURS value=168
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60 /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60
+4
View File
@@ -43,6 +43,10 @@
# Set a long ADMIN_PASSWORD to enable authenticated maintenance/rule-management. # Set a long ADMIN_PASSWORD to enable authenticated maintenance/rule-management.
/container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="admin" /container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="admin"
/container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value="" /container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value=""
# /metrics is IP-restricted by default. Replace/add the Prometheus source IP or CIDR.
/container/envs/add list=IDS_ENV key=METRICS_ALLOWED_IPS value="127.0.0.1/32,::1/128"
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_USERNAME value=""
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value=""
/container/envs/add list=IDS_ENV key=SESSION_HOURS value=168 /container/envs/add list=IDS_ENV key=SESSION_HOURS value=168
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value=false
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60 /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60
+3
View File
@@ -42,6 +42,9 @@ services:
LIVE_FLOW_UPDATE_SECONDS: "2.0" LIVE_FLOW_UPDATE_SECONDS: "2.0"
ADMIN_USERNAME: admin ADMIN_USERNAME: admin
ADMIN_PASSWORD: "" ADMIN_PASSWORD: ""
METRICS_ALLOWED_IPS: "127.0.0.1/32,::1/128"
METRICS_BASIC_AUTH_USERNAME: ""
METRICS_BASIC_AUTH_PASSWORD: ""
SESSION_HOURS: "168" SESSION_HOURS: "168"
SESSION_COOKIE_SECURE: "false" SESSION_COOKIE_SECURE: "false"
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS: "60" ANALYTICS_SNAPSHOT_INTERVAL_SECONDS: "60"
+14
View File
@@ -68,6 +68,9 @@ ROOT_DIR="/containers/${CONTAINER_NAME}/root"
: "${ALERT_IGNORE_CATEGORIES:=}" : "${ALERT_IGNORE_CATEGORIES:=}"
: "${ADMIN_USERNAME:=admin}" : "${ADMIN_USERNAME:=admin}"
: "${ADMIN_PASSWORD:=}" : "${ADMIN_PASSWORD:=}"
: "${METRICS_ALLOWED_IPS:=127.0.0.1/32,::1/128}"
: "${METRICS_BASIC_AUTH_USERNAME:=}"
: "${METRICS_BASIC_AUTH_PASSWORD:=}"
: "${SESSION_HOURS:=168}" : "${SESSION_HOURS:=168}"
: "${SESSION_COOKIE_SECURE:=false}" : "${SESSION_COOKIE_SECURE:=false}"
: "${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS:=60}" : "${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS:=60}"
@@ -133,6 +136,11 @@ case "$FORENSIC_PCAP_MODE" in
blocks|alerts|all|off) ;; blocks|alerts|all|off) ;;
*) echo "FORENSIC_PCAP_MODE must be one of: blocks, alerts, all, off" >&2; exit 2 ;; *) echo "FORENSIC_PCAP_MODE must be one of: blocks, alerts, all, off" >&2; exit 2 ;;
esac esac
if { [ -n "$METRICS_BASIC_AUTH_USERNAME" ] && [ -z "$METRICS_BASIC_AUTH_PASSWORD" ]; } || \
{ [ -z "$METRICS_BASIC_AUTH_USERNAME" ] && [ -n "$METRICS_BASIC_AUTH_PASSWORD" ]; }; then
echo "METRICS_BASIC_AUTH_USERNAME and METRICS_BASIC_AUTH_PASSWORD must both be set or both be empty" >&2
exit 2
fi
for numeric_pair in \ for numeric_pair in \
"REDIS_PORT=$REDIS_PORT" \ "REDIS_PORT=$REDIS_PORT" \
"SURICATA_LOG_MAX_MB=$SURICATA_LOG_MAX_MB" \ "SURICATA_LOG_MAX_MB=$SURICATA_LOG_MAX_MB" \
@@ -192,6 +200,9 @@ for pair in \
"ALERT_IGNORE_CATEGORIES=$ALERT_IGNORE_CATEGORIES" \ "ALERT_IGNORE_CATEGORIES=$ALERT_IGNORE_CATEGORIES" \
"ADMIN_USERNAME=$ADMIN_USERNAME" \ "ADMIN_USERNAME=$ADMIN_USERNAME" \
"ADMIN_PASSWORD=$ADMIN_PASSWORD" \ "ADMIN_PASSWORD=$ADMIN_PASSWORD" \
"METRICS_ALLOWED_IPS=$METRICS_ALLOWED_IPS" \
"METRICS_BASIC_AUTH_USERNAME=$METRICS_BASIC_AUTH_USERNAME" \
"METRICS_BASIC_AUTH_PASSWORD=$METRICS_BASIC_AUTH_PASSWORD" \
"SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE" \ "SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE" \
"REDIS_DATA_DIR=$REDIS_DATA_DIR" \ "REDIS_DATA_DIR=$REDIS_DATA_DIR" \
"SURICATA_PERSIST_LIB_DIR=$SURICATA_PERSIST_LIB_DIR" \ "SURICATA_PERSIST_LIB_DIR=$SURICATA_PERSIST_LIB_DIR" \
@@ -327,6 +338,9 @@ cat > "$LOCAL_RSC" <<RSC
/container/envs/add list=IDS_ENV key=ALERT_IGNORE_CATEGORIES value="${ALERT_IGNORE_CATEGORIES}" /container/envs/add list=IDS_ENV key=ALERT_IGNORE_CATEGORIES value="${ALERT_IGNORE_CATEGORIES}"
/container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="${ADMIN_USERNAME}" /container/envs/add list=IDS_ENV key=ADMIN_USERNAME value="${ADMIN_USERNAME}"
/container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value="${ADMIN_PASSWORD}" /container/envs/add list=IDS_ENV key=ADMIN_PASSWORD value="${ADMIN_PASSWORD}"
/container/envs/add list=IDS_ENV key=METRICS_ALLOWED_IPS value="${METRICS_ALLOWED_IPS}"
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_USERNAME value="${METRICS_BASIC_AUTH_USERNAME}"
/container/envs/add list=IDS_ENV key=METRICS_BASIC_AUTH_PASSWORD value="${METRICS_BASIC_AUTH_PASSWORD}"
/container/envs/add list=IDS_ENV key=SESSION_HOURS value="${SESSION_HOURS}" /container/envs/add list=IDS_ENV key=SESSION_HOURS value="${SESSION_HOURS}"
/container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value="${SESSION_COOKIE_SECURE}" /container/envs/add list=IDS_ENV key=SESSION_COOKIE_SECURE value="${SESSION_COOKIE_SECURE}"
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value="${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS}" /container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value="${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS}"
+19
View File
@@ -18,6 +18,25 @@ class ConfigTests(unittest.TestCase):
with patch.dict(os.environ, {"RULE_UPDATE_INTERVAL_HOURS": "-5"}, clear=True): with patch.dict(os.environ, {"RULE_UPDATE_INTERVAL_HOURS": "-5"}, clear=True):
self.assertEqual(Config.from_env().rule_update_interval_hours, 0) self.assertEqual(Config.from_env().rule_update_interval_hours, 0)
def test_metrics_acl_defaults_to_loopback_ip_only(self):
with patch.dict(os.environ, {}, clear=True):
cfg = Config.from_env()
self.assertEqual(cfg.metrics_allowed_ips, "127.0.0.1/32,::1/128")
self.assertEqual(cfg.metrics_basic_auth_username, "")
self.assertEqual(cfg.metrics_basic_auth_password, "")
def test_metrics_acl_reads_ip_and_basic_auth_from_env(self):
env = {
"METRICS_ALLOWED_IPS": "10.0.0.5/32,10.0.1.0/24",
"METRICS_BASIC_AUTH_USERNAME": "prometheus",
"METRICS_BASIC_AUTH_PASSWORD": "secret",
}
with patch.dict(os.environ, env, clear=True):
cfg = Config.from_env()
self.assertEqual(cfg.metrics_allowed_ips, env["METRICS_ALLOWED_IPS"])
self.assertEqual(cfg.metrics_basic_auth_username, "prometheus")
self.assertEqual(cfg.metrics_basic_auth_password, "secret")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+2
View File
@@ -64,5 +64,7 @@ def test_routeros_deploy_forwards_ndr_and_persistence_controls():
"BEHAVIOR_MIN_OBSERVATIONS", "NDR_AUTO_BLOCK", "NDR_AUTO_BLOCK_RISK", "BEHAVIOR_MIN_OBSERVATIONS", "NDR_AUTO_BLOCK", "NDR_AUTO_BLOCK_RISK",
"ROUTEROS_INVENTORY_INTERVAL_SECONDS", "NOTIFY_WEBHOOK_URL", "ROUTEROS_INVENTORY_INTERVAL_SECONDS", "NOTIFY_WEBHOOK_URL",
"NOTIFY_MIN_RISK", "NOTIFY_TIMEOUT_SECONDS", "NOTIFY_MIN_RISK", "NOTIFY_TIMEOUT_SECONDS",
"METRICS_ALLOWED_IPS", "METRICS_BASIC_AUTH_USERNAME",
"METRICS_BASIC_AUTH_PASSWORD",
): ):
assert f"key={key}" in script assert f"key={key}" in script
+26
View File
@@ -66,6 +66,32 @@ class FlowTrackerTests(unittest.TestCase):
self.assertFalse(first_persist) self.assertFalse(first_persist)
self.assertFalse(second_persist) self.assertFalse(second_persist)
def test_status_exposes_monotonic_directional_traffic_counters(self):
pipeline = _Pipeline()
tracker = FlowTracker(
TrafficNormalizer("192.168.100.0/24"),
pipeline, # type: ignore[arg-type]
max_flows=1000,
)
outbound = _ipv4_tcp_frame("192.168.100.10", 51000, "1.1.1.1", 443, b"out")
inbound = _ipv4_tcp_frame("1.1.1.1", 443, "192.168.100.10", 51000, b"in")
internal = _ipv4_tcp_frame("192.168.100.10", 51000, "192.168.100.20", 443, b"lan")
external = _ipv4_tcp_frame("1.1.1.1", 51000, "8.8.8.8", 443, b"wan")
for frame in (outbound, inbound, internal, external):
tracker.observe(frame)
traffic = tracker.status()["traffic_counters"]
self.assertEqual(traffic["packets_total"], 4)
self.assertEqual(traffic["packets_out"], 1)
self.assertEqual(traffic["packets_in"], 1)
self.assertEqual(traffic["packets_internal"], 1)
self.assertEqual(traffic["packets_external"], 1)
self.assertEqual(
traffic["bytes_total"],
traffic["bytes_out"] + traffic["bytes_in"] + traffic["bytes_internal"] + traffic["bytes_external"],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import base64
import contextlib
import io
import tempfile
import unittest
import urllib.error
import urllib.request
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from app.config import Config
from app.metrics import PrometheusMetrics
from app.state import RuntimeStats
from app.store import AlertStore
from app.webui import WebServer
class _Status:
def __init__(self, **values):
self.values = values
def status(self):
return dict(self.values)
class PrometheusMetricsTests(unittest.TestCase):
def test_render_exports_raw_runtime_suricata_and_in_memory_component_state(self):
stats = RuntimeStats()
stats.inc("tzsp_datagrams", 5)
stats.inc("frames_injected", 4)
stats.stamp("last_packet_at")
stats.update_suricata(
{
"capture": {"kernel_packets": 123, "kernel_drops": 2},
"detect": {"alert_queue_overflow": 1},
},
"2026-08-16T07:00:00+00:00",
)
metrics = PrometheusMetrics(
stats,
version="1.2.3",
mode="full",
started_at=datetime(2026, 8, 16, 6, 0, tzinfo=timezone.utc),
state_provider=lambda: {
"components": {"suricata": True, "tzsp": True},
"features": {"auto_block": False},
},
flow_tracker=_Status(
active_flows=3,
max_flows=20000,
published_updates=7,
evicted_flows=1,
parse_errors=2,
throughput_samples=9,
update_interval_seconds=2.0,
traffic_counters={
"bytes_total": 10000,
"bytes_in": 6000,
"bytes_out": 3000,
"bytes_internal": 750,
"bytes_external": 250,
"packets_total": 100,
"packets_in": 60,
"packets_out": 30,
"packets_internal": 7,
"packets_external": 3,
},
),
)
rendered = metrics.render()
self.assertIn('mikrosuricata_build_info{mode="full",version="1.2.3"} 1', rendered)
self.assertIn("mikrosuricata_tzsp_datagrams_total 5", rendered)
self.assertIn("mikrosuricata_frames_injected_total 4", rendered)
self.assertIn("mikrosuricata_suricata_capture_kernel_packets 123", rendered)
self.assertIn("mikrosuricata_suricata_capture_kernel_drops 2", rendered)
self.assertIn('mikrosuricata_component_up{component="suricata"} 1', rendered)
self.assertIn("mikrosuricata_flow_tracker_active_flows 3", rendered)
self.assertIn('mikrosuricata_traffic_bytes_total{direction="total"} 10000', rendered)
self.assertIn('mikrosuricata_traffic_bytes_total{direction="inbound"} 6000', rendered)
self.assertIn('mikrosuricata_traffic_packets_total{direction="external"} 3', rendered)
self.assertEqual(rendered.count("# TYPE mikrosuricata_traffic_bytes_total counter"), 1)
self.assertNotIn("block_success_rate", rendered)
self.assertNotIn("tzsp_to_tap_loss", rendered)
self.assertEqual(rendered.count("# TYPE mikrosuricata_component_up gauge"), 1)
def test_runtime_metrics_snapshot_has_no_derived_values(self):
stats = RuntimeStats()
stats.inc("tzsp_datagrams", 10)
stats.inc("frames_injected", 9)
raw = stats.metrics_snapshot()
self.assertNotIn("tzsp_to_tap_loss", raw)
self.assertNotIn("block_success_rate", raw)
def test_metrics_endpoint_allows_default_loopback_acl_and_does_not_call_health_provider(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(
Config.from_env(),
web_bind="127.0.0.1",
web_port=0,
db_path=db_path,
admin_password="test-password",
)
store = AlertStore(db_path)
def forbidden_health():
raise AssertionError("/metrics must not call the health provider")
web = WebServer(
cfg,
store,
forbidden_health,
metrics_provider=lambda: "# TYPE mikrosuricata_test gauge\nmikrosuricata_test 1\n",
)
try:
with contextlib.redirect_stdout(io.StringIO()):
web.start()
port = web.server.server_address[1]
with urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=2) as response:
body = response.read().decode("utf-8")
content_type = response.headers.get("Content-Type", "")
self.assertEqual(200, response.status)
self.assertIn("version=0.0.4", content_type)
self.assertIn("mikrosuricata_test 1", body)
finally:
web.stop()
store.close()
def test_metrics_ip_acl_denies_before_rendering_metrics(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(
Config.from_env(),
web_bind="127.0.0.1",
web_port=0,
db_path=db_path,
metrics_allowed_ips="192.0.2.10/32",
)
store = AlertStore(db_path)
calls = []
web = WebServer(
cfg,
store,
lambda: {},
metrics_provider=lambda: calls.append(True) or "mikrosuricata_test 1\n",
)
try:
with contextlib.redirect_stdout(io.StringIO()):
web.start()
port = web.server.server_address[1]
with self.assertRaises(urllib.error.HTTPError) as caught:
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=2)
self.assertEqual(403, caught.exception.code)
self.assertEqual([], calls)
finally:
web.stop()
store.close()
def test_metrics_basic_auth_requires_valid_credentials_after_ip_acl(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(
Config.from_env(),
web_bind="127.0.0.1",
web_port=0,
db_path=db_path,
metrics_allowed_ips="127.0.0.1",
metrics_basic_auth_username="prometheus",
metrics_basic_auth_password="strong-secret",
)
store = AlertStore(db_path)
calls = []
web = WebServer(
cfg,
store,
lambda: {},
metrics_provider=lambda: calls.append(True) or "mikrosuricata_test 1\n",
)
try:
with contextlib.redirect_stdout(io.StringIO()):
web.start()
port = web.server.server_address[1]
url = f"http://127.0.0.1:{port}/metrics"
with self.assertRaises(urllib.error.HTTPError) as caught:
urllib.request.urlopen(url, timeout=2)
self.assertEqual(401, caught.exception.code)
self.assertIn("Basic", caught.exception.headers.get("WWW-Authenticate", ""))
self.assertEqual([], calls)
bad = urllib.request.Request(url, headers={"Authorization": "Basic !!!"})
with self.assertRaises(urllib.error.HTTPError) as caught:
urllib.request.urlopen(bad, timeout=2)
self.assertEqual(401, caught.exception.code)
self.assertEqual([], calls)
token = base64.b64encode(b"prometheus:strong-secret").decode("ascii")
request = urllib.request.Request(url, headers={"Authorization": f"Basic {token}"})
with urllib.request.urlopen(request, timeout=2) as response:
body = response.read().decode("utf-8")
self.assertEqual(200, response.status)
self.assertIn("mikrosuricata_test 1", body)
self.assertEqual([True], calls)
finally:
web.stop()
store.close()
def test_metrics_basic_auth_configuration_must_be_complete(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(
Config.from_env(),
web_bind="127.0.0.1",
web_port=0,
db_path=db_path,
metrics_basic_auth_username="prometheus",
metrics_basic_auth_password="",
)
store = AlertStore(db_path)
try:
with self.assertRaisesRegex(ValueError, "must either both be set"):
WebServer(cfg, store, lambda: {}, metrics_provider=lambda: "")
finally:
store.close()
def test_metrics_acl_rejects_invalid_network_configuration(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(
Config.from_env(),
web_bind="127.0.0.1",
web_port=0,
db_path=db_path,
metrics_allowed_ips="not-an-ip",
)
store = AlertStore(db_path)
try:
with self.assertRaisesRegex(ValueError, "invalid METRICS_ALLOWED_IPS"):
WebServer(cfg, store, lambda: {}, metrics_provider=lambda: "")
finally:
store.close()
if __name__ == "__main__":
unittest.main()