diff --git a/Dockerfile b/Dockerfile index e593d5f..5ef2eae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 7663425..bc1e7e7 100644 --- a/README.md +++ b/README.md @@ -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= +``` + +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: diff --git a/VERSION b/VERSION index 85b7c69..e3e1807 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.6 +0.9.8 diff --git a/app/config.py b/app/config.py index 8a37a76..582e372 100644 --- a/app/config.py +++ b/app/config.py @@ -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), diff --git a/app/dev_web.py b/app/dev_web.py index 9528466..7d95512 100644 --- a/app/dev_web.py +++ b/app/dev_web.py @@ -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: diff --git a/app/flow_tracker.py b/app/flow_tracker.py index f0db2ad..525a3e2 100644 --- a/app/flow_tracker.py +++ b/app/flow_tracker.py @@ -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: diff --git a/app/main.py b/app/main.py index b207869..5037af1 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/app/metrics.py b/app/metrics.py new file mode 100644 index 0000000..e78b121 --- /dev/null +++ b/app/metrics.py @@ -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}") diff --git a/app/state.py b/app/state.py index 4d79e6d..34d435a 100644 --- a/app/state.py +++ b/app/state.py @@ -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) diff --git a/app/webui.py b/app/webui.py index 0b9b387..191d1ea 100644 --- a/app/webui.py +++ b/app/webui.py @@ -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 diff --git a/deploy-routeros.env.example b/deploy-routeros.env.example index 974a82c..2343dbf 100644 --- a/deploy-routeros.env.example +++ b/deploy-routeros.env.example @@ -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 diff --git a/grafana/README.md b/grafana/README.md new file mode 100644 index 0000000..afd1b97 --- /dev/null +++ b/grafana/README.md @@ -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. diff --git a/grafana/mikrosuricata-prometheus.json b/grafana/mikrosuricata-prometheus.json new file mode 100644 index 0000000..61d7951 --- /dev/null +++ b/grafana/mikrosuricata-prometheus.json @@ -0,0 +1,2536 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Core sensor", + "description": "Minimum state across exported core components.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "bool", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "min(mikrosuricata_component_up{instance=~\"$instance\"})", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 20, + "type": "stat", + "title": "Total throughput", + "description": "Current captured TZSP throughput across all traffic directions.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 4, + "y": 0, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "bps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "orange" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"total\"}[$__rate_interval]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 21, + "type": "stat", + "title": "Inbound", + "description": "Traffic entering monitored networks.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 8, + "y": 0, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "bps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#5794F2", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "#5794F2" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"inbound\"}[$__rate_interval]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 22, + "type": "stat", + "title": "Outbound", + "description": "Traffic leaving monitored networks.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "bps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#73BF69", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "#73BF69" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"outbound\"}[$__rate_interval]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 23, + "type": "stat", + "title": "Packet rate", + "description": "Packets per second observed from TZSP.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 16, + "y": 0, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "pps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#B877D9", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "#B877D9" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_traffic_packets_total{instance=~\"$instance\",direction=\"total\"}[$__rate_interval]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Capture drops", + "description": "Suricata kernel capture drops as a percentage of captured packets.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 20, + "y": 0, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.2 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "100 * sum(rate(mikrosuricata_suricata_capture_kernel_drops{instance=~\"$instance\"}[$__rate_interval])) / clamp_min(sum(rate(mikrosuricata_suricata_capture_kernel_packets{instance=~\"$instance\"}[$__rate_interval])), 1)", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 24, + "type": "timeseries", + "title": "Traffic throughput", + "description": "Real captured bandwidth from monotonic TZSP byte counters. Total includes inbound, outbound, internal and external traffic.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 16, + "h": 9 + }, + "fieldConfig": { + "defaults": { + "unit": "bps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 14, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Total" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + }, + { + "id": "custom.lineWidth", + "value": 3 + }, + { + "id": "custom.fillOpacity", + "value": 24 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Inbound" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#5794F2" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Outbound" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#73BF69" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Internal" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#B877D9" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + }, + { + "id": "custom.fillOpacity", + "value": 4 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "External" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#8AB8FF" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + }, + { + "id": "custom.fillOpacity", + "value": 4 + } + ] + } + ] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"total\"}[$__rate_interval]))", + "legendFormat": "Total", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"inbound\"}[$__rate_interval]))", + "legendFormat": "Inbound", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"outbound\"}[$__rate_interval]))", + "legendFormat": "Outbound", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"internal\"}[$__rate_interval]))", + "legendFormat": "Internal", + "range": true, + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"external\"}[$__rate_interval]))", + "legendFormat": "External", + "range": true, + "refId": "E" + } + ] + }, + { + "id": 25, + "type": "bargauge", + "title": "Traffic mix \u00b7 selected range", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 16, + "y": 4, + "w": 8, + "h": 9 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "color": { + "mode": "continuous-GrYlRd" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Inbound" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#5794F2" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Outbound" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#73BF69" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Internal" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#B877D9" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "External" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#8AB8FF" + } + } + ] + } + ] + }, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(increase(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"inbound\"}[$__range]))", + "legendFormat": "Inbound", + "range": false, + "refId": "A", + "instant": true + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(increase(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"outbound\"}[$__range]))", + "legendFormat": "Outbound", + "range": false, + "refId": "B", + "instant": true + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(increase(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"internal\"}[$__range]))", + "legendFormat": "Internal", + "range": false, + "refId": "C", + "instant": true + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(increase(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"external\"}[$__range]))", + "legendFormat": "External", + "range": false, + "refId": "D", + "instant": true + } + ], + "description": "Volume observed during the currently selected Grafana time range." + }, + { + "id": 3, + "type": "stat", + "title": "Alerts / s", + "description": "Suricata EVE alert rate.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 13, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "red" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_eve_alerts_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 26, + "type": "stat", + "title": "Active flows", + "description": "Currently tracked live L3/L4 flows.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 4, + "y": 13, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#5794F2", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "#5794F2" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_flow_tracker_active_flows{instance=~\"$instance\"})", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 5, + "type": "stat", + "title": "NDR queue", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 8, + "y": 13, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_ndr_queue{instance=~\"$instance\"})", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 6, + "type": "stat", + "title": "Last packet age", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 13, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "red", + "value": 120 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "time() - max(mikrosuricata_last_packet_timestamp_seconds{instance=~\"$instance\"})", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 27, + "type": "stat", + "title": "Peak throughput", + "description": "Highest total captured throughput in the selected Grafana time range.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 16, + "y": 13, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "bps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + }, + "color": { + "mode": "fixed", + "fixedColor": "orange" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "max" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "8 * sum(rate(mikrosuricata_traffic_bytes_total{instance=~\"$instance\",direction=\"total\"}[$__rate_interval]))", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 17, + "type": "stat", + "title": "Suricata stats age", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 20, + "y": 13, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "red", + "value": 180 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "time() - max(mikrosuricata_suricata_stats_timestamp_seconds{instance=~\"$instance\"})", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "Packet pipeline", + "description": "Packet-rate comparison from TZSP ingest through TAP injection to Suricata capture.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 17, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "pps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_tzsp_datagrams_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "TZSP datagrams", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_frames_injected_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "TAP injected", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_suricata_capture_kernel_packets{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Suricata captured", + "range": true, + "refId": "C" + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "Sensor loss & decode errors", + "description": "Rates that should stay near zero: kernel drops, injection errors, TZSP decode errors and Suricata alert queue overflow.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 17, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_suricata_capture_kernel_drops{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Kernel drops", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_inject_errors_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "TAP inject errors", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_tzsp_decode_errors_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "TZSP decode errors", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_suricata_detect_alert_queue_overflow{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Alert queue overflow", + "range": true, + "refId": "D" + } + ] + }, + { + "id": 9, + "type": "timeseries", + "title": "Alert pipeline", + "description": "Alert generation, filtering, deduplication and EVE parse errors.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 25, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_eve_alerts_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "EVE alerts", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_alerts_filtered_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Filtered", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_alerts_deduplicated_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Deduplicated", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_eve_parse_errors_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "EVE parse errors", + "range": true, + "refId": "D" + } + ] + }, + { + "id": 10, + "type": "timeseries", + "title": "Automatic blocking", + "description": "Automatic response attempts, successful blocks and errors.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 25, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_block_attempts_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Attempts", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_block_success_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Success", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_block_errors_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Errors", + "range": true, + "refId": "C" + } + ] + }, + { + "id": 11, + "type": "timeseries", + "title": "Live writer / Redis path", + "description": "Live-history writer pressure and Redis failures.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 33, + "w": 8, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_live_pipeline_writer_queue{instance=~\"$instance\"})", + "legendFormat": "Queue", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_live_pipeline_writer_dropped_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Dropped/s", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_live_pipeline_writer_redis_errors_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Redis errors/s", + "range": true, + "refId": "C" + } + ] + }, + { + "id": 12, + "type": "timeseries", + "title": "NDR activity", + "description": "NDR queue depth, generated signals, IOC/behavior hits and drops.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 8, + "y": 33, + "w": 8, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_ndr_queue{instance=~\"$instance\"})", + "legendFormat": "Queue", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_ndr_signals_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Signals/s", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_ndr_ioc_hits_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "IOC hits/s", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_ndr_behavior_hits_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Behavior hits/s", + "range": true, + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_ndr_dropped_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Dropped/s", + "range": true, + "refId": "E" + } + ] + }, + { + "id": 13, + "type": "timeseries", + "title": "Notifications", + "description": "Notification queue and delivery outcomes.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 16, + "y": 33, + "w": 8, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_notifier_queue{instance=~\"$instance\"})", + "legendFormat": "Queue", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_notifier_sent_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Sent/s", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_notifier_failed_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Failed/s", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_notifier_dropped_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Dropped/s", + "range": true, + "refId": "D" + } + ] + }, + { + "id": 14, + "type": "timeseries", + "title": "Live sessions", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 41, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_flow_tracker_active_flows{instance=~\"$instance\"})", + "legendFormat": "Active flows", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_event_bus_subscribers{instance=~\"$instance\"})", + "legendFormat": "WebSocket subscribers", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(mikrosuricata_flow_tracker_evicted_flows_total{instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "Evictions/s", + "range": true, + "refId": "C" + } + ] + }, + { + "id": 15, + "type": "timeseries", + "title": "Forensic PCAP ring", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 8, + "y": 41, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "barAlignment": 0, + "lineWidth": 2, + "fillOpacity": 12, + "gradientMode": "opacity", + "spanNulls": true, + "insertNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc", + "hideZeros": false + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_forensic_pcap_buffered_bytes{instance=~\"$instance\"})", + "legendFormat": "Buffered bytes", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "max(mikrosuricata_forensic_pcap_buffered_frames{instance=~\"$instance\"})", + "legendFormat": "Buffered frames", + "range": true, + "refId": "B" + } + ] + }, + { + "id": 16, + "type": "stat", + "title": "PCAP memory utilization", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 16, + "y": 41, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "100 * max(mikrosuricata_forensic_pcap_buffered_bytes{instance=~\"$instance\"}) / clamp_min(max(mikrosuricata_forensic_pcap_memory_bytes{instance=~\"$instance\"}), 1)", + "legendFormat": "", + "range": true, + "refId": "A" + } + ] + }, + { + "id": 18, + "type": "bargauge", + "title": "Core components", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 48, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "min": 0, + "max": 1, + "unit": "bool", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "mikrosuricata_component_up{instance=~\"$instance\"}", + "legendFormat": "{{component}}", + "range": false, + "refId": "A", + "instant": true + } + ] + }, + { + "id": 19, + "type": "bargauge", + "title": "Feature switches", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 48, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "min": 0, + "max": 1, + "unit": "bool", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "mikrosuricata_feature_enabled{instance=~\"$instance\"}", + "legendFormat": "{{feature}}", + "range": false, + "refId": "A", + "instant": true + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "mikrosuricata", + "suricata", + "ids", + "prometheus", + "routeros", + "traffic", + "ndr" + ], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "label": "Prometheus", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(mikrosuricata_build_info, instance)", + "hide": 0, + "includeAll": true, + "allValue": ".*", + "label": "Instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "query": "label_values(mikrosuricata_build_info, instance)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "MikroSuricata / Traffic & Security", + "uid": "mikrosuricata-prometheus", + "version": 2, + "weekStart": "monday", + "description": "Prometheus dashboard for MikroSuricata traffic throughput, packet pipeline, detection, response and sensor health." +} diff --git a/routeros/04-container-import-amd64.rsc b/routeros/04-container-import-amd64.rsc index ebefdaf..82113b8 100644 --- a/routeros/04-container-import-amd64.rsc +++ b/routeros/04-container-import-amd64.rsc @@ -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 diff --git a/routeros/04-container-import-arm.rsc b/routeros/04-container-import-arm.rsc index c2a8386..6a6e28d 100644 --- a/routeros/04-container-import-arm.rsc +++ b/routeros/04-container-import-arm.rsc @@ -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 diff --git a/routeros/04-container-import-arm64.rsc b/routeros/04-container-import-arm64.rsc index 5b11cbe..35fa4a3 100644 --- a/routeros/04-container-import-arm64.rsc +++ b/routeros/04-container-import-arm64.rsc @@ -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 diff --git a/routeros/app-template.yml b/routeros/app-template.yml index 27f8539..4eae26a 100644 --- a/routeros/app-template.yml +++ b/routeros/app-template.yml @@ -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" diff --git a/scripts/deploy-routeros.sh b/scripts/deploy-routeros.sh index 0f81ff9..5b2ff66 100755 --- a/scripts/deploy-routeros.sh +++ b/scripts/deploy-routeros.sh @@ -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" <