541 lines
20 KiB
Python
Executable File
541 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
SysDeck - Prometheus Bridge Helper
|
|
Author: Jeremy Anderson (https://dcos.net)
|
|
|
|
Manages Prometheus monitoring, alerting, and the centralized log pipeline.
|
|
All SysDeck module logs are pushed to the Prometheus pushgateway for
|
|
centralized observability, metric scraping, and alert-driven responses.
|
|
|
|
Prometheus is Apache-2.0 licensed by the Prometheus Authors. This bridge
|
|
helper communicates with Prometheus via its HTTP API — no Prometheus code
|
|
is bundled.
|
|
|
|
Subcommands:
|
|
summary - Overall Prometheus status and config
|
|
targets - Scrape target health (up/down/duration)
|
|
alerts - Current firing and pending alerts
|
|
rules - Alerting and recording rules from loaded rule files
|
|
config - Full Prometheus configuration
|
|
push-log - Push a SysDeck log entry to the pushgateway
|
|
log-summary - Summary of log pipeline throughput
|
|
restart - Restart the Prometheus service unit
|
|
reload - Send SIGHUP for config reload (no restart)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
PROM_LICENSE = "Apache-2.0"
|
|
PROM_AUTHORS = "Prometheus Authors"
|
|
PROM_URL = "https://prometheus.io"
|
|
|
|
# Security helpers come from firewall.py — one source of truth for
|
|
# hardening across the suite.
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
try:
|
|
from firewall import ( # type: ignore
|
|
SCRUBBED_ENV,
|
|
_sanitize_output,
|
|
_validate_filename,
|
|
)
|
|
except ImportError:
|
|
SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
|
|
def _sanitize_output(text: str, max_len: int = 4096) -> str:
|
|
if not text:
|
|
return ""
|
|
if len(text) > max_len:
|
|
text = text[:max_len] + " ... (truncated)"
|
|
return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
|
|
_FILENAME_RE_FALLBACK = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
|
def _validate_filename(name: str) -> bool:
|
|
return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name))
|
|
|
|
# Prometheus API endpoint from environment or default. Port 9090
|
|
# belongs to cockpit-ws on every SysDeck host, so Prometheus defaults
|
|
# to 9095 — same 909x range, clear of Pushgateway (9091), Alertmanager
|
|
# (9093), and Cockpit (9090). Operators running Prometheus on a custom
|
|
# port override it via PROMETHEUS_API_URL.
|
|
PROM_API_URL = os.environ.get("PROMETHEUS_API_URL", "http://localhost:9095")
|
|
# Pushgateway URL for log pipeline
|
|
PUSHGATEWAY_URL = os.environ.get("PROMETHEUS_PUSHGATEWAY_URL", "http://localhost:9091")
|
|
# SysDeck log persistence directory
|
|
LOG_DIR = Path("/var/lib/sysdeck/prometheus-logs")
|
|
|
|
|
|
def _systemd_status(unit: str) -> dict[str, Any]:
|
|
"""Check systemd unit active state and substate.
|
|
|
|
v0.0.39 hardening: env scrubbed (SCRUBBED_ENV), output sanitized.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["systemctl", "show", unit,
|
|
"--property=ActiveState,SubState,ActiveEnterTimestamp"],
|
|
capture_output=True, text=True, timeout=5,
|
|
env=SCRUBBED_ENV,
|
|
)
|
|
props = dict(
|
|
line.split("=", 1)
|
|
for line in _sanitize_output(result.stdout).strip().splitlines()
|
|
if "=" in line
|
|
)
|
|
return {
|
|
"active": props.get("ActiveState", "unknown"),
|
|
"sub": props.get("SubState", "unknown"),
|
|
"since": props.get("ActiveEnterTimestamp", ""),
|
|
}
|
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
|
# Systemd unavailable: select default unknown state
|
|
return {"active": "unknown", "sub": "unknown", "since": ""}
|
|
|
|
|
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Reject HTTP redirects — SSRF defense (CVE-2020-35850 lesson).
|
|
|
|
v0.0.39: prevents an attacker-controlled Prometheus/Pushgateway
|
|
from redirecting the bridge to an internal service (e.g. 169.254.169.254
|
|
metadata endpoint, or other localhost services).
|
|
"""
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None # raise HTTPError instead of following
|
|
|
|
|
|
def _is_localhost_url(url: str) -> bool:
|
|
"""Return True if url points to localhost/127.0.0.1 (SSRF defense)."""
|
|
return (url.startswith("http://localhost:") or
|
|
url.startswith("http://127.0.0.1:") or
|
|
url.startswith("http://[::1]:"))
|
|
|
|
|
|
def _prom_api_get(path: str, timeout: int = 5) -> dict[str, Any]:
|
|
"""GET from Prometheus HTTP API, returning parsed JSON.
|
|
|
|
v0.0.39 hardening:
|
|
- NoRedirectHandler (SSRF defense — CVE-2020-35850).
|
|
- 127.0.0.1-only URL check (SSRF defense).
|
|
- 5s timeout (DoS defense).
|
|
"""
|
|
url = f"{PROM_API_URL.rstrip('/')}/api/v1{path}"
|
|
if not _is_localhost_url(url):
|
|
return {"error": "refused", "message": f"non-localhost URL rejected: {url}"}
|
|
try:
|
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
opener = urllib.request.build_opener(_NoRedirectHandler)
|
|
with opener.open(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read())
|
|
if data.get("status") == "success":
|
|
return data.get("data", {})
|
|
return {"error": data.get("errorType", ""), "message": data.get("error", "")}
|
|
except urllib.error.HTTPError as e:
|
|
return {"error": f"http_{e.code}", "message": e.reason}
|
|
except urllib.error.URLError:
|
|
# Prometheus unreachable: report connection failure
|
|
return {"error": "connection_refused", "message": f"Cannot reach Prometheus at {url}"}
|
|
except (ValueError, KeyError, TypeError) as exc:
|
|
# API response structure unexpected: select error path
|
|
return {"error": "request_failed", "message": str(exc)}
|
|
|
|
|
|
def _pushgateway_post(job: str, data: str, timeout: int = 5) -> bool:
|
|
"""POST metrics to Prometheus pushgateway.
|
|
|
|
v0.0.39 hardening: NoRedirectHandler + 127.0.0.1-only (SSRF defense).
|
|
"""
|
|
# Validate the job name (it enters the URL path).
|
|
if not _validate_filename(job):
|
|
return False
|
|
url = f"{PUSHGATEWAY_URL.rstrip('/')}/metrics/job/{job}"
|
|
if not _is_localhost_url(url):
|
|
return False
|
|
try:
|
|
req = urllib.request.Request(url, data=data.encode(), method="POST")
|
|
req.add_header("Content-Type", "text/plain")
|
|
opener = urllib.request.build_opener(_NoRedirectHandler)
|
|
with opener.open(req, timeout=timeout) as resp:
|
|
return resp.status in (200, 202)
|
|
except (urllib.error.HTTPError, urllib.error.URLError, ValueError, OSError):
|
|
# Pushgateway unreachable: report push failure
|
|
return False
|
|
|
|
|
|
def summary() -> dict[str, Any]:
|
|
"""Overall Prometheus status, version, targets, alerts, config."""
|
|
status_info = _systemd_status("prometheus.service")
|
|
if status_info["active"] != "active":
|
|
return {
|
|
"installed": _is_installed(),
|
|
"version": "",
|
|
"uptime": "",
|
|
"status": "stopped" if _is_installed() else "uninstalled",
|
|
"targetsTotal": 0, "targetsUp": 0, "targetsDown": 0,
|
|
"activeAlerts": 0, "pendingAlerts": 0, "seriesCount": 0,
|
|
"config": _default_config(),
|
|
}
|
|
|
|
# Query Prometheus API for runtime info
|
|
build_info = _prom_api_get("/status/buildinfo")
|
|
version = build_info.get("version", "") if isinstance(build_info, dict) else ""
|
|
|
|
# Target stats
|
|
targets_data = _prom_api_get("/targets")
|
|
if not isinstance(targets_data, dict) or "activeTargets" not in targets_data:
|
|
targets_total, targets_up, targets_down = 0, 0, 0
|
|
else:
|
|
active = targets_data["activeTargets"]
|
|
targets_total = len(active)
|
|
targets_up = sum(1 for t in active if t.get("health") == "up")
|
|
targets_down = sum(1 for t in active if t.get("health") == "down")
|
|
|
|
# Alert stats
|
|
alerts_data = _prom_api_get("/alerts")
|
|
if not isinstance(alerts_data, dict) or "alerts" not in alerts_data:
|
|
active_alerts, pending_alerts = 0, 0
|
|
else:
|
|
alert_items = alerts_data["alerts"]
|
|
active_alerts = sum(1 for a in alert_items if a.get("state", "") == "firing")
|
|
pending_alerts = sum(1 for a in alert_items if a.get("state", "") == "pending")
|
|
|
|
# Series count (approximate via /status/tsdb)
|
|
tsdb_data = _prom_api_get("/status/tsdb")
|
|
series_count = 0
|
|
if isinstance(tsdb_data, dict):
|
|
raw = tsdb_data.get("seriesCountByMetricName", [{}])
|
|
series_count = len(raw) if isinstance(raw, list) else 0
|
|
|
|
config_data = _prom_api_get("/status/config")
|
|
if isinstance(config_data, dict) and "yaml" in config_data:
|
|
config = _parse_config(config_data)
|
|
else:
|
|
config = _default_config()
|
|
|
|
return {
|
|
"installed": True,
|
|
"version": version,
|
|
"uptime": status_info.get("since", ""),
|
|
"status": "running",
|
|
"targetsTotal": targets_total,
|
|
"targetsUp": targets_up,
|
|
"targetsDown": targets_down,
|
|
"activeAlerts": active_alerts,
|
|
"pendingAlerts": pending_alerts,
|
|
"seriesCount": series_count,
|
|
"config": config,
|
|
}
|
|
|
|
|
|
def _is_installed() -> bool:
|
|
"""Check if Prometheus binary or package exists."""
|
|
# Probe standard paths and package manager registries
|
|
for cmd in [
|
|
["/usr/bin/which", "prometheus"],
|
|
["systemctl", "list-unit-files", "prometheus.service"],
|
|
]:
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
if r.returncode == 0:
|
|
return True
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
# Probe target unavailable: continue to next probe
|
|
pass
|
|
return False
|
|
|
|
|
|
def _default_config() -> dict[str, Any]:
|
|
"""Provide default Prometheus configuration values."""
|
|
return {
|
|
"globalScrapeInterval": "15s",
|
|
"globalEvaluationInterval": "15s",
|
|
"retentionTime": "15d",
|
|
"retentionSize": "0",
|
|
"storagePath": "/var/lib/prometheus",
|
|
"configPath": "/etc/prometheus/prometheus.yml",
|
|
"webListenAddress": "127.0.0.1:9095",
|
|
"logLevel": "info",
|
|
"walCompression": True,
|
|
}
|
|
|
|
|
|
def _parse_config(config_data: dict) -> dict[str, Any]:
|
|
"""Extract key config values from Prometheus /status/config response."""
|
|
yaml_str = config_data.get("yaml", "")
|
|
cfg = _default_config()
|
|
|
|
# Dispatch table: YAML key to config field mapping
|
|
key_dispatch = {
|
|
"scrape_interval": "globalScrapeInterval",
|
|
"evaluation_interval": "globalEvaluationInterval",
|
|
}
|
|
for line in yaml_str.splitlines():
|
|
stripped = line.strip()
|
|
if ":" not in stripped:
|
|
continue
|
|
key, _, value = stripped.partition(":")
|
|
field = key_dispatch.get(key.strip())
|
|
if field:
|
|
cfg[field] = value.strip()
|
|
return cfg
|
|
|
|
|
|
def targets() -> list[dict[str, Any]]:
|
|
"""List all scrape targets with health status."""
|
|
data = _prom_api_get("/targets")
|
|
if not isinstance(data, dict) or "activeTargets" not in data:
|
|
return []
|
|
return [{
|
|
"instance": t.get("labels", {}).get("instance", ""),
|
|
"job": t.get("labels", {}).get("job", ""),
|
|
"lastScrape": t.get("lastScrape", ""),
|
|
"lastScrapeDuration": t.get("lastScrapeDuration", 0) / 1e6,
|
|
"health": t.get("health", "unknown"),
|
|
"labels": t.get("labels", {}),
|
|
"scrapeUrl": t.get("scrapeUrl", ""),
|
|
} for t in data["activeTargets"]]
|
|
|
|
|
|
def alerts() -> list[dict[str, Any]]:
|
|
"""List current firing and pending alerts from Prometheus."""
|
|
data = _prom_api_get("/alerts")
|
|
if not isinstance(data, dict) or "alerts" not in data:
|
|
return []
|
|
return [{
|
|
"labels": a.get("labels", {}),
|
|
"state": a.get("state", "inactive"),
|
|
"activeAt": a.get("activeAt", ""),
|
|
"value": a.get("value", 0),
|
|
"annotation": a.get("annotations", {}).get(
|
|
"summary", a.get("annotations", {}).get("description", "")
|
|
),
|
|
} for a in data["alerts"]]
|
|
|
|
|
|
def rules() -> list[dict[str, Any]]:
|
|
"""List alerting and recording rules from loaded rule groups."""
|
|
data = _prom_api_get("/rules")
|
|
if not isinstance(data, dict) or "groups" not in data:
|
|
return []
|
|
return [{
|
|
"name": g.get("name", ""),
|
|
"path": g.get("file", ""),
|
|
"groups": [{"name": g.get("name", ""), "rules": [{
|
|
"group": g.get("name", ""),
|
|
"name": r.get("name", ""),
|
|
"severity": r.get("labels", {}).get("severity", "info"),
|
|
"expr": r.get("query", ""),
|
|
"for": r.get("duration", "0s"),
|
|
"summary": r.get("annotations", {}).get("summary", ""),
|
|
"state": r.get("state", "inactive"),
|
|
"value": r.get("value", None),
|
|
} for r in g.get("rules", [])]}],
|
|
} for g in data["groups"]]
|
|
|
|
|
|
def config() -> dict[str, Any]:
|
|
"""Full Prometheus YAML configuration."""
|
|
data = _prom_api_get("/status/config")
|
|
if isinstance(data, dict):
|
|
return {"yaml": data.get("yaml", ""), "parsed": _parse_config(data)}
|
|
return {"yaml": "", "parsed": _default_config()}
|
|
|
|
|
|
def push_log(args: list[str]) -> dict[str, Any]:
|
|
"""Push a SysDeck log entry to the Prometheus pushgateway.
|
|
|
|
Expects args: [module, level, message, metadata_json]
|
|
The log is formatted as Prometheus metrics and pushed to the pushgateway
|
|
under the 'sysdeck_logs' job.
|
|
|
|
Metrics pushed:
|
|
sysdeck_log_total{module,level} 1
|
|
sysdeck_log_timestamp_seconds{module,level} <epoch>
|
|
"""
|
|
if len(args) < 3:
|
|
return {"error": "Usage: push-log <module> <level> <message> [metadata_json]"}
|
|
|
|
module = args[0]
|
|
level = args[1]
|
|
message = args[2]
|
|
try:
|
|
metadata = json.loads(args[3]) if len(args) > 3 else {}
|
|
except json.JSONDecodeError:
|
|
# Metadata JSON malformed: reject push request
|
|
return {"error": "metadata_json must be valid JSON"}
|
|
|
|
# Filesystem contract: the module name becomes a filename under
|
|
# LOG_DIR. Validate it like every other filename this suite writes —
|
|
# no absolute paths, no traversal, no symlink tricks downstream.
|
|
if not _validate_filename(module):
|
|
return {"error": "invalid module name (max 64 chars of [A-Za-z0-9._-])"}
|
|
|
|
ts = time.time()
|
|
iso_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
|
|
|
|
# Build Prometheus exposition format. Label values are escaped
|
|
# (backslash, quote, newline) per the exposition spec — raw values
|
|
# must never inject metric lines into the pushgateway payload.
|
|
def _esc(value: str) -> str:
|
|
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
|
|
|
|
mod_l, lvl_l, msg_l = _esc(module), _esc(level), _esc(message[:128])
|
|
metrics = (
|
|
'# TYPE sysdeck_log_total counter\n'
|
|
f'sysdeck_log_total{{module="{mod_l}",level="{lvl_l}"}} 1\n'
|
|
'# TYPE sysdeck_log_timestamp_seconds gauge\n'
|
|
f'sysdeck_log_timestamp_seconds{{module="{mod_l}",level="{lvl_l}"}} {ts:.3f}\n'
|
|
'# TYPE sysdeck_log_message_info gauge\n'
|
|
f'sysdeck_log_message_info{{module="{mod_l}",level="{lvl_l}",msg="{msg_l}"}} 1\n'
|
|
)
|
|
|
|
pushed = _pushgateway_post("sysdeck_logs", metrics)
|
|
|
|
# Persist to local audit log. Path stays inside LOG_DIR by
|
|
# construction (validated module name, fixed directory).
|
|
log_entry = {
|
|
"module": module,
|
|
"level": level,
|
|
"message": message,
|
|
"timestamp": iso_ts,
|
|
"metadata": metadata,
|
|
"pushedToPrometheus": pushed,
|
|
}
|
|
log_file = LOG_DIR / f"{module}.jsonl"
|
|
try:
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
with open(log_file, "a", encoding="utf-8") as f:
|
|
f.write(json.dumps(log_entry) + "\n")
|
|
except OSError as exc:
|
|
return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry,
|
|
"persisted": False, "persistError": str(exc)}
|
|
|
|
return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry, "persisted": True}
|
|
|
|
|
|
def log_summary() -> dict[str, Any]:
|
|
"""Summarize the SysDeck log pipeline: counts by module/level, push stats."""
|
|
if not LOG_DIR.exists():
|
|
return {
|
|
"totalLogs": 0, "pushedToPrometheus": 0, "failedPushes": 0,
|
|
"lastPushAt": "", "byModule": {}, "byLevel": {},
|
|
}
|
|
|
|
total = 0
|
|
pushed = 0
|
|
failed = 0
|
|
last_push = ""
|
|
by_module: dict[str, int] = {}
|
|
by_level: dict[str, int] = {}
|
|
|
|
for log_file in LOG_DIR.glob("*.jsonl"):
|
|
with open(log_file) as f:
|
|
for line in f:
|
|
try:
|
|
entry = json.loads(line)
|
|
total += 1
|
|
mod = entry.get("module", "unknown")
|
|
lvl = entry.get("level", "unknown")
|
|
by_module[mod] = by_module.get(mod, 0) + 1
|
|
by_level[lvl] = by_level.get(lvl, 0) + 1
|
|
if entry.get("pushedToPrometheus"):
|
|
pushed += 1
|
|
ts = entry.get("timestamp", "")
|
|
if ts > last_push:
|
|
last_push = ts
|
|
else:
|
|
failed += 1
|
|
except json.JSONDecodeError:
|
|
# Malformed log entry: skip and continue
|
|
continue
|
|
|
|
return {
|
|
"totalLogs": total,
|
|
"pushedToPrometheus": pushed,
|
|
"failedPushes": failed,
|
|
"lastPushAt": last_push,
|
|
"byModule": by_module,
|
|
"byLevel": by_level,
|
|
}
|
|
|
|
|
|
def restart() -> dict[str, Any]:
|
|
"""Restart the Prometheus systemd service.
|
|
|
|
v0.0.39 hardening: no sudo (cockpit superuser channel handles auth via
|
|
polkit org.sysdeck.monitoring.modify). env scrubbed. check=False with
|
|
structured error return. CVE-2022-0824 lesson — the bridge does not
|
|
trust the UI; polkit gates the privileged verb.
|
|
"""
|
|
try:
|
|
r = subprocess.run(
|
|
["systemctl", "restart", "prometheus.service"],
|
|
capture_output=True, text=True, check=False, timeout=10,
|
|
env=SCRUBBED_ENV,
|
|
)
|
|
if r.returncode == 0:
|
|
return {"action": "restart", "result": "ok"}
|
|
return {"action": "restart", "result": "error",
|
|
"rc": r.returncode, "stderr": _sanitize_output(r.stderr).strip()}
|
|
except subprocess.TimeoutExpired:
|
|
return {"action": "restart", "result": "error", "message": "restart timed out after 10s"}
|
|
except OSError as exc:
|
|
return {"action": "restart", "result": "error", "message": str(exc)}
|
|
|
|
|
|
def reload() -> dict[str, Any]:
|
|
"""Send SIGHUP to Prometheus for live config reload.
|
|
|
|
v0.0.39 hardening: no sudo, env scrubbed, check=False.
|
|
"""
|
|
try:
|
|
r = subprocess.run(
|
|
["systemctl", "kill", "--signal=SIGHUP", "prometheus.service"],
|
|
capture_output=True, text=True, check=False, timeout=5,
|
|
env=SCRUBBED_ENV,
|
|
)
|
|
if r.returncode == 0:
|
|
return {"action": "reload", "result": "ok"}
|
|
return {"action": "reload", "result": "error",
|
|
"rc": r.returncode, "stderr": _sanitize_output(r.stderr).strip()}
|
|
except subprocess.TimeoutExpired:
|
|
return {"action": "reload", "result": "error", "message": "reload timed out after 5s"}
|
|
except OSError as exc:
|
|
return {"action": "reload", "result": "error", "message": str(exc)}
|
|
|
|
|
|
COMMANDS = {
|
|
"summary": lambda _args: summary(),
|
|
"targets": lambda _args: targets(),
|
|
"alerts": lambda _args: alerts(),
|
|
"rules": lambda _args: rules(),
|
|
"config": lambda _args: config(),
|
|
"push-log": lambda args: push_log(args),
|
|
"log-summary": lambda _args: log_summary(),
|
|
"restart": lambda _args: restart(),
|
|
"reload": lambda _args: reload(),
|
|
}
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
"""Dispatch subcommand and emit JSON result."""
|
|
if not argv or argv[0] in ("-h", "--help"):
|
|
print(__doc__)
|
|
return 0
|
|
cmd = COMMANDS.get(argv[0])
|
|
if not cmd:
|
|
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
|
|
return 2
|
|
print(json.dumps(cmd(argv[1:]), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|