- **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.
146 lines
6.4 KiB
Python
146 lines
6.4 KiB
Python
"""The shared write path of every "push to a firewall, then confirm or auto-revert"
|
|
feature (rules, aliases, port forwards, 1:1 NAT, outbound NAT, hierarchy rules).
|
|
|
|
Safety property: from the moment OPNexus may have changed something on a
|
|
firewall, a durable record exists that lets the revert sweeper (or an operator)
|
|
find and undo it — even if the reply is lost, the apply fails or the process
|
|
dies in between.
|
|
|
|
1. *Intent* is committed BEFORE the first remote call:
|
|
- existing deployment: status "pending" with the revert deadline armed and
|
|
the previous payload as rollback target (a crash leaves a normal,
|
|
sweepable pending change);
|
|
- first deployment (no remote UUID known yet): status "revert_failed" =
|
|
"outcome unknown, check the firewall" — the state that already blocks
|
|
retries/edits and raises a notification; "Rollback wiederholen" just
|
|
forgets the row when no UUID was ever recorded.
|
|
2. The write. A refusal the firewall answered restores the previous state; a
|
|
lost/ambiguous reply keeps the intent record (existing: deadline stays armed,
|
|
new: stays "unknown").
|
|
3. After a confirmed write the UUID is committed at once and the row becomes a
|
|
normal armed "pending" change. If applying it fails the row is "error" but
|
|
keeps its deadline, so the sweeper still reverts a possibly live change.
|
|
4. Only after a successful apply the confirmation window starts.
|
|
|
|
policy_lock (a transaction-scoped advisory lock) is released by the intermediate
|
|
commits and taken again right away; the intent row itself keeps other deploys
|
|
and the sweeper away in the gap (pending/unknown rows are never re-deployed).
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
from sqlalchemy import and_, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app import runtime_settings
|
|
from app.config import settings
|
|
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
|
|
|
UNKNOWN_OUTCOME = (
|
|
"Ausgang unklar: Die Firewall hat die Anfrage möglicherweise ausgeführt. Stand dort prüfen; "
|
|
"„Rollback wiederholen“ entfernt diesen Eintrag, ohne die Firewall anzusprechen."
|
|
)
|
|
|
|
_FIELDS = ("status", "message", "last_payload", "previous_payload", "revert_deadline", "deployed_at")
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def is_unsettled(row) -> bool:
|
|
"""A change that may still be live and whose rollback is not yet done: pending, outcome unknown, or an
|
|
"error" row whose revert deadline is still armed (written but apply/ordering not confirmed)."""
|
|
return row.status in ("pending", "revert_failed") or (row.status == "error" and row.revert_deadline is not None)
|
|
|
|
|
|
def unsettled(model):
|
|
"""``is_unsettled`` as a SQL condition for the deployment model."""
|
|
return or_(
|
|
model.status.in_(("pending", "revert_failed")),
|
|
and_(model.status == "error", model.revert_deadline.isnot(None)),
|
|
)
|
|
|
|
|
|
async def checkpoint(db: AsyncSession) -> None:
|
|
from app.services.policy_service import policy_lock
|
|
|
|
await db.commit()
|
|
await policy_lock(db)
|
|
|
|
|
|
async def push_to_host(
|
|
db: AsyncSession,
|
|
*,
|
|
existing: Any | None,
|
|
make: Callable[..., Any],
|
|
uuid_attr: str,
|
|
prior_uuid: str | None,
|
|
prior_live_payload: dict | None,
|
|
payload: dict,
|
|
write: Callable[[str | None], Awaitable[str]],
|
|
apply: Callable[[], Awaitable[Any]],
|
|
) -> Any:
|
|
window = timedelta(seconds=runtime_settings.value("deploy_confirm_window_seconds"))
|
|
before = {name: getattr(existing, name) for name in _FIELDS} if existing else None
|
|
# An armed "error" row (written, apply not confirmed) holds the UNCONFIRMED payload as last_payload: a retry must
|
|
# keep the original rollback target instead of promoting that payload to it.
|
|
armed = existing is not None and existing.status == "error" and existing.revert_deadline is not None
|
|
rollback_target = existing.previous_payload if armed else prior_live_payload
|
|
|
|
# 1. durable intent
|
|
if existing is not None:
|
|
row = existing
|
|
row.status, row.message = "pending", None
|
|
row.previous_payload, row.last_payload = rollback_target, payload
|
|
row.revert_deadline, row.deployed_at = _now() + window, _now()
|
|
else:
|
|
row = make(
|
|
status="revert_failed", message=UNKNOWN_OUTCOME, last_payload=payload, previous_payload=None,
|
|
revert_deadline=None, deployed_at=_now(), **{uuid_attr: None},
|
|
)
|
|
db.add(row)
|
|
# The intent is a technical safety record, not a state change worth a history
|
|
# entry of its own (the request itself is already audited as "requested").
|
|
row._skip_audit = True
|
|
await checkpoint(db)
|
|
row._skip_audit = False
|
|
|
|
wrote = False
|
|
try:
|
|
new_uuid = await write(prior_uuid)
|
|
wrote = True
|
|
# 3. recoverable by UUID from here on
|
|
setattr(row, uuid_attr, new_uuid)
|
|
row.status, row.message = "pending", None
|
|
row.previous_payload = rollback_target if existing is not None else None
|
|
row.last_payload, row.revert_deadline, row.deployed_at = payload, _now() + window, _now()
|
|
await checkpoint(db)
|
|
await apply()
|
|
except OpnsenseAPIError as exc:
|
|
ambiguous = isinstance(exc, OpnsenseAmbiguousError)
|
|
if wrote:
|
|
# written, apply unconfirmed: possibly live — status "error" but the deadline stays armed
|
|
row.status, row.message = "error", str(exc)
|
|
row.revert_deadline, row.deployed_at = _now() + window, _now()
|
|
elif existing is not None and ambiguous:
|
|
# the update may or may not have been applied: keep the armed rollback
|
|
row.status, row.message = "error", f"{exc} (Ausgang unklar, wird zurückgesetzt)"
|
|
row.deployed_at = _now()
|
|
elif existing is not None:
|
|
# a refusal the firewall answered: nothing changed, restore what was there
|
|
for name, value in before.items():
|
|
setattr(row, name, value)
|
|
row.status, row.message, row.deployed_at = "error", str(exc), _now()
|
|
elif ambiguous:
|
|
row.status, row.message = "revert_failed", f"{exc} — {UNKNOWN_OUTCOME}"
|
|
else:
|
|
row.status, row.message = "error", str(exc)
|
|
row.last_payload, row.revert_deadline = None, None
|
|
return row
|
|
|
|
# 4. confirmed: the confirmation window starts now
|
|
row.status, row.message = "pending", None
|
|
row.revert_deadline, row.deployed_at = _now() + window, _now()
|
|
return row
|