Files
OPNexus/backend/app/migrate.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

231 lines
9.1 KiB
Python

"""Schema-Migrationen beim Start (Alembic, in-process).
Ablauf von `run()` — bei jedem Backend-Start:
1. Session-weiten Postgres-Advisory-Lock nehmen. Starten zwei Instanzen
gleichzeitig, migriert nur eine, die andere wartet und sieht dann "schon
aktuell".
2. Zustand der Datenbank bestimmen:
- **frisch** (keine Tabellen): `alembic upgrade head` baut alles auf.
- **Altinstallation** (Tabellen, aber keine `alembic_version`; alles bis
1.8.x): Sicherheits-Backup, dann `create_all()` + die eingefrorenen
Legacy-Patches, auf Revision 0001 stempeln, `upgrade head`.
- **Alembic-Installation**: nur wenn die Revision hinter `head` liegt,
Sicherheits-Backup und `upgrade head`.
3. Lock freigeben.
Das Backup vor einer Migration ist Pflicht: schlägt `pg_dump` fehl, wird
nicht migriert und der Start bricht mit klarer Meldung ab. Wer das bewusst
nicht will, setzt SKIP_MIGRATION_BACKUP=true.
Eine Datenbank, die schon von einer NEUEREN Version migriert wurde (unbekannte
Revision), wird nicht angefasst — der Start bricht ab, statt mit einem Schema
zu laufen, das der Code nicht kennt. Rollback = das Pre-Upgrade-Backup
einspielen oder die neuere Version wieder starten.
"""
import asyncio
import importlib
import logging
import os
import pkgutil
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncConnection
import app.models
from app import runtime_settings
from app import legacy_schema_patches
from app.config import settings
from app.database import Base, engine
logger = logging.getLogger(__name__)
BACKEND_DIR = Path(__file__).resolve().parent.parent
BASELINE_REVISION = "0001"
# "OPNX" — beliebige, aber feste Zahl für den Advisory-Lock.
_LOCK_KEY = 0x4F504E58
# Hochvolumige, verzichtbare Betriebsdaten: nur das Schema sichern, sonst
# wird das Backup selbst zum Platzproblem (siehe docker-compose.yml,
# postgres-backup).
_BACKUP_SKIP_DATA = ("firewall_log_entries", "host_resource_samples")
_BACKUP_PREFIX = "pre-upgrade-"
_BACKUP_TIMEOUT_SECONDS = 1800
def load_models() -> None:
"""Jedes Modell-Modul importieren, sonst fehlen Tabellen in Base.metadata
(app/models/__init__.py importiert nicht alle)."""
for module in pkgutil.iter_modules(app.models.__path__):
importlib.import_module(f"app.models.{module.name}")
def _config() -> Config:
return Config(str(BACKEND_DIR / "alembic.ini"))
def _run_alembic(connection, action: str, revision: str) -> None:
"""Alembic-Kommando auf einer bereits offenen (sync-)Verbindung — env.py
nimmt sie aus config.attributes, damit alles in EINER Transaktion läuft."""
cfg = _config()
cfg.attributes["connection"] = connection
getattr(command, action)(cfg, revision)
async def _database_state(conn: AsyncConnection) -> tuple[bool, str | None]:
"""(hat_app_tabellen, aktuelle_alembic_revision oder None)."""
has_tables = (
await conn.execute(text("SELECT to_regclass('admin_users') IS NOT NULL"))
).scalar_one()
has_version = (
await conn.execute(text("SELECT to_regclass('alembic_version') IS NOT NULL"))
).scalar_one()
current = None
if has_version:
current = (await conn.execute(text("SELECT version_num FROM alembic_version"))).scalar()
return bool(has_tables), current
def _dump_to(target: Path) -> None:
url = make_url(settings.database_url)
partial = target.with_suffix(".partial")
cmd = [
"pg_dump",
"-h", url.host or "localhost",
"-p", str(url.port or 5432),
"-U", url.username or "",
"-d", url.database or "",
"-F", "c",
*(f"--exclude-table-data={table}" for table in _BACKUP_SKIP_DATA),
"-f", str(partial),
]
try:
subprocess.run(
cmd,
env={**os.environ, "PGPASSWORD": url.password or ""},
check=True,
capture_output=True,
text=True,
timeout=_BACKUP_TIMEOUT_SECONDS,
)
os.replace(partial, target)
finally:
partial.unlink(missing_ok=True)
def _pg_dump_major() -> int:
out = subprocess.run(["pg_dump", "--version"], check=True, capture_output=True, text=True).stdout
match = re.search(r"PostgreSQL\) (\d+)", out)
if not match:
raise RuntimeError(f"pg_dump-Version nicht erkennbar: {out.strip()!r}")
return int(match.group(1))
async def _ensure_pg_client_matches_server() -> None:
"""Ein pg_dump anderer Hauptversion als der Server schreibt Backups, die sich
nicht zurückspielen lassen (z. B. Client 17 → Server 16: `SET
transaction_timeout`). Lieber vor der Migration abbrechen als ein
Backup anlegen, auf das man sich nicht verlassen kann."""
async with engine.connect() as conn:
server_major = int((await conn.execute(text("SHOW server_version_num"))).scalar_one()) // 10000
client_major = await asyncio.to_thread(_pg_dump_major)
if client_major != server_major:
raise RuntimeError(
f"pg_dump im Backend-Image ist Version {client_major}, der Postgres-Server Version {server_major}. "
"Ein solches Backup ließe sich nicht zurückspielen. backend/scripts/install_pg_client.sh und "
"das postgres-Image in docker-compose.yml müssen dieselbe Hauptversion haben. Die Datenbank "
"wurde NICHT migriert (SKIP_MIGRATION_BACKUP=true überspringt Backup und diese Prüfung bewusst)."
)
def _prune_backups(directory: Path) -> None:
dumps = sorted(directory.glob(f"{_BACKUP_PREFIX}*.dump"), key=lambda p: p.stat().st_mtime)
for old in dumps[: max(0, len(dumps) - runtime_settings.value("migration_backup_keep"))]:
old.unlink(missing_ok=True)
async def _backup_before_migration(from_revision: str, to_revision: str) -> None:
if settings.skip_migration_backup:
logger.warning("Pre-Upgrade-Backup übersprungen (SKIP_MIGRATION_BACKUP=true)")
return
directory = Path(settings.migration_backup_dir)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
target = directory / f"{_BACKUP_PREFIX}{from_revision}-to-{to_revision}-{stamp}.dump"
logger.info("Schema-Migration %s → %s: sichere Datenbank nach %s", from_revision, to_revision, target)
await _ensure_pg_client_matches_server()
try:
directory.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(_dump_to, target)
_prune_backups(directory)
except Exception as exc:
detail = exc.stderr.strip() if isinstance(exc, subprocess.CalledProcessError) and exc.stderr else repr(exc)
raise RuntimeError(
f"Pre-Upgrade-Backup nach {target} fehlgeschlagen ({detail}). Die Datenbank wurde "
"NICHT migriert. Backup-Ziel prüfen oder mit SKIP_MIGRATION_BACKUP=true bewusst überspringen."
) from exc
async def _migrate() -> None:
script = ScriptDirectory.from_config(_config())
head = script.get_current_head()
async with engine.connect() as conn:
has_tables, current = await _database_state(conn)
if current is not None:
try:
script.get_revision(current)
except Exception as exc:
raise RuntimeError(
f"Die Datenbank hat Schema-Revision {current}, die diese OPNexus-Version nicht kennt — "
"sie wurde von einer neueren Version migriert. Die neuere Version wieder starten oder "
"das Pre-Upgrade-Backup einspielen."
) from exc
if current is None and not has_tables:
logger.info("Leere Datenbank: lege Schema an (Revision %s)", head)
async with engine.begin() as conn:
await conn.run_sync(_run_alembic, "upgrade", "head")
return
if current is None:
await _backup_before_migration("legacy", head)
logger.info("Datenbank aus der Zeit vor Alembic erkannt: Legacy-Brücke, dann Migration auf %s", head)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await legacy_schema_patches.apply(conn)
await conn.run_sync(_run_alembic, "stamp", BASELINE_REVISION)
await conn.run_sync(_run_alembic, "upgrade", "head")
return
if current == head:
logger.info("Datenbankschema ist aktuell (Revision %s)", head)
return
try: # the kept-backups count may be set in the web interface; an old schema without the table keeps the default
await runtime_settings.refresh()
except Exception:
pass
await _backup_before_migration(current, head)
logger.info("Migriere Datenbankschema %s → %s", current, head)
async with engine.begin() as conn:
await conn.run_sync(_run_alembic, "upgrade", "head")
async def run() -> None:
load_models()
async with engine.connect() as lock_conn:
await lock_conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": _LOCK_KEY})
try:
await _migrate()
finally:
await lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": _LOCK_KEY})
await lock_conn.commit()