342 lines
12 KiB
Python
342 lines
12 KiB
Python
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}")
|