poc3
This commit is contained in:
@@ -86,6 +86,9 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
ALERT_IGNORE_SIDS=1000001 \
|
||||
ADMIN_USERNAME=admin \
|
||||
ADMIN_PASSWORD= \
|
||||
METRICS_ALLOWED_IPS=127.0.0.1/32,::1/128 \
|
||||
METRICS_BASIC_AUTH_USERNAME= \
|
||||
METRICS_BASIC_AUTH_PASSWORD= \
|
||||
SESSION_HOURS=168 \
|
||||
SESSION_COOKIE_SECURE=false \
|
||||
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60 \
|
||||
|
||||
@@ -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
|
||||
|
||||
The default configuration is observation-oriented:
|
||||
|
||||
@@ -49,6 +49,9 @@ class Config:
|
||||
rule_update_interval_hours: int
|
||||
web_bind: str
|
||||
web_port: int
|
||||
metrics_allowed_ips: str
|
||||
metrics_basic_auth_username: str
|
||||
metrics_basic_auth_password: str
|
||||
db_path: str
|
||||
eve_path: str
|
||||
suricata_log_max_mb: int
|
||||
@@ -135,6 +138,15 @@ class Config:
|
||||
rule_update_interval_hours=max(0, _int("RULE_UPDATE_INTERVAL_HOURS", 24)),
|
||||
web_bind=os.getenv("WEB_BIND", "0.0.0.0"),
|
||||
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"),
|
||||
eve_path=os.getenv("EVE_PATH", "/data/logs/suricata/eve.json"),
|
||||
suricata_log_max_mb=_int("SURICATA_LOG_MAX_MB", 512),
|
||||
|
||||
@@ -5,12 +5,14 @@ import signal
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .analytics_cache import AnalyticsSnapshotCache
|
||||
from .config import Config
|
||||
from .live import EventBus, LiveEventPipeline, TrafficHistory
|
||||
from .maintenance import storage_info
|
||||
from .metrics import PrometheusMetrics
|
||||
from .rules import RuleManager
|
||||
from .state import RuntimeStats
|
||||
from .store import AlertStore
|
||||
@@ -231,6 +233,32 @@ def main() -> int:
|
||||
"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(
|
||||
cfg,
|
||||
store,
|
||||
@@ -241,6 +269,7 @@ def main() -> int:
|
||||
event_bus=event_bus,
|
||||
live_pipeline=live_pipeline,
|
||||
analytics_cache=analytics_cache,
|
||||
metrics_provider=prometheus_metrics.render,
|
||||
)
|
||||
|
||||
def request_stop(_signum=None, _frame=None) -> None:
|
||||
|
||||
+23
-8
@@ -68,7 +68,12 @@ class FlowTracker:
|
||||
"bytes_total": 0, "bytes_in": 0, "bytes_out": 0,
|
||||
"bytes_internal": 0, "bytes_external": 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:
|
||||
parsed = _parse_frame(frame)
|
||||
@@ -138,23 +143,33 @@ class FlowTracker:
|
||||
"parse_errors": self._parse_errors,
|
||||
"throughput_samples": self._throughput_samples,
|
||||
"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:
|
||||
direction = self.normalizer._direction(src_ip, dest_ip)
|
||||
counters = self._rate_counters
|
||||
counters["bytes_total"] += frame_bytes
|
||||
counters["packets_total"] += 1
|
||||
totals = self._traffic_counters
|
||||
|
||||
for target in (counters, totals):
|
||||
target["bytes_total"] += frame_bytes
|
||||
target["packets_total"] += 1
|
||||
if direction == "inbound":
|
||||
counters["bytes_in"] += frame_bytes
|
||||
counters["packets_in"] += 1
|
||||
for target in (counters, totals):
|
||||
target["bytes_in"] += frame_bytes
|
||||
target["packets_in"] += 1
|
||||
elif direction == "outbound":
|
||||
counters["bytes_out"] += frame_bytes
|
||||
counters["packets_out"] += 1
|
||||
for target in (counters, totals):
|
||||
target["bytes_out"] += frame_bytes
|
||||
target["packets_out"] += 1
|
||||
elif direction == "internal":
|
||||
counters["bytes_internal"] += frame_bytes
|
||||
for target in (counters, totals):
|
||||
target["bytes_internal"] += frame_bytes
|
||||
target["packets_internal"] += 1
|
||||
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
|
||||
if elapsed < 1.0:
|
||||
|
||||
+42
-1
@@ -21,6 +21,7 @@ from .flow_tracker import FlowTracker
|
||||
from .forensics import ForensicPcapRing
|
||||
from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer
|
||||
from .maintenance import clear_suricata_logs, storage_info
|
||||
from .metrics import PrometheusMetrics
|
||||
from .ndr import NDRAnalyzer, ThreatIntelManager
|
||||
from .notifier import WebhookNotifier
|
||||
from .policy import PolicyEngine
|
||||
@@ -210,7 +211,7 @@ def main() -> int:
|
||||
cfg.redis_snapshot_seconds,
|
||||
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(
|
||||
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(),
|
||||
}
|
||||
|
||||
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(
|
||||
cfg,
|
||||
store,
|
||||
@@ -437,6 +477,7 @@ def main() -> int:
|
||||
ndr_analyzer=ndr_analyzer,
|
||||
backup_manager=backup_manager,
|
||||
forensic_pcap=forensic_pcap,
|
||||
metrics_provider=prometheus_metrics.render,
|
||||
)
|
||||
|
||||
def housekeeping() -> None:
|
||||
|
||||
+341
@@ -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
@@ -59,11 +59,16 @@ class RuntimeStats:
|
||||
self._data["last_packet_at"] = last_packet
|
||||
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:
|
||||
data = dict(self._data)
|
||||
data["suricata"] = dict(self._suricata)
|
||||
data["suricata_stats_at"] = self._suricata_timestamp
|
||||
return data
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
data = self.metrics_snapshot()
|
||||
datagrams = int(data.get("tzsp_datagrams", 0))
|
||||
frames = int(data.get("frames_injected", 0))
|
||||
data["tzsp_to_tap_loss"] = max(datagrams - frames, 0)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,10 @@ ALERT_IGNORE_CATEGORIES=
|
||||
# Dashboard login. Use a long random password. Empty ADMIN_PASSWORD keeps the UI read-only.
|
||||
ADMIN_USERNAME=admin
|
||||
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_COOKIE_SECURE=false
|
||||
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS=60
|
||||
|
||||
@@ -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
@@ -42,6 +42,10 @@
|
||||
# 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_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_COOKIE_SECURE value=false
|
||||
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
# 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_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_COOKIE_SECURE value=false
|
||||
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
# 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_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_COOKIE_SECURE value=false
|
||||
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value=60
|
||||
|
||||
@@ -42,6 +42,9 @@ services:
|
||||
LIVE_FLOW_UPDATE_SECONDS: "2.0"
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: ""
|
||||
METRICS_ALLOWED_IPS: "127.0.0.1/32,::1/128"
|
||||
METRICS_BASIC_AUTH_USERNAME: ""
|
||||
METRICS_BASIC_AUTH_PASSWORD: ""
|
||||
SESSION_HOURS: "168"
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
ANALYTICS_SNAPSHOT_INTERVAL_SECONDS: "60"
|
||||
|
||||
@@ -68,6 +68,9 @@ ROOT_DIR="/containers/${CONTAINER_NAME}/root"
|
||||
: "${ALERT_IGNORE_CATEGORIES:=}"
|
||||
: "${ADMIN_USERNAME:=admin}"
|
||||
: "${ADMIN_PASSWORD:=}"
|
||||
: "${METRICS_ALLOWED_IPS:=127.0.0.1/32,::1/128}"
|
||||
: "${METRICS_BASIC_AUTH_USERNAME:=}"
|
||||
: "${METRICS_BASIC_AUTH_PASSWORD:=}"
|
||||
: "${SESSION_HOURS:=168}"
|
||||
: "${SESSION_COOKIE_SECURE:=false}"
|
||||
: "${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS:=60}"
|
||||
@@ -133,6 +136,11 @@ case "$FORENSIC_PCAP_MODE" in
|
||||
blocks|alerts|all|off) ;;
|
||||
*) echo "FORENSIC_PCAP_MODE must be one of: blocks, alerts, all, off" >&2; exit 2 ;;
|
||||
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 \
|
||||
"REDIS_PORT=$REDIS_PORT" \
|
||||
"SURICATA_LOG_MAX_MB=$SURICATA_LOG_MAX_MB" \
|
||||
@@ -192,6 +200,9 @@ for pair in \
|
||||
"ALERT_IGNORE_CATEGORIES=$ALERT_IGNORE_CATEGORIES" \
|
||||
"ADMIN_USERNAME=$ADMIN_USERNAME" \
|
||||
"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" \
|
||||
"REDIS_DATA_DIR=$REDIS_DATA_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=ADMIN_USERNAME value="${ADMIN_USERNAME}"
|
||||
/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_COOKIE_SECURE value="${SESSION_COOKIE_SECURE}"
|
||||
/container/envs/add list=IDS_ENV key=ANALYTICS_SNAPSHOT_INTERVAL_SECONDS value="${ANALYTICS_SNAPSHOT_INTERVAL_SECONDS}"
|
||||
|
||||
@@ -18,6 +18,25 @@ class ConfigTests(unittest.TestCase):
|
||||
with patch.dict(os.environ, {"RULE_UPDATE_INTERVAL_HOURS": "-5"}, clear=True):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -64,5 +64,7 @@ def test_routeros_deploy_forwards_ndr_and_persistence_controls():
|
||||
"BEHAVIOR_MIN_OBSERVATIONS", "NDR_AUTO_BLOCK", "NDR_AUTO_BLOCK_RISK",
|
||||
"ROUTEROS_INVENTORY_INTERVAL_SECONDS", "NOTIFY_WEBHOOK_URL",
|
||||
"NOTIFY_MIN_RISK", "NOTIFY_TIMEOUT_SECONDS",
|
||||
"METRICS_ALLOWED_IPS", "METRICS_BASIC_AUTH_USERNAME",
|
||||
"METRICS_BASIC_AUTH_PASSWORD",
|
||||
):
|
||||
assert f"key={key}" in script
|
||||
|
||||
@@ -66,6 +66,32 @@ class FlowTrackerTests(unittest.TestCase):
|
||||
self.assertFalse(first_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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user