Files
ProxMenux/AppImage/scripts/audit_checks_pve.py
T

431 lines
14 KiB
Python

"""Proxmox-specific checks for Audit & Report.
Importing this module registers its checks. Everything here reads the
host and reports; nothing modifies it.
The checks are deliberately about configuration and posture rather than
transient load. A condition that resolves on its own as usage drops
belongs to the health monitor, which keeps its own catalogue and remains
the source of notifications.
"""
from __future__ import annotations
import re
from pathlib import Path
import audit_store
from audit_checks import (
AREA_BACKUP, AREA_GUESTS, AREA_SECURITY, AREA_STORAGE, AREA_SYSTEM,
register,
)
FAIL = audit_store.STATE_FAIL
WARN = audit_store.STATE_WARN
PASS = audit_store.STATE_PASS
NA = audit_store.STATE_NOT_APPLICABLE
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
def _parse_vzdump_jobs(text: str) -> list[dict]:
"""Split ``jobs.cfg`` into one entry per backup job.
A job opens with ``vzdump: <id>`` and its settings follow as indented
``key value`` lines. Values are kept verbatim; interpretation belongs
to the caller.
"""
jobs: list[dict] = []
current: dict | None = None
for line in text.splitlines():
if not line.strip():
continue
header = re.match(r"^vzdump:\s*(\S+)", line)
if header:
current = {"id": header.group(1)}
jobs.append(current)
continue
if current is None or not line[:1].isspace():
continue
parts = line.strip().split(None, 1)
if parts:
current[parts[0]] = parts[1] if len(parts) > 1 else ""
return jobs
def _pool_members(text: str) -> dict[str, set[int]]:
"""Map pool name to member guest identifiers from ``user.cfg``.
Pool entries are colon-separated: ``pool:<name>:<comment>:<vmids>:``.
"""
pools: dict[str, set[int]] = {}
for line in (text or "").splitlines():
if not line.startswith("pool:"):
continue
fields = line.split(":")
if len(fields) < 4:
continue
pools[fields[1]] = {int(x) for x in re.findall(r"\d+", fields[3])}
return pools
@register("backup.guest_coverage", AREA_BACKUP, "CRITICAL")
def _guest_coverage(ctx):
"""Guests that no enabled backup job includes.
A job selects guests by enumerating them (``vmid``), by taking every
guest (``all 1``), or by pool, and may subtract an ``exclude`` list.
A job carrying ``enabled 0`` selects nothing: it is defined but never
runs, which is precisely the situation this check exists to surface,
since a disabled job looks like coverage in the interface.
"""
guests = {}
for vmid in ctx.lxc_configs:
guests[vmid] = "lxc"
for vmid in ctx.qemu_configs:
guests[vmid] = "qemu"
if not guests:
return None
jobs = _parse_vzdump_jobs(ctx.vzdump_jobs)
if not jobs:
return {
"state": FAIL,
"summary_key": "noJobs",
"affected": [{"vmid": v, "type": t} for v, t in sorted(guests.items())],
"evidence": "no job definitions found in /etc/pve/jobs.cfg "
"or /etc/vzdump.cron",
}
pools = _pool_members(ctx.pve_user_cfg)
covered: set[int] = set()
considered: list[str] = []
skipped: list[str] = []
for job in jobs:
if job.get("enabled", "1").strip() == "0":
skipped.append(f"{job['id']} (disabled)")
continue
excluded = {int(x) for x in re.findall(r"\d+", job.get("exclude", ""))}
selected: set[int] = set()
if job.get("all", "0").strip() == "1":
selected = set(guests)
else:
selected |= {int(x) for x in re.findall(r"\d+", job.get("vmid", ""))}
for pool in re.split(r"[,\s]+", job.get("pool", "").strip()):
if pool:
selected |= pools.get(pool, set())
covered |= selected - excluded
considered.append(f"{job['id']} -> {sorted(selected - excluded) or 'nothing'}")
evidence = "enabled jobs:\n " + ("\n ".join(considered) or "(none)")
if skipped:
evidence += "\nignored jobs:\n " + "\n ".join(skipped)
uncovered = sorted(set(guests) - covered)
if not uncovered:
return {
"state": PASS,
"summary_key": "covered",
"summary_params": {"total": len(guests)},
"evidence": evidence,
}
return {
"state": FAIL,
"summary_key": "uncovered",
"summary_params": {"count": len(uncovered), "total": len(guests)},
"affected": [{"vmid": v, "type": guests[v]} for v in uncovered],
"evidence": evidence + f"\n\nuncovered: {uncovered}",
}
# ---------------------------------------------------------------------------
# System
# ---------------------------------------------------------------------------
@register("system.pending_reboot", AREA_SYSTEM, "WARNING")
def _pending_reboot(ctx):
"""Kernel or packages installed but not yet in effect."""
marker = Path("/var/run/reboot-required")
packages = ""
pkg_file = Path("/var/run/reboot-required.pkgs")
if pkg_file.exists():
try:
packages = pkg_file.read_text(errors="replace").strip()
except OSError:
packages = ""
rc, running = ctx.run(["uname", "-r"])
running = running.strip()
if not marker.exists():
return {
"state": PASS,
"summary_key": "none",
"evidence": f"running kernel: {running}",
}
return {
"state": WARN,
"summary_key": "pending",
"affected": [{"package": p} for p in packages.splitlines() if p],
"evidence": f"running kernel: {running}\n"
f"packages requesting a restart:\n{packages or '(not reported)'}",
}
@register("system.enterprise_repo_without_subscription", AREA_SYSTEM, "WARNING")
def _enterprise_repo(ctx):
"""Enterprise repository enabled on a host without a subscription.
The combination leaves ``apt update`` failing on every run, which
tends to be misread as a broken host rather than a licensing state.
"""
enabled = []
for path, text in ctx.apt_sources.items():
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("#") or not stripped:
continue
if "enterprise.proxmox.com" in stripped:
enabled.append((path, stripped))
if not enabled:
return {
"state": PASS,
"summary_key": "notEnabled",
}
rc, out = ctx.run(["pvesubscription", "get"])
status = ""
for line in (out or "").splitlines():
if line.lower().startswith("status:"):
status = line.split(":", 1)[1].strip().lower()
break
evidence = "\n".join(f"{p}: {l}" for p, l in enabled)
evidence += f"\n\npvesubscription status: {status or '(unavailable)'}"
if status in ("active", "new"):
return {
"state": PASS,
"summary_key": "subscribed",
"evidence": evidence,
}
return {
"state": WARN,
"summary_key": "unsubscribed",
"affected": [{"file": p, "line": l} for p, l in enabled],
"evidence": evidence,
}
# ---------------------------------------------------------------------------
# Guests
# ---------------------------------------------------------------------------
@register("guests.privileged_containers", AREA_GUESTS, "WARNING")
def _privileged_containers(ctx):
"""Containers running privileged.
A privileged container shares the host's user namespace, so a process
that escapes it is already root on the hypervisor. Proxmox creates
containers unprivileged by default; a container is privileged when
``unprivileged: 1`` is absent from its configuration.
"""
configs = ctx.lxc_configs
if not configs:
return None
privileged = []
for vmid, text in sorted(configs.items()):
if not re.search(r"^unprivileged:\s*1\s*$", text, re.M):
name = ""
m = re.search(r"^hostname:\s*(\S+)", text, re.M)
if m:
name = m.group(1)
privileged.append({"vmid": vmid, "name": name})
if not privileged:
return {
"state": PASS,
"summary_key": "allUnprivileged",
"summary_params": {"total": len(configs)},
}
listed = ", ".join(
f"{c['vmid']}{' (' + c['name'] + ')' if c['name'] else ''}"
for c in privileged
)
return {
"state": WARN,
"summary_key": "privileged",
"summary_params": {"count": len(privileged), "total": len(configs)},
"affected": privileged,
"evidence": f"privileged containers: {listed}",
}
@register("guests.qemu_without_agent", AREA_GUESTS, "INFO")
def _qemu_without_agent(ctx):
"""Virtual machines with no guest agent declared.
Without it the host cannot request a clean shutdown, quiesce the
filesystem for a snapshot, or report real disk usage.
"""
configs = ctx.qemu_configs
if not configs:
return None
missing = []
for vmid, text in sorted(configs.items()):
if not re.search(r"^agent:\s*(1|enabled=1)", text, re.M):
name = ""
m = re.search(r"^name:\s*(\S+)", text, re.M)
if m:
name = m.group(1)
missing.append({"vmid": vmid, "name": name})
if not missing:
return {
"state": PASS,
"summary_key": "allHaveAgent",
"summary_params": {"total": len(configs)},
}
listed = ", ".join(
f"{v['vmid']}{' (' + v['name'] + ')' if v['name'] else ''}"
for v in missing
)
return {
"state": WARN,
"summary_key": "missingAgent",
"summary_params": {"count": len(missing), "total": len(configs)},
"affected": missing,
"evidence": f"without agent: {listed}",
}
# ---------------------------------------------------------------------------
# Security
# ---------------------------------------------------------------------------
@register("security.host_firewall_enabled", AREA_SECURITY, "WARNING")
def _host_firewall(ctx):
"""Proxmox firewall enabled at datacenter and node level.
Both levels matter: the node rules are not applied while the
datacenter switch is off, so a node that looks configured can still
be filtering nothing.
"""
def enabled_in(path: Path) -> tuple[bool, str]:
try:
text = path.read_text(errors="replace")
except OSError:
return False, f"{path}: not present"
for line in text.splitlines():
if re.match(r"^\s*enable:\s*1\s*$", line):
return True, f"{path}: enable: 1"
return False, f"{path}: enable not set to 1"
dc_on, dc_note = enabled_in(Path("/etc/pve/firewall/cluster.fw"))
try:
node = Path("/etc/hostname").read_text().strip()
except OSError:
node = ""
node_path = Path(f"/etc/pve/nodes/{node}/host.fw") if node else None
node_on, node_note = (False, "node firewall file not resolved")
if node_path:
node_on, node_note = enabled_in(node_path)
evidence = f"{dc_note}\n{node_note}"
if dc_on and node_on:
return {
"state": PASS,
"summary_key": "bothEnabled",
"evidence": evidence,
}
if not dc_on:
return {
"state": WARN,
"summary_key": "datacenterOff",
"evidence": evidence,
}
return {
"state": WARN,
"summary_key": "nodeOff",
"evidence": evidence,
}
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
@register("storage.orphaned_volumes", AREA_STORAGE, "WARNING")
def _orphaned_volumes(ctx):
"""Disk images that no guest configuration references.
A volume survives when a guest is removed without its disks, or when
a restore leaves the previous copy behind. Nothing reports it and it
keeps occupying the pool.
Only storage that is not shared is examined. On shared storage a
volume may belong to a guest running on another node, which this node
cannot see, so flagging it would be wrong rather than merely noisy.
"""
known = set(ctx.lxc_configs) | set(ctx.qemu_configs)
if not known:
return None
candidates = [
s for s in ctx.storages
if str(s.get("shared", "0")).strip() != "1"
and any(c in (s.get("content") or "") for c in ("images", "rootdir"))
]
if not candidates:
return None
orphans: list[dict] = []
inspected: list[str] = []
for storage in candidates:
sid = storage["id"]
rc, out = ctx.run(["pvesm", "list", sid], timeout=15)
if rc != 0:
inspected.append(f"{sid}: not readable")
continue
count = 0
for line in (out or "").splitlines()[1:]:
fields = line.split()
if len(fields) < 5:
continue
volid, vmid_raw = fields[0], fields[-1]
if not vmid_raw.isdigit():
continue
count += 1
vmid = int(vmid_raw)
if vmid not in known:
orphans.append({"volume": volid, "vmid": vmid})
inspected.append(f"{sid}: {count} volume(s)")
shared_skipped = [
s["id"] for s in ctx.storages
if str(s.get("shared", "0")).strip() == "1"
]
evidence = "inspected:\n " + "\n ".join(inspected)
if shared_skipped:
evidence += ("\nskipped as shared (ownership not resolvable from this "
"node):\n " + ", ".join(shared_skipped))
if not orphans:
return {
"state": PASS,
"summary_key": "none",
"evidence": evidence,
}
return {
"state": WARN,
"summary_key": "found",
"summary_params": {"count": len(orphans)},
"affected": orphans,
"evidence": evidence + "\n\norphans:\n " + "\n ".join(
f"{o['volume']} (no config for {o['vmid']})" for o in orphans),
}