Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba6357193 | ||
|
|
23d3a00ae1 | ||
|
|
4379a17b15 | ||
|
|
983e05a816 | ||
|
|
dd6a64d953 | ||
|
|
6964ec6537 | ||
|
|
5e5d51b57f | ||
|
|
1aba618ad5 | ||
|
|
ba954c05bf | ||
|
|
63d014b14c | ||
|
|
4838dad588 | ||
|
|
c4c8c4ff3a | ||
|
|
295555cfd6 | ||
|
|
f5dbe8a41a |
No files matched your search
+8
-1
@@ -73,6 +73,8 @@ TRUST_PROXY_HEADERS=false
|
||||
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
||||
# (optional, siehe unten) per Klick im Webinterface.
|
||||
# Einfacher: im Webinterface unter Einstellungen › OPNexus aktualisieren einschalten.
|
||||
# Ein Wert hier hat Vorrang und sperrt den Schalter dort.
|
||||
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
# UPDATE_CHECK_INTERVAL_HOURS=12
|
||||
|
||||
@@ -80,7 +82,7 @@ TRUST_PROXY_HEADERS=false
|
||||
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
||||
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
|
||||
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
|
||||
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch, "Updates per Klick".
|
||||
# AUS. Braucht außerdem die Versionsprüfung (Webinterface oder UPDATE_CHECK_URL, oben). Siehe Handbuch, "Updates per Klick".
|
||||
# COMPOSE_PROFILES=updater
|
||||
|
||||
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||
@@ -93,3 +95,8 @@ TRUST_PROXY_HEADERS=false
|
||||
# SMTP_CA_FILE=/data/smtp-ca.pem
|
||||
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
|
||||
# ALLOW_INSECURE_SMTP_AUTH=false
|
||||
|
||||
# Management-Tunnel (WireGuard-Hub für Firewalls hinter NAT), optional.
|
||||
# Zum Aktivieren (Handbuch „Firewalls hinter NAT“): COMPOSE_PROFILES=overlay
|
||||
# und den UDP-Port aus dem Internet erreichbar machen.
|
||||
#OVERLAY_PORT=51820
|
||||
@@ -31,6 +31,103 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.23.11] "Harrier" — 2026-10-20
|
||||
|
||||
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
|
||||
geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
|
||||
(rund 266 statt 123 MB).
|
||||
|
||||
## [1.23.10] "Harrier" — 2026-10-20
|
||||
|
||||
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
|
||||
Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
|
||||
installiert ist. Anleitung: Handbuch, „Images prüfen“.
|
||||
|
||||
## [1.23.9] "Harrier" — 2026-10-20
|
||||
|
||||
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
|
||||
statt „baut aus dem Quellcode“.
|
||||
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
|
||||
|
||||
## [1.23.8] "Harrier" — 2026-10-20
|
||||
|
||||
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
|
||||
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
|
||||
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
|
||||
|
||||
## [1.23.7] "Harrier" — 2026-10-20
|
||||
|
||||
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
|
||||
|
||||
## [1.23.6] "Harrier" — 2026-10-20
|
||||
|
||||
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
|
||||
und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
|
||||
`UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
|
||||
|
||||
## [1.23.5] "Harrier" — 2026-10-20
|
||||
|
||||
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
|
||||
und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
|
||||
bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
|
||||
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
|
||||
darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
|
||||
|
||||
## [1.23.4] "Harrier" — 2026-10-20
|
||||
|
||||
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
|
||||
im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
|
||||
anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
|
||||
Prüfung weicht ab“.
|
||||
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
|
||||
einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
|
||||
|
||||
## [1.23.3] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
|
||||
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
|
||||
Garage, MinIO, RustFS und Versity S3 Gateway.
|
||||
|
||||
## [1.23.2] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
|
||||
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
|
||||
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
|
||||
|
||||
## [1.23.1] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
||||
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
|
||||
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
|
||||
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
|
||||
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
|
||||
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
|
||||
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
|
||||
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
|
||||
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
|
||||
|
||||
## [1.23.0] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
|
||||
Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht**
|
||||
(Knopf „Bericht“, Drucken/PDF über den Browser).
|
||||
|
||||
## [1.22.0] "Peregrine" — 2026-10-20
|
||||
|
||||
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
|
||||
(Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
|
||||
Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
|
||||
Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
|
||||
|
||||
## [1.21.0] "Osprey" — 2026-10-20
|
||||
|
||||
- **Management-Tunnel** (System › Management-Tunnel): Firewalls hinter NAT/CGNAT verwalten. Ein optionaler WireGuard-Hub auf dem
|
||||
OPNexus-Server (Compose-Profil `overlay`, Container `wg-hub` aus dem Backend-Image mit `NET_ADMIN`) nimmt die Firewalls entgegen, die sich
|
||||
nach außen einwählen; OPNexus erreicht deren API über einen kleinen CONNECT-Proxy des Hubs, der nur ins Tunnel-Netz verbindet. Je Firewall
|
||||
eine Tunnel-Adresse und ein eigener Pre-Shared Key, Anleitung mit kopierbaren Werten, Live-Status (Handshake, Datenmenge). Der Hub nimmt
|
||||
keine neuen Verbindungen aus dem Tunnel an. Ende-zu-Ende gegen eine echte OPNsense 26.7 geprüft (Handshake, API, Zertifikat festsetzen).
|
||||
Neue Tabellen `overlay_settings`, `overlay_peers` (Migration `0015`); das Backend-Image enthält jetzt `wireguard-tools`, `iproute2`, `iptables`.
|
||||
|
||||
## [1.20.0] "Lilli" — 2026-10-20
|
||||
|
||||
- **Single Sign-on (OIDC)**: Anmeldung über Authentik, Keycloak, Entra ID, Google u. a. (Authorization Code mit PKCE). Die Rolle
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **Stand:** `1.20.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.23.11` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -62,7 +62,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.20.0
|
||||
OPNEXUS_VERSION=1.23.11
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -73,7 +73,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
|
||||
|
||||
## 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/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||
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 (Schalter unter Einstellungen › OPNexus aktualisieren, oder `UPDATE_CHECK_URL` in der `.env`) ist **aus**, solange du ihn nicht einschaltest; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **Status:** `1.20.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.23.11` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -64,7 +64,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.20.0
|
||||
OPNEXUS_VERSION=1.23.11
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -77,7 +77,7 @@ docker compose pull && docker compose up -d --no-build
|
||||
|
||||
## 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/<version>` — no instance or user data.
|
||||
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 (switch under Settings › Update OPNexus, or `UPDATE_CHECK_URL` in the `.env`) is **off** unless you turn it on; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ WORKDIR /app
|
||||
COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh
|
||||
RUN /tmp/install_pg_client.sh 16 && rm /tmp/install_pg_client.sh
|
||||
|
||||
# Management-Overlay (nur der wg-hub-Container nutzt das, siehe app/overlay_hub.py).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wireguard-tools iproute2 iptables \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
||||
# keine Instanz- oder Nutzerdaten.
|
||||
update_check_url: str = ""
|
||||
update_check_interval_hours: int = 12
|
||||
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
|
||||
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
|
||||
opnexus_image_repo: str = ""
|
||||
|
||||
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
|
||||
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
||||
@@ -53,6 +56,9 @@ class Settings(BaseSettings):
|
||||
# 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"
|
||||
# Management overlay (WireGuard hub in the optional `wg-hub` container, see services/overlay_service.py).
|
||||
overlay_dir: str = "/data/overlay"
|
||||
overlay_proxy_url: str = "http://wg-hub:8888"
|
||||
# Lab only: allow an http:// endpoint for offsite backups (credentials in the clear).
|
||||
allow_insecure_offsite: bool = False
|
||||
opnsense_backup_dir: str = "/data/opnsense-backups"
|
||||
|
||||
@@ -11,6 +11,8 @@ class ErrorCode(str, Enum):
|
||||
UPDATER_REJECTED = "UPDATER_REJECTED"
|
||||
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
||||
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
|
||||
UPDATE_CHECK_MANAGED_BY_SERVER = "UPDATE_CHECK_MANAGED_BY_SERVER"
|
||||
UPDATE_CHECK_URL_INVALID = "UPDATE_CHECK_URL_INVALID"
|
||||
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
||||
BACKUP_EMPTY = "BACKUP_EMPTY"
|
||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||
@@ -157,6 +159,12 @@ class ErrorCode(str, Enum):
|
||||
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
|
||||
OFFSITE_TEST_FAILED = "OFFSITE_TEST_FAILED"
|
||||
OIDC_TEST_FAILED = "OIDC_TEST_FAILED"
|
||||
VARIABLES_MISSING = "VARIABLES_MISSING"
|
||||
VARIABLE_INVALID = "VARIABLE_INVALID"
|
||||
OVERLAY_INVALID = "OVERLAY_INVALID"
|
||||
OVERLAY_SUBNET_LOCKED = "OVERLAY_SUBNET_LOCKED"
|
||||
OVERLAY_NAME_TAKEN = "OVERLAY_NAME_TAKEN"
|
||||
OVERLAY_PEER_NOT_FOUND = "OVERLAY_PEER_NOT_FOUND"
|
||||
DEPLOY_NEEDS_ADMIN = "DEPLOY_NEEDS_ADMIN"
|
||||
OIDC_MANAGED_USER = "OIDC_MANAGED_USER"
|
||||
BACKUP_UNLESBAR = "BACKUP_UNLESBAR"
|
||||
|
||||
@@ -45,6 +45,7 @@ _DATA_DIRS = {
|
||||
"acme_secrets": "acme_secret_dir",
|
||||
"totp_secrets": "totp_secret_dir",
|
||||
"notification_secrets": "notification_secret_dir",
|
||||
"overlay": "overlay_dir",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+14
-1
@@ -18,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
|
||||
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, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router, audit_export as audit_export_router, backups as backups_router, oidc as oidc_router, governance as governance_router
|
||||
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router, audit_export as audit_export_router, backups as backups_router, oidc as oidc_router, governance as governance_router, overlay as overlay_router, variables as variables_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
|
||||
@@ -52,6 +52,7 @@ from app.routers import scheduled_maintenance as scheduled_maintenance_router
|
||||
from app.services import scheduled_maintenance_service
|
||||
from app.services import compliance_service
|
||||
from app.services import audit_export_service
|
||||
from app.services import overlay_service, variables_service
|
||||
from app.services import backup_schedule_service
|
||||
from app.services import carp_event_service, carp_maintenance_window_service
|
||||
|
||||
@@ -314,6 +315,10 @@ async def lifespan(app: FastAPI):
|
||||
await migrate.run()
|
||||
async with SessionLocal() as db:
|
||||
await auth_service.bootstrap_admin(db)
|
||||
try:
|
||||
await overlay_service.sync(db) # hub config + proxy rule follow the database after every start
|
||||
except Exception:
|
||||
logger.exception("Overlay-Konfiguration konnte nicht geschrieben werden")
|
||||
if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
|
||||
logger.warning(
|
||||
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
|
||||
@@ -391,6 +396,12 @@ async def _safe_validation_error(request: Request, exc: RequestValidationError):
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(variables_service.MissingVariables)
|
||||
async def _missing_variables(request: Request, exc: variables_service.MissingVariables):
|
||||
"""A deploy that needs a value no target host has defined is refused before anything is written."""
|
||||
return JSONResponse(status_code=422, content={"detail": {"code": ErrorCode.VARIABLES_MISSING.value, "params": {"details": str(exc)}}})
|
||||
|
||||
|
||||
@app.exception_handler(DBAPIError)
|
||||
async def _database_text_errors(request: Request, exc: DBAPIError):
|
||||
"""A NUL byte in any text field cannot be stored by Postgres and used to
|
||||
@@ -445,6 +456,8 @@ app.include_router(audit_export_router.router, prefix="/api")
|
||||
app.include_router(backups_router.router, prefix="/api")
|
||||
app.include_router(oidc_router.router, prefix="/api")
|
||||
app.include_router(governance_router.router, prefix="/api")
|
||||
app.include_router(overlay_router.router, prefix="/api")
|
||||
app.include_router(variables_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")
|
||||
|
||||
@@ -44,3 +44,7 @@ class OpnsenseHost(Base):
|
||||
policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship(
|
||||
"PolicyHierarchyGroup", back_populates="hosts"
|
||||
)
|
||||
# Values for {{NAME}} placeholders (services/variables_service.py); always loaded with the host.
|
||||
variables: Mapped[list["HostVariable"]] = relationship(
|
||||
"HostVariable", lazy="selectin", cascade="all, delete-orphan", order_by="HostVariable.name"
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Per-host values for ``{{NAME}}`` placeholders in centrally authored objects (see services/variables_service.py)."""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class HostVariable(Base):
|
||||
__tablename__ = "host_variables"
|
||||
__table_args__ = (UniqueConstraint("host_id", "name", name="uq_host_variable"),)
|
||||
|
||||
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(41), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -52,6 +52,8 @@ class NotificationSettings(Base):
|
||||
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
backup_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
|
||||
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
@@ -22,7 +22,7 @@ class OidcSettings(Base):
|
||||
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email groups")
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
|
||||
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
||||
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Management overlay: a WireGuard hub so OPNexus can reach firewalls behind NAT/CGNAT.
|
||||
|
||||
The firewall dials OUT to the hub; OPNexus then talks to its API at the firewall's overlay address. Keys live
|
||||
on disk (services/overlay_service.py), never in the database.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class OverlaySettings(Base):
|
||||
__tablename__ = "overlay_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# host or host:port that firewalls dial (public name/IP of this server and the published UDP port)
|
||||
endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
subnet: Mapped[str] = mapped_column(String(18), nullable=False, default="10.99.0.0/24")
|
||||
listen_port: Mapped[int] = mapped_column(Integer, nullable=False, default=51820)
|
||||
|
||||
|
||||
class OverlayPeer(Base):
|
||||
__tablename__ = "overlay_peers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||
ip: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
|
||||
# the firewall's WireGuard public key, entered once the firewall side exists
|
||||
public_key: Mapped[Optional[str]] = mapped_column(String(44), nullable=True, unique=True)
|
||||
host_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Version check configured in the web UI (singleton row, id fixed at 1).
|
||||
|
||||
Off by default: OPNexus makes no outgoing request until an admin switches it on. An
|
||||
UPDATE_CHECK_URL in the server's .env still wins over this row (server-managed)."""
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||
|
||||
|
||||
class UpdateCheckSettings(Base):
|
||||
__tablename__ = "update_check_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
feed_url: Mapped[str] = mapped_column(String(500), nullable=False, default=DEFAULT_FEED_URL, server_default=DEFAULT_FEED_URL)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""The ``wg-hub`` container: WireGuard interface + a CONNECT proxy for the management overlay.
|
||||
|
||||
Runs from the backend image (``python -m app.overlay_hub``) with CAP_NET_ADMIN and the WireGuard UDP port
|
||||
published. Everything it needs comes from ``<overlay_dir>/hub.json`` (written by the backend, see
|
||||
services/overlay_service.py); it reports back through ``status.json``. It never talks to the database.
|
||||
|
||||
* WireGuard: wg0 with the hub address, peers synced with ``wg syncconf`` whenever hub.json changes. Inbound
|
||||
NEW connections from the tunnel are dropped (iptables), so a compromised firewall cannot reach anything on this
|
||||
container; OPNexus' own requests to the firewalls and their replies are unaffected.
|
||||
* Proxy: an HTTP CONNECT proxy (port 8888, compose network only). It is not an open proxy: it only connects to
|
||||
IPv4 addresses inside the overlay subnet from hub.json.
|
||||
"""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("overlay_hub")
|
||||
INTERFACE = "wg0"
|
||||
PROXY_PORT = 8888
|
||||
APPLY_EVERY = 5
|
||||
|
||||
|
||||
def run(*cmd: str, stdin: str | None = None) -> tuple[int, str]:
|
||||
try:
|
||||
result = subprocess.run(list(cmd), input=stdin, capture_output=True, text=True, timeout=20)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return 127, str(exc)
|
||||
return result.returncode, (result.stdout + result.stderr).strip()
|
||||
|
||||
|
||||
def wg_conf(config: dict) -> str:
|
||||
lines = ["[Interface]", f"PrivateKey = {config['private_key']}", f"ListenPort = {int(config['listen_port'])}"]
|
||||
for peer in config.get("peers", []):
|
||||
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}", f"PresharedKey = {peer['psk']}", f"AllowedIPs = {peer['ip']}/32"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_dump(text: str) -> list[dict]:
|
||||
"""`wg show <if> dump`: first line is the interface, then one tab-separated line per peer."""
|
||||
peers = []
|
||||
for line in text.splitlines()[1:]:
|
||||
f = line.split("\t")
|
||||
if len(f) >= 7:
|
||||
peers.append({"public_key": f[0], "endpoint": None if f[2] == "(none)" else f[2], "handshake_at": int(f[4] or 0),
|
||||
"rx": int(f[5] or 0), "tx": int(f[6] or 0)})
|
||||
return peers
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self, directory: Path, runner=run):
|
||||
self.dir, self.run = directory, runner
|
||||
self.subnet: ipaddress.IPv4Network | None = None
|
||||
self._applied_mtime = 0.0
|
||||
|
||||
def read_config(self) -> dict | None:
|
||||
try:
|
||||
config = json.loads((self.dir / "hub.json").read_text())
|
||||
return config if isinstance(config, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def apply(self, config: dict) -> str | None:
|
||||
"""Bring the interface in line with the config. Returns an error text or None."""
|
||||
self.subnet = ipaddress.ip_network(config["subnet"]) if config.get("enabled") and config.get("subnet") else None
|
||||
exists = self.run("ip", "link", "show", INTERFACE)[0] == 0
|
||||
if not config.get("enabled"):
|
||||
if exists:
|
||||
self.run("ip", "link", "del", INTERFACE)
|
||||
return None
|
||||
steps = []
|
||||
if not exists:
|
||||
steps.append(("ip", "link", "add", INTERFACE, "type", "wireguard"))
|
||||
steps += [("ip", "address", "replace", config["address"], "dev", INTERFACE), ("ip", "link", "set", INTERFACE, "up")]
|
||||
for step in steps:
|
||||
code, out = self.run(*step)
|
||||
if code != 0:
|
||||
return f"{' '.join(step[:3])}: {out}"[:300]
|
||||
conf = self.dir / ".wg0.conf"
|
||||
fd = os.open(conf, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
handle.write(wg_conf(config))
|
||||
try:
|
||||
code, out = self.run("wg", "syncconf", INTERFACE, str(conf))
|
||||
finally:
|
||||
conf.unlink(missing_ok=True)
|
||||
if code != 0:
|
||||
return f"wg syncconf: {out}"[:300]
|
||||
# Nothing may start a connection INTO this container from the tunnel; replies to ours still pass.
|
||||
rule = ("INPUT", "-i", INTERFACE, "-m", "conntrack", "--ctstate", "NEW", "-j", "DROP")
|
||||
if self.run("iptables", "-C", *rule)[0] != 0:
|
||||
code, out = self.run("iptables", "-I", *rule)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
# The hub only needs its own proxy: never route between peers or into the compose network.
|
||||
fwd = ("FORWARD", "-j", "DROP")
|
||||
if self.run("iptables", "-C", *fwd)[0] != 0:
|
||||
code, out = self.run("iptables", "-I", *fwd)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
return None
|
||||
|
||||
def status(self, error: str | None) -> dict:
|
||||
code, out = self.run("wg", "show", INTERFACE, "dump")
|
||||
return {"updated_at": time.time(), "up": code == 0, "peers": parse_dump(out) if code == 0 else [], "error": error}
|
||||
|
||||
def write_status(self, error: str | None) -> None:
|
||||
tmp = self.dir / "status.json.tmp"
|
||||
tmp.write_text(json.dumps(self.status(error)))
|
||||
os.replace(tmp, self.dir / "status.json")
|
||||
|
||||
def tick(self, last_error: str | None) -> str | None:
|
||||
"""One round: re-apply if hub.json changed, then report. Returns the current error."""
|
||||
config = self.read_config()
|
||||
if config is not None:
|
||||
mtime = (self.dir / "hub.json").stat().st_mtime
|
||||
if mtime != self._applied_mtime or last_error:
|
||||
last_error = self.apply(config)
|
||||
self._applied_mtime = mtime
|
||||
self.write_status(last_error)
|
||||
return last_error
|
||||
|
||||
# ── CONNECT proxy ────────────────────────────────────────────────────
|
||||
def allowed(self, host: str) -> bool:
|
||||
try:
|
||||
return self.subnet is not None and ipaddress.ip_address(host) in self.subnet
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 10)
|
||||
method, target, _ = head.split(b"\r\n", 1)[0].decode("latin-1").split(" ", 2)
|
||||
host, _, port_text = target.rpartition(":")
|
||||
if method != "CONNECT" or not port_text.isdigit() or not 0 < int(port_text) < 65536 or not self.allowed(host.strip("[]")):
|
||||
writer.write(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
return
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.wait_for(asyncio.open_connection(host, int(port_text)), 8)
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
return
|
||||
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
||||
await writer.drain()
|
||||
await asyncio.gather(self._pipe(reader, up_writer), self._pipe(up_reader, writer))
|
||||
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ValueError, OSError):
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@staticmethod
|
||||
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
while data := await src.read(65536):
|
||||
dst.write(data)
|
||||
await dst.drain()
|
||||
except (OSError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
dst.close()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
directory = Path(settings.overlay_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
hub = Hub(directory)
|
||||
server = await asyncio.start_server(hub.handle_client, "0.0.0.0", PROXY_PORT)
|
||||
logger.info("Overlay-Hub gestartet (Proxy auf :%d)", PROXY_PORT)
|
||||
error = None
|
||||
async with server:
|
||||
while True:
|
||||
try:
|
||||
new_error = await asyncio.to_thread(hub.tick, error)
|
||||
except Exception:
|
||||
logger.exception("Overlay-Hub-Runde fehlgeschlagen")
|
||||
new_error = "interner Fehler"
|
||||
if new_error and new_error != error:
|
||||
logger.warning("Overlay: %s", new_error)
|
||||
error = new_error
|
||||
await asyncio.sleep(APPLY_EVERY)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -30,11 +30,11 @@ def client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=settings.session_lifetime,
|
||||
max_age=max_age or settings.session_lifetime,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.cookie_secure,
|
||||
@@ -305,6 +305,7 @@ async def change_password(
|
||||
# an attacker may hold) and the replacement session commit as one unit.
|
||||
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -10,6 +12,7 @@ from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import _set_session_cookie, client_ip, current_user
|
||||
from app.config import settings
|
||||
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
|
||||
from app.security_middleware import _origin_allowed
|
||||
from app.services import auth_service, notification_secret_store, oidc_service
|
||||
@@ -18,10 +21,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["oidc"])
|
||||
CALLBACK_PATH = "/api/auth/oidc/callback"
|
||||
STATE_COOKIE = "opnexus-oidc-state"
|
||||
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
|
||||
# must not keep a 30-day session.
|
||||
SSO_SESSION_SECONDS = 12 * 3600
|
||||
|
||||
|
||||
def _login_error(code: str) -> RedirectResponse:
|
||||
return RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
def _printable(value: str) -> str:
|
||||
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
|
||||
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
|
||||
|
||||
|
||||
@router.get("/auth/oidc/config")
|
||||
@@ -42,11 +56,15 @@ async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db
|
||||
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
url = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
except oidc_service.OidcError as exc:
|
||||
logger.warning("OIDC start fehlgeschlagen: %s", exc)
|
||||
return _login_error(exc.code)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
response = RedirectResponse(url, status_code=302)
|
||||
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
|
||||
# code+state pair is useless without the cookie.
|
||||
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
@@ -59,7 +77,9 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
if error or not code or not state:
|
||||
raise oidc_service.OidcError("provider", f"Anbieter meldet {error or 'keinen Code'}")
|
||||
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
|
||||
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
|
||||
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
|
||||
user = await oidc_service.complete(db, cfg, code, state)
|
||||
except oidc_service.OidcError as exc:
|
||||
auth_service.throttle.record_failure(ip, "oidc")
|
||||
@@ -67,9 +87,12 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _login_error(exc.code)
|
||||
auth_service.throttle.reset(ip, "oidc")
|
||||
await auth_service.finalize_login(db, user, ip)
|
||||
token, _ = await auth_service.create_session(db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip)
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
|
||||
)
|
||||
response = RedirectResponse("/dashboard", status_code=302)
|
||||
_set_session_cookie(response, token)
|
||||
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Management overlay API (see services/overlay_service.py). Writes and the pre-shared key are admin-only."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.overlay import OverlayPeer
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.overlay import OverlaySettingsRead, OverlaySettingsUpdate, PeerCreate, PeerRead, PeerUpdate
|
||||
from app.services import overlay_service as svc
|
||||
|
||||
router = APIRouter(prefix="/overlay", tags=["overlay"])
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
async def _settings_read(db: AsyncSession) -> dict:
|
||||
cfg = await svc.get_settings(db)
|
||||
net = svc.parse_subnet(cfg.subnet)
|
||||
return {
|
||||
"enabled": cfg.enabled, "endpoint": cfg.endpoint, "subnet": cfg.subnet, "listen_port": cfg.listen_port,
|
||||
"hub_public_key": svc.hub_public_key() if cfg.enabled else None, "hub_address": str(svc.hub_address(net)),
|
||||
"hub_state": svc.hub_state(svc.read_status()),
|
||||
}
|
||||
|
||||
|
||||
async def _peers(db: AsyncSession) -> list[dict]:
|
||||
status = svc.read_status()
|
||||
rows = (await db.execute(select(OverlayPeer, OpnsenseHost.name).outerjoin(OpnsenseHost, OverlayPeer.host_id == OpnsenseHost.id).order_by(OverlayPeer.ip))).all()
|
||||
return [
|
||||
{"id": p.id, "name": p.name, "ip": p.ip, "public_key": p.public_key, "host_id": p.host_id, "host_name": host_name,
|
||||
**svc.peer_status(status, p.public_key)}
|
||||
for p, host_name in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def overview(db: AsyncSession = Depends(get_db)):
|
||||
return {"settings": await _settings_read(db), "peers": await _peers(db)}
|
||||
|
||||
|
||||
@router.put("/settings", response_model=OverlaySettingsRead)
|
||||
async def update_settings(payload: OverlaySettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
cfg = await svc.get_settings(db)
|
||||
if payload.subnet is not None and payload.subnet.strip() != cfg.subnet:
|
||||
try:
|
||||
svc.parse_subnet(payload.subnet)
|
||||
except svc.OverlayError as exc:
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
||||
if (await db.scalar(select(OverlayPeer.id).limit(1))) is not None:
|
||||
raise api_error(409, ErrorCode.OVERLAY_SUBNET_LOCKED) # peers already hold addresses in the old network
|
||||
cfg.subnet = payload.subnet.strip()
|
||||
for field in ("enabled", "listen_port"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(cfg, field, value)
|
||||
if payload.endpoint is not None:
|
||||
cfg.endpoint = payload.endpoint.strip() or None
|
||||
if cfg.enabled and not cfg.endpoint:
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Ohne Endpunkt wissen die Firewalls nicht, wohin sie sich verbinden sollen")
|
||||
await db.commit()
|
||||
await svc.sync(db)
|
||||
return await _settings_read(db)
|
||||
|
||||
|
||||
@router.post("/peers", status_code=201)
|
||||
async def create_peer(payload: PeerCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
cfg = await svc.get_settings(db)
|
||||
taken = set((await db.scalars(select(OverlayPeer.ip))).all())
|
||||
try:
|
||||
ip = svc.next_free_ip(svc.parse_subnet(cfg.subnet), taken)
|
||||
except svc.OverlayError as exc:
|
||||
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
||||
peer = OverlayPeer(name=payload.name.strip(), ip=ip)
|
||||
db.add(peer)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.OVERLAY_NAME_TAKEN) from None
|
||||
await svc.sync(db)
|
||||
return {"id": peer.id, "name": peer.name, "ip": peer.ip, "instructions": svc.instructions(cfg, peer)}
|
||||
|
||||
|
||||
@router.get("/peers/{peer_id}/instructions")
|
||||
async def peer_instructions(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
peer = await db.get(OverlayPeer, peer_id)
|
||||
if peer is None:
|
||||
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||
return svc.instructions(await svc.get_settings(db), peer)
|
||||
|
||||
|
||||
@router.patch("/peers/{peer_id}")
|
||||
async def update_peer(peer_id: uuid.UUID, payload: PeerUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
peer = await db.get(OverlayPeer, peer_id)
|
||||
if peer is None:
|
||||
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||
if payload.public_key is not None:
|
||||
key = payload.public_key.strip()
|
||||
if key and not svc.valid_public_key(key):
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist kein WireGuard-Schlüssel (44 Zeichen, Base64)")
|
||||
if key and key == svc.hub_public_key():
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist der Schlüssel des Hubs, nicht der der Firewall")
|
||||
peer.public_key = key or None
|
||||
if payload.clear_host:
|
||||
peer.host_id = None
|
||||
elif payload.host_id is not None:
|
||||
if await db.get(OpnsenseHost, payload.host_id) is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
peer.host_id = payload.host_id
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason="Dieser Schlüssel gehört schon zu einem anderen Eintrag") from None
|
||||
await svc.sync(db)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/peers/{peer_id}", status_code=204)
|
||||
async def delete_peer(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
peer = await db.get(OverlayPeer, peer_id)
|
||||
if peer is None:
|
||||
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||
await db.delete(peer)
|
||||
await db.commit()
|
||||
svc.delete_peer_secret(peer_id)
|
||||
await svc.sync(db)
|
||||
@@ -1,6 +1,9 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
@@ -10,10 +13,10 @@ router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/version-check")
|
||||
async def version_check() -> dict:
|
||||
async def version_check(db: AsyncSession = Depends(get_db)) -> 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()
|
||||
return await update_check_service.check(db)
|
||||
|
||||
|
||||
def _admin(user: AdminUser) -> AdminUser:
|
||||
@@ -31,16 +34,63 @@ def _major(version: str | None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
class UpdateCheckRead(BaseModel):
|
||||
enabled: bool
|
||||
feed_url: str
|
||||
managed_by_server: bool
|
||||
|
||||
|
||||
class UpdateCheckUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
feed_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
async def _update_check_settings(db: AsyncSession) -> UpdateCheckRead:
|
||||
row = await update_check_service.get_settings(db)
|
||||
managed = update_check_service.managed_by_server()
|
||||
return UpdateCheckRead(
|
||||
enabled=True if managed else row.enabled,
|
||||
feed_url=settings.update_check_url.strip() if managed else row.feed_url,
|
||||
managed_by_server=managed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/update-check", response_model=UpdateCheckRead)
|
||||
async def read_update_check(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Versionsprüfung für „Einstellungen › OPNexus aktualisieren“ (nur Admins)."""
|
||||
_admin(user)
|
||||
return await _update_check_settings(db)
|
||||
|
||||
|
||||
@router.put("/update-check", response_model=UpdateCheckRead)
|
||||
async def update_update_check(payload: UpdateCheckUpdate, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)):
|
||||
_admin(user)
|
||||
if update_check_service.managed_by_server():
|
||||
raise api_error(409, ErrorCode.UPDATE_CHECK_MANAGED_BY_SERVER)
|
||||
row = await update_check_service.get_settings(db)
|
||||
if payload.feed_url is not None:
|
||||
url = payload.feed_url.strip()
|
||||
if not update_check_service.valid_feed_url(url):
|
||||
raise api_error(422, ErrorCode.UPDATE_CHECK_URL_INVALID)
|
||||
row.feed_url = url
|
||||
if payload.enabled is not None:
|
||||
row.enabled = payload.enabled
|
||||
await db.commit()
|
||||
update_check_service.reset_cache()
|
||||
return await _update_check_settings(db)
|
||||
|
||||
|
||||
class UpdateRequest(BaseModel):
|
||||
version: str = Field(min_length=5, max_length=20)
|
||||
|
||||
|
||||
@router.get("/updater")
|
||||
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
||||
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check()
|
||||
check = await update_check_service.check(db)
|
||||
result: dict = {
|
||||
"available": False, "reason": None, "mode": None,
|
||||
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
||||
@@ -54,6 +104,9 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
updater = await updater_client.status()
|
||||
except updater_client.UpdaterUnavailable as exc:
|
||||
result["reason"] = str(exc)
|
||||
# Without the updater the install mode is still known from the image registry setting; the UI uses it
|
||||
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
|
||||
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
|
||||
return result
|
||||
except updater_client.UpdaterRejected:
|
||||
result["reason"] = "unreachable"
|
||||
@@ -77,11 +130,12 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
|
||||
|
||||
@router.post("/updater/update", status_code=202)
|
||||
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
|
||||
async def updater_update(payload: UpdateRequest, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
||||
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check()
|
||||
check = await update_check_service.check(db)
|
||||
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
||||
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
||||
if _major(check["latest"]) != _major(check["current"]):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Site variables of one host: the values for {{NAME}} placeholders, and which placeholders its objects use."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.alias import FirewallAlias
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_variable import HostVariable
|
||||
from app.models.one_to_one import OneToOneRule
|
||||
from app.models.outbound_nat import OutboundNatRule
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.models.port_forward import PortForwardRule
|
||||
from app.models.rule import FirewallRule
|
||||
from app.services import alias_deploy_service, one_to_one_deploy_service, outbound_nat_deploy_service
|
||||
from app.services import port_forward_deploy_service, rule_deploy_service, variables_service
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/variables", tags=["variables"])
|
||||
|
||||
_KINDS = (
|
||||
("rule", FirewallRule, rule_deploy_service),
|
||||
("alias", FirewallAlias, alias_deploy_service),
|
||||
("port_forward", PortForwardRule, port_forward_deploy_service),
|
||||
("one_to_one", OneToOneRule, one_to_one_deploy_service),
|
||||
("outbound_nat", OutboundNatRule, outbound_nat_deploy_service),
|
||||
)
|
||||
|
||||
|
||||
class VariablesUpdate(BaseModel):
|
||||
variables: dict[str, str] = Field(default_factory=dict, max_length=200)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
async def _usage(db: AsyncSession, host: OpnsenseHost) -> dict[str, list[dict]]:
|
||||
"""name -> objects (that are deployed to this host) which use it."""
|
||||
used: dict[str, list[dict]] = {}
|
||||
for kind, model, service in _KINDS:
|
||||
entities = (await db.execute(
|
||||
select(model).options(selectinload(model.targets), selectinload(model.groups).selectinload(DeviceGroup.hosts))
|
||||
)).scalars().unique().all()
|
||||
await hydrate_hierarchy_targets(db, entities)
|
||||
for entity in entities:
|
||||
if host.id not in {h.id for h in effective_hosts(entity)}:
|
||||
continue
|
||||
label = getattr(entity, "description", None) or getattr(entity, "name", None) or str(entity.id)
|
||||
for name in variables_service.names_in_payload(service._base_payload(entity)):
|
||||
used.setdefault(name, []).append({"kind": kind, "id": str(entity.id), "label": label})
|
||||
return used
|
||||
|
||||
|
||||
async def _read(db: AsyncSession, host: OpnsenseHost) -> dict:
|
||||
values = variables_service.host_values(host)
|
||||
used = await _usage(db, host)
|
||||
names = sorted(set(values) | set(used))
|
||||
return {"variables": [
|
||||
{"name": n, "value": values.get(n), "used_by": used.get(n, []), "missing": n in used and n not in values}
|
||||
for n in names
|
||||
]}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def read(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
return await _read(db, await _host(db, host_id))
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession = Depends(get_db)):
|
||||
"""Replace this host's variables with exactly the given set."""
|
||||
host = await _host(db, host_id)
|
||||
for name, value in payload.variables.items():
|
||||
if not variables_service.NAME_RE.fullmatch(name):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
|
||||
if not variables_service.VALUE_RE.fullmatch(value):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
|
||||
existing = {v.name: v for v in host.variables}
|
||||
for name in set(existing) - set(payload.variables):
|
||||
host.variables.remove(existing[name])
|
||||
for name, value in payload.variables.items():
|
||||
if name in existing:
|
||||
existing[name].value = value
|
||||
else:
|
||||
host.variables.append(HostVariable(name=name, value=value))
|
||||
await db.commit()
|
||||
await db.refresh(host)
|
||||
return await _read(db, host)
|
||||
@@ -13,6 +13,7 @@ ProblemType = Literal[
|
||||
"scheduled_maintenance_failed",
|
||||
"backup_stale",
|
||||
"backup_offsite_failed",
|
||||
"compliance_finding",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +44,7 @@ class NotificationSettingsRead(BaseModel):
|
||||
carp_auto_revert_failed_enabled: bool
|
||||
scheduled_maintenance_failed_enabled: bool = True
|
||||
backup_failed_enabled: bool = True
|
||||
compliance_alerts_enabled: bool = False
|
||||
|
||||
poll_interval_seconds: int
|
||||
updated_at: datetime
|
||||
@@ -81,6 +83,7 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
carp_auto_revert_failed_enabled: bool | None = None
|
||||
scheduled_maintenance_failed_enabled: bool | None = None
|
||||
backup_failed_enabled: bool | None = None
|
||||
compliance_alerts_enabled: bool | None = None
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OverlaySettingsRead(BaseModel):
|
||||
enabled: bool
|
||||
endpoint: str | None
|
||||
subnet: str
|
||||
listen_port: int
|
||||
hub_public_key: str | None
|
||||
hub_address: str
|
||||
hub_state: str # running | stale | absent
|
||||
|
||||
|
||||
class OverlaySettingsUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
endpoint: str | None = Field(default=None, max_length=255)
|
||||
subnet: str | None = Field(default=None, max_length=18)
|
||||
listen_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
|
||||
|
||||
class PeerCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class PeerUpdate(BaseModel):
|
||||
public_key: str | None = Field(default=None, max_length=44)
|
||||
host_id: uuid.UUID | None = None
|
||||
clear_host: bool = False
|
||||
|
||||
|
||||
class PeerRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
ip: str
|
||||
public_key: str | None
|
||||
host_id: uuid.UUID | None
|
||||
host_name: str | None
|
||||
connected: bool
|
||||
last_handshake_at: datetime | None
|
||||
rx_bytes: int
|
||||
tx_bytes: int
|
||||
remote_endpoint: str | None
|
||||
@@ -18,7 +18,7 @@ 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 import opnsense_client, variables_service
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
@@ -27,7 +27,12 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_payload(alias: FirewallAlias) -> dict:
|
||||
def _build_payload(alias: FirewallAlias, host: OpnsenseHost | None = None) -> dict:
|
||||
payload = _base_payload(alias)
|
||||
return variables_service.resolve(payload, host) if host is not None else payload
|
||||
|
||||
|
||||
def _base_payload(alias: FirewallAlias) -> dict:
|
||||
return {
|
||||
"enabled": "1" if alias.enabled else "0",
|
||||
"name": alias.name,
|
||||
@@ -38,7 +43,7 @@ def _build_payload(alias: FirewallAlias) -> dict:
|
||||
|
||||
|
||||
async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment:
|
||||
payload = _build_payload(alias)
|
||||
payload = _build_payload(alias, host)
|
||||
existing = await db.scalar(
|
||||
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id)
|
||||
)
|
||||
@@ -79,6 +84,7 @@ async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[Opnse
|
||||
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)
|
||||
variables_service.ensure_resolvable(_build_payload, alias, hosts) # before anything is written
|
||||
deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
|
||||
await db.flush()
|
||||
for host, deployment in zip(hosts, deployments):
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
|
||||
from app.models.policy import AliasRevision
|
||||
from app.services.alias_deploy_service import _build_payload
|
||||
from app.services import variables_service
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
ALIAS_FIELDS = ("name", "type", "content", "description", "enabled")
|
||||
@@ -28,13 +29,17 @@ def revision(db, alias, note):
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
payload = _build_payload(alias, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||
continue
|
||||
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"):
|
||||
@@ -61,6 +66,8 @@ def preview(alias):
|
||||
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"] == "missing_variable" for r in rows):
|
||||
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||
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):
|
||||
@@ -71,7 +78,7 @@ def preview(alias):
|
||||
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),
|
||||
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||
|
||||
|
||||
@@ -79,12 +86,11 @@ 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),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, alias, h)),
|
||||
"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),
|
||||
|
||||
@@ -41,11 +41,11 @@ _SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
|
||||
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
|
||||
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
|
||||
# notification settings the webhook URL/SMTP data): admin scope only.
|
||||
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/")
|
||||
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/|^/api/overlay/peers/[^/]+/instructions$")
|
||||
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
|
||||
_MCP_PATH = "/api/mcp"
|
||||
_ADMIN_ONLY_WRITES = re.compile(
|
||||
r"^/api/(users|settings|system|notifications)(/|$)"
|
||||
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
|
||||
r"|^/api/logs/settings$"
|
||||
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
||||
)
|
||||
@@ -82,14 +82,27 @@ def operator_denies(method: str, path: str) -> bool:
|
||||
|
||||
|
||||
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
|
||||
# maintenance (reboot/update/CARP switch), scheduling it, and applying network changes.
|
||||
_ROLLOUT = re.compile(
|
||||
r"/(deploy|deployments/[^/]+/(remove|retry-rollback)|maintenance/(execute|carp-execute|schedule)|network/changes)$"
|
||||
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
|
||||
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
|
||||
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
|
||||
_ROLLOUT_POST = re.compile(
|
||||
r"/(deploy|push|sign|poll|register|reassert-ordering"
|
||||
r"|deployments/[^/]+/(remove|retry-rollback)"
|
||||
r"|maintenance/(execute|carp-execute|schedule)"
|
||||
r"|network/changes"
|
||||
r"|services/[^/]+/(start|stop|restart))$"
|
||||
)
|
||||
_ROLLOUT_DELETE = re.compile(
|
||||
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
|
||||
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
|
||||
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
|
||||
)
|
||||
|
||||
|
||||
def is_rollout(method: str, path: str) -> bool:
|
||||
return method == "POST" and bool(_ROLLOUT.search(path))
|
||||
if method == "POST":
|
||||
return bool(_ROLLOUT_POST.search(path))
|
||||
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
|
||||
|
||||
|
||||
def bearer_from_header(header: str) -> str | None:
|
||||
|
||||
@@ -88,7 +88,10 @@ def deployment_history(session, flush_context, instances):
|
||||
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)
|
||||
try:
|
||||
details["requested_payload"] = builder(entity, host) if host is not None else builder(entity)
|
||||
except Exception: # a missing variable must not break the audit write; record the unresolved intent instead
|
||||
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,
|
||||
|
||||
@@ -236,6 +236,7 @@ async def create_session(
|
||||
user_agent: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
verified_hash: str | None = None,
|
||||
lifetime_seconds: int | None = None,
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
||||
|
||||
@@ -253,7 +254,7 @@ async def create_session(
|
||||
await db.rollback()
|
||||
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
||||
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
|
||||
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
|
||||
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
|
||||
session = Session(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(token),
|
||||
|
||||
@@ -7,6 +7,8 @@ but its value never leaves the server. Output is a unified diff, capped in size.
|
||||
"""
|
||||
import difflib
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
from xml.etree.ElementTree import Element, indent, tostring
|
||||
|
||||
@@ -14,7 +16,7 @@ import defusedxml.ElementTree as safe_ET
|
||||
|
||||
MAX_LINES = 3000
|
||||
MAX_BYTES = 32 * 1024 * 1024
|
||||
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
|
||||
_SECRET = re.compile(r"pass|pwd|psk|secret|key|token|prv|hash|community|bindpw|privpass|otp|seed|cred", re.I)
|
||||
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||||
_BLOB = 120
|
||||
|
||||
@@ -23,15 +25,17 @@ class InvalidConfig(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _redact(root: Element) -> None:
|
||||
def _redact(root: Element, salt: bytes) -> None:
|
||||
for el in root.iter():
|
||||
if len(el) == 0 and el.text and el.text.strip():
|
||||
text = el.text.strip()
|
||||
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||||
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
|
||||
# Salted per diff: both sides of one diff share the salt (so a change shows), but the value cannot be
|
||||
# looked up in a dictionary or compared across hosts/requests.
|
||||
el.text = "‹" + hmac.new(salt, text.encode(), hashlib.sha256).hexdigest()[:12] + "›"
|
||||
|
||||
|
||||
def normalise(xml_bytes: bytes) -> list[str]:
|
||||
def normalise(xml_bytes: bytes, salt: bytes) -> list[str]:
|
||||
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||||
raise InvalidConfig("leer oder zu groß")
|
||||
try:
|
||||
@@ -42,13 +46,14 @@ def normalise(xml_bytes: bytes) -> list[str]:
|
||||
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||||
for volatile in root.findall("revision"):
|
||||
root.remove(volatile)
|
||||
_redact(root)
|
||||
_redact(root, salt)
|
||||
indent(root, space=" ")
|
||||
return tostring(root, encoding="unicode").splitlines()
|
||||
|
||||
|
||||
def diff(old: bytes, new: bytes) -> dict:
|
||||
a, b = normalise(old), normalise(new)
|
||||
salt = os.urandom(16)
|
||||
a, b = normalise(old, salt), normalise(new, salt)
|
||||
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||||
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||
|
||||
@@ -12,6 +12,7 @@ in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
||||
decided. The score starts at 100 and loses a penalty per failed check, so one
|
||||
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree.ElementTree import Element
|
||||
@@ -31,6 +32,15 @@ BACKUP_MAX_AGE_DAYS = 7
|
||||
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
||||
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
||||
MAX_EXAMPLES = 5
|
||||
# German titles for notifications (the UI translates the check ids itself).
|
||||
TITLES_DE = {
|
||||
"webgui_https": "Web-Oberfläche nur über HTTPS", "ssh_hardened": "SSH abgesichert", "snmp_community": "SNMP-Community nicht „public“",
|
||||
"wan_any_any": "Keine Allow-all-Regel am WAN", "internal_any_any": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
|
||||
"rules_shadowed": "Keine verdeckten Regeln", "rules_duplicate": "Keine doppelten Regeln", "rules_documented": "Regeln sind beschriftet",
|
||||
"rules_unused": "Regeln in Benutzung", "ntp_configured": "Zeitserver gesetzt", "remote_logging": "Logs liegen auch woanders",
|
||||
"ids_enabled": "Einbruchserkennung aktiv", "firmware_current": "Firmware aktuell", "backup_recent": "Aktuelles Backup",
|
||||
}
|
||||
SEVERITIES_DE = {"critical": "kritisch", "high": "hoch", "medium": "mittel", "low": "niedrig", "info": "Hinweis"}
|
||||
|
||||
|
||||
def grade(score: int) -> str:
|
||||
@@ -267,15 +277,18 @@ async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
||||
await rule_hit_service.sample_host(db, host)
|
||||
except OpnsenseAPIError:
|
||||
pass # usage data is a bonus; the check does not depend on it
|
||||
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id))
|
||||
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
|
||||
findings = await asyncio.to_thread(
|
||||
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id),
|
||||
)
|
||||
row.findings, row.score, row.error = findings, score(findings), None
|
||||
except OpnsenseAPIError as exc:
|
||||
row.findings, row.score, row.error = [], None, "unreachable"
|
||||
row.error_detail = str(exc)[:300]
|
||||
except ValueError as exc:
|
||||
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
|
||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||
row.error_detail = str(exc)[:300]
|
||||
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ def _extract_carp_status(data: dict | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _vip_states(data: dict | None) -> dict[str, str]:
|
||||
"""Per-VHID CARP state ("MASTER", "BACKUP", "DISABLED", "INIT", ...). The host-level carp_status above
|
||||
collapses these, so one VHID that is down on a node is invisible there."""
|
||||
rows = data.get("rows") if isinstance(data, dict) else None
|
||||
return {str(row.get("vhid")): str(row.get("status", "")).upper()
|
||||
for row in rows or [] if isinstance(row, dict) and row.get("vhid") not in (None, "")}
|
||||
|
||||
|
||||
async def _status_for(host: OpnsenseHost) -> dict:
|
||||
if not host.enabled:
|
||||
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
||||
@@ -43,6 +51,7 @@ async def _status_for(host: OpnsenseHost) -> dict:
|
||||
return {
|
||||
"status": "online",
|
||||
"carp_status": _extract_carp_status(data),
|
||||
"carp_vips": _vip_states(data),
|
||||
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
||||
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
||||
"carp_demotion": carp.get("demotion"),
|
||||
|
||||
@@ -22,6 +22,7 @@ taken before the change is the way back.
|
||||
Secrets (the CARP password) are never returned, hashed into the token only, and
|
||||
never written to the audit log.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
@@ -362,6 +363,20 @@ async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None,
|
||||
return False, ["unreadable"]
|
||||
|
||||
|
||||
async def _carp_vip_active(host: OpnsenseHost, vhid: str, attempts: int = 5) -> bool:
|
||||
"""True once the firewall reports the VHID as MASTER or BACKUP, i.e. the VIP really is on the interface and
|
||||
taking part in CARP (not DISABLED/INIT). The config can look right while the live VIP is gone."""
|
||||
for attempt in range(attempts):
|
||||
if attempt:
|
||||
await asyncio.sleep(2)
|
||||
data = await opnsense_client.get_vip_status(host)
|
||||
rows = data.get("rows") if isinstance(data, dict) else None
|
||||
states = {str(r.get("vhid")): str(r.get("status", "")).upper() for r in rows or [] if isinstance(r, dict)}
|
||||
if states.get(vhid) in ("MASTER", "BACKUP"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
||||
token: str, acknowledged: bool) -> dict:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
@@ -384,6 +399,12 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
|
||||
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
|
||||
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
|
||||
send.setdefault("network", plan["_before"]["network"])
|
||||
# VipSettingsController::setItemAction queues "delete the old address" whenever the posted interface differs
|
||||
# from the stored one, and a partial update that leaves "interface" out counts as different. The following
|
||||
# reconfigure then removes the live VIP and, working from a stale address list, never puts it back (found
|
||||
# live 2026-10-20: CARP VIP stuck on DISABLED after a description edit). So repeat the interface as well.
|
||||
if plan["_before"].get("interface"):
|
||||
send.setdefault("interface", plan["_before"]["interface"])
|
||||
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
|
||||
expected = {**plan["_before"], **send} if action == "set" else dict(send)
|
||||
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
|
||||
@@ -407,14 +428,27 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
|
||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
|
||||
# A description is not wired into anything, so there is nothing to re-apply for it.
|
||||
cosmetic = kind == "vip" and action == "set" and {c["field"] for c in plan["changes"]} <= {"descr"}
|
||||
carp_live = kind == "vip" and action != "del" and expected.get("mode") == "carp" and not cosmetic
|
||||
try:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
if not cosmetic:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
live_ok = True
|
||||
if carp_live:
|
||||
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||
if not live_ok: # one more apply usually brings it back up
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||
except OpnsenseAPIError as exc:
|
||||
# Written but not (provably) applied: say so instead of reporting success.
|
||||
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
|
||||
|
||||
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
|
||||
if not live_ok:
|
||||
verified = False
|
||||
mismatches = [*mismatches, "carp_vip_inactive"]
|
||||
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
||||
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
||||
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
from app.models.backup_settings import BackupSettings
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||
@@ -235,6 +236,28 @@ async def check_carp_health(db: AsyncSession) -> dict[str, dict]:
|
||||
"condition": "no_master",
|
||||
"host_names": sorted(h.name for h in online),
|
||||
}
|
||||
problems.update(_carp_vip_problems(cluster_name, online, statuses, split_brain=len(masters) == 2))
|
||||
return problems
|
||||
|
||||
|
||||
def _carp_vip_problems(cluster_name: str, online: list[OpnsenseHost], statuses: dict, split_brain: bool) -> dict[str, dict]:
|
||||
"""Per-VHID checks. The host-level role is "MASTER" as soon as one VIP is, so a VIP that is down on a
|
||||
node (a node without the VIP never advertises it, the peer takes it over) looks healthy in the totals."""
|
||||
problems: dict[str, dict] = {}
|
||||
for host in online:
|
||||
vips = statuses.get(host.id, {}).get("carp_vips") or {}
|
||||
down = sorted(v for v, state in vips.items() if state not in ("MASTER", "BACKUP"))
|
||||
if down:
|
||||
problems[f"{cluster_name}:vip_down:{host.name}"] = {
|
||||
"cluster_name": cluster_name, "condition": "vip_down", "host_names": [host.name], "vhids": down,
|
||||
}
|
||||
# One node MASTER on some VHIDs and BACKUP on others: a partial failover. Already covered by the
|
||||
# split-brain alert when both nodes hold a MASTER.
|
||||
if not split_brain and {"MASTER", "BACKUP"} <= set(vips.values()):
|
||||
problems[f"{cluster_name}:role_mismatch:{host.name}"] = {
|
||||
"cluster_name": cluster_name, "condition": "role_mismatch", "host_names": [host.name],
|
||||
"vhids": sorted(v for v, state in vips.items() if state == "MASTER"),
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
@@ -319,3 +342,24 @@ async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
|
||||
if cfg is None or not cfg.offsite_enabled or not cfg.last_offsite_error:
|
||||
return {}
|
||||
return {"offsite": {"message": cfg.last_offsite_error}}
|
||||
|
||||
|
||||
async def check_compliance(db: AsyncSession) -> dict[str, dict]:
|
||||
"""Critical and high findings of the last security check, one problem per (host, check); resolved when it passes."""
|
||||
from app.services import compliance_service as cs
|
||||
hosts = {h.id: h for h in await _enabled_hosts(db)}
|
||||
problems: dict[str, dict] = {}
|
||||
for row in (await db.scalars(select(ComplianceResult))).all():
|
||||
host = hosts.get(row.host_id)
|
||||
if host is None or row.error:
|
||||
continue
|
||||
for finding in row.findings:
|
||||
if finding["passed"] is False and finding["severity"] in ("critical", "high"):
|
||||
params = finding.get("params") or {}
|
||||
problems[f"{host.id}:{finding['id']}"] = {
|
||||
"host_id": str(host.id), "host_name": host.name, "check": finding["id"],
|
||||
"befund": cs.TITLES_DE.get(finding["id"], finding["id"]),
|
||||
"schweregrad": cs.SEVERITIES_DE[finding["severity"]],
|
||||
"beispiele": ", ".join(params.get("examples", [])) or None,
|
||||
}
|
||||
return problems
|
||||
@@ -36,6 +36,7 @@ _LABELS = {
|
||||
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
||||
"backup_stale": "Kein aktuelles Backup",
|
||||
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
|
||||
"compliance_finding": "Sicherheitsbefund",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ _CHECKS = [
|
||||
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
||||
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
|
||||
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
|
||||
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
|
||||
]
|
||||
|
||||
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
||||
|
||||
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
||||
"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",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
|
||||
"poll_interval_seconds",
|
||||
]
|
||||
for field in simple_fields:
|
||||
|
||||
@@ -50,7 +50,7 @@ async def get_settings(db: AsyncSession) -> OidcSettings:
|
||||
if row is None:
|
||||
row = OidcSettings(
|
||||
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
|
||||
scopes="openid profile email groups", groups_claim="groups",
|
||||
scopes="openid profile email", groups_claim="groups",
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
@@ -130,7 +130,7 @@ def _challenge(verifier: str) -> str:
|
||||
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
|
||||
|
||||
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
|
||||
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> tuple[str, str]:
|
||||
"""Remember a fresh login attempt and return the provider URL to send the browser to."""
|
||||
doc = await discovery(cfg.issuer)
|
||||
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(24), secrets.token_urlsafe(48)
|
||||
@@ -141,7 +141,7 @@ async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
|
||||
"response_type": "code", "client_id": cfg.client_id, "redirect_uri": redirect_uri, "scope": cfg.scopes,
|
||||
"state": state, "nonce": nonce, "code_challenge": _challenge(verifier), "code_challenge_method": "S256",
|
||||
})
|
||||
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}"
|
||||
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}", state
|
||||
|
||||
|
||||
async def _consume_state(db: AsyncSession, state: str) -> OidcLoginState:
|
||||
@@ -207,7 +207,8 @@ async def _verify_id_token(cfg: OidcSettings, doc: dict, id_token: str, nonce: s
|
||||
|
||||
def _as_groups(value) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [g for g in re.split(r"[,\s]+", value) if g]
|
||||
# Group names may contain spaces ("Network Admins"): a string claim is split on commas and line breaks only.
|
||||
return [g.strip() for g in re.split(r"[,\n]+", value) if g.strip()]
|
||||
if isinstance(value, list):
|
||||
return [str(g) for g in value]
|
||||
return []
|
||||
@@ -224,14 +225,31 @@ def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||
if cfg.groups_claim in claims:
|
||||
return _as_groups(claims[cfg.groups_claim])
|
||||
async def _userinfo(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||
"""The provider's userinfo answer for THIS identity ({} if there is none or it belongs to somebody else)."""
|
||||
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
|
||||
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
|
||||
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
|
||||
return _as_groups(info.get(cfg.groups_claim))
|
||||
return []
|
||||
return info
|
||||
return {}
|
||||
|
||||
|
||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||
if cfg.groups_claim in claims:
|
||||
return _as_groups(claims[cfg.groups_claim])
|
||||
return _as_groups((await _userinfo(doc, claims, access_token)).get(cfg.groups_claim))
|
||||
|
||||
|
||||
async def _with_display_name(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||
"""Some providers (Authelia) keep preferred_username/email out of the ID token: ask userinfo, else the user would be named
|
||||
after the opaque ``sub``. Best effort: a failing userinfo call must not block a login that already has its role."""
|
||||
if claims.get("preferred_username") or claims.get("email"):
|
||||
return claims
|
||||
try:
|
||||
info = await _userinfo(doc, claims, access_token)
|
||||
except OidcError:
|
||||
return claims
|
||||
return {**claims, **{k: info[k] for k in ("preferred_username", "email") if info.get(k)}}
|
||||
|
||||
|
||||
def _username(claims: dict) -> str:
|
||||
@@ -267,4 +285,4 @@ async def complete(db: AsyncSession, cfg: OidcSettings, code: str, state_value:
|
||||
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
|
||||
if role is None:
|
||||
raise OidcError("no_role", "Kein zugeordnete Gruppe")
|
||||
return await provision(db, cfg, claims, role)
|
||||
return await provision(db, cfg, await _with_display_name(doc, claims, tokens.get("access_token")), role)
|
||||
@@ -9,7 +9,7 @@ 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 import opnsense_client, variables_service
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
@@ -18,7 +18,12 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_payload(rule: OneToOneRule) -> dict:
|
||||
def _build_payload(rule: OneToOneRule, host: OpnsenseHost | None = None) -> dict:
|
||||
payload = _base_payload(rule)
|
||||
return variables_service.resolve(payload, host) if host is not None else payload
|
||||
|
||||
|
||||
def _base_payload(rule: OneToOneRule) -> dict:
|
||||
return {
|
||||
"description": rule.description,
|
||||
"interface": rule.interface,
|
||||
@@ -35,7 +40,7 @@ def _build_payload(rule: OneToOneRule) -> dict:
|
||||
|
||||
|
||||
async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHost) -> OneToOneDeployment:
|
||||
payload = _build_payload(rule)
|
||||
payload = _build_payload(rule, host)
|
||||
existing = await db.scalar(
|
||||
select(OneToOneDeployment).where(
|
||||
OneToOneDeployment.one_to_one_id == rule.id, OneToOneDeployment.host_id == host.id
|
||||
@@ -78,6 +83,7 @@ async def deploy_one_to_one(db: AsyncSession, rule: OneToOneRule, hosts: list[Op
|
||||
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)
|
||||
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
||||
await db.flush()
|
||||
for host, deployment in zip(hosts, deployments):
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
|
||||
from app.models.policy import OneToOneRevision
|
||||
from app.services.one_to_one_deploy_service import _build_payload
|
||||
from app.services import variables_service
|
||||
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')
|
||||
@@ -27,13 +28,17 @@ def revision(db, rule, note):
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
payload = _build_payload(rule, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||
continue
|
||||
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"):
|
||||
@@ -60,6 +65,8 @@ def preview(rule):
|
||||
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"] == "missing_variable" for r in rows):
|
||||
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||
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):
|
||||
@@ -70,7 +77,7 @@ def preview(rule):
|
||||
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),
|
||||
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||
|
||||
|
||||
@@ -78,12 +85,11 @@ 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),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||
"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),
|
||||
|
||||
@@ -16,7 +16,7 @@ import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import firewall_url, opnsense_secret_store, tls_pin_service
|
||||
from app.services import firewall_url, opnsense_secret_store, overlay_service, tls_pin_service
|
||||
from app.services.redaction import redact_data, redact_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,10 +46,23 @@ def _client(host: OpnsenseHost) -> httpx.AsyncClient:
|
||||
auth=(host.api_key, secret),
|
||||
verify=tls_pin_service.verify_argument(host.verify_tls, host.tls_ca_pem),
|
||||
timeout=settings.opnsense_timeout,
|
||||
# Firewalls reached through the management overlay (WireGuard hub) go via the hub's CONNECT proxy.
|
||||
proxy=overlay_service.proxy_for(host.base_url),
|
||||
)
|
||||
|
||||
|
||||
def _refuse_unresolved_placeholders(method: str, path: str, kwargs: dict) -> None:
|
||||
"""Last line of defence for site variables: a payload that still contains a {{NAME}} placeholder was never
|
||||
resolved for this host and must not reach a firewall."""
|
||||
if method.upper() == "GET" or "json" not in kwargs:
|
||||
return
|
||||
from app.services.variables_service import PLACEHOLDER
|
||||
if PLACEHOLDER.search(json.dumps(kwargs["json"], default=str)):
|
||||
raise OpnsenseAPIError(f"{path}: Die Anfrage enthält eine nicht aufgelöste Variable ({{{{NAME}}}}) und wird nicht gesendet")
|
||||
|
||||
|
||||
async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> httpx.Response:
|
||||
_refuse_unresolved_placeholders(method, path, kwargs)
|
||||
async with _client(host) as client:
|
||||
try:
|
||||
resp = await client.request(method, path, **kwargs)
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.models.outbound_nat import OutboundNatRule
|
||||
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||
from app.services.outbound_nat_mode import require_manual_mode
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services import opnsense_client, variables_service
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
@@ -19,7 +19,12 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_payload(rule: OutboundNatRule) -> dict:
|
||||
def _build_payload(rule: OutboundNatRule, host: OpnsenseHost | None = None) -> dict:
|
||||
payload = _base_payload(rule)
|
||||
return variables_service.resolve(payload, host) if host is not None else payload
|
||||
|
||||
|
||||
def _base_payload(rule: OutboundNatRule) -> dict:
|
||||
return {
|
||||
"description": rule.description,
|
||||
"interface": rule.interface,
|
||||
@@ -39,7 +44,7 @@ def _build_payload(rule: OutboundNatRule) -> dict:
|
||||
|
||||
async def _deploy_to_host(db: AsyncSession, rule: OutboundNatRule, host: OpnsenseHost) -> OutboundNatDeployment:
|
||||
await require_manual_mode(host)
|
||||
payload = _build_payload(rule)
|
||||
payload = _build_payload(rule, host)
|
||||
existing = await db.scalar(
|
||||
select(OutboundNatDeployment).where(
|
||||
OutboundNatDeployment.outbound_nat_id == rule.id, OutboundNatDeployment.host_id == host.id
|
||||
@@ -82,6 +87,7 @@ async def deploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule, hosts: li
|
||||
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)
|
||||
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||
deployments = []
|
||||
try:
|
||||
for host in hosts:
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
|
||||
from app.models.policy import OutboundNatRevision
|
||||
from app.services.outbound_nat_deploy_service import _build_payload
|
||||
from app.services import variables_service
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
OUTBOUND_NAT_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'ipprotocol', 'protocol', 'source_port', 'destination_port', 'target', 'target_port')
|
||||
@@ -27,13 +28,17 @@ def revision(db, rule, note):
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
payload = _build_payload(rule, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||
continue
|
||||
before = dep.last_payload if dep else None
|
||||
action = "create" if not dep or not dep.opnsense_outbound_nat_uuid else "update"
|
||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||
@@ -60,6 +65,8 @@ def preview(rule):
|
||||
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"] == "missing_variable" for r in rows):
|
||||
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||
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):
|
||||
@@ -70,7 +77,7 @@ def preview(rule):
|
||||
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),
|
||||
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||
|
||||
|
||||
@@ -78,12 +85,11 @@ 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),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||
"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),
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Management overlay: keys, addressing, the hub's config file and the live status.
|
||||
|
||||
How it fits together
|
||||
--------------------
|
||||
* The ``wg-hub`` container (app/overlay_hub.py, same image as the backend, NET_ADMIN, UDP port published)
|
||||
owns the WireGuard interface. It reads ``<overlay_dir>/hub.json`` — written here whenever settings or peers
|
||||
change — and writes ``<overlay_dir>/status.json`` (handshakes, traffic) every few seconds.
|
||||
* The backend itself needs no extra privileges: requests to a firewall whose address lies in the overlay subnet
|
||||
go through the hub's small CONNECT proxy (``proxy_for``), which only lets traffic into that subnet.
|
||||
* The firewall dials out to the hub. Its private key never leaves it; OPNexus only learns its public key. A
|
||||
per-peer pre-shared key is generated here and shown to an admin for entering on the firewall.
|
||||
"""
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.overlay import OverlayPeer, OverlaySettings
|
||||
from app.services import secure_files
|
||||
|
||||
SINGLETON_ID = 1
|
||||
KEEPALIVE = 25
|
||||
CONNECTED_WITHIN = 180 # seconds since the last handshake
|
||||
_B64_KEY = re.compile(r"^[A-Za-z0-9+/]{43}=$")
|
||||
|
||||
_active_network: ipaddress.IPv4Network | None = None
|
||||
|
||||
|
||||
class OverlayError(Exception):
|
||||
"""A user-facing problem; ``code`` is stable, ``detail`` is for the log."""
|
||||
|
||||
def __init__(self, code: str, detail: str = ""):
|
||||
super().__init__(detail or code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _dir() -> Path:
|
||||
return Path(settings.overlay_dir)
|
||||
|
||||
|
||||
# ── keys ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def valid_public_key(value: str) -> bool:
|
||||
if not _B64_KEY.match(value or ""):
|
||||
return False
|
||||
try:
|
||||
return len(base64.b64decode(value, validate=True)) == 32
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _raw_b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def generate_private_key() -> str:
|
||||
key = X25519PrivateKey.generate()
|
||||
return _raw_b64(key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()))
|
||||
|
||||
|
||||
def public_from_private(private_b64: str) -> str:
|
||||
key = X25519PrivateKey.from_private_bytes(base64.b64decode(private_b64))
|
||||
return _raw_b64(key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
|
||||
|
||||
|
||||
def hub_private_key() -> str:
|
||||
"""The hub's private key, created on first use."""
|
||||
path = _dir() / "hub.key"
|
||||
if not path.exists():
|
||||
secure_files.write_private(path, generate_private_key())
|
||||
return path.read_text().strip()
|
||||
|
||||
|
||||
def hub_public_key() -> str:
|
||||
return public_from_private(hub_private_key())
|
||||
|
||||
|
||||
def peer_psk(peer_id) -> str:
|
||||
path = _dir() / f"psk-{peer_id}"
|
||||
if not path.exists():
|
||||
secure_files.write_private(path, _raw_b64(secrets.token_bytes(32)))
|
||||
return path.read_text().strip()
|
||||
|
||||
|
||||
def delete_peer_secret(peer_id) -> None:
|
||||
(_dir() / f"psk-{peer_id}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ── addressing ───────────────────────────────────────────────────────────
|
||||
|
||||
def parse_subnet(value: str) -> ipaddress.IPv4Network:
|
||||
try:
|
||||
net = ipaddress.ip_network(value.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise OverlayError("subnet", "kein gültiges Netz") from None
|
||||
if (not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29)
|
||||
or net.is_loopback or net.is_link_local or net.is_multicast or net.is_unspecified):
|
||||
raise OverlayError("subnet", "ein privates IPv4-Netz zwischen /16 und /29 ist nötig")
|
||||
return net
|
||||
|
||||
|
||||
def hub_address(net: ipaddress.IPv4Network) -> ipaddress.IPv4Address:
|
||||
return net.network_address + 1
|
||||
|
||||
|
||||
def next_free_ip(net: ipaddress.IPv4Network, taken: set[str]) -> str:
|
||||
hub = hub_address(net)
|
||||
for candidate in net.hosts():
|
||||
if candidate != hub and str(candidate) not in taken:
|
||||
return str(candidate)
|
||||
raise OverlayError("full", "Im Overlay-Netz ist keine Adresse mehr frei")
|
||||
|
||||
|
||||
def proxy_for(base_url: str) -> str | None:
|
||||
"""The hub's proxy if this firewall address is inside the overlay subnet, else None (direct)."""
|
||||
if _active_network is None:
|
||||
return None
|
||||
try:
|
||||
host = urlsplit(base_url).hostname
|
||||
return settings.overlay_proxy_url if host and ipaddress.ip_address(host) in _active_network else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ── settings, hub config ─────────────────────────────────────────────────
|
||||
|
||||
async def get_settings(db: AsyncSession) -> OverlaySettings:
|
||||
row = await db.get(OverlaySettings, SINGLETON_ID)
|
||||
if row is None:
|
||||
row = OverlaySettings(id=SINGLETON_ID, enabled=False, subnet="10.99.0.0/24", listen_port=51820)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def endpoint_with_port(cfg: OverlaySettings) -> str | None:
|
||||
if not cfg.endpoint:
|
||||
return None
|
||||
return cfg.endpoint if re.search(r":\d+$", cfg.endpoint) else f"{cfg.endpoint}:{cfg.listen_port}"
|
||||
|
||||
|
||||
async def sync(db: AsyncSession) -> None:
|
||||
"""Make hub.json match the database (call after every change and at startup) and refresh the proxy rule."""
|
||||
global _active_network
|
||||
cfg = await get_settings(db)
|
||||
net = parse_subnet(cfg.subnet)
|
||||
_active_network = net if cfg.enabled else None
|
||||
peers = (await db.scalars(select(OverlayPeer).order_by(OverlayPeer.ip))).all()
|
||||
config = {
|
||||
"enabled": bool(cfg.enabled), "address": f"{hub_address(net)}/{net.prefixlen}", "subnet": str(net),
|
||||
"listen_port": cfg.listen_port,
|
||||
"private_key": hub_private_key() if cfg.enabled else None,
|
||||
"peers": [{"public_key": p.public_key, "psk": peer_psk(p.id), "ip": p.ip} for p in peers if p.public_key] if cfg.enabled else [],
|
||||
}
|
||||
_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
tmp = _dir() / "hub.json.tmp"
|
||||
secure_files.write_private(tmp, json.dumps(config))
|
||||
os.replace(tmp, _dir() / "hub.json")
|
||||
|
||||
|
||||
# ── live status (written by the hub) ─────────────────────────────────────
|
||||
|
||||
def read_status() -> dict | None:
|
||||
try:
|
||||
data = json.loads((_dir() / "status.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def hub_state(status: dict | None, now: float | None = None) -> str:
|
||||
"""'running' | 'stale' (the hub stopped reporting) | 'absent' (never reported: container not running)"""
|
||||
if not status:
|
||||
return "absent"
|
||||
now = now or datetime.now(timezone.utc).timestamp()
|
||||
return "running" if now - float(status.get("updated_at", 0)) < 30 else "stale"
|
||||
|
||||
|
||||
def peer_status(status: dict | None, public_key: str | None, now: float | None = None) -> dict:
|
||||
now = now or datetime.now(timezone.utc).timestamp()
|
||||
entry = next((p for p in (status or {}).get("peers", []) if p.get("public_key") == public_key), None) if public_key else None
|
||||
handshake = int(entry.get("handshake_at", 0)) if entry else 0
|
||||
return {
|
||||
"connected": bool(handshake and now - handshake < CONNECTED_WITHIN and hub_state(status, now) == "running"),
|
||||
"last_handshake_at": datetime.fromtimestamp(handshake, timezone.utc) if handshake else None,
|
||||
"rx_bytes": int(entry.get("rx", 0)) if entry else 0, "tx_bytes": int(entry.get("tx", 0)) if entry else 0,
|
||||
"remote_endpoint": entry.get("endpoint") if entry else None,
|
||||
}
|
||||
|
||||
|
||||
def instructions(cfg: OverlaySettings, peer: OverlayPeer) -> dict:
|
||||
net = parse_subnet(cfg.subnet)
|
||||
return {
|
||||
"hub_public_key": hub_public_key(), "endpoint": endpoint_with_port(cfg),
|
||||
"tunnel_address": f"{peer.ip}/32", "allowed_ips": f"{hub_address(net)}/32", "psk": peer_psk(peer.id),
|
||||
"keepalive": KEEPALIVE, "hub_ip": str(hub_address(net)), "host_url": f"https://{peer.ip}",
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import text
|
||||
|
||||
from app.models.policy import RuleRevision
|
||||
from app.services.rule_deploy_service import _build_payload
|
||||
from app.services import variables_service
|
||||
|
||||
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
|
||||
|
||||
@@ -45,13 +46,17 @@ def ensure_editable(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)
|
||||
try:
|
||||
payload = _build_payload(rule, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||
continue
|
||||
before = dep.last_payload if dep else None
|
||||
action = "create" if not dep or not dep.opnsense_rule_uuid else "update"
|
||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||
@@ -78,6 +83,8 @@ def preview(rule):
|
||||
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 ihre Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||
if any(r["action"] == "missing_variable" for r in rows):
|
||||
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||
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):
|
||||
@@ -89,7 +96,7 @@ def preview(rule):
|
||||
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),
|
||||
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||
|
||||
|
||||
@@ -98,12 +105,11 @@ 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),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||
"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),
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.models.host import OpnsenseHost
|
||||
from app.models.port_forward import PortForwardRule
|
||||
from app.models.port_forward_deployment import PortForwardDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services import opnsense_client, variables_service
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
@@ -30,7 +30,12 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_payload(rule: PortForwardRule) -> dict:
|
||||
def _build_payload(rule: PortForwardRule, host: OpnsenseHost | None = None) -> dict:
|
||||
payload = _base_payload(rule)
|
||||
return variables_service.resolve(payload, host) if host is not None else payload
|
||||
|
||||
|
||||
def _base_payload(rule: PortForwardRule) -> dict:
|
||||
return {
|
||||
"disabled": "0" if rule.enabled else "1",
|
||||
"interface": rule.interface,
|
||||
@@ -47,7 +52,7 @@ def _build_payload(rule: PortForwardRule) -> dict:
|
||||
|
||||
|
||||
async def _deploy_to_host(db: AsyncSession, rule: PortForwardRule, host: OpnsenseHost) -> PortForwardDeployment:
|
||||
payload = _build_payload(rule)
|
||||
payload = _build_payload(rule, host)
|
||||
existing = await db.scalar(
|
||||
select(PortForwardDeployment).where(
|
||||
PortForwardDeployment.port_forward_id == rule.id, PortForwardDeployment.host_id == host.id
|
||||
@@ -90,6 +95,7 @@ async def deploy_port_forward(db: AsyncSession, rule: PortForwardRule, hosts: li
|
||||
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)
|
||||
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
||||
await db.flush()
|
||||
for host, deployment in zip(hosts, deployments):
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
|
||||
from app.models.policy import PortForwardRevision
|
||||
from app.services.port_forward_deploy_service import _build_payload
|
||||
from app.services import variables_service
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
PORT_FORWARD_FIELDS = (
|
||||
@@ -30,13 +31,17 @@ def revision(db, rule, note):
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
payload = _build_payload(rule, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||
continue
|
||||
before = dep.last_payload if dep else None
|
||||
action = "create" if not dep or not dep.opnsense_port_forward_uuid else "update"
|
||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||
@@ -63,6 +68,8 @@ def preview(rule):
|
||||
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"] == "missing_variable" for r in rows):
|
||||
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||
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):
|
||||
@@ -73,7 +80,7 @@ def preview(rule):
|
||||
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),
|
||||
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||
|
||||
|
||||
@@ -81,12 +88,11 @@ 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),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||
"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),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""HTTP CONNECT through the overlay hub's proxy, for the few places that open a raw TLS connection."""
|
||||
import asyncio
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
async def open_tunnel(proxy_url: str, host: str, port: int, timeout: float = 8.0):
|
||||
"""(reader, writer) connected to host:port through the proxy; TLS can be started on top with ``writer.start_tls``."""
|
||||
proxy = urlsplit(proxy_url)
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection(proxy.hostname, proxy.port or 8888), timeout)
|
||||
try:
|
||||
writer.write(f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode())
|
||||
await writer.drain()
|
||||
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout)
|
||||
status = int(head.split(b" ", 2)[1])
|
||||
if status != 200:
|
||||
raise OSError(f"Der Overlay-Proxy lehnt die Verbindung ab (HTTP {status})")
|
||||
except Exception:
|
||||
writer.close()
|
||||
raise
|
||||
return reader, writer
|
||||
@@ -27,7 +27,7 @@ from app.models.host import OpnsenseHost
|
||||
from app.models.rule import FirewallRule
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services import opnsense_client, variables_service
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
@@ -36,7 +36,12 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_payload(rule: FirewallRule) -> dict:
|
||||
def _build_payload(rule: FirewallRule, host: OpnsenseHost | None = None) -> dict:
|
||||
payload = _base_payload(rule)
|
||||
return variables_service.resolve(payload, host) if host is not None else payload
|
||||
|
||||
|
||||
def _base_payload(rule: FirewallRule) -> dict:
|
||||
return {
|
||||
"enabled": "1" if rule.enabled else "0",
|
||||
"action": rule.action,
|
||||
@@ -55,7 +60,7 @@ def _build_payload(rule: FirewallRule) -> dict:
|
||||
|
||||
|
||||
async def _deploy_to_host(db: AsyncSession, rule: FirewallRule, host: OpnsenseHost) -> RuleDeployment:
|
||||
payload = _build_payload(rule)
|
||||
payload = _build_payload(rule, host)
|
||||
existing = await db.scalar(
|
||||
select(RuleDeployment).where(RuleDeployment.rule_id == rule.id, RuleDeployment.host_id == host.id)
|
||||
)
|
||||
@@ -98,6 +103,7 @@ async def deploy_rule(db: AsyncSession, rule: FirewallRule, hosts: list[Opnsense
|
||||
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)
|
||||
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
||||
await db.flush()
|
||||
for host, deployment in zip(hosts, deployments):
|
||||
|
||||
@@ -58,7 +58,12 @@ class S3Client:
|
||||
if parts.scheme == "http" and not settings.allow_insecure_offsite:
|
||||
# Credentials and (encrypted, but still) backups must not travel in the clear.
|
||||
raise S3Error("Der Endpunkt muss https:// sein")
|
||||
self.scheme, self.netloc = parts.scheme, parts.netloc
|
||||
# httpx leaves a scheme's default port out of the Host header, so the signed host must not carry it either.
|
||||
port = parts.port
|
||||
default = {"https": 443, "http": 80}[parts.scheme]
|
||||
host = parts.hostname if ":" not in parts.hostname else f"[{parts.hostname}]"
|
||||
self.scheme = parts.scheme
|
||||
self.netloc = host if port in (None, default) else f"{host}:{port}"
|
||||
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
|
||||
self.access_key, self.secret_key = access_key, secret_key
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.x509.oid import ExtensionOID, NameOID
|
||||
|
||||
from app.services import overlay_service, proxy_connect
|
||||
|
||||
MAX_PEM_BYTES = 32 * 1024
|
||||
|
||||
|
||||
@@ -90,6 +92,23 @@ def verify_argument(verify_tls: bool, tls_ca_pem: str | None):
|
||||
return True
|
||||
|
||||
|
||||
async def _open_tls(base_url: str, parsed, ctx: ssl.SSLContext, timeout: float):
|
||||
"""TLS connection to the firewall: directly, or through the overlay hub's proxy for overlay addresses."""
|
||||
proxy = overlay_service.proxy_for(base_url)
|
||||
port = parsed.port or 443
|
||||
if proxy is None:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.open_connection(parsed.hostname, port, ssl=ctx, server_hostname=parsed.hostname), timeout
|
||||
)
|
||||
reader, writer = await proxy_connect.open_tunnel(proxy, parsed.hostname, port, timeout)
|
||||
try:
|
||||
await asyncio.wait_for(writer.start_tls(ctx, server_hostname=parsed.hostname), timeout)
|
||||
except Exception:
|
||||
writer.close()
|
||||
raise
|
||||
return reader, writer
|
||||
|
||||
|
||||
async def probe(base_url: str, timeout: float = 8.0) -> dict:
|
||||
"""Fetch the certificate a firewall presents, *without* trusting it. The
|
||||
caller must show the fingerprint to a human to compare with the firewall."""
|
||||
@@ -100,10 +119,7 @@ async def probe(base_url: str, timeout: float = 8.0) -> dict:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(parsed.hostname, parsed.port or 443, ssl=ctx, server_hostname=parsed.hostname),
|
||||
timeout,
|
||||
)
|
||||
_, writer = await _open_tls(base_url, parsed, ctx, timeout)
|
||||
except (OSError, ssl.SSLError, asyncio.TimeoutError) as exc:
|
||||
raise TlsPinError(f"Verbindung fehlgeschlagen: {exc}") from exc
|
||||
try:
|
||||
@@ -122,10 +138,7 @@ async def verify_matches(base_url: str, pem: str, timeout: float = 8.0) -> None:
|
||||
presents anything else."""
|
||||
parsed = urlparse(base_url)
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(parsed.hostname, parsed.port or 443, ssl=pinned_context(pem), server_hostname=parsed.hostname),
|
||||
timeout,
|
||||
)
|
||||
_, writer = await _open_tls(base_url, parsed, pinned_context(pem), timeout)
|
||||
writer.close()
|
||||
except ssl.SSLError as exc:
|
||||
raise TlsPinError("Das Zertifikat der Firewall passt nicht zum festgesetzten Zertifikat") from exc
|
||||
|
||||
@@ -17,8 +17,10 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.update_check_settings import DEFAULT_FEED_URL, UpdateCheckSettings
|
||||
from app.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +40,7 @@ class _State:
|
||||
latest: str | None = None
|
||||
release_url: str | None = None
|
||||
error: str | None = None
|
||||
url: str | None = None
|
||||
|
||||
|
||||
_state = _State()
|
||||
@@ -45,11 +48,41 @@ _lock = asyncio.Lock()
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Nur für Tests."""
|
||||
"""Verwirft den gemerkten Stand (Tests, geänderter Feed)."""
|
||||
global _state
|
||||
_state = _State()
|
||||
|
||||
|
||||
_SINGLETON_ID = 1
|
||||
_URL = re.compile(r"^https?://[^\s<>\"']+$")
|
||||
|
||||
|
||||
def valid_feed_url(value: str) -> bool:
|
||||
return bool(_URL.match(value)) and len(value) <= 500
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> UpdateCheckSettings:
|
||||
row = await db.get(UpdateCheckSettings, _SINGLETON_ID)
|
||||
if row is None:
|
||||
row = UpdateCheckSettings(id=_SINGLETON_ID, enabled=False, feed_url=DEFAULT_FEED_URL)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def managed_by_server() -> bool:
|
||||
"""UPDATE_CHECK_URL in the server's .env wins over the setting in the web UI."""
|
||||
return bool(settings.update_check_url.strip())
|
||||
|
||||
|
||||
async def feed_url(db: AsyncSession) -> str:
|
||||
"""The feed to ask, or "" while the check is off."""
|
||||
if managed_by_server():
|
||||
return settings.update_check_url.strip()
|
||||
row = await get_settings(db)
|
||||
return row.feed_url.strip() if row.enabled else ""
|
||||
|
||||
|
||||
def _parse(tag: str) -> tuple[int, int, int] | None:
|
||||
match = _STABLE_TAG.match(tag.strip())
|
||||
return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value]
|
||||
@@ -62,10 +95,10 @@ def _safe_url(value: object) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch() -> tuple[str, str | None]:
|
||||
async def _fetch(url: str) -> tuple[str, str | None]:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True, max_redirects=3) as http:
|
||||
response = await http.get(
|
||||
settings.update_check_url,
|
||||
url,
|
||||
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -78,9 +111,10 @@ async def _fetch() -> tuple[str, str | None]:
|
||||
return tag, _safe_url(data.get("html_url"))
|
||||
|
||||
|
||||
async def check() -> dict:
|
||||
async def check(db: AsyncSession) -> dict:
|
||||
"""Aktueller Stand für GET /api/system/version-check."""
|
||||
enabled = bool(settings.update_check_url.strip())
|
||||
url = await feed_url(db)
|
||||
enabled = bool(url)
|
||||
current = _parse(APP_VERSION)
|
||||
result = {
|
||||
"enabled": enabled,
|
||||
@@ -95,6 +129,9 @@ async def check() -> dict:
|
||||
return result
|
||||
|
||||
async with _lock:
|
||||
if _state.url != url: # Feed geändert: alter Stand gehört nicht mehr dazu
|
||||
reset_cache()
|
||||
_state.url = url
|
||||
now = datetime.now(timezone.utc)
|
||||
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
|
||||
fresh = _state.checked_at is not None and now - _state.checked_at < interval
|
||||
@@ -102,12 +139,12 @@ async def check() -> dict:
|
||||
if not fresh and not backing_off:
|
||||
_state.attempted_at = now
|
||||
try:
|
||||
tag, url = await _fetch()
|
||||
tag, release_url = await _fetch(url)
|
||||
if _parse(tag) is None:
|
||||
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
|
||||
_state.latest, _state.release_url = None, None
|
||||
else:
|
||||
_state.latest, _state.release_url = tag.lstrip("v"), url
|
||||
_state.latest, _state.release_url = tag.lstrip("v"), release_url
|
||||
_state.checked_at, _state.error = now, None
|
||||
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
|
||||
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Site variables: ``{{NAME}}`` placeholders in centrally authored objects, filled per host.
|
||||
|
||||
A rule, alias or NAT entry can say ``{{LAN_NET}}`` instead of a literal network; every host that object is
|
||||
deployed to supplies its own value (Host page > Variables). Resolution happens when the payload for one host is
|
||||
built, so preview, deploy, the change counters and the audit all see the host's real values.
|
||||
|
||||
Fail closed: a placeholder without a value for a target host is never pushed. Preview marks that host
|
||||
``missing_variable``, the deploy is refused before anything is written, and as a last line of defence
|
||||
``opnsense_client`` refuses to send any payload that still contains a placeholder.
|
||||
"""
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
|
||||
NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,39}$")
|
||||
VALUE_RE = re.compile(r"^[\w .:/,@+\-]{1,255}$") # networks, addresses, hostnames, names, lists: no braces, quotes or newlines
|
||||
PLACEHOLDER = re.compile(r"\{\{\s*([A-Z][A-Z0-9_]{0,39})\s*\}\}")
|
||||
|
||||
|
||||
class MissingVariables(Exception):
|
||||
"""One or more hosts lack values for placeholders the object uses. ``missing`` maps host name -> names."""
|
||||
|
||||
def __init__(self, missing: dict[str, list[str]]):
|
||||
self.missing = missing
|
||||
super().__init__("; ".join(f"{host}: {', '.join(names)}" for host, names in missing.items()))
|
||||
|
||||
|
||||
def names_in(value) -> set[str]:
|
||||
return set(PLACEHOLDER.findall(value)) if isinstance(value, str) else set()
|
||||
|
||||
|
||||
def names_in_payload(payload: dict) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for value in payload.values():
|
||||
found |= names_in(value)
|
||||
return found
|
||||
|
||||
|
||||
def host_values(host: OpnsenseHost) -> dict[str, str]:
|
||||
return {v.name: v.value for v in host.variables}
|
||||
|
||||
|
||||
def resolve(payload: dict, host: OpnsenseHost) -> dict:
|
||||
"""The payload with this host's values filled in; raises MissingVariables if one is not defined."""
|
||||
values = host_values(host)
|
||||
missing = sorted(n for n in names_in_payload(payload) if n not in values)
|
||||
if missing:
|
||||
raise MissingVariables({host.name: missing})
|
||||
return {k: PLACEHOLDER.sub(lambda m: values[m.group(1)], v) if isinstance(v, str) else v for k, v in payload.items()}
|
||||
|
||||
|
||||
def ensure_resolvable(builder: Callable, entity, hosts: list[OpnsenseHost]) -> None:
|
||||
"""Raise MissingVariables (listing every affected host) before anything is written."""
|
||||
missing: dict[str, list[str]] = {}
|
||||
for host in hosts:
|
||||
try:
|
||||
builder(entity, host)
|
||||
except MissingVariables as exc:
|
||||
missing.update(exc.missing)
|
||||
if missing:
|
||||
raise MissingVariables(missing)
|
||||
|
||||
|
||||
def payload_or_none(builder: Callable, entity, host: OpnsenseHost):
|
||||
try:
|
||||
return builder(entity, host)
|
||||
except MissingVariables:
|
||||
return None
|
||||
|
||||
|
||||
def preview_row_missing(host: OpnsenseHost, names: list[str], dep) -> dict:
|
||||
return {
|
||||
"host_id": str(host.id), "host_name": host.name, "action": "missing_variable",
|
||||
"status": dep.status if dep else None,
|
||||
"message": "Variable fehlt: " + ", ".join(names), "can_remove": False,
|
||||
"can_retry_rollback": False, "changes": [],
|
||||
}
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.20.0"
|
||||
APP_CODENAME = "Lilli"
|
||||
APP_VERSION = "1.23.11"
|
||||
APP_CODENAME = "Harrier"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Management-Overlay (WireGuard-Hub) für Firewalls hinter NAT.
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0015"
|
||||
down_revision = "0014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("overlay_settings"):
|
||||
op.create_table(
|
||||
"overlay_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("endpoint", sa.String(length=255), nullable=True),
|
||||
sa.Column("subnet", sa.String(length=18), nullable=False),
|
||||
sa.Column("listen_port", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
if not inspector.has_table("overlay_peers"):
|
||||
op.create_table(
|
||||
"overlay_peers",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=80), nullable=False),
|
||||
sa.Column("ip", sa.String(length=15), nullable=False),
|
||||
sa.Column("public_key", sa.String(length=44), nullable=True),
|
||||
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("ip"),
|
||||
sa.UniqueConstraint("public_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS overlay_peers")
|
||||
op.execute("DROP TABLE IF EXISTS overlay_settings")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Standort-Variablen: Werte für {{NAME}}-Platzhalter je Host.
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0016"
|
||||
down_revision = "0015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table("host_variables"):
|
||||
return
|
||||
op.create_table(
|
||||
"host_variables",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=41), nullable=False),
|
||||
sa.Column("value", sa.String(length=255), nullable=False),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("host_id", "name", name="uq_host_variable"),
|
||||
)
|
||||
op.create_index("ix_host_variables_host_id", "host_variables", ["host_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS host_variables")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Benachrichtigung bei kritischen/hohen Sicherheitsbefunden (standardmäßig aus).
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0017"
|
||||
down_revision = "0016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS compliance_alerts_enabled boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS compliance_alerts_enabled")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""OIDC: Standard-Scopes ohne "groups" (Keycloak und Entra ID lehnen den Scope als ungültig ab).
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Only rows still carrying the old default; a value somebody typed on purpose stays.
|
||||
op.execute("UPDATE oidc_settings SET scopes = 'openid profile email' WHERE scopes = 'openid profile email groups'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Versionsprüfung im Webinterface einstellbar.
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table("update_check_settings"):
|
||||
return
|
||||
op.create_table(
|
||||
"update_check_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("feed_url", sa.String(length=500), server_default=_DEFAULT_FEED_URL, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS update_check_settings")
|
||||
@@ -20,6 +20,7 @@ os.environ["WIREGUARD_KEY_SECRET_DIR"] = "/tmp/opnexus-test-wireguard-secrets"
|
||||
os.environ["CERT_KEY_SECRET_DIR"] = "/tmp/opnexus-test-cert-key-secrets"
|
||||
os.environ["ACME_SECRET_DIR"] = "/tmp/opnexus-test-acme-secrets"
|
||||
os.environ["TOTP_SECRET_DIR"] = "/tmp/opnexus-test-totp-secrets"
|
||||
os.environ["OVERLAY_DIR"] = "/tmp/opnexus-test-overlay"
|
||||
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
||||
# Force-set rather than setdefault: docker-compose's backend service sets
|
||||
# ADMIN_PASSWORD="" (present-but-empty) by default, which setdefault would
|
||||
|
||||
@@ -279,3 +279,18 @@ async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, a
|
||||
assert (await api.get("/api/openapi.json")).status_code == 200
|
||||
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
|
||||
assert (await client.get("/docs")).status_code != 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changing_your_own_password_revokes_your_api_tokens(client):
|
||||
assert (await client.post("/api/users", json={"username": "pwtok", "password": "first-password-12", "role": "operator"})).status_code == 201
|
||||
user = await _login("pwtok", "first-password-12")
|
||||
try:
|
||||
token = (await user.post("/api/tokens", json={"name": "ci", "scope": "read", "current_password": "first-password-12"})).json()["token"]
|
||||
async with _bearer(token) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 200
|
||||
resp = await user.post("/api/auth/change-password", json={"current_password": "first-password-12", "new_password": "second-password-34"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert (await api.get("/api/hosts")).status_code == 401
|
||||
finally:
|
||||
await user.aclose()
|
||||
@@ -374,9 +374,9 @@ async def test_settings_changes_are_audited_without_secrets(client):
|
||||
|
||||
# ── config diff ──────────────────────────────────────────────────────────
|
||||
|
||||
def _xml(*, hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
def _xml(*, otp="OTPSEED1", hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey></user>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey><otp_seed>{otp}</otp_seed><pwd>PW-{password}</pwd></user>"
|
||||
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ async def test_diff_shows_changes_and_hides_every_secret(client):
|
||||
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
|
||||
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
|
||||
assert secret not in text, secret
|
||||
assert "‹sha256:" in text # a changed secret is still visible as a change
|
||||
assert "‹" in text # a changed secret is still visible as a change
|
||||
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
|
||||
|
||||
|
||||
@@ -433,3 +433,25 @@ async def test_diff_is_capped(client):
|
||||
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
assert body["truncated"] is True and len(body["lines"]) == 3000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_hides_otp_seeds_and_uses_a_salt_per_request(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
backup_store.save_snapshot(host["id"], _xml())
|
||||
backup_store.save_snapshot(host["id"], _xml(otp="OTPSEED2", password="p9", dns="9.9.9.9"))
|
||||
first = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
second = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
text = "\n".join(first["lines"])
|
||||
for secret in ("OTPSEED1", "OTPSEED2", "PW-p9", "PW-p1"):
|
||||
assert secret not in text, secret
|
||||
import hashlib
|
||||
assert hashlib.sha256(b"OTPSEED1").hexdigest()[:12] not in text # no unsalted hash to run a dictionary against
|
||||
assert first["lines"] != second["lines"] # a fresh salt per request: hashes cannot be matched across requests
|
||||
|
||||
|
||||
def test_signed_host_has_no_default_port_but_keeps_a_custom_one():
|
||||
plain = s3_client.S3Client(endpoint="https://s3.test:443", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
assert plain.netloc == "s3.test" and plain._target("k")[0] == "s3.test"
|
||||
custom = s3_client.S3Client(endpoint="https://s3.test:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
assert custom.netloc == "s3.test:9000"
|
||||
@@ -69,6 +69,43 @@ async def test_check_carp_health_healthy_reports_nothing(client, respx_mock):
|
||||
assert problems == {}
|
||||
|
||||
|
||||
def _vips(**states: str) -> dict:
|
||||
return {"rows": [{"vhid": v, "status": st} for v, st in states.items()],
|
||||
"carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_carp_health_detects_a_vip_that_is_down_on_one_node(client, respx_mock):
|
||||
# 2026-10-20: VHID 130 was DISABLED on the primary and taken over by the secondary; per host it looked fine.
|
||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "DISABLED"})))
|
||||
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
|
||||
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
|
||||
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
|
||||
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_carp_health(db)
|
||||
|
||||
assert {p["condition"] for p in problems.values()} == {"vip_down"}
|
||||
assert next(iter(problems.values()))["vhids"] == ["130"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_carp_health_detects_a_partial_failover(client, respx_mock):
|
||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "BACKUP"})))
|
||||
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
|
||||
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
|
||||
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
|
||||
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_carp_health(db)
|
||||
|
||||
assert [p["condition"] for p in problems.values()] == ["role_mismatch"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_carp_health_ignores_single_reachable_host(client, respx_mock):
|
||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(return_value=httpx.Response(200, json=_vip("MASTER")))
|
||||
|
||||
@@ -324,3 +324,58 @@ def test_unused_finding_is_unknown_while_collecting_and_a_low_finding_when_found
|
||||
found = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": ["old vpn rule"], "collecting": 0}))
|
||||
assert found["rules_unused"]["passed"] is False and found["rules_unused"]["severity"] == "low"
|
||||
assert found["rules_unused"]["params"] == {"count": 1, "examples": ["old vpn rule"]}
|
||||
|
||||
|
||||
# ── notifications ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_and_high_findings_become_problems_that_resolve_when_fixed(client, respx_mock):
|
||||
from app.database import SessionLocal
|
||||
from app.services import notification_checks
|
||||
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL"),
|
||||
ssh="<ssh><enabled>enabled</enabled><permitrootlogin>1</permitrootlogin><passwordauth>1</passwordauth></ssh>"))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {} # nothing checked yet
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_compliance(db)
|
||||
assert set(problems) == {f"{host['id']}:wan_any_any", f"{host['id']}:ssh_hardened"}
|
||||
wan = problems[f"{host['id']}:wan_any_any"]
|
||||
assert wan["host_name"] == "fw1" and wan["schweregrad"] == "kritisch" and wan["beispiele"] == "Allow ALL" and wan["befund"].startswith("Keine Allow-all")
|
||||
# The firewall is fixed: next check, the problems are gone (-> "resolved").
|
||||
respx_mock.clear()
|
||||
_mock_host(respx_mock, _config())
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client, respx_mock):
|
||||
from app.database import SessionLocal
|
||||
from app.services import notification_checks
|
||||
assert (await client.get("/api/notifications/settings")).json()["compliance_alerts_enabled"] is False
|
||||
assert (await client.put("/api/notifications/settings", json={"compliance_alerts_enabled": True})).json()["compliance_alerts_enabled"] is True
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(500))
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_truncated_config_does_not_stop_the_others(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
bad = await _create_host(client, "a-bad", "https://bad.test")
|
||||
good = await _create_host(client, "b-good", "https://good.test")
|
||||
for base, body in (("bad", b"<opnsense><system><webgui>"), ("good", _config())):
|
||||
respx_mock.get(f"https://{base}.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=body))
|
||||
respx_mock.get(f"https://{base}.test/api/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": "none"}))
|
||||
respx_mock.get(f"https://{base}.test/api/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
|
||||
respx_mock.get(f"https://{base}.test/api/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
resp = await client.post("/api/compliance/check")
|
||||
assert resp.status_code == 200, resp.text
|
||||
by_name = {h["host_name"]: h for h in resp.json()}
|
||||
assert by_name["a-bad"]["error"] == "invalid_config" and by_name["b-good"]["score"] is not None
|
||||
@@ -192,6 +192,7 @@ async def test_vip_password_never_leaves_the_server(client, respx_mock):
|
||||
_backup(respx_mock)
|
||||
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
||||
return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "10", "status": "BACKUP"}]}))
|
||||
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
|
||||
resp = await _preview(client, host_id, **change)
|
||||
@@ -462,6 +463,45 @@ async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mo
|
||||
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vip_description_edit_does_not_reapply_the_vip(client, respx_mock):
|
||||
# Re-applying a CARP VIP for a description edit left it in INIT on both prod nodes (2026-10-20).
|
||||
host_id = await _setup(client, respx_mock, "fw-v2")
|
||||
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
||||
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
||||
"vhid": "12", "advskew": "0", "descr": "old"})
|
||||
_backup(respx_mock)
|
||||
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"descr": "new"}}
|
||||
plan = (await _preview(client, host_id, **change)).json()
|
||||
done = await _execute(client, host_id, plan["token"], **change)
|
||||
assert done.status_code == 200, done.text
|
||||
assert not reconf.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vip_edit_reports_a_vip_that_is_not_live(client, respx_mock, monkeypatch):
|
||||
async def no_sleep(_):
|
||||
return None
|
||||
monkeypatch.setattr("app.services.network_change_service.asyncio.sleep", no_sleep)
|
||||
host_id = await _setup(client, respx_mock, "fw-v3")
|
||||
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
||||
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
||||
"vhid": "13", "advskew": "0", "descr": "x"})
|
||||
_backup(respx_mock)
|
||||
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(
|
||||
return_value=httpx.Response(200, json={"rows": [{"vhid": "13", "status": "DISABLED"}]}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||
plan = (await _preview(client, host_id, **change)).json()
|
||||
done = await _execute(client, host_id, plan["token"], **change)
|
||||
assert done.status_code == 200, done.text
|
||||
assert done.json()["verified"] is False and "carp_vip_inactive" in done.json()["mismatches"]
|
||||
assert reconf.call_count == 2 # one retry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
||||
# OPNsense blanks a VIP's address when an update leaves out "network" (seen live on 26.7.6).
|
||||
@@ -471,12 +511,15 @@ async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
||||
"vhid": "11", "advskew": "0", "descr": "x"})
|
||||
_backup(respx_mock)
|
||||
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "11", "status": "BACKUP"}]}))
|
||||
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||
plan = (await _preview(client, host_id, **change)).json()
|
||||
done = await _execute(client, host_id, plan["token"], **change)
|
||||
assert done.status_code == 200, done.text
|
||||
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24"}
|
||||
# ...and the interface: OPNsense queues a delete of the live address when the posted interface differs
|
||||
# from the stored one, and the reconfigure then drops the VIP (seen live 2026-10-20).
|
||||
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24", "interface": "lan"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -166,11 +166,24 @@ async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(clien
|
||||
assert _error(resp) == "no_role"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_username_comes_from_userinfo_if_the_id_token_has_none_and_a_failing_userinfo_does_not_block_the_login(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
# Authelia keeps preferred_username/email out of the ID token; without userinfo the account would be named after the opaque sub.
|
||||
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "preferred_username": "alice", "email": "[email protected]"})
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None))
|
||||
assert resp.headers["location"] == "/dashboard" and list(await _users("alice")) == ["alice"]
|
||||
respx_mock.clear()
|
||||
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "preferred_username": "mallory"})
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None, sub="user-2"))
|
||||
assert resp.headers["location"] == "/dashboard" and "mallory" not in await _users("x") and "user-2" in await _users("x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers bar"))
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers"))
|
||||
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "read_only"
|
||||
|
||||
|
||||
@@ -342,3 +355,57 @@ async def test_failed_callbacks_are_throttled(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
results = [_error(await anon_client.get("/api/auth/oidc/callback", params={"code": "x", "state": f"s{i}"})) for i in range(12)]
|
||||
assert "throttled" in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_string_group_claim_keeps_names_with_spaces_together(client, anon_client, respx_mock):
|
||||
await _configure(client, admin_group="Network Admins", operator_group="Admins", read_only_group="")
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network Admins"))
|
||||
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "admin" # not "Admins" = operator, not split
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network, Team"))
|
||||
assert _error(resp) == "no_role"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_callback_only_works_in_the_browser_that_started_the_login(client, anon_client, respx_mock):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from app.main import app
|
||||
await _configure(client)
|
||||
query = await _start(anon_client, respx_mock)
|
||||
# Somebody else (another browser, no state cookie) opens the callback URL: refused, and the state is not burned for the owner.
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as stranger:
|
||||
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={"id_token": _id_token(_claims(query["nonce"]))}))
|
||||
resp = await stranger.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||
assert _error(resp) == "state"
|
||||
assert await _users("alice") == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sso_sessions_are_short_and_the_state_cookie_is_httponly(client, anon_client, respx_mock):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.models.user import Session
|
||||
await _configure(client)
|
||||
_mock_idp(respx_mock)
|
||||
start = await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})
|
||||
cookie = start.headers["set-cookie"].lower()
|
||||
assert "opnexus-oidc-state=" in cookie and "httponly" in cookie and "samesite=lax" in cookie and "path=/api/auth/oidc" in cookie
|
||||
query = {k: v[0] for k, v in parse_qs(urlsplit(start.headers["location"]).query).items()}
|
||||
resp, _ = await _callback(anon_client, respx_mock, query)
|
||||
assert resp.headers["location"] == "/dashboard"
|
||||
assert "max-age=43200" in resp.headers["set-cookie"].lower()
|
||||
async with SessionLocal() as db:
|
||||
user = (await _users("alice"))["alice"]
|
||||
session = (await db.scalars(select(Session).where(Session.user_id == user.id))).one()
|
||||
assert session.expires_at - datetime.now(timezone.utc) < timedelta(hours=12, minutes=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_text_from_the_provider_cannot_forge_log_lines(client, anon_client, caplog):
|
||||
await _configure(client)
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await anon_client.get("/api/auth/oidc/callback", params={"error": "x\r\nFAKE LOG LINE\x1b[31m", "state": "s"})
|
||||
text = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "FAKE LOG LINE" in text and "\r" not in text and "\x1b" not in text and "\nFAKE" not in text
|
||||
@@ -113,6 +113,23 @@ async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
|
||||
("POST", "/api/hosts/abc/maintenance/carp-execute", True),
|
||||
("POST", "/api/hosts/abc/maintenance/schedule", True),
|
||||
("POST", "/api/hosts/abc/network/changes", True),
|
||||
("DELETE", "/api/rules/abc", True),
|
||||
("DELETE", "/api/aliases/abc", True),
|
||||
("DELETE", "/api/port-forward/abc", True),
|
||||
("DELETE", "/api/one-to-one/abc", True),
|
||||
("DELETE", "/api/outbound-nat/abc", True),
|
||||
("DELETE", "/api/policy-hierarchy/rules/abc", True),
|
||||
("DELETE", "/api/ipsec/tunnels/abc", True),
|
||||
("DELETE", "/api/certificates/managed/abc", True),
|
||||
("DELETE", "/api/hosts/abc/snapshots/def", True),
|
||||
("POST", "/api/certificates/managed/abc/push", True),
|
||||
("POST", "/api/acme/accounts/abc/register", True),
|
||||
("POST", "/api/acme/certificates/abc/sign", True),
|
||||
("POST", "/api/acme/certificates/abc/poll", True),
|
||||
("POST", "/api/policy-hierarchy/groups/abc/reassert-ordering", True),
|
||||
("POST", "/api/hosts/abc/services/unbound/restart", True),
|
||||
("DELETE", "/api/groups/abc", False),
|
||||
("DELETE", "/api/rules/abc/extra", False),
|
||||
("POST", "/api/rules/abc/preview", False),
|
||||
("POST", "/api/rules", False),
|
||||
("PUT", "/api/rules/abc", False),
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Management overlay: keys, addressing, hub config, the hub's proxy, and routing requests through it."""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from app.config import settings
|
||||
from app.overlay_hub import Hub, parse_dump, wg_conf
|
||||
from app.services import overlay_service as svc
|
||||
from tests.test_api_tokens import _login
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_overlay():
|
||||
root = Path(settings.overlay_dir)
|
||||
if root.exists():
|
||||
for f in root.iterdir():
|
||||
f.unlink()
|
||||
svc._active_network = None
|
||||
yield
|
||||
svc._active_network = None
|
||||
|
||||
|
||||
# ── keys and addresses ───────────────────────────────────────────────────
|
||||
|
||||
def test_keys_are_valid_wireguard_keys_and_the_hub_key_is_stable():
|
||||
private = svc.generate_private_key()
|
||||
public = svc.public_from_private(private)
|
||||
assert svc.valid_public_key(public) and len(public) == 44 and public != private
|
||||
assert svc.hub_public_key() == svc.hub_public_key()
|
||||
assert oct((Path(settings.overlay_dir) / "hub.key").stat().st_mode & 0o777) == "0o600"
|
||||
# RFC 7748 section 6.1 vector (Alice): the standard X25519 public key for a given private key
|
||||
alice_priv = bytes.fromhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")
|
||||
import base64
|
||||
assert svc.public_from_private(base64.b64encode(alice_priv).decode()) == base64.b64encode(
|
||||
bytes.fromhex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")).decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,ok", [
|
||||
("A" * 43 + "=", True), ("", False), ("short", False), ("A" * 44, False), ("A" * 42 + "==", False), ("!" * 43 + "=", False),
|
||||
])
|
||||
def test_public_key_validation(value, ok):
|
||||
assert svc.valid_public_key(value) is ok
|
||||
|
||||
|
||||
def test_subnets():
|
||||
assert str(svc.parse_subnet("10.99.0.0/24")) == "10.99.0.0/24"
|
||||
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", "", "127.0.0.0/24", "169.254.1.0/24"):
|
||||
with pytest.raises(svc.OverlayError):
|
||||
svc.parse_subnet(bad)
|
||||
|
||||
|
||||
def test_allocation_skips_the_hub_and_taken_addresses_and_runs_out():
|
||||
net = svc.parse_subnet("10.99.0.0/29") # .1 hub, .2-.6 usable
|
||||
assert str(svc.hub_address(net)) == "10.99.0.1"
|
||||
taken: set[str] = set()
|
||||
for expected in ("10.99.0.2", "10.99.0.3", "10.99.0.4", "10.99.0.5", "10.99.0.6"):
|
||||
ip = svc.next_free_ip(net, taken)
|
||||
assert ip == expected
|
||||
taken.add(ip)
|
||||
with pytest.raises(svc.OverlayError):
|
||||
svc.next_free_ip(net, taken)
|
||||
taken.discard("10.99.0.3")
|
||||
assert svc.next_free_ip(net, taken) == "10.99.0.3" # reuses holes
|
||||
|
||||
|
||||
def test_proxy_routing_decision():
|
||||
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
|
||||
assert svc.proxy_for("https://10.99.0.2") == settings.overlay_proxy_url
|
||||
assert svc.proxy_for("https://10.99.0.2:8443/x") == settings.overlay_proxy_url
|
||||
for direct in ("https://10.98.0.2", "https://fw.example.com", "https://[fd00::1]", "not a url", "https://"):
|
||||
assert svc.proxy_for(direct) is None
|
||||
svc._active_network = None
|
||||
assert svc.proxy_for("https://10.99.0.2") is None
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _enable(client, **extra):
|
||||
resp = await client.put("/api/overlay/settings", json={"enabled": True, "endpoint": "vpn.example.com", **extra})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _hub_json():
|
||||
return json.loads((Path(settings.overlay_dir) / "hub.json").read_text())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabling_needs_an_endpoint_and_writes_the_hub_config(client):
|
||||
assert (await client.put("/api/overlay/settings", json={"enabled": True})).status_code == 422
|
||||
body = await _enable(client)
|
||||
assert body["enabled"] and body["hub_public_key"] == svc.hub_public_key() and body["hub_address"] == "10.99.0.1"
|
||||
assert body["hub_state"] == "absent" # no hub container reporting
|
||||
config = _hub_json()
|
||||
assert config["enabled"] and config["address"] == "10.99.0.1/24" and config["listen_port"] == 51820 and config["peers"] == []
|
||||
assert config["private_key"] == svc.hub_private_key()
|
||||
assert svc._active_network == ipaddress.ip_network("10.99.0.0/24")
|
||||
assert oct((Path(settings.overlay_dir) / "hub.json").stat().st_mode & 0o777) == "0o600"
|
||||
await client.put("/api/overlay/settings", json={"enabled": False})
|
||||
off = _hub_json()
|
||||
assert off["enabled"] is False and off["private_key"] is None and off["peers"] == [] and svc._active_network is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_lifecycle_and_hub_config(client):
|
||||
await _enable(client, listen_port=51999)
|
||||
created = (await client.post("/api/overlay/peers", json={"name": "branch-1"})).json()
|
||||
assert created["ip"] == "10.99.0.2"
|
||||
ins = created["instructions"]
|
||||
assert ins["tunnel_address"] == "10.99.0.2/32" and ins["allowed_ips"] == "10.99.0.1/32" and ins["endpoint"] == "vpn.example.com:51999"
|
||||
assert ins["host_url"] == "https://10.99.0.2" and ins["keepalive"] == 25 and svc.valid_public_key(ins["hub_public_key"]) and ins["psk"]
|
||||
again = (await client.get(f"/api/overlay/peers/{created['id']}/instructions")).json()
|
||||
assert again["psk"] == ins["psk"] # stable, so the admin can look it up again
|
||||
second = (await client.post("/api/overlay/peers", json={"name": "branch-2"})).json()
|
||||
assert second["ip"] == "10.99.0.3" and second["instructions"]["psk"] != ins["psk"]
|
||||
assert (await client.post("/api/overlay/peers", json={"name": "branch-1"})).status_code == 409
|
||||
# A peer without a public key is not in the hub config yet.
|
||||
assert _hub_json()["peers"] == []
|
||||
key = svc.public_from_private(svc.generate_private_key())
|
||||
assert (await client.patch(f"/api/overlay/peers/{created['id']}", json={"public_key": key})).status_code == 200
|
||||
peers = _hub_json()["peers"]
|
||||
assert peers == [{"public_key": key, "psk": ins["psk"], "ip": "10.99.0.2"}]
|
||||
# Deleting removes it from the config and the PSK from disk.
|
||||
assert (await client.delete(f"/api/overlay/peers/{created['id']}")).status_code == 204
|
||||
assert _hub_json()["peers"] == [] and not (Path(settings.overlay_dir) / f"psk-{created['id']}").exists()
|
||||
assert (await client.delete(f"/api/overlay/peers/{created['id']}")).status_code == 404
|
||||
# The freed address is reused.
|
||||
assert (await client.post("/api/overlay/peers", json={"name": "branch-3"})).json()["ip"] == "10.99.0.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_key_validation_and_host_link(client, respx_mock):
|
||||
import httpx
|
||||
from tests.test_rules import _create_host
|
||||
await _enable(client)
|
||||
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
|
||||
other = (await client.post("/api/overlay/peers", json={"name": "q"})).json()
|
||||
for bad in ("nonsense", "A" * 44, svc.hub_public_key()):
|
||||
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": bad})).status_code == 422, bad
|
||||
good = svc.public_from_private(svc.generate_private_key())
|
||||
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": good})).status_code == 200
|
||||
assert (await client.patch(f"/api/overlay/peers/{other['id']}", json={"public_key": good})).status_code == 409 # one key, one peer
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"host_id": host["id"]})).status_code == 200
|
||||
listing = (await client.get("/api/overlay")).json()["peers"]
|
||||
assert next(p for p in listing if p["id"] == peer["id"])["host_name"] == "fw1"
|
||||
import uuid
|
||||
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"host_id": str(uuid.uuid4())})).status_code == 404
|
||||
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"clear_host": True})).status_code == 200
|
||||
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": ""})).status_code == 200 # clearing is allowed
|
||||
assert _hub_json()["peers"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subnet_changes_only_without_peers(client):
|
||||
await _enable(client)
|
||||
assert (await client.put("/api/overlay/settings", json={"subnet": "172.30.5.0/24"})).json()["hub_address"] == "172.30.5.1"
|
||||
assert (await client.put("/api/overlay/settings", json={"subnet": "8.8.8.0/24"})).status_code == 422
|
||||
peer = (await client.post("/api/overlay/peers", json={"name": "x"})).json()
|
||||
assert peer["ip"] == "172.30.5.2"
|
||||
resp = await client.put("/api/overlay/settings", json={"subnet": "10.50.0.0/24"})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OVERLAY_SUBNET_LOCKED"
|
||||
assert (await client.put("/api/overlay/settings", json={"subnet": "172.30.5.0/24"})).status_code == 200 # same value is no change
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_status_from_the_hubs_report(client):
|
||||
await _enable(client)
|
||||
key = svc.public_from_private(svc.generate_private_key())
|
||||
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
|
||||
await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": key})
|
||||
status_file = Path(settings.overlay_dir) / "status.json"
|
||||
now = time.time()
|
||||
status_file.write_text(json.dumps({"updated_at": now, "up": True, "error": None,
|
||||
"peers": [{"public_key": key, "endpoint": "203.0.113.9:4000", "handshake_at": int(now) - 30, "rx": 1000, "tx": 2000}]}))
|
||||
data = (await client.get("/api/overlay")).json()
|
||||
assert data["settings"]["hub_state"] == "running"
|
||||
p = data["peers"][0]
|
||||
assert p["connected"] is True and p["rx_bytes"] == 1000 and p["tx_bytes"] == 2000 and p["remote_endpoint"] == "203.0.113.9:4000"
|
||||
status_file.write_text(json.dumps({"updated_at": now, "up": True, "peers": [{"public_key": key, "handshake_at": int(now) - 900, "rx": 1, "tx": 1}]}))
|
||||
assert (await client.get("/api/overlay")).json()["peers"][0]["connected"] is False # handshake too old
|
||||
status_file.write_text(json.dumps({"updated_at": now - 600, "up": True, "peers": [{"public_key": key, "handshake_at": int(now), "rx": 1, "tx": 1}]}))
|
||||
data = (await client.get("/api/overlay")).json()
|
||||
assert data["settings"]["hub_state"] == "stale" and data["peers"][0]["connected"] is False # hub stopped reporting
|
||||
status_file.write_text("not json")
|
||||
assert (await client.get("/api/overlay")).json()["settings"]["hub_state"] == "absent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_admins_write_or_read_the_psk(client):
|
||||
await _enable(client)
|
||||
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
|
||||
for role in ("operator", "read_only"):
|
||||
name = f"ov-{role}"
|
||||
assert (await client.post("/api/users", json={"username": name, "password": "overlay-password-1", "role": role})).status_code == 201
|
||||
user = await _login(name, "overlay-password-1")
|
||||
try:
|
||||
assert (await user.get("/api/overlay")).status_code == 200 # status is for everybody ...
|
||||
assert "psk" not in (await user.get("/api/overlay")).text
|
||||
assert (await user.get(f"/api/overlay/peers/{peer['id']}/instructions")).status_code == 403 # ... the pre-shared key is not
|
||||
for method, path, body in (("PUT", "/api/overlay/settings", {"enabled": False}), ("POST", "/api/overlay/peers", {"name": "z"}),
|
||||
("PATCH", f"/api/overlay/peers/{peer['id']}", {"clear_host": True}), ("DELETE", f"/api/overlay/peers/{peer['id']}", None)):
|
||||
assert (await user.request(method, path, json=body)).status_code == 403, (role, path)
|
||||
finally:
|
||||
await user.aclose()
|
||||
|
||||
|
||||
# ── the hub ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_wg_config_and_dump_parsing():
|
||||
conf = wg_conf({"private_key": "PRIV", "listen_port": 51820, "peers": [{"public_key": "PUB1", "psk": "PSK1", "ip": "10.99.0.2"}]})
|
||||
assert conf == "[Interface]\nPrivateKey = PRIV\nListenPort = 51820\n\n[Peer]\nPublicKey = PUB1\nPresharedKey = PSK1\nAllowedIPs = 10.99.0.2/32\n"
|
||||
dump = "hubpriv\thubpub\t51820\toff\n" + "PUB1\tpsk\t198.51.100.7:5000\t10.99.0.2/32\t1700000000\t111\t222\t25\n" + "PUB2\tpsk\t(none)\t10.99.0.3/32\t0\t0\t0\t25\n"
|
||||
assert parse_dump(dump) == [
|
||||
{"public_key": "PUB1", "endpoint": "198.51.100.7:5000", "handshake_at": 1700000000, "rx": 111, "tx": 222},
|
||||
{"public_key": "PUB2", "endpoint": None, "handshake_at": 0, "rx": 0, "tx": 0}]
|
||||
assert parse_dump("only-interface-line") == []
|
||||
|
||||
|
||||
class _FakeRunner:
|
||||
def __init__(self, existing=False, fail=None):
|
||||
self.calls, self.existing, self.fail = [], existing, fail
|
||||
|
||||
def __call__(self, *cmd, stdin=None):
|
||||
self.calls.append(cmd)
|
||||
if cmd[:3] == ("ip", "link", "show"):
|
||||
return (0 if self.existing else 1), ""
|
||||
if cmd[:2] == ("iptables", "-C"):
|
||||
return 1, ""
|
||||
if self.fail and cmd[0] == self.fail:
|
||||
return 1, "boom"
|
||||
if cmd[:2] == ("wg", "show"):
|
||||
return 0, "hubpriv\thubpub\t51820\toff\nPUB1\tpsk\t(none)\t10.99.0.2/32\t0\t0\t0\t25\n"
|
||||
return 0, ""
|
||||
|
||||
|
||||
def _config(**over):
|
||||
return {"enabled": True, "address": "10.99.0.1/24", "subnet": "10.99.0.0/24", "listen_port": 51820, "private_key": "PRIV",
|
||||
"peers": [{"public_key": "PUB1", "psk": "PSK1", "ip": "10.99.0.2"}], **over}
|
||||
|
||||
|
||||
def test_hub_applies_the_interface_peers_and_the_inbound_drop_rule(tmp_path):
|
||||
runner = _FakeRunner()
|
||||
hub = Hub(tmp_path, runner)
|
||||
assert hub.apply(_config()) is None
|
||||
cmds = [c[:3] for c in runner.calls]
|
||||
assert ("ip", "link", "add") in cmds and ("ip", "address", "replace") in cmds and ("wg", "syncconf", "wg0") in cmds
|
||||
assert any(c[0] == "iptables" and c[1] == "-I" and "-i" in c and "NEW" in c and c[-1] == "DROP" for c in runner.calls)
|
||||
assert ("iptables", "-I", "FORWARD", "-j", "DROP") in runner.calls # no routing between peers or into the compose network
|
||||
assert not list(tmp_path.glob(".wg0.conf")) # the private key file does not linger
|
||||
assert hub.subnet == ipaddress.ip_network("10.99.0.0/24")
|
||||
# An existing interface is reused, not re-created.
|
||||
runner2 = _FakeRunner(existing=True)
|
||||
Hub(tmp_path, runner2).apply(_config())
|
||||
assert ("ip", "link", "add", "wg0", "type", "wireguard") not in runner2.calls
|
||||
|
||||
|
||||
def test_hub_takes_the_interface_down_when_disabled_and_reports_errors(tmp_path):
|
||||
runner = _FakeRunner(existing=True)
|
||||
hub = Hub(tmp_path, runner)
|
||||
assert hub.apply(_config(enabled=False)) is None and ("ip", "link", "del", "wg0") in runner.calls and hub.subnet is None
|
||||
failing = Hub(tmp_path, _FakeRunner(fail="wg"))
|
||||
assert "wg syncconf" in failing.apply(_config())
|
||||
assert "ip link add" in (Hub(tmp_path, _FakeRunner(fail="ip")).apply(_config()) or "")
|
||||
|
||||
|
||||
def test_hub_tick_applies_on_change_and_reports_status(tmp_path):
|
||||
runner = _FakeRunner()
|
||||
hub = Hub(tmp_path, runner)
|
||||
assert hub.tick(None) is None # no config yet: still reports
|
||||
status = json.loads((tmp_path / "status.json").read_text())
|
||||
assert status["up"] is True and status["peers"][0]["public_key"] == "PUB1" and abs(status["updated_at"] - time.time()) < 5
|
||||
(tmp_path / "hub.json").write_text(json.dumps(_config()))
|
||||
hub.tick(None)
|
||||
applied = len([c for c in runner.calls if c[:2] == ("wg", "syncconf")])
|
||||
assert applied == 1
|
||||
hub.tick(None)
|
||||
assert len([c for c in runner.calls if c[:2] == ("wg", "syncconf")]) == 1 # unchanged file: no re-apply
|
||||
os.utime(tmp_path / "hub.json", (time.time() + 10, time.time() + 10))
|
||||
hub.tick(None)
|
||||
assert len([c for c in runner.calls if c[:2] == ("wg", "syncconf")]) == 2
|
||||
|
||||
|
||||
async def _serve_echo(prefix=b"echo:"):
|
||||
async def handle(reader, writer):
|
||||
while data := await reader.read(1024):
|
||||
writer.write(prefix + data)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
||||
return server, server.sockets[0].getsockname()[1]
|
||||
|
||||
|
||||
async def _proxy(subnet="127.0.0.0/8"):
|
||||
hub = Hub(Path(tempfile.mkdtemp()), _FakeRunner())
|
||||
hub.subnet = ipaddress.ip_network(subnet)
|
||||
server = await asyncio.start_server(hub.handle_client, "127.0.0.1", 0)
|
||||
return server, server.sockets[0].getsockname()[1]
|
||||
|
||||
|
||||
async def _connect(proxy_port, target):
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
||||
writer.write(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n".encode())
|
||||
await writer.drain()
|
||||
return reader, writer, await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_relays_connections_into_the_overlay_subnet():
|
||||
echo, echo_port = await _serve_echo()
|
||||
proxy, proxy_port = await _proxy()
|
||||
try:
|
||||
reader, writer, head = await _connect(proxy_port, f"127.0.0.1:{echo_port}")
|
||||
assert head.startswith(b"HTTP/1.1 200")
|
||||
writer.write(b"hello")
|
||||
await writer.drain()
|
||||
assert await asyncio.wait_for(reader.readexactly(10), 5) == b"echo:hello"
|
||||
writer.close()
|
||||
finally:
|
||||
proxy.close(); echo.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_is_not_an_open_proxy():
|
||||
echo, echo_port = await _serve_echo()
|
||||
proxy, proxy_port = await _proxy(subnet="10.99.0.0/24") # loopback is outside this overlay
|
||||
try:
|
||||
for target in (f"127.0.0.1:{echo_port}", "8.8.8.8:443", "example.com:443", "10.99.0.2:0", "10.99.0.2:99999", "10.99.0.2:abc", "nonsense"):
|
||||
_, writer, head = await _connect(proxy_port, target)
|
||||
assert head.startswith(b"HTTP/1.1 403"), target
|
||||
writer.close()
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
||||
writer.write(b"GET http://10.99.0.2/ HTTP/1.1\r\nHost: 10.99.0.2\r\n\r\n") # plain proxying is not offered either
|
||||
assert (await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)).startswith(b"HTTP/1.1 403")
|
||||
writer.close()
|
||||
finally:
|
||||
proxy.close(); echo.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_reports_unreachable_firewalls_and_survives_garbage():
|
||||
proxy, proxy_port = await _proxy()
|
||||
free = socket_free_port()
|
||||
try:
|
||||
_, writer, head = await _connect(proxy_port, f"127.0.0.1:{free}")
|
||||
assert head.startswith(b"HTTP/1.1 502")
|
||||
writer.close()
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
||||
writer.write(b"\x00\xff" * 100 + b"\r\n\r\n")
|
||||
await writer.drain()
|
||||
await asyncio.sleep(0.2)
|
||||
writer.close()
|
||||
_, writer, head = await _connect(proxy_port, f"127.0.0.1:{free}") # still serving
|
||||
assert head.startswith(b"HTTP/1.1 502")
|
||||
writer.close()
|
||||
finally:
|
||||
proxy.close()
|
||||
|
||||
|
||||
def socket_free_port() -> int:
|
||||
import socket
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
# ── requests to overlay firewalls go through the proxy ───────────────────
|
||||
|
||||
def test_clients_for_overlay_hosts_use_the_proxy_and_others_do_not(monkeypatch):
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import opnsense_client, opnsense_secret_store
|
||||
import uuid
|
||||
monkeypatch.setattr(opnsense_secret_store, "read_secret", lambda host_id: "secret")
|
||||
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
|
||||
overlay = OpnsenseHost(id=uuid.uuid4(), name="o", base_url="https://10.99.0.2", api_key="k", verify_tls=False)
|
||||
direct = OpnsenseHost(id=uuid.uuid4(), name="d", base_url="https://10.10.10.2", api_key="k", verify_tls=False)
|
||||
assert opnsense_client._client(overlay)._mounts, "the overlay client must have a proxy mount"
|
||||
assert not opnsense_client._client(direct)._mounts
|
||||
|
||||
|
||||
def _self_signed(cn="fw.test"):
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
|
||||
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1)).not_valid_after(datetime.now(timezone.utc) + timedelta(days=30))
|
||||
.sign(key, hashes.SHA256()))
|
||||
return cert, key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tls_probe_and_pin_check_work_through_the_proxy(tmp_path, monkeypatch):
|
||||
from app.services import tls_pin_service
|
||||
cert, key = _self_signed()
|
||||
(tmp_path / "c.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
(tmp_path / "k.pem").write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(tmp_path / "c.pem", tmp_path / "k.pem")
|
||||
fw = await asyncio.start_server(lambda r, w: w.close(), "127.0.0.1", 0, ssl=ctx)
|
||||
fw_port = fw.sockets[0].getsockname()[1]
|
||||
proxy, proxy_port = await _proxy()
|
||||
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{proxy_port}")
|
||||
svc._active_network = ipaddress.ip_network("127.0.0.0/8")
|
||||
try:
|
||||
info = await tls_pin_service.probe(f"https://127.0.0.1:{fw_port}")
|
||||
assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
|
||||
await tls_pin_service.verify_matches(f"https://127.0.0.1:{fw_port}", info["pem"])
|
||||
other, _ = _self_signed("someone-else")
|
||||
with pytest.raises(tls_pin_service.TlsPinError):
|
||||
await tls_pin_service.verify_matches(f"https://127.0.0.1:{fw_port}", other.public_bytes(serialization.Encoding.PEM).decode())
|
||||
# The proxy refusing (target outside the overlay) surfaces as a connection error, not a hang.
|
||||
svc._active_network = ipaddress.ip_network("127.0.0.0/8")
|
||||
hub_only = await _proxy(subnet="10.99.0.0/24")
|
||||
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{hub_only[1]}")
|
||||
with pytest.raises(tls_pin_service.TlsPinError):
|
||||
await tls_pin_service.probe(f"https://127.0.0.1:{fw_port}")
|
||||
hub_only[0].close()
|
||||
finally:
|
||||
proxy.close(); fw.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dead_proxy_is_a_clean_error(monkeypatch):
|
||||
from app.services import tls_pin_service
|
||||
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{socket_free_port()}")
|
||||
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
|
||||
with pytest.raises(tls_pin_service.TlsPinError):
|
||||
await tls_pin_service.probe("https://10.99.0.2")
|
||||
@@ -112,3 +112,75 @@ async def test_unreachable_feed_keeps_last_known_result(client, respx_mock, monk
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_login(anon_client):
|
||||
assert (await anon_client.get("/api/system/version-check")).status_code == 401
|
||||
|
||||
|
||||
# ── Einstellung im Webinterface (statt .env) ────────────────────────────
|
||||
|
||||
WEB_FEED = "https://mirror.test/latest.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _env_unset(monkeypatch):
|
||||
monkeypatch.setattr(settings, "update_check_url", "")
|
||||
|
||||
|
||||
async def _reset_row(client):
|
||||
await client.put("/api/system/update-check", json={"enabled": False, "feed_url": "https://updates.opnexus.dev/latest.json"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_is_off_by_default_and_can_be_switched_on_in_the_ui(client, respx_mock, _env_unset):
|
||||
await _reset_row(client)
|
||||
body = (await client.get("/api/system/update-check")).json()
|
||||
assert body == {"enabled": False, "feed_url": "https://updates.opnexus.dev/latest.json", "managed_by_server": False}
|
||||
assert (await client.get("/api/system/version-check")).json()["enabled"] is False
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
||||
newer = _bump(APP_VERSION, minor=1)
|
||||
respx_mock.get(WEB_FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{newer}"}))
|
||||
resp = await client.put("/api/system/update-check", json={"enabled": True, "feed_url": f" {WEB_FEED} "})
|
||||
assert resp.status_code == 200 and resp.json()["feed_url"] == WEB_FEED and resp.json()["enabled"] is True
|
||||
check = (await client.get("/api/system/version-check")).json()
|
||||
assert check["enabled"] is True and check["latest"] == newer and check["update_available"] is True
|
||||
await _reset_row(client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switching_the_check_off_again_stops_requests(client, respx_mock, _env_unset):
|
||||
respx_mock.get(WEB_FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{_bump(APP_VERSION, minor=1)}"}))
|
||||
await client.put("/api/system/update-check", json={"enabled": True, "feed_url": WEB_FEED})
|
||||
assert (await client.get("/api/system/version-check")).json()["latest"]
|
||||
await client.put("/api/system/update-check", json={"enabled": False})
|
||||
after = (await client.get("/api/system/version-check")).json()
|
||||
assert after["enabled"] is False and after["latest"] is None
|
||||
await _reset_row(client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("url", ["", "ftp://x.test/f", "javascript:alert(1)", "https://a b.test/", "https://x.test/" + "a" * 500])
|
||||
async def test_invalid_feed_urls_are_rejected(client, _env_unset, url):
|
||||
resp = await client.put("/api/system/update-check", json={"feed_url": url})
|
||||
assert resp.status_code in (422, 400)
|
||||
if resp.status_code == 422 and isinstance(resp.json().get("detail"), dict):
|
||||
assert resp.json()["detail"]["code"] == "UPDATE_CHECK_URL_INVALID"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_setting_wins_and_locks_the_ui(client, respx_mock):
|
||||
# the autouse fixture sets UPDATE_CHECK_URL = FEED, i.e. the server decides
|
||||
body = (await client.get("/api/system/update-check")).json()
|
||||
assert body == {"enabled": True, "feed_url": FEED, "managed_by_server": True}
|
||||
resp = await client.put("/api/system/update-check", json={"enabled": False})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_CHECK_MANAGED_BY_SERVER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_admins_can_read_or_change_the_setting(client, _env_unset):
|
||||
from tests.test_users import _login_as
|
||||
await client.post("/api/users", json={"username": "viewer-upc", "password": "password-123456", "role": "read_only"})
|
||||
viewer = await _login_as("viewer-upc", "password-123456")
|
||||
try:
|
||||
assert (await viewer.get("/api/system/update-check")).status_code == 403
|
||||
assert (await viewer.put("/api/system/update-check", json={"enabled": True})).status_code == 403
|
||||
finally:
|
||||
await viewer.aclose()
|
||||
@@ -58,6 +58,17 @@ async def test_status_without_token_is_unavailable_not_an_error(client, respx_mo
|
||||
assert body["update_available"] is True # der Hinweis bleibt trotzdem
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("repo,mode", [("", "local"), ("git.example.test/opnexus", "release")])
|
||||
async def test_install_mode_is_known_without_the_updater(client, respx_mock, monkeypatch, tmp_path, repo, mode):
|
||||
# The panel must not tell a source-built install to enable an updater that could not update it anyway.
|
||||
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
||||
monkeypatch.setattr(settings, "opnexus_image_repo", repo)
|
||||
_feed(respx_mock, _bump(minor=1))
|
||||
body = (await client.get("/api/system/updater")).json()
|
||||
assert body["available"] is False and body["reason"] == "not_enabled" and body["mode"] == mode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_unreachable_updater(client, respx_mock):
|
||||
_feed(respx_mock, _bump(minor=1))
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Site variables: {{NAME}} placeholders filled per host, failing closed when a value is missing."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_variable import HostVariable
|
||||
from app.services import variables_service as vs
|
||||
from tests.test_aliases import _alias_payload
|
||||
from tests.test_port_forward import _pf_payload
|
||||
from tests.test_rules import _create_host, _rule_payload
|
||||
|
||||
|
||||
def _host(name="fw", **values):
|
||||
host = OpnsenseHost(name=name, base_url="https://x", api_key="k")
|
||||
host.variables = [HostVariable(name=n, value=v) for n, v in values.items()]
|
||||
return host
|
||||
|
||||
|
||||
# ── unit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_placeholders_are_found_and_resolved():
|
||||
payload = {"source_net": "{{LAN_NET}}", "destination_net": "any", "description": "ssh to {{ SITE }} ({{LAN_NET}})", "enabled": "1", "port": 22}
|
||||
assert vs.names_in_payload(payload) == {"LAN_NET", "SITE"}
|
||||
resolved = vs.resolve(payload, _host(LAN_NET="192.168.7.0/24", SITE="Nord"))
|
||||
assert resolved == {"source_net": "192.168.7.0/24", "destination_net": "any", "description": "ssh to Nord (192.168.7.0/24)", "enabled": "1", "port": 22}
|
||||
assert payload["source_net"] == "{{LAN_NET}}" # the original is untouched
|
||||
|
||||
|
||||
def test_a_missing_value_raises_naming_host_and_variable():
|
||||
with pytest.raises(vs.MissingVariables) as exc:
|
||||
vs.resolve({"a": "{{A}}", "b": "{{B}}"}, _host("fw-nord", A="1"))
|
||||
assert exc.value.missing == {"fw-nord": ["B"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["lowercase {{lan}}", "{LAN_NET}", "{{ }}", "{{1ABC}}", "$LAN_NET", "{{LAN-NET}}"])
|
||||
def test_things_that_are_not_placeholders_are_left_alone(text):
|
||||
assert vs.names_in(text) == set()
|
||||
assert vs.resolve({"x": text}, _host())["x"] == text
|
||||
|
||||
|
||||
def test_values_are_inserted_literally_not_interpreted():
|
||||
out = vs.resolve({"x": "{{A}}"}, _host(A="\\1 $& {{B}}"))
|
||||
assert out["x"] == "\\1 $& {{B}}" # no regex-group or second-level expansion
|
||||
|
||||
|
||||
def test_value_and_name_validation():
|
||||
assert vs.NAME_RE.fullmatch("LAN_NET2") and not vs.NAME_RE.fullmatch("lan") and not vs.NAME_RE.fullmatch("2LAN") and not vs.NAME_RE.fullmatch("A" * 41)
|
||||
for ok in ("192.168.7.0/24", "fw-nord.example.com", "10.0.0.1,10.0.0.2", "Filiale Nord", "a@b+c"):
|
||||
assert vs.VALUE_RE.fullmatch(ok)
|
||||
for bad in ("", "{{X}}", "a\nb", "ok\n", 'a"b', "a;b", "a" * 256, "<x>"):
|
||||
assert not vs.VALUE_RE.fullmatch(bad)
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _mock_hosts(respx_mock, *bases):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
# multi-host deploys compare what the interface name means on every target
|
||||
respx_mock.get(url__regex=r".*/firewall/filter/getRule").mock(
|
||||
return_value=httpx.Response(200, json={"rule": {"interface": {"lan": {"value": "LAN", "selected": 0}}}}))
|
||||
respx_mock.get(url__regex=r".*/firewall/alias/searchItem.*").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
|
||||
|
||||
def _bodies(route):
|
||||
return [json.loads(call.request.content) for call in route.calls]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_variables_api_roundtrip_validation_and_usage_discovery(client, respx_mock):
|
||||
await _mock_hosts(respx_mock)
|
||||
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||
h2 = await _create_host(client, "fw2", "https://fw2.test")
|
||||
assert (await client.get(f"/api/hosts/{h1['id']}/variables")).json() == {"variables": []}
|
||||
await client.post("/api/rules", json=_rule_payload([h1["id"]], description="ssh {{SITE}}", source_net="{{LAN_NET}}"))
|
||||
await client.post("/api/aliases", json=_alias_payload([h2["id"]], content="{{DNS_IP}}"))
|
||||
# fw1 uses LAN_NET and SITE, nothing yet defined -> both missing; fw2's alias is not fw1's business.
|
||||
got = (await client.get(f"/api/hosts/{h1['id']}/variables")).json()["variables"]
|
||||
assert [(v["name"], v["value"], v["missing"]) for v in got] == [("LAN_NET", None, True), ("SITE", None, True)]
|
||||
assert got[0]["used_by"] == [{"kind": "rule", "id": got[0]["used_by"][0]["id"], "label": "ssh {{SITE}}"}]
|
||||
put = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24", "EXTRA": "x"}})
|
||||
assert put.status_code == 200, put.text
|
||||
by_name = {v["name"]: v for v in put.json()["variables"]}
|
||||
assert by_name["LAN_NET"]["missing"] is False and by_name["SITE"]["missing"] is True and by_name["EXTRA"]["used_by"] == []
|
||||
# PUT replaces the whole set.
|
||||
put = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"SITE": "Nord"}})
|
||||
assert {v["name"] for v in put.json()["variables"]} == {"LAN_NET", "SITE"} # LAN_NET only listed because the rule still uses it
|
||||
assert next(v for v in put.json()["variables"] if v["name"] == "LAN_NET")["value"] is None
|
||||
for bad in ({"lan": "1"}, {"OK": "bad;value"}, {"OK": ""}, {"OK": "{{X}}"}):
|
||||
resp = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": bad})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "VARIABLE_INVALID", bad
|
||||
import uuid
|
||||
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/variables")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_host_gets_its_own_values_in_preview_and_deploy(client, respx_mock):
|
||||
await _mock_hosts(respx_mock)
|
||||
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||
h2 = await _create_host(client, "fw2", "https://fw2.test")
|
||||
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24", "SITE": "Nord"}})
|
||||
await client.put(f"/api/hosts/{h2['id']}/variables", json={"variables": {"LAN_NET": "10.20.0.0/16", "SITE": "Sued"}})
|
||||
rule = (await client.post("/api/rules", json=_rule_payload([h1["id"], h2["id"]], description="ssh {{SITE}}", source_net="{{LAN_NET}}"))).json()
|
||||
preview = (await client.get(f"/api/rules/{rule['id']}/preview")).json()
|
||||
rows = {r["host_name"]: r for r in preview["hosts"]}
|
||||
after = lambda r: {c["field"]: c["after"] for c in r["changes"]} # noqa: E731
|
||||
assert after(rows["fw1"])["source_net"] == "192.168.7.0/24" and after(rows["fw1"])["description"] == "ssh Nord"
|
||||
assert after(rows["fw2"])["source_net"] == "10.20.0.0/16" and after(rows["fw2"])["description"] == "ssh Sued"
|
||||
assert preview["can_deploy"] is True
|
||||
routes = {b: respx_mock.post(f"https://{b}.test/api/firewall/filter/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": f"u-{b}"})) for b in ("fw1", "fw2")}
|
||||
for b in ("fw1", "fw2"):
|
||||
respx_mock.post(f"https://{b}.test/api/firewall/filter/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
resp = await client.post(f"/api/rules/{rule['id']}/deploy", json={"preview_token": preview["token"]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
sent = {b: _bodies(r)[0]["rule"] for b, r in routes.items()}
|
||||
assert (sent["fw1"]["source_net"], sent["fw1"]["description"]) == ("192.168.7.0/24", "ssh Nord")
|
||||
assert (sent["fw2"]["source_net"], sent["fw2"]["description"]) == ("10.20.0.0/16", "ssh Sued")
|
||||
assert "{{" not in json.dumps(sent)
|
||||
# The centrally stored rule keeps its placeholders; the deployment stores what was really sent.
|
||||
assert (await client.get("/api/rules")).json()[0]["source_net"] == "{{LAN_NET}}"
|
||||
# Changing a host's value shows up as a change for that host only.
|
||||
await client.put(f"/api/hosts/{h2['id']}/variables", json={"variables": {"LAN_NET": "10.30.0.0/16", "SITE": "Sued"}})
|
||||
for r in routes.values():
|
||||
pass
|
||||
await client.post(f"/api/rules/{rule['id']}/confirm")
|
||||
again = {r["host_name"]: r["action"] for r in (await client.get(f"/api/rules/{rule['id']}/preview")).json()["hosts"]}
|
||||
assert again == {"fw1": "unchanged", "fw2": "update"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_value_blocks_the_whole_deploy_before_anything_is_written(client, respx_mock):
|
||||
await _mock_hosts(respx_mock)
|
||||
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||
h2 = await _create_host(client, "fw2", "https://fw2.test")
|
||||
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24"}})
|
||||
rule = (await client.post("/api/rules", json=_rule_payload([h1["id"], h2["id"]], source_net="{{LAN_NET}}"))).json()
|
||||
preview = (await client.get(f"/api/rules/{rule['id']}/preview")).json()
|
||||
rows = {r["host_name"]: r for r in preview["hosts"]}
|
||||
assert rows["fw2"]["action"] == "missing_variable" and "LAN_NET" in rows["fw2"]["message"] and rows["fw1"]["action"] == "create"
|
||||
assert preview["can_deploy"] is False and any("Variable" in w for w in preview["warnings"])
|
||||
# respx would fail on any unmocked firewall call: nothing may be sent, not even to the host that has its value.
|
||||
resp = await client.post(f"/api/rules/{rule['id']}/deploy", json={"preview_token": preview["token"]})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "VARIABLES_MISSING"
|
||||
assert "fw2: LAN_NET" in resp.json()["detail"]["params"]["details"] and "fw1" not in resp.json()["detail"]["params"]["details"]
|
||||
assert (await client.get("/api/rules")).json()[0]["deployments"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aliases_and_port_forwards_use_variables_too(client, respx_mock):
|
||||
await _mock_hosts(respx_mock)
|
||||
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"DNS_IP": "10.9.9.9", "WEB": "10.7.0.20"}})
|
||||
alias = (await client.post("/api/aliases", json=_alias_payload([h1["id"]], content="{{DNS_IP}}"))).json()
|
||||
pf = (await client.post("/api/port-forward", json=_pf_payload([h1["id"]], target="{{WEB}}"))).json()
|
||||
a_prev = (await client.get(f"/api/aliases/{alias['id']}/preview")).json()
|
||||
assert {c["field"]: c["after"] for c in a_prev["hosts"][0]["changes"]}["content"] == "10.9.9.9"
|
||||
p_prev = (await client.get(f"/api/port-forward/{pf['id']}/preview")).json()
|
||||
assert {c["field"]: c["after"] for c in p_prev["hosts"][0]["changes"]}["target"] == "10.7.0.20"
|
||||
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"DNS_IP": "10.9.9.9"}})
|
||||
p_prev = (await client.get(f"/api/port-forward/{pf['id']}/preview")).json()
|
||||
assert p_prev["hosts"][0]["action"] == "missing_variable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_state_does_not_crash_on_missing_values(client, respx_mock):
|
||||
await _mock_hosts(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await client.post("/api/rules", json=_rule_payload([host["id"]], source_net="{{NOPE}}"))
|
||||
listing = (await client.get("/api/rules")).json()
|
||||
assert listing[0]["workflow_state"] == "draft" and listing[0]["workflow_counts"]["changes"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_client_refuses_any_payload_with_an_unresolved_placeholder(client, respx_mock):
|
||||
from app.models.host import OpnsenseHost as H
|
||||
from app.services import opnsense_client, opnsense_secret_store
|
||||
import uuid
|
||||
await _mock_hosts(respx_mock)
|
||||
created = await _create_host(client, "fw1", "https://fw1.test")
|
||||
from app.database import SessionLocal
|
||||
async with SessionLocal() as db:
|
||||
host = await db.get(H, uuid.UUID(created["id"]))
|
||||
with pytest.raises(opnsense_client.OpnsenseAPIError, match="Variable"):
|
||||
await opnsense_client.add_rule(host, {"source_net": "{{LAN_NET}}", "action": "pass"})
|
||||
with pytest.raises(opnsense_client.OpnsenseAPIError, match="Variable"):
|
||||
await opnsense_client.add_rule(host, {"nested": "x {{ A_B }} y"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_variables_follow_the_host_and_operators_may_set_them(client, respx_mock):
|
||||
from tests.test_api_tokens import _login
|
||||
await _mock_hosts(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await client.put(f"/api/hosts/{host['id']}/variables", json={"variables": {"A": "1"}})
|
||||
assert (await client.post("/api/users", json={"username": "varop", "password": "variables-pass-1", "role": "operator"})).status_code == 201
|
||||
op = await _login("varop", "variables-pass-1")
|
||||
try:
|
||||
assert (await op.put(f"/api/hosts/{host['id']}/variables", json={"variables": {"A": "2"}})).status_code == 200
|
||||
finally:
|
||||
await op.aclose()
|
||||
assert (await client.post("/api/users", json={"username": "varro", "password": "variables-pass-1", "role": "read_only"})).status_code == 201
|
||||
ro = await _login("varro", "variables-pass-1")
|
||||
try:
|
||||
assert (await ro.get(f"/api/hosts/{host['id']}/variables")).status_code == 200
|
||||
assert (await ro.put(f"/api/hosts/{host['id']}/variables", json={"variables": {}})).status_code == 403
|
||||
finally:
|
||||
await ro.aclose()
|
||||
await client.delete(f"/api/hosts/{host['id']}") # variables go with the host
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import select
|
||||
async with SessionLocal() as db:
|
||||
assert (await db.scalars(select(HostVariable))).all() == []
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA4pZWkNMkAtCGc54M2wuWy7CXwkT
|
||||
3PCysv1/1IbkyJRPdM8+DFf26fBxWG9P9PLKfwavhqVYtHILxxzxKmtjeQ==
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -109,12 +109,14 @@ services:
|
||||
ACME_SECRET_DIR: /data/acme-secrets
|
||||
TOTP_SECRET_DIR: /data/totp-secrets
|
||||
NOTIFICATION_SECRET_DIR: /data/notification-secrets
|
||||
OVERLAY_DIR: /data/overlay
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
|
||||
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-}
|
||||
OPNEXUS_IMAGE_REPO: ${OPNEXUS_IMAGE_REPO:-}
|
||||
ALLOW_INSECURE_FIREWALL_URLS: ${ALLOW_INSECURE_FIREWALL_URLS:-false}
|
||||
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
|
||||
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
|
||||
@@ -132,6 +134,8 @@ services:
|
||||
- acme_secrets:/data/acme-secrets
|
||||
- totp_secrets:/data/totp-secrets
|
||||
- notification_secrets:/data/notification-secrets
|
||||
# Management-Overlay: Konfiguration für den wg-hub und dessen Status.
|
||||
- overlay_state:/data/overlay
|
||||
# Gleiches Volume wie der postgres-backup-Service: hier landet das
|
||||
# automatische Sicherheits-Backup vor einer Schema-Migration.
|
||||
- postgres_backups:/backups
|
||||
@@ -200,6 +204,30 @@ services:
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
# ── Optional: Management-Overlay (WireGuard-Hub) ─────────────────────
|
||||
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=overlay in
|
||||
# .env und `docker compose up -d`. Damit erreicht OPNexus auch Firewalls hinter
|
||||
# NAT/CGNAT: sie wählen sich per WireGuard hier ein (UDP-Port ${OVERLAY_PORT:-51820}
|
||||
# muss vom Internet erreichbar sein). Gleiches Image wie das Backend, mit
|
||||
# NET_ADMIN nur für die WireGuard-Schnittstelle. Einrichtung: Handbuch,
|
||||
# "Firewalls hinter NAT".
|
||||
wg-hub:
|
||||
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-backend:${OPNEXUS_VERSION:-latest}
|
||||
profiles: ["overlay"]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: ["python", "-m", "app.overlay_hub"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
net.ipv4.ip_forward: "0"
|
||||
environment:
|
||||
OVERLAY_DIR: /data/overlay
|
||||
volumes:
|
||||
- overlay_state:/data/overlay
|
||||
ports:
|
||||
- "${OVERLAY_PORT:-51820}:51820/udp"
|
||||
|
||||
# ── Optional: Updates aus dem Webinterface ──────────────────────────
|
||||
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in
|
||||
# .env und `docker compose up -d`. Dies ist der einzige Container mit
|
||||
@@ -239,5 +267,6 @@ volumes:
|
||||
acme_secrets:
|
||||
totp_secrets:
|
||||
notification_secrets:
|
||||
overlay_state:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
+117
-12
@@ -74,6 +74,32 @@ Alternativ lassen sich auch aus einem Checkout die fertigen Images verwenden: in
|
||||
der `.env` `OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus` und `OPNEXUS_VERSION=<version>`
|
||||
setzen, dann `docker compose pull && docker compose up -d --no-build`.
|
||||
|
||||
### Images prüfen (Signatur)
|
||||
|
||||
Die Release-Images sind mit [cosign](https://github.com/sigstore/cosign) signiert (ab 1.23.9).
|
||||
Damit lässt sich prüfen, dass ein Image wirklich von OPNexus stammt und seit dem Bau nicht
|
||||
verändert wurde. Der öffentliche Schlüssel liegt als `cosign.pub` im Repository und im
|
||||
Release-Bundle:
|
||||
|
||||
```bash
|
||||
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
|
||||
git.opnexus.dev/opnexus/opnexus-backend:<version>
|
||||
```
|
||||
|
||||
Das Gleiche gilt für `opnexus-frontend`, `opnexus-proxy` und `opnexus-updater`. Eine
|
||||
erfolgreiche Prüfung gibt die Signatur als JSON aus; bei einem fremden oder fehlenden
|
||||
Schlüssel endet der Befehl mit einem Fehler. `--insecure-ignore-tlog=true` ist hier gewollt:
|
||||
OPNexus lädt Signaturen nicht in das öffentliche Transparenzlog, damit die Prüfung auch in
|
||||
abgeschotteten Netzen funktioniert. Die Signatur gilt dem Inhalt (Digest) des Images, nicht
|
||||
dem Tag.
|
||||
|
||||
`opnexus update` prüft die Signaturen selbst, bevor es etwas wechselt, sobald `cosign`
|
||||
installiert ist und `cosign.pub` neben der `docker-compose.yml` liegt (`cosign.pub` liegt im Bundle
|
||||
bereits dabei, `cosign` selbst musst du installieren). Der Updater-Container für Updates per Klick
|
||||
bringt `cosign` mit und prüft deshalb ebenfalls, solange `cosign.pub` im Projektverzeichnis liegt. Schlägt die Prüfung fehl, bricht das
|
||||
Update ab und es wurde nichts verändert. Fehlt `cosign`, sagt `opnexus update` das und
|
||||
macht ohne Prüfung weiter.
|
||||
|
||||
### Erster Start und Passwort
|
||||
|
||||
Ohne `ADMIN_PASSWORD` in der `.env` erzeugt OPNexus beim ersten Start ein
|
||||
@@ -206,7 +232,7 @@ Alle Einstellungen stehen in der `.env` neben der `docker-compose.yml`. Nach ein
|
||||
| `BIND_ADDRESS` | Schnittstelle für Backend und Frontend (Standard `127.0.0.1`). |
|
||||
| `BACKEND_PORT`, `FRONTEND_PORT` | Host-Ports (Standard 8100 und 3100), z. B. für eine zweite Instanz. |
|
||||
| `OPNEXUS_IMAGE_REPO`, `OPNEXUS_VERSION` | Registry und Version der Release-Images. `OPNEXUS_VERSION` ist die „Spur“, siehe [Updates](#opnexus-aktualisieren). |
|
||||
| `UPDATE_CHECK_URL`, `UPDATE_CHECK_INTERVAL_HOURS` | Hinweis auf neue Versionen, standardmäßig **aus**. |
|
||||
| `UPDATE_CHECK_URL`, `UPDATE_CHECK_INTERVAL_HOURS` | Hinweis auf neue Versionen, standardmäßig **aus**. Einstellbar auch im Webinterface; ein Wert in der `.env` hat Vorrang. |
|
||||
| `COMPOSE_PROFILES=updater` | Aktiviert den optionalen Updater für Updates per Klick. |
|
||||
| `ALLOW_INSECURE_FIREWALL_URLS` | Erlaubt `http://`-Adressen für Firewalls. Nur für isolierte Labore. |
|
||||
| `SMTP_CA_FILE`, `ALLOW_INSECURE_SMTP_AUTH` | Eigene CA für den Mailserver bzw. SMTP-Anmeldung ohne Verschlüsselung (bleibt standardmäßig gesperrt). |
|
||||
@@ -238,7 +264,7 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
|
||||
|
||||
- **Rolle aus Gruppen, bei jeder Anmeldung:** gilt die höchste passende Rolle; wer in keiner der Gruppen ist,
|
||||
kommt nicht hinein (es wird auch kein Konto angelegt). Wird jemand aus der Gruppe entfernt, endet der Zugang
|
||||
bei der nächsten Anmeldung (laufende Sitzungen enden nach ihrer Laufzeit; beim Anbieter gesperrte Personen
|
||||
bei der nächsten Anmeldung (laufende SSO-Sitzungen enden spätestens nach 12 Stunden; beim Anbieter gesperrte Personen
|
||||
daher zusätzlich in der Benutzerliste löschen).
|
||||
- **Konten:** SSO-Benutzer werden bei der ersten Anmeldung angelegt (Kennzeichen „SSO“). Rolle und Passwort
|
||||
ändern sich nur beim Anbieter; die Zwei-Faktor-Anmeldung übernimmt der Anbieter. Ein SSO-Konto wird nie mit
|
||||
@@ -248,8 +274,11 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
|
||||
- **Geprüft** werden Signatur (nur asymmetrische Verfahren, nie `none` oder HMAC), Aussteller, Zielgruppe, Ablauf
|
||||
und Nonce des ID-Tokens; jeder Anmeldeversuch (State) gilt genau einmal und 10 Minuten. Gruppen kommen aus dem
|
||||
ID-Token, ersatzweise aus dem Userinfo-Endpunkt. Aussteller und Endpunkte müssen `https://` sein. Das ist
|
||||
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token), noch nicht gegen jeden echten
|
||||
Anbieter: melde, was bei deinem nicht klappt.
|
||||
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token) und gegen echte Server von Keycloak 26 und
|
||||
Authelia 4.39 (Anmeldung, Rollen aus Gruppen, Rollenentzug, abgefangener Callback); andere Anbieter: melde, was bei deinem nicht klappt.
|
||||
Die Standard-Scopes sind `openid profile email`; die Gruppen müssen als Claim im ID-Token oder Userinfo ankommen
|
||||
(Keycloak: Mapper „Group Membership“ am Client; Entra ID: optionaler Gruppen-Claim). Anbieter, die Gruppen nur auf
|
||||
Anfrage senden (z. B. Okta, Authelia), brauchen zusätzlich den Scope `groups`.
|
||||
|
||||
**Aufgabentrennung.** Einstellungen › Aufgabentrennung: Schaltest du „Operatoren dürfen Änderungen selbst
|
||||
ausrollen“ aus, bereiten Operatoren Änderungen nur vor (anlegen, bearbeiten, Vorschau), und **nur ein Admin
|
||||
@@ -340,6 +369,47 @@ ist der Schritt, der das Festsetzen absichert.
|
||||
zuerst die Deployments (Regel löschen oder früheres Ziel unter „Prüfen & Push“
|
||||
bereinigen).
|
||||
|
||||
### Firewalls hinter NAT (Management-Tunnel)
|
||||
|
||||
OPNexus muss die API einer Firewall erreichen können. Hat die Firewall keine öffentliche Adresse (hinter NAT,
|
||||
CGNAT, Mobilfunk-Router), baust du mit dem **Management-Tunnel** (System › Management-Tunnel) einen
|
||||
WireGuard-Hub auf dem OPNexus-Server auf: Die Firewall wählt sich **nach außen** dort ein, OPNexus erreicht
|
||||
ihre API durch den Tunnel. An der Firewall muss nichts aus dem Internet erreichbar sein. Der Tunnel ist
|
||||
nur für die Verwaltung gedacht; Nutzverkehr läuft nicht darüber.
|
||||
|
||||
**Voraussetzungen:** der Hub ist ein optionaler Container. In `.env` `COMPOSE_PROFILES=overlay` setzen (bei
|
||||
mehreren Profilen kommagetrennt), `docker compose up -d`, und der UDP-Port `OVERLAY_PORT` (Standard 51820)
|
||||
des Servers muss aus dem Internet erreichbar sein (Port-Weiterleitung am Router des OPNexus-Servers). Der Hub
|
||||
läuft mit dem Image des Backends und braucht nur die Berechtigung `NET_ADMIN` für die WireGuard-Schnittstelle;
|
||||
das Linux-Kernelmodul `wireguard` muss auf dem Server vorhanden sein (jeder aktuelle Kernel).
|
||||
|
||||
**Einrichten:** Im Management-Tunnel einschalten, die öffentliche Adresse (DNS-Name oder IP) des Servers
|
||||
eintragen, speichern. Dann pro Firewall **„Firewall hinzufügen“**: OPNexus vergibt eine Tunnel-Adresse (z. B.
|
||||
`10.99.0.2`) und einen Pre-Shared Key und zeigt eine Anleitung mit kopierbaren Werten:
|
||||
|
||||
1. In OPNsense (VPN › WireGuard) eine **lokale Instanz** anlegen, Schlüsselpaar erzeugen lassen, die
|
||||
Tunnel-Adresse eintragen.
|
||||
2. Den **Hub als Gegenstelle** eintragen (öffentlicher Schlüssel, Pre-Shared Key, Endpunkt, erlaubte IP
|
||||
`10.99.0.1/32`, Keepalive 25).
|
||||
3. Eine **Firewall-Regel** auf der WireGuard-Schnittstellengruppe: TCP von der Hub-Adresse zu „Diese
|
||||
Firewall“, Port der Weboberfläche (443).
|
||||
4. Den **öffentlichen Schlüssel** der neuen Instanz bei „Schlüssel / Host“ in OPNexus eintragen.
|
||||
5. Die Firewall wie gewohnt als **Host** anlegen, aber mit der Tunnel-Adresse (`https://10.99.0.2`). Der
|
||||
Status im Management-Tunnel wechselt auf „verbunden“.
|
||||
|
||||
Ab dann funktioniert alles wie bei jeder anderen Firewall (Regeln, Wartung, Logs, Sicherungen,
|
||||
Sicherheitslage). Das Festsetzen des Zertifikats funktioniert auch durch den Tunnel.
|
||||
|
||||
**Sicherheit:** Der Hub nimmt von den Firewalls **keine neuen Verbindungen** an (iptables `INPUT … NEW`
|
||||
verworfen), eine kompromittierte Außenstelle erreicht also weder OPNexus noch andere Außenstellen. Der
|
||||
Proxy, über den OPNexus die Firewalls erreicht, ist nur im Compose-Netz erreichbar und verbindet
|
||||
ausschließlich in das Tunnel-Netz. Schlüssel liegen als Dateien im Volume `overlay_state` (dort auch der
|
||||
private Schlüssel des Hubs; die Vollsicherung enthält ihn). Je Firewall gibt es einen eigenen
|
||||
Pre-Shared Key; „Löschen“ entfernt sie sofort aus dem Hub. Hosts im Tunnel-Netz werden nur über IP-Adressen
|
||||
angesprochen (keine DNS-Namen). Nicht enthalten: Die Einrichtung auf der Firewall übernimmt OPNexus nicht
|
||||
(bei einer Firewall hinter NAT kann es sie ja nicht erreichen); bei einer erreichbaren Firewall kannst du
|
||||
alternativ unter VPN › WireGuard einen Tunnel mit denselben Werten verteilen.
|
||||
|
||||
---
|
||||
|
||||
## Regeln sicher ausrollen
|
||||
@@ -483,6 +553,27 @@ automatischen und konfigurierten.
|
||||
- Die NAT-Drift-Erkennung vergleicht die Knoten eines Clusters und meldet
|
||||
Abweichungen.
|
||||
|
||||
### Standort-Variablen
|
||||
|
||||
Mit **Variablen** pflegst du ein Objekt einmal und rollst es auf Firewalls mit unterschiedlichen Netzen aus.
|
||||
Schreibe `{{NAME}}` (Großbuchstaben, Ziffern und `_`, beginnend mit einem Buchstaben) in ein Feld einer
|
||||
**Regel**, eines **Alias**, einer **Portweiterleitung**, einer **1:1-NAT-** oder **Outbound-NAT-Regel**, etwa als
|
||||
Quelle `{{LAN_NET}}` oder als Beschreibung `Zugriff {{SITE}}`. Den Wert legst du je Firewall auf der Host-Seite
|
||||
unter **Standort-Variablen** fest (`192.168.7.0/24`, `Filiale Nord`, …). Die Seite listet automatisch alle
|
||||
Platzhalter, die die für diesen Host bestimmten Objekte verwenden, und zeigt, welche noch ohne Wert sind.
|
||||
|
||||
- **Je Host aufgelöst:** Vorschau, Ausrollen, die Änderungszähler und das Audit sehen die echten Werte des
|
||||
jeweiligen Hosts; ändert sich ein Wert, erscheint nur bei diesem Host eine Änderung. Das zentral gespeicherte
|
||||
Objekt behält seine Platzhalter.
|
||||
- **Fehlt ein Wert, wird nichts gesendet:** Die Vorschau markiert den Host mit „Variable fehlt“, das Ausrollen
|
||||
wird abgelehnt, bevor irgendein Host beschickt wurde, und als letzte Absicherung sendet OPNexus keine Anfrage
|
||||
an eine Firewall, die noch einen Platzhalter enthält.
|
||||
- **Werte** dürfen Buchstaben, Ziffern und `. : / , @ + - _` sowie Leerzeichen enthalten (höchstens 255 Zeichen);
|
||||
sie werden wörtlich eingesetzt, nie als Muster oder weiterer Platzhalter ausgewertet.
|
||||
- **Nicht** unterstützt: Platzhalter in VPN-Tunneln und in der Policy-Hierarchie (dort stehen die Werte direkt im
|
||||
Objekt); Variablen für Gerätegruppen (Vorgabewerte) gibt es nicht, jeder Host hat seine eigenen. Operatoren dürfen
|
||||
Variablen pflegen; ob sie auch ausrollen dürfen, regelt die [Aufgabentrennung](#benutzer-rollen-und-anmeldung).
|
||||
|
||||
---
|
||||
|
||||
## Netzwerk und Host-System
|
||||
@@ -759,8 +850,9 @@ docker compose exec -T -e BACKUP_PASSPHRASE='…' backend python -m app.backup_d
|
||||
|
||||
Die Benachrichtigung „Kein aktuelles Backup“ meldet sich, wenn ein Host drei Intervalle (mindestens
|
||||
6 Stunden) ohne Sicherung war, „Offsite-Backup fehlgeschlagen“ bei Problemen mit dem Ziel. Die
|
||||
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele geprüft, aber noch nicht gegen einen
|
||||
laufenden S3-Anbieter; „Ziel testen“ zeigt dir sofort, ob dein Anbieter funktioniert.
|
||||
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele und gegen einen laufenden S3-Server mit echter
|
||||
Signaturprüfung (Garage, MinIO, RustFS und Versity S3 Gateway: Upload, Download, Löschen, 20-MB-Objekt, Sonderzeichen im
|
||||
Schlüssel, falsches Geheimnis wird abgelehnt) geprüft; bei AWS selbst, Backblaze, Wasabi & Co. zeigt dir „Ziel testen“ sofort, ob dein Anbieter funktioniert.
|
||||
|
||||
**Änderungen ansehen:** Der Knopf „Änderungen“ neben einer Sicherung zeigt, was sich gegenüber der
|
||||
vorherigen geändert hat. Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als
|
||||
@@ -894,6 +986,13 @@ bleibt eine Überschneidung über Aliase unentdeckt, aber es gibt keine falschen
|
||||
Befunde. Beide Regelformate (OPNsense 26.x und ältere Installationen) werden gelesen.
|
||||
Die Werte stehen auch als Prometheus-Metriken zur Verfügung (siehe unten).
|
||||
|
||||
Das **Dashboard** zeigt die Firewalls mit dem niedrigsten Wert. Der Knopf **Bericht** erzeugt eine druckbare Übersicht
|
||||
aller geprüften Firewalls mit ihren offenen Befunden und Hinweisen zur Behebung; im Browser „Drucken / als PDF speichern“
|
||||
wählen. In den Einstellungen › Benachrichtigungen lässt sich **„Kritischer oder hoher Sicherheitsbefund“**
|
||||
einschalten (standardmäßig aus, damit ein bestehender Bestand nicht plötzlich alle alten Befunde auf einmal meldet): Pro
|
||||
Firewall und Prüfung kommt eine Meldung, sobald sie auftritt, und eine „behoben“-Meldung, wenn sie nach der nächsten
|
||||
Prüfung verschwunden ist.
|
||||
|
||||
### Über OPNexus
|
||||
|
||||
Ein Klick auf das Logo öffnet **About**: alle Hosts mit Status, CARP-Rolle, Cluster,
|
||||
@@ -962,6 +1061,10 @@ scrape_configs:
|
||||
| `opnexus_problems_open{type}` | Offene Probleme der Benachrichtigungen |
|
||||
| `opnexus_scheduled_maintenance_pending` | Noch nicht gelaufene geplante Wartungen |
|
||||
|
||||
Ein fertiges **Grafana-Dashboard** liegt unter [`docs/grafana-opnexus.json`](grafana-opnexus.json)
|
||||
(Grafana › Dashboards › Importieren, Prometheus-Datenquelle wählen): erreichbare Firewalls, offene Aktionen
|
||||
und Probleme, Sicherheitswert und Befunde, Last, Speicher, Platte und CARP-Rolle.
|
||||
|
||||
### KI-Assistenten (MCP)
|
||||
|
||||
OPNexus spricht das Model Context Protocol, damit ein KI-Assistent die Flotte
|
||||
@@ -1083,11 +1186,11 @@ die Sicherung. Installationen bis 1.8.x gehen zuerst auf 1.9.x.
|
||||
### Hinweis auf neue Versionen
|
||||
|
||||
Optional zeigt OPNexus Administratoren oben einen Hinweis, wenn eine neuere Version
|
||||
veröffentlicht ist:
|
||||
|
||||
```bash
|
||||
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
```
|
||||
veröffentlicht ist. Eingeschaltet wird das unter **Einstellungen › OPNexus aktualisieren**
|
||||
(Schalter „Versionsprüfung aktivieren“, Feed-Adresse `https://updates.opnexus.dev/latest.json`)
|
||||
— ein Eingriff in die `.env` ist nicht nötig. Wer die Prüfung zentral festlegen will, kann
|
||||
stattdessen `UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json` in die `.env`
|
||||
schreiben; dann ist der Schalter im Webinterface gesperrt.
|
||||
|
||||
Standardmäßig ist das **aus**, dann macht OPNexus keine ausgehende Anfrage. Eingeschaltet
|
||||
geht höchstens alle 12 Stunden (`UPDATE_CHECK_INTERVAL_HOURS`) ein einzelnes `GET` mit
|
||||
@@ -1107,8 +1210,10 @@ Das ist **bewusst aus** und muss aktiviert werden, weil es Docker-Zugriff brauch
|
||||
```bash
|
||||
# .env
|
||||
COMPOSE_PROFILES=updater
|
||||
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
```
|
||||
|
||||
Die Versionsprüfung (Feed) schaltest du dazu im Webinterface ein, siehe oben.
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --no-build
|
||||
```
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_PROMETHEUS",
|
||||
"label": "Prometheus",
|
||||
"description": "",
|
||||
"type": "datasource",
|
||||
"pluginId": "prometheus",
|
||||
"pluginName": "Prometheus"
|
||||
}
|
||||
],
|
||||
"__requires": [
|
||||
{
|
||||
"type": "datasource",
|
||||
"id": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"title": "OPNexus",
|
||||
"uid": "opnexus-fleet",
|
||||
"tags": [
|
||||
"opnexus",
|
||||
"opnsense"
|
||||
],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "1m",
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Firewalls erreichbar / Reachable firewalls",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(opnexus_host_up)",
|
||||
"legendFormat": "up",
|
||||
"instant": true
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_hosts",
|
||||
"legendFormat": "total",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Offline",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "count(opnexus_host_up == 0) or vector(0)",
|
||||
"legendFormat": "offline",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Offene Aktionen / Pending actions",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(opnexus_pending_actions) or vector(0)",
|
||||
"legendFormat": "pending",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Offene Probleme / Open problems",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 14,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(opnexus_problems_open) or vector(0)",
|
||||
"legendFormat": "problems",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"colorMode": "background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Geplante Wartung / Scheduled maintenance",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 3,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_scheduled_maintenance_pending",
|
||||
"legendFormat": "pending",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Version",
|
||||
"type": "stat",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 21,
|
||||
"y": 0,
|
||||
"w": 3,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_info",
|
||||
"legendFormat": "{{version}}",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"textMode": "name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Sicherheitswert / Security score",
|
||||
"type": "bargauge",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_compliance_score",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Befunde / Findings",
|
||||
"type": "table",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_compliance_findings{severity=~\"critical|high|medium|low\"} > 0",
|
||||
"legendFormat": "",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"showHeader": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "CPU-Last (1 min) / Load",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_load1",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Speicher / Memory %",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_memory_used_percent",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"max": 100,
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Platte / Disk %",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 16,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_disk_used_percent",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"max": 100,
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "CARP (1 = MASTER)",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 20,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_carp_master",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "stepAfter"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Erreichbarkeit / Up",
|
||||
"type": "timeseries",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 20,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "opnexus_host_up",
|
||||
"legendFormat": "{{host}}",
|
||||
"instant": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"custom": {
|
||||
"lineInterpolation": "stepAfter"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
],
|
||||
"description": "Fleet overview from the OPNexus /api/metrics endpoint (see the OPNexus handbook, chapter Automation)."
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { api, DashboardSummary, NotificationProblem } from "@/lib/api";
|
||||
import { api, ComplianceHost, DashboardSummary, NotificationProblem } from "@/lib/api";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
@@ -115,6 +115,7 @@ export default function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
|
||||
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
|
||||
const [posture, setPosture] = useState<ComplianceHost[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pending confirmations expire after a few minutes, so keep the summary
|
||||
@@ -163,6 +164,8 @@ export default function DashboardPage() {
|
||||
})
|
||||
.catch((e) => setError(errorMessage(e, "dashboard.loadFailed")));
|
||||
|
||||
api.listCompliance().then((d) => active && setPosture(d)).catch(() => active && setPosture([]));
|
||||
|
||||
api
|
||||
.notificationProblems(false)
|
||||
.then((data) => {
|
||||
@@ -242,6 +245,26 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title={t("dashboard.securityPosture")} icon={ShieldAlert}>
|
||||
{posture === null ? (
|
||||
<Skeleton className="h-10" />
|
||||
) : posture.filter((h) => h.score !== null).length === 0 ? (
|
||||
<p className="text-sm text-slate-500">{t("dashboard.postureNone")} <Link href="/security" className="text-sky-700 hover:underline">{t("dashboard.postureOpen")}</Link></p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{[...posture].filter((h) => h.score !== null).sort((a, b) => (a.score ?? 0) - (b.score ?? 0)).slice(0, 6).map((h) => (
|
||||
<li key={h.host_id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<Link href="/security" className="min-w-0 truncate font-medium text-sky-700 hover:underline">{h.host_name}</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
{h.counts && h.counts.critical > 0 && <Badge tone="danger">{h.counts.critical} {t("security.severity.critical")}</Badge>}
|
||||
<Badge tone={h.grade === "good" ? "success" : h.grade === "fair" ? "warning" : "danger"}>{h.score} / 100</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title={t("dashboard.pendingActions")} icon={ClipboardList}>
|
||||
{summary === null ? (
|
||||
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Spinner } from "@/components/ui/Spinner";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { api, AliasOption, Host, HostBackup, LiveRule, PolicyAlias, RestoreOperation, Rule } from "@/lib/api";
|
||||
import { ArrowLeft, Download, ExternalLink, GitCompare, RotateCcw, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { HostVariables } from "@/components/HostVariables";
|
||||
import { BackupDiffModal } from "@/components/BackupDiffModal";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -424,6 +425,8 @@ export default function HostDetailPage() {
|
||||
|
||||
<HostServices host={host} />
|
||||
|
||||
<HostVariables host={host} />
|
||||
|
||||
{restoreHistory && restoreHistory.length > 0 && (
|
||||
<>
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold text-slate-900">{t("hostDetail.restoreHistory")}</h2>
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { FieldGroup, Input, Select } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, Host, OverlayInstructions, OverlayPeer, OverlaySettings } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const formatBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
||||
|
||||
export default function OverlayPage() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const notAdmin = useNotAdmin();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<OverlaySettings | null>(null);
|
||||
const [peers, setPeers] = useState<OverlayPeer[]>([]);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [port, setPort] = useState(51820);
|
||||
const [subnet, setSubnet] = useState("10.99.0.0/24");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [showing, setShowing] = useState<{ name: string; ip: string; instructions: OverlayInstructions } | null>(null);
|
||||
const [editing, setEditing] = useState<OverlayPeer | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.getOverlay();
|
||||
setSettings(data.settings);
|
||||
setPeers(data.peers);
|
||||
setEnabled(data.settings.enabled);
|
||||
setEndpoint(data.settings.endpoint ?? "");
|
||||
setPort(data.settings.listen_port);
|
||||
setSubnet(data.settings.subnet);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "overlay.loadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
api.listHosts().then(setHosts).catch(() => {});
|
||||
const timer = window.setInterval(load, 10000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateOverlay({ enabled, endpoint, listen_port: port, subnet });
|
||||
await load();
|
||||
toast.show(t("overlay.saved"), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInstructions(peer: OverlayPeer) {
|
||||
try {
|
||||
setShowing({ name: peer.name, ip: peer.ip, instructions: await api.overlayInstructions(peer.id) });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "overlay.loadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(peer: OverlayPeer) {
|
||||
if (!confirm(t("overlay.deleteConfirm", { name: peer.name }))) return;
|
||||
try {
|
||||
await api.deleteOverlayPeer(peer.id);
|
||||
await load();
|
||||
toast.show(t("overlay.deleted", { name: peer.name }), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const hubTone = settings?.hub_state === "running" ? "success" : settings?.hub_state === "stale" ? "warning" : "neutral";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-slate-900">
|
||||
{t("overlay.title")}
|
||||
<InfoTip text={t("overlay.intro")} size={18} />
|
||||
</h1>
|
||||
<fieldset disabled={notAdmin} className="contents">
|
||||
<Button onClick={() => setAdding(true)} disabled={!settings?.enabled}>
|
||||
<Plus size={16} />
|
||||
{t("overlay.addPeer")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<Card className="p-5">
|
||||
{!settings ? <Skeleton className="h-24 w-full" /> : (
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={enabled} disabled={notAdmin} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
{t("overlay.enable")}
|
||||
</label>
|
||||
{settings.enabled && <Badge tone={hubTone}>{t(`overlay.hub.${settings.hub_state}`)}</Badge>}
|
||||
</div>
|
||||
{settings.enabled && settings.hub_state === "absent" && <p className="text-sm text-amber-700">{t("overlay.hubAbsent")}</p>}
|
||||
<fieldset disabled={!enabled || notAdmin} className="grid gap-3 disabled:opacity-50 sm:grid-cols-3">
|
||||
<FieldGroup label={t("overlay.endpoint")} hint={t("overlay.endpointHint")}>
|
||||
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="vpn.example.com" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("overlay.port")}>
|
||||
<Input type="number" min={1} max={65535} value={port} onChange={(e) => setPort(Number(e.target.value))} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("overlay.subnet")} hint={t("overlay.subnetHint")}>
|
||||
<Input value={subnet} onChange={(e) => setSubnet(e.target.value)} disabled={peers.length > 0} />
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
{settings.hub_public_key && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("overlay.hubKey")} <code className="break-all font-mono">{settings.hub_public_key}</code> · {t("overlay.hubAddress")} <code className="font-mono">{settings.hub_address}</code>
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={saving || notAdmin} loading={saving}>{t("common.save")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">{t("overlay.colName")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("overlay.colAddress")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("overlay.colStatus")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("overlay.colHost")}</th>
|
||||
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-5 py-8 text-center text-slate-500">{t("overlay.empty")}</td></tr>
|
||||
)}
|
||||
{peers.map((peer) => (
|
||||
<tr key={peer.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-3 font-medium text-slate-900">{peer.name}</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-slate-600">{peer.ip}</td>
|
||||
<td className="px-5 py-3">
|
||||
{!peer.public_key ? <Badge tone="warning">{t("overlay.needsKey")}</Badge> : peer.connected ? (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<Badge tone="success">{t("overlay.connected")}</Badge>
|
||||
<span className="text-xs text-slate-500">
|
||||
{peer.last_handshake_at ? new Date(peer.last_handshake_at).toLocaleTimeString(locale) : ""} · ↓{formatBytes(peer.rx_bytes)} ↑{formatBytes(peer.tx_bytes)}
|
||||
</span>
|
||||
</span>
|
||||
) : <Badge tone="neutral">{t("overlay.waiting")}</Badge>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{peer.host_id ? <Link className="text-brand-600 hover:underline" href={`/hosts/${peer.host_id}`}>{peer.host_name}</Link> : <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<fieldset disabled={notAdmin} className="contents">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => showInstructions(peer)}>{t("overlay.instructions")}</Button>
|
||||
<Button variant="outline" onClick={() => setEditing(peer)}>{t("overlay.link")}</Button>
|
||||
<Button variant="dangerSubtle" onClick={() => remove(peer)}><Trash2 size={14} />{t("rules.delete")}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{adding && (
|
||||
<AddPeerModal
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={async (created) => { setAdding(false); setShowing(created); await load(); }}
|
||||
/>
|
||||
)}
|
||||
{showing && <InstructionsModal data={showing} onClose={() => setShowing(null)} />}
|
||||
{editing && (
|
||||
<LinkModal
|
||||
peer={editing}
|
||||
hosts={hosts}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => { setEditing(null); await load(); toast.show(t("overlay.saved"), "success"); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPeerModal({ onClose, onCreated }: { onClose: () => void; onCreated: (c: { name: string; ip: string; instructions: OverlayInstructions }) => void }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onCreated(await api.createOverlayPeer(name.trim()));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal title={t("overlay.addPeer")} onClose={onClose}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<FieldGroup label={t("overlay.colName")} hint={t("overlay.nameHint")}>
|
||||
<Input required maxLength={80} value={name} onChange={(e) => setName(e.target.value)} placeholder="filiale-nord" />
|
||||
</FieldGroup>
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button type="submit" disabled={busy} loading={busy}>{t("overlay.create")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyRow({ label, value }: { label: string; value: string }) {
|
||||
const { t } = useLanguage();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<code className="block break-all font-mono text-xs text-slate-900">{value}</code>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" onClick={() => { void navigator.clipboard?.writeText(value); toast.show(t("tokens.copied"), "success"); }}>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionsModal({ data, onClose }: { data: { name: string; ip: string; instructions: OverlayInstructions }; onClose: () => void }) {
|
||||
const { t } = useLanguage();
|
||||
const i = data.instructions;
|
||||
return (
|
||||
<Modal title={t("overlay.howTitle", { name: data.name })} onClose={onClose}>
|
||||
<div className="space-y-4 text-sm text-slate-700">
|
||||
<p>{t("overlay.howIntro")}</p>
|
||||
<h3 className="font-medium text-slate-900">{t("overlay.step1")}</h3>
|
||||
<div className="space-y-2 rounded-md border border-slate-200 p-3">
|
||||
<p className="text-xs text-slate-500">{t("overlay.step1Hint")}</p>
|
||||
<CopyRow label={t("overlay.fieldAddress")} value={i.tunnel_address} />
|
||||
</div>
|
||||
<h3 className="font-medium text-slate-900">{t("overlay.step2")}</h3>
|
||||
<div className="space-y-2 rounded-md border border-slate-200 p-3">
|
||||
<CopyRow label={t("overlay.fieldPublicKey")} value={i.hub_public_key} />
|
||||
<CopyRow label={t("overlay.fieldPsk")} value={i.psk} />
|
||||
<CopyRow label={t("overlay.fieldEndpoint")} value={i.endpoint ?? ""} />
|
||||
<CopyRow label={t("overlay.fieldAllowed")} value={i.allowed_ips} />
|
||||
<CopyRow label={t("overlay.fieldKeepalive")} value={String(i.keepalive)} />
|
||||
</div>
|
||||
<h3 className="font-medium text-slate-900">{t("overlay.step3")}</h3>
|
||||
<p>{t("overlay.step3Text", { hub: i.hub_ip })}</p>
|
||||
<h3 className="font-medium text-slate-900">{t("overlay.step4")}</h3>
|
||||
<p>{t("overlay.step4Text")}</p>
|
||||
<h3 className="font-medium text-slate-900">{t("overlay.step5")}</h3>
|
||||
<div className="space-y-2 rounded-md border border-slate-200 p-3">
|
||||
<p className="text-xs text-slate-500">{t("overlay.step5Text")}</p>
|
||||
<CopyRow label={t("overlay.fieldUrl")} value={i.host_url} />
|
||||
</div>
|
||||
<div className="flex justify-end"><Button onClick={onClose}>{t("tokens.done")}</Button></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkModal({ peer, hosts, onClose, onSaved }: { peer: OverlayPeer; hosts: Host[]; onClose: () => void; onSaved: () => void }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const [key, setKey] = useState(peer.public_key ?? "");
|
||||
const [hostId, setHostId] = useState(peer.host_id ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateOverlayPeer(peer.id, { public_key: key.trim(), ...(hostId ? { host_id: hostId } : { clear_host: true }) });
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal title={t("overlay.linkTitle", { name: peer.name })} onClose={onClose}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<FieldGroup label={t("overlay.firewallKey")} hint={t("overlay.firewallKeyHint")}>
|
||||
<Input value={key} onChange={(e) => setKey(e.target.value)} className="font-mono" placeholder="44 Zeichen, endet auf =" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("overlay.colHost")} hint={t("overlay.hostHint")}>
|
||||
<Select value={hostId} onChange={(e) => setHostId(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{hosts.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button type="submit" disabled={busy} loading={busy}>{t("common.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { api, ComplianceFinding, ComplianceHost, RuleHit, Severity } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { CheckCircle2, CircleHelp, Info, RefreshCw, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, CircleHelp, FileText, Info, RefreshCw, ShieldAlert, XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SEVERITY_TONE: Record<Severity, "danger" | "warning" | "info" | "neutral"> = {
|
||||
@@ -84,12 +85,18 @@ export default function SecurityPage() {
|
||||
{t("security.title")}
|
||||
<InfoTip text={t("security.intro")} size={18} />
|
||||
</h1>
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<Button onClick={() => runCheck(true)} disabled={busy} loading={busy}>
|
||||
<RefreshCw size={16} />
|
||||
{t("security.checkAll")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/security/report" className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">
|
||||
<FileText size={16} />
|
||||
{t("report.open_report")}
|
||||
</Link>
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<Button onClick={() => runCheck(true)} disabled={busy} loading={busy}>
|
||||
<RefreshCw size={16} />
|
||||
{t("security.checkAll")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { api, ComplianceFinding, ComplianceHost, Severity } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
import { Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
|
||||
/** Printable security report of the whole fleet ("Print / save as PDF" in the browser). */
|
||||
export default function SecurityReportPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [hosts, setHosts] = useState<ComplianceHost[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// A report is printed on white paper whatever theme the screen uses.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
let previous = root.dataset.theme;
|
||||
const before = () => { previous = root.dataset.theme; root.dataset.theme = "light"; };
|
||||
const after = () => { if (previous === undefined) delete root.dataset.theme; else root.dataset.theme = previous; };
|
||||
window.addEventListener("beforeprint", before);
|
||||
window.addEventListener("afterprint", after);
|
||||
return () => { window.removeEventListener("beforeprint", before); window.removeEventListener("afterprint", after); after(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.listCompliance()
|
||||
.then((list) => Promise.all(list.filter((h) => h.checked_at).map((h) => api.getHostCompliance(h.host_id))))
|
||||
.then((details) => setHosts(details.sort((a, b) => (a.score ?? 101) - (b.score ?? 101))))
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
const failed = (h: ComplianceHost): ComplianceFinding[] =>
|
||||
(h.findings ?? []).filter((f) => f.passed === false).sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-white p-6 text-slate-900 print:max-w-none print:p-0">
|
||||
<div className="mb-6 flex items-start justify-between print:mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("report.title")}</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("report.generated", { when: new Date().toLocaleString(locale), version: APP_VERSION })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 print:hidden">
|
||||
<Link href="/security" className="inline-flex items-center rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">{t("report.back")}</Link>
|
||||
<Button onClick={() => window.print()}><Printer size={16} />{t("report.print")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{t("security.loadFailed")}</p>}
|
||||
{hosts === null && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
|
||||
{hosts?.length === 0 && <p className="text-sm text-slate-500">{t("security.neverChecked")}</p>}
|
||||
{hosts && hosts.length > 0 && (
|
||||
<table className="mb-8 w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-300 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">{t("overlay.colName")}</th>
|
||||
<th className="py-2 font-medium">{t("report.score")}</th>
|
||||
<th className="py-2 font-medium">{t("report.open")}</th>
|
||||
<th className="py-2 font-medium">{t("report.checked")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hosts.map((h) => (
|
||||
<tr key={h.host_id} className="border-b border-slate-100">
|
||||
<td className="py-1.5 font-medium">{h.host_name}</td>
|
||||
<td className="py-1.5">{h.score === null ? "—" : `${h.score} / 100`}</td>
|
||||
<td className="py-1.5">{h.counts ? (["critical", "high", "medium", "low"] as Severity[]).map((s) => `${h.counts![s]} ${t(`security.severity.${s}`)}`).join(" · ") : "—"}</td>
|
||||
<td className="py-1.5 text-slate-500">{h.checked_at ? new Date(h.checked_at).toLocaleDateString(locale) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{hosts?.map((h) => (
|
||||
<section key={h.host_id} className="mb-6 break-inside-avoid">
|
||||
<h2 className="mb-1 flex items-baseline justify-between border-b border-slate-300 pb-1 text-lg font-semibold">
|
||||
<span>{h.host_name}</span>
|
||||
<span className="text-sm font-normal text-slate-600">{h.score === null ? t("security.notChecked") : `${h.score} / 100`}</span>
|
||||
</h2>
|
||||
{failed(h).length === 0 ? (
|
||||
<p className="text-sm text-slate-600">{t("security.allGood")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{failed(h).map((f) => (
|
||||
<li key={f.id}>
|
||||
<strong>{t(`security.checks.${f.id}.title`)}</strong> <span className="text-slate-500">({t(`security.severity.${f.severity}`)})</span>
|
||||
<div className="text-slate-700">{t(`security.checks.${f.id}.fail`, { count: f.params.count ?? 0, reason: f.params.reason ? t(`security.reason.${f.params.reason}`) : "", protocol: f.params.protocol ?? "", days: f.params.age_days ?? "" })}</div>
|
||||
{(f.params.examples ?? []).length > 0 && <div className="text-slate-600">{(f.params.examples ?? []).join(" · ")}</div>}
|
||||
<div className="text-slate-500">{t(`security.checks.${f.id}.fix`)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ function NotificationsSection() {
|
||||
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
||||
const [scheduledFailed, setScheduledFailed] = useState(true);
|
||||
const [backupFailed, setBackupFailed] = useState(true);
|
||||
const [complianceAlerts, setComplianceAlerts] = useState(false);
|
||||
const [pollInterval, setPollInterval] = useState(300);
|
||||
|
||||
function applyFromServer(data: NotificationSettings) {
|
||||
@@ -89,6 +90,7 @@ function NotificationsSection() {
|
||||
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
||||
setScheduledFailed(data.scheduled_maintenance_failed_enabled);
|
||||
setBackupFailed(data.backup_failed_enabled);
|
||||
setComplianceAlerts(data.compliance_alerts_enabled);
|
||||
setPollInterval(data.poll_interval_seconds);
|
||||
setWebhookSecret("");
|
||||
setClearWebhookSecret(false);
|
||||
@@ -131,6 +133,7 @@ function NotificationsSection() {
|
||||
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
||||
scheduled_maintenance_failed_enabled: scheduledFailed,
|
||||
backup_failed_enabled: backupFailed,
|
||||
compliance_alerts_enabled: complianceAlerts,
|
||||
poll_interval_seconds: pollInterval,
|
||||
});
|
||||
applyFromServer(data);
|
||||
@@ -264,6 +267,7 @@ function NotificationsSection() {
|
||||
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
||||
[t("settings.notify.event.scheduledFailed"), scheduledFailed, setScheduledFailed],
|
||||
[t("settings.notify.event.backupFailed"), backupFailed, setBackupFailed],
|
||||
[t("settings.notify.event.complianceAlerts"), complianceAlerts, setComplianceAlerts],
|
||||
].map(([label, value, setter]) => (
|
||||
<label key={label as string} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
<LoginInfoBanner />
|
||||
<UpdateBanner />
|
||||
<main id="main-content" tabIndex={-1} className="mx-auto max-w-6xl px-4 py-6 outline-none sm:px-6 sm:py-8">{children}</main>
|
||||
<footer className="mx-auto max-w-6xl px-4 pb-6 text-center text-xs text-slate-400 sm:px-6">
|
||||
<footer className="mx-auto max-w-6xl px-4 pb-6 text-center text-xs text-slate-400 sm:px-6 print:hidden">
|
||||
<Link href="/about" className="hover:text-slate-500">
|
||||
{t("common.versionLabel", { version: APP_VERSION, codename: APP_CODENAME })}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Field";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, Host, HostVariable } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Site variables: values for {{NAME}} placeholders that rules, aliases and NAT entries use for this firewall. */
|
||||
export function HostVariables({ host }: { host: Host }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const toast = useToast();
|
||||
const [rows, setRows] = useState<HostVariable[] | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [added, setAdded] = useState<string[]>([]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function apply(list: HostVariable[]) {
|
||||
setRows(list);
|
||||
setValues(Object.fromEntries(list.map((v) => [v.name, v.value ?? ""])));
|
||||
setAdded([]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getHostVariables(host.id).then((r) => apply(r.variables)).catch((e) => setError(errorMessage(e, "variables.loadFailed")));
|
||||
}, [host.id]);
|
||||
|
||||
const names = [...(rows ?? []).map((r) => r.name), ...added];
|
||||
const usage = (name: string) => rows?.find((r) => r.name === name);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = Object.fromEntries(names.filter((n) => (values[n] ?? "").trim() !== "").map((n) => [n, values[n].trim()]));
|
||||
apply((await api.setHostVariables(host.id, payload)).variables);
|
||||
toast.show(t("variables.saved"), "success");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "variables.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addName() {
|
||||
const name = newName.trim().toUpperCase();
|
||||
if (!name || names.includes(name)) return;
|
||||
setAdded([...added, name]);
|
||||
setNewName("");
|
||||
}
|
||||
|
||||
const missing = (rows ?? []).filter((r) => r.missing && !(values[r.name] ?? "").trim()).length;
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
{t("variables.title")}
|
||||
<InfoTip text={t("variables.intro")} />
|
||||
{missing > 0 && <Badge tone="warning">{t("variables.missing", { n: missing })}</Badge>}
|
||||
</h2>
|
||||
<Card className="p-5">
|
||||
{error && <p role="alert" className="mb-3 text-sm text-red-600">{error}</p>}
|
||||
{rows && names.length === 0 && <p className="text-sm text-slate-500">{t("variables.empty")}</p>}
|
||||
<fieldset disabled={readOnly} className="space-y-3">
|
||||
{names.map((name) => {
|
||||
const info = usage(name);
|
||||
return (
|
||||
<div key={name} className="grid items-center gap-2 sm:grid-cols-[14rem_1fr_auto]">
|
||||
<code className="font-mono text-sm text-slate-800">{`{{${name}}}`}</code>
|
||||
<div>
|
||||
<Input value={values[name] ?? ""} onChange={(e) => setValues({ ...values, [name]: e.target.value })} placeholder={info?.missing ? t("variables.required") : ""} aria-label={name} />
|
||||
{info && info.used_by.length > 0 && <p className="mt-1 text-xs text-slate-500">{t("variables.usedBy", { list: info.used_by.map((u) => u.label).slice(0, 4).join(", ") })}</p>}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" onClick={() => { setValues({ ...values, [name]: "" }); setAdded(added.filter((n) => n !== name)); }} aria-label={t("rules.delete")}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
<Input className="max-w-[14rem]" value={newName} onChange={(e) => setNewName(e.target.value.toUpperCase())} placeholder="LAN_NET" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addName(); } }} aria-label={t("variables.newName")} />
|
||||
<Button type="button" variant="outline" onClick={addName}><Plus size={14} />{t("variables.add")}</Button>
|
||||
<Button type="button" onClick={save} disabled={saving} loading={saving}>{t("common.save")}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Boxes,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Layers,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
@@ -165,6 +166,12 @@ export const navGroups: NavGroup[] = [
|
||||
icon: Users,
|
||||
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
|
||||
},
|
||||
{
|
||||
href: "/overlay",
|
||||
label: "Management-Tunnel",
|
||||
icon: Link2,
|
||||
keywords: ["Overlay", "WireGuard", "NAT", "CGNAT", "Hub", "hinter NAT", "Fernzugriff", "Außenstelle", "Filiale", "ohne öffentliche IP"],
|
||||
},
|
||||
{
|
||||
href: "/tokens",
|
||||
label: "API-Tokens",
|
||||
@@ -209,7 +216,7 @@ export function Nav() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<header className="border-b border-slate-200 bg-white print:hidden">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3 sm:px-6">
|
||||
<Link href="/about" title={t("nav.aboutTitle")} className="shrink-0">
|
||||
<Logo className="h-6 w-auto sm:h-7" />
|
||||
|
||||
@@ -468,6 +468,8 @@ export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, prese
|
||||
<p className="text-sm text-amber-700">
|
||||
{result.mismatches.includes("unreadable")
|
||||
? t("network.change.unreachable", { host: hostName })
|
||||
: result.mismatches.includes("carp_vip_inactive")
|
||||
? t("network.change.carpInactive")
|
||||
: t("network.change.mismatch", { fields: result.mismatches.join(", ") })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function OidcSection() {
|
||||
const [issuer, setIssuer] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [scopes, setScopes] = useState("openid profile email groups");
|
||||
const [scopes, setScopes] = useState("openid profile email");
|
||||
const [claim, setClaim] = useState("groups");
|
||||
const [admin, setAdmin] = useState("");
|
||||
const [operator, setOperator] = useState("");
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FieldGroup, Input } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { api, UpdaterStatus } from "@/lib/api";
|
||||
import { api, UpdateCheckSettings, UpdaterStatus } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
@@ -14,6 +16,74 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
const POLL_MS = 2000;
|
||||
const RELOAD_DELAY_MS = 3500;
|
||||
|
||||
/** Versionsprüfung an/aus und Feed-Adresse — statt UPDATE_CHECK_URL in der .env (die weiterhin Vorrang hat). */
|
||||
function FeedSettings({ onSaved }: { onSaved: () => void }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [feed, setFeed] = useState<UpdateCheckSettings | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.updateCheck().then((next) => {
|
||||
setFeed(next);
|
||||
setUrl(next.feed_url);
|
||||
}).catch(() => setFeed(null));
|
||||
}, []);
|
||||
|
||||
if (!feed) return null;
|
||||
|
||||
async function save(body: { enabled?: boolean; feed_url?: string }) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const next = await api.updateUpdateCheck(body);
|
||||
setFeed(next);
|
||||
setUrl(next.feed_url);
|
||||
toast.show(t("settings.update.feedSaved"), "success");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.show(errorMessage(e, "common.requestFailed"), "danger");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-slate-200 p-3 text-sm">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{t("settings.update.feedTitle")}
|
||||
<InfoTip text={t("settings.update.feedHint")} />
|
||||
</p>
|
||||
{feed.managed_by_server ? (
|
||||
<>
|
||||
<p className="text-slate-600">{t("settings.update.feedManaged")}</p>
|
||||
<code className="block overflow-x-auto rounded bg-slate-50 p-2 text-xs">{feed.feed_url}</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={feed.enabled}
|
||||
disabled={saving}
|
||||
onChange={(e) => void save({ enabled: e.target.checked })}
|
||||
/>
|
||||
{t("settings.update.feedToggle")}
|
||||
</label>
|
||||
<FieldGroup label={t("settings.update.feedUrl")}>
|
||||
<div className="flex gap-2">
|
||||
<Input value={url} onChange={(e) => setUrl(e.target.value)} inputMode="url" spellCheck={false} />
|
||||
<Button variant="secondary" disabled={saving || url.trim() === feed.feed_url} onClick={() => void save({ feed_url: url })}>
|
||||
{t("settings.update.feedSave")}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings card "OPNexus aktualisieren": zeigt die verfügbare Version und löst
|
||||
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
|
||||
@@ -125,6 +195,8 @@ export function OpnexusUpdateSection() {
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<FeedSettings onSaved={() => void load()} />
|
||||
|
||||
{!status.feed_enabled && <p className="text-sm text-slate-600">{t("settings.update.feedOff")}</p>}
|
||||
{status.feed_enabled && status.feed_error && <p className="text-sm text-amber-700">{t("settings.update.feedError")}</p>}
|
||||
{status.feed_enabled && !status.feed_error && !status.update_available && (
|
||||
@@ -136,14 +208,19 @@ export function OpnexusUpdateSection() {
|
||||
|
||||
{!status.available && (
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
||||
{status.reason === "not_enabled" || status.reason === "unauthorized" ? (
|
||||
{(status.reason === "not_enabled" || status.reason === "unauthorized") && status.mode === "local" ? (
|
||||
<p>{t("settings.update.localMode")}</p>
|
||||
) : status.reason === "not_enabled" || status.reason === "unauthorized" ? (
|
||||
<>
|
||||
<p className="font-medium">{t("settings.update.notEnabledTitle")}</p>
|
||||
<p>{t("settings.update.notEnabledBody")}</p>
|
||||
<p>{t("settings.update.notEnabledStep1")}</p>
|
||||
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">COMPOSE_PROFILES=updater</pre>
|
||||
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">
|
||||
COMPOSE_PROFILES=updater
|
||||
</pre>
|
||||
<p>{t("settings.update.notEnabledStep2")}</p>
|
||||
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">docker compose up -d</pre>
|
||||
<p className="text-slate-500">{t("settings.update.notEnabledImages")}</p>
|
||||
<p className="text-slate-500">{t("settings.update.notEnabledCli")}</p>
|
||||
</>
|
||||
) : (
|
||||
|
||||
+63
-1
@@ -784,6 +784,48 @@ export interface OidcSettings {
|
||||
|
||||
export type OidcSettingsUpdate = Partial<Omit<OidcSettings, "client_secret_set">> & { client_secret?: string; clear_client_secret?: boolean };
|
||||
|
||||
export interface OverlaySettings {
|
||||
enabled: boolean;
|
||||
endpoint: string | null;
|
||||
subnet: string;
|
||||
listen_port: number;
|
||||
hub_public_key: string | null;
|
||||
hub_address: string;
|
||||
hub_state: "running" | "stale" | "absent";
|
||||
}
|
||||
|
||||
export interface OverlayPeer {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
public_key: string | null;
|
||||
host_id: string | null;
|
||||
host_name: string | null;
|
||||
connected: boolean;
|
||||
last_handshake_at: string | null;
|
||||
rx_bytes: number;
|
||||
tx_bytes: number;
|
||||
remote_endpoint: string | null;
|
||||
}
|
||||
|
||||
export interface OverlayInstructions {
|
||||
hub_public_key: string;
|
||||
endpoint: string | null;
|
||||
tunnel_address: string;
|
||||
allowed_ips: string;
|
||||
psk: string;
|
||||
keepalive: number;
|
||||
hub_ip: string;
|
||||
host_url: string;
|
||||
}
|
||||
|
||||
export interface HostVariable {
|
||||
name: string;
|
||||
value: string | null;
|
||||
used_by: { kind: string; id: string; label: string }[];
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -1187,6 +1229,7 @@ export interface UpdaterJob {
|
||||
from_version: string | null;
|
||||
}
|
||||
|
||||
export type UpdateCheckSettings = { enabled: boolean; feed_url: string; managed_by_server: boolean };
|
||||
export interface UpdaterStatus {
|
||||
/** false = Updater nicht aktiviert/erreichbar (`reason` sagt warum). */
|
||||
available: boolean;
|
||||
@@ -1245,6 +1288,8 @@ export const api = {
|
||||
audit: (filters: Record<string,string | number> = {}) => request<AuditPage>(`/audit?${new URLSearchParams(Object.entries(filters).filter(([,v])=>v!=="").map(([k,v])=>[k,String(v)]))}`),
|
||||
dashboard: () => request<DashboardSummary>("/dashboard"),
|
||||
versionCheck: () => request<VersionCheck>("/system/version-check"),
|
||||
updateCheck: () => request<UpdateCheckSettings>("/system/update-check"),
|
||||
updateUpdateCheck: (body: { enabled?: boolean; feed_url?: string }) => request<UpdateCheckSettings>("/system/update-check", { method: "PUT", body: JSON.stringify(body) }),
|
||||
updaterStatus: () => request<UpdaterStatus>("/system/updater"),
|
||||
updaterUpdate: (version: string) => request<{ job: UpdaterJob }>("/system/updater/update", { method: "POST", body: JSON.stringify({ version }) }),
|
||||
updaterRollback: () => request<{ job: UpdaterJob }>("/system/updater/rollback", { method: "POST" }),
|
||||
@@ -1427,6 +1472,20 @@ export const api = {
|
||||
getGovernance: () => request<{ operators_may_deploy: boolean }>("/settings/governance"),
|
||||
updateGovernance: (operators_may_deploy: boolean) => request<{ operators_may_deploy: boolean }>("/settings/governance", { method: "PUT", body: JSON.stringify({ operators_may_deploy }) }),
|
||||
|
||||
getOverlay: () => request<{ settings: OverlaySettings; peers: OverlayPeer[] }>("/overlay"),
|
||||
updateOverlay: (data: Partial<Pick<OverlaySettings, "enabled" | "endpoint" | "subnet" | "listen_port">>) =>
|
||||
request<OverlaySettings>("/overlay/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
createOverlayPeer: (name: string) =>
|
||||
request<{ id: string; name: string; ip: string; instructions: OverlayInstructions }>("/overlay/peers", { method: "POST", body: JSON.stringify({ name }) }),
|
||||
overlayInstructions: (id: string) => request<OverlayInstructions>(`/overlay/peers/${id}/instructions`),
|
||||
updateOverlayPeer: (id: string, data: { public_key?: string; host_id?: string; clear_host?: boolean }) =>
|
||||
request<{ ok: boolean }>(`/overlay/peers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteOverlayPeer: (id: string) => request<void>(`/overlay/peers/${id}`, { method: "DELETE" }),
|
||||
|
||||
getHostVariables: (id: string) => request<{ variables: HostVariable[] }>(`/hosts/${id}/variables`),
|
||||
setHostVariables: (id: string, variables: Record<string, string>) =>
|
||||
request<{ variables: HostVariable[] }>(`/hosts/${id}/variables`, { method: "PUT", body: JSON.stringify({ variables }) }),
|
||||
|
||||
listApiTokens: () => request<ApiToken[]>("/tokens"),
|
||||
createApiToken: (data: { name: string; scope: TokenScope; expires_in_days: number | null; current_password: string }) =>
|
||||
request<ApiToken & { token: string }>("/tokens", { method: "POST", body: JSON.stringify(data) }),
|
||||
@@ -1701,6 +1760,7 @@ export interface NotificationSettings {
|
||||
carp_auto_revert_failed_enabled: boolean;
|
||||
scheduled_maintenance_failed_enabled: boolean;
|
||||
backup_failed_enabled: boolean;
|
||||
compliance_alerts_enabled: boolean;
|
||||
poll_interval_seconds: number;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -1730,6 +1790,7 @@ export interface NotificationSettingsUpdate {
|
||||
carp_auto_revert_failed_enabled?: boolean;
|
||||
scheduled_maintenance_failed_enabled?: boolean;
|
||||
backup_failed_enabled?: boolean;
|
||||
compliance_alerts_enabled?: boolean;
|
||||
poll_interval_seconds?: number;
|
||||
}
|
||||
|
||||
@@ -1755,7 +1816,8 @@ export type ProblemType =
|
||||
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift"
|
||||
| "scheduled_maintenance_failed"
|
||||
| "backup_stale"
|
||||
| "backup_offsite_failed";
|
||||
| "backup_offsite_failed"
|
||||
| "compliance_finding";
|
||||
|
||||
export interface CarpEvent {
|
||||
id: string;
|
||||
|
||||
+107
-3
@@ -65,6 +65,7 @@
|
||||
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
|
||||
"scheduledFailed": "Geplante Wartung nicht ausgeführt",
|
||||
"backupFailed": "Kein aktuelles Backup / Offsite-Kopie fehlgeschlagen",
|
||||
"complianceAlerts": "Kritischer oder hoher Sicherheitsbefund (standardmäßig aus)",
|
||||
"networkDrift": "Netzwerk-Abweichung im Cluster"
|
||||
},
|
||||
"pollInterval": "Prüfintervall (Sekunden)",
|
||||
@@ -121,15 +122,23 @@
|
||||
"upToDate": "OPNexus ist auf dem neuesten Stand.",
|
||||
"notes": "Release-Notes",
|
||||
"checked": "Zuletzt geprüft: {time}",
|
||||
"feedOff": "Die Versionsprüfung ist aus. Trage UPDATE_CHECK_URL in der .env des Servers ein, damit OPNexus neue Versionen erkennt.",
|
||||
"feedOff": "Die Versionsprüfung ist aus. Schalte sie oben ein, damit OPNexus neue Versionen erkennt.",
|
||||
"feedTitle": "Auf neue Versionen prüfen",
|
||||
"feedHint": "Fragt regelmäßig den Release-Feed ab, ob eine neuere Version erschienen ist. Gesendet wird nur eine einfache Anfrage (GET) mit der eigenen Versionsnummer, nichts sonst. Standardmäßig aus.",
|
||||
"feedToggle": "Versionsprüfung aktivieren",
|
||||
"feedUrl": "Feed-Adresse",
|
||||
"feedSave": "Speichern",
|
||||
"feedSaved": "Gespeichert.",
|
||||
"feedManaged": "Die Versionsprüfung ist durch UPDATE_CHECK_URL in der .env des Servers festgelegt und lässt sich hier nicht ändern.",
|
||||
"feedError": "Der Release-Feed ist gerade nicht erreichbar.",
|
||||
"notEnabledTitle": "Updates aus dem Webinterface sind nicht aktiviert.",
|
||||
"notEnabledBody": "Dafür läuft ein eigener kleiner Dienst (Updater) mit Zugriff auf Docker — er ist bewusst optional. Aktivieren:",
|
||||
"notEnabledStep1": "In der .env auf dem Server eintragen:",
|
||||
"notEnabledStep2": "Dann ausführen:",
|
||||
"notEnabledImages": "Das geht nur mit Release-Images (Release-Bundle oder OPNEXUS_IMAGE_REPO in der .env). Wer OPNexus selbst aus dem Quellcode baut, aktualisiert mit „git pull“ und „docker compose up -d --build“.",
|
||||
"notEnabledCli": "Bis dahin geht das Update auf dem Server mit „opnexus update“.",
|
||||
"unreachable": "Der Updater antwortet nicht. Auf dem Server prüfen: docker compose ps updater",
|
||||
"localMode": "Diese Installation baut OPNexus lokal aus dem Quellcode (kein Release-Image). Aktualisieren auf dem Server mit git pull und docker compose up -d --build.",
|
||||
"localMode": "Diese Installation nutzt keine Release-Images (OPNEXUS_IMAGE_REPO ist in der .env nicht gesetzt). Aktualisieren auf dem Server mit „git pull“ und „docker compose up -d --build“.",
|
||||
"majorChange": "Version {latest} ist ein Hauptversions-Sprung. Bitte erst die Release-Notes lesen und dann auf dem Server mit „opnexus update {latest}“ aktualisieren.",
|
||||
"button": "Auf {latest} aktualisieren",
|
||||
"confirmTitle": "Auf {latest} aktualisieren?",
|
||||
@@ -217,6 +226,7 @@
|
||||
"updates": "Update Center",
|
||||
"settings": "Einstellungen",
|
||||
"users": "Benutzer",
|
||||
"overlay": "Management-Tunnel",
|
||||
"tokens": "API-Tokens",
|
||||
"network-devices": "Geräte",
|
||||
"network-compare": "Abgleich",
|
||||
@@ -229,6 +239,9 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"securityPosture": "Sicherheitslage",
|
||||
"postureNone": "Es wurde noch keine Sicherheitsprüfung durchgeführt.",
|
||||
"postureOpen": "Öffnen",
|
||||
"subtitle": "Fleet-Status, offene Aktionen und Sicherheitswarnungen auf einen Blick.",
|
||||
"managedHosts": "Verwaltete Hosts",
|
||||
"online": "Online",
|
||||
@@ -594,10 +607,18 @@
|
||||
"UPDATER_REJECTED": "Der Updater hat die Anfrage abgelehnt ({reason}).",
|
||||
"UPDATE_NOT_OFFERED": "Diese Version wird nicht zur Installation angeboten — nur die neueste stabile Version.",
|
||||
"UPDATE_MAJOR_CHANGE": "Hauptversions-Sprünge bitte bewusst auf dem Server mit „opnexus update“ ausführen.",
|
||||
"UPDATE_CHECK_MANAGED_BY_SERVER": "Die Versionsprüfung ist durch UPDATE_CHECK_URL in der .env des Servers festgelegt und lässt sich hier nicht ändern.",
|
||||
"UPDATE_CHECK_URL_INVALID": "Die Feed-Adresse muss mit http:// oder https:// beginnen und darf keine Leerzeichen enthalten.",
|
||||
"ADMIN_REQUIRED": "Diese Funktion erfordert die Rolle „Admin“.",
|
||||
"API_TOKEN_LIMIT_REACHED": "Es sind höchstens {limit} API-Tokens pro Benutzer möglich. Widerrufe zuerst nicht mehr benötigte Tokens.",
|
||||
"BACKUP_UNLESBAR": "Das Backup lässt sich nicht lesen: {reason}",
|
||||
"DEPLOY_NEEDS_ADMIN": "Ausrollen darf hier nur ein Administrator (Aufgabentrennung). Bereite die Änderung vor und lass sie freigeben.",
|
||||
"OVERLAY_INVALID": "Ungültige Angabe: {reason}",
|
||||
"OVERLAY_SUBNET_LOCKED": "Das Overlay-Netz lässt sich nicht ändern, solange Firewalls eingetragen sind. Erst die Einträge löschen.",
|
||||
"OVERLAY_NAME_TAKEN": "Dieser Name ist schon vergeben.",
|
||||
"OVERLAY_PEER_NOT_FOUND": "Overlay-Eintrag nicht gefunden",
|
||||
"VARIABLES_MISSING": "Es fehlen Werte für Variablen: {details}. Trage sie auf der Host-Seite unter „Variablen“ ein.",
|
||||
"VARIABLE_INVALID": "Ungültige Variable: {reason}",
|
||||
"OIDC_TEST_FAILED": "Der Test des Identitätsanbieters ist fehlgeschlagen: {reason}",
|
||||
"OIDC_MANAGED_USER": "Dieses Konto wird vom Single Sign-on verwaltet: Rolle und Passwort ändern sich beim Identitätsanbieter.",
|
||||
"OFFSITE_TEST_FAILED": "Der Test des Offsite-Ziels ist fehlgeschlagen: {reason}",
|
||||
@@ -716,6 +737,7 @@
|
||||
"unchanged": "Unverändert",
|
||||
"disabled": "Deaktiviert · übersprungen",
|
||||
"retained": "Früheres Ziel · bleibt bestehen",
|
||||
"missing_variable": "Variable fehlt · übersprungen",
|
||||
"missing_cert": "Kein Zertifikat/CA ausgewählt · wird übersprungen"
|
||||
},
|
||||
"loadFailed": "Laden fehlgeschlagen",
|
||||
@@ -1506,6 +1528,77 @@
|
||||
"truncated": "Die Liste ist abgeschnitten; den Rest in OPNsense vergleichen.",
|
||||
"failed": "Der Vergleich ist fehlgeschlagen"
|
||||
},
|
||||
"overlay": {
|
||||
"title": "Management-Tunnel",
|
||||
"intro": "Lässt OPNexus Firewalls verwalten, die keine öffentliche Adresse haben (hinter NAT, CGNAT, Mobilfunk). Jede Firewall wählt sich per WireGuard NACH AUSSEN bei einem Hub auf diesem Server ein; OPNexus erreicht ihre API dann durch den Tunnel. An der Firewall muss nichts aus dem Internet erreichbar sein. Braucht den Hub-Container (COMPOSE_PROFILES=overlay) und einen aus dem Internet erreichbaren UDP-Port.",
|
||||
"loadFailed": "Der Management-Tunnel konnte nicht geladen werden",
|
||||
"saved": "Gespeichert",
|
||||
"enable": "Management-Tunnel einschalten",
|
||||
"hub": {
|
||||
"running": "Hub läuft",
|
||||
"stale": "Hub meldet sich nicht",
|
||||
"absent": "Hub läuft nicht"
|
||||
},
|
||||
"hubAbsent": "Der Hub-Container läuft nicht. Trage COMPOSE_PROFILES=overlay in .env ein und starte mit docker compose up -d.",
|
||||
"endpoint": "Adresse dieses Servers (wie die Firewalls sie sehen)",
|
||||
"endpointHint": "Öffentlicher DNS-Name oder öffentliche IP dieses Servers. Mit :Port, wenn der veröffentlichte UDP-Port abweicht.",
|
||||
"port": "UDP-Port",
|
||||
"subnet": "Tunnel-Netz",
|
||||
"subnetHint": "Ein privates Netz nur für diese Tunnel. Lässt sich nicht ändern, solange Firewalls eingetragen sind.",
|
||||
"hubKey": "Öffentlicher Schlüssel des Hubs",
|
||||
"hubAddress": "Hub-Adresse",
|
||||
"addPeer": "Firewall hinzufügen",
|
||||
"nameHint": "Ein Name für diese Firewall oder den Standort.",
|
||||
"create": "Anlegen",
|
||||
"colName": "Firewall",
|
||||
"colAddress": "Tunnel-Adresse",
|
||||
"colStatus": "Status",
|
||||
"colHost": "Verwalteter Host",
|
||||
"needsKey": "wartet auf den Schlüssel der Firewall",
|
||||
"connected": "verbunden",
|
||||
"waiting": "noch nicht verbunden",
|
||||
"empty": "Noch keine Firewalls.",
|
||||
"instructions": "Anleitung",
|
||||
"link": "Schlüssel / Host",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "„{name}“ aus dem Management-Tunnel entfernen? Die Firewall ist dann nicht mehr darüber erreichbar.",
|
||||
"deleted": "„{name}“ entfernt",
|
||||
"howTitle": "„{name}“ verbinden",
|
||||
"howIntro": "Einmalig in der Weboberfläche der Firewall (OPNsense: VPN › WireGuard) einrichten. Die Werte lassen sich mit den Knöpfen kopieren.",
|
||||
"step1": "1. Lokale Instanz",
|
||||
"step1Hint": "Neue Instanz anlegen: OPNsense erzeugt das Schlüsselpaar selbst, ein Listen-Port ist nicht nötig; diese Tunnel-Adresse eintragen:",
|
||||
"fieldAddress": "Tunnel-Adresse",
|
||||
"step2": "2. Gegenstelle (der Hub)",
|
||||
"fieldPublicKey": "Öffentlicher Schlüssel",
|
||||
"fieldPsk": "Pre-Shared Key",
|
||||
"fieldEndpoint": "Endpunkt-Adresse und Port",
|
||||
"fieldAllowed": "Erlaubte IPs",
|
||||
"fieldKeepalive": "Keepalive (Sekunden)",
|
||||
"step3": "3. Den Hub hereinlassen",
|
||||
"step3Text": "Eine Firewall-Regel auf der WireGuard-Schnittstellengruppe anlegen: TCP von {hub} zu „Diese Firewall“, Port 443 (oder der Port der Weboberfläche).",
|
||||
"step4": "4. OPNexus den Schlüssel der Firewall geben",
|
||||
"step4Text": "Den öffentlichen Schlüssel der neuen lokalen Instanz aus OPNsense kopieren, dieses Fenster schließen und bei dieser Firewall „Schlüssel / Host“ wählen.",
|
||||
"step5": "5. Die Firewall als Host eintragen",
|
||||
"step5Text": "Den Host mit dieser Adresse und dem üblichen API-Schlüssel anlegen oder ändern. Danach erscheint er hier als verbunden.",
|
||||
"fieldUrl": "Host-Adresse",
|
||||
"linkTitle": "Schlüssel und Host von „{name}“",
|
||||
"firewallKey": "Öffentlicher Schlüssel der WireGuard-Instanz der Firewall",
|
||||
"firewallKeyHint": "Aus OPNsense: VPN › WireGuard › Instanzen. Leer lassen, um ihn zu entfernen.",
|
||||
"hostHint": "Optional: welcher verwaltete Host über diesen Tunnel erreicht wird."
|
||||
},
|
||||
"variables": {
|
||||
"title": "Standort-Variablen",
|
||||
"intro": "Regeln, Aliase und NAT-Einträge können Platzhalter wie {{LAN_NET}} enthalten. Jede Firewall setzt dafür ihren eigenen Wert ein, ein Objekt bedient so viele Standorte mit unterschiedlichen Netzen. Eine Firewall ohne Wert für einen Platzhalter wird nie beschickt: die Vorschau zeigt es und das Ausrollen wird abgelehnt. Werte dürfen Buchstaben, Ziffern und . : / , @ + - _ sowie Leerzeichen enthalten.",
|
||||
"loadFailed": "Die Variablen konnten nicht geladen werden",
|
||||
"saved": "Variablen gespeichert",
|
||||
"saveFailed": "Die Variablen konnten nicht gespeichert werden",
|
||||
"empty": "Keine Platzhalter in Benutzung. Schreibe {{NAME}} (Großbuchstaben, Ziffern, _) in eine Regel, einen Alias oder NAT-Eintrag und lege den Wert hier fest.",
|
||||
"missing": "{n} Wert(e) fehlen",
|
||||
"required": "erforderlich",
|
||||
"usedBy": "Verwendet von: {list}",
|
||||
"newName": "Name der neuen Variable",
|
||||
"add": "Hinzufügen"
|
||||
},
|
||||
"sso": {
|
||||
"title": "Single Sign-on (OIDC)",
|
||||
"intro": "Lässt Personen sich mit deinem Identitätsanbieter anmelden (Authentik, Keycloak, Entra ID, Google, …), statt mit einem hier gespeicherten Passwort. Die Rolle kommt bei jeder Anmeldung aus den Gruppen des Anbieters; ohne zugeordnete Gruppe kommt niemand hinein. Lokale Konten funktionieren weiter, du kommst also bei einem Ausfall des Anbieters immer mit dem Admin-Passwort hinein.",
|
||||
@@ -1521,13 +1614,23 @@
|
||||
"scopes": "Scopes",
|
||||
"claim": "Gruppen-Claim",
|
||||
"mapping": "Welche Gruppe welche Rolle bekommt",
|
||||
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert.",
|
||||
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert. Anbieter, die Gruppen nur auf Anfrage senden (z. B. Okta), brauchen zusätzlich den Scope „groups“ in den Scopes.",
|
||||
"test": "Anbieter testen",
|
||||
"dutiesTitle": "Aufgabentrennung",
|
||||
"dutiesIntro": "Ist es ausgeschaltet, können Operatoren Änderungen vorbereiten (bearbeiten, Vorschau), aber nur ein Administrator rollt sie aus: ausrollen, ein Deployment entfernen oder zurückrollen, Wartung ausführen oder planen und Netzwerkänderungen anwenden. Die Ablehnung steht im Audit.",
|
||||
"operatorsDeploy": "Operatoren dürfen Änderungen selbst ausrollen",
|
||||
"dutiesSaved": "Gespeichert"
|
||||
},
|
||||
"report": {
|
||||
"title": "Sicherheitsbericht",
|
||||
"generated": "Erstellt {when} · OPNexus {version}",
|
||||
"back": "Zurück",
|
||||
"print": "Drucken / als PDF speichern",
|
||||
"score": "Wert",
|
||||
"open": "Offene Befunde",
|
||||
"checked": "Geprüft",
|
||||
"open_report": "Bericht"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicherheitslage",
|
||||
"intro": "Prüft die Konfiguration jeder Firewall auf typische Schwachstellen (offene Regeln, schwaches SSH/SNMP, fehlende Backups …) und vergibt einen Wert von 0 bis 100. Nur lesend: an den Firewalls wird nichts geändert. Automatisch alle 6 Stunden geprüft.",
|
||||
@@ -2389,6 +2492,7 @@
|
||||
"applied": "Änderung angewendet.",
|
||||
"verified": "Angewendet und geprüft",
|
||||
"notVerified": "Angewendet, Prüfung weicht ab",
|
||||
"carpInactive": "Die Änderung ist gespeichert, aber das CARP-VIP ist auf der Firewall nicht aktiv (Status nicht MASTER/BACKUP). Sofort unter Hosts › Status prüfen und die VIPs neu anwenden.",
|
||||
"mismatch": "Die Firewall meldet abweichende Werte: {fields}",
|
||||
"backupId": "Backup vor der Änderung: {id}",
|
||||
"col": {
|
||||
|
||||
+107
-3
@@ -65,6 +65,7 @@
|
||||
"carpAutoRevertFailed": "CARP auto-revert failed",
|
||||
"scheduledFailed": "Scheduled maintenance did not run",
|
||||
"backupFailed": "No recent backup / offsite copy failed",
|
||||
"complianceAlerts": "Critical or high security finding (off by default)",
|
||||
"networkDrift": "Network differences in the cluster"
|
||||
},
|
||||
"pollInterval": "Check interval (seconds)",
|
||||
@@ -121,15 +122,23 @@
|
||||
"upToDate": "OPNexus is up to date.",
|
||||
"notes": "Release notes",
|
||||
"checked": "Last checked: {time}",
|
||||
"feedOff": "The version check is off. Set UPDATE_CHECK_URL in the server's .env so OPNexus can detect new versions.",
|
||||
"feedOff": "The version check is off. Switch it on above so OPNexus can detect new versions.",
|
||||
"feedTitle": "Check for new versions",
|
||||
"feedHint": "Regularly asks the release feed whether a newer version has been published. Only a plain request (GET) carrying your own version number is sent, nothing else. Off by default.",
|
||||
"feedToggle": "Enable version check",
|
||||
"feedUrl": "Feed address",
|
||||
"feedSave": "Save",
|
||||
"feedSaved": "Saved.",
|
||||
"feedManaged": "The version check is fixed by UPDATE_CHECK_URL in the server's .env and cannot be changed here.",
|
||||
"feedError": "The release feed is currently unreachable.",
|
||||
"notEnabledTitle": "Updates from the web interface are not enabled.",
|
||||
"notEnabledBody": "They need a small separate service (the updater) with access to Docker — deliberately optional. To enable it:",
|
||||
"notEnabledStep1": "Add to the .env on the server:",
|
||||
"notEnabledStep2": "Then run:",
|
||||
"notEnabledImages": "This only works with release images (release bundle, or OPNEXUS_IMAGE_REPO in the .env). If you build OPNexus from source, update with “git pull” and “docker compose up -d --build”.",
|
||||
"notEnabledCli": "Until then, update on the server with “opnexus update”.",
|
||||
"unreachable": "The updater does not respond. Check on the server: docker compose ps updater",
|
||||
"localMode": "This installation builds OPNexus locally from source (no release image). Update on the server with git pull and docker compose up -d --build.",
|
||||
"localMode": "This installation does not use release images (OPNEXUS_IMAGE_REPO is not set in the .env). Update on the server with “git pull” and “docker compose up -d --build”.",
|
||||
"majorChange": "Version {latest} is a major version jump. Please read the release notes first, then update on the server with “opnexus update {latest}”.",
|
||||
"button": "Update to {latest}",
|
||||
"confirmTitle": "Update to {latest}?",
|
||||
@@ -217,6 +226,7 @@
|
||||
"updates": "Update Center",
|
||||
"settings": "Settings",
|
||||
"users": "Users",
|
||||
"overlay": "Management tunnel",
|
||||
"tokens": "API tokens",
|
||||
"network-devices": "Devices",
|
||||
"network-compare": "Compare",
|
||||
@@ -229,6 +239,9 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"securityPosture": "Security posture",
|
||||
"postureNone": "No security check has run yet.",
|
||||
"postureOpen": "Open",
|
||||
"subtitle": "Fleet status, pending actions, and security warnings at a glance.",
|
||||
"managedHosts": "Managed hosts",
|
||||
"online": "Online",
|
||||
@@ -594,10 +607,18 @@
|
||||
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
|
||||
"UPDATE_NOT_OFFERED": "This version is not offered for installation — only the latest stable version.",
|
||||
"UPDATE_MAJOR_CHANGE": "Please run major version jumps deliberately on the server with “opnexus update”.",
|
||||
"UPDATE_CHECK_MANAGED_BY_SERVER": "The version check is fixed by UPDATE_CHECK_URL in the server's .env and cannot be changed here.",
|
||||
"UPDATE_CHECK_URL_INVALID": "The feed address must start with http:// or https:// and must not contain spaces.",
|
||||
"ADMIN_REQUIRED": "This function requires the “Admin” role.",
|
||||
"API_TOKEN_LIMIT_REACHED": "At most {limit} API tokens are allowed per user. Revoke tokens you no longer need first.",
|
||||
"BACKUP_UNLESBAR": "The backup cannot be read: {reason}",
|
||||
"DEPLOY_NEEDS_ADMIN": "Only an administrator may roll out here (separation of duties). Prepare the change and have it approved.",
|
||||
"OVERLAY_INVALID": "Invalid value: {reason}",
|
||||
"OVERLAY_SUBNET_LOCKED": "The overlay network cannot be changed while firewalls are registered. Delete the entries first.",
|
||||
"OVERLAY_NAME_TAKEN": "This name is already taken.",
|
||||
"OVERLAY_PEER_NOT_FOUND": "Overlay entry not found",
|
||||
"VARIABLES_MISSING": "Values for variables are missing: {details}. Enter them on the host page under “Variables”.",
|
||||
"VARIABLE_INVALID": "Invalid variable: {reason}",
|
||||
"OIDC_TEST_FAILED": "The identity provider test failed: {reason}",
|
||||
"OIDC_MANAGED_USER": "This account is managed by single sign-on: change role and password at the identity provider.",
|
||||
"OFFSITE_TEST_FAILED": "The offsite target test failed: {reason}",
|
||||
@@ -716,6 +737,7 @@
|
||||
"unchanged": "Unchanged",
|
||||
"disabled": "Disabled · skipped",
|
||||
"retained": "Former target · stays in place",
|
||||
"missing_variable": "Variable missing · skipped",
|
||||
"missing_cert": "No certificate/CA selected · skipped"
|
||||
},
|
||||
"loadFailed": "Loading failed",
|
||||
@@ -1506,6 +1528,77 @@
|
||||
"truncated": "The list is cut off; compare the files in OPNsense for the rest.",
|
||||
"failed": "The comparison failed"
|
||||
},
|
||||
"overlay": {
|
||||
"title": "Management tunnel",
|
||||
"intro": "Lets OPNexus manage firewalls that have no public address (behind NAT, CGNAT, a mobile link). Each firewall dials OUT to a WireGuard hub on this server; OPNexus then reaches its API through the tunnel. Nothing on the firewall has to be reachable from the internet. Needs the hub container (COMPOSE_PROFILES=overlay) and the UDP port reachable from the internet.",
|
||||
"loadFailed": "Could not load the management tunnel",
|
||||
"saved": "Saved",
|
||||
"enable": "Enable the management tunnel",
|
||||
"hub": {
|
||||
"running": "hub running",
|
||||
"stale": "hub not reporting",
|
||||
"absent": "hub not running"
|
||||
},
|
||||
"hubAbsent": "The hub container is not running. Add COMPOSE_PROFILES=overlay to .env and run docker compose up -d.",
|
||||
"endpoint": "Address of this server (as the firewalls see it)",
|
||||
"endpointHint": "Public DNS name or IP of this server. Add :port if the published UDP port differs.",
|
||||
"port": "UDP port",
|
||||
"subnet": "Tunnel network",
|
||||
"subnetHint": "A private network used only for these tunnels. Cannot be changed while firewalls are registered.",
|
||||
"hubKey": "Hub public key",
|
||||
"hubAddress": "hub address",
|
||||
"addPeer": "Add a firewall",
|
||||
"nameHint": "A name for this firewall or site.",
|
||||
"create": "Create",
|
||||
"colName": "Firewall",
|
||||
"colAddress": "Tunnel address",
|
||||
"colStatus": "Status",
|
||||
"colHost": "Managed host",
|
||||
"needsKey": "waiting for the firewall's key",
|
||||
"connected": "connected",
|
||||
"waiting": "not connected yet",
|
||||
"empty": "No firewalls yet.",
|
||||
"instructions": "Instructions",
|
||||
"link": "Key / host",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Remove “{name}” from the management tunnel? The firewall will no longer be reachable through it.",
|
||||
"deleted": "“{name}” removed",
|
||||
"howTitle": "Connect “{name}”",
|
||||
"howIntro": "Do this once in the firewall's own web interface (OPNsense: VPN › WireGuard). Copy the values with the buttons.",
|
||||
"step1": "1. Local instance",
|
||||
"step1Hint": "Create a new instance: let OPNsense generate its key pair, no listen port is needed, and enter this tunnel address:",
|
||||
"fieldAddress": "Tunnel address",
|
||||
"step2": "2. Peer (the hub)",
|
||||
"fieldPublicKey": "Public key",
|
||||
"fieldPsk": "Pre-shared key",
|
||||
"fieldEndpoint": "Endpoint address and port",
|
||||
"fieldAllowed": "Allowed IPs",
|
||||
"fieldKeepalive": "Keepalive (seconds)",
|
||||
"step3": "3. Let the hub in",
|
||||
"step3Text": "Add a firewall rule on the WireGuard interface group: pass TCP from {hub} to “This Firewall”, port 443 (or the port of the web interface).",
|
||||
"step4": "4. Give OPNexus the firewall's public key",
|
||||
"step4Text": "Copy the public key of the new local instance from OPNsense, close this window and use “Key / host” on this firewall.",
|
||||
"step5": "5. Add the firewall as a host",
|
||||
"step5Text": "Add or edit the host with this address and the usual API key. It then shows up as connected here.",
|
||||
"fieldUrl": "Host address",
|
||||
"linkTitle": "Key and host of “{name}”",
|
||||
"firewallKey": "Public key of the firewall's WireGuard instance",
|
||||
"firewallKeyHint": "From OPNsense: VPN › WireGuard › Instances. Leave empty to remove it.",
|
||||
"hostHint": "Optional: which managed host is reached through this tunnel."
|
||||
},
|
||||
"variables": {
|
||||
"title": "Site variables",
|
||||
"intro": "Rules, aliases and NAT entries can contain placeholders like {{LAN_NET}}. Each firewall fills them with its own value, so one object serves many sites with different networks. A firewall without a value for a placeholder is never pushed: the preview says so and the deploy is refused. Values may contain letters, digits and . : / , @ + - _ and spaces.",
|
||||
"loadFailed": "Could not load the variables",
|
||||
"saved": "Variables saved",
|
||||
"saveFailed": "Could not save the variables",
|
||||
"empty": "No placeholders in use. Write {{NAME}} into a rule, alias or NAT entry (capital letters, digits and _) and define its value here.",
|
||||
"missing": "{n} value(s) missing",
|
||||
"required": "required",
|
||||
"usedBy": "Used by: {list}",
|
||||
"newName": "New variable name",
|
||||
"add": "Add"
|
||||
},
|
||||
"sso": {
|
||||
"title": "Single sign-on (OIDC)",
|
||||
"intro": "Lets people sign in with your identity provider (Authentik, Keycloak, Entra ID, Google, …) instead of a password stored here. Roles come from the provider's groups at every login; without a mapped group nobody gets in. Local accounts keep working, so you can always sign in with the admin password if the provider is down.",
|
||||
@@ -1521,13 +1614,23 @@
|
||||
"scopes": "Scopes",
|
||||
"claim": "Groups claim",
|
||||
"mapping": "Which group gets which role",
|
||||
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login.",
|
||||
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login. Providers that only send groups when asked (e.g. Okta) need the scope \"groups\" added to the scopes.",
|
||||
"test": "Test the provider",
|
||||
"dutiesTitle": "Separation of duties",
|
||||
"dutiesIntro": "When switched off, operators can prepare changes (edit, preview) but only an administrator may roll them out: deploy, remove or roll back a deployment, run or schedule maintenance and apply network changes. The refusal is recorded in the audit.",
|
||||
"operatorsDeploy": "Operators may roll out changes themselves",
|
||||
"dutiesSaved": "Saved"
|
||||
},
|
||||
"report": {
|
||||
"title": "Security report",
|
||||
"generated": "Created {when} · OPNexus {version}",
|
||||
"back": "Back",
|
||||
"print": "Print / save as PDF",
|
||||
"score": "Score",
|
||||
"open": "Open findings",
|
||||
"checked": "Checked",
|
||||
"open_report": "Report"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security posture",
|
||||
"intro": "Checks every firewall's configuration for common weaknesses (open rules, weak SSH/SNMP, missing backups…) and gives a score from 0 to 100. Read-only: nothing is changed on the firewalls. Checked automatically every 6 hours.",
|
||||
@@ -2389,6 +2492,7 @@
|
||||
"applied": "Change applied.",
|
||||
"verified": "Applied and checked",
|
||||
"notVerified": "Applied, check differs",
|
||||
"carpInactive": "The change is saved, but the CARP VIP is not active on the firewall (state is not MASTER/BACKUP). Check Hosts › Status right away and re-apply the VIPs.",
|
||||
"mismatch": "The firewall reports different values: {fields}",
|
||||
"backupId": "Backup before the change: {id}",
|
||||
"col": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||
// entries already kept in the project README/CHANGELOG.md, no build
|
||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
export const APP_VERSION = "1.20.0";
|
||||
export const APP_CODENAME = "Lilli";
|
||||
export const APP_VERSION = "1.23.11";
|
||||
export const APP_CODENAME = "Harrier";
|
||||
export const APP_MAINTAINER = "Julian Boldt";
|
||||
@@ -9,6 +9,8 @@
|
||||
# scripts/build_release_images.sh v1.10.0 --push # bauen und pushen
|
||||
# scripts/build_release_images.sh v1.10.0 --push --multiarch # amd64+arm64 (braucht QEMU/buildx)
|
||||
#
|
||||
# Signieren (cosign): OPNEXUS_SIGN=1 COSIGN_KEY=/pfad/cosign.key [COSIGN_PASSWORD=…] — der öffentliche
|
||||
# Schlüssel liegt als cosign.pub im Repo; die Registry muss ihren Token-Realm per https melden.
|
||||
# REPO: Umgebungsvariable OPNEXUS_IMAGE_REPO (Standard git.opnexus.dev/opnexus).
|
||||
# Hinweis: Hinter dem Cloudflare-Proxy (Free) scheitern Pushes mit Layern > 100 MB —
|
||||
# dann die Registry-Domain auf "DNS only" stellen oder aus dem LAN pushen.
|
||||
@@ -45,5 +47,19 @@ for svc in backend frontend proxy updater; do
|
||||
docker build "${targs[@]}" "$src/$svc"
|
||||
if [ "$push" = 1 ]; then for t in "${tags[@]}"; do docker push "$image:$t"; done; fi
|
||||
fi
|
||||
if [ "$push" = 1 ] && [ "${OPNEXUS_SIGN:-0}" = 1 ]; then
|
||||
# Signiert wird der Digest (nicht der veränderliche Tag). Ein Schlüssel: COSIGN_KEY (+ COSIGN_PASSWORD).
|
||||
[ -n "${COSIGN_KEY:-}" ] || { echo "OPNEXUS_SIGN=1, aber COSIGN_KEY ist nicht gesetzt" >&2; exit 1; }
|
||||
if [ "$multi" = 1 ]; then
|
||||
digest="$(docker buildx imagetools inspect "$image:$version" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
else
|
||||
digest="$(docker image inspect "$image:$version" --format '{{json .RepoDigests}}' | tr ',' '\n' | grep -F "$image@" | tr -d '[]"')"; digest="${digest##*@}"
|
||||
fi
|
||||
[ -n "$digest" ] || { echo "Digest von $image:$version nicht gefunden (Push fehlgeschlagen?)" >&2; exit 1; }
|
||||
# Ohne öffentliches Transparenzlog (Rekor): Prüfen soll auch in abgeschotteten Netzen gehen.
|
||||
cosign sign --yes --use-signing-config=false --tlog-upload=false --key "$COSIGN_KEY" "$image@$digest"
|
||||
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$image@$digest" >/dev/null
|
||||
echo " signiert: $image@$digest"
|
||||
fi
|
||||
done
|
||||
[ "$push" = 1 ] && echo "Fertig und gepusht: $repo (${tags[*]})" || echo "Fertig (lokal gebaut, nicht gepusht)."
|
||||
@@ -33,6 +33,7 @@ sed -e "s|^# OPNEXUS_IMAGE_REPO=.*|OPNEXUS_IMAGE_REPO=$repo|" \
|
||||
|
||||
cp scripts/opnexus "$out/opnexus"
|
||||
cp LICENSE "$out/"
|
||||
cp cosign.pub "$out/"
|
||||
cat > "$out/INSTALL.txt" <<TXT
|
||||
OPNexus $version
|
||||
|
||||
|
||||
@@ -5,6 +5,20 @@ FROM python:3.12-alpine
|
||||
|
||||
RUN apk add --no-cache bash docker-cli docker-cli-compose
|
||||
|
||||
# cosign prüft vor einem Update die Signatur der neuen Images (cosign.pub liegt im gemounteten
|
||||
# Projektverzeichnis, siehe scripts/opnexus). Version und Prüfsummen sind fest eingetragen.
|
||||
ARG COSIGN_VERSION=v3.1.3
|
||||
ARG TARGETARCH
|
||||
RUN set -e; arch="${TARGETARCH:-amd64}"; \
|
||||
case "$arch" in \
|
||||
amd64) sha=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 ;; \
|
||||
arm64) sha=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a ;; \
|
||||
*) echo "Architektur $arch nicht unterstützt" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
python -c "import urllib.request; urllib.request.urlretrieve('https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${arch}', '/usr/local/bin/cosign')"; \
|
||||
echo "$sha /usr/local/bin/cosign" | sha256sum -c -; \
|
||||
chmod 0755 /usr/local/bin/cosign
|
||||
|
||||
COPY opnexus /usr/local/bin/opnexus
|
||||
COPY server.py /app/server.py
|
||||
RUN chmod 0755 /usr/local/bin/opnexus
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+30
-1
@@ -80,10 +80,21 @@ updater_enabled() {
|
||||
[[ ",$profiles," == *",updater,"* ]]
|
||||
}
|
||||
|
||||
overlay_enabled() {
|
||||
local profiles
|
||||
profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
|
||||
[[ ",$profiles," == *",overlay,"* ]]
|
||||
}
|
||||
|
||||
# Welche Dienste `up` anfasst: im Updater-Container nur die Anwendung, auf dem
|
||||
# Host (CLI) alles — dort darf sich auch der Updater selbst erneuern.
|
||||
up_targets() {
|
||||
if [ -n "${OPNEXUS_UPDATER:-}" ]; then printf '%s\n' "${SERVICES[@]}"; fi
|
||||
if [ -n "${OPNEXUS_UPDATER:-}" ]; then
|
||||
printf '%s\n' "${SERVICES[@]}"
|
||||
# Der WireGuard-Hub (Profil "overlay") läuft mit dem Backend-Image und muss mit.
|
||||
overlay_enabled && echo wg-hub
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
psql_db() { c exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -Atq "$@"; }
|
||||
@@ -206,6 +217,23 @@ cmd_restore() {
|
||||
say "Wiederhergestellt."
|
||||
}
|
||||
|
||||
# Prüft die Signatur der gezogenen Images (cosign), bevor irgendetwas gewechselt wird. Nur wenn `cosign`
|
||||
# installiert ist und cosign.pub neben der docker-compose.yml liegt; sonst wird das gesagt und übersprungen.
|
||||
verify_signatures() {
|
||||
local target="$1"; shift
|
||||
local repo; repo=$(env_get OPNEXUS_IMAGE_REPO)
|
||||
if ! command -v cosign >/dev/null 2>&1 || [ ! -f cosign.pub ]; then
|
||||
say "Signaturprüfung übersprungen (cosign oder cosign.pub fehlt — siehe Handbuch, „Images prüfen“)."
|
||||
return 0
|
||||
fi
|
||||
local svc
|
||||
for svc in "$@"; do
|
||||
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$repo/opnexus-$svc:$target" >/dev/null 2>&1 \
|
||||
|| die "Signatur von $repo/opnexus-$svc:$target ist ungültig oder fehlt — nichts wurde verändert."
|
||||
done
|
||||
say "Signaturen geprüft."
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
require_release_mode
|
||||
local target="${1:-$(env_get OPNEXUS_VERSION)}"
|
||||
@@ -224,6 +252,7 @@ cmd_update() {
|
||||
local pull_services=("${SERVICES[@]}")
|
||||
if updater_enabled; then pull_services+=(updater); fi
|
||||
OPNEXUS_VERSION="$target" c pull "${pull_services[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
|
||||
verify_signatures "$target" "${pull_services[@]}"
|
||||
|
||||
started=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
cat > "$STATE_FILE" <<STATE
|
||||
|
||||
Reference in new issue
Block a user