mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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>
362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""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)
|