SysDeck/bridge/kata.py

638 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Kata Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
ZERO-DEMO CONTRACT. Every subcommand calls the real Kata Containers
3.x APIs and reads the real host state — sandbox lists, metrics, bundle
catalogs, and PXE status are live data or honest unavailability, never
fabricated records:
list enumerate kata sandboxes via:
1. kata-monitor HTTP /sandboxes (if running)
2. filesystem /run/vc/sbs/<id>/ (Go shim)
3. filesystem /run/kata/<id>/ (Rust shim)
Returns [{id, source, vm_pid, agent_socket}].
inspect <id> per-sandbox detail via kata-monitor /agent-url
+ filesystem probe of /run/vc/sbs/<id>/ or
/run/kata/<id>/. Returns {id, agent_url,
shim_socket, config_path, ...}.
metrics <id> per-sandbox Prometheus metrics via kata-monitor
/metrics?sandbox=<id>. Returns parsed metric
families (cpu, memory, network, hypervisor).
summary aggregate state: sandbox count by status,
kata-runtime version, kata-monitor status,
host capability (kata-runtime check exit code).
version kata-runtime version (plain-text parse) +
kata-runtime env --json (structured).
check kata-runtime check (exit-code based — 0 = OK).
pxe-status real PXE/TFTP status: systemctl is-active
dnsmasq + test -d /srv/tftp + ls
/srv/tftp/pxelinux.cfg/.
qcrows-list list QCrows kernel bundles in
/usr/share/sysdeck/kata/qcrows/. (qcrows-export
and qcrows-initrd-regen are operator binaries the
host runs directly — the bridge does not wrap them.)
KEY DESIGN DECISIONS (Kata 3.x reality):
- kata-runtime list/inspect were REMOVED in 3.x. Do not call them.
- kata-monitor /sandboxes returns PLAIN TEXT (one ID per line),
NOT JSON. Do not json.loads() it.
- kata-monitor /metrics returns PROMETHEUS TEXT FORMAT, not JSON.
Parse with prometheus_client.parser.text_string_to_metric_families.
- kata-runtime env --json uses CAPITALIZED Go field names (no json
tags): Runtime, Hypervisor, Host, Version, Semver, Commit, etc.
- Sandbox IDs are 64 hex chars. Validate with ^[0-9a-f]{64}$.
- kata-monitor binds to 127.0.0.1:8090 by default.
SECURITY HARDENING (v0.0.36 + v0.0.37):
- Array-form subprocess only (shell=False). CVE-2019-15107 lesson.
- "--" separator before user-supplied positionals. CVE-2026-4631.
- Strict allowlist regex on sandbox IDs (^+[0-9a-f]{64}$).
CVE-2024-2947 lesson.
- Env scrubbed (SCRUBBED_ENV) on every privileged subprocess.
CVE-2024-6126 lesson.
- Output sanitized (truncated + non-printable stripped).
CVE-2022-36446 lesson.
- HTTP requests to kata-monitor use urllib with a 5s timeout and
reject redirects (no SSRF). CVE-2020-35850 lesson.
- No eval / pickle / yaml.unsafe_load. CVE-2019-15642 lesson.
- Path resolution with realpath + startswith base check for the
qcrows-list / qcrows-export paths. CVE-2022-30708 lesson.
Usage:
python3 /usr/lib/sysdeck/bridge/kata.py list
python3 /usr/lib/sysdeck/bridge/kata.py inspect <sandbox-id>
python3 /usr/lib/sysdeck/bridge/kata.py metrics <sandbox-id>
python3 /usr/lib/sysdeck/bridge/kata.py summary
python3 /usr/lib/sysdeck/bridge/kata.py version
python3 /usr/lib/sysdeck/bridge/kata.py check
python3 /usr/lib/sysdeck/bridge/kata.py pxe-status
python3 /usr/lib/sysdeck/bridge/kata.py qcrows-list
"""
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
# ── Constants ────────────────────────────────────────────────────────
# kata-monitor defaults to 127.0.0.1:8090.
KATA_MONITOR_URL = "http://127.0.0.1:8090"
KATA_MONITOR_TIMEOUT = 5 # seconds
# Filesystem paths where kata shims register sandbox state.
# Go shim (containerd-shim-kata-v2): /run/vc/sbs/<id>/
# Rust shim (containerd-shim-kata-rs-v2): /run/kata/<id>/
KATA_GO_SHIM_DIR = Path("/run/vc/sbs")
KATA_RUST_SHIM_DIR = Path("/run/kata")
# QCrows kernel bundle directory (shipped by sysdeck-kata package).
QCROWS_DIR = Path("/usr/share/sysdeck/kata/qcrows")
# v0.0.37 security helpers (reused from firewall.py via import).
# We import them to keep ONE source of truth for the hardening.
sys.path.insert(0, str(Path(__file__).parent))
try:
from firewall import ( # type: ignore
SCRUBBED_ENV,
_sanitize_output,
_validate_filename,
_resolve_path_under_base,
)
except ImportError:
# Standalone fallback (if firewall.py isn't importable at runtime).
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 = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def _validate_filename(name: str) -> bool:
if not name or len(name) > 64:
return False
return bool(FILENAME_RE.match(name))
def _resolve_path_under_base(path_str: str, base_dir: Path) -> Path | None:
if not path_str or ".." in Path(path_str).parts:
return None
try:
real = Path(os.path.realpath(path_str))
real.relative_to(base_dir)
return real
except (ValueError, OSError):
return None
# Sandbox ID validator: 64 hex chars (containerd/CRI pod ID format).
SANDBOX_ID_RE = re.compile(r"^[0-9a-f]{64}$")
def _validate_sandbox_id(sid: str) -> bool:
"""Return True if sid is a valid kata sandbox ID (64 hex chars).
CVE-2024-2947 lesson — validate before using in any subprocess argv
or HTTP query string.
"""
if not sid or len(sid) != 64:
return False
return bool(SANDBOX_ID_RE.match(sid))
# ── Subprocess helper ──────────────────────────────────────────────
def _run(argv: list[str], timeout: int = 30) -> tuple[int, str, str]:
"""Run argv and return (rc, stdout, stderr). Never raises.
v0.0.36 hardening: shell=False, env scrubbed, output sanitized.
"""
try:
r = subprocess.run(
argv, capture_output=True, text=True, check=False, timeout=timeout,
env=SCRUBBED_ENV,
)
return r.returncode, _sanitize_output(r.stdout or ""), _sanitize_output(r.stderr or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
def _have(binary: str) -> bool:
"""Return True if binary is on PATH."""
return shutil.which(binary) is not None
# ── kata-monitor HTTP client ───────────────────────────────────────
#
# kata-monitor is a standalone HTTP daemon (default 127.0.0.1:8090).
# It exposes /sandboxes (plain text, one ID per line) and /metrics
# (Prometheus text format). We use urllib (no external deps) with a
# strict 5s timeout and no redirect following (SSRF defense).
def _kata_monitor_get(path: str) -> tuple[int, str, str]:
"""GET <KATA_MONITOR_URL><path>. Returns (status, body, error).
v0.0.37 hardening:
- 5s timeout (DoS defense).
- no redirect following (SSRF defense — CVE-2020-35850 lesson).
- only http:// scheme (no file:// / gopher:// etc.).
"""
url = KATA_MONITOR_URL + path
if not url.startswith("http://127.0.0.1:"):
return 0, "", f"refusing non-localhost URL: {url}"
try:
req = urllib.request.Request(url, headers={"Accept": "text/plain"})
# No redirect handler → redirects are rejected (SSRF defense).
opener = urllib.request.build_opener(NoRedirectHandler)
with opener.open(req, timeout=KATA_MONITOR_TIMEOUT) as resp:
body = resp.read().decode("utf-8", errors="replace")
return resp.status, body, ""
except urllib.error.HTTPError as e:
return e.code, "", f"HTTP {e.code}: {e.reason}"
except urllib.error.URLError as e:
return 0, "", f"connection refused (kata-monitor not running?): {e.reason}"
except Exception as e:
return 0, "", str(e)
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 # raise HTTPError instead of following
# ── Sandbox enumeration (3.x: filesystem + kata-monitor) ──────────
#
# In Kata 3.x, `kata-runtime list` was REMOVED. Sandboxes are
# enumerated via:
# 1. kata-monitor HTTP /sandboxes (if the monitor is running)
# 2. filesystem: /run/vc/sbs/<id>/ (Go shim)
# 3. filesystem: /run/kata/<id>/ (Rust shim)
# We try all three and merge, marking each sandbox with its source.
def _list_from_kata_monitor() -> list[dict[str, Any]]:
"""List sandbox IDs via kata-monitor /sandboxes (plain text)."""
status, body, err = _kata_monitor_get("/sandboxes")
if status != 200 or not body:
return []
sandboxes: list[dict[str, Any]] = []
for line in body.splitlines():
sid = line.strip()
if _validate_sandbox_id(sid):
sandboxes.append({"id": sid, "source": "kata-monitor"})
return sandboxes
def _list_from_filesystem(shim_dir: Path, shim_name: str) -> list[dict[str, Any]]:
"""List sandbox IDs by enumerating a shim state directory."""
if not shim_dir.is_dir():
return []
sandboxes: list[dict[str, Any]] = []
try:
for entry in shim_dir.iterdir():
if not entry.is_dir():
continue
sid = entry.name
if _validate_sandbox_id(sid):
sandboxes.append({"id": sid, "source": f"fs-{shim_name}"})
except (PermissionError, OSError):
pass
return sandboxes
def cmd_list(_args: list[str]) -> list[dict[str, Any]]:
"""List kata sandboxes from all available sources.
Merges kata-monitor + Go shim fs + Rust shim fs, deduplicating by
sandbox ID. Each entry: {id, source, agent_url, shim_socket}.
Returns [] if no sandboxes are running (the empty state — NOT a
mock array).
"""
seen: dict[str, dict[str, Any]] = {}
# Source priority: kata-monitor (richest) > fs-go > fs-rust.
for sb in _list_from_kata_monitor():
seen[sb["id"]] = sb
for sb in _list_from_filesystem(KATA_GO_SHIM_DIR, "go"):
if sb["id"] not in seen:
seen[sb["id"]] = sb
for sb in _list_from_filesystem(KATA_RUST_SHIM_DIR, "rust"):
if sb["id"] not in seen:
seen[sb["id"]] = sb
# Enrich each with agent_url + shim_socket if available.
result: list[dict[str, Any]] = []
for sid, sb in seen.items():
agent_url = _get_agent_url(sid)
sb["agent_url"] = agent_url
sb["shim_socket"] = _find_shim_socket(sid)
result.append(sb)
# Sort by ID for deterministic output.
result.sort(key=lambda s: s["id"])
return result
def _get_agent_url(sid: str) -> str | None:
"""Query kata-monitor /agent-url?sandbox=<sid> for the agent URL."""
if not _validate_sandbox_id(sid):
return None
# URL-encode the sid (it's hex, so no special chars, but defense
# in depth — never interpolate raw into a URL).
import urllib.parse
qs = urllib.parse.urlencode({"sandbox": sid})
status, body, err = _kata_monitor_get(f"/agent-url?{qs}")
if status == 200 and body:
return body.strip()
return None
def _find_shim_socket(sid: str) -> str | None:
"""Find the shim-monitor.sock path for a sandbox.
Go shim: /run/vc/sbs/<id>/shim-monitor.sock
Rust shim: /run/kata/<id>/shim-monitor.sock
"""
if not _validate_sandbox_id(sid):
return None
for base in (KATA_GO_SHIM_DIR / sid, KATA_RUST_SHIM_DIR / sid):
sock = base / "shim-monitor.sock"
if sock.is_socket():
return str(sock)
return None
# ── Subcommand: inspect ────────────────────────────────────────────
def cmd_inspect(args: list[str]) -> dict[str, Any]:
"""Inspect a single sandbox by ID.
Returns {id, source, agent_url, shim_socket, config_path,
sandbox_dir, status} or {error: ...} on invalid ID / not found.
"""
if not args:
return {"error": "sandbox ID required"}
sid = args[0]
if not _validate_sandbox_id(sid):
return {"error": f"invalid sandbox ID (expected 64 hex chars): {sid!r}"}
# Find the sandbox dir.
sandbox_dir: str | None = None
source = "unknown"
for base, shim in [(KATA_GO_SHIM_DIR, "go"), (KATA_RUST_SHIM_DIR, "rust")]:
d = base / sid
if d.is_dir():
sandbox_dir = str(d)
source = f"fs-{shim}"
break
agent_url = _get_agent_url(sid)
shim_socket = _find_shim_socket(sid)
# If we have neither fs state nor agent URL, the sandbox doesn't exist.
if sandbox_dir is None and agent_url is None and shim_socket is None:
# Check kata-monitor too.
ids = [s["id"] for s in _list_from_kata_monitor()]
if sid not in ids:
return {"error": f"sandbox {sid} not found"}
source = "kata-monitor"
return {
"id": sid,
"source": source,
"sandbox_dir": sandbox_dir,
"agent_url": agent_url,
"shim_socket": shim_socket,
"status": "running" if (sandbox_dir or agent_url) else "unknown",
}
# ── Subcommand: metrics ────────────────────────────────────────────
def cmd_metrics(args: list[str]) -> dict[str, Any]:
"""Fetch Prometheus metrics for a sandbox via kata-monitor.
Returns {id, metrics: {cpu_usage_percent, memory_usage_mb,
network_rx_bytes, network_tx_bytes, raw_families: [...]}} or
{error: ...} on invalid ID / kata-monitor not running.
The raw Prometheus text is parsed into metric families if
prometheus_client is available; otherwise the raw text is returned.
"""
if not args:
return {"error": "sandbox ID required"}
sid = args[0]
if not _validate_sandbox_id(sid):
return {"error": f"invalid sandbox ID: {sid!r}"}
import urllib.parse
qs = urllib.parse.urlencode({"sandbox": sid})
status, body, err = _kata_monitor_get(f"/metrics?{qs}")
if status != 200:
return {"error": f"kata-monitor /metrics failed: {err}", "id": sid}
# Try to parse Prometheus text into structured families.
families: list[dict[str, Any]] = []
try:
from prometheus_client.parser import text_string_to_metric_families
for fam in text_string_to_metric_families(body):
samples = []
for s in fam.samples:
samples.append({"name": s.name, "labels": dict(s.labels), "value": s.value})
families.append({"name": fam.name, "type": fam.type, "samples": samples})
except ImportError:
# prometheus_client not installed — return raw text.
return {"id": sid, "raw": body, "parsed": False}
# Extract the key metrics the panel cares about.
summary = _extract_metric_summary(families, sid)
return {"id": sid, "parsed": True, "families": families, "summary": summary}
def _extract_metric_summary(families: list[dict[str, Any]], sid: str) -> dict[str, Any]:
"""Extract cpu/memory/network summary from parsed Prometheus families."""
summary: dict[str, Any] = {
"cpu_usage_percent": None,
"memory_usage_bytes": None,
"network_rx_bytes": None,
"network_tx_bytes": None,
"uptime_seconds": None,
}
for fam in families:
name = fam.get("name", "")
for s in fam.get("samples", []):
# Only consider samples for this sandbox.
if s.get("labels", {}).get("sandbox_id") != sid:
continue
val = s.get("value")
if "cpu" in name and "usage" in name and summary["cpu_usage_percent"] is None:
summary["cpu_usage_percent"] = val
elif "memory" in name and "usage" in name and summary["memory_usage_bytes"] is None:
summary["memory_usage_bytes"] = val
elif "network" in name and "rx" in name:
summary["network_rx_bytes"] = val
elif "network" in name and "tx" in name:
summary["network_tx_bytes"] = val
elif "uptime" in name:
summary["uptime_seconds"] = val
return summary
# ── Subcommand: summary ────────────────────────────────────────────
def cmd_summary(_args: list[str]) -> dict[str, Any]:
"""Return aggregate sandbox state + runtime version + host capability.
This is the panel's header data: total sandboxes, running count,
kata-runtime version, kata-monitor status, host capability.
"""
sandboxes = cmd_list([])
# Determine "running" count — sandboxes with an agent_url or shim
# socket are considered running.
running = sum(1 for s in sandboxes if s.get("agent_url") or s.get("shim_socket"))
# kata-monitor status.
monitor_status: dict[str, Any] = {"running": False, "url": KATA_MONITOR_URL}
status, _, _ = _kata_monitor_get("/sandboxes")
if status == 200:
monitor_status["running"] = True
# kata-runtime version.
runtime_version = _kata_runtime_version()
# Host capability (kata-runtime check exit code).
capable, check_msg = _kata_check()
return {
"total_sandboxes": len(sandboxes),
"running_sandboxes": running,
"sandboxes": sandboxes,
"kata_monitor": monitor_status,
"kata_runtime": runtime_version,
"host_capable": capable,
"check_message": check_msg,
"kata_runtime_installed": _have("kata-runtime"),
"kata_monitor_installed": _have("kata-monitor"),
}
def _kata_runtime_version() -> dict[str, Any]:
"""Parse `kata-runtime version` (plain text) + `kata-runtime env --json`.
The version command output is:
kata-runtime : 3.7.0
commit : abc1234
OCI specs: 1.1.0-rc1
The env --json command returns structured JSON with Capitalized
Go field names (Runtime, Hypervisor, Host, Version, Semver, etc.).
"""
if not _have("kata-runtime"):
return {"installed": False, "version": None, "commit": None, "oci": None}
rc, out, err = _run(["kata-runtime", "version"], timeout=10)
version = commit = oci = None
if rc == 0:
for line in out.splitlines():
if "kata-runtime" in line and ":" in line:
version = line.split(":", 1)[1].strip()
elif "commit" in line and ":" in line:
commit = line.split(":", 1)[1].strip()
elif "OCI" in line and ":" in line:
oci = line.split(":", 1)[1].strip()
# Try env --json for structured info (best-effort).
env_info: dict[str, Any] = {}
rc2, out2, err2 = _run(["kata-runtime", "env", "--json"], timeout=10)
if rc2 == 0 and out2.strip():
try:
env_info = json.loads(out2)
except json.JSONDecodeError:
pass
return {
"installed": True,
"version": version,
"commit": commit,
"oci": oci,
"env": env_info,
}
def _kata_check() -> tuple[bool, str]:
"""Run `kata-runtime check`. Returns (capable, message).
Per Kata 3.x docs: exit 0 = capable, exit 1 = not capable.
The text output is "System is capable of running Kata Containers"
on success, or an error message on failure.
"""
if not _have("kata-runtime"):
return False, "kata-runtime not installed"
rc, out, err = _run(["kata-runtime", "check"], timeout=15)
if rc == 0:
return True, out.strip() or "System is capable of running Kata Containers"
return False, (err.strip() or out.strip() or "kata-runtime check failed")
# ── Subcommand: version ────────────────────────────────────────────
def cmd_version(_args: list[str]) -> dict[str, Any]:
"""Return kata-runtime version info."""
return _kata_runtime_version()
# ── Subcommand: check ──────────────────────────────────────────────
def cmd_check(_args: list[str]) -> dict[str, Any]:
"""Run kata-runtime check. Returns {capable, message}."""
capable, msg = _kata_check()
return {"capable": capable, "message": msg}
# ── Subcommand: pxe-status ─────────────────────────────────────────
#
# Real PXE/TFTP status straight from systemctl and /srv/tftp.
def cmd_pxe_status(_args: list[str]) -> dict[str, Any]:
"""Return real PXE/TFTP boot status.
Checks:
- systemctl is-active dnsmasq
- test -d /srv/tftp
- test -w /srv/tftp
- ls /srv/tftp/pxelinux.cfg/ (list existing entries)
"""
# dnsmasq service status.
rc, out, _ = _run(["systemctl", "is-active", "dnsmasq"], timeout=10)
dnsmasq_running = (rc == 0 and out.strip() == "active")
# /srv/tftp directory existence + writability.
tftp_dir = Path("/srv/tftp")
tftp_exists = tftp_dir.is_dir()
tftp_writable = os.access(str(tftp_dir), os.W_OK) if tftp_exists else False
# Existing pxelinux.cfg entries.
entries: list[str] = []
if tftp_exists:
cfg_dir = tftp_dir / "pxelinux.cfg"
if cfg_dir.is_dir():
try:
entries = sorted([e.name for e in cfg_dir.iterdir() if e.is_file()])
except (PermissionError, OSError):
entries = []
return {
"dnsmasq_running": dnsmasq_running,
"tftp_dir_exists": tftp_exists,
"tftp_dir_writable": tftp_writable,
"tftp_dir": "/srv/tftp",
"pxelinux_entries": entries,
"pxelinux_dir": "/srv/tftp/pxelinux.cfg/",
}
# ── Subcommand: qcrows-list ────────────────────────────────────────
#
# QCrows kernel bundles are the kata kernel/module compilation
# surface; the listing reads the real filesystem.
def cmd_qcrows_list(_args: list[str]) -> list[dict[str, Any]]:
"""List QCrows kernel bundles in /usr/share/sysdeck/kata/qcrows/.
Each entry: {filename, path, size_bytes, mtime}.
Returns [] if the directory doesn't exist (empty state, NOT mock).
"""
if not QCROWS_DIR.is_dir():
return []
bundles: list[dict[str, Any]] = []
try:
for entry in sorted(QCROWS_DIR.iterdir()):
if not entry.is_file():
continue
if not entry.name.endswith((".qcrows", ".tar.gz", ".tgz")):
continue
stat = entry.stat()
bundles.append({
"filename": entry.name,
"path": str(entry),
"size_bytes": stat.st_size,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"mtime": stat.st_mtime,
})
except (PermissionError, OSError):
pass
return bundles
# ── Dispatch table ─────────────────────────────────────────────────
COMMANDS = {
"list": cmd_list,
"inspect": cmd_inspect,
"metrics": cmd_metrics,
"summary": cmd_summary,
"version": cmd_version,
"check": cmd_check,
"pxe-status": cmd_pxe_status,
"qcrows-list": cmd_qcrows_list,
}
def main(argv: list[str]) -> int:
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)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))