Files
OPNexus/backend/app/services/backup_diff_service.py
T
Julian Boldt b2c69f51a0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.19.0
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende
(sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“.

- **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine
  Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit.
  Tokens eines Operators gehen bis zum Bereich „Ausrollen“.
- **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens
  einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`).
- **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM,
  Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen
  zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte
  `notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen
  AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus.
- **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer
  verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen).
- **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und
  Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle
  `rule_hits` (Migration `0012`).
- Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
2026-10-20 00:26:57 +02:00

56 lines
2.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Readable, redacted diff between two stored config backups of the same host.
Both files are parsed (defusedxml), the volatile ``revision`` block is dropped, and every leaf that
looks like a secret (password, key, token, PSK, community...) or is a long blob (certificates,
private keys) is replaced by a short hash. A changed secret therefore still shows up as a change,
but its value never leaves the server. Output is a unified diff, capped in size.
"""
import difflib
import hashlib
import re
from xml.etree.ElementTree import Element, indent, tostring
import defusedxml.ElementTree as safe_ET
MAX_LINES = 3000
MAX_BYTES = 32 * 1024 * 1024
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
_BLOB = 120
class InvalidConfig(ValueError):
pass
def _redact(root: Element) -> None:
for el in root.iter():
if len(el) == 0 and el.text and el.text.strip():
text = el.text.strip()
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
def normalise(xml_bytes: bytes) -> list[str]:
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
raise InvalidConfig("leer oder zu groß")
try:
root = safe_ET.fromstring(xml_bytes)
except Exception as exc: # defusedxml raises several parse/forbidden-entity types
raise InvalidConfig(type(exc).__name__) from None
if root.tag != "opnsense":
raise InvalidConfig("keine OPNsense-Konfiguration")
for volatile in root.findall("revision"):
root.remove(volatile)
_redact(root)
indent(root, space=" ")
return tostring(root, encoding="unicode").splitlines()
def diff(old: bytes, new: bytes) -> dict:
a, b = normalise(old), normalise(new)
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
return {"identical": not lines, "added": added, "removed": removed, "truncated": len(lines) > MAX_LINES, "lines": lines[:MAX_LINES]}