mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ not acceptable.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import copy
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
@@ -44,22 +48,30 @@ AREAS = (
|
||||
|
||||
SEVERITIES = ("OK", "INFO", "WARNING", "CRITICAL")
|
||||
|
||||
# Per-check wall-clock budget. A check that cannot answer within it is
|
||||
# recorded as not applicable rather than stalling the whole assessment.
|
||||
CHECK_TIMEOUT = 20
|
||||
# Shared deadline for all subprocesses in a check, not a fresh timeout
|
||||
# per device/storage. Exhaustion is unknown, never not applicable.
|
||||
CHECK_TIMEOUT = 30
|
||||
RUN_TIMEOUT = 300
|
||||
CATALOG_VERSION = 14
|
||||
|
||||
# A check that has to produce its own evidence — rather than read
|
||||
# evidence something else already produced — declares how long that
|
||||
# takes. The budget is still bounded by the run's own deadline.
|
||||
LYNIS_RUN_BUDGET = 240
|
||||
|
||||
|
||||
class Check:
|
||||
"""One registered assessment.
|
||||
|
||||
``evaluate`` receives the context and returns a dict with ``state``
|
||||
``evaluate`` receives the context and returns a dict with ``classification``
|
||||
and, optionally, ``summary``, ``affected``, ``evidence`` and
|
||||
``remediable_by``. Returning ``None`` marks the check as not
|
||||
applicable on this host.
|
||||
"""
|
||||
|
||||
def __init__(self, check_id: str, area: str, severity: str,
|
||||
evaluate: Callable[["AuditContext"], Optional[dict]]):
|
||||
evaluate: Callable[["AuditContext"], Optional[dict]],
|
||||
budget: int = CHECK_TIMEOUT):
|
||||
if area not in AREAS:
|
||||
raise ValueError(f"unknown area for {check_id}: {area}")
|
||||
if severity not in SEVERITIES:
|
||||
@@ -70,17 +82,20 @@ class Check:
|
||||
self.area = area
|
||||
self.severity = severity
|
||||
self.evaluate = evaluate
|
||||
self.budget = budget
|
||||
self.version = CATALOG_VERSION
|
||||
|
||||
|
||||
_REGISTRY: dict[str, Check] = {}
|
||||
|
||||
|
||||
def register(check_id: str, area: str, severity: str):
|
||||
def register(check_id: str, area: str, severity: str,
|
||||
budget: int = CHECK_TIMEOUT):
|
||||
"""Decorator registering a check under a stable identifier."""
|
||||
def wrap(fn):
|
||||
if check_id in _REGISTRY:
|
||||
raise ValueError(f"duplicate check identifier: {check_id}")
|
||||
_REGISTRY[check_id] = Check(check_id, area, severity, fn)
|
||||
_REGISTRY[check_id] = Check(check_id, area, severity, fn, budget)
|
||||
return fn
|
||||
return wrap
|
||||
|
||||
@@ -98,26 +113,91 @@ class AuditContext:
|
||||
|
||||
def __init__(self):
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._source_info = {}
|
||||
self._dependencies = {}
|
||||
self._sources_used = set()
|
||||
self._errors = {}
|
||||
self._check_deadline = float("inf")
|
||||
self._run_deadline = time.monotonic() + RUN_TIMEOUT
|
||||
|
||||
def begin_check(self, budget: int = CHECK_TIMEOUT):
|
||||
self._sources_used = set()
|
||||
self._check_deadline = min(time.monotonic() + budget, self._run_deadline)
|
||||
|
||||
def source(self, key, *, error=None):
|
||||
self._sources_used.add(key)
|
||||
self._source_info.setdefault(key, {"source": key, "collected_at": int(time.time())})
|
||||
if error:
|
||||
self._errors[key] = str(error)
|
||||
if key in self._errors:
|
||||
self._source_info[key]["error"] = self._errors[key]
|
||||
|
||||
def read(self, path, *, optional=False):
|
||||
def load():
|
||||
try:
|
||||
return Path(path).read_text(errors="replace")
|
||||
except FileNotFoundError:
|
||||
if optional:
|
||||
return ""
|
||||
raise
|
||||
return self._once(str(path), load) or ""
|
||||
|
||||
@property
|
||||
def node(self):
|
||||
return socket.gethostname().split(".")[0]
|
||||
|
||||
@property
|
||||
def policy(self):
|
||||
"""What has been declared about this host, or nothing declared.
|
||||
|
||||
Read once per assessment so every check judges against the same
|
||||
declaration, even if the file changes while a run is in progress.
|
||||
"""
|
||||
def load():
|
||||
import audit_policy
|
||||
value = audit_policy.load()
|
||||
if value.error:
|
||||
self.source("policy", error=value.error)
|
||||
return value
|
||||
return self._once("policy", load)
|
||||
|
||||
def _once(self, key: str, producer: Callable[[], Any]) -> Any:
|
||||
self.source(key)
|
||||
if key not in self._cache:
|
||||
parent_sources = self._sources_used
|
||||
self._sources_used = {key}
|
||||
try:
|
||||
self._cache[key] = producer()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
self._cache[key] = None
|
||||
self.source(key, error=exc)
|
||||
finally:
|
||||
self._dependencies[key] = self._sources_used - {key}
|
||||
parent_sources.update(self._sources_used)
|
||||
self._sources_used = parent_sources
|
||||
else:
|
||||
for dependency in self._dependencies.get(key, ()):
|
||||
self.source(dependency)
|
||||
return self._cache[key]
|
||||
|
||||
def run(self, cmd: list[str], timeout: int = 10) -> tuple[int, str]:
|
||||
def run(self, cmd: list[str], timeout: int = 10, allowed_codes=(0,)) -> tuple[int, str]:
|
||||
"""Run a read-only command, returning exit code and output."""
|
||||
key = f"cmd:{' '.join(cmd)}"
|
||||
key = "cmd:" + json.dumps(cmd)
|
||||
self.source(key)
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
try:
|
||||
remaining = min(timeout, self._check_deadline - time.monotonic(),
|
||||
self._run_deadline - time.monotonic())
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("assessment time budget exhausted")
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
timeout=remaining, env={**os.environ, "LC_ALL": "C", "LANG": "C"})
|
||||
result = (proc.returncode, (proc.stdout or "") + (proc.stderr or ""))
|
||||
except Exception as exc:
|
||||
result = (-1, str(exc))
|
||||
if result[0] not in allowed_codes:
|
||||
self.source(key, error=f"exit {result[0]}: {result[1][:500]}")
|
||||
self._cache[key] = result
|
||||
return result
|
||||
|
||||
@@ -128,11 +208,13 @@ class AuditContext:
|
||||
out: dict[int, str] = {}
|
||||
base = Path("/etc/pve/lxc")
|
||||
if not base.is_dir():
|
||||
self.source("lxc_configs", error="local PVE configuration directory unavailable")
|
||||
return out
|
||||
for path in base.glob("*.conf"):
|
||||
try:
|
||||
out[int(path.stem)] = path.read_text(errors="replace")
|
||||
except (OSError, ValueError):
|
||||
except (OSError, ValueError) as exc:
|
||||
self.source("lxc_configs", error=f"{path}: {exc}")
|
||||
continue
|
||||
return out
|
||||
return self._once("lxc_configs", load) or {}
|
||||
@@ -143,15 +225,31 @@ class AuditContext:
|
||||
out: dict[int, str] = {}
|
||||
base = Path("/etc/pve/qemu-server")
|
||||
if not base.is_dir():
|
||||
self.source("qemu_configs", error="local PVE configuration directory unavailable")
|
||||
return out
|
||||
for path in base.glob("*.conf"):
|
||||
try:
|
||||
out[int(path.stem)] = path.read_text(errors="replace")
|
||||
except (OSError, ValueError):
|
||||
except (OSError, ValueError) as exc:
|
||||
self.source("qemu_configs", error=f"{path}: {exc}")
|
||||
continue
|
||||
return out
|
||||
return self._once("qemu_configs", load) or {}
|
||||
|
||||
@property
|
||||
def cluster_configs(self):
|
||||
"""Local pmxcfs view only, to protect volumes referenced by other nodes."""
|
||||
def load():
|
||||
result = {}
|
||||
base = Path("/etc/pve/nodes")
|
||||
if not base.is_dir():
|
||||
raise OSError("cluster configuration view unavailable")
|
||||
for kind in ("lxc", "qemu-server"):
|
||||
for path in base.glob(f"*/{kind}/*.conf"):
|
||||
result[str(path)] = path.read_text(errors="replace")
|
||||
return result
|
||||
return self._once("cluster_configs", load) or {}
|
||||
|
||||
@property
|
||||
def apt_sources(self) -> dict[str, str]:
|
||||
"""Contents of the apt source files that define PVE repositories."""
|
||||
@@ -165,8 +263,10 @@ class AuditContext:
|
||||
for path in candidates:
|
||||
try:
|
||||
out[str(path)] = path.read_text(errors="replace")
|
||||
except OSError:
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
self.source("apt_sources", error=f"{path}: {exc}")
|
||||
return out
|
||||
return self._once("apt_sources", load) or {}
|
||||
|
||||
@@ -178,11 +278,86 @@ class AuditContext:
|
||||
for path in (Path("/etc/pve/jobs.cfg"), Path("/etc/vzdump.cron")):
|
||||
try:
|
||||
text += path.read_text(errors="replace") + "\n"
|
||||
except OSError:
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
self.source("vzdump_jobs", error=f"{path}: {exc}")
|
||||
return text
|
||||
return self._once("vzdump_jobs", load) or ""
|
||||
|
||||
def _run_lynis(self):
|
||||
"""Produce a Lynis report.
|
||||
|
||||
Returns the parsed report, whether this assessment produced it,
|
||||
and why it could not, so a check reports what actually happened
|
||||
rather than asserting a run that may never have started.
|
||||
"""
|
||||
from security_manager import (_find_lynis_cmd, get_lynis_audit_status,
|
||||
parse_lynis_report, run_lynis_audit)
|
||||
if not _find_lynis_cmd():
|
||||
return None, False, None
|
||||
|
||||
deadline = min(time.monotonic() + LYNIS_RUN_BUDGET, self._run_deadline)
|
||||
if not get_lynis_audit_status().get("running"):
|
||||
started, message = run_lynis_audit()
|
||||
if not started and "already running" not in (message or "").lower():
|
||||
reason = message or "Lynis could not be started"
|
||||
self.source("lynis:run", error=reason)
|
||||
return None, False, reason
|
||||
# A quick audit takes about a minute; the wait is bounded by the
|
||||
# budget and by the assessment's own deadline.
|
||||
while get_lynis_audit_status().get("running"):
|
||||
if time.monotonic() >= deadline:
|
||||
reason = "Lynis was still running when the time budget ran out"
|
||||
self.source("lynis:run", error=reason)
|
||||
return None, True, reason
|
||||
time.sleep(2)
|
||||
self.source("lynis:run")
|
||||
return parse_lynis_report(enrich_current=False), True, None
|
||||
|
||||
@property
|
||||
def lynis_report(self) -> Optional[dict]:
|
||||
"""The most recent Lynis audit, running one if there is none.
|
||||
|
||||
An assessment that reports "not verified" because nobody has
|
||||
opened the Security page yet is reporting on the Monitor, not on
|
||||
the host. Where Lynis is installed and has no usable report — or
|
||||
only the remains of an interrupted run — the audit is produced
|
||||
here, because that reading is what was asked for. Where Lynis is
|
||||
not installed there is nothing to report and the checks do not
|
||||
apply.
|
||||
|
||||
The run goes through Security's own entry point, which holds the
|
||||
lock that keeps two audits from starting at once, so an audit the
|
||||
user launched from that page is waited on rather than duplicated.
|
||||
"""
|
||||
def load():
|
||||
from security_manager import parse_lynis_report
|
||||
parsed = parse_lynis_report(enrich_current=False)
|
||||
ran, run_error = False, None
|
||||
if parsed is None or not parsed.get("is_complete"):
|
||||
produced, ran, run_error = self._run_lynis()
|
||||
if produced is not None:
|
||||
parsed = produced
|
||||
if parsed is None:
|
||||
return None
|
||||
source = next((p for p in (Path("/var/log/lynis-report.dat"),
|
||||
Path("/var/log/lynis-output.log")) if p.exists()), None)
|
||||
return {
|
||||
"mtime": source.stat().st_mtime if source else 0,
|
||||
"source": str(source), "version": parsed.get("lynis_version"),
|
||||
"warnings": parsed.get("warnings", []),
|
||||
"suggestions": parsed.get("suggestions", []),
|
||||
"hardening_index": parsed.get("hardening_index"),
|
||||
"complete": parsed.get("is_complete", False),
|
||||
# What the assessment itself did, so a check can say
|
||||
# whether it is reporting a stored result or one it
|
||||
# produced, and why a produced one is unusable.
|
||||
"produced_here": ran,
|
||||
"run_error": run_error,
|
||||
}
|
||||
return self._once("lynis_report", load)
|
||||
|
||||
@property
|
||||
def storages(self) -> list[dict]:
|
||||
"""Storage definitions from ``storage.cfg``.
|
||||
@@ -197,7 +372,7 @@ class AuditContext:
|
||||
try:
|
||||
text = Path("/etc/pve/storage.cfg").read_text(errors="replace")
|
||||
except OSError:
|
||||
return out
|
||||
raise
|
||||
current: Optional[dict] = None
|
||||
for line in text.splitlines():
|
||||
if not line.strip():
|
||||
@@ -221,77 +396,267 @@ class AuditContext:
|
||||
def load():
|
||||
try:
|
||||
return Path("/etc/pve/user.cfg").read_text(errors="replace")
|
||||
except OSError:
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return self._once("pve_user_cfg", load) or ""
|
||||
|
||||
@property
|
||||
def storage_snapshot(self):
|
||||
"""Reuse recent Monitor storage observations; one PVE metadata read otherwise.
|
||||
|
||||
Never invoke a mount, activate a volume, or connect to a remote host.
|
||||
A successful PVE resource query is not an end-to-end storage IO test.
|
||||
"""
|
||||
def load():
|
||||
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
|
||||
cache = copy.deepcopy(getattr(server, "_proxmox_storage_cache", {}))
|
||||
when = cache.get("time", 0)
|
||||
data = cache.get("data")
|
||||
if (isinstance(data, dict) and isinstance(data.get("storage"), list)
|
||||
and "error" not in data and 0 <= time.time() - when <= 120):
|
||||
return {"rows": data["storage"], "collected_at": when,
|
||||
"source": "Monitor storage cache", "units": "GiB"}
|
||||
rc, out = self.run(["pvesh", "get", "/cluster/resources", "--type", "storage",
|
||||
"--output-format", "json"], timeout=10)
|
||||
if rc != 0:
|
||||
raise RuntimeError("PVE storage resource metadata unavailable")
|
||||
resources = json.loads(out)
|
||||
if not isinstance(resources, list) or any(not isinstance(r, dict) for r in resources):
|
||||
raise ValueError("unrecognised storage resource metadata")
|
||||
rows = [{"name": r.get("storage"), "node": r.get("node"),
|
||||
"status": r.get("status", "unknown"), "total": r.get("maxdisk"),
|
||||
"used": r.get("disk"), "type": r.get("plugintype")}
|
||||
for r in resources if r.get("node") == self.node]
|
||||
return {"rows": rows, "collected_at": time.time(),
|
||||
"source": "PVE cluster resource metadata", "units": "bytes"}
|
||||
return self._once("storage_snapshot", load) or {}
|
||||
|
||||
def _block_devices(self) -> list[str]:
|
||||
"""Real disks, as the kernel lists them."""
|
||||
# zd* are ZFS volumes and dm-* device-mapper targets: guest
|
||||
# storage rather than hardware, with no SMART to read.
|
||||
skip = ("loop", "ram", "zram", "dm-", "md", "sr", "nbd", "fd", "zd")
|
||||
try:
|
||||
return sorted(d.name for d in Path("/sys/block").iterdir()
|
||||
if not d.name.startswith(skip))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
@property
|
||||
def monitor_snapshot(self):
|
||||
"""Copy existing Monitor data without triggering probes or importing Flask."""
|
||||
def load():
|
||||
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
|
||||
smart = copy.deepcopy(getattr(server, "_smart_result_cache", {}))
|
||||
# That cache is filled by whoever last opened the storage view,
|
||||
# so an assessment can find it empty and report nothing about
|
||||
# disks the interface is already showing wear for. Ask through
|
||||
# the Monitor's own accessor for what is missing: it serves a
|
||||
# sleeping disk from its last known values rather than waking
|
||||
# it, and reuses the same 30 s memoisation the interface hits.
|
||||
reader = getattr(server, "get_smart_data", None)
|
||||
if callable(reader):
|
||||
for device in self._block_devices():
|
||||
if device in smart:
|
||||
continue
|
||||
if time.monotonic() >= self._run_deadline:
|
||||
break
|
||||
try:
|
||||
data = reader(device)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
smart[device] = (time.time(), data)
|
||||
health_module = sys.modules.get("health_monitor")
|
||||
monitor = getattr(health_module, "health_monitor", None)
|
||||
health = copy.deepcopy(getattr(monitor, "cached_results", {}).get("_bg_detailed"))
|
||||
when = getattr(monitor, "last_check_times", {}).get("_bg_detailed")
|
||||
return {"smart": smart, "health": health, "health_collected_at": when}
|
||||
return self._once("monitor_snapshot", load) or {}
|
||||
|
||||
def metadata(self, checks):
|
||||
def local(path):
|
||||
try:
|
||||
return Path(path).read_text().strip()
|
||||
except OSError:
|
||||
return None
|
||||
version = (local(Path(__file__).resolve().parents[1] / "package.json") or
|
||||
local(Path(__file__).resolve().parents[2] / "package.json"))
|
||||
try:
|
||||
version = json.loads(version or "{}").get("version")
|
||||
except ValueError:
|
||||
version = None
|
||||
rc, pve = self.run(["pveversion"], timeout=5)
|
||||
return {"host": self.node, "kernel": os.uname().release,
|
||||
"boot_id": local("/proc/sys/kernel/random/boot_id"),
|
||||
"proxmenux_version": version, "pve_version": pve.strip() if rc == 0 else None,
|
||||
"catalog_version": CATALOG_VERSION, "scope": "local node; no guest interior probes",
|
||||
"checks": [c.check_id for c in checks],
|
||||
"policy": self.policy.describe(),
|
||||
"health_snapshot": self.monitor_snapshot.get("health"),
|
||||
"health_collected_at": self.monitor_snapshot.get("health_collected_at")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _classification_of(result: dict, check: "Check") -> str:
|
||||
"""The gravity of a result, from the result itself.
|
||||
|
||||
A check states the gravity of what it found. Where several objects
|
||||
were examined and each carries its own, the finding takes the gravest
|
||||
of them, because a report that says "observation" over an object it
|
||||
marked critical is wrong about the object it matters most for.
|
||||
"""
|
||||
per_object = [o.get("classification") for o in (result.get("affected") or [])
|
||||
if isinstance(o, dict) and o.get("classification")]
|
||||
declared = result.get("classification")
|
||||
values = ([declared] if declared else []) + per_object
|
||||
if result.get("incomplete"):
|
||||
values.append(audit_store.CLASS_UNVERIFIED)
|
||||
if any(v not in audit_store.CLASSIFICATIONS for v in values):
|
||||
values.append(audit_store.CLASS_UNVERIFIED)
|
||||
problems = [v for v in values if v in audit_store.CLASS_PROBLEMS]
|
||||
if problems:
|
||||
return audit_store.worst(problems)
|
||||
if audit_store.CLASS_UNVERIFIED in values:
|
||||
return audit_store.CLASS_UNVERIFIED
|
||||
if values:
|
||||
return audit_store.worst(values)
|
||||
if declared in audit_store.CLASSIFICATIONS:
|
||||
return declared
|
||||
# A check that has not been migrated to the scale is read on it from
|
||||
# what it used to return, so the catalogue keeps working while the
|
||||
# rules are revised one by one.
|
||||
return audit_store.classification_of(
|
||||
result.get("state", audit_store.STATE_UNKNOWN), check.severity)
|
||||
|
||||
|
||||
def run_assessment(profile: str = "full",
|
||||
only_areas: Optional[set[str]] = None) -> str:
|
||||
only_areas: Optional[set[str]] = None, *, run_id=None, progress=None) -> str:
|
||||
"""Evaluate every registered check and persist the result.
|
||||
|
||||
A check that raises is recorded as not applicable with the error kept
|
||||
A check that raises is recorded as unverified with the error kept
|
||||
as evidence. One faulty check must never abort an assessment: a
|
||||
partial report that says which check failed is more useful than no
|
||||
report at all.
|
||||
"""
|
||||
import audit_profiles
|
||||
if not audit_profiles.is_known(profile) or (
|
||||
only_areas is not None and (not only_areas or not only_areas <= set(AREAS))):
|
||||
raise ValueError("unsupported audit profile or areas")
|
||||
# The profile narrows the catalogue to its question; an explicit area
|
||||
# filter narrows it further within that.
|
||||
checks = audit_profiles.selected_checks(profile, registered_checks())
|
||||
if only_areas is not None:
|
||||
checks = [c for c in checks if c.area in only_areas]
|
||||
ctx = AuditContext()
|
||||
exceptions = audit_store.active_exceptions()
|
||||
run_id = audit_store.start_run(profile)
|
||||
metadata = ctx.metadata(checks)
|
||||
if run_id is None:
|
||||
run_id = audit_store.start_run(profile, metadata, len(checks))
|
||||
else:
|
||||
audit_store.update_run_metadata(run_id, metadata, len(checks))
|
||||
findings: list[dict[str, Any]] = []
|
||||
error: Optional[str] = None
|
||||
|
||||
try:
|
||||
for check in registered_checks():
|
||||
if only_areas and check.area not in only_areas:
|
||||
continue
|
||||
for check in checks:
|
||||
ctx.begin_check(check.budget)
|
||||
if progress:
|
||||
progress(run_id, len(findings), len(checks), check.check_id)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
if started >= ctx._run_deadline:
|
||||
raise TimeoutError("assessment time budget exhausted")
|
||||
result = check.evaluate(ctx)
|
||||
if result is not None and (not isinstance(result, dict)
|
||||
or not isinstance(result.get("affected", []), list)
|
||||
or any(not isinstance(obj, dict) for obj in result.get("affected", []))):
|
||||
raise ValueError("invalid check result")
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"state": audit_store.STATE_NOT_APPLICABLE,
|
||||
"classification": audit_store.CLASS_UNVERIFIED,
|
||||
"summary_key": "evaluationFailed",
|
||||
"evidence": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
if result is None:
|
||||
result = {"state": audit_store.STATE_NOT_APPLICABLE}
|
||||
# No prose here: this sentence reached a report that
|
||||
# exists in eight languages. The interface says it in the
|
||||
# reader's own, and a check with something specific to
|
||||
# say returns its own summary instead of None.
|
||||
result = {"classification": audit_store.CLASS_NOT_APPLICABLE}
|
||||
|
||||
state = result.get("state", audit_store.STATE_NOT_APPLICABLE)
|
||||
errors = [f"{k}: {ctx._errors[k]}" for k in ctx._sources_used if k in ctx._errors]
|
||||
if elapsed > check.budget:
|
||||
errors.append("check time budget exceeded")
|
||||
if errors:
|
||||
result["incomplete"] = True
|
||||
result["evidence"] = (result.get("evidence") or "") + "\n" + "\n".join(errors)
|
||||
# A source that could not be read cannot turn into a clean
|
||||
# result, but it must not soften one that already found a
|
||||
# problem either: what was found stands, what was missed is
|
||||
# named.
|
||||
if _classification_of(result, check) not in audit_store.CLASS_PROBLEMS:
|
||||
result.update(classification=audit_store.CLASS_UNVERIFIED,
|
||||
summary_key="evaluationFailed")
|
||||
|
||||
classification = _classification_of(result, check)
|
||||
# An accepted risk keeps its evidence and its declared
|
||||
# severity; only the state changes, so the report can still
|
||||
# show what was accepted and why it mattered.
|
||||
if state in (audit_store.STATE_FAIL, audit_store.STATE_WARN) \
|
||||
and check.check_id in exceptions:
|
||||
state = audit_store.STATE_ACCEPTED
|
||||
|
||||
evidence = result.get("evidence")
|
||||
if elapsed > CHECK_TIMEOUT:
|
||||
evidence = (evidence or "") + \
|
||||
f"\n[check exceeded its time budget: {elapsed:.1f}s]"
|
||||
|
||||
findings.append({
|
||||
# Names already collected by a check are display metadata, not a
|
||||
# reason to probe guests again or alter the finding's scope.
|
||||
for obj in result.get("affected") or []:
|
||||
vmid = obj.get("vmid")
|
||||
if vmid is None or obj.get("name"):
|
||||
continue
|
||||
for cache_key, field in (("lxc_configs", "hostname"), ("qemu_configs", "name")):
|
||||
config = (getattr(ctx, "_cache", {}).get(cache_key) or {}).get(vmid, "")
|
||||
match = re.search(r"^" + field + r":\s*(.+)$", config, re.MULTILINE)
|
||||
if match:
|
||||
obj["name"] = match.group(1).strip()
|
||||
break
|
||||
finding = {
|
||||
"check_id": check.check_id,
|
||||
"area": check.area,
|
||||
# Retained as the gravity the check can reach at worst,
|
||||
# which is what the catalogue advertises; the finding's own
|
||||
# gravity is its classification.
|
||||
"severity": check.severity,
|
||||
"state": state,
|
||||
"classification": classification,
|
||||
"summary_key": result.get("summary_key"),
|
||||
"summary_params": result.get("summary_params") or {},
|
||||
"affected": result.get("affected") or [],
|
||||
"evidence": evidence,
|
||||
"remediable_by": result.get("remediable_by"),
|
||||
})
|
||||
"raw_classification": classification,
|
||||
"check_version": check.version, "host": ctx.node,
|
||||
"collected_at": int(time.time()), "incomplete": result.get("incomplete", False),
|
||||
"observations": result.get("observations", []),
|
||||
"sources": [ctx._source_info[k] for k in sorted(ctx._sources_used)],
|
||||
}
|
||||
finding["scope"] = audit_store.finding_scope(finding)
|
||||
decision = exceptions.get(check.check_id)
|
||||
if (classification in audit_store.CLASS_PROBLEMS and decision
|
||||
and decision.get("scope") == finding["scope"] and not finding["incomplete"]
|
||||
and (decision.get("expires_at") is None or decision["expires_at"] > time.time())):
|
||||
finding.update(decision=audit_store.DECISION_ACCEPTED, exception=decision)
|
||||
findings.append(finding)
|
||||
except Exception as exc:
|
||||
error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
audit_store.record_findings(run_id, findings)
|
||||
audit_store.finish_run(run_id, checks_total=len(findings), error=error)
|
||||
audit_store.finish_run(
|
||||
run_id, checks_total=len(findings), error=error,
|
||||
partial=any(f["classification"] == audit_store.CLASS_UNVERIFIED
|
||||
or f.get("incomplete") for f in findings))
|
||||
if progress:
|
||||
progress(run_id, len(findings), len(checks), None)
|
||||
return run_id
|
||||
|
||||
|
||||
@@ -307,30 +672,37 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
``unchanged`` is kept so a report can state that the rest of the
|
||||
surface held steady rather than leaving it unaccounted for.
|
||||
"""
|
||||
failing = {audit_store.STATE_FAIL, audit_store.STATE_WARN}
|
||||
problems = set(audit_store.CLASS_PROBLEMS)
|
||||
base = {f["check_id"]: f for f in audit_store.get_findings(base_run)}
|
||||
other = {f["check_id"]: f for f in audit_store.get_findings(other_run)}
|
||||
|
||||
new, resolved, accepted, unchanged = [], [], [], []
|
||||
new, resolved, accepted, unchanged, unverified = [], [], [], [], []
|
||||
for check_id, current in other.items():
|
||||
previous = base.get(check_id)
|
||||
was = previous["state"] in failing if previous else False
|
||||
now = current["state"] in failing
|
||||
if now and not was:
|
||||
was = previous["classification"] in problems if previous else False
|
||||
now = current["classification"] in problems
|
||||
if current["classification"] in (audit_store.CLASS_UNVERIFIED,
|
||||
audit_store.CLASS_NOT_APPLICABLE) \
|
||||
or current.get("incomplete"):
|
||||
unverified.append(current)
|
||||
elif now and current.get("decision") == audit_store.DECISION_ACCEPTED:
|
||||
accepted.append(current)
|
||||
elif now and (not was or previous["classification"] != current["classification"]):
|
||||
new.append(current)
|
||||
elif was and not now:
|
||||
if current["state"] == audit_store.STATE_ACCEPTED:
|
||||
if current.get("decision") == audit_store.DECISION_ACCEPTED:
|
||||
accepted.append(current)
|
||||
else:
|
||||
elif current["classification"] in (audit_store.CLASS_CONFORMANT,
|
||||
audit_store.CLASS_OBSERVATION):
|
||||
resolved.append(current)
|
||||
elif previous and previous["state"] == current["state"]:
|
||||
elif previous and previous["classification"] == current["classification"]:
|
||||
unchanged.append(current)
|
||||
# A check present in the base run but absent from the later one was
|
||||
# retired between the two. It is reported as no longer assessed rather
|
||||
# than as resolved, since nothing verified that it stopped failing.
|
||||
retired = [
|
||||
previous for check_id, previous in base.items()
|
||||
if check_id not in other and previous["state"] in failing
|
||||
if check_id not in other and previous["classification"] in problems
|
||||
]
|
||||
|
||||
return {
|
||||
@@ -339,4 +711,5 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
"accepted": accepted,
|
||||
"unchanged": unchanged,
|
||||
"retired": retired,
|
||||
"unverified": unverified,
|
||||
}
|
||||
|
||||
+3492
-161
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,844 @@
|
||||
"""Structural inventory for Audit & Report.
|
||||
|
||||
Composes what the node is, what it holds and how those pieces connect,
|
||||
from the collectors the Monitor already runs. Nothing here probes the
|
||||
host: every section reads material that exists for another purpose.
|
||||
|
||||
The value of an inventory is not the lists but the relations between
|
||||
them. Enumerating interfaces and enumerating guests does not say which
|
||||
path a guest's traffic takes to the wire, nor which device a virtual
|
||||
disk actually lives on. Those chains are resolved here:
|
||||
|
||||
guest -> disk -> storage -> backing device
|
||||
guest -> interface -> bridge -> bond -> physical NIC
|
||||
guest -> backup job -> destination
|
||||
guest -> passthrough device -> IOMMU group -> controller
|
||||
node -> uplink -> measured latency to gateway and to the internet
|
||||
|
||||
Sections degrade independently. A source that cannot be read leaves its
|
||||
section marked unavailable with the reason, rather than dropping the
|
||||
whole inventory or presenting a gap as an empty result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
# Disk entries in a guest configuration: rootfs and mpN for containers,
|
||||
# the bus-prefixed keys for virtual machines.
|
||||
_DISK_KEYS = re.compile(
|
||||
r"^(rootfs|mp\d+|scsi\d+|virtio\d+|sata\d+|ide\d+|efidisk\d+|tpmstate\d+):",
|
||||
re.M)
|
||||
|
||||
|
||||
def _kv(text: str, key: str) -> str:
|
||||
m = re.search(rf"^{key}:\s*(.+)$", text, re.M)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _parse_options(value: str) -> dict[str, str]:
|
||||
"""Split a Proxmox option string into its comma-separated pairs."""
|
||||
out: dict[str, str] = {}
|
||||
for part in value.split(","):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _guest_disks(text: str) -> list[dict[str, Any]]:
|
||||
"""Disks declared by a guest, resolved to their storage.
|
||||
|
||||
A volume reads as ``storage:volume,option=value``. Anything without
|
||||
that shape is a passthrough or a raw device path and is reported as
|
||||
such rather than being attributed to a storage that does not own it.
|
||||
"""
|
||||
disks = []
|
||||
for line in text.splitlines():
|
||||
m = _DISK_KEYS.match(line)
|
||||
if not m:
|
||||
continue
|
||||
key = m.group(1)
|
||||
value = line.split(":", 1)[1].strip()
|
||||
head = value.split(",", 1)[0]
|
||||
options = _parse_options(value)
|
||||
entry: dict[str, Any] = {"slot": key, "size": options.get("size", "")}
|
||||
if ":" in head and not head.startswith("/"):
|
||||
storage, volume = head.split(":", 1)
|
||||
entry.update(storage=storage, volume=volume)
|
||||
else:
|
||||
entry.update(storage=None, volume=head, passthrough=True)
|
||||
disks.append(entry)
|
||||
return disks
|
||||
|
||||
|
||||
def _guest_interfaces(text: str) -> list[dict[str, Any]]:
|
||||
"""Network devices declared by a guest, with the bridge each uses."""
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^(net\d+):\s*(.+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
options = _parse_options(m.group(2))
|
||||
out.append({
|
||||
"slot": m.group(1),
|
||||
"name": options.get("name", ""),
|
||||
"bridge": options.get("bridge", ""),
|
||||
"mac": options.get("hwaddr") or options.get("macaddr", ""),
|
||||
"vlan": options.get("tag", ""),
|
||||
"model": next((p for p in m.group(2).split(",") if "=" not in p), ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _network_topology() -> Optional[dict[str, Any]]:
|
||||
"""Physical path from each bridge to the wire.
|
||||
|
||||
Built from the Monitor's own per-interface resolvers rather than from
|
||||
the aggregate network payload: ``get_bridge_info`` already reports a
|
||||
bridge's uplink and, when that uplink is a bond, its member
|
||||
interfaces. Absent those resolvers the chain is left unresolved
|
||||
rather than guessed.
|
||||
"""
|
||||
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
|
||||
bridge_info = getattr(server, "get_bridge_info", None)
|
||||
bond_info = getattr(server, "get_bond_info", None)
|
||||
if not callable(bridge_info):
|
||||
return None
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
# fwbr* bridges are created by Proxmox per guest interface to
|
||||
# attach its firewall. They are plumbing rather than part of the
|
||||
# host's configured topology, so the inventory omits them.
|
||||
names = sorted(p.name for p in Path("/sys/class/net").iterdir()
|
||||
if (p / "bridge").is_dir()
|
||||
and not p.name.startswith("fwbr"))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
bridges: dict[str, Any] = {}
|
||||
bonds: dict[str, Any] = {}
|
||||
for name in names:
|
||||
try:
|
||||
info = copy.deepcopy(bridge_info(name))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
uplink = info.get("physical_interface")
|
||||
vlan = info.get("vlan_interface")
|
||||
chain: list[dict[str, str]] = []
|
||||
if uplink:
|
||||
slaves = info.get("bond_slaves") or []
|
||||
if slaves:
|
||||
mode = ""
|
||||
if callable(bond_info):
|
||||
try:
|
||||
detail = bond_info(uplink) or {}
|
||||
mode = detail.get("mode_detail") or detail.get("mode", "")
|
||||
bonds[uplink] = detail
|
||||
except Exception:
|
||||
mode = ""
|
||||
chain.append({"kind": "bond", "id": uplink, "mode": mode})
|
||||
chain.extend({"kind": "nic", "id": s} for s in slaves)
|
||||
else:
|
||||
chain.append({"kind": "nic", "id": uplink})
|
||||
bridges[name] = {
|
||||
"parent": uplink,
|
||||
"vlan_interface": vlan,
|
||||
# Guest taps are excluded upstream, so members here are the
|
||||
# bridge's own ports rather than every attached guest.
|
||||
"members": info.get("members") or [],
|
||||
"uplink": chain,
|
||||
}
|
||||
return {"bridges": bridges, "bonds": bonds}
|
||||
|
||||
|
||||
def _latency(ctx) -> Optional[dict[str, Any]]:
|
||||
"""Network latency over the last day, from the Monitor's own history.
|
||||
|
||||
The Monitor samples the gateway and two public resolvers
|
||||
continuously. A report that describes a node's network without
|
||||
saying how it behaves is describing the wiring, not the network, so
|
||||
the measurements already on disk are carried here. Nothing is probed:
|
||||
the samples exist whether or not anyone asks for them.
|
||||
"""
|
||||
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
|
||||
history = getattr(server, "get_latency_history", None)
|
||||
if not callable(history):
|
||||
return None
|
||||
|
||||
targets = []
|
||||
for name in ("gateway", "cloudflare", "google"):
|
||||
try:
|
||||
result = history(name, "day") or {}
|
||||
except Exception:
|
||||
continue
|
||||
stats = result.get("stats") or {}
|
||||
samples = result.get("data") or []
|
||||
if not samples:
|
||||
continue
|
||||
losses = [s.get("packet_loss") for s in samples
|
||||
if isinstance(s.get("packet_loss"), (int, float))]
|
||||
targets.append({
|
||||
"target": name,
|
||||
"samples": len(samples),
|
||||
"min_ms": stats.get("min"),
|
||||
"avg_ms": stats.get("avg"),
|
||||
"max_ms": stats.get("max"),
|
||||
"current_ms": stats.get("current"),
|
||||
"packet_loss": round(sum(losses) / len(losses), 2) if losses else None,
|
||||
# Kept for the chart: one point per sample, oldest first.
|
||||
# The peak travels with the average because a chart of
|
||||
# averages alone contradicts the maximum in the table.
|
||||
"series": [{"t": s.get("timestamp"), "v": s.get("value"),
|
||||
"max": s.get("max")}
|
||||
for s in samples if s.get("value") is not None],
|
||||
})
|
||||
if not targets:
|
||||
return None
|
||||
return {"window": "day", "targets": targets}
|
||||
|
||||
|
||||
def _backup_map(ctx) -> dict[int, list[dict[str, str]]]:
|
||||
"""Which enabled backup job selects each guest, and where it writes."""
|
||||
import audit_checks_pve as pve
|
||||
|
||||
guests = set(ctx.lxc_configs) | set(ctx.qemu_configs)
|
||||
pools = pve._pool_members(ctx.pve_user_cfg)
|
||||
out: dict[int, list[dict[str, str]]] = {}
|
||||
for job in pve._parse_vzdump_jobs(ctx.vzdump_jobs):
|
||||
if job.get("enabled", "1").strip() == "0":
|
||||
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())
|
||||
entry = {"job": job["id"], "storage": job.get("storage", ""),
|
||||
"schedule": job.get("schedule", ""),
|
||||
"retention": job.get("prune-backups") or job.get("maxfiles", "")}
|
||||
for vmid in selected - excluded:
|
||||
out.setdefault(vmid, []).append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _identity(ctx) -> dict[str, Any]:
|
||||
rc, version = ctx.run(["pveversion"], timeout=10)
|
||||
rc2, kernel = ctx.run(["uname", "-r"], timeout=10)
|
||||
rc3, sub = ctx.run(["pvesubscription", "get"], timeout=10)
|
||||
status = ""
|
||||
for line in (sub or "").splitlines():
|
||||
if line.lower().startswith("status:"):
|
||||
status = line.split(":", 1)[1].strip()
|
||||
break
|
||||
cluster = ""
|
||||
try:
|
||||
from pathlib import Path
|
||||
corosync = Path("/etc/corosync/corosync.conf")
|
||||
if corosync.exists():
|
||||
m = re.search(r"cluster_name:\s*(\S+)",
|
||||
corosync.read_text(errors="replace"))
|
||||
cluster = m.group(1) if m else "unnamed"
|
||||
except OSError:
|
||||
cluster = ""
|
||||
return {
|
||||
"node": ctx.node,
|
||||
"pve_version": (version or "").strip().splitlines()[0] if version else "",
|
||||
"kernel": (kernel or "").strip(),
|
||||
"subscription": status or "unknown",
|
||||
"cluster": cluster or None,
|
||||
}
|
||||
|
||||
|
||||
def _storages(ctx) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for storage in ctx.storages:
|
||||
out.append({
|
||||
"id": storage.get("id"),
|
||||
"type": storage.get("type"),
|
||||
"content": storage.get("content", ""),
|
||||
"shared": str(storage.get("shared", "0")).strip() == "1",
|
||||
"path": storage.get("path") or storage.get("export") or "",
|
||||
"server": storage.get("server", ""),
|
||||
})
|
||||
return sorted(out, key=lambda s: s["id"] or "")
|
||||
|
||||
|
||||
def _guests(ctx, topology, backups) -> list[dict[str, Any]]:
|
||||
"""Every local guest with its disks, interfaces and protection resolved."""
|
||||
entries = []
|
||||
for kind, configs in (("lxc", ctx.lxc_configs), ("qemu", ctx.qemu_configs)):
|
||||
for vmid, text in configs.items():
|
||||
interfaces = _guest_interfaces(text)
|
||||
for nic in interfaces:
|
||||
if topology is None:
|
||||
# Distinguish a bridge with no uplink from one whose
|
||||
# path could not be read: the first is a fact about
|
||||
# the host, the second is a gap in this inventory.
|
||||
nic["uplink"] = None
|
||||
else:
|
||||
bridge = topology["bridges"].get(nic["bridge"])
|
||||
nic["uplink"] = bridge["uplink"] if bridge else []
|
||||
entries.append({
|
||||
"vmid": vmid,
|
||||
"type": kind,
|
||||
"name": _kv(text, "hostname") or _kv(text, "name"),
|
||||
"cores": _kv(text, "cores"),
|
||||
"memory": _kv(text, "memory"),
|
||||
"ostype": _kv(text, "ostype"),
|
||||
"onboot": _kv(text, "onboot") == "1",
|
||||
"tags": _kv(text, "tags"),
|
||||
"protected": _kv(text, "protection") == "1",
|
||||
"unprivileged": _kv(text, "unprivileged") == "1" if kind == "lxc" else None,
|
||||
"features": _kv(text, "features") if kind == "lxc" else None,
|
||||
"agent": bool(_kv(text, "agent")) if kind == "qemu" else None,
|
||||
"cpu": _kv(text, "cpu") if kind == "qemu" else None,
|
||||
"disks": _guest_disks(text),
|
||||
"interfaces": interfaces,
|
||||
"backups": backups.get(vmid, []),
|
||||
})
|
||||
return sorted(entries, key=lambda g: g["vmid"])
|
||||
|
||||
|
||||
def collect(ctx, sections: Optional[tuple] = None) -> dict[str, Any]:
|
||||
"""Assemble the inventory, keeping each section independent.
|
||||
|
||||
A section that raises is recorded with its error so the rest of the
|
||||
document still describes what could be read. An inventory that fails
|
||||
as a whole because one source was unavailable is less useful than one
|
||||
that says which part is missing.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
wanted = None if sections is None else set(sections)
|
||||
|
||||
def section(name, producer):
|
||||
# A section the profile did not ask for is absent rather than
|
||||
# empty, so a reader never takes an omission for a finding.
|
||||
if wanted is not None and name not in wanted:
|
||||
return
|
||||
try:
|
||||
out[name] = producer()
|
||||
except Exception as exc:
|
||||
out[name] = None
|
||||
errors[name] = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
topology = None
|
||||
try:
|
||||
topology = _network_topology()
|
||||
if topology is None:
|
||||
errors["network"] = ("the Monitor's network view is not reachable "
|
||||
"from this process, so bridge uplinks are "
|
||||
"unresolved")
|
||||
except Exception as exc:
|
||||
errors["network"] = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
backups: dict[int, list] = {}
|
||||
try:
|
||||
backups = _backup_map(ctx)
|
||||
except Exception as exc:
|
||||
errors["backup_map"] = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
section("identity", lambda: _identity(ctx))
|
||||
section("hardware", lambda: _hardware(ctx))
|
||||
section("cluster", lambda: _cluster(ctx))
|
||||
section("storages", lambda: _storages(ctx))
|
||||
section("guests", lambda: _guests(ctx, topology, backups))
|
||||
section("passthrough", lambda: _passthrough(ctx))
|
||||
section("applications", lambda: _applications(ctx))
|
||||
section("custom_links", _custom_links)
|
||||
section("proxmenux", lambda: _proxmenux(ctx))
|
||||
section("latency", lambda: _latency(ctx))
|
||||
if wanted is None or "network" in wanted:
|
||||
out["network"] = topology
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"collected_at": int(time.time()),
|
||||
"node": ctx.node,
|
||||
"sections": out,
|
||||
# Named so a reader can tell an empty section from an unread one.
|
||||
"unavailable": errors,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passthrough, applications and hardware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _iommu_groups() -> dict[str, str]:
|
||||
"""Map each PCI address to the IOMMU group that contains it.
|
||||
|
||||
A device can only be handed to a guest together with everything else
|
||||
in its group, so the group is what determines whether a passthrough
|
||||
is possible at all.
|
||||
"""
|
||||
from pathlib import Path
|
||||
out: dict[str, str] = {}
|
||||
base = Path("/sys/kernel/iommu_groups")
|
||||
if not base.is_dir():
|
||||
return out
|
||||
for group in base.iterdir():
|
||||
devices = group / "devices"
|
||||
if not devices.is_dir():
|
||||
continue
|
||||
for device in devices.iterdir():
|
||||
out[device.name] = group.name
|
||||
return out
|
||||
|
||||
|
||||
def _passthrough(ctx) -> list[dict[str, Any]]:
|
||||
"""PCI devices assigned to a guest, with their IOMMU group.
|
||||
|
||||
``hostpci`` may name a function (``0000:03:00.0``) or a whole device
|
||||
(``0000:03:00``). Both are reported as written and resolved against
|
||||
the groups, so a reader sees what was configured rather than a
|
||||
normalised form that no longer matches the configuration.
|
||||
"""
|
||||
groups = _iommu_groups()
|
||||
out = []
|
||||
for vmid, text in sorted(ctx.qemu_configs.items()):
|
||||
name = _kv(text, "name")
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^(hostpci\d+):\s*(.+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
value = m.group(2)
|
||||
address = value.split(",", 1)[0].strip()
|
||||
# A device written without its function covers every function
|
||||
# of that device, so the group is looked up through them.
|
||||
candidates = ([address] if address.count(".") else
|
||||
[f"{address}.{fn}" for fn in range(8)])
|
||||
found = {groups[c] for c in candidates if c in groups}
|
||||
out.append({
|
||||
"vmid": vmid,
|
||||
"guest": name,
|
||||
"slot": m.group(1),
|
||||
"address": address,
|
||||
"options": _parse_options(value),
|
||||
"iommu_groups": sorted(found) or None,
|
||||
"shared_group_devices": sorted(
|
||||
d for d, gid in groups.items()
|
||||
if gid in found and d not in candidates) or [],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _applications(ctx) -> list[dict[str, Any]]:
|
||||
"""Applications registered inside each container and their web links.
|
||||
|
||||
Read from the sidecars the App tab maintains, which is where a
|
||||
container's real purpose is recorded; the configuration alone only
|
||||
says how much memory it has.
|
||||
"""
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
base = Path("/etc/proxmenux/apps")
|
||||
out = []
|
||||
if not base.is_dir():
|
||||
return out
|
||||
for path in sorted(base.glob("*.json")):
|
||||
try:
|
||||
data = _json.loads(path.read_text(errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
vmid = data.get("vmid")
|
||||
for app in data.get("apps", []) or []:
|
||||
# Detection results live under `state`, separate from the
|
||||
# registration itself, and carry the moment they were taken.
|
||||
# A version that could not be detected is stored as null, so
|
||||
# the value is coerced rather than defaulted: a key present
|
||||
# with no value would otherwise pass a default straight through.
|
||||
state = app.get("state") or {}
|
||||
out.append({
|
||||
"vmid": vmid,
|
||||
"name": app.get("name") or "",
|
||||
"slug": app.get("helper_slug") or app.get("slug") or "",
|
||||
"installed_via": app.get("installed_via") or "",
|
||||
"version": state.get("installed_version") or "",
|
||||
"available": state.get("latest_version") or "",
|
||||
"update_available": bool(state.get("update_available")),
|
||||
"checked_at": state.get("checked_at") or "",
|
||||
"ports": [
|
||||
{"port": p.get("port"), "path": p.get("web_path", ""),
|
||||
"scheme": p.get("scheme", ""),
|
||||
"category": p.get("category", ""),
|
||||
"url": p.get("custom_url", "")}
|
||||
for p in (app.get("ports") or [])
|
||||
],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _custom_links() -> list[dict[str, Any]]:
|
||||
"""User-defined web links, including those pointing inside guests."""
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
try:
|
||||
data = _json.loads(
|
||||
Path("/etc/proxmenux/custom_links.json").read_text(errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
entries = data if isinstance(data, list) else data.get("links", [])
|
||||
return [{"name": e.get("name", ""), "url": e.get("url", ""),
|
||||
"category": e.get("category", ""), "vmid": e.get("vmid")}
|
||||
for e in entries if isinstance(e, dict)]
|
||||
|
||||
|
||||
def _memory_modules(ctx) -> dict[str, Any]:
|
||||
"""Populated and empty slots, so remaining capacity is visible.
|
||||
|
||||
dmidecode reports every slot the board has; a slot without a module
|
||||
carries the literal "No Module Installed" as its size.
|
||||
"""
|
||||
rc, out = ctx.run(["dmidecode", "-t", "memory"], timeout=15)
|
||||
devices: list[dict[str, str]] = []
|
||||
current: Optional[dict[str, str]] = None
|
||||
for line in (out or "").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped == "Memory Device":
|
||||
current = {}
|
||||
devices.append(current)
|
||||
continue
|
||||
if current is None or ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
current[key.strip()] = value.strip()
|
||||
|
||||
modules, empty = [], 0
|
||||
for dev in devices:
|
||||
size = dev.get("Size", "")
|
||||
if not size or size.lower().startswith("no module"):
|
||||
empty += 1
|
||||
continue
|
||||
modules.append({
|
||||
"locator": dev.get("Locator", ""),
|
||||
"size": size,
|
||||
"type": dev.get("Type", ""),
|
||||
"form_factor": dev.get("Form Factor", ""),
|
||||
"speed": dev.get("Configured Memory Speed") or dev.get("Speed", ""),
|
||||
"manufacturer": dev.get("Manufacturer", ""),
|
||||
"part_number": dev.get("Part Number", ""),
|
||||
})
|
||||
return {"slots": len(devices) or None, "populated": len(modules),
|
||||
"empty": empty, "modules": modules}
|
||||
|
||||
|
||||
def _lsblk_pairs(ctx) -> list[dict[str, str]]:
|
||||
"""lsblk key="value" output; model strings contain spaces."""
|
||||
rc, out = ctx.run(
|
||||
["lsblk", "-dn", "-P", "-b", "-o",
|
||||
"NAME,MODEL,SERIAL,SIZE,ROTA,TRAN,TYPE"], timeout=15)
|
||||
rows = []
|
||||
for line in (out or "").splitlines():
|
||||
fields = dict(re.findall(r'(\w+)="([^"]*)"', line))
|
||||
# zd* are ZFS volumes: guest disks the kernel exposes as block
|
||||
# devices. They are not hardware and report no SMART.
|
||||
if fields.get("TYPE") == "disk" and not fields.get("NAME", "").startswith("zd"):
|
||||
rows.append(fields)
|
||||
return rows
|
||||
|
||||
|
||||
def _disk_observations() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Recorded disk events, keyed by device.
|
||||
|
||||
The Monitor keeps these because a transient error that clears is
|
||||
still part of a disk's history: SMART reports the present state,
|
||||
the observation log reports what happened. A report that only shows
|
||||
the present state hides the pattern that precedes a failure.
|
||||
"""
|
||||
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
|
||||
store = getattr(server, "health_persistence", None)
|
||||
getter = getattr(store, "get_disk_observations", None)
|
||||
if getter is None:
|
||||
return {}
|
||||
try:
|
||||
records = getter() or []
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for record in records:
|
||||
device = (record.get("device_name") or "").replace("/dev/", "")
|
||||
if not device:
|
||||
continue
|
||||
grouped.setdefault(device, []).append({
|
||||
"type": record.get("error_type", ""),
|
||||
"severity": record.get("severity", ""),
|
||||
"count": record.get("occurrence_count", 0),
|
||||
"first_seen": record.get("first_occurrence"),
|
||||
"last_seen": record.get("last_occurrence"),
|
||||
"message": (record.get("raw_message") or "")[:400],
|
||||
})
|
||||
for entries in grouped.values():
|
||||
entries.sort(key=lambda e: e.get("last_seen") or 0, reverse=True)
|
||||
return grouped
|
||||
|
||||
|
||||
def _physical_disks(ctx) -> list[dict[str, Any]]:
|
||||
observations = _disk_observations()
|
||||
# The SMART cache is keyed by device, each entry a (collected_at, data)
|
||||
# pair as the Monitor stores it.
|
||||
smart = {}
|
||||
cached = (getattr(ctx, "monitor_snapshot", None) or {}).get("smart") or {}
|
||||
for device, value in cached.items():
|
||||
data = value[1] if isinstance(value, (list, tuple)) and len(value) == 2 else value
|
||||
if isinstance(data, dict):
|
||||
smart[str(device).replace("/dev/", "")] = data
|
||||
|
||||
disks = []
|
||||
for row in _lsblk_pairs(ctx):
|
||||
size = row.get("SIZE", "")
|
||||
name = row.get("NAME", "")
|
||||
health = smart.get(name) or {}
|
||||
disks.append({
|
||||
"name": name,
|
||||
"model": (row.get("MODEL") or "").strip(),
|
||||
"serial": (row.get("SERIAL") or "").strip(),
|
||||
"size_bytes": int(size) if size.isdigit() else None,
|
||||
"rotational": row.get("ROTA") == "1",
|
||||
"bus": (row.get("TRAN") or "").strip(),
|
||||
"health": health.get("smart_status"),
|
||||
"temperature": health.get("temperature"),
|
||||
"power_on_hours": health.get("power_on_hours"),
|
||||
"observations": observations.get(name, []),
|
||||
})
|
||||
return sorted(disks, key=lambda d: d["name"])
|
||||
|
||||
|
||||
def _network_adapters() -> list[dict[str, Any]]:
|
||||
"""Physical adapters only: an interface backed by a real device."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
return _Path(path).read_text(errors="replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
adapters = []
|
||||
try:
|
||||
entries = sorted(_Path("/sys/class/net").iterdir())
|
||||
except OSError:
|
||||
return adapters
|
||||
for iface in entries:
|
||||
device = iface / "device"
|
||||
if not device.exists():
|
||||
continue
|
||||
speed = read(iface / "speed")
|
||||
driver = ""
|
||||
try:
|
||||
driver = (device / "driver").resolve().name
|
||||
except OSError:
|
||||
pass
|
||||
pci = ""
|
||||
try:
|
||||
pci = device.resolve().name
|
||||
except OSError:
|
||||
pass
|
||||
adapters.append({
|
||||
"name": iface.name,
|
||||
"mac": read(iface / "address"),
|
||||
"state": read(iface / "operstate"),
|
||||
# An interface that is down reports -1, which is not a speed.
|
||||
"speed_mbps": int(speed) if speed.lstrip("-").isdigit()
|
||||
and int(speed) > 0 else None,
|
||||
"driver": driver,
|
||||
"pci": pci,
|
||||
})
|
||||
return adapters
|
||||
|
||||
|
||||
# Device classes worth naming in a report: what moves the storage and
|
||||
# what a guest could be given directly.
|
||||
_CONTROLLER_CLASSES = (
|
||||
"RAID bus controller", "Serial Attached SCSI controller",
|
||||
"SATA controller", "SCSI storage controller",
|
||||
"Non-Volatile memory controller", "Fibre Channel",
|
||||
"VGA compatible controller", "3D controller", "Display controller",
|
||||
"Ethernet controller", "Network controller",
|
||||
)
|
||||
|
||||
|
||||
def _controllers(ctx) -> list[dict[str, Any]]:
|
||||
rc, out = ctx.run(["lspci", "-D"], timeout=15)
|
||||
devices = []
|
||||
for line in (out or "").splitlines():
|
||||
if " " not in line:
|
||||
continue
|
||||
slot, rest = line.split(" ", 1)
|
||||
if ":" not in rest:
|
||||
continue
|
||||
klass, name = rest.split(":", 1)
|
||||
klass = klass.strip()
|
||||
if klass in _CONTROLLER_CLASSES:
|
||||
devices.append({"slot": slot, "class": klass, "name": name.strip()})
|
||||
return devices
|
||||
|
||||
|
||||
def _cluster(ctx) -> Optional[dict[str, Any]]:
|
||||
"""The cluster this node belongs to, or None when it stands alone.
|
||||
|
||||
Membership is read from corosync's own configuration; quorum state
|
||||
comes from pvecm, which reports what the node currently sees.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
conf = _Path("/etc/pve/corosync.conf")
|
||||
if not conf.exists():
|
||||
conf = _Path("/etc/corosync/corosync.conf")
|
||||
if not conf.exists():
|
||||
return None
|
||||
try:
|
||||
text = conf.read_text(errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
name = ""
|
||||
m = re.search(r"cluster_name:\s*(\S+)", text)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
|
||||
nodes = []
|
||||
for block in re.findall(r"node\s*{([^}]*)}", text):
|
||||
entry = {
|
||||
"name": _kv(block, r"\s*name") or _kv(block, r"\s*ring0_addr"),
|
||||
"nodeid": _kv(block, r"\s*nodeid"),
|
||||
"ring0_addr": _kv(block, r"\s*ring0_addr"),
|
||||
"ring1_addr": _kv(block, r"\s*ring1_addr") or None,
|
||||
}
|
||||
entry["local"] = entry["name"] == ctx.node
|
||||
nodes.append(entry)
|
||||
|
||||
quorate, expected, total = None, None, None
|
||||
rc, status = ctx.run(["pvecm", "status"], timeout=15, allowed_codes=(0, 2))
|
||||
for line in (status or "").splitlines():
|
||||
low = line.lower()
|
||||
if low.startswith("quorate:"):
|
||||
quorate = line.split(":", 1)[1].strip().lower() == "yes"
|
||||
elif low.startswith("expected votes:"):
|
||||
expected = line.split(":", 1)[1].strip()
|
||||
elif low.startswith("total votes:"):
|
||||
total = line.split(":", 1)[1].strip()
|
||||
|
||||
# pvecm lists the members it currently sees; a configured node absent
|
||||
# from that list is configured but not reachable right now.
|
||||
online = set()
|
||||
rc2, members = ctx.run(["pvecm", "nodes"], timeout=15, allowed_codes=(0, 2))
|
||||
for line in (members or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 3 and parts[0].isdigit():
|
||||
# The local node is marked with a trailing "(local)" token.
|
||||
online.add(parts[-2] if parts[-1] == "(local)" else parts[-1])
|
||||
if online:
|
||||
for node in nodes:
|
||||
node["online"] = node["name"] in online
|
||||
|
||||
return {"name": name or "unnamed", "nodes": sorted(nodes, key=lambda n: n["name"]),
|
||||
"quorate": quorate, "expected_votes": expected, "total_votes": total,
|
||||
"links": 2 if any(n.get("ring1_addr") for n in nodes) else 1}
|
||||
|
||||
|
||||
def _hardware(ctx) -> dict[str, Any]:
|
||||
"""System identity and processor, from data the host already exposes."""
|
||||
def dmi(field):
|
||||
rc, out = ctx.run(["dmidecode", "-s", field], timeout=10)
|
||||
value = (out or "").strip().splitlines()
|
||||
value = value[-1].strip() if value else ""
|
||||
# dmidecode returns these placeholders when a board ships without
|
||||
# the field populated; they are not identities.
|
||||
return "" if value.lower() in ("default string", "to be filled by o.e.m.",
|
||||
"not specified", "unknown") else value
|
||||
|
||||
cpu_model, sockets, cores, threads = "", 0, 0, 0
|
||||
physical: set[str] = set()
|
||||
rc, cpuinfo = ctx.run(["cat", "/proc/cpuinfo"], timeout=10)
|
||||
for line in (cpuinfo or "").splitlines():
|
||||
if line.startswith("model name") and not cpu_model:
|
||||
cpu_model = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("physical id"):
|
||||
physical.add(line.split(":", 1)[1].strip())
|
||||
elif line.startswith("processor"):
|
||||
threads += 1
|
||||
elif line.startswith("cpu cores") and not cores:
|
||||
cores = int(line.split(":", 1)[1].strip() or 0)
|
||||
sockets = len(physical) or 1
|
||||
|
||||
virt = ""
|
||||
if cpuinfo:
|
||||
if " vmx" in cpuinfo:
|
||||
virt = "vmx"
|
||||
elif " svm" in cpuinfo:
|
||||
virt = "svm"
|
||||
|
||||
return {
|
||||
"system": {"manufacturer": dmi("system-manufacturer"),
|
||||
"product": dmi("system-product-name"),
|
||||
"serial": dmi("system-serial-number")},
|
||||
"board": {"manufacturer": dmi("baseboard-manufacturer"),
|
||||
"product": dmi("baseboard-product-name")},
|
||||
"bios": {"vendor": dmi("bios-vendor"), "version": dmi("bios-version"),
|
||||
"date": dmi("bios-release-date")},
|
||||
"cpu": {"model": cpu_model, "sockets": sockets,
|
||||
"cores_per_socket": cores, "threads": threads,
|
||||
"virtualisation": virt or None},
|
||||
"memory_bytes": _host_memory(ctx),
|
||||
"memory": _memory_modules(ctx),
|
||||
"disks": _physical_disks(ctx),
|
||||
"adapters": _network_adapters(),
|
||||
"controllers": _controllers(ctx),
|
||||
"iommu_groups": len(set(_iommu_groups().values())) or None,
|
||||
}
|
||||
|
||||
|
||||
def _host_memory(ctx) -> int:
|
||||
rc, out = ctx.run(["cat", "/proc/meminfo"], timeout=10)
|
||||
for line in (out or "").splitlines():
|
||||
if line.startswith("MemTotal:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return int(parts[1]) * 1024
|
||||
return 0
|
||||
|
||||
|
||||
def _proxmenux(ctx) -> dict[str, Any]:
|
||||
"""What ProxMenux itself has applied to this host."""
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
|
||||
def load(path):
|
||||
try:
|
||||
return _json.loads(Path(path).read_text(errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
from post_install_versions import load_installed_tools
|
||||
installed = load_installed_tools()
|
||||
updates = load("/usr/local/share/proxmenux/updates_available.json") or {}
|
||||
tools = []
|
||||
for key in sorted(installed):
|
||||
value = installed[key]
|
||||
if not value.get("installed", False):
|
||||
continue
|
||||
version = value.get("version")
|
||||
tools.append({"key": key, "version": str(version) if version is not None else ""})
|
||||
return {
|
||||
"optimizations": tools,
|
||||
"pending_updates": [
|
||||
{"key": u.get("key"), "current": u.get("current_version"),
|
||||
"available": u.get("available_version")}
|
||||
for u in (updates.get("updates") or [])
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Declared policy for Audit & Report.
|
||||
|
||||
An assessment can see what a host does; it cannot see what the host is
|
||||
*for*. Whether a guest needs a backup, whether a service has to come back
|
||||
by itself after a reboot, whether a storage is essential or convenient —
|
||||
none of that is discoverable, and guessing at it is what turns an
|
||||
ordinary configuration into an alarm.
|
||||
|
||||
So the audit reports an absence it cannot interpret as an observation,
|
||||
and only calls it a warning once somebody has declared what was expected.
|
||||
Nothing here is required: a host with no policy at all still produces a
|
||||
complete report, just one that describes rather than judges.
|
||||
|
||||
The declaration lives in ``/usr/local/share/proxmenux/audit_policy.json``
|
||||
and is written by hand or by the interface. It is read, never inferred:
|
||||
if the file is missing, malformed or partial, every unstated question
|
||||
stays unstated.
|
||||
|
||||
A guest marked as exempt is not a risk somebody accepted. It is a guest
|
||||
outside the scope of the expectation, so it leaves the count entirely
|
||||
rather than appearing as something to justify.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import fcntl
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
POLICY_PATH = Path("/usr/local/share/proxmenux/audit_policy.json")
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# What a declaration can say about an expectation.
|
||||
REQUIRED = "required"
|
||||
NOT_REQUIRED = "not_required"
|
||||
UNSPECIFIED = "unspecified"
|
||||
|
||||
_EXPECTATIONS = (REQUIRED, NOT_REQUIRED, UNSPECIFIED)
|
||||
|
||||
# What a site can declare about the host itself, as opposed to about a
|
||||
# guest. Each is read as "is this expected here": `firewall: required`
|
||||
# expects the switch on, `ssh_root_login: not_required` expects that
|
||||
# access not to be available.
|
||||
HOST_EXPECTATIONS = ("firewall", "ssh_root_login")
|
||||
|
||||
# What a storage is for, which decides how gravely its loss reads.
|
||||
ROLE_ESSENTIAL = "essential"
|
||||
ROLE_OPTIONAL = "optional"
|
||||
ROLE_UNSPECIFIED = "unspecified"
|
||||
|
||||
_ROLES = (ROLE_ESSENTIAL, ROLE_OPTIONAL, ROLE_UNSPECIFIED)
|
||||
|
||||
# Thresholds a site may want to move. The defaults are the values the
|
||||
# checks used before policy existed, so a host without a declaration
|
||||
# behaves exactly as it did.
|
||||
DEFAULT_THRESHOLDS: dict[str, float] = {
|
||||
"storage_usage_percent": 90,
|
||||
"thin_pool_usage_percent": 90,
|
||||
"thin_overprovision_ratio": 2.0,
|
||||
"zfs_scrub_days": 35,
|
||||
"backup_fallback_days": 30,
|
||||
"backup_schedule_grace_ratio": 0.5,
|
||||
"certificate_expiry_days": 30,
|
||||
"memory_overcommit_ratio": 1.5,
|
||||
"disk_service_life_hours": 43800,
|
||||
"lynis_report_days": 30,
|
||||
"package_index_days": 7,
|
||||
"journal_usage_percent": 80,
|
||||
"filesystem_usage_percent": 90,
|
||||
"filesystem_inode_percent": 90,
|
||||
"disk_error_recent_days": 7,
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class PolicyConflict(ValueError):
|
||||
"""The declaration changed after the editor read it."""
|
||||
|
||||
|
||||
def _valid_number(value, name: str = "") -> bool:
|
||||
try:
|
||||
return (type(value) in (int, float) and math.isfinite(value)
|
||||
and value > 0 and (not name.endswith("_percent") or value <= 100))
|
||||
except OverflowError:
|
||||
return False
|
||||
|
||||
|
||||
class Policy:
|
||||
"""One reading of the declaration, answering only what it was told."""
|
||||
|
||||
def __init__(self, raw: Optional[dict] = None, source: str = "",
|
||||
error: Optional[str] = None, revision: str = "missing"):
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
self.source = source
|
||||
self.error = error
|
||||
self.revision = revision
|
||||
self.declared = bool(raw)
|
||||
self._guests = raw.get("guests") if isinstance(raw.get("guests"), dict) else {}
|
||||
self._storages = raw.get("storages") if isinstance(raw.get("storages"), dict) else {}
|
||||
self._defaults = raw.get("defaults") if isinstance(raw.get("defaults"), dict) else {}
|
||||
self._host = raw.get("host") if isinstance(raw.get("host"), dict) else {}
|
||||
thresholds = raw.get("thresholds") if isinstance(raw.get("thresholds"), dict) else {}
|
||||
self._thresholds = {}
|
||||
for name, value in thresholds.items():
|
||||
# A malformed threshold falls back to the default rather than
|
||||
# silently disabling the check it belongs to.
|
||||
if name in DEFAULT_THRESHOLDS and _valid_number(value, name):
|
||||
self._thresholds[name] = float(value)
|
||||
|
||||
# -- guests ------------------------------------------------------
|
||||
|
||||
def _guest(self, vmid) -> dict:
|
||||
entry = self._guests.get(str(vmid))
|
||||
return entry if isinstance(entry, dict) else {}
|
||||
|
||||
def expectation(self, vmid, name: str) -> str:
|
||||
"""Whether something is expected of a guest, as declared.
|
||||
|
||||
Falls back to the site default for that expectation, and to
|
||||
``unspecified`` when neither says anything.
|
||||
"""
|
||||
value = self._guest(vmid).get(name)
|
||||
if value not in _EXPECTATIONS:
|
||||
value = self._defaults.get(name)
|
||||
return value if value in _EXPECTATIONS else UNSPECIFIED
|
||||
|
||||
def backup_required(self, vmid) -> str:
|
||||
return self.expectation(vmid, "backup")
|
||||
|
||||
def autostart_required(self, vmid) -> str:
|
||||
return self.expectation(vmid, "autostart")
|
||||
|
||||
def guest_note(self, vmid) -> str:
|
||||
note = self._guest(vmid).get("note")
|
||||
return note if isinstance(note, str) else ""
|
||||
|
||||
def recovery_objective_hours(self, vmid) -> Optional[float]:
|
||||
"""How old a guest's newest backup may be before it is a warning.
|
||||
|
||||
Declared per guest because it is a property of the workload, not
|
||||
of the schedule that happens to protect it.
|
||||
"""
|
||||
value = self._guest(vmid).get("recovery_objective_hours")
|
||||
if value is None:
|
||||
value = self._defaults.get("recovery_objective_hours")
|
||||
return float(value) if _valid_number(value) else None
|
||||
|
||||
# -- the host itself ---------------------------------------------
|
||||
|
||||
def host_expectation(self, name: str) -> str:
|
||||
"""What the site declares about the host's own configuration.
|
||||
|
||||
Kept apart from ``defaults``, which are per-guest fallbacks. The
|
||||
vocabulary is the same one the guest expectations use, read the
|
||||
same way: ``ssh_root_login: not_required`` says that access is
|
||||
not meant to be available here, and ``firewall: required`` says
|
||||
the switch is meant to be on. Undeclared means the check states
|
||||
the fact and does not judge it.
|
||||
"""
|
||||
value = self._host.get(name)
|
||||
return value if value in _EXPECTATIONS else UNSPECIFIED
|
||||
|
||||
def exempt_guests(self, name: str) -> set:
|
||||
"""Guests explicitly declared as not needing something."""
|
||||
return {vmid for vmid, entry in self._guests.items()
|
||||
if isinstance(entry, dict) and entry.get(name) == NOT_REQUIRED}
|
||||
|
||||
# -- storages ----------------------------------------------------
|
||||
|
||||
def storage_role(self, storage_id: str) -> str:
|
||||
entry = self._storages.get(storage_id)
|
||||
role = entry.get("role") if isinstance(entry, dict) else None
|
||||
if role not in _ROLES:
|
||||
role = self._defaults.get("storage_role")
|
||||
return role if role in _ROLES else ROLE_UNSPECIFIED
|
||||
|
||||
# -- thresholds --------------------------------------------------
|
||||
|
||||
def threshold(self, name: str) -> float:
|
||||
if name in self._thresholds:
|
||||
return self._thresholds[name]
|
||||
return float(DEFAULT_THRESHOLDS[name])
|
||||
|
||||
def is_default(self, name: str) -> bool:
|
||||
"""Whether a threshold is the shipped value or a declared one."""
|
||||
return name not in self._thresholds
|
||||
|
||||
# -- reporting ---------------------------------------------------
|
||||
|
||||
def describe(self) -> dict[str, Any]:
|
||||
"""What the report says about the policy it applied."""
|
||||
return {
|
||||
"declared": self.declared,
|
||||
"source": self.source or str(POLICY_PATH),
|
||||
"guests_declared": len(self._guests),
|
||||
"storages_declared": len(self._storages),
|
||||
"thresholds_declared": sorted(self._thresholds),
|
||||
"host_declared": sorted(k for k in self._host if k in HOST_EXPECTATIONS),
|
||||
"error": self.error,
|
||||
"revision": self.revision,
|
||||
}
|
||||
|
||||
|
||||
def load(path: Path = POLICY_PATH) -> Policy:
|
||||
"""Read one complete snapshot of the small declaration file.
|
||||
|
||||
An unreadable or malformed file is reported as an error and treated as
|
||||
no declaration at all. Falling back to an assumed policy would be
|
||||
worse than having none: it would judge the host against expectations
|
||||
nobody set.
|
||||
"""
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return Policy(source=str(path))
|
||||
except OSError as exc:
|
||||
return Policy(source=str(path), error=f"{type(exc).__name__}: {exc}")
|
||||
revision = hashlib.sha256(content).hexdigest()
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
_clean(raw)
|
||||
return Policy(raw, source=str(path), revision=revision)
|
||||
except (ValueError, UnicodeError, OverflowError) as exc:
|
||||
return Policy(source=str(path), error=f"{type(exc).__name__}: {exc}",
|
||||
revision=revision)
|
||||
|
||||
|
||||
def _clean(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("the declaration must be an object")
|
||||
|
||||
cleaned: dict[str, Any] = {"version": SCHEMA_VERSION,
|
||||
"updated_at": int(time.time())}
|
||||
|
||||
guests = raw.get("guests", {})
|
||||
if not isinstance(guests, dict):
|
||||
raise ValueError("guests must be an object keyed by VMID")
|
||||
kept_guests: dict[str, dict] = {}
|
||||
for vmid, entry in guests.items():
|
||||
if not str(vmid).isdigit() or not isinstance(entry, dict):
|
||||
raise ValueError(f"invalid guest declaration: {vmid}")
|
||||
kept: dict[str, Any] = {}
|
||||
for name in ("backup", "autostart"):
|
||||
value = entry.get(name)
|
||||
if value in _EXPECTATIONS:
|
||||
kept[name] = value
|
||||
elif value is not None:
|
||||
raise ValueError(f"invalid expectation for guest {vmid}: {name}={value}")
|
||||
rpo = entry.get("recovery_objective_hours")
|
||||
if rpo is not None:
|
||||
if not _valid_number(rpo):
|
||||
raise ValueError(f"invalid recovery objective for guest {vmid}: {rpo}")
|
||||
kept["recovery_objective_hours"] = float(rpo)
|
||||
note = entry.get("note")
|
||||
if isinstance(note, str) and note.strip():
|
||||
kept["note"] = note.strip()[:500]
|
||||
if kept:
|
||||
kept_guests[str(vmid)] = kept
|
||||
cleaned["guests"] = kept_guests
|
||||
|
||||
storages = raw.get("storages", {})
|
||||
if not isinstance(storages, dict):
|
||||
raise ValueError("storages must be an object keyed by storage id")
|
||||
kept_storages: dict[str, dict] = {}
|
||||
for storage_id, entry in storages.items():
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"invalid storage declaration: {storage_id}")
|
||||
role = entry.get("role")
|
||||
if role in _ROLES:
|
||||
kept_storages[str(storage_id)] = {"role": role}
|
||||
elif role is not None:
|
||||
raise ValueError(f"invalid role for storage {storage_id}: {role}")
|
||||
cleaned["storages"] = kept_storages
|
||||
|
||||
thresholds = raw.get("thresholds", {})
|
||||
if not isinstance(thresholds, dict):
|
||||
raise ValueError("thresholds must be an object")
|
||||
kept_thresholds: dict[str, float] = {}
|
||||
for name, value in thresholds.items():
|
||||
if name not in DEFAULT_THRESHOLDS:
|
||||
raise ValueError(f"unknown threshold: {name}")
|
||||
if not _valid_number(value, name):
|
||||
raise ValueError(f"invalid value for {name}: {value}")
|
||||
kept_thresholds[name] = float(value)
|
||||
cleaned["thresholds"] = kept_thresholds
|
||||
|
||||
defaults = raw.get("defaults", {})
|
||||
if not isinstance(defaults, dict):
|
||||
raise ValueError("defaults must be an object")
|
||||
kept_defaults: dict[str, Any] = {}
|
||||
for name in ("backup", "autostart"):
|
||||
if defaults.get(name) in _EXPECTATIONS:
|
||||
kept_defaults[name] = defaults[name]
|
||||
elif defaults.get(name) is not None:
|
||||
raise ValueError(f"invalid default expectation: {name}")
|
||||
if defaults.get("storage_role") in _ROLES:
|
||||
kept_defaults["storage_role"] = defaults["storage_role"]
|
||||
elif defaults.get("storage_role") is not None:
|
||||
raise ValueError("invalid default storage role")
|
||||
if defaults.get("recovery_objective_hours") is not None:
|
||||
if not _valid_number(defaults["recovery_objective_hours"]):
|
||||
raise ValueError("invalid default recovery objective")
|
||||
kept_defaults["recovery_objective_hours"] = float(
|
||||
defaults["recovery_objective_hours"])
|
||||
cleaned["defaults"] = kept_defaults
|
||||
|
||||
host = raw.get("host", {})
|
||||
if not isinstance(host, dict):
|
||||
raise ValueError("host must be an object")
|
||||
kept_host: dict[str, Any] = {}
|
||||
for name in HOST_EXPECTATIONS:
|
||||
if host.get(name) in _EXPECTATIONS:
|
||||
kept_host[name] = host[name]
|
||||
elif host.get(name) is not None:
|
||||
raise ValueError(f"invalid host expectation: {name}")
|
||||
cleaned["host"] = kept_host
|
||||
return cleaned
|
||||
|
||||
|
||||
def save(raw: dict, path: Path = POLICY_PATH,
|
||||
expected_revision: Optional[str] = None) -> Policy:
|
||||
"""Validate and atomically replace a declaration, rejecting stale editors.
|
||||
|
||||
The process lock and flock cover revision comparison and replacement.
|
||||
Each writer owns a private 0600 temporary file in the target directory.
|
||||
"""
|
||||
cleaned = _clean(raw)
|
||||
content = json.dumps(cleaned, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _lock:
|
||||
lock_fd = os.open(str(path) + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
|
||||
with os.fdopen(lock_fd, "a") as lock_file:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||
current = load(path)
|
||||
if expected_revision is not None and current.revision != expected_revision:
|
||||
raise PolicyConflict("The declaration changed in another session; reload before saving.")
|
||||
if current.error:
|
||||
raise ValueError(current.error)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8",
|
||||
dir=path.parent, prefix=".audit-policy-",
|
||||
delete=False) as handle:
|
||||
temporary = Path(handle.name)
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return load(path)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Report profiles for Audit & Report.
|
||||
|
||||
A profile answers one question, so it selects the checks and the
|
||||
inventory sections that bear on it. The alternative — always producing
|
||||
everything — leaves the reader to find the relevant part, and is how a
|
||||
report grows section by section until nobody reads it.
|
||||
|
||||
Profiles are declared as data rather than as code so the backend and the
|
||||
interface work from the same definition, and so adding a check does not
|
||||
require revisiting every profile: a profile names areas, and only names
|
||||
individual checks when it needs one that lives elsewhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# Every inventory section the composer can produce. A profile lists the
|
||||
# subset its question needs.
|
||||
ALL_SECTIONS = (
|
||||
"identity", "cluster", "hardware", "network", "latency", "storages", "guests",
|
||||
"passthrough", "applications", "custom_links", "proxmenux",
|
||||
)
|
||||
|
||||
PROFILES: dict[str, dict[str, Any]] = {
|
||||
# The whole picture. What an assessment produces when no narrower
|
||||
# question has been asked.
|
||||
"full": {
|
||||
"areas": None, # None means every area
|
||||
"include": (),
|
||||
"sections": ALL_SECTIONS,
|
||||
},
|
||||
|
||||
# Everything is assessed and almost nothing is printed. The reader
|
||||
# of this one is deciding what to do in the next few minutes, so it
|
||||
# carries the findings that ask for a decision and the readings that
|
||||
# could not be taken, and leaves out the inventory, the diagrams and
|
||||
# the annex. Scope stays full deliberately: a short report that
|
||||
# skipped checks would be quick and untrustworthy.
|
||||
"diagnostic": {
|
||||
"areas": None,
|
||||
"include": (),
|
||||
"sections": ("identity",),
|
||||
"brief": True,
|
||||
},
|
||||
|
||||
# Describes the node without judging it. Runs no checks, so it is
|
||||
# available on a host that has never been assessed.
|
||||
"inventory": {
|
||||
"areas": (), # empty means no checks
|
||||
"include": (),
|
||||
"sections": ALL_SECTIONS,
|
||||
},
|
||||
|
||||
# Exposure and access. Container privilege and the enterprise
|
||||
# repository sit in other areas but bear on the same question.
|
||||
"security": {
|
||||
"areas": ("security",),
|
||||
"include": (
|
||||
"guests.privileged_containers",
|
||||
"system.security_updates",
|
||||
"system.enterprise_repo_without_subscription",
|
||||
"system.update_chain",
|
||||
),
|
||||
"sections": ("identity", "cluster", "network", "latency", "guests"),
|
||||
},
|
||||
|
||||
# Whether guests are protected, and whether the protection is real.
|
||||
# Storage is included because a destination that cannot be reached
|
||||
# accepts no backup.
|
||||
"backup": {
|
||||
"areas": ("backup",),
|
||||
"include": ("storage.connected_storage", "system.notification_delivery"),
|
||||
"sections": ("identity", "cluster", "guests", "storages"),
|
||||
},
|
||||
|
||||
# Room to grow and the age of what it grows on.
|
||||
"capacity": {
|
||||
"areas": ("storage", "hardware"),
|
||||
"include": ("system.memory_overcommit", "system.journal_size",
|
||||
"system.swap_configured", "system.filesystem_capacity"),
|
||||
"sections": ("identity", "cluster", "hardware", "storages", "guests"),
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_PROFILE = "full"
|
||||
|
||||
|
||||
def is_known(profile: str) -> bool:
|
||||
return profile in PROFILES
|
||||
|
||||
|
||||
def selected_checks(profile: str, checks) -> list:
|
||||
"""Checks a profile runs, from the registered catalogue.
|
||||
|
||||
``areas`` of ``None`` selects everything and an empty tuple selects
|
||||
nothing, which is what lets the inventory profile produce a document
|
||||
without assessing the host.
|
||||
"""
|
||||
spec = PROFILES.get(profile) or PROFILES[DEFAULT_PROFILE]
|
||||
areas = spec["areas"]
|
||||
include = set(spec["include"])
|
||||
if areas is None:
|
||||
return list(checks)
|
||||
areas = set(areas)
|
||||
return [c for c in checks if c.area in areas or c.check_id in include]
|
||||
|
||||
|
||||
def sections(profile: str) -> tuple:
|
||||
spec = PROFILES.get(profile) or PROFILES[DEFAULT_PROFILE]
|
||||
return tuple(spec["sections"])
|
||||
|
||||
|
||||
def describe() -> list[dict[str, Any]]:
|
||||
"""Profile catalogue for the interface, without any host data."""
|
||||
return [
|
||||
{
|
||||
"id": name,
|
||||
"areas": None if spec["areas"] is None else list(spec["areas"]),
|
||||
"include": list(spec["include"]),
|
||||
"sections": list(spec["sections"]),
|
||||
"runs_checks": spec["areas"] != (),
|
||||
"brief": bool(spec.get("brief")),
|
||||
}
|
||||
for name, spec in PROFILES.items()
|
||||
]
|
||||
+305
-26
@@ -19,6 +19,9 @@ and is stored verbatim.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@@ -28,22 +31,147 @@ from typing import Any, Optional
|
||||
|
||||
DB_PATH = Path("/usr/local/share/proxmenux/audit.db")
|
||||
|
||||
# Result of a check within one run. Severity is what the check declares
|
||||
# for a failure; state is what actually happened this time.
|
||||
# What a check concluded, on one scale.
|
||||
#
|
||||
# Severity used to be declared per check and state per run, which meant a
|
||||
# storage at 90% capacity was labelled "critical" because the check that
|
||||
# found it is the one that can also find an unreachable storage. Gravity
|
||||
# belongs to the situation, so the check now returns it with the result,
|
||||
# and it may differ between the objects one check reports on.
|
||||
#
|
||||
# The scale is deliberately short, and each step says what it takes to
|
||||
# earn it:
|
||||
#
|
||||
# critical an interruption or an urgent threat to availability,
|
||||
# integrity or recoverability, backed by evidence
|
||||
# warning a verified degradation, an expected protection that is
|
||||
# absent, or a declared policy that is not met
|
||||
# observation a configuration, a limit or planning information; it
|
||||
# does not demonstrate a problem and is not counted as one
|
||||
# conformant the criterion was verified and is met
|
||||
# unverified not enough information to conclude; not a fault
|
||||
# not_applicable nothing on this host to evaluate
|
||||
CLASS_CRITICAL = "critical"
|
||||
CLASS_WARNING = "warning"
|
||||
CLASS_OBSERVATION = "observation"
|
||||
CLASS_CONFORMANT = "conformant"
|
||||
CLASS_UNVERIFIED = "unverified"
|
||||
CLASS_NOT_APPLICABLE = "not_applicable"
|
||||
|
||||
CLASSIFICATIONS = (CLASS_CRITICAL, CLASS_WARNING, CLASS_OBSERVATION,
|
||||
CLASS_CONFORMANT, CLASS_UNVERIFIED, CLASS_NOT_APPLICABLE)
|
||||
|
||||
# Worst first: a finding takes the gravity of its gravest object.
|
||||
CLASS_ORDER = {name: i for i, name in enumerate(CLASSIFICATIONS)}
|
||||
|
||||
# Only these two are problems. An observation is information, and
|
||||
# unverified is an absence of information; counting either as a problem is
|
||||
# what made ordinary configurations look like faults.
|
||||
CLASS_PROBLEMS = (CLASS_CRITICAL, CLASS_WARNING)
|
||||
|
||||
# What the reader decided about a finding, kept apart from what the
|
||||
# assessment concluded. A technical result does not change because someone
|
||||
# accepted it; only the decision layered over it does.
|
||||
DECISION_NONE = ""
|
||||
DECISION_ACCEPTED = "accepted" # a signed exception over a real finding
|
||||
DECISION_BY_DESIGN = "by_design" # declared policy: this object is exempt
|
||||
|
||||
# Retained so findings recorded before the scale existed still read, and
|
||||
# so the interface can be migrated without breaking the stored history.
|
||||
STATE_FAIL = "fail"
|
||||
STATE_WARN = "warn"
|
||||
STATE_PASS = "pass"
|
||||
STATE_NOT_APPLICABLE = "not_applicable"
|
||||
STATE_ACCEPTED = "accepted"
|
||||
STATE_UNKNOWN = "unknown"
|
||||
|
||||
# A finding written before the scale is read on the scale, using the
|
||||
# severity its check declared at the time.
|
||||
_LEGACY_STATE_MAP = {
|
||||
STATE_PASS: CLASS_CONFORMANT,
|
||||
STATE_UNKNOWN: CLASS_UNVERIFIED,
|
||||
STATE_NOT_APPLICABLE: CLASS_NOT_APPLICABLE,
|
||||
STATE_ACCEPTED: CLASS_WARNING,
|
||||
}
|
||||
|
||||
|
||||
def classification_of(state: str, severity: str) -> str:
|
||||
"""Read a stored state and severity on the current scale."""
|
||||
mapped = _LEGACY_STATE_MAP.get(state)
|
||||
if mapped:
|
||||
return mapped
|
||||
if state == STATE_FAIL:
|
||||
return CLASS_CRITICAL if severity == "CRITICAL" else CLASS_WARNING
|
||||
if state == STATE_WARN:
|
||||
return CLASS_OBSERVATION if severity == "INFO" else CLASS_WARNING
|
||||
return CLASS_UNVERIFIED
|
||||
|
||||
|
||||
def state_of(classification: str) -> str:
|
||||
"""The state a classification would have had, for stored compatibility."""
|
||||
return {
|
||||
CLASS_CRITICAL: STATE_FAIL,
|
||||
CLASS_WARNING: STATE_WARN,
|
||||
CLASS_OBSERVATION: STATE_WARN,
|
||||
CLASS_CONFORMANT: STATE_PASS,
|
||||
CLASS_UNVERIFIED: STATE_UNKNOWN,
|
||||
CLASS_NOT_APPLICABLE: STATE_NOT_APPLICABLE,
|
||||
}.get(classification, STATE_UNKNOWN)
|
||||
|
||||
|
||||
def worst(classifications) -> str:
|
||||
"""The gravest of several, or not applicable when there are none."""
|
||||
ranked = [c for c in classifications if c in CLASS_ORDER]
|
||||
if not ranked:
|
||||
return CLASS_NOT_APPLICABLE
|
||||
return min(ranked, key=lambda c: CLASS_ORDER[c])
|
||||
|
||||
RUN_RUNNING = "running"
|
||||
RUN_COMPLETE = "complete"
|
||||
RUN_FAILED = "failed"
|
||||
RUN_PARTIAL = "partial"
|
||||
|
||||
_schema_lock = threading.Lock()
|
||||
_schema_ready = False
|
||||
|
||||
|
||||
def safe_evidence(value):
|
||||
"""Redact secrets before persistence; bound individual evidence fields."""
|
||||
if isinstance(value, dict):
|
||||
return {k: ("[redacted]" if re.search(r"password|secret|token|authorization|private.key", k, re.I)
|
||||
else safe_evidence(v)) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [safe_evidence(v) for v in value]
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
value = re.sub(r"(?s)-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
"[private key redacted]", value)
|
||||
value = re.sub(r"(https?://)[^/\s@]+@", r"\1[redacted]@", value)
|
||||
value = re.sub(r"(?i)((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)[^\s&,;]+",
|
||||
r"\1[redacted]", value)
|
||||
value = re.sub(r"(?im)(authorization\s*:\s*).*", r"\1[redacted]", value)
|
||||
return value if len(value) <= 32768 else value[:32768] + "\n[evidence truncated]"
|
||||
|
||||
|
||||
def finding_scope(finding):
|
||||
"""Bind decisions to object identity, rule version, host and gravity.
|
||||
|
||||
A decision is about a situation, not about a check. If the same
|
||||
objects come back at a different gravity, the situation is not the one
|
||||
that was accepted, so the acceptance does not carry over.
|
||||
"""
|
||||
objects = []
|
||||
for obj in finding.get("affected") or []:
|
||||
identity = {k: obj[k] for k in ("vmid", "type", "volume", "device", "pool",
|
||||
"job", "test", "bridge", "file", "snapshot", "storage", "package") if k in obj}
|
||||
objects.append(identity or obj)
|
||||
payload = {"objects": sorted(objects, key=lambda v: json.dumps(v, sort_keys=True)),
|
||||
"check": finding["check_id"], "version": finding.get("check_version", 1),
|
||||
"classification": finding.get("classification", ""),
|
||||
"host": finding.get("host", "")}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -59,6 +187,9 @@ def init_db() -> None:
|
||||
if _schema_ready:
|
||||
return
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(DB_PATH, os.O_CREAT | os.O_WRONLY, 0o600)
|
||||
os.close(fd)
|
||||
os.chmod(DB_PATH, 0o600)
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.executescript("""
|
||||
@@ -113,7 +244,34 @@ def init_db() -> None:
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_runs_started
|
||||
ON audit_runs(started_at);
|
||||
""")
|
||||
# Additive migration: retain existing runs and decisions.
|
||||
for table, columns in {
|
||||
"audit_runs": {"metadata": "TEXT", "checks_expected": "INTEGER NOT NULL DEFAULT 0"},
|
||||
"audit_findings": {"raw_state": "TEXT", "exception_snapshot": "TEXT",
|
||||
"scope": "TEXT", "details": "TEXT", "classification": "TEXT",
|
||||
"raw_classification": "TEXT", "decision": "TEXT"},
|
||||
"audit_exceptions": {"scope": "TEXT"},
|
||||
}.items():
|
||||
present = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
for name, kind in columns.items():
|
||||
if name not in present:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {kind}")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS audit_exception_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, check_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, happened_at INTEGER NOT NULL, decision TEXT NOT NULL)""")
|
||||
conn.row_factory = sqlite3.Row
|
||||
for legacy in conn.execute("SELECT * FROM audit_exceptions WHERE scope IS NULL"):
|
||||
exists = conn.execute("SELECT 1 FROM audit_exception_events WHERE check_id = ? LIMIT 1",
|
||||
(legacy["check_id"],)).fetchone()
|
||||
if not exists:
|
||||
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
|
||||
"VALUES (?, 'legacy-unscoped', ?, ?)",
|
||||
(legacy["check_id"], legacy["accepted_at"], json.dumps(safe_evidence(dict(legacy)))))
|
||||
# Old accepted findings have no recoverable technical state.
|
||||
conn.execute("UPDATE audit_findings SET raw_state = CASE WHEN state = 'accepted' "
|
||||
"THEN 'unknown' ELSE state END WHERE raw_state IS NULL")
|
||||
conn.commit()
|
||||
os.chmod(DB_PATH, 0o600)
|
||||
_schema_ready = True
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -123,16 +281,17 @@ def init_db() -> None:
|
||||
# Runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start_run(profile: str) -> str:
|
||||
def start_run(profile: str, metadata=None, checks_expected=0) -> str:
|
||||
"""Open a run and return its identifier."""
|
||||
init_db()
|
||||
run_id = uuid.uuid4().hex[:16]
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO audit_runs (run_id, profile, started_at, status) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(run_id, profile, int(time.time()), RUN_RUNNING),
|
||||
"INSERT INTO audit_runs (run_id, profile, started_at, status, metadata, "
|
||||
"checks_expected, schema_version) VALUES (?, ?, ?, ?, ?, ?, 2)",
|
||||
(run_id, profile, int(time.time()), RUN_RUNNING,
|
||||
json.dumps(safe_evidence(metadata or {})), checks_expected),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -140,8 +299,19 @@ def start_run(profile: str) -> str:
|
||||
return run_id
|
||||
|
||||
|
||||
def update_run_metadata(run_id, metadata, checks_expected):
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("UPDATE audit_runs SET metadata = ?, checks_expected = ? WHERE run_id = ?",
|
||||
(json.dumps(safe_evidence(metadata)), checks_expected, run_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def finish_run(run_id: str, *, checks_total: int,
|
||||
error: Optional[str] = None) -> None:
|
||||
error: Optional[str] = None, partial: bool = False) -> None:
|
||||
"""Close a run, marking it failed when an error is supplied."""
|
||||
init_db()
|
||||
conn = _connect()
|
||||
@@ -149,8 +319,8 @@ def finish_run(run_id: str, *, checks_total: int,
|
||||
conn.execute(
|
||||
"UPDATE audit_runs SET finished_at = ?, status = ?, error = ?, "
|
||||
"checks_total = ? WHERE run_id = ?",
|
||||
(int(time.time()), RUN_FAILED if error else RUN_COMPLETE,
|
||||
error, checks_total, run_id),
|
||||
(int(time.time()), RUN_FAILED if error else RUN_PARTIAL if partial else RUN_COMPLETE,
|
||||
safe_evidence(error), checks_total, run_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -165,7 +335,7 @@ def get_run(run_id: str) -> Optional[dict[str, Any]]:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM audit_runs WHERE run_id = ?", (run_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
return _run_row(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -179,22 +349,36 @@ def list_runs(limit: int = 20) -> list[dict[str, Any]]:
|
||||
"SELECT * FROM audit_runs ORDER BY started_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
return [_run_row(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def latest_run(status: str = RUN_COMPLETE) -> Optional[dict[str, Any]]:
|
||||
def _run_row(row) -> dict[str, Any]:
|
||||
"""A run as its consumers need it, with metadata as an object.
|
||||
|
||||
The column holds JSON text; handing that to an interface means every
|
||||
caller parses it, and the one that forgets silently reads nothing
|
||||
rather than failing.
|
||||
"""
|
||||
run = dict(row)
|
||||
try:
|
||||
run["metadata"] = json.loads(run.get("metadata") or "{}")
|
||||
except (TypeError, ValueError):
|
||||
run["metadata"] = {}
|
||||
return run
|
||||
|
||||
|
||||
def latest_run(status: Optional[str] = None) -> Optional[dict[str, Any]]:
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT * FROM audit_runs WHERE status = ? "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(status,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
condition = "status = ?" if status else "status != 'running'"
|
||||
row = conn.execute(f"SELECT * FROM audit_runs WHERE {condition} "
|
||||
"ORDER BY started_at DESC, rowid DESC LIMIT 1",
|
||||
(status,) if status else ()).fetchone()
|
||||
return _run_row(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -212,18 +396,32 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
|
||||
init_db()
|
||||
if not findings:
|
||||
return 0
|
||||
findings = safe_evidence(findings)
|
||||
rows = [
|
||||
(
|
||||
run_id,
|
||||
f["check_id"],
|
||||
f["area"],
|
||||
f["severity"],
|
||||
f["state"],
|
||||
# state is derived from the classification and kept so a
|
||||
# database written by this version still reads on the old
|
||||
# columns; the scale is what the interface reads.
|
||||
state_of(f["classification"]),
|
||||
f.get("summary_key"),
|
||||
json.dumps(f.get("summary_params") or {}, ensure_ascii=False),
|
||||
json.dumps(f.get("affected") or [], ensure_ascii=False),
|
||||
f.get("evidence"),
|
||||
f.get("remediable_by"),
|
||||
# raw_state stays a state, on the old vocabulary; the scale
|
||||
# travels in its own column.
|
||||
state_of(f.get("raw_classification", f["classification"])),
|
||||
json.dumps(f.get("exception")),
|
||||
f.get("scope"),
|
||||
json.dumps({k: f[k] for k in ("check_version", "collected_at", "sources",
|
||||
"incomplete", "observations", "host") if k in f}),
|
||||
f["classification"],
|
||||
f.get("raw_classification", f["classification"]),
|
||||
f.get("decision", DECISION_NONE),
|
||||
)
|
||||
for f in findings
|
||||
]
|
||||
@@ -233,7 +431,9 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
|
||||
conn.executemany(
|
||||
"INSERT INTO audit_findings (run_id, check_id, area, severity, "
|
||||
"state, summary_key, summary_params, affected, evidence, "
|
||||
"remediable_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"remediable_by, raw_state, exception_snapshot, scope, details, "
|
||||
"classification, raw_classification, decision) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
@@ -263,6 +463,18 @@ def get_findings(run_id: str) -> list[dict[str, Any]]:
|
||||
item.get("summary_params") or "{}")
|
||||
except (TypeError, ValueError):
|
||||
item["summary_params"] = {}
|
||||
item.update(json.loads(item.pop("details", None) or "{}"))
|
||||
item["exception"] = json.loads(item.pop("exception_snapshot", None) or "null")
|
||||
# A finding recorded before the scale existed is read on it,
|
||||
# from the state and severity it was stored with.
|
||||
if not item.get("classification"):
|
||||
item["classification"] = classification_of(
|
||||
item.get("state", ""), item.get("severity", ""))
|
||||
item["raw_classification"] = (
|
||||
item.get("raw_classification")
|
||||
or classification_of(item.get("raw_state") or item.get("state", ""),
|
||||
item.get("severity", "")))
|
||||
item.setdefault("decision", DECISION_NONE)
|
||||
out.append(item)
|
||||
return out
|
||||
finally:
|
||||
@@ -291,7 +503,7 @@ def check_history(check_id: str, limit: int = 30) -> list[dict[str, Any]]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def accept_risk(check_id: str, reason: str, accepted_by: str,
|
||||
expires_at: Optional[int] = None) -> None:
|
||||
expires_at: Optional[int] = None, *, scope: str) -> None:
|
||||
"""Record a deliberate decision to leave a finding unresolved.
|
||||
|
||||
A reason is mandatory: an acceptance without one is indistinguishable
|
||||
@@ -300,25 +512,44 @@ def accept_risk(check_id: str, reason: str, accepted_by: str,
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("an accepted risk requires a reason")
|
||||
if not scope:
|
||||
raise ValueError("an accepted risk requires an assessed scope")
|
||||
if expires_at is not None and expires_at <= time.time():
|
||||
raise ValueError("expiry must be in the future")
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
decision = dict(check_id=check_id, reason=reason.strip(), accepted_by=accepted_by,
|
||||
accepted_at=int(time.time()), expires_at=expires_at, scope=scope)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO audit_exceptions "
|
||||
"(check_id, reason, accepted_by, accepted_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
"(check_id, reason, accepted_by, accepted_at, expires_at, scope) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(check_id, reason.strip(), accepted_by, int(time.time()),
|
||||
expires_at),
|
||||
expires_at, scope),
|
||||
)
|
||||
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
|
||||
"VALUES (?, 'accepted', ?, ?)",
|
||||
(check_id, int(time.time()), json.dumps(safe_evidence(decision))))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_risk(check_id: str) -> bool:
|
||||
def revoke_risk(check_id: str, actor: str = "local-admin") -> bool:
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
previous = conn.execute("SELECT * FROM audit_exceptions WHERE check_id = ?", (check_id,)).fetchone()
|
||||
if previous:
|
||||
decision = dict(previous)
|
||||
decision["revoked_by"] = actor
|
||||
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
|
||||
"VALUES (?, 'revoked', ?, ?)",
|
||||
(check_id, int(time.time()), json.dumps(safe_evidence(decision))))
|
||||
cur = conn.execute(
|
||||
"DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,)
|
||||
)
|
||||
@@ -349,6 +580,54 @@ def active_exceptions() -> dict[str, dict[str, Any]]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def effective_findings(run_id):
|
||||
"""Current decisions over immutable technical results; history stays intact.
|
||||
|
||||
The classification is what the assessment concluded and does not
|
||||
change because somebody accepted it. What changes is the decision
|
||||
recorded beside it, which is why the two are separate fields: a
|
||||
report can still show that a critical finding was accepted, and by
|
||||
whom, instead of showing a finding that looks resolved.
|
||||
"""
|
||||
exceptions = active_exceptions()
|
||||
findings = get_findings(run_id)
|
||||
for f in findings:
|
||||
f["classification"] = f["raw_classification"]
|
||||
f["state"] = f["raw_state"]
|
||||
f["exception"] = None
|
||||
f["decision"] = DECISION_NONE
|
||||
decision = exceptions.get(f["check_id"])
|
||||
if (decision and decision.get("scope") and decision["scope"] == f.get("scope")
|
||||
and f["classification"] in CLASS_PROBLEMS and not f.get("incomplete")):
|
||||
f["decision"] = DECISION_ACCEPTED
|
||||
f["state"] = STATE_ACCEPTED
|
||||
f["exception"] = decision
|
||||
return findings
|
||||
|
||||
|
||||
def exception_history():
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
return [dict(row) for row in conn.execute(
|
||||
"SELECT * FROM audit_exception_events ORDER BY id DESC LIMIT 200")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def recover_interrupted_runs():
|
||||
"""Called at service startup, never during an active assessment."""
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("UPDATE audit_runs SET status = ?, error = ?, finished_at = ? WHERE status = ?",
|
||||
(RUN_FAILED, "Assessment interrupted by Monitor restart", int(time.time()), RUN_RUNNING))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def all_exceptions() -> list[dict[str, Any]]:
|
||||
init_db()
|
||||
now = int(time.time())
|
||||
@@ -414,7 +693,7 @@ def prune_runs(keep: int = 30) -> int:
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
cur = conn.execute(
|
||||
"DELETE FROM audit_runs WHERE is_baseline = 0 AND run_id NOT IN ("
|
||||
"DELETE FROM audit_runs WHERE is_baseline = 0 AND status != 'running' AND run_id NOT IN ("
|
||||
" SELECT run_id FROM audit_runs "
|
||||
" WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?"
|
||||
")",
|
||||
|
||||
@@ -307,6 +307,8 @@ def verify_password(password, password_hash):
|
||||
can log in once and trigger a rehash via `_maybe_rehash_password` —
|
||||
see lazy migration in `authenticate()`.
|
||||
"""
|
||||
if not isinstance(password, str) or not password:
|
||||
return False
|
||||
if not isinstance(password_hash, str) or not password_hash:
|
||||
return False
|
||||
if password_hash.startswith(_PWD_PBKDF2_PREFIX):
|
||||
|
||||
@@ -168,7 +168,13 @@ cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
|
||||
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
|
||||
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
|
||||
cp "$SCRIPT_DIR/audit_profiles.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_profiles.py not found"
|
||||
cp "$SCRIPT_DIR/audit_policy.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_policy.py not found"
|
||||
cp "$SCRIPT_DIR/changes_journal.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ changes_journal.py not found"
|
||||
cp "$SCRIPT_DIR/audit_inventory.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_inventory.py not found"
|
||||
cp "$SCRIPT_DIR/audit_checks_pve.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks_pve.py not found"
|
||||
# Preserve the existing build version as assessment provenance; no version bump.
|
||||
cp "$APPIMAGE_ROOT/package.json" "$APP_DIR/package.json"
|
||||
cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found"
|
||||
|
||||
# Copy AI providers module for notification enhancement
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""ProxMenux change journal — reading side.
|
||||
|
||||
The scripts that change this host write one small JSON file per change
|
||||
into a spool directory, and copy whatever they replaced into a content
|
||||
store keyed by digest. Nothing there needs a database, a daemon or a
|
||||
network: recording has to work during a first installation, before
|
||||
anything else exists, and it must never be the reason an operation fails.
|
||||
|
||||
This module is the other half. It consolidates the spool into a table
|
||||
that can be queried, and answers the question the whole thing exists
|
||||
for: *what did ProxMenux change on this machine, and what was there
|
||||
before.*
|
||||
|
||||
Two distinctions are load-bearing and are kept throughout:
|
||||
|
||||
* **What was changed** against **what was run.** A post-install
|
||||
function that rewrites a file authored that change. An upgrade
|
||||
launched from a menu did not: apt decided what changed, and claiming
|
||||
it would be taking credit and blame for someone else's work. Both are
|
||||
recorded; they are not the same kind of entry.
|
||||
|
||||
* **How well the previous state is known.** A change recorded as it
|
||||
happened carries the original. A function re-applied on a host that
|
||||
was already modified carries what was there at the time, which is not
|
||||
the original. Anything applied before the journal existed carries
|
||||
nothing at all. A reader who is deciding whether to revert needs to
|
||||
know which of the three they are looking at.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path("/usr/local/share/proxmenux/changes")
|
||||
SPOOL = ROOT / "spool"
|
||||
OBJECTS = ROOT / "objects"
|
||||
DB_PATH = Path("/usr/local/share/proxmenux/changes.db")
|
||||
|
||||
# What kind of act an entry records.
|
||||
CLASS_CONFIGURATION = "configuration" # ProxMenux changed this
|
||||
CLASS_INSTALLATION = "installation" # ProxMenux put this here
|
||||
CLASS_EXECUTION = "execution" # ProxMenux ran this; it did not decide the outcome
|
||||
CLASS_REGISTRATION = "registration" # applied, with no record of what changed
|
||||
|
||||
CLASSES = (CLASS_CONFIGURATION, CLASS_INSTALLATION,
|
||||
CLASS_EXECUTION, CLASS_REGISTRATION)
|
||||
|
||||
# How much of the previous state is actually known.
|
||||
CAPTURE_PRESENT = "present" # what was there when the change was made
|
||||
CAPTURE_CREATED = "created" # nothing was there; the change created it
|
||||
CAPTURE_UNKNOWN = "unknown" # applied before the journal, or unknowable
|
||||
CAPTURE_NONE = "none" # nothing to capture (an execution)
|
||||
|
||||
# A file large enough that keeping it whole in the journal would cost
|
||||
# more than the answer is worth; the digest and size are still recorded.
|
||||
MAX_OBJECT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# Diffs are for reading, not for archiving: past this many lines the
|
||||
# reader is better served by the counts than by the hunks.
|
||||
MAX_DIFF_LINES = 400
|
||||
|
||||
_lock = threading.Lock()
|
||||
_ready = False
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
global _ready
|
||||
with _lock:
|
||||
if _ready:
|
||||
return
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
first = not DB_PATH.exists()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recorded_at INTEGER NOT NULL,
|
||||
ingested_at INTEGER NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
source TEXT,
|
||||
function TEXT,
|
||||
function_version TEXT,
|
||||
target TEXT,
|
||||
before_ref TEXT,
|
||||
after_ref TEXT,
|
||||
capture TEXT,
|
||||
revert TEXT,
|
||||
exactness TEXT,
|
||||
result TEXT,
|
||||
detail TEXT,
|
||||
-- The spool file this came from, so an entry is
|
||||
-- ingested once however often the reader runs.
|
||||
origin TEXT UNIQUE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_time
|
||||
ON changes(recorded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_function
|
||||
ON changes(function);
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if first:
|
||||
try:
|
||||
DB_PATH.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
_ready = True
|
||||
|
||||
|
||||
def object_path(digest: str) -> Optional[Path]:
|
||||
"""Where a captured content lives, if it is still there."""
|
||||
if not digest or len(digest) < 4 or not digest.isalnum():
|
||||
return None
|
||||
path = OBJECTS / digest[:2] / digest
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def read_object(digest: str) -> Optional[str]:
|
||||
"""Captured content as text, or None when it is gone or too large."""
|
||||
path = object_path(digest)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
if path.stat().st_size > MAX_OBJECT_BYTES:
|
||||
return None
|
||||
return path.read_text(errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def ingest(limit: int = 5000) -> int:
|
||||
"""Move what the scripts wrote into the table.
|
||||
|
||||
A malformed entry is dropped rather than allowed to stop the rest:
|
||||
the spool is written by shell running under conditions this process
|
||||
cannot see, and one bad file must not cost the reader every other
|
||||
change on the host.
|
||||
"""
|
||||
init_db()
|
||||
if not SPOOL.is_dir():
|
||||
return 0
|
||||
try:
|
||||
pending = sorted(p for p in SPOOL.iterdir()
|
||||
if p.suffix == ".json" and p.is_file())[:limit]
|
||||
except OSError:
|
||||
return 0
|
||||
if not pending:
|
||||
return 0
|
||||
|
||||
rows, consumed = [], []
|
||||
for path in pending:
|
||||
try:
|
||||
entry = json.loads(path.read_text(errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
# Keep it out of the way but do not delete it: a file that
|
||||
# could not be read is evidence of its own.
|
||||
_quarantine(path)
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
_quarantine(path)
|
||||
continue
|
||||
rows.append((
|
||||
int(entry.get("recorded_at") or time.time()),
|
||||
int(time.time()),
|
||||
str(entry.get("class") or CLASS_CONFIGURATION),
|
||||
str(entry.get("operation") or "unknown"),
|
||||
str(entry.get("source") or ""),
|
||||
str(entry.get("function") or ""),
|
||||
str(entry.get("function_version") or ""),
|
||||
str(entry.get("target") or ""),
|
||||
str(entry.get("before") or ""),
|
||||
str(entry.get("after") or ""),
|
||||
str(entry.get("capture") or CAPTURE_UNKNOWN),
|
||||
str(entry.get("revert") or "none"),
|
||||
str(entry.get("exactness") or "none"),
|
||||
str(entry.get("result") or "ok"),
|
||||
json.dumps({k: v for k, v in entry.items()
|
||||
if k not in ("recorded_at", "class", "operation", "source",
|
||||
"function", "function_version", "target",
|
||||
"before", "after", "capture", "revert",
|
||||
"exactness", "result")}, ensure_ascii=False),
|
||||
path.name,
|
||||
))
|
||||
consumed.append(path)
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO changes (recorded_at, ingested_at, class, "
|
||||
"operation, source, function, function_version, target, before_ref, "
|
||||
"after_ref, capture, revert, exactness, result, detail, origin) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
for path in consumed:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _quarantine(path: Path) -> None:
|
||||
bad = ROOT / "unreadable"
|
||||
try:
|
||||
bad.mkdir(parents=True, exist_ok=True)
|
||||
path.rename(bad / path.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def diff_of(entry: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""What changed in a file, as the difference and nothing else.
|
||||
|
||||
A function may run to four hundred lines and alter two values; the
|
||||
reader is owed the two values, not the function. Where the content is
|
||||
gone or too large to hold, the absence is reported rather than
|
||||
guessed at.
|
||||
"""
|
||||
if entry.get("class") != CLASS_CONFIGURATION:
|
||||
return None
|
||||
before_ref, after_ref = entry.get("before_ref"), entry.get("after_ref")
|
||||
before = read_object(before_ref) if before_ref else ""
|
||||
after = read_object(after_ref) if after_ref else ""
|
||||
if before is None or after is None:
|
||||
return {"available": False,
|
||||
"reason": "content no longer stored or too large to show"}
|
||||
|
||||
before_lines = before.splitlines()
|
||||
after_lines = after.splitlines()
|
||||
hunks = list(difflib.unified_diff(before_lines, after_lines,
|
||||
lineterm="", n=2))[2:]
|
||||
added = sum(1 for l in hunks if l.startswith("+"))
|
||||
removed = sum(1 for l in hunks if l.startswith("-"))
|
||||
return {
|
||||
"available": True,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"before_lines": len(before_lines),
|
||||
"after_lines": len(after_lines),
|
||||
"truncated": len(hunks) > MAX_DIFF_LINES,
|
||||
"hunks": hunks[:MAX_DIFF_LINES],
|
||||
}
|
||||
|
||||
|
||||
def changes(limit: int = 200, offset: int = 0,
|
||||
function: str = "", klass: str = "") -> list[dict[str, Any]]:
|
||||
"""Recorded changes, newest first."""
|
||||
init_db()
|
||||
ingest()
|
||||
query = "SELECT * FROM changes WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
if function:
|
||||
query += " AND function = ?"
|
||||
params.append(function)
|
||||
if klass:
|
||||
query += " AND class = ?"
|
||||
params.append(klass)
|
||||
query += " ORDER BY recorded_at DESC, id DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = [dict(r) for r in conn.execute(query, params)]
|
||||
finally:
|
||||
conn.close()
|
||||
for row in rows:
|
||||
try:
|
||||
row["detail"] = json.loads(row.get("detail") or "{}")
|
||||
except ValueError:
|
||||
row["detail"] = {}
|
||||
# Whether the previous state can still be shown at all, which is
|
||||
# what decides if a revert is even discussable.
|
||||
row["recoverable"] = bool(row.get("before_ref")
|
||||
and object_path(row["before_ref"]))
|
||||
return rows
|
||||
|
||||
|
||||
def summary() -> dict[str, Any]:
|
||||
"""What the host has been through, in the shape the page opens with."""
|
||||
init_db()
|
||||
ingest()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
by_class = {row["class"]: row["n"] for row in conn.execute(
|
||||
"SELECT class, COUNT(*) AS n FROM changes GROUP BY class")}
|
||||
functions = [dict(row) for row in conn.execute(
|
||||
"SELECT function, source, MAX(function_version) AS version, "
|
||||
"COUNT(*) AS changes, MAX(recorded_at) AS last_change, "
|
||||
"MIN(recorded_at) AS first_change "
|
||||
"FROM changes WHERE function <> '' "
|
||||
"GROUP BY function ORDER BY last_change DESC")]
|
||||
total = sum(by_class.values())
|
||||
finally:
|
||||
conn.close()
|
||||
return {
|
||||
"total": total,
|
||||
"by_class": by_class,
|
||||
"functions": functions,
|
||||
# Where the journal itself stands, so a host with nothing recorded
|
||||
# can say why rather than looking like a host nothing touched.
|
||||
"journal_started": _journal_started(),
|
||||
}
|
||||
|
||||
|
||||
def _journal_started() -> Optional[int]:
|
||||
"""When this host first recorded anything, if it ever has."""
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute("SELECT MIN(recorded_at) AS first FROM changes").fetchone()
|
||||
return row[0] if row and row[0] else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def prune(keep_days: int = 365) -> int:
|
||||
"""Drops entries and their content past the retention window.
|
||||
|
||||
Content is only removed once no entry references it, since the same
|
||||
original may be shared by several changes.
|
||||
"""
|
||||
init_db()
|
||||
cutoff = int(time.time()) - keep_days * 86400
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
removed = conn.execute("DELETE FROM changes WHERE recorded_at < ?",
|
||||
(cutoff,)).rowcount
|
||||
referenced = {row[0] for row in conn.execute(
|
||||
"SELECT before_ref FROM changes WHERE before_ref <> '' "
|
||||
"UNION SELECT after_ref FROM changes WHERE after_ref <> ''")}
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if OBJECTS.is_dir():
|
||||
for shard in OBJECTS.iterdir():
|
||||
if not shard.is_dir():
|
||||
continue
|
||||
for obj in shard.iterdir():
|
||||
if obj.name not in referenced:
|
||||
try:
|
||||
obj.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
@@ -14,7 +14,8 @@ import threading
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from jwt_middleware import require_auth
|
||||
from jwt_middleware import require_auth, require_admin_scope
|
||||
from auth_manager import verify_token, load_auth_config
|
||||
|
||||
audit_bp = Blueprint('audit', __name__)
|
||||
|
||||
@@ -22,14 +23,47 @@ try:
|
||||
import audit_store
|
||||
import audit_checks
|
||||
import audit_checks_pve # noqa: F401 — importing registers the checks
|
||||
import audit_inventory
|
||||
import audit_profiles
|
||||
import audit_policy
|
||||
import changes_journal
|
||||
except ImportError:
|
||||
audit_store = None
|
||||
audit_checks = None
|
||||
audit_inventory = None
|
||||
audit_profiles = None
|
||||
audit_policy = None
|
||||
changes_journal = None
|
||||
|
||||
# One assessment at a time. The flag is also what the interface polls to
|
||||
# know a run is still in progress.
|
||||
_run_lock = threading.Lock()
|
||||
_running: dict = {'active': False, 'run_id': None, 'started_at': 0}
|
||||
_startup_error = None
|
||||
|
||||
|
||||
def _actor():
|
||||
config = load_auth_config()
|
||||
if not config.get('enabled') or config.get('declined'):
|
||||
return 'local-admin (authentication disabled)'
|
||||
parts = request.headers.get('Authorization', '').split()
|
||||
return verify_token(parts[1]) if len(parts) == 2 else 'unknown'
|
||||
|
||||
|
||||
def _progress(run_id, completed, total, check_id):
|
||||
_running.update(run_id=run_id, completed=completed, total=total, check_id=check_id)
|
||||
|
||||
|
||||
@audit_bp.record_once
|
||||
def _on_register(state):
|
||||
global _startup_error
|
||||
if audit_store:
|
||||
try:
|
||||
audit_store.recover_interrupted_runs()
|
||||
except Exception as exc:
|
||||
# An audit DB problem must never prevent the Monitor starting.
|
||||
_startup_error = str(exc)
|
||||
print(f"[audit] persistence unavailable: {exc}")
|
||||
|
||||
|
||||
def _unavailable():
|
||||
@@ -66,17 +100,22 @@ def list_checks():
|
||||
@require_auth
|
||||
def status():
|
||||
"""Latest run, whether an assessment is in progress, and the baseline."""
|
||||
if not audit_store:
|
||||
if not audit_store or _startup_error:
|
||||
return _unavailable()
|
||||
try:
|
||||
latest = audit_store.latest_run()
|
||||
summary = {}
|
||||
if latest:
|
||||
for f in audit_store.get_findings(latest['run_id']):
|
||||
summary[f['state']] = summary.get(f['state'], 0) + 1
|
||||
for f in audit_store.effective_findings(latest['run_id']):
|
||||
# An accepted finding is counted as a decision, not as the
|
||||
# problem it still technically is, so the counters and the
|
||||
# list a reader sees agree with each other.
|
||||
key = (f.get('decision') or f['classification'])
|
||||
summary[key] = summary.get(key, 0) + 1
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"running": _running['active'],
|
||||
"progress": {k: _running.get(k) for k in ('run_id', 'completed', 'total', 'check_id')},
|
||||
"latest": latest,
|
||||
"summary": summary,
|
||||
"baseline": audit_store.get_baseline(),
|
||||
@@ -87,7 +126,7 @@ def status():
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/run', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def run():
|
||||
"""Start an assessment in the background.
|
||||
|
||||
@@ -95,13 +134,17 @@ def run():
|
||||
interface polls ``/api/audit/status``. A full assessment is short but
|
||||
runs against a production host, so it must not hold an HTTP worker.
|
||||
"""
|
||||
if not audit_checks:
|
||||
if not audit_checks or _startup_error:
|
||||
return _unavailable()
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
profile = str(data.get('profile') or 'full')
|
||||
areas = data.get('areas')
|
||||
only = set(areas) if isinstance(areas, list) and areas else None
|
||||
if (not audit_profiles.is_known(profile) or (areas is not None and
|
||||
(not isinstance(areas, list) or not areas or
|
||||
any(not isinstance(a, str) or a not in audit_checks.AREAS for a in areas)))):
|
||||
return jsonify(success=False, message="Unsupported audit profile or areas"), 400
|
||||
only = set(areas) if areas is not None else None
|
||||
|
||||
with _run_lock:
|
||||
if _running['active']:
|
||||
@@ -110,21 +153,27 @@ def run():
|
||||
"message": "An assessment is already running",
|
||||
"run_id": _running['run_id'],
|
||||
}), 409
|
||||
_running.update({'active': True, 'run_id': None,
|
||||
'started_at': time.time()})
|
||||
run_id = audit_store.start_run(profile)
|
||||
_running.update({'active': True, 'run_id': run_id,
|
||||
'started_at': time.time(), 'completed': 0, 'total': 0, 'check_id': None})
|
||||
|
||||
def worker():
|
||||
try:
|
||||
run_id = audit_checks.run_assessment(profile, only_areas=only)
|
||||
_running['run_id'] = run_id
|
||||
audit_checks.run_assessment(profile, only_areas=only, run_id=run_id, progress=_progress)
|
||||
audit_store.prune_runs()
|
||||
except Exception as e:
|
||||
audit_store.finish_run(run_id, checks_total=_running.get('completed', 0), error=str(e))
|
||||
print(f"[audit] assessment failed: {e}")
|
||||
finally:
|
||||
_running['active'] = False
|
||||
|
||||
threading.Thread(target=worker, daemon=True, name='audit-run').start()
|
||||
return jsonify({"success": True, "started": True})
|
||||
try:
|
||||
threading.Thread(target=worker, daemon=True, name='audit-run').start()
|
||||
except Exception as e:
|
||||
_running['active'] = False
|
||||
audit_store.finish_run(run_id, checks_total=0, error=str(e))
|
||||
return jsonify(success=False, message="Unable to start assessment"), 500
|
||||
return jsonify({"success": True, "started": True, "run_id": run_id})
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/runs', methods=['GET'])
|
||||
@@ -154,10 +203,10 @@ def run_detail(run_id):
|
||||
run = audit_store.get_run(run_id)
|
||||
if not run:
|
||||
return jsonify({"success": False, "message": "Run not found"}), 404
|
||||
exceptions = audit_store.active_exceptions()
|
||||
findings = audit_store.get_findings(run_id)
|
||||
for f in findings:
|
||||
f['exception'] = exceptions.get(f['check_id'])
|
||||
# History is immutable by default. The live view explicitly asks
|
||||
# for current decisions, so acceptance/revocation needs no scan.
|
||||
findings = (audit_store.effective_findings(run_id) if request.args.get('effective') == '1'
|
||||
else audit_store.get_findings(run_id))
|
||||
return jsonify({"success": True, "run": run, "findings": findings})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
@@ -181,6 +230,7 @@ def compare():
|
||||
if not base or not other:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"reason": "insufficient_runs",
|
||||
"message": "Two runs are required to compare",
|
||||
}), 400
|
||||
return jsonify({
|
||||
@@ -194,7 +244,7 @@ def compare():
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/baseline', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def set_baseline():
|
||||
if not audit_store:
|
||||
return _unavailable()
|
||||
@@ -218,13 +268,14 @@ def list_exceptions():
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"exceptions": audit_store.all_exceptions(),
|
||||
"history": audit_store.exception_history(),
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/exceptions', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def accept_exception():
|
||||
"""Record a finding as a deliberate decision.
|
||||
|
||||
@@ -244,11 +295,20 @@ def accept_exception():
|
||||
if not reason:
|
||||
return jsonify({"success": False,
|
||||
"message": "A reason is required"}), 400
|
||||
latest = audit_store.latest_run()
|
||||
if not latest or data.get('run_id') != latest['run_id']:
|
||||
return jsonify(success=False, message="Reload the latest assessment before accepting a risk"), 409
|
||||
finding = next((f for f in audit_store.get_findings(latest['run_id']) if f['check_id'] == check_id), None)
|
||||
if (not finding or finding.get('raw_classification') not in audit_store.CLASS_PROBLEMS or
|
||||
finding.get('incomplete') or not finding.get('scope')):
|
||||
return jsonify(success=False, message="This finding cannot be accepted"), 400
|
||||
|
||||
expires_at = None
|
||||
days = data.get('expires_in_days')
|
||||
if days:
|
||||
if days is not None:
|
||||
try:
|
||||
if isinstance(days, bool) or int(days) != float(days) or not 1 <= int(days) <= 3650:
|
||||
raise ValueError("invalid expiry")
|
||||
expires_at = int(time.time()) + int(days) * 86400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False,
|
||||
@@ -256,8 +316,9 @@ def accept_exception():
|
||||
|
||||
audit_store.accept_risk(
|
||||
check_id, reason,
|
||||
accepted_by=str(data.get('accepted_by') or 'admin'),
|
||||
accepted_by=_actor(),
|
||||
expires_at=expires_at,
|
||||
scope=finding['scope'],
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
except ValueError as e:
|
||||
@@ -267,15 +328,140 @@ def accept_exception():
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/exceptions/<path:check_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def revoke_exception(check_id):
|
||||
if not audit_store:
|
||||
return _unavailable()
|
||||
try:
|
||||
removed = audit_store.revoke_risk(check_id)
|
||||
removed = audit_store.revoke_risk(check_id, _actor())
|
||||
if not removed:
|
||||
return jsonify({"success": False,
|
||||
"message": "Exception not found"}), 404
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/inventory', methods=['GET'])
|
||||
@require_auth
|
||||
def inventory():
|
||||
"""Structural inventory of the node.
|
||||
|
||||
Composed from collectors the Monitor already runs; the assessment and
|
||||
the inventory answer different questions and neither depends on the
|
||||
other, so this endpoint does not require a run to exist.
|
||||
"""
|
||||
if not audit_inventory:
|
||||
return _unavailable()
|
||||
try:
|
||||
profile = request.args.get('profile') or audit_profiles.DEFAULT_PROFILE
|
||||
if not audit_profiles.is_known(profile):
|
||||
return jsonify(success=False, message="Unsupported report profile"), 400
|
||||
ctx = audit_checks.AuditContext()
|
||||
ctx.begin_check()
|
||||
inventory = audit_inventory.collect(ctx, sections=audit_profiles.sections(profile))
|
||||
return jsonify({"success": True, "profile": profile, "inventory": inventory})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/profiles', methods=['GET'])
|
||||
@require_auth
|
||||
def profiles():
|
||||
"""Report profiles this build offers, without touching the host."""
|
||||
if not audit_profiles:
|
||||
return _unavailable()
|
||||
try:
|
||||
return jsonify({"success": True, "default": audit_profiles.DEFAULT_PROFILE,
|
||||
"profiles": audit_profiles.describe()})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/policy', methods=['GET'])
|
||||
@require_auth
|
||||
def policy():
|
||||
"""The declaration, and what a declaration can say.
|
||||
|
||||
The vocabulary travels with the declaration so the interface offers
|
||||
exactly the expectations and thresholds this build understands,
|
||||
rather than a list written twice and drifting apart.
|
||||
"""
|
||||
if not audit_policy:
|
||||
return _unavailable()
|
||||
try:
|
||||
current = audit_policy.load()
|
||||
if current.error:
|
||||
return jsonify(success=False, message=current.error), 422
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"policy": {
|
||||
"guests": current._guests,
|
||||
"storages": current._storages,
|
||||
"defaults": current._defaults,
|
||||
"thresholds": current._thresholds,
|
||||
},
|
||||
"summary": current.describe(),
|
||||
"vocabulary": {
|
||||
"expectations": list(audit_policy._EXPECTATIONS),
|
||||
"roles": list(audit_policy._ROLES),
|
||||
"thresholds": audit_policy.DEFAULT_THRESHOLDS,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/policy', methods=['PUT'])
|
||||
@require_admin_scope
|
||||
def save_policy():
|
||||
"""Replace the declaration.
|
||||
|
||||
Validation is the store's, not this endpoint's: a declaration that
|
||||
cannot be understood is refused with the reason rather than written
|
||||
and reinterpreted later.
|
||||
"""
|
||||
if not audit_policy:
|
||||
return _unavailable()
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify(success=False, message="A policy object is required"), 400
|
||||
revision = payload.get("expected_revision")
|
||||
if not isinstance(revision, str) or not revision:
|
||||
return jsonify(success=False, message="A policy revision is required"), 428
|
||||
try:
|
||||
saved = audit_policy.save(payload, expected_revision=revision)
|
||||
except audit_policy.PolicyConflict as e:
|
||||
return jsonify(success=False, message=str(e)), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
return jsonify({"success": True, "summary": saved.describe()})
|
||||
|
||||
|
||||
@audit_bp.route('/api/audit/changes', methods=['GET'])
|
||||
@require_auth
|
||||
def changes():
|
||||
"""What ProxMenux changed on this host, and what was there before.
|
||||
|
||||
The diff of each configuration change travels with it: a function may
|
||||
run to hundreds of lines and alter two values, and it is the two
|
||||
values the reader is owed.
|
||||
"""
|
||||
if not changes_journal:
|
||||
return _unavailable()
|
||||
try:
|
||||
limit = min(int(request.args.get('limit', 200)), 1000)
|
||||
entries = changes_journal.changes(
|
||||
limit=limit,
|
||||
offset=int(request.args.get('offset', 0)),
|
||||
function=request.args.get('function', ''),
|
||||
klass=request.args.get('class', ''),
|
||||
)
|
||||
for entry in entries:
|
||||
entry["diff"] = changes_journal.diff_of(entry)
|
||||
return jsonify({"success": True, "changes": entries,
|
||||
"summary": changes_journal.summary()})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@@ -495,10 +495,26 @@ def auth_change_password():
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
# `old_password` is the canonical API field. Accept the original
|
||||
# frontend name as a compatibility alias so an already-open browser
|
||||
# tab can still complete the request after a Monitor update.
|
||||
old_password = data.get('old_password')
|
||||
if old_password is None:
|
||||
old_password = data.get('current_password')
|
||||
new_password = data.get('new_password')
|
||||
totp_code = data.get('totp_code')
|
||||
|
||||
if not isinstance(old_password, str) or not isinstance(new_password, str):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Current password and new password are required",
|
||||
}), 400
|
||||
if totp_code is not None and not isinstance(totp_code, str):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Invalid 2FA code",
|
||||
}), 400
|
||||
|
||||
success, message = auth_manager.change_password(old_password, new_password, totp_code)
|
||||
|
||||
if success:
|
||||
|
||||
@@ -2254,11 +2254,17 @@ def _vm_disk_refresher_loop():
|
||||
cycle_started = time.time()
|
||||
try:
|
||||
resources = get_cached_pvesh_cluster_resources_vm() or []
|
||||
local_node = get_proxmox_node_name()
|
||||
live_vmids = set()
|
||||
targets = []
|
||||
for r in resources:
|
||||
if r.get('type') not in ('qemu', 'vm'):
|
||||
continue
|
||||
# Cluster resources contains guests from every member. `qm
|
||||
# guest cmd` and the resulting health ownership are local-node
|
||||
# operations, so never probe a VM currently owned elsewhere.
|
||||
if r.get('node') != local_node:
|
||||
continue
|
||||
if r.get('status') != 'running':
|
||||
continue
|
||||
vmid = r.get('vmid')
|
||||
@@ -6669,7 +6675,12 @@ def get_proxmox_vms():
|
||||
# producing a false "1 package pending"
|
||||
# every time a registered app had a newer
|
||||
# upstream version.
|
||||
app_list = lxc_app_map.get(str(resource.get('vmid')))
|
||||
# Docker inventory can be ready before this CT has an
|
||||
# app sidecar (especially during startup). Keep the
|
||||
# core VM/LXC inventory independent from that optional
|
||||
# decoration: an absent app entry is an empty list,
|
||||
# never a reason to discard every guest in /api/vms.
|
||||
app_list = lxc_app_map.get(str(resource.get('vmid'))) or []
|
||||
if app_list:
|
||||
vm_data['app_watches'] = app_list
|
||||
# Apps dashboard reads this to build
|
||||
|
||||
@@ -6149,6 +6149,7 @@ class HealthMonitor:
|
||||
try:
|
||||
import flask_server # deferred — avoids circular import at module load
|
||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||
local_node = flask_server.get_proxmox_node_name()
|
||||
except Exception as e:
|
||||
print(f"[HealthMonitor] LXC disk check failed: {e}")
|
||||
return None
|
||||
@@ -6170,6 +6171,12 @@ class HealthMonitor:
|
||||
for r in resources:
|
||||
if r.get('type') != 'lxc':
|
||||
continue
|
||||
# `/cluster/resources` is cluster-wide. Capacity belongs to the
|
||||
# node currently running the CT, so every Monitor must ignore
|
||||
# guests owned by another node or the same condition is recorded
|
||||
# and notified independently by every cluster member.
|
||||
if r.get('node') != local_node:
|
||||
continue
|
||||
if r.get('status') != 'running':
|
||||
# Stopped CTs — `disk` reads as 0 from pvesh because the
|
||||
# rootfs isn't mounted. Skip rather than report a
|
||||
@@ -6194,6 +6201,7 @@ class HealthMonitor:
|
||||
'maxdisk_bytes': maxdisk,
|
||||
'vmid': vmid,
|
||||
'name': name,
|
||||
'node': local_node,
|
||||
}
|
||||
error_key = f'lxc_disk_{vmid}'
|
||||
|
||||
@@ -6287,13 +6295,16 @@ class HealthMonitor:
|
||||
try:
|
||||
import flask_server # deferred — avoids circular import
|
||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||
local_node = flask_server.get_proxmox_node_name()
|
||||
except Exception as e:
|
||||
print(f"[HealthMonitor] VM disk check failed: {e}")
|
||||
return None
|
||||
|
||||
# Cheap short-circuit: no running QEMU VMs on this node.
|
||||
if not any(
|
||||
r.get('type') in ('qemu', 'vm') and r.get('status') == 'running'
|
||||
r.get('type') in ('qemu', 'vm')
|
||||
and r.get('node') == local_node
|
||||
and r.get('status') == 'running'
|
||||
for r in resources
|
||||
):
|
||||
return None
|
||||
@@ -6308,6 +6319,8 @@ class HealthMonitor:
|
||||
for r in resources:
|
||||
if r.get('type') not in ('qemu', 'vm'):
|
||||
continue
|
||||
if r.get('node') != local_node:
|
||||
continue
|
||||
if r.get('status') != 'running':
|
||||
continue
|
||||
|
||||
@@ -6338,6 +6351,7 @@ class HealthMonitor:
|
||||
'maxdisk_bytes': total,
|
||||
'vmid': vmid_str,
|
||||
'name': name,
|
||||
'node': local_node,
|
||||
}
|
||||
error_key = f'vm_disk_{vmid_str}'
|
||||
|
||||
|
||||
+137
-49
@@ -17,8 +17,8 @@
|
||||
# update_app(vmid, app_id, config) -> (bool, …)
|
||||
# delete_app(vmid, app_id) -> bool
|
||||
# delete_all(vmid) -> bool
|
||||
# check_app(vmid, app_id, force=False) -> dict|None
|
||||
# check_all(vmid, force=False) -> dict|None
|
||||
# check_app(vmid, app_id, force=False, notify=True) -> dict|None
|
||||
# check_all(vmid, force=False, notify=True) -> dict|None
|
||||
# get_active_apps() -> {str(vmid): [summary, …]}
|
||||
# get_suggestions(vmid) -> {name, port_suggestions[], web_path_hint}
|
||||
# ==========================================================
|
||||
@@ -3872,43 +3872,69 @@ def clear_schedule_reboot_required(vmid) -> bool:
|
||||
return _write_sidecar(vmid, sidecar)
|
||||
|
||||
|
||||
def _fire_update_notification(vmid, app: dict) -> None:
|
||||
def _app_update_notification_payload(vmid, app: dict) -> Optional[dict]:
|
||||
"""Return the notification payload for one pending app update.
|
||||
|
||||
The same eligibility rules are used by direct/manual checks and by the
|
||||
scheduled batch so per-app opt-outs and Docker-owned updates cannot drift
|
||||
between the two paths.
|
||||
"""
|
||||
# Per-app opt-out: user flipped the bell icon off for this specific
|
||||
# app (because they know it can't be updated on their box or they
|
||||
# just don't care). Field defaults to True — an app registered
|
||||
# before this feature landed keeps receiving notifications.
|
||||
if app.get("notifications_enabled", True) is False:
|
||||
return
|
||||
return None
|
||||
if app.get("helper_slug") == "docker":
|
||||
return
|
||||
return None
|
||||
# Delegated apps are announced by their Docker image's own event; a
|
||||
# second one for the same release would land in a different event type
|
||||
# and therefore escape deduplication.
|
||||
if app.get("update_via") == "docker":
|
||||
return
|
||||
return None
|
||||
state = app.get("state") or {}
|
||||
latest = state.get("latest_version")
|
||||
if not state.get("update_available") or not latest:
|
||||
return None
|
||||
return {
|
||||
"vmid": int(vmid),
|
||||
"ct_name": app.get("name") or f"CT-{vmid}",
|
||||
"app_name": app.get("name") or "app",
|
||||
"installed": state.get("installed_version") or "unknown",
|
||||
"latest": latest,
|
||||
"app_id": str(app.get("id") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _emit_app_update_event(data: dict, entity: str, entity_id: str) -> bool:
|
||||
try:
|
||||
from notification_manager import notification_manager
|
||||
import socket
|
||||
state = app.get("state") or {}
|
||||
notification_manager.emit_event(
|
||||
event_type='app_update_available',
|
||||
severity='INFO',
|
||||
data={
|
||||
'hostname': socket.gethostname(),
|
||||
'vmid': int(vmid),
|
||||
'ct_name': app.get('name') or f'CT-{vmid}',
|
||||
'app_name': app.get('name') or 'app',
|
||||
'installed': state.get('installed_version') or 'unknown',
|
||||
'latest': state.get('latest_version') or 'unknown',
|
||||
},
|
||||
data={"hostname": socket.gethostname(), **data},
|
||||
source='app_watch',
|
||||
entity='ct',
|
||||
# vmid + app_id + latest so multi-app CTs don't dedup and
|
||||
# subsequent upstream releases still fire.
|
||||
entity_id=f"{vmid}:{app.get('id')}:{state.get('latest_version') or ''}",
|
||||
entity=entity,
|
||||
entity_id=entity_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] lxc_apps: notif emit failed for CT {vmid}: {e}")
|
||||
print(f"[ProxMenux] lxc_apps: app update notification failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _fire_update_notification(vmid, app: dict) -> bool:
|
||||
payload = _app_update_notification_payload(vmid, app)
|
||||
if payload is None:
|
||||
return False
|
||||
app_id = payload.pop("app_id")
|
||||
return _emit_app_update_event(
|
||||
payload,
|
||||
entity="ct",
|
||||
# vmid + app_id + latest so multi-app CTs don't dedup and
|
||||
# subsequent upstream releases still fire.
|
||||
entity_id=f"{vmid}:{app_id}:{payload['latest']}",
|
||||
)
|
||||
|
||||
|
||||
def _docker_stack_notification_payload(
|
||||
@@ -4166,7 +4192,9 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
|
||||
return installed, err, False
|
||||
|
||||
|
||||
def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]:
|
||||
def check_app(
|
||||
vmid, app_id: str, force: bool = False, notify: bool = True,
|
||||
) -> Optional[dict]:
|
||||
with _cache_lock:
|
||||
sidecar = _read_sidecar(vmid)
|
||||
if not sidecar:
|
||||
@@ -4230,18 +4258,20 @@ def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]:
|
||||
# (vmid + app_id + latest_version) with its cooldown, and only
|
||||
# a genuinely new upstream release changes the entity_id and
|
||||
# triggers a fresh delivery.
|
||||
if update_available and latest:
|
||||
if notify and update_available and latest:
|
||||
_fire_update_notification(vmid, app)
|
||||
|
||||
return sidecar
|
||||
|
||||
|
||||
def emit_all_pending_updates() -> int:
|
||||
"""Walk every sidecar and emit `app_update_available` for each
|
||||
app currently marked with a pending upstream release. Safe to
|
||||
call repeatedly — `notification_manager` dedups by entity_id
|
||||
(vmid + app_id + latest_version), so a given release only sends
|
||||
once until a newer version appears.
|
||||
"""Emit pending registered-app updates as one scheduled summary.
|
||||
|
||||
A single pending app retains the original per-app notification. Multiple
|
||||
apps are grouped into one event, ordered by CT and app, while preserving
|
||||
every installed/latest version pair. Safe to call repeatedly: the batch
|
||||
entity id is derived from the exact pending set and notification_manager
|
||||
applies its normal cooldown.
|
||||
|
||||
Needed because `check_app(force=False)` short-circuits on a fresh
|
||||
`checked_at` and never reaches the emit path. The 24 h
|
||||
@@ -4249,14 +4279,14 @@ def emit_all_pending_updates() -> int:
|
||||
this helper the notification only ever fired on the exact tick
|
||||
where a new upstream version was FIRST observed — and even that
|
||||
was silenced when the user's setting was OFF at the time.
|
||||
Returns the number of emits attempted (delivery still depends on
|
||||
channel enablement + cooldown + rate limit)."""
|
||||
Returns the number of eligible pending apps represented by the event
|
||||
(delivery still depends on channel enablement + cooldown + rate limit)."""
|
||||
try:
|
||||
entries = sorted(os.listdir(_APPS_DIR))
|
||||
except (FileNotFoundError, OSError):
|
||||
print("[ProxMenux] emit_all_pending_updates: _APPS_DIR missing", flush=True)
|
||||
return 0
|
||||
n = 0
|
||||
pending_payloads: list[dict] = []
|
||||
print(f"[ProxMenux] emit_all_pending_updates: scanning {len(entries)} sidecar file(s)", flush=True)
|
||||
for name in entries:
|
||||
if not name.endswith(".json"):
|
||||
@@ -4271,36 +4301,81 @@ def emit_all_pending_updates() -> int:
|
||||
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} sidecar empty", flush=True)
|
||||
continue
|
||||
apps = sidecar.get("apps") or []
|
||||
pending = [a for a in apps
|
||||
if (a.get("state") or {}).get("update_available")
|
||||
and (a.get("state") or {}).get("latest_version")]
|
||||
pending = [
|
||||
app for app in apps
|
||||
if (app.get("state") or {}).get("update_available")
|
||||
and (app.get("state") or {}).get("latest_version")
|
||||
]
|
||||
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} apps={len(apps)} pending={len(pending)}", flush=True)
|
||||
for app in pending:
|
||||
try:
|
||||
_fire_update_notification(vmid, app)
|
||||
n += 1
|
||||
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}'", flush=True)
|
||||
except Exception as inner:
|
||||
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}' FAILED: {inner}", flush=True)
|
||||
payload = _app_update_notification_payload(vmid, app)
|
||||
if payload is not None:
|
||||
pending_payloads.append(payload)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} outer failure: {e}", flush=True)
|
||||
print(f"[ProxMenux] emit_all_pending_updates: {n} emit(s) attempted total", flush=True)
|
||||
return n
|
||||
pending_payloads.sort(
|
||||
key=lambda item: (
|
||||
item["vmid"],
|
||||
item["app_name"].casefold(),
|
||||
item["app_id"],
|
||||
)
|
||||
)
|
||||
count = len(pending_payloads)
|
||||
if count == 0:
|
||||
print("[ProxMenux] emit_all_pending_updates: no eligible pending apps", flush=True)
|
||||
return 0
|
||||
|
||||
if count == 1:
|
||||
payload = dict(pending_payloads[0])
|
||||
app_id = payload.pop("app_id")
|
||||
_emit_app_update_event(
|
||||
payload,
|
||||
entity="ct",
|
||||
entity_id=f"{payload['vmid']}:{app_id}:{payload['latest']}",
|
||||
)
|
||||
print("[ProxMenux] emit_all_pending_updates: 1 app in 1 notification", flush=True)
|
||||
return 1
|
||||
|
||||
signature = "|".join(
|
||||
f"{item['vmid']}:{item['app_id']}:{item['latest']}"
|
||||
for item in pending_payloads
|
||||
)
|
||||
updates = [
|
||||
{key: value for key, value in item.items() if key != "app_id"}
|
||||
for item in pending_payloads
|
||||
]
|
||||
container_count = len({item["vmid"] for item in pending_payloads})
|
||||
_emit_app_update_event(
|
||||
{
|
||||
"count": count,
|
||||
"container_count": container_count,
|
||||
"updates": updates,
|
||||
},
|
||||
entity="node",
|
||||
entity_id=f"batch:{hashlib.sha256(signature.encode()).hexdigest()[:20]}",
|
||||
)
|
||||
print(
|
||||
f"[ProxMenux] emit_all_pending_updates: {count} apps in 1 notification",
|
||||
flush=True,
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def check_all(vmid, force: bool = False) -> Optional[dict]:
|
||||
def check_all(
|
||||
vmid, force: bool = False, notify: bool = True,
|
||||
) -> Optional[dict]:
|
||||
sidecar = _read_sidecar(vmid)
|
||||
if not sidecar:
|
||||
return None
|
||||
for app in (sidecar.get("apps") or []):
|
||||
try:
|
||||
check_app(vmid, app.get("id"), force=force)
|
||||
check_app(vmid, app.get("id"), force=force, notify=notify)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] lxc_apps.check_all: CT {vmid} app {app.get('id')} failed: {e}")
|
||||
return _read_sidecar(vmid)
|
||||
|
||||
|
||||
def refresh_all_apps(force: bool = False) -> int:
|
||||
def refresh_all_apps(force: bool = False, notify: bool = True) -> int:
|
||||
"""Called from the polling collector's daily cycle so header
|
||||
badges stay fresh without needing to open every modal."""
|
||||
try:
|
||||
@@ -4316,7 +4391,7 @@ def refresh_all_apps(force: bool = False) -> int:
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
check_all(vmid, force=force)
|
||||
check_all(vmid, force=force, notify=notify)
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] lxc_apps refresh_all: CT {vmid} failed: {e}")
|
||||
@@ -4985,12 +5060,14 @@ def _docker_service_catalog_meta(service: str, container: str, image: str) -> di
|
||||
|
||||
|
||||
def _probe_docker_web_links(vmid) -> list[dict]:
|
||||
"""Return running Docker workloads that publish TCP ports on the LXC.
|
||||
"""Return Docker workloads that publish TCP ports on the LXC.
|
||||
|
||||
The result is suggestion-only. No sidecar entry is written and no port is
|
||||
assumed to be HTTP until the user explicitly adds it in the editor. IPv4
|
||||
and IPv6 bindings of the same host port are deduplicated; loopback-only
|
||||
bindings are omitted because they cannot form a usable remote LXC link.
|
||||
Stopped containers are included from their persistent HostConfig bindings,
|
||||
so their links remain registrable before the workload is started again.
|
||||
"""
|
||||
key = str(vmid)
|
||||
now = time.time()
|
||||
@@ -4999,7 +5076,7 @@ def _probe_docker_web_links(vmid) -> list[dict]:
|
||||
if cached and (now - cached[0]) < _PORT_PROBE_TTL_SEC:
|
||||
return [dict(item) for item in cached[1]]
|
||||
|
||||
rc, out, _ = _pct_exec(vmid, ["docker", "ps", "-q"], timeout=10)
|
||||
rc, out, _ = _pct_exec(vmid, ["docker", "ps", "-aq"], timeout=10)
|
||||
if rc != 0:
|
||||
result: list[dict] = []
|
||||
else:
|
||||
@@ -5023,7 +5100,18 @@ def _probe_docker_web_links(vmid) -> list[dict]:
|
||||
labels = config.get("Labels") or {}
|
||||
service = str(labels.get("com.docker.compose.service") or container).strip()
|
||||
meta = _docker_service_catalog_meta(service, container, image)
|
||||
ports = (obj.get("NetworkSettings") or {}).get("Ports") or {}
|
||||
# NetworkSettings.Ports is populated while a container is
|
||||
# running, but Docker empties it after the container stops.
|
||||
# HostConfig.PortBindings retains the declared mapping and
|
||||
# is therefore the fallback needed to keep those web-link
|
||||
# suggestions available. Prefer live bindings whenever
|
||||
# Docker provides them.
|
||||
ports = dict((obj.get("HostConfig") or {}).get("PortBindings") or {})
|
||||
for endpoint, bindings in (
|
||||
(obj.get("NetworkSettings") or {}).get("Ports") or {}
|
||||
).items():
|
||||
if bindings:
|
||||
ports[endpoint] = bindings
|
||||
seen_host_ports: set[int] = set()
|
||||
for container_endpoint, bindings in ports.items():
|
||||
if not str(container_endpoint).endswith("/tcp") or not isinstance(bindings, list):
|
||||
|
||||
@@ -513,6 +513,16 @@ class JournalWatcher:
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
|
||||
# Keep the small amount of journal history that precedes a kernel
|
||||
# diagnostic. `Call Trace:` is only a structural marker inside that
|
||||
# diagnostic, never the cause itself. The old detector promoted the
|
||||
# marker to an event and therefore sent an unactionable "Kernel call
|
||||
# trace" every 24 h, sometimes followed by a second burst message for
|
||||
# another line from the same incident.
|
||||
from collections import deque as _deque
|
||||
self._kernel_context = _deque(maxlen=40)
|
||||
self._KERNEL_CONTEXT_WINDOW_SECS = 15
|
||||
|
||||
# 24h anti-cascade for disk I/O + filesystem errors. The dict
|
||||
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
|
||||
# so a disk in WARNING cooldown can still escalate to CRITICAL
|
||||
@@ -526,7 +536,6 @@ class JournalWatcher:
|
||||
# paper showed ~36% of failed drives gave no SMART warning.
|
||||
# Rate-based escalation catches the dying drives that SMART
|
||||
# would never flag until they were already bricked.
|
||||
from collections import deque as _deque
|
||||
self._disk_error_window: Dict[str, "_deque[float]"] = {}
|
||||
self._DISK_ERROR_WINDOW_SECS = 86400 # 24h
|
||||
# Tiers calibrated for homelab/SMB Proxmox usage:
|
||||
@@ -767,7 +776,7 @@ class JournalWatcher:
|
||||
|
||||
self._check_auth_failure(msg, syslog_id, entry)
|
||||
self._check_fail2ban(msg, syslog_id)
|
||||
self._check_kernel_critical(msg, syslog_id, priority)
|
||||
self._check_kernel_critical(msg, syslog_id, priority, entry)
|
||||
self._check_service_failure(msg, unit)
|
||||
self._check_disk_io(msg, syslog_id, priority)
|
||||
self._check_cluster_events(msg, syslog_id)
|
||||
@@ -849,13 +858,69 @@ class JournalWatcher:
|
||||
'hostname': self._hostname,
|
||||
}, entity='user', entity_id=ip)
|
||||
|
||||
def _check_kernel_critical(self, msg: str, syslog_id: str, priority: int):
|
||||
def _remember_kernel_context(self, msg: str, now: float) -> str:
|
||||
"""Record and return the recent journal excerpt for a kernel event."""
|
||||
self._kernel_context.append((now, msg))
|
||||
cutoff = now - self._KERNEL_CONTEXT_WINDOW_SECS
|
||||
while self._kernel_context and self._kernel_context[0][0] < cutoff:
|
||||
self._kernel_context.popleft()
|
||||
return '\n'.join(line for _, line in self._kernel_context)[-4000:]
|
||||
|
||||
@staticmethod
|
||||
def _kernel_diagnostic(msg: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Return (kind, process, component) for an attributable kernel event.
|
||||
|
||||
A bare ``Call Trace:`` intentionally has no match. It is analogous to
|
||||
a heading in a diagnostic block and cannot establish that a new fault
|
||||
occurred. The patterns below identify the line that explains why the
|
||||
kernel printed the trace.
|
||||
"""
|
||||
patterns = (
|
||||
(r'\bWARNING:\s+CPU:', 'Kernel warning'),
|
||||
(r'\bINFO:\s+task\s+.+?\s+blocked for more than\s+\d+', 'Blocked kernel task'),
|
||||
(r'\btask\s+.+?\s+blocked for more than\s+\d+', 'Blocked kernel task'),
|
||||
(r'\brcu(?:_preempt|_sched|):.*detected stalls?', 'RCU stall'),
|
||||
(r'\bsoft lockup\b', 'CPU soft lockup'),
|
||||
(r'\bhard LOCKUP\b', 'CPU hard lockup'),
|
||||
(r'\bgeneral protection fault\b', 'General protection fault'),
|
||||
(r'\bunable to handle kernel (?:NULL pointer dereference|paging request)', 'Kernel memory access fault'),
|
||||
(r'\bOops:', 'Kernel oops'),
|
||||
(r'\bUBSAN:', 'Undefined behaviour detected'),
|
||||
(r'\bKASAN:', 'Kernel memory safety violation'),
|
||||
)
|
||||
kind = ''
|
||||
for pattern, label in patterns:
|
||||
if re.search(pattern, msg, re.IGNORECASE):
|
||||
kind = label
|
||||
break
|
||||
if not kind:
|
||||
return None
|
||||
|
||||
process = ''
|
||||
process_match = re.search(r'\bPID:\s*(\d+)\s+Comm:\s*([^\s]+)', msg)
|
||||
if process_match:
|
||||
process = f'{process_match.group(2)} (PID {process_match.group(1)})'
|
||||
else:
|
||||
blocked_match = re.search(r'\btask\s+([^:\s]+)(?::\d+)?\s+blocked for more than', msg, re.IGNORECASE)
|
||||
if blocked_match:
|
||||
process = blocked_match.group(1)
|
||||
|
||||
component = ''
|
||||
component_match = re.search(r'\bat\s+([^\s+]+)(?:\+0x[0-9a-f]+/0x[0-9a-f]+)?', msg, re.IGNORECASE)
|
||||
if component_match:
|
||||
component = component_match.group(1)
|
||||
|
||||
return kind, process, component
|
||||
|
||||
def _check_kernel_critical(self, msg: str, syslog_id: str, priority: int,
|
||||
entry: Optional[Dict] = None):
|
||||
"""Detect kernel panics, OOM, segfaults, hardware errors."""
|
||||
# Only process messages from kernel or systemd (not app-level logs)
|
||||
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
journal_context = self._remember_kernel_context(msg, now)
|
||||
if self._oom_lines and now - self._oom_started_at > 15:
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
@@ -918,6 +983,43 @@ class JournalWatcher:
|
||||
for noise in _KERNEL_NOISE:
|
||||
if re.search(noise, msg, re.IGNORECASE):
|
||||
return
|
||||
|
||||
# A JSON journal entry lets us prove that the diagnostic came from the
|
||||
# kernel transport. Plain-mode input remains supported for older
|
||||
# journalctl fallbacks, but a systemd/application entry containing the
|
||||
# words "WARNING: CPU" cannot masquerade as a kernel event.
|
||||
transport = str((entry or {}).get('_TRANSPORT', '') or '')
|
||||
is_kernel_source = entry is None or syslog_id == 'kernel' or transport == 'kernel'
|
||||
diagnostic = self._kernel_diagnostic(msg) if is_kernel_source and not self._oom_lines else None
|
||||
if diagnostic:
|
||||
kind, process, component = diagnostic
|
||||
observed_us = str((entry or {}).get('__REALTIME_TIMESTAMP', '') or '')
|
||||
try:
|
||||
observed_ts = int(observed_us) / 1_000_000 if observed_us else now
|
||||
except (TypeError, ValueError):
|
||||
observed_ts = now
|
||||
observed_at = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(observed_ts))
|
||||
details = [f'Type: {kind}']
|
||||
if process:
|
||||
details.append(f'Process: {process}')
|
||||
if component:
|
||||
details.append(f'Component: {component}')
|
||||
details.extend((f'Message: {msg[:500]}', f'Recorded: {observed_at}'))
|
||||
identity = f'{kind}\x1f{component}\x1f{process}\x1f{msg[:300]}'
|
||||
entity_id = f'kernel_{hashlib.sha256(identity.encode(errors="replace")).hexdigest()[:16]}'
|
||||
self._emit(
|
||||
'kernel_warning',
|
||||
'WARNING',
|
||||
{
|
||||
'hostname': self._hostname,
|
||||
'reason': f'{kind}\n{msg[:500]}',
|
||||
'kernel_details': '\n'.join(details),
|
||||
'_journal_context': journal_context,
|
||||
},
|
||||
entity='node',
|
||||
entity_id=entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
# NOTE: Disk I/O errors (ATA, SCSI, blk_update_request) are NOT handled
|
||||
# here. They are detected exclusively by HealthMonitor._check_disks_optimized
|
||||
@@ -932,7 +1034,6 @@ class JournalWatcher:
|
||||
r'Out of memory': ('system_problem', 'CRITICAL', 'Out of memory killer activated'),
|
||||
r'segfault': ('system_problem', 'WARNING', 'Segmentation fault detected'),
|
||||
r'BUG:': ('system_problem', 'CRITICAL', 'Kernel BUG detected'),
|
||||
r'Call Trace:': ('system_problem', 'WARNING', 'Kernel call trace'),
|
||||
r'EXT4-fs error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
||||
r'BTRFS error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
||||
r'XFS.*error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
||||
@@ -2634,6 +2735,53 @@ class PollingCollector:
|
||||
def _hostname(self) -> str:
|
||||
return _hostname()
|
||||
|
||||
@staticmethod
|
||||
def _guest_storage_error_is_now_foreign(error_key: str, old_meta: dict) -> bool:
|
||||
"""Return True when a disappearing guest-capacity error moved nodes.
|
||||
|
||||
Older versions recorded `lxc_disk_<vmid>` and `vm_disk_<vmid>` on
|
||||
every cluster member because the health check consumed the unfiltered
|
||||
cluster resource list. A normal `resolved_keys` transition would make
|
||||
those foreign records produce one final, false recovery after the
|
||||
ownership filter is installed. The same distinction matters during a
|
||||
real migration: leaving the old node is not recovery.
|
||||
|
||||
Prefer the current cluster owner over the historical details, because
|
||||
a legitimate local alert can subsequently migrate. The stored node is
|
||||
only a fallback for a guest no longer present in the resource list.
|
||||
"""
|
||||
match = re.fullmatch(r'(?:lxc|vm)_disk_(\d+)', str(error_key or ''))
|
||||
if not match:
|
||||
return False
|
||||
|
||||
try:
|
||||
import flask_server # deferred: flask_server imports this module
|
||||
local_node = str(flask_server.get_proxmox_node_name() or '')
|
||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||
vmid = match.group(1)
|
||||
for resource in resources:
|
||||
if str(resource.get('vmid', '')) != vmid:
|
||||
continue
|
||||
if resource.get('type') not in ('lxc', 'qemu', 'vm'):
|
||||
continue
|
||||
owner = str(resource.get('node') or '')
|
||||
if owner and local_node:
|
||||
return owner != local_node
|
||||
except Exception:
|
||||
local_node = ''
|
||||
|
||||
details = old_meta.get('details') if isinstance(old_meta, dict) else None
|
||||
if isinstance(details, str):
|
||||
try:
|
||||
details = json.loads(details)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
details = None
|
||||
if isinstance(details, dict):
|
||||
owner = str(details.get('node') or '')
|
||||
if owner and local_node:
|
||||
return owner != local_node
|
||||
return False
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
@@ -2988,6 +3136,15 @@ class PollingCollector:
|
||||
reason = old_meta.get('reason', '')
|
||||
first_seen = old_meta.get('first_seen', '')
|
||||
|
||||
# A guest moving to another cluster node — or a legacy foreign
|
||||
# record created by the old cluster-wide capacity scan — has not
|
||||
# recovered. Drop only this node's tracking state and let the
|
||||
# current owner report the condition if it is still present.
|
||||
if self._guest_storage_error_is_now_foreign(key, old_meta):
|
||||
self._last_notified.pop(key, None)
|
||||
self._notified_severity.pop(key, None)
|
||||
continue
|
||||
|
||||
# Skip recovery for INFO/OK - they never triggered an alert
|
||||
if old_meta.get('severity', '') in ('INFO', 'OK'):
|
||||
self._last_notified.pop(key, None)
|
||||
@@ -3642,7 +3799,11 @@ class PollingCollector:
|
||||
# blocks the others.
|
||||
try:
|
||||
import lxc_apps
|
||||
lxc_apps.refresh_all_apps(force=False)
|
||||
# The automatic sweep builds one detailed summary after every app
|
||||
# has been refreshed. Suppress the per-app emit here so the user
|
||||
# does not receive the individual messages before that summary.
|
||||
# Explicit UI checks keep the default notify=True behaviour.
|
||||
lxc_apps.refresh_all_apps(force=False, notify=False)
|
||||
# Docker images have an independent lifecycle from both the OS
|
||||
# packages and the Docker engine. Refresh their read-only
|
||||
# registry digest inventory on the same daily cadence; this never
|
||||
@@ -3652,16 +3813,9 @@ class PollingCollector:
|
||||
# yesterday's cycle cannot postpone the next automatic scan by an
|
||||
# additional day. Normal UI reads remain cache-only for 24 hours.
|
||||
lxc_apps.refresh_docker_inventories(force=True)
|
||||
# After the refresh, emit `app_update_available` for every
|
||||
# sidecar entry currently flagged with a pending upstream
|
||||
# release. `check_app(force=False)` short-circuits on a
|
||||
# fresh `checked_at` and never reaches the emit path, so
|
||||
# without this call the notification only ever fired on
|
||||
# the exact tick where a new version was FIRST observed —
|
||||
# missed forever if the user had the toggle off at that
|
||||
# moment. `notification_manager` dedups by entity_id
|
||||
# (vmid + app_id + latest_version) so repeated calls only
|
||||
# deliver one notification per release.
|
||||
# Emit one detailed registered-app summary for this sweep. A
|
||||
# single pending app retains the existing individual wording;
|
||||
# several apps are grouped by CT with every version pair intact.
|
||||
lxc_apps.emit_all_pending_docker_stacks()
|
||||
lxc_apps.emit_all_pending_updates()
|
||||
except Exception as e:
|
||||
|
||||
@@ -497,6 +497,7 @@ AGGREGATION_RULES = {
|
||||
'service_fail': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
|
||||
'service_fail_batch': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
|
||||
'system_problem': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'},
|
||||
'kernel_warning': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'},
|
||||
'oom_kill': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
|
||||
'firewall_issue': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
|
||||
}
|
||||
@@ -522,12 +523,10 @@ _DEFAULT_AGGREGATION = {'window': 60, 'min_count': 2, 'burst_type': 'burst_gener
|
||||
# recovery is per-event; collapsing them adds zero information.
|
||||
_AGGREGATION_EXEMPT_EVENTS = frozenset({
|
||||
'error_resolved',
|
||||
# Per-app upstream update. Each event carries a distinct app name,
|
||||
# version and CT id — collapsing "5 app updates burst" into a
|
||||
# summary hides exactly the information the user wants (which
|
||||
# apps, which versions). Startup emit fires all pending updates
|
||||
# at once, so without this exemption only the first 1-2 land and
|
||||
# the rest get buffered into a useless summary.
|
||||
# Registered-app updates are grouped deliberately by their producer during
|
||||
# automatic/startup sweeps, preserving each app, CT and version pair.
|
||||
# Manual checks still emit one complete per-app event. Sending either form
|
||||
# through the generic burst formatter would discard those details.
|
||||
'app_update_available',
|
||||
'docker_stack_update_available',
|
||||
'lxc_update_applied',
|
||||
@@ -1274,8 +1273,18 @@ class NotificationManager:
|
||||
channels = dict(self._channels)
|
||||
|
||||
template = TEMPLATES.get(event_type, {})
|
||||
event_group = template.get('group', 'other')
|
||||
default_event_enabled = 'true' if template.get('default_enabled', True) else 'false'
|
||||
# Hidden burst templates represent their originating event; they must
|
||||
# inherit both its category and its per-event toggle. Otherwise turning
|
||||
# off an individual alert suppresses the first message but the hidden
|
||||
# "+N more" summary still arrives later.
|
||||
filter_event_type = event_type
|
||||
if template.get('hidden', False):
|
||||
source_event_type = str(data.get('event_type', '') or '')
|
||||
if source_event_type in TEMPLATES:
|
||||
filter_event_type = source_event_type
|
||||
filter_template = TEMPLATES.get(filter_event_type, template)
|
||||
event_group = filter_template.get('group', template.get('group', 'other'))
|
||||
default_event_enabled = 'true' if filter_template.get('default_enabled', True) else 'false'
|
||||
|
||||
# Build AI config once (shared across channels, detail_level varies)
|
||||
ai_config = self._build_ai_config()
|
||||
@@ -1292,7 +1301,7 @@ class NotificationManager:
|
||||
|
||||
# ── Per-channel event check ──
|
||||
# Default: from template default_enabled, unless explicitly set.
|
||||
ch_event_key = f'{ch_name}.event.{event_type}'
|
||||
ch_event_key = f'{ch_name}.event.{filter_event_type}'
|
||||
if self._config.get(ch_event_key, default_event_enabled) == 'false':
|
||||
continue # Channel has this specific event disabled
|
||||
|
||||
|
||||
@@ -418,6 +418,73 @@ def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
return title, body
|
||||
|
||||
|
||||
def _format_app_update_available(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
"""Render one app update or a scheduled multi-app summary."""
|
||||
hostname = str(data.get("hostname") or _get_hostname())
|
||||
updates = data.get("updates")
|
||||
if not isinstance(updates, list) or len(updates) < 2:
|
||||
app_name = str(data.get("app_name") or "app")
|
||||
vmid = data.get("vmid", "")
|
||||
ct_name = str(data.get("ct_name") or f"CT-{vmid}")
|
||||
installed = str(data.get("installed") or "unknown")
|
||||
latest = str(data.get("latest") or "unknown")
|
||||
return (
|
||||
f"{hostname}: {app_name} update available on CT {vmid}",
|
||||
f"{app_name} on CT {vmid} ({ct_name}) has a new version:\n"
|
||||
f" {installed} → {latest}",
|
||||
)
|
||||
|
||||
clean_updates = []
|
||||
for item in updates:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
vmid = int(item.get("vmid"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
clean_updates.append({
|
||||
"vmid": vmid,
|
||||
"app_name": str(item.get("app_name") or "app"),
|
||||
"installed": str(item.get("installed") or "unknown"),
|
||||
"latest": str(item.get("latest") or "unknown"),
|
||||
})
|
||||
clean_updates.sort(
|
||||
key=lambda item: (item["vmid"], item["app_name"].casefold())
|
||||
)
|
||||
if not clean_updates:
|
||||
return (
|
||||
f"{hostname}: Application updates available",
|
||||
"Application updates are available.",
|
||||
)
|
||||
|
||||
count = len(clean_updates)
|
||||
container_count = len({item["vmid"] for item in clean_updates})
|
||||
title = f"{hostname}: {count} application updates available"
|
||||
lead = (
|
||||
f"{count} applications in {container_count} LXC "
|
||||
f"container{'s' if container_count != 1 else ''} have a newer version:"
|
||||
)
|
||||
sections = []
|
||||
omitted = 0
|
||||
for vmid in sorted({item["vmid"] for item in clean_updates}):
|
||||
rows = [item for item in clean_updates if item["vmid"] == vmid]
|
||||
section = [f"CT {vmid}"]
|
||||
section.extend(
|
||||
f"• {item['app_name']}: {item['installed']} → {item['latest']}"
|
||||
for item in rows
|
||||
)
|
||||
candidate = "\n\n".join([lead, *sections, "\n".join(section)])
|
||||
# Leave room for channel-specific wrappers and AI formatting while
|
||||
# keeping the raw Telegram message comfortably below 4096 chars.
|
||||
if len(candidate) > 3200:
|
||||
omitted += len(rows)
|
||||
continue
|
||||
sections.append("\n".join(section))
|
||||
if omitted:
|
||||
sections.append(f"… {omitted} additional application(s)")
|
||||
return title, "\n\n".join([lead, *sections])
|
||||
|
||||
|
||||
# ─── Severity Icons ──────────────────────────────────────────────
|
||||
|
||||
SEVERITY_ICONS = {
|
||||
@@ -536,6 +603,7 @@ TEMPLATES = {
|
||||
# this one off meant users who registered apps in the App tab
|
||||
# never received the notification they explicitly asked for.
|
||||
'default_enabled': True,
|
||||
'formatter': '_format_app_update_available',
|
||||
},
|
||||
'docker_stack_update_available': {
|
||||
'title': '{hostname}: Docker updates available on CT {vmid}',
|
||||
@@ -968,6 +1036,13 @@ TEMPLATES = {
|
||||
'group': 'services',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'kernel_warning': {
|
||||
'title': '{hostname}: Kernel diagnostic event detected',
|
||||
'body': 'The kernel recorded a diagnostic event.\n{kernel_details}',
|
||||
'label': 'Kernel warnings and diagnostic traces',
|
||||
'group': 'services',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'service_fail': {
|
||||
'title': '{hostname}: Service failed — {service_name}',
|
||||
'body': 'System service "{service_name}" has failed.\nReason: {reason}',
|
||||
@@ -1811,6 +1886,7 @@ EVENT_EMOJI = {
|
||||
'system_reboot': '\U0001F504',
|
||||
'system_restore_completed': '✅', # check mark
|
||||
'system_problem': '\u26A0\uFE0F',
|
||||
'kernel_warning': '\u26A0\uFE0F',
|
||||
'service_fail': '\u274C',
|
||||
'oom_kill': '\U0001F4A3', # bomb
|
||||
# Health
|
||||
|
||||
@@ -1760,11 +1760,27 @@ def get_lynis_audit_status():
|
||||
}
|
||||
|
||||
|
||||
def parse_lynis_report():
|
||||
def _parse_lynis_warning(value):
|
||||
"""Lynis 3.x: ID|message|details|solution; retain legacy L/M/H records."""
|
||||
parts = [part.strip() for part in value.split("|")]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
legacy = parts[1] in ("L", "M", "H")
|
||||
return {
|
||||
"test_id": parts[0],
|
||||
"severity": parts[1] if legacy else "",
|
||||
"description": (parts[2] if len(parts) > 2 else "") if legacy else parts[1],
|
||||
"details": "" if legacy or len(parts) < 3 or parts[2] == "-" else parts[2],
|
||||
"solution": parts[3] if len(parts) > 3 and parts[3] != "-" else "",
|
||||
}
|
||||
|
||||
|
||||
def parse_lynis_report(enrich_current=True):
|
||||
"""
|
||||
Parse /var/log/lynis-report.dat into structured report data.
|
||||
Also enriches with data from lynis.log when report.dat is sparse.
|
||||
Returns a dict with all audit findings.
|
||||
Returns a dict with all audit findings. Set enrich_current=False when
|
||||
consuming historical evidence: do not run live fallback probes.
|
||||
"""
|
||||
report_file = "/var/log/lynis-report.dat"
|
||||
output_file = "/var/log/lynis-output.log"
|
||||
@@ -1890,14 +1906,9 @@ def parse_lynis_report():
|
||||
|
||||
# Parse warnings
|
||||
for w in warnings_raw:
|
||||
parts = w.split("|")
|
||||
if len(parts) >= 2:
|
||||
report["warnings"].append({
|
||||
"test_id": parts[0].strip() if len(parts) > 0 else "",
|
||||
"severity": parts[1].strip() if len(parts) > 1 else "",
|
||||
"description": parts[2].strip() if len(parts) > 2 else parts[1].strip(),
|
||||
"solution": parts[3].strip() if len(parts) > 3 else "",
|
||||
})
|
||||
warning = _parse_lynis_warning(w)
|
||||
if warning:
|
||||
report["warnings"].append(warning)
|
||||
|
||||
# Parse suggestions
|
||||
for s in suggestions_raw:
|
||||
@@ -2100,7 +2111,7 @@ def parse_lynis_report():
|
||||
break
|
||||
|
||||
# Also check pve-firewall directly (Proxmox uses its own firewall service)
|
||||
if not report["firewall_active"]:
|
||||
if enrich_current and not report["firewall_active"]:
|
||||
try:
|
||||
rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"])
|
||||
if rc == 0 and out.strip() == "active":
|
||||
@@ -2246,7 +2257,7 @@ def parse_lynis_report():
|
||||
pass
|
||||
|
||||
# Fallback: get kernel from uname if still empty
|
||||
if not report["kernel_version"]:
|
||||
if enrich_current and not report["kernel_version"]:
|
||||
try:
|
||||
rc, out, _ = _run_cmd(["uname", "-r"])
|
||||
if rc == 0 and out.strip():
|
||||
@@ -2255,7 +2266,7 @@ def parse_lynis_report():
|
||||
pass
|
||||
|
||||
# Fallback: get hostname from system
|
||||
if not report["hostname"]:
|
||||
if enrich_current and not report["hostname"]:
|
||||
try:
|
||||
import socket
|
||||
report["hostname"] = socket.gethostname()
|
||||
@@ -2263,7 +2274,7 @@ def parse_lynis_report():
|
||||
pass
|
||||
|
||||
# Fallback: get installed packages count
|
||||
if report["installed_packages"] == 0:
|
||||
if enrich_current and report["installed_packages"] == 0:
|
||||
try:
|
||||
rc, out, _ = _run_cmd(["dpkg", "-l"])
|
||||
if rc == 0 and out:
|
||||
|
||||
@@ -109,5 +109,105 @@ class SetupAuthTests(unittest.TestCase):
|
||||
self.assertEqual(config[key], value)
|
||||
|
||||
|
||||
class ChangePasswordTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
config_dir = Path(self.temp_dir.name)
|
||||
config_patch = mock.patch.multiple(
|
||||
auth_manager,
|
||||
CONFIG_DIR=config_dir,
|
||||
AUTH_CONFIG_FILE=config_dir / "auth.json",
|
||||
)
|
||||
config_patch.start()
|
||||
self.addCleanup(config_patch.stop)
|
||||
|
||||
self.current_password = "CurrentPass1!"
|
||||
self.new_password = "Replacement2!"
|
||||
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps({
|
||||
"enabled": True,
|
||||
"configured": True,
|
||||
"declined": False,
|
||||
"username": "admin",
|
||||
"password_hash": auth_manager.hash_password(self.current_password),
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": [],
|
||||
}))
|
||||
|
||||
def read_config(self):
|
||||
return json.loads(auth_manager.AUTH_CONFIG_FILE.read_text())
|
||||
|
||||
def test_missing_current_password_is_rejected_without_exception(self):
|
||||
self.assertFalse(auth_manager.verify_password(None, self.read_config()["password_hash"]))
|
||||
|
||||
success, message = auth_manager.change_password(None, self.new_password)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(message, "Current password is incorrect")
|
||||
|
||||
def test_password_change_without_2fa(self):
|
||||
success, message = auth_manager.change_password(
|
||||
self.current_password, self.new_password
|
||||
)
|
||||
|
||||
self.assertTrue(success, message)
|
||||
self.assertTrue(auth_manager.verify_password(
|
||||
self.new_password, self.read_config()["password_hash"]
|
||||
))
|
||||
|
||||
def test_password_change_requires_2fa_when_enabled(self):
|
||||
config = self.read_config()
|
||||
config["totp_enabled"] = True
|
||||
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
|
||||
|
||||
success, message = auth_manager.change_password(
|
||||
self.current_password, self.new_password
|
||||
)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(message, "2FA code required to change password")
|
||||
self.assertTrue(auth_manager.verify_password(
|
||||
self.current_password, self.read_config()["password_hash"]
|
||||
))
|
||||
|
||||
def test_password_change_accepts_valid_2fa_code(self):
|
||||
config = self.read_config()
|
||||
config["totp_enabled"] = True
|
||||
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
|
||||
|
||||
with mock.patch.object(
|
||||
auth_manager, "verify_totp", return_value=(True, "accepted")
|
||||
) as verify_totp:
|
||||
success, message = auth_manager.change_password(
|
||||
self.current_password, self.new_password, "123456"
|
||||
)
|
||||
|
||||
self.assertTrue(success, message)
|
||||
verify_totp.assert_called_once_with("admin", "123456", use_backup=False)
|
||||
self.assertTrue(auth_manager.verify_password(
|
||||
self.new_password, self.read_config()["password_hash"]
|
||||
))
|
||||
|
||||
def test_password_change_rejects_invalid_2fa_and_preserves_password(self):
|
||||
config = self.read_config()
|
||||
config["totp_enabled"] = True
|
||||
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
|
||||
|
||||
with mock.patch.object(
|
||||
auth_manager, "verify_totp", return_value=(False, "rejected")
|
||||
) as verify_totp:
|
||||
success, message = auth_manager.change_password(
|
||||
self.current_password, self.new_password, "000000"
|
||||
)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(message, "Invalid 2FA code")
|
||||
self.assertEqual(verify_totp.call_count, 2)
|
||||
self.assertTrue(auth_manager.verify_password(
|
||||
self.current_password, self.read_config()["password_hash"]
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
# These modules normally bind the live `/usr/local/share/proxmenux` database
|
||||
# while importing. Ownership tests need no host state, so provide the same
|
||||
# narrow dependency boundary used by the production functions below.
|
||||
_health_persistence_module = ModuleType("health_persistence")
|
||||
_health_persistence_module.health_persistence = SimpleNamespace(
|
||||
cleanup_old_errors=lambda: None,
|
||||
)
|
||||
_health_persistence_module.disk_base_name = lambda name: str(name).replace("/dev/", "")
|
||||
sys.modules.setdefault("health_persistence", _health_persistence_module)
|
||||
|
||||
sys.modules.setdefault("psutil", ModuleType("psutil"))
|
||||
|
||||
flask_server = SimpleNamespace(
|
||||
get_proxmox_node_name=lambda: "fixture",
|
||||
get_cached_pvesh_cluster_resources_vm=lambda: [],
|
||||
get_cached_vm_disk=lambda _vmid: None,
|
||||
)
|
||||
sys.modules.setdefault("flask_server", flask_server)
|
||||
|
||||
import health_monitor # noqa: E402
|
||||
import notification_events # noqa: E402
|
||||
|
||||
|
||||
class _Persistence:
|
||||
def __init__(self):
|
||||
self.recorded = []
|
||||
self.cleared = []
|
||||
|
||||
def record_error(self, **kwargs):
|
||||
self.recorded.append(kwargs)
|
||||
|
||||
def get_active_errors(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
def clear_error(self, key):
|
||||
self.cleared.append(key)
|
||||
|
||||
|
||||
class ClusterGuestStorageOwnershipTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.monitor = health_monitor.HealthMonitor.__new__(health_monitor.HealthMonitor)
|
||||
self.persistence = _Persistence()
|
||||
self.resources = [
|
||||
{
|
||||
"type": "lxc", "node": "hades", "status": "running",
|
||||
"vmid": 128, "name": "plex", "disk": 94, "maxdisk": 100,
|
||||
},
|
||||
{
|
||||
"type": "lxc", "node": "poseidon", "status": "running",
|
||||
"vmid": 129, "name": "remote", "disk": 99, "maxdisk": 100,
|
||||
},
|
||||
]
|
||||
|
||||
def test_lxc_capacity_records_only_guests_owned_by_local_node(self):
|
||||
with (
|
||||
patch.object(health_monitor, "MOUNT_MONITOR_AVAILABLE", False),
|
||||
patch.object(health_monitor, "health_persistence", self.persistence),
|
||||
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
|
||||
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=self.resources),
|
||||
):
|
||||
result = self.monitor._check_lxc_disk_usage()
|
||||
|
||||
self.assertEqual(result["status"], "WARNING")
|
||||
self.assertEqual([row["error_key"] for row in self.persistence.recorded], ["lxc_disk_128"])
|
||||
self.assertEqual(self.persistence.recorded[0]["details"]["node"], "hades")
|
||||
self.assertNotIn("CT 129", result["checks"])
|
||||
|
||||
def test_vm_capacity_does_not_probe_remote_guest_agent(self):
|
||||
resources = [
|
||||
{"type": "qemu", "node": "hades", "status": "running", "vmid": 201, "name": "local"},
|
||||
{"type": "qemu", "node": "poseidon", "status": "running", "vmid": 202, "name": "remote"},
|
||||
]
|
||||
|
||||
def disk_for(vmid):
|
||||
if vmid == 201:
|
||||
return (94, 100)
|
||||
raise AssertionError("remote VM was probed")
|
||||
|
||||
with (
|
||||
patch.object(health_monitor, "health_persistence", self.persistence),
|
||||
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
|
||||
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources),
|
||||
patch.object(flask_server, "get_cached_vm_disk", side_effect=disk_for),
|
||||
):
|
||||
result = self.monitor._check_vm_disk_usage()
|
||||
|
||||
self.assertEqual(result["status"], "WARNING")
|
||||
self.assertEqual([row["error_key"] for row in self.persistence.recorded], ["vm_disk_201"])
|
||||
self.assertEqual(self.persistence.recorded[0]["details"]["node"], "hades")
|
||||
|
||||
def test_foreign_legacy_record_is_not_a_recovery(self):
|
||||
collector = notification_events.PollingCollector(Queue())
|
||||
resources = [{"type": "lxc", "node": "poseidon", "vmid": 128}]
|
||||
with (
|
||||
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
|
||||
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources),
|
||||
):
|
||||
foreign = collector._guest_storage_error_is_now_foreign(
|
||||
"lxc_disk_128", {"details": {"vmid": "128"}}
|
||||
)
|
||||
self.assertTrue(foreign)
|
||||
|
||||
def test_local_recovery_remains_a_recovery(self):
|
||||
collector = notification_events.PollingCollector(Queue())
|
||||
resources = [{"type": "lxc", "node": "hades", "vmid": 128}]
|
||||
with (
|
||||
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
|
||||
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources),
|
||||
):
|
||||
foreign = collector._guest_storage_error_is_now_foreign(
|
||||
"lxc_disk_128", {"details": {"vmid": "128", "node": "hades"}}
|
||||
)
|
||||
self.assertFalse(foreign)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
APPIMAGE_DIR = SCRIPTS_DIR.parent
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import notification_events # noqa: E402
|
||||
import notification_templates # noqa: E402
|
||||
|
||||
|
||||
class KernelTraceNotificationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.queue = Queue()
|
||||
self.watcher = notification_events.JournalWatcher(self.queue)
|
||||
|
||||
def _check(self, message, *, syslog_id="kernel", transport="kernel"):
|
||||
self.watcher._check_kernel_critical(
|
||||
message,
|
||||
syslog_id,
|
||||
4,
|
||||
{
|
||||
"_TRANSPORT": transport,
|
||||
"__REALTIME_TIMESTAMP": "1788883200000000",
|
||||
},
|
||||
)
|
||||
|
||||
def test_bare_call_trace_is_not_an_event(self):
|
||||
self._check("Call Trace:")
|
||||
with self.assertRaises(Empty):
|
||||
self.queue.get_nowait()
|
||||
|
||||
def test_kernel_warning_carries_attributable_fields(self):
|
||||
self._check(
|
||||
"WARNING: CPU: 2 PID: 418 Comm: z_wr_iss at arc_evict_state+0x12/0x80"
|
||||
)
|
||||
event = self.queue.get_nowait()
|
||||
self.assertEqual(event.event_type, "kernel_warning")
|
||||
self.assertEqual(event.severity, "WARNING")
|
||||
self.assertIn("Type: Kernel warning", event.data["kernel_details"])
|
||||
self.assertIn("Process: z_wr_iss (PID 418)", event.data["kernel_details"])
|
||||
self.assertIn("Component: arc_evict_state", event.data["kernel_details"])
|
||||
self.assertIn("Recorded: 2026-", event.data["kernel_details"])
|
||||
self.assertIn("WARNING: CPU", event.data["_journal_context"])
|
||||
|
||||
self._check("Call Trace:")
|
||||
with self.assertRaises(Empty):
|
||||
self.queue.get_nowait()
|
||||
|
||||
def test_application_text_cannot_impersonate_kernel_warning(self):
|
||||
self._check(
|
||||
"WARNING: CPU: 0 PID: 99 Comm: example at fake_function+0x1/0x2",
|
||||
syslog_id="systemd",
|
||||
transport="stdout",
|
||||
)
|
||||
with self.assertRaises(Empty):
|
||||
self.queue.get_nowait()
|
||||
|
||||
def test_blocked_task_is_identified(self):
|
||||
self._check("INFO: task txg_sync:812 blocked for more than 120 seconds.")
|
||||
event = self.queue.get_nowait()
|
||||
self.assertEqual(event.event_type, "kernel_warning")
|
||||
self.assertIn("Type: Blocked kernel task", event.data["kernel_details"])
|
||||
self.assertIn("Process: txg_sync", event.data["kernel_details"])
|
||||
|
||||
def test_event_is_visible_and_translated_in_every_monitor_locale(self):
|
||||
services = notification_templates.get_event_types_by_group()["services"]
|
||||
self.assertIn("kernel_warning", {item["type"] for item in services})
|
||||
for locale in ("en", "es", "de", "fr", "it", "pt", "sk", "sv"):
|
||||
messages = json.loads(
|
||||
(APPIMAGE_DIR / "messages" / locale / "common.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
messages["settings"]["notifications"]["eventTypes"]["kernel_warning"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,140 @@
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import lxc_apps
|
||||
import notification_templates
|
||||
|
||||
|
||||
def _app(app_id, name, installed, latest, **extra):
|
||||
return {
|
||||
"id": app_id,
|
||||
"name": name,
|
||||
"state": {
|
||||
"installed_version": installed,
|
||||
"latest_version": latest,
|
||||
"update_available": True,
|
||||
},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
class _FakeNotificationManager:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def emit_event(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class AppUpdateNotificationBatchTests(unittest.TestCase):
|
||||
def _write_sidecar(self, directory, vmid, apps):
|
||||
Path(directory, f"{vmid}.json").write_text(
|
||||
json.dumps({"vmid": vmid, "apps": apps}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _emit(self, sidecars):
|
||||
fake = _FakeNotificationManager()
|
||||
module = types.SimpleNamespace(notification_manager=fake)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for vmid, apps in sidecars.items():
|
||||
self._write_sidecar(directory, vmid, apps)
|
||||
with (
|
||||
mock.patch.object(lxc_apps, "_APPS_DIR", directory),
|
||||
mock.patch.dict(sys.modules, {"notification_manager": module}),
|
||||
):
|
||||
count = lxc_apps.emit_all_pending_updates()
|
||||
return count, fake.calls
|
||||
|
||||
def test_multiple_updates_are_sent_as_one_sorted_batch(self):
|
||||
count, calls = self._emit({
|
||||
115: [
|
||||
_app("redis", "Redis", "7.0.15-1", "8.10.1"),
|
||||
_app("docmost", "Docmost", "0.23.2", "0.95.0"),
|
||||
],
|
||||
100: [_app("adguard", "AdGuard Home", "0.107.78", "0.107.79")],
|
||||
})
|
||||
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(len(calls), 1)
|
||||
event = calls[0]
|
||||
self.assertEqual(event["event_type"], "app_update_available")
|
||||
self.assertEqual(event["entity"], "node")
|
||||
self.assertTrue(event["entity_id"].startswith("batch:"))
|
||||
self.assertEqual(event["data"]["count"], 3)
|
||||
self.assertEqual(event["data"]["container_count"], 2)
|
||||
self.assertEqual(
|
||||
[(item["vmid"], item["app_name"]) for item in event["data"]["updates"]],
|
||||
[(100, "AdGuard Home"), (115, "Docmost"), (115, "Redis")],
|
||||
)
|
||||
|
||||
def test_single_update_keeps_the_individual_event_shape(self):
|
||||
count, calls = self._emit({
|
||||
101: [_app("npm", "Nginx Proxy Manager", "2.9.19", "2.15.1")],
|
||||
})
|
||||
|
||||
self.assertEqual(count, 1)
|
||||
self.assertEqual(len(calls), 1)
|
||||
event = calls[0]
|
||||
self.assertEqual(event["entity"], "ct")
|
||||
self.assertNotIn("updates", event["data"])
|
||||
self.assertEqual(event["data"]["vmid"], 101)
|
||||
self.assertEqual(event["data"]["latest"], "2.15.1")
|
||||
|
||||
def test_batch_respects_opt_outs_and_docker_delegation(self):
|
||||
count, calls = self._emit({
|
||||
110: [
|
||||
_app("silent", "Silent", "1.0", "2.0", notifications_enabled=False),
|
||||
_app("docker", "Docker", "1.0", "2.0", helper_slug="docker"),
|
||||
_app("portainer", "Portainer", "2.0", "2.1", update_via="docker"),
|
||||
],
|
||||
})
|
||||
|
||||
self.assertEqual(count, 0)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_check_all_can_refresh_without_emitting_individual_events(self):
|
||||
sidecar = {"vmid": 120, "apps": [{"id": "one"}, {"id": "two"}]}
|
||||
with (
|
||||
mock.patch.object(lxc_apps, "_read_sidecar", return_value=sidecar),
|
||||
mock.patch.object(lxc_apps, "check_app") as check,
|
||||
):
|
||||
lxc_apps.check_all(120, force=False, notify=False)
|
||||
|
||||
self.assertEqual(check.call_count, 2)
|
||||
check.assert_any_call(120, "one", force=False, notify=False)
|
||||
check.assert_any_call(120, "two", force=False, notify=False)
|
||||
|
||||
def test_batch_formatter_groups_versions_by_container(self):
|
||||
rendered = notification_templates.render_template(
|
||||
"app_update_available",
|
||||
{
|
||||
"hostname": "pve01",
|
||||
"updates": [
|
||||
{"vmid": 115, "app_name": "Redis", "installed": "7.0", "latest": "8.1"},
|
||||
{"vmid": 100, "app_name": "AdGuard Home", "installed": "1.0", "latest": "1.1"},
|
||||
{"vmid": 115, "app_name": "Docmost", "installed": "0.2", "latest": "0.9"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(rendered["title"], "pve01: 3 application updates available")
|
||||
self.assertIn("3 applications in 2 LXC containers", rendered["body"])
|
||||
self.assertLess(rendered["body"].index("CT 100"), rendered["body"].index("CT 115"))
|
||||
self.assertIn("• Docmost: 0.2 → 0.9", rendered["body"])
|
||||
self.assertIn("• Redis: 7.0 → 8.1", rendered["body"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import notification_manager # noqa: E402
|
||||
|
||||
|
||||
class RecordingChannel:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def send(self, title, body, severity, data):
|
||||
self.calls += 1
|
||||
return {"success": True, "error": ""}
|
||||
|
||||
|
||||
class NotificationBurstToggleInheritanceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.channel = RecordingChannel()
|
||||
self.manager = notification_manager.NotificationManager()
|
||||
self.manager._channels = {"email": self.channel}
|
||||
self.manager._config = {
|
||||
"email.enabled": "true",
|
||||
"email.events.services": "true",
|
||||
"email.rich_format": "false",
|
||||
"email.event.kernel_warning": "false",
|
||||
"ai_enabled": "false",
|
||||
}
|
||||
|
||||
def test_hidden_summary_inherits_source_event_toggle(self):
|
||||
delivered = self.manager._dispatch_to_channels(
|
||||
"host: +1 more system problem",
|
||||
"One additional issue",
|
||||
"WARNING",
|
||||
"burst_system",
|
||||
{"event_type": "kernel_warning", "hostname": "host"},
|
||||
"aggregator",
|
||||
)
|
||||
self.assertFalse(delivered)
|
||||
self.assertEqual(self.channel.calls, 0)
|
||||
|
||||
def test_generic_summary_inherits_source_event_category(self):
|
||||
self.manager._config.update({
|
||||
"email.event.oom_kill": "true",
|
||||
"email.events.services": "false",
|
||||
"email.events.other": "true",
|
||||
})
|
||||
delivered = self.manager._dispatch_to_channels(
|
||||
"host: related events",
|
||||
"One additional issue",
|
||||
"WARNING",
|
||||
"burst_generic",
|
||||
{"event_type": "oom_kill", "hostname": "host"},
|
||||
"aggregator",
|
||||
)
|
||||
self.assertFalse(delivered)
|
||||
self.assertEqual(self.channel.calls, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user