Files
OPNexus/backend/tests/test_full_backup.py
T
Julian Boldt b1a96c7563
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
Release v1.34.0
- **Zum Update:** Das erste Update auf diese Version übernimmt auch die `docker-compose.yml` (siehe unten; braucht wie die
  CLI-Aktualisierung `cosign` und `cosign.pub`), migriert die Datenbank (Revision 0032; ein Rollback spielt die Sicherung davor
  zurück) und stellt das Backend auf einen unprivilegierten Benutzer um. Nichts davon braucht einen Handgriff.
- **Nur ein Backend führt die Hintergrunddienste aus (HA, Stufe H0):** Laufen zwei Backends an derselben Datenbank, verschickten
  beide jeden Alarm und brachen Zyklen mit `UniqueViolation` ab. Jetzt führt nur der Prozess mit der Leader-Sperre in der
  Datenbank die neun Hintergrundschleifen aus; die anderen warten in Bereitschaft und übernehmen innerhalb einer halben Minute,
  wenn der Leader ausfällt. Verliert der Leader seine Datenbankverbindung, hält er alle Schleifen sofort an (geprüft alle 5 Sekunden
  und vor jedem Durchlauf). An einer Replica startet kein Leader. Rolle in `GET /api/health` und als Metrik `opnexus_leader`.
  Zwei gleichzeitige Erststarts legen das Admin-Konto nur einmal an.
- **Umzug und Kaltreserve (HA, Stufe H1):** `opnexus backup --full --with-env --with-ca` nimmt zusätzlich die `.env` und die
  interne CA des Proxys mit, `opnexus restore DATEI --with-env --with-ca` spielt sie zurück (ohne Datenbank-Zugang, Version und
  Adressen des Zielservers; die CA bleibt dieselbe, Browser vertrauen weiter). Neu: `opnexus migrate-host BENUTZER@ZIEL` zieht
  eine Installation in einem Schritt auf einen anderen Server um (Vorprüfung, Quelle anhalten, Sicherung mit Zufalls-Passphrase,
  Restore, Kontrolle). Im Lab 90 Sekunden mit drei Firewalls. `opnexus status` zeigt das Alter der letzten Vollsicherung.
  Handbuch: „Umzug und Notfall“.
- **Betriebseinstellungen im Webinterface:** Was bisher nur in der `.env` oder gar nur im Code stand, lässt sich unter
  Einstellungen › Betriebseinstellungen ändern: Sitzungsdauer und Anmeldesperren, Bestätigungsfrist beim Ausrollen, Zeitlimit für
  Firewall-Anfragen, Messintervalle und Aufbewahrung, Intervall der Versionsprüfung, Heartbeat-Adresse, CA des Mailservers (PEM
  einfügen statt Datei einbinden), Zeitlimit Mailversand. Mit Grenzen, Standardwert, Zurücksetzen und Audit-Eintrag. Ein Wert in der
  `.env` hat weiter Vorrang und wird als „vom Server“ angezeigt. Adressen, TLS und Datenbank-Zugang bleiben bewusst in der `.env`.
- **Heartbeat für einen externen Überwacher:** Die Heartbeat-Adresse (nur `https://`) wird nach jedem erfolgreichen Prüfzyklus
  aufgerufen; bleibt der Aufruf aus, schlägt der Überwacher Alarm. Die Adresse wird nie angezeigt oder protokolliert.
- **Updates bringen die Compose-Datei mit:** Bisher tauschte `opnexus update` nur die Images; Änderungen an der
  `docker-compose.yml` (neue Variablen, Härtung) kamen bei bestehenden Installationen nie an. Jetzt übernimmt das Update die Datei
  des Zielreleases aus dem signierten Updater-Image, sichert die alte als `docker-compose.yml.bak-<version>` und legt sie beim
  Rollback zurück. Eine hier angepasste Datei wird nicht überschrieben (neue Fassung als `docker-compose.yml.new`); eigene
  Anpassungen gehören in `docker-compose.override.yml`.
- **Alarm „Platte des OPNexus-Servers fast voll“** (standardmäßig an, ab 90 %, einstellbar): eine volle Platte hält die Datenbank
  an und damit jeden anderen Alarm. Auch im Dashboard und als Metrik `opnexus_server_disk_used_percent`.
- **Härtung aus einem externen Audit:**
  - **Kein root mehr in den Containern:** Backend (uid 10001) und Frontend (`node`) laufen unprivilegiert, ohne neue Rechte (`no-new-privileges`), das Frontend zusätzlich
    ohne Capabilities. Das Backend startet über `backend/scripts/start.sh` noch kurz als root, stellt die vorhandenen Datenvolumes einmalig um und gibt dann alle Rechte ab:
    ein Update braucht keinen Handgriff, ein Rückweg auf eine ältere Version bleibt möglich. Notausgang für Sonderfälle: `OPNEXUS_RUN_AS_ROOT=1` in der `.env`.
    `wg-hub` (braucht root für WireGuard) und `gui-gateway` (läuft schon als nobody) sind unverändert. Der Proxy (Caddy) und der optionale Updater (Docker-Socket) laufen weiter wie bisher.
  - **Vollsicherung konsistent:** Ändern sich Geheimnisse, während die Sicherung läuft (jemand legt gerade ein Objekt an oder löscht eines), wird sie bis zu dreimal wiederholt;
    gelingt das nicht, bricht sie mit einer klaren Meldung ab, statt ein unstimmiges Archiv zu schreiben.
  - **Zieladress-Schutz überall:** Audit-Export (Webhook und Syslog), Offsite-S3 und der Update-Feed verbinden nur noch zu Adressen, die die Prüfung bestanden haben
    (kein Loopback, Link-Local, Metadaten-Dienst; auch nicht über DNS-Rebinding oder Weiterleitungen). Adressen im eigenen Netz bleiben erlaubt. Der Update-Feed wird beim Lesen auf 1 MB begrenzt, nicht erst danach.
- **Fehlende Basis-Rechte werden sichtbar:** Fehlen dem API-Schlüssel schon die Rechte für die Grundmessungen (Speicher, Last, Platte), schlug das ganze Sampling still fehl und der
  Rechte-Alarm konnte nie auslösen. Jetzt entsteht ein Befund „Grundwerte“ (Recht „Lobby: Dashboard“), der sich nach Korrektur von selbst schließt.
- **Installation:** Die `INSTALL.txt` im Bundle und die Anleitung auf der Website legen die `.env` jetzt mit `install -m 600` an (nur für dich lesbar), statt mit `cp`.
- **Oberfläche:** Die Aktionsknöpfe der Backup-Tabelle auf der Host-Seite werden auf schmalen Bildschirmen als Symbole gezeigt, statt abgeschnitten zu werden.
2026-10-24 18:55:10 +02:00

186 lines
7.5 KiB
Python

"""Full OPNexus backup: round trip, integrity, refusal of hostile or newer archives."""
import io
import json
import os
import tarfile
from pathlib import Path
import pytest
from app import full_backup as fb
from app.config import settings
from app.services import backup_crypto
PASS = "full backup passphrase 1"
old_mtime = 1_700_000_000 # Nov 2023
@pytest.fixture
def data(tmp_path, monkeypatch):
"""Point every data directory at a scratch tree with recognisable content."""
for name, attr in fb._DATA_DIRS.items():
path = tmp_path / name
path.mkdir()
monkeypatch.setattr(settings, attr, str(path))
(tmp_path / "opnsense_secrets" / "host-1.secret").write_text("API-SECRET-1")
(tmp_path / "totp_secrets").joinpath("u.secret").write_text("TOTP-SECRET")
sub = tmp_path / "opnsense_backups" / "host-1"
sub.mkdir()
(sub / "20260101T000000.xml").write_text("<opnsense/>")
os.utime(sub / "20260101T000000.xml", (old_mtime, old_mtime))
return tmp_path
def _dump(path: Path) -> None:
path.write_bytes(b"PGDMP-fake-database")
def _create(**kw):
return fb.create(PASS, dump=_dump, revision="0009", **kw)
def test_roundtrip_restores_every_secret_and_the_database(data):
blob = _create()
assert b"API-SECRET-1" not in blob and b"TOTP-SECRET" not in blob and b"PGDMP" not in blob
# Wipe the data, then restore.
for f in data.rglob("*"):
if f.is_file():
f.unlink()
seen = {}
result = fb.restore(blob, PASS, restore_db=lambda p: seen.update(dump=p.read_bytes()), head="0012")
assert seen["dump"] == b"PGDMP-fake-database"
assert (data / "opnsense_secrets" / "host-1.secret").read_text() == "API-SECRET-1"
assert (data / "totp_secrets" / "u.secret").read_text() == "TOTP-SECRET"
assert (data / "opnsense_backups" / "host-1" / "20260101T000000.xml").read_text() == "<opnsense/>"
assert result["files_restored"] == 3 and result["db_revision"] == "0009"
mode = (data / "opnsense_secrets" / "host-1.secret").stat().st_mode & 0o777
assert mode == 0o600
# file times survive: stored config backups are aged by their mtime
assert abs((data / "opnsense_backups" / "host-1" / "20260101T000000.xml").stat().st_mtime - old_mtime) < 2
def test_info_shows_only_the_manifest_and_needs_the_passphrase(data):
blob = _create()
manifest = fb.info(blob, PASS)
assert manifest["format"] == 1 and manifest["db_revision"] == "0009" and "opnsense_secrets" in manifest["data"]
with pytest.raises(fb.FullBackupError, match="Passphrase"):
fb.info(blob, "wrong passphrase 123")
def test_short_passphrase_is_refused(data):
with pytest.raises(fb.FullBackupError, match="mindestens"):
fb.create("short", dump=_dump, revision="0009")
def test_tampered_or_foreign_files_are_refused(data):
blob = bytearray(_create())
blob[-5] ^= 0xFF
with pytest.raises(fb.FullBackupError):
fb.restore(bytes(blob), PASS, restore_db=lambda p: None, head="0012")
for junk in (b"", b"not a backup"):
with pytest.raises(fb.FullBackupError):
fb.restore(junk, PASS, restore_db=lambda p: None, head="0012")
# A valid encrypted file that is not a full backup (a single config backup, say).
with pytest.raises(fb.FullBackupError, match="Archiv unlesbar"):
fb.restore(backup_crypto.encrypt(b"<opnsense/>", PASS), PASS, restore_db=lambda p: None, head="0012")
def test_a_backup_from_a_newer_version_is_refused_before_anything_is_touched(data):
blob = fb.create(PASS, dump=_dump, revision="0020")
touched = []
with pytest.raises(fb.FullBackupError, match="neueren"):
fb.restore(blob, PASS, restore_db=lambda p: touched.append(p), head="0012")
assert touched == []
(data / "totp_secrets" / "u.secret").write_text("UNCHANGED")
assert (data / "totp_secrets" / "u.secret").read_text() == "UNCHANGED"
def _hostile(member_name: str, kind=tarfile.REGTYPE, link=""):
plain = io.BytesIO()
with tarfile.open(fileobj=plain, mode="w:gz") as tar:
manifest = json.dumps({"format": 1, "db_revision": "0001"}).encode()
info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
tar.addfile(info, io.BytesIO(manifest))
dump = tarfile.TarInfo("database.dump"); dump.size = 1
tar.addfile(dump, io.BytesIO(b"x"))
evil = tarfile.TarInfo(member_name); evil.type = kind; evil.linkname = link
evil.size = 4 if kind == tarfile.REGTYPE else 0
tar.addfile(evil, io.BytesIO(b"evil") if kind == tarfile.REGTYPE else None)
return backup_crypto.encrypt(plain.getvalue(), PASS)
@pytest.mark.parametrize("name,kind,link", [
("../../etc/cron.d/x", tarfile.REGTYPE, ""),
("/etc/passwd", tarfile.REGTYPE, ""),
("data/opnsense_secrets/../../escape", tarfile.REGTYPE, ""),
("data/opnsense_secrets/link", tarfile.SYMTYPE, "/etc/shadow"),
("data/opnsense_secrets/dev", tarfile.CHRTYPE, ""),
])
def test_hostile_archives_are_rejected_before_the_database_is_touched(data, name, kind, link):
touched = []
with pytest.raises(fb.FullBackupError, match="Unzulässig"):
fb.restore(_hostile(name, kind, link), PASS, restore_db=lambda p: touched.append(p), head="0012")
assert touched == [] and not (data.parent / "escape").exists()
def test_unknown_directories_in_the_archive_are_ignored(data, tmp_path):
blob = _hostile("data/not_a_known_volume/file")
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
assert not (tmp_path / "not_a_known_volume").exists()
def test_command_line_reads_the_passphrase_from_the_environment(data, monkeypatch, capsys):
blob = _create()
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", PASS)
class _In: # sys.stdin stand-in with a binary buffer
buffer = io.BytesIO(blob)
monkeypatch.setattr("sys.stdin", _In)
assert fb._main(["full_backup", "info"]) == 0
assert '"format": 1' in capsys.readouterr().out
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", "")
assert fb._main(["full_backup", "create"]) == 1
assert fb._main(["full_backup", "bogus"]) == 2
def test_a_secret_changing_during_the_backup_triggers_a_new_attempt(data):
calls = []
def dump(path: Path) -> None:
calls.append(1)
if len(calls) == 1: # ein Objekt wird mitten in der Sicherung angelegt
(data / "opnsense_secrets" / "host-2.secret").write_text("API-SECRET-2")
path.write_bytes(b"PGDMP-fake-database")
blob = fb.create(PASS, dump=dump, revision="0009")
assert len(calls) == 2
for f in data.rglob("*"):
if f.is_file():
f.unlink()
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
assert (data / "opnsense_secrets" / "host-2.secret").read_text() == "API-SECRET-2"
def test_a_backup_that_never_holds_still_is_refused_instead_of_written_inconsistent(data):
n = []
def dump(path: Path) -> None:
n.append(1)
(data / "totp_secrets" / f"u{len(n)}.secret").write_text("x")
path.write_bytes(b"PGDMP-fake-database")
with pytest.raises(fb.FullBackupError, match="Geheimnisse geändert"):
fb.create(PASS, dump=dump, revision="0009")
assert len(n) == fb.ATTEMPTS
def test_new_firewall_config_backups_do_not_count_as_a_change(data):
calls = []
def dump(path: Path) -> None:
calls.append(1)
(data / "opnsense_backups" / "host-1" / f"new-{len(calls)}.xml").write_text("<opnsense/>")
path.write_bytes(b"PGDMP-fake-database")
fb.create(PASS, dump=dump, revision="0009")
assert len(calls) == 1