Files
OPNexus/backend/app/services/backup_schedule_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

137 lines
6.1 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.
"""Scheduled config backups: take, keep the newest N, copy encrypted offsite.
Called by a poll loop (main.py). Per enabled host a new snapshot is taken when the newest one
is older than the interval; the oldest beyond ``keep_count`` are deleted. When an offsite target
is set, every local snapshot that is not offsite yet is encrypted (backup_crypto) and uploaded
(s3_client); the ledger ``backup_uploads`` remembers what is already there. Nothing is ever
deleted offsite: use the bucket's lifecycle rules for that.
"""
import logging
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.backup_settings import BackupSettings, BackupUpload
from app.models.host import OpnsenseHost
from app.services import backup_crypto, backup_store, notification_secret_store
from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError
from app.services.s3_client import S3Client, S3Error
logger = logging.getLogger(__name__)
SINGLETON_ID = 1
S3_SECRET, PASSPHRASE = "backup_s3_secret", "backup_passphrase"
MAX_UPLOADS_PER_RUN = 20
async def get_settings(db: AsyncSession) -> BackupSettings:
row = await db.get(BackupSettings, SINGLETON_ID)
if row is None:
row = BackupSettings(
id=SINGLETON_ID, scheduled_enabled=True, interval_hours=24, keep_count=30, offsite_enabled=False,
s3_region="us-east-1", s3_prefix="opnexus", s3_path_style=True, s3_secret_set=False, passphrase_set=False,
)
db.add(row)
await db.flush()
return row
def offsite_ready(cfg: BackupSettings) -> bool:
return bool(cfg.offsite_enabled and cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and cfg.s3_secret_set and cfg.passphrase_set)
def s3_for(cfg: BackupSettings) -> S3Client:
secret = notification_secret_store.read_secret(S3_SECRET)
if not (cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and secret):
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten)")
return S3Client(endpoint=cfg.s3_endpoint, region=cfg.s3_region, bucket=cfg.s3_bucket, access_key=cfg.s3_access_key,
secret_key=secret, path_style=cfg.s3_path_style)
def remote_key(cfg: BackupSettings, host: OpnsenseHost, snapshot_id: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", host.name).strip("-") or "host"
prefix = cfg.s3_prefix.strip("/")
return "/".join(p for p in (prefix, f"{safe}-{str(host.id)[:8]}", snapshot_id.removesuffix(".xml") + ".opxbak") if p)
async def _take(host: OpnsenseHost) -> str:
content = await client.download_backup(host)
if b"<opnsense" not in content[:4096]:
raise OpnsenseAPIError("Antwort ist keine OPNsense-Konfiguration")
return backup_store.save_snapshot(host.id, content)
async def _prune(db: AsyncSession, host: OpnsenseHost, keep: int) -> int:
old = backup_store.list_snapshots(host.id)[keep:]
for snapshot in old:
backup_store.delete_snapshot(host.id, snapshot["id"])
if old:
rows = (await db.scalars(select(BackupUpload).where(BackupUpload.host_id == host.id, BackupUpload.snapshot_id.in_([s["id"] for s in old])))).all()
for row in rows:
await db.delete(row)
return len(old)
async def upload_pending(db: AsyncSession, cfg: BackupSettings, hosts: list[OpnsenseHost]) -> int:
"""Encrypt and upload local snapshots that are not offsite yet (newest first, bounded per run)."""
passphrase = notification_secret_store.read_secret(PASSPHRASE)
if not passphrase:
raise S3Error("Passphrase fehlt oder ist nicht lesbar – bitte neu setzen")
s3 = s3_for(cfg)
done, budget = 0, MAX_UPLOADS_PER_RUN
for host in hosts:
have = set((await db.scalars(select(BackupUpload.snapshot_id).where(BackupUpload.host_id == host.id))).all())
for snapshot in backup_store.list_snapshots(host.id):
if snapshot["id"] in have:
continue
if budget == 0:
return done
content = backup_store.read_snapshot(host.id, snapshot["id"])
if content is None:
continue
key = remote_key(cfg, host, snapshot["id"])
await s3.put(key, backup_crypto.encrypt(content, passphrase))
db.add(BackupUpload(host_id=host.id, snapshot_id=snapshot["id"], remote_key=key, uploaded_at=datetime.now(timezone.utc)))
await db.commit()
budget -= 1
done += 1
return done
async def run_due(db: AsyncSession, *, force: bool = False) -> dict:
cfg = await get_settings(db)
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
taken = failed = pruned = 0
if cfg.scheduled_enabled or force:
now = datetime.now(timezone.utc)
for host in hosts:
snapshots = backup_store.list_snapshots(host.id)
if not force and snapshots and now - snapshots[0]["created_at"] < timedelta(hours=cfg.interval_hours):
pruned += await _prune(db, host, cfg.keep_count)
continue
try:
await _take(host)
taken += 1
except OpnsenseAPIError as exc:
failed += 1
logger.warning("Geplantes Backup von %s fehlgeschlagen: %s", host.name, exc)
pruned += await _prune(db, host, cfg.keep_count)
cfg.last_run_at = now
uploaded = 0
if cfg.offsite_enabled:
try:
if not offsite_ready(cfg):
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten, Passphrase)")
uploaded = await upload_pending(db, cfg, hosts)
cfg.last_offsite_error = None
if uploaded or cfg.last_offsite_ok_at is None:
cfg.last_offsite_ok_at = datetime.now(timezone.utc)
except S3Error as exc:
cfg.last_offsite_error = str(exc)[:300]
logger.warning("Offsite-Backup: %s", exc)
await db.commit()
return {"hosts": len(hosts), "taken": taken, "failed": failed, "pruned": pruned, "uploaded": uploaded}