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:
MacRimi
2026-09-08 21:06:04 +02:00
co-authored by Claude Opus 5
parent ae75508eff
commit da8a480eff
102 changed files with 24118 additions and 1403 deletions
+420 -47
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+844
View File
@@ -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 [])
],
}
+361
View File
@@ -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)
+125
View File
@@ -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
View File
@@ -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 ?"
")",
+2
View File
@@ -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):
+6
View File
@@ -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
+367
View File
@@ -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
+209 -23
View File
@@ -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
+16
View File
@@ -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:
+12 -1
View File
@@ -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
+15 -1
View File
@@ -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
View File
@@ -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):
+169 -15
View File
@@ -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:
+18 -9
View File
@@ -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
+25 -14
View File
@@ -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()