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.
105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
"""Scheduled backups: status for everybody, settings and offsite test for admins, "run now" for operators."""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.errors import ErrorCode, api_error
|
|
from app.models.host import OpnsenseHost
|
|
from app.models.user import AdminUser
|
|
from app.routers.auth import current_user
|
|
from app.schemas.backup_settings import BackupSettingsRead, BackupSettingsUpdate, BackupStatusRead
|
|
from app.services import backup_diff_service, backup_store
|
|
from app.services import backup_schedule_service as svc
|
|
from app.services import notification_secret_store
|
|
from app.services.s3_client import S3Error
|
|
|
|
router = APIRouter(tags=["backups"])
|
|
|
|
|
|
def _require_admin(user: AdminUser) -> None:
|
|
if user.role != "admin":
|
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
|
|
|
|
|
@router.get("/backups/status", response_model=BackupStatusRead)
|
|
async def status(db: AsyncSession = Depends(get_db)):
|
|
return await svc.get_settings(db)
|
|
|
|
|
|
@router.post("/backups/run")
|
|
async def run_now(db: AsyncSession = Depends(get_db)):
|
|
"""Back up every enabled host now (and copy offsite if configured)."""
|
|
return await svc.run_due(db, force=True)
|
|
|
|
|
|
@router.get("/settings/backups", response_model=BackupSettingsRead)
|
|
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
return await svc.get_settings(db)
|
|
|
|
|
|
@router.put("/settings/backups", response_model=BackupSettingsRead)
|
|
async def update(payload: BackupSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
row = await svc.get_settings(db)
|
|
for field in ("scheduled_enabled", "interval_hours", "keep_count", "offsite_enabled", "s3_region", "s3_path_style"):
|
|
value = getattr(payload, field)
|
|
if value is not None:
|
|
setattr(row, field, value)
|
|
for field in ("s3_endpoint", "s3_bucket", "s3_access_key", "s3_prefix"):
|
|
value = getattr(payload, field)
|
|
if value is not None:
|
|
setattr(row, field, value.strip().rstrip("/") if field == "s3_endpoint" else value.strip())
|
|
if payload.clear_s3_secret:
|
|
notification_secret_store.delete_secret(svc.S3_SECRET)
|
|
row.s3_secret_set = False
|
|
elif payload.s3_secret_key is not None:
|
|
notification_secret_store.save_secret(svc.S3_SECRET, payload.s3_secret_key)
|
|
row.s3_secret_set = True
|
|
if payload.passphrase is not None:
|
|
notification_secret_store.save_secret(svc.PASSPHRASE, payload.passphrase)
|
|
row.passphrase_set = True
|
|
await db.commit()
|
|
return row
|
|
|
|
|
|
@router.post("/settings/backups/test-offsite")
|
|
async def test_offsite(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
"""Write, read back and delete a probe object in the bucket."""
|
|
_require_admin(user)
|
|
cfg = await svc.get_settings(db)
|
|
try:
|
|
key = "/".join(p for p in (cfg.s3_prefix.strip("/"), f".opnexus-test-{int(datetime.now(timezone.utc).timestamp())}") if p)
|
|
await svc.s3_for(cfg).test(key)
|
|
except S3Error as exc:
|
|
raise api_error(502, ErrorCode.OFFSITE_TEST_FAILED, reason=str(exc)) from exc
|
|
return {"success": True}
|
|
|
|
|
|
@router.get("/hosts/{host_id}/backup-diff")
|
|
async def backup_diff(
|
|
host_id: uuid.UUID,
|
|
a: str | None = Query(default=None, description="Older backup id (default: the one before b)"),
|
|
b: str | None = Query(default=None, description="Newer backup id (default: the newest)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""What changed between two stored backups of this host. Secrets are replaced by hashes."""
|
|
if await db.get(OpnsenseHost, host_id) is None:
|
|
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
|
snapshots = [s["id"] for s in backup_store.list_snapshots(host_id)] # newest first
|
|
new_id = b or (snapshots[0] if snapshots else None)
|
|
old_id = a or (snapshots[snapshots.index(new_id) + 1] if new_id in snapshots and snapshots.index(new_id) + 1 < len(snapshots) else None)
|
|
if not new_id or not old_id:
|
|
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
|
old, new = backup_store.read_snapshot(host_id, old_id), backup_store.read_snapshot(host_id, new_id)
|
|
if old is None or new is None:
|
|
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
|
try:
|
|
result = backup_diff_service.diff(old, new)
|
|
except backup_diff_service.InvalidConfig as exc:
|
|
raise api_error(422, ErrorCode.BACKUP_UNLESBAR, reason=str(exc)) from exc
|
|
return {"a": old_id, "b": new_id, **result}
|