SysDeck/bridge/grafana.py

475 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Grafana Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Manages Grafana visualization dashboards, datasources, alerting,
and plugin configuration via the Grafana HTTP API.
Grafana is AGPL-3.0 licensed by Grafana Labs. This bridge helper
communicates with Grafana via its HTTP API — no Grafana code is bundled.
Subcommands:
summary - Overall Grafana status (version, health, counts)
dashboards - List all dashboards with folder/tags
datasources - List and health-check datasources
alerts - List Grafana-managed alert rules and states
health - Grafana health check endpoint
org - Current organization info
users - List Grafana users
plugins - List installed plugins
search - Search dashboards by query string
restart - Restart the Grafana systemd service
reload - Reload Grafana provisioning (SIGUSR2)
"""
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import urllib.error
from pathlib import Path
from typing import Any
GRAFANA_LICENSE = "AGPL-3.0"
GRAFANA_AUTHORS = "Grafana Labs"
GRAFANA_URL = "https://grafana.com"
# 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))
# Grafana API endpoint from environment or default
GRAFANA_API_URL = os.environ.get("GRAFANA_API_URL", "http://localhost:3000")
# Admin credentials from environment or default
GRAFANA_USER = os.environ.get("GRAFANA_ADMIN_USER", "admin")
GRAFANA_PASSWORD = os.environ.get("GRAFANA_ADMIN_PASSWORD", "admin")
def _systemd_status(unit: str) -> dict[str, Any]:
"""Check systemd unit active state.
v0.0.39 hardening: env scrubbed, output sanitized.
"""
try:
result = subprocess.run(
["systemctl", "show", unit,
"--property=ActiveState,SubState"],
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"),
}
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
# Systemd unavailable: select default unknown state
return {"active": "unknown", "sub": "unknown"}
def _is_installed() -> bool:
"""Check if Grafana binary or package exists."""
# Probe standard paths and package manager registries
for cmd in [
["which", "grafana-server"],
["systemctl", "list-unit-files", "grafana-server.service"],
]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3,
env=SCRUBBED_ENV)
if r.returncode == 0:
return True
except (subprocess.TimeoutExpired, OSError):
pass
return False
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject HTTP redirects — SSRF defense (CVE-2020-35850 lesson)."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
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 _grafana_api_get(path: str, timeout: int = 5) -> Any:
"""GET from Grafana HTTP API with basic auth, 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"{GRAFANA_API_URL.rstrip('/')}/api{path}"
if not _is_localhost_url(url):
return None
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
# HTTP Basic authentication credential encoding
credentials = base64.b64encode(
f"{GRAFANA_USER}:{GRAFANA_PASSWORD}".encode()
).decode()
req.add_header("Authorization", f"Basic {credentials}")
opener = urllib.request.build_opener(_NoRedirectHandler)
with opener.open(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError:
# Grafana returned an HTTP error (auth failed, not found, etc.)
return None
except urllib.error.URLError:
# Grafana unreachable: report connection failure
return None
except (ValueError, json.JSONDecodeError, OSError):
# API response parse failure or I/O error: select null result
return None
def _default_health() -> dict[str, Any]:
"""Provide default Grafana health status when unreachable."""
return {
"database": "unknown",
"commit": "",
"version": "",
"status": "unknown",
"message": "Grafana unreachable",
}
def health() -> dict[str, Any]:
"""Grafana health check endpoint."""
data = _grafana_api_get("/health")
if not isinstance(data, dict):
# Health endpoint unavailable: select default health state
return _default_health()
return {
"database": data.get("database", ""),
"commit": data.get("commit", ""),
"version": data.get("version", ""),
"status": data.get("status", "unknown"),
"message": data.get("message", ""),
}
def summary() -> dict[str, Any]:
"""Overall Grafana status, version, health, dashboards, datasources."""
status_info = _systemd_status("grafana-server.service")
if status_info["active"] != "active":
return {
"installed": _is_installed(),
"version": "",
"status": "stopped" if _is_installed() else "uninstalled",
"url": GRAFANA_API_URL,
"dashboardsCount": 0,
"datasourcesCount": 0,
"activeAlerts": 0,
"orgName": "",
"adminUser": GRAFANA_USER,
"health": _default_health(),
}
health_info = health()
version = health_info.get("version", "")
# Dashboard inventory count
dash_search = _grafana_api_get("/search?type=dash-db")
dashboards_count = len(dash_search) if isinstance(dash_search, list) else 0
# Datasource inventory count
ds_list = _grafana_api_get("/datasources")
datasources_count = len(ds_list) if isinstance(ds_list, list) else 0
# Alert rules (Grafana unified alerting)
alert_rules = _grafana_api_get("/v1/provisioning/alert-rules")
if not isinstance(alert_rules, list):
active_alerts = 0
else:
active_alerts = sum(
1 for r in alert_rules
if isinstance(r, dict) and r.get("status", {}).get("state") == "alerting"
)
# Organization identity
org = _grafana_api_get("/org")
org_name = org.get("name", "Main Org.") if isinstance(org, dict) else "Main Org."
return {
"installed": True,
"version": version,
"status": "running",
"url": GRAFANA_API_URL,
"dashboardsCount": dashboards_count,
"datasourcesCount": datasources_count,
"activeAlerts": active_alerts,
"orgName": org_name,
"adminUser": GRAFANA_USER,
"health": health_info,
}
def dashboards() -> list[dict[str, Any]]:
"""List all dashboards with folder and tags."""
data = _grafana_api_get("/search?type=dash-db")
if not isinstance(data, list):
return []
return [{
"id": d.get("id", 0),
"uid": d.get("uid", ""),
"title": d.get("title", ""),
"slug": d.get("slug", ""),
"uri": d.get("uri", ""),
"url": d.get("url", ""),
"type": d.get("type", ""),
"tags": d.get("tags", []),
"isStarred": d.get("isStarred", False),
"folderTitle": d.get("folderTitle", ""),
"folderUid": d.get("folderUid", ""),
"folderId": d.get("folderId", 0),
} for d in data]
def _datasource_record(ds: dict[str, Any]) -> dict[str, Any]:
"""Construct datasource record with live health status."""
ds_id = ds.get("id", 0)
resp = _grafana_api_get(f"/datasources/{ds_id}/health")
# Health endpoint unavailable: select default success indicator
if not isinstance(resp, dict):
ds_status, ds_message = "success", ""
else:
ds_status = resp.get("status", "error")
ds_message = resp.get("message", "")
return {
"id": ds_id,
"name": ds.get("name", ""),
"type": ds.get("type", ""),
"url": ds.get("url", ""),
"access": ds.get("access", "proxy"),
"isDefault": ds.get("isDefault", False),
"database": ds.get("database", ""),
"jsonData": ds.get("jsonData", {}),
"status": ds_status,
"message": ds_message,
}
def datasources() -> list[dict[str, Any]]:
"""List datasources with health check."""
data = _grafana_api_get("/datasources")
if not isinstance(data, list):
return []
return [_datasource_record(ds) for ds in data]
def alerts() -> list[dict[str, Any]]:
"""List Grafana unified alerting rules."""
data = _grafana_api_get("/v1/provisioning/alert-rules")
if not isinstance(data, list):
return []
return [{
"id": 0, # Unified alerting uses uid, not numeric id
"uid": a.get("uid", ""),
"title": a.get("title", ""),
"condition": a.get("condition", ""),
"dashboardUid": a.get("dashboardUid", ""),
"dashboardTitle": a.get("dashboardTitle", ""),
"panelId": a.get("panelId", 0),
"state": (
a.get("status", {}).get("state", "unknown")
if isinstance(a.get("status"), dict) else "unknown"
),
"noDataState": a.get("noDataState", ""),
"executionErrorState": a.get("executionErrorState", ""),
"labels": a.get("labels", {}),
} for a in data]
def org() -> dict[str, Any]:
"""Current organization info."""
data = _grafana_api_get("/org")
if not isinstance(data, dict):
# Organization endpoint unavailable: select empty defaults
return {
"id": 0, "name": "", "address1": "",
"address2": "", "city": "", "country": "",
}
return {
"id": data.get("id", 0),
"name": data.get("name", ""),
"address1": data.get("address1", ""),
"address2": data.get("address2", ""),
"city": data.get("city", ""),
"country": data.get("country", ""),
}
def users() -> list[dict[str, Any]]:
"""List Grafana users."""
data = _grafana_api_get("/org/users")
if not isinstance(data, list):
return []
return [{
"id": u.get("id", 0),
"login": u.get("login", ""),
"name": u.get("name", ""),
"email": u.get("email", ""),
"isAdmin": u.get("isGrafanaAdmin", False),
"isGrafanaAdmin": u.get("isGrafanaAdmin", False),
"lastSeenAt": u.get("lastSeenAt", ""),
"lastSeenAtAge": u.get("lastSeenAtAge", ""),
"authLabels": u.get("authLabels", []),
} for u in data]
def plugins() -> list[dict[str, Any]]:
"""List installed Grafana plugins."""
data = _grafana_api_get("/plugins")
if not isinstance(data, list):
return []
return [{
"id": p.get("id", ""),
"name": p.get("name", ""),
"type": p.get("type", ""),
"enabled": p.get("enabled", False),
"pinned": p.get("pinned", False),
"version": p.get("version", ""),
"signature": p.get("signature", ""),
"info": {
"description": p.get("info", {}).get("description", ""),
"author": p.get("info", {}).get("author", {"name": "", "url": ""}),
"logos": p.get("info", {}).get("logos", {"small": "", "large": ""}),
},
} for p in data]
def search(args: list[str]) -> list[dict[str, Any]]:
"""Search dashboards by query string."""
query = args[0] if args else ""
if query:
from urllib.parse import quote
endpoint = f"/search?type=dash-db&query={quote(query, safe='')}"
else:
endpoint = "/search?type=dash-db"
data = _grafana_api_get(endpoint)
if not isinstance(data, list):
return []
return [{
"id": d.get("id", 0),
"uid": d.get("uid", ""),
"title": d.get("title", ""),
"slug": d.get("slug", ""),
"uri": d.get("uri", ""),
"url": d.get("url", ""),
"type": d.get("type", ""),
"tags": d.get("tags", []),
"isStarred": d.get("isStarred", False),
"folderTitle": d.get("folderTitle", ""),
"folderUid": d.get("folderUid", ""),
"folderId": d.get("folderId", 0),
} for d in data]
def restart() -> dict[str, Any]:
"""Restart the Grafana systemd service.
v0.0.39 hardening: no sudo (cockpit superuser channel handles auth via
polkit org.sysdeck.monitoring.modify). env scrubbed. check=False.
"""
try:
r = subprocess.run(
["systemctl", "restart", "grafana-server.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 SIGUSR2 to Grafana for provisioning reload.
v0.0.39 hardening: no sudo, env scrubbed, check=False.
"""
try:
r = subprocess.run(
["systemctl", "kill", "--signal=SIGUSR2", "grafana-server.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(),
"dashboards": lambda _args: dashboards(),
"datasources": lambda _args: datasources(),
"alerts": lambda _args: alerts(),
"health": lambda _args: health(),
"org": lambda _args: org(),
"users": lambda _args: users(),
"plugins": lambda _args: plugins(),
"search": lambda args: search(args),
"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:]))