- **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.
186 lines
7.5 KiB
Python
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
|