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
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.
65 lines
2.3 KiB
Python
65 lines
2.3 KiB
Python
"""Per-host OPNsense config.xml backup storage, on disk under
|
|
<backup_dir>/<host_id>/<timestamp>.xml — mirrors opnsense_secret_store.py's
|
|
convention (never in the database, settings-driven directory, chmod 600)
|
|
but keeps multiple retained snapshots per host instead of a single file.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
from app.services import secure_files
|
|
|
|
_SUFFIX = ".xml"
|
|
|
|
|
|
def _host_dir(host_id: uuid.UUID) -> Path:
|
|
return Path(settings.opnsense_backup_dir) / str(host_id)
|
|
|
|
|
|
def save_snapshot(host_id: uuid.UUID, content: bytes) -> str:
|
|
"""Writes a new snapshot, filename = sortable UTC timestamp with
|
|
microsecond precision to avoid collisions on rapid consecutive saves.
|
|
Returns the filename, used as the backup's id."""
|
|
host_dir = _host_dir(host_id)
|
|
host_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
filename = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + _SUFFIX
|
|
path = host_dir / filename
|
|
secure_files.write_private(path, content)
|
|
return filename
|
|
|
|
|
|
def list_snapshots(host_id: uuid.UUID) -> list[dict]:
|
|
"""Returns [{"id", "created_at", "size"}], newest first."""
|
|
host_dir = _host_dir(host_id)
|
|
if not host_dir.exists():
|
|
return []
|
|
items = []
|
|
for path in host_dir.glob(f"*{_SUFFIX}"):
|
|
stat = path.stat()
|
|
items.append({
|
|
"id": path.name,
|
|
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
|
"size": stat.st_size,
|
|
})
|
|
# Names are sortable timestamps: they break ties between files written within one mtime tick.
|
|
return sorted(items, key=lambda i: (i["created_at"], i["id"]), reverse=True)
|
|
|
|
|
|
def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
|
path = _host_dir(host_id) / backup_id
|
|
# backup_id comes straight from a URL path parameter — reject anything
|
|
# that would escape the host's own directory (e.g. "../../etc/passwd")
|
|
# before it ever reaches the filesystem.
|
|
if backup_id != path.name or not path.is_file():
|
|
return None
|
|
return path.read_bytes()
|
|
|
|
|
|
def delete_snapshot(host_id: uuid.UUID, backup_id: str) -> bool:
|
|
path = _host_dir(host_id) / backup_id
|
|
if backup_id != path.name or not path.is_file():
|
|
return False
|
|
path.unlink()
|
|
return True
|