commit e26b26eca6122ce658d3a97ffb4d12ec5d9abc26 Author: Julian Boldt Date: Wed Oct 7 09:34:26 2026 +0200 Release v1.10.1-rc2 Siehe CHANGELOG.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5601b64 --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +POSTGRES_PASSWORD=change-me +# Datenbank- und Benutzername (Standard: opnexus). Gelten nur beim ersten Start +# eines leeren Volumes. Eine bestehende Installation trägt hier ihre bisherigen +# Namen ein, sonst findet das Backend die Datenbank nicht (Handbuch, "Datenbank-Namen"). +# POSTGRES_DB=opnexus +# POSTGRES_USER=opnexus + +# Optional — if left unset, a random admin password is generated on first +# startup and printed once to `docker compose logs backend`. +ADMIN_USERNAME=admin +ADMIN_PASSWORD= + +# Required for any deployment reachable under a hostname other than +# localhost/127.0.0.1 — the exact origin (scheme://host[:port], no +# trailing slash) the frontend is actually served from, e.g. +# "https://opnexus.example.com" or "http://192.168.1.50:3100" (include +# the port unless a reverse proxy fronts this on the default 80/443). +# Without this, the backend only accepts credentialed requests from +# localhost on the default frontend port. +# Several origins (IP and name) may be listed comma-separated: +# FRONTEND_ORIGIN=https://10.0.0.5,https://opnexus.lan +# Behind the bundled TLS proxy requests are same-origin, so this only matters +# for cross-origin API clients. +FRONTEND_ORIGIN= + +# Addresses OPNexus is reached by through the TLS proxy (Caddy), comma- +# separated: the server's IP and/or names. No DNS needed - the proxy issues +# certificates from its own CA (import /opnexus-ca.crt once in your browser). +# OPNEXUS_SITES=10.0.0.5, opnexus.lan +OPNEXUS_SITES=localhost +# The primary address from the list above (normally the IP): used when a +# client connects by bare IP and sends no server name. +OPNEXUS_DEFAULT_SNI=localhost +# Interface the proxy listens on (0.0.0.0 = whole network). +PROXY_BIND_ADDRESS=0.0.0.0 + +# Host interface the backend/frontend ports are published on. Defaults +# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from +# other machines without a reverse proxy (see README's "Produktivbetrieb" +# for the safer TLS-proxy alternative), or to one specific interface IP. +BIND_ADDRESS= +# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt +# sind, z. B. bei einer zweiten Instanz auf demselben Rechner; Standard 8100/3100). +# BACKEND_PORT=8100 +# FRONTEND_PORT=3100 + +# Set to true once this is running behind TLS (see README's +# "Produktivbetrieb" section) — otherwise the session cookie is sent +# unencrypted and browsers won't even attach a Secure cookie without +# HTTPS in the first place. +COOKIE_SECURE=false + +# Set to true ONLY when running behind a reverse proxy you trust to +# always set/overwrite X-Forwarded-For itself (every example in README's +# "Produktivbetrieb" does). Used to attribute failed logins to the real +# client IP for the login lockout instead of the proxy's own IP. Leave +# false (default) if the app is reachable directly with no proxy in +# front — otherwise any client could spoof this header to dodge the +# per-IP lockout entirely. +TRUST_PROXY_HEADERS=false + +# ── Release-Images (statt lokalem Build) ────────────────────────────── +# Registry + Namespace, unter dem die OPNexus-Images liegen; daraus werden +# /opnexus-backend, /opnexus-frontend, /opnexus-proxy. Leer lassen = lokal bauen +# mit `docker compose up -d --build`. +# OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus +# Version fest pinnen (z. B. 1.9.0), statt automatisch "latest" zu folgen. +# OPNEXUS_VERSION=1.9.0 + +# ── Hinweis auf neue Versionen (optional) ───────────────────────────── +# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht +# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit +# User-Agent "OPNexus/". Admins sehen bei einer neueren Version einen +# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`. +# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json +# UPDATE_CHECK_INTERVAL_HOURS=12 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8885a18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +# Tests bei jedem Push/PR. Wird auch vom Release-Workflow wiederverwendet, +# damit ein Release-Tag nie Images baut, deren Tests nicht grün waren. +# +# Die Jobs laufen bewusst in Containern (`container:`): dann ist der +# Postgres-Service überall unter dem Namen "postgres" erreichbar — auf +# GitHub genauso wie auf einem Forgejo/Gitea-Runner im Docker-Modus. +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_call: + +jobs: + backend: + runs-on: ubuntu-latest + # Volles Image (nicht -slim): enthält git für actions/checkout. + container: python:3.12 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: opnexus + POSTGRES_PASSWORD: ci + # conftest.py erwartet eine DB, deren Name auf _test endet. + POSTGRES_DB: opnexus_test + options: >- + --health-cmd "pg_isready -U opnexus" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql+asyncpg://opnexus:ci@postgres:5432/opnexus_test + steps: + - uses: actions/checkout@v4 + # pg_dump/pg_restore 16 wie im Backend-Image (Dumps nur gleiche Hauptversion). + - run: backend/scripts/install_pg_client.sh 16 + - run: pip install -r backend/requirements.txt + - run: python -m pytest -q + working-directory: backend + + frontend: + runs-on: ubuntu-latest + container: node:20 + steps: + - uses: actions/checkout@v4 + - run: npm ci --no-audit --no-fund + working-directory: frontend + - run: npm run lint + working-directory: frontend + # next build prüft auch die Typen. + - run: npm run build + working-directory: frontend + + # Schnelle Konsistenzprüfungen, die ohne Datenbank und Build auskommen. + checks: + runs-on: ubuntu-latest + container: node:20 + steps: + - uses: actions/checkout@v4 + - run: python3 scripts/check_i18n.py + - run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..12bb1c6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +# Baut und veröffentlicht die Release-Images, wenn ein Tag vX.Y.Z gepusht wird. +# +# Bewusst registry-neutral: dieselbe Datei läuft auf GitHub (ghcr.io) und auf +# Forgejo/Gitea mit Actions (Forgejo liest .github/workflows, wenn es kein +# .forgejo/workflows gibt). Wohin gepusht wird, steht NICHT hier, sondern in +# den Repository-Einstellungen: +# Variablen: REGISTRY (Default ghcr.io, z. B. git.opnexus.dev) +# IMAGE_NAMESPACE (Default: Repo-Besitzer, kleingeschrieben) +# Secrets: REGISTRY_USERNAME (Default: der auslösende Benutzer) +# REGISTRY_TOKEN (Default: GITHUB_TOKEN — das reicht nur für +# ghcr.io; auf Forgejo ein Zugriffstoken mit +# Berechtigung "write:package" eintragen) +# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu +# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration. +# Ergebnis: //opnexus-{backend,frontend,proxy}: +# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen). +# +# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU- +# Emulation für arm64 erlaubt (privilegiert). +name: Release + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: scripts/check_version.sh "${GITHUB_REF_NAME%%-*}" + + test: + uses: ./.github/workflows/ci.yml + + publish: + needs: [verify, test] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: true + matrix: + service: [backend, frontend, proxy] + steps: + - uses: actions/checkout@v4 + + - name: Ziel bestimmen + id: target + env: + REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }} + NAMESPACE: ${{ vars.IMAGE_NAMESPACE || github.repository_owner }} + run: | + # Registry-Pfade müssen kleingeschrieben sein. + echo "image=$(echo "$REGISTRY/$NAMESPACE/opnexus-${{ matrix.service }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY || 'ghcr.io' }} + username: ${{ secrets.REGISTRY_USERNAME || github.actor }} + password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.target.outputs.image }} + # v1.9.0 -> 1.9.0, 1.9, 1 (+ latest); Vorabversionen bekommen nur + # den vollen Tag und nie "latest". + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.service }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e479aac --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +frontend/node_modules/ +frontend/.next/ +frontend/tsconfig.tsbuildinfo +frontend/next-env.d.ts +backend/__pycache__/ +backend/**/__pycache__/ +backend/.pytest_cache/ +.opnexus-update-state +dist/ +.public-repo/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0888f4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,369 @@ +# Changelog + +Alle nennenswerten Änderungen an OPNexus, mit Version und Datum. Für die +vollständige Feature-Beschreibung (Screenshots, Verifikationsnachweise, +v1-Scope-Grenzen) siehe die datierten Abschnitte in [Handbuch](docs/HANDBUCH.md) +— dieses Dokument ist die kurze Versions-/Namens-Übersicht, nicht der +Ersatz dafür. + +## Versionierung + +OPNexus folgt [Semantic Versioning](https://semver.org/lang/de/) +(`MAJOR.MINOR.PATCH`): + +- **MAJOR** — Breaking Changes (z.B. inkompatible API-/Schema-Änderungen, + entfernte Features). +- **MINOR** — neue Features, rückwärtskompatibel (der Regelfall bei einem + Projekt, das noch aktiv wächst). +- **PATCH** — Bugfixes und kleine Korrekturen ohne neues Feature. + +Zusätzlich bekommt **jede MINOR- und MAJOR-Version einen Codenamen** — +Raubvögel, alphabetisch aufsteigend über die Releases hinweg (ähnliches +Prinzip wie Ubuntus alliterierende Tier-Codenamen, nur ohne den doppelten +Anfangsbuchstaben-Zwang). Ein PATCH-Release behält den Codenamen seiner +MINOR-Version. Die aktuelle Version steht in `frontend/lib/version.ts` +(`APP_VERSION`/`APP_CODENAME`) und `backend/app/version.py` — beide von +Hand gepflegt, keine Build-Pipeline/kein Git-Hash-Tagging, bewusst analog +zur bisherigen Konvention (siehe Handbuch, "Login-Infos" 2026-09-09). Sichtbar +in der App auf der Login-Seite, im Footer jeder Seite, auf `/about` und in +den Einstellungen. + +Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version +ansteht): Merlin … + +## [1.10.1] "Lanner" — 2026-10-06 + +PATCH-Release (Bugfixes, Dokumentation, Lizenz/Repo-Umstellung) nach dem ersten Live-Test +gegen frische OPNsense-26.7-Firewalls; löst die Release Candidates +`1.10.0-rc1`/`-rc2` ab. + +- **Fehlerbehebung aus dem ersten Live-Test mit OPNsense 26.7:** + - Beschädigte HTTP-Antworten einer Firewall (bekannter kTLS-Fehler in + OPNsense 26.x) werden als solche gemeldet, mit dem Workaround + `kern.ipc.tls.enable=0` im Log (vorher nur „Verbindung fehlgeschlagen“). + - Die Log-Ingest-Warnung nennt den echten Grund statt pauschal „nicht + erreichbar?“ – die Firewall war in diesem Fall erreichbar. + - `BACKEND_PORT`/`FRONTEND_PORT` in der `.env`, damit eine zweite (Test-)Instanz + auf demselben Rechner ohne Änderung am Compose-File läuft. + - Neues Handbuch-Kapitel „Fehlerbehebung“ (kTLS, zweite Instanz, + `OUTBOUND_MODE_BLOCKS_PUSH`, Erkennungstakt der CARP-Historie) und zwei + Regressionstests. +- **Datenbank-Namen:** Standard für Datenbank und Benutzer ist jetzt `opnexus` + (`POSTGRES_DB`/`POSTGRES_USER` in der `.env`, auch von Backup-Dienst, + `opnexus`-Kommando und CI gelesen). Ältere Installationen tragen ihre + bisherigen Namen in der `.env` ein (Handbuch, „Datenbank-Namen“). Alte + Projektnamen sind aus Quelltext, Handbuch und Changelog entfernt. +- **Lizenzwechsel zur PolyForm Shield License 1.0.0** (siehe Handbuch, „Lizenz“): + Download, Quellcode-Einsicht und eigener Einsatz bleiben kostenlos, der + Weiterverkauf und konkurrierende Produkte durch Dritte sind ausgeschlossen. + Bereits unter AGPL-3.0 veröffentlichte Stände bleiben unter der AGPL. +- Website: „Open Source“-Aussagen entfernt, Texte zur Lizenz angepasst. +- **Öffentliches Repo** `git.opnexus.dev/opnexus/opnexus`: pro Release ein sauberer + Snapshot-Commit (`scripts/publish_public.sh`), Images in der Registry derselben + Gitea (`scripts/build_release_images.sh`). +- **Neue kurze `README.md`** (Englisch) und `README.de.md`; die ausführliche + Dokumentation ist jetzt das **Handbuch** `docs/HANDBUCH.md`. Dazu `SECURITY.md`. +- Update-Feed liegt unter `website/latest.json` (ausgeliefert als + `https://updates.opnexus.dev/latest.json`); Website-Auslieferung über + Cloudflare Pages (`website/_headers`). + +## [1.10.0] "Lanner" — 2026-10-05 + +Update-Fähigkeit, Teil 2: Release-Images (siehe Handbuch „Release-Images & CI“): + +- **CI-Workflows** (`.github/workflows/`): Tests bei Push/PR; bei einem Tag + `vX.Y.Z` baut der Release-Workflow Multi-Arch-Images (amd64/arm64) für + Backend, Frontend und Proxy und pusht sie. Die Registry ist reine + Repository-Konfiguration (GitHub oder Forgejo). +- **`docker-compose.yml`:** jeder Dienst hat `build:` und `image:` + (`OPNEXUS_IMAGE_REPO`/`OPNEXUS_VERSION`, Image-Namen `opnexus-backend`/`-frontend`/`-proxy`); Update aus Release-Images ist + `docker compose pull && docker compose up -d --no-build`. +- **Proxy-Image** mit eingebackener Caddyfile statt Bind-Mount. +- `scripts/check_version.sh` prüft Tag, Versionsdateien und CHANGELOG. + +Update-Fähigkeit, Teil 3: Update-Werkzeug (siehe Handbuch „Update und Rollback mit +`opnexus`“): + +- **`scripts/opnexus`** mit `status`, `update`, `rollback`, `backup`: Pull vor + dem Eingriff, Warten auf „gesund“, automatisches Zurückrollen, Rollback + schließt die Datenbank-Wiederherstellung aus dem Pre-Upgrade-Backup ein. +- **Behoben — Pre-Upgrade-Backup war nicht zurückspielbar:** das Backend-Image + hatte `pg_dump` 17 (Debian 13) gegen einen Postgres-16-Server, solche Dumps + lassen sich dort nicht einspielen. Das Image installiert jetzt den Client 16 + (`backend/scripts/install_pg_client.sh`); der Start bricht vor der Migration ab, + wenn Client- und Server-Hauptversion nicht übereinstimmen. Ein neuer Test + spielt das Backup tatsächlich zurück. +- `PROXY_HTTPS_PORT` und `PROXY_HTTP_PORT` machen die Proxy-Ports konfigurierbar. +- **Compose getrennt:** `docker-compose.yml` nur mit Images (= Release-Compose), + `docker-compose.override.yml` mit den Build-Kontexten für den Repo-Checkout. +- **`scripts/make_release_bundle.sh`** erzeugt ein Release-Bundle (Compose mit + eingebackener Registry/Version, `.env.example`, `opnexus`, Anleitung). +- ESLint ignoriert die generierte `next-env.d.ts`. + +Update-Fähigkeit, Teil 4: Hinweis auf neue Versionen: + +- **„Neue Version verfügbar“-Banner** für Administratoren. Quelle ist ein + Release-Feed im Format von GitHub/Forgejo `releases/latest` + (`UPDATE_CHECK_URL`); ohne URL ist die Funktion aus und OPNexus macht keine + ausgehende Anfrage. Neuer Endpunkt `GET /api/system/version-check` + (Ergebnis 12 Stunden gecacht, Fehler werden leise abgefangen und nicht + sofort wiederholt, Vorabversionen werden nie angeboten, nur http(s)-Links + werden weitergegeben). Pro Version wegklickbar. Aktualisiert wird nie + automatisch, sondern mit `opnexus update`. + +## [1.9.0] "Kite" — 2026-10-04 + +Update-Fähigkeit, Teil 1: Schema-Migrationen (siehe Handbuch „Schema-Migrationen & +Upgrade“): + +- **Alembic ersetzt `schema_patches.py`.** Das Schema wird versioniert + (`backend/migrations/`); `app/migrate.py` migriert beim Start unter einem + Advisory-Lock und in einer Transaktion. Frische Installationen bauen das + Schema aus der Baseline `0001` auf, Installationen bis 1.8.x werden einmalig + überbrückt und gestempelt (`0002` gleicht dabei fehlende Server-Defaults und + einen fehlenden Index an). +- **Automatisches Backup vor jeder Migration:** `pg_dump` nach + `/backups/pre-upgrade-…dump`; schlägt es fehl, wird nicht migriert. +- Eine Datenbank, die von einer neueren Version migriert wurde, wird nicht mehr + stillschweigend weiterbenutzt — der Start bricht mit klarer Meldung ab. +- `app/schema_patches.py` ist jetzt `app/legacy_schema_patches.py` + (eingefroren, nur noch die Brücke). +- Neue Tests (`tests/test_migrations.py`): Frisch-Aufbau gleicht den Modellen, + Altinstallation behält ihre Daten, Backup-Pflicht, parallele Starts. +- Server-Defaults, die bisher nur auf gepatchten Installationen existierten, + stehen jetzt auch in den Modellen. + +## [1.8.1] "Harris" — 2026-10-04 + +- **Laufende Aktionen sind sichtbar:** Während ein Push, Speichern oder eine + andere Aktion läuft, zeigt der Button jetzt einen drehenden Spinner vor dem + Text („Push läuft…“) statt nur den Text zu wechseln (neue Prop `loading` an + `Button`, gesetzt in allen Review-Dialogen, Formularen und Bestätigungen). + Lade-Hinweise („Wird geladen…“) nutzen die neue Komponente + `components/ui/Loading.tsx` mit Spinner. Bei „Bewegung reduzieren“ im + Betriebssystem bleibt der Spinner stehen. + +## [1.8.0] "Harris" — 2026-10-04 + +Barrierefreiheit (siehe Handbuch „Barrierefreiheit“): + +- **Kontrast:** Die Grautöne für Nebentext (`slate-400`/`slate-500`) erfüllen + im Hellmodus jetzt WCAG AA (4,5:1); Links in `sky-700` statt `sky-600`; + im Dunkelmodus war der aktive Menüpunkt hell auf hell (Kontrast 1,99) und + ist behoben. +- **Tastatur:** sichtbarer Fokusring für alle Elemente, „Zum Inhalt springen“- + Link, `aria-current` im Menü, Bereichsnavigation als eigene Landmarke, + scrollbare Tabellenbereiche per Tab erreichbar. +- **Screenreader:** sprechende Seitentitel pro Seite („Regeln · OPNexus“), + genau eine `h1` je Seite (Login und Über-Seite hatten keine), + korrekte Überschriftenreihenfolge in Regeln, Aliasen und Logs, Icons nur + dekorativ, leere Tabellenköpfe tragen „Aktionen“, Host-Status wird + jetzt übersetzt statt fest deutsch. +- Neues Prüfskript `scripts/verify_a11y.mjs` (axe-core, alle Seiten, Hell/ + Dunkel/Mobil): 0 Verstöße. + +## [1.7.0] "Goshawk" — 2026-10-04 + +Sichere Übertragung (siehe Handbuch „Sichere Übertragung“): + +- **HTTPS-Proxy (Caddy)** mit interner CA: Zertifikate auch für reine IPs, + Zugriff per IP und Namen ohne DNS, CA-Download unter `/opnexus-ca.crt`. + Backend/Frontend nur noch auf `127.0.0.1`; API same-origin. +- **Zertifikats-Pinning pro Firewall** (Hosts → Bearbeiten, Fingerprint- + Bestätigung); `verify_tls=false` ist nicht mehr nötig. Neue Spalte + `opnsense_hosts.tls_ca_pem`, Endpunkt `POST /api/hosts/tls-probe`. +- **Zwei-Faktor-Anmeldung (TOTP)** optional pro Benutzer mit + Wiederherstellungscodes, Replay-Schutz, Admin-Reset und Notfall-CLI + `python -m app.reset_2fa`. Neue Spalten `admin_users.totp_*`. +- **Origin-Prüfung** gegen CSRF, **Sicherheits-Header** (HSTS, CSP, …), + `FRONTEND_ORIGIN` akzeptiert mehrere Ursprünge, API-Antworten `no-store`. +- Login-Screen fragt bei aktivem 2FA den Code ab; Hosts zeigen ein Schild für + den TLS-Status. +- Abhängigkeiten: PostCSS-Lücken per `overrides` behoben (`npm audit`: 0), + `pip-audit`: keine bekannten Lücken; neue Abhängigkeiten `cryptography` + (Backend) und `qrcode` (Frontend). +- Tests laufen unabhängig von den Transport-Einstellungen in `.env`. +- Fehler behoben: Kehrte ein bereits gelöstes Problem zurück (z. B. „Rollback + fehlgeschlagen“), scheiterte die Benachrichtigungsprüfung an der + Unique-Constraint, und der Alarm ging verloren. Die Zeile wird jetzt + wiederverwendet (`notification_dedup.py`). + +## [1.6.0] "Peregrine" — 2026-10-04 + +Bedienbarkeit: Hinweise statt Textblöcke. + +- Neue Tooltip-Komponente (`components/ui/Tooltip.tsx`): erscheint bei Hover, + Tastaturfokus und Tippen, schließt mit Esc, wird nie von Karten oder + Dialogen abgeschnitten und funktioniert auch auf deaktivierten Buttons. +- Update Center: Die großen Hinweis- und Sperrgrund-Kästen sind entfallen. + Warum „Update vorbereiten“ gesperrt ist, steht jetzt im Tooltip des + Buttons (mit Schloss-Symbol); Intro, Backup-Regel, Neustart- und + CARP-Hinweise sind Tooltips bzw. kompakte Badges. Erfolgsmeldungen + erscheinen als Toast. Die Bestätigungsdialoge (Hostname + Häkchen) bleiben + unverändert, sind aber kürzer, fokussieren das Eingabefeld und sagen am + deaktivierten Button, was noch fehlt. +- Dauerhafte Hinweisabsätze auf Audit, Einstellungen, Host-Wartung, + Live-NAT, Review-Verläufen und in Formularen (Hierarchie, Private Key) + sind zu Info-Tooltips geworden (`FieldGroup` hat dafür ein `hint`). +- Zeilenaktionen vereinheitlicht (Regeln, NAT, Aliase, VPN, Hosts, Benutzer): + eine Hauptaktion („Prüfen & Push“), Bearbeiten als Outline-Button, Löschen + dezent in Rot, Verlauf/Duplizieren als Icon mit Tooltip. Deploy-Ergebnisse + stehen kompakt in einer Zeile. +- Seitenintros auf allen Seiten sind Info-Tooltips am Seitentitel; der + Push-Hinweis (Bestätigungsfrist, automatischer Rollback) ist eine kurze + Zeile mit Tooltip. +- Host-Detail: Live-Regeln und Live-Aliase sind einklappbar (mit Anzahl) und + scrollen intern; die Seite ist von ~10.900 px auf ~2.300 px geschrumpft. +- Restore-Wizard: ein Schritt statt zwei, Warnung bleibt sichtbar, Übergabe + nur nach Eingabe des Hostnamens. +- Dashboard-Aktivität zeigt lesbare Aktionsnamen („Update-Suche“ statt + `check-updates`); große Zahlen in den Logs sind mit Tausendertrennung. +- Log-Erfassung: neues Größenlimit in GB (Einstellungen → Log-Erfassung, + Standard 10 GB, 0 = unbegrenzt). Ist es erreicht, werden die ältesten + Einträge gelöscht; die Einstellungen zeigen den belegten Speicher mit + Balken. Neue Spalte `log_settings.max_size_gb` (per `schema_patches.py`). + Beschriftungen klargestellt: „gespeichert“ (ganze Tabelle) in den + Einstellungen, „Treffern“ (aktueller Filter) auf der Logs-Seite. +- UI-Korrekturen: Karten in Regel-/Alias-/NAT-Listen haben wieder Abstand + (`space-y` wirkte nicht über `fieldset.contents`), `Input`/`Select` + behalten ihre Standardstyles auch mit eigener `className`, alle Buttons + sind gleich hoch, Einstellungen mit klaren Feldabständen. +- Dark Mode jetzt in neutralem Anthrazit statt Navy-Blau (Skala zentral in + `globals.css`); Info-/Neutral-Badges, Toasts und der Sekundär-Button sind + entsättigt, der Dialog-Hintergrund ist ein dunkler Schleier. +- Login-Screen: animierter Netzwerk-Hintergrund (Knoten, Verbindungen, + wandernde Datenpakete – blau = erlaubt, gelegentlich orange = blockiert), + gestaffelte Eingangsanimation, Schütteln bei falschen Zugangsdaten, + Ausblenden nach erfolgreichem Login. Respektiert `prefers-reduced-motion` + (dann statisch) und pausiert bei verstecktem Tab. +- Dialoge halten den Tastaturfokus (Tab-Falle) und geben ihn beim Schließen + zurück; Login-Felder haben Autocomplete-Attribute. + +## [1.5.0] "Osprey" — 2026-09-19 + +Mehrsprachigkeit (Deutsch/Englisch) vollständig — siehe Handbuch, +"Mehrsprachigkeit (Deutsch/Englisch)": + +- Alle Seiten und Komponenten der Oberfläche sind auf Katalogschlüssel + umgestellt (vorher nur Login, Navigation, Dashboard, Hosts und ein Teil + der Regeln); Datums-/Zeitformate folgen der gewählten Sprache. +- API-Fehler bleiben sprachneutral (`detail: {code, params}`): die letzten + rohen `HTTPException`s in `services/` sowie die Middleware-Antworten + (401 „nicht angemeldet“, 403 „Nur-Lese“) liefern jetzt ebenfalls Codes. +- Deutsche Fließtexte in Erfolgsantworten (Deploy-Vorschau-Warnungen, + Wartungs-/Deploy-Meldungen, Revisionsnotizen, Validierungsfehler) werden im + Client über `frontend/lib/i18n/serverMessages.ts` ins Englische übersetzt. +- Neu: `scripts/check_i18n.py` prüft Katalog-Parität, Platzhalter, + `ErrorCode`-Abdeckung und referenzierte Schlüssel. +- `tsconfig.json`: `target` auf ES2018 (nur Typprüfung, `noEmit`) wegen + benannter Regex-Gruppen. + +378 Backend-Tests grün, `tsc --noEmit`/`next build` sauber. + +## [1.4.0] "Merlin" — 2026-09-17 + +Lizenz und dritte Härtungsrunde: + +- **AGPL-3.0** als Projektlizenz festgelegt — komplett kostenlos nutzbar, + Netzwerk-Klausel verhindert unveränderten/leicht angepassten Weiterverkauf + als proprietären Hosted-Dienst (siehe Handbuch, "Lizenz"). +- `/docs`, `/redoc`, `/openapi.json` erfordern jetzt eine Anmeldung statt + öffentlich erreichbar zu sein. +- Login-Sperrschutz kann jetzt optional (`TRUST_PROXY_HEADERS`) die echte + Client-IP aus `X-Forwarded-For` lesen, für den Betrieb hinter einem + Reverse-Proxy — standardmäßig aus, nur mit explizitem Vertrauen in den + Proxy zu aktivieren. + +378 Backend-Tests grün. + +## [1.3.0] "Kestrel" — 2026-09-17 + +Zweite Härtungsrunde, ausgelöst durch das Live-Rollout von 1.2.0 selbst +(siehe Handbuch, "Zweite Härtungsrunde"): + +- Keine rohen OPNsense-Fehlertexte mehr in 502-Antworten (Info-Leckage + an `read_only`-Nutzer). +- Healthchecks für Backend/Frontend ergänzt; dabei einen echten Next.js- + Bindungs-Bug gefunden und behoben (`HOSTNAME=0.0.0.0`). +- Nächtliches Backup der eigenen OPNexus-Datenbank (neuer + `postgres-backup`-Service), plus Fix für zu kleines Postgres-`/dev/shm`. +- Log-Erfassungs-Einstellungen (Intervall/Aufbewahrung) erstmals über + UI/API sicht- und editierbar; Default-Aufbewahrung 30→7 Tage. +- Realer Disk-Vorfall behoben: `firewall_log_entries` war bei den zwei + echten Firewalls in 9 Tagen auf ~9,7GB angewachsen, hat zusammen mit + Rebuilds zweimal kurz die Disk gefüllt — bereinigt, keine Datenverluste. + +375 Backend-Tests grün, `tsc --noEmit`/`next build` sauber. + +## [1.2.0] "Harrier" — 2026-09-17 + +Sicherheits- und Stabilitäts-Härtung vor dem geplanten öffentlichen +Release, aus einem vollständigen Audit (siehe Handbuch, "Sicherheits- und +Stabilitäts-Härtung vor dem öffentlichen Release"): + +- CORS-Default auf `localhost`/`127.0.0.1` verengt, neue `FRONTEND_ORIGIN`- + Variable für alle anderen Deployments. +- `POSTGRES_PASSWORD` ohne unsicheren Fallback — muss explizit gesetzt sein. +- Ports standardmäßig nur auf `127.0.0.1`, steuerbar über neue + `BIND_ADDRESS`-Variable statt manuellem Editieren von + `docker-compose.yml`. +- TLS-/Reverse-Proxy-Betrieb dokumentiert (Caddy-Beispiel). +- Backend-Logging aktiviert (war zuvor komplett stumm) + Log-Rotation für + alle Container. +- Neuer automatischer Schema-Patch-Mechanismus (`app/schema_patches.py`) + ersetzt die bisherigen manuellen `ALTER TABLE`-Schritte bei jedem Start. +- Reproduzierbare Builds: Frontend-Lockfile committet (`npm ci`), + Backend-Abhängigkeiten exakt gepinnt. + +Live auf der Produktivinstanz ausgerollt und verifiziert, 371 Backend-Tests +grün. + +## [1.1.0] "Goshawk" — 2026-09-17 + +Vier funktionale Lücken gegenüber DynFi Manager geschlossen (dem einzigen +echten Software-Wettbewerber in der Nische zentrales OPNsense-Fleet- +Management) — Multi-Tenancy/MSP-Betrieb bewusst ausgeklammert, Zielgruppe +bleiben Privatleute/kleine Unternehmen mit eigener Firewall: + +- **Direct-View-Link**: Ein-Klick-Button öffnet die native OPNsense-GUI + eines Hosts in einem neuen Tab. +- **Generische Dienst-Steuerung**: Beliebigen von OPNsense gemeldeten + Dienst direkt aus OPNexus starten/stoppen/neustarten. +- **Resource-Monitoring**: Kompakte 24h-Zeitverlaufs-Charts für CPU-Last, + Speicher- und Plattenbelegung je Host, eigene minimale + Inline-SVG-Sparkline-Komponente, live gegen die reale Flotte im + Hintergrund alle 60s abgetastet. +- **Geplante Wartung**: Neustart/Update/Major-Upgrade auf einen + zukünftigen Zeitpunkt statt sofort legen (einmalig, kein Recurring). + +14 neue Backend-Tests (371 gesamt grün). Siehe Handbuch, "Direct-View-Link, +Dienst-Steuerung, Resource-Monitoring, geplante Wartung". + +## [1.0.0] "Falcon" — 2026-09-17 + +Erstes Release unter dem neuen Versionsschema — markiert den aktuellen, +bereits sehr ausgereiften Stand des Projekts (siehe Handbuch für die lange +Vorgeschichte des Projekts). Fasst +zwei zuvor unversionierte Arbeits-Batches zusammen, die mit diesem Release +erstmals committet wurden: + +- **Fleet-Dashboard, neue UI-Bausteine, Konsistenz-Pass & Toast-Feedback** + (`/dashboard` als neue Startseite; `Badge`/`Tabs`/`Table`/`EmptyState`/ + `Toast`-Primitives; CSS-Variablen-basierter Dark Mode für die + Slate-Palette; Erfolgs-Toasts für praktisch alle schreibenden Aktionen + inkl. aller Deploy-Push-Overlays). Siehe Handbuch, "Fleet-Dashboard, neue + UI-Bausteine und Konsistenz-Pass". +- **CARP/HA vertieft**: Ereignis-Historie pro Cluster, Split-Brain-/ + Kein-Master-Alarm, NAT-Drift-Erkennung (Port Forward/1:1/Outbound), + optionaler Auto-Revert-Timer für den CARP-Wartungsmodus. Siehe Handbuch, + "CARP/HA vertiefen". +- **Versionierung & Update-System** (dieses Release selbst): Semantic + Versioning mit Raubvogel-Codenamen, `CHANGELOG.md`, Versionsanzeige an + vier Stellen in der App (Login, Footer, `/about`, Einstellungen), + Backend-`FastAPI`-App trägt dieselbe Version. Kleiner Nebenfund dabei + behoben: Login leitete nach erfolgreicher Anmeldung noch auf `/hosts` + weiter statt auf die seit dem Dashboard-Release neue Startseite + `/dashboard`. + +357 Backend-Tests grün, `tsc --noEmit`/`next build` sauber. Details, offene +Punkte und Verifikationsstand: siehe docs/HANDBUCH.md. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bfbb058 --- /dev/null +++ b/LICENSE @@ -0,0 +1,91 @@ +Required Notice: Copyright 2026 Julian Boldt (https://opnexus.dev) + +# PolyForm Shield License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Noncompete + +Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software. + +## Competition + +Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes. + +## New Products + +If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions. + +## Discontinued Products + +You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example: + +> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms) + +## Sales of Business + +If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +A **product** can be a good or service, or a combination of them. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates. + +**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with. + +**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..cf75f6d --- /dev/null +++ b/README.de.md @@ -0,0 +1,72 @@ +# OPNexus + +**Alle OPNsense-Firewalls. Eine Konsole.** +Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln, Aliase, NAT, VPN, Zertifikate, Logs und Updates — per OPNsense-API ausgerollt, mit Vorschau, Bestätigung und automatischem Rollback. + +[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md) + +![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp) + +> **Stand:** `1.10.1-rc2` ist der aktuelle öffentliche Release-Candidate (er löst `1.10.0-rc1`/`-rc2` ab). Entwickelt wurde gegen echte OPNsense-Instanzen, eine frische Installation aus den veröffentlichten Images ist aber noch nicht im Feld erprobt — bitte Ecken und Kanten melden. + +## Funktionen + +- **Flotte & Hochverfügbarkeit** — Live-Status aller Hosts, CARP-Rollen nebeneinander, Ereignis-Historie, Alarm bei Split-Brain oder fehlendem MASTER, Wartungsmodus mit optionalem Auto-Revert-Timer. +- **Regeln, Aliase & NAT** — zentral gepflegt, gezielt auf Hosts oder Gerätegruppen ausgerollt; Port-Forward, 1:1- und Outbound-NAT mit Drift-Erkennung zwischen Cluster-Knoten. +- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück. +- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume. +- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt. +- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt. +- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf. +- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus. +- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch. + +Alle Screenshots zeigen fiktive Beispieldaten. + +## Schnellstart + +Voraussetzung: Docker mit Compose-Plugin. + +```bash +git clone https://git.opnexus.dev/opnexus/opnexus.git +cd opnexus +cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen +docker compose up -d --build +``` + +Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben: + +```bash +docker compose logs backend | grep -A5 "generated password" +``` + +Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls). + +**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`: + +```bash +OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus +OPNEXUS_VERSION=1.10.1-rc2 +``` + +```bash +docker compose pull && docker compose up -d --no-build +``` + +Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen, vor Migrationen ein Datenbank-Backup, automatisches Zurückrollen, wenn die Dienste nicht gesund werden). + +## Datenschutz + +OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) ist **aus**, solange du ihn nicht setzt; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/` hinaus — ohne Instanz- oder Nutzerdaten. + +## Lizenz + +OPNexus ist **quelloffen einsehbar (source-available)** unter der [PolyForm Shield License 1.0.0](LICENSE) — es ist *keine* Open-Source-Software im Sinne der OSI. Du darfst den Code herunterladen, lesen, verändern und nutzen, auch in einem Unternehmen für die eigenen Firewalls. Nicht erlaubt ist, damit ein konkurrierendes Produkt anzubieten (etwa Weiterverkauf oder Betrieb als gehosteter Dienst). Maßgeblich ist der Lizenztext. + +## Sicherheit + +Sicherheitslücken bitte vertraulich an **security@opnexus.dev** melden, siehe [SECURITY.md](SECURITY.md). + +--- + +OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab67284 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# OPNexus + +**All your OPNsense firewalls. One console.** +Central management for multiple [OPNsense](https://opnsense.org) firewalls: rules, aliases, NAT, VPN, certificates, logs and updates — rolled out through the OPNsense API with preview, confirmation and automatic rollback. + +[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md) + +![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp) + +> **Status:** `1.10.1-rc2` is the current public release candidate (it replaces `1.10.0-rc1`/`-rc2`). It has been developed against real OPNsense instances, but a fresh install from the published images has not been field-tested yet — please expect rough edges and report them. + +## What it does + +- **Fleet & high availability** — live status of all hosts, CARP roles side by side, event history, alerts for split-brain or a missing MASTER, maintenance mode with an optional auto-revert timer. +- **Rules, aliases & NAT** — maintained centrally, rolled out to single hosts or device groups; port forward, 1:1 and outbound NAT with drift detection between cluster nodes. +- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back. +- **Hierarchical policies** — pre/post rulebases with inheritance across group trees. +- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out. +- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance. +- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail. +- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode. +- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI. + +![Cluster view](website/assets/screenshots/en/cluster-light-desktop.webp) + +All screenshots show fictitious sample data. + +## Quick start + +Requires Docker with the Compose plugin. + +```bash +git clone https://git.opnexus.dev/opnexus/opnexus.git +cd opnexus +cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD) +docker compose up -d --build +``` + +The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log: + +```bash +docker compose logs backend | grep -A5 "generated password" +``` + +For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German). + +**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`: + +```bash +OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus +OPNEXUS_VERSION=1.10.1-rc2 +``` + +```bash +docker compose pull && docker compose up -d --no-build +``` + +`scripts/opnexus update` / `rollback` then update the installation (pull first, database backup before migrations, automatic rollback if the services do not become healthy). + +## Privacy + +OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/` — no instance or user data. + +## License + +OPNexus is **source-available** under the [PolyForm Shield License 1.0.0](LICENSE) — it is *not* open source in the OSI sense. You may download, read, modify and use it, including inside a company for its own firewalls. You may not use it to provide a product that competes with OPNexus (for example reselling it or offering it as a hosted service). The license text is authoritative. + +## Security + +Please report vulnerabilities privately to **security@opnexus.dev**, see [SECURITY.md](SECURITY.md). + +--- + +OPNexus is an independent project and is not affiliated with Deciso B.V. or the OPNsense® project. OPNsense® is a trademark of Deciso B.V. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eece421 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security policy / Sicherheitshinweise + +**Please do not open a public issue for security problems.** Report them privately to **security@opnexus.dev** (German or English) with the affected version, steps to reproduce and the impact you see. Do not include real credentials or firewall data. + +I will acknowledge your report as soon as I can, keep you informed about the fix and credit you in the changelog if you wish. This is a one-person project, so please allow some days for a first reply. + +**Supported versions:** the latest release only. + +--- + +**Bitte keine öffentlichen Issues für Sicherheitsprobleme.** Meldungen vertraulich an **security@opnexus.dev** (Deutsch oder Englisch), mit betroffener Version, Schritten zur Reproduktion und der Auswirkung. Bitte keine echten Zugangsdaten oder Firewall-Daten mitschicken. + +Ich bestätige den Eingang so bald wie möglich, halte dich über die Behebung auf dem Laufenden und nenne dich auf Wunsch im Changelog. Es ist ein Ein-Personen-Projekt — für eine erste Antwort können ein paar Tage vergehen. + +**Unterstützte Versionen:** nur das jeweils neueste Release. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..01563d4 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# pg_dump/pg_restore für das automatische Sicherheits-Backup vor Schema- +# Migrationen (app/migrate.py) — bewusst in der Hauptversion des Servers +# (siehe das Skript für das Warum). +COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh +RUN /tmp/install_pg_client.sh 16 && rm /tmp/install_pg_client.sh + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..18aa170 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +# Alembic-Konfiguration. Die DB-URL steht bewusst NICHT hier — migrations/env.py +# liest sie aus app.config.settings (DATABASE_URL), genau wie die App selbst. +[alembic] +script_location = %(here)s/migrations +file_template = %%(rev)s_%%(slug)s +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth_middleware.py b/backend/app/auth_middleware.py new file mode 100644 index 0000000..6b2f1d4 --- /dev/null +++ b/backend/app/auth_middleware.py @@ -0,0 +1,89 @@ +import uuid +from app.services import audit_service + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from app.database import SessionLocal +from app.errors import ErrorCode +from app.routers.auth import COOKIE_NAME +from app.services import auth_service + +_PUBLIC_PATHS = { + "/api/auth/login", + "/api/auth/logout", + "/api/auth/me", + "/api/health", +} + +# /docs, /redoc and /openapi.json used to be public — fully documenting a +# firewall-fleet-management API surface to anyone unauthenticated is +# unnecessary recon for an attacker probing a public instance. Kept out +# of _PUBLIC_PATHS so a logged-in browser session (cookie sent +# automatically, same-origin) still sees them fine, anonymous requests +# now get the normal 401 like any other endpoint. + +# A read_only user can call any GET, plus these specific writes — both are +# purely self-scoped (only ever touch the caller's own row, never app +# config or another account) so blocking them would serve no security +# purpose while breaking normal self-service: change-password so a +# read_only account can always get out of a leaked/weak password, and +# PUT /settings because that endpoint only ever writes the caller's own +# UserPreferences.theme (see routers/settings.py) — a read_only user must +# still be able to pick light/dark/system for themselves. +_READ_ONLY_ALLOWED_WRITES = { + "/api/auth/change-password", + "/api/settings", + # Self-scoped: a user managing their own second factor. + "/api/auth/2fa/setup", + "/api/auth/2fa/enable", + "/api/auth/2fa/disable", +} + + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if request.url.path in _PUBLIC_PATHS: + return await call_next(request) + + token = request.cookies.get(COOKIE_NAME, "") + if token: + async with SessionLocal() as db: + user = await auth_service.resolve_session(db, token) + if user is not None: + if ( + user.role == "read_only" + and request.method not in ("GET", "HEAD", "OPTIONS") + and request.url.path not in _READ_ONLY_ALLOWED_WRITES + ): + return JSONResponse( + status_code=403, + content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}}, + ) + request.state.user = user + parts = request.url.path.strip("/").split("/") + kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"} + kind = kinds.get(parts[1]) if len(parts) > 1 else None + tracked = kind and request.method in ("POST", "PUT", "PATCH", "DELETE") and not request.url.path.endswith(("/preview", "/carp-preview", "/revisions")) + action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create") + token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": action}) + event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None, + "host_id": parts[2] if kind == "host" and len(parts) > 2 else None, + "details": {"method": request.method, "path": request.url.path}} + try: + if tracked: + await audit_service.persist(**event, status="requested") + response = await call_next(request) + if tracked: + await audit_service.persist(**event, status="accepted" if response.status_code < 400 else "rejected", + message=f"HTTP {response.status_code}") + return response + except Exception: + if tracked: + await audit_service.persist(**event, status="interrupted", message="Anfrage nicht abgeschlossen; Zustand vor Wiederholung prüfen.") + raise + finally: + audit_service.context.reset(token) + + return JSONResponse(status_code=401, content={"detail": {"code": ErrorCode.NICHT_AUTHENTIFIZIERT.value, "params": {}}}) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..d144f79 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,135 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + @property + def allowed_origins(self) -> list[str]: + return [o.strip().rstrip("/") for o in self.frontend_origin.split(",") if o.strip()] + + database_url: str = ( + "postgresql+asyncpg://opnexus:opnexus@postgres:5432/opnexus" + ) + # ── Schema migrations (app/migrate.py) ──────────────────────────── + # Vor jeder Migration an einer bestehenden Datenbank wird per pg_dump + # ein Sicherheits-Backup abgelegt (dasselbe Volume wie der + # postgres-backup-Service). Schlägt es fehl, wird NICHT migriert. + migration_backup_dir: str = "/backups" + # Nur für Entwicklung/Notfall: Backup überspringen. + skip_migration_backup: bool = False + migration_backup_keep: int = 5 + + # ── Update check (app/services/update_check_service.py) ─────────── + # Release-Feed im Format von GitHub/Forgejo `releases/latest`, z. B. + # https://api.github.com/repos///releases/latest + # https://git.example.com/api/v1/repos///releases/latest + # Leer = die Funktion ist aus: OPNexus macht dann KEINE ausgehende + # Anfrage. Gesendet wird nur ein GET mit User-Agent "OPNexus/", + # keine Instanz- oder Nutzerdaten. + update_check_url: str = "" + update_check_interval_hours: int = 12 + + # Cookies now carry the session, so this can no longer be a bare "*" + # (the Fetch spec forbids credentialed requests against a wildcard + # response) — see main.py's allow_origin_regex. Default only matches + # localhost/127.0.0.1 on the compose-default frontend port, i.e. local + # dev out of the box. A real deployment reachable under any other + # hostname MUST set FRONTEND_ORIGIN instead (see below) — a bare + # port-only regex would accept a credentialed cross-site request from + # *any* host serving on that port. + cors_origin_regex: str = r"^https?://(localhost|127\.0\.0\.1):3100$" + # Exact origin (scheme://host[:port], no trailing slash) of the + # deployed frontend, e.g. "https://opnexus.example.com". When set, + # this replaces cors_origin_regex entirely with an exact match — set + # it for any non-localhost deployment. + # Several origins may be given comma-separated, e.g. when OPNexus is + # reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan". + frontend_origin: str = "" + opnsense_secret_dir: str = "/data/opnsense-secrets" + opnsense_backup_dir: str = "/data/opnsense-backups" + opnsense_timeout: float = 10.0 + + # ── Auth ────────────────────────────────────────────────────────── + admin_username: str = "admin" + # Empty → bootstrap_admin() generates one and logs it once. + admin_password: str = "" + session_lifetime: int = 60 * 60 * 24 * 30 # 30 days, seconds + # No TLS termination in front of this tool by default (see README) — + # a Secure cookie would just never be sent. Set to true once HTTPS is + # in place. + cookie_secure: bool = False + login_max_attempts: int = 5 + login_ip_max_attempts: int = 20 + login_window_seconds: int = 300 + login_lockout_seconds: int = 300 + # Off by default: request.client.host is the actual TCP peer, safe to + # trust with no proxy in front. Behind a reverse proxy (see README's + # "Produktivbetrieb"), that peer is the proxy itself, collapsing every + # real client onto one IP for the login-throttle bucket — set this to + # true ONLY when the proxy is trusted to always set/overwrite + # X-Forwarded-For itself (every proxy setup in the README's example + # does). Never enable this if the app is reachable directly (no + # proxy) — any client could then spoof the header to dodge the + # per-IP lockout entirely. + trust_proxy_headers: bool = False + + # ── Deploy lockout protection ──────────────────────────────────── + deploy_confirm_window_seconds: int = 180 + + # ── Notifications ───────────────────────────────────────────────── + # Infra-only knobs (deployment concerns); delivery config itself + # (webhook URL, SMTP settings, per-event toggles, poll interval) is + # admin-editable via NotificationSettings — see app/models/notification.py. + notification_secret_dir: str = "/data/notification-secrets" + totp_secret_dir: str = "/data/totp-secrets" + # Floor against an admin setting an absurdly low poll interval, which + # would hammer both real firewalls with live status/cert/rule-drift + # calls every cycle. + notification_poll_min_interval_seconds: int = 60 + notification_smtp_timeout_seconds: float = 10.0 + + # ── Log ingest ──────────────────────────────────────────────────── + # Floor against an admin setting an absurdly low poll interval — same + # rationale as notification_poll_min_interval_seconds, but for the + # digest-based firewall-log ingest loop (see log_ingest_service.py). + # A single GET per enabled host per cycle, so a lower floor than + # notifications' is acceptable — this is the same call the log page + # already makes on-demand today, just automated. + log_poll_min_interval_seconds: int = 10 + + # ── IPsec VPN management ──────────────────────────────────────────── + # Pre-shared-key secrets, one file per tunnel id — same convention as + # opnsense_secret_dir above, see ipsec_psk_store.py. + ipsec_psk_secret_dir: str = "/data/ipsec-psk-secrets" + + # ── OpenVPN instance management ───────────────────────────────────── + # Static-key (tls-auth/tls-crypt hardening) key material, one file per + # instance id — same convention as ipsec_psk_secret_dir above, see + # openvpn_key_store.py. + openvpn_key_secret_dir: str = "/data/openvpn-key-secrets" + + # ── WireGuard tunnel management ───────────────────────────────────── + # Server private key + optional per-peer pre-shared key, both stored + # one-file-per-tunnel-id under this directory — same convention as + # ipsec_psk_secret_dir/openvpn_key_secret_dir above, see + # wireguard_key_store.py. + wireguard_key_secret_dir: str = "/data/wireguard-key-secrets" + + # ── Resource monitoring ────────────────────────────────────────────── + # Fixed interval, no admin-editable settings row/UI for this — v1 + # deliberately kept minimal (see resource_monitor_service.py). + resource_poll_interval_seconds: int = 60 + resource_sample_retention_days: int = 30 + + # ── Trust-Store + ACME certificate management ──────────────────────── + # Private-key material for action="import" ManagedCertificate rows, + # one file per entity id — same convention as ipsec_psk_secret_dir + # above, see cert_key_store.py. + cert_key_secret_dir: str = "/data/cert-key-secrets" + # External-Account-Binding HMAC for AcmeAccount rows, one file per + # entity id — see acme_secret_store.py. + acme_secret_dir: str = "/data/acme-secrets" + + model_config = SettingsConfigDict(case_sensitive=False) + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..031cb49 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_async_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db() -> AsyncSession: + async with SessionLocal() as session: + yield session diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..c7446c8 --- /dev/null +++ b/backend/app/errors.py @@ -0,0 +1,140 @@ +"""Stable, language-neutral errors returned by the HTTP API.""" +from enum import Enum +from typing import Any + +from fastapi import HTTPException + + +class ErrorCode(str, Enum): + BACKUP_EMPTY = "BACKUP_EMPTY" + BACKUP_INVALID_XML = "BACKUP_INVALID_XML" + BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE" + BACKUP_WRONG_ROOT = "BACKUP_WRONG_ROOT" + ACCOUNT_MUSS_ZUERST_AUF_DIE_FIREWALL_GESCHRIEBEN_WERDEN_EEEA04A9 = "ACCOUNT_MUSS_ZUERST_AUF_DIE_FIREWALL_GESCHRIEBEN_WERDEN_EEEA04A9" + ACCOUNT_NICHT_GEFUNDEN = "ACCOUNT_NICHT_GEFUNDEN" + ACCOUNT_WIRD_NOCH_VON_MINDESTENS_EINER_ZERTIFIKATSANFRAGE_REFERENZIERT = "ACCOUNT_WIRD_NOCH_VON_MINDESTENS_EINER_ZERTIFIKATSANFRAGE_REFERENZIERT" + ACME_ACCOUNT_GEHOERT_ZU_EINEM_ANDEREN_HOST = "ACME_ACCOUNT_GEHOERT_ZU_EINEM_ANDEREN_HOST" + ACME_ACCOUNT_NICHT_GEFUNDEN = "ACME_ACCOUNT_NICHT_GEFUNDEN" + ACTION_NOT_ALLOWED = "ACTION_NOT_ALLOWED" + AKTUELLES_PASSWORT_IST_FALSCH = "AKTUELLES_PASSWORT_IST_FALSCH" + ALIAS_CONTENT_DIFFERS = "ALIAS_CONTENT_DIFFERS" + ALIAS_HAT_KEINE_ZIEL_HOSTS = "ALIAS_HAT_KEINE_ZIEL_HOSTS" + ALIAS_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS = "ALIAS_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS" + ALIAS_NICHT_GEFUNDEN = "ALIAS_NICHT_GEFUNDEN" + ALIAS_NOCH_NICHT_BESTAETIGT = "ALIAS_NOCH_NICHT_BESTAETIGT" + ALIAS_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_5A2C1888 = "ALIAS_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_5A2C1888" + BACKUP_NICHT_GEFUNDEN = "BACKUP_NICHT_GEFUNDEN" + BENUTZERNAME_BEREITS_VERGEBEN = "BENUTZERNAME_BEREITS_VERGEBEN" + BENUTZER_NICHT_GEFUNDEN = "BENUTZER_NICHT_GEFUNDEN" + CARP_VORSCHAU_NICHT_GEFUNDEN = "CARP_VORSCHAU_NICHT_GEFUNDEN" + CARP_ZUSTAND_WURDE_VERAENDERT_VORSCHAU_ERNEUT_OEFFNEN = "CARP_ZUSTAND_WURDE_VERAENDERT_VORSCHAU_ERNEUT_OEFFNEN" + DAS_NEUE_PASSWORT_MUSS_SICH_VOM_AKTUELLEN_UNTERSCHEIDEN = "DAS_NEUE_PASSWORT_MUSS_SICH_VOM_AKTUELLEN_UNTERSCHEIDEN" + DEPLOYMENT_NICHT_GEFUNDEN = "DEPLOYMENT_NICHT_GEFUNDEN" + DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN = "DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN" + DER_EIGENE_ACCOUNT_KANN_NICHT_GELOESCHT_WERDEN = "DER_EIGENE_ACCOUNT_KANN_NICHT_GELOESCHT_WERDEN" + EINE_REGEL_DARF_IN_DER_REIHENFOLGE_NUR_EINMAL_F05DF47C = "EINE_REGEL_DARF_IN_DER_REIHENFOLGE_NUR_EINMAL_F05DF47C" + EINTRAG_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_B5A5CE87 = "EINTRAG_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_B5A5CE87" + EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN = "EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN" + ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C = "ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C" + ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN = "ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN" + ERROR_1_1_NAT_REGEL_NICHT_GEFUNDEN = "ERROR_1_1_NAT_REGEL_NICHT_GEFUNDEN" + FIRMWARE_AUFTRAG_LAEUFT_BEREITS_ODER_ZUSTAND_IST_UNBEKANNT = "FIRMWARE_AUFTRAG_LAEUFT_BEREITS_ODER_ZUSTAND_IST_UNBEKANNT" + FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_PRE_SHARED_4AE1FC63 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_PRE_SHARED_4AE1FC63" + FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998" + FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR" + GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN" + GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS" + GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN" + GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN" + GRUPPE_HAT_NOCH_UNTERGRUPPEN_DIESE_ZUERST_VERSCHIEBEN_ODER_9FFFD853 = "GRUPPE_HAT_NOCH_UNTERGRUPPEN_DIESE_ZUERST_VERSCHIEBEN_ODER_9FFFD853" + GRUPPE_HAT_NOCH_ZUGEORDNETE_REGELN_DIESE_ZUERST_VERSCHIEBEN_2CCBC48B = "GRUPPE_HAT_NOCH_ZUGEORDNETE_REGELN_DIESE_ZUERST_VERSCHIEBEN_2CCBC48B" + GRUPPE_NICHT_GEFUNDEN = "GRUPPE_NICHT_GEFUNDEN" + GRUPPE_WIRD_NOCH_VON_ALIASEN_VERWENDET = "GRUPPE_WIRD_NOCH_VON_ALIASEN_VERWENDET" + GRUPPE_WIRD_NOCH_VON_NAT_REGELN_VERWENDET = "GRUPPE_WIRD_NOCH_VON_NAT_REGELN_VERWENDET" + GRUPPE_WIRD_NOCH_VON_REGELN_VERWENDET = "GRUPPE_WIRD_NOCH_VON_REGELN_VERWENDET" + HOSTNAMEN_EXAKT_EINGEBEN = "HOSTNAMEN_EXAKT_EINGEBEN" + HOSTZUSTAND_WURDE_VERAENDERT_VORSCHAU_ERNEUT_OEFFNEN = "HOSTZUSTAND_WURDE_VERAENDERT_VORSCHAU_ERNEUT_OEFFNEN" + HOST_HAT_EINE_OFFENE_WARTUNGSAKTION_ZUERST_PRUEFEN_UND_8CC5FBE0 = "HOST_HAT_EINE_OFFENE_WARTUNGSAKTION_ZUERST_PRUEFEN_UND_8CC5FBE0" + HOST_HAT_NOCH_AKTIVE_ALIAS_DEPLOYMENTS_DIESE_ZUERST_F4A3ED46 = "HOST_HAT_NOCH_AKTIVE_ALIAS_DEPLOYMENTS_DIESE_ZUERST_F4A3ED46" + HOST_HAT_NOCH_AKTIVE_POLICY_HIERARCHIE_DEPLOYMENTS_DIESE_ADD78845 = "HOST_HAT_NOCH_AKTIVE_POLICY_HIERARCHIE_DEPLOYMENTS_DIESE_ADD78845" + HOST_HAT_NOCH_AKTIVE_REGEL_DEPLOYMENTS_DIESE_ZUERST_0BF41721 = "HOST_HAT_NOCH_AKTIVE_REGEL_DEPLOYMENTS_DIESE_ZUERST_0BF41721" + HOST_HAT_NOCH_NAT_DEPLOYMENTS_DIESE_ZUERST_GEZIELT_0C2A9E85 = "HOST_HAT_NOCH_NAT_DEPLOYMENTS_DIESE_ZUERST_GEZIELT_0C2A9E85" + HOST_IST_DEAKTIVIERT = "HOST_IST_DEAKTIVIERT" + HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS = "HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS" + HOST_MUSS_ERREICHBAR_SEIN_UND_FIRMWARE_AUFTRAG_BEENDET_BCE1390B = "HOST_MUSS_ERREICHBAR_SEIN_UND_FIRMWARE_AUFTRAG_BEENDET_BCE1390B" + HOST_NICHT_GEFUNDEN = "HOST_NICHT_GEFUNDEN" + HOST_REQUEST_FAILED = "HOST_REQUEST_FAILED" + INSTANZ_HAT_KEINE_ZIEL_HOSTS = "INSTANZ_HAT_KEINE_ZIEL_HOSTS" + INSTANZ_NICHT_GEFUNDEN = "INSTANZ_NICHT_GEFUNDEN" + INSTANZ_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_DB923B59 = "INSTANZ_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_DB923B59" + INTERFACE_MEANING_DIFFERS = "INTERFACE_MEANING_DIFFERS" + KEINE_OFFENE_WARTUNGSAKTION = "KEINE_OFFENE_WARTUNGSAKTION" + KEIN_OFFENER_RESTORE_VORSCHAU_VORGANG = "KEIN_OFFENER_RESTORE_VORSCHAU_VORGANG" + KEIN_OFFENER_UEBERGEBENER_RESTORE_VORGANG = "KEIN_OFFENER_UEBERGEBENER_RESTORE_VORGANG" + MAINTENANCE_WAIT_REQUIRED = "MAINTENANCE_WAIT_REQUIRED" + MINDESTENS_EINE_REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_932925BD = "MINDESTENS_EINE_REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_932925BD" + MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN = "MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN" + NICHT_AUTHENTIFIZIERT = "NICHT_AUTHENTIFIZIERT" + CROSS_ORIGIN_BLOCKED = "CROSS_ORIGIN_BLOCKED" + TLS_PROBE_FAILED = "TLS_PROBE_FAILED" + TLS_PEM_INVALID = "TLS_PEM_INVALID" + TLS_PIN_MISMATCH = "TLS_PIN_MISMATCH" + TOTP_REQUIRED = "TOTP_REQUIRED" + TOTP_INVALID = "TOTP_INVALID" + TOTP_ALREADY_ENABLED = "TOTP_ALREADY_ENABLED" + TOTP_NOT_ENABLED = "TOTP_NOT_ENABLED" + TOTP_SETUP_MISSING = "TOTP_SETUP_MISSING" + NORMALBETRIEB_NOCH_NICHT_BESTAETIGT_WARTUNGSMODUS_MUSS_AUS_UND_8AF9290B = "NORMALBETRIEB_NOCH_NICHT_BESTAETIGT_WARTUNGSMODUS_MUSS_AUS_UND_8AF9290B" + NUR_AUSSTEHENDE_GEPLANTE_WARTUNGEN_KOENNEN_ABGEBROCHEN_WERDEN = "NUR_AUSSTEHENDE_GEPLANTE_WARTUNGEN_KOENNEN_ABGEBROCHEN_WERDEN" + NUR_FRUEHERE_AKTIVE_ZIELE_KOENNEN_BEREINIGT_WERDEN = "NUR_FRUEHERE_AKTIVE_ZIELE_KOENNEN_BEREINIGT_WERDEN" + NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB = "NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB" + OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN = "OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN" + OFFENES_DEPLOYMENT_ZUERST_KLAEREN = "OFFENES_DEPLOYMENT_ZUERST_KLAEREN" + ORDERING_FAILED = "ORDERING_FAILED" + OUTBOUND_NAT_REGEL_NICHT_GEFUNDEN = "OUTBOUND_NAT_REGEL_NICHT_GEFUNDEN" + PEER_PRE_SHARED_KEY_IST_AKTIVIERT_ABER_ES_FCFAC129 = "PEER_PRE_SHARED_KEY_IST_AKTIVIERT_ABER_ES_FCFAC129" + POLICY_HIERARCHIE_GRUPPE_NICHT_GEFUNDEN = "POLICY_HIERARCHIE_GRUPPE_NICHT_GEFUNDEN" + PORT_FORWARD_REGEL_NICHT_GEFUNDEN = "PORT_FORWARD_REGEL_NICHT_GEFUNDEN" + REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203 = "REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203" + REGEL_HAT_KEINE_ZIEL_HOSTS = "REGEL_HAT_KEINE_ZIEL_HOSTS" + REGEL_NICHT_GEFUNDEN = "REGEL_NICHT_GEFUNDEN" + REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0" + RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN" + ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL" + ROLLENWECHSEL_NOCH_NICHT_BESTAETIGT_DIESER_HOST_MUSS_IM_4676309B = "ROLLENWECHSEL_NOCH_NICHT_BESTAETIGT_DIESER_HOST_MUSS_IM_4676309B" + RULEBASE_NICHT_GEFUNDEN = "RULEBASE_NICHT_GEFUNDEN" + SICHERUNG_NICHT_GEFUNDEN = "SICHERUNG_NICHT_GEFUNDEN" + SICHERUNG_WURDE_SEIT_DER_VORSCHAU_VERAENDERT_ODER_IST_2732C4DD = "SICHERUNG_WURDE_SEIT_DER_VORSCHAU_VERAENDERT_ODER_IST_2732C4DD" + TLS_HARDENING_IST_AKTIVIERT_ABER_ES_WURDE_NOCH_F53C81DD = "TLS_HARDENING_IST_AKTIVIERT_ABER_ES_WURDE_NOCH_F53C81DD" + TOO_MANY_LOGIN_ATTEMPTS = "TOO_MANY_LOGIN_ATTEMPTS" + TUNNEL_HAT_KEINE_ZIEL_HOSTS = "TUNNEL_HAT_KEINE_ZIEL_HOSTS" + TUNNEL_NICHT_GEFUNDEN = "TUNNEL_NICHT_GEFUNDEN" + TUNNEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_50830F70 = "TUNNEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_50830F70" + UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN = "UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN" + UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT = "UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT" + UPSTREAM_REQUEST_FAILED = "UPSTREAM_REQUEST_FAILED" + VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN = "VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN" + VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN = "VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN" + VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN = "VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN" + VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET = "VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET" + WARTUNGSAKTION_NICHT_GEFUNDEN = "WARTUNGSAKTION_NICHT_GEFUNDEN" + WARTUNGSVORSCHAU_NICHT_GEFUNDEN = "WARTUNGSVORSCHAU_NICHT_GEFUNDEN" + ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN = "ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN" + ZERTIFIKATSANFRAGE_MUSS_ZUERST_AUF_DIE_FIREWALL_GESCHRIEBEN_WERDEN_F684C449 = "ZERTIFIKATSANFRAGE_MUSS_ZUERST_AUF_DIE_FIREWALL_GESCHRIEBEN_WERDEN_F684C449" + ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN = "ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN" + ZERTIFIKATSANFRAGE_WURDE_NOCH_NICHT_AUF_DIE_FIREWALL_GESCHRIEBEN = "ZERTIFIKATSANFRAGE_WURDE_NOCH_NICHT_AUF_DIE_FIREWALL_GESCHRIEBEN" + ZERTIFIKAT_NICHT_GEFUNDEN = "ZERTIFIKAT_NICHT_GEFUNDEN" + ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN = "ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN" + ZIEL_HOST_NICHT_GEFUNDEN = "ZIEL_HOST_NICHT_GEFUNDEN" + OUTBOUND_MODE_UNREADABLE = "OUTBOUND_MODE_UNREADABLE" + OUTBOUND_MODE_BLOCKS_PUSH = "OUTBOUND_MODE_BLOCKS_PUSH" + GROUP_CANNOT_BE_OWN_PARENT = "GROUP_CANNOT_BE_OWN_PARENT" + GROUP_HIERARCHY_CYCLE = "GROUP_HIERARCHY_CYCLE" + OPEN_DEPLOYMENT_DRAFT_UNCHANGED = "OPEN_DEPLOYMENT_DRAFT_UNCHANGED" + OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY = "OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY" + OPEN_RESTORE_BLOCKS_DEPLOY = "OPEN_RESTORE_BLOCKS_DEPLOY" + READ_ONLY_ACCESS = "READ_ONLY_ACCESS" + + +def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException: + return HTTPException(status_code=status_code, detail={"code": code.value, "params": params}) diff --git a/backend/app/legacy_schema_patches.py b/backend/app/legacy_schema_patches.py new file mode 100644 index 0000000..ec9bdc4 --- /dev/null +++ b/backend/app/legacy_schema_patches.py @@ -0,0 +1,69 @@ +"""EINGEFROREN — nur noch die Brücke für Installationen aus der Zeit vor Alembic. + +Bis 1.8.x hat OPNexus Schema-Änderungen an bestehenden Tabellen über diese +idempotenten `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`-Statements nach +`create_all()` nachgezogen. Ab 1.9.0 verwaltet Alembic das Schema (siehe +migrations/ und app/migrate.py); diese Datei wird nur noch ein einziges Mal +ausgeführt: beim ersten Start einer Datenbank, die Tabellen, aber noch keine +`alembic_version` hat. Danach wird sie auf Revision 0001 gestempelt. + +REGELN: +- NIE wieder einen Eintrag hinzufügen. Neue Schema-Änderungen sind eine + Alembic-Migration: `alembic revision --autogenerate -m "..."`. +- Keinen Eintrag ändern oder entfernen: die Brücke muss jede Datenbank von + 1.2.0 bis 1.8.x auf das 1.8.1-Schema bringen. +- Die Brücke ruft `create_all()` mit den AKTUELLEN Modellen auf, um fehlende + Tabellen anzulegen. Das ist nur korrekt, solange diese Tabellen dem + Baseline-Schema entsprechen. Installationen älter als 1.8.1 müssen deshalb + erst auf 1.9.x aktualisiert werden, bevor sie ein späteres Release + überspringen (siehe README, "Upgrade"). +""" + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection + +_STATEMENTS: tuple[str, ...] = ( + # 2026-09-08 — cluster identity for CARP-aware fleet grouping. + "ALTER TABLE opnsense_hosts ADD COLUMN IF NOT EXISTS cluster_name VARCHAR(120)", + # 2026-09-08 — deploy lockout / auto-revert protection for rule pushes. + "ALTER TABLE rule_deployments ADD COLUMN IF NOT EXISTS revert_deadline TIMESTAMPTZ", + "ALTER TABLE rule_deployments ADD COLUMN IF NOT EXISTS previous_payload JSON", + "ALTER TABLE rule_deployments ADD COLUMN IF NOT EXISTS last_payload JSON", + # 2026-09-08 — hierarchical policy grouping, one column per rule-shaped + # table (host + the four alias/NAT tables that can belong to a group). + "ALTER TABLE opnsense_hosts ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL", + "ALTER TABLE firewall_aliases ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL", + "ALTER TABLE port_forward_rules ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL", + "ALTER TABLE one_to_one_rules ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL", + "ALTER TABLE outbound_nat_rules ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL", + # 2026-09-09 — per-session login security info shown post-login. + "ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45)", + # 2026-09-10 — role-based access control (admin / read_only). Default + # keeps every pre-existing account (until then, necessarily a single + # admin) as "admin" with no manual backfill needed. + "ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'admin'", + # 2026-09-15 — CARP health / NAT drift / CARP auto-revert alerting + # toggles on the single existing NotificationSettings row. + "ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS carp_health_enabled BOOLEAN NOT NULL DEFAULT true", + "ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS nat_drift_enabled BOOLEAN NOT NULL DEFAULT true", + "ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS carp_auto_revert_failed_enabled BOOLEAN NOT NULL DEFAULT true", + # 2026-09-17 — per-user UI language (German remains the default). + "ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS language VARCHAR(5) NOT NULL DEFAULT 'de'", + # 2026-10-04 — size cap for stored firewall logs (GB, 0 = unlimited). + # 2026-10-04 — optional TOTP second factor. + "ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false", + "ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS totp_last_step BIGINT NOT NULL DEFAULT 0", + "ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS totp_recovery_hashes JSON", + # 2026-10-04 — pinned TLS certificate per firewall. + "ALTER TABLE opnsense_hosts ADD COLUMN IF NOT EXISTS tls_ca_pem TEXT", + "ALTER TABLE log_settings ADD COLUMN IF NOT EXISTS max_size_gb DOUBLE PRECISION NOT NULL DEFAULT 10", +) + + +async def apply(conn: AsyncConnection) -> None: + """Run every statement, in order, inside the caller's connection/ + transaction. Postgres DDL is transactional, so a failure partway + through rolls back cleanly rather than leaving the schema half-patched. + """ + for statement in _STATEMENTS: + await conn.execute(text(statement)) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5318b3d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,357 @@ +import asyncio +import logging +import re +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app import models # noqa: F401 - registers models with Base.metadata +from app.auth_middleware import AuthMiddleware +from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddleware +from app.config import settings +from app.database import SessionLocal +from app import migrate +from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router +from app.routers import one_to_one +from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments +from app.routers import outbound_nat +from app.services.outbound_nat_deploy_service import sweep_expired_outbound_nat_deployments +from app.services import auth_service, audit_service +from app.routers import audit, maintenance, settings as settings_router, notifications as notifications_router +from app.routers import system as system_router +from app.services.rule_deploy_service import sweep_expired_deployments +from app.services.alias_deploy_service import sweep_expired_alias_deployments +from app.services.port_forward_deploy_service import sweep_expired_port_forward_deployments +from app.services import notification_service, notification_settings_service +from app.routers import policy_hierarchy, policy_hierarchy_groups +from app.services.policy_hierarchy_deploy_service import sweep_expired_hierarchy_deployments +from app.services import log_ingest_service, log_settings_service +from app.routers import ipsec as ipsec_router +from app.routers import openvpn as openvpn_router +from app.routers import wireguard as wireguard_router +from app.routers import certificates as certificates_router +from app.routers import restore as restore_router +from app.version import APP_VERSION +from app.routers import dashboard as dashboard_router +from app.routers import cluster as cluster_router +from app.routers import services as services_router +from app.routers import resource_samples as resource_samples_router +from app.services import resource_monitor_service +from app.routers import scheduled_maintenance as scheduled_maintenance_router +from app.services import scheduled_maintenance_service +from app.services import carp_event_service, carp_maintenance_window_service + +# Without this, Python's root logger defaults to WARNING with no handler, +# so every logger.info() call in the 5 background loops below (CARP role +# changes, log-ingest counts, sweep results, ...) is silently dropped — +# `docker compose logs backend` then shows only uvicorn's own access log, +# even after days of the loops actually running. Only warning/exception +# calls survived before this (e.g. the one-time generated-admin-password +# banner), which made debugging "is X even running" needlessly hard. +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) + +logger = logging.getLogger(__name__) + +_SWEEP_INTERVAL_SECONDS = 10 +# Prune retention-expired log rows once every N poll cycles rather than +# every tick — cleanup has no reason to run at poll cadence (default +# 30s), and a DELETE scanning by timestamp on every cycle would be wasted +# work once the table is non-trivially sized. +_LOG_PRUNE_EVERY_N_CYCLES = 20 +# Same rationale as _LOG_PRUNE_EVERY_N_CYCLES, but the resource-sampling +# loop's own cadence (settings.resource_poll_interval_seconds, default 60s). +_RESOURCE_PRUNE_EVERY_N_CYCLES = 20 + + +async def _deploy_revert_sweep_loop(): + """Background safety net for the deploy lockout protection: reverts any + RuleDeployment whose confirm window passed without a human confirming + (see rule_deploy_service.sweep_expired_deployments).""" + while True: + try: + async with SessionLocal() as db: + reverted = await sweep_expired_deployments(db) + if reverted: + logger.info("Auto-reverted %d unconfirmed rule deployment(s)", len(reverted)) + except Exception: + logger.exception("Deploy-revert sweep failed") + try: + async with SessionLocal() as db: + reverted = await sweep_expired_alias_deployments(db) + if reverted: + logger.info("Auto-reverted %d unconfirmed alias deployment(s)", len(reverted)) + except Exception: + logger.exception("Alias deploy-revert sweep failed") + try: + async with SessionLocal() as db: + reverted = await sweep_expired_port_forward_deployments(db) + if reverted: + logger.info("Auto-reverted %d unconfirmed port-forward deployment(s)", len(reverted)) + except Exception: + logger.exception("Port-forward deploy-revert sweep failed") + try: + async with SessionLocal() as db: + reverted = await sweep_expired_outbound_nat_deployments(db) + if reverted: + logger.info("Auto-reverted %d unconfirmed outbound-nat deployment(s)", len(reverted)) + except Exception: + logger.exception("Outbound NAT deploy-revert sweep failed") + try: + async with SessionLocal() as db: + reverted = await sweep_expired_one_to_one_deployments(db) + if reverted: + logger.info("Auto-reverted %d unconfirmed one-to-one deployment(s)", len(reverted)) + except Exception: + logger.exception("1:1 NAT deploy-revert sweep failed") + try: + async with SessionLocal() as db: + reverted = await carp_maintenance_window_service.sweep_expired(db) + if reverted: + logger.info("CARP-Auto-Revert: %d Zeitfenster verarbeitet", len(reverted)) + except Exception: + logger.exception("CARP-Auto-Revert-Sweep fehlgeschlagen") + try: + async with SessionLocal() as db: + reverted = await sweep_expired_hierarchy_deployments(db) + if reverted: + logger.info("Auto-reverted %d unconfirmed policy-hierarchy deployment(s)", len(reverted)) + except Exception: + logger.exception("Policy-hierarchy deploy-revert sweep failed") + await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) + + +async def _notification_poll_loop(): + """Separate, slower loop from the deploy-revert sweep above: this one + makes live HTTP calls to every real firewall per cycle (host status, + certificates, live rules for drift), so it must not run at the sweep's + 10s cadence — that cadence is a security control for the deploy-confirm + window, not applicable here. Interval is admin-configurable via + NotificationSettings.poll_interval_seconds, floored by + settings.notification_poll_min_interval_seconds.""" + while True: + interval = settings.notification_poll_min_interval_seconds + try: + async with SessionLocal() as db: + await notification_service.run_notification_checks(db) + row = await notification_settings_service.get_settings(db) + interval = max(row.poll_interval_seconds, settings.notification_poll_min_interval_seconds) + except Exception: + logger.exception("Notification poll failed") + try: + async with SessionLocal() as db: + events = await carp_event_service.detect_and_record(db) + for event in events: + logger.info( + "CARP-Rollenwechsel auf %s: %s -> %s", event.host_name, event.from_status, event.to_status, + ) + except Exception: + logger.exception("CARP-Ereignis-Erkennung fehlgeschlagen") + await asyncio.sleep(interval) + + +async def _log_ingest_poll_loop(): + """Third background loop, mirroring _notification_poll_loop's shape: + a live GET call (diagnostics/firewall/log) to every enabled host per + cycle, so it runs on its own admin-configurable cadence rather than + the 10s deploy-revert sweep's. Interval comes from + LogSettings.poll_interval_seconds, floored by + settings.log_poll_min_interval_seconds. Retention pruning runs only + every _LOG_PRUNE_EVERY_N_CYCLES cycles (see constant above).""" + cycle = 0 + while True: + interval = settings.log_poll_min_interval_seconds + cycle += 1 + try: + async with SessionLocal() as db: + row = await log_settings_service.get_settings(db) + interval = max(row.poll_interval_seconds, settings.log_poll_min_interval_seconds) + enabled = row.enabled + retention_days = row.retention_days + max_size_gb = row.max_size_gb + await db.commit() + + if enabled: + result = await log_ingest_service.poll_all_hosts(db) + if result["inserted"]: + logger.info( + "Log-Ingest: %d neue Einträge über %d Host(s) (%d fehlgeschlagen)", + result["inserted"], result["hosts_polled"], result["hosts_failed"], + ) + + if cycle % _LOG_PRUNE_EVERY_N_CYCLES == 0: + deleted = await log_ingest_service.prune_old_entries(db, retention_days) + if deleted: + logger.info("Log-Retention: %d alte Einträge gelöscht", deleted) + trimmed = await log_ingest_service.enforce_size_limit(db, max_size_gb) + if trimmed: + logger.info("Log-Größenlimit (%.1f GB): %d älteste Einträge gelöscht", max_size_gb, trimmed) + except Exception: + logger.exception("Log-Ingest-Poll fehlgeschlagen") + await asyncio.sleep(interval) + + +async def _resource_poll_loop(): + """Fourth background loop, same shape as _log_ingest_poll_loop but with + a fixed interval (settings.resource_poll_interval_seconds) — v1 + deliberately has no admin-editable settings row for this, see + resource_monitor_service.py's module docstring.""" + cycle = 0 + while True: + cycle += 1 + try: + async with SessionLocal() as db: + result = await resource_monitor_service.poll_all_hosts(db) + if result["hosts_failed"]: + logger.warning( + "Resource-Sampling: %d von %d Host(s) fehlgeschlagen", + result["hosts_failed"], result["hosts_polled"], + ) + if cycle % _RESOURCE_PRUNE_EVERY_N_CYCLES == 0: + deleted = await resource_monitor_service.prune_old_samples(db, settings.resource_sample_retention_days) + if deleted: + logger.info("Resource-Sample-Retention: %d alte Einträge gelöscht", deleted) + except Exception: + logger.exception("Resource-Poll fehlgeschlagen") + await asyncio.sleep(settings.resource_poll_interval_seconds) + + +_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60 + + +async def _scheduled_maintenance_poll_loop(): + """Fifth background loop: fires due one-shot ScheduledMaintenance rows. + A fixed 60s cadence is plenty — these are human-picked timestamps, not + a sub-minute-precision cron.""" + while True: + try: + async with SessionLocal() as db: + result = await scheduled_maintenance_service.fire_due(db) + if result["due"]: + logger.info( + "Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen", + result["due"], result["fired"], result["blocked_or_failed"], + ) + except Exception: + logger.exception("Geplante-Wartung-Poll fehlgeschlagen") + await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await migrate.run() + async with SessionLocal() as db: + await auth_service.bootstrap_admin(db) + + sweep_task = asyncio.create_task(_deploy_revert_sweep_loop()) + notification_task = asyncio.create_task(_notification_poll_loop()) + log_ingest_task = asyncio.create_task(_log_ingest_poll_loop()) + resource_task = asyncio.create_task(_resource_poll_loop()) + scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop()) + try: + yield + finally: + sweep_task.cancel() + notification_task.cancel() + log_ingest_task.cancel() + resource_task.cancel() + scheduled_maintenance_task.cancel() + for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task): + try: + await task + except asyncio.CancelledError: + pass + + +app = FastAPI(title="OPNexus", version=APP_VERSION, lifespan=lifespan) + +app.add_middleware(AuthMiddleware) +# Inside CORS so preflights are answered first; outside Auth so a forged +# cross-site write is rejected before any session lookup happens. +app.add_middleware(OriginCheckMiddleware) +app.add_middleware(SecurityHeadersMiddleware) +# Added last so it wraps outermost (Starlette runs the last-added +# middleware first) — CORS must see and answer OPTIONS preflight requests +# before AuthMiddleware ever gets a chance to 401 them. +# An explicit FRONTEND_ORIGIN (required for any non-localhost deployment, +# see config.py) is matched exactly, never as a regex fragment — avoids +# turning operator-supplied input into an accidental regex hole. +_cors_origin_regex = ( + "^(" + "|".join(re.escape(o) for o in settings.allowed_origins) + ")$" + if settings.allowed_origins else settings.cors_origin_regex +) + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=_cors_origin_regex, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + # Browsers hide all but a few standard response headers cross-origin + # by default — the backup-download endpoint's filename (carried in + # Content-Disposition) needs this to reach the frontend's fetch(). + expose_headers=["Content-Disposition"], +) + +app.include_router(auth.router) +app.include_router(hosts.router, prefix="/api") +app.include_router(rules.router, prefix="/api") +app.include_router(search_router.router, prefix="/api") +app.include_router(users_router.router, prefix="/api") +app.include_router(logs.router, prefix="/api") +app.include_router(groups.router, prefix="/api") +app.include_router(aliases.router, prefix="/api") +app.include_router(port_forward.router, prefix="/api") + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} + +app.include_router(one_to_one.router, prefix="/api") + +app.include_router(outbound_nat.router, prefix="/api") + +app.include_router(audit.router, prefix="/api") + +app.include_router(maintenance.router, prefix="/api") + +app.include_router(settings_router.router, prefix="/api") +app.include_router(system_router.router, prefix="/api") + +app.include_router(notifications_router.router, prefix="/api") + +app.include_router(policy_hierarchy_groups.router, prefix="/api") +app.include_router(policy_hierarchy.router, prefix="/api") + +# No background sweep loop for this router — see +# ipsec_deploy_service.py's module docstring: there is no pending/ +# auto-revert state here to sweep. +app.include_router(ipsec_router.router, prefix="/api") + +# Same as above — no sweep loop, see openvpn_deploy_service.py's module +# docstring (identical no-pending/auto-revert rationale as IPsec). +app.include_router(openvpn_router.router, prefix="/api") + +# Same as above — no sweep loop, see wireguard_deploy_service.py's module +# docstring (identical no-pending/auto-revert rationale as IPsec/OpenVPN). +app.include_router(wireguard_router.router, prefix="/api") + +# Trust-Store + ACME certificate management — single-host entities (no +# multi-host targeting, see the models' own docstrings), no sweep loop +# either: Trust-Store has no restart/apply step to auto-revert at all, and +# ACME's register/sign are one-shot external-network triggers, not +# config-write-then-reconfigure flows with a "config live, reload +# unconfirmed" middle state to sweep. +app.include_router(certificates_router.managed_router, prefix="/api") +app.include_router(certificates_router.acme_accounts_router, prefix="/api") +app.include_router(certificates_router.acme_certificates_router, prefix="/api") +app.include_router(restore_router.router, prefix="/api") +app.include_router(dashboard_router.router, prefix="/api") +app.include_router(cluster_router.router, prefix="/api") +app.include_router(services_router.router, prefix="/api") +app.include_router(resource_samples_router.router, prefix="/api") +app.include_router(scheduled_maintenance_router.router, prefix="/api") diff --git a/backend/app/migrate.py b/backend/app/migrate.py new file mode 100644 index 0000000..5e79deb --- /dev/null +++ b/backend/app/migrate.py @@ -0,0 +1,225 @@ +"""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 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) - settings.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 + + 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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..4bf685e --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,95 @@ +from app.models.host import OpnsenseHost +from app.models.rule_target import rule_targets +from app.models.rule import FirewallRule +from app.models.rule_deployment import RuleDeployment +from app.models.alias_target import alias_targets +from app.models.alias import FirewallAlias +from app.models.alias_deployment import AliasDeployment +from app.models.port_forward_target import port_forward_targets +from app.models.port_forward import PortForwardRule +from app.models.port_forward_deployment import PortForwardDeployment +from app.models.user import AdminUser, Session + +__all__ = [ + "OpnsenseHost", "FirewallRule", "RuleDeployment", "rule_targets", + "FirewallAlias", "AliasDeployment", "alias_targets", + "PortForwardRule", "PortForwardDeployment", "port_forward_targets", + "AdminUser", "Session", +] + +from app.models.policy import DeviceGroup, RuleRevision, AliasRevision, PortForwardRevision + +from app.models.one_to_one import OneToOneRule +from app.models.one_to_one_deployment import OneToOneDeployment +from app.models.one_to_one_target import one_to_one_targets +from app.models.policy import OneToOneRevision +__all__ += ["OneToOneRule", "OneToOneDeployment", "one_to_one_targets", "OneToOneRevision"] + +from app.models.outbound_nat import OutboundNatRule +from app.models.outbound_nat_deployment import OutboundNatDeployment +from app.models.outbound_nat_target import outbound_nat_targets +from app.models.policy import OutboundNatRevision +__all__ += ["OutboundNatRule", "OutboundNatDeployment", "outbound_nat_targets", "OutboundNatRevision"] + +from app.models.audit import AuditEvent +__all__ += ["AuditEvent"] + +from app.models.host_operation import HostOperation +__all__ += ["HostOperation"] + +from app.models.preferences import UserPreferences +__all__ += ["UserPreferences"] + +from app.models.notification import NotificationSettings, NotificationProblem +__all__ += ["NotificationSettings", "NotificationProblem"] + +from app.models.policy_hierarchy_group import PolicyHierarchyGroup +from app.models.policy_hierarchy_rule import PolicyHierarchyRule +from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment +from app.models.policy import PolicyHierarchyRuleRevision +__all__ += ["PolicyHierarchyGroup", "PolicyHierarchyRule", "PolicyHierarchyRuleDeployment", "PolicyHierarchyRuleRevision"] + +from app.models.log_entry import FirewallLogEntry +from app.models.log_cursor import HostLogCursor +from app.models.log_settings import LogSettings +__all__ += ["FirewallLogEntry", "HostLogCursor", "LogSettings"] + +from app.models.ipsec_tunnel_target import ipsec_tunnel_targets +from app.models.ipsec_tunnel import IpsecTunnel +from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment +from app.models.policy import ipsec_tunnel_groups, IpsecTunnelRevision + +from app.models.openvpn_instance_target import openvpn_instance_targets +from app.models.openvpn_instance import OpenVpnInstance +from app.models.openvpn_instance_deployment import OpenVpnInstanceDeployment +from app.models.policy import openvpn_instance_groups, OpenVpnInstanceRevision +__all__ += ["IpsecTunnel", "IpsecTunnelDeployment", "ipsec_tunnel_targets", "ipsec_tunnel_groups", "IpsecTunnelRevision"] +__all__ += ["OpenVpnInstance", "OpenVpnInstanceDeployment", "openvpn_instance_targets", "openvpn_instance_groups", "OpenVpnInstanceRevision"] + +from app.models.wireguard_tunnel_target import wireguard_tunnel_targets +from app.models.wireguard_tunnel import WireguardTunnel +from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment +from app.models.policy import wireguard_tunnel_groups, WireguardTunnelRevision +__all__ += ["WireguardTunnel", "WireguardTunnelDeployment", "wireguard_tunnel_targets", "wireguard_tunnel_groups", "WireguardTunnelRevision"] + +# Trust-Store + ACME certificate management — deliberately per-host (plain +# host_id FK, no DeviceGroup/effective_hosts targeting) — see +# ManagedCertificate/AcmeAccount/AcmeCertificateRequest's own docstrings +# for why. +from app.models.managed_certificate import ManagedCertificate +from app.models.acme_account import AcmeAccount +from app.models.acme_certificate_request import AcmeCertificateRequest +__all__ += ["ManagedCertificate", "AcmeAccount", "AcmeCertificateRequest"] + +from app.models.restore_operation import RestoreOperation +__all__ += ["RestoreOperation"] + +from app.models.carp_event import CarpEvent +from app.models.carp_maintenance_window import CarpMaintenanceWindow +__all__ += ["CarpEvent", "CarpMaintenanceWindow"] + +from app.models.resource_sample import HostResourceSample +__all__ += ["HostResourceSample"] + +from app.models.scheduled_maintenance import ScheduledMaintenance +__all__ += ["ScheduledMaintenance"] diff --git a/backend/app/models/acme_account.py b/backend/app/models/acme_account.py new file mode 100644 index 0000000..8e4eb59 --- /dev/null +++ b/backend/app/models/acme_account.py @@ -0,0 +1,76 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AcmeAccount(Base): + """A centrally-authored ACME account (`os-acme-client` plugin, + `AcmeClient.xml` -> `accounts.account`, `AccountsController.php`) — + per-host like ManagedCertificate, same reasoning: the account's + OPNsense-assigned uuid (referenced by AcmeCertificateRequest.account at + deploy time — see acme_service.py's UUID-injection, mirroring + ipsec_deploy_service.py's `connection` UUID pattern) is host-local. + + Field names verified directly against AcmeClient.xml (fetched + 2026-09-09): `ca` OptionValues are letsencrypt/letsencrypt_test/zerossl/ + buypass/buypass_test/google/google_test/sslcom/custom (Default + "letsencrypt"). `custom_ca` only applies when ca="custom". + + `eab_hmac` is sensitive External-Account-Binding material — like + ManagedCertificate's private key, it NEVER touches Postgres as + plaintext, only disk (see acme_secret_store.py, mirroring + ipsec_psk_store.py). `eab_hmac_set` tracks whether a value has been + provided. `eab_kid` is a plain (non-secret) key identifier, stored + directly. + + Local add/set (create/edit) is a purely local config write, same + "no restart, no lockout risk" status model as ManagedCertificate + (status: draft/success/error). The SEPARATE `register` action + (AccountsController::registerAction(), `POST + /acmeclient/accounts/register/{uuid}`) is a REAL, blocking network + call to the configured external CA — this is what needs the two-step + UI warning gate (see AcmeActionConfirm.tsx / README), not the local + add/set. register_status/register_message/register_at record the last + attempt's outcome for display; they are NOT re-derived from OPNsense + (there is no "is this account registered" flag on the model itself to + poll — see acme_service.py).""" + + __tablename__ = "acme_accounts" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str] = mapped_column(String(255), default="") + email: Mapped[str] = mapped_column(String(255), default="") + ca: Mapped[str] = mapped_column(String(32), default="letsencrypt") + custom_ca: Mapped[str] = mapped_column(String(255), default="") + eab_kid: Mapped[str] = mapped_column(String(255), default="") + eab_hmac_set: Mapped[bool] = mapped_column(default=False) + enabled: Mapped[bool] = mapped_column(default=True) + + opnsense_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="draft") + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + # Excludes eab_hmac's actual value (see acme_secret_store.py) — a + # boolean flag stands in for it so no-op-push detection still notices + # when the secret changes without ever persisting it. + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + register_status: Mapped[str | None] = mapped_column(String(16), nullable=True) + register_message: Mapped[str | None] = mapped_column(String(2000), nullable=True) + register_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) + + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/acme_certificate_request.py b/backend/app/models/acme_certificate_request.py new file mode 100644 index 0000000..d627266 --- /dev/null +++ b/backend/app/models/acme_certificate_request.py @@ -0,0 +1,93 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AcmeCertificateRequest(Base): + """A centrally-authored ACME certificate request (`AcmeClient.xml` -> + `certificates.certificate`, `CertificatesController.php`) — per-host, + same reasoning as AcmeAccount/ManagedCertificate. + + Field names verified directly against AcmeClient.xml (fetched + 2026-09-09): `name` is the CN/FQDN (Required). `altNames` is a + CSVListField (comma-separated on the wire) — modeled here as a plain + `alt_names` string, NOT a sub-array, matching OPNsense's own + convention. `account` and `validationMethod` are BOTH Required + ModelRelationFields on the wire — `account` is filled in by THIS app + at deploy time (injecting the linked AcmeAccount's own per-host + OPNsense uuid — see acme_service.py's build_payload(), the same + UUID-injection pattern ipsec_deploy_service.py documents for its own + `connection` field, including the SAME no-op-redeploy pitfall: the + injected uuid must never be baked into the dict compared/stored as + `last_payload`). `validationMethod` (this app's `validation_ref`) is + plain, OPNexus-unvalidated: the plan is explicit that DNS-provider + validation config (`validations.validation`) is entirely out of v1 + scope — the operator configures it themselves in OPNsense's own GUI + and types the resulting UUID in here; OPNexus never validates the + UUID's content, only that a target host actually has one selected + (same trust model IpsecTunnel's local_address/remote_address use). + + `keyLength` OptionValues are PREFIXED (`key_2048`/`key_3072`/ + `key_4096`/`key_ec256`/`key_ec384`), NOT bare numbers — a real + field-shape trap the plan's prose summary ("2048/3072/4096/ec256/ + ec384") would have produced a silently-rejected payload for if not + re-verified against the model XML directly. + + `renew_interval` (IntegerField, Default 60): DAYS SINCE LAST + ISSUANCE, NOT days-before-expiry (verified in the plan against + AcmeClient's own cron semantics) — this is the exact semantic trap + the plan calls out; see the UI label in AcmeCertificateForm.tsx and + the README for the same warning surfaced to the operator. + + Local add/set is a purely local config write (status: + draft/success/error, no restart/lockout risk — same model as + ManagedCertificate/AcmeAccount). The separate `sign` action + (`POST /acmeclient/certificates/sign/{uuid}`) is asynchronous + (configd forks and returns immediately) AND reaches the real external + CA — sign_status/sign_triggered_at record only that a trigger was + sent, never a claimed outcome; remote_status_code/ + remote_status_last_update/remote_last_update are populated by polling + `GET /acmeclient/certificates/get/{uuid}` afterwards (see + acme_service.py's poll_certificate_status()).""" + + __tablename__ = "acme_certificate_requests" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + alt_names: Mapped[str] = mapped_column(String(2000), default="") + account_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("acme_accounts.id")) + validation_ref: Mapped[str] = mapped_column(String(64)) + key_length: Mapped[str] = mapped_column(String(16), default="key_4096") + ocsp: Mapped[bool] = mapped_column(default=False) + auto_renewal: Mapped[bool] = mapped_column(default=True) + renew_interval: Mapped[int] = mapped_column(Integer, default=60) + enabled: Mapped[bool] = mapped_column(default=True) + + opnsense_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="draft") + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + sign_status: Mapped[str | None] = mapped_column(String(16), nullable=True) + sign_message: Mapped[str | None] = mapped_column(String(2000), nullable=True) + sign_triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + remote_status_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + remote_status_last_update: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + remote_last_update: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) + + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") + account: Mapped["AcmeAccount"] = relationship("AcmeAccount") diff --git a/backend/app/models/alias.py b/backend/app/models/alias.py new file mode 100644 index 0000000..b9eec54 --- /dev/null +++ b/backend/app/models/alias.py @@ -0,0 +1,51 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.alias_target import alias_targets +from app.models.policy import alias_groups + + +class FirewallAlias(Base): + """A centrally-authored alias (object), pushed to hosts the same way + FirewallRule is — see services/alias_deploy_service.py. Editing an + alias's content changes what every rule referencing it enforces on + every host it reaches, so it goes through the same + draft/preview/push/confirm/auto-rollback safety net as rules, not an + immediate write. + """ + + __tablename__ = "firewall_aliases" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(64)) + # OPNsense alias types this app manages centrally — the common, + # user-editable ones. "internal" (auto-generated __if_network) and + # "external" (built-ins like bogons/sshlockout) aren't created here. + type: Mapped[str] = mapped_column(String(32)) + # Newline-separated entries, matching OPNsense's own wire format for + # this field (verified live via GET .../alias/searchItem). + content: Mapped[str] = mapped_column(Text, default="") + description: Mapped[str] = mapped_column(String(255), default="") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + policy_hierarchy_group_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="SET NULL"), nullable=True + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=alias_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=alias_groups) + deployments: Mapped[list["AliasDeployment"]] = relationship( + "AliasDeployment", back_populates="alias", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/alias_deployment.py b/backend/app/models/alias_deployment.py new file mode 100644 index 0000000..09fcc05 --- /dev/null +++ b/backend/app/models/alias_deployment.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AliasDeployment(Base): + """Last known deploy result of one alias on one host — mirrors + RuleDeployment exactly (see that model for the status meanings); + opnsense_alias_uuid is the UUID OPNsense assigned this alias on THAT + host, needed to target setItem/delItem there later.""" + + __tablename__ = "alias_deployments" + __table_args__ = (UniqueConstraint("alias_id", "host_id", name="uq_alias_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + alias_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("firewall_aliases.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_alias_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + revert_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + previous_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + alias: Mapped["FirewallAlias"] = relationship("FirewallAlias", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/alias_target.py b/backend/app/models/alias_target.py new file mode 100644 index 0000000..ce26ad1 --- /dev/null +++ b/backend/app/models/alias_target.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, ForeignKey, Table + +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +alias_targets = Table( + "alias_targets", + Base.metadata, + Column("alias_id", PGUUID(as_uuid=True), ForeignKey("firewall_aliases.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..7cb3230 --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,32 @@ +"""Append-only history, deliberately no foreign keys: deletion keeps evidence.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, String, JSON, Text, event, DDL +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + +class AuditEvent(Base): + __tablename__ = "audit_events" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) + correlation_id: Mapped[str] = mapped_column(String(36), index=True) + actor: Mapped[str] = mapped_column(String(150), index=True) + kind: Mapped[str] = mapped_column(String(32), index=True) + action: Mapped[str] = mapped_column(String(40), index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + entity_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + host_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + host_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + details: Mapped[dict] = mapped_column(JSON, default=dict) + +event.listen(AuditEvent.__table__, "after_create", DDL(""" +CREATE OR REPLACE FUNCTION opnexus_audit_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN RAISE EXCEPTION 'Audit history is append-only'; END $$; +""").execute_if(dialect="postgresql")) +event.listen(AuditEvent.__table__, "after_create", DDL(""" +CREATE TRIGGER audit_events_immutable BEFORE UPDATE OR DELETE ON audit_events +FOR EACH ROW EXECUTE FUNCTION opnexus_audit_immutable(); +""").execute_if(dialect="postgresql")) diff --git a/backend/app/models/carp_event.py b/backend/app/models/carp_event.py new file mode 100644 index 0000000..7473dff --- /dev/null +++ b/backend/app/models/carp_event.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class CarpEvent(Base): + """One row per observed CARP-role transition on a host, e.g. BACKUP -> MASTER. + + Populated by carp_event_service.detect_and_record, called from main.py's + existing _notification_poll_loop. Deliberately has no "current state" + column on OpnsenseHost to diff against — the previous state is read back + from this table's own latest row per host, so no manual ALTER is needed + on an existing table for this feature. + """ + + __tablename__ = "carp_events" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), index=True) + host_name: Mapped[str] = mapped_column(String(120)) + cluster_name: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + from_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + to_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + detected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True + ) diff --git a/backend/app/models/carp_maintenance_window.py b/backend/app/models/carp_maintenance_window.py new file mode 100644 index 0000000..7f7d8f9 --- /dev/null +++ b/backend/app/models/carp_maintenance_window.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class CarpMaintenanceWindow(Base): + """Tracks an optional auto-revert timer on top of the existing CARP + maintenance-mode toggle (opnsense_client.toggle_carp_maintenance). + + One open ("active") row per host at most. The sweep in + carp_maintenance_sweep_service checks for rows past auto_revert_at and + calls the same toggle used by a manual "CARP-Normalbetrieb herstellen" + click. status="confirmed_permanent" means the user chose no auto-revert + (or explicitly cancelled the timer) — the row is kept for history rather + than deleted. + """ + + __tablename__ = "carp_maintenance_windows" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), index=True) + host_name: Mapped[str] = mapped_column(String(120)) + entered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + auto_revert_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # active | confirmed_permanent | reverted | revert_failed + status: Mapped[str] = mapped_column(String(20), default="active", index=True) + message: Mapped[str | None] = mapped_column(String(1000), nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/host.py b/backend/app/models/host.py new file mode 100644 index 0000000..d8e301f --- /dev/null +++ b/backend/app/models/host.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class OpnsenseHost(Base): + """A managed OPNsense firewall. The API secret never lives here — see + services/opnsense_secret_store.py, which keeps it on disk only.""" + + __tablename__ = "opnsense_hosts" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(120), unique=True) + base_url: Mapped[str] = mapped_column(String(255)) + api_key: Mapped[str] = mapped_column(String(255)) + verify_tls: Mapped[bool] = mapped_column(Boolean, default=True) + # Pinned certificate (or CA) of the firewall, PEM. When set together with + # verify_tls, only this certificate is trusted — see tls_pin_service. + tls_ca_pem: Mapped[str | None] = mapped_column(Text, nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + # Free-text grouping label — hosts sharing the same value are shown + # together on the cluster view (e.g. an HA pair). Not a separate + # entity/table on purpose: a host belongs to at most one such group, + # and there's no cluster-level data to manage beyond the name itself. + cluster_name: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + # A host belongs to at most one policy-hierarchy group (see + # models/policy_hierarchy_group.py for why this is nullable+singular + # rather than the DeviceGroup multi-membership pattern). NOT created by + # Base.metadata.create_all() on an existing table — see README for the + # required manual ALTER TABLE step, following the cluster_name / + # deploy-lockout precedent. + policy_hierarchy_group_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship( + "PolicyHierarchyGroup", back_populates="hosts" + ) diff --git a/backend/app/models/host_operation.py b/backend/app/models/host_operation.py new file mode 100644 index 0000000..76b8345 --- /dev/null +++ b/backend/app/models/host_operation.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, String, JSON +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + +class HostOperation(Base): + __tablename__ = "host_operations" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), index=True) + host_name: Mapped[str] = mapped_column(String(120)) + actor: Mapped[str] = mapped_column(String(150)) + action: Mapped[str] = mapped_column(String(20)) + status: Mapped[str] = mapped_column(String(20), default="preview") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + fingerprint: Mapped[str] = mapped_column(String(64)) + snapshot: Mapped[dict] = mapped_column(JSON) + message: Mapped[str | None] = mapped_column(String(1000), nullable=True) diff --git a/backend/app/models/ipsec_tunnel.py b/backend/app/models/ipsec_tunnel.py new file mode 100644 index 0000000..ef54d75 --- /dev/null +++ b/backend/app/models/ipsec_tunnel.py @@ -0,0 +1,79 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.ipsec_tunnel_target import ipsec_tunnel_targets +from app.models.policy import ipsec_tunnel_groups + + +class IpsecTunnel(Base): + """A centrally-authored site-to-site IPsec tunnel (PSK auth only — v1 + scope, see README/plan). Hides 5 linked OPNsense objects (Swanctl + Connection, Local, Remote, Child, and IPsec.preSharedKeys.preSharedKey) + behind one composite form, mirroring how an admin actually thinks about + "a VPN tunnel to site X" rather than five separate model array items — + see services/ipsec_deploy_service.py for exactly how each field maps. + + Targeting mirrors FirewallRule exactly (direct host targets + DeviceGroup + membership, resolved via policy_service.effective_hosts) — the classic + use case is pushing the identical tunnel config to both HA cluster + members (CARP VIP as local endpoint). + + The pre-shared-key SECRET itself is never stored here — like + OpnsenseHost's OPNsense API secret (see opnsense_secret_store.py), it + lives only on disk, one file per tunnel id (see ipsec_psk_store.py). + pre_shared_key_set just tracks whether one has been provided yet. + + Deploy status model is deliberately simpler than FirewallRule's — see + services/ipsec_deploy_service.py's module docstring for why (no + pending/confirm/auto-revert window here, on purpose).""" + + __tablename__ = "ipsec_tunnels" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + description: Mapped[str] = mapped_column(String(255)) + # Connection.local_addrs / remote_addrs (IKEAddressField) — v1 keeps + # this to a single IP/hostname per side (no comma-separated list in the + # form), matching the "simple 1:1 network-to-network tunnel" v1 scope. + local_address: Mapped[str] = mapped_column(String(255)) + remote_address: Mapped[str] = mapped_column(String(255)) + # Connection.version: "ike" (0, IKEv1+IKEv2), "ikev1" (1), "ikev2" (2). + ike_version: Mapped[str] = mapped_column(String(8), default="ikev2") + # Connection.proposals (IPsecProposalField, phase 1). "default" is + # OPNsense's own model default (Swanctl.xml default) + # — the conservative choice documented in ipsec_deploy_service.py. + proposal: Mapped[str] = mapped_column(String(128), default="default") + # locals.local.id (this side's IKE identity) — also becomes + # preSharedKeys.preSharedKey.ident. Required: PSK lookup keys off it. + local_identity: Mapped[str] = mapped_column(String(255)) + # remotes.remote.id — also becomes preSharedKeys.preSharedKey.remote_ident. + # Optional in the OPNsense model (unlike local id); default "". + remote_identity: Mapped[str] = mapped_column(String(255), default="") + pre_shared_key_set: Mapped[bool] = mapped_column(Boolean, default=False) + # children.child.local_ts / remote_ts (NetworkField, AsList=Y) — + # comma-separated CIDRs; v1 form still just takes a single CIDR each, + # matching the "no pools / wildcard traffic selectors" v1 scope. + local_networks: Mapped[str] = mapped_column(String(255)) + remote_networks: Mapped[str] = mapped_column(String(255)) + # children.child.esp_proposals (IPsecProposalField, phase 2). "default" + # for the same reason as `proposal` above. + esp_proposal: Mapped[str] = mapped_column(String(128), default="default") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=ipsec_tunnel_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=ipsec_tunnel_groups) + deployments: Mapped[list["IpsecTunnelDeployment"]] = relationship( + "IpsecTunnelDeployment", back_populates="tunnel", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/ipsec_tunnel_deployment.py b/backend/app/models/ipsec_tunnel_deployment.py new file mode 100644 index 0000000..5549177 --- /dev/null +++ b/backend/app/models/ipsec_tunnel_deployment.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class IpsecTunnelDeployment(Base): + """Last known deploy result of one tunnel on one host. The five + opnsense_*_uuid columns are the per-host UUIDs OPNsense assigned to + this tunnel's Connection/Local/Remote/Child/PreSharedKey objects — + required to target setX/delX there later, exactly like + RuleDeployment.opnsense_rule_uuid. + + status: "success" (all 5 objects written AND the follow-up reconfigure + call was confirmed) or "error" (something failed — see `message`). + Deliberately NO "pending"/"reverted"/"revert_failed" here — see + services/ipsec_deploy_service.py's module docstring for why this + entity has no auto-revert safety net (an IPsec reconfigure can cut off + the very management path OPNexus would need to auto-revert it). + + A host with no IpsecTunnelDeployment row at all is this tunnel's + "draft" state on that host (never pushed there) — surfaced by + ipsec_service.preview()'s action="create" for that row, not by a + persisted status value. This mirrors how RuleDeployment rows also + don't exist until a rule's first deploy attempt. + """ + + __tablename__ = "ipsec_tunnel_deployments" + __table_args__ = (UniqueConstraint("tunnel_id", "host_id", name="uq_ipsec_tunnel_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tunnel_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("ipsec_tunnels.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_connection_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + opnsense_local_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + opnsense_remote_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + opnsense_child_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + opnsense_psk_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # The OPNsense-shaped payloads (keyed "connection"/"local"/"remote"/ + # "child"/"psk") last successfully written to this host — compared + # against a fresh deploy's payloads to detect a true no-op re-deploy. + # Unlike RuleDeployment there is deliberately no previous_payload: + # nothing here is ever restored automatically. + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + tunnel: Mapped["IpsecTunnel"] = relationship("IpsecTunnel", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/ipsec_tunnel_target.py b/backend/app/models/ipsec_tunnel_target.py new file mode 100644 index 0000000..a31ae78 --- /dev/null +++ b/backend/app/models/ipsec_tunnel_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +ipsec_tunnel_targets = Table( + "ipsec_tunnel_targets", + Base.metadata, + Column("tunnel_id", PGUUID(as_uuid=True), ForeignKey("ipsec_tunnels.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/log_cursor.py b/backend/app/models/log_cursor.py new file mode 100644 index 0000000..d4e5fb7 --- /dev/null +++ b/backend/app/models/log_cursor.py @@ -0,0 +1,26 @@ +"""Per-host log-ingest cursor. A separate table rather than a column on +`opnsense_hosts` — deliberately, so this brand-new feature needs no manual +`ALTER TABLE` on the existing table for already-running installs (see +host.py's `policy_hierarchy_group_id` comment for the alternative that DID +need one). `Base.metadata.create_all()` alone is enough since this whole +table is new. +""" +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class HostLogCursor(Base): + __tablename__ = "host_log_cursor" + + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True + ) + # None until the first successful poll of this host. + last_digest: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/log_entry.py b/backend/app/models/log_entry.py new file mode 100644 index 0000000..b166390 --- /dev/null +++ b/backend/app/models/log_entry.py @@ -0,0 +1,52 @@ +"""Persisted firewall log entries, ingested incrementally by +log_ingest_service.py via OPNsense's digest-based cursor (`diagnostics/ +firewall/log`'s `digest` param — verified against core's +scripts/filter/read_log.py, see opnsense_client.get_log). + +Counter-example to models/audit.py on purpose: audit_events is made +immutable (DB trigger blocks UPDATE/DELETE) because it's an audit trail. +This table needs the OPPOSITE property — retention pruning +(log_ingest_service.prune_old_entries) must be able to delete old rows, +so no such trigger is added here. +""" +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, JSON, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class FirewallLogEntry(Base): + __tablename__ = "firewall_log_entries" + __table_args__ = ( + # "unique je Host" per the design — a digest is only guaranteed + # unique within one host's own log stream, and this constraint is + # also what the ingest service's ON CONFLICT DO NOTHING dedupe + # keys off of. + UniqueConstraint("host_id", "digest", name="uq_firewall_log_entry_host_digest"), + Index("ix_firewall_log_entries_host_timestamp", "host_id", "timestamp"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True + ) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + action: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + interface: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + direction: Mapped[str | None] = mapped_column(String(16), nullable=True) + protocol: Mapped[str | None] = mapped_column(String(32), nullable=True) + source_net: Mapped[str | None] = mapped_column(String(255), nullable=True) + source_port: Mapped[str | None] = mapped_column(String(16), nullable=True) + destination_net: Mapped[str | None] = mapped_column(String(255), nullable=True) + destination_port: Mapped[str | None] = mapped_column(String(16), nullable=True) + label: Mapped[str | None] = mapped_column(String(500), nullable=True) + # Full original OPNsense record, for future-proofing / debugging — + # the mapped columns above only cover today's known-useful fields. + raw: Mapped[dict] = mapped_column(JSON, default=dict) + # OPNsense's own `__digest__` for this exact line — the cursor value + # log_ingest_service.py hands back as `digest` on the next poll. + digest: Mapped[str] = mapped_column(String(64)) diff --git a/backend/app/models/log_settings.py b/backend/app/models/log_settings.py new file mode 100644 index 0000000..4ee2051 --- /dev/null +++ b/backend/app/models/log_settings.py @@ -0,0 +1,36 @@ +"""Log-ingest configuration, singleton row (id fixed at 1) — exactly the +NotificationSettings pattern (see models/notification.py / +services/notification_settings_service.py).""" +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, Float, Integer +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class LogSettings(Base): + __tablename__ = "log_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=30) + # Lowered from 30 to 7 (2026-09-17): two real, moderately busy + # firewalls filled firewall_log_entries to ~8.7M rows / ~9.7GB in just + # 9 days at the old default, with no settings UI to notice or fix it + # (see log_settings_service's update_settings() docstring) — a public + # self-hoster with busier traffic could fill a small disk in weeks. + # Still just a default; admin-editable per-install via the new + # GET/PUT /api/logs/settings. + retention_days: Mapped[int] = mapped_column(Integer, default=7) + # Upper bound for the log table in GB (0 = unlimited). Retention by age + # alone cannot protect the disk on a busy firewall — the oldest entries + # are deleted once the estimated live data exceeds this. + max_size_gb: Mapped[float] = mapped_column(Float, nullable=False, default=10.0, server_default="10") + + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) diff --git a/backend/app/models/managed_certificate.py b/backend/app/models/managed_certificate.py new file mode 100644 index 0000000..f198633 --- /dev/null +++ b/backend/app/models/managed_certificate.py @@ -0,0 +1,103 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ManagedCertificate(Base): + """A centrally-authored Trust-Store certificate (OPNsense core, + `Trust/Api/CertController.php` — no plugin involved), deliberately + modeled per-host (a plain `host_id` FK), NOT with the + DeviceGroup/`effective_hosts` multi-target pattern `policy_service.py` + provides for rules/IPsec/OpenVPN/WireGuard. Reason (per the plan): a + certificate's `caref` and its own OPNsense-assigned `refid`/uuid are + host-local — an internally-signed cert or CSR on host A has no + meaningful counterpart UUID on host B, so a "push identical payload to + N hosts" model would be actively misleading here, unlike a firewall + rule or a PSK-based IPsec tunnel where the same payload legitimately + IS the same intent on every target. + + Field names verified directly against Trust/Cert.xml (fetched + 2026-09-09): the model's DescriptionField wire key is `descr`, NOT + `description` (this app's own `description` column is translated to + `descr` in managed_certificate_service.py's payload builder). The + plaintext-in fields are `crt_payload`/`csr_payload`/`prv_payload` — + CertController::setBaseHook() base64-encodes these into `crt`/`csr`/ + `prv` server-side, so this app always sends plain PEM/CSR text, never + base64 itself. `key_type` OptionValues are RSA sizes ("512".."8192") + or EC curve names ("prime256v1"/"secp384r1"/"secp521r1") — this app + exposes a curated subset (2048/3072/4096 RSA + the three EC curves). + `cert_type` (Required, Default "usr_cert") is a verified field beyond + what the plan enumerated — exposed here with a "server_cert" default + since TLS server certs are this project's realistic use case, whereas + OPNsense's own UI default targets client certs. + + No deploy/apply step exists for Trust-Store writes at all (confirmed + in the plan, `opnsense/core#9399`) — a push IS the add/set API call, + immediately live, no follow-up reconfigure/restart. `status` is + therefore only "draft" (never pushed) / "success" / "error" — even + simpler than IPsec/OpenVPN/WireGuard's already-reduced model, and this + entity needs NO warning gate in the UI (no lockout-shaped risk exists + here — see README). + + The private key (`action="import"` only, user-supplied) is NEVER + stored in Postgres — disk-only, one file per entity id, mode 0600 (see + cert_key_store.py, mirroring ipsec_psk_store.py exactly). + `private_key_set` just tracks whether a file exists. + + Deleting an in-use certificate is blocked by OPNsense itself + (`checkAndThrowValueInUse()` inside `CertController::delAction()`) — + this app does not pre-check that condition, it surfaces OPNsense's own + error message verbatim (see managed_certificate_service.py).""" + + __tablename__ = "managed_certificates" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True + ) + # Cert.action OptionValues, verified against Cert.xml: import/internal/ + # external/sign_csr/import_csr/reissue/manual. + action: Mapped[str] = mapped_column(String(16)) + description: Mapped[str] = mapped_column(String(255)) # -> wire "descr" + # DN fields, only meaningful for action in {internal, external, reissue} + # (CertStore::createCert()/reIssueCert()'s $node->dn() argument). + commonname: Mapped[str] = mapped_column(String(255), default="") + country: Mapped[str] = mapped_column(String(8), default="DE") + state: Mapped[str] = mapped_column(String(255), default="") + city: Mapped[str] = mapped_column(String(255), default="") + organization: Mapped[str] = mapped_column(String(255), default="") + email: Mapped[str] = mapped_column(String(255), default="") + key_type: Mapped[str] = mapped_column(String(16), default="2048") + digest: Mapped[str] = mapped_column(String(16), default="sha256") + cert_type: Mapped[str] = mapped_column(String(32), default="server_cert") + lifetime: Mapped[int] = mapped_column(Integer, default=397) + # Optional, manually-entered internal-CA UUID already existing on this + # host (host-local — see class docstring for why this can't be a + # cross-host reference). + caref: Mapped[str | None] = mapped_column(String(64), nullable=True) + csr_payload: Mapped[str | None] = mapped_column(Text, nullable=True) + crt_payload: Mapped[str | None] = mapped_column(Text, nullable=True) + private_key_set: Mapped[bool] = mapped_column(default=False) + + opnsense_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="draft") + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + # The OPNsense-shaped payload last successfully written — compared + # against a fresh push's payload to detect a true no-op re-push. The + # private key is deliberately never part of this (see cert_key_store.py + # module docstring for why this deviates from IPsec/OpenVPN/WireGuard's + # precedent of embedding secret material in last_payload). + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) + + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..0b7ae4f --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,82 @@ +"""Delivery config (singleton row) and open/resolved problem tracking for +the notification feature. Secrets (SMTP password, webhook signing secret) +never live here — see services/notification_secret_store.py, mirroring how +OPNsense API secrets stay off the database entirely. +""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class NotificationSettings(Base): + """Single row, id fixed at 1 — see notification_settings_service.get_settings + for the get-or-create helper. The only admin-editable operational config + in the codebase today; everywhere else config is env-var only, but this + feature is meant for future non-technical users without compose access.""" + + __tablename__ = "notification_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + webhook_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + webhook_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + webhook_signing_secret_set: Mapped[bool] = mapped_column(Boolean, default=False) + + email_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_port: Mapped[int] = mapped_column(Integer, default=587) + # "none" | "starttls" | "ssl" + smtp_security: Mapped[str] = mapped_column(String(10), default="starttls") + smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_password_set: Mapped[bool] = mapped_column(Boolean, default=False) + smtp_from_address: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_to_addresses: Mapped[list] = mapped_column(JSON, default=list) + + host_offline_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + cert_expiring_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + rule_drift_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + maintenance_unknown_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + carp_health_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + + poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + +class NotificationProblem(Base): + """Open/resolved state per detected problem, keyed by (problem_type, + dedup_key) so a still-open problem is never re-notified every poll — + only transitions (newly open, newly resolved) trigger delivery. + + Delivery is best-effort, not an audit trail: last_notified_at/ + resolved_notified_at are set once per transition and never retried. + audit_events remains the real immutable record for deploy actions; + this table is a convenience signal layered on top, not a new source + of truth. + """ + + __tablename__ = "notification_problems" + __table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + # host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown + problem_type: Mapped[str] = mapped_column(String(32)) + dedup_key: Mapped[str] = mapped_column(String(300)) + status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved + context: Mapped[dict] = mapped_column(JSON, default=dict) + first_detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + resolved_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/one_to_one.py b/backend/app/models/one_to_one.py new file mode 100644 index 0000000..0a85320 --- /dev/null +++ b/backend/app/models/one_to_one.py @@ -0,0 +1,44 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.one_to_one_target import one_to_one_targets +from app.models.policy import one_to_one_groups + + +class OneToOneRule(Base): + __tablename__ = "one_to_one_rules" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + description: Mapped[str] = mapped_column(String(255)) + interface: Mapped[str] = mapped_column(String(64)) + source_net: Mapped[str] = mapped_column(String(128)) + destination_net: Mapped[str] = mapped_column(String(128)) + source_not: Mapped[bool] = mapped_column(Boolean, default=False) + destination_not: Mapped[bool] = mapped_column(Boolean, default=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + type: Mapped[str] = mapped_column(String(8), default="binat") + external: Mapped[str] = mapped_column(String(128)) + natreflection: Mapped[str] = mapped_column(String(16), default="") + log: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + policy_hierarchy_group_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="SET NULL"), nullable=True + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=one_to_one_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=one_to_one_groups) + deployments: Mapped[list["OneToOneDeployment"]] = relationship( + "OneToOneDeployment", back_populates="one_to_one", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/one_to_one_deployment.py b/backend/app/models/one_to_one_deployment.py new file mode 100644 index 0000000..b210d72 --- /dev/null +++ b/backend/app/models/one_to_one_deployment.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class OneToOneDeployment(Base): + """Last known deploy result of one 1:1 NAT rule on one host — + mirrors RuleDeployment/AliasDeployment exactly; see rule_deployment.py + for the status-value docstring, unchanged here.""" + + __tablename__ = "one_to_one_deployments" + __table_args__ = (UniqueConstraint("one_to_one_id", "host_id", name="uq_one_to_one_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + one_to_one_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("one_to_one_rules.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_one_to_one_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + revert_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + previous_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + one_to_one: Mapped["OneToOneRule"] = relationship("OneToOneRule", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/one_to_one_target.py b/backend/app/models/one_to_one_target.py new file mode 100644 index 0000000..ed040ff --- /dev/null +++ b/backend/app/models/one_to_one_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +one_to_one_targets = Table( + "one_to_one_targets", + Base.metadata, + Column("one_to_one_id", PGUUID(as_uuid=True), ForeignKey("one_to_one_rules.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/openvpn_instance.py b/backend/app/models/openvpn_instance.py new file mode 100644 index 0000000..f81ad42 --- /dev/null +++ b/backend/app/models/openvpn_instance.py @@ -0,0 +1,128 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, Boolean, DateTime, String, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.openvpn_instance_target import openvpn_instance_targets +from app.models.policy import openvpn_instance_groups + + +class OpenVpnInstance(Base): + """A centrally-authored OpenVPN instance (server OR client role — v1 + scope, see services/openvpn_deploy_service.py's module docstring for + the full field verification and the scope decision this class embodies). + + Unlike IpsecTunnel (5 linked OPNsense objects), OPNsense's modern + "Instance" object (OpenVPN.xml -> Instances.Instance, 24.1+) is a + single flat model item — EXCEPT for the optional tls-auth/tls-crypt + control-channel hardening key, which is its OWN separate OPNsense + object (StaticKeys.StaticKey) referenced from the Instance via the + `tls_key` ModelRelationField. So this entity is closer to a 2-object + composite (StaticKey optionally created first, Instance referencing + its UUID) than a perfectly flat single-row entity like FirewallRule — + see OpenVpnInstanceDeployment's docstring for the resulting 2-UUID + shape, and openvpn_deploy_service.py for exactly why. + + IMPORTANT SCOPE FINDING (verified directly against OpenVPN.xml, see + openvpn_deploy_service.py's module docstring): the modern Instance + model has NO "shared-key-only" role (unlike legacy pre-24.1 + openvpn-server/-client sections, which had a `mode` of + "p2p_shared_key" needing no PKI at all) — `role` here is only + "server"/"client", and BOTH still fundamentally require a working + TLS handshake (a `cert` + `ca`, both ModelRelationFields into + OPNsense's own certificate store) to actually pass traffic. The + `tls_key`/StaticKey mechanism this class supports is only an + OPTIONAL hardening layer on TOP of that TLS handshake (OpenVPN's + --tls-auth/--tls-crypt), not a substitute for certificates — so, + unlike IPsec's PSK, it can't be this entity's ONLY form of "auth + material". cert_ref/ca_ref reference an already-existing certificate/CA + on the TARGET host — but a cert's UUID is host-local, so a single + shared string can't be correct for more than one target host at once + (confirmed as a real, not hypothetical, gap: instances already support + genuine multi-host targeting below). cert_refs/ca_refs are therefore + **per-host maps** (`{host_id_str: uuid}`, JSON columns), populated via + the frontend's per-target-host picker (backed by this project's + existing read-only `GET /api/hosts/{id}/certificates` plus the sibling + `GET /api/hosts/{id}/cas`) rather than a single hand-typed value. A + target host missing from either map fails closed at deploy time — see + openvpn_deploy_service.py's build_payloads(). + + Targeting mirrors IpsecTunnel/FirewallRule exactly (direct host + targets + DeviceGroup membership, resolved via + policy_service.effective_hosts) — realistic v1 use cases: an + identical server-role instance pushed to both HA members sharing a + CARP VIP, or a client-role instance pointed at one specific remote + peer (targeted at just that one host). + + The static key's raw material is never stored here — like + IpsecTunnel's PSK, it lives only on disk, one file per instance id + (see openvpn_key_store.py). tls_key_material_set just tracks whether + one has been provided yet. tls_key_enabled/tls_key_mode/material are + all optional — a valid instance can exist with tls hardening off. + + Deploy status model is deliberately as simple as IpsecTunnel's — see + services/openvpn_deploy_service.py's module docstring for why (no + pending/confirm/auto-revert window here either, same rationale: an + OpenVPN `reconfigure` can equally sever the management path OPNexus + would need to auto-revert it).""" + + __tablename__ = "openvpn_instances" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + description: Mapped[str] = mapped_column(String(255)) + # Instances.Instance.role — OptionValues are literally only + # "client"/"server" (verified against OpenVPN.xml), no p2p/shared-key + # role exists in the modern model. + role: Mapped[str] = mapped_column(String(8), default="server") + # Instances.Instance.proto — model OptionValues also include + # udp4/udp6/tcp4/tcp6; v1 only exposes the dual-stack "udp"/"tcp" + # defaults, same "don't over-engineer" simplification IPsec applied + # to its own proposal/esp_proposal fields. + proto: Mapped[str] = mapped_column(String(8), default="udp") + # Instances.Instance.port (PortField) — kept as a plain string, sent + # verbatim, same as every other OPNsense-validated field in this app. + port: Mapped[str] = mapped_column(String(8), default="1194") + # Instances.Instance.local (NetworkField, no netmask) — server-role + # bind address; blank means "any" (OPNsense's own model default). + local: Mapped[str] = mapped_column(String(255), default="") + # Instances.Instance.remote (RemoteHostField) — client-role remote + # peer ("host" or "host:port"); required for role=client, sent + # verbatim with no comma-list building (v1 keeps to one peer). + remote: Mapped[str] = mapped_column(String(255), default="") + # Instances.Instance.server (NetworkField, NetMaskRequired=Y) — + # server-role tunnel network in CIDR notation; required for role=server. + server_network: Mapped[str] = mapped_column(String(255), default="") + # Instances.Instance.cert / .ca (CertificateField -> ModelRelationField + # into Trust\Cert) — per-host maps ({host_id_str: uuid}), see class + # docstring for why a single shared value can't work across hosts. + cert_refs: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, server_default=text("'{}'::json")) + ca_refs: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, server_default=text("'{}'::json")) + # Whether to also create/attach a StaticKeys.StaticKey for + # tls-auth/tls-crypt hardening (Instances.Instance.tls_key). Optional — + # see class docstring. + tls_key_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + # StaticKeys.StaticKey.mode — OptionValues are auth/crypt/crypt-v2; + # v1 exposes auth/crypt only (crypt-v2 needs the "server" vs "client" + # key-half distinction genKeyAction's tls-crypt-v2-server type hints + # at, deliberately deferred as extra complexity not needed for the v1 + # HA-pair / single-peer use cases). + tls_key_mode: Mapped[str] = mapped_column(String(16), default="crypt") + tls_key_material_set: Mapped[bool] = mapped_column(Boolean, default=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=openvpn_instance_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=openvpn_instance_groups) + deployments: Mapped[list["OpenVpnInstanceDeployment"]] = relationship( + "OpenVpnInstanceDeployment", back_populates="instance", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/openvpn_instance_deployment.py b/backend/app/models/openvpn_instance_deployment.py new file mode 100644 index 0000000..8007f09 --- /dev/null +++ b/backend/app/models/openvpn_instance_deployment.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class OpenVpnInstanceDeployment(Base): + """Last known deploy result of one OpenVPN instance on one host. + + Two opnsense_*_uuid columns, not one — see OpenVpnInstance's docstring + for why this entity isn't perfectly flat: `opnsense_statickey_uuid` is + only populated when tls_key_enabled (the StaticKeys.StaticKey object, + written FIRST so its UUID can be injected into the Instance's tls_key + field), `opnsense_instance_uuid` is the Instances.Instance object + itself, required to target setAction/delAction there later — exactly + like IpsecTunnelDeployment.opnsense_connection_uuid etc. + + status: "success" (the instance — and, if enabled, its StaticKey — + were written AND the follow-up reconfigure call was confirmed) or + "error". Deliberately NO "pending"/"reverted"/"revert_failed" here — + see services/openvpn_deploy_service.py's module docstring for why this + entity has no auto-revert safety net (same rationale as + IpsecTunnelDeployment: an OpenVPN reconfigure can cut off the very + management path OPNexus would need to auto-revert it). + + A host with no OpenVpnInstanceDeployment row at all is this instance's + "draft" state on that host (never pushed there) — surfaced by + openvpn_service.preview()'s action="create" for that row, not by a + persisted status value. + """ + + __tablename__ = "openvpn_instance_deployments" + __table_args__ = (UniqueConstraint("instance_id", "host_id", name="uq_openvpn_instance_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + instance_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("openvpn_instances.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_statickey_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + opnsense_instance_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # The OPNsense-shaped payloads (keyed "statickey"/"instance") last + # successfully written to this host — compared against a fresh + # deploy's payloads to detect a true no-op re-deploy. Like + # IpsecTunnelDeployment there is deliberately no previous_payload: + # nothing here is ever restored automatically. + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + instance: Mapped["OpenVpnInstance"] = relationship("OpenVpnInstance", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/openvpn_instance_target.py b/backend/app/models/openvpn_instance_target.py new file mode 100644 index 0000000..a211808 --- /dev/null +++ b/backend/app/models/openvpn_instance_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +openvpn_instance_targets = Table( + "openvpn_instance_targets", + Base.metadata, + Column("instance_id", PGUUID(as_uuid=True), ForeignKey("openvpn_instances.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/outbound_nat.py b/backend/app/models/outbound_nat.py new file mode 100644 index 0000000..b808537 --- /dev/null +++ b/backend/app/models/outbound_nat.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.outbound_nat_target import outbound_nat_targets +from app.models.policy import outbound_nat_groups + + +class OutboundNatRule(Base): + __tablename__ = "outbound_nat_rules" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + description: Mapped[str] = mapped_column(String(255)) + interface: Mapped[str] = mapped_column(String(64)) + source_net: Mapped[str] = mapped_column(String(128)) + destination_net: Mapped[str] = mapped_column(String(128)) + source_not: Mapped[bool] = mapped_column(Boolean, default=False) + destination_not: Mapped[bool] = mapped_column(Boolean, default=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + ipprotocol: Mapped[str] = mapped_column(String(8), default="inet") + protocol: Mapped[str] = mapped_column(String(16)) + source_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + target: Mapped[str] = mapped_column(String(128)) + target_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + policy_hierarchy_group_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="SET NULL"), nullable=True + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=outbound_nat_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=outbound_nat_groups) + deployments: Mapped[list["OutboundNatDeployment"]] = relationship( + "OutboundNatDeployment", back_populates="outbound_nat", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/outbound_nat_deployment.py b/backend/app/models/outbound_nat_deployment.py new file mode 100644 index 0000000..32d979b --- /dev/null +++ b/backend/app/models/outbound_nat_deployment.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class OutboundNatDeployment(Base): + """Last known deploy result of one Outbound NAT rule on one host — + mirrors RuleDeployment/AliasDeployment exactly; see rule_deployment.py + for the status-value docstring, unchanged here.""" + + __tablename__ = "outbound_nat_deployments" + __table_args__ = (UniqueConstraint("outbound_nat_id", "host_id", name="uq_outbound_nat_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + outbound_nat_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("outbound_nat_rules.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_outbound_nat_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + revert_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + previous_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + outbound_nat: Mapped["OutboundNatRule"] = relationship("OutboundNatRule", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/outbound_nat_target.py b/backend/app/models/outbound_nat_target.py new file mode 100644 index 0000000..aa69fc4 --- /dev/null +++ b/backend/app/models/outbound_nat_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +outbound_nat_targets = Table( + "outbound_nat_targets", + Base.metadata, + Column("outbound_nat_id", PGUUID(as_uuid=True), ForeignKey("outbound_nat_rules.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/policy.py b/backend/app/models/policy.py new file mode 100644 index 0000000..9a42f53 --- /dev/null +++ b/backend/app/models/policy.py @@ -0,0 +1,167 @@ +"""Additive tables: existing rule/host schemas and data remain valid.""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Table, Text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +group_members = Table( + "device_group_members", Base.metadata, + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) +rule_groups = Table( + "rule_groups", Base.metadata, + Column("rule_id", PGUUID(as_uuid=True), ForeignKey("firewall_rules.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) +alias_groups = Table( + "alias_groups", Base.metadata, + Column("alias_id", PGUUID(as_uuid=True), ForeignKey("firewall_aliases.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) +port_forward_groups = Table( + "port_forward_groups", Base.metadata, + Column("port_forward_id", PGUUID(as_uuid=True), ForeignKey("port_forward_rules.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) + + +class DeviceGroup(Base): + __tablename__ = "device_groups" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(120), unique=True) + description: Mapped[str] = mapped_column(Text, default="") + hosts: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=group_members) + + +class RuleRevision(Base): + __tablename__ = "rule_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + rule_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("firewall_rules.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + snapshot: Mapped[dict] = mapped_column(JSON) + + +class AliasRevision(Base): + __tablename__ = "alias_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + alias_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("firewall_aliases.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + snapshot: Mapped[dict] = mapped_column(JSON) + + +class PortForwardRevision(Base): + __tablename__ = "port_forward_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + port_forward_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("port_forward_rules.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + snapshot: Mapped[dict] = mapped_column(JSON) + + +one_to_one_groups = Table( + "one_to_one_groups", Base.metadata, + Column("one_to_one_id", PGUUID(as_uuid=True), ForeignKey("one_to_one_rules.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) + +class OneToOneRevision(Base): + __tablename__ = "one_to_one_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + one_to_one_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("one_to_one_rules.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + snapshot: Mapped[dict] = mapped_column(JSON) + + + +outbound_nat_groups = Table( + "outbound_nat_groups", Base.metadata, + Column("outbound_nat_id", PGUUID(as_uuid=True), ForeignKey("outbound_nat_rules.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) + +class OutboundNatRevision(Base): + __tablename__ = "outbound_nat_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + outbound_nat_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("outbound_nat_rules.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + snapshot: Mapped[dict] = mapped_column(JSON) + + +ipsec_tunnel_groups = Table( + "ipsec_tunnel_groups", Base.metadata, + Column("tunnel_id", PGUUID(as_uuid=True), ForeignKey("ipsec_tunnels.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) + +class IpsecTunnelRevision(Base): + __tablename__ = "ipsec_tunnel_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tunnel_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("ipsec_tunnels.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + # Snapshot never includes the PSK secret itself (not stored in the DB + # at all, see models/ipsec_tunnel.py) — only pre_shared_key_set, so a + # restored revision reflects whether a key was configured, not what it + # was. + snapshot: Mapped[dict] = mapped_column(JSON) + + +openvpn_instance_groups = Table( + "openvpn_instance_groups", Base.metadata, + Column("instance_id", PGUUID(as_uuid=True), ForeignKey("openvpn_instances.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) + +class OpenVpnInstanceRevision(Base): + __tablename__ = "openvpn_instance_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + instance_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("openvpn_instances.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + # Snapshot never includes the static-key material itself (not stored in + # the DB at all, see models/openvpn_instance.py) — only + # tls_key_material_set, mirroring IpsecTunnelRevision's PSK handling. + snapshot: Mapped[dict] = mapped_column(JSON) + + +wireguard_tunnel_groups = Table( + "wireguard_tunnel_groups", Base.metadata, + Column("tunnel_id", PGUUID(as_uuid=True), ForeignKey("wireguard_tunnels.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True), +) + +class WireguardTunnelRevision(Base): + __tablename__ = "wireguard_tunnel_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tunnel_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("wireguard_tunnels.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + # Snapshot never includes the server private key or peer PSK material + # itself (not stored in the DB at all, see models/wireguard_tunnel.py) + # — only server_private_key_set/peer_psk_set, mirroring + # IpsecTunnelRevision/OpenVpnInstanceRevision's secret handling. + snapshot: Mapped[dict] = mapped_column(JSON) + + +class PolicyHierarchyRuleRevision(Base): + __tablename__ = "policy_hierarchy_rule_revisions" + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + policy_hierarchy_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_rules.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + note: Mapped[str] = mapped_column(String(255)) + # Snapshot includes hierarchy_group_id/rulebase/sequence (unlike the + # other *Revision classes above) since, unlike a plain rule's + # target_host_ids/target_group_ids, those three fields directly change + # deploy ordering/targeting for this entity and must be restorable too. + snapshot: Mapped[dict] = mapped_column(JSON) + diff --git a/backend/app/models/policy_hierarchy_group.py b/backend/app/models/policy_hierarchy_group.py new file mode 100644 index 0000000..07a5010 --- /dev/null +++ b/backend/app/models/policy_hierarchy_group.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PolicyHierarchyGroup(Base): + """A node in the pre-/post-rulebase inheritance tree — deliberately + separate from DeviceGroup (see models/policy.py), which allows a host + to belong to more than one group at once. That multi-membership model + is fine for plain rule-target grouping, but would make "which ancestor + chain applies to this host" ambiguous for ordered inheritance, which is + the entire point of this feature. A host belongs to at most one + PolicyHierarchyGroup (see OpnsenseHost.policy_hierarchy_group_id). + + parent_id uses ondelete="RESTRICT": a group with children can't be + deleted out from under them (see policy_hierarchy_group_service / + routers/policy_hierarchy_groups.py, which also blocks deletion while + rules are still attached, mirroring DeviceGroup's own delete guard).""" + + __tablename__ = "policy_hierarchy_groups" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(120), unique=True) + description: Mapped[str] = mapped_column(String(2000), default="") + parent_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="RESTRICT"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + parent: Mapped["PolicyHierarchyGroup | None"] = relationship( + "PolicyHierarchyGroup", remote_side=[id], back_populates="children" + ) + children: Mapped[list["PolicyHierarchyGroup"]] = relationship( + "PolicyHierarchyGroup", back_populates="parent" + ) + hosts: Mapped[list["OpnsenseHost"]] = relationship( + "OpnsenseHost", back_populates="policy_hierarchy_group" + ) diff --git a/backend/app/models/policy_hierarchy_rule.py b/backend/app/models/policy_hierarchy_rule.py new file mode 100644 index 0000000..b00bfaf --- /dev/null +++ b/backend/app/models/policy_hierarchy_rule.py @@ -0,0 +1,64 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PolicyHierarchyRule(Base): + """A rule authored on a PolicyHierarchyGroup rather than a specific + host/DeviceGroup — see services/policy_hierarchy_service.py for how it + gets pushed to every host in the group's subtree (effective_hosts) in + the correct cross-group order (ordered_hierarchy_rules). + + Deliberately a separate table/entity from FirewallRule (not an + extension of it) — the existing rule-deploy path is security-critical + and this keeps zero regression risk on it. Same field set as + FirewallRule plus hierarchy_group_id/rulebase/sequence. + + rulebase: "pre" or "post" — splits the rules into a pre- and a + post-rulebase. Pre-rules are evaluated root-most-group-first (the top of the + org hierarchy wins first, like a pre-rule should); post-rules + leaf-most-group-first (the most specific group gets the last, most + specific word before any host-local NAT/default-deny). See + ordered_hierarchy_rules() for the actual implementation of this. + + sequence: manual ordering within the same (hierarchy_group_id, + rulebase) pair — persisted atomically by the drag-and-drop endpoint; + up/down buttons remain as an accessible keyboard fallback.""" + + __tablename__ = "policy_hierarchy_rules" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + hierarchy_group_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="CASCADE") + ) + rulebase: Mapped[str] = mapped_column(String(8)) + sequence: Mapped[int] = mapped_column(Integer, default=0) + description: Mapped[str] = mapped_column(String(255)) + interface: Mapped[str] = mapped_column(String(64)) + direction: Mapped[str] = mapped_column(String(8)) + ipprotocol: Mapped[str] = mapped_column(String(8), default="inet") + protocol: Mapped[str] = mapped_column(String(16), default="any") + action: Mapped[str] = mapped_column(String(16), default="pass") + source_net: Mapped[str] = mapped_column(String(128), default="any") + source_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + destination_net: Mapped[str] = mapped_column(String(128), default="any") + destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + group: Mapped["PolicyHierarchyGroup"] = relationship("PolicyHierarchyGroup") + deployments: Mapped[list["PolicyHierarchyRuleDeployment"]] = relationship( + "PolicyHierarchyRuleDeployment", back_populates="rule", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/policy_hierarchy_rule_deployment.py b/backend/app/models/policy_hierarchy_rule_deployment.py new file mode 100644 index 0000000..1705c59 --- /dev/null +++ b/backend/app/models/policy_hierarchy_rule_deployment.py @@ -0,0 +1,48 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PolicyHierarchyRuleDeployment(Base): + """Copy of models/rule_deployment.py for PolicyHierarchyRule — see that + file's docstring for the status lifecycle and lockout mechanism, which + this mirrors exactly (policy_hierarchy_deploy_service.py is a 1:1 port + of rule_deploy_service.py). Columns are named policy_hierarchy_id / + opnsense_policy_hierarchy_uuid (not rule_id/opnsense_rule_uuid) so + audit_service.tracked_rollback("policy_hierarchy") — which derives + attribute names from its `kind` argument — resolves them correctly. + """ + + __tablename__ = "policy_hierarchy_rule_deployments" + __table_args__ = (UniqueConstraint("policy_hierarchy_id", "host_id", name="uq_policy_hierarchy_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + policy_hierarchy_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_rules.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_policy_hierarchy_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # Set while status == "pending"; sweep_expired_hierarchy_deployments + # reverts once this passes without a confirm call. + revert_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # The OPNsense-shaped payload we last successfully applied on this host, + # BEFORE the current pending change (None if this deploy created the + # rule fresh on this host). + previous_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # The payload actually active on this host right now. + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + rule: Mapped["PolicyHierarchyRule"] = relationship("PolicyHierarchyRule", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/port_forward.py b/backend/app/models/port_forward.py new file mode 100644 index 0000000..3e76ea1 --- /dev/null +++ b/backend/app/models/port_forward.py @@ -0,0 +1,51 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.port_forward_target import port_forward_targets +from app.models.policy import port_forward_groups + + +class PortForwardRule(Base): + """A centrally-authored Port Forward (destination NAT) rule. OPNsense's + wire field for this is "disabled", not "enabled" (see + port_forward_deploy_service._build_payload) — this model stores the + same enabled=True/False polarity as FirewallRule/FirewallAlias for + consistency within OPNexus; only the outgoing payload flips it.""" + + __tablename__ = "port_forward_rules" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + description: Mapped[str] = mapped_column(String(255)) + interface: Mapped[str] = mapped_column(String(64)) + ipprotocol: Mapped[str] = mapped_column(String(8), default="inet") + protocol: Mapped[str] = mapped_column(String(16), default="tcp") + source_net: Mapped[str] = mapped_column(String(128), default="any") + source_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + destination_net: Mapped[str] = mapped_column(String(128), default="any") + destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + target: Mapped[str] = mapped_column(String(128)) + local_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + natreflection: Mapped[str] = mapped_column(String(16), default="") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + policy_hierarchy_group_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("policy_hierarchy_groups.id", ondelete="SET NULL"), nullable=True + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=port_forward_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=port_forward_groups) + deployments: Mapped[list["PortForwardDeployment"]] = relationship( + "PortForwardDeployment", back_populates="port_forward", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/port_forward_deployment.py b/backend/app/models/port_forward_deployment.py new file mode 100644 index 0000000..175aae8 --- /dev/null +++ b/backend/app/models/port_forward_deployment.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PortForwardDeployment(Base): + """Last known deploy result of one Port Forward rule on one host — + mirrors RuleDeployment/AliasDeployment exactly; see rule_deployment.py + for the status-value docstring, unchanged here.""" + + __tablename__ = "port_forward_deployments" + __table_args__ = (UniqueConstraint("port_forward_id", "host_id", name="uq_port_forward_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + port_forward_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("port_forward_rules.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_port_forward_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + revert_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + previous_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + port_forward: Mapped["PortForwardRule"] = relationship("PortForwardRule", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/port_forward_target.py b/backend/app/models/port_forward_target.py new file mode 100644 index 0000000..7f108cd --- /dev/null +++ b/backend/app/models/port_forward_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +port_forward_targets = Table( + "port_forward_targets", + Base.metadata, + Column("port_forward_id", PGUUID(as_uuid=True), ForeignKey("port_forward_rules.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/preferences.py b/backend/app/models/preferences.py new file mode 100644 index 0000000..4e06a94 --- /dev/null +++ b/backend/app/models/preferences.py @@ -0,0 +1,11 @@ +import uuid +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + +class UserPreferences(Base): + __tablename__ = "user_preferences" + user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True) + theme: Mapped[str] = mapped_column(String(10), default='system') + language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de') diff --git a/backend/app/models/resource_sample.py b/backend/app/models/resource_sample.py new file mode 100644 index 0000000..23304b0 --- /dev/null +++ b/backend/app/models/resource_sample.py @@ -0,0 +1,39 @@ +"""Periodic host resource samples (CPU load, memory, disk, interface +counters) — OPNexus's own time series, polled independently of whatever +retention OPNsense's own on-device RRD graphs keep. A brand-new table, +same "no manual ALTER needed" situation as host_log_cursor/ +firewall_log_entries (see those modules' docstrings) since nothing here +touches an existing table. + +Like firewall_log_entries and unlike audit_events, this table needs +retention pruning (resource_monitor_service.prune_old_entries) to be able +to delete old rows, so no immutability trigger is added here either. +""" +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Index, JSON +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class HostResourceSample(Base): + __tablename__ = "host_resource_samples" + __table_args__ = ( + Index("ix_host_resource_samples_host_sampled_at", "host_id", "sampled_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True + ) + sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + mem_used_pct: Mapped[float | None] = mapped_column(Float, nullable=True) + load_avg_1m: Mapped[float | None] = mapped_column(Float, nullable=True) + disk_used_pct: Mapped[float | None] = mapped_column(Float, nullable=True) + # Catch-all: full memory/disk-device/interface-statistics payloads, for + # anything the three columns above don't cover (per-interface + # throughput counters in particular — see resource_monitor_service.py). + raw: Mapped[dict] = mapped_column(JSON, default=dict) diff --git a/backend/app/models/restore_operation.py b/backend/app/models/restore_operation.py new file mode 100644 index 0000000..80fadf7 --- /dev/null +++ b/backend/app/models/restore_operation.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, String, JSON +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + + +class RestoreOperation(Base): + """Stage 1 restore: preview a locally-stored config.xml backup and + record a human-performed handoff to the OPNsense GUI. No row here ever + triggers an OPNsense write — see restore_service.py's module docstring. + Shaped like HostOperation (no FK to opnsense_hosts, denormalized name). + """ + + __tablename__ = "restore_operations" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), index=True) + host_name: Mapped[str] = mapped_column(String(120)) + actor: Mapped[str] = mapped_column(String(150)) + backup_id: Mapped[str] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(20), default="preview") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + fingerprint: Mapped[str] = mapped_column(String(64)) + manifest: Mapped[dict] = mapped_column(JSON) + warnings: Mapped[list] = mapped_column(JSON, default=list) + confirm_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + handed_off_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + message: Mapped[str | None] = mapped_column(String(1000), nullable=True) diff --git a/backend/app/models/rule.py b/backend/app/models/rule.py new file mode 100644 index 0000000..36fb361 --- /dev/null +++ b/backend/app/models/rule.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.rule_target import rule_targets +from app.models.policy import rule_groups + + +class FirewallRule(Base): + """A centrally-authored rule. Pushing it to a host happens via + services/rule_deploy_service.py, which records one RuleDeployment row + per (rule, host).""" + + __tablename__ = "firewall_rules" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + description: Mapped[str] = mapped_column(String(255)) + interface: Mapped[str] = mapped_column(String(64)) + direction: Mapped[str] = mapped_column(String(8)) + ipprotocol: Mapped[str] = mapped_column(String(8), default="inet") + protocol: Mapped[str] = mapped_column(String(16), default="any") + action: Mapped[str] = mapped_column(String(16), default="pass") + source_net: Mapped[str] = mapped_column(String(128), default="any") + source_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + destination_net: Mapped[str] = mapped_column(String(128), default="any") + destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=rule_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=rule_groups) + deployments: Mapped[list["RuleDeployment"]] = relationship( + "RuleDeployment", back_populates="rule", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/rule_deployment.py b/backend/app/models/rule_deployment.py new file mode 100644 index 0000000..f079acd --- /dev/null +++ b/backend/app/models/rule_deployment.py @@ -0,0 +1,57 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class RuleDeployment(Base): + """Last known deploy result of one rule on one host. opnsense_rule_uuid + is the UUID OPNsense assigned on THAT host (each host gets its own copy + of the rule) — required to target setRule/delRule there later. + + status: "pending" (applied, awaiting confirmation — see + rule_deploy_service.sweep_expired_deployments), "success" (confirmed, + or a no-op re-deploy of an unchanged payload), "error" (the deploy + call itself failed), "reverted" (auto-rolled-back after the + confirmation window expired unconfirmed), "revert_failed" (the + rollback call itself failed — needs manual attention). + """ + + __tablename__ = "rule_deployments" + __table_args__ = (UniqueConstraint("rule_id", "host_id", name="uq_rule_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + rule_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("firewall_rules.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_rule_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # Set while status == "pending"; sweep_expired_deployments reverts once + # this passes without a confirm call. + revert_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # The OPNsense-shaped payload we last successfully applied on this host, + # BEFORE the current pending change (None if this deploy created the + # rule fresh on this host — the revert path for that case is delRule, + # not setRule). This is our own record of "what was there", not a value + # fetched back from OPNsense — its getRule response shape is more + # complex/uncertain than we want to depend on for a safety mechanism. + previous_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # The payload actually active on this host right now (mirrors + # previous_payload's shape) — compared against a new deploy's payload + # to detect a true no-op re-deploy, which skips the pending window + # entirely since nothing risky happened. + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + rule: Mapped["FirewallRule"] = relationship("FirewallRule", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/rule_target.py b/backend/app/models/rule_target.py new file mode 100644 index 0000000..7f7837a --- /dev/null +++ b/backend/app/models/rule_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +rule_targets = Table( + "rule_targets", + Base.metadata, + Column("rule_id", PGUUID(as_uuid=True), ForeignKey("firewall_rules.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/models/scheduled_maintenance.py b/backend/app/models/scheduled_maintenance.py new file mode 100644 index 0000000..4721c67 --- /dev/null +++ b/backend/app/models/scheduled_maintenance.py @@ -0,0 +1,42 @@ +"""One-shot scheduled host-maintenance actions (reboot/update/major_update +at a future timestamp instead of immediately). A brand-new table, same +"no manual ALTER needed" situation as host_resource_samples. + +Deliberately v1-minimal: one fixed `run_at`, no recurrence/cron +expression — see resource_monitor_service.py's sibling note in +config.py for the same "keep v1 small" rationale. Firing reuses the +existing HostOperation/ensure_no_maintenance lock machinery +(routers/scheduled_maintenance.py, main.py's +_scheduled_maintenance_poll_loop) rather than duplicating it — this +table only tracks the *schedule* itself, not the resulting operation. +""" +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, JSON, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ScheduledMaintenance(Base): + __tablename__ = "scheduled_maintenance" + __table_args__ = ( + Index("ix_scheduled_maintenance_status_run_at", "status", "run_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True + ) + host_name: Mapped[str] = mapped_column(String(120)) + action: Mapped[str] = mapped_column(String(32)) # reboot | update | major_update + run_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + status: Mapped[str] = mapped_column(String(16), default="pending") # pending|running|succeeded|failed|cancelled + actor: Mapped[str] = mapped_column(String(120)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + result: Mapped[dict] = mapped_column(JSON, default=dict) + # Set to the resulting HostOperation's id once fired, so the UI can + # link from the schedule to the operation it produced. + host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..80548a5 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,57 @@ +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import JSON, BigInteger, Boolean, DateTime, ForeignKey, String, func +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AdminUser(Base): + """A user account. Two roles, no orgs: "admin" (full read/write, incl. + managing other users) and "read_only" (every GET works, every write is + rejected by AuthMiddleware before it reaches a router — see there for + the exact allowlist). See auth_service.bootstrap_admin for how the + first account is created.""" + + __tablename__ = "admin_users" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + username: Mapped[str] = mapped_column(String(150), nullable=False, unique=True, index=True) + # Argon2id PHC string — never the raw password. + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(20), nullable=False, default="admin", server_default="admin") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + last_login_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + # Optional TOTP second factor. The secret itself is on disk + # (services/totp_service), not here; only SHA-256 hashes of the one-time + # recovery codes and the last accepted time-step (replay guard) are. + totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + totp_last_step: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0") + totp_recovery_hashes: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + + sessions: Mapped[list["Session"]] = relationship( + "Session", back_populates="user", cascade="all, delete-orphan" + ) + + +class Session(Base): + __tablename__ = "sessions" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True + ) + # SHA-256 hex of the random session token. The raw token only ever lives + # in the client's httpOnly cookie, so a DB leak yields no usable sessions. + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + user_agent: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + + user: Mapped["AdminUser"] = relationship("AdminUser", back_populates="sessions") diff --git a/backend/app/models/wireguard_tunnel.py b/backend/app/models/wireguard_tunnel.py new file mode 100644 index 0000000..78b137b --- /dev/null +++ b/backend/app/models/wireguard_tunnel.py @@ -0,0 +1,141 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.wireguard_tunnel_target import wireguard_tunnel_targets +from app.models.policy import wireguard_tunnel_groups + + +class WireguardTunnel(Base): + """A centrally-authored WireGuard interconnect (v1 scope, see + services/wireguard_deploy_service.py's module docstring for the full + field verification and the scope decision this class embodies). + + ENTITY-SHAPE DECISION (see wireguard_deploy_service.py for the full + reasoning): WireGuard's real OPNsense object model is one `Server` + (the local wg-quick interface: keypair, listen port, own tunnel + address) that owns a comma-list `peers` field pointing at zero or more + `Client` (peer) objects — a genuine one-to-many, NOT a 1:1 pairing. + Nonetheless, this class models exactly ONE Server + exactly ONE Client + per WireguardTunnel row (a 2-object composite, closest in shape to + OpenVpnInstance's Instance+optional-StaticKey composite, NOT IPsec's + 5-object one) rather than either (a) a single flat entity, or (b) two + fully independent top-level CRUD entities (WireguardServer/ + WireguardPeer) that the operator would have to keep in sync by hand. + Reasoning: the realistic v1 fleet use case named in the plan — one + interconnect between two managed firewalls, or a managed firewall and + one external peer — is exactly a 1:1 Server<->Peer pairing, the same + way IpsecTunnel models "a site-to-site VPN connection" as one form an + admin actually thinks in, instead of exposing Swanctl's Connection/ + Local/Remote/Child as four separate CRUD screens. A multi-peer WG hub + (one Server, many Clients) is a real, deliberately deferred v1 gap — + see the module docstring for why "always exactly one peer" was chosen + over building that out tonight. + + The peer<->server linkage itself is NOT a plain ModelRelationField + written by this app directly (unlike IPsec's Local/Remote/Child, which + carry their own `connection` UUID field this app sets) — it is a + denormalized, bidirectionally-maintained relationship where the + SERVER's `peers` field is the only persisted list, and OPNsense's own + `ClientController::setClientAction`/`delClientAction` (verified + directly from source, see module docstring) update that list + server-side as a side effect of writing/deleting the Client, whenever + this app's Client payload includes a `servers` field naming the + Server's UUID. This app therefore never reads or rewrites the + Server's `peers` field itself — seebuild_payloads()/deploy service for + exactly how the `servers` field is injected at deploy time. + + Targeting mirrors IpsecTunnel/OpenVpnInstance exactly (direct host + targets + DeviceGroup membership, resolved via + policy_service.effective_hosts). + + Two secrets live on disk only, never in Postgres as their own column + (see wireguard_key_store.py, mirroring ipsec_psk_store.py/ + openvpn_key_store.py): the Server's WireGuard private key (required — + OPNsense's own model requires `privkey`, and does NOT derive a public + key from it anywhere in the model/field-type source, confirmed by + reading ServerField.php — so both server_public_key AND the private + key must be operator-supplied, see module docstring for why this app + doesn't call `keyPairAction` to generate one), and the peer's optional + WireGuard pre-shared key (an extra symmetric hardening layer WireGuard + itself supports per-peer, unrelated to IPsec's PSK despite the shared + name — OPNsense's `Client.psk` field). `server_private_key_set` / + `peer_psk_set` just track whether material has been provided, same + pattern as IpsecTunnel.pre_shared_key_set / + OpenVpnInstance.tls_key_material_set. + + Deploy status model is deliberately as simple as IpsecTunnel's/ + OpenVpnInstance's — see services/wireguard_deploy_service.py's module + docstring for why (no pending/confirm/auto-revert window here either, + same rationale: a WireGuard `reconfigure` can equally sever the + management path OPNexus would need to auto-revert it).""" + + __tablename__ = "wireguard_tunnels" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ── Server (local wg-quick interface) — Wireguard\Server.xml + # servers.server fields, see module docstring for the exact mapping. + # `name` doubles as this entity's display label (WireGuard's model has + # no separate free-text `description` field the way Swanctl/OpenVPN + # do — masked identically to OPNsense's own Mask, + # `/^([0-9a-zA-Z._\-]){1,64}$/u`, enforced again in schemas/wireguard.py). + server_name: Mapped[str] = mapped_column(String(64)) + server_public_key: Mapped[str] = mapped_column(String(64)) + server_private_key_set: Mapped[bool] = mapped_column(Boolean, default=False) + # Server.port (PortField) — kept as a plain string, sent verbatim, + # same convention as every other OPNsense-validated field in this app. + listen_port: Mapped[str] = mapped_column(String(8), default="51820") + # Server.tunneladdress (NetworkField, NetMaskRequired=Y, AsList=Y) — + # this interface's own tunnel address in CIDR notation. v1 keeps this + # to a single CIDR (no comma-list UI), same simplification precedent + # as IpsecTunnel.local_networks/OpenVpnInstance.server_network. + server_tunnel_address: Mapped[str] = mapped_column(String(255)) + # Server.dns / .mtu — both optional in the OPNsense model. + dns: Mapped[str] = mapped_column(String(255), default="") + mtu: Mapped[str] = mapped_column(String(8), default="") + + # ── Peer (Wireguard\Client.xml clients.client fields) — v1 pins + # exactly one peer per tunnel, see class docstring. + peer_name: Mapped[str] = mapped_column(String(64)) + peer_public_key: Mapped[str] = mapped_column(String(64)) + peer_psk_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + peer_psk_set: Mapped[bool] = mapped_column(Boolean, default=False) + # Client.tunneladdress (NetworkField, NetMaskRequired=Y, Required=Y, + # AsList=Y) — this peer's Allowed-IPs entry in the server's config. + # Single CIDR only in v1, same precedent as above. + peer_allowed_ips: Mapped[str] = mapped_column(String(255)) + # Client.serveraddress/.serverport — the Endpoint (host/port) this + # OPNsense box should dial to reach the peer. Optional: a peer that + # only ever connects inward (e.g. a road-warrior-style client with no + # fixed address) legitimately has neither set — see module docstring + # for why these are believed (not independently confirmed beyond + # field placement/naming in Client.xml) to be the real, persisted + # Endpoint fields, as opposed to the sibling `endpoint` field, which + # is explicitly marked `volatile="true"` in the model XML (i.e. + # runtime/status-only, never written to config). + peer_endpoint_address: Mapped[str] = mapped_column(String(255), default="") + peer_endpoint_port: Mapped[str] = mapped_column(String(8), default="") + # Client.keepalive (IntegerField, 1-65535) — optional persistent + # keepalive interval in seconds. + peer_keepalive: Mapped[str] = mapped_column(String(8), default="") + + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + targets: Mapped[list["OpnsenseHost"]] = relationship("OpnsenseHost", secondary=wireguard_tunnel_targets) + groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=wireguard_tunnel_groups) + deployments: Mapped[list["WireguardTunnelDeployment"]] = relationship( + "WireguardTunnelDeployment", back_populates="tunnel", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/wireguard_tunnel_deployment.py b/backend/app/models/wireguard_tunnel_deployment.py new file mode 100644 index 0000000..c39b8c5 --- /dev/null +++ b/backend/app/models/wireguard_tunnel_deployment.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class WireguardTunnelDeployment(Base): + """Last known deploy result of one WireguardTunnel (one Server + one + Peer, see WireguardTunnel's docstring) on one host. + + Two opnsense_*_uuid columns, mirroring OpenVpnInstanceDeployment's + shape: `opnsense_server_uuid` (Wireguard\\Server) is written FIRST + (the Peer's payload needs to reference it via the `servers` field — + see wireguard_deploy_service.py for why getting this order wrong would + silently produce an unlinked peer), `opnsense_peer_uuid` + (Wireguard\\Client) required to target setClient/delClient there later. + + status: "success" (both objects written AND the follow-up reconfigure + call was confirmed) or "error". Deliberately NO "pending"/"reverted"/ + "revert_failed" here — see services/wireguard_deploy_service.py's + module docstring for why this entity has no auto-revert safety net + (same rationale as IpsecTunnelDeployment/OpenVpnInstanceDeployment: a + WireGuard reconfigure can cut off the very management path OPNexus + would need to auto-revert it). + + A host with no WireguardTunnelDeployment row at all is this tunnel's + "draft" state on that host (never pushed there) — surfaced by + wireguard_service.preview()'s action="create" for that row, not by a + persisted status value. + """ + + __tablename__ = "wireguard_tunnel_deployments" + __table_args__ = (UniqueConstraint("tunnel_id", "host_id", name="uq_wireguard_tunnel_host"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tunnel_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("wireguard_tunnels.id", ondelete="CASCADE") + ) + host_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE") + ) + status: Mapped[str] = mapped_column(String(16)) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + opnsense_server_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + opnsense_peer_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + deployed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # The OPNsense-shaped payloads (keyed "server"/"peer") last + # successfully written to this host — compared against a fresh + # deploy's payloads to detect a true no-op re-deploy. Like the other + # two VPN features, deliberately no previous_payload: nothing here is + # ever restored automatically. + last_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + tunnel: Mapped["WireguardTunnel"] = relationship("WireguardTunnel", back_populates="deployments") + host: Mapped["OpnsenseHost"] = relationship("OpnsenseHost") diff --git a/backend/app/models/wireguard_tunnel_target.py b/backend/app/models/wireguard_tunnel_target.py new file mode 100644 index 0000000..4b6ba50 --- /dev/null +++ b/backend/app/models/wireguard_tunnel_target.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from app.database import Base + +wireguard_tunnel_targets = Table( + "wireguard_tunnel_targets", + Base.metadata, + Column("tunnel_id", PGUUID(as_uuid=True), ForeignKey("wireguard_tunnels.id", ondelete="CASCADE"), primary_key=True), + Column("host_id", PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True), +) diff --git a/backend/app/reset_2fa.py b/backend/app/reset_2fa.py new file mode 100644 index 0000000..471c3ba --- /dev/null +++ b/backend/app/reset_2fa.py @@ -0,0 +1,38 @@ +"""Emergency recovery: switch off two-factor sign-in for one account. + +For the case where the only admin lost both their authenticator and their +recovery codes (no other admin can use the UI reset). Needs shell access to the +server, which is the trust boundary for this tool anyway: + + docker compose exec backend python -m app.reset_2fa +""" +import asyncio +import sys + +from sqlalchemy import select + +from app.database import SessionLocal +from app.models.user import AdminUser +from app.services import totp_service + + +async def main(username: str) -> int: + async with SessionLocal() as db: + user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none() + if user is None: + print(f"No such user: {username}") + return 1 + totp_service.delete_secrets(user.id) + user.totp_enabled = False + user.totp_recovery_hashes = None + user.totp_last_step = 0 + await db.commit() + print(f"Two-factor sign-in disabled for {username}.") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: python -m app.reset_2fa ") + sys.exit(2) + sys.exit(asyncio.run(main(sys.argv[1]))) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/aliases.py b/backend/app/routers/aliases.py new file mode 100644 index 0000000..b7b444c --- /dev/null +++ b/backend/app/routers/aliases.py @@ -0,0 +1,232 @@ +import uuid + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, AliasRevision +from app.services.policy_service import effective_hosts, policy_lock, ensure_editable +from app.services.alias_policy_service import ALIAS_FIELDS, snapshot, revision, preview, workflow +from app.models.alias import FirewallAlias +from app.models.alias_deployment import AliasDeployment +from app.schemas.alias import DeploymentResult, AliasCreate, AliasRead, AliasEdit, DeployApproval +from app.services.alias_deploy_service import confirm_alias, deploy_alias, undeploy_alias, revert_alias_deployment +from app.services.opnsense_client import OpnsenseAPIError +from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group + +router = APIRouter(prefix="/aliases", tags=["aliases"]) + + +def _load_options(): + return [ + selectinload(FirewallAlias.targets), + selectinload(FirewallAlias.groups).selectinload(DeviceGroup.hosts), + selectinload(FirewallAlias.deployments).selectinload(AliasDeployment.host), + ] + + +async def _load_alias(db: AsyncSession, alias_id: uuid.UUID) -> FirewallAlias: + await policy_lock(db) + alias = await db.get(FirewallAlias, alias_id, options=_load_options()) + if not alias: + raise api_error(404, ErrorCode.ALIAS_NICHT_GEFUNDEN) + await hydrate_hierarchy_targets(db, [alias]) + return alias + + +def _to_read(alias: FirewallAlias, hosts: list[OpnsenseHost], deployments: list[AliasDeployment]) -> AliasRead: + hosts_by_id = {host.id: host for host in effective_hosts(alias)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(alias, deployments) + return AliasRead( + workflow_state=state, + workflow_counts=counts, + id=alias.id, + name=alias.name, + type=alias.type, + content=alias.content, + description=alias.description, + enabled=alias.enabled, + created_at=alias.created_at, + updated_at=alias.updated_at, + target_host_ids=[host.id for host in alias.targets], + target_group_ids=[g.id for g in alias.groups], + policy_hierarchy_group_id=alias.policy_hierarchy_group_id, + effective_host_ids=[h.id for h in effective_hosts(alias)], + deployments=[ + DeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + revert_deadline=d.revert_deadline, + ) + for d in deployments + ], + ) + + +@router.get("", response_model=list[AliasRead]) +async def list_aliases(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(FirewallAlias).options(*_load_options())) + aliases = result.scalars().unique().all() + await hydrate_hierarchy_targets(db, aliases) + return [_to_read(alias, alias.targets, alias.deployments) for alias in aliases] + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.post("", response_model=AliasRead, status_code=201) +async def create_alias(payload: AliasCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + existing = await db.scalar(select(FirewallAlias).where(FirewallAlias.name == payload.name)) + if existing: + raise api_error(409, ErrorCode.ALIAS_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + + alias = FirewallAlias( + name=payload.name, + type=payload.type, + content=payload.content, + description=payload.description, + enabled=payload.enabled, + targets=hosts, + groups=groups, + policy_hierarchy_group_id=payload.policy_hierarchy_group_id, + ) + db.add(alias) + await db.flush() + revision(db, alias, "Alias angelegt") + await db.commit() + await hydrate_hierarchy_targets(db, [alias]) + return _to_read(alias, hosts, []) + + +@router.post("/{alias_id}/deploy", response_model=AliasRead) +async def deploy(alias_id: uuid.UUID, approval: DeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + alias = await _load_alias(db, alias_id) + plan = preview(alias) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if any(d.status in ("pending", "revert_failed") for d in alias.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN) + hosts = [h for h in effective_hosts(alias) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.ALIAS_HAT_KEINE_ZIEL_HOSTS) + await deploy_alias(db, alias, hosts) + await db.refresh(alias, attribute_names=["deployments"]) + for d in alias.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(alias, hosts, alias.deployments) + + +@router.post("/{alias_id}/confirm", response_model=AliasRead) +async def confirm(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + alias = await _load_alias(db, alias_id) + await confirm_alias(db, alias) + return _to_read(alias, alias.targets, alias.deployments) + + +@router.delete("/{alias_id}", status_code=204) +async def delete_alias(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + alias = await _load_alias(db, alias_id) + if any(d.status in ("pending", "revert_failed") for d in alias.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN) + try: + await undeploy_alias(db, alias) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(alias, attribute_names=["deployments"]) + await db.delete(alias) + await db.commit() + + +@router.put("/{alias_id}", response_model=AliasRead) +async def edit_alias(alias_id: uuid.UUID, payload: AliasEdit, db: AsyncSession = Depends(get_db)): + alias = await _load_alias(db, alias_id) + ensure_editable(alias) + if alias.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.ALIAS_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_5A2C1888) + duplicate = await db.scalar(select(FirewallAlias).where(FirewallAlias.name == payload.name, FirewallAlias.id != alias_id)) + if duplicate: + raise api_error(409, ErrorCode.ALIAS_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + if not await db.scalar(select(AliasRevision.id).where(AliasRevision.alias_id == alias.id).limit(1)): + revision(db, alias, "Vor erster Bearbeitung") + for field in ALIAS_FIELDS: + setattr(alias, field, getattr(payload, field)) + alias.targets, alias.groups = hosts, groups + alias.policy_hierarchy_group_id = payload.policy_hierarchy_group_id + from datetime import datetime, timezone + alias.updated_at = datetime.now(timezone.utc) + revision(db, alias, "Entwurf gespeichert") + await db.commit() + await hydrate_hierarchy_targets(db, [alias]) + return _to_read(alias, hosts, alias.deployments) + + +@router.get("/{alias_id}/preview") +async def preview_alias(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_alias(db, alias_id)) + + +@router.get("/{alias_id}/revisions") +async def list_revisions(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_alias(db, alias_id) + rows = (await db.scalars(select(AliasRevision).where(AliasRevision.alias_id == alias_id).order_by(AliasRevision.created_at.desc(), AliasRevision.id))).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{alias_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(alias_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + alias = await _load_alias(db, alias_id) + plan = preview(alias) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB) + deployment = next(d for d in alias.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + await opnsense_client.del_alias_item(deployment.host, deployment.opnsense_alias_uuid) + await opnsense_client.reconfigure_aliases(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() + + +@router.post("/{alias_id}/deployments/{host_id}/retry-rollback", status_code=204) +async def retry_rollback(alias_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + alias = await _load_alias(db, alias_id) + plan = preview(alias) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_retry_rollback"]: + raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR) + deployment = next(d for d in alias.deployments if d.host_id == host_id) + reverted = await revert_alias_deployment(db, deployment) + await db.commit() + if not reverted: + raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL) diff --git a/backend/app/routers/audit.py b/backend/app/routers/audit.py new file mode 100644 index 0000000..79bcbf8 --- /dev/null +++ b/backend/app/routers/audit.py @@ -0,0 +1,28 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.models.audit import AuditEvent + +router = APIRouter(prefix="/audit", tags=["audit"]) + +@router.get("") +async def history(kind: str | None = None, host_id: str | None = None, entity_id: str | None = None, + status: str | None = None, action: str | None = None, actor: str | None = None, + before: datetime | None = None, since: datetime | None = None, + q: str = Query(default="", max_length=200), offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), db: AsyncSession = Depends(get_db)): + clauses = [] + for column, value in [(AuditEvent.kind,kind), (AuditEvent.host_id,host_id), (AuditEvent.entity_id,entity_id), + (AuditEvent.status,status), (AuditEvent.action,action), (AuditEvent.actor,actor)]: + if value: + clauses.append(column == value) + if before: clauses.append(AuditEvent.created_at <= before) + if since: clauses.append(AuditEvent.created_at >= since) + if q: + clauses.append(or_(*(c.ilike('%'+q+'%') for c in [AuditEvent.entity_name, AuditEvent.host_name, AuditEvent.actor, AuditEvent.message]))) + total = await db.scalar(select(func.count()).select_from(AuditEvent).where(*clauses)) + rows = (await db.scalars(select(AuditEvent).where(*clauses).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).offset(offset).limit(limit))).all() + return {"total": total, "offset": offset, "limit": limit, + "items": [{c.name: getattr(row, c.name) for c in AuditEvent.__table__.columns} for row in rows]} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..8247c18 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,241 @@ +import logging + +from fastapi import APIRouter, Depends, Request, Response, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ErrorCode, api_error +from app.config import settings +from app.database import get_db +from app.models.user import AdminUser +from app.services import auth_service, totp_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +COOKIE_NAME = "opnexus-session" + + +def client_ip(request: Request) -> str: + if settings.trust_proxy_headers: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + # First entry is the original client; a trusted proxy + # overwrites (not appends blindly to) this header, so this + # is safe only under the condition documented on the config + # flag itself. + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=settings.session_lifetime, + httponly=True, + samesite="lax", + secure=settings.cookie_secure, + path="/", + ) + + +def _clear_session_cookie(response: Response) -> None: + response.delete_cookie(key=COOKIE_NAME, path="/") + + +async def resolve_user(request: Request, db: AsyncSession) -> AdminUser: + token = request.cookies.get(COOKIE_NAME, "") + user = await auth_service.resolve_session(db, token) + if user is None: + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.NICHT_AUTHENTIFIZIERT) + return user + + +async def current_user(request: Request, db: AsyncSession = Depends(get_db)) -> AdminUser: + return await resolve_user(request, db) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=150) + password: str = Field(min_length=1, max_length=1024) + # Authenticator code or one-time recovery code; only needed (and only + # requested) once the password was right and the account has 2FA enabled. + totp_code: str | None = Field(default=None, max_length=32) + + +class ChangePasswordRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=1024) + new_password: str = Field(min_length=10, max_length=1024) + + +@router.post("/login") +async def login(payload: LoginRequest, request: Request, response: Response, db: AsyncSession = Depends(get_db)): + ip = client_ip(request) + + wait = auth_service.throttle.retry_after(ip, payload.username) + if wait > 0: + raise api_error(status.HTTP_429_TOO_MANY_REQUESTS, ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, wait=wait) + + user = await auth_service.authenticate(db, payload.username, payload.password, ip, finalize=False) + if user is None: + auth_service.throttle.record_failure(ip, payload.username) + logger.info("Failed login for '%s' from %s", payload.username, ip) + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT) + + if user.totp_enabled: + # Only revealed after the password was correct, so it cannot be used + # to find out which usernames exist or have 2FA. + if not payload.totp_code: + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_REQUIRED) + if not await _accept_second_factor(db, user, payload.totp_code): + auth_service.throttle.record_failure(ip, payload.username) + logger.info("Failed second factor for '%s' from %s", payload.username, ip) + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID) + await auth_service.finalize_login(db, user, ip) + + auth_service.throttle.reset(ip, payload.username) + failed_since_last_login = auth_service.throttle.pop_failures_since_success(payload.username) + token, expires_at = await auth_service.create_session( + db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip + ) + _set_session_cookie(response, token) + logger.info("Successful login for '%s' from %s", user.username, ip) + return { + "authenticated": True, + "username": user.username, + "role": user.role, + "previous_login_at": getattr(user, "previous_login_at", None), + "previous_login_ip": getattr(user, "previous_login_ip", None), + "failed_attempts_since_last_login": failed_since_last_login, + "session_expires_at": expires_at, + } + + +async def _accept_second_factor(db: AsyncSession, user: AdminUser, code: str) -> bool: + """Authenticator code (replay-protected) or, failing that, a one-time recovery code.""" + secret = totp_service.read_secret(user.id) + if secret: + step = totp_service.verify_code(secret, code, user.totp_last_step) + if step is not None: + user.totp_last_step = step + await db.commit() + return True + remaining = totp_service.consume_recovery(user.totp_recovery_hashes, code) + if remaining is not None: + user.totp_recovery_hashes = remaining + await db.commit() + logger.warning("Recovery code used by '%s' (%d left)", user.username, len(remaining)) + return True + return False + + +class TotpSetupRequest(BaseModel): + password: str = Field(min_length=1, max_length=1024) + + +class TotpEnableRequest(BaseModel): + code: str = Field(min_length=1, max_length=32) + + +class TotpDisableRequest(BaseModel): + password: str = Field(min_length=1, max_length=1024) + code: str = Field(min_length=1, max_length=32) + + +@router.get("/2fa") +async def totp_status(user: AdminUser = Depends(current_user)): + return {"enabled": user.totp_enabled, "recovery_codes_left": len(user.totp_recovery_hashes or [])} + + +@router.post("/2fa/setup") +async def totp_setup(payload: TotpSetupRequest, user: AdminUser = Depends(current_user)): + """Step 1: needs the current password (a stolen session alone must not be able + to add an attacker's authenticator). Returns the secret to scan; nothing is + active until /2fa/enable proves the authenticator works.""" + if user.totp_enabled: + raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_ALREADY_ENABLED) + if not auth_service.verify_password(user.password_hash, payload.password): + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH) + secret = totp_service.generate_secret() + totp_service.save_secret(user.id, secret, pending=True) + return {"secret": secret, "otpauth_uri": totp_service.otpauth_uri(user.username, secret)} + + +@router.post("/2fa/enable") +async def totp_enable(payload: TotpEnableRequest, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)): + if user.totp_enabled: + raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_ALREADY_ENABLED) + pending = totp_service.read_secret(user.id, pending=True) + if not pending: + raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_SETUP_MISSING) + step = totp_service.verify_code(pending, payload.code) + if step is None: + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID) + plain, hashes = totp_service.new_recovery_codes() + totp_service.promote_pending(user.id) + user.totp_enabled = True + user.totp_last_step = step + user.totp_recovery_hashes = hashes + await db.commit() + logger.info("Two-factor enabled for '%s'", user.username) + return {"enabled": True, "recovery_codes": plain} + + +@router.post("/2fa/disable") +async def totp_disable(payload: TotpDisableRequest, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)): + if not user.totp_enabled: + raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_NOT_ENABLED) + if not auth_service.verify_password(user.password_hash, payload.password): + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH) + if not await _accept_second_factor(db, user, payload.code): + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID) + totp_service.delete_secrets(user.id) + user.totp_enabled = False + user.totp_recovery_hashes = None + user.totp_last_step = 0 + await db.commit() + logger.info("Two-factor disabled for '%s'", user.username) + return {"enabled": False} + + +@router.post("/logout") +async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)): + token = request.cookies.get(COOKIE_NAME, "") + await auth_service.revoke_session(db, token) + _clear_session_cookie(response) + return {"authenticated": False} + + +@router.get("/me") +async def me(request: Request, db: AsyncSession = Depends(get_db)): + token = request.cookies.get(COOKIE_NAME, "") + user = await auth_service.resolve_session(db, token) + if user is None: + return {"authenticated": False} + return {"authenticated": True, "username": user.username, "role": user.role} + + +@router.post("/change-password") +async def change_password( + payload: ChangePasswordRequest, + response: Response, + request: Request, + db: AsyncSession = Depends(get_db), + user: AdminUser = Depends(current_user), +): + if not auth_service.verify_password(user.password_hash, payload.current_password): + raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH) + if payload.new_password == payload.current_password: + raise api_error(status.HTTP_400_BAD_REQUEST, ErrorCode.DAS_NEUE_PASSWORT_MUSS_SICH_VOM_AKTUELLEN_UNTERSCHEIDEN) + + user.password_hash = auth_service.hash_password(payload.new_password) + await db.commit() + + await auth_service.revoke_session(db, request.cookies.get(COOKIE_NAME, "")) + token, _ = await auth_service.create_session( + db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request) + ) + _set_session_cookie(response, token) + return {"ok": True} diff --git a/backend/app/routers/certificates.py b/backend/app/routers/certificates.py new file mode 100644 index 0000000..e25cab9 --- /dev/null +++ b/backend/app/routers/certificates.py @@ -0,0 +1,301 @@ +"""Trust-Store (`ManagedCertificate`) + ACME (`AcmeAccount`, +`AcmeCertificateRequest`) management routers. Deliberately simpler shape +than ipsec.py/openvpn.py/wireguard.py: single-host (no target +resolution), no preview/revision/workflow machinery (no multi-host +no-op/drift concept applies to a single fixed host), and — for +Trust-Store only — no deploy warning gate (see managed_certificate_ +service.py's module docstring for why). ACME's `register`/`sign` DO get +a distinct warning in the frontend (AcmeActionConfirm.tsx) even though +the backend endpoints here are plain POSTs — the two-step confirmation is +a UI-level gate, the same architectural choice ipsec.py/openvpn.py/ +wireguard.py make for their own "push" action. +""" +import uuid + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.acme_account import AcmeAccount +from app.models.acme_certificate_request import AcmeCertificateRequest +from app.models.host import OpnsenseHost +from app.models.managed_certificate import ManagedCertificate +from app.schemas.certificates import ( + AcmeAccountCreate, AcmeAccountEdit, AcmeAccountRead, + AcmeCertificateRequestCreate, AcmeCertificateRequestEdit, AcmeCertificateRequestRead, + ManagedCertificateCreate, ManagedCertificateEdit, ManagedCertificateRead, +) +from app.services import acme_secret_store, acme_service, cert_key_store, managed_certificate_service +from app.services.host_maintenance_service import ensure_no_maintenance +from app.services.opnsense_client import OpnsenseAPIError +from app.services.policy_service import policy_lock + +managed_router = APIRouter(prefix="/certificates/managed", tags=["certificates"]) +acme_accounts_router = APIRouter(prefix="/acme/accounts", tags=["acme"]) +acme_certificates_router = APIRouter(prefix="/acme/certificates", tags=["acme"]) + + +async def _get_host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost: + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(400, ErrorCode.HOST_NICHT_GEFUNDEN) + return host + + +async def _get_enabled_host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost: + host = await _get_host(db, host_id) + if not host.enabled: + raise api_error(400, ErrorCode.HOST_IST_DEAKTIVIERT) + return host + + +# ── Trust-Store certificates ────────────────────────────────────────── +@managed_router.get("", response_model=list[ManagedCertificateRead]) +async def list_managed_certificates(db: AsyncSession = Depends(get_db)): + rows = (await db.scalars(select(ManagedCertificate).order_by(ManagedCertificate.created_at.desc()))).all() + return rows + + +@managed_router.post("", response_model=ManagedCertificateRead, status_code=201) +async def create_managed_certificate(payload: ManagedCertificateCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + await _get_host(db, payload.host_id) + data = payload.model_dump(exclude={"private_key"}) + cert = ManagedCertificate(**data) + db.add(cert) + await db.flush() + if payload.private_key: + cert_key_store.save_secret(cert.id, payload.private_key) + cert.private_key_set = True + await db.commit() + return cert + + +@managed_router.put("/{cert_id}", response_model=ManagedCertificateRead) +async def edit_managed_certificate(cert_id: uuid.UUID, payload: ManagedCertificateEdit, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + cert = await db.get(ManagedCertificate, cert_id) + if not cert: + raise api_error(404, ErrorCode.ZERTIFIKAT_NICHT_GEFUNDEN) + if cert.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.EINTRAG_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_B5A5CE87) + await _get_host(db, payload.host_id) + for field, value in payload.model_dump(exclude={"expected_updated_at", "private_key"}).items(): + setattr(cert, field, value) + if payload.private_key: + cert_key_store.save_secret(cert.id, payload.private_key) + cert.private_key_set = True + await db.commit() + return cert + + +@managed_router.post("/{cert_id}/push", response_model=ManagedCertificateRead) +async def push_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + cert = await db.get(ManagedCertificate, cert_id) + if not cert: + raise api_error(404, ErrorCode.ZERTIFIKAT_NICHT_GEFUNDEN) + host = await _get_enabled_host(db, cert.host_id) + await ensure_no_maintenance(db, [host]) + return await managed_certificate_service.push_certificate(db, cert, host) + + +@managed_router.delete("/{cert_id}", status_code=204) +async def delete_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + cert = await db.get(ManagedCertificate, cert_id) + if not cert: + raise api_error(404, ErrorCode.ZERTIFIKAT_NICHT_GEFUNDEN) + host = await db.get(OpnsenseHost, cert.host_id) + try: + await managed_certificate_service.delete_certificate(db, cert, host) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +# ── ACME accounts ────────────────────────────────────────────────────── +@acme_accounts_router.get("", response_model=list[AcmeAccountRead]) +async def list_acme_accounts(db: AsyncSession = Depends(get_db)): + rows = (await db.scalars(select(AcmeAccount).order_by(AcmeAccount.created_at.desc()))).all() + return rows + + +@acme_accounts_router.post("", response_model=AcmeAccountRead, status_code=201) +async def create_acme_account(payload: AcmeAccountCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + await _get_host(db, payload.host_id) + data = payload.model_dump(exclude={"eab_hmac"}) + account = AcmeAccount(**data) + db.add(account) + await db.flush() + if payload.eab_hmac: + acme_secret_store.save_secret(account.id, payload.eab_hmac) + account.eab_hmac_set = True + await db.commit() + return account + + +@acme_accounts_router.put("/{account_id}", response_model=AcmeAccountRead) +async def edit_acme_account(account_id: uuid.UUID, payload: AcmeAccountEdit, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + account = await db.get(AcmeAccount, account_id) + if not account: + raise api_error(404, ErrorCode.ACCOUNT_NICHT_GEFUNDEN) + if account.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.EINTRAG_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_B5A5CE87) + await _get_host(db, payload.host_id) + for field, value in payload.model_dump(exclude={"expected_updated_at", "eab_hmac"}).items(): + setattr(account, field, value) + if payload.eab_hmac: + acme_secret_store.save_secret(account.id, payload.eab_hmac) + account.eab_hmac_set = True + await db.commit() + return account + + +@acme_accounts_router.post("/{account_id}/push", response_model=AcmeAccountRead) +async def push_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + account = await db.get(AcmeAccount, account_id) + if not account: + raise api_error(404, ErrorCode.ACCOUNT_NICHT_GEFUNDEN) + host = await _get_enabled_host(db, account.host_id) + await ensure_no_maintenance(db, [host]) + return await acme_service.push_account(db, account, host) + + +@acme_accounts_router.post("/{account_id}/register", response_model=AcmeAccountRead) +async def register_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + """REAL, blocking network call to the configured external CA — see + acme_service.register_account()'s docstring. The frontend gates this + behind an explicit two-step warning (AcmeActionConfirm.tsx); this + endpoint itself fires immediately on request, same as ipsec.py's + deploy endpoint firing immediately once the frontend has armed it.""" + account = await db.get(AcmeAccount, account_id) + if not account: + raise api_error(404, ErrorCode.ACCOUNT_NICHT_GEFUNDEN) + if not account.opnsense_uuid: + raise api_error(400, ErrorCode.ACCOUNT_MUSS_ZUERST_AUF_DIE_FIREWALL_GESCHRIEBEN_WERDEN_EEEA04A9) + host = await _get_enabled_host(db, account.host_id) + try: + return await acme_service.register_account(db, account, host) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +@acme_accounts_router.delete("/{account_id}", status_code=204) +async def delete_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + account = await db.get(AcmeAccount, account_id) + if not account: + raise api_error(404, ErrorCode.ACCOUNT_NICHT_GEFUNDEN) + if await db.scalar(select(AcmeCertificateRequest.id).where(AcmeCertificateRequest.account_id == account_id).limit(1)): + raise api_error(409, ErrorCode.ACCOUNT_WIRD_NOCH_VON_MINDESTENS_EINER_ZERTIFIKATSANFRAGE_REFERENZIERT) + host = await db.get(OpnsenseHost, account.host_id) + try: + await acme_service.delete_account(db, account, host) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +# ── ACME certificate requests ───────────────────────────────────────── +@acme_certificates_router.get("", response_model=list[AcmeCertificateRequestRead]) +async def list_acme_certificate_requests(db: AsyncSession = Depends(get_db)): + rows = (await db.scalars(select(AcmeCertificateRequest).order_by(AcmeCertificateRequest.created_at.desc()))).all() + return rows + + +async def _get_account(db: AsyncSession, account_id: uuid.UUID, host_id: uuid.UUID) -> AcmeAccount: + account = await db.get(AcmeAccount, account_id) + if not account: + raise api_error(400, ErrorCode.ACME_ACCOUNT_NICHT_GEFUNDEN) + if account.host_id != host_id: + raise api_error(400, ErrorCode.ACME_ACCOUNT_GEHOERT_ZU_EINEM_ANDEREN_HOST) + return account + + +@acme_certificates_router.post("", response_model=AcmeCertificateRequestRead, status_code=201) +async def create_acme_certificate_request(payload: AcmeCertificateRequestCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + await _get_host(db, payload.host_id) + await _get_account(db, payload.account_id, payload.host_id) + req = AcmeCertificateRequest(**payload.model_dump()) + db.add(req) + await db.commit() + return req + + +@acme_certificates_router.put("/{request_id}", response_model=AcmeCertificateRequestRead) +async def edit_acme_certificate_request(request_id: uuid.UUID, payload: AcmeCertificateRequestEdit, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + req = await db.get(AcmeCertificateRequest, request_id) + if not req: + raise api_error(404, ErrorCode.ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN) + if req.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.EINTRAG_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_B5A5CE87) + await _get_host(db, payload.host_id) + await _get_account(db, payload.account_id, payload.host_id) + for field, value in payload.model_dump(exclude={"expected_updated_at"}).items(): + setattr(req, field, value) + await db.commit() + return req + + +@acme_certificates_router.post("/{request_id}/push", response_model=AcmeCertificateRequestRead) +async def push_acme_certificate_request(request_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + req = await db.get(AcmeCertificateRequest, request_id) + if not req: + raise api_error(404, ErrorCode.ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN) + host = await _get_enabled_host(db, req.host_id) + account = await db.get(AcmeAccount, req.account_id) + if not account: + raise api_error(400, ErrorCode.VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN) + await ensure_no_maintenance(db, [host]) + return await acme_service.push_certificate_request(db, req, host, account) + + +@acme_certificates_router.post("/{request_id}/sign", response_model=AcmeCertificateRequestRead) +async def sign_acme_certificate_request(request_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + """REAL, asynchronous network call to the configured external CA — see + acme_service.sign_certificate_request()'s docstring. Frontend-gated + the same way register_acme_account() above is.""" + req = await db.get(AcmeCertificateRequest, request_id) + if not req: + raise api_error(404, ErrorCode.ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN) + if not req.opnsense_uuid: + raise api_error(400, ErrorCode.ZERTIFIKATSANFRAGE_MUSS_ZUERST_AUF_DIE_FIREWALL_GESCHRIEBEN_WERDEN_F684C449) + host = await _get_enabled_host(db, req.host_id) + try: + return await acme_service.sign_certificate_request(db, req, host) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +@acme_certificates_router.post("/{request_id}/poll", response_model=AcmeCertificateRequestRead) +async def poll_acme_certificate_request(request_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + req = await db.get(AcmeCertificateRequest, request_id) + if not req: + raise api_error(404, ErrorCode.ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN) + if not req.opnsense_uuid: + raise api_error(400, ErrorCode.ZERTIFIKATSANFRAGE_WURDE_NOCH_NICHT_AUF_DIE_FIREWALL_GESCHRIEBEN) + host = await _get_host(db, req.host_id) + try: + return await acme_service.poll_certificate_status(db, req, host) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +@acme_certificates_router.delete("/{request_id}", status_code=204) +async def delete_acme_certificate_request(request_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + req = await db.get(AcmeCertificateRequest, request_id) + if not req: + raise api_error(404, ErrorCode.ZERTIFIKATSANFRAGE_NICHT_GEFUNDEN) + host = await db.get(OpnsenseHost, req.host_id) + try: + await acme_service.delete_certificate_request(db, req, host) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc diff --git a/backend/app/routers/cluster.py b/backend/app/routers/cluster.py new file mode 100644 index 0000000..3094fc3 --- /dev/null +++ b/backend/app/routers/cluster.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.carp_event import CarpEvent +from app.schemas.carp_event import CarpEventRead + +router = APIRouter(prefix="/cluster", tags=["cluster"]) + + +@router.get("/carp-events", response_model=list[CarpEventRead]) +async def list_carp_events( + cluster_name: str | None = None, + limit: int = Query(default=50, ge=1, le=500), + db: AsyncSession = Depends(get_db), +): + clauses = [] if cluster_name is None else [CarpEvent.cluster_name == cluster_name] + result = await db.execute( + select(CarpEvent).where(*clauses).order_by(CarpEvent.detected_at.desc()).limit(limit) + ) + return list(result.scalars().all()) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..f56248e --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,224 @@ +"""Fleet-wide "what needs my attention" summary for the frontend dashboard +(app/dashboard/page.tsx). Deliberately DB-only — no live OPNsense calls — +so this endpoint stays fast and doesn't fail just because one host is +offline; the dashboard fetches live-only data (certificate expiry, firmware +update availability) itself per host via the existing +/hosts/{id}/certificates and /hosts/{id}/maintenance endpoints, exactly the +way the Certificates and Update Center pages already do, so a slow/offline +host degrades only that one panel instead of the whole page. + +"Pending actions" aggregates the same three attention-worthy deployment +statuses ("pending" awaiting confirmation, "error" the deploy call itself +failed, "revert_failed" the rollback call failed) across all deployable +entity kinds (rules, aliases, the three NAT types, policy-hierarchy rules, +IPsec/OpenVPN/WireGuard — the VPN kinds only ever reach "error") plus any host_operations still awaiting manual confirmation +(status in "starting"/"submitted"/"unknown" — mirrors +HostMaintenance.tsx's own definition of "needs a click"). Restore-operation +locks are deliberately NOT included here (no fleet-wide list endpoint +exists for those, only per-host, and it's a rarely-triggered flow) — +a documented v1 gap, not an oversight. +""" +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.alias import FirewallAlias +from app.models.alias_deployment import AliasDeployment +from app.models.audit import AuditEvent +from app.models.carp_maintenance_window import CarpMaintenanceWindow +from app.models.host import OpnsenseHost +from app.models.host_operation import HostOperation +from app.models.ipsec_tunnel import IpsecTunnel +from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment +from app.models.one_to_one import OneToOneRule +from app.models.one_to_one_deployment import OneToOneDeployment +from app.models.outbound_nat import OutboundNatRule +from app.models.openvpn_instance import OpenVpnInstance +from app.models.openvpn_instance_deployment import OpenVpnInstanceDeployment +from app.models.outbound_nat_deployment import OutboundNatDeployment +from app.models.policy_hierarchy_rule import PolicyHierarchyRule +from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment +from app.models.port_forward import PortForwardRule +from app.models.port_forward_deployment import PortForwardDeployment +from app.models.rule import FirewallRule +from app.models.rule_deployment import RuleDeployment +from app.models.wireguard_tunnel import WireguardTunnel +from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment +from app.schemas.dashboard import ActivityItem, DashboardHost, DashboardSummary, PendingAction +from app.services.host_status_service import get_statuses + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + +ATTENTION_STATUSES = ("pending", "error", "revert_failed") +STUCK_OPERATION_STATUSES = ("starting", "submitted", "unknown") + +OPERATION_LABELS = { + "reboot": "Neustart", + "update": "Firmware-Update", + "major_update": "Major-Upgrade", + "carp_enter": "CARP-Übergabe", + "carp_leave": "CARP-Normalbetrieb", +} + + +async def _rule_actions(db: AsyncSession) -> list[PendingAction]: + rows = ( + await db.execute( + select(RuleDeployment, FirewallRule.description, OpnsenseHost.name) + .join(FirewallRule, RuleDeployment.rule_id == FirewallRule.id) + .join(OpnsenseHost, RuleDeployment.host_id == OpnsenseHost.id) + .where(RuleDeployment.status.in_(ATTENTION_STATUSES)) + ) + ).all() + return [ + PendingAction( + kind="rule", status=dep.status, entity_name=description, host_name=host_name, + message=dep.message, created_at=dep.deployed_at, link=f"/rules?rule={dep.rule_id}", + ) + for dep, description, host_name in rows + ] + + +async def _nat_actions( + db: AsyncSession, deployment_model, rule_model, fk_attr: str, kind: str, nat_type: str +) -> list[PendingAction]: + fk_column = getattr(deployment_model, fk_attr) + rows = ( + await db.execute( + select(deployment_model, rule_model.description, OpnsenseHost.name) + .join(rule_model, fk_column == rule_model.id) + .join(OpnsenseHost, deployment_model.host_id == OpnsenseHost.id) + .where(deployment_model.status.in_(ATTENTION_STATUSES)) + ) + ).all() + return [ + PendingAction( + kind=kind, status=dep.status, entity_name=description, host_name=host_name, + message=dep.message, created_at=dep.deployed_at, + link=f"/nat?type={nat_type}&rule={getattr(dep, fk_attr)}", + ) + for dep, description, host_name in rows + ] + + +async def _simple_actions( + db: AsyncSession, deployment_model, parent_model, fk_attr: str, name_column, kind: str, link_template: str +) -> list[PendingAction]: + """Same shape as _nat_actions for kinds whose entity label is not + `description` and whose page takes a different query parameter; + `link_template` is formatted with the parent id.""" + rows = ( + await db.execute( + select(deployment_model, name_column, OpnsenseHost.name) + .join(parent_model, getattr(deployment_model, fk_attr) == parent_model.id) + .join(OpnsenseHost, deployment_model.host_id == OpnsenseHost.id) + .where(deployment_model.status.in_(ATTENTION_STATUSES)) + ) + ).all() + return [ + PendingAction( + kind=kind, status=dep.status, entity_name=name, host_name=host_name, + message=dep.message, created_at=dep.deployed_at, link=link_template.format(getattr(dep, fk_attr)), + ) + for dep, name, host_name in rows + ] + + +async def _policy_hierarchy_actions(db: AsyncSession) -> list[PendingAction]: + rows = ( + await db.execute( + select(PolicyHierarchyRuleDeployment, PolicyHierarchyRule.description, + PolicyHierarchyRule.hierarchy_group_id, OpnsenseHost.name) + .join(PolicyHierarchyRule, PolicyHierarchyRuleDeployment.policy_hierarchy_id == PolicyHierarchyRule.id) + .join(OpnsenseHost, PolicyHierarchyRuleDeployment.host_id == OpnsenseHost.id) + .where(PolicyHierarchyRuleDeployment.status.in_(ATTENTION_STATUSES)) + ) + ).all() + return [ + PendingAction( + kind="policy_hierarchy", status=dep.status, entity_name=description, host_name=host_name, + message=dep.message, created_at=dep.deployed_at, link=f"/policy-hierarchy?group={group_id}", + ) + for dep, description, group_id, host_name in rows + ] + + +async def _stuck_host_operations(db: AsyncSession) -> list[PendingAction]: + rows = ( + await db.scalars( + select(HostOperation) + .where(HostOperation.status.in_(STUCK_OPERATION_STATUSES)) + .order_by(HostOperation.created_at.desc()) + ) + ).all() + return [ + PendingAction( + kind="host_operation", status=op.status, + entity_name=OPERATION_LABELS.get(op.action, op.action), host_name=op.host_name, + message=op.message, created_at=op.created_at, link=f"/hosts/{op.host_id}", + ) + for op in rows + ] + + +async def _carp_auto_revert_actions(db: AsyncSession) -> list[PendingAction]: + rows = ( + await db.scalars( + select(CarpMaintenanceWindow) + .where(CarpMaintenanceWindow.status.in_(("active", "revert_failed"))) + .order_by(CarpMaintenanceWindow.entered_at.desc()) + ) + ).all() + actions = [] + for window in rows: + if window.status == "active": + message = f"Automatischer Rückschalt-Zeitpunkt: {window.auto_revert_at.strftime('%d.%m. %H:%M') if window.auto_revert_at else 'unbekannt'}" + else: + message = window.message + actions.append(PendingAction( + kind="carp_auto_revert", status=window.status, entity_name="CARP-Wartungsmodus", + host_name=window.host_name, message=message, created_at=window.entered_at, + link=f"/hosts/{window.host_id}", + )) + return actions + + +@router.get("", response_model=DashboardSummary) +async def summary(db: AsyncSession = Depends(get_db)): + hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all() + statuses = await get_statuses(hosts) + host_summaries = [ + DashboardHost( + id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"), + carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name, + ) + for host in hosts + ] + + pending_actions: list[PendingAction] = [] + pending_actions += await _rule_actions(db) + pending_actions += await _nat_actions(db, PortForwardDeployment, PortForwardRule, "port_forward_id", "port_forward", "port-forward") + pending_actions += await _nat_actions(db, OneToOneDeployment, OneToOneRule, "one_to_one_id", "one_to_one", "one-to-one") + pending_actions += await _nat_actions(db, OutboundNatDeployment, OutboundNatRule, "outbound_nat_id", "outbound_nat", "outbound-nat") + pending_actions += await _policy_hierarchy_actions(db) + pending_actions += await _simple_actions(db, AliasDeployment, FirewallAlias, "alias_id", FirewallAlias.name, "alias", "/aliases?alias={}") + pending_actions += await _simple_actions(db, IpsecTunnelDeployment, IpsecTunnel, "tunnel_id", IpsecTunnel.description, "ipsec", "/ipsec?tunnel={}") + pending_actions += await _simple_actions(db, OpenVpnInstanceDeployment, OpenVpnInstance, "instance_id", OpenVpnInstance.description, "openvpn", "/openvpn?instance={}") + pending_actions += await _simple_actions(db, WireguardTunnelDeployment, WireguardTunnel, "tunnel_id", WireguardTunnel.server_name, "wireguard", "/wireguard?tunnel={}") + pending_actions += await _stuck_host_operations(db) + pending_actions += await _carp_auto_revert_actions(db) + pending_actions.sort(key=lambda item: item.created_at, reverse=True) + + activity_rows = ( + await db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(15)) + ).all() + recent_activity = [ + ActivityItem( + id=row.id, created_at=row.created_at, actor=row.actor, kind=row.kind, action=row.action, + status=row.status, entity_name=row.entity_name, host_name=row.host_name, message=row.message, + ) + for row in activity_rows + ] + + return DashboardSummary(hosts=host_summaries, pending_actions=pending_actions[:30], recent_activity=recent_activity) diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py new file mode 100644 index 0000000..de09cff --- /dev/null +++ b/backend/app/routers/groups.py @@ -0,0 +1,166 @@ +import uuid +from app.errors import ErrorCode, api_error +from app.models.port_forward import PortForwardRule +from app.models.policy import port_forward_groups +from app.models.one_to_one import OneToOneRule +from app.models.policy import one_to_one_groups +from app.models.outbound_nat import OutboundNatRule +from app.models.policy import outbound_nat_groups +import hashlib +import json + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, rule_groups, alias_groups +from app.models.rule import FirewallRule +from app.models.alias import FirewallAlias +from app.services.policy_service import policy_lock, effective_hosts + +router = APIRouter(prefix="/groups", tags=["device-groups"]) + + +class GroupWrite(BaseModel): + name: str = Field(min_length=1, max_length=120) + description: str = Field(default="", max_length=2000) + host_ids: list[uuid.UUID] = Field(default_factory=list) + + @field_validator("name") + @classmethod + def clean_name(cls, value): + if not value.strip(): + raise ValueError("Name darf nicht leer sein") + return value.strip() + + +class GroupUpdate(GroupWrite): + preview_token: str | None = Field(default=None, min_length=64, max_length=64) + + +def read(group): + return {"id": group.id, "name": group.name, "description": group.description, + "host_ids": [h.id for h in group.hosts]} + + +@router.get("") +async def list_groups(db: AsyncSession = Depends(get_db)): + groups = (await db.scalars(select(DeviceGroup).options(selectinload(DeviceGroup.hosts)).order_by(DeviceGroup.name))).all() + return [read(g) for g in groups] + + +async def save(db, payload, group=None): + duplicate = await db.scalar(select(DeviceGroup).where(DeviceGroup.name == payload.name)) + if duplicate and (group is None or duplicate.id != group.id): + raise api_error(409, ErrorCode.GRUPPENNAME_BEREITS_VERGEBEN) + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.host_ids)))).all()) + if len(hosts) != len(set(payload.host_ids)): + raise api_error(400, ErrorCode.ZIEL_HOST_NICHT_GEFUNDEN) + if group is None: + group = DeviceGroup() + db.add(group) + group.name, group.description, group.hosts = payload.name, payload.description, hosts + await db.commit() + return read(group) + + +@router.post("", status_code=201) +async def create_group(payload: GroupWrite, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + return await save(db, payload) + + +@router.put("/{group_id}") +async def update_group(group_id: uuid.UUID, payload: GroupUpdate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + group = await db.get(DeviceGroup, group_id, options=[selectinload(DeviceGroup.hosts)]) + if not group: + raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN) + impact = await group_impact(db, group, payload) + if payload.preview_token and payload.preview_token != impact["token"]: + raise api_error(409, ErrorCode.GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN) + return await save(db, payload, group) + + +@router.delete("/{group_id}", status_code=204) +async def delete_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + group = await db.get(DeviceGroup, group_id) + if not group: + raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN) + if await db.scalar(select(rule_groups.c.rule_id).where(rule_groups.c.group_id == group_id).limit(1)): + raise api_error(409, ErrorCode.GRUPPE_WIRD_NOCH_VON_REGELN_VERWENDET) + if await db.scalar(select(alias_groups.c.alias_id).where(alias_groups.c.group_id == group_id).limit(1)): + raise api_error(409, ErrorCode.GRUPPE_WIRD_NOCH_VON_ALIASEN_VERWENDET) + for assignment in (port_forward_groups, one_to_one_groups, outbound_nat_groups): + if await db.scalar(select(assignment.c.group_id).where(assignment.c.group_id == group_id).limit(1)): + raise api_error(409, ErrorCode.GRUPPE_WIRD_NOCH_VON_NAT_REGELN_VERWENDET) + await db.delete(group) + await db.commit() + + +def _impact_rows(entities, group, proposed, opnsense_uuid_attr): + impacts = [] + for entity in entities: + before = {h.id for h in effective_hosts(entity)} + after = {h.id for h in entity.targets} + for target_group in entity.groups: + after.update(h.id for h in (proposed if target_group.id == group.id else target_group.hosts)) + removed = before - after + deployed = {d.host_id for d in entity.deployments if getattr(d, opnsense_uuid_attr)} + impacts.append({"added_host_ids": sorted(map(str, after - before)), + "removed_host_ids": sorted(map(str, removed)), + "retained_host_ids": sorted(map(str, removed & deployed))}) + return impacts + + +async def group_impact(db, group, payload): + proposed = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.host_ids)))).all()) + if len(proposed) != len(set(payload.host_ids)): + raise api_error(400, ErrorCode.ZIEL_HOST_NICHT_GEFUNDEN) + rules = (await db.scalars(select(FirewallRule).where(FirewallRule.groups.any(DeviceGroup.id == group.id)).options( + selectinload(FirewallRule.targets), + selectinload(FirewallRule.groups).selectinload(DeviceGroup.hosts), + selectinload(FirewallRule.deployments), + ).order_by(FirewallRule.id))).all() + aliases = (await db.scalars(select(FirewallAlias).where(FirewallAlias.groups.any(DeviceGroup.id == group.id)).options( + selectinload(FirewallAlias.targets), + selectinload(FirewallAlias.groups).selectinload(DeviceGroup.hosts), + selectinload(FirewallAlias.deployments), + ).order_by(FirewallAlias.id))).all() + rule_impacts = [{"rule_id": str(r.id), "description": r.description, **row} + for r, row in zip(rules, _impact_rows(rules, group, proposed, "opnsense_rule_uuid"))] + alias_impacts = [{"alias_id": str(a.id), "description": a.name, **row} + for a, row in zip(aliases, _impact_rows(aliases, group, proposed, "opnsense_alias_uuid"))] + nat_impacts = [] + for model, kind in ((PortForwardRule, "port_forward"), (OneToOneRule, "one_to_one"), (OutboundNatRule, "outbound_nat")): + entities = (await db.scalars(select(model).where(model.groups.any(DeviceGroup.id == group.id)).options( + selectinload(model.targets), selectinload(model.groups).selectinload(DeviceGroup.hosts), + selectinload(model.deployments), + ).order_by(model.id))).all() + nat_impacts.extend({"id": str(e.id), "kind": kind, "description": e.description, **row} + for e, row in zip(entities, _impact_rows(entities, group, proposed, f"opnsense_{kind}_uuid"))) + old_ids = {h.id for h in group.hosts} + new_ids = {h.id for h in proposed} + result = {"added_members": [{"id": str(h.id), "name": h.name} for h in sorted(proposed, key=lambda h: str(h.id)) if h.id not in old_ids], + "removed_members": [{"id": str(h.id), "name": h.name} for h in sorted(group.hosts, key=lambda h: str(h.id)) if h.id not in new_ids], + "rules": rule_impacts, + "aliases": alias_impacts, "nat": nat_impacts} + state = {"current": {"name": group.name, "description": group.description, "host_ids": sorted(map(str, old_ids))}, + "proposed": {"name": payload.name, "description": payload.description, "host_ids": sorted(map(str, new_ids))}, + "impact": result} + result["token"] = hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest() + return result + + +@router.post("/{group_id}/preview") +async def preview_group(group_id: uuid.UUID, payload: GroupWrite, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + group = await db.get(DeviceGroup, group_id, options=[selectinload(DeviceGroup.hosts)]) + if not group: + raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN) + return await group_impact(db, group, payload) diff --git a/backend/app/routers/hosts.py b/backend/app/routers/hosts.py new file mode 100644 index 0000000..b6da5ae --- /dev/null +++ b/backend/app/routers/hosts.py @@ -0,0 +1,294 @@ +import logging +import uuid +from app.errors import ErrorCode, api_error +from app.models.port_forward_deployment import PortForwardDeployment +from app.models.one_to_one_deployment import OneToOneDeployment +from app.models.outbound_nat_deployment import OutboundNatDeployment + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.services.policy_service import policy_lock +from app.models.host import OpnsenseHost +from app.models.policy_hierarchy_group import PolicyHierarchyGroup +from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment +from app.models.rule_deployment import RuleDeployment +from app.models.alias_deployment import AliasDeployment +from app.schemas.host import AliasOption, FirmwareInfo, HostBackup, HostCa, HostCertificate, HostCreate, HostRead, HostUpdate, TlsProbeRequest, TlsProbeResult, InterfaceOption, LiveRule +from app.services import backup_store, opnsense_client, opnsense_secret_store, tls_pin_service +from app.services.alias_service import get_aliases +from app.services.firmware_service import get_firmware_info +from app.services.host_status_service import get_statuses +from app.services.interface_service import get_interfaces, get_protocols +from app.services.live_certificates_service import get_live_cas, get_live_certificates +from app.services.live_rules_service import get_live_rules +from app.services.opnsense_client import OpnsenseAPIError + +router = APIRouter(prefix="/hosts", tags=["hosts"]) +logger = logging.getLogger(__name__) + + +def _upstream_error(host: OpnsenseHost, exc: OpnsenseAPIError) -> HTTPException: + """OpnsenseAPIError's message embeds the raw upstream response body + (see opnsense_client._do_request) — useful in a log, not something to + hand an authenticated-but-otherwise-unprivileged (read_only) caller. + Full detail goes to the backend log (visible via `docker compose logs + backend` since the logging fix), client gets a generic 502.""" + logger.warning("OPNsense-Anfrage an Host '%s' fehlgeschlagen: %s", host.name, exc) + return api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) + + +def _to_read(host: OpnsenseHost, info: dict) -> HostRead: + return HostRead( + id=host.id, + name=host.name, + base_url=host.base_url, + verify_tls=host.verify_tls, + tls_pinned=bool(host.tls_ca_pem), + tls_fingerprint=tls_pin_service.fingerprint_of(host.tls_ca_pem), + enabled=host.enabled, + cluster_name=host.cluster_name, + policy_hierarchy_group_id=host.policy_hierarchy_group_id, + created_at=host.created_at, + status=info.get("status", "offline"), + carp_status=info.get("carp_status"), + status_detail=info.get("status_detail"), + ) + + +async def _get_host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost: + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + return host + + +@router.get("", response_model=list[HostRead]) +async def list_hosts(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(OpnsenseHost).order_by(OpnsenseHost.name)) + hosts = result.scalars().all() + statuses = await get_statuses(hosts) + return [_to_read(host, statuses.get(host.id, {})) for host in hosts] + + +async def _checked_pin(base_url: str, pem: str) -> str: + """Validate the PEM and prove the firewall really presents it.""" + try: + tls_pin_service.parse_pem(pem) + except tls_pin_service.TlsPinError: + raise api_error(422, ErrorCode.TLS_PEM_INVALID) + try: + await tls_pin_service.verify_matches(base_url, pem) + except tls_pin_service.TlsPinError as exc: + logger.warning("TLS-Pin für %s abgelehnt: %s", base_url, exc) + raise api_error(422, ErrorCode.TLS_PIN_MISMATCH) + return pem.strip() + "\n" + + +@router.post("/tls-probe", response_model=TlsProbeResult) +async def tls_probe(payload: TlsProbeRequest): + """Read (not trust) the certificate a firewall presents, so a human can compare + its fingerprint with the one shown on the firewall before pinning it.""" + try: + return await tls_pin_service.probe(payload.base_url.rstrip("/")) + except tls_pin_service.TlsPinError as exc: + logger.warning("TLS-Probe %s fehlgeschlagen: %s", payload.base_url, exc) + raise api_error(502, ErrorCode.TLS_PROBE_FAILED) + + +@router.post("", response_model=HostRead, status_code=201) +async def create_host(payload: HostCreate, db: AsyncSession = Depends(get_db)): + existing = await db.scalar(select(OpnsenseHost).where(OpnsenseHost.name == payload.name)) + if existing: + raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS) + if payload.policy_hierarchy_group_id and not await db.get(PolicyHierarchyGroup, payload.policy_hierarchy_group_id): + raise api_error(400, ErrorCode.POLICY_HIERARCHIE_GRUPPE_NICHT_GEFUNDEN) + + base_url = payload.base_url.rstrip("/") + pinned_pem = await _checked_pin(base_url, payload.tls_ca_pem) if payload.tls_ca_pem else None + host = OpnsenseHost( + name=payload.name, + base_url=base_url, + api_key=payload.api_key, + verify_tls=payload.verify_tls or bool(pinned_pem), + tls_ca_pem=pinned_pem, + enabled=payload.enabled, + cluster_name=payload.cluster_name or None, + policy_hierarchy_group_id=payload.policy_hierarchy_group_id, + ) + db.add(host) + await db.flush() + opnsense_secret_store.save_secret(host.id, payload.api_secret) + await db.commit() + + statuses = await get_statuses([host]) + return _to_read(host, statuses.get(host.id, {})) + + +@router.get("/{host_id}", response_model=HostRead) +async def get_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + statuses = await get_statuses([host]) + return _to_read(host, statuses.get(host.id, {})) + + +@router.patch("/{host_id}", response_model=HostRead) +async def update_host(host_id: uuid.UUID, payload: HostUpdate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await _get_host_or_404(db, host_id) + + if payload.name is not None: + existing = await db.scalar( + select(OpnsenseHost).where(OpnsenseHost.name == payload.name, OpnsenseHost.id != host_id) + ) + if existing: + raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS) + host.name = payload.name + if payload.verify_tls is not None: + host.verify_tls = payload.verify_tls + if payload.clear_tls_pin: + host.tls_ca_pem = None + elif payload.tls_ca_pem: + host.tls_ca_pem = await _checked_pin(host.base_url, payload.tls_ca_pem) + host.verify_tls = True + if payload.enabled is not None: + host.enabled = payload.enabled + if payload.clear_cluster_name: + host.cluster_name = None + elif payload.cluster_name is not None: + host.cluster_name = payload.cluster_name + if payload.clear_policy_hierarchy_group_id: + host.policy_hierarchy_group_id = None + elif payload.policy_hierarchy_group_id is not None: + if not await db.get(PolicyHierarchyGroup, payload.policy_hierarchy_group_id): + raise api_error(400, ErrorCode.POLICY_HIERARCHIE_GRUPPE_NICHT_GEFUNDEN) + host.policy_hierarchy_group_id = payload.policy_hierarchy_group_id + + await db.commit() + statuses = await get_statuses([host]) + return _to_read(host, statuses.get(host.id, {})) + + +@router.delete("/{host_id}", status_code=204) +async def delete_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + # Both deployment tables FK to opnsense_hosts with ondelete=CASCADE, so + # deleting the host would silently drop OPNexus's only record of what + # it pushed there — the rule/alias itself stays live on the actual + # firewall, now completely untracked. Block until those are cleared + # (delete the rule/alias, or remove it as a target and clean up the + # deployment under "Prüfen & Push"). + if await db.scalar(select(RuleDeployment.id).where(RuleDeployment.host_id == host_id).limit(1)): + raise api_error(409, ErrorCode.HOST_HAT_NOCH_AKTIVE_REGEL_DEPLOYMENTS_DIESE_ZUERST_0BF41721) + if await db.scalar(select(AliasDeployment.id).where(AliasDeployment.host_id == host_id).limit(1)): + raise api_error(409, ErrorCode.HOST_HAT_NOCH_AKTIVE_ALIAS_DEPLOYMENTS_DIESE_ZUERST_F4A3ED46) + for deployment_model in (PortForwardDeployment, OneToOneDeployment, OutboundNatDeployment): + if await db.scalar(select(deployment_model.id).where(deployment_model.host_id == host_id).limit(1)): + raise api_error(409, ErrorCode.HOST_HAT_NOCH_NAT_DEPLOYMENTS_DIESE_ZUERST_GEZIELT_0C2A9E85) + if await db.scalar(select(PolicyHierarchyRuleDeployment.id).where(PolicyHierarchyRuleDeployment.host_id == host_id).limit(1)): + raise api_error(409, ErrorCode.HOST_HAT_NOCH_AKTIVE_POLICY_HIERARCHIE_DEPLOYMENTS_DIESE_ADD78845) + from app.models.host_operation import HostOperation + from app.services.host_maintenance_service import ACTIVE + if await db.scalar(select(HostOperation.id).where(HostOperation.host_id == host_id, HostOperation.status.in_(ACTIVE)).limit(1)): + raise api_error(409, ErrorCode.HOST_HAT_EINE_OFFENE_WARTUNGSAKTION_ZUERST_PRUEFEN_UND_8CC5FBE0) + await db.delete(host) + await db.commit() + opnsense_secret_store.delete_secret(host_id) + + +@router.get("/{host_id}/live-rules", response_model=list[LiveRule]) +async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_live_rules(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + + +@router.get("/{host_id}/certificates", response_model=list[HostCertificate]) +async def certificates(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_live_certificates(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + + +@router.get("/{host_id}/cas", response_model=list[HostCa]) +async def cas(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_live_cas(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + + +@router.post("/{host_id}/backups", response_model=HostBackup, status_code=201) +async def trigger_backup(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + content = await opnsense_client.download_backup(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + backup_id = backup_store.save_snapshot(host_id, content) + return next(s for s in backup_store.list_snapshots(host_id) if s["id"] == backup_id) + + +@router.get("/{host_id}/backups", response_model=list[HostBackup]) +async def list_host_backups(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _get_host_or_404(db, host_id) + return backup_store.list_snapshots(host_id) + + +@router.get("/{host_id}/backups/{backup_id}") +async def download_host_backup(host_id: uuid.UUID, backup_id: str, db: AsyncSession = Depends(get_db)): + await _get_host_or_404(db, host_id) + content = backup_store.read_snapshot(host_id, backup_id) + if content is None: + raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN) + return Response( + content=content, + media_type="application/xml", + headers={"Content-Disposition": f'attachment; filename="{backup_id}"'}, + ) + + +@router.get("/{host_id}/firmware-info", response_model=FirmwareInfo) +async def firmware_info(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_firmware_info(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + + +@router.get("/{host_id}/interfaces", response_model=list[InterfaceOption]) +async def interfaces(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_interfaces(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + + +@router.get("/{host_id}/protocols", response_model=list[InterfaceOption]) +async def protocols(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_protocols(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc + + +@router.get("/{host_id}/aliases", response_model=list[AliasOption]) +async def aliases(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await _get_host_or_404(db, host_id) + try: + return await get_aliases(host) + except OpnsenseAPIError as exc: + raise _upstream_error(host, exc) from exc diff --git a/backend/app/routers/ipsec.py b/backend/app/routers/ipsec.py new file mode 100644 index 0000000..39eccdc --- /dev/null +++ b/backend/app/routers/ipsec.py @@ -0,0 +1,215 @@ +import uuid + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, IpsecTunnelRevision +from app.models.ipsec_tunnel import IpsecTunnel +from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment +from app.schemas.ipsec import IpsecDeployApproval, IpsecDeploymentResult, IpsecTunnelCreate, IpsecTunnelEdit, IpsecTunnelRead +from app.services import ipsec_psk_store +from app.services.ipsec_service import effective_hosts, preview, revision, snapshot, workflow, IPSEC_FIELDS +from app.services.ipsec_deploy_service import deploy_tunnel, undeploy_tunnel +from app.services.opnsense_client import OpnsenseAPIError +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/ipsec/tunnels", tags=["ipsec"]) + + +def _load_options(): + return [ + selectinload(IpsecTunnel.targets), + selectinload(IpsecTunnel.groups).selectinload(DeviceGroup.hosts), + selectinload(IpsecTunnel.deployments).selectinload(IpsecTunnelDeployment.host), + ] + + +async def _load_tunnel(db: AsyncSession, tunnel_id: uuid.UUID) -> IpsecTunnel: + # Same advisory lock as rules/aliases/NAT — serializes concurrent + # deploy-affecting changes across processes. Unlike rules there is no + # ensure_editable() gate here: this entity has no pending/revert_failed + # state to ever block editing on (see ipsec_deploy_service.py's module + # docstring) — editing is always allowed, same scope decision already + # made for rule DELETE ("already an explicit action, not gated"). + await policy_lock(db) + tunnel = await db.get(IpsecTunnel, tunnel_id, options=_load_options()) + if not tunnel: + raise api_error(404, ErrorCode.TUNNEL_NICHT_GEFUNDEN) + return tunnel + + +def _to_read(tunnel: IpsecTunnel, deployments: list[IpsecTunnelDeployment]) -> IpsecTunnelRead: + hosts_by_id = {host.id: host for host in effective_hosts(tunnel)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(tunnel, deployments) + return IpsecTunnelRead( + workflow_state=state, + workflow_counts=counts, + id=tunnel.id, + description=tunnel.description, + local_address=tunnel.local_address, + remote_address=tunnel.remote_address, + ike_version=tunnel.ike_version, + proposal=tunnel.proposal, + local_identity=tunnel.local_identity, + remote_identity=tunnel.remote_identity, + local_networks=tunnel.local_networks, + remote_networks=tunnel.remote_networks, + esp_proposal=tunnel.esp_proposal, + enabled=tunnel.enabled, + pre_shared_key_set=tunnel.pre_shared_key_set, + created_at=tunnel.created_at, + updated_at=tunnel.updated_at, + target_host_ids=[host.id for host in tunnel.targets], + target_group_ids=[g.id for g in tunnel.groups], + effective_host_ids=[h.id for h in effective_hosts(tunnel)], + deployments=[ + IpsecDeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + ) + for d in deployments + ], + ) + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.get("", response_model=list[IpsecTunnelRead]) +async def list_tunnels(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(IpsecTunnel).options(*_load_options())) + tunnels = result.scalars().unique().all() + return [_to_read(tunnel, tunnel.deployments) for tunnel in tunnels] + + +@router.post("", response_model=IpsecTunnelRead, status_code=201) +async def create_tunnel(payload: IpsecTunnelCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + + tunnel = IpsecTunnel( + **{field: getattr(payload, field) for field in IPSEC_FIELDS}, + targets=hosts, + groups=groups, + ) + db.add(tunnel) + await db.flush() + ipsec_psk_store.save_secret(tunnel.id, payload.pre_shared_key) + tunnel.pre_shared_key_set = True + revision(db, tunnel, "Tunnel angelegt") + await db.commit() + return _to_read(tunnel, []) + + +@router.put("/{tunnel_id}", response_model=IpsecTunnelRead) +async def edit_tunnel(tunnel_id: uuid.UUID, payload: IpsecTunnelEdit, db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + if tunnel.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.TUNNEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_50830F70) + hosts, groups = await resolve_targets(db, payload) + if not await db.scalar(select(IpsecTunnelRevision.id).where(IpsecTunnelRevision.tunnel_id == tunnel.id).limit(1)): + revision(db, tunnel, "Vor erster Bearbeitung") + for field in IPSEC_FIELDS: + setattr(tunnel, field, getattr(payload, field)) + tunnel.targets, tunnel.groups = hosts, groups + if payload.pre_shared_key is not None: + ipsec_psk_store.save_secret(tunnel.id, payload.pre_shared_key) + tunnel.pre_shared_key_set = True + from datetime import datetime, timezone + tunnel.updated_at = datetime.now(timezone.utc) + revision(db, tunnel, "Entwurf gespeichert") + await db.commit() + return _to_read(tunnel, tunnel.deployments) + + +@router.get("/{tunnel_id}/preview") +async def preview_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_tunnel(db, tunnel_id)) + + +@router.get("/{tunnel_id}/revisions") +async def list_revisions(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_tunnel(db, tunnel_id) + rows = (await db.scalars( + select(IpsecTunnelRevision).where(IpsecTunnelRevision.tunnel_id == tunnel_id) + .order_by(IpsecTunnelRevision.created_at.desc(), IpsecTunnelRevision.id) + )).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{tunnel_id}/deploy", response_model=IpsecTunnelRead) +async def deploy(tunnel_id: uuid.UUID, approval: IpsecDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + plan = preview(tunnel) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if not tunnel.pre_shared_key_set: + raise api_error(400, ErrorCode.FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_PRE_SHARED_4AE1FC63) + hosts = [h for h in effective_hosts(tunnel) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.TUNNEL_HAT_KEINE_ZIEL_HOSTS) + await deploy_tunnel(db, tunnel, hosts) + await db.refresh(tunnel, attribute_names=["deployments"]) + for d in tunnel.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(tunnel, tunnel.deployments) + + +@router.delete("/{tunnel_id}", status_code=204) +async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + try: + await undeploy_tunnel(db, tunnel) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(tunnel, attribute_names=["deployments"]) + ipsec_psk_store.delete_secret(tunnel.id) + await db.delete(tunnel) + await db.commit() + + +@router.post("/{tunnel_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(tunnel_id: uuid.UUID, host_id: uuid.UUID, approval: IpsecDeployApproval, db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + plan = preview(tunnel) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_KOENNEN_BEREINIGT_WERDEN) + deployment = next(d for d in tunnel.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + if deployment.opnsense_child_uuid: + await opnsense_client.del_ipsec_child(deployment.host, deployment.opnsense_child_uuid) + if deployment.opnsense_local_uuid: + await opnsense_client.del_ipsec_local(deployment.host, deployment.opnsense_local_uuid) + if deployment.opnsense_remote_uuid: + await opnsense_client.del_ipsec_remote(deployment.host, deployment.opnsense_remote_uuid) + if deployment.opnsense_connection_uuid: + await opnsense_client.del_ipsec_connection(deployment.host, deployment.opnsense_connection_uuid) + if deployment.opnsense_psk_uuid: + await opnsense_client.del_ipsec_psk(deployment.host, deployment.opnsense_psk_uuid) + await opnsense_client.reconfigure_ipsec(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..4a39db6 --- /dev/null +++ b/backend/app/routers/logs.py @@ -0,0 +1,173 @@ +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.log_entry import FirewallLogEntry +from app.schemas.logs import LogFilterOptions, LogSearchResult, LogSettingsRead, LogSettingsUpdate, LogStatCount, LogStats +from app.services import log_ingest_service, log_settings_service + +router = APIRouter(prefix="/logs", tags=["logs"]) + + +async def _settings_with_usage(db: AsyncSession, row) -> LogSettingsRead: + usage = await log_ingest_service.storage_usage(db) + data = LogSettingsRead.model_validate(row) + oldest = await db.scalar(select(func.min(FirewallLogEntry.timestamp))) + return data.model_copy(update={"used_bytes": usage["used_bytes"], "entries": usage["entries"], "oldest_at": oldest}) + + +@router.get("/settings", response_model=LogSettingsRead) +async def read_log_settings(db: AsyncSession = Depends(get_db)): + return await _settings_with_usage(db, await log_settings_service.get_settings(db)) + + +@router.put("/settings", response_model=LogSettingsRead) +async def update_log_settings(payload: LogSettingsUpdate, db: AsyncSession = Depends(get_db)): + return await _settings_with_usage(db, await log_settings_service.update_settings(db, payload)) + +# Aggregated over the same filter set as /logs itself (host/cluster/time +# range), not a generic analytics engine — a handful of fixed queries. +_TOP_N = 10 +# Actions counted as "blocked" for the top-sources aggregate. OPNsense's +# firewall log action vocabulary is effectively pass/block/reject. +_BLOCKED_ACTIONS = ("block", "reject") + + +def _base_filters( + host_id: uuid.UUID | None, + action: str | None, + interface: str | None, + protocol: str | None, + q: str, + since: datetime | None, + until: datetime | None, +) -> list: + clauses = [] + if host_id is not None: + clauses.append(FirewallLogEntry.host_id == host_id) + if action: + clauses.append(FirewallLogEntry.action == action) + if interface: + clauses.append(FirewallLogEntry.interface == interface) + if protocol: + clauses.append(FirewallLogEntry.protocol == protocol) + if since: + clauses.append(FirewallLogEntry.timestamp >= since) + if until: + clauses.append(FirewallLogEntry.timestamp <= until) + if q: + pattern = f"%{q}%" + clauses.append(or_( + FirewallLogEntry.label.ilike(pattern), + FirewallLogEntry.source_net.ilike(pattern), + FirewallLogEntry.destination_net.ilike(pattern), + )) + return clauses + + +async def _cluster_host_ids(db: AsyncSession, cluster_name: str) -> list[uuid.UUID]: + result = await db.execute(select(OpnsenseHost.id).where(OpnsenseHost.cluster_name == cluster_name)) + return [row[0] for row in result.all()] + + +@router.get("", response_model=LogSearchResult) +async def search_logs( + host_id: uuid.UUID | None = None, + cluster_name: str | None = None, + q: str = Query(default="", max_length=200), + action: str | None = None, + interface: str | None = None, + protocol: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=500), + db: AsyncSession = Depends(get_db), +): + clauses = _base_filters(host_id, action, interface, protocol, q, since, until) + if cluster_name: + clauses.append(FirewallLogEntry.host_id.in_(await _cluster_host_ids(db, cluster_name))) + + total = await db.scalar(select(func.count()).select_from(FirewallLogEntry).where(*clauses)) + rows = (await db.scalars( + select(FirewallLogEntry) + .where(*clauses) + .order_by(FirewallLogEntry.timestamp.desc(), FirewallLogEntry.id.desc()) + .offset(offset) + .limit(limit) + )).all() + return LogSearchResult(total=total or 0, offset=offset, limit=limit, items=list(rows)) + + +@router.get("/stats", response_model=LogStats) +async def log_stats( + host_id: uuid.UUID | None = None, + cluster_name: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + db: AsyncSession = Depends(get_db), +): + clauses = _base_filters(host_id, None, None, None, "", since, until) + if cluster_name: + clauses.append(FirewallLogEntry.host_id.in_(await _cluster_host_ids(db, cluster_name))) + + by_action_rows = (await db.execute( + select(FirewallLogEntry.action, func.count()) + .where(*clauses) + .group_by(FirewallLogEntry.action) + )).all() + by_interface_rows = (await db.execute( + select(FirewallLogEntry.interface, func.count()) + .where(*clauses) + .group_by(FirewallLogEntry.interface) + )).all() + + source_count = func.count().label("source_count") + top_sources_rows = (await db.execute( + select(FirewallLogEntry.source_net, source_count) + .where(*clauses, FirewallLogEntry.action.in_(_BLOCKED_ACTIONS), FirewallLogEntry.source_net.is_not(None)) + .group_by(FirewallLogEntry.source_net) + .order_by(source_count.desc()) + .limit(_TOP_N) + )).all() + + port_count = func.count().label("port_count") + top_ports_rows = (await db.execute( + select(FirewallLogEntry.destination_port, port_count) + .where(*clauses, FirewallLogEntry.destination_port.is_not(None)) + .group_by(FirewallLogEntry.destination_port) + .order_by(port_count.desc()) + .limit(_TOP_N) + )).all() + + return LogStats( + by_action={(a or "unbekannt"): c for a, c in by_action_rows}, + by_interface={(i or "unbekannt"): c for i, c in by_interface_rows}, + top_blocked_sources=[LogStatCount(value=s, count=c) for s, c in top_sources_rows], + top_destination_ports=[LogStatCount(value=p, count=c) for p, c in top_ports_rows], + ) + + +@router.get("/filter-options", response_model=LogFilterOptions) +async def filter_options(db: AsyncSession = Depends(get_db)): + """Distinct action/interface/protocol values from OPNexus's own + persisted log data — replaces the old live viewer's reliance on + OPNsense's `logFiltersAction`, since that endpoint only ever reflects + one host at a time and this feature now searches across all of them.""" + actions = (await db.scalars( + select(FirewallLogEntry.action).where(FirewallLogEntry.action.is_not(None)).distinct() + )).all() + interfaces = (await db.scalars( + select(FirewallLogEntry.interface).where(FirewallLogEntry.interface.is_not(None)).distinct() + )).all() + protocols = (await db.scalars( + select(FirewallLogEntry.protocol).where(FirewallLogEntry.protocol.is_not(None)).distinct() + )).all() + return LogFilterOptions( + actions=sorted(actions), interfaces=sorted(interfaces), protocols=sorted(protocols), + ) diff --git a/backend/app/routers/maintenance.py b/backend/app/routers/maintenance.py new file mode 100644 index 0000000..c05ac17 --- /dev/null +++ b/backend/app/routers/maintenance.py @@ -0,0 +1,211 @@ +import uuid +from datetime import datetime, timedelta, timezone +from typing import Literal +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models import OpnsenseHost +from app.models.host_operation import HostOperation +from app.services import opnsense_client as client, audit_service, carp_maintenance_window_service +from app.services.host_maintenance_service import inspect_host, inspect_carp_switch, fingerprint, peers, ACTIVE +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/hosts/{host_id}/maintenance", tags=["host-maintenance"]) + +class Preview(BaseModel): + action: Literal['reboot','update','major_update'] + +class Execute(BaseModel): + preview_id: uuid.UUID + confirm_name: str = Field(min_length=1, max_length=120) + acknowledge_downtime: Literal[True] + # carp_enter only: optional auto-revert timer (see + # carp_maintenance_window_service) — ignored for every other action. + auto_revert_minutes: int | None = Field(default=None, ge=1, le=1440) + +class Acknowledge(BaseModel): + confirm_name: str = Field(min_length=1, max_length=120) + +class Cancel(BaseModel): + confirm_name: str = Field(min_length=1, max_length=120) + reason: str = Field(min_length=3, max_length=500) + +async def host_or_404(db, host_id): + h=await db.get(OpnsenseHost,host_id) + if not h: raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + return h + +def read_operation(op): + return {"id":op.id,"host_id":op.host_id,"action":op.action,"status":op.status,"created_at":op.created_at,"expires_at":op.expires_at,"actor":op.actor,"message":op.message} + +async def snapshot_or_502(db, host): + try: return await inspect_host(db,host) + except client.OpnsenseAPIError as exc: raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + +async def carp_snapshot_or_502(db, host): + try: return await inspect_carp_switch(db,host) + except client.OpnsenseAPIError as exc: raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + +@router.get('') +async def status(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host=await host_or_404(db,host_id) + operations=(await db.scalars(select(HostOperation).where(HostOperation.host_id==host.id, HostOperation.status!='preview').order_by(HostOperation.created_at.desc()).limit(20))).all() + # Keep operation records visible while the firewall is rebooting/offline. + try: state=await inspect_host(db,host); error=None + except client.OpnsenseAPIError as exc: state=None; error=str(exc) + return {"state":state,"error":error,"operations":[read_operation(o) for o in operations]} + +@router.post('/preview') +async def preview(host_id: uuid.UUID, payload: Preview, request: Request, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host=await host_or_404(db,host_id) + state=await snapshot_or_502(db,host) + if state['actions'][payload.action]: raise api_error(409, ErrorCode.ACTION_NOT_ALLOWED, reason=' '.join(state['actions'][payload.action])) + op=HostOperation(host_id=host.id,host_name=host.name,actor=request.state.user.username,action=payload.action, + expires_at=datetime.now(timezone.utc)+timedelta(minutes=5),fingerprint=fingerprint(state),snapshot=state) + db.add(op);await db.commit() + return {"preview_id":op.id,"expires_at":op.expires_at,"action":op.action,"state":state} + +@router.post('/execute') +async def execute(host_id: uuid.UUID, payload: Execute, request: Request, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host=await host_or_404(db,host_id) + op=await db.get(HostOperation,payload.preview_id) + if not op or op.host_id!=host_id or op.actor!=request.state.user.username: raise api_error(404, ErrorCode.WARTUNGSVORSCHAU_NICHT_GEFUNDEN) + if op.status!='preview' or op.expires_at <= datetime.now(timezone.utc): raise api_error(409, ErrorCode.VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET) + if payload.confirm_name != host.name: raise api_error(409, ErrorCode.HOSTNAMEN_EXAKT_EINGEBEN) + state=await snapshot_or_502(db,host) + if state['actions'][op.action]: raise api_error(409, ErrorCode.ACTION_NOT_ALLOWED, reason=' '.join(state['actions'][op.action])) + if fingerprint(state)!=op.fingerprint: raise api_error(409, ErrorCode.HOSTZUSTAND_WURDE_VERAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + # Consume durably BEFORE the remote request. A timeout is ambiguous, and + # replaying a reboot/update automatically could interrupt recovery. + op.status='starting'; op.started_at=datetime.now(timezone.utc); await db.commit() + await audit_service.persist(kind='host',action=op.action,status='requested',host_id=str(host.id),host_name=host.name,entity_id=str(op.id),details={'snapshot':state}) + action_fn={'reboot':client.reboot_host,'update':client.update_firmware,'major_update':client.upgrade_firmware}[op.action] + try: + result=await action_fn(host) + op.status='submitted';op.message='An Firewall übergeben. Abschluss und Erreichbarkeit prüfen; anschließend Wartung abschließen.' + except client.OpnsenseAPIError as exc: + op.status='unknown';op.message='Ausgang unklar: '+str(exc)[:800]+'. Nicht automatisch wiederholen.' + result={} + await db.commit() + await audit_service.persist(kind='host',action=op.action,status=op.status,host_id=str(host.id),host_name=host.name,entity_id=str(op.id),message=op.message,details={'response':result}) + return read_operation(op) + +@router.post('/check-updates', status_code=202) +async def check_updates(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + from app.services.host_maintenance_service import ensure_no_maintenance + await policy_lock(db) + host=await host_or_404(db,host_id) + if not host.enabled: raise api_error(409, ErrorCode.HOST_IST_DEAKTIVIERT) + await ensure_no_maintenance(db,[host]) + try: + running=await client.get_firmware_running(host) + if running.get('status')!='ready': raise api_error(409, ErrorCode.FIRMWARE_AUFTRAG_LAEUFT_BEREITS_ODER_ZUSTAND_IST_UNBEKANNT) + return await client.check_firmware_updates(host) + except client.OpnsenseAPIError as exc: raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + +@router.get('/progress') +async def progress(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host=await host_or_404(db,host_id) + try: return await client.get_firmware_update_progress(host) + except client.OpnsenseAPIError as exc: raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + +@router.post('/carp-preview') +async def carp_preview(host_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host=await host_or_404(db,host_id) + state=await carp_snapshot_or_502(db,host) + if state['actions']['carp']: raise api_error(409, ErrorCode.ACTION_NOT_ALLOWED, reason=' '.join(state['actions']['carp'])) + op=HostOperation(host_id=host.id,host_name=host.name,actor=request.state.user.username,action=state['action'], + expires_at=datetime.now(timezone.utc)+timedelta(minutes=5),fingerprint=fingerprint(state),snapshot=state) + db.add(op);await db.commit() + return {"preview_id":op.id,"expires_at":op.expires_at,"action":op.action,"state":state} + +@router.post('/carp-execute') +async def carp_execute(host_id: uuid.UUID, payload: Execute, request: Request, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host=await host_or_404(db,host_id) + op=await db.get(HostOperation,payload.preview_id) + if not op or op.host_id!=host_id or op.actor!=request.state.user.username or op.action not in ('carp_enter','carp_leave'): + raise api_error(404, ErrorCode.CARP_VORSCHAU_NICHT_GEFUNDEN) + if op.status!='preview' or op.expires_at <= datetime.now(timezone.utc): raise api_error(409, ErrorCode.VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET) + if payload.confirm_name != host.name: raise api_error(409, ErrorCode.HOSTNAMEN_EXAKT_EINGEBEN) + state=await carp_snapshot_or_502(db,host) + if state['actions']['carp']: raise api_error(409, ErrorCode.ACTION_NOT_ALLOWED, reason=' '.join(state['actions']['carp'])) + if state['action']!=op.action or fingerprint(state)!=op.fingerprint: raise api_error(409, ErrorCode.CARP_ZUSTAND_WURDE_VERAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + # The OPNsense endpoint is a toggle. Consume the preview before calling it + # and never replay after a timeout, otherwise a retry could undo the switch. + op.status='starting';op.started_at=datetime.now(timezone.utc);await db.commit() + await audit_service.persist(kind='host',action=op.action,status='requested',host_id=str(host.id),host_name=host.name,entity_id=str(op.id),details={'snapshot':state}) + try: + result=await client.toggle_carp_maintenance(host) + expected='enter_maintenance' if op.action=='carp_enter' else 'leave_maintenance' + if result.get('action')!=expected: + raise client.OpnsenseAPIError('CARP-Antwort passt nicht zur angeforderten Richtung') + op.status='submitted' + op.message=('CARP-Übergabe angefordert. Prüfen, dass der Partner MASTER und dieser Host BACKUP ist.' if op.action=='carp_enter' + else 'Normalen CARP-Betrieb angefordert. Rollen und Erreichbarkeit beider Hosts prüfen.') + if op.action=='carp_enter': + await carp_maintenance_window_service.open_window(db,host,payload.auto_revert_minutes) + else: + await carp_maintenance_window_service.close_windows_for_manual_leave(db,host) + except client.OpnsenseAPIError as exc: + op.status='unknown';op.message='CARP-Ausgang unklar: '+str(exc)[:760]+'. Nicht erneut klicken; Rollen zuerst prüfen.';result={} + await db.commit() + await audit_service.persist(kind='host',action=op.action,status=op.status,host_id=str(host.id),host_name=host.name,entity_id=str(op.id),message=op.message,details={'response':result}) + return read_operation(op) + +@router.post('/{operation_id}/acknowledge') +async def acknowledge(host_id: uuid.UUID, operation_id: uuid.UUID, payload: Acknowledge, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host=await host_or_404(db,host_id) + op=await db.get(HostOperation,operation_id) + if not op or op.host_id!=host.id: raise api_error(404, ErrorCode.WARTUNGSAKTION_NICHT_GEFUNDEN) + if payload.confirm_name!=host.name: raise api_error(409, ErrorCode.HOSTNAMEN_EXAKT_EINGEBEN) + if op.status not in ACTIVE: raise api_error(409, ErrorCode.KEINE_OFFENE_WARTUNGSAKTION) + minimum_wait=5 if op.action in ('carp_enter','carp_leave') else 30 + if op.started_at and (datetime.now(timezone.utc)-op.started_at).total_seconds() < minimum_wait: + raise api_error(409, ErrorCode.MAINTENANCE_WAIT_REQUIRED, seconds=minimum_wait) + if op.action in ('carp_enter','carp_leave'): + state=await carp_snapshot_or_502(db,host) + current=next((p for p in state['peers'] if p['id']==str(host.id)),{}) + others=[p for p in state['peers'] if p['id']!=str(host.id)] + healthy_roles=any(p['status']=='online' and p['carp_status']=='MASTER' for p in state['peers']) and any(p['status']=='online' and p['carp_status']=='BACKUP' for p in state['peers']) + if op.action=='carp_enter' and not (state['carp_maintenance_mode'] and current.get('carp_status')=='BACKUP' and any(p['status']=='online' and p['carp_status']=='MASTER' for p in others)): + raise api_error(409, ErrorCode.ROLLENWECHSEL_NOCH_NICHT_BESTAETIGT_DIESER_HOST_MUSS_IM_4676309B) + if op.action=='carp_leave' and not (not state['carp_maintenance_mode'] and healthy_roles): + raise api_error(409, ErrorCode.NORMALBETRIEB_NOCH_NICHT_BESTAETIGT_WARTUNGSMODUS_MUSS_AUS_UND_8AF9290B) + else: + state=await snapshot_or_502(db,host) + if state['running']!='ready' or not any(p['id']==str(host.id) and p['status']=='online' for p in state['peers']): + raise api_error(409, ErrorCode.HOST_MUSS_ERREICHBAR_SEIN_UND_FIRMWARE_AUFTRAG_BEENDET_BCE1390B) + op.status='acknowledged';op.message='Abschluss manuell geprüft und bestätigt.' + await db.commit() + details={'observed_roles':state['peers']} if op.action in ('carp_enter','carp_leave') else {'observed_version':state['version']} + await audit_service.persist(kind='host',action=op.action,status='acknowledged',host_id=str(host.id),host_name=host.name,entity_id=str(op.id),message=op.message,details=details) + return read_operation(op) + +@router.post('/{operation_id}/cancel') +async def cancel(host_id: uuid.UUID, operation_id: uuid.UUID, payload: Cancel, db: AsyncSession = Depends(get_db)): + """Escape hatch for an operation whose claimed target state never became + true (e.g. OPNsense's CARP maintenance toggle accepted the request but the + role switch itself never completed). Unlike acknowledge, this does NOT + assert success — it only closes the record so the host/cluster stops being + blocked, and requires a human-written reason for the audit trail.""" + await policy_lock(db) + host=await host_or_404(db,host_id) + op=await db.get(HostOperation,operation_id) + if not op or op.host_id!=host.id: raise api_error(404, ErrorCode.WARTUNGSAKTION_NICHT_GEFUNDEN) + if payload.confirm_name!=host.name: raise api_error(409, ErrorCode.HOSTNAMEN_EXAKT_EINGEBEN) + if op.status not in ACTIVE: raise api_error(409, ErrorCode.KEINE_OFFENE_WARTUNGSAKTION) + minimum_wait=5 if op.action in ('carp_enter','carp_leave') else 30 + if op.started_at and (datetime.now(timezone.utc)-op.started_at).total_seconds() < minimum_wait: + raise api_error(409, ErrorCode.MAINTENANCE_WAIT_REQUIRED, seconds=minimum_wait) + op.status='cancelled';op.message='Manuell abgebrochen, kein bestätigter Abschluss. Grund: '+payload.reason.strip()[:400] + await db.commit() + await audit_service.persist(kind='host',action=op.action,status='cancelled',host_id=str(host.id),host_name=host.name,entity_id=str(op.id),message=op.message,details={'reason':payload.reason}) + return read_operation(op) diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py new file mode 100644 index 0000000..acc114d --- /dev/null +++ b/backend/app/routers/notifications.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.notification import NotificationProblem +from app.schemas.notification import ( + NotificationProblemRead, + NotificationSettingsRead, + NotificationSettingsUpdate, + TestNotificationRequest, +) +from app.services import notification_delivery, notification_settings_service + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +@router.get("/settings", response_model=NotificationSettingsRead) +async def read_settings(db: AsyncSession = Depends(get_db)): + return await notification_settings_service.get_settings(db) + + +@router.put("/settings", response_model=NotificationSettingsRead) +async def update_settings(payload: NotificationSettingsUpdate, db: AsyncSession = Depends(get_db)): + return await notification_settings_service.update_settings(db, payload) + + +@router.get("/problems", response_model=list[NotificationProblemRead]) +async def list_problems( + include_resolved: bool = False, + limit: int = Query(default=100, ge=1, le=500), + db: AsyncSession = Depends(get_db), +): + clauses = [] if include_resolved else [NotificationProblem.status == "open"] + result = await db.execute( + select(NotificationProblem) + .where(*clauses) + .order_by(NotificationProblem.last_seen_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + +@router.post("/test") +async def test_notification(payload: TestNotificationRequest, db: AsyncSession = Depends(get_db)): + settings_row = await notification_settings_service.get_settings(db) + fake_problem = NotificationProblem( + problem_type="test", + dedup_key="test", + context={"message": "Dies ist eine Testbenachrichtigung von OPNexus."}, + ) + try: + if payload.channel == "webhook": + await notification_delivery.send_webhook(settings_row, fake_problem, "opened") + else: + subject, body = notification_delivery.render_message(fake_problem, "opened") + await notification_delivery.send_email(settings_row, subject, body) + except notification_delivery.NotificationDeliveryError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + return {"success": True} diff --git a/backend/app/routers/one_to_one.py b/backend/app/routers/one_to_one.py new file mode 100644 index 0000000..168262d --- /dev/null +++ b/backend/app/routers/one_to_one.py @@ -0,0 +1,271 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, OneToOneRevision +from app.services.policy_service import effective_hosts, policy_lock, ensure_editable +from app.services.one_to_one_policy_service import ONE_TO_ONE_FIELDS, snapshot, revision, preview, workflow +from app.models.one_to_one import OneToOneRule +from app.models.one_to_one_deployment import OneToOneDeployment +from app.schemas.one_to_one import DeploymentResult, OneToOneCreate, OneToOneRead, OneToOneEdit, DeployApproval +from app.services.alias_service import check_alias_consistency, describe_alias_contents, ensure_aliases_settled +from app.services.interface_service import check_interface_consistency +from app.services.opnsense_client import OpnsenseAPIError +from app.services.one_to_one_deploy_service import confirm_one_to_one, deploy_one_to_one, undeploy_one_to_one, revert_deployment +from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group + +router = APIRouter(prefix="/one-to-one", tags=["one-to-one"]) + + +def _load_options(): + return [ + selectinload(OneToOneRule.targets), + selectinload(OneToOneRule.groups).selectinload(DeviceGroup.hosts), + selectinload(OneToOneRule.deployments).selectinload(OneToOneDeployment.host), + ] + + +async def _load_rule(db: AsyncSession, rule_id: uuid.UUID) -> OneToOneRule: + await policy_lock(db) + rule = await db.get(OneToOneRule, rule_id, options=_load_options()) + if not rule: + raise api_error(404, ErrorCode.ERROR_1_1_NAT_REGEL_NICHT_GEFUNDEN) + await hydrate_hierarchy_targets(db, [rule]) + return rule + + +def _to_read(rule: OneToOneRule, hosts: list[OpnsenseHost], deployments: list[OneToOneDeployment]) -> OneToOneRead: + hosts_by_id = {host.id: host for host in effective_hosts(rule)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(rule, deployments) + return OneToOneRead( + workflow_state=state, + workflow_counts=counts, + id=rule.id, + description=rule.description, + interface=rule.interface, + source_net=rule.source_net, + destination_net=rule.destination_net, + source_not=rule.source_not, + destination_not=rule.destination_not, + enabled=rule.enabled, + type=rule.type, + external=rule.external, + natreflection=rule.natreflection, + log=rule.log, + created_at=rule.created_at, + updated_at=rule.updated_at, + target_host_ids=[host.id for host in rule.targets], + target_group_ids=[g.id for g in rule.groups], + policy_hierarchy_group_id=rule.policy_hierarchy_group_id, + effective_host_ids=[h.id for h in effective_hosts(rule)], + deployments=[ + DeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + revert_deadline=d.revert_deadline, + ) + for d in deployments + ], + ) + + +@router.get("", response_model=list[OneToOneRead]) +async def list_one_to_one_rules(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(OneToOneRule).options(*_load_options())) + rules = result.scalars().unique().all() + await hydrate_hierarchy_targets(db, rules) + return [_to_read(rule, rule.targets, rule.deployments) for rule in rules] + + +@router.post("", response_model=OneToOneRead, status_code=201) +async def create_one_to_one_rule(payload: OneToOneCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + + rule = OneToOneRule( + description=payload.description, + interface=payload.interface, + source_net=payload.source_net, + destination_net=payload.destination_net, + source_not=payload.source_not, + destination_not=payload.destination_not, + enabled=payload.enabled, + type=payload.type, + external=payload.external, + natreflection=payload.natreflection, + log=payload.log, + targets=hosts, + groups=groups, + policy_hierarchy_group_id=payload.policy_hierarchy_group_id, + ) + db.add(rule) + await db.flush() + revision(db, rule, "Regel angelegt") + await db.commit() + await hydrate_hierarchy_targets(db, [rule]) + return _to_read(rule, hosts, []) + + +@router.post("/{rule_id}/deploy", response_model=OneToOneRead) +async def deploy(rule_id: uuid.UUID, approval: DeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN) + hosts = [h for h in effective_hosts(rule) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.REGEL_HAT_KEINE_ZIEL_HOSTS) + if len(hosts) > 1: + labels = await check_interface_consistency(hosts, rule.interface) + distinct = set(labels.values()) + if len(distinct) > 1: + detail = ", ".join(f"{name}={label}" for name, label in sorted(labels.items())) + raise api_error(409, ErrorCode.INTERFACE_MEANING_DIFFERS, interface=rule.interface, detail=detail) + for field_label, value in ( + ("Source", rule.source_net), + ("Destination", rule.destination_net), + ("Externes Netz", rule.external), + ): + if not value: + continue + contents = await check_alias_consistency(hosts, value) + if len(set(contents.values())) > 1: + detail = describe_alias_contents(contents) + raise api_error(409, ErrorCode.ALIAS_CONTENT_DIFFERS, field_label=field_label, alias=value, detail=detail) + await ensure_aliases_settled(db, hosts, [rule.source_net, rule.destination_net, rule.external]) + await deploy_one_to_one(db, rule, hosts) + await db.refresh(rule, attribute_names=["deployments"]) + for d in rule.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(rule, hosts, rule.deployments) + + +@router.post("/{rule_id}/confirm", response_model=OneToOneRead) +async def confirm(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + await confirm_one_to_one(db, rule) + return _to_read(rule, rule.targets, rule.deployments) + + +@router.delete("/{rule_id}", status_code=204) +async def delete_one_to_one_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN) + try: + await undeploy_one_to_one(db, rule) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(rule, attribute_names=["deployments"]) + await db.delete(rule) + await db.commit() + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.put("/{rule_id}", response_model=OneToOneRead) +async def edit_one_to_one_rule(rule_id: uuid.UUID, payload: OneToOneEdit, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + ensure_editable(rule) + if rule.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + if not await db.scalar(select(OneToOneRevision.id).where(OneToOneRevision.one_to_one_id == rule.id).limit(1)): + revision(db, rule, "Vor erster Bearbeitung") + for field in ONE_TO_ONE_FIELDS: + setattr(rule, field, getattr(payload, field)) + rule.targets, rule.groups = hosts, groups + rule.policy_hierarchy_group_id = payload.policy_hierarchy_group_id + rule.updated_at = datetime.now(timezone.utc) + revision(db, rule, "Entwurf gespeichert") + await db.commit() + await hydrate_hierarchy_targets(db, [rule]) + return _to_read(rule, hosts, rule.deployments) + + +@router.get("/{rule_id}/preview") +async def preview_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_rule(db, rule_id)) + + +@router.get("/{rule_id}/revisions") +async def list_revisions(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_rule(db, rule_id) + rows = (await db.scalars(select(OneToOneRevision).where(OneToOneRevision.one_to_one_id == rule_id).order_by(OneToOneRevision.created_at.desc(), OneToOneRevision.id))).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{rule_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + await opnsense_client.del_one_to_one_rule(deployment.host, deployment.opnsense_one_to_one_uuid) + await opnsense_client.apply_one_to_one(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() + + +@router.post("/{rule_id}/deployments/{host_id}/retry-rollback", status_code=204) +async def retry_rollback(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_retry_rollback"]: + raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + reverted = await revert_deployment(db, deployment) + await db.commit() + if not reverted: + raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL) + + +@router.get("/hosts/{host_id}/live") +async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + from app.services import opnsense_client + from app.services.nat_live_service import split_rows + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + try: + return split_rows(await opnsense_client.search_one_to_one_rules(host)) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc diff --git a/backend/app/routers/openvpn.py b/backend/app/routers/openvpn.py new file mode 100644 index 0000000..2baad40 --- /dev/null +++ b/backend/app/routers/openvpn.py @@ -0,0 +1,211 @@ +import uuid + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, OpenVpnInstanceRevision +from app.models.openvpn_instance import OpenVpnInstance +from app.models.openvpn_instance_deployment import OpenVpnInstanceDeployment +from app.schemas.openvpn import ( + OpenVpnDeployApproval, OpenVpnDeploymentResult, OpenVpnInstanceCreate, OpenVpnInstanceEdit, OpenVpnInstanceRead, +) +from app.services import openvpn_key_store +from app.services.openvpn_service import effective_hosts, preview, revision, snapshot, workflow, OPENVPN_FIELDS +from app.services.openvpn_deploy_service import deploy_instance, undeploy_instance +from app.services.opnsense_client import OpnsenseAPIError +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/openvpn/instances", tags=["openvpn"]) + + +def _load_options(): + return [ + selectinload(OpenVpnInstance.targets), + selectinload(OpenVpnInstance.groups).selectinload(DeviceGroup.hosts), + selectinload(OpenVpnInstance.deployments).selectinload(OpenVpnInstanceDeployment.host), + ] + + +async def _load_instance(db: AsyncSession, instance_id: uuid.UUID) -> OpenVpnInstance: + # Same advisory lock as rules/aliases/NAT/IPsec — serializes concurrent + # deploy-affecting changes across processes. No ensure_editable() gate + # here either: this entity has no pending/revert_failed state to ever + # block editing on (see openvpn_deploy_service.py's module docstring). + await policy_lock(db) + instance = await db.get(OpenVpnInstance, instance_id, options=_load_options()) + if not instance: + raise api_error(404, ErrorCode.INSTANZ_NICHT_GEFUNDEN) + return instance + + +def _to_read(instance: OpenVpnInstance, deployments: list[OpenVpnInstanceDeployment]) -> OpenVpnInstanceRead: + hosts_by_id = {host.id: host for host in effective_hosts(instance)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(instance, deployments) + return OpenVpnInstanceRead( + workflow_state=state, + workflow_counts=counts, + id=instance.id, + description=instance.description, + role=instance.role, + proto=instance.proto, + port=instance.port, + local=instance.local, + remote=instance.remote, + server_network=instance.server_network, + cert_refs=instance.cert_refs, + ca_refs=instance.ca_refs, + tls_key_enabled=instance.tls_key_enabled, + tls_key_mode=instance.tls_key_mode, + tls_key_material_set=instance.tls_key_material_set, + enabled=instance.enabled, + created_at=instance.created_at, + updated_at=instance.updated_at, + target_host_ids=[host.id for host in instance.targets], + target_group_ids=[g.id for g in instance.groups], + effective_host_ids=[h.id for h in effective_hosts(instance)], + deployments=[ + OpenVpnDeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + ) + for d in deployments + ], + ) + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.get("", response_model=list[OpenVpnInstanceRead]) +async def list_instances(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(OpenVpnInstance).options(*_load_options())) + instances = result.scalars().unique().all() + return [_to_read(instance, instance.deployments) for instance in instances] + + +@router.post("", response_model=OpenVpnInstanceRead, status_code=201) +async def create_instance(payload: OpenVpnInstanceCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + + instance = OpenVpnInstance( + **{field: getattr(payload, field) for field in OPENVPN_FIELDS}, + targets=hosts, + groups=groups, + ) + db.add(instance) + await db.flush() + if payload.tls_key_material: + openvpn_key_store.save_secret(instance.id, payload.tls_key_material) + instance.tls_key_material_set = True + revision(db, instance, "Instanz angelegt") + await db.commit() + return _to_read(instance, []) + + +@router.put("/{instance_id}", response_model=OpenVpnInstanceRead) +async def edit_instance(instance_id: uuid.UUID, payload: OpenVpnInstanceEdit, db: AsyncSession = Depends(get_db)): + instance = await _load_instance(db, instance_id) + if instance.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.INSTANZ_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_DB923B59) + hosts, groups = await resolve_targets(db, payload) + if not await db.scalar(select(OpenVpnInstanceRevision.id).where(OpenVpnInstanceRevision.instance_id == instance.id).limit(1)): + revision(db, instance, "Vor erster Bearbeitung") + for field in OPENVPN_FIELDS: + setattr(instance, field, getattr(payload, field)) + instance.targets, instance.groups = hosts, groups + if payload.tls_key_material is not None: + openvpn_key_store.save_secret(instance.id, payload.tls_key_material) + instance.tls_key_material_set = True + from datetime import datetime, timezone + instance.updated_at = datetime.now(timezone.utc) + revision(db, instance, "Entwurf gespeichert") + await db.commit() + return _to_read(instance, instance.deployments) + + +@router.get("/{instance_id}/preview") +async def preview_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_instance(db, instance_id)) + + +@router.get("/{instance_id}/revisions") +async def list_revisions(instance_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_instance(db, instance_id) + rows = (await db.scalars( + select(OpenVpnInstanceRevision).where(OpenVpnInstanceRevision.instance_id == instance_id) + .order_by(OpenVpnInstanceRevision.created_at.desc(), OpenVpnInstanceRevision.id) + )).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{instance_id}/deploy", response_model=OpenVpnInstanceRead) +async def deploy(instance_id: uuid.UUID, approval: OpenVpnDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + instance = await _load_instance(db, instance_id) + plan = preview(instance) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if instance.tls_key_enabled and not instance.tls_key_material_set: + raise api_error(400, ErrorCode.TLS_HARDENING_IST_AKTIVIERT_ABER_ES_WURDE_NOCH_F53C81DD) + hosts = [h for h in effective_hosts(instance) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.INSTANZ_HAT_KEINE_ZIEL_HOSTS) + await deploy_instance(db, instance, hosts) + await db.refresh(instance, attribute_names=["deployments"]) + for d in instance.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(instance, instance.deployments) + + +@router.delete("/{instance_id}", status_code=204) +async def delete_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + instance = await _load_instance(db, instance_id) + try: + await undeploy_instance(db, instance) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(instance, attribute_names=["deployments"]) + openvpn_key_store.delete_secret(instance.id) + await db.delete(instance) + await db.commit() + + +@router.post("/{instance_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(instance_id: uuid.UUID, host_id: uuid.UUID, approval: OpenVpnDeployApproval, db: AsyncSession = Depends(get_db)): + instance = await _load_instance(db, instance_id) + plan = preview(instance) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_KOENNEN_BEREINIGT_WERDEN) + deployment = next(d for d in instance.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + if deployment.opnsense_instance_uuid: + await opnsense_client.del_openvpn_instance(deployment.host, deployment.opnsense_instance_uuid) + if deployment.opnsense_statickey_uuid: + await opnsense_client.del_openvpn_static_key(deployment.host, deployment.opnsense_statickey_uuid) + await opnsense_client.reconfigure_openvpn(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() diff --git a/backend/app/routers/outbound_nat.py b/backend/app/routers/outbound_nat.py new file mode 100644 index 0000000..4e01b0f --- /dev/null +++ b/backend/app/routers/outbound_nat.py @@ -0,0 +1,286 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, OutboundNatRevision +from app.services.policy_service import effective_hosts, policy_lock, ensure_editable +from app.services.outbound_nat_policy_service import OUTBOUND_NAT_FIELDS, snapshot, revision, preview, workflow +from app.models.outbound_nat import OutboundNatRule +from app.models.outbound_nat_deployment import OutboundNatDeployment +from app.schemas.outbound_nat import DeploymentResult, OutboundNatCreate, OutboundNatRead, OutboundNatEdit, DeployApproval +from app.services.outbound_nat_mode import require_manual_mode, host_mode +from app.services.alias_service import check_alias_consistency, describe_alias_contents, ensure_aliases_settled +from app.services.interface_service import check_interface_consistency +from app.services.opnsense_client import OpnsenseAPIError +from app.services.outbound_nat_deploy_service import confirm_outbound_nat, deploy_outbound_nat, undeploy_outbound_nat, revert_deployment +from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group + +router = APIRouter(prefix="/outbound-nat", tags=["outbound-nat"]) + + +def _load_options(): + return [ + selectinload(OutboundNatRule.targets), + selectinload(OutboundNatRule.groups).selectinload(DeviceGroup.hosts), + selectinload(OutboundNatRule.deployments).selectinload(OutboundNatDeployment.host), + ] + + +async def _load_rule(db: AsyncSession, rule_id: uuid.UUID) -> OutboundNatRule: + await policy_lock(db) + rule = await db.get(OutboundNatRule, rule_id, options=_load_options()) + if not rule: + raise api_error(404, ErrorCode.OUTBOUND_NAT_REGEL_NICHT_GEFUNDEN) + await hydrate_hierarchy_targets(db, [rule]) + return rule + + +def _to_read(rule: OutboundNatRule, hosts: list[OpnsenseHost], deployments: list[OutboundNatDeployment]) -> OutboundNatRead: + hosts_by_id = {host.id: host for host in effective_hosts(rule)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(rule, deployments) + return OutboundNatRead( + workflow_state=state, + workflow_counts=counts, + id=rule.id, + description=rule.description, + interface=rule.interface, + source_net=rule.source_net, + destination_net=rule.destination_net, + source_not=rule.source_not, + destination_not=rule.destination_not, + enabled=rule.enabled, + ipprotocol=rule.ipprotocol, + protocol=rule.protocol, + source_port=rule.source_port, + destination_port=rule.destination_port, + target=rule.target, + target_port=rule.target_port, + created_at=rule.created_at, + updated_at=rule.updated_at, + target_host_ids=[host.id for host in rule.targets], + target_group_ids=[g.id for g in rule.groups], + policy_hierarchy_group_id=rule.policy_hierarchy_group_id, + effective_host_ids=[h.id for h in effective_hosts(rule)], + deployments=[ + DeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + revert_deadline=d.revert_deadline, + ) + for d in deployments + ], + ) + + +@router.get("", response_model=list[OutboundNatRead]) +async def list_outbound_nat_rules(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(OutboundNatRule).options(*_load_options())) + rules = result.scalars().unique().all() + await hydrate_hierarchy_targets(db, rules) + return [_to_read(rule, rule.targets, rule.deployments) for rule in rules] + + +@router.post("", response_model=OutboundNatRead, status_code=201) +async def create_outbound_nat_rule(payload: OutboundNatCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + + rule = OutboundNatRule( + description=payload.description, + interface=payload.interface, + source_net=payload.source_net, + destination_net=payload.destination_net, + source_not=payload.source_not, + destination_not=payload.destination_not, + enabled=payload.enabled, + ipprotocol=payload.ipprotocol, + protocol=payload.protocol, + source_port=payload.source_port, + destination_port=payload.destination_port, + target=payload.target, + target_port=payload.target_port, + targets=hosts, + groups=groups, + policy_hierarchy_group_id=payload.policy_hierarchy_group_id, + ) + db.add(rule) + await db.flush() + revision(db, rule, "Regel angelegt") + await db.commit() + await hydrate_hierarchy_targets(db, [rule]) + return _to_read(rule, hosts, []) + + +@router.post("/{rule_id}/deploy", response_model=OutboundNatRead) +async def deploy(rule_id: uuid.UUID, approval: DeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN) + hosts = [h for h in effective_hosts(rule) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.REGEL_HAT_KEINE_ZIEL_HOSTS) + for host in hosts: + await require_manual_mode(host) + if len(hosts) > 1: + labels = await check_interface_consistency(hosts, rule.interface) + distinct = set(labels.values()) + if len(distinct) > 1: + detail = ", ".join(f"{name}={label}" for name, label in sorted(labels.items())) + raise api_error(409, ErrorCode.INTERFACE_MEANING_DIFFERS, interface=rule.interface, detail=detail) + for field_label, value in ( + ("Source", rule.source_net), + ("Destination", rule.destination_net), + ("Ziel (Target)", rule.target), + ): + if not value: + continue + contents = await check_alias_consistency(hosts, value) + if len(set(contents.values())) > 1: + detail = describe_alias_contents(contents) + raise api_error(409, ErrorCode.ALIAS_CONTENT_DIFFERS, field_label=field_label, alias=value, detail=detail) + await ensure_aliases_settled(db, hosts, [rule.source_net, rule.destination_net, rule.target]) + await deploy_outbound_nat(db, rule, hosts) + await db.refresh(rule, attribute_names=["deployments"]) + for d in rule.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(rule, hosts, rule.deployments) + + +@router.post("/{rule_id}/confirm", response_model=OutboundNatRead) +async def confirm(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + await confirm_outbound_nat(db, rule) + return _to_read(rule, rule.targets, rule.deployments) + + +@router.delete("/{rule_id}", status_code=204) +async def delete_outbound_nat_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN) + try: + await undeploy_outbound_nat(db, rule) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(rule, attribute_names=["deployments"]) + await db.delete(rule) + await db.commit() + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.put("/{rule_id}", response_model=OutboundNatRead) +async def edit_outbound_nat_rule(rule_id: uuid.UUID, payload: OutboundNatEdit, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + ensure_editable(rule) + if rule.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + if not await db.scalar(select(OutboundNatRevision.id).where(OutboundNatRevision.outbound_nat_id == rule.id).limit(1)): + revision(db, rule, "Vor erster Bearbeitung") + for field in OUTBOUND_NAT_FIELDS: + setattr(rule, field, getattr(payload, field)) + rule.targets, rule.groups = hosts, groups + rule.policy_hierarchy_group_id = payload.policy_hierarchy_group_id + rule.updated_at = datetime.now(timezone.utc) + revision(db, rule, "Entwurf gespeichert") + await db.commit() + await hydrate_hierarchy_targets(db, [rule]) + return _to_read(rule, hosts, rule.deployments) + + +@router.get("/{rule_id}/preview") +async def preview_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_rule(db, rule_id)) + + +@router.get("/{rule_id}/revisions") +async def list_revisions(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_rule(db, rule_id) + rows = (await db.scalars(select(OutboundNatRevision).where(OutboundNatRevision.outbound_nat_id == rule_id).order_by(OutboundNatRevision.created_at.desc(), OutboundNatRevision.id))).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{rule_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + await opnsense_client.del_outbound_nat_rule(deployment.host, deployment.opnsense_outbound_nat_uuid) + await opnsense_client.apply_outbound_nat(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() + + +@router.post("/{rule_id}/deployments/{host_id}/retry-rollback", status_code=204) +async def retry_rollback(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_retry_rollback"]: + raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + reverted = await revert_deployment(db, deployment) + await db.commit() + if not reverted: + raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL) + + +@router.get("/hosts/{host_id}/live") +async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + from app.services import opnsense_client + from app.services.nat_live_service import split_rows + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + try: + return split_rows(await opnsense_client.search_outbound_nat_rules(host)) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +@router.get("/hosts/{host_id}/mode") +async def outbound_mode(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + return await host_mode(host) diff --git a/backend/app/routers/policy_hierarchy.py b/backend/app/routers/policy_hierarchy.py new file mode 100644 index 0000000..fdb2480 --- /dev/null +++ b/backend/app/routers/policy_hierarchy.py @@ -0,0 +1,379 @@ +import uuid + +from fastapi import APIRouter, Body, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.policy import PolicyHierarchyRuleRevision +from app.models.policy_hierarchy_group import PolicyHierarchyGroup +from app.models.policy_hierarchy_rule import PolicyHierarchyRule +from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment +from app.schemas.rule import DeploymentResult +from app.schemas.policy_hierarchy import ( + PolicyHierarchyDeployApproval, + PolicyHierarchyMoveDirection, + PolicyHierarchyReorder, + PolicyHierarchyRuleCreate, + PolicyHierarchyRuleEdit, + PolicyHierarchyRuleRead, +) +from app.services.alias_service import check_alias_consistency, describe_alias_contents, ensure_aliases_settled +from app.services.interface_service import check_interface_consistency +from app.services.opnsense_client import OpnsenseAPIError +from app.services.policy_hierarchy_deploy_service import ( + confirm_hierarchy_rule, + deploy_hierarchy_rule, + reassert_group_ordering, + revert_hierarchy_deployment, + undeploy_hierarchy_rule, +) +from app.services.policy_hierarchy_service import ( + HIERARCHY_RULE_FIELDS, + effective_hosts, + ensure_editable, + policy_lock, + preview, + revision, + workflow, +) + +router = APIRouter(prefix="/policy-hierarchy", tags=["policy-hierarchy"]) + + +def _rule_load_options(): + return [selectinload(PolicyHierarchyRule.deployments).selectinload(PolicyHierarchyRuleDeployment.host)] + + +async def _load_all_groups(db: AsyncSession) -> list[PolicyHierarchyGroup]: + return list((await db.scalars( + select(PolicyHierarchyGroup).options(selectinload(PolicyHierarchyGroup.hosts)) + )).all()) + + +async def _load_rule(db: AsyncSession, rule_id: uuid.UUID) -> PolicyHierarchyRule: + await policy_lock(db) + # populate_existing=True: without it, a rule already in this request's + # identity map (e.g. right after create_rule's db.add()+commit(), or a + # second _load_rule call later in the same request) is returned as-is + # by db.get() and the selectinload(deployments) option is silently + # skipped — leaving `.deployments` unloaded and triggering a + # synchronous lazy-load attempt outside any greenlet context later. + rule = await db.get(PolicyHierarchyRule, rule_id, options=_rule_load_options(), populate_existing=True) + if not rule: + raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) + return rule + + +def _to_read(rule: PolicyHierarchyRule, all_groups: list[PolicyHierarchyGroup]) -> PolicyHierarchyRuleRead: + hosts_by_id = {h.id: h for h in effective_hosts(rule, all_groups)} + hosts_by_id.update({d.host_id: d.host for d in rule.deployments if d.host is not None}) + state, counts = workflow(rule, rule.deployments, all_groups) + return PolicyHierarchyRuleRead( + workflow_state=state, + workflow_counts=counts, + id=rule.id, + hierarchy_group_id=rule.hierarchy_group_id, + rulebase=rule.rulebase, + sequence=rule.sequence, + description=rule.description, + interface=rule.interface, + direction=rule.direction, + ipprotocol=rule.ipprotocol, + protocol=rule.protocol, + action=rule.action, + source_net=rule.source_net, + source_port=rule.source_port, + destination_net=rule.destination_net, + destination_port=rule.destination_port, + enabled=rule.enabled, + created_at=rule.created_at, + updated_at=rule.updated_at, + effective_host_ids=[h.id for h in effective_hosts(rule, all_groups)], + deployments=[ + DeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + revert_deadline=d.revert_deadline, + ) + for d in rule.deployments + ], + ) + + +async def _get_group_or_400(db: AsyncSession, group_id: uuid.UUID) -> PolicyHierarchyGroup: + group = await db.get(PolicyHierarchyGroup, group_id) + if not group: + raise api_error(400, ErrorCode.POLICY_HIERARCHIE_GRUPPE_NICHT_GEFUNDEN) + return group + + +@router.get("/rules", response_model=list[PolicyHierarchyRuleRead]) +async def list_rules(db: AsyncSession = Depends(get_db)): + all_groups = await _load_all_groups(db) + rules = (await db.scalars(select(PolicyHierarchyRule).options(*_rule_load_options()))).all() + return [_to_read(rule, all_groups) for rule in rules] + + +@router.post("/rules", response_model=PolicyHierarchyRuleRead, status_code=201) +async def create_rule(payload: PolicyHierarchyRuleCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + await _get_group_or_400(db, payload.hierarchy_group_id) + # New rules are appended to the end of their (group, rulebase) list; + # atomic drag-and-drop and up/down controls handle reordering later. + max_sequence = await db.scalar( + select(PolicyHierarchyRule.sequence).where( + PolicyHierarchyRule.hierarchy_group_id == payload.hierarchy_group_id, + PolicyHierarchyRule.rulebase == payload.rulebase, + ).order_by(PolicyHierarchyRule.sequence.desc()).limit(1) + ) + rule = PolicyHierarchyRule( + hierarchy_group_id=payload.hierarchy_group_id, + rulebase=payload.rulebase, + sequence=(max_sequence + 1) if max_sequence is not None else 0, + description=payload.description, + interface=payload.interface, + direction=payload.direction, + ipprotocol=payload.ipprotocol, + protocol=payload.protocol, + action=payload.action, + source_net=payload.source_net, + source_port=payload.source_port, + destination_net=payload.destination_net, + destination_port=payload.destination_port, + enabled=payload.enabled, + ) + db.add(rule) + await db.flush() + revision(db, rule, "Regel angelegt") + await db.commit() + return _to_read(await _load_rule(db, rule.id), await _load_all_groups(db)) + + +@router.put("/rules/{rule_id}", response_model=PolicyHierarchyRuleRead) +async def edit_rule(rule_id: uuid.UUID, payload: PolicyHierarchyRuleEdit, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + ensure_editable(rule) + if rule.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0) + await _get_group_or_400(db, payload.hierarchy_group_id) + if not await db.scalar(select(PolicyHierarchyRuleRevision.id).where(PolicyHierarchyRuleRevision.policy_hierarchy_id == rule.id).limit(1)): + revision(db, rule, "Vor erster Bearbeitung") + for field in HIERARCHY_RULE_FIELDS: + setattr(rule, field, getattr(payload, field)) + rule.hierarchy_group_id = payload.hierarchy_group_id + rule.rulebase = payload.rulebase + from datetime import datetime, timezone + rule.updated_at = datetime.now(timezone.utc) + revision(db, rule, "Entwurf gespeichert") + await db.commit() + return _to_read(rule, await _load_all_groups(db)) + + +@router.put("/groups/{group_id}/rules/{rulebase}/order", response_model=list[PolicyHierarchyRuleRead]) +async def reorder_rules( + group_id: uuid.UUID, + rulebase: str, + payload: PolicyHierarchyReorder, + db: AsyncSession = Depends(get_db), +): + """Atomically persist a complete drag-and-drop order. + + The complete sibling set and every optimistic timestamp must still + match. This prevents two administrators from silently overwriting each + other's order and avoids the partial state produced by several + sequential up/down requests. + """ + if rulebase not in ("pre", "post"): + raise api_error(404, ErrorCode.RULEBASE_NICHT_GEFUNDEN) + await policy_lock(db) + await _get_group_or_400(db, group_id) + siblings = list((await db.scalars( + select(PolicyHierarchyRule).where( + PolicyHierarchyRule.hierarchy_group_id == group_id, + PolicyHierarchyRule.rulebase == rulebase, + ).options(*_rule_load_options()).order_by(PolicyHierarchyRule.sequence, PolicyHierarchyRule.id) + )).all()) + if len({item.id for item in payload.items}) != len(payload.items): + raise api_error(422, ErrorCode.EINE_REGEL_DARF_IN_DER_REIHENFOLGE_NUR_EINMAL_F05DF47C) + if {rule.id for rule in siblings} != {item.id for item in payload.items}: + raise api_error(409, ErrorCode.REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203) + expected = {item.id: item.expected_updated_at for item in payload.items} + for rule in siblings: + ensure_editable(rule) + if any(rule.updated_at != expected[rule.id] for rule in siblings): + raise api_error(409, ErrorCode.MINDESTENS_EINE_REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_932925BD) + by_id = {rule.id: rule for rule in siblings} + from datetime import datetime, timezone + changed_at = datetime.now(timezone.utc) + for sequence, item in enumerate(payload.items): + rule = by_id[item.id] + if rule.sequence != sequence: + rule.sequence = sequence + rule.updated_at = changed_at + revision(db, rule, "Reihenfolge per Drag-and-drop geändert") + await db.commit() + all_groups = await _load_all_groups(db) + return [_to_read(await _load_rule(db, item.id), all_groups) for item in payload.items] + + +@router.post("/rules/{rule_id}/move", response_model=PolicyHierarchyRuleRead) +async def move_rule(rule_id: uuid.UUID, payload: PolicyHierarchyMoveDirection, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + ensure_editable(rule) + siblings = list((await db.scalars( + select(PolicyHierarchyRule).where( + PolicyHierarchyRule.hierarchy_group_id == rule.hierarchy_group_id, + PolicyHierarchyRule.rulebase == rule.rulebase, + ).order_by(PolicyHierarchyRule.sequence, PolicyHierarchyRule.id) + )).all()) + index = next(i for i, r in enumerate(siblings) if r.id == rule.id) + neighbor_index = index - 1 if payload.direction == "up" else index + 1 + if 0 <= neighbor_index < len(siblings): + neighbor = siblings[neighbor_index] + rule.sequence, neighbor.sequence = neighbor.sequence, rule.sequence + revision(db, rule, "Reihenfolge geändert") + await db.commit() + return _to_read(await _load_rule(db, rule_id), await _load_all_groups(db)) + + +@router.post("/rules/{rule_id}/deploy", response_model=PolicyHierarchyRuleRead) +async def deploy(rule_id: uuid.UUID, approval: PolicyHierarchyDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + all_groups = await _load_all_groups(db) + plan = preview(rule, all_groups) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN) + hosts = [h for h in effective_hosts(rule, all_groups) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.REGEL_HAT_KEINE_ZIEL_HOSTS) + if len(hosts) > 1: + labels = await check_interface_consistency(hosts, rule.interface) + distinct = set(labels.values()) + if len(distinct) > 1: + detail = ", ".join(f"{name}={label}" for name, label in sorted(labels.items())) + raise api_error(409, ErrorCode.INTERFACE_MEANING_DIFFERS, interface=rule.interface, detail=detail) + for field_label, value in ( + ("Source", rule.source_net), + ("Destination", rule.destination_net), + ("Source-Port", rule.source_port), + ("Destination-Port", rule.destination_port), + ): + if not value: + continue + contents = await check_alias_consistency(hosts, value) + if len(set(contents.values())) > 1: + detail = describe_alias_contents(contents) + raise api_error(409, ErrorCode.ALIAS_CONTENT_DIFFERS, field_label=field_label, alias=value, detail=detail) + await ensure_aliases_settled(db, hosts, [rule.source_net, rule.destination_net, rule.source_port, rule.destination_port]) + await deploy_hierarchy_rule(db, rule, hosts) + await db.refresh(rule, attribute_names=["deployments"]) + for d in rule.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(rule, await _load_all_groups(db)) + + +@router.post("/rules/{rule_id}/confirm", response_model=PolicyHierarchyRuleRead) +async def confirm(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + await confirm_hierarchy_rule(db, rule) + return _to_read(rule, await _load_all_groups(db)) + + +@router.delete("/rules/{rule_id}", status_code=204) +async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN) + try: + await undeploy_hierarchy_rule(db, rule) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(rule, attribute_names=["deployments"]) + await db.delete(rule) + await db.commit() + + +@router.get("/rules/{rule_id}/preview") +async def preview_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + return preview(rule, await _load_all_groups(db)) + + +@router.get("/rules/{rule_id}/revisions") +async def list_revisions(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_rule(db, rule_id) + rows = (await db.scalars( + select(PolicyHierarchyRuleRevision).where(PolicyHierarchyRuleRevision.policy_hierarchy_id == rule_id) + .order_by(PolicyHierarchyRuleRevision.created_at.desc(), PolicyHierarchyRuleRevision.id) + )).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/rules/{rule_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(rule_id: uuid.UUID, host_id: uuid.UUID, approval: PolicyHierarchyDeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + all_groups = await _load_all_groups(db) + plan = preview(rule, all_groups) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + await opnsense_client.del_rule(deployment.host, deployment.opnsense_policy_hierarchy_uuid) + await opnsense_client.apply(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() + + +@router.post("/rules/{rule_id}/deployments/{host_id}/retry-rollback", status_code=204) +async def retry_rollback(rule_id: uuid.UUID, host_id: uuid.UUID, approval: PolicyHierarchyDeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + all_groups = await _load_all_groups(db) + plan = preview(rule, all_groups) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_retry_rollback"]: + raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + reverted = await revert_hierarchy_deployment(db, deployment) + await db.commit() + if not reverted: + raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL) + + +@router.post("/groups/{group_id}/reassert-ordering") +async def reassert_ordering(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + """Explicit ordering repair for a group subtree. + + Normal OPNexus rule/alias/NAT deploys also reassert automatically; this + action remains useful after changing the desired order or after manual + edits made directly on a firewall. + """ + await policy_lock(db) + group = await db.get(PolicyHierarchyGroup, group_id) + if not group: + raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN) + errors = await reassert_group_ordering(db, group) + if errors: + raise api_error(502, ErrorCode.ORDERING_FAILED, reason='; '.join(errors)) + return {"status": "ok"} diff --git a/backend/app/routers/policy_hierarchy_groups.py b/backend/app/routers/policy_hierarchy_groups.py new file mode 100644 index 0000000..f0263a4 --- /dev/null +++ b/backend/app/routers/policy_hierarchy_groups.py @@ -0,0 +1,105 @@ +import uuid + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.policy_hierarchy_group import PolicyHierarchyGroup +from app.models.policy_hierarchy_rule import PolicyHierarchyRule +from app.models.alias import FirewallAlias +from app.models.one_to_one import OneToOneRule +from app.models.outbound_nat import OutboundNatRule +from app.models.port_forward import PortForwardRule +from app.schemas.policy_hierarchy import PolicyHierarchyGroupRead, PolicyHierarchyGroupWrite +from app.services.policy_hierarchy_group_service import assert_no_cycle +from app.services.policy_hierarchy_service import policy_lock + +router = APIRouter(prefix="/policy-hierarchy/groups", tags=["policy-hierarchy"]) + + +def _to_read(group: PolicyHierarchyGroup) -> PolicyHierarchyGroupRead: + return PolicyHierarchyGroupRead( + id=group.id, name=group.name, description=group.description, parent_id=group.parent_id, + created_at=group.created_at, host_ids=[h.id for h in group.hosts], + ) + + +async def _get_or_404(db: AsyncSession, group_id: uuid.UUID) -> PolicyHierarchyGroup: + # populate_existing=True: without it, a group already in this request's + # identity map (e.g. right after create_group's db.add()+commit()) is + # returned as-is by db.get() and the `hosts` selectinload option is + # silently skipped — leaving `.hosts` unloaded and triggering a + # synchronous lazy-load attempt outside any greenlet context later. + group = await db.get( + PolicyHierarchyGroup, group_id, + options=[selectinload(PolicyHierarchyGroup.hosts)], populate_existing=True, + ) + if not group: + raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN) + return group + + +@router.get("", response_model=list[PolicyHierarchyGroupRead]) +async def list_groups(db: AsyncSession = Depends(get_db)): + groups = (await db.scalars( + select(PolicyHierarchyGroup).options(selectinload(PolicyHierarchyGroup.hosts)).order_by(PolicyHierarchyGroup.name) + )).all() + return [_to_read(g) for g in groups] + + +@router.post("", response_model=PolicyHierarchyGroupRead, status_code=201) +async def create_group(payload: PolicyHierarchyGroupWrite, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + if await db.scalar(select(PolicyHierarchyGroup.id).where(PolicyHierarchyGroup.name == payload.name)): + raise api_error(409, ErrorCode.GRUPPENNAME_BEREITS_VERGEBEN) + if payload.parent_id and not await db.get(PolicyHierarchyGroup, payload.parent_id): + raise api_error(400, ErrorCode.UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN) + group = PolicyHierarchyGroup(name=payload.name, description=payload.description, parent_id=payload.parent_id) + db.add(group) + await db.commit() + return _to_read(await _get_or_404(db, group.id)) + + +@router.put("/{group_id}", response_model=PolicyHierarchyGroupRead) +async def update_group(group_id: uuid.UUID, payload: PolicyHierarchyGroupWrite, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + group = await _get_or_404(db, group_id) + duplicate = await db.scalar(select(PolicyHierarchyGroup.id).where( + PolicyHierarchyGroup.name == payload.name, PolicyHierarchyGroup.id != group_id)) + if duplicate: + raise api_error(409, ErrorCode.GRUPPENNAME_BEREITS_VERGEBEN) + if payload.parent_id and not await db.get(PolicyHierarchyGroup, payload.parent_id): + raise api_error(400, ErrorCode.UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN) + await assert_no_cycle(db, group_id, payload.parent_id) + group.name, group.description, group.parent_id = payload.name, payload.description, payload.parent_id + await db.commit() + return _to_read(await _get_or_404(db, group_id)) + + +@router.delete("/{group_id}", status_code=204) +async def delete_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + group = await db.get(PolicyHierarchyGroup, group_id) + if not group: + raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN) + if await db.scalar(select(PolicyHierarchyGroup.id).where(PolicyHierarchyGroup.parent_id == group_id).limit(1)): + raise api_error(409, ErrorCode.GRUPPE_HAT_NOCH_UNTERGRUPPEN_DIESE_ZUERST_VERSCHIEBEN_ODER_9FFFD853) + if await db.scalar(select(PolicyHierarchyRule.id).where(PolicyHierarchyRule.hierarchy_group_id == group_id).limit(1)): + raise api_error(409, ErrorCode.GRUPPE_HAT_NOCH_ZUGEORDNETE_REGELN_DIESE_ZUERST_VERSCHIEBEN_2CCBC48B) + inherited_resources = ( + (FirewallAlias, "Aliase"), + (PortForwardRule, "Port-Forward-Regeln"), + (OneToOneRule, "1:1-NAT-Regeln"), + (OutboundNatRule, "Outbound-NAT-Regeln"), + ) + for model, label in inherited_resources: + if await db.scalar(select(model.id).where(model.policy_hierarchy_group_id == group_id).limit(1)): + raise api_error(409, ErrorCode.GROUP_HAS_ASSIGNED_ITEMS, label=label) + # Hosts assigned directly to this group are unassigned (ondelete="SET + # NULL" on OpnsenseHost.policy_hierarchy_group_id), not blocked — a + # group with only host members and no children/rules is safe to remove. + await db.delete(group) + await db.commit() diff --git a/backend/app/routers/port_forward.py b/backend/app/routers/port_forward.py new file mode 100644 index 0000000..569bd6c --- /dev/null +++ b/backend/app/routers/port_forward.py @@ -0,0 +1,273 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, PortForwardRevision +from app.services.policy_service import effective_hosts, policy_lock, ensure_editable +from app.services.port_forward_policy_service import PORT_FORWARD_FIELDS, snapshot, revision, preview, workflow +from app.models.port_forward import PortForwardRule +from app.models.port_forward_deployment import PortForwardDeployment +from app.schemas.port_forward import DeploymentResult, PortForwardCreate, PortForwardRead, PortForwardEdit, DeployApproval +from app.services.alias_service import check_alias_consistency, describe_alias_contents, ensure_aliases_settled +from app.services.interface_service import check_interface_consistency +from app.services.opnsense_client import OpnsenseAPIError +from app.services.port_forward_deploy_service import confirm_port_forward, deploy_port_forward, undeploy_port_forward, revert_deployment +from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group + +router = APIRouter(prefix="/port-forward", tags=["port-forward"]) + + +def _load_options(): + return [ + selectinload(PortForwardRule.targets), + selectinload(PortForwardRule.groups).selectinload(DeviceGroup.hosts), + selectinload(PortForwardRule.deployments).selectinload(PortForwardDeployment.host), + ] + + +async def _load_rule(db: AsyncSession, rule_id: uuid.UUID) -> PortForwardRule: + await policy_lock(db) + rule = await db.get(PortForwardRule, rule_id, options=_load_options()) + if not rule: + raise api_error(404, ErrorCode.PORT_FORWARD_REGEL_NICHT_GEFUNDEN) + await hydrate_hierarchy_targets(db, [rule]) + return rule + + +def _to_read(rule: PortForwardRule, hosts: list[OpnsenseHost], deployments: list[PortForwardDeployment]) -> PortForwardRead: + hosts_by_id = {host.id: host for host in effective_hosts(rule)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(rule, deployments) + return PortForwardRead( + workflow_state=state, + workflow_counts=counts, + id=rule.id, + description=rule.description, + interface=rule.interface, + ipprotocol=rule.ipprotocol, + protocol=rule.protocol, + source_net=rule.source_net, + source_port=rule.source_port, + destination_net=rule.destination_net, + destination_port=rule.destination_port, + target=rule.target, + local_port=rule.local_port, + natreflection=rule.natreflection, + enabled=rule.enabled, + created_at=rule.created_at, + updated_at=rule.updated_at, + target_host_ids=[host.id for host in rule.targets], + target_group_ids=[g.id for g in rule.groups], + policy_hierarchy_group_id=rule.policy_hierarchy_group_id, + effective_host_ids=[h.id for h in effective_hosts(rule)], + deployments=[ + DeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + revert_deadline=d.revert_deadline, + ) + for d in deployments + ], + ) + + +@router.get("", response_model=list[PortForwardRead]) +async def list_port_forward_rules(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(PortForwardRule).options(*_load_options())) + rules = result.scalars().unique().all() + await hydrate_hierarchy_targets(db, rules) + return [_to_read(rule, rule.targets, rule.deployments) for rule in rules] + + +@router.post("", response_model=PortForwardRead, status_code=201) +async def create_port_forward_rule(payload: PortForwardCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + + rule = PortForwardRule( + description=payload.description, + interface=payload.interface, + ipprotocol=payload.ipprotocol, + protocol=payload.protocol, + source_net=payload.source_net, + source_port=payload.source_port, + destination_net=payload.destination_net, + destination_port=payload.destination_port, + target=payload.target, + local_port=payload.local_port, + natreflection=payload.natreflection, + enabled=payload.enabled, + targets=hosts, + groups=groups, + policy_hierarchy_group_id=payload.policy_hierarchy_group_id, + ) + db.add(rule) + await db.flush() + revision(db, rule, "Regel angelegt") + await db.commit() + await hydrate_hierarchy_targets(db, [rule]) + return _to_read(rule, hosts, []) + + +@router.post("/{rule_id}/deploy", response_model=PortForwardRead) +async def deploy(rule_id: uuid.UUID, approval: DeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN) + hosts = [h for h in effective_hosts(rule) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.REGEL_HAT_KEINE_ZIEL_HOSTS) + if len(hosts) > 1: + labels = await check_interface_consistency(hosts, rule.interface) + distinct = set(labels.values()) + if len(distinct) > 1: + detail = ", ".join(f"{name}={label}" for name, label in sorted(labels.items())) + raise api_error(409, ErrorCode.INTERFACE_MEANING_DIFFERS, interface=rule.interface, detail=detail) + for field_label, value in ( + ("Source", rule.source_net), + ("Destination", rule.destination_net), + ("Ziel (Target)", rule.target), + ): + if not value: + continue + contents = await check_alias_consistency(hosts, value) + if len(set(contents.values())) > 1: + detail = describe_alias_contents(contents) + raise api_error(409, ErrorCode.ALIAS_CONTENT_DIFFERS, field_label=field_label, alias=value, detail=detail) + await ensure_aliases_settled(db, hosts, [rule.source_net, rule.destination_net, rule.target]) + await deploy_port_forward(db, rule, hosts) + await db.refresh(rule, attribute_names=["deployments"]) + for d in rule.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(rule, hosts, rule.deployments) + + +@router.post("/{rule_id}/confirm", response_model=PortForwardRead) +async def confirm(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + await confirm_port_forward(db, rule) + return _to_read(rule, rule.targets, rule.deployments) + + +@router.delete("/{rule_id}", status_code=204) +async def delete_port_forward_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN) + try: + await undeploy_port_forward(db, rule) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(rule, attribute_names=["deployments"]) + await db.delete(rule) + await db.commit() + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.put("/{rule_id}", response_model=PortForwardRead) +async def edit_port_forward_rule(rule_id: uuid.UUID, payload: PortForwardEdit, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + ensure_editable(rule) + if rule.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0) + hosts, groups = await resolve_targets(db, payload) + await validate_hierarchy_group(db, payload.policy_hierarchy_group_id) + if not await db.scalar(select(PortForwardRevision.id).where(PortForwardRevision.port_forward_id == rule.id).limit(1)): + revision(db, rule, "Vor erster Bearbeitung") + for field in PORT_FORWARD_FIELDS: + setattr(rule, field, getattr(payload, field)) + rule.targets, rule.groups = hosts, groups + rule.policy_hierarchy_group_id = payload.policy_hierarchy_group_id + rule.updated_at = datetime.now(timezone.utc) + revision(db, rule, "Entwurf gespeichert") + await db.commit() + await hydrate_hierarchy_targets(db, [rule]) + return _to_read(rule, hosts, rule.deployments) + + +@router.get("/{rule_id}/preview") +async def preview_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_rule(db, rule_id)) + + +@router.get("/{rule_id}/revisions") +async def list_revisions(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_rule(db, rule_id) + rows = (await db.scalars(select(PortForwardRevision).where(PortForwardRevision.port_forward_id == rule_id).order_by(PortForwardRevision.created_at.desc(), PortForwardRevision.id))).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{rule_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + await opnsense_client.del_port_forward_rule(deployment.host, deployment.opnsense_port_forward_uuid) + await opnsense_client.apply_port_forward(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() + + +@router.post("/{rule_id}/deployments/{host_id}/retry-rollback", status_code=204) +async def retry_rollback(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_retry_rollback"]: + raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + reverted = await revert_deployment(db, deployment) + await db.commit() + if not reverted: + raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL) + + +@router.get("/hosts/{host_id}/live") +async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + from app.services import opnsense_client + from app.services.nat_live_service import split_rows + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + try: + return split_rows(await opnsense_client.search_port_forward_rules(host)) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc diff --git a/backend/app/routers/resource_samples.py b/backend/app/routers/resource_samples.py new file mode 100644 index 0000000..6500595 --- /dev/null +++ b/backend/app/routers/resource_samples.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.resource_sample import HostResourceSample + +router = APIRouter(prefix="/hosts/{host_id}/resource-samples", tags=["host-resources"]) + + +def _read(sample: HostResourceSample) -> dict: + return { + "id": str(sample.id), + "sampled_at": sample.sampled_at, + "mem_used_pct": sample.mem_used_pct, + "load_avg_1m": sample.load_avg_1m, + "disk_used_pct": sample.disk_used_pct, + "raw": sample.raw, + } + + +@router.get("") +async def list_samples( + host_id: uuid.UUID, + since_hours: int = Query(default=24, ge=1, le=24 * 30), + db: AsyncSession = Depends(get_db), +): + cutoff = datetime.now(timezone.utc) - timedelta(hours=since_hours) + rows = ( + await db.scalars( + select(HostResourceSample) + .where(HostResourceSample.host_id == host_id, HostResourceSample.sampled_at >= cutoff) + .order_by(HostResourceSample.sampled_at.asc()) + ) + ).all() + return [_read(r) for r in rows] diff --git a/backend/app/routers/restore.py b/backend/app/routers/restore.py new file mode 100644 index 0000000..820a791 --- /dev/null +++ b/backend/app/routers/restore.py @@ -0,0 +1,186 @@ +import uuid +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models import OpnsenseHost +from app.models.restore_operation import RestoreOperation +from app.services import backup_store, opnsense_client as client, audit_service, restore_service +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/hosts/{host_id}/restore", tags=["restore"]) + +PREVIEW_LIFETIME = timedelta(minutes=15) + + +class Preview(BaseModel): + backup_id: str = Field(min_length=1, max_length=255) + + +class Handoff(BaseModel): + confirm_name: str = Field(min_length=1, max_length=120) + + +class Confirm(BaseModel): + confirm_name: str = Field(min_length=1, max_length=120) + message: str | None = Field(default=None, max_length=1000) + + +async def host_or_404(db, host_id): + h = await db.get(OpnsenseHost, host_id) + if not h: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + return h + + +async def op_or_404(db, host_id, operation_id): + op = await db.get(RestoreOperation, operation_id) + if not op or op.host_id != host_id: + raise api_error(404, ErrorCode.RESTORE_VORGANG_NICHT_GEFUNDEN) + return op + + +def read_operation(op: RestoreOperation) -> dict: + return { + "id": op.id, "host_id": op.host_id, "host_name": op.host_name, "actor": op.actor, + "backup_id": op.backup_id, "status": op.status, "created_at": op.created_at, + "expires_at": op.expires_at, "manifest": op.manifest, "warnings": op.warnings, + "handed_off_at": op.handed_off_at, "confirmed_at": op.confirmed_at, + "cancelled_at": op.cancelled_at, "message": op.message, + } + + +@router.get("") +async def list_operations(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await host_or_404(db, host_id) + ops = (await db.scalars( + select(RestoreOperation) + .where(RestoreOperation.host_id == host_id, RestoreOperation.status != "preview") + .order_by(RestoreOperation.created_at.desc()) + .limit(20) + )).all() + return [read_operation(o) for o in ops] + + +@router.get("/{operation_id}") +async def get_operation(host_id: uuid.UUID, operation_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await host_or_404(db, host_id) + op = await op_or_404(db, host_id, operation_id) + return read_operation(op) + + +@router.post("/preview") +async def preview(host_id: uuid.UUID, payload: Preview, request: Request, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await host_or_404(db, host_id) + + # Best-effort fresh safety snapshot before previewing — same call the + # existing "Jetzt sichern" button already makes. If it fails we still + # let the preview proceed (the chosen backup is unaffected), just warn. + safety_warning = None + try: + content = await client.download_backup(host) + backup_store.save_snapshot(host_id, content) + except client.OpnsenseAPIError as exc: + safety_warning = f"Frische Sicherheitssicherung vor dem Restore fehlgeschlagen: {exc}" + + raw = backup_store.read_snapshot(host_id, payload.backup_id) + if raw is None: + raise api_error(404, ErrorCode.SICHERUNG_NICHT_GEFUNDEN) + + try: + manifest = restore_service.parse_and_redact(raw) + except restore_service.InvalidBackupError as exc: + raise api_error(400, ErrorCode(exc.code), **exc.params) from exc + + warnings = restore_service.diff_against_host(manifest, host) + if safety_warning: + warnings.append(safety_warning) + + op = RestoreOperation( + host_id=host.id, host_name=host.name, actor=request.state.user.username, + backup_id=payload.backup_id, expires_at=datetime.now(timezone.utc) + PREVIEW_LIFETIME, + fingerprint=restore_service.fingerprint(raw), manifest=manifest, warnings=warnings, + ) + db.add(op) + await db.commit() + await audit_service.persist( + kind="restore", action="preview", status="preview", host_id=str(host.id), host_name=host.name, + entity_id=str(op.id), details={"backup_id": payload.backup_id, "manifest": manifest, "warnings": warnings}, + ) + return read_operation(op) + + +@router.post("/{operation_id}/handoff") +async def handoff(host_id: uuid.UUID, operation_id: uuid.UUID, payload: Handoff, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await host_or_404(db, host_id) + op = await op_or_404(db, host_id, operation_id) + if op.status != "preview": + raise api_error(409, ErrorCode.KEIN_OFFENER_RESTORE_VORSCHAU_VORGANG) + if op.expires_at <= datetime.now(timezone.utc): + raise api_error(409, ErrorCode.VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN) + if payload.confirm_name != host.name: + raise api_error(409, ErrorCode.HOSTNAMEN_EXAKT_EINGEBEN) + + raw = backup_store.read_snapshot(host_id, op.backup_id) + if raw is None or restore_service.fingerprint(raw) != op.fingerprint: + raise api_error(409, ErrorCode.SICHERUNG_WURDE_SEIT_DER_VORSCHAU_VERAENDERT_ODER_IST_2732C4DD) + + op.status = "handed_off" + op.handed_off_at = datetime.now(timezone.utc) + op.confirm_name = payload.confirm_name + op.message = ( + "Zur manuellen Wiederherstellung übergeben. Sicherung jetzt im OPNsense-Webinterface " + "importieren (System → Konfiguration → Historie/Wiederherstellen), Interface-/" + "Konsolenzuordnung prüfen, danach hier bestätigen." + ) + await db.commit() + await audit_service.persist( + kind="restore", action="handoff", status="handed_off", host_id=str(host.id), host_name=host.name, + entity_id=str(op.id), message=op.message, + ) + return read_operation(op) + + +@router.post("/{operation_id}/confirm") +async def confirm(host_id: uuid.UUID, operation_id: uuid.UUID, payload: Confirm, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await host_or_404(db, host_id) + op = await op_or_404(db, host_id, operation_id) + if op.status != "handed_off": + raise api_error(409, ErrorCode.KEIN_OFFENER_UEBERGEBENER_RESTORE_VORGANG) + if payload.confirm_name != host.name: + raise api_error(409, ErrorCode.HOSTNAMEN_EXAKT_EINGEBEN) + + op.status = "verified" + op.confirmed_at = datetime.now(timezone.utc) + op.message = ( + (payload.message or "").strip() or "Wiederherstellung manuell geprüft und bestätigt." + ) + " Hinweis: Zentral verwaltete Regeln/Aliase/NAT für diesen Host vor dem nächsten Push neu abgleichen." + await db.commit() + await audit_service.persist( + kind="restore", action="confirm", status="verified", host_id=str(host.id), host_name=host.name, + entity_id=str(op.id), message=op.message, + ) + return read_operation(op) + + +@router.post("/{operation_id}/cancel") +async def cancel(host_id: uuid.UUID, operation_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await host_or_404(db, host_id) + op = await op_or_404(db, host_id, operation_id) + if op.status not in ("preview", "handed_off"): + raise api_error(409, ErrorCode.VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN) + op.status = "cancelled" + op.cancelled_at = datetime.now(timezone.utc) + await db.commit() + await audit_service.persist( + kind="restore", action="cancel", status="cancelled", host_id=str(host.id), host_name=host.name, + entity_id=str(op.id), + ) + return read_operation(op) diff --git a/backend/app/routers/rules.py b/backend/app/routers/rules.py new file mode 100644 index 0000000..00afe05 --- /dev/null +++ b/backend/app/routers/rules.py @@ -0,0 +1,261 @@ +import uuid + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, RuleRevision +from app.services.policy_service import effective_hosts, snapshot, revision, preview, policy_lock, ensure_editable, RULE_FIELDS, workflow +from app.models.rule import FirewallRule +from app.models.rule_deployment import RuleDeployment +from app.schemas.rule import DeploymentResult, RuleCreate, RuleRead, RuleEdit, DeployApproval +from app.services.alias_service import check_alias_consistency, describe_alias_contents, ensure_aliases_settled +from app.services.interface_service import check_interface_consistency +from app.services.opnsense_client import OpnsenseAPIError +from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment + +router = APIRouter(prefix="/rules", tags=["rules"]) + + +def _load_options(): + return [ + selectinload(FirewallRule.targets), + selectinload(FirewallRule.groups).selectinload(DeviceGroup.hosts), + selectinload(FirewallRule.deployments).selectinload(RuleDeployment.host), + ] + + +async def _load_rule(db: AsyncSession, rule_id: uuid.UUID) -> FirewallRule: + await policy_lock(db) + rule = await db.get(FirewallRule, rule_id, options=_load_options()) + if not rule: + raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) + return rule + + +def _to_read( + rule: FirewallRule, + hosts: list[OpnsenseHost], + deployments: list[RuleDeployment], +) -> RuleRead: + hosts_by_id = {host.id: host for host in effective_hosts(rule)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(rule, deployments) + return RuleRead( + workflow_state=state, + workflow_counts=counts, id=rule.id, + description=rule.description, + interface=rule.interface, + direction=rule.direction, + ipprotocol=rule.ipprotocol, + protocol=rule.protocol, + action=rule.action, + source_net=rule.source_net, + source_port=rule.source_port, + destination_net=rule.destination_net, + destination_port=rule.destination_port, + enabled=rule.enabled, + created_at=rule.created_at, + updated_at=rule.updated_at, + target_host_ids=[host.id for host in rule.targets], + target_group_ids=[g.id for g in rule.groups], + effective_host_ids=[h.id for h in effective_hosts(rule)], + deployments=[ + DeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + revert_deadline=d.revert_deadline, + ) + for d in deployments + ], + ) + + +@router.get("", response_model=list[RuleRead]) +async def list_rules(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(FirewallRule).options(*_load_options())) + rules = result.scalars().unique().all() + return [_to_read(rule, rule.targets, rule.deployments) for rule in rules] + + +@router.post("", response_model=RuleRead, status_code=201) +async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + + rule = FirewallRule( + description=payload.description, + interface=payload.interface, + direction=payload.direction, + ipprotocol=payload.ipprotocol, + protocol=payload.protocol, + action=payload.action, + source_net=payload.source_net, + source_port=payload.source_port, + destination_net=payload.destination_net, + destination_port=payload.destination_port, + enabled=payload.enabled, + targets=hosts, + groups=groups, + ) + db.add(rule) + await db.flush() + revision(db, rule, "Regel angelegt") + await db.commit() + # All attributes we need (id, created_at, ...) were set client-side + # before commit, and `hosts`/an empty deployment list are already in + # hand — no need to touch any lazy relationship on `rule` here. + return _to_read(rule, hosts, []) + + +@router.post("/{rule_id}/deploy", response_model=RuleRead) +async def deploy(rule_id: uuid.UUID, approval: DeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_BESTAETIGEN_ODER_ROLLBACK_KLAEREN) + hosts = [h for h in effective_hosts(rule) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.REGEL_HAT_KEINE_ZIEL_HOSTS) + if len(hosts) > 1: + labels = await check_interface_consistency(hosts, rule.interface) + distinct = set(labels.values()) + if len(distinct) > 1: + detail = ", ".join(f"{name}={label}" for name, label in sorted(labels.items())) + raise api_error(409, ErrorCode.INTERFACE_MEANING_DIFFERS, interface=rule.interface, detail=detail) + for field_label, value in ( + ("Source", rule.source_net), + ("Destination", rule.destination_net), + ("Source-Port", rule.source_port), + ("Destination-Port", rule.destination_port), + ): + if not value: + continue + contents = await check_alias_consistency(hosts, value) + if len(set(contents.values())) > 1: + detail = describe_alias_contents(contents) + raise api_error(409, ErrorCode.ALIAS_CONTENT_DIFFERS, field_label=field_label, alias=value, detail=detail) + await ensure_aliases_settled(db, hosts, [rule.source_net, rule.destination_net, rule.source_port, rule.destination_port]) + await deploy_rule(db, rule, hosts) + await db.refresh(rule, attribute_names=["deployments"]) + for d in rule.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(rule, hosts, rule.deployments) + + +@router.post("/{rule_id}/confirm", response_model=RuleRead) +async def confirm(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + # Mirrors the deploy endpoint below: confirm_rule mutates the same + # RuleDeployment objects already loaded onto rule.deployments (same + # session, same identity map), so it's visible immediately with no + # reload — deliberately avoiding an expire+db.get() round trip here, + # which previously surfaced a MissingGreenlet bug under pool_pre_ping + # (see rule_deploy_service history). + rule = await _load_rule(db, rule_id) + await confirm_rule(db, rule) + return _to_read(rule, rule.targets, rule.deployments) + + +@router.delete("/{rule_id}", status_code=204) +async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + if any(d.status in ("pending", "revert_failed") for d in rule.deployments): + raise api_error(409, ErrorCode.OFFENES_DEPLOYMENT_ZUERST_KLAEREN) + try: + await undeploy_rule(db, rule) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(rule, attribute_names=["deployments"]) + await db.delete(rule) + await db.commit() + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.put("/{rule_id}", response_model=RuleRead) +async def edit_rule(rule_id: uuid.UUID, payload: RuleEdit, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + ensure_editable(rule) + if rule.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0) + hosts, groups = await resolve_targets(db, payload) + if not await db.scalar(select(RuleRevision.id).where(RuleRevision.rule_id == rule.id).limit(1)): + revision(db, rule, "Vor erster Bearbeitung") + for field in RULE_FIELDS: + setattr(rule, field, getattr(payload, field)) + rule.targets, rule.groups = hosts, groups + # Association-only changes must also invalidate optimistic edits. + from datetime import datetime, timezone + rule.updated_at = datetime.now(timezone.utc) + revision(db, rule, "Entwurf gespeichert") + await db.commit() + return _to_read(rule, hosts, rule.deployments) + + +@router.get("/{rule_id}/preview") +async def preview_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_rule(db, rule_id)) + + +@router.get("/{rule_id}/revisions") +async def list_revisions(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_rule(db, rule_id) + rows = (await db.scalars(select(RuleRevision).where(RuleRevision.rule_id == rule_id).order_by(RuleRevision.created_at.desc(), RuleRevision.id))).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{rule_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_OHNE_OFFENEN_ROLLBACK_KOENNEN_06DE0ADB) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + await opnsense_client.del_rule(deployment.host, deployment.opnsense_rule_uuid) + await opnsense_client.apply(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() + + +@router.post("/{rule_id}/deployments/{host_id}/retry-rollback", status_code=204) +async def retry_rollback(rule_id: uuid.UUID, host_id: uuid.UUID, approval: DeployApproval, db: AsyncSession = Depends(get_db)): + rule = await _load_rule(db, rule_id) + plan = preview(rule) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.DEPLOYMENT_WURDE_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_retry_rollback"]: + raise api_error(409, ErrorCode.FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR) + deployment = next(d for d in rule.deployments if d.host_id == host_id) + reverted = await revert_deployment(db, deployment) + await db.commit() + if not reverted: + raise api_error(502, ErrorCode.ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL) diff --git a/backend/app/routers/scheduled_maintenance.py b/backend/app/routers/scheduled_maintenance.py new file mode 100644 index 0000000..dd1ecb6 --- /dev/null +++ b/backend/app/routers/scheduled_maintenance.py @@ -0,0 +1,84 @@ +import uuid +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models import OpnsenseHost +from app.models.scheduled_maintenance import ScheduledMaintenance +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"]) + + +class Schedule(BaseModel): + action: Literal["reboot", "update", "major_update"] + run_at: datetime + acknowledge_unattended: Literal[True] + + +def _read(sched: ScheduledMaintenance) -> dict: + return { + "id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at, + "status": sched.status, "actor": sched.actor, "created_at": sched.created_at, + "result": sched.result, "host_operation_id": sched.host_operation_id, + } + + +async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost: + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + return host + + +@router.get("") +async def list_scheduled(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _host_or_404(db, host_id) + rows = ( + await db.scalars( + select(ScheduledMaintenance) + .where(ScheduledMaintenance.host_id == host_id) + .order_by(ScheduledMaintenance.created_at.desc()) + .limit(50) + ) + ).all() + return [_read(r) for r in rows] + + +@router.post("") +async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Request, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + host = await _host_or_404(db, host_id) + if not host.enabled: + raise api_error(409, ErrorCode.HOST_IST_DEAKTIVIERT) + now = datetime.now(timezone.utc) + run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc) + if run_at <= now: + raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN) + sched = ScheduledMaintenance( + host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at, + actor=request.state.user.username, created_at=now, + ) + db.add(sched) + await db.commit() + return _read(sched) + + +@router.delete("/{schedule_id}") +async def cancel_scheduled(host_id: uuid.UUID, schedule_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + await _host_or_404(db, host_id) + sched = await db.get(ScheduledMaintenance, schedule_id) + if not sched or sched.host_id != host_id: + raise api_error(404, ErrorCode.GEPLANTE_WARTUNG_NICHT_GEFUNDEN) + if sched.status != "pending": + raise api_error(409, ErrorCode.NUR_AUSSTEHENDE_GEPLANTE_WARTUNGEN_KOENNEN_ABGEBROCHEN_WERDEN) + sched.status = "cancelled" + await db.commit() + return _read(sched) diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py new file mode 100644 index 0000000..3c1b9c4 --- /dev/null +++ b/backend/app/routers/search.py @@ -0,0 +1,306 @@ +from typing import Callable + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.elements import ColumnElement + +from app.database import get_db +from app.models.alias import FirewallAlias +from app.models.host import OpnsenseHost +from app.models.ipsec_tunnel import IpsecTunnel +from app.models.managed_certificate import ManagedCertificate +from app.models.one_to_one import OneToOneRule +from app.models.openvpn_instance import OpenVpnInstance +from app.models.outbound_nat import OutboundNatRule +from app.models.policy import DeviceGroup +from app.models.policy_hierarchy_rule import PolicyHierarchyRule +from app.models.port_forward import PortForwardRule +from app.models.rule import FirewallRule +from app.models.wireguard_tunnel import WireguardTunnel +from app.schemas.search import SearchHit + +router = APIRouter(prefix="/search", tags=["search"]) + +# Below this, ILIKE '%q%' on a handful of local tables is instant and a +# shorter query mostly returns noise — not a real limit on data volume. +MIN_QUERY_LENGTH = 2 + +# One spec = one queryable model within a result category. A category with +# several specs (NAT's port-forward/one-to-one/outbound rules; VPN's +# IPsec/OpenVPN/WireGuard) competes on relevance across all of them together +# — see _search_models — rather than giving each sub-type its own `limit` +# slots regardless of how well it actually matches. +Spec = tuple[type, list[ColumnElement], object, Callable[..., SearchHit]] + + +def _strip_scheme(base_url: str) -> str: + return base_url.removeprefix("https://").removeprefix("http://") + + +def _match_score(text: str | None, q: str) -> int: + """Cheap relevance heuristic — no fuzzy/edit-distance matching, just + reward tighter matches so e.g. a rule *named* "vpn-uplink" outranks one + that merely mentions "vpn" in its destination net. `q` must already be + lowercased; `text` may be `None` for optional columns.""" + if not text: + return 0 + t = text.lower() + if t == q: + return 100 + if t.startswith(q): + return 80 + idx = t.find(q) + if idx == -1: + return 0 + if idx == 0 or not t[idx - 1].isalnum(): + return 60 + return 40 + + +async def _search_models( + db: AsyncSession, + specs: list[Spec], + q: str, + limit: int, +) -> list[SearchHit]: + like = f"%{q}%" + # Widen the per-spec candidate pool beyond `limit` so the cross-spec + # rescore below has enough to choose from — otherwise an early spec's + # weaker matches could crowd out a later spec's stronger ones just by + # being fetched first. + candidate_limit = min(max(limit * 2, 10), 30) + scored: list[tuple[int, object, Callable[..., SearchHit]]] = [] + for model, columns, order_by, to_hit in specs: + rows = ( + await db.scalars( + select(model).where(or_(*[c.ilike(like) for c in columns])).order_by(order_by).limit(candidate_limit) + ) + ).all() + for row in rows: + score = max(_match_score(getattr(row, c.key), q) for c in columns) + scored.append((score, row, to_hit)) + scored.sort(key=lambda item: item[0], reverse=True) + return [to_hit(row) for _, row, to_hit in scored[:limit]] + + +@router.get("", response_model=list[SearchHit]) +async def search( + q: str = Query(default=""), + limit: int = Query(default=5, ge=1, le=25), + db: AsyncSession = Depends(get_db), +): + q = q.strip().lower() + if len(q) < MIN_QUERY_LENGTH: + return [] + hits: list[SearchHit] = [] + + hits += await _search_models( + db, + [ + ( + OpnsenseHost, + [OpnsenseHost.name, OpnsenseHost.base_url, OpnsenseHost.cluster_name], + OpnsenseHost.name, + lambda host: SearchHit( + category="hosts", + id=str(host.id), + title=host.name, + subtitle=host.cluster_name or _strip_scheme(host.base_url), + href=f"/hosts/{host.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + FirewallRule, + [FirewallRule.description, FirewallRule.source_net, FirewallRule.destination_net], + FirewallRule.updated_at.desc(), + lambda r: SearchHit( + category="rules", + id=str(r.id), + title=r.description or f"{r.source_net} → {r.destination_net}", + subtitle=f"{r.interface} · {r.action}", + href=f"/rules?rule={r.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + FirewallAlias, + [FirewallAlias.name, FirewallAlias.content, FirewallAlias.description], + FirewallAlias.updated_at.desc(), + lambda a: SearchHit( + category="aliases", + id=str(a.id), + title=a.name, + subtitle=a.content or a.description or None, + href=f"/aliases?alias={a.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + PortForwardRule, + [PortForwardRule.description, PortForwardRule.destination_net, PortForwardRule.target], + PortForwardRule.updated_at.desc(), + lambda r: SearchHit( + category="nat", + id=str(r.id), + title=r.description or f"{r.destination_net} → {r.target}", + subtitle="Port Forward", + href=f"/nat?type=port-forward&rule={r.id}", + ), + ), + ( + OneToOneRule, + [OneToOneRule.description, OneToOneRule.source_net, OneToOneRule.external], + OneToOneRule.updated_at.desc(), + lambda r: SearchHit( + category="nat", + id=str(r.id), + title=r.description or f"{r.source_net} ↔ {r.external}", + subtitle="1:1 NAT", + href=f"/nat?type=one-to-one&rule={r.id}", + ), + ), + ( + OutboundNatRule, + [OutboundNatRule.description, OutboundNatRule.source_net, OutboundNatRule.target], + OutboundNatRule.updated_at.desc(), + lambda r: SearchHit( + category="nat", + id=str(r.id), + title=r.description or f"{r.source_net} → {r.target}", + subtitle="Outbound NAT", + href=f"/nat?type=outbound-nat&rule={r.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + IpsecTunnel, + [IpsecTunnel.description, IpsecTunnel.remote_address, IpsecTunnel.remote_identity], + IpsecTunnel.updated_at.desc(), + lambda t: SearchHit( + category="vpn", + id=str(t.id), + title=t.description or t.remote_address, + subtitle=f"IPsec · {t.remote_address}", + href=f"/ipsec?tunnel={t.id}", + ), + ), + ( + OpenVpnInstance, + [OpenVpnInstance.description, OpenVpnInstance.remote, OpenVpnInstance.server_network], + OpenVpnInstance.updated_at.desc(), + lambda i: SearchHit( + category="vpn", + id=str(i.id), + title=i.description, + subtitle=f"OpenVPN · {i.role}", + href=f"/openvpn?instance={i.id}", + ), + ), + ( + WireguardTunnel, + [WireguardTunnel.server_name, WireguardTunnel.peer_name, WireguardTunnel.peer_allowed_ips], + WireguardTunnel.updated_at.desc(), + lambda t: SearchHit( + category="vpn", + id=str(t.id), + title=f"{t.server_name} · {t.peer_name}", + subtitle="WireGuard", + href=f"/wireguard?tunnel={t.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + ManagedCertificate, + [ManagedCertificate.description, ManagedCertificate.commonname], + ManagedCertificate.updated_at.desc(), + lambda c: SearchHit( + category="certificates", + id=str(c.id), + title=c.description or c.commonname, + subtitle=c.commonname or None, + href=f"/certificates?cert={c.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + DeviceGroup, + [DeviceGroup.name, DeviceGroup.description], + DeviceGroup.name, + lambda g: SearchHit( + category="groups", + id=str(g.id), + title=g.name, + subtitle=g.description or None, + href=f"/groups?group={g.id}", + ), + ), + ], + q, + limit, + ) + + hits += await _search_models( + db, + [ + ( + PolicyHierarchyRule, + [PolicyHierarchyRule.description, PolicyHierarchyRule.source_net, PolicyHierarchyRule.destination_net], + PolicyHierarchyRule.updated_at.desc(), + lambda r: SearchHit( + category="policy-hierarchy", + id=str(r.id), + title=r.description or f"{r.source_net} → {r.destination_net}", + subtitle=f"{r.rulebase} · {r.interface}", + href=f"/policy-hierarchy?group={r.hierarchy_group_id}", + ), + ), + ], + q, + limit, + ) + + return hits diff --git a/backend/app/routers/services.py b/backend/app/routers/services.py new file mode 100644 index 0000000..1b454b1 --- /dev/null +++ b/backend/app/routers/services.py @@ -0,0 +1,69 @@ +import uuid +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models import OpnsenseHost +from app.services import opnsense_client as client, audit_service +from app.services.host_maintenance_service import ensure_no_maintenance +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/hosts/{host_id}/services", tags=["host-services"]) + + +async def host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost: + host = await db.get(OpnsenseHost, host_id) + if not host: + raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN) + return host + + +@router.get("") +async def list_services(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + host = await host_or_404(db, host_id) + try: + return await client.search_services(host) + except client.OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +async def _control(host_id: uuid.UUID, name: str, action: str, fn, db: AsyncSession): + await policy_lock(db) + host = await host_or_404(db, host_id) + await ensure_no_maintenance(db, [host]) + await audit_service.persist( + kind="host", action=action, status="requested", + host_id=str(host.id), host_name=host.name, + details={"service": name}, + ) + try: + result = await fn(host, name) + message = f"Dienst '{name}' {action} übergeben." + await audit_service.persist( + kind="host", action=action, status="accepted", + host_id=str(host.id), host_name=host.name, + message=message, details={"service": name, "response": result}, + ) + return result + except client.OpnsenseAPIError as exc: + await audit_service.persist( + kind="host", action=action, status="rejected", + host_id=str(host.id), host_name=host.name, + message=str(exc)[:800], details={"service": name}, + ) + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + + +@router.post("/{name}/start") +async def start_service(host_id: uuid.UUID, name: str, db: AsyncSession = Depends(get_db)): + return await _control(host_id, name, "start", client.start_service, db) + + +@router.post("/{name}/stop") +async def stop_service(host_id: uuid.UUID, name: str, db: AsyncSession = Depends(get_db)): + return await _control(host_id, name, "stop", client.stop_service, db) + + +@router.post("/{name}/restart") +async def restart_service(host_id: uuid.UUID, name: str, db: AsyncSession = Depends(get_db)): + return await _control(host_id, name, "restart", client.restart_service, db) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..9acd95b --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,36 @@ +from typing import Literal +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.dialects.postgresql import insert +from app.config import settings +from app.database import get_db +from app.models.preferences import UserPreferences +from app.models.user import AdminUser +from app.routers.auth import current_user + +router=APIRouter(prefix='/settings',tags=['settings']) + +class Preferences(BaseModel): + theme: Literal['light','dark','system'] | None = None + language: Literal['de','en'] | None = None + +@router.get('') +async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)): + preferences=await db.get(UserPreferences,user.id) + return {'username':user.username,'theme':preferences.theme if preferences else 'system', + 'language':preferences.language if preferences else 'de', + 'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds, + 'session_lifetime_seconds':settings.session_lifetime} + +@router.put('') +async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)): + username = user.username + current = await db.get(UserPreferences, user.id) + theme = payload.theme or (current.theme if current else 'system') + language = payload.language or (current.language if current else 'de') + await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language})) + await db.commit() + return {'username':username,'theme':theme,'language':language, + 'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds, + 'session_lifetime_seconds':settings.session_lifetime} diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py new file mode 100644 index 0000000..5ef7bc9 --- /dev/null +++ b/backend/app/routers/system.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.services import update_check_service + +router = APIRouter(prefix="/system", tags=["system"]) + + +@router.get("/version-check") +async def version_check() -> dict: + """Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange + UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage).""" + return await update_check_service.check() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..a5c6034 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,98 @@ +import uuid + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.user import AdminUser +from app.routers.auth import current_user +from app.schemas.user import UserCreate, UserPasswordReset, UserRead, UserRoleUpdate +from app.services import auth_service, totp_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +async def _admin_count(db: AsyncSession, exclude_id: uuid.UUID | None = None) -> int: + query = select(func.count()).select_from(AdminUser).where(AdminUser.role == "admin") + if exclude_id is not None: + query = query.where(AdminUser.id != exclude_id) + return await db.scalar(query) + + +@router.get("", response_model=list[UserRead]) +async def list_users(db: AsyncSession = Depends(get_db)): + rows = (await db.scalars(select(AdminUser).order_by(AdminUser.username))).all() + return rows + + +@router.post("", response_model=UserRead, status_code=201) +async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)): + existing = await db.scalar(select(AdminUser).where(AdminUser.username == payload.username)) + if existing: + raise api_error(409, ErrorCode.BENUTZERNAME_BEREITS_VERGEBEN) + user = AdminUser( + username=payload.username, + password_hash=auth_service.hash_password(payload.password), + role=payload.role.value, + ) + db.add(user) + await db.commit() + return user + + +@router.patch("/{user_id}/role", response_model=UserRead) +async def update_role(user_id: uuid.UUID, payload: UserRoleUpdate, db: AsyncSession = Depends(get_db)): + target = await db.get(AdminUser, user_id) + if target is None: + raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN) + if target.role == "admin" and payload.role.value != "admin" and await _admin_count(db, exclude_id=target.id) == 0: + raise api_error(409, ErrorCode.MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN) + target.role = payload.role.value + await db.commit() + return target + + +@router.post("/{user_id}/reset-2fa", response_model=UserRead) +async def reset_two_factor(user_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + """Admin recovery for a user who lost their authenticator and recovery codes.""" + target = await db.get(AdminUser, user_id) + if target is None: + raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN) + totp_service.delete_secrets(target.id) + target.totp_enabled = False + target.totp_recovery_hashes = None + target.totp_last_step = 0 + await db.commit() + return target + + +@router.post("/{user_id}/reset-password", response_model=UserRead) +async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: AsyncSession = Depends(get_db)): + target = await db.get(AdminUser, user_id) + if target is None: + raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN) + target.password_hash = auth_service.hash_password(payload.new_password) + await db.commit() + return target + + +@router.delete("/{user_id}", status_code=204) +async def delete_user( + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current: AdminUser = Depends(current_user), +): + if user_id == current.id: + raise api_error(400, ErrorCode.DER_EIGENE_ACCOUNT_KANN_NICHT_GELOESCHT_WERDEN) + target = await db.get(AdminUser, user_id) + if target is None: + raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN) + # No separate "last admin" guard here: only an admin can reach this + # endpoint (read_only is rejected earlier, in AuthMiddleware) and the + # self-delete check above already guarantees target != current — so + # `current` is always a still-existing admin afterward, meaning the + # admin count can never reach zero this way. + await db.delete(target) + await db.commit() diff --git a/backend/app/routers/wireguard.py b/backend/app/routers/wireguard.py new file mode 100644 index 0000000..b7a7110 --- /dev/null +++ b/backend/app/routers/wireguard.py @@ -0,0 +1,223 @@ +import uuid + +from fastapi import APIRouter, Depends, Body +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.errors import ErrorCode, api_error +from app.database import get_db +from app.models.host import OpnsenseHost +from app.models.policy import DeviceGroup, WireguardTunnelRevision +from app.models.wireguard_tunnel import WireguardTunnel +from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment +from app.schemas.wireguard import ( + WireguardDeployApproval, WireguardDeploymentResult, WireguardTunnelCreate, WireguardTunnelEdit, WireguardTunnelRead, +) +from app.services import wireguard_key_store +from app.services.wireguard_service import effective_hosts, preview, revision, snapshot, workflow, WIREGUARD_FIELDS +from app.services.wireguard_deploy_service import deploy_tunnel, undeploy_tunnel +from app.services.opnsense_client import OpnsenseAPIError +from app.services.policy_service import policy_lock + +router = APIRouter(prefix="/wireguard/tunnels", tags=["wireguard"]) + + +def _load_options(): + return [ + selectinload(WireguardTunnel.targets), + selectinload(WireguardTunnel.groups).selectinload(DeviceGroup.hosts), + selectinload(WireguardTunnel.deployments).selectinload(WireguardTunnelDeployment.host), + ] + + +async def _load_tunnel(db: AsyncSession, tunnel_id: uuid.UUID) -> WireguardTunnel: + # Same advisory lock as rules/aliases/NAT/IPsec/OpenVPN — serializes + # concurrent deploy-affecting changes across processes. No + # ensure_editable() gate here either: this entity has no pending/ + # revert_failed state to ever block editing on (see + # wireguard_deploy_service.py's module docstring). + await policy_lock(db) + tunnel = await db.get(WireguardTunnel, tunnel_id, options=_load_options()) + if not tunnel: + raise api_error(404, ErrorCode.TUNNEL_NICHT_GEFUNDEN) + return tunnel + + +def _to_read(tunnel: WireguardTunnel, deployments: list[WireguardTunnelDeployment]) -> WireguardTunnelRead: + hosts_by_id = {host.id: host for host in effective_hosts(tunnel)} + hosts_by_id.update({d.host_id: d.host for d in deployments if d.host is not None}) + state, counts = workflow(tunnel, deployments) + return WireguardTunnelRead( + workflow_state=state, + workflow_counts=counts, + id=tunnel.id, + server_name=tunnel.server_name, + server_public_key=tunnel.server_public_key, + server_private_key_set=tunnel.server_private_key_set, + listen_port=tunnel.listen_port, + server_tunnel_address=tunnel.server_tunnel_address, + dns=tunnel.dns, + mtu=tunnel.mtu, + peer_name=tunnel.peer_name, + peer_public_key=tunnel.peer_public_key, + peer_psk_enabled=tunnel.peer_psk_enabled, + peer_psk_set=tunnel.peer_psk_set, + peer_allowed_ips=tunnel.peer_allowed_ips, + peer_endpoint_address=tunnel.peer_endpoint_address, + peer_endpoint_port=tunnel.peer_endpoint_port, + peer_keepalive=tunnel.peer_keepalive, + enabled=tunnel.enabled, + created_at=tunnel.created_at, + updated_at=tunnel.updated_at, + target_host_ids=[host.id for host in tunnel.targets], + target_group_ids=[g.id for g in tunnel.groups], + effective_host_ids=[h.id for h in effective_hosts(tunnel)], + deployments=[ + WireguardDeploymentResult( + host_id=d.host_id, + host_name=hosts_by_id[d.host_id].name if d.host_id in hosts_by_id else "?", + status=d.status, + message=d.message, + ) + for d in deployments + ], + ) + + +async def resolve_targets(db, payload): + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(payload.target_host_ids)))).all()) + groups = list((await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(payload.target_group_ids)).options(selectinload(DeviceGroup.hosts)))).all()) + if len(hosts) != len(set(payload.target_host_ids)) or len(groups) != len(set(payload.target_group_ids)): + raise api_error(400, ErrorCode.EIN_ODER_MEHRERE_ZIELE_WURDEN_NICHT_GEFUNDEN) + return hosts, groups + + +@router.get("", response_model=list[WireguardTunnelRead]) +async def list_tunnels(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(WireguardTunnel).options(*_load_options())) + tunnels = result.scalars().unique().all() + return [_to_read(tunnel, tunnel.deployments) for tunnel in tunnels] + + +@router.post("", response_model=WireguardTunnelRead, status_code=201) +async def create_tunnel(payload: WireguardTunnelCreate, db: AsyncSession = Depends(get_db)): + await policy_lock(db) + hosts, groups = await resolve_targets(db, payload) + + tunnel = WireguardTunnel( + **{field: getattr(payload, field) for field in WIREGUARD_FIELDS}, + targets=hosts, + groups=groups, + ) + db.add(tunnel) + await db.flush() + wireguard_key_store.save_private_key(tunnel.id, payload.server_private_key) + tunnel.server_private_key_set = True + if payload.peer_psk_material: + wireguard_key_store.save_psk(tunnel.id, payload.peer_psk_material) + tunnel.peer_psk_set = True + revision(db, tunnel, "Tunnel angelegt") + await db.commit() + return _to_read(tunnel, []) + + +@router.put("/{tunnel_id}", response_model=WireguardTunnelRead) +async def edit_tunnel(tunnel_id: uuid.UUID, payload: WireguardTunnelEdit, db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + if tunnel.updated_at != payload.expected_updated_at: + raise api_error(409, ErrorCode.TUNNEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_50830F70) + hosts, groups = await resolve_targets(db, payload) + if not await db.scalar(select(WireguardTunnelRevision.id).where(WireguardTunnelRevision.tunnel_id == tunnel.id).limit(1)): + revision(db, tunnel, "Vor erster Bearbeitung") + for field in WIREGUARD_FIELDS: + setattr(tunnel, field, getattr(payload, field)) + tunnel.targets, tunnel.groups = hosts, groups + if payload.server_private_key is not None: + wireguard_key_store.save_private_key(tunnel.id, payload.server_private_key) + tunnel.server_private_key_set = True + if payload.peer_psk_material is not None: + wireguard_key_store.save_psk(tunnel.id, payload.peer_psk_material) + tunnel.peer_psk_set = True + from datetime import datetime, timezone + tunnel.updated_at = datetime.now(timezone.utc) + revision(db, tunnel, "Entwurf gespeichert") + await db.commit() + return _to_read(tunnel, tunnel.deployments) + + +@router.get("/{tunnel_id}/preview") +async def preview_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + return preview(await _load_tunnel(db, tunnel_id)) + + +@router.get("/{tunnel_id}/revisions") +async def list_revisions(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + await _load_tunnel(db, tunnel_id) + rows = (await db.scalars( + select(WireguardTunnelRevision).where(WireguardTunnelRevision.tunnel_id == tunnel_id) + .order_by(WireguardTunnelRevision.created_at.desc(), WireguardTunnelRevision.id) + )).all() + return [{"id": r.id, "created_at": r.created_at, "note": r.note, "snapshot": r.snapshot} for r in rows] + + +@router.post("/{tunnel_id}/deploy", response_model=WireguardTunnelRead) +async def deploy(tunnel_id: uuid.UUID, approval: WireguardDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + plan = preview(tunnel) + if approval and approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN) + if not tunnel.server_private_key_set: + raise api_error(400, ErrorCode.FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998) + if tunnel.peer_psk_enabled and not tunnel.peer_psk_set: + raise api_error(400, ErrorCode.PEER_PRE_SHARED_KEY_IST_AKTIVIERT_ABER_ES_FCFAC129) + hosts = [h for h in effective_hosts(tunnel) if h.enabled] + if not hosts: + raise api_error(400, ErrorCode.TUNNEL_HAT_KEINE_ZIEL_HOSTS) + await deploy_tunnel(db, tunnel, hosts) + await db.refresh(tunnel, attribute_names=["deployments"]) + for d in tunnel.deployments: + await db.refresh(d, attribute_names=["host"]) + return _to_read(tunnel, tunnel.deployments) + + +@router.delete("/{tunnel_id}", status_code=204) +async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + try: + await undeploy_tunnel(db, tunnel) + except OpnsenseAPIError as exc: + raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc + await db.refresh(tunnel, attribute_names=["deployments"]) + wireguard_key_store.delete_private_key(tunnel.id) + wireguard_key_store.delete_psk(tunnel.id) + await db.delete(tunnel) + await db.commit() + + +@router.post("/{tunnel_id}/deployments/{host_id}/remove", status_code=204) +async def remove_previous_target(tunnel_id: uuid.UUID, host_id: uuid.UUID, approval: WireguardDeployApproval, db: AsyncSession = Depends(get_db)): + tunnel = await _load_tunnel(db, tunnel_id) + plan = preview(tunnel) + if approval.preview_token != plan["token"]: + raise api_error(409, ErrorCode.ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN) + entry = next((h for h in plan["hosts"] if h["host_id"] == str(host_id)), None) + if entry is None: + raise api_error(404, ErrorCode.DEPLOYMENT_NICHT_GEFUNDEN) + if not entry["can_remove"]: + raise api_error(409, ErrorCode.NUR_FRUEHERE_AKTIVE_ZIELE_KOENNEN_BEREINIGT_WERDEN) + deployment = next(d for d in tunnel.deployments if d.host_id == host_id) + from app.services import opnsense_client + try: + if deployment.opnsense_peer_uuid: + await opnsense_client.del_wireguard_peer(deployment.host, deployment.opnsense_peer_uuid) + if deployment.opnsense_server_uuid: + await opnsense_client.del_wireguard_server(deployment.host, deployment.opnsense_server_uuid) + await opnsense_client.reconfigure_wireguard(deployment.host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + await db.commit() + raise api_error(502, ErrorCode.ENTFERNEN_FEHLGESCHLAGEN_DIE_ZUORDNUNG_BLEIBT_FUER_EINEN_NEUEN_4CF90E4C) from exc + await db.delete(deployment) + await db.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/alias.py b/backend/app/schemas/alias.py new file mode 100644 index 0000000..5a5d7fa --- /dev/null +++ b/backend/app/schemas/alias.py @@ -0,0 +1,71 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.rule import DeployApproval, DeploymentResult # generic, reused as-is + +# The OPNsense alias types this app manages centrally — content is a plain +# newline-separated list for all of these (verified against OPNsense's own +# alias model). Deliberately excludes: "internal" (auto-generated +# __if_network, not user-creatable), "external" (built-ins like +# bogons/sshlockout), and "url"/"urltable"/"urljson"/"geoip"/"dynipv6host"/ +# "authgroup" (need a URL or other external config instead of/alongside a +# content list — different form shape, not yet built). +AliasType = Literal["host", "network", "port", "networkgroup", "mac", "asn"] + + +class AliasCreate(BaseModel): + name: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_]+$") + type: AliasType = "host" + # One entry per line — IPs/hostnames (host), CIDRs (network), or + # numbers/ranges (port). Not validated further here: OPNsense is the + # authority on what's a legal entry for a given type, and rejects an + # invalid one at push time (surfaced as a per-host deploy error, same + # as an unknown interface today). + content: str = Field(default="", max_length=8000) + description: str = Field(default="", max_length=255) + enabled: bool = True + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def clean_name(cls, value): + if not value.strip(): + raise ValueError("Name darf nicht leer sein") + return value.strip() + + @field_validator("content") + @classmethod + def clean_content(cls, value): + return "\n".join(line.strip() for line in value.splitlines() if line.strip()) + + +class AliasRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + type: str + content: str + description: str + enabled: bool + created_at: datetime + updated_at: datetime + target_host_ids: list[uuid.UUID] + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "pending", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[DeploymentResult] + + +class AliasEdit(AliasCreate): + expected_updated_at: datetime + + +__all__ = ["AliasCreate", "AliasRead", "AliasEdit", "DeployApproval", "DeploymentResult"] diff --git a/backend/app/schemas/carp_event.py b/backend/app/schemas/carp_event.py new file mode 100644 index 0000000..46d4721 --- /dev/null +++ b/backend/app/schemas/carp_event.py @@ -0,0 +1,16 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class CarpEventRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + host_id: uuid.UUID + host_name: str + cluster_name: str | None + from_status: str | None + to_status: str | None + detected_at: datetime diff --git a/backend/app/schemas/certificates.py b/backend/app/schemas/certificates.py new file mode 100644 index 0000000..e81ce81 --- /dev/null +++ b/backend/app/schemas/certificates.py @@ -0,0 +1,202 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +DeployStatus = Literal["draft", "success", "error"] + +# ── Trust-Store (ManagedCertificate) ───────────────────────────────────── +CertAction = Literal["import", "internal", "external", "sign_csr", "import_csr", "reissue", "manual"] +# Curated subset of Trust/Cert.xml's key_type OptionValues (RSA sizes + +# EC curves) — see ManagedCertificate's docstring. +CertKeyType = Literal["2048", "3072", "4096", "prime256v1", "secp384r1", "secp521r1"] +CertDigest = Literal["sha1", "sha224", "sha256", "sha384", "sha512"] +CertType = Literal["usr_cert", "server_cert", "combined_server_client", "v3_ca"] + +_DN_ACTIONS = {"internal", "external", "reissue"} +_CRT_ACTIONS = {"import", "import_csr", "manual"} + + +class ManagedCertificateBase(BaseModel): + host_id: uuid.UUID + action: CertAction + description: str = Field(min_length=1, max_length=255) + commonname: str = Field(default="", max_length=255) + country: str = Field(default="DE", max_length=8) + state: str = Field(default="", max_length=255) + city: str = Field(default="", max_length=255) + organization: str = Field(default="", max_length=255) + email: str = Field(default="", max_length=255) + key_type: CertKeyType = "2048" + digest: CertDigest = "sha256" + cert_type: CertType = "server_cert" + lifetime: int = Field(default=397, ge=1, le=36500) + caref: str | None = Field(default=None, max_length=64) + csr_payload: str | None = Field(default=None, max_length=20000) + crt_payload: str | None = Field(default=None, max_length=20000) + + @field_validator("description") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + @model_validator(mode="after") + def check_action_requirements(self): + if self.action in _DN_ACTIONS and not self.commonname.strip(): + raise ValueError("Für diese Aktion wird ein Common Name benötigt") + if self.action == "sign_csr" and not (self.csr_payload or "").strip(): + raise ValueError("Für 'CSR signieren' wird ein CSR (PEM) benötigt") + if self.action in _CRT_ACTIONS and not (self.crt_payload or "").strip(): + raise ValueError("Für diese Aktion wird ein Zertifikat (PEM) benötigt") + return self + + +class ManagedCertificateCreate(ManagedCertificateBase): + # Only meaningful for action="import" — see cert_key_store.py. Never + # returned by the API, only private_key_set. + private_key: str | None = Field(default=None, max_length=20000) + + @model_validator(mode="after") + def check_private_key_scope(self): + if self.private_key and self.action != "import": + raise ValueError("Ein privater Schlüssel kann nur bei Aktion 'import' hinterlegt werden") + return self + + +class ManagedCertificateEdit(ManagedCertificateBase): + expected_updated_at: datetime + # None keeps the previously stored key unchanged (write-only, same + # pattern as IpsecTunnelEdit.pre_shared_key). + private_key: str | None = Field(default=None, max_length=20000) + + @model_validator(mode="after") + def check_private_key_scope(self): + if self.private_key and self.action != "import": + raise ValueError("Ein privater Schlüssel kann nur bei Aktion 'import' hinterlegt werden") + return self + + +class ManagedCertificateRead(ManagedCertificateBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + private_key_set: bool + opnsense_uuid: str | None + status: DeployStatus + message: str | None + deployed_at: datetime | None + created_at: datetime + updated_at: datetime + + +# ── ACME accounts ───────────────────────────────────────────────────── +AcmeCA = Literal[ + "letsencrypt", "letsencrypt_test", "zerossl", "buypass", "buypass_test", "google", "google_test", "sslcom", "custom" +] + + +class AcmeAccountBase(BaseModel): + host_id: uuid.UUID + name: str = Field(min_length=1, max_length=255) + description: str = Field(default="", max_length=255) + email: str = Field(default="", max_length=255) + ca: AcmeCA = "letsencrypt" + custom_ca: str = Field(default="", max_length=255) + eab_kid: str = Field(default="", max_length=255) + enabled: bool = True + + @field_validator("name") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + @model_validator(mode="after") + def check_custom_ca(self): + if self.ca == "custom" and not self.custom_ca.strip(): + raise ValueError("Für CA 'custom' wird eine Custom-CA-URL benötigt") + return self + + +class AcmeAccountCreate(AcmeAccountBase): + eab_hmac: str | None = Field(default=None, max_length=8192) + + +class AcmeAccountEdit(AcmeAccountBase): + expected_updated_at: datetime + eab_hmac: str | None = Field(default=None, max_length=8192) + + +class AcmeAccountRead(AcmeAccountBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + eab_hmac_set: bool + opnsense_uuid: str | None + status: DeployStatus + message: str | None + deployed_at: datetime | None + register_status: str | None + register_message: str | None + register_at: datetime | None + created_at: datetime + updated_at: datetime + + +# ── ACME certificate requests ───────────────────────────────────────── +# Prefixed OptionValues, verified against AcmeClient.xml — see +# AcmeCertificateRequest's docstring for why this matters. +AcmeKeyLength = Literal["key_2048", "key_3072", "key_4096", "key_ec256", "key_ec384"] + + +class AcmeCertificateRequestBase(BaseModel): + host_id: uuid.UUID + name: str = Field(min_length=1, max_length=255) + alt_names: str = Field(default="", max_length=2000) + account_id: uuid.UUID + validation_ref: str = Field(min_length=1, max_length=64) + key_length: AcmeKeyLength = "key_4096" + ocsp: bool = False + auto_renewal: bool = True + # Days SINCE LAST ISSUANCE, not days before expiry — see + # AcmeCertificateRequest's docstring. MinimumValue/MaximumValue + # verified against AcmeClient.xml's renewInterval field (0..5000). + renew_interval: int = Field(default=60, ge=0, le=5000) + enabled: bool = True + + @field_validator("name", "validation_ref") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class AcmeCertificateRequestCreate(AcmeCertificateRequestBase): + pass + + +class AcmeCertificateRequestEdit(AcmeCertificateRequestBase): + expected_updated_at: datetime + + +class AcmeCertificateRequestRead(AcmeCertificateRequestBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + opnsense_uuid: str | None + status: DeployStatus + message: str | None + deployed_at: datetime | None + sign_status: str | None + sign_message: str | None + sign_triggered_at: datetime | None + remote_status_code: int | None + remote_status_last_update: datetime | None + remote_last_update: datetime | None + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..3c420a4 --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class DashboardHost(BaseModel): + id: uuid.UUID + name: str + status: str + carp_status: str | None + cluster_name: str | None + + +class PendingAction(BaseModel): + kind: str + status: str + entity_name: str + host_name: str + message: str | None + created_at: datetime + link: str + + +class ActivityItem(BaseModel): + id: uuid.UUID + created_at: datetime + actor: str + kind: str + action: str + status: str + entity_name: str | None + host_name: str | None + message: str | None + + +class DashboardSummary(BaseModel): + hosts: list[DashboardHost] + pending_actions: list[PendingAction] + recent_activity: list[ActivityItem] diff --git a/backend/app/schemas/host.py b/backend/app/schemas/host.py new file mode 100644 index 0000000..c443207 --- /dev/null +++ b/backend/app/schemas/host.py @@ -0,0 +1,136 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class HostCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + base_url: str = Field(min_length=1, max_length=255) + api_key: str = Field(min_length=1, max_length=255) + api_secret: str = Field(min_length=1) + verify_tls: bool = True + # Pinned certificate of the firewall (PEM), obtained via POST /hosts/tls-probe. + tls_ca_pem: str | None = Field(default=None, max_length=32768) + enabled: bool = True + cluster_name: str | None = Field(default=None, max_length=120) + policy_hierarchy_group_id: uuid.UUID | None = None + + +class TlsProbeRequest(BaseModel): + base_url: str = Field(min_length=1, max_length=255) + + +class TlsProbeResult(BaseModel): + pem: str + sha256: str + subject: str + issuer: str + self_signed: bool + not_before: datetime + not_after: datetime + expired: bool + san: list[str] + + +class HostUpdate(BaseModel): + """Partial update — everything optional, only provided fields change. + Credential rotation (api_key/api_secret) isn't included yet; delete + + recreate is still the way to rotate a host's API key for now.""" + + name: str | None = Field(default=None, min_length=1, max_length=120) + verify_tls: bool | None = None + tls_ca_pem: str | None = Field(default=None, max_length=32768) + clear_tls_pin: bool = False + enabled: bool | None = None + cluster_name: str | None = Field(default=None, max_length=120) + clear_cluster_name: bool = False # explicit flag, since cluster_name=None is ambiguous with "don't touch it" + policy_hierarchy_group_id: uuid.UUID | None = None + clear_policy_hierarchy_group_id: bool = False # same ambiguity as clear_cluster_name above + + +class HostRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + base_url: str + verify_tls: bool + tls_pinned: bool = False + tls_fingerprint: str | None = None + enabled: bool + cluster_name: str | None + policy_hierarchy_group_id: uuid.UUID | None = None + created_at: datetime + status: Literal["online", "offline", "disabled"] + carp_status: str | None = None + status_detail: str | None = None + + +class LiveRule(BaseModel): + uuid: str + description: str + action: str + action_label: str + direction: str + direction_label: str + interface: str | None + interface_label: str | None + protocol: str | None + protocol_label: str | None + source_net: str + source_port: str | None + destination_net: str + destination_port: str | None + enabled: bool + is_automatic: bool + + +class FirmwareInfo(BaseModel): + product_id: str + product_version: str + + +class InterfaceOption(BaseModel): + name: str + label: str + + +class AliasOption(BaseModel): + name: str + type: str + description: str + content: str + enabled: bool + + +class HostBackup(BaseModel): + id: str + created_at: datetime + size: int + + +CertStatus = Literal["valid", "expiring_soon", "expired", "unknown"] + + +class HostCertificate(BaseModel): + uuid: str + descr: str + common_name: str | None + issuer: str + valid_from: datetime | None + valid_to: datetime | None + days_until_expiry: int | None + status: CertStatus + in_use: bool + + +class HostCa(BaseModel): + uuid: str + descr: str + common_name: str | None + valid_from: datetime | None + valid_to: datetime | None + days_until_expiry: int | None + status: CertStatus diff --git a/backend/app/schemas/ipsec.py b/backend/app/schemas/ipsec.py new file mode 100644 index 0000000..fda27df --- /dev/null +++ b/backend/app/schemas/ipsec.py @@ -0,0 +1,71 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +IkeVersion = Literal["ike", "ikev1", "ikev2"] + + +class IpsecTunnelBase(BaseModel): + description: str = Field(min_length=1, max_length=255) + local_address: str = Field(min_length=1, max_length=255) + remote_address: str = Field(min_length=1, max_length=255) + ike_version: IkeVersion = "ikev2" + proposal: str = Field(default="default", min_length=1, max_length=128) + local_identity: str = Field(min_length=1, max_length=255) + remote_identity: str = Field(default="", max_length=255) + local_networks: str = Field(min_length=1, max_length=255) + remote_networks: str = Field(min_length=1, max_length=255) + esp_proposal: str = Field(default="default", min_length=1, max_length=128) + enabled: bool = True + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + + @field_validator("description", "local_address", "remote_address", "local_identity", "local_networks", "remote_networks", "proposal", "esp_proposal") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class IpsecTunnelCreate(IpsecTunnelBase): + # Required on create — a PSK tunnel with no key is pointless, even + # though OPNsense's own model doesn't mark preSharedKey.Key required. + pre_shared_key: str = Field(min_length=1, max_length=512) + + +class IpsecTunnelEdit(IpsecTunnelBase): + expected_updated_at: datetime + # Optional on edit: omitted (None) keeps the previously stored secret + # unchanged, mirroring NotificationSettingsUpdate's smtp_password/ + # webhook_signing_secret pattern — the secret is write-only and never + # sent back to the client, so the edit form can't "already have it". + pre_shared_key: str | None = Field(default=None, min_length=1, max_length=512) + + +class IpsecDeploymentResult(BaseModel): + model_config = ConfigDict(from_attributes=True) + + host_id: uuid.UUID + host_name: str + status: Literal["success", "error"] + message: str | None = None + + +class IpsecTunnelRead(IpsecTunnelBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + pre_shared_key_set: bool + created_at: datetime + updated_at: datetime + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[IpsecDeploymentResult] + + +class IpsecDeployApproval(BaseModel): + preview_token: str = Field(min_length=64, max_length=64) diff --git a/backend/app/schemas/logs.py b/backend/app/schemas/logs.py new file mode 100644 index 0000000..cc5c8ae --- /dev/null +++ b/backend/app/schemas/logs.py @@ -0,0 +1,71 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class LogEntryRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + host_id: uuid.UUID + timestamp: datetime + action: str | None + interface: str | None + direction: str | None + protocol: str | None + source_net: str | None + source_port: str | None + destination_net: str | None + destination_port: str | None + label: str | None + + +class LogSearchResult(BaseModel): + total: int + offset: int + limit: int + items: list[LogEntryRead] + + +class LogStatCount(BaseModel): + value: str + count: int + + +class LogStats(BaseModel): + by_action: dict[str, int] + by_interface: dict[str, int] + top_blocked_sources: list[LogStatCount] + top_destination_ports: list[LogStatCount] + + +class LogFilterOptions(BaseModel): + actions: list[str] + interfaces: list[str] + protocols: list[str] + + +class LogSettingsRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + enabled: bool + poll_interval_seconds: int + retention_days: int + max_size_gb: float + # Filled by the router from Postgres statistics, not stored. + used_bytes: int = 0 + entries: int = 0 + oldest_at: datetime | None = None + + +class LogSettingsUpdate(BaseModel): + enabled: bool | None = None + poll_interval_seconds: int | None = Field(default=None, ge=1) + # No upper bound enforced server-side beyond the floor below — it's a + # disk-usage tradeoff for the operator to judge based on their own + # firewalls' traffic volume and available disk, not a fixed limit + # OPNexus can know in advance. + retention_days: int | None = Field(default=None, ge=1) + # 0 = unlimited; the upper bound only guards against typos. + max_size_gb: float | None = Field(default=None, ge=0, le=100000) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..d3d262f --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,96 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from app.config import settings + +SmtpSecurity = Literal["none", "starttls", "ssl"] +ProblemType = Literal[ + "host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown", + "carp_split_brain", "nat_drift", "carp_auto_revert_failed", +] + + +class NotificationSettingsRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + webhook_enabled: bool + webhook_url: str | None + webhook_signing_secret_set: bool + + email_enabled: bool + smtp_host: str | None + smtp_port: int + smtp_security: SmtpSecurity + smtp_username: str | None + smtp_password_set: bool + smtp_from_address: str | None + smtp_to_addresses: list[str] + + host_offline_enabled: bool + cert_expiring_enabled: bool + rule_drift_enabled: bool + revert_failed_enabled: bool + maintenance_unknown_enabled: bool + carp_health_enabled: bool + nat_drift_enabled: bool + carp_auto_revert_failed_enabled: bool + + poll_interval_seconds: int + updated_at: datetime + + +class NotificationSettingsUpdate(BaseModel): + """Partial update — everything optional, only provided fields change. + Secret values (smtp_password/webhook_signing_secret) are write-only: + provide a new value to set it, or the matching clear_* flag to remove + it — omitting both leaves the currently stored secret untouched, same + disambiguation pattern as HostUpdate.clear_cluster_name.""" + + webhook_enabled: bool | None = None + webhook_url: str | None = Field(default=None, max_length=500) + webhook_signing_secret: str | None = Field(default=None, min_length=1) + clear_webhook_signing_secret: bool = False + + email_enabled: bool | None = None + smtp_host: str | None = Field(default=None, max_length=255) + smtp_port: int | None = Field(default=None, ge=1, le=65535) + smtp_security: SmtpSecurity | None = None + smtp_username: str | None = Field(default=None, max_length=255) + smtp_password: str | None = Field(default=None, min_length=1) + clear_smtp_password: bool = False + smtp_from_address: str | None = Field(default=None, max_length=255) + smtp_to_addresses: list[str] | None = None + + host_offline_enabled: bool | None = None + cert_expiring_enabled: bool | None = None + rule_drift_enabled: bool | None = None + revert_failed_enabled: bool | None = None + maintenance_unknown_enabled: bool | None = None + carp_health_enabled: bool | None = None + nat_drift_enabled: bool | None = None + carp_auto_revert_failed_enabled: bool | None = None + + poll_interval_seconds: int | None = Field( + default=None, ge=settings.notification_poll_min_interval_seconds + ) + + +class NotificationProblemRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + problem_type: ProblemType + status: Literal["open", "resolved"] + context: dict + first_detected_at: datetime + last_seen_at: datetime + resolved_at: datetime | None + last_notified_at: datetime | None + resolved_notified_at: datetime | None + + +class TestNotificationRequest(BaseModel): + channel: Literal["webhook", "email"] diff --git a/backend/app/schemas/one_to_one.py b/backend/app/schemas/one_to_one.py new file mode 100644 index 0000000..ab17724 --- /dev/null +++ b/backend/app/schemas/one_to_one.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.rule import DeployApproval, DeploymentResult # noqa: F401 (re-exported for routers/one_to_one.py) + + +class OneToOneCreate(BaseModel): + description: str = Field(min_length=1, max_length=255) + interface: str = Field(min_length=1, max_length=64) + source_net: str = Field(default="any", min_length=1, max_length=128) + destination_net: str = Field(default="any", min_length=1, max_length=128) + source_not: bool = False + destination_not: bool = False + enabled: bool = True + type: Literal["binat", "nat"] = "binat" + external: str = Field(min_length=1, max_length=128) + natreflection: Literal["", "enable", "disable"] = "" + log: bool = False + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + + @field_validator('description', 'interface', 'source_net', 'destination_net', 'external') + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class OneToOneRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + description: str + interface: str + source_net: str + destination_net: str + source_not: bool + destination_not: bool + enabled: bool + type: Literal["binat", "nat"] + external: str + natreflection: Literal["", "enable", "disable"] + log: bool + created_at: datetime + updated_at: datetime + target_host_ids: list[uuid.UUID] + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "pending", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[DeploymentResult] + + +class OneToOneEdit(OneToOneCreate): + expected_updated_at: datetime diff --git a/backend/app/schemas/openvpn.py b/backend/app/schemas/openvpn.py new file mode 100644 index 0000000..55bc1cb --- /dev/null +++ b/backend/app/schemas/openvpn.py @@ -0,0 +1,107 @@ +import re +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +Role = Literal["server", "client"] +Proto = Literal["udp", "tcp"] +TlsKeyMode = Literal["auth", "crypt"] + +_PORT_RE = re.compile(r"^\d{1,5}$") + + +class OpenVpnInstanceBase(BaseModel): + description: str = Field(min_length=1, max_length=255) + role: Role = "server" + proto: Proto = "udp" + port: str = Field(default="1194", max_length=8) + # Server-role bind address (optional — blank means "any"). + local: str = Field(default="", max_length=255) + # Client-role remote peer ("host" or "host:port") — required if role=="client". + remote: str = Field(default="", max_length=255) + # Server-role tunnel network in CIDR notation — required if role=="server". + server_network: str = Field(default="", max_length=255) + # OPNsense-side certificate/CA UUIDs, already present on the target + # host(s) — per-host maps ({host_id_str: uuid}) since a UUID is + # host-local, see models/openvpn_instance.py's class docstring. + cert_refs: dict[str, str] = Field(default_factory=dict) + ca_refs: dict[str, str] = Field(default_factory=dict) + tls_key_enabled: bool = False + tls_key_mode: TlsKeyMode = "crypt" + enabled: bool = True + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + + @field_validator("description") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + @field_validator("cert_refs", "ca_refs") + @classmethod + def refs_nonblank(cls, value): + for host_id, ref in value.items(): + if not ref.strip(): + raise ValueError(f"Zertifikat-/CA-Auswahl für Host {host_id} darf nicht leer sein") + return {host_id: ref.strip() for host_id, ref in value.items()} + + @field_validator("port") + @classmethod + def valid_port(cls, value): + if not _PORT_RE.match(value) or not (1 <= int(value) <= 65535): + raise ValueError("Port muss eine Zahl zwischen 1 und 65535 sein") + return value + + @model_validator(mode="after") + def role_requires_matching_field(self): + if self.role == "server" and not self.server_network.strip(): + raise ValueError("Server-Rolle benötigt ein Tunnelnetz (server_network)") + if self.role == "client" and not self.remote.strip(): + raise ValueError("Client-Rolle benötigt ein Remote-Ziel (remote)") + return self + + +class OpenVpnInstanceCreate(OpenVpnInstanceBase): + # Optional even when tls_key_enabled — an operator may save a draft + # before having key material ready; preview()/workflow() surface the + # gap as a warning and block deploy until it's filled in (see + # openvpn_service.py), the same "allow an incomplete draft" philosophy + # already used elsewhere in this app. + tls_key_material: str | None = Field(default=None, min_length=1, max_length=8192) + + +class OpenVpnInstanceEdit(OpenVpnInstanceBase): + expected_updated_at: datetime + # Optional on edit: omitted (None) keeps the previously stored key + # material unchanged, mirroring IpsecTunnelEdit.pre_shared_key. + tls_key_material: str | None = Field(default=None, min_length=1, max_length=8192) + + +class OpenVpnDeploymentResult(BaseModel): + model_config = ConfigDict(from_attributes=True) + + host_id: uuid.UUID + host_name: str + status: Literal["success", "error"] + message: str | None = None + + +class OpenVpnInstanceRead(OpenVpnInstanceBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tls_key_material_set: bool + created_at: datetime + updated_at: datetime + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[OpenVpnDeploymentResult] + + +class OpenVpnDeployApproval(BaseModel): + preview_token: str = Field(min_length=64, max_length=64) diff --git a/backend/app/schemas/outbound_nat.py b/backend/app/schemas/outbound_nat.py new file mode 100644 index 0000000..d3f0af2 --- /dev/null +++ b/backend/app/schemas/outbound_nat.py @@ -0,0 +1,65 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.rule import DeployApproval, DeploymentResult # noqa: F401 (re-exported for routers/outbound_nat.py) + + +class OutboundNatCreate(BaseModel): + description: str = Field(min_length=1, max_length=255) + interface: str = Field(min_length=1, max_length=64) + source_net: str = Field(default="any", min_length=1, max_length=128) + destination_net: str = Field(default="any", min_length=1, max_length=128) + source_not: bool = False + destination_not: bool = False + enabled: bool = True + ipprotocol: Literal["inet", "inet6"] = "inet" + protocol: str = Field(default="any", min_length=1, max_length=16) + source_port: str | None = Field(default=None, max_length=32) + destination_port: str | None = Field(default=None, max_length=32) + target: str = Field(default="", max_length=128) + target_port: str | None = Field(default=None, max_length=32) + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + + @field_validator('description', 'interface', 'source_net', 'destination_net', 'protocol') + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class OutboundNatRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + description: str + interface: str + source_net: str + destination_net: str + source_not: bool + destination_not: bool + enabled: bool + ipprotocol: Literal["inet", "inet6"] + protocol: str + source_port: str | None + destination_port: str | None + target: str + target_port: str | None + created_at: datetime + updated_at: datetime + target_host_ids: list[uuid.UUID] + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "pending", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[DeploymentResult] + + +class OutboundNatEdit(OutboundNatCreate): + expected_updated_at: datetime diff --git a/backend/app/schemas/policy_hierarchy.py b/backend/app/schemas/policy_hierarchy.py new file mode 100644 index 0000000..01ab7f4 --- /dev/null +++ b/backend/app/schemas/policy_hierarchy.py @@ -0,0 +1,101 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.rule import DeploymentResult + + +class PolicyHierarchyGroupWrite(BaseModel): + name: str = Field(min_length=1, max_length=120) + description: str = Field(default="", max_length=2000) + parent_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def clean_name(cls, value): + if not value.strip(): + raise ValueError("Name darf nicht leer sein") + return value.strip() + + +class PolicyHierarchyGroupRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str + parent_id: uuid.UUID | None + created_at: datetime + host_ids: list[uuid.UUID] = Field(default_factory=list) + + +class PolicyHierarchyRuleCreate(BaseModel): + hierarchy_group_id: uuid.UUID + rulebase: Literal["pre", "post"] = "pre" + description: str = Field(min_length=1, max_length=255) + interface: str = Field(min_length=1, max_length=64) + direction: Literal["in", "out"] = "in" + ipprotocol: Literal["inet", "inet6"] = "inet" + protocol: str = Field(default="any", min_length=1, max_length=16) + action: Literal["pass", "block", "reject"] = "pass" + source_net: str = Field(default="any", min_length=1, max_length=128) + source_port: str | None = Field(default=None, max_length=32) + destination_net: str = Field(default="any", min_length=1, max_length=128) + destination_port: str | None = Field(default=None, max_length=32) + enabled: bool = True + + @field_validator("description", "interface", "protocol", "source_net", "destination_net") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class PolicyHierarchyRuleEdit(PolicyHierarchyRuleCreate): + expected_updated_at: datetime + + +class PolicyHierarchyRuleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + hierarchy_group_id: uuid.UUID + rulebase: Literal["pre", "post"] + sequence: int + description: str + interface: str + direction: str + ipprotocol: str + protocol: str + action: str + source_net: str + source_port: str | None + destination_net: str + destination_port: str | None + enabled: bool + created_at: datetime + updated_at: datetime + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "pending", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[DeploymentResult] + + +class PolicyHierarchyDeployApproval(BaseModel): + preview_token: str = Field(min_length=64, max_length=64) + + +class PolicyHierarchyMoveDirection(BaseModel): + direction: Literal["up", "down"] + + +class PolicyHierarchyReorderItem(BaseModel): + id: uuid.UUID + expected_updated_at: datetime + + +class PolicyHierarchyReorder(BaseModel): + items: list[PolicyHierarchyReorderItem] = Field(min_length=1) diff --git a/backend/app/schemas/port_forward.py b/backend/app/schemas/port_forward.py new file mode 100644 index 0000000..5400c3f --- /dev/null +++ b/backend/app/schemas/port_forward.py @@ -0,0 +1,63 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.rule import DeployApproval, DeploymentResult # noqa: F401 (re-exported for routers/port_forward.py) + + +class PortForwardCreate(BaseModel): + description: str = Field(min_length=1, max_length=255) + interface: str = Field(min_length=1, max_length=64) + ipprotocol: Literal["inet", "inet6"] = "inet" + protocol: str = Field(default="tcp", min_length=1, max_length=16) + source_net: str = Field(default="any", min_length=1, max_length=128) + source_port: str | None = Field(default=None, max_length=32) + destination_net: str = Field(default="any", min_length=1, max_length=128) + destination_port: str | None = Field(default=None, max_length=32) + target: str = Field(min_length=1, max_length=128) + local_port: str | None = Field(default=None, max_length=32) + natreflection: Literal["", "purenat", "disable"] = "" + enabled: bool = True + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + + @field_validator("description", "interface", "protocol", "source_net", "destination_net", "target") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class PortForwardRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + description: str + interface: str + ipprotocol: str + protocol: str + source_net: str + source_port: str | None + destination_net: str + destination_port: str | None + target: str + local_port: str | None + natreflection: str + enabled: bool + created_at: datetime + updated_at: datetime + target_host_ids: list[uuid.UUID] + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + policy_hierarchy_group_id: uuid.UUID | None = None + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "pending", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[DeploymentResult] + + +class PortForwardEdit(PortForwardCreate): + expected_updated_at: datetime diff --git a/backend/app/schemas/rule.py b/backend/app/schemas/rule.py new file mode 100644 index 0000000..acbc0aa --- /dev/null +++ b/backend/app/schemas/rule.py @@ -0,0 +1,74 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class RuleCreate(BaseModel): + description: str = Field(min_length=1, max_length=255) + interface: str = Field(min_length=1, max_length=64) + direction: Literal["in", "out"] = "in" + ipprotocol: Literal["inet", "inet6"] = "inet" + protocol: str = Field(default="any", min_length=1, max_length=16) + action: Literal["pass", "block", "reject"] = "pass" + source_net: str = Field(default="any", min_length=1, max_length=128) + source_port: str | None = Field(default=None, max_length=32) + destination_net: str = Field(default="any", min_length=1, max_length=128) + destination_port: str | None = Field(default=None, max_length=32) + enabled: bool = True + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + + + @field_validator("description", "interface", "protocol", "source_net", "destination_net") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + +class DeploymentResult(BaseModel): + model_config = ConfigDict(from_attributes=True) + + host_id: uuid.UUID + host_name: str + status: Literal["success", "error", "pending", "reverted", "revert_failed"] + message: str | None = None + # Only set while status == "pending" — the client counts down to this + # and shows the Confirm button until it passes. + revert_deadline: datetime | None = None + + +class RuleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + description: str + interface: str + direction: str + ipprotocol: str + protocol: str + action: str + source_net: str + source_port: str | None + destination_net: str + destination_port: str | None + enabled: bool + created_at: datetime + updated_at: datetime + target_host_ids: list[uuid.UUID] + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "pending", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[DeploymentResult] + + +class RuleEdit(RuleCreate): + expected_updated_at: datetime + + +class DeployApproval(BaseModel): + preview_token: str = Field(min_length=64, max_length=64) diff --git a/backend/app/schemas/search.py b/backend/app/schemas/search.py new file mode 100644 index 0000000..53004ef --- /dev/null +++ b/backend/app/schemas/search.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class SearchHit(BaseModel): + category: str + id: str + title: str + subtitle: str | None = None + href: str diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..eceb317 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class UserRole(str, Enum): + admin = "admin" + read_only = "read_only" + + +class UserCreate(BaseModel): + username: str = Field(min_length=1, max_length=150) + password: str = Field(min_length=10, max_length=1024) + # Defaults to the least-privileged role — an admin has to explicitly + # opt a new account into write access rather than it happening by + # omission. + role: UserRole = UserRole.read_only + + +class UserRoleUpdate(BaseModel): + role: UserRole + + +class UserPasswordReset(BaseModel): + new_password: str = Field(min_length=10, max_length=1024) + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + username: str + role: UserRole + created_at: datetime + last_login_at: datetime | None + totp_enabled: bool = False diff --git a/backend/app/schemas/wireguard.py b/backend/app/schemas/wireguard.py new file mode 100644 index 0000000..b43a683 --- /dev/null +++ b/backend/app/schemas/wireguard.py @@ -0,0 +1,118 @@ +import re +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_PORT_RE = re.compile(r"^\d{1,5}$") +# Same mask OPNsense's own Server.name/Client.name fields enforce +# (Wireguard\Server.xml / Wireguard\Client.xml, both +# `/^([0-9a-zA-Z._\-]){1,64}$/u`) — re-validated here so a bad name fails +# fast in this app's own form rather than only on push. +_NAME_RE = re.compile(r"^[0-9a-zA-Z._-]{1,64}$") + + +class WireguardTunnelBase(BaseModel): + server_name: str = Field(min_length=1, max_length=64) + server_public_key: str = Field(min_length=1, max_length=64) + listen_port: str = Field(default="51820", max_length=8) + server_tunnel_address: str = Field(min_length=1, max_length=255) + dns: str = Field(default="", max_length=255) + mtu: str = Field(default="", max_length=8) + + peer_name: str = Field(min_length=1, max_length=64) + peer_public_key: str = Field(min_length=1, max_length=64) + peer_psk_enabled: bool = False + peer_allowed_ips: str = Field(min_length=1, max_length=255) + # Optional Endpoint (host/port) this OPNsense box should dial to reach + # the peer — blank means "this peer only ever connects inward" (e.g. a + # road-warrior-style client with no fixed address). See + # wireguard_deploy_service.py's module docstring for the verification + # caveat on these two fields. + peer_endpoint_address: str = Field(default="", max_length=255) + peer_endpoint_port: str = Field(default="", max_length=8) + peer_keepalive: str = Field(default="", max_length=8) + + enabled: bool = True + target_host_ids: list[uuid.UUID] = Field(default_factory=list) + target_group_ids: list[uuid.UUID] = Field(default_factory=list) + + @field_validator("server_name", "peer_name") + @classmethod + def valid_name(cls, value): + if not _NAME_RE.match(value): + raise ValueError("Nur alphanumerische Zeichen, Punkt, Bindestrich und Unterstrich, 1-64 Zeichen") + return value + + @field_validator("server_public_key", "peer_public_key", "server_tunnel_address", "peer_allowed_ips") + @classmethod + def nonblank(cls, value): + if not value.strip(): + raise ValueError("Feld darf nicht nur Leerzeichen enthalten") + return value.strip() + + @field_validator("listen_port", "peer_endpoint_port") + @classmethod + def valid_port(cls, value): + if value == "": + return value + if not _PORT_RE.match(value) or not (1 <= int(value) <= 65535): + raise ValueError("Port muss leer sein oder eine Zahl zwischen 1 und 65535") + return value + + @model_validator(mode="after") + def endpoint_address_requires_port(self): + if self.peer_endpoint_address.strip() and not self.peer_endpoint_port.strip(): + raise ValueError("Endpoint-Port wird benötigt, wenn eine Endpoint-Adresse gesetzt ist") + return self + + +class WireguardTunnelCreate(WireguardTunnelBase): + # Required on create — a WireGuard interface with no private key is + # non-functional, even though OPNsense's own Server model would still + # accept an add() call with a blank privkey (it would just fail at the + # protocol level, not the config-API level). + server_private_key: str = Field(min_length=1, max_length=8192) + # Optional even when peer_psk_enabled — an operator may save a draft + # before having key material ready; preview()/workflow() surface the + # gap as a warning and block deploy until it's filled in (see + # wireguard_service.py), same "allow an incomplete draft" philosophy + # already used for IPsec/OpenVPN. + peer_psk_material: str | None = Field(default=None, min_length=1, max_length=8192) + + +class WireguardTunnelEdit(WireguardTunnelBase): + expected_updated_at: datetime + # Optional on edit: omitted (None) keeps the previously stored secret + # unchanged, mirroring IpsecTunnelEdit.pre_shared_key/ + # OpenVpnInstanceEdit.tls_key_material. + server_private_key: str | None = Field(default=None, min_length=1, max_length=8192) + peer_psk_material: str | None = Field(default=None, min_length=1, max_length=8192) + + +class WireguardDeploymentResult(BaseModel): + model_config = ConfigDict(from_attributes=True) + + host_id: uuid.UUID + host_name: str + status: Literal["success", "error"] + message: str | None = None + + +class WireguardTunnelRead(WireguardTunnelBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + server_private_key_set: bool + peer_psk_set: bool + created_at: datetime + updated_at: datetime + effective_host_ids: list[uuid.UUID] = Field(default_factory=list) + workflow_state: Literal["attention", "cleanup", "unassigned", "disabled", "draft", "synced"] + workflow_counts: dict[str, int] + deployments: list[WireguardDeploymentResult] + + +class WireguardDeployApproval(BaseModel): + preview_token: str = Field(min_length=64, max_length=64) diff --git a/backend/app/security_middleware.py b/backend/app/security_middleware.py new file mode 100644 index 0000000..661618c --- /dev/null +++ b/backend/app/security_middleware.py @@ -0,0 +1,59 @@ +"""Defence-in-depth HTTP hardening for the API. + +OriginCheckMiddleware: cookie sessions are SameSite=Lax, which already blocks +most cross-site writes; this additionally rejects any state-changing request +whose Origin/Referer is neither the host it was sent to (same-origin, which is +how the proxy setup works for IP *and* name access) nor an explicitly allowed +origin. Requests without either header (curl, scripts) are not browsers, so +they cannot be riding on a victim's cookie and pass through to normal auth. + +SecurityHeadersMiddleware: API responses must never be cached by browsers or +proxies and must not be sniffed or framed. +""" +from urllib.parse import urlparse + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from app.config import settings +from app.errors import ErrorCode + +_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + +def _origin_allowed(origin: str, host_header: str) -> bool: + parsed = urlparse(origin) + if not parsed.scheme or not parsed.netloc: + return False + if parsed.netloc.lower() == host_header.lower(): + return True + return f"{parsed.scheme}://{parsed.netloc}".lower() in {o.lower() for o in settings.allowed_origins} + + +class OriginCheckMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if request.method not in _SAFE_METHODS: + source = request.headers.get("origin") or request.headers.get("referer") + if source and source != "null" and not _origin_allowed(source, request.headers.get("host", "")): + return JSONResponse( + status_code=403, + content={"detail": {"code": ErrorCode.CROSS_ORIGIN_BLOCKED.value, "params": {}}}, + ) + if source == "null": + return JSONResponse( + status_code=403, + content={"detail": {"code": ErrorCode.CROSS_ORIGIN_BLOCKED.value, "params": {}}}, + ) + return await call_next(request) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + if request.url.path.startswith("/api"): + response.headers["Cache-Control"] = "no-store" + return response diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/acme_secret_store.py b/backend/app/services/acme_secret_store.py new file mode 100644 index 0000000..7673cb3 --- /dev/null +++ b/backend/app/services/acme_secret_store.py @@ -0,0 +1,36 @@ +"""Per-account External-Account-Binding HMAC storage for AcmeAccount. + +Mirrors ipsec_psk_store.py/cert_key_store.py's convention exactly: `eab_hmac` +never touches the database, only disk, one file per account id, mode 0600. +AcmeAccount.eab_hmac_set is the DB-side flag tracking whether a file +exists. Same "never appears in last_payload, not even the pre-existing +IPsec/OpenVPN/WireGuard wrinkle" rule as cert_key_store.py — see +AcmeAccount's own docstring.""" +import uuid +from pathlib import Path + +from app.config import settings + +_SUFFIX = ".eabhmac" + + +def _path(account_id: uuid.UUID) -> Path: + return Path(settings.acme_secret_dir) / f"{account_id}{_SUFFIX}" + + +def save_secret(account_id: uuid.UUID, secret: str) -> None: + path = _path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret.strip()) + path.chmod(0o600) + + +def read_secret(account_id: uuid.UUID) -> str | None: + path = _path(account_id) + if not path.exists(): + return None + return path.read_text().strip() + + +def delete_secret(account_id: uuid.UUID) -> None: + _path(account_id).unlink(missing_ok=True) diff --git a/backend/app/services/acme_service.py b/backend/app/services/acme_service.py new file mode 100644 index 0000000..ac8f2a7 --- /dev/null +++ b/backend/app/services/acme_service.py @@ -0,0 +1,286 @@ +"""Pushes centrally-authored ACME accounts/certificate-requests +(`AcmeAccount`, `AcmeCertificateRequest` — os-acme-client plugin, v1 +scope) to their single target host, plus the two REAL external-network +action triggers (`register`/`sign`) — see the two models' own docstrings +for the full field-mapping writeup and why this entity is deliberately +per-host, not multi-host like IPsec/OpenVPN/WireGuard. + +VERIFIED FIELDS / ENDPOINTS (fetched directly from raw.githubusercontent. +com/opnsense/plugins/master/security/acme-client/..., 2026-09-09 — see +opnsense_client.py's "ACME" section for the endpoint list and citations): + +- AcmeClient.xml -> accounts.account: `ca` OptionValues letsencrypt/ + letsencrypt_test/zerossl/buypass/buypass_test/google/google_test/ + sslcom/custom. `eab_hmac` never touches Postgres (see + acme_secret_store.py) — sent as a wire-only value at push time, exactly + like ManagedCertificate's private key. +- AcmeClient.xml -> certificates.certificate: `altNames` is a + comma-separated CSVListField (this app's `alt_names`, sent verbatim, + no sub-array). `account` and `validationMethod` are BOTH Required + ModelRelationFields. `keyLength` OptionValues are PREFIXED + ("key_4096" etc, not "4096") — see AcmeCertificateRequest's docstring + for why this matters. `renewInterval` is DAYS SINCE LAST ISSUANCE, not + days-before-expiry. +- AccountsController.php / CertificatesController.php: addAction/ + updateAction (NOT setAction — explicitly confirmed different from + Trust-Store's setAction and every VPN feature's setX naming, per the + plan's own warning)/delAction/getAction, all confirmed literal PHP + method names. registerAction($uuid) / signAction($uuid) both return + `{"result": "failed"}` immediately if the uuid doesn't resolve, else + `{"response": ""}` — parsed by + opnsense_client._acme_action_result(), NOT `_mutation_result` (see that + module's own citations). registerAction is a REAL, blocking network + call to the external CA; signAction is the same shape but its + underlying configd action is documented to fork and return + immediately — the trigger response is proof-of-acceptance only, never + proof of actual issuance (see poll_certificate_status() below). + +UUID-INJECTION FOR AcmeCertificateRequest.account — mirrors +ipsec_deploy_service.py's `connection`/`local_wire`/`remote_wire`/ +`child_wire` pattern EXACTLY, including the SAME no-op-redeploy pitfall +it documents: `build_payload()` never includes the wire-level `account` +uuid (that's deploy-time wiring, filled in fresh from the CALLER-supplied +AcmeAccount at push time, in a shallow copy, onto `last_payload` never). +Instead it includes a leading-underscore `_account_id` bookkeeping key +(THIS app's own FK, not the OPNsense uuid) purely so switching which +account a request is linked to is still detected as a real change to +push — without it, changing account_id alone (with every other field +unchanged) would look like a no-op and silently never re-push the +updated `account` wire value. + +DELIBERATELY SIMPLER STATUS MODEL, same reasoning as +managed_certificate_service.py: "draft"/"success"/"error" for the local +add/set/del config write, no restart step to separately fail on. The +`register_status`/`sign_status` fields are a SEPARATE, orthogonal bit of +state — the outcome of the external-CA-reaching trigger actions, which +the UI gates behind an explicit two-step warning (see +AcmeActionConfirm.tsx / README) for a different reason than the VPN +features' no-rollback warning: external-CA-contact / rate-limit risk, +not connectivity loss.""" +import hashlib +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.acme_account import AcmeAccount +from app.models.acme_certificate_request import AcmeCertificateRequest +from app.models.host import OpnsenseHost +from app.services import acme_secret_store, opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _to_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _to_epoch_datetime(value: Any) -> datetime | None: + ts = _to_int(value) + if not ts: + return None + return datetime.fromtimestamp(ts, tz=timezone.utc) + + +# ── Accounts ────────────────────────────────────────────────────────── +def build_account_payload(account: AcmeAccount) -> dict: + """SECURITY FIX (2026-09-09): originally used the plain `eab_hmac_set` + boolean as a change-detection stand-in for no-op-push comparison — + insufficient for the same reason ManagedCertificate's private-key flag + was (see managed_certificate_service.py's build_payload() docstring): + the flag stays `True` across a rotation, so a new EAB HMAC value would + silently never get pushed. Hash the current secret's content instead — + changes on rotation, never exposes the recoverable value in Postgres.""" + secret = acme_secret_store.read_secret(account.id) or "" if account.eab_hmac_set else "" + return { + "enabled": "1" if account.enabled else "0", + "name": account.name, + "description": account.description, + "email": account.email, + "ca": account.ca, + "custom_ca": account.custom_ca, + "eab_kid": account.eab_kid, + "eab_hmac_hash": hashlib.sha256(secret.encode()).hexdigest() if secret else "", + } + + +async def push_account(db: AsyncSession, account: AcmeAccount, host: OpnsenseHost) -> AcmeAccount: + payload = build_account_payload(account) + if account.status == "success" and account.last_payload == payload: + account.message = None + account.deployed_at = _now() + await db.commit() + return account + + wire = {k: v for k, v in payload.items() if k != "eab_hmac_hash"} + wire["eab_hmac"] = acme_secret_store.read_secret(account.id) or "" + + try: + if account.opnsense_uuid: + result = await opnsense_client.update_acme_account(host, account.opnsense_uuid, wire) + else: + result = await opnsense_client.add_acme_account(host, wire) + account.opnsense_uuid = result.get("uuid") if isinstance(result, dict) else None + if not account.opnsense_uuid: + raise OpnsenseAPIError("Firewall hat keine Account-UUID zurückgegeben; Stand manuell prüfen") + except OpnsenseAPIError as exc: + account.status = "error" + account.message = str(exc) + account.deployed_at = _now() + await db.commit() + return account + + account.status = "success" + account.message = None + account.last_payload = payload + account.deployed_at = _now() + await db.commit() + return account + + +async def delete_account(db: AsyncSession, account: AcmeAccount, host: OpnsenseHost | None) -> None: + if host is not None and account.opnsense_uuid: + await opnsense_client.del_acme_account(host, account.opnsense_uuid) + acme_secret_store.delete_secret(account.id) + await db.delete(account) + await db.commit() + + +async def register_account(db: AsyncSession, account: AcmeAccount, host: OpnsenseHost) -> AcmeAccount: + """Triggers the REAL, blocking `register-account` call against the + configured external CA — see opnsense_client.register_acme_account(). + Never call this against anything but a respx mock / fully-mocked + write path in tests/verification (see module docstring).""" + if not account.opnsense_uuid: + raise OpnsenseAPIError("Account wurde noch nicht auf die Firewall geschrieben") + try: + result = await opnsense_client.register_acme_account(host, account.opnsense_uuid) + except OpnsenseAPIError as exc: + account.register_status = "error" + account.register_message = str(exc) + account.register_at = _now() + await db.commit() + raise + account.register_status = "ok" + account.register_message = str(result.get("response", ""))[:2000] + account.register_at = _now() + await db.commit() + return account + + +# ── Certificate requests ────────────────────────────────────────────── +def build_certificate_payload(req: AcmeCertificateRequest) -> dict: + """Deliberately excludes the wire-level `account` uuid — see module + docstring's UUID-injection section. `_account_id` is OPNexus-only + bookkeeping, stripped before the wire send in + push_certificate_request() below.""" + return { + "enabled": "1" if req.enabled else "0", + "name": req.name, + "altNames": req.alt_names, + "validationMethod": req.validation_ref, + "keyLength": req.key_length, + "ocsp": "1" if req.ocsp else "0", + "autoRenewal": "1" if req.auto_renewal else "0", + "renewInterval": str(req.renew_interval), + "_account_id": str(req.account_id), + } + + +async def push_certificate_request( + db: AsyncSession, req: AcmeCertificateRequest, host: OpnsenseHost, account: AcmeAccount +) -> AcmeCertificateRequest: + if not account.opnsense_uuid: + req.status = "error" + req.message = "Verknüpfter ACME-Account wurde noch nicht auf die Firewall geschrieben" + req.deployed_at = _now() + await db.commit() + return req + + payload = build_certificate_payload(req) + if req.status == "success" and req.last_payload == payload: + req.message = None + req.deployed_at = _now() + await db.commit() + return req + + wire = {k: v for k, v in payload.items() if not k.startswith("_")} + wire["account"] = account.opnsense_uuid + + try: + if req.opnsense_uuid: + result = await opnsense_client.update_acme_certificate(host, req.opnsense_uuid, wire) + else: + result = await opnsense_client.add_acme_certificate(host, wire) + req.opnsense_uuid = result.get("uuid") if isinstance(result, dict) else None + if not req.opnsense_uuid: + raise OpnsenseAPIError("Firewall hat keine Certificate-UUID zurückgegeben; Stand manuell prüfen") + except OpnsenseAPIError as exc: + req.status = "error" + req.message = str(exc) + req.deployed_at = _now() + await db.commit() + return req + + req.status = "success" + req.message = None + req.last_payload = payload + req.deployed_at = _now() + await db.commit() + return req + + +async def delete_certificate_request(db: AsyncSession, req: AcmeCertificateRequest, host: OpnsenseHost | None) -> None: + if host is not None and req.opnsense_uuid: + await opnsense_client.del_acme_certificate(host, req.opnsense_uuid) + await db.delete(req) + await db.commit() + + +async def sign_certificate_request(db: AsyncSession, req: AcmeCertificateRequest, host: OpnsenseHost) -> AcmeCertificateRequest: + """Triggers the REAL, ASYNCHRONOUS `sign-cert` call against the + configured external CA. `sign_status="triggered"` is the ONLY thing + this sets on success — it is deliberately NOT "success", because the + trigger returning does not mean issuance succeeded (see module + docstring). Never call this against anything but a respx mock / + fully-mocked write path in tests/verification.""" + if not req.opnsense_uuid: + raise OpnsenseAPIError("Zertifikatsanfrage wurde noch nicht auf die Firewall geschrieben") + try: + result = await opnsense_client.sign_acme_certificate(host, req.opnsense_uuid) + except OpnsenseAPIError as exc: + req.sign_status = "error" + req.sign_message = str(exc) + req.sign_triggered_at = _now() + await db.commit() + raise + req.sign_status = "triggered" + req.sign_message = str(result.get("response", ""))[:2000] + req.sign_triggered_at = _now() + await db.commit() + return req + + +async def poll_certificate_status(db: AsyncSession, req: AcmeCertificateRequest, host: OpnsenseHost) -> AcmeCertificateRequest: + """`GET /acmeclient/certificates/get/{uuid}` — refreshes + remote_status_code/remote_status_last_update/remote_last_update from + OPNsense's own model fields (statusCode/statusLastUpdate/lastUpdate). + The only way to learn the real outcome of an async sign() trigger — + call this after sign_certificate_request(), and again later/on + refresh, per the plan (OPNexus never claims success from the trigger + call alone).""" + if not req.opnsense_uuid: + raise OpnsenseAPIError("Zertifikatsanfrage wurde noch nicht auf die Firewall geschrieben") + data = await opnsense_client.get_acme_certificate(host, req.opnsense_uuid) + node = data.get("certificate", {}) if isinstance(data, dict) else {} + req.remote_status_code = _to_int(node.get("statusCode")) + req.remote_status_last_update = _to_epoch_datetime(node.get("statusLastUpdate")) + req.remote_last_update = _to_epoch_datetime(node.get("lastUpdate")) + await db.commit() + return req diff --git a/backend/app/services/alias_deploy_service.py b/backend/app/services/alias_deploy_service.py new file mode 100644 index 0000000..05f3882 --- /dev/null +++ b/backend/app/services/alias_deploy_service.py @@ -0,0 +1,195 @@ +"""Pushes a centrally-authored alias to its target hosts via the OPNsense +API — the alias equivalent of rule_deploy_service.py. Same deploy lockout +protection: a successful push goes to "pending" with a revert_deadline, +not straight to "success", since editing an alias's content changes +enforcement for every rule that references it on that host. See +rule_deploy_service.py for the full rationale; this mirrors it exactly, +kept as a separate module rather than a shared generic implementation so a +bug in alias handling can't affect the already-relied-upon rule deploy +path. +""" +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.host import OpnsenseHost +from app.models.alias import FirewallAlias +from app.models.alias_deployment import AliasDeployment +from app.services.audit_service import tracked_rollback +from app.services import opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _build_payload(alias: FirewallAlias) -> dict: + return { + "enabled": "1" if alias.enabled else "0", + "name": alias.name, + "type": alias.type, + "content": alias.content, + "description": alias.description, + } + + +async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment: + payload = _build_payload(alias) + existing = await db.scalar( + select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id) + ) + + if existing and existing.status in ("success", "reverted") and existing.last_payload == payload: + existing.status = "success" + existing.message = None + existing.revert_deadline = None + existing.deployed_at = _now() + return existing + + if existing and existing.status in ("pending", "revert_failed"): + return existing + + prior_uuid = existing.opnsense_alias_uuid if existing else None + prior_live_payload = existing.last_payload if existing else None + new_uuid = prior_uuid + try: + if prior_uuid: + await opnsense_client.set_alias_item(host, prior_uuid, payload) + else: + result = await opnsense_client.add_alias_item(host, payload) + new_uuid = result.get("uuid") if isinstance(result, dict) else None + if not new_uuid: + raise OpnsenseAPIError("Firewall hat keine Alias-UUID zurückgegeben; Stand manuell prüfen") + await opnsense_client.reconfigure_aliases(host) + except OpnsenseAPIError as exc: + if existing: + existing.status = "error" + existing.opnsense_alias_uuid = new_uuid + existing.message = str(exc) + existing.revert_deadline = None + existing.deployed_at = _now() + return existing + deployment = AliasDeployment( + alias_id=alias.id, host_id=host.id, status="error", message=str(exc), + opnsense_alias_uuid=new_uuid, deployed_at=_now(), + ) + db.add(deployment) + return deployment + + deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds) + if existing: + existing.status = "pending" + existing.message = None + existing.opnsense_alias_uuid = new_uuid + existing.deployed_at = _now() + existing.revert_deadline = deadline + existing.previous_payload = prior_live_payload + existing.last_payload = payload + return existing + + deployment = AliasDeployment( + alias_id=alias.id, host_id=host.id, status="pending", message=None, + opnsense_alias_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline, + previous_payload=None, last_payload=payload, + ) + db.add(deployment) + return deployment + + +async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[OpnsenseHost]) -> list[AliasDeployment]: + from app.services.host_maintenance_service import ensure_no_maintenance + from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change + await ensure_no_maintenance(db, hosts) + deployments = [await _deploy_to_host(db, alias, host) for host in hosts] + await db.flush() + for host, deployment in zip(hosts, deployments): + if deployment.status != "pending": + continue + try: + await reassert_after_managed_change(db, host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = f"Alias wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}" + deployment.revert_deadline = None + await db.commit() + return deployments + + +async def confirm_alias(db: AsyncSession, alias: FirewallAlias) -> list[AliasDeployment]: + result = await db.execute( + select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.status == "pending", AliasDeployment.revert_deadline > _now()) + ) + deployments = list(result.scalars().all()) + for deployment in deployments: + deployment.status = "success" + deployment.revert_deadline = None + await db.commit() + return deployments + + +async def sweep_expired_alias_deployments(db: AsyncSession) -> list[AliasDeployment]: + from app.services.policy_service import policy_lock + await policy_lock(db) + result = await db.execute( + select(AliasDeployment).where(AliasDeployment.status == "pending", AliasDeployment.revert_deadline <= _now()) + ) + expired = list(result.scalars().all()) + touched: list[AliasDeployment] = [] + + for deployment in expired: + await revert_alias_deployment(db, deployment) + touched.append(deployment) + + await db.commit() + return touched + + +async def undeploy_alias(db: AsyncSession, alias: FirewallAlias) -> None: + failures = [] + result = await db.execute(select(AliasDeployment).where(AliasDeployment.alias_id == alias.id)) + for deployment in result.scalars().all(): + host = await db.get(OpnsenseHost, deployment.host_id) + if host and deployment.opnsense_alias_uuid: + try: + await opnsense_client.del_alias_item(host, deployment.opnsense_alias_uuid) + await opnsense_client.reconfigure_aliases(host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + failures.append(host.name) + continue + await db.delete(deployment) + await db.flush() + if failures: + await db.commit() + raise OpnsenseAPIError("Alias konnte nicht von allen Hosts entfernt werden: " + ", ".join(failures)) + + +@tracked_rollback("alias") +async def revert_alias_deployment(db: AsyncSession, deployment: AliasDeployment) -> bool: + host = await db.get(OpnsenseHost, deployment.host_id) + if host is None: + await db.delete(deployment) + return True + try: + if deployment.previous_payload is None: + if deployment.opnsense_alias_uuid: + await opnsense_client.del_alias_item(host, deployment.opnsense_alias_uuid) + await opnsense_client.reconfigure_aliases(host) + await db.delete(deployment) + else: + await opnsense_client.set_alias_item(host, deployment.opnsense_alias_uuid, deployment.previous_payload) + await opnsense_client.reconfigure_aliases(host) + deployment.last_payload = deployment.previous_payload + deployment.status = "reverted" + deployment.message = None + deployment.revert_deadline = None + return True + except OpnsenseAPIError as exc: + deployment.status = "revert_failed" + deployment.message = str(exc) + deployment.revert_deadline = None + return False diff --git a/backend/app/services/alias_policy_service.py b/backend/app/services/alias_policy_service.py new file mode 100644 index 0000000..c7dd098 --- /dev/null +++ b/backend/app/services/alias_policy_service.py @@ -0,0 +1,106 @@ +"""Draft/preview/push state machine for aliases — the alias equivalent of +policy_service.py's snapshot/revision/preview/workflow. effective_hosts, +ensure_editable and policy_lock are generic over any {targets, groups, +deployments} entity, so those are reused directly from policy_service +rather than duplicated. +""" +import hashlib +import json + +from app.models.policy import AliasRevision +from app.services.alias_deploy_service import _build_payload +from app.services.policy_service import effective_hosts + +ALIAS_FIELDS = ("name", "type", "content", "description", "enabled") + + +def snapshot(alias): + return { + **{key: getattr(alias, key) for key in ALIAS_FIELDS}, + "target_host_ids": sorted(str(h.id) for h in alias.targets), + "target_group_ids": sorted(str(g.id) for g in alias.groups), + "policy_hierarchy_group_id": str(alias.policy_hierarchy_group_id) if alias.policy_hierarchy_group_id else None, + } + + +def revision(db, alias, note): + db.add(AliasRevision(alias_id=alias.id, note=note, snapshot=snapshot(alias))) + + +def preview(alias): + payload = _build_payload(alias) + deployments = {d.host_id: d for d in alias.deployments} + hosts = effective_hosts(alias) + blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments) + rows = [] + for host in hosts: + dep = deployments.get(host.id) + before = dep.last_payload if dep else None + action = "create" if not dep or not dep.opnsense_alias_uuid else "update" + if before == payload and dep and dep.status in ("success", "reverted"): + action = "unchanged" + if not host.enabled: + action = "disabled" + rows.append({"host_id": str(host.id), "host_name": host.name, "action": action, + "status": dep.status if dep else None, + "message": dep.message if dep else None, + "can_remove": False, + "can_retry_rollback": bool(dep and dep.status == "revert_failed" and host.enabled), + "changes": [{"field": k, "before": (before or {}).get(k), "after": v} + for k, v in payload.items() if (before or {}).get(k) != v]}) + target_ids = {h.id for h in hosts} + for dep in alias.deployments: + if dep.host_id not in target_ids: + rows.append({"host_id": str(dep.host_id), "host_name": dep.host.name, + "action": "retained", "status": dep.status, "message": dep.message, + "can_remove": bool(not blocked and dep.host.enabled and dep.opnsense_alias_uuid), + "can_retry_rollback": bool(dep.status == "revert_failed" and dep.host.enabled), + "changes": []}) + warnings = [] + if blocked: + warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.") + if any(r["action"] == "retained" for r in rows): + warnings.append("Frühere Ziele behalten diesen Alias, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.") + if any(r["action"] == "disabled" for r in rows): + warnings.append("Deaktivierte Hosts werden übersprungen.") + if any(d.status == "error" for d in alias.deployments): + warnings.append("Vorheriger Push fehlgeschlagen. Vor erneutem Push den Firewall-Stand prüfen.") + state = {"snapshot": snapshot(alias), "rows": rows, + "hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts], + "deployments": sorted([(str(d.host_id), d.opnsense_alias_uuid, d.last_payload, d.previous_payload, + d.host.base_url, d.host.enabled, d.host.verify_tls) for d in alias.deployments], key=lambda row: row[0])} + token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() + return {"token": token, "hosts": rows, "warnings": warnings, + "can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows), + "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."} + + +def workflow(alias, deployments): + hosts = effective_hosts(alias) + active = [h for h in hosts if h.enabled] + by_host = {d.host_id: d for d in deployments} + payload = _build_payload(alias) + target_ids = {h.id for h in hosts} + counts = { + "targets": len(hosts), + "active_targets": len(active), + "changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload), + "pending": sum(d.status == "pending" for d in deployments), + "errors": sum(d.status in ("error", "revert_failed") for d in deployments), + "retained": sum(d.host_id not in target_ids for d in deployments), + } + if counts["errors"]: + state = "attention" + elif counts["pending"]: + state = "pending" + elif counts["retained"]: + state = "cleanup" + elif not hosts: + state = "unassigned" + elif not active: + state = "disabled" + elif counts["changes"]: + state = "draft" + else: + state = "synced" + return state, counts diff --git a/backend/app/services/alias_service.py b/backend/app/services/alias_service.py new file mode 100644 index 0000000..d02656a --- /dev/null +++ b/backend/app/services/alias_service.py @@ -0,0 +1,97 @@ +"""Firewall aliases for populating the Source/Destination autocomplete — +sourced live via `firewall/alias/searchItem`, the same list OPNsense's own +rule-edit UI suggests from. Covers user-defined aliases (hosts, networks, +ports, ...) as well as OPNsense's auto-generated `___network` aliases +("this interface's subnet"). +""" +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.errors import ErrorCode, api_error +from app.models.alias import FirewallAlias +from app.models.alias_deployment import AliasDeployment +from app.models.host import OpnsenseHost +from app.schemas.host import AliasOption +from app.services import opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + + +async def _rows(host: OpnsenseHost) -> list[dict]: + data = await opnsense_client.search_aliases(host) + return data.get("rows", []) if isinstance(data, dict) else [] + + +async def get_aliases(host: OpnsenseHost) -> list[AliasOption]: + return [ + AliasOption( + name=row["name"], + type=row.get("type", ""), + description=row.get("description") or "", + content=row.get("content") or "", + enabled=row.get("enabled") in ("1", 1, True), + ) + for row in await _rows(host) + if row.get("name") + ] + + +async def check_alias_consistency(hosts: list[OpnsenseHost], value: str) -> dict[str, str]: + """For a rule pushed to more than one host: if `value` names an alias + that exists on more than one target, returns {host_name: content} so + the caller can tell whether it's the *same* alias content everywhere + (the aliases in this app's own fleet are config-synced, so in practice + this is nearly always consistent — but nothing enforces that, and nothing + else would catch it silently drifting). A `value` that isn't a known + alias name anywhere (a literal "any"/CIDR/IP) simply returns {}, since + there's nothing host-specific to compare — see check_interface_consistency + for the same pattern applied to interface identifiers. + """ + contents: dict[str, str] = {} + for host in hosts: + try: + rows = await _rows(host) + except OpnsenseAPIError: + continue + match = next((row for row in rows if row.get("name") == value), None) + if match is not None: + contents[host.name] = match.get("content") or "" + return contents + + +def describe_alias_contents(contents: dict[str, str]) -> str: + """Human-readable form of check_alias_consistency's result for error + messages: OPNsense stores alias content newline-separated, which a plain + repr() would render as literal "\\n" in the UI.""" + return "; ".join( + f"{name}: {', '.join(line.strip() for line in content.splitlines() if line.strip()) or '—'}" + for name, content in sorted(contents.items()) + ) + + +async def ensure_aliases_settled(db: AsyncSession, hosts: list[OpnsenseHost], values: list[str | None]) -> None: + """Refuses to deploy something that references an OPNexus-managed alias + whose own deployment on one of `hosts` is still unconfirmed (pending) or + has a failed rollback. A pending alias is auto-reverted when its confirm + window runs out; if a rule already uses it by then, OPNsense refuses to + delete it ("Alias in use"), the alias is stuck in revert_failed and the + rule's host can no longer be pushed to. Aliases that are not managed by + OPNexus (hand-made, `__lan_network`, literal IPs) have no deployment row + and are not affected.""" + names = {value for value in values if value} + if not names or not hosts: + return + rows = (await db.execute( + select(FirewallAlias.name, OpnsenseHost.name) + .join(AliasDeployment, AliasDeployment.alias_id == FirewallAlias.id) + .join(OpnsenseHost, OpnsenseHost.id == AliasDeployment.host_id) + .where( + FirewallAlias.name.in_(names), + AliasDeployment.host_id.in_([host.id for host in hosts]), + AliasDeployment.status.in_(("pending", "revert_failed")), + ) + .order_by(FirewallAlias.name, OpnsenseHost.name) + )).all() + if rows: + aliases = ", ".join(sorted({alias for alias, _ in rows})) + host_names = ", ".join(sorted({host for _, host in rows})) + raise api_error(409, ErrorCode.ALIAS_NOCH_NICHT_BESTAETIGT, alias=aliases, hosts=host_names) diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..5cb7488 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,112 @@ +"""HTTP attempts plus transactionally recorded deployment state transitions. + +Requests are committed before executing an operation, so an interrupted +request remains visible. Deployment events commit with the deployment state. +No credentials, cookies or arbitrary request bodies are recorded. +""" +import copy +import uuid +from contextvars import ContextVar +from sqlalchemy import event, inspect +from sqlalchemy.orm import Session +from app.database import SessionLocal +from app.models.audit import AuditEvent + +context = ContextVar("audit_context", default=None) + + +def make_event(**values): + ctx = context.get() or {} + return AuditEvent(actor=ctx.get("actor", "system:rollback"), + correlation_id=ctx.get("id", str(uuid.uuid4())), **values) + + +async def persist(**values): + async with SessionLocal() as db: + db.add(make_event(**values)) + await db.commit() + + +@event.listens_for(Session, "before_flush") +def deployment_history(session, flush_context, instances): + from app.models import (FirewallRule, FirewallAlias, RuleDeployment, AliasDeployment, + PortForwardRule, PortForwardDeployment, OneToOneRule, OneToOneDeployment, + OutboundNatRule, OutboundNatDeployment, OpnsenseHost, + PolicyHierarchyRule, PolicyHierarchyRuleDeployment) + mapping = { + RuleDeployment: ("rule", FirewallRule, "rule_id", "opnsense_rule_uuid"), + AliasDeployment: ("alias", FirewallAlias, "alias_id", "opnsense_alias_uuid"), + PortForwardDeployment: ("port_forward", PortForwardRule, "port_forward_id", "opnsense_port_forward_uuid"), + OneToOneDeployment: ("one_to_one", OneToOneRule, "one_to_one_id", "opnsense_one_to_one_uuid"), + OutboundNatDeployment: ("outbound_nat", OutboundNatRule, "outbound_nat_id", "opnsense_outbound_nat_uuid"), + PolicyHierarchyRuleDeployment: ("policy_hierarchy", PolicyHierarchyRule, "policy_hierarchy_id", "opnsense_policy_hierarchy_uuid"), + } + for dep in list(session.new) + list(session.dirty) + list(session.deleted): + if type(dep) not in mapping: + continue + is_new, deleted = dep in session.new, dep in session.deleted + if not is_new and not deleted and not session.is_modified(dep, include_collections=False): + continue + kind, model, id_attr, uuid_attr = mapping[type(dep)] + entity_id = getattr(dep, id_attr) + entity = session.get(model, entity_id) + host = session.get(OpnsenseHost, dep.host_id) + state = inspect(dep) + old_status = state.attrs.status.history.deleted + old_payload = state.attrs.last_payload.history.deleted + ctx = context.get() or {} + action = ctx.get("action", "rollback") + if action == "confirm" and dep.status != "success": + action = "state_change" + details = { + "remote_uuid": getattr(dep, uuid_attr), + "before_status": old_status[0] if old_status else (None if is_new else dep.status), + "before_payload": copy.deepcopy(old_payload[0] if old_payload else (None if is_new else dep.last_payload)), + "after_payload": None if deleted else copy.deepcopy(dep.last_payload), + "rollback_payload": copy.deepcopy(dep.previous_payload), + "revert_deadline": dep.revert_deadline.isoformat() if dep.revert_deadline else None, + } + # A failed push may never have populated last_payload. Preserve the + # intended payload separately from the last known applied state. + if entity is not None and action == "deploy": + from importlib import import_module + builder = import_module(f"app.services.{kind}_deploy_service")._build_payload + details["requested_payload"] = builder(entity) + session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status, + entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)), + host_id=str(dep.host_id), host_name=host.name if host else None, + message=dep.message, details=details)) + + +def tracked_rollback(kind): + """Persist worker attempts before contacting a firewall, including crashes. + Flush the resulting state while its correlation context is still active. + """ + from functools import wraps + def decorate(function): + @wraps(function) + async def wrapped(db, deployment): + from app.models import OpnsenseHost + token = None + if context.get() is None: + token = context.set({"actor": "system:rollback", "id": str(uuid.uuid4()), "action": "rollback"}) + host = await db.get(OpnsenseHost, deployment.host_id) + values = {"kind": kind, "action": (context.get() or {}).get("action", "rollback"), + "entity_id": str(getattr(deployment, f"{kind}_id")), + "host_id": str(deployment.host_id), "host_name": host.name if host else None} + try: + await persist(**values, status="requested", details={ + "remote_uuid": getattr(deployment, f"opnsense_{kind}_uuid"), + "rollback_payload": copy.deepcopy(deployment.previous_payload), + "before_payload": copy.deepcopy(deployment.last_payload)}) + result = await function(db, deployment) + await db.flush() + return result + except Exception: + await persist(**values, status="interrupted", message="Rollback-Ausgang unklar; gespeicherten und tatsächlichen Stand prüfen.") + raise + finally: + if token is not None: + context.reset(token) + return wrapped + return decorate diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..69e53a2 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,270 @@ +"""Authentication core: password hashing, server-side sessions, brute-force +protection and the initial admin bootstrap. + +Two roles ("admin", "read_only"), no orgs, no API keys — see +AdminUser/AuthMiddleware for how a role is enforced. Originally mirrored +InfraPanel's own app/services/auth_service.py (single-admin-only); the role +column and its enforcement are OPNexus-specific additions on top of that. + +Design notes +------------ +* Passwords are hashed with Argon2id (argon2-cffi). On every successful + login we transparently re-hash if the cost parameters changed. +* Sessions are stored server-side so they can be revoked. The database only + ever holds the SHA-256 of the random session token; the raw token lives + solely in the client's httpOnly cookie. A DB leak therefore cannot be + turned into a valid session. +* Login attempts are rate-limited per (ip, username) with a temporary + lockout to blunt online brute-force / credential-stuffing. +* Unknown usernames still trigger a dummy verify so response timing does + not reveal whether the account exists. +""" + +import hashlib +import logging +import secrets +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.user import AdminUser, Session + +logger = logging.getLogger(__name__) + +_ph = PasswordHasher() + +# Pre-computed hash of a random string, used to equalise timing for logins +# against a non-existent username (defends against username enumeration). +_DUMMY_HASH = _ph.hash(secrets.token_urlsafe(32)) + +SESSION_TOKEN_BYTES = 32 + + +# ── Password hashing ───────────────────────────────────────────────────── + +def hash_password(password: str) -> str: + return _ph.hash(password) + + +def verify_password(stored_hash: str, password: str) -> bool: + try: + _ph.verify(stored_hash, password) + return True + except (VerifyMismatchError, InvalidHashError): + return False + + +def needs_rehash(stored_hash: str) -> bool: + try: + return _ph.check_needs_rehash(stored_hash) + except InvalidHashError: + return True + + +# ── Brute-force protection (in-memory, per process) ────────────────────── + +@dataclass +class _AttemptRecord: + failures: list[float] = field(default_factory=list) + locked_until: float = 0.0 + + +class _LoginThrottle: + """Failed-login limiter with two independent buckets per window: + + * ``ip::username`` — targeted guessing against the one account + * ``ip`` — spraying across many usernames from one source + + A login is blocked while *either* bucket is locked out. + """ + + def __init__(self) -> None: + self._records: dict[str, _AttemptRecord] = {} + # Total failures per username since their last successful login — + # informational only (shown to the user after login), separate from + # the rolling lockout windows above. In-memory/per-process like the + # rest of this throttle, so it resets on a backend restart. + self._failures_since_success: dict[str, int] = {} + + @staticmethod + def _user_key(ip: str, username: str) -> str: + return f"u:{ip}::{username.lower()}" + + @staticmethod + def _ip_key(ip: str) -> str: + return f"i:{ip}" + + def _retry_after_key(self, key: str) -> int: + rec = self._records.get(key) + if not rec: + return 0 + remaining = rec.locked_until - time.monotonic() + return int(remaining) + 1 if remaining > 0 else 0 + + def _record_key(self, key: str, max_attempts: int) -> None: + now = time.monotonic() + rec = self._records.setdefault(key, _AttemptRecord()) + window = settings.login_window_seconds + rec.failures = [t for t in rec.failures if now - t < window] + rec.failures.append(now) + if len(rec.failures) >= max_attempts: + rec.locked_until = now + settings.login_lockout_seconds + rec.failures.clear() + + def retry_after(self, ip: str, username: str) -> int: + """Seconds the caller must wait, or 0 if not currently locked out.""" + return max( + self._retry_after_key(self._user_key(ip, username)), + self._retry_after_key(self._ip_key(ip)), + ) + + def record_failure(self, ip: str, username: str) -> None: + self._record_key(self._user_key(ip, username), settings.login_max_attempts) + self._record_key(self._ip_key(ip), settings.login_ip_max_attempts) + key = username.lower() + self._failures_since_success[key] = self._failures_since_success.get(key, 0) + 1 + + def reset(self, ip: str, username: str) -> None: + self._records.pop(self._user_key(ip, username), None) + + def pop_failures_since_success(self, username: str) -> int: + """Read and clear the failure count for a username, called on a + successful login so the count reflects only attempts since then.""" + return self._failures_since_success.pop(username.lower(), 0) + + +throttle = _LoginThrottle() + + +# ── Sessions ─────────────────────────────────────────────────────────── + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def create_session( + db: AsyncSession, + user: AdminUser, + *, + user_agent: str | None = None, + ip_address: str | None = None, +) -> tuple[str, datetime]: + """Create a session, returning the raw token (to be set as a cookie) and its expiry.""" + token = secrets.token_urlsafe(SESSION_TOKEN_BYTES) + expires_at = _now() + timedelta(seconds=settings.session_lifetime) + session = Session( + user_id=user.id, + token_hash=_hash_token(token), + expires_at=expires_at, + last_used_at=_now(), + user_agent=(user_agent or "")[:255] or None, + ip_address=ip_address, + ) + db.add(session) + await db.execute(delete(Session).where(Session.user_id == user.id, Session.expires_at < _now())) + await db.commit() + return token, expires_at + + +async def resolve_session(db: AsyncSession, token: str) -> AdminUser | None: + if not token: + return None + result = await db.execute(select(Session).where(Session.token_hash == _hash_token(token))) + session = result.scalar_one_or_none() + if session is None or session.expires_at <= _now(): + return None + return await db.get(AdminUser, session.user_id) + + +async def revoke_session(db: AsyncSession, token: str) -> None: + if not token: + return + await db.execute(delete(Session).where(Session.token_hash == _hash_token(token))) + await db.commit() + + +# ── Authentication ──────────────────────────────────────────────────── + +async def authenticate( + db: AsyncSession, username: str, password: str, ip: str, *, finalize: bool = True +) -> AdminUser | None: + """Verify credentials. Returns the user on success, else None. + + Performs a dummy verify on an unknown username to keep timing roughly + constant, and transparently upgrades the stored hash if Argon2 + parameters changed. On success, stashes the *previous* login + timestamp/IP as transient (non-persisted) attributes on the returned + user before overwriting them, so the caller can show "last login was + ..." — the mapped columns always hold the current login after this. + """ + result = await db.execute(select(AdminUser).where(AdminUser.username == username)) + user = result.scalar_one_or_none() + + if user is None: + verify_password(_DUMMY_HASH, password) + return None + + if not verify_password(user.password_hash, password): + return None + + if needs_rehash(user.password_hash): + user.password_hash = hash_password(password) + if finalize: + await finalize_login(db, user, ip) + else: + await db.commit() + return user + + +async def finalize_login(db: AsyncSession, user: AdminUser, ip: str) -> None: + """Record the login (and expose the previous one). Split from authenticate() + so a user with a second factor is only stamped once that factor passed.""" + user.previous_login_at = user.last_login_at # type: ignore[attr-defined] + user.previous_login_ip = user.last_login_ip # type: ignore[attr-defined] + user.last_login_at = _now() + user.last_login_ip = ip + await db.commit() + + +# ── Bootstrap ───────────────────────────────────────────────────────── + +async def bootstrap_admin(db: AsyncSession) -> None: + """Ensure the admin account exists.""" + existing = await db.execute(select(AdminUser.id).limit(1)) + if existing.first() is not None: + return + + password = settings.admin_password.strip() + generated = False + if not password: + password = secrets.token_urlsafe(18) + generated = True + elif len(password) < 12: + logger.warning( + "ADMIN_PASSWORD is shorter than 12 characters — please choose a " + "stronger password." + ) + + user = AdminUser(username=settings.admin_username, password_hash=hash_password(password), role="admin") + db.add(user) + await db.commit() + + if generated: + logger.warning( + "\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n" + " generated password:\n\n %s\n\n" + " Log in and change it now. This is shown only once.\n%s", + "=" * 64, settings.admin_username, password, "=" * 64, + ) + else: + logger.info("Created admin user '%s' from configuration.", settings.admin_username) diff --git a/backend/app/services/backup_store.py b/backend/app/services/backup_store.py new file mode 100644 index 0000000..f06fa0c --- /dev/null +++ b/backend/app/services/backup_store.py @@ -0,0 +1,55 @@ +"""Per-host OPNsense config.xml backup storage, on disk under +//.xml — mirrors opnsense_secret_store.py's +convention (never in the database, settings-driven directory, chmod 600) +but keeps multiple retained snapshots per host instead of a single file. +""" +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from app.config import settings + +_SUFFIX = ".xml" + + +def _host_dir(host_id: uuid.UUID) -> Path: + return Path(settings.opnsense_backup_dir) / str(host_id) + + +def save_snapshot(host_id: uuid.UUID, content: bytes) -> str: + """Writes a new snapshot, filename = sortable UTC timestamp with + microsecond precision to avoid collisions on rapid consecutive saves. + Returns the filename, used as the backup's id.""" + host_dir = _host_dir(host_id) + host_dir.mkdir(parents=True, exist_ok=True) + filename = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + _SUFFIX + path = host_dir / filename + path.write_bytes(content) + path.chmod(0o600) + return filename + + +def list_snapshots(host_id: uuid.UUID) -> list[dict]: + """Returns [{"id", "created_at", "size"}], newest first.""" + host_dir = _host_dir(host_id) + if not host_dir.exists(): + return [] + items = [] + for path in host_dir.glob(f"*{_SUFFIX}"): + stat = path.stat() + items.append({ + "id": path.name, + "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + "size": stat.st_size, + }) + return sorted(items, key=lambda i: i["created_at"], reverse=True) + + +def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None: + path = _host_dir(host_id) / backup_id + # backup_id comes straight from a URL path parameter — reject anything + # that would escape the host's own directory (e.g. "../../etc/passwd") + # before it ever reaches the filesystem. + if backup_id != path.name or not path.is_file(): + return None + return path.read_bytes() diff --git a/backend/app/services/carp_event_service.py b/backend/app/services/carp_event_service.py new file mode 100644 index 0000000..b0f1f60 --- /dev/null +++ b/backend/app/services/carp_event_service.py @@ -0,0 +1,53 @@ +"""Detects CARP-role transitions and records them to carp_events for a +historical timeline (see app/models/carp_event.py). Called periodically from +main.py's existing _notification_poll_loop, on the same cadence as the other +live-status checks — a deliberate extra get_statuses() round trip rather than +reworking notification_checks.check_host_offline's own call to it. +""" +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.carp_event import CarpEvent +from app.models.host import OpnsenseHost +from app.services.host_status_service import get_statuses + + +async def _last_status(db: AsyncSession, host_id) -> str | None: + result = await db.execute( + select(CarpEvent.to_status) + .where(CarpEvent.host_id == host_id) + .order_by(CarpEvent.detected_at.desc()) + .limit(1) + ) + row = result.first() + return row[0] if row else None + + +async def detect_and_record(db: AsyncSession) -> list[CarpEvent]: + result = await db.execute(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True))) + hosts = [h for h in result.scalars().all() if h.cluster_name] + if not hosts: + return [] + + statuses = await get_statuses(hosts) + created: list[CarpEvent] = [] + for host in hosts: + info = statuses.get(host.id, {}) + if info.get("status") != "online": + # Unreachable readings are noise here, not a role change — the + # last confirmed role stays on record until it's seen again. + continue + current = info.get("carp_status") + previous = await _last_status(db, host.id) + if current == previous: + continue + event = CarpEvent( + host_id=host.id, host_name=host.name, cluster_name=host.cluster_name, + from_status=previous, to_status=current, + ) + db.add(event) + created.append(event) + + if created: + await db.commit() + return created diff --git a/backend/app/services/carp_maintenance_window_service.py b/backend/app/services/carp_maintenance_window_service.py new file mode 100644 index 0000000..f85a4c1 --- /dev/null +++ b/backend/app/services/carp_maintenance_window_service.py @@ -0,0 +1,117 @@ +"""Optional auto-revert timer layered on top of the existing CARP +maintenance-mode toggle (opnsense_client.toggle_carp_maintenance, driven by +routers/maintenance.py's carp-preview/carp-execute/{id}/acknowledge flow). + +Unlike a rule/NAT deploy, entering CARP maintenance is a role handover, not +a config write that could sever the management path — so, unlike the VPN +trilogy's deliberate no-auto-revert stance, automatically calling the same +toggle back is safe and prevents the real, already-observed failure mode of +a host being left demoted indefinitely because a human forgot to switch it +back. The sweep bypasses the human confirm-name/acknowledge gate entirely +(same rationale _deploy_revert_sweep_loop already uses elsewhere): the +human already opted in by choosing a duration when entering maintenance. +""" +import logging +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.carp_maintenance_window import CarpMaintenanceWindow +from app.models.host import OpnsenseHost +from app.services import audit_service, host_status_service +from app.services import opnsense_client as client + +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def open_window(db: AsyncSession, host: OpnsenseHost, auto_revert_minutes: int | None) -> CarpMaintenanceWindow | None: + if not auto_revert_minutes or auto_revert_minutes <= 0: + return None + window = CarpMaintenanceWindow( + host_id=host.id, host_name=host.name, + auto_revert_at=_now() + timedelta(minutes=auto_revert_minutes), + status="active", + ) + db.add(window) + await db.commit() + return window + + +async def active_window(db: AsyncSession, host_id) -> CarpMaintenanceWindow | None: + return await db.scalar( + select(CarpMaintenanceWindow) + .where(CarpMaintenanceWindow.host_id == host_id, CarpMaintenanceWindow.status == "active") + .order_by(CarpMaintenanceWindow.entered_at.desc()) + ) + + +async def close_windows_for_manual_leave(db: AsyncSession, host: OpnsenseHost) -> None: + """Called when a human completes a carp_leave themselves, pre-empting + any running timer for that host.""" + result = await db.execute( + select(CarpMaintenanceWindow).where(CarpMaintenanceWindow.host_id == host.id, CarpMaintenanceWindow.status == "active") + ) + for window in result.scalars().all(): + window.status = "cancelled_manual" + window.resolved_at = _now() + window.message = "Manuell vor Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet." + await db.commit() + + +async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]: + result = await db.execute( + select(CarpMaintenanceWindow).where( + CarpMaintenanceWindow.status == "active", CarpMaintenanceWindow.auto_revert_at <= _now(), + ) + ) + windows = list(result.scalars().all()) + resolved: list[CarpMaintenanceWindow] = [] + for window in windows: + host = await db.get(OpnsenseHost, window.host_id) + if not host: + window.status = "revert_failed" + window.resolved_at = _now() + window.message = "Host wurde gelöscht, bevor das Zeitfenster ablief." + resolved.append(window) + continue + try: + statuses = await host_status_service.get_statuses([host]) + if not statuses.get(host.id, {}).get("carp_maintenance_mode"): + # Already left maintenance some other way (manual action that + # didn't go through close_windows_for_manual_leave, e.g. a + # direct OPNsense-side change) — nothing to do. + window.status = "cancelled_manual" + window.resolved_at = _now() + window.message = "Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus." + resolved.append(window) + continue + result_payload = await client.toggle_carp_maintenance(host) + if result_payload.get("action") != "leave_maintenance": + raise client.OpnsenseAPIError("CARP-Antwort passt nicht zur erwarteten Richtung (leave_maintenance)") + window.status = "reverted" + window.resolved_at = _now() + window.message = "Automatisch nach Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet." + await audit_service.persist( + kind="host", action="carp_auto_revert", status="submitted", + host_id=str(host.id), host_name=host.name, entity_id=str(window.id), + message=window.message, details={"response": result_payload}, + ) + except client.OpnsenseAPIError as exc: + window.status = "revert_failed" + window.resolved_at = _now() + window.message = f"Automatischer Rückschalt-Versuch fehlgeschlagen: {exc}"[:800] + await audit_service.persist( + kind="host", action="carp_auto_revert", status="revert_failed", + host_id=str(host.id), host_name=host.name, entity_id=str(window.id), + message=window.message, + ) + resolved.append(window) + + if resolved: + await db.commit() + return resolved diff --git a/backend/app/services/cert_key_store.py b/backend/app/services/cert_key_store.py new file mode 100644 index 0000000..581a1c5 --- /dev/null +++ b/backend/app/services/cert_key_store.py @@ -0,0 +1,48 @@ +"""Per-certificate private-key storage for ManagedCertificate. + +Mirrors ipsec_psk_store.py's convention exactly: the key material never +touches the database, only disk, one file per entity id, mode 0600. +ManagedCertificate.private_key_set is the DB-side flag tracking whether a +file exists. Only relevant for `action="import"` (the one action where the +operator supplies their own key material — every other action either has +no private key at all (external/sign_csr/import_csr/manual) or has +OPNsense itself generate and store the key server-side (internal/reissue, +via `private_key_location="firewall"`, this app's default — see +managed_certificate_service.py)). + +UNLIKE ipsec_psk_store.py/openvpn_key_store.py/wireguard_key_store.py's +documented (pre-existing) wrinkle of embedding secret material into +`last_payload` for no-op-push-detection, this key is deliberately NEVER +included in ManagedCertificate.last_payload — the plan is explicit that +private key material must never touch Postgres, full stop, with no +"matched here for consistency" carve-out. no-op detection therefore +compares a `private_key_set` boolean stand-in instead of the real value — +see managed_certificate_service.py's build_payload().""" +import uuid +from pathlib import Path + +from app.config import settings + +_SUFFIX = ".certkey" + + +def _path(cert_id: uuid.UUID) -> Path: + return Path(settings.cert_key_secret_dir) / f"{cert_id}{_SUFFIX}" + + +def save_secret(cert_id: uuid.UUID, secret: str) -> None: + path = _path(cert_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret.strip()) + path.chmod(0o600) + + +def read_secret(cert_id: uuid.UUID) -> str | None: + path = _path(cert_id) + if not path.exists(): + return None + return path.read_text().strip() + + +def delete_secret(cert_id: uuid.UUID) -> None: + _path(cert_id).unlink(missing_ok=True) diff --git a/backend/app/services/firmware_service.py b/backend/app/services/firmware_service.py new file mode 100644 index 0000000..941e9b0 --- /dev/null +++ b/backend/app/services/firmware_service.py @@ -0,0 +1,15 @@ +"""Extracts product_id/product_version from core/firmware/info — that +response also includes a `package` array with hundreds of entries (every +installed and available package), which is discarded here rather than +ever being sent to the frontend.""" +from app.models.host import OpnsenseHost +from app.schemas.host import FirmwareInfo +from app.services import opnsense_client + + +async def get_firmware_info(host: OpnsenseHost) -> FirmwareInfo: + data = await opnsense_client.get_firmware_info(host) + return FirmwareInfo( + product_id=data.get("product_id", "opnsense"), + product_version=data.get("product_version", "?"), + ) diff --git a/backend/app/services/host_maintenance_service.py b/backend/app/services/host_maintenance_service.py new file mode 100644 index 0000000..c4c76b4 --- /dev/null +++ b/backend/app/services/host_maintenance_service.py @@ -0,0 +1,163 @@ +"""Single-host maintenance. Previews are short-lived, bound to state and single use. +An accepted request is NOT proof of completed reboot/update. Explicit human +verification closes the operation; uncertain transport results are never retried. +""" +import asyncio +import hashlib +import json +from datetime import datetime, timezone +from app.errors import ErrorCode, api_error +from sqlalchemy import select +from app.models import OpnsenseHost, RuleDeployment, AliasDeployment, PortForwardDeployment, OneToOneDeployment, OutboundNatDeployment +from app.models.host_operation import HostOperation +from app.models.restore_operation import RestoreOperation +from app.services import opnsense_client as client, backup_store, carp_maintenance_window_service +from app.services.host_status_service import get_statuses + +DEPLOYMENTS = (RuleDeployment, AliasDeployment, PortForwardDeployment, OneToOneDeployment, OutboundNatDeployment) +ACTIVE = ("starting", "submitted", "unknown") +# A restore that's been handed off to a human (manually applying it via the +# OPNsense GUI) is exactly the window where an automated push or reboot/ +# update could land mid-restore or on stale config — block both until the +# operator confirms or cancels it. "preview" alone blocks nothing since +# nothing has actually happened yet. +RESTORE_OPEN = ("handed_off",) + + +def fingerprint(data): + return hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest() + + +async def peers(db, host): + if host.cluster_name: + return list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.cluster_name == host.cluster_name).order_by(OpnsenseHost.id))).all()) + return [host] + + +async def ensure_no_maintenance(db, hosts): + ids = set() + for host in hosts: + ids.update(h.id for h in await peers(db, host)) + if ids and await db.scalar(select(HostOperation.id).where(HostOperation.host_id.in_(ids), HostOperation.status.in_(ACTIVE)).limit(1)): + raise api_error(409, ErrorCode.OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY) + if ids and await db.scalar(select(RestoreOperation.id).where(RestoreOperation.host_id.in_(ids), RestoreOperation.status.in_(RESTORE_OPEN)).limit(1)): + raise api_error(409, ErrorCode.OPEN_RESTORE_BLOCKS_DEPLOY) + + +async def _shared_change_blockers(db, fleet): + blocked = [] + fleet_ids = [h.id for h in fleet] + for model in DEPLOYMENTS: + if await db.scalar(select(model.id).where(model.host_id.in_(fleet_ids), model.status.in_(("pending", "revert_failed"))).limit(1)): + blocked.append("Offene Bestätigung oder fehlgeschlagener Rollback im Host/Cluster.") + break + operations = list((await db.scalars(select(HostOperation).where(HostOperation.host_id.in_(fleet_ids), HostOperation.status.in_(ACTIVE)))).all()) + if operations: + blocked.append("Eine frühere Wartungsaktion im Host/Cluster muss zuerst geprüft und abgeschlossen werden.") + if await db.scalar(select(RestoreOperation.id).where(RestoreOperation.host_id.in_(fleet_ids), RestoreOperation.status.in_(RESTORE_OPEN)).limit(1)): + blocked.append("Eine Wiederherstellung im Host/Cluster ist offen und muss zuerst bestätigt oder abgebrochen werden.") + return blocked + + +async def inspect_host(db, host): + fleet = await peers(db, host) + statuses, info, firmware, running = await asyncio.gather( + get_statuses(fleet), client.get_firmware_info(host), client.get_firmware_status(host), client.get_firmware_running(host)) + blocked = [] + if not host.enabled: blocked.append("Host ist deaktiviert.") + if statuses.get(host.id, {}).get("status") != "online": blocked.append("Host ist nicht erreichbar.") + # Fail closed: only the verified ready value permits a disruptive action. + if running.get("status") != "ready": blocked.append("Firmware-Auftrag läuft oder Status ist unbekannt.") + if host.cluster_name: + others = [h for h in fleet if h.id != host.id] + if statuses.get(host.id, {}).get("carp_status") != "BACKUP": + blocked.append("Cluster-Wartung nur auf dem CARP-BACKUP. Rollenwechsel separat in OPNsense durchführen.") + if not others or not all(h.enabled and statuses.get(h.id, {}).get("status") == "online" for h in others) or not any(statuses.get(h.id, {}).get("carp_status") == "MASTER" for h in others): + blocked.append("Kein erreichbarer aktiver Cluster-Partner bestätigt.") + blocked.extend(await _shared_change_blockers(db, fleet)) + backups = backup_store.list_snapshots(host.id) + latest = backups[0] if backups else None + fresh = bool(latest and latest['size'] > 0 and (datetime.now(timezone.utc)-latest['created_at']).total_seconds() <= 86400) + snapshot = { + "host_id": str(host.id), "host_name": host.name, "base_url": host.base_url, "enabled": host.enabled, + "verify_tls": host.verify_tls, "cluster_name": host.cluster_name, + "carp_status": statuses.get(host.id, {}).get("carp_status"), + "carp_maintenance_mode": statuses.get(host.id, {}).get("carp_maintenance_mode", False), + "version": info.get("product_version", "unbekannt"), + "running": running.get("status", "unknown"), + "firmware_status": firmware.get("status", "unknown"), "firmware_message": firmware.get("status_msg", ""), + "last_check": firmware.get("last_check"), "reboot_required": str(firmware.get("status_reboot", "0")) == "1", + "packages": firmware.get("all_packages", {}), "upgrade_sets": firmware.get("all_sets", {}), "product_latest": firmware.get("product_latest"), + "peers": [{"id": str(h.id), "name": h.name, "enabled": h.enabled, + "status": statuses.get(h.id, {}).get("status"), "carp_status": statuses.get(h.id, {}).get("carp_status"), + "carp_maintenance_mode": statuses.get(h.id, {}).get("carp_maintenance_mode", False)} for h in fleet], + "backup": {"id": latest['id'], "created_at": latest['created_at'].isoformat(), "size": latest['size']} if latest else None, + "backup_fresh": fresh, "blocked": blocked, + } + backup_blocker = [] if fresh else ["Vor der Installation ein lokales Konfigurationsbackup erstellen (maximal 24 Stunden alt)."] + snapshot['actions'] = { + "reboot": blocked, + "update": blocked + backup_blocker + + ([] if firmware.get("status") == "update" else ["Kein stabiles Update zur Installation gemeldet. Update-Suche ausführen."]), + # Major upgrades reuse every gate the stable update path has (backup + # freshness, HA ordering, no open deploy/restore) plus the same + # fail-closed `running == "ready"` check at preview/execute time in + # maintenance.py - OPNsense's own statusAction() only ever reports + # "upgrade" once there are zero pending "update" packages, so this + # can never race ahead of a pending stable update. + "major_update": blocked + backup_blocker + + ([] if firmware.get("status") == "upgrade" else ["Kein Major-Upgrade zur Installation gemeldet. Update-Suche ausführen."]), + } + return snapshot + + +async def inspect_carp_switch(db, host): + """Return a fail-closed snapshot for the upstream CARP-mode toggle.""" + fleet = await peers(db, host) + statuses = await get_statuses(fleet) + blocked = [] + if not host.enabled: + blocked.append("Host ist deaktiviert.") + if not host.cluster_name or len(fleet) < 2: + blocked.append("CARP-Rollenwechsel benötigt einen Cluster mit mindestens zwei Hosts.") + current = statuses.get(host.id, {}) + if current.get("status") != "online": + blocked.append("Der ausgewählte Host ist nicht erreichbar.") + if not current.get("carp_allowed", False): + blocked.append("CARP ist auf dem ausgewählten Host deaktiviert.") + maintenance_mode = bool(current.get("carp_maintenance_mode")) + action = "carp_leave" if maintenance_mode else "carp_enter" + others = [h for h in fleet if h.id != host.id] + if action == "carp_enter": + if current.get("carp_status") != "MASTER": + blocked.append("Nur der aktuelle CARP-MASTER kann auf den Partner umgeschaltet werden.") + if not any(h.enabled and statuses.get(h.id, {}).get("status") == "online" and statuses.get(h.id, {}).get("carp_status") == "BACKUP" for h in others): + blocked.append("Kein erreichbarer CARP-BACKUP als Übernahmeziel bestätigt.") + already_demoted = next((h for h in others if statuses.get(h.id, {}).get("carp_maintenance_mode")), None) + if already_demoted: + # A demoted BACKUP still reports carp_status BACKUP, so the check + # above alone can't catch this: demoting a second node on top of + # an already-demoted one leaves both stuck (observed live twice — + # OPNsense's maintenance toggle then doesn't reliably re-elect, + # see opnsense/core#7877). The only valid way back is leaving + # maintenance on the node that's still in it. + blocked.append(f"{already_demoted.name} steht bereits im CARP-Wartungsmodus. Zuerst dort „CARP-Normalbetrieb herstellen“ verwenden, nicht hier erneut umschalten.") + elif not any(h.enabled and statuses.get(h.id, {}).get("status") == "online" and statuses.get(h.id, {}).get("carp_status") == "MASTER" for h in others): + blocked.append("Kein erreichbarer CARP-MASTER bestätigt, der den Verkehr aktuell trägt.") + running = await asyncio.gather(*(client.get_firmware_running(h) for h in fleet)) + if any(result.get("status") != "ready" for result in running): + blocked.append("Auf mindestens einem Cluster-Knoten läuft ein Firmware-Auftrag oder sein Zustand ist unbekannt.") + blocked.extend(await _shared_change_blockers(db, fleet)) + window = await carp_maintenance_window_service.active_window(db, host.id) + return { + "host_id": str(host.id), "host_name": host.name, "base_url": host.base_url, + "cluster_name": host.cluster_name, "action": action, + "carp_status": current.get("carp_status"), + "carp_maintenance_mode": maintenance_mode, + "carp_demotion": current.get("carp_demotion"), + "peers": [{"id": str(h.id), "name": h.name, "status": statuses.get(h.id, {}).get("status"), + "carp_status": statuses.get(h.id, {}).get("carp_status"), + "carp_maintenance_mode": statuses.get(h.id, {}).get("carp_maintenance_mode", False)} for h in fleet], + "blocked": blocked, "actions": {"carp": blocked}, + "auto_revert_window": {"auto_revert_at": window.auto_revert_at.isoformat()} if window and window.auto_revert_at else None, + } diff --git a/backend/app/services/host_status_service.py b/backend/app/services/host_status_service.py new file mode 100644 index 0000000..4482e11 --- /dev/null +++ b/backend/app/services/host_status_service.py @@ -0,0 +1,55 @@ +"""Live host status (online/offline + CARP master/backup) via getVipStatus. + +Each host is queried independently and failures are caught per-host, so one +unreachable firewall never blocks the others from reporting — this backs +the hosts list endpoint's mixed online/offline behaviour. +""" +import asyncio + +from app.models.host import OpnsenseHost +from app.services.opnsense_client import OpnsenseAPIError, get_vip_status + + +def _truthy(value, default: bool) -> bool: + """OPNsense's getVipStatus returns carp.maintenancemode as a native JSON + bool but other carp.* flags as "1"/"0" strings — normalize both.""" + if isinstance(value, bool): + return value + if value is None: + return default + return str(value) == "1" + + +def _extract_carp_status(data: dict | None) -> str | None: + rows = data.get("rows") if isinstance(data, dict) else None + if not rows: + return None + statuses = {row.get("status", "").upper() for row in rows if isinstance(row, dict)} + if "MASTER" in statuses: + return "MASTER" + if "BACKUP" in statuses: + return "BACKUP" + return None + + +async def _status_for(host: OpnsenseHost) -> dict: + if not host.enabled: + return {"status": "disabled", "carp_status": None, "status_detail": None} + try: + data = await get_vip_status(host) + except OpnsenseAPIError as exc: + return {"status": "offline", "carp_status": None, "status_detail": str(exc)} + carp = data.get("carp", {}) if isinstance(data, dict) else {} + return { + "status": "online", + "carp_status": _extract_carp_status(data), + "carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False), + "carp_allowed": _truthy(carp.get("allow"), default=True), + "carp_demotion": carp.get("demotion"), + "status_detail": carp.get("status_msg") or None, + } + + +async def get_statuses(hosts: list[OpnsenseHost]) -> dict: + results = await asyncio.gather(*(_status_for(h) for h in hosts)) + return {host.id: result for host, result in zip(hosts, results)} diff --git a/backend/app/services/interface_service.py b/backend/app/services/interface_service.py new file mode 100644 index 0000000..94b0e28 --- /dev/null +++ b/backend/app/services/interface_service.py @@ -0,0 +1,52 @@ +"""Interface identifiers/labels for populating the rule-form dropdown — +sourced live from each host via `getRule`'s template response, so it always +matches that host's actual interface assignments (lan/wan/optN, VLANs, +custom names) instead of a guessed static list. +""" +from app.models.host import OpnsenseHost +from app.schemas.host import InterfaceOption +from app.services import opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + + +def _template_options(data: dict, field: str) -> list[InterfaceOption]: + options = data.get("rule", {}).get(field, {}) if isinstance(data, dict) else {} + return [InterfaceOption(name=name, label=meta.get("value") or name) for name, meta in options.items()] + + +async def get_interfaces(host: OpnsenseHost) -> list[InterfaceOption]: + return _template_options(await opnsense_client.get_rule_template(host), "interface") + + +async def get_protocols(host: OpnsenseHost) -> list[InterfaceOption]: + """Unlike interfaces, protocol names are IANA-standard constants baked + into OPNsense itself, not per-host config — same list on every box (bar + version drift), so callers don't need the cross-host consistency + dance get_interfaces/check_interface_consistency exist for.""" + return _template_options(await opnsense_client.get_rule_template(host), "protocol") + + +async def check_interface_consistency(hosts: list[OpnsenseHost], interface_name: str) -> dict[str, str]: + """For a rule pushed to more than one host: resolves what + `interface_name` actually means on each host right now, live, and + returns {host_name: label} for hosts where it resolved (silently + skipping hosts that fail to answer — they surface their own, more + specific error later in the actual deploy). + + The same identifier (e.g. "opt6") can be assigned to a completely + different physical/VLAN interface on different boxes — OPNsense's + addRule/setRule accepts it either way since it's syntactically valid + on each host individually, so nothing else catches this. Callers + should only bother calling this when len(hosts) > 1 — a single target + host can never conflict with itself. + """ + labels: dict[str, str] = {} + for host in hosts: + try: + options = await get_interfaces(host) + except OpnsenseAPIError: + continue + match = next((o.label for o in options if o.name == interface_name), None) + if match is not None: + labels[host.name] = match + return labels diff --git a/backend/app/services/ipsec_deploy_service.py b/backend/app/services/ipsec_deploy_service.py new file mode 100644 index 0000000..3b781b1 --- /dev/null +++ b/backend/app/services/ipsec_deploy_service.py @@ -0,0 +1,333 @@ +"""Pushes a centrally-authored site-to-site IPsec tunnel (PSK auth, v1 +scope) to its target hosts, the same "OPNexus writes via the API instead +of relying on OPNsense's own HA config sync" model as rule_deploy_service.py. + +VERIFIED FIELDS / ENDPOINTS (fetched directly from +github.com/opnsense/core, branch master, 2026-09-08 — see +opnsense_client.py's "IPsec (site-to-site, PSK)" section for the endpoint +list and its own citations): + +- Swanctl.xml → Connections.Connection: enabled, proposals + (IPsecProposalField, Phase=1), unique, aggressive, version (OptionField: + 0=IKEv1+IKEv2, 1=IKEv1, 2=IKEv2), mobike, local_addrs/remote_addrs + (IKEAddressField — comma-separated IP/subnet/hostname), local_port/ + remote_port, encap, reauth_time, rekey_time, over_time, dpd_delay, + dpd_timeout, send_certreq, send_cert, keyingtries, description. +- Swanctl.xml → locals.local / remotes.remote (separate top-level + ArrayFields, NOT nested under Connection): enabled, `connection` + (ModelRelationField — the parent Connection's UUID, confirmed as a + plain string equality check in ConnectionsController::connectionFilter() + and ::delConnectionAction()), round, auth (OptionValues include "psk" — + v1 only ever sends this), id (StrictTextField, the IKE identity), + eap_id, certs/pubkeys (unused, v1 is PSK-only), description. `remote` + additionally has groups/cacerts — unused in v1. +- Swanctl.xml → children.child (separate ArrayField, `connection` + ModelRelationField back to the same Connection UUID): enabled, + `connection`, reqid, esp_proposals (IPsecProposalField, Phase=2), + sha256_96, start_action/close_action/dpd_action, mode (default + "tunnel" — what this app relies on), policies, local_ts/remote_ts + (NetworkField, AsList=Y — comma-separated CIDRs), rekey_time, + description. +- IPsec.xml → preSharedKeys.preSharedKey (SEPARATE model, + `OPNsense\\IPsec\\IPsec`, NOT part of Swanctl): ident, remote_ident, + keyType (default "PSK"), Key (capital K), description. Confirmed: this + is NOT linked to the Connection/Local/Remote by UUID — swanctl resolves + it by STRING EQUALITY between ident/remote_ident and the Local/Remote + `id` field, which is exactly why local_identity/remote_identity are + reused verbatim for both the Local/Remote payload's `id` and the PSK + payload's `ident`/`remote_ident` below. +- Every OPNsense field NOT listed in the payload builders below is + deliberately omitted, not forgotten: ApiMutableModelControllerBase:: + addBase()/setBase() only overlay the keys actually posted onto a + model node that Add() already populated with the model's own XML + values (verified directly in ApiMutableModelControllerBase.php) + — e.g. Child.mode defaults to "tunnel", Connection.mobike defaults to + enabled, without OPNexus ever having to state so. +- IPsecProposalField (FieldTypes/IPsecProposalField.php): a + BaseListField (Multiple=Y ⇒ comma-separated on the wire) whose values + are cipher-suite strings like "aes256-sha256-modp2048", OR the literal + "default" — which is what both proposals and esp_proposals default to + in the model XML itself. This app always sends "default" unless a user + explicitly overrides it in the form: a source-confirmed, conservative + choice, NOT verified against a live getConnection response (no call was + ever made against a real host for this feature). +- Endpoints (module "ipsec"): connections/{add,set,del,search,get} + {Connection,Local,Remote,Child}, preSharedKeys/{add,set,del,search,get} + Item, service/reconfigure. All confirmed as literal PHP method names in + ConnectionsController.php / PreSharedKeysController.php / (inherited) + ApiMutableServiceControllerBase.php, and the URL-segment-to-class-name + mapping confirmed against Router.php::parsePath() — see + opnsense_client.py for the full writeup. + +DELIBERATELY SIMPLER STATUS MODEL THAN rule_deploy_service.py — no +pending/confirm/auto-revert window (user-approved decision, see plan +"IPsec-VPN-Management"): a firewall rule mistake is a foot-gun; an IPsec +`reconfigure` mistake can be a fatal one, because it can sever the exact +management path OPNexus would need in order to auto-revert it. Pretending +to offer a safety net here would be actively misleading, so this entity +only ever reports "success" or "error" (see IpsecTunnelDeployment's +docstring) — no revert is ever attempted, automatically or otherwise. +Undeploy (delete) is a normal explicit action, same as rule delete's +scope decision to not be lockout-gated either. + +Per-host deploy order, mirroring the OPNsense model's own dependency +chain (Local/Remote/Child all reference their Connection's UUID, and +delConnectionAction cascades in the same direction — see +ConnectionsController::delConnectionAction() above): Connection, then +Local, then Remote, then Child, then the PreSharedKey — and undeploy runs +the exact reverse (Child/Local/Remote deleted before their parent +Connection, PreSharedKey has no ordering dependency on the others at all +since it's a separate model linked only by identity-string match). If any +config-write step fails, the deploy stops there: status="error" with the +concrete failure message, and whatever was already written up to that +point on that host is NOT rolled back — there is no savepoint/rollback +primitive for this on the OPNsense side (same as filter rules), and this +entity doesn't build one on top the way rule_deploy_service.py's +pending/confirm window does. A follow-up deploy attempt reuses whatever +UUIDs got created (via last_payload / opnsense_*_uuid) instead of +duplicating them. + +The `reconfigure` call is a separate step after all 5 config writes +succeed, per the plan: if it fails, status="error" with the specific +message "Konfiguration wurde geschrieben, Dienst-Reload aber nicht +bestätigt — Erreichbarkeit manuell prüfen" — the config IS live in +swanctl's stored model at that point, it's only the reload confirmation +that's missing. The frontend is responsible for gating the whole +"push" action (config write + reconfigure, both happen in one backend +call) behind an explicit, hard-to-miss warning — see +frontend/components/IpsecTunnelReview.tsx — not for splitting it into two +separate user-facing steps. +""" +import hashlib +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.host import OpnsenseHost +from app.models.ipsec_tunnel import IpsecTunnel +from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment +from app.services import opnsense_client, ipsec_psk_store +from app.services.opnsense_client import OpnsenseAPIError + +_VERSION_MAP = {"ike": "0", "ikev1": "1", "ikev2": "2"} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _redact_for_storage(payloads: dict) -> dict: + """SECURITY FIX (2026-09-09): `payloads["psk"]["Key"]` carries the raw + PSK secret, needed on the wire, but `payloads` itself used to also + become `deployment.last_payload` verbatim — i.e. the secret was + persisted in plaintext into Postgres, directly contradicting + ipsec_psk_store.py's own "never touches the database, disk-only" + docstring. Fixed by storing/comparing a SHA-256 hash of the secret + instead of the secret itself: a rotated PSK (with every other field + unchanged) still correctly counts as a real change to push, but the + recoverable secret value never reaches the deployments table.""" + redacted = {**payloads, "psk": {**payloads["psk"]}} + secret = redacted["psk"].get("Key", "") + redacted["psk"]["Key"] = hashlib.sha256(secret.encode()).hexdigest() if secret else "" + return redacted + + +def build_payloads(tunnel: IpsecTunnel) -> dict: + """The 5 OPNsense-shaped sub-payloads for this tunnel, keyed the same + way _deploy_to_host/undeploy use them. `connection` is filled in by + the caller once the parent Connection's UUID is known — see below.""" + secret = ipsec_psk_store.read_secret(tunnel.id) + return { + "connection": { + "enabled": "1" if tunnel.enabled else "0", + "proposals": tunnel.proposal, + "version": _VERSION_MAP[tunnel.ike_version], + "local_addrs": tunnel.local_address, + "remote_addrs": tunnel.remote_address, + "description": tunnel.description, + }, + "local": { + "enabled": "1" if tunnel.enabled else "0", + "auth": "psk", + "id": tunnel.local_identity, + "description": tunnel.description, + }, + "remote": { + "enabled": "1" if tunnel.enabled else "0", + "auth": "psk", + "id": tunnel.remote_identity, + "description": tunnel.description, + }, + "child": { + "enabled": "1" if tunnel.enabled else "0", + "esp_proposals": tunnel.esp_proposal, + "local_ts": tunnel.local_networks, + "remote_ts": tunnel.remote_networks, + "description": tunnel.description, + }, + "psk": { + "ident": tunnel.local_identity, + "remote_ident": tunnel.remote_identity, + "keyType": "PSK", + "Key": secret or "", + "description": tunnel.description, + }, + } + + +async def _write_config(host: OpnsenseHost, payloads: dict, deployment: IpsecTunnelDeployment) -> None: + """Runs the 5 config-write calls in dependency order, persisting each + UUID onto `deployment` as soon as it's known so a later retry (after a + partial failure) updates rather than duplicates. Raises + OpnsenseAPIError uncaught on the first failure — the caller decides + what that means for `deployment.status`.""" + if deployment.opnsense_connection_uuid: + await opnsense_client.set_ipsec_connection(host, deployment.opnsense_connection_uuid, payloads["connection"]) + else: + result = await opnsense_client.add_ipsec_connection(host, payloads["connection"]) + deployment.opnsense_connection_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_connection_uuid: + raise OpnsenseAPIError("Firewall hat keine Connection-UUID zurückgegeben; Stand manuell prüfen") + + # `payloads` (built by build_payloads()) is also what gets persisted as + # last_payload for no-op-redeploy comparison — the injected `connection` + # UUID is deploy-time wiring, not user-editable tunnel state, so it's + # sent to OPNsense via a shallow copy here rather than mutated into the + # dict that gets compared/stored (a prior version of this function + # mutated payloads["local"] etc. directly, which broke no-op detection + # on the very next deploy: build_payloads() never re-adds `connection`, + # so the freshly-built dict would never again equal the stored one). + local_wire = {**payloads["local"], "connection": deployment.opnsense_connection_uuid} + remote_wire = {**payloads["remote"], "connection": deployment.opnsense_connection_uuid} + child_wire = {**payloads["child"], "connection": deployment.opnsense_connection_uuid} + + if deployment.opnsense_local_uuid: + await opnsense_client.set_ipsec_local(host, deployment.opnsense_local_uuid, local_wire) + else: + result = await opnsense_client.add_ipsec_local(host, local_wire) + deployment.opnsense_local_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_local_uuid: + raise OpnsenseAPIError("Firewall hat keine Local-UUID zurückgegeben; Stand manuell prüfen") + + if deployment.opnsense_remote_uuid: + await opnsense_client.set_ipsec_remote(host, deployment.opnsense_remote_uuid, remote_wire) + else: + result = await opnsense_client.add_ipsec_remote(host, remote_wire) + deployment.opnsense_remote_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_remote_uuid: + raise OpnsenseAPIError("Firewall hat keine Remote-UUID zurückgegeben; Stand manuell prüfen") + + if deployment.opnsense_child_uuid: + await opnsense_client.set_ipsec_child(host, deployment.opnsense_child_uuid, child_wire) + else: + result = await opnsense_client.add_ipsec_child(host, child_wire) + deployment.opnsense_child_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_child_uuid: + raise OpnsenseAPIError("Firewall hat keine Child-UUID zurückgegeben; Stand manuell prüfen") + + if deployment.opnsense_psk_uuid: + await opnsense_client.set_ipsec_psk(host, deployment.opnsense_psk_uuid, payloads["psk"]) + else: + result = await opnsense_client.add_ipsec_psk(host, payloads["psk"]) + deployment.opnsense_psk_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_psk_uuid: + raise OpnsenseAPIError("Firewall hat keine PSK-UUID zurückgegeben; Stand manuell prüfen") + + +async def _deploy_to_host(db: AsyncSession, tunnel: IpsecTunnel, host: OpnsenseHost) -> IpsecTunnelDeployment: + payloads = build_payloads(tunnel) + comparable = _redact_for_storage(payloads) + existing = await db.scalar( + select(IpsecTunnelDeployment).where( + IpsecTunnelDeployment.tunnel_id == tunnel.id, IpsecTunnelDeployment.host_id == host.id + ) + ) + if existing and existing.status == "success" and existing.last_payload == comparable: + # Nothing actually changed on this host — re-affirm without + # touching the firewall at all (no risk was introduced). + existing.message = None + existing.deployed_at = _now() + return existing + + deployment = existing or IpsecTunnelDeployment(tunnel_id=tunnel.id, host_id=host.id, status="error") + + try: + await _write_config(host, payloads, deployment) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + try: + await opnsense_client.reconfigure_ipsec(host) + except OpnsenseAPIError: + # Config IS live at this point (all 5 writes above succeeded) — + # this exact message is specified by the plan, not a generic + # str(exc), because "the config write failed" and "the config + # wrote fine but the reload wasn't confirmed" are different + # failure modes an operator needs to react to differently. + deployment.status = "error" + deployment.message = "Konfiguration wurde geschrieben, Dienst-Reload aber nicht bestätigt — Erreichbarkeit manuell prüfen" + deployment.last_payload = comparable + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + deployment.status = "success" + deployment.message = None + deployment.last_payload = comparable + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + +async def deploy_tunnel(db: AsyncSession, tunnel: IpsecTunnel, hosts: list[OpnsenseHost]) -> list[IpsecTunnelDeployment]: + from app.services.host_maintenance_service import ensure_no_maintenance + await ensure_no_maintenance(db, hosts) + deployments = [await _deploy_to_host(db, tunnel, host) for host in hosts] + await db.commit() + return deployments + + +async def undeploy_tunnel(db: AsyncSession, tunnel: IpsecTunnel) -> None: + """Explicit action, not gated by anything (there is no lockout state + for this entity to gate on) — deletes Child/Local/Remote before their + parent Connection (the ModelRelationField dependency direction), then + the PreSharedKey (independent of the other 4), then reconfigures.""" + failures = [] + result = await db.execute(select(IpsecTunnelDeployment).where(IpsecTunnelDeployment.tunnel_id == tunnel.id)) + for deployment in result.scalars().all(): + host = await db.get(OpnsenseHost, deployment.host_id) + if host is None: + await db.delete(deployment) + continue + try: + if deployment.opnsense_child_uuid: + await opnsense_client.del_ipsec_child(host, deployment.opnsense_child_uuid) + if deployment.opnsense_local_uuid: + await opnsense_client.del_ipsec_local(host, deployment.opnsense_local_uuid) + if deployment.opnsense_remote_uuid: + await opnsense_client.del_ipsec_remote(host, deployment.opnsense_remote_uuid) + if deployment.opnsense_connection_uuid: + await opnsense_client.del_ipsec_connection(host, deployment.opnsense_connection_uuid) + if deployment.opnsense_psk_uuid: + await opnsense_client.del_ipsec_psk(host, deployment.opnsense_psk_uuid) + if any([deployment.opnsense_child_uuid, deployment.opnsense_local_uuid, deployment.opnsense_remote_uuid, + deployment.opnsense_connection_uuid, deployment.opnsense_psk_uuid]): + await opnsense_client.reconfigure_ipsec(host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + failures.append(host.name) + continue + await db.delete(deployment) + await db.flush() + if failures: + await db.commit() + raise OpnsenseAPIError("Tunnel konnte nicht von allen Hosts entfernt werden: " + ", ".join(failures)) diff --git a/backend/app/services/ipsec_psk_store.py b/backend/app/services/ipsec_psk_store.py new file mode 100644 index 0000000..d8b8a76 --- /dev/null +++ b/backend/app/services/ipsec_psk_store.py @@ -0,0 +1,37 @@ +"""Per-tunnel IPsec pre-shared-key storage. + +Mirrors opnsense_secret_store.py's convention exactly (itself mirroring +InfraPanel's proxmox_connection.py / webhook_secret_store.py): the PSK +secret never touches the database, only disk, one file per tunnel id, +mode 0600. IpsecTunnel.pre_shared_key_set is the DB-side flag tracking +whether a file exists, the same pattern used by NotificationSettings for +its webhook signing secret / SMTP password. +""" +import uuid +from pathlib import Path + +from app.config import settings + +_SUFFIX = ".psk" + + +def _path(tunnel_id: uuid.UUID) -> Path: + return Path(settings.ipsec_psk_secret_dir) / f"{tunnel_id}{_SUFFIX}" + + +def save_secret(tunnel_id: uuid.UUID, secret: str) -> None: + path = _path(tunnel_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret.strip()) + path.chmod(0o600) + + +def read_secret(tunnel_id: uuid.UUID) -> str | None: + path = _path(tunnel_id) + if not path.exists(): + return None + return path.read_text().strip() + + +def delete_secret(tunnel_id: uuid.UUID) -> None: + _path(tunnel_id).unlink(missing_ok=True) diff --git a/backend/app/services/ipsec_service.py b/backend/app/services/ipsec_service.py new file mode 100644 index 0000000..8f1ca9c --- /dev/null +++ b/backend/app/services/ipsec_service.py @@ -0,0 +1,133 @@ +"""Non-deploy helpers for IpsecTunnel: snapshot/revision/preview/workflow, +mirroring policy_service.py's role for FirewallRule. effective_hosts() and +policy_lock() are NOT reimplemented here — policy_service's versions work +unchanged for any object exposing `.targets`/`.groups` (see plan: "reuse +policy_service.effective_hosts directly"). +""" +import hashlib +import json + +from app.models.policy import IpsecTunnelRevision +from app.services.ipsec_deploy_service import build_payloads +from app.services.policy_service import effective_hosts # noqa: F401 - re-exported for routers/ipsec.py + +IPSEC_FIELDS = ( + "description", "local_address", "remote_address", "ike_version", "proposal", + "local_identity", "remote_identity", "local_networks", "remote_networks", + "esp_proposal", "enabled", +) + + +def snapshot(tunnel): + return { + **{key: getattr(tunnel, key) for key in IPSEC_FIELDS}, + "pre_shared_key_set": tunnel.pre_shared_key_set, + "target_host_ids": sorted(str(h.id) for h in tunnel.targets), + "target_group_ids": sorted(str(g.id) for g in tunnel.groups), + } + + +def revision(db, tunnel, note): + db.add(IpsecTunnelRevision(tunnel_id=tunnel.id, note=note, snapshot=snapshot(tunnel))) + + +def preview(tunnel): + payloads = build_payloads(tunnel) + deployments = {d.host_id: d for d in tunnel.deployments} + hosts = effective_hosts(tunnel) + rows = [] + for host in hosts: + dep = deployments.get(host.id) + before = dep.last_payload if dep else None + action = "create" if not dep or not dep.opnsense_connection_uuid else "update" + if before == payloads and dep and dep.status == "success": + action = "unchanged" + if not host.enabled: + action = "disabled" + changes = [] + if action not in ("disabled",): + for section in ("connection", "local", "remote", "child", "psk"): + before_section = (before or {}).get(section, {}) + after_section = payloads[section] + for key, value in after_section.items(): + if key == "connection": + continue # UUID filled in at deploy time, not a user-visible field + prior = before_section.get(key) + if prior != value: + label = "Pre-Shared-Key" if section == "psk" and key == "Key" else f"{section}.{key}" + # Never surface the PSK's own value in a diff view. + shown_before = "•••" if section == "psk" and key == "Key" and prior else prior + shown_after = "•••" if section == "psk" and key == "Key" else value + changes.append({"field": label, "before": shown_before, "after": shown_after}) + rows.append({ + "host_id": str(host.id), "host_name": host.name, "action": action, + "status": dep.status if dep else None, + "message": dep.message if dep else None, + "can_remove": False, + "changes": changes, + }) + target_ids = {h.id for h in hosts} + for dep in tunnel.deployments: + if dep.host_id not in target_ids: + rows.append({ + "host_id": str(dep.host_id), "host_name": dep.host.name, + "action": "retained", "status": dep.status, "message": dep.message, + "can_remove": bool(dep.host.enabled and (dep.opnsense_connection_uuid or dep.opnsense_psk_uuid)), + "changes": [], + }) + warnings = [ + "Kein automatischer Rollback: falls dieser Host nur über den VPN-Tunnel erreichbar ist, den du gerade " + "änderst, kann OPNexus die Verbindung nach einem Fehler NICHT automatisch wiederherstellen." + ] + if any(r["action"] == "retained" for r in rows): + warnings.append("Frühere Ziele behalten ihren Tunnel, bis du sie hier gezielt entfernst.") + if any(r["action"] == "disabled" for r in rows): + warnings.append("Deaktivierte Hosts werden übersprungen.") + if any(d.status == "error" for d in tunnel.deployments): + warnings.append("Vorheriger Push fehlgeschlagen. Vor erneutem Push den Firewall-Stand prüfen.") + if not tunnel.pre_shared_key_set: + warnings.append("Für diesen Tunnel ist noch kein Pre-Shared-Key hinterlegt.") + state = { + "snapshot": snapshot(tunnel), "rows": rows, + "hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts], + "deployments": sorted( + [(str(d.host_id), d.opnsense_connection_uuid, d.opnsense_local_uuid, d.opnsense_remote_uuid, + d.opnsense_child_uuid, d.opnsense_psk_uuid, d.last_payload, d.host.base_url, d.host.enabled, d.host.verify_tls) + for d in tunnel.deployments], + key=lambda row: row[0], + ), + } + token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() + return { + "token": token, "hosts": rows, "warnings": warnings, + "can_deploy": tunnel.pre_shared_key_set and any(r["action"] in ("create", "update") for r in rows), + "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich.", + } + + +def workflow(tunnel, deployments): + hosts = effective_hosts(tunnel) + active = [h for h in hosts if h.enabled] + by_host = {d.host_id: d for d in deployments} + payloads = build_payloads(tunnel) + target_ids = {h.id for h in hosts} + counts = { + "targets": len(hosts), + "active_targets": len(active), + "changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payloads), + "errors": sum(d.status == "error" for d in deployments), + "retained": sum(d.host_id not in target_ids for d in deployments), + } + if counts["errors"]: + state = "attention" + elif counts["retained"]: + state = "cleanup" + elif not hosts: + state = "unassigned" + elif not active: + state = "disabled" + elif counts["changes"] or not tunnel.pre_shared_key_set: + state = "draft" + else: + state = "synced" + return state, counts diff --git a/backend/app/services/live_certificates_service.py b/backend/app/services/live_certificates_service.py new file mode 100644 index 0000000..012ace9 --- /dev/null +++ b/backend/app/services/live_certificates_service.py @@ -0,0 +1,83 @@ +"""Fetches the TLS certificates actually stored on a host right now, straight +from OPNsense's trust/cert/search — read-only, no persistence, mirrors +live_rules_service.py. + +Response shape verified against the OPNsense core source (Trust/Api/ +CertController.php, Trust/Store.php::parseX509()): {"total": N, "rowCount": +N, "current": 1, "rows": [...]}, each row carrying valid_from/valid_to as +Unix timestamps and a %caref label (the locally-managed issuing CA's name, +empty for self-signed or externally-imported certs). CSR-pending entries +have no parsed X.509 fields at all (valid_from/valid_to/commonname empty) +and are surfaced as status "unknown" rather than raised as an error. +""" +from datetime import datetime, timezone + +from app.models.host import OpnsenseHost +from app.schemas.host import CertStatus, HostCa, HostCertificate +from app.services import opnsense_client + +EXPIRING_SOON_DAYS = 30 + + +def _parse_epoch(value) -> datetime | None: + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc) + except (TypeError, ValueError): + return None + + +def _expiry(valid_to: datetime | None) -> tuple[int | None, CertStatus]: + if valid_to is None: + return None, "unknown" + days_until_expiry = (valid_to - datetime.now(timezone.utc)).days + if days_until_expiry < 0: + return days_until_expiry, "expired" + if days_until_expiry <= EXPIRING_SOON_DAYS: + return days_until_expiry, "expiring_soon" + return days_until_expiry, "valid" + + +def _row_to_certificate(row: dict) -> HostCertificate: + valid_from = _parse_epoch(row.get("valid_from")) + valid_to = _parse_epoch(row.get("valid_to")) + days_until_expiry, status = _expiry(valid_to) + + return HostCertificate( + uuid=row.get("uuid", ""), + descr=row.get("descr") or "(ohne Beschreibung)", + common_name=row.get("commonname") or None, + issuer=row.get("%caref") or "Selbstsigniert", + valid_from=valid_from, + valid_to=valid_to, + days_until_expiry=days_until_expiry, + status=status, + in_use=row.get("in_use") in ("1", 1, True), + ) + + +async def get_live_certificates(host: OpnsenseHost) -> list[HostCertificate]: + data = await opnsense_client.search_certificates(host) + rows = data.get("rows", []) if isinstance(data, dict) else [] + return [_row_to_certificate(row) for row in rows] + + +def _row_to_ca(row: dict) -> HostCa: + valid_from = _parse_epoch(row.get("valid_from")) + valid_to = _parse_epoch(row.get("valid_to")) + days_until_expiry, status = _expiry(valid_to) + + return HostCa( + uuid=row.get("uuid", ""), + descr=row.get("descr") or "(ohne Beschreibung)", + common_name=row.get("commonname") or None, + valid_from=valid_from, + valid_to=valid_to, + days_until_expiry=days_until_expiry, + status=status, + ) + + +async def get_live_cas(host: OpnsenseHost) -> list[HostCa]: + data = await opnsense_client.search_ca(host) + rows = data.get("rows", []) if isinstance(data, dict) else [] + return [_row_to_ca(row) for row in rows] diff --git a/backend/app/services/live_rules_service.py b/backend/app/services/live_rules_service.py new file mode 100644 index 0000000..f6986f4 --- /dev/null +++ b/backend/app/services/live_rules_service.py @@ -0,0 +1,42 @@ +"""Fetches the rules actually configured on a host right now, straight from +OPNsense's searchRule — including ones never pushed by this tool (built-in +defaults, anything configured by hand). Separate from RuleDeployment, which +only tracks what OPNexus itself has deployed. + +Response shape verified live against a real OPNsense 25.x host: {"total": +N, "rowCount": N, "current": 1, "rows": [...]}, each row a flat dict with +both raw fields (action, direction, protocol, ...) and OPNsense's own +human-readable "%"-prefixed counterparts (%action, %direction, ...), which +this maps onto LiveRule's *_label fields directly rather than re-deriving +them. +""" +from app.models.host import OpnsenseHost +from app.schemas.host import LiveRule +from app.services import opnsense_client + + +def _row_to_live_rule(row: dict) -> LiveRule: + return LiveRule( + uuid=row.get("uuid", ""), + description=row.get("description") or "(ohne Beschreibung)", + action=row.get("action", ""), + action_label=row.get("%action") or row.get("action", ""), + direction=row.get("direction", ""), + direction_label=row.get("%direction") or row.get("direction", ""), + interface=row.get("interface") or None, + interface_label=row.get("%interface") or row.get("interface") or None, + protocol=row.get("protocol") or None, + protocol_label=row.get("%protocol") or row.get("protocol") or None, + source_net=row.get("source_net") or "any", + source_port=row.get("source_port") or None, + destination_net=row.get("destination_net") or "any", + destination_port=row.get("destination_port") or None, + enabled=row.get("enabled") in ("1", 1, True), + is_automatic=bool(row.get("is_automatic")), + ) + + +async def get_live_rules(host: OpnsenseHost) -> list[LiveRule]: + data = await opnsense_client.search_rules(host) + rows = data.get("rows", []) if isinstance(data, dict) else [] + return [_row_to_live_rule(row) for row in rows] diff --git a/backend/app/services/log_ingest_service.py b/backend/app/services/log_ingest_service.py new file mode 100644 index 0000000..5128603 --- /dev/null +++ b/backend/app/services/log_ingest_service.py @@ -0,0 +1,229 @@ +"""Digest-based incremental firewall-log ingest. The single testable entry +point (poll_all_hosts) is called directly by main.py's background loop AND +directly by tests — same pattern as notification_service.run_notification_checks +and rule_deploy_service.sweep_expired_deployments. + +Incremental fetch relies on a verified OPNsense core behaviour (see +opnsense_client.get_log / scripts/filter/read_log.py): passing the +previous poll's newest `__digest__` back as the `digest` query param makes +OPNsense return only strictly-newer lines, read in reverse-chronological +order. That means the first poll of a host (no cursor yet) is a plain +"latest N lines" fetch, and every poll after that only pulls what's new. +""" +import logging +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +import math + +from sqlalchemy import delete, select, text +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.host import OpnsenseHost +from app.models.log_cursor import HostLogCursor +from app.models.log_entry import FirewallLogEntry +from app.services import opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + +logger = logging.getLogger(__name__) + +# Generous enough to cover a burst since the last poll at the default +# 30s interval without truncating, while still bounding a first-ever +# poll's backlog fetch (see docstring above) to something reasonable. +_FETCH_LIMIT = 1000 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _extract_rows(data: Any) -> list[dict]: + """Same envelope handling the old live router used: OPNsense has been + observed to return either a flat JSON array or a {"rows": [...]} + envelope depending on version/config.""" + if isinstance(data, dict) and isinstance(data.get("rows"), list): + return data["rows"] + if isinstance(data, list): + return data + return [] + + +def _parse_timestamp(value: Any) -> datetime: + """OPNsense's exact `__timestamp__` format was not part of this + feature's core-source-verified fact set (only the digest-cursor + semantics were) — since implemented, live-checked against both real + hosts: it comes back as a bare "YYYY-MM-DDTHH:MM:SS" with NO timezone + offset (e.g. "2026-09-08T12:45:05"), which `fromisoformat` parses fine + but leaves naive. That naive value is stored as-is (Postgres then + treats it as the session timezone, effectively "as if UTC"). + + KNOWN LIMITATION: this is the firewall's own local clock as OPNsense + reports it, not necessarily UTC — if the firewall isn't configured for + UTC, every stored timestamp (and therefore since/until filtering) is + off by that fixed offset. The API gives no separate offset field to + correct for this. Documented in README rather than guessed at, since + guessing a specific offset without confirming the host's configured + timezone would just trade one unverified assumption for another. + + Also defensively accepts a numeric epoch and falls back to "now" (poll + time) for anything unparseable, so one oddly-formatted line never + aborts an entire ingest cycle.""" + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(value, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return _now() + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except ValueError: + pass + return _now() + + +def _map_row(host_id: uuid.UUID, row: dict) -> dict | None: + digest = row.get("__digest__") + if not digest: + logger.warning("Log-Zeile ohne __digest__ übersprungen (host_id=%s)", host_id) + return None + return { + "id": uuid.uuid4(), + "host_id": host_id, + "timestamp": _parse_timestamp(row.get("__timestamp__")), + "action": row.get("action"), + "interface": row.get("interface"), + "direction": row.get("dir"), + "protocol": row.get("protoname"), + "source_net": row.get("src"), + "source_port": row.get("srcport"), + "destination_net": row.get("dst"), + "destination_port": row.get("dstport"), + "label": row.get("label"), + "raw": row, + "digest": str(digest), + } + + +async def poll_host(db: AsyncSession, host: OpnsenseHost) -> int: + """Fetch new log lines since the host's stored cursor, insert them + (deduped via ON CONFLICT DO NOTHING on the (host_id, digest) unique + constraint — the same postgresql insert().on_conflict_do_nothing() + upsert pattern already used in routers/settings.py for the + preferences upsert, just bulk), and advance the cursor to the newest + fetched row's digest. Returns the number of newly inserted rows (0 is + the normal/expected result on a quiet host with nothing new). + + Raises OpnsenseAPIError on request failure — callers (poll_all_hosts, + tests) decide how to handle a single host's failure without aborting + the whole cycle.""" + cursor = await db.get(HostLogCursor, host.id) + last_digest = cursor.last_digest if cursor else None + + data = await opnsense_client.get_log(host, limit=_FETCH_LIMIT, digest=last_digest) + rows = _extract_rows(data) + + if cursor is None: + cursor = HostLogCursor(host_id=host.id) + db.add(cursor) + + if not rows: + cursor.last_polled_at = _now() + return 0 + + mapped = [m for m in (_map_row(host.id, row) for row in rows) if m is not None] + actually_inserted = 0 + if mapped: + stmt = insert(FirewallLogEntry).values(mapped).on_conflict_do_nothing( + index_elements=["host_id", "digest"] + ) + result = await db.execute(stmt) + # rowcount reflects rows the ON CONFLICT DO NOTHING clause actually + # inserted, not the number attempted — an overlapping re-fetch of + # an already-ingested digest correctly counts as 0 here. + actually_inserted = result.rowcount or 0 + # Newest by parsed timestamp rather than "first in the response" — + # a defensive choice independent of trusting the API's ordering, + # in case it's ever not strictly reverse-chronological. + newest = max(mapped, key=lambda m: m["timestamp"]) + cursor.last_digest = newest["digest"] + + cursor.last_polled_at = _now() + return actually_inserted + + +async def poll_all_hosts(db: AsyncSession) -> dict: + """Polls every enabled host independently — one host's failure (e.g. + unreachable firewall) must not block ingest for the others, same + principle as host_status_service.get_statuses.""" + hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))).all()) + inserted_total = 0 + failed = 0 + for host in hosts: + try: + inserted_total += await poll_host(db, host) + except OpnsenseAPIError as exc: + failed += 1 + logger.warning("Log-Ingest für Host %s fehlgeschlagen: %s", host.name, exc) + except Exception: + failed += 1 + logger.exception("Log-Ingest für Host %s fehlgeschlagen", host.name) + await db.commit() + return {"hosts_polled": len(hosts), "hosts_failed": failed, "inserted": inserted_total} + + +async def prune_old_entries(db: AsyncSession, retention_days: int) -> int: + """Deletes log entries older than the retention window. Unlike + audit_events (models/audit.py), firewall_log_entries carries no + immutability trigger — pruning is a core requirement here, not + something to guard against.""" + cutoff = _now() - timedelta(days=retention_days) + result = await db.execute(delete(FirewallLogEntry).where(FirewallLogEntry.timestamp < cutoff)) + await db.commit() + return result.rowcount or 0 + + +# After a size-based prune the table is brought to this fraction of the cap, +# so the next cycles do not delete a handful of rows each time. +_SIZE_PRUNE_TARGET = 0.95 +# One pass never deletes more than this; the next prune cycle continues. +_SIZE_PRUNE_MAX_ROWS = 500_000 + + +async def storage_usage(db: AsyncSession) -> dict[str, int]: + """Disk footprint of the log table (incl. indexes) from Postgres statistics. + `live_bytes` scales that by the share of live tuples: Postgres keeps the + file size after DELETE (space is reused, not returned), so the raw size + alone would never fall below the cap again.""" + row = (await db.execute(text( + "SELECT pg_total_relation_size('firewall_log_entries') AS total, " + "COALESCE(s.n_live_tup, 0) AS live, COALESCE(s.n_dead_tup, 0) AS dead " + "FROM (SELECT 1) AS one " + "LEFT JOIN pg_stat_user_tables s ON s.relname = 'firewall_log_entries'" + ))).one() + total, live, dead = int(row.total), int(row.live), int(row.dead) + tuples = live + dead + live_bytes = int(total * live / tuples) if tuples else 0 + per_entry = int(total / tuples) if tuples else 0 + return {"used_bytes": total, "live_bytes": live_bytes, "entries": live, "bytes_per_entry": per_entry} + + +async def enforce_size_limit(db: AsyncSession, max_size_gb: float) -> int: + """Deletes the oldest entries while the estimated live data exceeds the cap. + max_size_gb <= 0 means unlimited.""" + if max_size_gb <= 0: + return 0 + limit = int(max_size_gb * 1024**3) + usage = await storage_usage(db) + if usage["live_bytes"] <= limit or usage["bytes_per_entry"] <= 0: + return 0 + excess = usage["live_bytes"] - int(limit * _SIZE_PRUNE_TARGET) + to_delete = min(math.ceil(excess / usage["bytes_per_entry"]), _SIZE_PRUNE_MAX_ROWS) + oldest = select(FirewallLogEntry.id).order_by(FirewallLogEntry.timestamp.asc()).limit(to_delete) + result = await db.execute(delete(FirewallLogEntry).where(FirewallLogEntry.id.in_(oldest))) + await db.commit() + return result.rowcount or 0 diff --git a/backend/app/services/log_settings_service.py b/backend/app/services/log_settings_service.py new file mode 100644 index 0000000..d54ab89 --- /dev/null +++ b/backend/app/services/log_settings_service.py @@ -0,0 +1,40 @@ +"""Get-or-create for the LogSettings singleton row — same shape as +notification_settings_service.get_settings. + +update_settings() added 2026-09-17: v1 shipped with no settings UI/router +for this (row only ever read, with defaults, by the ingest poll loop), +which meant an admin who wanted a different retention_days had to edit +the row directly in Postgres. Found to be a real gap the hard way: the +30-day default, combined with two busy real firewalls, grew +firewall_log_entries to ~8.7M rows / ~9.7GB in 9 days with no way for an +operator to see or change that from the app itself.""" +from app.config import settings +from app.models.log_settings import LogSettings +from app.schemas.logs import LogSettingsUpdate +from sqlalchemy.ext.asyncio import AsyncSession + +_SINGLETON_ID = 1 + + +async def get_settings(db: AsyncSession) -> LogSettings: + row = await db.get(LogSettings, _SINGLETON_ID) + if row is None: + row = LogSettings(id=_SINGLETON_ID) + db.add(row) + await db.flush() + return row + + +async def update_settings(db: AsyncSession, payload: LogSettingsUpdate) -> LogSettings: + row = await get_settings(db) + if payload.enabled is not None: + row.enabled = payload.enabled + if payload.poll_interval_seconds is not None: + row.poll_interval_seconds = max(payload.poll_interval_seconds, settings.log_poll_min_interval_seconds) + if payload.retention_days is not None: + row.retention_days = payload.retention_days + if payload.max_size_gb is not None: + row.max_size_gb = payload.max_size_gb + await db.commit() + await db.refresh(row) + return row diff --git a/backend/app/services/managed_certificate_service.py b/backend/app/services/managed_certificate_service.py new file mode 100644 index 0000000..cd66643 --- /dev/null +++ b/backend/app/services/managed_certificate_service.py @@ -0,0 +1,142 @@ +"""Pushes a centrally-authored Trust-Store certificate (`ManagedCertificate`, +v1 scope, see the model's own docstring) to its single target host — no +multi-host targeting (host-local `caref`/uuid, see the plan and +ManagedCertificate's docstring), no restart/apply step (Trust-Store writes +are immediately live — confirmed in the plan, `opnsense/core#9399`). + +VERIFIED FIELDS / ENDPOINTS (fetched directly from raw.githubusercontent. +com/opnsense/core/master/..., 2026-09-09 — see opnsense_client.py's +"Trust-Store certificates" section for the endpoint list and citations): + +- Trust/Cert.xml: the DescriptionField wire key is `descr` (this app's own + `description` column is translated here, NOT sent verbatim under that + name). Plaintext-in fields are `crt_payload`/`csr_payload`/`prv_payload` + — CertController::setBaseHook() base64-encodes these server-side into + `crt`/`csr`/`prv`; this app always sends plain PEM/CSR text. + `private_key_location` (Required, Default "firewall") is deliberately + NEVER sent by this app — relying on the model default means OPNsense + itself generates AND stores the private key server-side for + action=internal/reissue, so this app never needs to capture key + material for those two actions at all. The ONE case this app DOES + manage key material for is action="import", where the operator brings + their own key (see cert_key_store.py) — sent as `prv_payload` at push + time only, in a wire-only shallow copy, never persisted to Postgres + (not even in `last_payload` — see cert_key_store.py's module docstring + for why this deliberately does NOT follow the pre-existing IPsec/ + OpenVPN/WireGuard precedent of embedding secret material there). + +DELIBERATELY SIMPLER STATUS MODEL THAN every multi-host entity in this +app: "draft" (never pushed) / "success" / "error" — there is no +reconfigure/apply/restart step to separately fail on, so there is no +"config written but reload unconfirmed" middle state either. No warning +gate exists in the frontend for this entity (see README) — the plan is +explicit that no lockout-shaped risk applies here. + +Deleting an in-use certificate is blocked by OPNsense itself +(checkAndThrowValueInUse() inside CertController::delAction(), confirmed +reading the controller source directly) — this app does NOT pre-check +that condition; delete_certificate() below lets OpnsenseAPIError (which +will carry OPNsense's own " - {}" +message) propagate uncaught to the router, which surfaces it verbatim.""" +import hashlib +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.host import OpnsenseHost +from app.models.managed_certificate import ManagedCertificate +from app.services import cert_key_store, opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def build_payload(cert: ManagedCertificate) -> dict: + """The OPNsense-shaped payload, EXCLUDING private key material (see + module docstring) — this is what gets persisted as `last_payload` for + no-op-push comparison. + + SECURITY FIX (2026-09-09): originally used the plain + `private_key_set` boolean as a change-detection stand-in. That's + insufficient — the flag stays `True` before AND after a key + *rotation* (only the disk-stored content changes), so re-uploading a + new key for an already-`success` certificate would have silently + no-op'd and never actually pushed the new key to the firewall, while + the UI kept showing "success". Fixed by hashing the current secret's + content (SHA-256) instead: the hash changes on rotation, still never + exposes the recoverable key itself in Postgres.""" + key_hash = "" + if cert.action == "import" and cert.private_key_set: + secret = cert_key_store.read_secret(cert.id) or "" + key_hash = hashlib.sha256(secret.encode()).hexdigest() if secret else "" + return { + "action": cert.action, + "descr": cert.description, + "key_type": cert.key_type, + "digest": cert.digest, + "cert_type": cert.cert_type, + "lifetime": str(cert.lifetime), + "caref": cert.caref or "", + "commonname": cert.commonname, + "country": cert.country, + "state": cert.state, + "city": cert.city, + "organization": cert.organization, + "email": cert.email, + "csr_payload": cert.csr_payload or "", + "crt_payload": cert.crt_payload or "", + "private_key_hash": key_hash, + } + + +async def push_certificate(db: AsyncSession, cert: ManagedCertificate, host: OpnsenseHost) -> ManagedCertificate: + payload = build_payload(cert) + if cert.status == "success" and cert.last_payload == payload: + # Nothing actually changed — re-affirm without touching the + # firewall at all, same no-op rule as every other deploy service + # in this app. + cert.message = None + cert.deployed_at = _now() + await db.commit() + return cert + + wire = {k: v for k, v in payload.items() if k != "private_key_hash"} + if cert.action == "import" and cert.private_key_set: + wire["prv_payload"] = cert_key_store.read_secret(cert.id) or "" + + try: + if cert.opnsense_uuid: + result = await opnsense_client.set_trust_cert(host, cert.opnsense_uuid, wire) + else: + result = await opnsense_client.add_trust_cert(host, wire) + cert.opnsense_uuid = result.get("uuid") if isinstance(result, dict) else None + if not cert.opnsense_uuid: + raise OpnsenseAPIError("Firewall hat keine Zertifikat-UUID zurückgegeben; Stand manuell prüfen") + except OpnsenseAPIError as exc: + cert.status = "error" + cert.message = str(exc) + cert.deployed_at = _now() + await db.commit() + return cert + + cert.status = "success" + cert.message = None + cert.last_payload = payload + cert.deployed_at = _now() + await db.commit() + return cert + + +async def delete_certificate(db: AsyncSession, cert: ManagedCertificate, host: OpnsenseHost | None) -> None: + """Explicit action, not gated by anything (no lockout state to gate + on for this entity). If a push was ever made (opnsense_uuid set) AND + the host still exists, deletes it there first — OpnsenseAPIError + (including OPNsense's own "in use" message) propagates uncaught, same + as every del* flow in this app; the caller decides the HTTP status.""" + if host is not None and cert.opnsense_uuid: + await opnsense_client.del_trust_cert(host, cert.opnsense_uuid) + cert_key_store.delete_secret(cert.id) + await db.delete(cert) + await db.commit() diff --git a/backend/app/services/nat_drift_service.py b/backend/app/services/nat_drift_service.py new file mode 100644 index 0000000..f6b8745 --- /dev/null +++ b/backend/app/services/nat_drift_service.py @@ -0,0 +1,81 @@ +"""Backend port of frontend/components/LiveNatDrift.tsx's fingerprint/diff +logic for the three NAT types, mirroring rule_drift_service.py's approach +for firewall rules. Keep the three fingerprint functions here in exact sync +with LiveNatDrift.tsx's frontend copies. + +Field names verified against OPNsense core source before use, not guessed: +- Port Forward (D-NAT): DNatController::searchRuleAction returns "descr" + (not "description") and dotted flat keys "source.network"/"source.port"/ + "destination.network"/"destination.port" (not nested objects — that + nesting only exists in the addRule/setRule *write* payload shape used by + port_forward_deploy_service.py's _build_payload, not the search response). +- One-to-One / Outbound NAT: flat "description"/"source_net"/ + "destination_net" etc., matching the field names already verified and + used by one_to_one_deploy_service.py / outbound_nat_deploy_service.py's + own _build_payload (search response and write payload agree here since + neither model nests source/destination). + +Only the "rules" key (user-managed) is compared — "automatic_rules" are +OPNsense-internal, host/interface-specific, and would produce false-positive +drift if compared across hosts (see nat_live_service.py's own docstring). +""" +from app.models.host import OpnsenseHost +from app.services import opnsense_client +from app.services.nat_live_service import split_rows +from app.services.opnsense_client import OpnsenseAPIError + + +def port_forward_fingerprint(row: dict) -> str: + return "|".join(str(row.get(key, "")) for key in ( + "descr", "interface", "protocol", "source.network", "source.port", + "destination.network", "destination.port", "target", "local-port", + )) + + +def one_to_one_fingerprint(row: dict) -> str: + return "|".join(str(row.get(key, "")) for key in ( + "description", "interface", "type", "source_net", "destination_net", "external", + )) + + +def outbound_nat_fingerprint(row: dict) -> str: + return "|".join(str(row.get(key, "")) for key in ( + "description", "interface", "protocol", "source_net", "source_port", + "destination_net", "destination_port", "target", "target_port", + )) + + +_SPECS = { + "port_forward": (opnsense_client.search_port_forward_rules, port_forward_fingerprint), + "one_to_one": (opnsense_client.search_one_to_one_rules, one_to_one_fingerprint), + "outbound_nat": (opnsense_client.search_outbound_nat_rules, outbound_nat_fingerprint), +} + + +async def _live_rows_or_none(host: OpnsenseHost, nat_type: str) -> list[dict] | None: + search_fn, _ = _SPECS[nat_type] + try: + data = await search_fn(host) + return split_rows(data)["rules"] + except OpnsenseAPIError: + return None + + +async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> list[dict]: + """Same shape/contract as rule_drift_service.compute_cluster_drift: + one row per fingerprint missing from at least one reachable host.""" + _, fingerprint_fn = _SPECS[nat_type] + per_host = {host.id: await _live_rows_or_none(host, nat_type) for host in hosts} + reachable_ids = {host_id for host_id, rows in per_host.items() if rows is not None} + + by_fingerprint: dict[str, dict] = {} + for host_id in reachable_ids: + for row in per_host[host_id]: + fp = fingerprint_fn(row) + entry = by_fingerprint.setdefault(fp, {"fingerprint": fp, "sample": row, "present_on": set()}) + entry["present_on"].add(host_id) + + for entry in by_fingerprint.values(): + entry["reachable_host_ids"] = reachable_ids + + return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)] diff --git a/backend/app/services/nat_live_service.py b/backend/app/services/nat_live_service.py new file mode 100644 index 0000000..34009e0 --- /dev/null +++ b/backend/app/services/nat_live_service.py @@ -0,0 +1,13 @@ +"""Read-only search rows. Automatic UUIDs never enter a managed rule set.""" +def split_rows(data): + rows = data.get("rows", []) + if not isinstance(rows, list): + from app.services.opnsense_client import OpnsenseAPIError + raise OpnsenseAPIError("Ungültige NAT-Suchantwort") + rules, automatic = [], [] + for row in rows: + if not isinstance(row, dict): + continue + is_auto = row.get("is_automatic") in (True, 1, "1", "true") or str(row.get("uuid", "")).startswith(("automatic_", "lockout_")) + (automatic if is_auto else rules).append(row) + return {"rules": rules, "automatic_rules": automatic} diff --git a/backend/app/services/notification_checks.py b/backend/app/services/notification_checks.py new file mode 100644 index 0000000..6ae3884 --- /dev/null +++ b/backend/app/services/notification_checks.py @@ -0,0 +1,217 @@ +"""Five detection functions, each returning {dedup_key: context} for +whatever's currently bad. Pure detection — no delivery, no state tracking +here (see notification_dedup.py for the open/resolved diffing that turns +these snapshots into actual notifications).""" +from collections import defaultdict + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.alias_deployment import AliasDeployment +from app.models.host import OpnsenseHost +from app.models.host_operation import HostOperation +from app.models.one_to_one_deployment import OneToOneDeployment +from app.models.outbound_nat_deployment import OutboundNatDeployment +from app.models.port_forward_deployment import PortForwardDeployment +from app.models.rule_deployment import RuleDeployment +from app.services import nat_drift_service, rule_drift_service +from app.services.host_status_service import get_statuses +from app.services.live_certificates_service import get_live_certificates +from app.services.opnsense_client import OpnsenseAPIError + +# (deployment_type, model, id_attr) — same shape audit_service.py's mapping +# uses, so a revert_failed row can point back at the underlying entity. +_DEPLOYMENT_MODELS = [ + ("rule", RuleDeployment, "rule_id"), + ("alias", AliasDeployment, "alias_id"), + ("port_forward", PortForwardDeployment, "port_forward_id"), + ("outbound_nat", OutboundNatDeployment, "outbound_nat_id"), + ("one_to_one", OneToOneDeployment, "one_to_one_id"), +] + + +async def _enabled_hosts(db: AsyncSession) -> list[OpnsenseHost]: + result = await db.execute(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True))) + return list(result.scalars().all()) + + +async def check_host_offline(db: AsyncSession) -> dict[str, dict]: + hosts = await _enabled_hosts(db) + statuses = await get_statuses(hosts) + problems: dict[str, dict] = {} + for host in hosts: + info = statuses.get(host.id, {}) + if info.get("status") != "offline": + continue + problems[str(host.id)] = { + "host_id": str(host.id), + "host_name": host.name, + "detail": info.get("status_detail"), + } + return problems + + +async def check_certificates(db: AsyncSession) -> dict[str, dict]: + hosts = await _enabled_hosts(db) + problems: dict[str, dict] = {} + for host in hosts: + try: + certs = await get_live_certificates(host) + except OpnsenseAPIError: + # Unreachable — already reported by check_host_offline, no need + # to double-report it here as a certificate problem. + continue + for cert in certs: + if cert.status not in ("expiring_soon", "expired"): + continue + key = f"{host.id}:{cert.uuid}" + problems[key] = { + "host_id": str(host.id), + "host_name": host.name, + "cert_descr": cert.descr, + "common_name": cert.common_name, + "status": cert.status, + "days_until_expiry": cert.days_until_expiry, + } + return problems + + +async def check_rule_drift(db: AsyncSession) -> dict[str, dict]: + hosts = await _enabled_hosts(db) + by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list) + for host in hosts: + if host.cluster_name: + by_cluster[host.cluster_name].append(host) + + problems: dict[str, dict] = {} + for cluster_name, cluster_hosts in by_cluster.items(): + if len(cluster_hosts) < 2: + continue + names_by_id = {h.id: h.name for h in cluster_hosts} + for row in await rule_drift_service.compute_cluster_drift(cluster_hosts): + key = f"{cluster_name}:{row['fingerprint']}" + present_names = sorted(names_by_id[hid] for hid in row["present_on"]) + missing_names = sorted( + names_by_id[hid] for hid in row["reachable_host_ids"] - row["present_on"] + ) + problems[key] = { + "cluster_name": cluster_name, + "description": row["sample"].description, + "present_on": present_names, + "missing_from": missing_names, + } + return problems + + +_NAT_TYPE_LABELS = {"port_forward": "Port-Forward", "one_to_one": "1:1-NAT", "outbound_nat": "Outbound-NAT"} + + +async def check_nat_drift(db: AsyncSession) -> dict[str, dict]: + hosts = await _enabled_hosts(db) + by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list) + for host in hosts: + if host.cluster_name: + by_cluster[host.cluster_name].append(host) + + problems: dict[str, dict] = {} + for cluster_name, cluster_hosts in by_cluster.items(): + if len(cluster_hosts) < 2: + continue + names_by_id = {h.id: h.name for h in cluster_hosts} + for nat_type in _NAT_TYPE_LABELS: + for row in await nat_drift_service.compute_cluster_drift(cluster_hosts, nat_type): + key = f"{cluster_name}:{nat_type}:{row['fingerprint']}" + present_names = sorted(names_by_id[hid] for hid in row["present_on"]) + missing_names = sorted( + names_by_id[hid] for hid in row["reachable_host_ids"] - row["present_on"] + ) + problems[key] = { + "cluster_name": cluster_name, + "nat_type": _NAT_TYPE_LABELS[nat_type], + "description": row["sample"].get("descr") or row["sample"].get("description"), + "present_on": present_names, + "missing_from": missing_names, + } + return problems + + +async def check_revert_failed(db: AsyncSession) -> dict[str, dict]: + problems: dict[str, dict] = {} + for deployment_type, model, id_attr in _DEPLOYMENT_MODELS: + result = await db.execute(select(model).where(model.status == "revert_failed")) + for deployment in result.scalars().all(): + host = await db.get(OpnsenseHost, deployment.host_id) + key = f"{deployment_type}:{deployment.id}" + problems[key] = { + "deployment_type": deployment_type, + "deployment_id": str(deployment.id), + "entity_id": str(getattr(deployment, id_attr)), + "host_id": str(deployment.host_id), + "host_name": host.name if host else None, + "message": deployment.message, + } + return problems + + +async def check_carp_health(db: AsyncSession) -> dict[str, dict]: + hosts = await _enabled_hosts(db) + by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list) + for host in hosts: + if host.cluster_name: + by_cluster[host.cluster_name].append(host) + + problems: dict[str, dict] = {} + for cluster_name, cluster_hosts in by_cluster.items(): + if len(cluster_hosts) < 2: + continue + statuses = await get_statuses(cluster_hosts) + online = [h for h in cluster_hosts if statuses.get(h.id, {}).get("status") == "online"] + if len(online) < 2: + # Can't tell split-brain from a simple outage with only one + # reachable node — check_host_offline already reports that case. + continue + masters = [h for h in online if statuses.get(h.id, {}).get("carp_status") == "MASTER"] + if len(masters) == 2: + key = f"{cluster_name}:split_brain" + problems[key] = { + "cluster_name": cluster_name, + "condition": "split_brain", + "host_names": sorted(h.name for h in online), + } + elif len(masters) == 0: + key = f"{cluster_name}:no_master" + problems[key] = { + "cluster_name": cluster_name, + "condition": "no_master", + "host_names": sorted(h.name for h in online), + } + return problems + + +async def check_maintenance_unknown(db: AsyncSession) -> dict[str, dict]: + result = await db.execute(select(HostOperation).where(HostOperation.status == "unknown")) + problems: dict[str, dict] = {} + for operation in result.scalars().all(): + problems[str(operation.id)] = { + "operation_id": str(operation.id), + "host_id": str(operation.host_id), + "host_name": operation.host_name, + "action": operation.action, + "message": operation.message, + } + return problems + + +async def check_carp_auto_revert_failed(db: AsyncSession) -> dict[str, dict]: + from app.models.carp_maintenance_window import CarpMaintenanceWindow + + result = await db.execute(select(CarpMaintenanceWindow).where(CarpMaintenanceWindow.status == "revert_failed")) + problems: dict[str, dict] = {} + for window in result.scalars().all(): + problems[str(window.id)] = { + "window_id": str(window.id), + "host_id": str(window.host_id), + "host_name": window.host_name, + "message": window.message, + } + return problems diff --git a/backend/app/services/notification_dedup.py b/backend/app/services/notification_dedup.py new file mode 100644 index 0000000..9fae135 --- /dev/null +++ b/backend/app/services/notification_dedup.py @@ -0,0 +1,83 @@ +"""Diffs a check's current-problem snapshot against previously open +NotificationProblem rows, so a still-open problem is never re-notified +every poll cycle — only transitions matter.""" +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.notification import NotificationProblem + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def diff_and_update( + db: AsyncSession, problem_type: str, current: dict[str, dict] +) -> tuple[list[NotificationProblem], list[NotificationProblem]]: + """current: {dedup_key: context}. Returns (opened, resolved) — the rows + that just transitioned, not the full open set. A key present in both + the last run and this one has its last_seen_at/context refreshed but is + NOT returned (that's the dedup guarantee).""" + result = await db.execute( + select(NotificationProblem).where( + NotificationProblem.problem_type == problem_type, + NotificationProblem.status == "open", + ) + ) + open_rows = {row.dedup_key: row for row in result.scalars().all()} + + # (problem_type, dedup_key) is unique across *all* rows, so a problem that + # was resolved earlier and now comes back must reuse its row instead of + # inserting a second one (that raised IntegrityError and aborted the whole + # check, silently dropping e.g. "rollback failed" alerts). + returning = [k for k in current if k not in open_rows] + resolved_rows = {} + if returning: + again = await db.execute( + select(NotificationProblem).where( + NotificationProblem.problem_type == problem_type, + NotificationProblem.dedup_key.in_(returning), + ) + ) + resolved_rows = {row.dedup_key: row for row in again.scalars().all()} + + opened: list[NotificationProblem] = [] + resolved: list[NotificationProblem] = [] + now = _now() + + for key, context in current.items(): + existing = open_rows.get(key) + if existing is None and key in resolved_rows: + row = resolved_rows[key] + row.status = "open" + row.context = context + row.first_detected_at = now + row.last_seen_at = now + row.resolved_at = None + row.last_notified_at = None + row.resolved_notified_at = None + opened.append(row) + elif existing is None: + row = NotificationProblem( + problem_type=problem_type, + dedup_key=key, + status="open", + context=context, + first_detected_at=now, + last_seen_at=now, + ) + db.add(row) + opened.append(row) + else: + existing.last_seen_at = now + existing.context = context + + for key, row in open_rows.items(): + if key not in current: + row.status = "resolved" + row.resolved_at = now + resolved.append(row) + + return opened, resolved diff --git a/backend/app/services/notification_delivery.py b/backend/app/services/notification_delivery.py new file mode 100644 index 0000000..4e29d2a --- /dev/null +++ b/backend/app/services/notification_delivery.py @@ -0,0 +1,111 @@ +"""Webhook + email delivery for notification_service.py. Webhook uses httpx +(already the project's only HTTP client); email uses stdlib smtplib/email, +run via asyncio.to_thread since smtplib is blocking — no new dependency +needed (requirements.txt has no SMTP library, and this project deliberately +avoids adding dependencies where the standard library already covers it). +""" +import asyncio +import hashlib +import hmac +import json +import smtplib +from email.message import EmailMessage + +import httpx + +from app.config import settings +from app.models.notification import NotificationProblem, NotificationSettings +from app.services import notification_secret_store + + +class NotificationDeliveryError(Exception): + pass + + +_LABELS = { + "host_offline": "Host nicht erreichbar", + "cert_expiry": "Zertifikat läuft ab", + "rule_drift": "Regel-Drift im Cluster", + "revert_failed": "Rollback fehlgeschlagen", + "maintenance_unknown": "Wartungsergebnis unklar", + "carp_split_brain": "CARP-Zustand kritisch", + "nat_drift": "NAT-Drift im Cluster", + "carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen", +} + + +def render_message(problem: NotificationProblem, transition: str) -> tuple[str, str]: + """transition: "opened" | "resolved". Single source of copy for both + delivery channels, so webhook and email never drift in wording.""" + label = _LABELS.get(problem.problem_type, problem.problem_type) + if transition == "resolved": + subject = f"OPNexus: {label} behoben" + else: + subject = f"OPNexus: {label}" + + lines = [subject, ""] + for key, value in problem.context.items(): + if value in (None, "", []): + continue + lines.append(f"{key}: {value}") + body = "\n".join(lines) + return subject, body + + +async def send_webhook(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None: + if not settings_row.webhook_url: + raise NotificationDeliveryError("Keine Webhook-URL konfiguriert") + subject, body = render_message(problem, transition) + payload = { + "problem_type": problem.problem_type, + "transition": transition, + "subject": subject, + "message": body, + "context": problem.context, + } + raw = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + if settings_row.webhook_signing_secret_set: + secret = notification_secret_store.read_secret("webhook_signing_secret") + if secret: + signature = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest() + headers["X-OPNexus-Signature"] = f"sha256={signature}" + + try: + async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client: + resp = await client.post(settings_row.webhook_url, content=raw, headers=headers) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise NotificationDeliveryError(f"Webhook-Zustellung fehlgeschlagen: {exc}") from exc + + +def _send_email_sync(settings_row: NotificationSettings, subject: str, body: str) -> None: + password = notification_secret_store.read_secret("smtp_password") + message = EmailMessage() + message["Subject"] = subject + message["From"] = settings_row.smtp_from_address + message["To"] = ", ".join(settings_row.smtp_to_addresses) + message.set_content(body) + + timeout = settings.notification_smtp_timeout_seconds + if settings_row.smtp_security == "ssl": + client = smtplib.SMTP_SSL(settings_row.smtp_host, settings_row.smtp_port, timeout=timeout) + else: + client = smtplib.SMTP(settings_row.smtp_host, settings_row.smtp_port, timeout=timeout) + try: + if settings_row.smtp_security == "starttls": + client.starttls() + if settings_row.smtp_username and password: + client.login(settings_row.smtp_username, password) + client.send_message(message) + finally: + client.quit() + + +async def send_email(settings_row: NotificationSettings, subject: str, body: str) -> None: + if not settings_row.smtp_host or not settings_row.smtp_from_address or not settings_row.smtp_to_addresses: + raise NotificationDeliveryError("SMTP-Konfiguration unvollständig") + try: + await asyncio.to_thread(_send_email_sync, settings_row, subject, body) + except (OSError, smtplib.SMTPException) as exc: + raise NotificationDeliveryError(f"E-Mail-Zustellung fehlgeschlagen: {exc}") from exc diff --git a/backend/app/services/notification_secret_store.py b/backend/app/services/notification_secret_store.py new file mode 100644 index 0000000..121de78 --- /dev/null +++ b/backend/app/services/notification_secret_store.py @@ -0,0 +1,34 @@ +"""Notification delivery secrets (SMTP password, webhook signing secret). + +Exact mirror of opnsense_secret_store.py's convention: the secret never +touches the database, only disk, mode 0600 — keyed by a fixed name instead +of a host id, since these are singleton secrets (one SMTP config, one +webhook config for the whole install). +""" +from pathlib import Path + +from app.config import settings + +_SUFFIX = ".secret" + + +def _path(name: str) -> Path: + return Path(settings.notification_secret_dir) / f"{name}{_SUFFIX}" + + +def save_secret(name: str, secret: str) -> None: + path = _path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret.strip()) + path.chmod(0o600) + + +def read_secret(name: str) -> str | None: + path = _path(name) + if not path.exists(): + return None + return path.read_text().strip() + + +def delete_secret(name: str) -> None: + _path(name).unlink(missing_ok=True) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..da7168e --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,83 @@ +"""Orchestrates the notification feature: runs each enabled check, dedups +against previously open problems, and delivers on transitions. The single +testable entry point — called directly by main.py's background loop AND +directly by tests, same pattern as rule_deploy_service.sweep_expired_deployments. +""" +import logging +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.notification import NotificationProblem, NotificationSettings +from app.services import notification_checks, notification_dedup, notification_delivery +from app.services import notification_settings_service + +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +# (problem_type, settings-enabled-flag, check function) +_CHECKS = [ + ("host_offline", "host_offline_enabled", notification_checks.check_host_offline), + ("cert_expiry", "cert_expiring_enabled", notification_checks.check_certificates), + ("rule_drift", "rule_drift_enabled", notification_checks.check_rule_drift), + ("revert_failed", "revert_failed_enabled", notification_checks.check_revert_failed), + ("maintenance_unknown", "maintenance_unknown_enabled", notification_checks.check_maintenance_unknown), + ("carp_split_brain", "carp_health_enabled", notification_checks.check_carp_health), + ("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift), + ("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed), +] + + +async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None: + """Best-effort, not an audit trail: attempted once per transition and + never retried, even on failure — avoids retry-storming a broken webhook + URL or SMTP relay every poll cycle.""" + if settings_row.webhook_enabled: + try: + await notification_delivery.send_webhook(settings_row, problem, transition) + except notification_delivery.NotificationDeliveryError: + logger.exception("Webhook delivery failed for %s/%s", problem.problem_type, problem.dedup_key) + if settings_row.email_enabled: + try: + subject, body = notification_delivery.render_message(problem, transition) + await notification_delivery.send_email(settings_row, subject, body) + except notification_delivery.NotificationDeliveryError: + logger.exception("Email delivery failed for %s/%s", problem.problem_type, problem.dedup_key) + + if transition == "resolved": + problem.resolved_notified_at = _now() + else: + problem.last_notified_at = _now() + + +async def run_notification_checks(db: AsyncSession) -> dict: + settings_row = await notification_settings_service.get_settings(db) + result = {"opened": 0, "resolved": 0} + + for problem_type, enabled_flag, check_fn in _CHECKS: + if not getattr(settings_row, enabled_flag): + # Deliberately leaves any already-open problems of this type + # untouched rather than auto-resolving them — disabling a + # check just stops new notifications, it isn't a claim that + # the underlying problem went away. + continue + try: + current = await check_fn(db) + except Exception: + logger.exception("Notification check %s failed", problem_type) + continue + + opened, resolved = await notification_dedup.diff_and_update(db, problem_type, current) + for problem in opened: + await _deliver(settings_row, problem, "opened") + for problem in resolved: + await _deliver(settings_row, problem, "resolved") + result["opened"] += len(opened) + result["resolved"] += len(resolved) + + await db.commit() + return result diff --git a/backend/app/services/notification_settings_service.py b/backend/app/services/notification_settings_service.py new file mode 100644 index 0000000..1eb3923 --- /dev/null +++ b/backend/app/services/notification_settings_service.py @@ -0,0 +1,51 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.notification import NotificationSettings +from app.schemas.notification import NotificationSettingsUpdate +from app.services import notification_secret_store + +_SINGLETON_ID = 1 + + +async def get_settings(db: AsyncSession) -> NotificationSettings: + row = await db.get(NotificationSettings, _SINGLETON_ID) + if row is None: + row = NotificationSettings(id=_SINGLETON_ID) + db.add(row) + await db.flush() + return row + + +async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate) -> NotificationSettings: + row = await get_settings(db) + + simple_fields = [ + "webhook_enabled", "webhook_url", + "email_enabled", "smtp_host", "smtp_port", "smtp_security", "smtp_username", + "smtp_from_address", "smtp_to_addresses", + "host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled", + "revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled", + "carp_auto_revert_failed_enabled", + "poll_interval_seconds", + ] + for field in simple_fields: + value = getattr(payload, field) + if value is not None: + setattr(row, field, value) + + if payload.clear_webhook_signing_secret: + notification_secret_store.delete_secret("webhook_signing_secret") + row.webhook_signing_secret_set = False + elif payload.webhook_signing_secret is not None: + notification_secret_store.save_secret("webhook_signing_secret", payload.webhook_signing_secret) + row.webhook_signing_secret_set = True + + if payload.clear_smtp_password: + notification_secret_store.delete_secret("smtp_password") + row.smtp_password_set = False + elif payload.smtp_password is not None: + notification_secret_store.save_secret("smtp_password", payload.smtp_password) + row.smtp_password_set = True + + await db.commit() + return row diff --git a/backend/app/services/one_to_one_deploy_service.py b/backend/app/services/one_to_one_deploy_service.py new file mode 100644 index 0000000..af63292 --- /dev/null +++ b/backend/app/services/one_to_one_deploy_service.py @@ -0,0 +1,200 @@ +"""Independent 1:1 NAT deploy / confirm / rollback state machine.""" +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.host import OpnsenseHost +from app.models.one_to_one import OneToOneRule +from app.models.one_to_one_deployment import OneToOneDeployment +from app.services.audit_service import tracked_rollback +from app.services import opnsense_client +from app.services.opnsense_client import OpnsenseAPIError + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _build_payload(rule: OneToOneRule) -> dict: + return { + "description": rule.description, + "interface": rule.interface, + "source_net": rule.source_net, + "destination_net": rule.destination_net, + "source_not": "1" if rule.source_not else "0", + "destination_not": "1" if rule.destination_not else "0", + "enabled": "1" if rule.enabled else "0", + "type": rule.type, + "external": rule.external, + "natreflection": rule.natreflection, + "log": "1" if rule.log else "0", + } + + +async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHost) -> OneToOneDeployment: + payload = _build_payload(rule) + existing = await db.scalar( + select(OneToOneDeployment).where( + OneToOneDeployment.one_to_one_id == rule.id, OneToOneDeployment.host_id == host.id + ) + ) + + if existing and existing.status in ("success", "reverted") and existing.last_payload == payload: + existing.status = "success" + existing.message = None + existing.revert_deadline = None + existing.deployed_at = _now() + return existing + + if existing and existing.status in ("pending", "revert_failed"): + return existing + + prior_uuid = existing.opnsense_one_to_one_uuid if existing else None + prior_live_payload = existing.last_payload if existing else None + new_uuid = prior_uuid + try: + if prior_uuid: + await opnsense_client.set_one_to_one_rule(host, prior_uuid, payload) + else: + result = await opnsense_client.add_one_to_one_rule(host, payload) + new_uuid = result.get("uuid") if isinstance(result, dict) else None + if not new_uuid: + raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen") + await opnsense_client.apply_one_to_one(host) + except OpnsenseAPIError as exc: + if existing: + existing.status = "error" + existing.opnsense_one_to_one_uuid = new_uuid + existing.message = str(exc) + existing.revert_deadline = None + existing.deployed_at = _now() + return existing + deployment = OneToOneDeployment( + one_to_one_id=rule.id, host_id=host.id, status="error", message=str(exc), + opnsense_one_to_one_uuid=new_uuid, deployed_at=_now(), + ) + db.add(deployment) + return deployment + + deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds) + if existing: + existing.status = "pending" + existing.message = None + existing.opnsense_one_to_one_uuid = new_uuid + existing.deployed_at = _now() + existing.revert_deadline = deadline + existing.previous_payload = prior_live_payload + existing.last_payload = payload + return existing + + deployment = OneToOneDeployment( + one_to_one_id=rule.id, host_id=host.id, status="pending", message=None, + opnsense_one_to_one_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline, + previous_payload=None, last_payload=payload, + ) + db.add(deployment) + return deployment + + +async def deploy_one_to_one(db: AsyncSession, rule: OneToOneRule, hosts: list[OpnsenseHost]) -> list[OneToOneDeployment]: + from app.services.host_maintenance_service import ensure_no_maintenance + from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change + await ensure_no_maintenance(db, hosts) + deployments = [await _deploy_to_host(db, rule, host) for host in hosts] + await db.flush() + for host, deployment in zip(hosts, deployments): + if deployment.status != "pending": + continue + try: + await reassert_after_managed_change(db, host, {rule.interface}) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = f"1:1 NAT wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}" + deployment.revert_deadline = None + await db.commit() + return deployments + + +async def confirm_one_to_one(db: AsyncSession, rule: OneToOneRule) -> list[OneToOneDeployment]: + result = await db.execute( + select(OneToOneDeployment).where( + OneToOneDeployment.one_to_one_id == rule.id, + OneToOneDeployment.status == "pending", + OneToOneDeployment.revert_deadline > _now(), + ) + ) + deployments = list(result.scalars().all()) + for deployment in deployments: + deployment.status = "success" + deployment.revert_deadline = None + await db.commit() + return deployments + + +async def sweep_expired_one_to_one_deployments(db: AsyncSession) -> list[OneToOneDeployment]: + from app.services.policy_service import policy_lock + await policy_lock(db) + result = await db.execute( + select(OneToOneDeployment).where( + OneToOneDeployment.status == "pending", OneToOneDeployment.revert_deadline <= _now() + ) + ) + expired = list(result.scalars().all()) + touched: list[OneToOneDeployment] = [] + + for deployment in expired: + await revert_deployment(db, deployment) + touched.append(deployment) + + await db.commit() + return touched + + +async def undeploy_one_to_one(db: AsyncSession, rule: OneToOneRule) -> None: + failures = [] + result = await db.execute(select(OneToOneDeployment).where(OneToOneDeployment.one_to_one_id == rule.id)) + for deployment in result.scalars().all(): + host = await db.get(OpnsenseHost, deployment.host_id) + if host and deployment.opnsense_one_to_one_uuid: + try: + await opnsense_client.del_one_to_one_rule(host, deployment.opnsense_one_to_one_uuid) + await opnsense_client.apply_one_to_one(host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + failures.append(host.name) + continue + await db.delete(deployment) + await db.flush() + if failures: + await db.commit() + raise OpnsenseAPIError("1:1 NAT-Regel konnte nicht von allen Hosts entfernt werden: " + ", ".join(failures)) + + +@tracked_rollback("one_to_one") +async def revert_deployment(db: AsyncSession, deployment: OneToOneDeployment) -> bool: + host = await db.get(OpnsenseHost, deployment.host_id) + if host is None: + await db.delete(deployment) + return True + try: + if deployment.previous_payload is None: + if deployment.opnsense_one_to_one_uuid: + await opnsense_client.del_one_to_one_rule(host, deployment.opnsense_one_to_one_uuid) + await opnsense_client.apply_one_to_one(host) + await db.delete(deployment) + else: + await opnsense_client.set_one_to_one_rule(host, deployment.opnsense_one_to_one_uuid, deployment.previous_payload) + await opnsense_client.apply_one_to_one(host) + deployment.last_payload = deployment.previous_payload + deployment.status = "reverted" + deployment.message = None + deployment.revert_deadline = None + return True + except OpnsenseAPIError as exc: + deployment.status = "revert_failed" + deployment.message = str(exc) + deployment.revert_deadline = None + return False diff --git a/backend/app/services/one_to_one_policy_service.py b/backend/app/services/one_to_one_policy_service.py new file mode 100644 index 0000000..0195d5a --- /dev/null +++ b/backend/app/services/one_to_one_policy_service.py @@ -0,0 +1,105 @@ +"""Draft/preview/push state machine for 1:1 NAT rules — mirrors +alias_policy_service.py exactly. effective_hosts is generic over any +{targets, groups, deployments} entity, reused directly from +policy_service rather than duplicated. +""" +import hashlib +import json + +from app.models.policy import OneToOneRevision +from app.services.one_to_one_deploy_service import _build_payload +from app.services.policy_service import effective_hosts + +ONE_TO_ONE_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'type', 'external', 'natreflection', 'log') + + +def snapshot(rule): + return { + **{key: getattr(rule, key) for key in ONE_TO_ONE_FIELDS}, + "target_host_ids": sorted(str(h.id) for h in rule.targets), + "target_group_ids": sorted(str(g.id) for g in rule.groups), + "policy_hierarchy_group_id": str(rule.policy_hierarchy_group_id) if rule.policy_hierarchy_group_id else None, + } + + +def revision(db, rule, note): + db.add(OneToOneRevision(one_to_one_id=rule.id, note=note, snapshot=snapshot(rule))) + + +def preview(rule): + payload = _build_payload(rule) + deployments = {d.host_id: d for d in rule.deployments} + hosts = effective_hosts(rule) + blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments) + rows = [] + for host in hosts: + dep = deployments.get(host.id) + before = dep.last_payload if dep else None + action = "create" if not dep or not dep.opnsense_one_to_one_uuid else "update" + if before == payload and dep and dep.status in ("success", "reverted"): + action = "unchanged" + if not host.enabled: + action = "disabled" + rows.append({"host_id": str(host.id), "host_name": host.name, "action": action, + "status": dep.status if dep else None, + "message": dep.message if dep else None, + "can_remove": False, + "can_retry_rollback": bool(dep and dep.status == "revert_failed" and host.enabled), + "changes": [{"field": k, "before": (before or {}).get(k), "after": v} + for k, v in payload.items() if (before or {}).get(k) != v]}) + target_ids = {h.id for h in hosts} + for dep in rule.deployments: + if dep.host_id not in target_ids: + rows.append({"host_id": str(dep.host_id), "host_name": dep.host.name, + "action": "retained", "status": dep.status, "message": dep.message, + "can_remove": bool(not blocked and dep.host.enabled and dep.opnsense_one_to_one_uuid), + "can_retry_rollback": bool(dep.status == "revert_failed" and dep.host.enabled), + "changes": []}) + warnings = [] + if blocked: + warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.") + if any(r["action"] == "retained" for r in rows): + warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.") + if any(r["action"] == "disabled" for r in rows): + warnings.append("Deaktivierte Hosts werden übersprungen.") + if any(d.status == "error" for d in rule.deployments): + warnings.append("Vorheriger Push fehlgeschlagen. Vor erneutem Push den Firewall-Stand prüfen.") + state = {"snapshot": snapshot(rule), "rows": rows, + "hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts], + "deployments": sorted([(str(d.host_id), d.opnsense_one_to_one_uuid, d.last_payload, d.previous_payload, + d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])} + token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() + return {"token": token, "hosts": rows, "warnings": warnings, + "can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows), + "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."} + + +def workflow(rule, deployments): + hosts = effective_hosts(rule) + active = [h for h in hosts if h.enabled] + by_host = {d.host_id: d for d in deployments} + payload = _build_payload(rule) + target_ids = {h.id for h in hosts} + counts = { + "targets": len(hosts), + "active_targets": len(active), + "changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload), + "pending": sum(d.status == "pending" for d in deployments), + "errors": sum(d.status in ("error", "revert_failed") for d in deployments), + "retained": sum(d.host_id not in target_ids for d in deployments), + } + if counts["errors"]: + state = "attention" + elif counts["pending"]: + state = "pending" + elif counts["retained"]: + state = "cleanup" + elif not hosts: + state = "unassigned" + elif not active: + state = "disabled" + elif counts["changes"]: + state = "draft" + else: + state = "synced" + return state, counts diff --git a/backend/app/services/openvpn_deploy_service.py b/backend/app/services/openvpn_deploy_service.py new file mode 100644 index 0000000..899af9f --- /dev/null +++ b/backend/app/services/openvpn_deploy_service.py @@ -0,0 +1,379 @@ +"""Pushes a centrally-authored OpenVPN instance (v1 scope, see below) to +its target hosts, the same "OPNexus writes via the API instead of relying +on OPNsense's own HA config sync" model as ipsec_deploy_service.py / +rule_deploy_service.py. + +VERIFIED FIELDS / ENDPOINTS (fetched directly from +raw.githubusercontent.com/opnsense/core/master/..., branch master, +2026-09-09 — downloaded the raw XML/PHP files directly rather than relying +on a summarized fetch, specifically to catch exact field names and the +exact reconfigure response shape without risking paraphrase error; see +opnsense_client.py's "OpenVPN" section for the endpoint list and its own +citations): + +- OpenVPN.xml (mount `//OPNsense/OpenVPN`, version 1.0.1) -> + Instances.Instance: vpnid (VPNIdField, Required, MinimumValue 1, NO + — never sent by this app; OPNsense's own UI never prompts for + one either, so its field-type presumably auto-assigns one the same way + on addAction — NOT independently verified, flagged rather than guessed), + enabled (BooleanField, Default 1), dev_type (OptionValues + tun/tap/ovpn(DCO), Default "tun" — never sent, relies on the model + default exactly like IPsec relies on Child.mode defaulting to "tunnel"), + proto (OptionValues udp/udp4/udp6/tcp/tcp4/tcp6, Default "udp" — v1 + exposes only "udp"/"tcp"), port (PortField), local (NetworkField, no + netmask — server-role bind address), topology (Default "subnet", never + sent), remote (RemoteHostField — client-role peer), role (OptionValues + ONLY "client"/"server", Default "server" — see the scope-decision + section below, this is the key finding that reshaped v1), server + (NetworkField, NetMaskRequired Y — server-role tunnel network), cert / + ca (CertificateField, i.e. ModelRelationField into OPNsense's own + Trust\\Cert store), tls_key (ModelRelationField -> this SAME model's + StaticKeys.StaticKey, display "description,mode"), auth/data-ciphers + (cipher suite OptionFields — v1 always omits these, relying on + OPNsense's own defaults, exactly like IPsec's proposals default to + "default"), description (DescriptionField). +- OpenVPN.xml -> StaticKeys.StaticKey (separate top-level ArrayField, NOT + nested under Instance): mode (OptionValues auth/crypt/crypt-v2, Default + "crypt"), key (TextField, Required, masked to the base64-ish charset + OpenVPN static keys use), description (DescriptionField, Required). + Referenced from an Instance by UUID via `tls_key` — this app creates/ + updates the StaticKey FIRST, then writes that UUID into the Instance's + `tls_key` field, mirroring exactly how IPsec's Local/Remote/Child + reference their parent Connection's UUID. +- InstancesController.php (module "openvpn", controller "instances", + `class InstancesController extends ApiMutableModelControllerBase`, + internalModelName 'instance', internalModelClass + 'OPNsense\\OpenVPN\\OpenVPN'): searchAction/getAction/addAction/ + setAction/delAction/toggleAction (Instances.Instance) and + searchStaticKeyAction/getStaticKeyAction/addStaticKeyAction/ + setStaticKeyAction/delStaticKeyAction (StaticKeys.StaticKey, + internalModelName 'statickey') — all confirmed as literal PHP method + names read directly from the controller source. genKeyAction (backend + key generation) exists but is deliberately unused — see + opnsense_client.py. +- ServiceController.php (module "openvpn", controller "service"): + reconfigureAction's exact body, read directly from source: + `(new Backend())->configdRun('openvpn configure'); + $this->runInterfaceRegistration(); return ['result' => 'ok'];` — a + hand-rolled ApiControllerBase method, NOT inherited from + ApiMutableServiceControllerBase like IPsec's reconfigureAction is. This + matters concretely: its success response key is "result", NOT "status" + like IPsec's ({"status": "ok"}) — checked explicitly rather than assumed + symmetric, per the plan's own warning not to assume this. Also present + in this controller but unused by this app: startServiceAction/ + stopServiceAction/restartServiceAction (per-instance start/stop/restart + via `configdpRun('service {start,stop,restart}', ['openvpn', $id])`) — + this app always uses the global reconfigureAction as its one apply step, + the same choice IPsec made. +- Router.php::parsePath() URL-segment convention: identical to the one + already documented in opnsense_client.py's IPsec section (each URL + segment + "Action" = the PHP method name) — e.g. "addStaticKey" -> + addStaticKeyAction(). Re-confirmed here, not re-derived independently. + +SCOPE DECISION — why this ISN'T "static-key-only auth" the way IPsec is +"PSK-only auth", and what v1 actually does instead: + +The plan anticipated that if certificate-based auth needed "deep +integration" with the existing read-only certificate feature, v1 could +fall back to a simpler "static-key (pre-shared) auth only" mode, the +same way IPsec deferred certs/EAP in favor of PSK-only. Reading +OpenVPN.xml directly shows this fallback doesn't exist as such: `role`'s +OptionValues are ONLY "client"/"server" — the legacy (pre-24.1) +openvpn-server/openvpn-client sections' third `mode`, "p2p_shared_key" +(a real, certificate-free "secret" directive), has NO equivalent in the +modern unified Instance model. Every Instance, regardless of role, still +needs a working TLS handshake — i.e. `cert` and `ca` populated with a +certificate/CA that already exists on the target host — to actually pass +traffic. `tls_key` (this app's StaticKey support) is only OpenVPN's +--tls-auth/--tls-crypt control-channel hardening, layered ON TOP of that +TLS handshake, not a replacement for it — so it cannot fill the role IPsec +PSK filled (the sole, sufficient auth material). + +Given that, the decision made here (revised 2026-09-09, see +models/openvpn_instance.py's class docstring for the original v1 rationale +this replaces): `cert_ref`/`ca_ref` are per-host maps (`cert_refs`/ +`ca_refs`, `{host_id_str: uuid}`), populated via a frontend picker backed +by this project's read-only `GET /api/hosts/{id}/certificates` and its +sibling `GET /api/hosts/{id}/cas`, rather than one hand-typed string +assumed valid on every target host — a real, not hypothetical, gap, since +instances already support genuine multi-host targeting and a cert's UUID +is host-local. `build_payloads()` below resolves the pair for one specific +host_id and raises `MissingCertError` (handled as a fail-closed, no-write +deployment error) if that host has no selection yet — OPNexus still never +validates the UUID's *content* against anything (OPNsense's own API +remains the source of truth and will reject a bad value with a concrete +error, same trust model IPsec uses for local_address/remote_address), it +just guarantees the UUID sent for a given host was actually chosen FOR +that host. tls_key/StaticKey support IS fully built end-to-end (this app +CAN manage that object completely, the same way it +fully manages IPsec's PSK), as an OPTIONAL hardening layer — the closest +available analogue to "PSK-only", just not a drop-in substitute for it. +Deferred to a later pass, matching how IPsec deferred certs/EAP: an actual +certificate-management/picker integration (creating CAs/certs, or at +least a per-host cert dropdown backed by the read-only certs endpoint). + +DELIBERATELY SIMPLER STATUS MODEL THAN rule_deploy_service.py — identical +rationale to IpsecTunnel (see ipsec_deploy_service.py's module docstring +for the full argument): an OpenVPN `reconfigure` can equally sever the +exact management path OPNexus needs to auto-revert it, so this entity +also only ever reports "success" or "error" — no pending/confirm/ +auto-revert window, no revert attempted automatically or otherwise. +Undeploy (delete) is a normal explicit action, same scope decision as +IPsec/rule delete. + +Per-host deploy order (mirrors the OPNsense model's own dependency +direction — Instance.tls_key references the StaticKey's UUID, so the +StaticKey must exist first): StaticKey (only if tls_key_enabled), then +Instance. If tls_key_enabled is turned OFF after previously being on, the +now-orphaned StaticKey is explicitly deleted as part of the same config +write (not left behind as clutter) and the Instance's `tls_key` is +explicitly overlaid to "" to clear the relation — ApiMutableModelController +Base::setBase() only overlays the keys actually posted (same behavior +already relied on for IPsec), so clearing a previously-set +ModelRelationField requires explicitly posting an empty value for it, +not just omitting the key. Undeploy runs the reverse (Instance deleted +before StaticKey, respecting the reference direction), then reconfigures. +If any config-write step fails, the deploy stops there: status="error" +with the concrete failure message, and whatever was already written up to +that point on that host is NOT rolled back (no savepoint/rollback +primitive exists on the OPNsense side, same as every other entity here). +A follow-up deploy attempt reuses whatever UUIDs got created instead of +duplicating them. + +The `reconfigure` call is a separate step after all config writes +succeed, per the same pattern IPsec uses: if it fails, status="error" +with the specific message "Konfiguration wurde geschrieben, Dienst-Reload +aber nicht bestätigt — Erreichbarkeit manuell prüfen" (byte-for-byte the +same message IPsec uses, deliberately — it's the exact same failure mode: +config IS live in the OPNsense model at that point, only the reload +confirmation is missing). The frontend gates the whole "push" action +(config write + reconfigure, one backend call) behind an explicit, +hard-to-miss warning — see frontend/components/OpenVpnInstanceReview.tsx — +not two separate user-facing steps. +""" +import hashlib +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.host import OpnsenseHost +from app.models.openvpn_instance import OpenVpnInstance +from app.models.openvpn_instance_deployment import OpenVpnInstanceDeployment +from app.services import opnsense_client, openvpn_key_store +from app.services.opnsense_client import OpnsenseAPIError + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _redact_for_storage(payloads: dict) -> dict: + """SECURITY FIX (2026-09-09): `payloads["statickey"]["key"]` carries + the raw tls-auth/tls-crypt secret, needed on the wire, but `payloads` + itself used to also become `deployment.last_payload` verbatim — i.e. + the secret was persisted in plaintext into Postgres, directly + contradicting openvpn_key_store.py's own "never touches the database, + disk-only" docstring. Fixed the same way IPsec's build_payloads()/ + _redact_for_storage() now is: store/compare a SHA-256 hash of the + secret instead of the secret itself, so a rotated key (with every + other field unchanged) still counts as a real change to push, without + the recoverable secret ever reaching the deployments table.""" + redacted = dict(payloads) + statickey = payloads.get("statickey") + if statickey is not None: + redacted_statickey = {**statickey} + secret = redacted_statickey.get("key", "") + redacted_statickey["key"] = hashlib.sha256(secret.encode()).hexdigest() if secret else "" + redacted["statickey"] = redacted_statickey + return redacted + + +class MissingCertError(ValueError): + """No certificate/CA selected for this specific target host — the + caller must fail closed (no config write, no reconfigure) rather than + send a blank or wrong-host UUID.""" + + +def build_payloads(instance: OpenVpnInstance, host_id: uuid.UUID) -> dict: + """The (up to) 2 OPNsense-shaped sub-payloads for this instance/host + pair, keyed the way _write_config uses them. `statickey` is None when + tls_key hardening is disabled — see module docstring. `instance["tls_key"]` + is deliberately NOT included here (it's the deploy-time-injected + StaticKey UUID or ""), the same "don't persist deploy-time wiring into + the dict used for no-op comparison" rule IPsec's build_payloads() + documents for its own `connection` field. + + cert/ca are resolved per host_id — see models/openvpn_instance.py's + class docstring for why a single shared UUID can't work across hosts. + Raises MissingCertError if this host has no selection yet.""" + cert = instance.cert_refs.get(str(host_id)) + ca = instance.ca_refs.get(str(host_id)) + if not cert or not ca: + raise MissingCertError(f"Kein Zertifikat/CA für Host {host_id} ausgewählt") + + statickey_payload = None + if instance.tls_key_enabled: + key_material = openvpn_key_store.read_secret(instance.id) or "" + statickey_payload = { + "mode": instance.tls_key_mode, + "key": key_material, + "description": instance.description, + } + return { + "statickey": statickey_payload, + "instance": { + "enabled": "1" if instance.enabled else "0", + "role": instance.role, + "proto": instance.proto, + "port": instance.port, + "local": instance.local, + "remote": instance.remote, + "server": instance.server_network, + "cert": cert, + "ca": ca, + "description": instance.description, + }, + } + + +async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnInstanceDeployment) -> None: + """Writes the StaticKey (if enabled) then the Instance, in that + dependency order, persisting each UUID onto `deployment` as soon as + it's known so a later retry (after a partial failure) updates rather + than duplicates. Raises OpnsenseAPIError uncaught on the first + failure — the caller decides what that means for `deployment.status`.""" + statickey_payload = payloads["statickey"] + if statickey_payload is not None: + if deployment.opnsense_statickey_uuid: + await opnsense_client.set_openvpn_static_key(host, deployment.opnsense_statickey_uuid, statickey_payload) + else: + result = await opnsense_client.add_openvpn_static_key(host, statickey_payload) + deployment.opnsense_statickey_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_statickey_uuid: + raise OpnsenseAPIError("Firewall hat keine StaticKey-UUID zurückgegeben; Stand manuell prüfen") + elif deployment.opnsense_statickey_uuid: + # tls_key hardening was switched off after previously being on — + # remove the now-orphaned StaticKey rather than leaving it behind. + await opnsense_client.del_openvpn_static_key(host, deployment.opnsense_statickey_uuid) + deployment.opnsense_statickey_uuid = None + + # Overlay the deploy-time-known tls_key UUID (or "" to explicitly + # clear a previously-set relation — see module docstring) onto a + # shallow copy, never mutating `payloads["instance"]` itself: the same + # reasoning ipsec_deploy_service.py documents for its own + # local_wire/remote_wire/child_wire, to avoid breaking no-op-redeploy + # detection on the next deploy. + instance_wire = {**payloads["instance"], "tls_key": deployment.opnsense_statickey_uuid or ""} + + if deployment.opnsense_instance_uuid: + await opnsense_client.set_openvpn_instance(host, deployment.opnsense_instance_uuid, instance_wire) + else: + result = await opnsense_client.add_openvpn_instance(host, instance_wire) + deployment.opnsense_instance_uuid = result.get("uuid") if isinstance(result, dict) else None + if not deployment.opnsense_instance_uuid: + raise OpnsenseAPIError("Firewall hat keine Instance-UUID zurückgegeben; Stand manuell prüfen") + + +async def _deploy_to_host(db: AsyncSession, instance: OpenVpnInstance, host: OpnsenseHost) -> OpenVpnInstanceDeployment: + existing = await db.scalar( + select(OpenVpnInstanceDeployment).where( + OpenVpnInstanceDeployment.instance_id == instance.id, OpenVpnInstanceDeployment.host_id == host.id + ) + ) + try: + payloads = build_payloads(instance, host.id) + except MissingCertError as exc: + # Fail closed: no config write, no reconfigure call — never push a + # blank or wrong-host cert/CA reference. + deployment = existing or OpenVpnInstanceDeployment(instance_id=instance.id, host_id=host.id, status="error") + deployment.status = "error" + deployment.message = str(exc) + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + comparable = _redact_for_storage(payloads) + if existing and existing.status == "success" and existing.last_payload == comparable: + # Nothing actually changed on this host — re-affirm without + # touching the firewall at all (no risk was introduced). + existing.message = None + existing.deployed_at = _now() + return existing + + deployment = existing or OpenVpnInstanceDeployment(instance_id=instance.id, host_id=host.id, status="error") + + try: + await _write_config(host, payloads, deployment) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + try: + await opnsense_client.reconfigure_openvpn(host) + except OpnsenseAPIError: + # Config IS live at this point — this exact message is specified + # by the plan (byte-for-byte the same as IPsec's), not a generic + # str(exc), because "the config write failed" and "the config + # wrote fine but the reload wasn't confirmed" are different + # failure modes an operator needs to react to differently. + deployment.status = "error" + deployment.message = "Konfiguration wurde geschrieben, Dienst-Reload aber nicht bestätigt — Erreichbarkeit manuell prüfen" + deployment.last_payload = comparable + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + deployment.status = "success" + deployment.message = None + deployment.last_payload = comparable + deployment.deployed_at = _now() + if not existing: + db.add(deployment) + return deployment + + +async def deploy_instance(db: AsyncSession, instance: OpenVpnInstance, hosts: list[OpnsenseHost]) -> list[OpenVpnInstanceDeployment]: + from app.services.host_maintenance_service import ensure_no_maintenance + await ensure_no_maintenance(db, hosts) + deployments = [await _deploy_to_host(db, instance, host) for host in hosts] + await db.commit() + return deployments + + +async def undeploy_instance(db: AsyncSession, instance: OpenVpnInstance) -> None: + """Explicit action, not gated by anything (there is no lockout state + for this entity to gate on) — deletes the Instance before its + referenced StaticKey (the ModelRelationField dependency direction), + then reconfigures.""" + failures = [] + result = await db.execute(select(OpenVpnInstanceDeployment).where(OpenVpnInstanceDeployment.instance_id == instance.id)) + for deployment in result.scalars().all(): + host = await db.get(OpnsenseHost, deployment.host_id) + if host is None: + await db.delete(deployment) + continue + try: + if deployment.opnsense_instance_uuid: + await opnsense_client.del_openvpn_instance(host, deployment.opnsense_instance_uuid) + if deployment.opnsense_statickey_uuid: + await opnsense_client.del_openvpn_static_key(host, deployment.opnsense_statickey_uuid) + if deployment.opnsense_instance_uuid or deployment.opnsense_statickey_uuid: + await opnsense_client.reconfigure_openvpn(host) + except OpnsenseAPIError as exc: + deployment.status = "error" + deployment.message = str(exc) + failures.append(host.name) + continue + await db.delete(deployment) + await db.flush() + if failures: + await db.commit() + raise OpnsenseAPIError("Instanz konnte nicht von allen Hosts entfernt werden: " + ", ".join(failures)) diff --git a/backend/app/services/openvpn_key_store.py b/backend/app/services/openvpn_key_store.py new file mode 100644 index 0000000..5897b15 --- /dev/null +++ b/backend/app/services/openvpn_key_store.py @@ -0,0 +1,47 @@ +"""Per-instance OpenVPN static-key (tls-auth/tls-crypt hardening) material +storage. + +Mirrors ipsec_psk_store.py's convention exactly (itself mirroring +opnsense_secret_store.py): the raw key blob never touches the database +as its own column, only disk, one file per instance id, mode 0600. +OpenVpnInstance.tls_key_material_set is the DB-side flag tracking whether +a file exists, same pattern as IpsecTunnel.pre_shared_key_set. + +Note (documented, not a new issue introduced here): exactly like +ipsec_deploy_service.py's build_payloads(), the StaticKey payload built +for deploy embeds this same raw key text under payloads["statickey"]["key"] +so no-op-redeploy detection can compare it — and that whole payloads dict +is what gets persisted as OpenVpnInstanceDeployment.last_payload (a +Postgres JSON column). So the key material DOES end up in Postgres via +last_payload, same pre-existing wrinkle already present for IPsec's PSK +(see IpsecTunnelDeployment.last_payload) — matched here for consistency +with the established pattern rather than diverging from it. +""" +import uuid +from pathlib import Path + +from app.config import settings + +_SUFFIX = ".ovpnkey" + + +def _path(instance_id: uuid.UUID) -> Path: + return Path(settings.openvpn_key_secret_dir) / f"{instance_id}{_SUFFIX}" + + +def save_secret(instance_id: uuid.UUID, secret: str) -> None: + path = _path(instance_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret.strip()) + path.chmod(0o600) + + +def read_secret(instance_id: uuid.UUID) -> str | None: + path = _path(instance_id) + if not path.exists(): + return None + return path.read_text().strip() + + +def delete_secret(instance_id: uuid.UUID) -> None: + _path(instance_id).unlink(missing_ok=True) diff --git a/backend/app/services/openvpn_service.py b/backend/app/services/openvpn_service.py new file mode 100644 index 0000000..7384ce8 --- /dev/null +++ b/backend/app/services/openvpn_service.py @@ -0,0 +1,160 @@ +"""Non-deploy helpers for OpenVpnInstance: snapshot/revision/preview/ +workflow, mirroring ipsec_service.py's role for IpsecTunnel. +effective_hosts() and policy_lock() are NOT reimplemented here — +policy_service's versions work unchanged for any object exposing +`.targets`/`.groups`, same reuse ipsec_service.py already established.""" +import hashlib +import json + +from app.models.policy import OpenVpnInstanceRevision +from app.services.openvpn_deploy_service import MissingCertError, build_payloads +from app.services.policy_service import effective_hosts # noqa: F401 - re-exported for routers/openvpn.py + +OPENVPN_FIELDS = ( + "description", "role", "proto", "port", "local", "remote", "server_network", + "cert_refs", "ca_refs", "tls_key_enabled", "tls_key_mode", "enabled", +) + +# Exact wording shared verbatim with frontend/components/OpenVpnInstanceReview.tsx +# (its NO_ROLLBACK_WARNING constant) so the frontend can filter it out of +# the generic warnings list and render it as its own prominent banner — +# same technique already used for IpsecTunnelReview.tsx. +NO_ROLLBACK_WARNING = ( + "Kein automatischer Rollback: falls dieser Host nur über den OpenVPN-Tunnel erreichbar ist, den du gerade " + "änderst, kann OPNexus die Verbindung nach einem Fehler NICHT automatisch wiederherstellen." +) + + +def snapshot(instance): + return { + **{key: getattr(instance, key) for key in OPENVPN_FIELDS}, + "tls_key_material_set": instance.tls_key_material_set, + "target_host_ids": sorted(str(h.id) for h in instance.targets), + "target_group_ids": sorted(str(g.id) for g in instance.groups), + } + + +def revision(db, instance, note): + db.add(OpenVpnInstanceRevision(instance_id=instance.id, note=note, snapshot=snapshot(instance))) + + +def preview(instance): + deployments = {d.host_id: d for d in instance.deployments} + hosts = effective_hosts(instance) + rows = [] + for host in hosts: + dep = deployments.get(host.id) + before = dep.last_payload if dep else None + try: + payloads = build_payloads(instance, host.id) + except MissingCertError: + rows.append({ + "host_id": str(host.id), "host_name": host.name, "action": "missing_cert", + "status": dep.status if dep else None, + "message": "Kein Zertifikat/CA für diesen Host ausgewählt.", + "can_remove": False, + "changes": [], + }) + continue + action = "create" if not dep or not dep.opnsense_instance_uuid else "update" + if before == payloads and dep and dep.status == "success": + action = "unchanged" + if not host.enabled: + action = "disabled" + changes = [] + if action not in ("disabled",): + for section in ("statickey", "instance"): + before_section = (before or {}).get(section) or {} + after_section = payloads[section] or {} + keys = set(before_section) | set(after_section) + for key in keys: + if key == "tls_key": + continue # UUID filled in at deploy time, not a user-visible field + prior = before_section.get(key) + value = after_section.get(key) + if prior != value: + label = "Static-Key" if section == "statickey" and key == "key" else f"{section}.{key}" + # Never surface the key material's own value in a diff view. + shown_before = "•••" if section == "statickey" and key == "key" and prior else prior + shown_after = "•••" if section == "statickey" and key == "key" and value else value + changes.append({"field": label, "before": shown_before, "after": shown_after}) + rows.append({ + "host_id": str(host.id), "host_name": host.name, "action": action, + "status": dep.status if dep else None, + "message": dep.message if dep else None, + "can_remove": False, + "changes": changes, + }) + target_ids = {h.id for h in hosts} + for dep in instance.deployments: + if dep.host_id not in target_ids: + rows.append({ + "host_id": str(dep.host_id), "host_name": dep.host.name, + "action": "retained", "status": dep.status, "message": dep.message, + "can_remove": bool(dep.host.enabled and (dep.opnsense_instance_uuid or dep.opnsense_statickey_uuid)), + "changes": [], + }) + warnings = [NO_ROLLBACK_WARNING] + if any(r["action"] == "retained" for r in rows): + warnings.append("Frühere Ziele behalten ihre Instanz, bis du sie hier gezielt entfernst.") + if any(r["action"] == "disabled" for r in rows): + warnings.append("Deaktivierte Hosts werden übersprungen.") + if any(r["action"] == "missing_cert" for r in rows): + warnings.append("Für mindestens einen Ziel-Host fehlt eine Zertifikat-/CA-Auswahl. Dieser Host wird beim Push mit einem Fehler übersprungen.") + if any(d.status == "error" for d in instance.deployments): + warnings.append("Vorheriger Push fehlgeschlagen. Vor erneutem Push den Firewall-Stand prüfen.") + if instance.tls_key_enabled and not instance.tls_key_material_set: + warnings.append("TLS-Hardening (tls-auth/tls-crypt) ist aktiviert, aber es wurde noch kein Schlüsselmaterial hinterlegt.") + blocked = instance.tls_key_enabled and not instance.tls_key_material_set + state = { + "snapshot": snapshot(instance), "rows": rows, + "hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts], + "deployments": sorted( + [(str(d.host_id), d.opnsense_statickey_uuid, d.opnsense_instance_uuid, d.last_payload, + d.host.base_url, d.host.enabled, d.host.verify_tls) + for d in instance.deployments], + key=lambda row: row[0], + ), + } + token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() + return { + "token": token, "hosts": rows, "warnings": warnings, + "can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows), + "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich.", + } + + +def _host_changed(instance, by_host, host): + try: + payloads = build_payloads(instance, host.id) + except MissingCertError: + return True + dep = by_host.get(host.id) + return dep is None or dep.last_payload != payloads + + +def workflow(instance, deployments): + hosts = effective_hosts(instance) + active = [h for h in hosts if h.enabled] + by_host = {d.host_id: d for d in deployments} + target_ids = {h.id for h in hosts} + counts = { + "targets": len(hosts), + "active_targets": len(active), + "changes": sum(1 for h in active if _host_changed(instance, by_host, h)), + "errors": sum(d.status == "error" for d in deployments), + "retained": sum(d.host_id not in target_ids for d in deployments), + } + if counts["errors"]: + state = "attention" + elif counts["retained"]: + state = "cleanup" + elif not hosts: + state = "unassigned" + elif not active: + state = "disabled" + elif counts["changes"] or (instance.tls_key_enabled and not instance.tls_key_material_set): + state = "draft" + else: + state = "synced" + return state, counts diff --git a/backend/app/services/opnsense_client.py b/backend/app/services/opnsense_client.py new file mode 100644 index 0000000..3d916f8 --- /dev/null +++ b/backend/app/services/opnsense_client.py @@ -0,0 +1,983 @@ +"""Thin async client for the OPNsense REST API. + +Endpoint paths verified against docs.opnsense.org and the OPNsense core +source (FilterController.php, Diagnostics/Api/InterfaceController.php, +Diagnostics/Api/FirewallController.php) — see README for the full table. +All of these are CORE endpoints; no os-firewall plugin exists or is +required. + +Auth is HTTP Basic: username = API key, password = API secret. +""" +import logging +import json +from typing import Any + +import httpx + +from app.config import settings +from app.models.host import OpnsenseHost +from app.services import opnsense_secret_store, tls_pin_service + +logger = logging.getLogger(__name__) + + +class OpnsenseAPIError(Exception): + pass + + +def _client(host: OpnsenseHost) -> httpx.AsyncClient: + secret = opnsense_secret_store.read_secret(host.id) + if not secret: + raise OpnsenseAPIError(f"Kein API-Secret für Host '{host.name}' hinterlegt") + return httpx.AsyncClient( + base_url=host.base_url.rstrip("/") + "/api", + auth=(host.api_key, secret), + verify=tls_pin_service.verify_argument(host.verify_tls, host.tls_ca_pem), + timeout=settings.opnsense_timeout, + ) + + +async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> httpx.Response: + async with _client(host) as client: + try: + resp = await client.request(method, path, **kwargs) + resp.raise_for_status() + return resp + except httpx.HTTPStatusError as exc: + raise OpnsenseAPIError(f"{exc.response.status_code} {path}: {exc.response.text}") from exc + except httpx.HTTPError as exc: + if host.tls_ca_pem and "CERTIFICATE_VERIFY_FAILED" in str(exc): + raise OpnsenseAPIError( + f"Das TLS-Zertifikat von {host.name} passt nicht zum festgesetzten Zertifikat " + "(Zertifikat erneuert oder Verbindung manipuliert?)" + ) from exc + if isinstance(exc, httpx.RemoteProtocolError): + # The firewall answered, but the HTTP framing is broken (seen with + # OPNsense 26.7: kernel TLS corrupts responses above ~64 KiB). + raise OpnsenseAPIError( + f"{host.name} hat eine beschädigte HTTP-Antwort geliefert ({exc}). " + "Bei OPNsense 26.x hilft meist `kern.ipc.tls.enable=0` (System ▸ Einstellungen ▸ " + "Tunables), siehe Handbuch „Fehlerbehebung“." + ) from exc + raise OpnsenseAPIError(f"Verbindung zu {host.name} fehlgeschlagen: {exc}") from exc + + +async def _request(host: OpnsenseHost, method: str, path: str, **kwargs) -> Any: + resp = await _do_request(host, method, path, **kwargs) + if not resp.content: + return None + try: + return resp.json() + except ValueError as exc: + raise OpnsenseAPIError(f"Ungültige API-Antwort von {host.name} bei {path}") from exc + + +async def _request_raw(host: OpnsenseHost, method: str, path: str, **kwargs) -> bytes: + """Like _request(), but for endpoints that return a raw byte payload + (e.g. backup/download's application/octet-stream) instead of JSON.""" + resp = await _do_request(host, method, path, **kwargs) + return resp.content + + +async def get_vip_status(host: OpnsenseHost) -> dict: + return await _request(host, "GET", "/diagnostics/interface/getVipStatus") + + +# ── Resource monitoring (read-only) ───────────────────────────────────── +# Diagnostics\Api\SystemController + InterfaceController, verified against +# core source (github.com/opnsense/core, master) AND live-verified with a +# real read-only GET against fw-primary (2026-09-17): +# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Diagnostics/Api/SystemController.php +# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Diagnostics/Api/InterfaceController.php +# Confirmed real shapes: +# systemResources -> {"memory": {"total": "", "used": , ...}} +# systemTime -> {"uptime": "...", "loadavg": "0.28, 0.30, 0.26", ...} +# systemDisk -> {"devices": [{"mountpoint": "/", "used_pct": 2, ...}, ...]} +# getInterfaceStatistics -> {"statistics": {"