Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8128d14da4 | ||
|
|
95f297d3f2 | ||
|
|
ad17dcad25 | ||
|
|
5cb2ebe1c6 | ||
|
|
18d489693d |
No files matched your search
@@ -100,3 +100,26 @@ TRUST_PROXY_HEADERS=false
|
||||
# Zum Aktivieren (Handbuch „Firewalls hinter NAT“): COMPOSE_PROFILES=overlay
|
||||
# und den UDP-Port aus dem Internet erreichbar machen.
|
||||
#OVERLAY_PORT=51820
|
||||
|
||||
# ── GUI-Fernzugriff auf verwaltete Firewalls (optional, Standard AUS) ──
|
||||
# Öffnet die Weboberfläche einer Firewall über OPNexus, auch hinter NAT
|
||||
# (Management-Tunnel), jeweils in einem eigenen Browser-Ursprung (Slot-Port).
|
||||
# Dreifach aus, bis alles hier gesetzt ist UND ein Admin den Schalter unter
|
||||
# Einstellungen einschaltet. Voraussetzungen: HTTPS über den Proxy und
|
||||
# COOKIE_SECURE=true. Siehe Handbuch, „Firewall-Oberfläche über OPNexus“.
|
||||
# 1. Profil aktivieren (mit anderen Profilen kommagetrennt, z. B. overlay,gui):
|
||||
# COMPOSE_PROFILES=gui
|
||||
# 2. Slot-Ursprünge: dieselbe Adresse wie in OPNEXUS_SITES, je Slot ein Port
|
||||
# aus GUI_PORTS. Darf NICHT in FRONTEND_ORIGIN stehen (Start wird sonst verweigert).
|
||||
# OPNEXUS_GUI_SITES=https://10.0.0.5:9441, https://10.0.0.5:9442, https://10.0.0.5:9443, https://10.0.0.5:9444
|
||||
# 3. Gemeinsames Geheimnis Backend <-> Gateway, mind. 32 Zeichen, z. B.
|
||||
# `openssl rand -hex 32`:
|
||||
# GUI_GATEWAY_TOKEN=
|
||||
# 4. Wo die Slot-Ports lauschen. Standard nur lokal (127.0.0.1); für den
|
||||
# Zugriff aus dem Netz die Adresse dieses Hosts eintragen (wie bei
|
||||
# PROXY_BIND_ADDRESS). GUI_PORTS ist ein Bereich, Host- und Container-Port
|
||||
# sind gleich. GUI_PORTS zum Einschalten SETZEN: ohne den Eintrag nimmt
|
||||
# Docker zufällige freie Ports (damit ein Update nie an belegten Ports wie
|
||||
# 9443 scheitert), und die Slot-Adressen oben wären nicht erreichbar.
|
||||
# GUI_BIND_ADDRESS=127.0.0.1
|
||||
# GUI_PORTS=9441-9444
|
||||
@@ -31,6 +31,82 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.28.0] "Sparrowhawk" — 2026-10-21
|
||||
|
||||
- **Übernehmen: mit vorhandenem zentralem Objekt verknüpfen:** Dieselbe von Hand angelegte Regel, derselbe Alias oder dieselbe NAT-Regel auf mehreren
|
||||
Firewalls (z. B. beide HA-Knoten) wird nicht mehr als zweites Objekt übernommen. Im Dialog „Von Firewall übernehmen“ erscheint bei exakter
|
||||
Übereinstimmung (nach Einsetzen der Host-Variablen, bei Aliasen inkl. Verweisen) die Aktion **Verknüpfen**: die Firewall wird weiteres Ziel des
|
||||
vorhandenen Objekts, die Vorschau zeigt „keine Änderung“, und der nächste Rollout aktualisiert alle Firewalls an einem Objekt. Auf der Firewall wird
|
||||
dabei nichts geändert. Unterscheidet sich ein gleichnamiges Objekt, zeigt der Dialog die abweichenden Felder; verknüpft wird dann nie. „Alle
|
||||
übernehmbaren“ fragt vorher nach („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“). Aliase, die bisher wegen „Name existiert“ abgelehnt wurden,
|
||||
lassen sich so übernehmen.
|
||||
Wartet das vorhandene Objekt gerade auf eine Bestätigung, wird nicht verknüpft; „Alle übernehmbaren“ lässt den Eintrag dann aus. Verknüpfen
|
||||
macht ein gerade geöffnetes Bearbeiten-Formular des Objekts ungültig, damit die neue Firewall nicht versehentlich wieder aus den Zielen fällt.
|
||||
|
||||
- **Tooltips überarbeitet:** Der deaktivierte Button „Oberfläche öffnen“ nennt jetzt den Grund im Klartext samt nächstem Schritt (statt nur einer
|
||||
Variablen wie `GUI_GATEWAY_TOKEN`); dasselbe gilt für ausgeschaltete Funktion, deaktivierte Firewall, Nicht-HTTPS-Adresse und ausgeschaltete
|
||||
Zertifikatsprüfung. Texte durchgehend in Du-Form, kürzere VPN-Einleitungen, verständlichere Hinweise bei gesperrten Buttons (Zertifikate, Benutzer,
|
||||
Dienste). Icon-Buttons (Bearbeiten, Löschen, Schließen, Reihenfolge) zeigen beim Darüberfahren ihren Namen.
|
||||
|
||||
## [1.27.2] "Buzzard" — 2026-10-21
|
||||
|
||||
- **`opnexus update` prüft den Proxy vor dem Wechsel:** Nach dem Ziehen der Images validiert das Werkzeug die Caddy-Konfiguration des neuen
|
||||
Proxy-Images mit den Umgebungsvariablen *dieser* Installation. Ist sie ungültig, bricht das Update ab, bevor etwas gewechselt wurde.
|
||||
Der Fall aus 1.27.0 (Compose-Datei mit leerem `OPNEXUS_GUI_SITES`) wird dabei automatisch repariert: Das Werkzeug trägt den Platzhalter
|
||||
`OPNEXUS_GUI_SITES=https://localhost:9441` in die `.env` ein und sagt es. Installationen, die noch die Compose-Datei aus 1.27.0 haben,
|
||||
müssen also nur einmal `opnexus update` ausführen.
|
||||
- **`opnexus update` erkennt einen abstürzenden Proxy:** Ein Dienst ohne Healthcheck (der Proxy) gilt jetzt erst nach 10 Sekunden
|
||||
durchgehender Laufzeit als gesund. Bisher meldete das Update „Fertig“, obwohl der Proxy in einer Neustart-Schleife hing, und das
|
||||
automatische Zurückrollen griff nicht.
|
||||
|
||||
## [1.27.1] "Buzzard" — 2026-10-21
|
||||
|
||||
- **Fix (1.27.1): Proxy startete mit der neuen Compose-Datei nicht.** Mit der `docker-compose.yml` aus 1.27.0 gab Compose die Variable
|
||||
`OPNEXUS_GUI_SITES` als leeren Text an den Proxy weiter; Caddy nutzt seinen Standardwert aber nur bei *nicht gesetzter* Variable und
|
||||
brach mit „server block without any key“ ab, OPNexus war dann nicht mehr erreichbar. Die Compose-Datei setzt jetzt einen gültigen
|
||||
Platzhalter als Standard. Betroffen waren nur Installationen, die die neue Compose-Datei aus 1.27.0 übernommen hatten (ohne
|
||||
`OPNEXUS_GUI_SITES` in der `.env`); wer nur die Images aktualisiert hatte, war nicht betroffen. Sofort-Abhilfe ohne Update:
|
||||
`OPNEXUS_GUI_SITES=https://localhost:9441` in die `.env` eintragen und `docker compose up -d proxy` ausführen.
|
||||
|
||||
## [1.27.0] "Buzzard" — 2026-10-21
|
||||
|
||||
Vorgesehen als MINOR-Release **1.27.0** „Firewall-Oberfläche über OPNexus“.
|
||||
|
||||
- **Firewall-Oberfläche über OPNexus:** Auf der Host-Seite öffnet **Oberfläche öffnen** die Weboberfläche der Firewall
|
||||
in einem neuen Tab, auch bei Firewalls hinter NAT im Management-Tunnel. Nur für Admins; jede Firewall läuft in einer
|
||||
eigenen Browser-Origin (Platz = Port), Cookies bleiben auf dem Server, das Ticket gilt einmalig 30 Sekunden,
|
||||
Inaktivitäts- und Gesamtlimit sind einstellbar, Ticket, Start und Ende stehen im Audit (Art „GUI-Zugriff“).
|
||||
Standardmäßig **aus**: braucht das Compose-Profil `gui`, `OPNEXUS_GUI_SITES`, `GUI_PORTS`, `GUI_GATEWAY_TOKEN`,
|
||||
`COOKIE_SECURE=true` und den Schalter unter Einstellungen (Handbuch, „Firewall-Oberfläche über OPNexus“).
|
||||
Live getestet auf OPNsense 26.7.
|
||||
- **Einschränkungen:** kein WebSocket, keine automatische Anmeldung, höchstens 4 Plätze gleichzeitig. OPNexus-Adresse
|
||||
vorher in die Whitelist des Anmeldeschutzes (sshguard) der Firewall eintragen, sonst können Fehlanmeldungen die
|
||||
API-Zugriffe von OPNexus aussperren. Nach einem Neustart des wg-hub braucht der Tunnel 2 bis 4 Minuten.
|
||||
- **Hinweis für Betreiber mit eigenen Skripten:** Bei HTTPS heißt das Sitzungs-Cookie jetzt `__Host-opnexus-session`
|
||||
(bestehende Sitzungen werden ohne Abmelden übernommen), und Cookie-Anfragen an `/api` mit `Sec-Fetch-Site: same-site/cross-site`
|
||||
werden abgelehnt. Zugriffe mit API-Token sind nicht betroffen.
|
||||
|
||||
## [1.26.0] "Kite" — 2026-10-21
|
||||
|
||||
Geplant als MINOR-Release **1.26.0** (Regel-Analyse). Backend, MCP und Oberfläche.
|
||||
|
||||
- **Regel-Analyse „Welche Regel greift?“ (API):** `GET /api/hosts/{id}/rule-analysis/match` und
|
||||
`GET /api/rule-analysis/match` (ganze Flotte, Auswahl oder Gerätegruppe) beantworten für Quelle, Ziel, Protokoll
|
||||
und Port, ob eine Firewall das erste Paket einer neuen Verbindung durchlässt oder blockiert, welche Regel das
|
||||
entscheidet und warum (Prüfprotokoll Regel für Regel). Grundlage ist der tatsächlich geladene pf-Regelsatz
|
||||
inklusive automatischer Regeln, Floating- und Gruppenregeln, `quick`/letzter Treffer, Negation, verschachtelter
|
||||
Aliase (pf-Tabellentest der Firewall), Port-Aliase und Adressfamilien. Jede Antwort trägt eine ehrliche
|
||||
Konfidenz **sicher / vermutlich / unbekannt** mit Begründungscodes; was nicht belegbar ist (z. B. `tagged`,
|
||||
nicht auflösbare Aliase, geratenes Interface, NAT in der Nähe), wird nie als „sicher“ gemeldet. Nur lesend,
|
||||
auch mit `read`-Token nutzbar; es wird nichts gespeichert und nichts an der Firewall geändert.
|
||||
- **MCP:** neues Werkzeug `rule_match` für KI-Assistenten (gleiche Abfrage, ohne Prüfprotokoll).
|
||||
- **Seite „Regel-Analyse“ (Regelwerk):** Formular für Hosts oder Gerätegruppe, Quell-/Ziel-IP, Protokoll, Ports,
|
||||
Eingangs-Interface („automatisch“ oder Liste) und optional die Ausgangsseite. Die Abfrage steht in der Adresszeile
|
||||
(teilbar, wird beim Öffnen sofort ausgeführt). Je Firewall eine Karte mit Erlaubt/Blockiert/Abgewiesen, Sicherheit
|
||||
des Urteils, entscheidender Regel und Hinweisen; ein „unbekannt“-Ergebnis erscheint bewusst nicht als Urteil.
|
||||
Aufklappbares „Warum?“ zeigt jede geprüfte Regel mit ✓/✗/? je Feld, überstimmte Regeln durchgestrichen. Auch mit
|
||||
Nur-Lesen-Rolle nutzbar.
|
||||
|
||||
## [1.25.0] "Goshawk" — 2026-10-21
|
||||
|
||||
Geplant als MINOR-Release **1.25.0** (Flotten-Rollout).
|
||||
|
||||
+4
-2
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **Stand:** `1.25.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.28.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.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -19,12 +19,14 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln, Aliase und NAT-Regeln lassen sich übernehmen.
|
||||
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
|
||||
- **Alarme** — Gateway-/WAN-Ausfall (down, Paketverlust, Latenz), Schwellen für Platte, Speicher, Swap und CPU sowie ausgefallene Dienste; über mehrere Messungen entprellt, damit Einzelspitzen und flackernde Verbindungen schweigen. Zustellung per E-Mail, Webhook, ntfy, Gotify, Telegram, Microsoft Teams oder Matrix, jedes Ereignis einzeln schaltbar.
|
||||
- **Firewall-Oberfläche über OPNexus** — die Weboberfläche einer Firewall in einem eigenen Browser-Tab über OPNexus öffnen, auch hinter NAT; nur für Admins, Einmal-Ticket, eigene Origin, Zeitlimits und Audit (standardmäßig aus).
|
||||
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
|
||||
- **Update-Center und Rollouts** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus; Rollouts in Wellen (Canary → Pilot → Prod) mit wiederkehrenden Wartungsfenstern, Gesundheitsprüfung zwischen den Wellen und automatischem Halt bei jeder Auffälligkeit.
|
||||
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
|
||||
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
|
||||
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
|
||||
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
|
||||
- **Regel-Analyse** — „Welche Regel trifft?“ für Quelle, Ziel, Protokoll und Port: die entscheidende Regel je Firewall (oder Flotte/Gruppe) mit ehrlicher Sicherheit (sicher / vermutlich / unbekannt) und Prüfprotokoll Regel für Regel, nur lesend.
|
||||
- **Backups & Wiederherstellung** — geplante Konfigurationssicherungen mit Aufbewahrung, verschlüsselte Offsite-Kopie auf S3-kompatiblen Speicher, Änderungsansicht zwischen Sicherungen und eine vollständige verschlüsselte Sicherung von OPNexus selbst (`opnexus backup --full`).
|
||||
- **Single Sign-on** — Anmeldung über deinen Identitätsanbieter (OIDC); die Rolle kommt bei jeder Anmeldung aus dessen Gruppen.
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Operator / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
@@ -63,7 +65,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.25.0
|
||||
OPNEXUS_VERSION=1.28.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **Status:** `1.25.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.28.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.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -19,12 +19,14 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules, aliases and NAT rules created by hand on a firewall can be adopted.
|
||||
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
|
||||
- **Alerts** — gateway/WAN outage (down, packet loss, latency), disk/memory/swap/CPU thresholds and stopped services, debounced over several measurements so spikes and flapping links stay quiet; delivered by email, webhook, ntfy, Gotify, Telegram, Microsoft Teams or Matrix, all switchable per event.
|
||||
- **Firewall interface through OPNexus** — open a firewall's own web interface in a separate browser tab through OPNexus, even behind NAT; admins only, one-time ticket, isolated origin, time limits and audit (off by default).
|
||||
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
|
||||
- **Update center & rollouts** — firmware updates for the fleet, aware of HA order and maintenance mode; rollouts in waves (canary → pilot → prod) with recurring maintenance windows, a health gate between the waves and an automatic stop on anything unusual.
|
||||
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
|
||||
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
|
||||
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
|
||||
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
|
||||
- **Rule analysis** — "Which rule matches?" for a source, destination, protocol and port: the deciding rule per firewall (or fleet/group) with an honest confidence (sure / probable / unknown) and a rule-by-rule trace, read-only.
|
||||
- **Backups & recovery** — scheduled config backups with retention, an encrypted offsite copy to S3-compatible storage, a redacted change view between backups, and a full encrypted backup/restore of OPNexus itself (`opnexus backup --full`).
|
||||
- **Single sign-on** — sign in with your identity provider (OIDC); roles come from its groups at every login.
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / operator / read-only), keyboard operation, dark mode, German and English UI.
|
||||
@@ -65,7 +67,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.25.0
|
||||
OPNEXUS_VERSION=1.28.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ from starlette.responses import JSONResponse
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.errors import ErrorCode
|
||||
from app.routers.auth import COOKIE_NAME
|
||||
from app.routers.auth import session_token
|
||||
from app.routers import governance
|
||||
from app.routers.auth import client_ip
|
||||
from app.services import api_token_service, auth_service
|
||||
@@ -94,10 +94,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
if request.url.path.startswith("/internal/gui/"):
|
||||
# GUI gateway only: never routed by the proxy, authenticated by the
|
||||
# shared gateway token inside routers/gui_access.py.
|
||||
return await call_next(request)
|
||||
|
||||
api_token = None
|
||||
bearer = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
token = session_token(request)
|
||||
if bearer is not None:
|
||||
# An API token wins over a cookie; a bad one never falls back to it.
|
||||
async with SessionLocal() as db:
|
||||
|
||||
@@ -6,6 +6,17 @@ class Settings(BaseSettings):
|
||||
def allowed_origins(self) -> list[str]:
|
||||
return [o.strip().rstrip("/") for o in self.frontend_origin.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def gui_site_origins(self) -> list[str]:
|
||||
return [o.strip().rstrip("/") for o in self.opnexus_gui_sites.split(",") if o.strip()]
|
||||
|
||||
def gui_origin_conflicts(self) -> list[str]:
|
||||
"""Slot origins of the GUI gateway that are also listed in FRONTEND_ORIGIN.
|
||||
Such an origin would get CORS with credentials and pass the Sec-Fetch-Site
|
||||
exception — a firewall GUI could then read OPNexus' API. Startup is refused."""
|
||||
allowed = {o.lower() for o in self.allowed_origins}
|
||||
return [o for o in self.gui_site_origins if o.lower() in allowed]
|
||||
|
||||
database_url: str = (
|
||||
"postgresql+asyncpg://opnexus:opnexus@postgres:5432/opnexus"
|
||||
)
|
||||
@@ -92,6 +103,14 @@ class Settings(BaseSettings):
|
||||
# per-IP lockout entirely.
|
||||
trust_proxy_headers: bool = False
|
||||
|
||||
# ── GUI gateway (1.27, optional container "gui-gateway") ─────────
|
||||
# Slot origins the gateway is served on, comma-separated, e.g.
|
||||
# "https://192.0.2.10:9441,https://192.0.2.10:9442". Empty = feature unavailable.
|
||||
opnexus_gui_sites: str = ""
|
||||
# Shared secret between backend and gateway for /internal/gui/* (at least
|
||||
# 32 characters). Empty = the internal endpoints answer 404.
|
||||
gui_gateway_token: str = ""
|
||||
|
||||
# ── Deploy lockout protection ────────────────────────────────────
|
||||
deploy_confirm_window_seconds: int = 180
|
||||
|
||||
|
||||
@@ -87,6 +87,13 @@ class ErrorCode(str, Enum):
|
||||
MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN = "MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN"
|
||||
NICHT_AUTHENTIFIZIERT = "NICHT_AUTHENTIFIZIERT"
|
||||
CROSS_ORIGIN_BLOCKED = "CROSS_ORIGIN_BLOCKED"
|
||||
CROSS_SITE_REQUEST_BLOCKED = "CROSS_SITE_REQUEST_BLOCKED"
|
||||
GUI_SESSION_REQUIRED = "GUI_SESSION_REQUIRED"
|
||||
GUI_GATEWAY_NOT_CONFIGURED = "GUI_GATEWAY_NOT_CONFIGURED"
|
||||
GUI_REQUIRES_HTTPS = "GUI_REQUIRES_HTTPS"
|
||||
GUI_ACCESS_DISABLED = "GUI_ACCESS_DISABLED"
|
||||
GUI_HOST_NOT_ELIGIBLE = "GUI_HOST_NOT_ELIGIBLE"
|
||||
GUI_NO_FREE_SLOT = "GUI_NO_FREE_SLOT"
|
||||
TLS_PROBE_FAILED = "TLS_PROBE_FAILED"
|
||||
TLS_PEM_INVALID = "TLS_PEM_INVALID"
|
||||
TLS_PIN_MISMATCH = "TLS_PIN_MISMATCH"
|
||||
@@ -119,6 +126,10 @@ class ErrorCode(str, Enum):
|
||||
OBJECT_NOT_ADOPTABLE = "OBJECT_NOT_ADOPTABLE"
|
||||
ADOPT_OBJECT_NOT_FOUND = "ADOPT_OBJECT_NOT_FOUND"
|
||||
OUTBOUND_MODE_BLOCKS_ADOPT = "OUTBOUND_MODE_BLOCKS_ADOPT"
|
||||
ADOPT_LINK_TARGET_NOT_FOUND = "ADOPT_LINK_TARGET_NOT_FOUND"
|
||||
ADOPT_LINK_TARGET_CHANGED = "ADOPT_LINK_TARGET_CHANGED"
|
||||
ADOPT_LINK_HOST_HAS_OBJECT = "ADOPT_LINK_HOST_HAS_OBJECT"
|
||||
ADOPT_LINK_BUSY = "ADOPT_LINK_BUSY"
|
||||
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
|
||||
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
|
||||
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
|
||||
@@ -200,6 +211,11 @@ class ErrorCode(str, Enum):
|
||||
ROLLOUT_HOST_NOT_IN_RUN = "ROLLOUT_HOST_NOT_IN_RUN"
|
||||
ROLLOUT_HOST_RUNNING = "ROLLOUT_HOST_RUNNING"
|
||||
ROLLOUT_HOST_FAILED = "ROLLOUT_HOST_FAILED"
|
||||
ANALYSE_ADRESSE_UNGUELTIG = "ANALYSE_ADRESSE_UNGUELTIG"
|
||||
ANALYSE_FAMILIEN_MISCHUNG = "ANALYSE_FAMILIEN_MISCHUNG"
|
||||
ANALYSE_INTERFACE_UNBEKANNT = "ANALYSE_INTERFACE_UNBEKANNT"
|
||||
ANALYSE_PARAMETER_UNGUELTIG = "ANALYSE_PARAMETER_UNGUELTIG"
|
||||
ANALYSE_FIREWALL_NICHT_LESBAR = "ANALYSE_FIREWALL_NICHT_LESBAR"
|
||||
|
||||
|
||||
def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException:
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"""The ``gui-gateway`` container: firewall web interfaces in their own browser origin (1.27).
|
||||
|
||||
Runs from the backend image (``python -m app.gui_gateway``) behind the proxy, which terminates TLS for the
|
||||
slot ports and sets ``X-Onx-Slot`` (overwriting any client value). It never touches the database or the
|
||||
secret stores; it only talks to the backend's internal API (``/internal/gui/*``, shared GUI_GATEWAY_TOKEN)
|
||||
and, per gateway session, to exactly one firewall.
|
||||
|
||||
Security model (see the 1.27 design):
|
||||
* Every slot port is its own origin. The firewall GUI therefore never runs in the OPNexus origin.
|
||||
* Cookies never cross: the browser's ``Cookie`` header is not forwarded (it carries the OPNexus session),
|
||||
and the firewall's ``Set-Cookie`` never reaches the browser — a server-side jar per gateway session
|
||||
(memory only) holds them. The browser only gets ``__Host-onx-gw-<slot>``.
|
||||
* Each upstream request is built from scratch: method, path and query from the browser, a small header
|
||||
allow-list, the jar's cookies; target origin, pinned certificate and overlay proxy from the backend.
|
||||
Absolute-form targets, CONNECT/TRACE, ``Upgrade`` and service-worker script requests are refused.
|
||||
* ``Sec-Fetch-Site`` must be same-origin/none (same-site only for the redeem step), fail-closed;
|
||||
non-GET/HEAD requests also need the slot's own ``Origin``.
|
||||
* ``Location`` pointing anywhere but the firewall is dropped; responses get CSP, X-Frame-Options DENY,
|
||||
``Cache-Control: no-store``.
|
||||
* At most 3 failed GUI logins per firewall in 5 minutes (protects OPNexus' API access from the firewall's
|
||||
login lockout, which would see all attempts coming from OPNexus).
|
||||
* Idle and absolute limits; every 20 s the backend re-checks the session (OPNexus session, admin role,
|
||||
switch, host). Logs carry method, path (no query), status and duration only.
|
||||
|
||||
OPNsense behaviour this relies on (opnsense/core, src/www/authgui.inc, master as of 2026-10-21):
|
||||
* session_auth(): a login is ``isset($_POST['login']) && !empty($_POST['usernamefld']) &&
|
||||
!empty($_POST['passwordfld'])`` posted to the current URL (login form has no action). Success sends a
|
||||
``Location:`` header and exits (302); a failure re-renders the login form (200) with "Wrong username or
|
||||
password." and an auth_log entry.
|
||||
* check_security_http_referer_enforcement() only runs when HTTP_REFERER is set, and wants the firewall's
|
||||
own host name/IP — so the gateway sends no Referer (and no Origin) upstream at all.
|
||||
* check_security_dns_rebind() accepts an IP address or the configured host name as Host — the gateway
|
||||
sends the Host of the host record's base URL (httpx derives it from the URL).
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import ssl
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from http.cookies import CookieError, SimpleCookie
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("gui_gateway")
|
||||
# httpx/httpcore log full request URLs (query strings) at INFO/DEBUG: never in this process.
|
||||
for _noisy in ("httpx", "httpcore"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
LISTEN_PORT = 8080
|
||||
COOKIE_PREFIX = "__Host-onx-gw-"
|
||||
OPNEXUS_SESSION_COOKIE = "__Host-opnexus-session"
|
||||
BODY_LIMIT = 64 * 1024 * 1024 # config restore uploads
|
||||
LOGIN_FAILURE_LIMIT = 3
|
||||
LOGIN_FAILURE_WINDOW = 300.0
|
||||
UPSTREAM_TIMEOUT = 60.0
|
||||
MAX_SESSIONS = 64
|
||||
|
||||
# Request headers copied from the browser; everything else (Cookie, Authorization, Origin, Referer,
|
||||
# X-Forwarded-*, hop-by-hop, Sec-*, ...) is dropped.
|
||||
_FORWARD_REQUEST_HEADERS = {
|
||||
"accept", "accept-language", "content-type", "user-agent", "x-requested-with", "x-csrftoken", "range",
|
||||
}
|
||||
# Response headers copied from the firewall. Set-Cookie goes to the jar, Location is rewritten.
|
||||
_FORWARD_RESPONSE_HEADERS = {
|
||||
"content-type", "content-length", "content-encoding", "content-disposition", "content-language",
|
||||
"content-range", "accept-ranges", "content-security-policy",
|
||||
}
|
||||
_GATEWAY_CSP = ("connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; "
|
||||
"object-src 'none'; worker-src 'none'")
|
||||
_SECURITY_HEADERS = [
|
||||
(b"content-security-policy", _GATEWAY_CSP.encode()),
|
||||
(b"x-frame-options", b"DENY"),
|
||||
(b"x-content-type-options", b"nosniff"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"pragma", b"no-cache"),
|
||||
(b"referrer-policy", b"same-origin"),
|
||||
(b"cross-origin-opener-policy", b"same-origin"),
|
||||
(b"cross-origin-resource-policy", b"same-origin"),
|
||||
(b"permissions-policy", b"camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
|
||||
]
|
||||
|
||||
|
||||
def _sha(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _origin_of(url: str) -> str | None:
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return None
|
||||
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||||
return None
|
||||
default = 443 if parts.scheme == "https" else 80
|
||||
host = parts.hostname.lower()
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
return f"{parts.scheme}://{host}" + (f":{port}" if port and port != default else "")
|
||||
|
||||
|
||||
def slot_origins_from(sites: str) -> dict[int, set[str]]:
|
||||
"""OPNEXUS_GUI_SITES -> {port: {normalised origins}} (https only, explicit port)."""
|
||||
slots: dict[int, set[str]] = {}
|
||||
for raw in sites.split(","):
|
||||
raw = raw.strip().rstrip("/")
|
||||
try:
|
||||
parts = urlsplit(raw)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
continue
|
||||
if parts.scheme == "https" and parts.hostname and port:
|
||||
slots.setdefault(port, set()).add(_origin_of(raw))
|
||||
return slots
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewaySession:
|
||||
gui_session_id: str
|
||||
session_hash: str
|
||||
slot: int
|
||||
upstream_origin: str
|
||||
ca_pem: str | None
|
||||
proxy_url: str | None
|
||||
idle_timeout: float
|
||||
absolute_deadline: float
|
||||
revalidate_every: float
|
||||
last_activity: float = field(default_factory=time.monotonic)
|
||||
next_check: float = 0.0
|
||||
jar: dict[str, str] = field(default_factory=dict)
|
||||
client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
class BackendClient:
|
||||
"""The backend's internal GUI API. Raises httpx.HTTPError when the backend is unreachable."""
|
||||
|
||||
def __init__(self, base_url: str, token: str, transport: httpx.AsyncBaseTransport | None = None):
|
||||
self._client = httpx.AsyncClient(base_url=base_url, headers={"X-Onx-Gateway-Token": token},
|
||||
timeout=10.0, transport=transport)
|
||||
|
||||
async def redeem(self, ticket_hash: str, session_hash: str, slot: int, client_ip: str | None) -> dict:
|
||||
resp = await self._client.post("/internal/gui/redeem", json={
|
||||
"ticket_hash": ticket_hash, "session_hash": session_hash, "slot": slot, "client_ip": client_ip})
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
if resp.status_code == 403:
|
||||
return {"ok": False, "reason": (resp.json().get("detail") or {}).get("reason", "denied")}
|
||||
resp.raise_for_status()
|
||||
return {"ok": False, "reason": f"http_{resp.status_code}"}
|
||||
|
||||
async def revalidate(self, gs: GatewaySession) -> dict:
|
||||
resp = await self._client.post("/internal/gui/revalidate",
|
||||
json={"gui_session_id": gs.gui_session_id, "session_hash": gs.session_hash})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def end(self, gs: GatewaySession, reason: str) -> None:
|
||||
try:
|
||||
await self._client.post("/internal/gui/end", json={
|
||||
"gui_session_id": gs.gui_session_id, "session_hash": gs.session_hash, "reason": reason})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Could not report the end of a GUI session (%s)", type(exc).__name__)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
def build_upstream_client(gs: GatewaySession) -> httpx.AsyncClient:
|
||||
"""Same transport rules as opnsense_client._client: pinned certificate (or system CAs), overlay proxy."""
|
||||
verify: ssl.SSLContext | bool
|
||||
if gs.ca_pem:
|
||||
from app.services.tls_pin_service import pinned_context # no database layer behind this import
|
||||
verify = pinned_context(gs.ca_pem)
|
||||
else:
|
||||
verify = True
|
||||
return httpx.AsyncClient(verify=verify, proxy=gs.proxy_url, timeout=UPSTREAM_TIMEOUT,
|
||||
follow_redirects=False, trust_env=False)
|
||||
|
||||
|
||||
class LoginLimiter:
|
||||
"""At most LOGIN_FAILURE_LIMIT failed GUI logins per firewall within LOGIN_FAILURE_WINDOW seconds."""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] = time.monotonic):
|
||||
self._failures: dict[str, deque] = {}
|
||||
self._clock = clock
|
||||
|
||||
def _recent(self, key: str) -> deque:
|
||||
now = self._clock()
|
||||
q = self._failures.setdefault(key, deque())
|
||||
while q and now - q[0] > LOGIN_FAILURE_WINDOW:
|
||||
q.popleft()
|
||||
return q
|
||||
|
||||
def blocked(self, key: str) -> bool:
|
||||
return len(self._recent(key)) >= LOGIN_FAILURE_LIMIT
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
self._recent(key).append(self._clock())
|
||||
|
||||
|
||||
def is_login_attempt(method: str, content_type: str, body: bytes) -> bool:
|
||||
if method != "POST":
|
||||
return False
|
||||
if content_type.lower().startswith("multipart/form-data"):
|
||||
# PHP fills $_POST from multipart bodies too, so authgui.inc accepts a login posted this way.
|
||||
return all(f'name="{field}"'.encode() in body for field in ("login", "usernamefld", "passwordfld"))
|
||||
if not content_type.lower().startswith("application/x-www-form-urlencoded"):
|
||||
return False
|
||||
try:
|
||||
form = parse_qs(body.decode("latin-1"), keep_blank_values=True)
|
||||
except ValueError:
|
||||
return False
|
||||
return "login" in form and bool(form.get("usernamefld", [""])[0]) and bool(form.get("passwordfld", [""])[0])
|
||||
|
||||
|
||||
def rewrite_location(location: str, upstream_origin: str) -> str | None:
|
||||
"""Relative target for the browser, or None when the redirect leaves the firewall."""
|
||||
location = location.strip()
|
||||
if not location or "\\" in location or any(ord(c) < 0x20 for c in location):
|
||||
return None
|
||||
if location.startswith("//"):
|
||||
location = "https:" + location
|
||||
parts = urlsplit(location)
|
||||
if parts.scheme or parts.netloc:
|
||||
if _origin_of(location) != _origin_of(upstream_origin):
|
||||
return None
|
||||
path = parts.path or "/"
|
||||
return path + (f"?{parts.query}" if parts.query else "") + (f"#{parts.fragment}" if parts.fragment else "")
|
||||
return location
|
||||
|
||||
|
||||
def _page(title: str, text_de: str, text_en: str) -> bytes:
|
||||
return (f"<!doctype html><html lang=\"de\"><head><meta charset=\"utf-8\"><title>{title}</title></head>"
|
||||
f"<body style=\"font-family:sans-serif;max-width:40rem;margin:3rem auto\"><h1>{title}</h1>"
|
||||
f"<p>{text_de}</p><p lang=\"en\">{text_en}</p></body></html>").encode()
|
||||
|
||||
|
||||
_SESSION_GONE = _page("OPNexus", "Diese Sitzung ist abgelaufen oder ungültig. Öffne die Oberfläche erneut aus OPNexus.",
|
||||
"This session has expired or is invalid. Open the interface again from OPNexus.")
|
||||
_FORBIDDEN = _page("OPNexus", "Diese Anfrage wurde vom GUI-Gateway abgelehnt.", "This request was refused by the GUI gateway.")
|
||||
_BAD_REDIRECT = _page("OPNexus", "Die Firewall wollte auf eine fremde Adresse umleiten; die Umleitung wurde verworfen.",
|
||||
"The firewall tried to redirect to a foreign address; the redirect was dropped.")
|
||||
_LOGIN_LOCKED = _page("OPNexus", "Zu viele fehlgeschlagene Anmeldungen an dieser Firewall. Bitte warte 5 Minuten.",
|
||||
"Too many failed sign-ins at this firewall. Please wait 5 minutes.")
|
||||
_UNAVAILABLE = _page("OPNexus", "Die Firewall oder OPNexus ist gerade nicht erreichbar.",
|
||||
"The firewall or OPNexus is not reachable right now.")
|
||||
_REDEEMED = (b"<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url=/\">"
|
||||
b"<title>OPNexus</title></head><body><a href=\"/\">→</a></body></html>")
|
||||
|
||||
|
||||
class GuiGateway:
|
||||
def __init__(self, *, backend: BackendClient, slot_origins: dict[int, set[str]],
|
||||
upstream_factory: Callable[[GatewaySession], httpx.AsyncClient] = build_upstream_client,
|
||||
clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], float] = time.time):
|
||||
self.backend = backend
|
||||
self.slot_origins = slot_origins
|
||||
self.upstream_factory = upstream_factory
|
||||
self.clock = clock
|
||||
self.wall_clock = wall_clock
|
||||
self.sessions: dict[str, GatewaySession] = {} # sha256(gateway cookie) -> session
|
||||
self.limiter = LoginLimiter(clock)
|
||||
self._loop_task: asyncio.Task | None = None
|
||||
|
||||
# ── ASGI ──────────────────────────────────────────────────────────────
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "lifespan":
|
||||
await self._lifespan(receive, send)
|
||||
return
|
||||
if scope["type"] != "http":
|
||||
# WebSocket and anything else: not supported (design: no WebSocket).
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
return
|
||||
started = time.monotonic()
|
||||
status = await self._handle(scope, receive, send)
|
||||
# Method, path and status only: never the query string, headers or bodies.
|
||||
logger.info("%s %s %d %.0fms", scope.get("method", "?"), scope.get("path", "?")[:200], status,
|
||||
(time.monotonic() - started) * 1000)
|
||||
|
||||
async def _lifespan(self, receive, send):
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "lifespan.startup":
|
||||
self._loop_task = asyncio.create_task(self._revalidate_loop())
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
elif message["type"] == "lifespan.shutdown":
|
||||
if self._loop_task:
|
||||
self._loop_task.cancel()
|
||||
for key in list(self.sessions):
|
||||
await self._drop(key, "shutdown")
|
||||
await self.backend.aclose()
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
return
|
||||
|
||||
async def _revalidate_loop(self):
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
try:
|
||||
await self.sweep()
|
||||
except Exception: # never let the loop die
|
||||
logger.exception("GUI session sweep failed")
|
||||
|
||||
async def sweep(self) -> None:
|
||||
"""Idle/absolute limits and the periodic backend re-check for every gateway session."""
|
||||
for key, gs in list(self.sessions.items()):
|
||||
reason = self._expired(gs)
|
||||
if reason:
|
||||
await self._drop(key, reason)
|
||||
elif self.clock() >= gs.next_check:
|
||||
await self._recheck(key, gs)
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _headers(scope) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for k, v in scope.get("headers", []):
|
||||
name = k.decode("latin-1").lower()
|
||||
value = v.decode("latin-1")
|
||||
if name in result:
|
||||
value = f"{result[name]}{'; ' if name == 'cookie' else ', '}{value}"
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _cookies(header: str) -> dict[str, str]:
|
||||
cookies: dict[str, str] = {}
|
||||
for part in header.split(";"):
|
||||
name, sep, value = part.strip().partition("=")
|
||||
if sep and name and name not in cookies:
|
||||
cookies[name] = value
|
||||
return cookies
|
||||
|
||||
async def _respond(self, send, status: int, body: bytes = b"", headers: list | None = None,
|
||||
content_type: bytes = b"text/html; charset=utf-8") -> int:
|
||||
out = [(b"content-type", content_type), (b"content-length", str(len(body)).encode())]
|
||||
out += list(headers or []) + _SECURITY_HEADERS
|
||||
await send({"type": "http.response.start", "status": status, "headers": out})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
return status
|
||||
|
||||
def _expired(self, gs: GatewaySession) -> str | None:
|
||||
if self.wall_clock() >= gs.absolute_deadline:
|
||||
return "absolute"
|
||||
if self.clock() - gs.last_activity >= gs.idle_timeout:
|
||||
return "idle"
|
||||
return None
|
||||
|
||||
async def _drop(self, key: str, reason: str, *, report: bool = True) -> None:
|
||||
gs = self.sessions.pop(key, None)
|
||||
if gs is None:
|
||||
return
|
||||
gs.jar.clear()
|
||||
if gs.client is not None:
|
||||
await gs.client.aclose()
|
||||
if report:
|
||||
await self.backend.end(gs, reason)
|
||||
|
||||
async def _recheck(self, key: str, gs: GatewaySession) -> bool | None:
|
||||
"""True = still valid, False = ended (dropped), None = backend unreachable (fail closed, keep for retry)."""
|
||||
try:
|
||||
result = await self.backend.revalidate(gs)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("GUI session re-check failed (%s)", type(exc).__name__)
|
||||
return None
|
||||
if not result.get("ok"):
|
||||
await self._drop(key, str(result.get("reason", "revoked")), report=False) # backend already ended it
|
||||
return False
|
||||
gs.absolute_deadline = min(gs.absolute_deadline, float(result.get("absolute_deadline", gs.absolute_deadline)))
|
||||
gs.idle_timeout = float(result.get("idle_timeout_seconds", gs.idle_timeout))
|
||||
gs.next_check = self.clock() + gs.revalidate_every
|
||||
return True
|
||||
|
||||
# ── request handling ──────────────────────────────────────────────────
|
||||
async def _handle(self, scope, receive, send) -> int:
|
||||
method = scope.get("method", "")
|
||||
path = scope.get("path", "")
|
||||
raw_path = scope.get("raw_path") or path.encode()
|
||||
headers = self._headers(scope)
|
||||
|
||||
if method in ("CONNECT", "TRACE"):
|
||||
return await self._respond(send, 501, _FORBIDDEN)
|
||||
# Origin-form only: "GET http://elsewhere/ HTTP/1.1" (absolute-form) or "//host" never reach upstream.
|
||||
if not path.startswith("/") or path.startswith("//") or not raw_path.startswith(b"/") \
|
||||
or raw_path.startswith(b"//") or "\\" in path:
|
||||
return await self._respond(send, 400, _FORBIDDEN)
|
||||
if "upgrade" in headers or "upgrade" in headers.get("connection", "").lower():
|
||||
return await self._respond(send, 400, _FORBIDDEN)
|
||||
if headers.get("service-worker", "").lower() == "script":
|
||||
return await self._respond(send, 403, _FORBIDDEN)
|
||||
|
||||
try:
|
||||
slot = int(headers.get("x-onx-slot", ""))
|
||||
except ValueError:
|
||||
slot = 0
|
||||
own_origins = self.slot_origins.get(slot)
|
||||
if not own_origins:
|
||||
return await self._respond(send, 400, _FORBIDDEN) # not coming through the proxy's slot block
|
||||
|
||||
site = headers.get("sec-fetch-site", "").strip().lower()
|
||||
if path == "/__onx/redeem":
|
||||
if method != "GET" or site not in ("same-site", "same-origin", "none"):
|
||||
return await self._respond(send, 403, _FORBIDDEN)
|
||||
return await self._redeem(scope, headers, slot, send)
|
||||
if site not in ("same-origin", "none"):
|
||||
return await self._respond(send, 403, _FORBIDDEN) # missing header = old/odd client: fail closed
|
||||
if method not in ("GET", "HEAD"):
|
||||
origin = headers.get("origin", "")
|
||||
if _origin_of(origin) not in own_origins:
|
||||
return await self._respond(send, 403, _FORBIDDEN)
|
||||
if path.startswith("/__onx/"):
|
||||
return await self._respond(send, 404, _FORBIDDEN)
|
||||
|
||||
cookies = self._cookies(headers.get("cookie", ""))
|
||||
token = cookies.get(f"{COOKIE_PREFIX}{slot}", "")
|
||||
key = _sha(token) if token else ""
|
||||
gs = self.sessions.get(key) if key else None
|
||||
if gs is None or gs.slot != slot:
|
||||
return await self._respond(send, 401, _SESSION_GONE)
|
||||
reason = self._expired(gs)
|
||||
if reason:
|
||||
await self._drop(key, reason)
|
||||
return await self._respond(send, 401, _SESSION_GONE)
|
||||
if self.clock() >= gs.next_check:
|
||||
state = await self._recheck(key, gs)
|
||||
if state is False:
|
||||
return await self._respond(send, 401, _SESSION_GONE)
|
||||
if state is None:
|
||||
return await self._respond(send, 503, _UNAVAILABLE)
|
||||
gs.last_activity = self.clock()
|
||||
return await self._proxy(scope, receive, send, headers, gs)
|
||||
|
||||
async def _redeem(self, scope, headers, slot: int, send) -> int:
|
||||
query = parse_qs(scope.get("query_string", b"").decode("latin-1"))
|
||||
ticket = (query.get("t") or [""])[0]
|
||||
browser_session = self._cookies(headers.get("cookie", "")).get(OPNEXUS_SESSION_COOKIE, "")
|
||||
if not ticket or len(ticket) > 128 or not browser_session:
|
||||
return await self._respond(send, 403, _SESSION_GONE)
|
||||
client_ip = (headers.get("x-forwarded-for", "").split(",")[0].strip() or None)
|
||||
try:
|
||||
result = await self.backend.redeem(_sha(ticket), _sha(browser_session), slot, client_ip and client_ip[:45])
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Ticket redeem failed (%s)", type(exc).__name__)
|
||||
return await self._respond(send, 503, _UNAVAILABLE)
|
||||
if not result.get("ok"):
|
||||
return await self._respond(send, 403, _SESSION_GONE)
|
||||
# One browser session, one gateway session per slot: an older one in this slot is replaced.
|
||||
for old_key, old in list(self.sessions.items()):
|
||||
if old.slot == slot and old.session_hash == _sha(browser_session):
|
||||
await self._drop(old_key, "superseded", report=False)
|
||||
if len(self.sessions) >= MAX_SESSIONS:
|
||||
oldest = min(self.sessions, key=lambda k: self.sessions[k].last_activity)
|
||||
await self._drop(oldest, "superseded")
|
||||
value = secrets.token_urlsafe(32)
|
||||
gs = GatewaySession(
|
||||
gui_session_id=result["gui_session_id"], session_hash=_sha(browser_session), slot=slot,
|
||||
upstream_origin=result["upstream_origin"], ca_pem=result.get("ca_pem"), proxy_url=result.get("proxy_url"),
|
||||
idle_timeout=float(result["idle_timeout_seconds"]), absolute_deadline=float(result["absolute_deadline"]),
|
||||
revalidate_every=float(result.get("revalidate_seconds", 20)),
|
||||
)
|
||||
gs.last_activity = self.clock()
|
||||
gs.next_check = gs.last_activity + gs.revalidate_every
|
||||
self.sessions[_sha(value)] = gs
|
||||
cookie = f"{COOKIE_PREFIX}{slot}={value}; Path=/; Secure; HttpOnly; SameSite=Strict"
|
||||
# 200 + meta refresh instead of a 303: the follow-up request then starts in this origin
|
||||
# (Sec-Fetch-Site: same-origin) and the ticket leaves the address bar.
|
||||
return await self._respond(send, 200, _REDEEMED, headers=[
|
||||
(b"set-cookie", cookie.encode()), (b"clear-site-data", b'"cache", "storage"'),
|
||||
])
|
||||
|
||||
async def _read_body(self, receive) -> bytes | None:
|
||||
chunks, size = [], 0
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
return None
|
||||
chunk = message.get("body", b"")
|
||||
size += len(chunk)
|
||||
if size > BODY_LIMIT:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
if not message.get("more_body"):
|
||||
return b"".join(chunks)
|
||||
|
||||
def _store_cookies(self, gs: GatewaySession, response: httpx.Response) -> None:
|
||||
for header in response.headers.get_list("set-cookie"):
|
||||
jar = SimpleCookie()
|
||||
try:
|
||||
jar.load(header)
|
||||
except CookieError:
|
||||
continue
|
||||
for name, morsel in jar.items():
|
||||
expired = morsel["max-age"] in ("0",) or morsel.value in ("", "deleted")
|
||||
if not expired and morsel["expires"]:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
expired = parsedate_to_datetime(morsel["expires"]).timestamp() <= self.wall_clock()
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if expired:
|
||||
gs.jar.pop(name, None)
|
||||
else:
|
||||
gs.jar[name] = morsel.value
|
||||
|
||||
async def _proxy(self, scope, receive, send, headers: dict, gs: GatewaySession) -> int:
|
||||
length = headers.get("content-length", "")
|
||||
if length.isdigit() and int(length) > BODY_LIMIT:
|
||||
return await self._respond(send, 413, _FORBIDDEN)
|
||||
body = await self._read_body(receive)
|
||||
if body is None:
|
||||
return await self._respond(send, 413, _FORBIDDEN)
|
||||
method = scope["method"]
|
||||
login = is_login_attempt(method, headers.get("content-type", ""), body)
|
||||
if login and self.limiter.blocked(gs.upstream_origin):
|
||||
return await self._respond(send, 429, _LOGIN_LOCKED)
|
||||
|
||||
out_headers = {k: v for k, v in headers.items() if k in _FORWARD_REQUEST_HEADERS}
|
||||
out_headers["accept-encoding"] = "identity"
|
||||
if gs.jar:
|
||||
out_headers["cookie"] = "; ".join(f"{k}={v}" for k, v in gs.jar.items())
|
||||
query = scope.get("query_string", b"").decode("latin-1")
|
||||
# The path exactly as the browser sent it (still percent-encoded): the decoded scope["path"] would
|
||||
# turn %3F/%23 into query/fragment, %2F into a separator and decode %25xx a second time upstream.
|
||||
raw_path = (scope.get("raw_path") or scope["path"].encode()).split(b"?", 1)[0].decode("latin-1")
|
||||
url = gs.upstream_origin + raw_path + (f"?{query}" if query else "")
|
||||
if gs.client is None:
|
||||
gs.client = self.upstream_factory(gs)
|
||||
try:
|
||||
request = gs.client.build_request(method, url, headers=out_headers, content=body)
|
||||
except httpx.InvalidURL:
|
||||
return await self._respond(send, 400, _FORBIDDEN) # e.g. control characters in the path
|
||||
try:
|
||||
response = await gs.client.send(request, stream=True)
|
||||
except (httpx.HTTPError, ssl.SSLError, OSError) as exc:
|
||||
logger.warning("Upstream request failed (%s)", type(exc).__name__)
|
||||
return await self._respond(send, 502, _UNAVAILABLE)
|
||||
try:
|
||||
self._store_cookies(gs, response)
|
||||
if login and not (300 <= response.status_code < 400):
|
||||
self.limiter.record_failure(gs.upstream_origin)
|
||||
out = []
|
||||
for name, value in response.headers.multi_items():
|
||||
lname = name.lower()
|
||||
if lname in _FORWARD_RESPONSE_HEADERS:
|
||||
out.append((lname.encode("latin-1"), value.encode("latin-1")))
|
||||
if "location" in response.headers:
|
||||
target = rewrite_location(response.headers["location"], gs.upstream_origin)
|
||||
if target is None:
|
||||
await response.aclose()
|
||||
return await self._respond(send, 502, _BAD_REDIRECT)
|
||||
out.append((b"location", target.encode("latin-1")))
|
||||
await send({"type": "http.response.start", "status": response.status_code, "headers": out + _SECURITY_HEADERS})
|
||||
async for chunk in response.aiter_raw():
|
||||
await send({"type": "http.response.body", "body": chunk, "more_body": True})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return response.status_code
|
||||
finally:
|
||||
await response.aclose()
|
||||
|
||||
|
||||
def create_app(transport: httpx.AsyncBaseTransport | None = None) -> GuiGateway:
|
||||
token = os.environ.get("GUI_GATEWAY_TOKEN", "")
|
||||
if len(token) < 32:
|
||||
raise SystemExit("GUI_GATEWAY_TOKEN (at least 32 characters) is required")
|
||||
slots = slot_origins_from(os.environ.get("OPNEXUS_GUI_SITES", ""))
|
||||
if not slots:
|
||||
raise SystemExit("OPNEXUS_GUI_SITES must list the https slot origins")
|
||||
backend = BackendClient(os.environ.get("GUI_BACKEND_URL", "http://backend:8000"), token, transport)
|
||||
return GuiGateway(backend=backend, slot_origins=slots)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
# h11: an absolute-form target stays visible in scope["path"] (and is refused); access_log off because
|
||||
# uvicorn's access log would contain query strings (the redeem ticket).
|
||||
uvicorn.run(create_app(), host="0.0.0.0", port=LISTEN_PORT, http="h11", ws="none", access_log=False,
|
||||
server_header=False, date_header=False, proxy_headers=False, lifespan="on", log_config=None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -52,6 +52,8 @@ from app.services import resource_monitor_service
|
||||
from app.routers import scheduled_maintenance as scheduled_maintenance_router
|
||||
from app.routers import maintenance_windows as maintenance_windows_router
|
||||
from app.routers import rollouts as rollouts_router
|
||||
from app.routers import rule_analysis as rule_analysis_router
|
||||
from app.routers import gui_access as gui_access_router
|
||||
from app.services import scheduled_maintenance_service
|
||||
from app.services import rollout_engine
|
||||
from app.services import compliance_service
|
||||
@@ -322,6 +324,10 @@ async def _backup_poll_loop():
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
conflicts = settings.gui_origin_conflicts()
|
||||
if conflicts:
|
||||
# A GUI-gateway slot must never be a trusted OPNexus origin (CORS with credentials).
|
||||
raise RuntimeError(f"OPNEXUS_GUI_SITES overlaps FRONTEND_ORIGIN: {', '.join(conflicts)} — refusing to start")
|
||||
await migrate.run()
|
||||
async with SessionLocal() as db:
|
||||
await auth_service.bootstrap_admin(db)
|
||||
@@ -529,3 +535,6 @@ app.include_router(resource_samples_router.router, prefix="/api")
|
||||
app.include_router(scheduled_maintenance_router.router, prefix="/api")
|
||||
app.include_router(maintenance_windows_router.router, prefix="/api")
|
||||
app.include_router(rollouts_router.router, prefix="/api")
|
||||
app.include_router(rule_analysis_router.router, prefix="/api")
|
||||
app.include_router(gui_access_router.router, prefix="/api")
|
||||
app.include_router(gui_access_router.internal_router)
|
||||
@@ -99,3 +99,6 @@ __all__ += ["MaintenanceWindow", "MaintenanceWindowException"]
|
||||
|
||||
from app.models.rollout import RolloutPlan, RolloutPlanWave, RolloutRun, RolloutRunHost, RolloutRunWave, rollout_wave_groups
|
||||
__all__ += ["RolloutPlan", "RolloutPlanWave", "RolloutRun", "RolloutRunHost", "RolloutRunWave", "rollout_wave_groups"]
|
||||
|
||||
from app.models.gui_access import GuiAccessSettings, GuiSession, GuiTicket
|
||||
__all__ += ["GuiAccessSettings", "GuiSession", "GuiTicket"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""GUI access to managed firewalls through the separate gui-gateway container (1.27).
|
||||
|
||||
Settings are a singleton row (id fixed at 1), off by default. Tickets and gateway
|
||||
sessions only ever store hashes: the raw ticket travels once through the browser,
|
||||
the OPNexus session is referenced by the same SHA-256 that `sessions.token_hash` holds.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class GuiAccessSettings(Base):
|
||||
__tablename__ = "gui_access_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
idle_timeout_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15, server_default="15")
|
||||
absolute_timeout_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=60, server_default="60")
|
||||
slot_count: Mapped[int] = mapped_column(Integer, nullable=False, default=4, server_default="4")
|
||||
updated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class GuiTicket(Base):
|
||||
"""One-time, 30-second ticket, bound to the OPNexus session, host and slot."""
|
||||
__tablename__ = "gui_tickets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
session_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
slot: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class GuiSession(Base):
|
||||
"""A redeemed ticket: one firewall GUI open in one slot of one browser session."""
|
||||
__tablename__ = "gui_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
slot: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
client_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
end_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
@@ -18,6 +18,7 @@ from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.schemas.adopt import AdoptAllRequest, AdoptAllResult, AdoptObjectRequest, UnmanagedObject
|
||||
from app.services import object_adopt_service
|
||||
from app.services.adopt_common import LINK_ERRORS, link_api_error
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
|
||||
from app.services.policy_service import policy_lock
|
||||
@@ -44,11 +45,13 @@ def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Calla
|
||||
await policy_lock(db)
|
||||
host = await _host(db, payload.host_id)
|
||||
try:
|
||||
created = await object_adopt_service.adopt(db, kind, host, payload.uuid)
|
||||
created = await object_adopt_service.adopt(db, kind, host, payload.uuid, link_to=payload.link_to)
|
||||
except object_adopt_service.NotFoundOnHost as exc:
|
||||
raise api_error(404, ErrorCode.ADOPT_OBJECT_NOT_FOUND) from exc
|
||||
except object_adopt_service.NotAdoptable as exc:
|
||||
raise api_error(409, ErrorCode.OBJECT_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
|
||||
except LINK_ERRORS as exc:
|
||||
raise link_api_error(exc) from exc
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await db.commit()
|
||||
@@ -65,7 +68,9 @@ def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Calla
|
||||
await policy_lock(db)
|
||||
host = await _host(db, payload.host_id)
|
||||
try:
|
||||
result = await object_adopt_service.adopt_all(db, kind, host)
|
||||
result = await object_adopt_service.adopt_all(db, kind, host, link_existing=payload.link_existing)
|
||||
except LINK_ERRORS as exc:
|
||||
raise link_api_error(exc) from exc
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await db.commit()
|
||||
|
||||
@@ -15,7 +15,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
# Plain name: used on HTTP-only (lab) deployments and accepted for a while on
|
||||
# HTTPS deployments so that sessions issued before 1.27 survive the upgrade.
|
||||
COOKIE_NAME = "opnexus-session"
|
||||
# HTTPS deployments: the __Host- prefix makes the browser refuse any cookie of
|
||||
# this name that is not Secure, has a Domain attribute or a Path other than
|
||||
# "/". A page on another port of the same host (the GUI gateway slots, any
|
||||
# other service on that machine) therefore cannot toss a narrower-path cookie
|
||||
# that shadows the real session, and cannot overwrite it from script (HttpOnly).
|
||||
SECURE_COOKIE_NAME = "__Host-opnexus-session"
|
||||
|
||||
|
||||
def session_cookie_name() -> str:
|
||||
return SECURE_COOKIE_NAME if settings.cookie_secure else COOKIE_NAME
|
||||
|
||||
|
||||
def session_token(request: Request) -> str:
|
||||
"""The session token the browser sent. On HTTPS the __Host- cookie always
|
||||
wins; the plain name is only a migration fallback when the browser has no
|
||||
__Host- cookie yet (it is replaced on the next /api/auth/me)."""
|
||||
if settings.cookie_secure:
|
||||
return request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(COOKIE_NAME, "")
|
||||
return request.cookies.get(COOKIE_NAME, "")
|
||||
|
||||
|
||||
def uses_legacy_cookie(request: Request) -> bool:
|
||||
return settings.cookie_secure and not request.cookies.get(SECURE_COOKIE_NAME) and bool(request.cookies.get(COOKIE_NAME))
|
||||
|
||||
|
||||
def has_session_cookie(request: Request) -> bool:
|
||||
return bool(request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(COOKIE_NAME))
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
@@ -32,7 +61,7 @@ def client_ip(request: Request) -> str:
|
||||
|
||||
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
key=session_cookie_name(),
|
||||
value=token,
|
||||
max_age=max_age or settings.session_lifetime,
|
||||
httponly=True,
|
||||
@@ -40,10 +69,15 @@ def _set_session_cookie(response: Response, token: str, max_age: int | None = No
|
||||
secure=settings.cookie_secure,
|
||||
path="/",
|
||||
)
|
||||
if settings.cookie_secure:
|
||||
# Migration: a pre-1.27 plain-name cookie must not linger next to the new one.
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
|
||||
|
||||
|
||||
def _clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/", secure=settings.cookie_secure, httponly=True, samesite="lax")
|
||||
if settings.cookie_secure:
|
||||
response.delete_cookie(key=SECURE_COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
|
||||
|
||||
|
||||
def _check_account_budget(user: AdminUser, action: str) -> None:
|
||||
@@ -71,7 +105,7 @@ async def resolve_user(request: Request, db: AsyncSession) -> AdminUser:
|
||||
user = await db.get(AdminUser, resolved.id)
|
||||
if user is not None:
|
||||
return user
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
token = session_token(request)
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if user is None:
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.NICHT_AUTHENTIFIZIERT)
|
||||
@@ -229,7 +263,7 @@ async def totp_enable(
|
||||
user.totp_last_step = step
|
||||
user.totp_recovery_hashes = hashes
|
||||
# Other sessions were established without the second factor — end them.
|
||||
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
|
||||
await auth_service.revoke_sessions(db, user.id, keep_token=session_token(request), commit=False)
|
||||
await db.commit()
|
||||
logger.info("Two-factor enabled for '%s'", user.username)
|
||||
return {"enabled": True, "recovery_codes": plain}
|
||||
@@ -254,7 +288,7 @@ async def totp_disable(
|
||||
user.totp_enabled = False
|
||||
user.totp_recovery_hashes = None
|
||||
user.totp_last_step = 0
|
||||
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
|
||||
await auth_service.revoke_sessions(db, user.id, keep_token=session_token(request), commit=False)
|
||||
await db.commit()
|
||||
logger.info("Two-factor disabled for '%s'", user.username)
|
||||
return {"enabled": False}
|
||||
@@ -262,14 +296,14 @@ async def totp_disable(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
token = session_token(request)
|
||||
await auth_service.revoke_session(db, token)
|
||||
_clear_session_cookie(response)
|
||||
return {"authenticated": False}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
async def me(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
raw = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
||||
if raw is not None:
|
||||
resolved = await api_token_service.resolve(db, raw, client_ip(request))
|
||||
@@ -278,10 +312,14 @@ async def me(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
user, api_token = resolved
|
||||
return {"authenticated": True, "username": user.username, "role": user.role,
|
||||
"via": "token", "scope": api_token.scope, "token_name": api_token.name}
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
token = session_token(request)
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if user is None:
|
||||
return {"authenticated": False}
|
||||
if uses_legacy_cookie(request):
|
||||
# Soft migration (1.27): the same server-side session moves to the
|
||||
# __Host- cookie and the plain-name cookie is deleted — nobody is logged out.
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True, "username": user.username, "role": user.role}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Firewall GUI access through the gui-gateway container (1.27).
|
||||
|
||||
Public API (session cookie, admin only, never API tokens):
|
||||
* POST /api/hosts/{id}/gui-ticket — one-time ticket + the slot URL the browser opens (noopener).
|
||||
* GET/PUT /api/settings/gui-access — switch, idle/absolute timeout, number of slots.
|
||||
|
||||
Internal API for the gateway only (NOT under /api, so the proxy never forwards it; shared secret
|
||||
GUI_GATEWAY_TOKEN in the X-Onx-Gateway-Token header, compared in constant time):
|
||||
* POST /internal/gui/redeem, /internal/gui/revalidate, /internal/gui/end.
|
||||
The answers never contain firewall credentials: only the firewall origin, its pinned (public) CA
|
||||
certificate, the overlay proxy address and the timeouts.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
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.host import OpnsenseHost
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import SECURE_COOKIE_NAME, current_user
|
||||
from app.services import api_token_service, gui_ticket_service as svc
|
||||
|
||||
router = APIRouter(tags=["gui-access"])
|
||||
internal_router = APIRouter(prefix="/internal/gui", include_in_schema=False)
|
||||
|
||||
|
||||
def _require_admin_session(request: Request, user: AdminUser) -> None:
|
||||
if getattr(request.state, "api_token", None) is not None or api_token_service.bearer_from_header(
|
||||
request.headers.get("authorization", "")
|
||||
) is not None:
|
||||
raise api_error(status.HTTP_403_FORBIDDEN, ErrorCode.GUI_SESSION_REQUIRED)
|
||||
if user.role != "admin":
|
||||
raise api_error(status.HTTP_403_FORBIDDEN, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
class GuiTicketRead(BaseModel):
|
||||
url: str
|
||||
slot: int
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/gui-ticket", response_model=GuiTicketRead)
|
||||
async def create_ticket(host_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)):
|
||||
_require_admin_session(request, user)
|
||||
if not svc.gateway_configured():
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_GATEWAY_NOT_CONFIGURED)
|
||||
session_cookie = request.cookies.get(SECURE_COOKIE_NAME, "")
|
||||
if not settings.cookie_secure or not session_cookie:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_REQUIRES_HTTPS)
|
||||
row = await svc.get_settings(db)
|
||||
if not row.enabled:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_ACCESS_DISABLED)
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(status.HTTP_404_NOT_FOUND, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
refusal = svc.host_refusal(host)
|
||||
if refusal:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_HOST_NOT_ELIGIBLE, reason=refusal)
|
||||
try:
|
||||
return await svc.issue_ticket(db, user=user, session_token=session_cookie, host=host,
|
||||
request_host=request.headers.get("host", ""))
|
||||
except svc.GuiAccessError as exc:
|
||||
raise api_error(exc.status, exc.code, **exc.params)
|
||||
|
||||
|
||||
class GuiAccessSettingsRead(BaseModel):
|
||||
enabled: bool
|
||||
idle_timeout_minutes: int
|
||||
absolute_timeout_minutes: int
|
||||
slot_count: int
|
||||
available: bool
|
||||
unavailable_reasons: list[str]
|
||||
slots: list[int]
|
||||
updated_by: str | None
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
class GuiAccessSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
idle_timeout_minutes: int = Field(ge=1, le=240)
|
||||
absolute_timeout_minutes: int = Field(ge=5, le=720)
|
||||
slot_count: int = Field(ge=1, le=svc.MAX_SLOTS)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _idle_within_absolute(self):
|
||||
if self.idle_timeout_minutes > self.absolute_timeout_minutes:
|
||||
raise ValueError("idle_timeout_minutes must not exceed absolute_timeout_minutes")
|
||||
return self
|
||||
|
||||
|
||||
def _settings_read(row) -> dict:
|
||||
reasons = svc.unavailable_reasons()
|
||||
return {"enabled": row.enabled, "idle_timeout_minutes": row.idle_timeout_minutes,
|
||||
"absolute_timeout_minutes": row.absolute_timeout_minutes, "slot_count": row.slot_count,
|
||||
"available": not reasons, "unavailable_reasons": reasons, "slots": svc.active_slots(row),
|
||||
"updated_by": row.updated_by, "updated_at": row.updated_at}
|
||||
|
||||
|
||||
@router.get("/settings/gui-access", response_model=GuiAccessSettingsRead)
|
||||
async def read_settings(request: Request, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin_session(request, user)
|
||||
row = await svc.get_settings(db)
|
||||
await db.commit()
|
||||
return _settings_read(row)
|
||||
|
||||
|
||||
@router.put("/settings/gui-access", response_model=GuiAccessSettingsRead)
|
||||
async def update_settings(payload: GuiAccessSettingsUpdate, request: Request, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)):
|
||||
_require_admin_session(request, user)
|
||||
if payload.enabled and svc.unavailable_reasons():
|
||||
# Never switchable on without HTTPS cookies and a configured gateway.
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_GATEWAY_NOT_CONFIGURED,
|
||||
reasons=", ".join(svc.unavailable_reasons()))
|
||||
row = await svc.get_settings(db)
|
||||
row.enabled = payload.enabled
|
||||
row.idle_timeout_minutes = payload.idle_timeout_minutes
|
||||
row.absolute_timeout_minutes = payload.absolute_timeout_minutes
|
||||
row.slot_count = payload.slot_count
|
||||
row.updated_by = user.username
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if not payload.enabled:
|
||||
await svc.end_all(db, "disabled")
|
||||
await db.commit()
|
||||
return _settings_read(row)
|
||||
|
||||
|
||||
# ── internal: gateway only ────────────────────────────────────────────────
|
||||
|
||||
def _gateway_auth(x_onx_gateway_token: str = Header(default="")) -> None:
|
||||
expected = settings.gui_gateway_token
|
||||
if len(expected) < svc.MIN_GATEWAY_TOKEN_LENGTH:
|
||||
raise HTTPException(status_code=404)
|
||||
# Fixed-length digests: the comparison time does not depend on the presented value.
|
||||
if not hmac.compare_digest(hashlib.sha256(x_onx_gateway_token.encode()).digest(),
|
||||
hashlib.sha256(expected.encode()).digest()):
|
||||
raise HTTPException(status_code=401)
|
||||
|
||||
|
||||
_HEX64 = r"^[0-9a-f]{64}$"
|
||||
|
||||
|
||||
class RedeemRequest(BaseModel):
|
||||
ticket_hash: str = Field(pattern=_HEX64)
|
||||
session_hash: str = Field(pattern=_HEX64)
|
||||
slot: int = Field(ge=1, le=65535)
|
||||
client_ip: str | None = Field(default=None, max_length=45)
|
||||
|
||||
|
||||
class RevalidateRequest(BaseModel):
|
||||
gui_session_id: uuid.UUID
|
||||
session_hash: str = Field(pattern=_HEX64)
|
||||
|
||||
|
||||
class EndRequest(RevalidateRequest):
|
||||
reason: Literal["idle", "absolute", "logout", "revoked", "superseded", "shutdown"]
|
||||
|
||||
|
||||
@internal_router.post("/redeem", dependencies=[Depends(_gateway_auth)])
|
||||
async def internal_redeem(payload: RedeemRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await svc.redeem(db, ticket_hash=payload.ticket_hash, session_hash=payload.session_hash,
|
||||
slot=payload.slot, client_ip=payload.client_ip)
|
||||
if not result["ok"]:
|
||||
raise HTTPException(status_code=403, detail={"ok": False, "reason": result["reason"]})
|
||||
return result
|
||||
|
||||
|
||||
@internal_router.post("/revalidate", dependencies=[Depends(_gateway_auth)])
|
||||
async def internal_revalidate(payload: RevalidateRequest, db: AsyncSession = Depends(get_db)):
|
||||
return await svc.revalidate(db, gui_session_id=payload.gui_session_id, session_hash=payload.session_hash)
|
||||
|
||||
|
||||
@internal_router.post("/end", dependencies=[Depends(_gateway_auth)])
|
||||
async def internal_end(payload: EndRequest, db: AsyncSession = Depends(get_db)):
|
||||
return {"ok": await svc.end(db, gui_session_id=payload.gui_session_id, session_hash=payload.session_hash,
|
||||
reason=payload.reason)}
|
||||
@@ -88,6 +88,31 @@ TOOLS = [
|
||||
"path": "/api/search",
|
||||
"query": ("q",),
|
||||
},
|
||||
{
|
||||
"name": "rule_match",
|
||||
"description": "Which firewall rule decides a packet (first packet of a new connection), per firewall, "
|
||||
"with the verdict (pass/block/reject), the deciding rule and an honest confidence "
|
||||
"(sure/probable/unknown plus reason codes). Reads only; nothing is changed.",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["src", "dst"],
|
||||
"properties": {
|
||||
"src": {"type": "string", "description": "source IP address"},
|
||||
"dst": {"type": "string", "description": "destination IP address"},
|
||||
"proto": {"type": "string", "description": "tcp, udp, icmp, ipv6-icmp or a protocol number", "default": "tcp"},
|
||||
"dport": {"type": "integer", "minimum": 0, "maximum": 65535},
|
||||
"sport": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "default: any ephemeral port"},
|
||||
"icmptype": {"type": "string", "description": "e.g. echoreq or 8"},
|
||||
"iface": {"type": "string", "description": "ingress interface identifier (e.g. lan); default: guessed per firewall"},
|
||||
"egress": {"type": "string", "description": "also check the outgoing side: auto or an interface identifier"},
|
||||
"host_ids": {"type": "string", "description": "comma separated firewall ids (default: all)"},
|
||||
"group_id": {"type": "string", "description": "device group id instead of host_ids"},
|
||||
},
|
||||
},
|
||||
"path": "/api/rule-analysis/match",
|
||||
"query": ("src", "dst", "proto", "dport", "sport", "icmptype", "iface", "egress", "host_ids", "group_id"),
|
||||
# The per-rule trace is for the UI; the verdict, deciding rule and reasons are what matters here.
|
||||
"drop": ("trace",),
|
||||
},
|
||||
{
|
||||
"name": "api_get",
|
||||
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Rule analysis: which rule decides a packet (read-only, GET so ``read`` tokens and MCP can use it).
|
||||
|
||||
Nothing is stored and nothing is written to a firewall; every request reads the rules, the
|
||||
loaded pf ruleset and the interfaces (cached for 60 s per firewall, ``refresh=true`` skips
|
||||
the cache) and evaluates them with services/rule_analysis/engine.py.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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.host import OpnsenseHost
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.services.rule_analysis import service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["rule-analysis"])
|
||||
_PARALLEL = 5
|
||||
|
||||
|
||||
def _query(src: str, dst: str, proto: str, sport: str | None, dport: str | None, icmptype: str | None,
|
||||
iface: str | None, direction: str, egress: str | None):
|
||||
return service.build_query(src=src, dst=dst, proto=proto, sport=sport, dport=dport, icmptype=icmptype,
|
||||
iface=iface, direction=direction, egress=egress)
|
||||
|
||||
|
||||
async def _run(host: OpnsenseHost, q, refresh: bool) -> dict:
|
||||
head = {"host_id": str(host.id), "host_name": host.name, "cluster_name": host.cluster_name}
|
||||
try:
|
||||
return {**head, **await service.analyze_host(host, q, refresh=refresh), "error": None}
|
||||
except HTTPException as exc:
|
||||
return {**head, "error": exc.detail}
|
||||
except Exception as exc: # noqa: BLE001 - one unreachable firewall must not fail the fleet answer
|
||||
logger.info("rule analysis failed for %s: %s", host.name, exc)
|
||||
return {**head, "error": {"code": ErrorCode.ANALYSE_FIREWALL_NICHT_LESBAR.value, "params": {}}}
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/rule-analysis/match")
|
||||
async def host_match(
|
||||
host_id: uuid.UUID, src: str, dst: str, proto: str = "tcp", sport: str | None = None, dport: str | None = None,
|
||||
icmptype: str | None = None, iface: str | None = None, direction: str = "in", egress: str | None = None,
|
||||
refresh: bool = False, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
q = _query(src, dst, proto, sport, dport, icmptype, iface, direction, egress)
|
||||
result = await _run(host, q, refresh)
|
||||
error = result.get("error")
|
||||
if error:
|
||||
status = 422 if error.get("code") == ErrorCode.ANALYSE_INTERFACE_UNBEKANNT.value else 502
|
||||
raise HTTPException(status_code=status, detail=error)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/rule-analysis/match")
|
||||
async def fleet_match(
|
||||
src: str, dst: str, proto: str = "tcp", sport: str | None = None, dport: str | None = None,
|
||||
icmptype: str | None = None, iface: str | None = None, direction: str = "in", egress: str | None = None,
|
||||
host_ids: str | None = Query(None, description="comma separated host ids; default: all enabled hosts"),
|
||||
group_id: uuid.UUID | None = None, refresh: bool = False, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = _query(src, dst, proto, sport, dport, icmptype, iface, direction, egress)
|
||||
if group_id is not None:
|
||||
group = await db.scalar(select(DeviceGroup).options(selectinload(DeviceGroup.hosts)).where(DeviceGroup.id == group_id))
|
||||
if group is None:
|
||||
raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN)
|
||||
hosts = sorted(group.hosts, key=lambda h: h.name)
|
||||
elif host_ids:
|
||||
try:
|
||||
ids = [uuid.UUID(part.strip()) for part in host_ids.split(",") if part.strip()]
|
||||
except ValueError:
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="host_ids", value=host_ids[:64]) from None
|
||||
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(ids)).order_by(OpnsenseHost.name))).all())
|
||||
if len(hosts) != len(set(ids)):
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
else:
|
||||
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
|
||||
gate = asyncio.Semaphore(_PARALLEL)
|
||||
|
||||
async def one(host):
|
||||
async with gate:
|
||||
return await _run(host, q, refresh)
|
||||
|
||||
return list(await asyncio.gather(*(one(h) for h in hosts)))
|
||||
@@ -18,6 +18,7 @@ from app.services.interface_service import check_interface_consistency
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
|
||||
from app.services import rule_adopt_service
|
||||
from app.services.adopt_common import LINK_ERRORS, link_api_error
|
||||
from app.schemas.rule import AdoptRequest, UnmanagedRule
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
@@ -107,11 +108,13 @@ async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid)
|
||||
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid, link_to=payload.link_to)
|
||||
except rule_adopt_service.RuleNotFoundOnHost as exc:
|
||||
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
|
||||
except rule_adopt_service.NotAdoptable as exc:
|
||||
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
|
||||
except LINK_ERRORS as exc:
|
||||
raise link_api_error(exc) from exc
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await db.commit()
|
||||
|
||||
@@ -5,11 +5,31 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class AdoptObjectRequest(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
# Link to this existing central object instead of creating a new one (services/adopt_link_service.py).
|
||||
# Declared before `uuid`: that field's name shadows the module inside the class body.
|
||||
link_to: uuid.UUID | None = None
|
||||
uuid: str = Field(min_length=1, max_length=64) # the object's UUID on the firewall
|
||||
|
||||
|
||||
class AdoptAllRequest(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
# Link entries that have exactly one exact central twin instead of creating a duplicate.
|
||||
link_existing: bool = False
|
||||
|
||||
|
||||
class LinkDiff(BaseModel):
|
||||
field: str # model field ("content", "destination_port", ...) or "variables" (a {{VARIABLE}} has no value on this host)
|
||||
central: str
|
||||
live: str
|
||||
|
||||
|
||||
class LinkTarget(BaseModel):
|
||||
"""An existing central object that a live object could be linked to (exact match) or is similar to."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
targets: list[str] # names of the hosts it is deployed to
|
||||
diff: list[LinkDiff] = Field(default_factory=list) # empty for an exact match
|
||||
|
||||
|
||||
class UnmanagedObject(BaseModel):
|
||||
@@ -28,12 +48,20 @@ class UnmanagedObject(BaseModel):
|
||||
kept: list[str] = Field(default_factory=list)
|
||||
# Facts for display, depend on the object type.
|
||||
info: dict[str, str] = Field(default_factory=dict)
|
||||
# Central objects whose content, resolved for this host, equals the live object exactly (first = preferred).
|
||||
matches: list[LinkTarget] = Field(default_factory=list)
|
||||
# Objects with the same name/description but different content: a hint with the differing fields, never linked.
|
||||
similar: list[LinkTarget] = Field(default_factory=list)
|
||||
linkable: bool = False # can be linked to `matches[0]` (or another match) on its own
|
||||
# What "adopt all" would do: "link" | "new" | "skip" (None only on the adopt-all result).
|
||||
bulk_action: str | None = None
|
||||
|
||||
|
||||
class AdoptedObject(BaseModel):
|
||||
uuid: str
|
||||
title: str
|
||||
id: uuid.UUID
|
||||
linked: bool = False # linked to an existing central object instead of newly created
|
||||
|
||||
|
||||
class SkippedObject(BaseModel):
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.schemas.adopt import LinkTarget
|
||||
|
||||
|
||||
class RuleCreate(BaseModel):
|
||||
description: str = Field(min_length=1, max_length=255)
|
||||
@@ -78,6 +80,8 @@ class DeployApproval(BaseModel):
|
||||
|
||||
class AdoptRequest(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
# Link to this existing central rule instead of creating a new one (services/adopt_link_service.py).
|
||||
link_to: uuid.UUID | None = None
|
||||
rule_uuid: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
@@ -98,3 +102,7 @@ class UnmanagedRule(BaseModel):
|
||||
adoptable: bool
|
||||
# Codes such as "negated_source" or "not_quick"; the UI turns them into text.
|
||||
reasons: list[str]
|
||||
# Central rules equal to this one (resolved for the host), same-description rules that differ, link possible.
|
||||
matches: list[LinkTarget] = Field(default_factory=list)
|
||||
similar: list[LinkTarget] = Field(default_factory=list)
|
||||
linkable: bool = False
|
||||
@@ -7,9 +7,19 @@ how the proxy setup works for IP *and* name access) nor an explicitly allowed
|
||||
origin. Requests without either header (curl, scripts) are not browsers, so
|
||||
they cannot be riding on a victim's cookie and pass through to normal auth.
|
||||
|
||||
Sec-Fetch-Site (1.27): a page on another port of the same host (e.g. a
|
||||
firewall GUI behind the GUI gateway) is *same-site* to OPNexus, so the
|
||||
browser attaches the session cookie to its requests and SameSite=Lax does
|
||||
not help. Cookie-carrying /api requests that the browser itself marks as
|
||||
same-site or cross-site are therefore refused — unless the Origin is an
|
||||
explicitly configured frontend origin (development setup with the frontend
|
||||
on its own port). Clients that send no Sec-Fetch-Site header (curl,
|
||||
scripts, API tokens) are not browsers and stay unaffected.
|
||||
|
||||
SecurityHeadersMiddleware: API responses must never be cached by browsers or
|
||||
proxies and must not be sniffed or framed.
|
||||
"""
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -18,6 +28,7 @@ from starlette.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import ErrorCode
|
||||
from app.routers.auth import has_session_cookie
|
||||
|
||||
_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
@@ -35,8 +46,41 @@ def _origin_allowed(origin: str, host_header: str) -> bool:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".lower() in {o.lower() for o in settings.allowed_origins}
|
||||
|
||||
|
||||
# The SSO provider sends the browser back here by a cross-site navigation;
|
||||
# the endpoint authenticates by its own state cookie and creates the session.
|
||||
_SEC_FETCH_EXEMPT = {"/api/auth/oidc/callback"}
|
||||
|
||||
|
||||
def _explicit_frontend_origin(origin: str) -> bool:
|
||||
origin = origin.rstrip("/").lower()
|
||||
if not origin or origin == "null":
|
||||
return False
|
||||
if settings.allowed_origins:
|
||||
return origin in {o.lower() for o in settings.allowed_origins}
|
||||
return re.fullmatch(settings.cors_origin_regex, origin) is not None
|
||||
|
||||
|
||||
def sec_fetch_blocked(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
if not (path == "/api" or path.startswith("/api/")) or path in _SEC_FETCH_EXEMPT:
|
||||
return False
|
||||
if not has_session_cookie(request):
|
||||
return False
|
||||
site = request.headers.get("sec-fetch-site", "").strip().lower()
|
||||
if site == "cross-site":
|
||||
return True
|
||||
if site == "same-site":
|
||||
return not _explicit_frontend_origin(request.headers.get("origin", ""))
|
||||
return False # same-origin, none (typed URL/bookmark) or no header (not a browser)
|
||||
|
||||
|
||||
class OriginCheckMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if sec_fetch_blocked(request):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": {"code": ErrorCode.CROSS_SITE_REQUEST_BLOCKED.value, "params": {}}},
|
||||
)
|
||||
if request.method not in _SAFE_METHODS:
|
||||
source = request.headers.get("origin") or request.headers.get("referer")
|
||||
if source and source != "null" and not _origin_allowed(source, request.headers.get("host", "")):
|
||||
|
||||
@@ -25,3 +25,39 @@ def on(value) -> bool:
|
||||
|
||||
def clean(value) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
class LinkTargetNotFound(Exception):
|
||||
"""The central object chosen to link to was deleted in the meantime."""
|
||||
|
||||
|
||||
class LinkTargetChanged(Exception):
|
||||
"""The central object no longer equals what is live on the host. `diff` lists the differing fields."""
|
||||
|
||||
def __init__(self, diff: list[dict]):
|
||||
super().__init__(", ".join(d["field"] for d in diff))
|
||||
self.diff = diff
|
||||
|
||||
|
||||
class HostAlreadyHasObject(Exception):
|
||||
"""The central object is already deployed to / managed on this host under another UUID."""
|
||||
|
||||
|
||||
class LinkTargetBusy(Exception):
|
||||
"""The central object has an unconfirmed deployment or a failed rollback; linking waits until it is settled."""
|
||||
|
||||
|
||||
LINK_ERRORS = (LinkTargetNotFound, LinkTargetChanged, HostAlreadyHasObject, LinkTargetBusy)
|
||||
|
||||
|
||||
def link_api_error(exc: Exception):
|
||||
"""The HTTP error for a failed link (shared by the rule and the object endpoints)."""
|
||||
from app.errors import ErrorCode, api_error
|
||||
|
||||
if isinstance(exc, LinkTargetNotFound):
|
||||
return api_error(404, ErrorCode.ADOPT_LINK_TARGET_NOT_FOUND)
|
||||
if isinstance(exc, LinkTargetChanged):
|
||||
return api_error(409, ErrorCode.ADOPT_LINK_TARGET_CHANGED, fields=", ".join(d["field"] for d in exc.diff))
|
||||
if isinstance(exc, HostAlreadyHasObject):
|
||||
return api_error(409, ErrorCode.ADOPT_LINK_HOST_HAS_OBJECT)
|
||||
return api_error(409, ErrorCode.ADOPT_LINK_BUSY)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Links a hand-made object that was found on a firewall to a central object that already exists, instead
|
||||
of creating a second central object for it ("Mit vorhandenem zentralem Objekt verknüpfen").
|
||||
|
||||
Typical case: the same rule/alias/NAT entry was made by hand on several firewalls (or on both nodes of an HA
|
||||
pair). The first adoption creates the central object; every further firewall is LINKED to it: the host
|
||||
becomes another target of that object and gets a deployment row holding the live UUID (remote_uuid) and
|
||||
the payload OPNexus would send. Like adopting, linking writes NOTHING to the firewall. The live state is
|
||||
read fresh from the host at link time (the browser only names the central object), so the preview right
|
||||
afterwards says "unchanged" and a later rollout updates the one object on every host without a duplicate.
|
||||
|
||||
A central object is an EXACT match when every field OPNexus manages is equal after the object's
|
||||
{{VARIABLE}} placeholders are replaced with this host's values (variables_service.resolve). An object with
|
||||
the same identity (alias name / rule description) but different content is only shown as a hint with the
|
||||
differing fields and is NEVER linked automatically.
|
||||
|
||||
Callers hold policy_lock (same serialisation as adopting), which also makes a repeated link idempotent.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.services import variables_service
|
||||
from app.services.adopt_common import HostAlreadyHasObject, LinkTargetBusy, LinkTargetChanged, LinkTargetNotFound
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
LINK_MESSAGE = "Mit vorhandenem Objekt verknüpft"
|
||||
BUSY_STATUSES = ("pending", "revert_failed") # the central object waits for a confirmation / failed to roll back
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinkSpec:
|
||||
model: type
|
||||
deployment: type
|
||||
fk: str # deployment column pointing at the central object
|
||||
uuid_attr: str # deployment column holding the OPNsense UUID (remote_uuid)
|
||||
identity: str # attribute naming the object: "name" (alias) or "description"
|
||||
payload: Callable[[Any, OpnsenseHost], dict]
|
||||
revision: Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class Match:
|
||||
obj: Any
|
||||
diff: list[dict] # empty = exact match
|
||||
busy: bool = False # the central object waits for a confirmation: linking is refused until that is settled
|
||||
|
||||
|
||||
def _norm(value) -> Any:
|
||||
if value is None:
|
||||
return ""
|
||||
return value if isinstance(value, bool) else str(value)
|
||||
|
||||
|
||||
def _show(value) -> str:
|
||||
return ("true" if value else "false") if isinstance(value, bool) else ("" if value is None else str(value))
|
||||
|
||||
|
||||
def diff_fields(obj, values: dict, host: OpnsenseHost) -> list[dict]:
|
||||
"""Fields in which the central object (resolved for `host`) differs from the live `values`."""
|
||||
raw = {key: getattr(obj, key) for key in values}
|
||||
try:
|
||||
central = variables_service.resolve(raw, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
# Cannot be compared (and could not be deployed to this host either): report, never match.
|
||||
return [{"field": "variables", "central": ", ".join(exc.missing[host.name]), "live": ""}]
|
||||
return [{"field": key, "central": _show(central[key]), "live": _show(values[key])}
|
||||
for key in values if _norm(central[key]) != _norm(values[key])]
|
||||
|
||||
|
||||
def _options(spec: LinkSpec):
|
||||
return [
|
||||
selectinload(spec.model.targets),
|
||||
selectinload(spec.model.groups).selectinload(DeviceGroup.hosts),
|
||||
selectinload(spec.model.deployments),
|
||||
]
|
||||
|
||||
|
||||
async def load_central(db: AsyncSession, spec: LinkSpec) -> list:
|
||||
objs = list((await db.scalars(select(spec.model).options(*_options(spec)))).unique().all())
|
||||
await hydrate_hierarchy_targets(db, objs)
|
||||
return objs
|
||||
|
||||
|
||||
def find(objs: list, spec: LinkSpec, host: OpnsenseHost, values: dict) -> tuple[list[Match], list[Match]]:
|
||||
"""(exact matches, same-identity objects that differ). Objects that already have a deployment on
|
||||
this host are not candidates: linking would collide with that deployment."""
|
||||
exact: list[Match] = []
|
||||
similar: list[Match] = []
|
||||
for obj in objs:
|
||||
if any(d.host_id == host.id for d in obj.deployments):
|
||||
continue
|
||||
if _norm(getattr(obj, spec.identity)) != _norm(values[spec.identity]):
|
||||
continue
|
||||
d = diff_fields(obj, values, host)
|
||||
busy = any(dep.status in BUSY_STATUSES for dep in obj.deployments)
|
||||
(similar if d else exact).append(Match(obj, d, busy))
|
||||
# prefer the object that already reaches the most hosts (the "real" one), then a stable order
|
||||
exact.sort(key=lambda m: (-len(effective_hosts(m.obj)), str(m.obj.id)))
|
||||
return exact, similar
|
||||
|
||||
|
||||
def describe(spec: LinkSpec, match: Match) -> dict:
|
||||
return {
|
||||
"id": match.obj.id,
|
||||
"name": getattr(match.obj, spec.identity),
|
||||
"targets": [h.name for h in effective_hosts(match.obj)],
|
||||
"diff": match.diff,
|
||||
}
|
||||
|
||||
|
||||
async def link(db: AsyncSession, spec: LinkSpec, host: OpnsenseHost, object_id, remote_uuid: str, values: dict):
|
||||
"""Links the live object `remote_uuid` (whose normalised fields are `values`, read fresh by the caller)
|
||||
to the central object `object_id`. Returns it; a repeated call for the same pair changes nothing."""
|
||||
obj = (await db.execute(
|
||||
select(spec.model).where(spec.model.id == object_id).options(*_options(spec))
|
||||
.execution_options(populate_existing=True)
|
||||
)).unique().scalar_one_or_none()
|
||||
if obj is None:
|
||||
raise LinkTargetNotFound(str(object_id))
|
||||
await hydrate_hierarchy_targets(db, [obj])
|
||||
|
||||
existing = next((d for d in obj.deployments if d.host_id == host.id), None)
|
||||
if existing is not None:
|
||||
if getattr(existing, spec.uuid_attr) == remote_uuid:
|
||||
return obj # already linked: nothing to do
|
||||
raise HostAlreadyHasObject(str(object_id))
|
||||
if any(d.status in BUSY_STATUSES for d in obj.deployments):
|
||||
raise LinkTargetBusy(str(object_id))
|
||||
changed = diff_fields(obj, values, host)
|
||||
if changed:
|
||||
raise LinkTargetChanged(changed)
|
||||
|
||||
if host.id not in {h.id for h in effective_hosts(obj)}:
|
||||
obj.targets.append(host)
|
||||
# an association-only change does not touch the row; stale edit forms must still be refused (see edit_rule)
|
||||
obj.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
spec.revision(db, obj, f"Mit {host.name} verknüpft")
|
||||
db.add(spec.deployment(**{
|
||||
spec.fk: obj.id, "host_id": host.id, "status": "success", "message": LINK_MESSAGE,
|
||||
spec.uuid_attr: remote_uuid, "last_payload": spec.payload(obj, host), "previous_payload": None,
|
||||
}))
|
||||
await db.flush()
|
||||
return obj
|
||||
@@ -38,7 +38,8 @@ MAX_TOKENS_PER_USER = 50
|
||||
_LAST_USED_GRANULARITY = timedelta(minutes=1)
|
||||
|
||||
_SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
|
||||
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
|
||||
# GUI tickets open a firewall's web interface in the browser: an interactive admin session only.
|
||||
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)|^/api/hosts/[^/]+/gui-ticket$")
|
||||
# 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/|^/api/overlay/peers/[^/]+/instructions$")
|
||||
@@ -48,6 +49,7 @@ _ADMIN_ONLY_WRITES = re.compile(
|
||||
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
|
||||
r"|^/api/logs/settings$"
|
||||
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
||||
r"|^/api/hosts/[^/]+/gui-ticket$"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Tickets and gateway sessions for the firewall GUI access (1.27, gui-gateway container).
|
||||
|
||||
Flow (see docs in routers/gui_access.py):
|
||||
* An admin asks for a ticket (POST /api/hosts/{id}/gui-ticket). 32 random bytes, valid 30 s, single use;
|
||||
only its SHA-256 is stored, bound to the OPNexus session (same hash as sessions.token_hash), the user,
|
||||
the host and a slot (gateway port = its own browser origin).
|
||||
* The gateway redeems it through /internal/gui/redeem and gets what it needs to reach the firewall —
|
||||
base URL, pinned CA, overlay proxy, timeouts. Never the API key or secret.
|
||||
* Every 20 s the gateway re-checks the gateway session (/internal/gui/revalidate): OPNexus session still
|
||||
valid, user still admin, switch still on, host still eligible, absolute limit not reached.
|
||||
|
||||
A slot stays assigned to one firewall for as long as that browser session has the firewall open; another
|
||||
firewall gets another slot (another origin), so two firewall GUIs never share an origin at the same time.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import ErrorCode
|
||||
from app.models.gui_access import GuiAccessSettings, GuiSession, GuiTicket
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.user import AdminUser, Session
|
||||
from app.services import audit_service, firewall_url, overlay_service
|
||||
|
||||
TICKET_TTL = timedelta(seconds=30)
|
||||
# 20 s: a switch-off, logout or role change must end a gateway session within 30 s of the change, and
|
||||
# the last re-check can lie up to one interval before it (live test 2026-10-21: 30 s gave 31 s).
|
||||
REVALIDATE_SECONDS = 20
|
||||
MAX_SLOTS = 4
|
||||
MIN_GATEWAY_TOKEN_LENGTH = 32
|
||||
AUDIT_KIND = "gui_access"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuiAccessError(Exception):
|
||||
status: int
|
||||
code: ErrorCode
|
||||
params: dict
|
||||
|
||||
|
||||
def hash_value(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ── configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
def slot_sites() -> dict[int, list[str]]:
|
||||
"""Slot port -> configured https origins with that port (OPNEXUS_GUI_SITES)."""
|
||||
sites: dict[int, list[str]] = {}
|
||||
for origin in settings.gui_site_origins:
|
||||
try:
|
||||
parts = urlsplit(origin)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
continue
|
||||
if parts.scheme != "https" or not parts.hostname or not port or parts.path not in ("", "/"):
|
||||
continue
|
||||
sites.setdefault(port, []).append(f"https://{parts.netloc}")
|
||||
return sites
|
||||
|
||||
|
||||
def gateway_configured() -> bool:
|
||||
return len(settings.gui_gateway_token) >= MIN_GATEWAY_TOKEN_LENGTH and bool(slot_sites())
|
||||
|
||||
|
||||
def unavailable_reasons() -> list[str]:
|
||||
reasons = []
|
||||
if not settings.cookie_secure:
|
||||
reasons.append("cookie_secure")
|
||||
if len(settings.gui_gateway_token) < MIN_GATEWAY_TOKEN_LENGTH:
|
||||
reasons.append("gateway_token")
|
||||
if not slot_sites():
|
||||
reasons.append("gui_sites")
|
||||
return reasons
|
||||
|
||||
|
||||
def active_slots(row: GuiAccessSettings) -> list[int]:
|
||||
return sorted(slot_sites())[: max(1, min(row.slot_count, MAX_SLOTS))]
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> GuiAccessSettings:
|
||||
row = await db.get(GuiAccessSettings, 1)
|
||||
if row is None:
|
||||
row = GuiAccessSettings(id=1, enabled=False, idle_timeout_minutes=15, absolute_timeout_minutes=60, slot_count=MAX_SLOTS)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def host_refusal(host: OpnsenseHost) -> str | None:
|
||||
"""Why this firewall must not be opened through the gateway (None = eligible)."""
|
||||
if not host.enabled:
|
||||
return "disabled"
|
||||
if urlsplit(host.base_url.strip()).scheme != "https" or firewall_url.check_base_url(host.base_url):
|
||||
return "https"
|
||||
if not host.verify_tls:
|
||||
return "tls"
|
||||
return None
|
||||
|
||||
|
||||
def firewall_origin(base_url: str) -> str:
|
||||
parts = urlsplit(base_url.strip())
|
||||
return f"https://{parts.netloc.rsplit('@', 1)[-1]}"
|
||||
|
||||
|
||||
# ── audit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _audit(actor: str, *, action: str, status: str, host: OpnsenseHost | None = None,
|
||||
host_id=None, entity_id=None, message: str | None = None, details: dict | None = None) -> None:
|
||||
token = audit_service.context.set({"actor": actor, "id": str(uuid.uuid4()), "action": action})
|
||||
try:
|
||||
await audit_service.persist(
|
||||
kind=AUDIT_KIND, action=action, status=status, entity_id=str(entity_id) if entity_id else None,
|
||||
host_id=str(host.id) if host is not None else (str(host_id) if host_id else None),
|
||||
host_name=host.name if host is not None else None, message=message, details=details or {},
|
||||
)
|
||||
finally:
|
||||
audit_service.context.reset(token)
|
||||
|
||||
|
||||
# ── tickets ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _redeem_origin(slot: int, request_host: str) -> str:
|
||||
candidates = slot_sites().get(slot, [])
|
||||
wanted = (urlsplit(f"//{request_host}").hostname or "").lower()
|
||||
for origin in candidates:
|
||||
if (urlsplit(origin).hostname or "").lower() == wanted:
|
||||
return origin
|
||||
return candidates[0]
|
||||
|
||||
|
||||
async def _pick_slot(db: AsyncSession, row: GuiAccessSettings, session_hash: str, user_id, host_id) -> int:
|
||||
now = _now()
|
||||
slots = active_slots(row)
|
||||
absolute = timedelta(minutes=row.absolute_timeout_minutes)
|
||||
in_use: dict[int, uuid.UUID] = {}
|
||||
open_sessions = await db.scalars(select(GuiSession).where(
|
||||
GuiSession.session_hash == session_hash, GuiSession.ended_at.is_(None), GuiSession.started_at > now - absolute))
|
||||
for gs in open_sessions:
|
||||
in_use[gs.slot] = gs.host_id
|
||||
pending = await db.scalars(select(GuiTicket).where(
|
||||
GuiTicket.session_hash == session_hash, GuiTicket.redeemed_at.is_(None), GuiTicket.expires_at > now))
|
||||
for ticket in pending:
|
||||
in_use.setdefault(ticket.slot, ticket.host_id)
|
||||
for slot in slots:
|
||||
if in_use.get(slot) == host_id:
|
||||
return slot # this firewall is already open in this browser session: same origin again
|
||||
free = [s for s in slots if s not in in_use]
|
||||
if not free:
|
||||
raise GuiAccessError(409, ErrorCode.GUI_NO_FREE_SLOT, {"slots": str(len(slots))})
|
||||
# Prefer the slot this user had this firewall on before, then the slot the user used least recently
|
||||
# (an old tab of another firewall on a reused origin is the documented residual risk).
|
||||
history = (await db.execute(
|
||||
select(GuiSession.slot, GuiSession.host_id, func.max(GuiSession.started_at))
|
||||
.where(GuiSession.user_id == user_id).group_by(GuiSession.slot, GuiSession.host_id)
|
||||
)).all()
|
||||
for slot, h_id, _ in sorted(history, key=lambda r: r[2], reverse=True):
|
||||
if h_id == host_id and slot in free:
|
||||
return slot
|
||||
last_used = {}
|
||||
for slot, _, started in history:
|
||||
last_used[slot] = max(last_used.get(slot, started), started)
|
||||
epoch = datetime.min.replace(tzinfo=timezone.utc)
|
||||
return min(free, key=lambda s: (last_used.get(s, epoch), s))
|
||||
|
||||
|
||||
async def issue_ticket(db: AsyncSession, *, user: AdminUser, session_token: str, host: OpnsenseHost,
|
||||
request_host: str) -> dict:
|
||||
row = await get_settings(db)
|
||||
now = _now()
|
||||
await db.execute(delete(GuiTicket).where(GuiTicket.expires_at < now - timedelta(hours=1)))
|
||||
session_hash = hash_value(session_token)
|
||||
slot = await _pick_slot(db, row, session_hash, user.id, host.id)
|
||||
raw = secrets.token_urlsafe(32)
|
||||
db.add(GuiTicket(ticket_hash=hash_value(raw), session_hash=session_hash, user_id=user.id, host_id=host.id,
|
||||
slot=slot, created_at=now, expires_at=now + TICKET_TTL))
|
||||
await db.commit()
|
||||
await _audit(user.username, action="ticket", status="accepted", host=host, details={"slot": slot})
|
||||
return {"url": f"{_redeem_origin(slot, request_host)}/__onx/redeem?t={raw}", "slot": slot,
|
||||
"expires_in": int(TICKET_TTL.total_seconds())}
|
||||
|
||||
|
||||
# ── gateway side (internal endpoints) ─────────────────────────────────────
|
||||
|
||||
async def _live_check(db: AsyncSession, row: GuiAccessSettings, session_hash: str, user_id, host_id,
|
||||
slot: int) -> tuple[str | None, AdminUser | None, OpnsenseHost | None]:
|
||||
session = await db.scalar(select(Session).where(Session.token_hash == session_hash))
|
||||
if session is None or session.expires_at <= _now() or session.user_id != user_id:
|
||||
return "session", None, None
|
||||
user = await db.get(AdminUser, user_id)
|
||||
if user is None or user.role != "admin":
|
||||
return "role", user, None
|
||||
if not row.enabled:
|
||||
return "disabled", user, None
|
||||
if not settings.cookie_secure or not gateway_configured():
|
||||
return "config", user, None
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None or host_refusal(host):
|
||||
return "host", user, host
|
||||
if slot not in active_slots(row):
|
||||
return "slot", user, host
|
||||
return None, user, host
|
||||
|
||||
|
||||
def _timing(row: GuiAccessSettings, started_at: datetime) -> dict:
|
||||
deadline = started_at + timedelta(minutes=row.absolute_timeout_minutes)
|
||||
return {"idle_timeout_seconds": row.idle_timeout_minutes * 60,
|
||||
"absolute_deadline": int(deadline.timestamp()),
|
||||
"revalidate_seconds": REVALIDATE_SECONDS}
|
||||
|
||||
|
||||
async def redeem(db: AsyncSession, *, ticket_hash: str, session_hash: str, slot: int, client_ip: str | None) -> dict:
|
||||
"""{'ok': True, ...upstream data...} or {'ok': False, 'reason': ...}. Any attempt burns the ticket."""
|
||||
now = _now()
|
||||
ticket = await db.scalar(select(GuiTicket).where(GuiTicket.ticket_hash == ticket_hash).with_for_update())
|
||||
if ticket is None:
|
||||
await db.rollback()
|
||||
return {"ok": False, "reason": "unknown"}
|
||||
replayed = ticket.redeemed_at is not None
|
||||
ticket.redeemed_at = ticket.redeemed_at or now
|
||||
row = await get_settings(db)
|
||||
reason = None
|
||||
if replayed:
|
||||
reason = "replayed"
|
||||
elif ticket.expires_at <= now:
|
||||
reason = "expired"
|
||||
elif not secrets.compare_digest(ticket.session_hash, session_hash):
|
||||
reason = "session"
|
||||
elif ticket.slot != slot:
|
||||
reason = "slot"
|
||||
user = host = None
|
||||
if reason is None:
|
||||
reason, user, host = await _live_check(db, row, ticket.session_hash, ticket.user_id, ticket.host_id, slot)
|
||||
if user is None:
|
||||
user = await db.get(AdminUser, ticket.user_id)
|
||||
if host is None:
|
||||
host = await db.get(OpnsenseHost, ticket.host_id)
|
||||
actor = user.username if user is not None else "unknown"
|
||||
if reason is not None:
|
||||
await db.commit()
|
||||
await _audit(actor, action="redeem", status="denied", host=host, host_id=ticket.host_id,
|
||||
message=reason, details={"slot": slot, "client_ip": client_ip, "reason": reason})
|
||||
return {"ok": False, "reason": reason}
|
||||
# The same browser session reopening this slot replaces the earlier gateway session there.
|
||||
superseded = (await db.scalars(select(GuiSession).where(
|
||||
GuiSession.session_hash == session_hash, GuiSession.slot == slot, GuiSession.ended_at.is_(None)))).all()
|
||||
for old in superseded:
|
||||
old.ended_at, old.end_reason = now, "superseded"
|
||||
gs = GuiSession(session_hash=session_hash, user_id=user.id, host_id=host.id, slot=slot,
|
||||
client_ip=(client_ip or None) and client_ip[:45], started_at=now, last_checked_at=now)
|
||||
db.add(gs)
|
||||
await db.commit()
|
||||
await _audit(actor, action="redeem", status="accepted", host=host, entity_id=gs.id,
|
||||
details={"slot": slot, "client_ip": client_ip})
|
||||
return {
|
||||
"ok": True, "gui_session_id": str(gs.id), "host_id": str(host.id), "host_name": host.name,
|
||||
"upstream_origin": firewall_origin(host.base_url),
|
||||
"ca_pem": host.tls_ca_pem or None, # public certificate (pin); None = system trust store
|
||||
"proxy_url": overlay_service.proxy_for(host.base_url),
|
||||
**_timing(row, now),
|
||||
}
|
||||
|
||||
|
||||
async def _finish(db: AsyncSession, gs: GuiSession, reason: str) -> None:
|
||||
now = _now()
|
||||
if gs.ended_at is None:
|
||||
gs.ended_at, gs.end_reason = now, reason[:32]
|
||||
await db.commit()
|
||||
user = await db.get(AdminUser, gs.user_id)
|
||||
host = await db.get(OpnsenseHost, gs.host_id)
|
||||
await _audit(user.username if user else "unknown", action="end", status="accepted", host=host,
|
||||
host_id=gs.host_id, entity_id=gs.id, message=reason,
|
||||
details={"slot": gs.slot, "client_ip": gs.client_ip, "reason": reason,
|
||||
"duration_seconds": int((gs.ended_at - gs.started_at).total_seconds())})
|
||||
|
||||
|
||||
async def revalidate(db: AsyncSession, *, gui_session_id: uuid.UUID, session_hash: str) -> dict:
|
||||
gs = await db.get(GuiSession, gui_session_id)
|
||||
if gs is None or not secrets.compare_digest(gs.session_hash, session_hash):
|
||||
return {"ok": False, "reason": "unknown"}
|
||||
if gs.ended_at is not None:
|
||||
return {"ok": False, "reason": gs.end_reason or "ended"}
|
||||
row = await get_settings(db)
|
||||
if _now() >= gs.started_at + timedelta(minutes=row.absolute_timeout_minutes):
|
||||
await _finish(db, gs, "absolute")
|
||||
return {"ok": False, "reason": "absolute"}
|
||||
reason, _, _ = await _live_check(db, row, gs.session_hash, gs.user_id, gs.host_id, gs.slot)
|
||||
if reason is not None:
|
||||
await _finish(db, gs, reason)
|
||||
return {"ok": False, "reason": reason}
|
||||
gs.last_checked_at = _now()
|
||||
await db.commit()
|
||||
return {"ok": True, **_timing(row, gs.started_at)}
|
||||
|
||||
|
||||
async def end(db: AsyncSession, *, gui_session_id: uuid.UUID, session_hash: str, reason: str) -> bool:
|
||||
gs = await db.get(GuiSession, gui_session_id)
|
||||
if gs is None or not secrets.compare_digest(gs.session_hash, session_hash):
|
||||
return False
|
||||
if gs.ended_at is None:
|
||||
await _finish(db, gs, reason)
|
||||
return True
|
||||
|
||||
|
||||
async def end_all(db: AsyncSession, reason: str) -> int:
|
||||
"""Switch turned off: mark every open gateway session ended (the gateway drops them on its next check)."""
|
||||
result = await db.execute(update(GuiSession).where(GuiSession.ended_at.is_(None))
|
||||
.values(ended_at=_now(), end_reason=reason[:32]))
|
||||
return result.rowcount or 0
|
||||
@@ -10,6 +10,10 @@ the deployment's last_payload. So the preview directly afterwards says "unchange
|
||||
updates the same object via setItem/setRule (no duplicate), and the normal preview → push →
|
||||
confirm/auto-revert flow applies from the first change on.
|
||||
|
||||
An object that already exists centrally for another firewall is LINKED instead of adopted a second time
|
||||
(adopt_link_service.py: exact match after resolving the host's variables, never automatic for a mere
|
||||
resemblance).
|
||||
|
||||
Only objects OPNexus can describe without distortion are adopted. Anything the model cannot express
|
||||
is refused with machine-readable reasons (the UI translates them) instead of being imported in a way
|
||||
that would silently change its meaning on the next edit. Fields OPNexus does not manage (log flag of a
|
||||
@@ -72,6 +76,7 @@ from app.services import alias_deploy_service, alias_policy_service
|
||||
from app.services import one_to_one_deploy_service, one_to_one_policy_service
|
||||
from app.services import opnsense_client, outbound_nat_deploy_service, outbound_nat_policy_service
|
||||
from app.services import port_forward_deploy_service, port_forward_policy_service
|
||||
from app.services import adopt_link_service
|
||||
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on
|
||||
from app.services.nat_live_service import split_rows
|
||||
from app.services.outbound_nat_mode import host_mode
|
||||
@@ -85,6 +90,7 @@ ALIAS_CONTENT_MAX = 8000 # schemas/alias.py AliasCreate.content
|
||||
# OPNsense's own aliases that are not user objects (Firewall/Alias.php: bogons & co. are `external`
|
||||
# rows created by the system, __<if>_network are `internal`).
|
||||
SYSTEM_ALIAS_NAMES = frozenset({"bogons", "bogonsv6", "sshlockout", "virusprot"})
|
||||
LINK_TOLERATED = frozenset({"name_exists", "references_unmanaged_alias"})
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -93,6 +99,19 @@ class Entry:
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
details: dict[str, str] = field(default_factory=dict)
|
||||
bulk: bool = False # not adoptable alone yet, but adoptable in the same run (its references come first)
|
||||
matches: list = field(default_factory=list) # central objects equal to the live object (link candidates)
|
||||
similar: list = field(default_factory=list) # same name/description but different content (hint only)
|
||||
|
||||
@property
|
||||
def linkable(self) -> bool:
|
||||
"""An exact central twin exists and nothing but "the name is taken" / "its references are not
|
||||
managed here yet" speaks against it (both are moot when the twin is linked, its content is equal)."""
|
||||
return any(not m.busy for m in self.matches) and set(self.reasons) <= LINK_TOLERATED
|
||||
|
||||
@property
|
||||
def busy_twin(self) -> bool:
|
||||
"""The one exact twin waits for a confirmation: \"adopt all\" leaves this entry alone (neither link nor duplicate)."""
|
||||
return len(self.matches) == 1 and self.matches[0].busy
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
@@ -117,6 +136,14 @@ class Kind:
|
||||
context: Callable[[AsyncSession, list[dict]], Awaitable[dict]] | None = None
|
||||
refs: Callable[[dict], set[str]] | None = None
|
||||
name: Callable[[dict], str] | None = None
|
||||
identity: str = "description" # attribute that names the object (the alias name for aliases)
|
||||
|
||||
@property
|
||||
def link_spec(self) -> adopt_link_service.LinkSpec:
|
||||
return adopt_link_service.LinkSpec(
|
||||
model=self.model, deployment=self.deployment, fk=self.fk, uuid_attr=self.uuid_attr,
|
||||
identity=self.identity, payload=self.payload, revision=self.revision,
|
||||
)
|
||||
|
||||
|
||||
# ── shared helpers ───────────────────────────────────────────────────────
|
||||
@@ -388,7 +415,7 @@ KINDS: dict[str, Kind] = {
|
||||
uuid_attr="opnsense_alias_uuid", fetch=_fetch_aliases, check=_alias_check,
|
||||
title=lambda row: clean(row.get("name")), info=_alias_info, build=_alias_build,
|
||||
payload=alias_deploy_service._build_payload, revision=alias_policy_service.revision,
|
||||
context=_alias_context, refs=_alias_refs, name=lambda row: clean(row.get("name")),
|
||||
context=_alias_context, refs=_alias_refs, name=lambda row: clean(row.get("name")), identity="name",
|
||||
),
|
||||
"port_forward": Kind(
|
||||
key="port_forward", model=PortForwardRule, deployment=PortForwardDeployment, fk="port_forward_id",
|
||||
@@ -462,12 +489,16 @@ async def _plan(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> tuple[list[
|
||||
rows = await kind.fetch(host)
|
||||
managed = await _managed_uuids(db, kind, host)
|
||||
ctx = await kind.context(db, rows) if kind.context else {}
|
||||
central = await adopt_link_service.load_central(db, kind.link_spec)
|
||||
entries = []
|
||||
for row in rows:
|
||||
if clean(row.get("uuid")) in managed:
|
||||
continue
|
||||
reasons, details = kind.check(row, ctx)
|
||||
entries.append(Entry(row, list(reasons), dict(details)))
|
||||
entry = Entry(row, list(reasons), dict(details))
|
||||
if reasons != ["system"]:
|
||||
entry.matches, entry.similar = adopt_link_service.find(central, kind.link_spec, host, kind.build(row))
|
||||
entries.append(entry)
|
||||
if kind.refs:
|
||||
order = _resolve_references(kind, entries, rows, managed, ctx)
|
||||
else:
|
||||
@@ -475,8 +506,31 @@ async def _plan(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> tuple[list[
|
||||
return entries, order
|
||||
|
||||
|
||||
def _describe(kind: Kind, entry: Entry) -> dict:
|
||||
def _bulk_actions(entries: list[Entry], order: list[Entry], link_existing: bool) -> dict[str, tuple[str, Any]]:
|
||||
"""What "adopt all" does with each entry: ("link", central object) when exactly one central twin exists
|
||||
(and no other entry of this run took it), ("new", None), or nothing. One function for the dialog's
|
||||
summary and for the real run, so they cannot disagree. Several twins are never guessed between."""
|
||||
actions: dict[str, tuple[str, Any]] = {}
|
||||
taken: set = set()
|
||||
if link_existing:
|
||||
for entry in entries:
|
||||
if entry.linkable and len(entry.matches) == 1 and entry.matches[0].obj.id not in taken:
|
||||
actions[entry.uuid] = ("link", entry.matches[0].obj)
|
||||
taken.add(entry.matches[0].obj.id)
|
||||
for entry in order:
|
||||
if link_existing and entry.busy_twin:
|
||||
continue
|
||||
actions.setdefault(entry.uuid, ("new", None))
|
||||
return actions
|
||||
|
||||
|
||||
def _describe(kind: Kind, entry: Entry, action: str | None = None) -> dict:
|
||||
spec = kind.link_spec
|
||||
return {
|
||||
"matches": [adopt_link_service.describe(spec, m) for m in entry.matches],
|
||||
"similar": [adopt_link_service.describe(spec, m) for m in entry.similar],
|
||||
"linkable": entry.linkable,
|
||||
"bulk_action": action,
|
||||
"uuid": entry.uuid,
|
||||
"title": kind.title(entry.row),
|
||||
"enabled": on(entry.row.get("enabled")) if "disabled" not in entry.row else not on(entry.row.get("disabled")),
|
||||
@@ -491,8 +545,9 @@ def _describe(kind: Kind, entry: Entry) -> dict:
|
||||
|
||||
async def list_unmanaged(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> list[dict]:
|
||||
kind = KINDS[kind_key]
|
||||
entries, _ = await _plan(db, kind, host)
|
||||
return [_describe(kind, e) for e in entries]
|
||||
entries, order = await _plan(db, kind, host)
|
||||
actions = _bulk_actions(entries, order, True)
|
||||
return [_describe(kind, e, actions.get(e.uuid, ("skip", None))[0]) for e in entries]
|
||||
|
||||
|
||||
def _create(db: AsyncSession, kind: Kind, host: OpnsenseHost, entry: Entry):
|
||||
@@ -511,8 +566,17 @@ async def _record(db: AsyncSession, kind: Kind, host: OpnsenseHost, obj, remote_
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid: str):
|
||||
async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid: str, link_to=None):
|
||||
"""Takes over one object as a NEW central object, or - with `link_to` - links it to that existing
|
||||
central object (adopt_link_service). Either way the live state is read fresh here."""
|
||||
kind = KINDS[kind_key]
|
||||
if link_to is not None:
|
||||
existing = await db.scalar(select(kind.deployment).where(
|
||||
kind.deployment.host_id == host.id, getattr(kind.deployment, kind.uuid_attr) == remote_uuid))
|
||||
if existing is not None:
|
||||
if getattr(existing, kind.fk) == link_to:
|
||||
return await db.get(kind.model, link_to) # second click: already linked, nothing to do
|
||||
raise NotAdoptable(["already_managed"])
|
||||
entries, _ = await _plan(db, kind, host)
|
||||
entry = next((e for e in entries if e.uuid == remote_uuid), None)
|
||||
if entry is None:
|
||||
@@ -520,6 +584,11 @@ async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid
|
||||
if remote_uuid in await _managed_uuids(db, kind, host):
|
||||
raise NotAdoptable(["already_managed"])
|
||||
raise NotFoundOnHost(remote_uuid)
|
||||
if link_to is not None:
|
||||
if not set(entry.reasons) <= LINK_TOLERATED:
|
||||
raise NotAdoptable(entry.reasons, entry.details)
|
||||
obj = await adopt_link_service.link(db, kind.link_spec, host, link_to, remote_uuid, kind.build(entry.row))
|
||||
return obj
|
||||
if entry.reasons:
|
||||
raise NotAdoptable(entry.reasons, entry.details)
|
||||
obj = _create(db, kind, host, entry)
|
||||
@@ -527,19 +596,32 @@ async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid
|
||||
return obj
|
||||
|
||||
|
||||
async def adopt_all(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> dict:
|
||||
async def adopt_all(db: AsyncSession, kind_key: str, host: OpnsenseHost, link_existing: bool = False) -> dict:
|
||||
"""Adopts everything adoptable in one transaction (the caller commits), nested aliases in
|
||||
dependency order. System objects are not reported; everything else that stays behind is, with reasons."""
|
||||
dependency order. With `link_existing`, entries with exactly one exact central twin are linked to it
|
||||
instead (see _bulk_actions). System objects are not reported; everything else that stays behind is,
|
||||
with reasons."""
|
||||
kind = KINDS[kind_key]
|
||||
entries, order = await _plan(db, kind, host)
|
||||
actions = _bulk_actions(entries, order, link_existing)
|
||||
adopted = []
|
||||
done: set[str] = set()
|
||||
for entry in entries: # linked first: their names are central already, so nested aliases can refer to them
|
||||
action, target = actions.get(entry.uuid, ("skip", None))
|
||||
if action == "link":
|
||||
obj = await adopt_link_service.link(db, kind.link_spec, host, target.id, entry.uuid, kind.build(entry.row))
|
||||
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id, "linked": True})
|
||||
done.add(entry.uuid)
|
||||
for entry in order:
|
||||
if actions.get(entry.uuid, ("skip", None))[0] != "new":
|
||||
continue
|
||||
obj = _create(db, kind, host, entry)
|
||||
await _record(db, kind, host, obj, entry.uuid)
|
||||
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id})
|
||||
done = {e.uuid for e in order}
|
||||
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id, "linked": False})
|
||||
done.add(entry.uuid)
|
||||
skipped = [
|
||||
{"uuid": e.uuid, "title": kind.title(e.row), "reasons": e.reasons, "reason_details": e.details}
|
||||
{"uuid": e.uuid, "title": kind.title(e.row),
|
||||
"reasons": e.reasons + (["twin_busy"] if link_existing and e.busy_twin else []), "reason_details": e.details}
|
||||
for e in entries if e.uuid not in done and e.reasons != ["system"]
|
||||
]
|
||||
return {"adopted": adopted, "skipped": skipped}
|
||||
@@ -419,6 +419,42 @@ async def get_pf_rule_statistics(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/diagnostics/firewall/pfStatistics/rules")
|
||||
|
||||
|
||||
# ── Rule analysis (read-only) ─────────────────────────────────────────────
|
||||
# Checked against opnsense/core master (2026-10-21) and live on OPNsense 26.7:
|
||||
# - Firewall/Api/FilterUtilController::ruleStatsAction runs configd `!filter rule stats` and
|
||||
# returns {"status": "ok", "stats": {"<label>": {pf_rules, evaluations, packets, bytes, states}}};
|
||||
# the label is the rule UUID (MVC), an md5 (automatic) or a 32-hex label (unmigrated legacy).
|
||||
# - Firewall/Api/AliasUtilController::findReferencesAction requires POST with "ip" (only hex,
|
||||
# ":", ".", "/", "_" accepted, else {"status": "Not an IP address!"}), runs configd
|
||||
# `filter find_table_references` (a pf table test over all aliases) and returns
|
||||
# {"status": "ok", "matches": ["<alias>", ...]}. It changes nothing; POST is only the
|
||||
# controller's calling convention.
|
||||
# - Firewall/Api/FilterController::getInterfaceListAction: {"floating"|"groups"|"interfaces"|"any":
|
||||
# {"label", "items": [{"value", "label", "type"}]}} (lo0 omitted).
|
||||
# - Firewall/Api/GroupController::searchItemAction -> searchBase("ifgroupentry"): rows with
|
||||
# ifname, members (comma separated identifiers), sequence; the built-in groups (enc0, openvpn,
|
||||
# wireguard) appear with their name as uuid and empty members.
|
||||
async def get_rule_stats(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/firewall/filter_util/ruleStats")
|
||||
|
||||
|
||||
async def find_alias_references(host: OpnsenseHost, ip: str) -> list[str] | None:
|
||||
"""Names of the aliases (pf tables) that contain ``ip`` right now; None when the firewall
|
||||
could not answer (callers then treat table membership as unknown)."""
|
||||
data = await _request(host, "POST", "/firewall/alias_util/findReferences", json={"ip": ip})
|
||||
if not isinstance(data, dict) or data.get("status") != "ok" or not isinstance(data.get("matches"), list):
|
||||
return None
|
||||
return [str(name) for name in data["matches"]]
|
||||
|
||||
|
||||
async def get_filter_interface_list(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/firewall/filter/get_interface_list")
|
||||
|
||||
|
||||
async def search_interface_groups(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/firewall/group/searchItem", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def get_firmware_status(host: OpnsenseHost) -> dict:
|
||||
# GET reads cached results. POST would trigger a synchronous probe.
|
||||
return await _request(host, "GET", "/core/firmware/status")
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.rule import FirewallRule
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.services import opnsense_client
|
||||
from app.services import adopt_link_service, opnsense_client
|
||||
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on # noqa: F401 (NotAdoptable is re-exported for routers/rules.py)
|
||||
from app.services.policy_service import revision
|
||||
from app.services.rule_deploy_service import _build_payload
|
||||
@@ -64,6 +64,30 @@ def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
|
||||
return reasons
|
||||
|
||||
|
||||
def _values(row: dict) -> dict:
|
||||
"""The fields OPNexus manages, normalised exactly as adopting stores them (also what a link compares)."""
|
||||
return {
|
||||
"description": _clean(row.get("description")),
|
||||
"interface": _clean(row.get("interface")),
|
||||
"direction": _clean(row.get("direction")),
|
||||
"ipprotocol": _clean(row.get("ipprotocol")),
|
||||
"protocol": _clean(row.get("protocol")) or "any",
|
||||
"action": _clean(row.get("action")),
|
||||
"source_net": _clean(row.get("source_net")) or "any",
|
||||
"source_port": _clean(row.get("source_port")) or None,
|
||||
"destination_net": _clean(row.get("destination_net")) or "any",
|
||||
"destination_port": _clean(row.get("destination_port")) or None,
|
||||
"enabled": _on(row.get("enabled")),
|
||||
"log": _on(row.get("log")),
|
||||
}
|
||||
|
||||
|
||||
LINK_SPEC = adopt_link_service.LinkSpec(
|
||||
model=FirewallRule, deployment=RuleDeployment, fk="rule_id", uuid_attr="opnsense_rule_uuid",
|
||||
identity="description", payload=_build_payload, revision=revision,
|
||||
)
|
||||
|
||||
|
||||
async def _managed_uuids(db: AsyncSession, host: OpnsenseHost) -> set[str]:
|
||||
rows = await db.scalars(
|
||||
select(RuleDeployment.opnsense_rule_uuid).where(
|
||||
@@ -81,6 +105,7 @@ async def _live_rows(host: OpnsenseHost) -> list[dict]:
|
||||
|
||||
async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
|
||||
managed = await _managed_uuids(db, host)
|
||||
central = await adopt_link_service.load_central(db, LINK_SPEC)
|
||||
result = []
|
||||
for row in await _live_rows(host):
|
||||
if row.get("is_automatic"):
|
||||
@@ -88,7 +113,11 @@ async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
|
||||
reasons = reasons_not_adoptable(row, managed)
|
||||
if "already_managed" in reasons:
|
||||
continue
|
||||
matches, similar = adopt_link_service.find(central, LINK_SPEC, host, _values(row))
|
||||
result.append({
|
||||
"matches": [adopt_link_service.describe(LINK_SPEC, m) for m in matches],
|
||||
"similar": [adopt_link_service.describe(LINK_SPEC, m) for m in similar],
|
||||
"linkable": any(not m.busy for m in matches) and not reasons,
|
||||
"uuid": _clean(row.get("uuid")),
|
||||
"description": _clean(row.get("description")),
|
||||
"interface": _clean(row.get("interface")),
|
||||
@@ -108,7 +137,15 @@ async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> FirewallRule:
|
||||
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str, link_to=None) -> FirewallRule:
|
||||
"""Takes the rule over as a new central rule, or - with `link_to` - links it to that existing rule."""
|
||||
if link_to is not None:
|
||||
existing = await db.scalar(select(RuleDeployment).where(
|
||||
RuleDeployment.host_id == host.id, RuleDeployment.opnsense_rule_uuid == rule_uuid))
|
||||
if existing is not None:
|
||||
if existing.rule_id == link_to:
|
||||
return await db.get(FirewallRule, link_to) # second click: already linked
|
||||
raise NotAdoptable(["already_managed"])
|
||||
managed = await _managed_uuids(db, host)
|
||||
row = next((r for r in await _live_rows(host) if _clean(r.get("uuid")) == rule_uuid), None)
|
||||
if row is None:
|
||||
@@ -116,23 +153,10 @@ async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> Firewal
|
||||
reasons = reasons_not_adoptable(row, managed)
|
||||
if reasons:
|
||||
raise NotAdoptable(reasons)
|
||||
if link_to is not None:
|
||||
return await adopt_link_service.link(db, LINK_SPEC, host, link_to, rule_uuid, _values(row))
|
||||
|
||||
rule = FirewallRule(
|
||||
description=_clean(row.get("description")),
|
||||
interface=_clean(row.get("interface")),
|
||||
direction=_clean(row.get("direction")),
|
||||
ipprotocol=_clean(row.get("ipprotocol")),
|
||||
protocol=_clean(row.get("protocol")) or "any",
|
||||
action=_clean(row.get("action")),
|
||||
source_net=_clean(row.get("source_net")) or "any",
|
||||
source_port=_clean(row.get("source_port")) or None,
|
||||
destination_net=_clean(row.get("destination_net")) or "any",
|
||||
destination_port=_clean(row.get("destination_port")) or None,
|
||||
enabled=_on(row.get("enabled")),
|
||||
log=_on(row.get("log")),
|
||||
targets=[host],
|
||||
groups=[],
|
||||
)
|
||||
rule = FirewallRule(**_values(row), targets=[host], groups=[])
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
revision(db, rule, "Von Firewall übernommen")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Rule analysis (1.26): which firewall rule decides a packet, with honest confidence."""
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Everything about one firewall the analysis needs, fetched read-only and cached briefly.
|
||||
|
||||
Sources (see the evidence block in opnsense_client.py): ``firewall/filter/searchRule`` (all
|
||||
rules incl. automatic ones, with ``sort_order``), ``diagnostics/firewall/pfStatistics/rules``
|
||||
(what pf has loaded), ``interfaces/overview/interfacesInfo`` (identifier, device, addresses
|
||||
incl. CARP VIPs, gateways), ``firewall/group/searchItem`` (interface groups),
|
||||
``firewall/alias/searchItem`` (alias definitions), ``firewall/d_nat/searchRule`` (port
|
||||
forwards, only to warn about NAT) and, per queried IP, ``firewall/alias_util/findReferences``.
|
||||
Optional sources may fail; the analysis then lowers its confidence instead of guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import opnsense_client
|
||||
from app.services.rule_analysis import pf_ruleset
|
||||
from app.services.rule_analysis.model import IPAddr, IPNet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_SECONDS = 60
|
||||
# Built-in interface groups whose members are the matching dynamic devices.
|
||||
BUILTIN_GROUP_DEVICES = {"wireguard": ("wg",), "openvpn": ("ovpns", "ovpnc"), "enc0": ("enc0",)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class IfInfo:
|
||||
identifier: str
|
||||
device: str
|
||||
description: str
|
||||
nets: list[IPNet] = field(default_factory=list)
|
||||
addrs: list[IPAddr] = field(default_factory=list) # primary addresses
|
||||
vips: list[IPAddr] = field(default_factory=list) # CARP / alias VIPs on this interface
|
||||
has_gateway: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
rows: list[dict]
|
||||
pf: pf_ruleset.PfRuleset | None
|
||||
interfaces: dict[str, IfInfo]
|
||||
groups: dict[str, set[str]] | None # name -> member identifiers; None = unknown
|
||||
aliases: dict[str, dict]
|
||||
port_forwards: list[dict] | None
|
||||
refs: dict[str, list[str] | None] = field(default_factory=dict)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
loaded_at: float = 0.0
|
||||
|
||||
@property
|
||||
def self_addrs(self) -> list[IPAddr]:
|
||||
addrs = [a for i in self.interfaces.values() for a in (*i.addrs, *i.vips)]
|
||||
return addrs + [ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")]
|
||||
|
||||
def by_device(self, device: str) -> IfInfo | None:
|
||||
return next((i for i in self.interfaces.values() if i.device == device), None)
|
||||
|
||||
def by_label(self, label: str) -> IfInfo | None:
|
||||
lowered = label.strip().lower()
|
||||
return self.interfaces.get(lowered) or next(
|
||||
(i for i in self.interfaces.values() if i.description.lower() == lowered), None)
|
||||
|
||||
|
||||
def _interfaces(info: dict) -> dict[str, IfInfo]:
|
||||
result: dict[str, IfInfo] = {}
|
||||
for row in (info or {}).get("rows", []) or []:
|
||||
identifier = str(row.get("identifier") or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
item = IfInfo(identifier=identifier, device=str(row.get("device") or ""),
|
||||
description=str(row.get("description") or identifier),
|
||||
has_gateway=bool(row.get("gateways")) or "default" in (row.get("routes") or []))
|
||||
for entry in [*(row.get("ipv4") or []), *(row.get("ipv6") or [])]:
|
||||
try:
|
||||
iface = ipaddress.ip_interface(str(entry.get("ipaddr")).split("%")[0])
|
||||
except ValueError:
|
||||
continue
|
||||
(item.vips if entry.get("vhid") or entry.get("vip") else item.addrs).append(iface.ip)
|
||||
if iface.network not in item.nets:
|
||||
item.nets.append(iface.network)
|
||||
result[identifier] = item
|
||||
return result
|
||||
|
||||
|
||||
def _groups(data: dict | None, interfaces: dict[str, IfInfo]) -> dict[str, set[str]] | None:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("rows"), list):
|
||||
return None
|
||||
groups: dict[str, set[str]] = {}
|
||||
for row in data["rows"]:
|
||||
name = str(row.get("ifname") or "")
|
||||
members = {m.strip() for m in str(row.get("members") or "").split(",") if m.strip()}
|
||||
prefixes = BUILTIN_GROUP_DEVICES.get(name)
|
||||
if prefixes:
|
||||
members |= {i.identifier for i in interfaces.values() if i.device.startswith(prefixes)}
|
||||
if name:
|
||||
groups[name] = members
|
||||
return groups
|
||||
|
||||
|
||||
async def _optional(coro, note: str, notes: list[str]):
|
||||
try:
|
||||
return await coro
|
||||
except Exception as exc: # noqa: BLE001 - optional source: degrade, never fail the analysis
|
||||
logger.info("rule analysis: optional source failed (%s): %s", note, exc)
|
||||
notes.append(note)
|
||||
return None
|
||||
|
||||
|
||||
_CACHE: dict[str, Context] = {}
|
||||
|
||||
|
||||
async def load_context(host: OpnsenseHost, *, use_cache: bool = True) -> Context:
|
||||
key = str(host.id)
|
||||
cached = _CACHE.get(key)
|
||||
if use_cache and cached and time.monotonic() - cached.loaded_at < CACHE_SECONDS:
|
||||
return cached
|
||||
notes: list[str] = []
|
||||
rules, info = await asyncio.gather(
|
||||
opnsense_client.search_rules(host, all_rows=True), opnsense_client.get_interfaces_info(host))
|
||||
stats, groups, aliases, forwards = await asyncio.gather(
|
||||
_optional(opnsense_client.get_pf_rule_statistics(host), "PF_RULESET_UNAVAILABLE", notes),
|
||||
_optional(opnsense_client.search_interface_groups(host), "GROUPS_UNAVAILABLE", notes),
|
||||
_optional(opnsense_client._request(host, "GET", "/firewall/alias/searchItem", params={"rowCount": -1}),
|
||||
"ALIASES_UNAVAILABLE", notes),
|
||||
_optional(opnsense_client.search_port_forward_rules(host), "NAT_UNAVAILABLE", notes),
|
||||
)
|
||||
interfaces = _interfaces(info)
|
||||
pf = pf_ruleset.parse(stats)
|
||||
if stats is not None and pf is None:
|
||||
notes.append("PF_RULESET_UNAVAILABLE")
|
||||
ctx = Context(
|
||||
rows=list((rules or {}).get("rows") or []), pf=pf, interfaces=interfaces,
|
||||
groups=_groups(groups, interfaces),
|
||||
aliases={str(a.get("name")): a for a in ((aliases or {}).get("rows") or []) if a.get("name")},
|
||||
port_forwards=None if forwards is None else list(forwards.get("rows") or []),
|
||||
notes=notes, loaded_at=time.monotonic(),
|
||||
)
|
||||
_CACHE[key] = ctx
|
||||
return ctx
|
||||
|
||||
|
||||
async def ensure_refs(host: OpnsenseHost, ctx: Context, ips: list[str]) -> None:
|
||||
"""Ask pf which tables contain each IP (cached in the context)."""
|
||||
for ip in ips:
|
||||
if ip in ctx.refs:
|
||||
continue
|
||||
try:
|
||||
ctx.refs[ip] = await opnsense_client.find_alias_references(host, ip)
|
||||
except Exception as exc: # noqa: BLE001 - table membership becomes "unknown"
|
||||
logger.info("rule analysis: findReferences failed: %s", exc)
|
||||
ctx.refs[ip] = None
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_CACHE.clear()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Which rule decides a packet, following pf semantics.
|
||||
|
||||
pf walks the loaded ruleset top to bottom; the *last* matching rule wins unless a matching
|
||||
rule is ``quick``, which ends the walk. OPNsense orders rules by ``sort_order`` (system,
|
||||
floating, groups, interfaces). The analysis looks at the first packet of a new connection.
|
||||
|
||||
Confidence:
|
||||
- ``rule_confidence`` is "sure" only when no rule that could still change the outcome
|
||||
matched with an unknown result;
|
||||
- ``confidence`` (the verdict) is "sure" when every such unknown rule has the same action as
|
||||
the deciding rule (then the verdict holds either way), "unknown" otherwise;
|
||||
- notes about guesses (interface guessed from the address, NAT nearby, pf ruleset missing,
|
||||
TCP flags, rate limits) lower a "sure" to "probable".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.services.rule_analysis import resolve
|
||||
from app.services.rule_analysis.context import Context
|
||||
from app.services.rule_analysis.model import IPAddr, NormRule, Outcome, t_and, tri_str
|
||||
from app.services.rule_analysis.normalize import norm_proto, normalize
|
||||
|
||||
SURE, PROBABLE, UNKNOWN = "sure", "probable", "unknown"
|
||||
_RANK = {SURE: 2, PROBABLE: 1, UNKNOWN: 0}
|
||||
DOWNGRADING_NOTES = {
|
||||
"IFACE_GUESSED", "EGRESS_GUESSED", "NAT_NEARBY", "TCP_FLAGS", "RATE_LIMIT", "ORDER_GUESSED",
|
||||
"ORDER_MISMATCH", "PF_RULESET_UNAVAILABLE", "NAT_UNAVAILABLE", "QUICK_UNKNOWN",
|
||||
}
|
||||
_PORT_PROTOS = {"6", "17"}
|
||||
_ICMP_PROTOS = {"1", "58"}
|
||||
ICMP_NAMES = {
|
||||
4: {"0": "echorep", "3": "unreach", "4": "squench", "5": "redir", "8": "echoreq", "9": "routeradv",
|
||||
"10": "routersol", "11": "timex", "12": "paramprob", "13": "timereq", "14": "timerep"},
|
||||
6: {"1": "unreach", "2": "toobig", "3": "timex", "4": "paramprob", "128": "echoreq", "129": "echorep",
|
||||
"133": "routersol", "134": "routeradv", "135": "neighbrsol", "136": "neighbradv", "137": "redir"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Query:
|
||||
src: IPAddr
|
||||
dst: IPAddr
|
||||
proto: str # normalized ("6", "17", "1", "58", other number or name)
|
||||
proto_label: str
|
||||
sport: int | None = None
|
||||
dport: int | None = None
|
||||
icmptype: str | None = None
|
||||
iface: str | None = None # None = guess from the source address
|
||||
direction: str = "in"
|
||||
egress: str | None = "none" # "none" = only the first leg, None = guess, else identifier
|
||||
|
||||
@property
|
||||
def af(self) -> int:
|
||||
return self.src.version
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegResult:
|
||||
direction: str
|
||||
iface: str | None
|
||||
iface_source: str
|
||||
action: str | None
|
||||
decisive: NormRule | None
|
||||
confidence: str
|
||||
rule_confidence: str
|
||||
notes: list[str] = field(default_factory=list)
|
||||
unknowns: list[str] = field(default_factory=list)
|
||||
steps: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _min_conf(*values: str) -> str:
|
||||
return min(values, key=lambda v: _RANK[v])
|
||||
|
||||
|
||||
def _max_conf(*values: str) -> str:
|
||||
return max(values, key=lambda v: _RANK[v])
|
||||
|
||||
|
||||
def _icmp_name(q: Query) -> str | None:
|
||||
if q.icmptype is None:
|
||||
return None
|
||||
return ICMP_NAMES[q.af].get(q.icmptype, q.icmptype)
|
||||
|
||||
|
||||
def rule_fields(r: NormRule, q: Query, direction: str, iface: str, ctx: Context,
|
||||
tags: set[str], first_leg_iface: str | None) -> dict[str, Outcome]:
|
||||
fields: dict[str, Outcome] = {
|
||||
"interface": resolve.iface_match(r.interfaces, r.if_not, iface, ctx),
|
||||
"direction": Outcome(r.direction in ("any", direction)),
|
||||
"family": Outcome(q.af in r.af),
|
||||
"protocol": Outcome(True if r.protos is None else q.proto in r.protos),
|
||||
"source": resolve.endpoint_match(r.src, q.src, ctx),
|
||||
"destination": resolve.endpoint_match(r.dst, q.dst, ctx),
|
||||
}
|
||||
if q.proto in _PORT_PROTOS:
|
||||
fields["source_port"] = resolve.port_match(r.sport, q.sport, ctx, ephemeral_default=True)
|
||||
fields["destination_port"] = resolve.port_match(r.dport, q.dport, ctx)
|
||||
elif r.sport or r.dport:
|
||||
fields["ports"] = Outcome(False) # pf only generates port rules for tcp/udp
|
||||
if r.icmptypes is not None and q.proto in _ICMP_PROTOS:
|
||||
name = _icmp_name(q)
|
||||
fields["icmp_type"] = Outcome(None, ["ICMP_TYPE_NOT_GIVEN"]) if name is None else \
|
||||
Outcome(name in r.icmptypes or q.icmptype in r.icmptypes)
|
||||
if r.tagged:
|
||||
fields["tagged"] = Outcome(True) if r.tagged in tags else Outcome(None, ["TAGGED"])
|
||||
if r.received_on:
|
||||
if direction == "in":
|
||||
fields["received_on"] = Outcome(r.received_on == iface)
|
||||
elif first_leg_iface:
|
||||
fields["received_on"] = Outcome(r.received_on == first_leg_iface)
|
||||
else:
|
||||
fields["received_on"] = Outcome(None, ["RECEIVED_ON"])
|
||||
if r.action is None:
|
||||
fields["action"] = Outcome(None, ["ACTION_UNKNOWN"])
|
||||
return fields
|
||||
|
||||
|
||||
def _step(r: NormRule, fields: dict[str, Outcome], result, effect: str) -> dict:
|
||||
return {
|
||||
"rule": r.summary(), "result": tri_str(result), "effect": effect,
|
||||
"fields": {k: tri_str(v.value) for k, v in fields.items()},
|
||||
"reasons": sorted({reason for v in fields.values() if v.value is None for reason in v.reasons}),
|
||||
}
|
||||
|
||||
|
||||
def eval_leg(rules: list[NormRule], q: Query, ctx: Context, direction: str, iface: str,
|
||||
first_leg_iface: str | None = None) -> LegResult:
|
||||
steps: list[dict] = []
|
||||
candidate: NormRule | None = None
|
||||
possible: list[tuple[NormRule, bool]] = [] # (rule, came after the current candidate)
|
||||
tags: set[str] = set()
|
||||
final: NormRule | None = None
|
||||
for r in rules:
|
||||
if not r.enabled:
|
||||
steps.append({"rule": r.summary(), "result": "false", "effect": "disabled", "fields": {}, "reasons": []})
|
||||
continue
|
||||
if r.loaded is False:
|
||||
steps.append({"rule": r.summary(), "result": "false", "effect": "not_loaded", "fields": {}, "reasons": []})
|
||||
continue
|
||||
fields = rule_fields(r, q, direction, iface, ctx, tags, first_leg_iface)
|
||||
result = t_and(*(f.value for f in fields.values()))
|
||||
if result is False:
|
||||
steps.append(_step(r, fields, result, "no_match"))
|
||||
continue
|
||||
if result is None or r.quick is None:
|
||||
possible.append((r, True))
|
||||
steps.append(_step(r, fields, result, "possible"))
|
||||
continue
|
||||
if r.quick:
|
||||
final = r
|
||||
for s in steps:
|
||||
if s["effect"] == "candidate":
|
||||
s["effect"] = "overridden"
|
||||
steps.append(_step(r, fields, result, "final"))
|
||||
break
|
||||
if candidate is not None:
|
||||
for s in steps:
|
||||
if s["effect"] == "candidate":
|
||||
s["effect"] = "overridden"
|
||||
candidate = r
|
||||
possible = [(p, False) for p, _ in possible]
|
||||
if r.tag:
|
||||
tags.add(r.tag)
|
||||
steps.append(_step(r, fields, result, "candidate"))
|
||||
decisive = final or candidate
|
||||
if final is None:
|
||||
for s in steps:
|
||||
if s["effect"] == "candidate":
|
||||
s["effect"] = "decisive"
|
||||
else:
|
||||
steps[-1]["effect"] = "decisive"
|
||||
action = decisive.action if decisive else "pass" # pf without any matching rule passes
|
||||
# Unknown matches that could still change the outcome: quick (or quick-unknown) ones always,
|
||||
# non-quick ones only after the deciding candidate (an earlier one is overridden anyway).
|
||||
relevant = [p for p, after in possible if p.quick is not False or (final is None and after)]
|
||||
rule_conf = SURE if not relevant else PROBABLE
|
||||
conf = SURE if all(p.action == action for p in relevant) else UNKNOWN
|
||||
unknowns = sorted({reason for s in steps if s["effect"] == "possible" for reason in s["reasons"]})
|
||||
if any(p.quick is None for p, _ in possible):
|
||||
unknowns = sorted({*unknowns, "QUICK_UNKNOWN"})
|
||||
notes: list[str] = []
|
||||
if decisive is None:
|
||||
notes.append("PF_DEFAULT_PASS")
|
||||
else:
|
||||
notes += decisive.extra_notes
|
||||
if decisive.gateway and action == "pass":
|
||||
notes.append("POLICY_ROUTE")
|
||||
if decisive.statetype.lower() == "none" or "no state" in getattr(decisive.pf_line, "text", ""):
|
||||
notes.append("STATELESS")
|
||||
if decisive.sched:
|
||||
notes.append("SCHEDULE_NOW")
|
||||
if any(s["effect"] == "not_loaded" and s["rule"]["loaded"] is False for s in steps):
|
||||
notes.append("RULES_NOT_LOADED")
|
||||
return LegResult(direction=direction, iface=iface, iface_source="given", action=action, decisive=decisive,
|
||||
confidence=conf, rule_confidence=rule_conf, notes=notes, unknowns=unknowns, steps=steps)
|
||||
|
||||
|
||||
def guess_iface(ip: IPAddr, ctx: Context) -> str | None:
|
||||
"""The interface whose connected network holds ``ip`` (longest prefix), else the one with
|
||||
the default gateway."""
|
||||
best, best_len = None, -1
|
||||
for info in ctx.interfaces.values():
|
||||
if info.identifier == "lo0":
|
||||
continue
|
||||
for net in info.nets:
|
||||
if net.version == ip.version and ip in net and net.prefixlen > best_len and not net.is_link_local:
|
||||
best, best_len = info.identifier, net.prefixlen
|
||||
if best:
|
||||
return best
|
||||
gateways = [i.identifier for i in ctx.interfaces.values() if i.has_gateway]
|
||||
return gateways[0] if len(gateways) == 1 else None
|
||||
|
||||
|
||||
def _nat_nearby(q: Query, iface: str, ctx: Context) -> bool:
|
||||
for row in ctx.port_forwards or []:
|
||||
if str(row.get("disabled") or "0") == "1" or str(row.get("enabled", "1")) == "0":
|
||||
continue
|
||||
if str(row.get("nordr") or "0") == "1": # "no rdr" exclusions (anti-lockout) redirect nothing
|
||||
continue
|
||||
if q.dport is not None and resolve.port_match(str(row.get("destination.port") or ""), q.dport, ctx).value is False:
|
||||
continue
|
||||
ifaces = {i.strip().lower() for i in str(row.get("interface") or "").split(",") if i.strip()}
|
||||
if ifaces and iface not in ifaces:
|
||||
continue
|
||||
proto = str(row.get("protocol") or "").lower()
|
||||
if proto and proto not in ("any", "tcp/udp") and norm_proto(proto) != q.proto:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def evaluate(ctx: Context, q: Query) -> dict:
|
||||
rules, order_notes = normalize(ctx)
|
||||
context_notes = [*ctx.notes, *order_notes]
|
||||
legs: list[LegResult] = []
|
||||
iface, iface_source = q.iface, "given"
|
||||
if iface is None:
|
||||
iface, iface_source = guess_iface(q.src if q.direction == "in" else q.dst, ctx), "guessed"
|
||||
if iface is None:
|
||||
return _result(q, legs, context_notes + ["IFACE_UNRESOLVED"], unresolved=True)
|
||||
first = eval_leg(rules, q, ctx, q.direction, iface)
|
||||
first.iface_source = iface_source
|
||||
if iface_source == "guessed":
|
||||
first.notes.append("IFACE_GUESSED")
|
||||
if q.direction == "in" and (ctx.port_forwards is None or _nat_nearby(q, iface, ctx)):
|
||||
first.notes.append("NAT_NEARBY" if ctx.port_forwards is not None else "NAT_UNAVAILABLE")
|
||||
legs.append(first)
|
||||
if q.direction == "in" and q.egress != "none":
|
||||
egress, source = q.egress, "given"
|
||||
if egress is None:
|
||||
egress, source = guess_iface(q.dst, ctx), "guessed"
|
||||
if egress is None:
|
||||
context_notes.append("EGRESS_UNRESOLVED")
|
||||
else:
|
||||
out = eval_leg(rules, q, ctx, "out", egress, first_leg_iface=iface)
|
||||
out.iface_source = source
|
||||
if source == "guessed":
|
||||
out.notes.append("EGRESS_GUESSED")
|
||||
legs.append(out)
|
||||
return _result(q, legs, context_notes)
|
||||
|
||||
|
||||
def _leg_conf(leg: LegResult, context_notes: list[str]) -> tuple[str, str]:
|
||||
downgrade = any(n in DOWNGRADING_NOTES for n in (*leg.notes, *context_notes))
|
||||
conf, rule_conf = leg.confidence, leg.rule_confidence
|
||||
if downgrade:
|
||||
conf = _min_conf(conf, PROBABLE)
|
||||
rule_conf = _min_conf(rule_conf, PROBABLE)
|
||||
return conf, rule_conf
|
||||
|
||||
|
||||
def _result(q: Query, legs: list[LegResult], context_notes: list[str], unresolved: bool = False) -> dict:
|
||||
leg_dicts, confs = [], []
|
||||
for leg in legs:
|
||||
conf, rule_conf = _leg_conf(leg, context_notes)
|
||||
confs.append((leg, conf, rule_conf))
|
||||
leg_dicts.append({
|
||||
"direction": leg.direction, "interface": leg.iface, "interface_source": leg.iface_source,
|
||||
"action": leg.action, "confidence": conf, "rule_confidence": rule_conf,
|
||||
"decisive_rule": leg.decisive.summary() if leg.decisive else None,
|
||||
"notes": sorted(set(leg.notes)), "unknowns": leg.unknowns,
|
||||
})
|
||||
if unresolved or not legs:
|
||||
action, conf, rule_conf, decisive = None, UNKNOWN, UNKNOWN, None
|
||||
else:
|
||||
blocking = [c for c in confs if c[0].action != "pass"]
|
||||
if blocking:
|
||||
leg, _, rule_conf = blocking[0]
|
||||
action, decisive = leg.action, leg.decisive
|
||||
# Blocked if any blocking leg is right: the best of their confidences counts.
|
||||
conf = _max_conf(*(c[1] for c in blocking))
|
||||
if conf == SURE and any(c[0].action != action for c in blocking):
|
||||
conf = PROBABLE
|
||||
else:
|
||||
leg, _, rule_conf = confs[0]
|
||||
action, decisive = "pass", leg.decisive
|
||||
conf = _min_conf(*(c[1] for c in confs))
|
||||
return {
|
||||
"query": {
|
||||
"src": str(q.src), "dst": str(q.dst), "proto": q.proto_label, "sport": q.sport, "dport": q.dport,
|
||||
"icmptype": q.icmptype, "direction": q.direction,
|
||||
"assumptions": ["FIRST_PACKET"] + (["SRC_PORT_EPHEMERAL"] if q.sport is None and q.proto in _PORT_PROTOS else []),
|
||||
},
|
||||
"action": action, "confidence": conf, "rule_confidence": rule_conf,
|
||||
"decisive_rule": decisive.summary() if decisive else None,
|
||||
"notes": sorted({*context_notes, *(n for leg in legs for n in leg.notes)}),
|
||||
"unknowns": sorted({u for leg in legs for u in leg.unknowns}),
|
||||
"legs": leg_dicts,
|
||||
"trace": [{"leg": i, "direction": leg.direction, "interface": leg.iface, "steps": leg.steps}
|
||||
for i, leg in enumerate(legs)],
|
||||
}
|
||||
|
||||
|
||||
def parse_ip(value: str) -> IPAddr:
|
||||
return ipaddress.ip_address(value.strip())
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Shared types of the rule analysis: three-valued logic and the normalized rule.
|
||||
|
||||
``Tri`` is Kleene logic with ``True``/``False``/``None`` (unknown). An unknown never turns
|
||||
into a definite answer by negation, and an AND is only False when one side is definitely
|
||||
False. This is what keeps the engine from claiming "sure" for something it cannot prove.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
Tri = Optional[bool]
|
||||
|
||||
IPAddr = ipaddress.IPv4Address | ipaddress.IPv6Address
|
||||
IPNet = ipaddress.IPv4Network | ipaddress.IPv6Network
|
||||
|
||||
|
||||
def t_and(*values: Tri) -> Tri:
|
||||
if any(v is False for v in values):
|
||||
return False
|
||||
if any(v is None for v in values):
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def t_or(*values: Tri) -> Tri:
|
||||
if any(v is True for v in values):
|
||||
return True
|
||||
if any(v is None for v in values):
|
||||
return None
|
||||
return False
|
||||
|
||||
|
||||
def t_not(value: Tri) -> Tri:
|
||||
return None if value is None else not value
|
||||
|
||||
|
||||
def tri_str(value: Tri) -> str:
|
||||
return "unknown" if value is None else ("true" if value else "false")
|
||||
|
||||
|
||||
class Outcome:
|
||||
"""A Tri plus the reason codes that made it unknown (or noteworthy)."""
|
||||
__slots__ = ("value", "reasons")
|
||||
|
||||
def __init__(self, value: Tri, reasons: tuple[str, ...] | list[str] = ()):
|
||||
self.value = value
|
||||
self.reasons = list(reasons)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"Outcome({self.value!r}, {self.reasons!r})"
|
||||
|
||||
|
||||
def o_or(outcomes: list[Outcome]) -> Outcome:
|
||||
value = t_or(*(o.value for o in outcomes)) if outcomes else False
|
||||
reasons = [] if value is True else [r for o in outcomes if o.value is None for r in o.reasons]
|
||||
return Outcome(value, reasons)
|
||||
|
||||
|
||||
SECTION_SYSTEM, SECTION_FLOATING, SECTION_GROUP, SECTION_INTERFACE = "system", "floating", "group", "interface"
|
||||
SECTION_NAT_AUTO, SECTION_INVALID, SECTION_ANTISPOOF, SECTION_OTHER = "nat_auto", "invalid", "antispoof", "other"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Endpoint:
|
||||
raw: str
|
||||
negated: bool = False
|
||||
items: list[str] = field(default_factory=list) # empty = any
|
||||
|
||||
@property
|
||||
def is_any(self) -> bool:
|
||||
return not self.items
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormRule:
|
||||
uuid: str
|
||||
label: str
|
||||
description: str
|
||||
order: tuple[int, int]
|
||||
order_known: bool
|
||||
section: str
|
||||
enabled: bool
|
||||
action: str | None # pass | block | reject | None (unknown)
|
||||
quick: bool | None # None = unknown (automatic rule without pf ruleset)
|
||||
interfaces: set[str] # identifiers or group names; empty = every interface
|
||||
if_not: bool
|
||||
direction: str # in | out | any
|
||||
af: set[int] # {4}, {6} or {4, 6}
|
||||
protos: set[str] | None # None = any; normalized names/numbers as strings
|
||||
src: Endpoint
|
||||
dst: Endpoint
|
||||
sport: str
|
||||
dport: str
|
||||
icmptypes: set[str] | None = None # None = no restriction
|
||||
tag: str = ""
|
||||
tagged: str = ""
|
||||
received_on: str = ""
|
||||
gateway: str = ""
|
||||
statetype: str = ""
|
||||
sched: str = ""
|
||||
automatic: bool = False
|
||||
legacy: bool = False
|
||||
loaded: bool | None = None # None = pf ruleset not available
|
||||
pf_numbers: list[int] = field(default_factory=list)
|
||||
extra_notes: list[str] = field(default_factory=list) # e.g. TCP_FLAGS, RATE_LIMIT from pf text
|
||||
pf_line: object = None # set for unlabeled pf lines (antispoof)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"uuid": self.uuid, "label": self.label, "description": self.description,
|
||||
"section": self.section, "sort_order": f"{self.order[0]:06d}.{self.order[1]:07d}" if self.order_known else None,
|
||||
"pf_numbers": self.pf_numbers, "action": self.action, "quick": self.quick,
|
||||
"interfaces": sorted(self.interfaces), "interface_not": self.if_not, "direction": self.direction,
|
||||
"automatic": self.automatic, "legacy": self.legacy, "loaded": self.loaded,
|
||||
"editable": not self.automatic and not self.legacy and self.section != SECTION_ANTISPOOF,
|
||||
"source": ("!" if self.src.negated else "") + (self.src.raw or "any"),
|
||||
"destination": ("!" if self.dst.negated else "") + (self.dst.raw or "any"),
|
||||
"source_port": self.sport or None, "destination_port": self.dport or None,
|
||||
"gateway": self.gateway or None, "schedule": self.sched or None,
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Turn ``searchRule`` rows (+ the loaded pf ruleset) into ``NormRule`` objects.
|
||||
|
||||
Format pitfalls seen live on OPNsense 26.7:
|
||||
- automatic rows carry the interface *description* (``"LAN"``), MVC rows the identifier;
|
||||
- automatic rows often have ``quick``/``action`` empty; the pf lines with the same label
|
||||
know the truth (``pass in quick on vtnet1_vlan10 ...``);
|
||||
- empty source/destination means any; address lists are comma separated;
|
||||
- unmigrated legacy rules have a 32-hex label instead of a UUID.
|
||||
|
||||
Order is ``sort_order`` (``prio.0seq`` from FilterRuleField::getPriority: 1/5 system,
|
||||
10000 force gateway, 200000 floating, 300000+n groups, 400000 interface, 500000 automatic
|
||||
NAT, 600000 = interface missing, never loaded).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.rule_analysis.context import Context
|
||||
from app.services.rule_analysis.model import (
|
||||
SECTION_ANTISPOOF, SECTION_FLOATING, SECTION_GROUP, SECTION_INTERFACE, SECTION_INVALID,
|
||||
SECTION_NAT_AUTO, SECTION_OTHER, SECTION_SYSTEM, Endpoint, NormRule,
|
||||
)
|
||||
from app.services.rule_analysis.pf_ruleset import PfLine
|
||||
|
||||
PROTO_NUMBERS = {
|
||||
"icmp": "1", "igmp": "2", "ggp": "3", "ipencap": "4", "tcp": "6", "egp": "8", "udp": "17",
|
||||
"ipv6": "41", "gre": "47", "esp": "50", "ah": "51", "ipv6-icmp": "58", "icmp6": "58",
|
||||
"icmpv6": "58", "ospf": "89", "pim": "103", "vrrp": "112", "carp": "112", "sctp": "132", "pfsync": "240",
|
||||
}
|
||||
|
||||
|
||||
def norm_proto(value: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
return PROTO_NUMBERS.get(value, value)
|
||||
|
||||
|
||||
def _truthy(value) -> bool:
|
||||
return value is True or str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _section(prio: int) -> str:
|
||||
if prio < 10001:
|
||||
return SECTION_SYSTEM
|
||||
if prio == 200000:
|
||||
return SECTION_FLOATING
|
||||
if 300000 <= prio < 400000:
|
||||
return SECTION_GROUP
|
||||
if prio == 400000:
|
||||
return SECTION_INTERFACE
|
||||
if prio == 500000:
|
||||
return SECTION_NAT_AUTO
|
||||
if prio >= 600000:
|
||||
return SECTION_INVALID
|
||||
return SECTION_OTHER
|
||||
|
||||
|
||||
def _order(row: dict) -> tuple[tuple[int, int], bool]:
|
||||
raw = str(row.get("sort_order") or "")
|
||||
if "." in raw:
|
||||
head, _, tail = raw.partition(".")
|
||||
if head.isdigit() and tail.isdigit():
|
||||
return (int(head), int(tail)), True
|
||||
# Older releases without sort_order: best effort from prio_group/sequence (confidence drops).
|
||||
prio = str(row.get("prio_group") or row.get("#priority") or "")
|
||||
seq = str(row.get("sequence") or "")
|
||||
return (int(prio) if prio.isdigit() else 400000, int(seq) if seq.isdigit() else 0), False
|
||||
|
||||
|
||||
def _endpoint(row: dict, side: str) -> Endpoint:
|
||||
raw = str(row.get(f"{side}_net") or "").strip()
|
||||
items = [] if raw.lower() in ("", "any") else [i.strip() for i in raw.split(",") if i.strip()]
|
||||
return Endpoint(raw=raw, negated=_truthy(row.get(f"{side}_not")), items=items)
|
||||
|
||||
|
||||
def _af(value: str) -> set[int]:
|
||||
value = str(value or "").strip().lower()
|
||||
return {4} if value == "inet" else ({6} if value == "inet6" else {4, 6})
|
||||
|
||||
|
||||
def _protos(value: str) -> set[str] | None:
|
||||
value = str(value or "").strip().lower()
|
||||
if value in ("", "any"):
|
||||
return None
|
||||
return {norm_proto(p) for p in value.split("/") if p}
|
||||
|
||||
|
||||
def _pf_interfaces(lines: list[PfLine], ctx: Context) -> tuple[set[str], bool] | None:
|
||||
devices = {(line.on_device, line.on_negated) for line in lines}
|
||||
if any(device is None for device, _ in devices):
|
||||
return set(), False
|
||||
if len({neg for _, neg in devices}) != 1:
|
||||
return None
|
||||
names: set[str] = set()
|
||||
for device, _ in devices:
|
||||
info = ctx.by_device(device)
|
||||
if info is not None:
|
||||
names.add(info.identifier)
|
||||
elif ctx.groups is not None and device in ctx.groups:
|
||||
names.add(device)
|
||||
else:
|
||||
return None
|
||||
return names, next(iter(devices))[1]
|
||||
|
||||
|
||||
def normalize_row(row: dict, ctx: Context) -> NormRule:
|
||||
order, order_known = _order(row)
|
||||
label = str(row.get("uuid") or "")
|
||||
automatic = _truthy(row.get("is_automatic"))
|
||||
lines = ctx.pf.by_label.get(label, []) if ctx.pf else []
|
||||
rule = NormRule(
|
||||
uuid=label, label=label, description=str(row.get("description") or ""),
|
||||
order=order, order_known=order_known, section=_section(order[0]),
|
||||
enabled=_truthy(row.get("enabled", "1")),
|
||||
action=(str(row.get("action") or "").lower() or None),
|
||||
quick=None, interfaces=set(), if_not=_truthy(row.get("interfacenot")),
|
||||
direction=(str(row.get("direction") or "in").lower() or "in"),
|
||||
af=_af(row.get("ipprotocol")), protos=_protos(row.get("protocol")),
|
||||
src=_endpoint(row, "source"), dst=_endpoint(row, "destination"),
|
||||
sport=str(row.get("source_port") or "").strip(), dport=str(row.get("destination_port") or "").strip(),
|
||||
tag=str(row.get("tag") or ""), tagged=str(row.get("tagged") or ""),
|
||||
received_on=str(row.get("received-on") or row.get("received_on") or ""),
|
||||
gateway=str(row.get("gateway") or ""), statetype=str(row.get("statetype") or ""),
|
||||
sched=str(row.get("sched") or ""), automatic=automatic, legacy=_truthy(row.get("legacy")),
|
||||
pf_numbers=[line.number for line in lines],
|
||||
)
|
||||
if rule.action not in ("pass", "block", "reject"):
|
||||
rule.action = None
|
||||
if row.get("quick") not in (None, ""):
|
||||
rule.quick = _truthy(row.get("quick"))
|
||||
raw_if = str(row.get("interface") or "").strip()
|
||||
names = {n.strip() for n in raw_if.split(",") if n.strip()}
|
||||
# Automatic and unmigrated legacy rows carry descriptions ("LAN"): map them to identifiers.
|
||||
# Identifiers and group names stay as they are; the pf "on" clause decides when available.
|
||||
mapped = set()
|
||||
for name in names:
|
||||
info = None if name in ctx.interfaces or name in (ctx.groups or {}) else ctx.by_label(name)
|
||||
mapped.add(info.identifier if info else name)
|
||||
names = mapped
|
||||
rule.interfaces = names
|
||||
if ctx.pf is not None:
|
||||
rule.loaded = bool(lines) and rule.enabled
|
||||
if lines:
|
||||
actions = {line.action for line in lines}
|
||||
if len(actions) == 1 and next(iter(actions)) in ("pass", "block", "reject"):
|
||||
pf_action = next(iter(actions))
|
||||
if rule.action is None or automatic:
|
||||
rule.action = pf_action
|
||||
quick = {line.quick for line in lines}
|
||||
rule.quick = next(iter(quick)) if len(quick) == 1 else None
|
||||
if automatic or rule.legacy:
|
||||
on = _pf_interfaces(lines, ctx)
|
||||
if on is not None:
|
||||
rule.interfaces, rule.if_not = on
|
||||
directions = {line.direction for line in lines}
|
||||
if len(directions) == 1:
|
||||
rule.direction = next(iter(directions))
|
||||
families = {line.af for line in lines}
|
||||
if None not in families:
|
||||
rule.af &= families
|
||||
types = {t for line in lines for t in line.icmptypes}
|
||||
if types:
|
||||
rule.icmptypes = types
|
||||
if any(line.flags not in (None, "S/SA", "any") for line in lines):
|
||||
rule.extra_notes.append("TCP_FLAGS")
|
||||
if any(word in line.text for line in lines for word in ("max-src-", "overload", "max-pkt-rate")):
|
||||
rule.extra_notes.append("RATE_LIMIT")
|
||||
elif rule.section == SECTION_INVALID:
|
||||
rule.loaded = False
|
||||
if rule.icmptypes is None and str(row.get("icmptype") or "").strip():
|
||||
rule.icmptypes = {t.strip() for t in str(row["icmptype"]).split(",") if t.strip()}
|
||||
return rule
|
||||
|
||||
|
||||
def antispoof_rule(line: PfLine, order: tuple[int, int]) -> NormRule:
|
||||
"""An unlabeled pf line OPNsense generated itself (antispoof / own-address block)."""
|
||||
return NormRule(
|
||||
uuid=f"pf@{line.number}", label="", description=line.text, order=order, order_known=True,
|
||||
section=SECTION_ANTISPOOF, enabled=True, action=line.action if line.action != "other" else None,
|
||||
quick=line.quick, interfaces=set(), if_not=False, direction=line.direction,
|
||||
af={line.af} if line.af else {4, 6}, protos=None,
|
||||
src=Endpoint(raw="" if line.src == "any" else line.src, items=[] if line.src == "any" else [line.src]),
|
||||
dst=Endpoint(raw="" if line.dst == "any" else line.dst, items=[] if line.dst == "any" else [line.dst]),
|
||||
sport="", dport="", automatic=True, loaded=True, pf_numbers=[line.number], pf_line=line,
|
||||
)
|
||||
|
||||
|
||||
def normalize(ctx: Context) -> tuple[list[NormRule], list[str]]:
|
||||
"""All rules in evaluation order plus context notes (order problems)."""
|
||||
notes: list[str] = []
|
||||
rules = [normalize_row(row, ctx) for row in ctx.rows]
|
||||
if any(not r.order_known for r in rules):
|
||||
notes.append("ORDER_GUESSED")
|
||||
rules.sort(key=lambda r: (r.order, r.uuid))
|
||||
if ctx.pf is not None:
|
||||
loaded = [r for r in rules if r.loaded and r.pf_numbers]
|
||||
firsts = [min(r.pf_numbers) for r in loaded]
|
||||
if firsts != sorted(firsts):
|
||||
notes.append("ORDER_MISMATCH")
|
||||
# Unlabeled pf lines: placed before the first configured rule that pf evaluates later.
|
||||
extra: list[tuple[tuple, NormRule]] = []
|
||||
for line in ctx.pf.unlabeled:
|
||||
after = next((r for r in loaded if min(r.pf_numbers) > line.number), None)
|
||||
key = after.order if after else (999999, 9999999)
|
||||
spoof = antispoof_rule(line, key)
|
||||
if line.on_device:
|
||||
info = ctx.by_device(line.on_device)
|
||||
spoof.interfaces = {info.identifier if info else line.on_device}
|
||||
spoof.if_not = line.on_negated
|
||||
extra.append(((key, 0, line.number), spoof))
|
||||
keyed = [((r.order, 1, 0), r) for r in rules] + extra
|
||||
keyed.sort(key=lambda item: item[0])
|
||||
rules = [r for _, r in keyed]
|
||||
return rules, notes
|
||||
@@ -0,0 +1,93 @@
|
||||
"""The ruleset pf has actually loaded (``diagnostics/firewall/pfStatistics/rules``).
|
||||
|
||||
Each key looks like ``@76 pass in quick on vtnet1_vlan10 inet from (...) to any flags S/SA
|
||||
keep state label "<uuid>"``. One configured rule can expand to several pf lines (address
|
||||
families, ICMP types, port lists); they share one label. Lines without a label are generated
|
||||
by OPNsense itself (antispoof: ``block drop in log on ! vtnet1_vlan10 inet from 192.0.2.0/24
|
||||
to any``). Only what the analysis needs is parsed: position, action, quick, direction, the
|
||||
``on`` interface, address family, ICMP types and a few pf options that make the answer
|
||||
uncertain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_HEAD = re.compile(r"^@(\d+)\s+(.*)$")
|
||||
_LABEL = re.compile(r'label "([^"]+)"')
|
||||
_ON = re.compile(r"\bon (!\s*)?([A-Za-z0-9_.]+)")
|
||||
_ICMP = re.compile(r"\bicmp6?-type (\S+)")
|
||||
_FROM = re.compile(r"\bfrom (\S+) to (\S+)")
|
||||
_FLAGS = re.compile(r"\bflags (\S+)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PfLine:
|
||||
number: int
|
||||
text: str
|
||||
action: str # pass | block | reject | match | scrub | other
|
||||
quick: bool
|
||||
direction: str # in | out | any
|
||||
on_negated: bool
|
||||
on_device: str | None
|
||||
af: int | None # 4, 6 or None (both)
|
||||
label: str | None
|
||||
icmptypes: list[str] = field(default_factory=list)
|
||||
src: str = "any"
|
||||
dst: str = "any"
|
||||
flags: str | None = None
|
||||
|
||||
|
||||
def parse_line(number: int, text: str) -> PfLine:
|
||||
words = text.split()
|
||||
first = words[0] if words else ""
|
||||
action = first if first in ("pass", "match", "scrub") else "other"
|
||||
if first == "block":
|
||||
action = "reject" if len(words) > 1 and words[1].startswith("return") else "block"
|
||||
direction = "in" if " in " in f" {text} " else ("out" if " out " in f" {text} " else "any")
|
||||
on = _ON.search(text)
|
||||
af = 4 if re.search(r"\binet\b", text) else (6 if re.search(r"\binet6\b", text) else None)
|
||||
label = _LABEL.search(text)
|
||||
endpoints = _FROM.search(text)
|
||||
flags = _FLAGS.search(text)
|
||||
return PfLine(
|
||||
number=number, text=text, action=action, quick=" quick " in f" {text} ",
|
||||
direction=direction, on_negated=bool(on and on.group(1)), on_device=on.group(2) if on else None,
|
||||
af=af, label=label.group(1) if label else None, icmptypes=_ICMP.findall(text),
|
||||
src=endpoints.group(1) if endpoints else ("any" if " all" in f" {text}" else "?"),
|
||||
dst=endpoints.group(2) if endpoints else ("any" if " all" in f" {text}" else "?"),
|
||||
flags=flags.group(1) if flags else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PfRuleset:
|
||||
lines: list[PfLine]
|
||||
by_label: dict[str, list[PfLine]]
|
||||
|
||||
@property
|
||||
def unlabeled(self) -> list[PfLine]:
|
||||
return [line for line in self.lines if line.label is None and line.action in ("pass", "block", "reject")]
|
||||
|
||||
|
||||
def parse(stats: dict | None) -> PfRuleset | None:
|
||||
"""None when the answer does not look like a pf ruleset at all."""
|
||||
rules = (stats or {}).get("rules") if isinstance(stats, dict) else None
|
||||
filter_rules = rules.get("filter rules") if isinstance(rules, dict) else None
|
||||
if not isinstance(filter_rules, dict):
|
||||
return None
|
||||
lines: list[PfLine] = []
|
||||
for key in filter_rules:
|
||||
head = _HEAD.match(str(key).strip())
|
||||
if not head:
|
||||
continue
|
||||
line = parse_line(int(head.group(1)), head.group(2))
|
||||
if line.action == "scrub":
|
||||
continue
|
||||
lines.append(line)
|
||||
lines.sort(key=lambda line: line.number)
|
||||
by_label: dict[str, list[PfLine]] = {}
|
||||
for line in lines:
|
||||
if line.label:
|
||||
by_label.setdefault(line.label, []).append(line)
|
||||
return PfRuleset(lines=lines, by_label=by_label)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Does a rule field match the queried packet? Addresses, interfaces, aliases, ports.
|
||||
|
||||
Every function answers with an ``Outcome`` (True/False/None + reason codes). Whatever cannot
|
||||
be proven from the firewall's own data is ``None`` (unknown), never a guess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from app.services.rule_analysis.context import Context
|
||||
from app.services.rule_analysis.model import IPAddr, Outcome, o_or, t_not
|
||||
|
||||
MAX_ALIAS_DEPTH = 10
|
||||
TABLE_ONLY_TYPES = {"url", "urltable", "geoip", "asn", "mac", "dynipv6host", "authgroup", "external", "internal"}
|
||||
ADDRESS_TYPES = {"host", "network", "networkgroup"}
|
||||
|
||||
SERVICES = {
|
||||
"ftp-data": 20, "ftp": 21, "ssh": 22, "telnet": 23, "smtp": 25, "domain": 53, "dns": 53, "bootps": 67,
|
||||
"bootpc": 68, "tftp": 69, "http": 80, "www": 80, "kerberos": 88, "pop3": 110, "ntp": 123, "imap": 143,
|
||||
"snmp": 161, "snmptrap": 162, "ldap": 389, "https": 443, "smb": 445, "microsoft-ds": 445, "isakmp": 500,
|
||||
"syslog": 514, "submission": 587, "ldaps": 636, "imaps": 993, "pop3s": 995, "openvpn": 1194,
|
||||
"ms-sql-s": 1433, "radius": 1812, "radius-acct": 1813, "nfs": 2049, "mysql": 3306, "rdp": 3389,
|
||||
"ipsec-nat-t": 4500, "sip": 5060, "postgresql": 5432, "dhcpv6-client": 546, "dhcpv6-server": 547,
|
||||
"http-alt": 8080, "wireguard": 51820,
|
||||
}
|
||||
EPHEMERAL = (1024, 65535)
|
||||
|
||||
|
||||
def _ip_in(token: str, ip: IPAddr) -> bool | None:
|
||||
"""Literal IP / CIDR / range; None when the token is not a literal."""
|
||||
token = token.strip()
|
||||
if "-" in token and not token.startswith("-"):
|
||||
low, _, high = token.partition("-")
|
||||
try:
|
||||
low_ip, high_ip = ipaddress.ip_address(low.strip()), ipaddress.ip_address(high.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return low_ip.version == ip.version and low_ip <= ip <= high_ip
|
||||
try:
|
||||
net = ipaddress.ip_network(token, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
return net.version == ip.version and ip in net
|
||||
|
||||
|
||||
def _interface_ref(token: str, ip: IPAddr, ctx: Context) -> Outcome | None:
|
||||
name = token.strip()
|
||||
lower = name.lower()
|
||||
m = re.fullmatch(r"__(.+)_network", lower)
|
||||
if m and m.group(1) in ctx.interfaces:
|
||||
lower = m.group(1)
|
||||
info = ctx.interfaces.get(lower)
|
||||
if info is not None: # "lan" = the interface's networks (pf: (dev:network), incl. VIP subnets)
|
||||
return Outcome(any(ip in net for net in info.nets))
|
||||
if lower.endswith("ip") and lower[:-2] in ctx.interfaces: # "lanip" = the interface address
|
||||
info = ctx.interfaces[lower[:-2]]
|
||||
if ip in info.addrs:
|
||||
return Outcome(True)
|
||||
if ip in info.vips:
|
||||
return Outcome(None, ["VIP_AS_INTERFACE_ADDRESS"])
|
||||
return Outcome(False)
|
||||
return None
|
||||
|
||||
|
||||
def addr_item(token: str, ip: IPAddr, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> Outcome:
|
||||
token = token.strip()
|
||||
if token.lower() in ("any", ""):
|
||||
return Outcome(True)
|
||||
if token.lower() in ("(self)", "self", "this firewall"):
|
||||
return Outcome(ip in ctx.self_addrs)
|
||||
literal = _ip_in(token, ip)
|
||||
if literal is not None:
|
||||
return Outcome(literal)
|
||||
ref = _interface_ref(token, ip, ctx)
|
||||
if ref is not None:
|
||||
return ref
|
||||
return alias_contains(token, ip, ctx, depth=depth, seen=seen)
|
||||
|
||||
|
||||
def alias_contains(name: str, ip: IPAddr, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> Outcome:
|
||||
if name in seen:
|
||||
return Outcome(None, ["ALIAS_CYCLE"])
|
||||
if depth > MAX_ALIAS_DEPTH:
|
||||
return Outcome(None, ["ALIAS_TOO_DEEP"])
|
||||
alias = ctx.aliases.get(name)
|
||||
refs = ctx.refs.get(str(ip))
|
||||
if alias is None:
|
||||
# pf knows tables the alias list may not show (e.g. internal ones): trust a positive hit.
|
||||
if refs is not None and name in refs:
|
||||
return Outcome(True)
|
||||
return Outcome(None, ["ALIAS_MISSING"])
|
||||
kind = str(alias.get("type") or "").lower()
|
||||
if depth == 0 and refs is not None:
|
||||
# pf's own table test: authoritative for every alias type (nesting, "!" entries, FQDNs).
|
||||
return Outcome(name in refs)
|
||||
if kind in ADDRESS_TYPES:
|
||||
return _alias_content(alias, ip, ctx, depth, (*seen, name))
|
||||
if refs is not None:
|
||||
return Outcome(name in refs)
|
||||
return Outcome(None, ["ALIAS_TABLE_UNAVAILABLE"])
|
||||
|
||||
|
||||
def _alias_content(alias: dict, ip: IPAddr, ctx: Context, depth: int, seen: tuple) -> Outcome:
|
||||
entries = [e.strip() for e in re.split(r"[\n,]", str(alias.get("content") or "")) if e.strip()]
|
||||
if any(e.startswith("!") for e in entries):
|
||||
return Outcome(None, ["ALIAS_EXCLUSION"])
|
||||
results = []
|
||||
for entry in entries:
|
||||
literal = _ip_in(entry, ip)
|
||||
if literal is not None:
|
||||
results.append(Outcome(literal))
|
||||
elif entry in ctx.aliases or entry in seen:
|
||||
results.append(alias_contains(entry, ip, ctx, depth=depth + 1, seen=seen))
|
||||
elif "." in entry or ":" in entry:
|
||||
results.append(Outcome(None, ["FQDN_UNRESOLVED"]))
|
||||
else:
|
||||
results.append(Outcome(None, ["ALIAS_MISSING"]))
|
||||
return o_or(results)
|
||||
|
||||
|
||||
def endpoint_match(endpoint, ip: IPAddr, ctx: Context) -> Outcome:
|
||||
if endpoint.is_any:
|
||||
return Outcome(True)
|
||||
result = o_or([addr_item(item, ip, ctx) for item in endpoint.items])
|
||||
if endpoint.negated:
|
||||
return Outcome(t_not(result.value), result.reasons)
|
||||
return result
|
||||
|
||||
|
||||
# ── Ports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def port_ranges(spec: str, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> tuple[list[tuple[int, int]] | None, list[str]]:
|
||||
"""The port ranges a spec stands for; (None, reasons) when that cannot be known."""
|
||||
ranges: list[tuple[int, int]] = []
|
||||
reasons: list[str] = []
|
||||
for part in [p.strip() for p in re.split(r"[\n,]", spec) if p.strip()]:
|
||||
m = re.fullmatch(r"(\w[\w-]*?)\s*[-:]\s*(\w[\w-]*)", part) if not part.isdigit() and part not in ctx.aliases else None
|
||||
if part.isdigit():
|
||||
ranges.append((int(part), int(part)))
|
||||
elif m and _port_number(m.group(1)) is not None and _port_number(m.group(2)) is not None:
|
||||
ranges.append((_port_number(m.group(1)), _port_number(m.group(2))))
|
||||
elif part.lower() in SERVICES:
|
||||
ranges.append((SERVICES[part.lower()],) * 2)
|
||||
elif part in ctx.aliases:
|
||||
if part in seen or depth >= MAX_ALIAS_DEPTH:
|
||||
return None, ["ALIAS_CYCLE" if part in seen else "ALIAS_TOO_DEEP"]
|
||||
alias = ctx.aliases[part]
|
||||
if str(alias.get("type") or "").lower() != "port":
|
||||
return None, ["PORT_ALIAS_UNRESOLVED"]
|
||||
sub, why = port_ranges(str(alias.get("content") or ""), ctx, depth=depth + 1, seen=(*seen, part))
|
||||
if sub is None:
|
||||
return None, why
|
||||
ranges += sub
|
||||
else:
|
||||
return None, ["PORT_ALIAS_UNRESOLVED" if part.isidentifier() else "PORT_UNPARSEABLE"]
|
||||
return ranges, reasons
|
||||
|
||||
|
||||
def _port_number(token: str) -> int | None:
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
return SERVICES.get(token.lower())
|
||||
|
||||
|
||||
def port_match(spec: str, port: int | None, ctx: Context, *, ephemeral_default: bool = False) -> Outcome:
|
||||
spec = (spec or "").strip()
|
||||
if spec.lower() in ("", "any"):
|
||||
return Outcome(True)
|
||||
ranges, reasons = port_ranges(spec, ctx)
|
||||
if ranges is None:
|
||||
return Outcome(None, reasons)
|
||||
if port is not None:
|
||||
return Outcome(any(low <= port <= high for low, high in ranges))
|
||||
if not ephemeral_default:
|
||||
return Outcome(None, ["PORT_NOT_GIVEN"])
|
||||
# No source port given: the query stands for an ephemeral client port (1024-65535).
|
||||
low_e, high_e = EPHEMERAL
|
||||
overlap = [(max(low, low_e), min(high, high_e)) for low, high in ranges if high >= low_e and low <= high_e]
|
||||
if not overlap:
|
||||
return Outcome(False)
|
||||
covered, cursor = True, low_e
|
||||
for low, high in sorted(overlap):
|
||||
if low > cursor:
|
||||
covered = False
|
||||
break
|
||||
cursor = max(cursor, high + 1)
|
||||
if covered and cursor > high_e:
|
||||
return Outcome(True)
|
||||
return Outcome(None, ["SRC_PORT_NOT_GIVEN"])
|
||||
|
||||
|
||||
# ── Interfaces ────────────────────────────────────────────────────────────
|
||||
|
||||
def iface_match(interfaces: set[str], if_not: bool, iface: str, ctx: Context) -> Outcome:
|
||||
if not interfaces:
|
||||
return Outcome(True)
|
||||
results = []
|
||||
for name in interfaces:
|
||||
if name == iface:
|
||||
results.append(Outcome(True))
|
||||
elif name in ctx.interfaces:
|
||||
results.append(Outcome(False))
|
||||
elif ctx.groups is not None and name in ctx.groups:
|
||||
results.append(Outcome(iface in ctx.groups[name]))
|
||||
else:
|
||||
results.append(Outcome(None, ["IFACE_UNRESOLVED" if ctx.groups is not None else "GROUPS_UNAVAILABLE"]))
|
||||
result = o_or(results)
|
||||
if if_not:
|
||||
return Outcome(t_not(result.value), result.reasons)
|
||||
return result
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Entry point used by the API: validate a query, load the firewall's context, evaluate."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services.rule_analysis import context as ctx_mod
|
||||
from app.services.rule_analysis.engine import Query, evaluate
|
||||
from app.services.rule_analysis.normalize import PROTO_NUMBERS, norm_proto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ip(value: str | None, field: str):
|
||||
try:
|
||||
return ipaddress.ip_address(str(value or "").strip())
|
||||
except ValueError:
|
||||
raise api_error(422, ErrorCode.ANALYSE_ADRESSE_UNGUELTIG, field=field, value=str(value or "")[:64]) from None
|
||||
|
||||
|
||||
def _port(value: str | int | None, field: str) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text.isdigit() or int(text) > 65535:
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field=field, value=text[:32])
|
||||
return int(text)
|
||||
|
||||
|
||||
def build_query(*, src: str, dst: str, proto: str = "tcp", sport=None, dport=None, icmptype: str | None = None,
|
||||
iface: str | None = None, direction: str = "in", egress: str | None = None) -> Query:
|
||||
source, destination = _ip(src, "src"), _ip(dst, "dst")
|
||||
if source.version != destination.version:
|
||||
raise api_error(422, ErrorCode.ANALYSE_FAMILIEN_MISCHUNG)
|
||||
label = str(proto or "tcp").strip().lower()
|
||||
if label == "icmp" and source.version == 6:
|
||||
label = "ipv6-icmp"
|
||||
number = norm_proto(label)
|
||||
if not (number.isdigit() and int(number) <= 255) and label not in PROTO_NUMBERS:
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="proto", value=label[:32])
|
||||
q = Query(src=source, dst=destination, proto=number, proto_label=label,
|
||||
sport=_port(sport, "sport"), dport=_port(dport, "dport"))
|
||||
if (q.sport is not None or q.dport is not None) and number not in ("6", "17"):
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="dport", value=str(dport or sport))
|
||||
if icmptype not in (None, ""):
|
||||
if number not in ("1", "58"):
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="icmptype", value=str(icmptype)[:32])
|
||||
q.icmptype = str(icmptype).strip().lower()
|
||||
if direction not in ("in", "out"):
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="direction", value=str(direction)[:8])
|
||||
q.direction = direction
|
||||
q.iface = None if str(iface or "").strip().lower() in ("", "auto") else str(iface).strip()
|
||||
egress_value = str(egress or "").strip().lower()
|
||||
q.egress = "none" if egress_value in ("", "none") else (None if egress_value == "auto" else str(egress).strip())
|
||||
return q
|
||||
|
||||
|
||||
def check_interfaces(q: Query, ctx: ctx_mod.Context) -> None:
|
||||
for name in (q.iface, None if q.egress in (None, "none") else q.egress):
|
||||
if name is not None and name not in ctx.interfaces:
|
||||
raise api_error(422, ErrorCode.ANALYSE_INTERFACE_UNBEKANNT, interface=name[:64],
|
||||
known=", ".join(sorted(i for i in ctx.interfaces if i != "lo0")))
|
||||
|
||||
|
||||
async def analyze_host(host: OpnsenseHost, q: Query, *, refresh: bool = False) -> dict:
|
||||
"""Raises api_error for a bad interface; OpnsenseAPIError/httpx errors when the firewall
|
||||
cannot be read (callers map that)."""
|
||||
ctx = await ctx_mod.load_context(host, use_cache=not refresh)
|
||||
check_interfaces(q, ctx)
|
||||
await ctx_mod.ensure_refs(host, ctx, [str(q.src), str(q.dst)])
|
||||
result = evaluate(ctx, q)
|
||||
if any(ctx.refs.get(ip) is None for ip in (str(q.src), str(q.dst))):
|
||||
result["notes"] = sorted({*result["notes"], "ALIAS_REFERENCES_UNAVAILABLE"})
|
||||
return result
|
||||
@@ -18,7 +18,7 @@ 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
|
||||
from app.services import proxy_connect
|
||||
|
||||
MAX_PEM_BYTES = 32 * 1024
|
||||
|
||||
@@ -94,6 +94,8 @@ def verify_argument(verify_tls: bool, tls_ca_pem: str | None):
|
||||
|
||||
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."""
|
||||
# Imported here so that pinned_context() stays usable without the database layer (gui-gateway container).
|
||||
from app.services import overlay_service
|
||||
proxy = overlay_service.proxy_for(base_url)
|
||||
port = parsed.port or 443
|
||||
if proxy is None:
|
||||
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.25.0"
|
||||
APP_CODENAME = "Goshawk"
|
||||
APP_VERSION = "1.28.0"
|
||||
APP_CODENAME = "Sparrowhawk"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""GUI-Zugriff über das gui-gateway (1.27): Einstellungen, Einmal-Tickets, Gateway-Sitzungen.
|
||||
|
||||
Revision ID: 0024
|
||||
Revises: 0023
|
||||
Create Date: 2026-10-21
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0024"
|
||||
down_revision = "0023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
UUID = postgresql.UUID(as_uuid=True)
|
||||
TS = sa.DateTime(timezone=True)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# A pre-Alembic install is bridged with create_all() first (app/migrate.py), which already made the tables.
|
||||
if sa.inspect(op.get_bind()).has_table("gui_access_settings"):
|
||||
return
|
||||
op.create_table(
|
||||
"gui_access_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("idle_timeout_minutes", sa.Integer(), server_default="15", nullable=False),
|
||||
sa.Column("absolute_timeout_minutes", sa.Integer(), server_default="60", nullable=False),
|
||||
sa.Column("slot_count", sa.Integer(), server_default="4", nullable=False),
|
||||
sa.Column("updated_by", sa.String(150), nullable=True),
|
||||
sa.Column("updated_at", TS, nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"gui_tickets",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("ticket_hash", sa.String(64), nullable=False),
|
||||
sa.Column("session_hash", sa.String(64), nullable=False),
|
||||
sa.Column("user_id", UUID, nullable=False),
|
||||
sa.Column("host_id", UUID, nullable=False),
|
||||
sa.Column("slot", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", TS, nullable=False),
|
||||
sa.Column("expires_at", TS, nullable=False),
|
||||
sa.Column("redeemed_at", TS, nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["admin_users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("ticket_hash"),
|
||||
)
|
||||
op.create_index("ix_gui_tickets_session_hash", "gui_tickets", ["session_hash"])
|
||||
op.create_index("ix_gui_tickets_user_id", "gui_tickets", ["user_id"])
|
||||
op.create_index("ix_gui_tickets_host_id", "gui_tickets", ["host_id"])
|
||||
op.create_table(
|
||||
"gui_sessions",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("session_hash", sa.String(64), nullable=False),
|
||||
sa.Column("user_id", UUID, nullable=False),
|
||||
sa.Column("host_id", UUID, nullable=False),
|
||||
sa.Column("slot", sa.Integer(), nullable=False),
|
||||
sa.Column("client_ip", sa.String(45), nullable=True),
|
||||
sa.Column("started_at", TS, nullable=False),
|
||||
sa.Column("last_checked_at", TS, nullable=False),
|
||||
sa.Column("ended_at", TS, nullable=True),
|
||||
sa.Column("end_reason", sa.String(32), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["admin_users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_gui_sessions_session_hash", "gui_sessions", ["session_hash"])
|
||||
op.create_index("ix_gui_sessions_user_id", "gui_sessions", ["user_id"])
|
||||
op.create_index("ix_gui_sessions_host_id", "gui_sessions", ["host_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("gui_sessions")
|
||||
op.drop_table("gui_tickets")
|
||||
op.drop_table("gui_access_settings")
|
||||
+1003
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
"""Mutation check helper (run manually, not collected by pytest).
|
||||
|
||||
python tests/mutation_check.py tests/mutations_gui.json
|
||||
|
||||
Each mutation: {"file": ..., "old": ..., "new": ..., "tests": "pytest selection"}.
|
||||
The file is patched, the selected tests must FAIL, then the file is restored.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(spec_path: str) -> int:
|
||||
mutations = json.loads(Path(spec_path).read_text())
|
||||
survivors = []
|
||||
for i, m in enumerate(mutations, 1):
|
||||
path = Path(m["file"])
|
||||
original = path.read_text()
|
||||
if original.count(m["old"]) != 1:
|
||||
print(f"[{i}] SKIP (anchor not unique/found): {m['file']}: {m['old'][:60]!r}")
|
||||
survivors.append(m)
|
||||
continue
|
||||
try:
|
||||
path.write_text(original.replace(m["old"], m["new"]))
|
||||
result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", *m["tests"].split()],
|
||||
capture_output=True, text=True)
|
||||
finally:
|
||||
path.write_text(original)
|
||||
killed = result.returncode != 0
|
||||
print(f"[{i}] {'KILLED ' if killed else 'SURVIVED'} {m.get('name', m['old'][:50])}")
|
||||
if not killed:
|
||||
survivors.append(m)
|
||||
print(f"{len(mutations) - len(survivors)}/{len(mutations)} mutations killed")
|
||||
return 1 if survivors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1]))
|
||||
@@ -0,0 +1,226 @@
|
||||
[
|
||||
{
|
||||
"name": "GW browser Cookie forwarded",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " out_headers = {k: v for k, v in headers.items() if k in _FORWARD_REQUEST_HEADERS}",
|
||||
"new": " out_headers = {k: v for k, v in headers.items() if k in _FORWARD_REQUEST_HEADERS or k == \"cookie\"}",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW Referer/Authorization forwarded",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": "\"accept\", \"accept-language\", \"content-type\", \"user-agent\", \"x-requested-with\", \"x-csrftoken\", \"range\",",
|
||||
"new": "\"accept\", \"accept-language\", \"content-type\", \"user-agent\", \"x-requested-with\", \"x-csrftoken\", \"range\", \"referer\", \"authorization\",",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW Set-Cookie passed to browser",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": "\"content-range\", \"accept-ranges\", \"content-security-policy\",",
|
||||
"new": "\"content-range\", \"accept-ranges\", \"content-security-policy\", \"set-cookie\",",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW jar never forgets deleted cookies",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if expired:\n gs.jar.pop(name, None)",
|
||||
"new": " if False:\n gs.jar.pop(name, None)",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW foreign Location kept",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if _origin_of(location) != _origin_of(upstream_origin):\n return None",
|
||||
"new": " pass",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW backslash Location kept",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if not location or \"\\\\\" in location",
|
||||
"new": " if not location",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW Sec-Fetch-Site lax",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if site not in (\"same-origin\", \"none\"):",
|
||||
"new": " if site in (\"cross-site\",):",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW redeem Sec-Fetch-Site unchecked",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if method != \"GET\" or site not in (\"same-site\", \"same-origin\", \"none\"):",
|
||||
"new": " if method != \"GET\":",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW redeem without OPNexus cookie",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if not ticket or len(ticket) > 128 or not browser_session:",
|
||||
"new": " if not ticket or len(ticket) > 128:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW Origin unchecked for writes",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if _origin_of(origin) not in own_origins:",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW absolute-form accepted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if not path.startswith(\"/\") or path.startswith(\"//\") or not raw_path.startswith(b\"/\") \\",
|
||||
"new": " if False \\",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW CONNECT accepted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if method in (\"CONNECT\", \"TRACE\"):",
|
||||
"new": " if method in (\"TRACE\",):",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW Upgrade accepted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if \"upgrade\" in headers or \"upgrade\" in headers.get(\"connection\", \"\").lower():",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW service worker accepted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if headers.get(\"service-worker\", \"\").lower() == \"script\":",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW slot header optional",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if not own_origins:\n return await self._respond(send, 400, _FORBIDDEN)",
|
||||
"new": " if False:\n return await self._respond(send, 400, _FORBIDDEN)",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW foreign slot cookie accepted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if gs is None or gs.slot != slot:",
|
||||
"new": " if gs is None:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW login rate limit off",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if login and self.limiter.blocked(gs.upstream_origin):",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW login failures not counted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if login and not (300 <= response.status_code < 400):",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW no periodic re-check",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if self.clock() >= gs.next_check:\n state = await self._recheck(key, gs)",
|
||||
"new": " if False:\n state = await self._recheck(key, gs)",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW fail-open when backend down",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if state is None:\n return await self._respond(send, 503, _UNAVAILABLE)",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW idle timeout off",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if self.clock() - gs.last_activity >= gs.idle_timeout:",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW absolute timeout off",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if self.wall_clock() >= gs.absolute_deadline:",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW no CSP",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " (b\"content-security-policy\", _GATEWAY_CSP.encode()),",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW no X-Frame-Options",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " (b\"x-frame-options\", b\"DENY\"),",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW no no-store",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " (b\"cache-control\", b\"no-store\"),",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW streaming body limit off",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if size > BODY_LIMIT:\n return None",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW query logged",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": "scope.get(\"path\", \"?\")[:200], status,",
|
||||
"new": "scope.get(\"path\", \"?\")[:200] + \"?\" + scope.get(\"query_string\", b\"\").decode(), status,",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW httpx URL logging on",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " logging.getLogger(_noisy).setLevel(logging.WARNING)",
|
||||
"new": " pass",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW weak gateway cookie",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": "={value}; Path=/; Secure; HttpOnly; SameSite=Strict\"",
|
||||
"new": "={value}; Path=/\"",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW no Clear-Site-Data",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " (b\"set-cookie\", cookie.encode()), (b\"clear-site-data\", b'\"cache\", \"storage\"'),",
|
||||
"new": " (b\"set-cookie\", cookie.encode()),",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW pin not used",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " verify = pinned_context(gs.ca_pem)",
|
||||
"new": " verify = False",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "GW access log on",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": "http=\"h11\", ws=\"none\", access_log=False,",
|
||||
"new": "http=\"h11\", ws=\"none\", access_log=True,",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
[
|
||||
{
|
||||
"name": "AP1 sec-fetch: cross-site not blocked",
|
||||
"file": "app/security_middleware.py",
|
||||
"old": " if site == \"cross-site\":\n return True",
|
||||
"new": " if site == \"cross-site\":\n return False",
|
||||
"tests": "tests/test_session_hardening.py"
|
||||
},
|
||||
{
|
||||
"name": "AP1 sec-fetch: same-site always allowed",
|
||||
"file": "app/security_middleware.py",
|
||||
"old": " return not _explicit_frontend_origin(request.headers.get(\"origin\", \"\"))",
|
||||
"new": " return False",
|
||||
"tests": "tests/test_session_hardening.py"
|
||||
},
|
||||
{
|
||||
"name": "AP1 sec-fetch: check not wired",
|
||||
"file": "app/security_middleware.py",
|
||||
"old": " if sec_fetch_blocked(request):",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_session_hardening.py"
|
||||
},
|
||||
{
|
||||
"name": "AP1 cookie: plain name falls back even with __Host-",
|
||||
"file": "app/routers/auth.py",
|
||||
"old": " return request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(COOKIE_NAME, \"\")",
|
||||
"new": " return request.cookies.get(COOKIE_NAME) or request.cookies.get(SECURE_COOKIE_NAME, \"\")",
|
||||
"tests": "tests/test_session_hardening.py"
|
||||
},
|
||||
{
|
||||
"name": "AP1 cookie: no __Host- prefix",
|
||||
"file": "app/routers/auth.py",
|
||||
"old": " return SECURE_COOKIE_NAME if settings.cookie_secure else COOKIE_NAME",
|
||||
"new": " return COOKIE_NAME",
|
||||
"tests": "tests/test_session_hardening.py"
|
||||
},
|
||||
{
|
||||
"name": "AP1 startup: conflict check removed",
|
||||
"file": "app/main.py",
|
||||
"old": " if conflicts:\n",
|
||||
"new": " if False:\n",
|
||||
"tests": "tests/test_session_hardening.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 ticket expiry ignored",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " elif ticket.expires_at <= now:",
|
||||
"new": " elif False:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 replay allowed",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if replayed:\n reason = \"replayed\"",
|
||||
"new": " if False:\n reason = \"replayed\"",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 session binding ignored",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " elif not secrets.compare_digest(ticket.session_hash, session_hash):",
|
||||
"new": " elif False:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 slot binding ignored",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " elif ticket.slot != slot:",
|
||||
"new": " elif False:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 revalidate: role not rechecked",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if user is None or user.role != \"admin\":",
|
||||
"new": " if user is None:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 revalidate: session not rechecked",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if session is None or session.expires_at <= _now() or session.user_id != user_id:",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 revalidate: switch not rechecked",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if not row.enabled:\n return \"disabled\", user, None",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_access.py::test_revalidate_fails_when_switched_off"
|
||||
},
|
||||
{
|
||||
"name": "AP2 absolute limit ignored",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if _now() >= gs.started_at + timedelta(minutes=row.absolute_timeout_minutes):",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 host tls requirement dropped",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if not host.verify_tls:\n return \"tls\"",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 slot limit ignored",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " if not free:\n raise",
|
||||
"new": " if False:\n raise",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 gateway token not compared",
|
||||
"file": "app/routers/gui_access.py",
|
||||
"old": " if not hmac.compare_digest(",
|
||||
"new": " if False and not hmac.compare_digest(",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 switch not checked for tickets",
|
||||
"file": "app/routers/gui_access.py",
|
||||
"old": " if not row.enabled:\n raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_ACCESS_DISABLED)",
|
||||
"new": "",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 legacy cookie accepted for tickets",
|
||||
"file": "app/routers/gui_access.py",
|
||||
"old": " session_cookie = request.cookies.get(SECURE_COOKIE_NAME, \"\")",
|
||||
"new": " session_cookie = request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(\"opnexus-session\", \"\")",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 enabling without https allowed",
|
||||
"file": "app/routers/gui_access.py",
|
||||
"old": " if payload.enabled and svc.unavailable_reasons():",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP2 redeem leaks api key",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": " \"upstream_origin\": firewall_origin(host.base_url),",
|
||||
"new": " \"upstream_origin\": firewall_origin(host.base_url), \"k\": host.api_key,",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "AP7 revalidation interval back to 30 s (switch-off took 31 s live)",
|
||||
"file": "app/services/gui_ticket_service.py",
|
||||
"old": "REVALIDATE_SECONDS = 20",
|
||||
"new": "REVALIDATE_SECONDS = 30",
|
||||
"tests": "tests/test_gui_access.py"
|
||||
},
|
||||
{
|
||||
"name": "Review: multipart login not counted",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": " if content_type.lower().startswith(\"multipart/form-data\"):",
|
||||
"new": " if False:",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
},
|
||||
{
|
||||
"name": "Review: decoded path forwarded upstream",
|
||||
"file": "app/gui_gateway.py",
|
||||
"old": "url = gs.upstream_origin + raw_path +",
|
||||
"new": "url = gs.upstream_origin + scope[\"path\"] +",
|
||||
"tests": "tests/test_gui_gateway.py"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Linking hand-made objects on a second firewall to the central object that already exists
|
||||
(services/adopt_link_service.py), instead of creating a duplicate."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.alias import FirewallAlias
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.port_forward import PortForwardRule
|
||||
from tests.helpers import deploy_approved
|
||||
from tests.test_api_tokens import _login
|
||||
from tests.test_object_adopt import AUTOMATIC_NAT, NAT_KINDS, SYSTEM_ALIASES, alias_row, o2o_row, pf_row, snat_row
|
||||
from tests.test_object_adopt import _mode
|
||||
from tests.test_rule_adopt import _row as rule_row
|
||||
|
||||
A = "https://fw-link-a.test"
|
||||
B = "https://fw-link-b.test"
|
||||
|
||||
|
||||
async def _host(client, respx_mock, base, name):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
r = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s"})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _search(respx_mock, base, path, rows):
|
||||
return respx_mock.get(f"{base}/api/firewall/{path}").mock(return_value=httpx.Response(200, json={"rows": rows, "total": len(rows)}))
|
||||
|
||||
|
||||
def _live_writes(respx_mock):
|
||||
return [c for c in respx_mock.calls if c.request.method != "GET" and "/firewall/" in str(c.request.url)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def two(client, respx_mock):
|
||||
a = await _host(client, respx_mock, A, "fw-link-a")
|
||||
b = await _host(client, respx_mock, B, "fw-link-b")
|
||||
_mode(respx_mock) # outbound NAT mode is read per base url below
|
||||
for base in (A, B):
|
||||
respx_mock.get(f"{base}/api/firewall/source_nat/get").mock(return_value=httpx.Response(
|
||||
200, json={"filter": {"general": {"snat_mode": {m: {"selected": 1 if m == "hybrid" else 0} for m in
|
||||
("automatic", "hybrid", "advanced", "disabled")}}}}))
|
||||
return a, b
|
||||
|
||||
|
||||
async def _put_vars(client, host_id, values):
|
||||
r = await client.put(f"/api/hosts/{host_id}/variables", json={"variables": values})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ── aliases ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_is_linked_not_duplicated_and_rolls_out_to_both_hosts(client, respx_mock, two):
|
||||
a, b = two
|
||||
live_a = alias_row("web_servers", "host", "10.0.0.1\n10.0.0.2", uuid="uuid-a")
|
||||
live_b = alias_row("web_servers", "host", "10.0.0.1\n10.0.0.2", uuid="uuid-b")
|
||||
_search(respx_mock, A, "alias/searchItem", SYSTEM_ALIASES + [live_a])
|
||||
_search(respx_mock, B, "alias/searchItem", SYSTEM_ALIASES + [live_b])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
|
||||
|
||||
# host B: the candidate carries the exact twin (name_exists no longer decides alone)
|
||||
cand = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()
|
||||
row = next(r for r in cand if r["uuid"] == "uuid-b")
|
||||
assert row["linkable"] is True and row["reasons"] == ["name_exists"]
|
||||
assert [(m["id"], m["name"], m["targets"], m["diff"]) for m in row["matches"]] == [(first["id"], "web_servers", ["fw-link-a"], [])]
|
||||
assert row["similar"] == [] and row["bulk_action"] == "link"
|
||||
|
||||
# adopting it as an own object is still refused (the name is taken)
|
||||
assert (await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b"})).status_code == 409
|
||||
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": first["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
linked = resp.json()
|
||||
assert linked["id"] == first["id"] and sorted(linked["target_host_ids"]) == sorted([a, b])
|
||||
assert {d["host_id"]: d["status"] for d in linked["deployments"]} == {a: "success", b: "success"}
|
||||
assert not _live_writes(respx_mock) # nothing is written to a firewall
|
||||
async with SessionLocal() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(FirewallAlias)) == 1
|
||||
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "alias"))).all()
|
||||
assert any(e.message == "Mit vorhandenem Objekt verknüpft" and e.details["remote_uuid"] == "uuid-b" for e in events)
|
||||
revisions = (await client.get(f"/api/aliases/{first['id']}/revisions")).json()
|
||||
assert any("fw-link-b verknüpft" in r["note"] for r in revisions)
|
||||
|
||||
# preview: unchanged on both hosts; it is no longer offered on B
|
||||
preview = (await client.get(f"/api/aliases/{first['id']}/preview")).json()
|
||||
assert sorted(h["action"] for h in preview["hosts"]) == ["unchanged", "unchanged"]
|
||||
assert [r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json() if r["reasons"] != ["system"]] == []
|
||||
|
||||
# one edit rolls out to both remote objects, never as a new one
|
||||
edit = {"name": "web_servers", "type": "host", "content": "10.0.0.1\n10.0.0.3", "description": "web_servers desc", "enabled": True,
|
||||
"target_host_ids": [a, b], "target_group_ids": [], "expected_updated_at": linked["updated_at"]}
|
||||
assert (await client.put(f"/api/aliases/{first['id']}", json=edit)).status_code == 200
|
||||
add = respx_mock.post(url__regex=r".*/alias/addItem").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
|
||||
set_a = respx_mock.post(f"{A}/api/firewall/alias/setItem/uuid-a").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
set_b = respx_mock.post(f"{B}/api/firewall/alias/setItem/uuid-b").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.post(url__regex=r".*/alias/reconfigure").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
assert (await deploy_approved(client, f"/api/aliases/{first['id']}/deploy")).status_code == 200
|
||||
assert set_a.called and set_b.called and not add.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_is_idempotent(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("dup_me", uuid="uuid-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("dup_me", uuid="uuid-b")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
|
||||
body = {"host_id": b, "uuid": "uuid-b", "link_to": first["id"]}
|
||||
assert (await client.post("/api/aliases/adopt", json=body)).status_code == 201
|
||||
again = await client.post("/api/aliases/adopt", json=body)
|
||||
assert again.status_code == 201, again.text
|
||||
assert len(again.json()["deployments"]) == 2 and len(again.json()["target_host_ids"]) == 2
|
||||
# linking the same live object to ANOTHER central object is refused
|
||||
other = (await client.post("/api/aliases", json={"name": "other", "type": "host", "content": "1.1.1.1"})).json()
|
||||
assert (await client.post("/api/aliases/adopt", json={**body, "link_to": other["id"]})).status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_content_shows_a_diff_and_is_never_linked(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("ntp", "host", "10.0.0.1", uuid="uuid-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("ntp", "host", "10.0.0.1\n10.0.0.9", uuid="uuid-b", description="other text")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
|
||||
row = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]
|
||||
assert row["linkable"] is False and row["matches"] == [] and row["bulk_action"] == "skip"
|
||||
sim = row["similar"][0]
|
||||
assert sim["id"] == first["id"]
|
||||
assert {d["field"]: (d["central"], d["live"]) for d in sim["diff"]} == {
|
||||
"content": ("10.0.0.1", "10.0.0.1\n10.0.0.9"), "description": ("ntp desc", "other text")}
|
||||
# forcing the link by id is refused too: the live state is compared again on the server
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": first["id"]})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
|
||||
assert "content" in resp.json()["detail"]["params"]["fields"]
|
||||
# nothing was linked
|
||||
alias = next(x for x in (await client.get("/api/aliases")).json() if x["id"] == first["id"])
|
||||
assert alias["target_host_ids"] == [a] and len(alias["deployments"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_state_is_not_trusted_live_state_decides(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("srv", "host", "10.0.0.1", uuid="uuid-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("srv", "host", "10.0.0.1", uuid="uuid-b")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
|
||||
assert (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]["linkable"] is True
|
||||
# between listing and clicking somebody changes the alias on host B by hand
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("srv", "host", "10.0.0.1\n10.0.0.77", uuid="uuid-b")])
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": first["id"]})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_variables_are_resolved_per_host(client, respx_mock, two):
|
||||
a, b = two
|
||||
await _put_vars(client, a, {"LAN_NET": "192.168.1.0/24"})
|
||||
await _put_vars(client, b, {"LAN_NET": "192.168.2.0/24"})
|
||||
central = (await client.post("/api/aliases", json={"name": "lan_net", "type": "network", "content": "{{LAN_NET}}", "description": "lan_net desc",
|
||||
"target_host_ids": [a]})).json()
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("lan_net", "network", "192.168.2.0/24", uuid="uuid-b")])
|
||||
row = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]
|
||||
assert row["linkable"] is True and row["matches"][0]["id"] == central["id"]
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": central["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
dep_b = next(d for d in resp.json()["deployments"] if d["host_id"] == b)
|
||||
assert dep_b["status"] == "success"
|
||||
async with SessionLocal() as db:
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
deps = {str(d.host_id): d for d in (await db.scalars(select(AliasDeployment))).all()}
|
||||
assert deps[b].opnsense_alias_uuid == "uuid-b" and deps[b].last_payload["content"] == "192.168.2.0/24"
|
||||
preview = (await client.get(f"/api/aliases/{central['id']}/preview")).json()
|
||||
assert {h["host_name"]: h["action"] for h in preview["hosts"]}["fw-link-b"] == "unchanged" # resolved payload == live
|
||||
|
||||
# a host with a DIFFERENT value for the variable does not match; the diff shows the resolved value
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("lan_net", "network", "192.168.9.0/24", uuid="uuid-x")])
|
||||
# (host B already has the object, so ask for it through a third host)
|
||||
c = await _host(client, respx_mock, "https://fw-link-c.test", "fw-link-c")
|
||||
await _put_vars(client, c, {"LAN_NET": "192.168.3.0/24"})
|
||||
_search(respx_mock, "https://fw-link-c.test", "alias/searchItem", [alias_row("lan_net", "network", "192.168.9.0/24", uuid="uuid-c")])
|
||||
row = (await client.get(f"/api/aliases/unmanaged?host_id={c}")).json()[0]
|
||||
assert row["matches"] == [] and row["similar"][0]["diff"] == [{"field": "content", "central": "192.168.3.0/24", "live": "192.168.9.0/24"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_variable_on_the_host_never_matches(client, respx_mock, two):
|
||||
a, b = two
|
||||
await _put_vars(client, a, {"LAN_NET": "192.168.1.0/24"})
|
||||
await client.post("/api/aliases", json={"name": "lan_net", "type": "network", "content": "{{LAN_NET}}", "description": "lan_net desc", "target_host_ids": [a]})
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("lan_net", "network", "192.168.2.0/24", uuid="uuid-b")])
|
||||
row = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]
|
||||
assert row["matches"] == [] and row["similar"][0]["diff"][0]["field"] == "variables"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_alias_references_are_part_of_the_comparison(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [
|
||||
alias_row("inner", "network", "10.1.0.0/16", uuid="in-a"),
|
||||
alias_row("outer", "networkgroup", "inner\n10.2.0.0/16", uuid="out-a")])
|
||||
result = (await client.post("/api/aliases/adopt-all", json={"host_id": a})).json()
|
||||
assert len(result["adopted"]) == 2
|
||||
_search(respx_mock, B, "alias/searchItem", [
|
||||
alias_row("inner", "network", "10.1.0.0/16", uuid="in-b"),
|
||||
alias_row("outer", "networkgroup", "inner\n10.2.0.0/16", uuid="out-b")])
|
||||
rows = {r["title"]: r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()}
|
||||
assert rows["inner"]["linkable"] and rows["outer"]["linkable"]
|
||||
# a different reference is a diff, not a match
|
||||
_search(respx_mock, B, "alias/searchItem", [
|
||||
alias_row("inner", "network", "10.1.0.0/16", uuid="in-b"),
|
||||
alias_row("outer", "networkgroup", "other_alias\n10.2.0.0/16", uuid="out-b"),
|
||||
alias_row("other_alias", "network", "10.5.0.0/16", uuid="oth-b")])
|
||||
rows = {r["title"]: r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()}
|
||||
assert rows["outer"]["matches"] == [] and rows["outer"]["similar"][0]["diff"][0]["field"] == "content"
|
||||
|
||||
# "adopt all" with link: both linked, nothing created
|
||||
_search(respx_mock, B, "alias/searchItem", [
|
||||
alias_row("inner", "network", "10.1.0.0/16", uuid="in-b"),
|
||||
alias_row("outer", "networkgroup", "inner\n10.2.0.0/16", uuid="out-b")])
|
||||
done = (await client.post("/api/aliases/adopt-all", json={"host_id": b, "link_existing": True})).json()
|
||||
assert sorted((x["title"], x["linked"]) for x in done["adopted"]) == [("inner", True), ("outer", True)] and done["skipped"] == []
|
||||
async with SessionLocal() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(FirewallAlias)) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopt_all_without_link_flag_keeps_old_behaviour(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("x1", uuid="u-a")])
|
||||
await client.post("/api/aliases/adopt-all", json={"host_id": a})
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("x1", uuid="u-b")])
|
||||
done = (await client.post("/api/aliases/adopt-all", json={"host_id": b})).json()
|
||||
assert done["adopted"] == [] and done["skipped"][0]["reasons"] == ["name_exists"]
|
||||
|
||||
|
||||
# ── NAT + rules, every kind ─────────────────────────────────────────────
|
||||
|
||||
KINDS = {
|
||||
"port-forward": ("d_nat/searchRule", pf_row, {"descr": "Web"}, {"destination.port": "9999"}, "local_port"),
|
||||
"one-to-one": ("one_to_one/searchRule", o2o_row, {"description": "Mail 1:1"}, {"external": "203.0.113.77"}, "external"),
|
||||
"outbound-nat": ("source_nat/searchRule", snat_row, {"description": "LAN out"}, {"source_net": "192.168.9.0/24"}, "source_net"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prefix", list(KINDS))
|
||||
async def test_nat_kinds_link_diff_and_bulk(client, respx_mock, two, prefix):
|
||||
a, b = two
|
||||
path, factory, _, change, _ = KINDS[prefix]
|
||||
_search(respx_mock, A, path, [factory(uuid="a-1"), *AUTOMATIC_NAT])
|
||||
first = (await client.post(f"/api/{prefix}/adopt", json={"host_id": a, "uuid": "a-1"})).json()
|
||||
|
||||
# different on B -> hint + diff, no link possible
|
||||
_search(respx_mock, B, path, [factory(uuid="b-1", **change)])
|
||||
row = (await client.get(f"/api/{prefix}/unmanaged?host_id={b}")).json()[0]
|
||||
assert row["adoptable"] is True and row["linkable"] is False and row["matches"] == []
|
||||
assert row["similar"][0]["id"] == first["id"] and row["similar"][0]["diff"]
|
||||
resp = await client.post(f"/api/{prefix}/adopt", json={"host_id": b, "uuid": "b-1", "link_to": first["id"]})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
|
||||
adopt_all = (await client.post(f"/api/{prefix}/adopt-all", json={"host_id": b, "link_existing": True})).json()
|
||||
assert adopt_all["adopted"][0]["linked"] is False # a different object is adopted as its own, not linked
|
||||
|
||||
# fresh start for the exact case on a third host
|
||||
c = await _host(client, respx_mock, "https://fw-link-c.test", "fw-link-c")
|
||||
respx_mock.get("https://fw-link-c.test/api/firewall/source_nat/get").mock(return_value=httpx.Response(
|
||||
200, json={"filter": {"general": {"snat_mode": {m: {"selected": 1 if m == "hybrid" else 0} for m in
|
||||
("automatic", "hybrid", "advanced", "disabled")}}}}))
|
||||
_search(respx_mock, "https://fw-link-c.test", path, [factory(uuid="c-1")])
|
||||
row = (await client.get(f"/api/{prefix}/unmanaged?host_id={c}")).json()[0]
|
||||
ids = [m["id"] for m in row["matches"]]
|
||||
assert ids == [first["id"]] and row["bulk_action"] == "link" and row["linkable"]
|
||||
resp = await client.post(f"/api/{prefix}/adopt", json={"host_id": c, "uuid": "c-1", "link_to": first["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["id"] == first["id"] and c in resp.json()["target_host_ids"]
|
||||
preview = (await client.get(f"/api/{prefix}/{first['id']}/preview")).json()
|
||||
assert {h["host_name"]: h["action"] for h in preview["hosts"]}["fw-link-c"] == "unchanged"
|
||||
assert not _live_writes(respx_mock)
|
||||
listed = (await client.get(f"/api/{prefix}")).json()
|
||||
assert len(listed) == 2 # first (A + C) and the different one from B
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_forward_link_updates_remote_object_without_duplicate(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "d_nat/searchRule", [pf_row(uuid="pf-a")])
|
||||
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="pf-b")])
|
||||
first = (await client.post("/api/port-forward/adopt", json={"host_id": a, "uuid": "pf-a"})).json()
|
||||
linked = (await client.post("/api/port-forward/adopt", json={"host_id": b, "uuid": "pf-b", "link_to": first["id"]})).json()
|
||||
edit = {k: linked[k] for k in ("description", "interface", "ipprotocol", "protocol", "source_net", "source_port",
|
||||
"destination_net", "destination_port", "target", "local_port", "natreflection", "enabled")}
|
||||
for base in (A, B): # alias lookups and the interface consistency check of a multi-host rollout
|
||||
_search(respx_mock, base, "alias/searchItem", [])
|
||||
for base in (A, B): # the interface consistency check of a multi-host rollout
|
||||
respx_mock.get(f"{base}/api/firewall/filter/getRule").mock(return_value=httpx.Response(
|
||||
200, json={"rule": {"interface": {"wan": {"value": "WAN", "selected": 1}}}}))
|
||||
edit.update(target="192.168.1.99", target_host_ids=[a, b], target_group_ids=[], expected_updated_at=linked["updated_at"])
|
||||
assert (await client.put(f"/api/port-forward/{first['id']}", json=edit)).status_code == 200
|
||||
add = respx_mock.post(url__regex=r".*/d_nat/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
|
||||
sa = respx_mock.post(f"{A}/api/firewall/d_nat/setRule/pf-a").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
sb = respx_mock.post(f"{B}/api/firewall/d_nat/setRule/pf-b").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.post(url__regex=r".*/d_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
assert (await deploy_approved(client, f"/api/port-forward/{first['id']}/deploy")).status_code == 200
|
||||
assert sa.called and sb.called and not add.called
|
||||
assert json.loads(sb.calls.last.request.content)["rule"]["target"] == "192.168.1.99"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rule_link_diff_and_errors(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "filter/searchRule", [rule_row("r-a")])
|
||||
_search(respx_mock, B, "filter/searchRule", [rule_row("r-b")])
|
||||
first = (await client.post("/api/rules/adopt", json={"host_id": a, "rule_uuid": "r-a"})).json()
|
||||
row = (await client.get(f"/api/rules/unmanaged?host_id={b}")).json()[0]
|
||||
assert row["linkable"] is True and [m["id"] for m in row["matches"]] == [first["id"]]
|
||||
resp = await client.post("/api/rules/adopt", json={"host_id": b, "rule_uuid": "r-b", "link_to": first["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["id"] == first["id"] and sorted(resp.json()["target_host_ids"]) == sorted([a, b])
|
||||
again = await client.post("/api/rules/adopt", json={"host_id": b, "rule_uuid": "r-b", "link_to": first["id"]})
|
||||
assert again.status_code == 201 and len(again.json()["deployments"]) == 2
|
||||
preview = (await client.get(f"/api/rules/{first['id']}/preview")).json()
|
||||
assert sorted(h["action"] for h in preview["hosts"]) == ["unchanged", "unchanged"]
|
||||
assert not _live_writes(respx_mock)
|
||||
|
||||
# different port -> diff only
|
||||
c = await _host(client, respx_mock, "https://fw-link-c.test", "fw-link-c")
|
||||
_search(respx_mock, "https://fw-link-c.test", "filter/searchRule", [rule_row("r-c", destination_port="2222")])
|
||||
row = (await client.get(f"/api/rules/unmanaged?host_id={c}")).json()[0]
|
||||
assert row["linkable"] is False and row["similar"][0]["diff"] == [{"field": "destination_port", "central": "22", "live": "2222"}]
|
||||
refused = await client.post("/api/rules/adopt", json={"host_id": c, "rule_uuid": "r-c", "link_to": first["id"]})
|
||||
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
|
||||
|
||||
|
||||
# ── error cases ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_target_and_host_already_has_object_and_busy(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("gone", uuid="u-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("gone", uuid="u-b")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
|
||||
# busy: an unconfirmed deployment on the central object
|
||||
respx_mock.post(f"{A}/api/firewall/alias/setItem/u-a").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.post(url__regex=r".*/alias/reconfigure").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
edit = {"name": "gone", "type": "host", "content": "10.9.9.1\n10.9.9.2", "description": "gone desc", "enabled": False,
|
||||
"target_host_ids": [a], "target_group_ids": [], "expected_updated_at": first["updated_at"]}
|
||||
assert (await client.put(f"/api/aliases/{first['id']}", json=edit)).status_code == 200
|
||||
assert (await deploy_approved(client, f"/api/aliases/{first['id']}/deploy")).status_code == 200 # pending now
|
||||
busy = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
|
||||
assert busy.status_code == 409 # content differs now AND it is pending; either way nothing is linked
|
||||
await client.post(f"/api/aliases/{first['id']}/confirm")
|
||||
|
||||
# deleted in the meantime
|
||||
respx_mock.post(url__regex=r".*/alias/delItem/.*").mock(return_value=httpx.Response(200, json={"result": "deleted"}))
|
||||
assert (await client.delete(f"/api/aliases/{first['id']}")).status_code == 204
|
||||
gone = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
|
||||
assert gone.status_code == 404 and gone.json()["detail"]["code"] == "ADOPT_LINK_TARGET_NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_deployment_blocks_the_link(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("busy1", uuid="u-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("busy1", uuid="u-b")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
|
||||
async with SessionLocal() as db:
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
dep = (await db.scalars(select(AliasDeployment))).one()
|
||||
dep.status = "pending"
|
||||
await db.commit()
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_BUSY"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_that_already_has_the_object_is_not_a_candidate_and_cannot_link(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("twice", uuid="u-a")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
|
||||
# a second, differently-UUID'd copy on host A itself: A already has the object
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("twice", uuid="u-a"), alias_row("twice", uuid="u-a2")])
|
||||
rows = (await client.get(f"/api/aliases/unmanaged?host_id={a}")).json()
|
||||
assert [r["uuid"] for r in rows] == ["u-a2"] and rows[0]["matches"] == [] and rows[0]["linkable"] is False
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a2", "link_to": first["id"]})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_HOST_HAS_OBJECT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_never_guesses_between_two_twins_and_links_each_object_once(client, respx_mock, two):
|
||||
a, b = two
|
||||
# two identical central objects exist from earlier separate adoptions
|
||||
for _ in range(2):
|
||||
await client.post("/api/port-forward", json={
|
||||
"description": "Web", "interface": "wan", "ipprotocol": "inet", "protocol": "tcp", "source_net": "any",
|
||||
"destination_net": "wanip", "destination_port": "8443", "target": "192.168.1.50", "local_port": "443",
|
||||
"natreflection": "", "enabled": True, "target_host_ids": [a], "target_group_ids": []})
|
||||
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="pf-b")])
|
||||
row = (await client.get(f"/api/port-forward/unmanaged?host_id={b}")).json()[0]
|
||||
assert len(row["matches"]) == 2 and row["bulk_action"] == "new" # ask the user, do not guess
|
||||
done = (await client.post("/api/port-forward/adopt-all", json={"host_id": b, "link_existing": True})).json()
|
||||
assert [x["linked"] for x in done["adopted"]] == [False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_links_each_central_object_only_once(client, respx_mock, two):
|
||||
a, b = two
|
||||
await client.post("/api/port-forward", json={
|
||||
"description": "Web", "interface": "wan", "ipprotocol": "inet", "protocol": "tcp", "source_net": "any",
|
||||
"destination_net": "wanip", "destination_port": "8443", "target": "192.168.1.50", "local_port": "443",
|
||||
"natreflection": "", "enabled": True, "target_host_ids": [a], "target_group_ids": []})
|
||||
# two identical live rows on B, ONE twin: the first links, the second becomes its own object
|
||||
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="b-1"), pf_row(uuid="b-2")])
|
||||
rows = (await client.get(f"/api/port-forward/unmanaged?host_id={b}")).json()
|
||||
assert sorted(r["bulk_action"] for r in rows) == ["link", "new"] # the dialog's summary equals what happens
|
||||
done = (await client.post("/api/port-forward/adopt-all", json={"host_id": b, "link_existing": True})).json()
|
||||
assert sorted(x["linked"] for x in done["adopted"]) == [False, True]
|
||||
assert len((await client.get("/api/port-forward")).json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roles_for_linking(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("roles1", uuid="u-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("roles1", uuid="u-b")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
|
||||
for role, expected in (("read_only", 403), ("operator", 201)):
|
||||
user = await client.post("/api/users", json={"username": f"lnk-{role}", "password": "adopt-test-password-1", "role": role})
|
||||
assert user.status_code == 201, user.text
|
||||
session = await _login(f"lnk-{role}", "adopt-test-password-1")
|
||||
try:
|
||||
resp = await session.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
|
||||
assert resp.status_code == expected, (role, resp.text)
|
||||
finally:
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_leaves_a_twin_alone_that_waits_for_a_confirmation(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("busy2", uuid="u-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("busy2", uuid="u-b")])
|
||||
await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})
|
||||
async with SessionLocal() as db:
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
(await db.scalars(select(AliasDeployment))).one().status = "pending"
|
||||
await db.commit()
|
||||
row = next(r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json() if r["uuid"] == "u-b")
|
||||
assert row["linkable"] is False and row["bulk_action"] == "skip"
|
||||
resp = await client.post("/api/aliases/adopt-all", json={"host_id": b, "link_existing": True})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["adopted"] == []
|
||||
assert "twin_busy" in resp.json()["skipped"][0]["reasons"]
|
||||
async with SessionLocal() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(FirewallAlias)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linking_invalidates_an_open_edit_form(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "alias/searchItem", [alias_row("stale1", uuid="u-a")])
|
||||
_search(respx_mock, B, "alias/searchItem", [alias_row("stale1", uuid="u-b")])
|
||||
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
|
||||
linked = (await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})).json()
|
||||
assert linked["updated_at"] != first["updated_at"]
|
||||
stale = {"name": "stale1", "type": "host", "content": linked["content"], "description": linked["description"],
|
||||
"enabled": linked["enabled"], "target_host_ids": [a], "target_group_ids": [],
|
||||
"expected_updated_at": first["updated_at"]}
|
||||
assert (await client.put(f"/api/aliases/{first['id']}", json=stale)).status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_neither_links_nor_duplicates_a_busy_nat_twin(client, respx_mock, two):
|
||||
a, b = two
|
||||
_search(respx_mock, A, "d_nat/searchRule", [pf_row(uuid="pf-a")])
|
||||
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="pf-b")])
|
||||
await client.post("/api/port-forward/adopt", json={"host_id": a, "uuid": "pf-a"})
|
||||
async with SessionLocal() as db:
|
||||
from app.models.port_forward_deployment import PortForwardDeployment
|
||||
(await db.scalars(select(PortForwardDeployment))).one().status = "pending"
|
||||
await db.commit()
|
||||
resp = await client.post("/api/port-forward/adopt-all", json={"host_id": b, "link_existing": True})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["adopted"] == [] and "twin_busy" in resp.json()["skipped"][0]["reasons"]
|
||||
async with SessionLocal() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(PortForwardRule)) == 1
|
||||
@@ -0,0 +1,330 @@
|
||||
"""GUI access (1.27): settings, one-time tickets and the gateway's internal redeem/revalidate/end API."""
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.main import app
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.gui_access import GuiSession, GuiTicket
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.routers.auth import SECURE_COOKIE_NAME
|
||||
|
||||
GW_TOKEN = "gateway-token-" + "x" * 40
|
||||
SITES = "https://opnexus.example:9441,https://opnexus.example:9442,https://opnexus.example:9443"
|
||||
PW = "gui-user-password-1"
|
||||
|
||||
|
||||
def sha(v: str) -> str:
|
||||
return hashlib.sha256(v.encode()).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gui_env(monkeypatch):
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
monkeypatch.setattr(settings, "gui_gateway_token", GW_TOKEN)
|
||||
monkeypatch.setattr(settings, "opnexus_gui_sites", SITES)
|
||||
|
||||
|
||||
async def _https_login(username: str, password: str) -> AsyncClient:
|
||||
ac = AsyncClient(transport=ASGITransport(app=app), base_url="https://opnexus.example")
|
||||
resp = await ac.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert ac.cookies.get(SECURE_COOKIE_NAME)
|
||||
return ac
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin(gui_env):
|
||||
ac = await _https_login(settings.admin_username, settings.admin_password)
|
||||
yield ac
|
||||
await ac.aclose()
|
||||
|
||||
|
||||
async def _user(admin: AsyncClient, name: str, role: str) -> AsyncClient:
|
||||
resp = await admin.post("/api/users", json={"username": name, "password": PW, "role": role})
|
||||
if resp.status_code == 409 or (resp.status_code == 400 and "BENUTZERNAME" in resp.text):
|
||||
users = (await admin.get("/api/users")).json()
|
||||
uid = next(u["id"] for u in users if u["username"] == name)
|
||||
await admin.patch(f"/api/users/{uid}/role", json={"role": role})
|
||||
else:
|
||||
assert resp.status_code == 201, resp.text
|
||||
return await _https_login(name, PW)
|
||||
|
||||
|
||||
async def _host(name="fw-gui", base_url="https://fw-gui.test", verify_tls=True, enabled=True) -> uuid.UUID:
|
||||
async with SessionLocal() as db:
|
||||
host = OpnsenseHost(name=name, base_url=base_url, api_key="apikey-must-not-leak", verify_tls=verify_tls,
|
||||
enabled=enabled, tls_ca_pem="-----BEGIN CERTIFICATE-----\nPIN\n-----END CERTIFICATE-----\n")
|
||||
db.add(host)
|
||||
await db.commit()
|
||||
return host.id
|
||||
|
||||
|
||||
async def _enable(admin: AsyncClient, **over) -> dict:
|
||||
body = {"enabled": True, "idle_timeout_minutes": 15, "absolute_timeout_minutes": 60, "slot_count": 4, **over}
|
||||
resp = await admin.put("/api/settings/gui-access", json=body)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _ticket_from(url: str) -> str:
|
||||
return url.split("?t=", 1)[1]
|
||||
|
||||
|
||||
async def _internal(path: str, body: dict, token: str = GW_TOKEN):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://backend:8000") as ac:
|
||||
return await ac.post(f"/internal/gui/{path}", json=body, headers={"X-Onx-Gateway-Token": token})
|
||||
|
||||
|
||||
async def _redeem(admin: AsyncClient, url: str, slot: int | None = None, session_hash: str | None = None):
|
||||
return await _internal("redeem", {
|
||||
"ticket_hash": sha(_ticket_from(url)), "slot": slot or int(url.split(":")[2].split("/")[0]),
|
||||
"session_hash": session_hash or sha(admin.cookies.get(SECURE_COOKIE_NAME)), "client_ip": "192.0.2.10",
|
||||
})
|
||||
|
||||
|
||||
# ── settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_default_off_and_switchable(admin):
|
||||
data = (await admin.get("/api/settings/gui-access")).json()
|
||||
assert data["enabled"] is False and data["available"] is True and data["slots"] == [9441, 9442, 9443]
|
||||
assert (await _enable(admin, slot_count=2))["slots"] == [9441, 9442]
|
||||
bad = await admin.put("/api/settings/gui-access", json={"enabled": True, "idle_timeout_minutes": 90,
|
||||
"absolute_timeout_minutes": 60, "slot_count": 2})
|
||||
assert bad.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_enable_without_https_cookies(admin, monkeypatch):
|
||||
monkeypatch.setattr(settings, "cookie_secure", False)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as plain:
|
||||
await plain.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
||||
resp = await plain.put("/api/settings/gui-access", json={"enabled": True, "idle_timeout_minutes": 15,
|
||||
"absolute_timeout_minutes": 60, "slot_count": 4})
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "GUI_GATEWAY_NOT_CONFIGURED"
|
||||
assert "cookie_secure" in (await plain.get("/api/settings/gui-access")).json()["unavailable_reasons"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_admin_only(admin):
|
||||
op = await _user(admin, "gui-op-settings", "operator")
|
||||
assert (await op.get("/api/settings/gui-access")).status_code == 403
|
||||
assert (await op.put("/api/settings/gui-access", json={"enabled": False, "idle_timeout_minutes": 15,
|
||||
"absolute_timeout_minutes": 60, "slot_count": 4})).status_code == 403
|
||||
|
||||
|
||||
# ── tickets ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticket_issued_and_only_hash_stored(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
resp = await admin.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["url"].startswith("https://opnexus.example:9441/__onx/redeem?t=") and data["expires_in"] == 30
|
||||
raw = _ticket_from(data["url"])
|
||||
async with SessionLocal() as db:
|
||||
ticket = (await db.scalars(select(GuiTicket))).one()
|
||||
assert ticket.ticket_hash == sha(raw) and raw not in repr(ticket.__dict__)
|
||||
assert ticket.session_hash == sha(admin.cookies.get(SECURE_COOKIE_NAME))
|
||||
assert (ticket.expires_at - ticket.created_at) == timedelta(seconds=30)
|
||||
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "gui_access"))).all()
|
||||
assert [e.action for e in events] == ["ticket"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("role,code", [("operator", "ADMIN_REQUIRED"), ("read_only", "READ_ONLY_ACCESS")])
|
||||
async def test_ticket_refused_for_non_admins(admin, role, code):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
other = await _user(admin, f"gui-{role}", role)
|
||||
resp = await other.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 403 and resp.json()["detail"]["code"] == code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticket_refused_for_api_tokens(admin, anon_client):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
token = (await admin.post("/api/tokens", json={"name": "gui", "scope": "admin", "current_password": settings.admin_password})).json()["token"]
|
||||
resp = await anon_client.post(f"/api/hosts/{host_id}/gui-ticket", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticket_refused_when_switch_off_or_not_configured(admin, monkeypatch):
|
||||
host_id = await _host()
|
||||
resp = await admin.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "GUI_ACCESS_DISABLED"
|
||||
await _enable(admin)
|
||||
monkeypatch.setattr(settings, "gui_gateway_token", "short")
|
||||
resp = await admin.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "GUI_GATEWAY_NOT_CONFIGURED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticket_refused_without_secure_cookie(admin, monkeypatch):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
monkeypatch.setattr(settings, "cookie_secure", False)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as plain:
|
||||
await plain.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
||||
resp = await plain.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] in ("GUI_REQUIRES_HTTPS", "GUI_GATEWAY_NOT_CONFIGURED")
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
# A legacy plain-name cookie (pre-migration) is not enough either.
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="https://opnexus.example") as legacy:
|
||||
legacy.cookies.set("opnexus-session", admin.cookies.get(SECURE_COOKIE_NAME))
|
||||
resp = await legacy.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "GUI_REQUIRES_HTTPS"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kwargs,reason", [
|
||||
({"verify_tls": False}, "tls"), ({"base_url": "http://fw-plain.test"}, "https"), ({"enabled": False}, "disabled"),
|
||||
])
|
||||
async def test_ticket_refused_for_ineligible_host(admin, kwargs, reason):
|
||||
host_id = await _host(**kwargs)
|
||||
await _enable(admin)
|
||||
resp = await admin.post(f"/api/hosts/{host_id}/gui-ticket")
|
||||
assert resp.status_code == 409 and resp.json()["detail"] == {"code": "GUI_HOST_NOT_ELIGIBLE", "params": {"reason": reason}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slots_fixed_per_firewall_and_limited(admin):
|
||||
a, b, c = await _host("fw-a"), await _host("fw-b", "https://fw-b.test"), await _host("fw-c", "https://fw-c.test")
|
||||
await _enable(admin, slot_count=2)
|
||||
slot_a = (await admin.post(f"/api/hosts/{a}/gui-ticket")).json()["slot"]
|
||||
slot_b = (await admin.post(f"/api/hosts/{b}/gui-ticket")).json()["slot"]
|
||||
assert slot_a != slot_b and {slot_a, slot_b} == {9441, 9442}
|
||||
assert (await admin.post(f"/api/hosts/{a}/gui-ticket")).json()["slot"] == slot_a
|
||||
full = await admin.post(f"/api/hosts/{c}/gui-ticket")
|
||||
assert full.status_code == 409 and full.json()["detail"]["code"] == "GUI_NO_FREE_SLOT"
|
||||
|
||||
|
||||
# ── internal API ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_api_needs_gateway_token(admin, monkeypatch):
|
||||
body = {"gui_session_id": str(uuid.uuid4()), "session_hash": "0" * 64}
|
||||
assert (await _internal("revalidate", body, token="wrong")).status_code == 401
|
||||
assert (await _internal("revalidate", body, token="")).status_code == 401
|
||||
monkeypatch.setattr(settings, "gui_gateway_token", "")
|
||||
assert (await _internal("revalidate", body, token="")).status_code == 404
|
||||
# Not part of the public API surface.
|
||||
assert "/internal/gui/redeem" not in (await admin.get("/api/openapi.json")).text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeem_returns_no_credentials_and_is_single_use(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
url = (await admin.post(f"/api/hosts/{host_id}/gui-ticket")).json()["url"]
|
||||
resp = await _redeem(admin, url)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["upstream_origin"] == "https://fw-gui.test" and "PIN" in data["ca_pem"]
|
||||
assert data["idle_timeout_seconds"] == 900 and data["revalidate_seconds"] == 20
|
||||
assert "apikey-must-not-leak" not in resp.text and "secret" not in resp.text.lower() and "api_key" not in resp.text
|
||||
again = await _redeem(admin, url)
|
||||
assert again.status_code == 403 and again.json()["detail"]["reason"] == "replayed"
|
||||
async with SessionLocal() as db:
|
||||
actions = [e.action for e in (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "gui_access")
|
||||
.order_by(AuditEvent.created_at))).all()]
|
||||
assert actions[:2] == ["ticket", "redeem"] and actions.count("redeem") == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeem_bound_to_session_slot_and_time(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
url = (await admin.post(f"/api/hosts/{host_id}/gui-ticket")).json()["url"]
|
||||
wrong = await _redeem(admin, url, session_hash=sha("another-browser-session"))
|
||||
assert wrong.status_code == 403 and wrong.json()["detail"]["reason"] == "session"
|
||||
assert (await _redeem(admin, url)).json()["detail"]["reason"] == "replayed" # burned by the failed attempt
|
||||
|
||||
url = (await admin.post(f"/api/hosts/{host_id}/gui-ticket")).json()["url"]
|
||||
assert (await _redeem(admin, url, slot=9443)).json()["detail"]["reason"] == "slot"
|
||||
|
||||
url = (await admin.post(f"/api/hosts/{host_id}/gui-ticket")).json()["url"]
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(update(GuiTicket).where(GuiTicket.ticket_hash == sha(_ticket_from(url)))
|
||||
.values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)))
|
||||
await db.commit()
|
||||
assert (await _redeem(admin, url)).json()["detail"]["reason"] == "expired"
|
||||
unknown = await _internal("redeem", {"ticket_hash": sha("nope"), "session_hash": sha("x"), "slot": 9441})
|
||||
assert unknown.status_code == 403 and unknown.json()["detail"]["reason"] == "unknown"
|
||||
|
||||
|
||||
async def _open(admin: AsyncClient, host_id) -> dict:
|
||||
url = (await admin.post(f"/api/hosts/{host_id}/gui-ticket")).json()["url"]
|
||||
data = (await _redeem(admin, url)).json()
|
||||
return {"gui_session_id": data["gui_session_id"], "session_hash": sha(admin.cookies.get(SECURE_COOKIE_NAME))}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revalidate_ok_then_logout_ends_it(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
ref = await _open(admin, host_id)
|
||||
ok = (await _internal("revalidate", ref)).json()
|
||||
assert ok["ok"] is True and ok["revalidate_seconds"] == 20
|
||||
await admin.post("/api/auth/logout")
|
||||
assert (await _internal("revalidate", ref)).json() == {"ok": False, "reason": "session"}
|
||||
assert (await _internal("revalidate", ref)).json()["ok"] is False
|
||||
async with SessionLocal() as db:
|
||||
ends = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "gui_access", AuditEvent.action == "end"))).all()
|
||||
assert len(ends) == 1 and ends[0].details["reason"] == "session" and "duration_seconds" in ends[0].details
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revalidate_fails_after_demotion(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
second = await _user(admin, "gui-second-admin", "admin")
|
||||
ref = await _open(second, host_id)
|
||||
users = (await admin.get("/api/users")).json()
|
||||
uid = next(u["id"] for u in users if u["username"] == "gui-second-admin")
|
||||
assert (await admin.patch(f"/api/users/{uid}/role", json={"role": "operator"})).status_code == 200
|
||||
assert (await _internal("revalidate", ref)).json()["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revalidate_fails_when_switched_off(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
ref = await _open(admin, host_id)
|
||||
await _enable(admin, enabled=False)
|
||||
assert (await _internal("revalidate", ref)).json()["ok"] is False
|
||||
# The live re-check alone (switch flipped without the end-all sweep) also ends it.
|
||||
await _enable(admin)
|
||||
ref = await _open(admin, host_id)
|
||||
from app.models.gui_access import GuiAccessSettings
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(update(GuiAccessSettings).values(enabled=False))
|
||||
await db.commit()
|
||||
assert (await _internal("revalidate", ref)).json() == {"ok": False, "reason": "disabled"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revalidate_enforces_absolute_limit_and_end(admin):
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
ref = await _open(admin, host_id)
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(update(GuiSession).values(started_at=datetime.now(timezone.utc) - timedelta(minutes=61)))
|
||||
await db.commit()
|
||||
assert (await _internal("revalidate", ref)).json() == {"ok": False, "reason": "absolute"}
|
||||
ref2 = await _open(admin, host_id)
|
||||
assert (await _internal("end", {**ref2, "reason": "idle"})).json() == {"ok": True}
|
||||
assert (await _internal("revalidate", ref2)).json() == {"ok": False, "reason": "idle"}
|
||||
assert (await _internal("end", {**ref2, "session_hash": sha("x"), "reason": "idle"})).json() == {"ok": False}
|
||||
@@ -0,0 +1,470 @@
|
||||
"""GUI gateway (1.27): every counter-measure of the design's threat table against a mock firewall,
|
||||
with the real backend app behind the internal API."""
|
||||
import datetime
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, Response, StreamingResponse
|
||||
|
||||
from app import gui_gateway as gw
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.main import app as backend_app
|
||||
from app.models.gui_access import GuiSession
|
||||
from app.routers.auth import SECURE_COOKIE_NAME
|
||||
from tests.test_gui_access import GW_TOKEN, SITES, _enable, _host, _https_login, gui_env # noqa: F401
|
||||
|
||||
PASSWORD = "S3cret-Testpasswort!"
|
||||
OWN = "https://opnexus.example:9441"
|
||||
|
||||
|
||||
class MockFirewall:
|
||||
"""Records what reaches the "firewall" and answers like OPNsense's authgui.inc."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
request = Request(scope, receive)
|
||||
body = await request.body()
|
||||
self.requests.append({"method": request.method, "path": request.url.path, "query": request.url.query,
|
||||
"headers": dict(request.headers), "body": body})
|
||||
path = request.url.path
|
||||
if path == "/" and request.method == "POST":
|
||||
from urllib.parse import parse_qs
|
||||
if parse_qs(body.decode()).get("passwordfld") == ["right"]:
|
||||
response = Response(status_code=302, headers={"Location": "/ui/core/dashboard"})
|
||||
else:
|
||||
response = HTMLResponse("<form>Wrong username or password.</form>")
|
||||
elif path == "/redir":
|
||||
response = Response(status_code=302, headers={"Location": request.query_params["to"]})
|
||||
elif path == "/logout":
|
||||
response = Response(status_code=302, headers={"Location": "/"})
|
||||
response.headers.append("set-cookie", "PHPSESSID=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/")
|
||||
elif path == "/big":
|
||||
async def chunks():
|
||||
for i in range(50):
|
||||
yield (b"%04d" % i) * 1024
|
||||
response = StreamingResponse(chunks(), media_type="application/octet-stream")
|
||||
else:
|
||||
response = HTMLResponse("<html>firewall</html>", headers={"Content-Security-Policy": "default-src 'self'"})
|
||||
response.headers.append("set-cookie", "PHPSESSID=fw-session-1; path=/; secure; HttpOnly")
|
||||
response.headers.append("x-powered-by", "PHP")
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firewall():
|
||||
return MockFirewall()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def setup(gui_env, firewall): # noqa: F811
|
||||
admin = await _https_login(settings.admin_username, settings.admin_password)
|
||||
host_id = await _host()
|
||||
await _enable(admin)
|
||||
clock = Clock()
|
||||
gateway = gw.GuiGateway(
|
||||
backend=gw.BackendClient("http://backend:8000", GW_TOKEN, transport=ASGITransport(app=backend_app)),
|
||||
slot_origins=gw.slot_origins_from(SITES),
|
||||
upstream_factory=lambda gs: httpx.AsyncClient(transport=ASGITransport(app=firewall)),
|
||||
clock=clock,
|
||||
)
|
||||
browser = AsyncClient(transport=ASGITransport(app=gateway), base_url=OWN)
|
||||
yield {"admin": admin, "host_id": host_id, "gateway": gateway, "browser": browser, "clock": clock, "fw": firewall}
|
||||
await browser.aclose()
|
||||
await admin.aclose()
|
||||
|
||||
|
||||
async def _ticket(s) -> tuple[str, int]:
|
||||
data = (await s["admin"].post(f"/api/hosts/{s['host_id']}/gui-ticket")).json()
|
||||
return data["url"].split("?t=", 1)[1], data["slot"]
|
||||
|
||||
|
||||
def _opnexus_cookie(s) -> str:
|
||||
return f"{SECURE_COOKIE_NAME}={s['admin'].cookies.get(SECURE_COOKIE_NAME)}"
|
||||
|
||||
|
||||
async def _redeem(s, ticket: str, slot: int = 9441, site: str = "same-site", cookie: str | None = None):
|
||||
return await s["browser"].get(f"/__onx/redeem?t={ticket}", headers={
|
||||
"X-Onx-Slot": str(slot), "Sec-Fetch-Site": site, "Cookie": _opnexus_cookie(s) if cookie is None else cookie})
|
||||
|
||||
|
||||
async def _open(s) -> str:
|
||||
ticket, slot = await _ticket(s)
|
||||
resp = await _redeem(s, ticket, slot)
|
||||
assert resp.status_code == 200, resp.text
|
||||
raw = resp.headers["set-cookie"]
|
||||
return raw.split(";")[0] # "__Host-onx-gw-9441=<value>"
|
||||
|
||||
|
||||
async def _get(s, path: str, gw_cookie: str, slot: int = 9441, site: str | None = "same-origin", extra: dict | None = None,
|
||||
method: str = "GET", **kwargs):
|
||||
headers = {"X-Onx-Slot": str(slot), "Cookie": f"{_opnexus_cookie(s)}; {gw_cookie}; other=1", **(extra or {})}
|
||||
if site is not None:
|
||||
headers["Sec-Fetch-Site"] = site
|
||||
return await s["browser"].request(method, path, headers=headers, **kwargs)
|
||||
|
||||
|
||||
# ── redeem ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeem_sets_host_cookie_and_clears_site_data(setup):
|
||||
ticket, slot = await _ticket(setup)
|
||||
resp = await _redeem(setup, ticket, slot)
|
||||
assert resp.status_code == 200
|
||||
cookie = resp.headers["set-cookie"]
|
||||
assert cookie.startswith(f"__Host-onx-gw-{slot}=")
|
||||
for attr in ("Path=/", "Secure", "HttpOnly", "SameSite=Strict"):
|
||||
assert attr in cookie
|
||||
assert "Domain" not in cookie and ticket not in resp.text
|
||||
assert resp.headers["clear-site-data"] == '"cache", "storage"'
|
||||
assert 'http-equiv="refresh"' in resp.text and resp.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeem_refused_without_session_cookie_cross_site_or_twice(setup):
|
||||
ticket, _ = await _ticket(setup)
|
||||
# Refused by the gateway itself, before the backend sees (and burns) the ticket:
|
||||
assert (await _redeem(setup, ticket, cookie="")).status_code == 403
|
||||
assert (await _redeem(setup, ticket, site="cross-site")).status_code == 403
|
||||
assert (await _redeem(setup, ticket, site="")).status_code == 403
|
||||
assert (await _redeem(setup, ticket)).status_code == 200
|
||||
assert (await _redeem(setup, ticket)).status_code == 403 # single use
|
||||
other = await _ticket(setup)
|
||||
assert (await _redeem(setup, other[0], slot=9442)).status_code == 403 # bound to its slot
|
||||
|
||||
|
||||
# ── cookies never cross ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_cookies_never_upstream_and_firewall_cookies_never_downstream(setup):
|
||||
cookie = await _open(setup)
|
||||
first = await _get(setup, "/ui/core/dashboard", cookie)
|
||||
assert first.status_code == 200 and "set-cookie" not in first.headers and "x-powered-by" not in first.headers
|
||||
second = await _get(setup, "/ui/x", cookie)
|
||||
assert second.status_code == 200
|
||||
up = setup["fw"].requests
|
||||
assert "cookie" not in up[0]["headers"]
|
||||
assert up[1]["headers"]["cookie"] == "PHPSESSID=fw-session-1"
|
||||
session_value = setup["admin"].cookies.get(SECURE_COOKIE_NAME)
|
||||
for r in up:
|
||||
flat = repr(r["headers"])
|
||||
assert session_value not in flat and "onx-gw" not in flat and "other=1" not in flat
|
||||
# A firewall logout deletes the cookie from the jar.
|
||||
await _get(setup, "/logout", cookie)
|
||||
await _get(setup, "/ui/y", cookie)
|
||||
assert "cookie" not in setup["fw"].requests[-1]["headers"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_rebuilt_with_header_allow_list(setup):
|
||||
cookie = await _open(setup)
|
||||
await _get(setup, "/ui/a?b=1", cookie, extra={
|
||||
"Authorization": "Basic Zm9vOmJhcg==", "Referer": f"{OWN}/ui/prev", "X-Forwarded-For": "192.0.2.99",
|
||||
"X-CSRFToken": "csrf-1", "Accept-Language": "de", "Proxy-Authorization": "x",
|
||||
})
|
||||
r = setup["fw"].requests[-1]
|
||||
assert r["path"] == "/ui/a" and r["query"] == "b=1"
|
||||
h = r["headers"]
|
||||
assert h["host"] == "fw-gui.test" and h["x-csrftoken"] == "csrf-1" and h["accept-language"] == "de"
|
||||
for dropped in ("authorization", "referer", "origin", "x-forwarded-for", "proxy-authorization", "x-onx-slot",
|
||||
"sec-fetch-site"):
|
||||
assert dropped not in h
|
||||
|
||||
|
||||
# ── redirects ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("target,expected", [
|
||||
("https://evil.example/x", None), ("//evil.example/x", None), ("/\\evil.example", None),
|
||||
("https://fw-gui.test:8443/x", None), ("http://fw-gui.test/x", None),
|
||||
("https://fw-gui.test/ui/x?y=1", "/ui/x?y=1"), ("https://FW-GUI.test:443/", "/"), ("/ui/z", "/ui/z"),
|
||||
])
|
||||
async def test_location_rewritten_or_dropped(setup, target, expected):
|
||||
cookie = await _open(setup)
|
||||
resp = await _get(setup, "/redir", cookie, params={"to": target})
|
||||
if expected is None:
|
||||
assert resp.status_code == 502 and "location" not in resp.headers
|
||||
else:
|
||||
assert resp.status_code == 302 and resp.headers["location"] == expected
|
||||
|
||||
|
||||
# ── request shape ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _raw(gateway, method="GET", path="/", raw_path=None, headers=None, scope_type="http"):
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
scope = {"type": scope_type, "method": method, "path": path, "raw_path": raw_path if raw_path is not None else path.encode(),
|
||||
"query_string": b"", "headers": [(k.encode(), v.encode()) for k, v in (headers or {}).items()]}
|
||||
await gateway(scope, receive, send)
|
||||
return sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_form_connect_upgrade_websocket_service_worker_refused(setup):
|
||||
g = setup["gateway"]
|
||||
base = {"x-onx-slot": "9441", "sec-fetch-site": "same-origin"}
|
||||
assert (await _raw(g, path="http://evil.example/", headers=base))[0]["status"] == 400
|
||||
assert (await _raw(g, path="/", raw_path=b"http://evil.example/", headers=base))[0]["status"] == 400
|
||||
assert (await _raw(g, path="//evil.example/", headers=base))[0]["status"] == 400
|
||||
assert (await _raw(g, method="CONNECT", path="/", headers=base))[0]["status"] == 501
|
||||
assert (await _raw(g, method="TRACE", path="/", headers=base))[0]["status"] == 501
|
||||
assert (await _raw(g, headers={**base, "upgrade": "websocket", "connection": "Upgrade"}))[0]["status"] == 400
|
||||
assert (await _raw(g, headers={**base, "service-worker": "script"}))[0]["status"] == 403
|
||||
assert (await _raw(g, scope_type="websocket"))[0]["type"] == "websocket.close"
|
||||
assert setup["fw"].requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("slot", ["", "abc", "9999"])
|
||||
async def test_slot_header_required(setup, slot):
|
||||
sent = await _raw(setup["gateway"], headers={"x-onx-slot": slot, "sec-fetch-site": "same-origin"})
|
||||
assert sent[0]["status"] == 400
|
||||
|
||||
|
||||
# ── Sec-Fetch-Site and Origin ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("site,expected", [("same-site", 403), ("cross-site", 403), (None, 403), ("none", 200), ("same-origin", 200)])
|
||||
async def test_sec_fetch_site_enforced(setup, site, expected):
|
||||
cookie = await _open(setup)
|
||||
assert (await _get(setup, "/ui/x", cookie, site=site)).status_code == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_required_for_writes(setup):
|
||||
cookie = await _open(setup)
|
||||
assert (await _get(setup, "/api/x", cookie, method="POST", content=b"{}")).status_code == 403
|
||||
other_slot = await _get(setup, "/api/x", cookie, method="POST", content=b"{}", extra={"Origin": "https://opnexus.example:9442"})
|
||||
assert other_slot.status_code == 403
|
||||
opnexus = await _get(setup, "/api/x", cookie, method="POST", content=b"{}", extra={"Origin": "https://opnexus.example"})
|
||||
assert opnexus.status_code == 403
|
||||
assert (await _get(setup, "/api/x", cookie, method="POST", content=b"{}", extra={"Origin": OWN})).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_foreign_slot_cookie_rejected(setup):
|
||||
cookie = await _open(setup) # slot 9441
|
||||
value = cookie.split("=", 1)[1]
|
||||
resp = await _get(setup, "/ui/x", f"__Host-onx-gw-9442={value}", slot=9442)
|
||||
assert resp.status_code == 401
|
||||
assert (await _get(setup, "/ui/x", "", slot=9441)).status_code == 401
|
||||
assert (await _get(setup, "/__onx/anything", cookie)).status_code == 404
|
||||
|
||||
|
||||
# ── response hardening ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_headers_and_streaming(setup):
|
||||
cookie = await _open(setup)
|
||||
resp = await _get(setup, "/ui/x", cookie)
|
||||
csps = resp.headers.get_list("content-security-policy")
|
||||
assert any("frame-ancestors 'none'" in c and "connect-src 'self'" in c and "form-action 'self'" in c for c in csps)
|
||||
assert "default-src 'self'" in csps # the firewall's own CSP applies as well
|
||||
assert resp.headers["x-frame-options"] == "DENY" and resp.headers["cache-control"] == "no-store"
|
||||
big = await _get(setup, "/big", cookie)
|
||||
assert big.status_code == 200 and len(big.content) == 50 * 4 * 1024 and big.content.endswith(b"0049" * 1024)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_limit(setup, monkeypatch):
|
||||
cookie = await _open(setup)
|
||||
monkeypatch.setattr(gw, "BODY_LIMIT", 1000)
|
||||
resp = await _get(setup, "/api/x", cookie, method="POST", content=b"x" * 2000, extra={"Origin": OWN})
|
||||
assert resp.status_code == 413 and setup["fw"].requests == []
|
||||
|
||||
async def stream():
|
||||
for _ in range(4):
|
||||
yield b"y" * 600
|
||||
chunked = await _get(setup, "/api/x", cookie, method="POST", content=stream(), extra={"Origin": OWN})
|
||||
assert chunked.status_code == 413 and setup["fw"].requests == []
|
||||
|
||||
|
||||
# ── login protection and logging ──────────────────────────────────────────
|
||||
|
||||
def _login(password: str) -> dict:
|
||||
return {"content": f"usernamefld=root&passwordfld={password}&login=1".encode(),
|
||||
"extra": {"Origin": OWN, "Content-Type": "application/x-www-form-urlencoded"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_rate_limit_per_firewall(setup):
|
||||
cookie = await _open(setup)
|
||||
for _ in range(3):
|
||||
assert (await _get(setup, "/", cookie, method="POST", **_login("wrong"))).status_code == 200
|
||||
blocked = await _get(setup, "/", cookie, method="POST", **_login("right"))
|
||||
assert blocked.status_code == 429
|
||||
assert sum(1 for r in setup["fw"].requests if r["method"] == "POST") == 3
|
||||
setup["clock"].now += gw.LOGIN_FAILURE_WINDOW + 1
|
||||
await _get(setup, "/ui/x", cookie) # (session re-check due; still valid)
|
||||
assert (await _get(setup, "/", cookie, method="POST", **_login("right"))).status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multipart_login_counted_too(setup):
|
||||
# PHP's $_POST is filled from multipart bodies as well: a login posted that way must not bypass the limit.
|
||||
cookie = await _open(setup)
|
||||
files = {"login": (None, "1"), "usernamefld": (None, "root"), "passwordfld": (None, "wrong")}
|
||||
codes = [(await _get(setup, "/", cookie, method="POST", files=files, extra={"Origin": OWN})).status_code
|
||||
for _ in range(4)]
|
||||
assert codes == [200, 200, 200, 429]
|
||||
assert not gw.is_login_attempt("POST", "multipart/form-data; boundary=x", b'name="file"; name="login"')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_forwarded_as_sent_and_control_characters_refused(setup):
|
||||
cookie = await _open(setup)
|
||||
for sent, seen in (("/a%3Fb=1", "/a?b=1"), ("/api/x%2Fy", "/api/x/y"), ("/a%2541", "/a%41"), ("/a%23f", "/a#f")):
|
||||
resp = await _get(setup, sent, cookie)
|
||||
last = setup["fw"].requests[-1]
|
||||
# Reaches the firewall still encoded (or decoded exactly once): never split into a query, never
|
||||
# decoded twice (%2541 must not become "A").
|
||||
assert resp.status_code == 200 and last["path"] in (sent, seen) and last["query"] == "", (sent, last)
|
||||
before = len(setup["fw"].requests)
|
||||
resp = await _get(setup, "/a%0d%0aX-Injected:%201", cookie)
|
||||
assert resp.status_code in (200, 400)
|
||||
assert all("x-injected" not in r["headers"] for r in setup["fw"].requests[before:])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_login_not_counted(setup):
|
||||
cookie = await _open(setup)
|
||||
for _ in range(5):
|
||||
assert (await _get(setup, "/", cookie, method="POST", **_login("right"))).status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_and_ticket_never_logged(setup, caplog):
|
||||
caplog.set_level(logging.DEBUG)
|
||||
ticket, slot = await _ticket(setup)
|
||||
resp = await _redeem(setup, ticket, slot)
|
||||
cookie = resp.headers["set-cookie"].split(";")[0]
|
||||
await _get(setup, "/", cookie, method="POST", **_login(PASSWORD))
|
||||
await _get(setup, "/ui/x?secretquery=abc", cookie)
|
||||
text = caplog.text
|
||||
assert "POST / 200" in text and "GET /ui/x 200" in text
|
||||
for leaked in (PASSWORD, ticket, "secretquery", "usernamefld", cookie.split("=", 1)[1], "fw-session-1"):
|
||||
assert leaked not in text
|
||||
|
||||
|
||||
def test_uvicorn_runs_without_access_log(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setenv("GUI_GATEWAY_TOKEN", GW_TOKEN)
|
||||
monkeypatch.setenv("OPNEXUS_GUI_SITES", SITES)
|
||||
import uvicorn
|
||||
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: captured.update(kw))
|
||||
gw.main()
|
||||
assert captured["access_log"] is False and captured["http"] == "h11" and captured["proxy_headers"] is False
|
||||
|
||||
|
||||
def test_gateway_refuses_to_start_without_token(monkeypatch):
|
||||
monkeypatch.setenv("GUI_GATEWAY_TOKEN", "short")
|
||||
monkeypatch.setenv("OPNEXUS_GUI_SITES", SITES)
|
||||
with pytest.raises(SystemExit):
|
||||
gw.create_app()
|
||||
|
||||
|
||||
# ── session lifetime ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revalidation_failure_ends_session(setup):
|
||||
cookie = await _open(setup)
|
||||
assert (await _get(setup, "/ui/x", cookie)).status_code == 200
|
||||
await setup["admin"].post("/api/auth/logout")
|
||||
assert (await _get(setup, "/ui/x", cookie)).status_code == 200 # not yet due (≤ 30 s)
|
||||
setup["clock"].now += 31
|
||||
assert (await _get(setup, "/ui/x", cookie)).status_code == 401
|
||||
assert setup["gateway"].sessions == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_ends_idle_sessions_and_reports(setup):
|
||||
cookie = await _open(setup)
|
||||
setup["clock"].now += 15 * 60
|
||||
await setup["gateway"].sweep()
|
||||
assert setup["gateway"].sessions == {}
|
||||
assert (await _get(setup, "/ui/x", cookie)).status_code == 401
|
||||
async with SessionLocal() as db:
|
||||
assert (await db.scalars(select(GuiSession.end_reason))).one() == "idle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_deadline(setup):
|
||||
cookie = await _open(setup)
|
||||
gs = next(iter(setup["gateway"].sessions.values()))
|
||||
gs.absolute_deadline = setup["gateway"].wall_clock() - 1
|
||||
assert (await _get(setup, "/ui/x", cookie)).status_code == 401
|
||||
assert setup["gateway"].sessions == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_unreachable_fails_closed(setup):
|
||||
cookie = await _open(setup)
|
||||
|
||||
async def broken(gs):
|
||||
raise httpx.ConnectError("down")
|
||||
setup["gateway"].backend.revalidate = broken
|
||||
setup["clock"].now += 31
|
||||
assert (await _get(setup, "/ui/x", cookie)).status_code == 503
|
||||
assert len(setup["fw"].requests) == 0
|
||||
|
||||
|
||||
# ── upstream transport and isolation ──────────────────────────────────────
|
||||
|
||||
def _pem() -> str:
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "OPNsense.localdomain")])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
|
||||
.serial_number(1).not_valid_before(now).not_valid_after(now + datetime.timedelta(days=1)).sign(key, hashes.SHA256()))
|
||||
return cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
|
||||
|
||||
def test_upstream_client_pins_and_uses_overlay_proxy(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(gw.httpx, "AsyncClient", lambda **kw: captured.update(kw) or "client")
|
||||
gs = gw.GatewaySession(gui_session_id="x", session_hash="y", slot=9441, upstream_origin="https://203.0.113.5",
|
||||
ca_pem=_pem(), proxy_url="http://wg-hub:8888", idle_timeout=900, absolute_deadline=0,
|
||||
revalidate_every=30)
|
||||
assert gw.build_upstream_client(gs) == "client"
|
||||
ctx = captured["verify"]
|
||||
assert ctx.check_hostname is False and ctx.verify_mode.name == "CERT_REQUIRED"
|
||||
assert captured["proxy"] == "http://wg-hub:8888" and captured["follow_redirects"] is False and captured["trust_env"] is False
|
||||
gs.ca_pem, gs.proxy_url = None, None
|
||||
gw.build_upstream_client(gs)
|
||||
assert captured["verify"] is True and captured["proxy"] is None
|
||||
|
||||
|
||||
def test_gateway_never_loads_the_database_layer():
|
||||
code = ("import sys, app.gui_gateway as g; "
|
||||
"g.build_upstream_client(g.GatewaySession('x','y',9441,'https://203.0.113.5',None,None,1,1,1)); "
|
||||
"from app.services import tls_pin_service; "
|
||||
"bad=[m for m in ('app.database','sqlalchemy','app.models') if m in sys.modules]; "
|
||||
"print(bad); sys.exit(1 if bad else 0)")
|
||||
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Rule analysis (1.26): normalization, resolver and pf semantics of the engine.
|
||||
|
||||
Pure functions against an anonymized live fixture (OPNsense 26.7: automatic rules, antispoof
|
||||
lines, a CARP VIP on LAN). Synthetic rules get pf lines in sort order, as pf would load them.
|
||||
The numbers in the test names follow the test matrix of the design (section 8).
|
||||
"""
|
||||
import copy
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.rule_analysis import context as ctx_mod
|
||||
from app.services.rule_analysis import pf_ruleset, resolve
|
||||
from app.services.rule_analysis.engine import evaluate
|
||||
from app.services.rule_analysis.normalize import normalize
|
||||
from app.services.rule_analysis.service import build_query
|
||||
|
||||
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "rule_analysis_fw.json").read_text())
|
||||
DEVICES = {"lan": "vtnet1_vlan10", "wan": "vtnet0"}
|
||||
LAN_CLIENT, INTERNET, WAN_IP = "10.20.30.50", "198.51.100.10", "203.0.113.57"
|
||||
|
||||
|
||||
def mvc(uuid, *, prio=400000, seq=1, action="pass", quick="1", interface="lan", src="any", dst="any",
|
||||
proto="any", ipprotocol="inet", dport="", sport="", direction="in", loaded=True, **extra):
|
||||
row = {
|
||||
"uuid": uuid, "sort_order": f"{prio:06d}.{seq:07d}", "prio_group": str(prio), "sequence": str(seq),
|
||||
"enabled": "1", "action": action, "quick": quick, "interface": interface, "interfacenot": "0",
|
||||
"direction": direction, "ipprotocol": ipprotocol, "protocol": proto, "source_net": src, "source_not": "0",
|
||||
"source_port": sport, "destination_net": dst, "destination_not": "0", "destination_port": dport,
|
||||
"gateway": "", "sched": "", "statetype": "keep", "tagged": "", "description": uuid,
|
||||
}
|
||||
row.update(extra)
|
||||
row["_loaded"] = loaded
|
||||
return row
|
||||
|
||||
|
||||
def build(extra_rows=(), *, drop=(), aliases=None, refs=None, groups=None, pf=True, port_forwards=()):
|
||||
"""A Context from the fixture plus synthetic MVC rules. pf lines of MVC rules are generated
|
||||
in sort order (pf numbers follow the evaluation order like on a real firewall)."""
|
||||
data = copy.deepcopy(FIXTURE)
|
||||
rows = [r for r in data["rules"]["rows"] if r["description"] not in drop] + [dict(r) for r in extra_rows]
|
||||
interfaces = ctx_mod._interfaces(data["interfaces"])
|
||||
group_rows = data["groups"]["rows"] + [{"ifname": k, "members": ",".join(v)} for k, v in (groups or {}).items()]
|
||||
stats = None
|
||||
if pf:
|
||||
auto = {k: v for k, v in data["pf"]["rules"]["filter rules"].items()
|
||||
if not any(r.get("uuid") in k for r in data["rules"]["rows"] if not r.get("is_automatic"))}
|
||||
lines = sorted(((int(k.split()[0][1:]), k.split(" ", 1)[1]) for k in auto), key=lambda x: x[0])
|
||||
number = max(n for n, _ in lines) + 1
|
||||
mvc_rows = sorted((r for r in rows if not r.get("is_automatic") and r.get("_loaded", True)
|
||||
and r.get("enabled") == "1"), key=lambda r: r.get("sort_order") or "400000." + r["sequence"].zfill(7))
|
||||
for r in mvc_rows:
|
||||
words = ["block" if r["action"] == "block" else ("block return" if r["action"] == "reject" else "pass"),
|
||||
r["direction"] if r["direction"] != "any" else ""]
|
||||
if r["quick"] == "1":
|
||||
words.append("quick")
|
||||
ifs = [i for i in str(r["interface"]).split(",") if i]
|
||||
if len(ifs) == 1 and ifs[0] in DEVICES:
|
||||
words.append(f"on {DEVICES[ifs[0]]}")
|
||||
if r["ipprotocol"] in ("inet", "inet6"):
|
||||
words.append(r["ipprotocol"])
|
||||
words.append(f'all label "{r["uuid"]}"')
|
||||
lines.append((number, " ".join(w for w in words if w)))
|
||||
number += 1
|
||||
stats = {"rules": {"filter rules": {f"@{n} {t}": {} for n, t in lines}}}
|
||||
for r in rows:
|
||||
r.pop("_loaded", None)
|
||||
alias_rows = {a["name"]: a for a in data["aliases"]["rows"]}
|
||||
alias_rows.update({name: {"name": name, **a} for name, a in (aliases or {}).items()})
|
||||
ctx = ctx_mod.Context(rows=rows, pf=pf_ruleset.parse(stats) if pf else None, interfaces=interfaces,
|
||||
groups=ctx_mod._groups({"rows": group_rows}, interfaces), aliases=alias_rows,
|
||||
port_forwards=list(port_forwards), notes=[] if pf else ["PF_RULESET_UNAVAILABLE"])
|
||||
default_refs = {LAN_CLIENT: ["__lan_network"], INTERNET: [], WAN_IP: ["__wan_network"]}
|
||||
ctx.refs = {**default_refs, **(refs or {})}
|
||||
return ctx
|
||||
|
||||
|
||||
def run(ctx, src=LAN_CLIENT, dst=INTERNET, proto="tcp", dport="443", iface="lan", **kw):
|
||||
q = build_query(src=src, dst=dst, proto=proto, dport=dport, iface=iface, **kw)
|
||||
for ip in (str(q.src), str(q.dst)):
|
||||
ctx.refs.setdefault(ip, [])
|
||||
return evaluate(ctx, q)
|
||||
|
||||
|
||||
def decided_by(result):
|
||||
return result["decisive_rule"]["description"] if result["decisive_rule"] else None
|
||||
|
||||
|
||||
# ── Normalization ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_normalize_live_fixture_uses_pf_for_automatic_rules():
|
||||
rules, notes = normalize(build())
|
||||
by_desc = {}
|
||||
for r in rules:
|
||||
by_desc.setdefault(r.description, r)
|
||||
assert notes == []
|
||||
deny = by_desc["Default deny / state violation rule"]
|
||||
assert deny.quick is False and deny.action == "block" and deny.loaded and deny.pf_numbers
|
||||
dhcp = by_desc["allow access to DHCP server"]
|
||||
assert dhcp.action == "pass" and dhcp.quick is True and dhcp.interfaces == {"lan"} # "LAN" + empty action
|
||||
lockout = by_desc["anti-lockout rule"]
|
||||
assert lockout.interfaces == {"lan"} and lockout.automatic and lockout.legacy
|
||||
allow = by_desc["Default allow LAN to any rule"]
|
||||
assert allow.section == "interface" and allow.quick and allow.af == {4} and not allow.automatic
|
||||
spoof = [r for r in rules if r.section == "antispoof"]
|
||||
assert spoof and spoof[0].interfaces == {"lan"} and spoof[0].if_not and spoof[0].quick is False
|
||||
# Antispoof lines sit before the first automatic rule, like in pf.
|
||||
assert rules.index(spoof[0]) < rules.index(deny)
|
||||
|
||||
|
||||
def test_normalize_reports_order_mismatch_and_missing_sort_order():
|
||||
ctx = build([mvc("r-a", seq=1)])
|
||||
# Swap pf positions: the rule now appears in pf before the system rules.
|
||||
key = next(k for k in ctx.pf.by_label if k == "r-a")
|
||||
ctx.pf.by_label[key][0].number = -1
|
||||
assert "ORDER_MISMATCH" in normalize(ctx)[1]
|
||||
row = mvc("r-b")
|
||||
row.pop("sort_order")
|
||||
assert "ORDER_GUESSED" in normalize(build([row]))[1]
|
||||
|
||||
|
||||
def test_unmigrated_legacy_rule_with_interface_description():
|
||||
# Seen live: legacy user rules have a 32-hex label and "WAN" instead of "wan".
|
||||
legacy = mvc("0123456789abcdef0123456789abcdef", seq=1, action="block", interface="WAN", legacy=True)
|
||||
r = run(build([legacy], drop=("Allow ALL",)), src=INTERNET, dst=WAN_IP, iface="wan")
|
||||
assert decided_by(r) == legacy["uuid"] and r["confidence"] == "sure"
|
||||
assert r["decisive_rule"]["legacy"] and not r["decisive_rule"]["editable"]
|
||||
|
||||
|
||||
# ── Engine: matrix 1-8, 14-20 ─────────────────────────────────────────────
|
||||
|
||||
def test_01_lan_client_to_internet_is_default_allow_sure():
|
||||
r = run(build())
|
||||
assert r["action"] == "pass" and decided_by(r) == "Default allow LAN to any rule"
|
||||
assert r["confidence"] == "sure" and r["rule_confidence"] == "sure" and r["unknowns"] == []
|
||||
|
||||
|
||||
def test_02_wan_to_firewall_ssh_hits_default_deny():
|
||||
r = run(build(drop=("Allow ALL",)), src=INTERNET, dst=WAN_IP, dport="22", iface="wan")
|
||||
assert r["action"] == "block" and decided_by(r) == "Default deny / state violation rule"
|
||||
assert r["confidence"] == "sure" and r["decisive_rule"]["quick"] is False
|
||||
|
||||
|
||||
def test_03_floating_non_quick_block_loses_to_interface_quick_pass():
|
||||
ctx = build([mvc("float-block", prio=200000, action="block", quick="0", interface="")])
|
||||
r = run(ctx)
|
||||
assert r["action"] == "pass" and decided_by(r) == "Default allow LAN to any rule"
|
||||
effects = {s["rule"]["description"]: s["effect"] for s in r["trace"][0]["steps"]}
|
||||
assert effects["float-block"] == "overridden"
|
||||
|
||||
|
||||
def test_04_floating_quick_block_on_lan_and_wan_wins():
|
||||
r = run(build([mvc("float-quick", prio=200000, action="block", quick="1", interface="lan,wan")]))
|
||||
assert r["action"] == "block" and decided_by(r) == "float-quick" and r["confidence"] == "sure"
|
||||
assert r["decisive_rule"]["section"] == "floating"
|
||||
|
||||
|
||||
def test_05_floating_interfacenot_applies_to_the_other_interfaces():
|
||||
ctx = build([mvc("not-lan", prio=200000, action="block", interface="lan", interfacenot="1")])
|
||||
assert decided_by(run(ctx)) == "Default allow LAN to any rule"
|
||||
wan = run(ctx, src=INTERNET, dst=WAN_IP, dport="22", iface="wan")
|
||||
assert wan["action"] == "block" and decided_by(wan) == "not-lan"
|
||||
|
||||
|
||||
def test_06_group_rule_comes_before_interface_rules():
|
||||
ctx = build([mvc("grp-block", prio=300005, seq=900, action="block", interface="office")], groups={"office": ["lan"]})
|
||||
r = run(ctx)
|
||||
assert r["action"] == "block" and decided_by(r) == "grp-block" and r["decisive_rule"]["section"] == "group"
|
||||
|
||||
|
||||
def test_07_last_non_quick_match_wins():
|
||||
ctx = build([mvc("first-pass", seq=1, quick="0", interface="wan"), mvc("then-block", seq=2, action="block", quick="0", interface="wan")],
|
||||
drop=("Allow ALL",))
|
||||
r = run(ctx, src=INTERNET, dst=WAN_IP, iface="wan")
|
||||
assert r["action"] == "block" and decided_by(r) == "then-block" and r["confidence"] == "sure"
|
||||
|
||||
|
||||
def test_08_negated_source_alias():
|
||||
aliases = {"BADHOSTS": {"type": "host", "content": "10.20.30.66"}}
|
||||
rule = mvc("not-bad", seq=1, action="block", src="BADHOSTS", source_not="1")
|
||||
ctx = build([rule], aliases=aliases, refs={LAN_CLIENT: ["__lan_network"], "10.20.30.66": ["BADHOSTS", "__lan_network"]})
|
||||
assert run(ctx)["action"] == "block" # .50 is not in BADHOSTS -> !BADHOSTS matches
|
||||
assert run(ctx, src="10.20.30.66")["action"] == "pass" # in BADHOSTS -> negation does not match
|
||||
# Table membership unknown: the negation stays unknown, the verdict is not "sure".
|
||||
unknown = build([dict(rule, source_net="FEED")], aliases={"FEED": {"type": "urltable", "content": ""}},
|
||||
refs={LAN_CLIENT: None})
|
||||
r = run(unknown)
|
||||
assert r["action"] == "pass" and r["confidence"] == "unknown" and "ALIAS_TABLE_UNAVAILABLE" in r["unknowns"]
|
||||
|
||||
|
||||
def test_14_rule_not_loaded_is_skipped():
|
||||
ctx = build([mvc("scheduled", seq=1, action="block", sched="weekends", loaded=False)])
|
||||
r = run(ctx)
|
||||
assert decided_by(r) == "Default allow LAN to any rule"
|
||||
step = next(s for s in r["trace"][0]["steps"] if s["rule"]["description"] == "scheduled")
|
||||
assert step["effect"] == "not_loaded" and "RULES_NOT_LOADED" in r["notes"]
|
||||
|
||||
|
||||
def test_15_16_gateway_and_stateless_notes():
|
||||
r = run(build([mvc("via-gw", seq=1, gateway="WAN_GW", statetype="none")]))
|
||||
assert decided_by(r) == "via-gw" and {"POLICY_ROUTE", "STATELESS"} <= set(r["notes"])
|
||||
assert r["confidence"] == "sure"
|
||||
|
||||
|
||||
def test_17_tagged_rule_is_never_sure():
|
||||
r = run(build([mvc("tagged-block", seq=1, action="block", tagged="FROM_NAT")]))
|
||||
assert r["confidence"] == "unknown" and "TAGGED" in r["unknowns"]
|
||||
|
||||
|
||||
def test_18_unknown_rule_with_same_action_keeps_the_verdict_sure():
|
||||
ctx = build([mvc("maybe-pass", seq=1, src="GHOST")]) # alias unknown on the firewall
|
||||
r = run(ctx)
|
||||
assert r["action"] == "pass" and r["confidence"] == "sure" and r["rule_confidence"] == "probable"
|
||||
|
||||
|
||||
def test_19_unknown_rule_with_other_action_makes_the_verdict_unknown():
|
||||
ctx = build([mvc("maybe-block", seq=1, action="block", src="GHOST")])
|
||||
r = run(ctx)
|
||||
assert r["action"] == "pass" and r["confidence"] == "unknown" and "ALIAS_MISSING" in r["unknowns"]
|
||||
assert any(s["effect"] == "possible" for s in r["trace"][0]["steps"])
|
||||
|
||||
|
||||
def test_20_rule_with_missing_interface_never_matches():
|
||||
row = mvc("orphan", prio=600000, action="pass", interface="opt9", loaded=False)
|
||||
r = run(build([row], drop=("Allow ALL",)), src=INTERNET, dst=WAN_IP, iface="wan")
|
||||
assert decided_by(r) == "Default deny / state violation rule"
|
||||
assert next(s for s in r["trace"][0]["steps"] if s["rule"]["description"] == "orphan")["effect"] == "not_loaded"
|
||||
# Even without a pf ruleset a 600000 rule counts as not loaded; the verdict is no longer sure.
|
||||
no_pf = run(build([row], pf=False))
|
||||
assert decided_by(no_pf) == "Default allow LAN to any rule" and no_pf["confidence"] != "sure"
|
||||
|
||||
|
||||
def test_guessed_interface_and_nat_nearby_lower_confidence():
|
||||
r = run(build(), iface=None)
|
||||
assert r["legs"][0]["interface"] == "lan" and "IFACE_GUESSED" in r["notes"] and r["confidence"] == "probable"
|
||||
nat = run(build(port_forwards=[{"interface": "lan", "protocol": "tcp", "destination.port": "443"}]))
|
||||
assert "NAT_NEARBY" in nat["notes"] and nat["confidence"] == "probable"
|
||||
# "no rdr" exclusions and forwards for other ports do not count.
|
||||
other = run(build(port_forwards=[{"interface": "lan", "protocol": "tcp", "destination.port": "22", "nordr": "0"},
|
||||
{"interface": "lan", "protocol": "tcp", "destination.port": "443", "nordr": "1"}]))
|
||||
assert "NAT_NEARBY" not in other["notes"] and other["confidence"] == "sure"
|
||||
|
||||
|
||||
def test_egress_leg_blocks_when_out_rule_blocks():
|
||||
ctx = build([mvc("no-out", prio=200000, action="block", interface="wan", direction="out")])
|
||||
r = run(ctx, egress="wan")
|
||||
assert [leg["action"] for leg in r["legs"]] == ["pass", "block"]
|
||||
assert r["action"] == "block" and decided_by(r) == "no-out" and r["confidence"] == "sure"
|
||||
# Without the block the automatic "let out anything" rule passes the out leg.
|
||||
ok = run(build(), egress="wan")
|
||||
assert ok["action"] == "pass" and ok["legs"][1]["decisive_rule"]["description"] == "let out anything from firewall host itself"
|
||||
|
||||
|
||||
# ── Resolver: matrix 9-13 ─────────────────────────────────────────────────
|
||||
|
||||
def _ip(text):
|
||||
return ipaddress.ip_address(text)
|
||||
|
||||
|
||||
def test_09_nested_aliases_and_cycles():
|
||||
ctx = build(aliases={
|
||||
"OUTER": {"type": "host", "content": "MIDDLE\n192.0.2.9"},
|
||||
"MIDDLE": {"type": "network", "content": "10.20.30.0/25\nINNER"},
|
||||
"INNER": {"type": "host", "content": "10.20.30.200"},
|
||||
"LOOP_A": {"type": "host", "content": "LOOP_B"}, "LOOP_B": {"type": "host", "content": "LOOP_A"},
|
||||
"NAMED": {"type": "host", "content": "server.example.org"},
|
||||
})
|
||||
ctx.refs = {}
|
||||
assert resolve.alias_contains("OUTER", _ip("10.20.30.5"), ctx).value is True
|
||||
assert resolve.alias_contains("OUTER", _ip("10.20.30.200"), ctx).value is True
|
||||
assert resolve.alias_contains("OUTER", _ip("10.20.30.150"), ctx).value is False
|
||||
loop = resolve.alias_contains("LOOP_A", _ip("10.20.30.5"), ctx)
|
||||
assert loop.value is None and "ALIAS_CYCLE" in loop.reasons
|
||||
fqdn = resolve.alias_contains("NAMED", _ip("10.20.30.5"), ctx)
|
||||
assert fqdn.value is None and "FQDN_UNRESOLVED" in fqdn.reasons
|
||||
assert resolve.alias_contains("NOPE", _ip("10.20.30.5"), ctx).value is None
|
||||
|
||||
|
||||
def test_10_table_aliases_need_find_references():
|
||||
ctx = build(aliases={"FEED": {"type": "urltable", "content": "https://example.org/list"}})
|
||||
ctx.refs = {"192.0.2.7": ["FEED"], "192.0.2.8": []}
|
||||
assert resolve.alias_contains("FEED", _ip("192.0.2.7"), ctx).value is True
|
||||
assert resolve.alias_contains("FEED", _ip("192.0.2.8"), ctx).value is False
|
||||
ctx.refs = {"192.0.2.7": None}
|
||||
out = resolve.alias_contains("FEED", _ip("192.0.2.7"), ctx)
|
||||
assert out.value is None and out.reasons == ["ALIAS_TABLE_UNAVAILABLE"]
|
||||
|
||||
|
||||
def test_11_address_family():
|
||||
ctx = build([mvc("v4-only", seq=1, action="block", interface="wan", ipprotocol="inet")], drop=("Allow ALL",))
|
||||
v6 = run(ctx, src="2001:db8::5", dst="2001:db8:1::1", iface="wan")
|
||||
assert decided_by(v6) == "Default deny / state violation rule"
|
||||
both = build([mvc("v46", seq=1, action="reject", interface="wan", ipprotocol="inet46")], drop=("Allow ALL",))
|
||||
assert decided_by(run(both, src="2001:db8::5", dst="2001:db8:1::1", iface="wan")) == "v46"
|
||||
|
||||
|
||||
def test_12_port_ranges_protocol_pairs_and_nested_port_aliases():
|
||||
ctx = build(aliases={"WEB": {"type": "port", "content": "1000:2000\nMORE"}, "MORE": {"type": "port", "content": "https\n8000-8001"}})
|
||||
for port, expected in ((999, False), (1000, True), (2000, True), (2001, False), (443, True), (8001, True), (8002, False)):
|
||||
assert resolve.port_match("WEB", port, ctx).value is expected, port
|
||||
assert resolve.port_match("1000:2000", 1500, ctx).value is True
|
||||
rule = mvc("tcpudp", seq=1, action="block", proto="TCP/UDP", dport="1000-2000")
|
||||
assert decided_by(run(build([rule]), proto="udp", dport="1000")) == "tcpudp"
|
||||
assert decided_by(run(build([rule]), proto="tcp", dport="2001")) == "Default allow LAN to any rule"
|
||||
# No source port given: assumed ephemeral, so "port 0" rules do not match, a narrow one is unknown.
|
||||
assert resolve.port_match("0", None, ctx, ephemeral_default=True).value is False
|
||||
assert resolve.port_match("5000", None, ctx, ephemeral_default=True).value is None
|
||||
assert resolve.port_match("1024-65535", None, ctx, ephemeral_default=True).value is True
|
||||
|
||||
|
||||
def test_13_interface_networks_addresses_self_and_carp_vip():
|
||||
ctx = build()
|
||||
vip, primary, client = _ip("10.20.30.1"), _ip("10.20.30.2"), _ip("10.20.30.50")
|
||||
assert resolve.addr_item("lan", vip, ctx).value is True and resolve.addr_item("lan", client, ctx).value is True
|
||||
assert resolve.addr_item("lanip", primary, ctx).value is True
|
||||
assert resolve.addr_item("lanip", client, ctx).value is False
|
||||
assert resolve.addr_item("lanip", vip, ctx).value is None # VIP vs interface address: not provable
|
||||
assert resolve.addr_item("(self)", vip, ctx).value is True
|
||||
assert resolve.addr_item("(self)", _ip(WAN_IP), ctx).value is True
|
||||
assert resolve.addr_item("(self)", client, ctx).value is False
|
||||
assert resolve.addr_item("__wan_network", _ip("203.0.113.9"), ctx).value is True
|
||||
|
||||
|
||||
def test_query_validation_errors():
|
||||
from fastapi import HTTPException
|
||||
for kwargs, code in (({"src": "host.example", "dst": INTERNET}, "ANALYSE_ADRESSE_UNGUELTIG"),
|
||||
({"src": "2001:db8::1", "dst": INTERNET}, "ANALYSE_FAMILIEN_MISCHUNG"),
|
||||
({"src": LAN_CLIENT, "dst": INTERNET, "dport": "70000"}, "ANALYSE_PARAMETER_UNGUELTIG"),
|
||||
({"src": LAN_CLIENT, "dst": INTERNET, "proto": "icmp", "dport": "80"}, "ANALYSE_PARAMETER_UNGUELTIG")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
build_query(**kwargs)
|
||||
assert exc.value.detail["code"] == code
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Rule analysis API + MCP tool (matrix case 25): GET only, read tokens allowed, nothing written."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.rule_analysis import context as ctx_mod
|
||||
from tests.test_api_tokens import _bearer, _make
|
||||
from tests.test_rule_analysis import FIXTURE
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
FW, DOWN = "https://198.51.100.200", "https://198.51.100.201"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
ctx_mod.clear_cache()
|
||||
yield
|
||||
ctx_mod.clear_cache()
|
||||
|
||||
|
||||
def _mock_fw(respx_mock, base=FW):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
respx_mock.get(f"{base}/api/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json=FIXTURE["rules"]))
|
||||
respx_mock.get(f"{base}/api/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json=FIXTURE["pf"]))
|
||||
respx_mock.get(f"{base}/api/interfaces/overview/interfacesInfo").mock(return_value=httpx.Response(200, json=FIXTURE["interfaces"]))
|
||||
respx_mock.get(f"{base}/api/firewall/group/searchItem").mock(return_value=httpx.Response(200, json=FIXTURE["groups"]))
|
||||
respx_mock.get(f"{base}/api/firewall/alias/searchItem").mock(return_value=httpx.Response(200, json=FIXTURE["aliases"]))
|
||||
respx_mock.get(f"{base}/api/firewall/d_nat/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
return respx_mock.post(f"{base}/api/firewall/alias_util/findReferences").mock(
|
||||
return_value=httpx.Response(200, json={"status": "ok", "matches": []}))
|
||||
|
||||
|
||||
Q = {"src": "10.20.30.50", "dst": "198.51.100.10", "proto": "tcp", "dport": "443", "iface": "lan"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_match_and_validation(client, respx_mock):
|
||||
refs = _mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw-a", FW)
|
||||
resp = await client.get(f"/api/hosts/{host['id']}/rule-analysis/match", params=Q)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["action"] == "pass" and body["confidence"] == "sure" and body["error"] is None
|
||||
assert body["decisive_rule"]["description"] == "Default allow LAN to any rule"
|
||||
assert body["legs"][0]["interface"] == "lan" and body["trace"][0]["steps"]
|
||||
assert refs.call_count == 2 and json.loads(refs.calls[0].request.content) == {"ip": "10.20.30.50"}
|
||||
bad = await client.get(f"/api/hosts/{host['id']}/rule-analysis/match", params={**Q, "src": "pc.example"})
|
||||
assert bad.status_code == 422 and bad.json()["detail"]["code"] == "ANALYSE_ADRESSE_UNGUELTIG"
|
||||
mixed = await client.get(f"/api/hosts/{host['id']}/rule-analysis/match", params={**Q, "dst": "2001:db8::1"})
|
||||
assert mixed.json()["detail"]["code"] == "ANALYSE_FAMILIEN_MISCHUNG"
|
||||
iface = await client.get(f"/api/hosts/{host['id']}/rule-analysis/match", params={**Q, "iface": "opt7"})
|
||||
assert iface.status_code == 422 and iface.json()["detail"]["code"] == "ANALYSE_INTERFACE_UNBEKANNT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_match_reports_unreachable_hosts_per_host(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
respx_mock.route(url__startswith=DOWN).mock(side_effect=httpx.ConnectError("down"))
|
||||
await _create_host(client, "fw-a", FW)
|
||||
await _create_host(client, "fw-down", DOWN)
|
||||
resp = await client.get("/api/rule-analysis/match", params={**Q, "iface": "auto"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
by_name = {r["host_name"]: r for r in resp.json()}
|
||||
assert by_name["fw-a"]["action"] == "pass" and "IFACE_GUESSED" in by_name["fw-a"]["notes"]
|
||||
assert by_name["fw-a"]["confidence"] == "probable"
|
||||
assert by_name["fw-down"]["error"]["code"] == "ANALYSE_FIREWALL_NICHT_LESBAR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_25_mcp_rule_match_with_a_read_token_changes_nothing(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw-a", FW)
|
||||
token = await _make(client, "read")
|
||||
async with _bearer(token["token"]) as api:
|
||||
direct = await api.get(f"/api/hosts/{host['id']}/rule-analysis/match", params=Q)
|
||||
assert direct.status_code == 200
|
||||
rpc = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": "rule_match", "arguments": {**Q, "dport": 443, "host_ids": host["id"]}}}
|
||||
result = (await api.post("/api/mcp", json=rpc)).json()["result"]
|
||||
tools = (await api.post("/api/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).json()["result"]["tools"]
|
||||
assert result["isError"] is False
|
||||
payload = json.loads(result["content"][0]["text"])
|
||||
assert payload[0]["action"] == "pass" and "trace" not in payload[0]
|
||||
assert "rule_match" in {t["name"] for t in tools}
|
||||
# The only POST that reached the firewall is the read-only pf table lookup.
|
||||
posts = {c.request.url.path for c in respx_mock.calls if c.request.method != "GET"}
|
||||
assert posts == {"/api/firewall/alias_util/findReferences"}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""1.27 prerequisite hardening: __Host- session cookie (with soft migration) and
|
||||
the Sec-Fetch-Site check for cookie-authenticated /api requests."""
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app import main
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.routers.auth import COOKIE_NAME, SECURE_COOKIE_NAME
|
||||
|
||||
PW = "tokpass-12345678"
|
||||
|
||||
|
||||
def _https_client() -> AsyncClient:
|
||||
return AsyncClient(transport=ASGITransport(app=app), base_url="https://test")
|
||||
|
||||
|
||||
async def _login(ac: AsyncClient) -> None:
|
||||
resp = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _set_cookies(resp) -> list[str]:
|
||||
return resp.headers.get_list("set-cookie")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secure_login_sets_host_prefixed_cookie_and_drops_legacy(monkeypatch):
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
async with _https_client() as ac:
|
||||
resp = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
||||
assert resp.status_code == 200
|
||||
cookies = _set_cookies(resp)
|
||||
new = [c for c in cookies if c.startswith(f"{SECURE_COOKIE_NAME}=")]
|
||||
assert new and "Secure" in new[0] and "Path=/" in new[0] and "HttpOnly" in new[0] and "Domain" not in new[0]
|
||||
assert any(c.startswith(f'{COOKIE_NAME}=""') or c.startswith(f"{COOKIE_NAME}=;") or "Max-Age=0" in c
|
||||
for c in cookies if c.startswith(f"{COOKIE_NAME}="))
|
||||
assert (await ac.get("/api/hosts")).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_name_cookie_without_https():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
||||
assert any(c.startswith(f"{COOKIE_NAME}=") for c in _set_cookies(resp))
|
||||
assert not any(c.startswith(SECURE_COOKIE_NAME) for c in _set_cookies(resp))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_cookie_is_migrated_not_logged_out(monkeypatch):
|
||||
# A session issued before the upgrade (plain cookie name) ...
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
await _login(ac)
|
||||
legacy = ac.cookies.get(COOKIE_NAME)
|
||||
assert legacy
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
async with _https_client() as ac:
|
||||
ac.cookies.set(COOKIE_NAME, legacy)
|
||||
assert (await ac.get("/api/hosts")).status_code == 200 # still accepted
|
||||
me = await ac.get("/api/auth/me")
|
||||
assert me.json()["authenticated"] is True
|
||||
moved = [c for c in _set_cookies(me) if c.startswith(f"{SECURE_COOKIE_NAME}={legacy}")]
|
||||
assert moved and "Secure" in moved[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_cookie_wins_over_tossed_plain_cookie(monkeypatch):
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
async with _https_client() as ac:
|
||||
await _login(ac)
|
||||
real = ac.cookies.get(SECURE_COOKIE_NAME)
|
||||
async with _https_client() as ac:
|
||||
# A script on another port may set a plain-name cookie; it is ignored ...
|
||||
ac.cookies.set(SECURE_COOKIE_NAME, real)
|
||||
ac.cookies.set(COOKIE_NAME, "attacker-token")
|
||||
assert (await ac.get("/api/hosts")).status_code == 200
|
||||
async with _https_client() as ac:
|
||||
# ... and a bad __Host- cookie never falls back to the plain one.
|
||||
ac.cookies.set(SECURE_COOKIE_NAME, "garbage")
|
||||
ac.cookies.set(COOKIE_NAME, real)
|
||||
assert (await ac.get("/api/hosts")).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("site,expected", [
|
||||
("same-site", 403), ("cross-site", 403), ("Cross-Site", 403),
|
||||
("same-origin", 200), ("none", 200), (None, 200),
|
||||
])
|
||||
async def test_sec_fetch_site_on_cookie_requests(client, site, expected):
|
||||
headers = {"Sec-Fetch-Site": site} if site else {}
|
||||
resp = await client.get("/api/hosts", headers=headers)
|
||||
assert resp.status_code == expected, resp.text
|
||||
if expected == 403:
|
||||
assert resp.json()["detail"]["code"] == "CROSS_SITE_REQUEST_BLOCKED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sec_fetch_same_site_allowed_only_for_configured_frontend(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "frontend_origin", "https://opnexus.example:3100")
|
||||
ok = await client.get("/api/hosts", headers={"Sec-Fetch-Site": "same-site", "Origin": "https://opnexus.example:3100"})
|
||||
assert ok.status_code == 200
|
||||
slot = await client.get("/api/hosts", headers={"Sec-Fetch-Site": "same-site", "Origin": "https://opnexus.example:9441"})
|
||||
assert slot.status_code == 403
|
||||
# Writes from a slot page are refused as well (before auth even runs).
|
||||
write = await client.post("/api/auth/logout", headers={"Sec-Fetch-Site": "same-site", "Origin": "https://opnexus.example:9441"})
|
||||
assert write.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sec_fetch_does_not_touch_token_requests(client, anon_client):
|
||||
created = (await client.post("/api/tokens", json={"name": "sfs", "scope": "read", "current_password": settings.admin_password})).json()
|
||||
resp = await anon_client.get("/api/hosts", headers={"Authorization": f"Bearer {created['token']}", "Sec-Fetch-Site": "cross-site"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sec_fetch_exempts_sso_callback(client):
|
||||
resp = await client.get("/api/auth/oidc/callback?code=x&state=y", headers={"Sec-Fetch-Site": "cross-site"}, follow_redirects=False)
|
||||
assert resp.status_code != 403 or resp.json().get("detail", {}).get("code") != "CROSS_SITE_REQUEST_BLOCKED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_refused_when_slot_origin_is_a_frontend_origin(monkeypatch):
|
||||
monkeypatch.setattr(settings, "frontend_origin", "https://opnexus.example, https://opnexus.example:9441")
|
||||
monkeypatch.setattr(settings, "opnexus_gui_sites", "https://opnexus.example:9441,https://opnexus.example:9442")
|
||||
assert settings.gui_origin_conflicts() == ["https://opnexus.example:9441"]
|
||||
called = []
|
||||
|
||||
async def _no_migrate():
|
||||
called.append(1)
|
||||
monkeypatch.setattr(main.migrate, "run", _no_migrate)
|
||||
with pytest.raises(RuntimeError):
|
||||
async with main.lifespan(app):
|
||||
pass
|
||||
assert not called
|
||||
monkeypatch.setattr(settings, "opnexus_gui_sites", "https://opnexus.example:9442")
|
||||
assert settings.gui_origin_conflicts() == []
|
||||
@@ -124,6 +124,10 @@ services:
|
||||
# Optionaler Updater-Dienst (Profil "updater", siehe unten). Ohne ihn
|
||||
# zeigt das Webinterface nur an, wie man ihn aktiviert.
|
||||
UPDATER_URL: ${UPDATER_URL:-http://updater:8080}
|
||||
# Optionaler GUI-Fernzugriff (Profil "gui", siehe gui-gateway unten).
|
||||
# Leer = aus; das Backend lässt den Schalter dann nicht einschalten.
|
||||
OPNEXUS_GUI_SITES: ${OPNEXUS_GUI_SITES:-}
|
||||
GUI_GATEWAY_TOKEN: ${GUI_GATEWAY_TOKEN:-}
|
||||
volumes:
|
||||
- secrets:/data/opnsense-secrets
|
||||
- backups:/data/opnsense-backups
|
||||
@@ -197,9 +201,19 @@ services:
|
||||
environment:
|
||||
OPNEXUS_SITES: ${OPNEXUS_SITES:-localhost}
|
||||
OPNEXUS_DEFAULT_SNI: ${OPNEXUS_DEFAULT_SNI:-localhost}
|
||||
OPNEXUS_GUI_SITES: ${OPNEXUS_GUI_SITES:-https://localhost:9441}
|
||||
ports:
|
||||
- "${PROXY_BIND_ADDRESS:-0.0.0.0}:${PROXY_HTTPS_PORT:-443}:443"
|
||||
- "${PROXY_BIND_ADDRESS:-0.0.0.0}:${PROXY_HTTP_PORT:-80}:80"
|
||||
# Slot-Ports des GUI-Fernzugriffs (Profil "gui"). Standardmäßig nur auf
|
||||
# 127.0.0.1; ohne den gui-gateway-Dienst antworten sie nur mit 502.
|
||||
# Host- und Container-Port sind absichtlich gleich: die Ports stehen so
|
||||
# auch in OPNEXUS_GUI_SITES, und der Proxy meldet dem Gateway den Port,
|
||||
# auf dem die Verbindung ankam.
|
||||
# Ohne GUI_PORTS belegt Docker zufällige freie Host-Ports: ein Update darf
|
||||
# den Proxy nicht daran scheitern lassen, dass 9441-9444 schon vergeben
|
||||
# sind (z. B. Portainer auf 0.0.0.0:9443). Zum Einschalten GUI_PORTS setzen.
|
||||
- "${GUI_BIND_ADDRESS:-127.0.0.1}:${GUI_PORTS:-}:${GUI_PORTS:-9441-9444}"
|
||||
volumes:
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
@@ -228,6 +242,38 @@ services:
|
||||
ports:
|
||||
- "${OVERLAY_PORT:-51820}:51820/udp"
|
||||
|
||||
# ── Optional: GUI-Fernzugriff auf verwaltete Firewalls ──────────────
|
||||
# Standardmäßig NICHT gestartet und dreifach aus: Profil "gui"
|
||||
# (COMPOSE_PROFILES=gui), GUI_BIND_ADDRESS (Standard 127.0.0.1) und der
|
||||
# Schalter unter Einstellungen. Gleiches Image wie das Backend, aber ohne
|
||||
# Datenbank, ohne Secret-Volumes und ohne eigene Ports: der Proxy terminiert
|
||||
# TLS auf den Slot-Ports und leitet hierher weiter; mit dem Backend spricht
|
||||
# das Gateway nur über /internal/gui/* (GUI_GATEWAY_TOKEN). Siehe Handbuch,
|
||||
# "Firewall-Oberfläche über OPNexus".
|
||||
gui-gateway:
|
||||
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-backend:${OPNEXUS_VERSION:-latest}
|
||||
profiles: ["gui"]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: ["python", "-m", "app.gui_gateway"]
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GUI_GATEWAY_TOKEN: ${GUI_GATEWAY_TOKEN:-}
|
||||
OPNEXUS_GUI_SITES: ${OPNEXUS_GUI_SITES:-}
|
||||
GUI_BACKEND_URL: http://backend:8000
|
||||
user: "65534:65534"
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 3).close()"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
# ── 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
|
||||
|
||||
@@ -410,6 +410,83 @@ angesprochen (keine DNS-Namen). Nicht enthalten: Die Einrichtung auf der Firewal
|
||||
(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.
|
||||
|
||||
### Firewall-Oberfläche über OPNexus
|
||||
|
||||
Mit **Oberfläche öffnen** auf der Host-Seite öffnest du die Weboberfläche einer Firewall in einem neuen
|
||||
Browser-Tab, und zwar **über OPNexus**. Das lohnt sich vor allem bei Firewalls hinter NAT, die nur über den
|
||||
[Management-Tunnel](#firewalls-hinter-nat-management-tunnel) erreichbar sind: Dein Browser braucht keinen eigenen
|
||||
Zugang zum Tunnel-Netz. Du meldest dich an der Firewall wie gewohnt mit deren Benutzer und Passwort an; OPNexus
|
||||
kennt und speichert diese Zugangsdaten nicht und meldet nichts automatisch an.
|
||||
|
||||
**Standardmäßig ist die Funktion aus** und doppelt gesichert: Sie braucht eine Einrichtung auf dem Server und
|
||||
einen Schalter in den Einstellungen.
|
||||
|
||||
**Einrichten** (in der `.env` im OPNexus-Verzeichnis):
|
||||
|
||||
1. **HTTPS und `COOKIE_SECURE=true`** (siehe [Zugriff aus dem Netz und TLS](#zugriff-aus-dem-netz-und-tls)).
|
||||
Ohne sichere Cookies lässt sich die Funktion nicht einschalten.
|
||||
2. **Compose-Profil `gui`** aktivieren: `COMPOSE_PROFILES=gui` (mit weiteren Profilen kommagetrennt, z. B.
|
||||
`overlay,gui`). Das startet den Container `gui-gateway`.
|
||||
3. **Slot-Adressen** festlegen: `OPNEXUS_GUI_SITES=https://10.0.0.5:9441, https://10.0.0.5:9442, https://10.0.0.5:9443, https://10.0.0.5:9444`.
|
||||
Es ist dieselbe Adresse wie in `OPNEXUS_SITES`, je Slot ein Port. Diese Ursprünge dürfen **nicht** in
|
||||
`FRONTEND_ORIGIN` stehen (OPNexus startet sonst nicht).
|
||||
4. **Gemeinsames Geheimnis** `GUI_GATEWAY_TOKEN` (mindestens 32 Zeichen, z. B. `openssl rand -hex 32`).
|
||||
5. **Ports:** `GUI_PORTS=9441-9444` (die Ports aus `OPNEXUS_GUI_SITES`) sind die Slot-Ports am Proxy. Setze den
|
||||
Eintrag ausdrücklich: ohne ihn vergibt Docker zufällige freie Ports, damit ein Update nicht an schon belegten
|
||||
Ports (z. B. Portainer auf 9443) scheitert. `GUI_BIND_ADDRESS` bestimmt, wo
|
||||
sie lauschen; Standard ist `127.0.0.1` (nur lokal). Für den Zugriff aus dem Netz trägst du die Adresse des
|
||||
OPNexus-Rechners ein und gibst die Ports in der Firewall des Rechners nur für Administratoren frei.
|
||||
6. `docker compose up -d`, dann unter **Einstellungen › Firewall-Oberfläche über OPNexus** den Schalter einschalten.
|
||||
Fehlt etwas, nennt die Einstellungsseite genau, was.
|
||||
|
||||
In den Einstellungen legst du außerdem fest: **Inaktivität** (Standard 15 Minuten, 1 bis 240), **Gesamtdauer**
|
||||
(Standard 60 Minuten, 5 bis 720) und die Zahl der **gleichzeitigen Plätze** (Standard 4, 1 bis 4). Ausschalten
|
||||
beendet alle offenen Sitzungen (das Gateway bemerkt es spätestens nach 20 Sekunden).
|
||||
|
||||
**So ist es abgesichert:**
|
||||
|
||||
- **Eigene Origin:** Jede Firewall-Oberfläche läuft auf einem eigenen Port (einem „Platz“) und damit in einem
|
||||
eigenen Browser-Ursprung, getrennt von OPNexus. Skripte der Firewall-Oberfläche können OPNexus nicht
|
||||
ansprechen; OPNexus lehnt außerdem Anfragen mit Anmelde-Cookie von fremden Ursprüngen ab.
|
||||
- **Cookies bleiben auf dem Server:** Das Gateway reicht die OPNexus-Sitzung nie an die Firewall weiter und
|
||||
die Cookies der Firewall nie an deinen Browser. Die Firewall-Sitzung liegt nur im Arbeitsspeicher des Gateways.
|
||||
- **Nur Admins**, nur mit angemeldeter Browsersitzung (nie mit einem API-Token). Das Gateway prüft alle 20
|
||||
Sekunden nach: Abmelden, Entzug der Admin-Rolle oder Ausschalten beendet die Sitzung.
|
||||
- **Einmal-Ticket:** Der Klick erzeugt ein Ticket, das nur **30 Sekunden** und **genau einmal** gilt und an deine
|
||||
Sitzung und den Platz gebunden ist. Danach verschwindet es aus der Adresszeile.
|
||||
- **Nur sichere Firewalls:** https-Adresse und geprüftes oder festgesetztes Zertifikat (siehe Pinning); sonst
|
||||
bleibt der Knopf deaktiviert und nennt den Grund.
|
||||
- **Zeitlimits** (Inaktivität und Gesamtdauer) und **Audit:** Unter **Monitoring › Audit** (Art „GUI-Zugriff“)
|
||||
stehen Ticket, Start und Ende jeder Sitzung mit Benutzer, Firewall, Platz und Client-IP. Was du in der
|
||||
Oberfläche tust, protokolliert die Firewall selbst, dort aber mit der Adresse von OPNexus; das OPNexus-Audit
|
||||
zeigt, wer dahinter stand.
|
||||
- **Anmeldeschutz:** Das Gateway lässt höchstens drei fehlgeschlagene Anmeldungen pro Firewall in fünf Minuten zu.
|
||||
|
||||
**Wichtig: OPNexus-Adresse beim Anmeldeschutz der Firewall eintragen.** Über das Gateway kommen alle
|
||||
Anmeldungen von der Adresse von OPNexus (bei Firewalls im Tunnel von der des Hubs). Die Anmeldesperre von
|
||||
OPNsense (sshguard) sperrt nach mehreren Fehlversuchen diese Adresse, und damit auch die **API-Zugriffe von
|
||||
OPNexus**. Trage sie deshalb auf jeder Firewall unter **System › Einstellungen › Administration** (Login
|
||||
Protection) in die Whitelist ein, bevor du die Funktion benutzt.
|
||||
|
||||
**Einschränkungen:**
|
||||
|
||||
- **Kein WebSocket** (Funktionen, die einen brauchen, gehen nicht) und **keine automatische Anmeldung**.
|
||||
- **Höchstens 4 Plätze gleichzeitig.** Ein Platz bleibt pro OPNexus-Sitzung bei derselben Firewall; sind alle
|
||||
belegt, schließe eine Oberfläche oder warte, bis sie abläuft. Ein noch offener Tab einer alten Sitzung auf
|
||||
einem wiederverwendeten Platz bleibt ein theoretisches Restrisiko; schließe Tabs nach der Arbeit.
|
||||
- Nach einem **Neustart des wg-hub** braucht der Management-Tunnel 2 bis 4 Minuten, bis die Firewalls
|
||||
sich wieder verbunden haben; bis dahin lässt sich die Oberfläche nicht öffnen.
|
||||
- Mit `COOKIE_SECURE=false` ist die Funktion nicht verfügbar.
|
||||
|
||||
**Hinweis für Betreiber mit eigenen Skripten:** Seit dieser Funktion heißt das Sitzungs-Cookie bei HTTPS
|
||||
`__Host-opnexus-session` (bestehende Sitzungen werden beim nächsten Seitenaufruf übernommen, niemand wird abgemeldet), und Anfragen an `/api` mit Cookie
|
||||
und `Sec-Fetch-Site: same-site` oder `cross-site` werden abgelehnt. Skripte mit API-Token (`Authorization: Bearer`)
|
||||
sind nicht betroffen.
|
||||
|
||||
**Alternative ohne diese Funktion:** Du richtest dir einen eigenen WireGuard-Zugang in das Netz der Firewalls ein
|
||||
(z. B. am Standort oder auf der Firewall selbst) und rufst die Oberfläche direkt auf. Das ist unabhängig von
|
||||
OPNexus, hat aber keine Rollenprüfung und kein Audit durch OPNexus.
|
||||
|
||||
---
|
||||
|
||||
## Regeln sicher ausrollen
|
||||
@@ -519,6 +596,40 @@ OPNexus verweist dann darauf und ändert den Modus nicht.
|
||||
Einstellungen, die OPNexus nicht verwaltet (Protokollierung und Pool-Optionen beim Port Forward, Tags, Kategorien, statischer
|
||||
NAT-Port), werden nie überschrieben und im Dialog als „Bleibt auf der Firewall unverändert“ angezeigt.
|
||||
|
||||
#### Gleiches Objekt auf mehreren Firewalls: verknüpfen statt doppelt übernehmen
|
||||
|
||||
Gibt es dieselbe Regel, denselben Alias oder dieselbe NAT-Regel auf mehreren Firewalls (typisch: beide Knoten eines
|
||||
HA-Clusters, mehrere Standorte mit gleicher Vorgabe), übernimmst du sie **einmal** und **verknüpfst** sie auf den anderen
|
||||
Firewalls mit dem zentralen Objekt. Das gilt für Regeln, Aliase, Port Forward, 1:1-NAT und Outbound-NAT.
|
||||
|
||||
1. Auf der ersten Firewall normal **übernehmen**.
|
||||
2. Im Dialog die zweite Firewall wählen. Bei jedem Eintrag, der einem zentralen Objekt **exakt** entspricht, steht
|
||||
„Gibt es schon zentral“ und die Aktion **Verknüpfen** (daneben **Als eigenes Objekt übernehmen**). Verknüpfen trägt die
|
||||
Firewall als weiteres Ziel in das Objekt ein und merkt sich die UUID dort. Auf der Firewall wird nichts geändert.
|
||||
3. Die Vorschau zeigt danach für diese Firewall „keine Änderung“. Eine spätere Änderung am Objekt rollst du wie gewohnt für
|
||||
alle Ziele gemeinsam aus; es entsteht kein Duplikat.
|
||||
|
||||
**Was „exakt“ heißt:** Alle von OPNexus verwalteten Felder sind gleich, nachdem die `{{VARIABLEN}}` des zentralen Objekts mit
|
||||
den Werten **dieser** Firewall ersetzt wurden (ein Alias `{{LAN_NET}}` passt also zu `192.168.2.0/24`, wenn das die Variable
|
||||
dieser Firewall ist). Bei Aliasen müssen Name, Typ, Inhalt einschließlich der Verweise auf andere Aliase, Beschreibung und
|
||||
Aktiv-Status stimmen. Fehlt der Firewall ein Variablenwert, passt das Objekt nicht, der Dialog nennt die Variable.
|
||||
|
||||
**Abweichungen:** Gibt es ein Objekt mit gleichem Namen (Alias) bzw. gleicher Beschreibung (Regeln, NAT), das sich im Inhalt
|
||||
unterscheidet, zeigt der Dialog „Ähnlich“ mit den abweichenden Feldern (zentral gegenüber Firewall). Verknüpft wird dann **nie**,
|
||||
auch nicht automatisch. Gleiche entweder die Firewall oder das zentrale Objekt an, oder übernimm das Objekt getrennt (bei Aliasen
|
||||
geht das nur nach einer Umbenennung, der Name ist zentral eindeutig).
|
||||
|
||||
**Alle übernehmbaren:** Der Dialog zeigt vorher, was passiert („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“), und du
|
||||
bestätigst. Verknüpft wird nur, wenn genau **ein** zentrales Objekt exakt passt; gibt es mehrere identische (Altlast aus früheren
|
||||
Einzelübernahmen), entscheidest du bei dem Eintrag selbst, und „Alle“ übernimmt ihn als neues Objekt. Ein zentrales Objekt wird in
|
||||
einem Lauf höchstens einmal verknüpft.
|
||||
|
||||
**Sicherheit:** Die Firewall wird beim Verknüpfen frisch gelesen und noch einmal exakt verglichen; hat sich zwischen Anzeige und Klick
|
||||
etwas geändert, wird nichts verknüpft und die Meldung nennt die Felder. Ein zweiter Klick ändert nichts. Die Aktion steht im
|
||||
Audit-Log und in den Revisionen des Objekts („Mit <Firewall> verknüpft“). Rollen wie beim Übernehmen: Operatoren dürfen, Nur-Lese-Benutzer
|
||||
nicht. Hat das zentrale Objekt eine offene Bestätigung oder einen offenen Rollback, wird erst nach deren Klärung verknüpft.
|
||||
„Alle übernehmbaren“ lässt solche Einträge dann aus (Hinweis im Ergebnis). **Wichtig:** Ein verknüpftes Objekt wird wie jedes verwaltete Objekt behandelt: Wird das zentrale Objekt gelöscht oder die Firewall aus dessen Zielen entfernt, löscht der nächste Rollout das Objekt auch auf dieser Firewall.
|
||||
|
||||
---
|
||||
|
||||
## Gerätegruppen und hierarchische Policies
|
||||
@@ -1100,6 +1211,18 @@ beurteilt OPNexus sie nicht (das meldet „Host nicht erreichbar“) und lässt
|
||||
nie lief (etwa DHCPv6 ohne Präfix), bleibt still; einen absichtlich gestoppten Dienst nimmst du aus der Liste. Eine Firewall listet nur Dienste, die
|
||||
in ihrer Konfiguration aktiviert sind, ein nicht eingerichteter Dienst taucht also gar nicht auf.
|
||||
|
||||
### Regel-Analyse
|
||||
|
||||
Unter **Regelwerk › Regel-Analyse** fragst du, welche Regel für eine Verbindung greift: Quell- und Ziel-IP,
|
||||
Protokoll, Zielport und das Eingangs-Interface („automatisch“ rät es aus der Quell-IP und kennzeichnet das als
|
||||
Hinweis). Mit „Ausgangsseite prüfen“ werden zusätzlich die Regeln für ausgehenden Verkehr geprüft. Geprüft wird
|
||||
das erste Paket einer neuen Verbindung gegen den tatsächlich geladenen Regelsatz der Firewall; es wird nur gelesen.
|
||||
Je Firewall erscheint eine Karte mit dem Urteil (Erlaubt, Blockiert, Abgewiesen), der **Sicherheit** (sicher,
|
||||
vermutlich, unbekannt; Tooltip mit den Gründen) und der entscheidenden Regel. Ist das Ergebnis „unbekannt“
|
||||
(zum Beispiel fehlt der Zielport oder ein Alias ist nicht auflösbar), zeigt die Karte bewusst kein Urteil, sondern
|
||||
die offenen Punkte. Unter „Warum?“ steht jede geprüfte Regel in der Prüfreihenfolge mit ✓/✗/? je Feld.
|
||||
Die Abfrage steht in der Adresszeile und lässt sich als Link teilen.
|
||||
|
||||
### Sicherheitslage
|
||||
|
||||
Unter **Monitoring › Sicherheitslage** prüft OPNexus jede Firewall auf typische
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { RuleAnalysisCard } from "@/components/RuleAnalysisCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { FieldGroup, Input, Label, Select } from "@/components/ui/Field";
|
||||
import { Loading } from "@/components/ui/Loading";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { AnalysisResult, api, DeviceGroup, Host, InterfaceOption } from "@/lib/api";
|
||||
import { Network, Search } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { FormEvent, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Scope = "all" | "hosts" | "group";
|
||||
interface FormState {
|
||||
scope: Scope; hostIds: string[]; groupId: string; src: string; dst: string; proto: string;
|
||||
dport: string; sport: string; icmptype: string; iface: string; egress: boolean; egressIface: string;
|
||||
}
|
||||
|
||||
const PROTOCOLS = ["tcp", "udp", "icmp", "ipv6-icmp", "gre", "esp", "ah", "ospf", "vrrp", "carp"];
|
||||
|
||||
function fromParams(params: URLSearchParams): FormState {
|
||||
const hosts = (params.get("hosts") ?? "").split(",").filter(Boolean);
|
||||
const group = params.get("group") ?? "";
|
||||
const egress = params.get("egress") ?? "";
|
||||
return {
|
||||
scope: group ? "group" : hosts.length ? "hosts" : "all", hostIds: hosts, groupId: group,
|
||||
src: params.get("src") ?? "", dst: params.get("dst") ?? "", proto: params.get("proto") ?? "tcp",
|
||||
dport: params.get("dport") ?? "", sport: params.get("sport") ?? "", icmptype: params.get("icmptype") ?? "",
|
||||
iface: params.get("iface") ?? "auto", egress: egress !== "" && egress !== "none", egressIface: egress && egress !== "none" ? egress : "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function toParams(form: FormState): URLSearchParams {
|
||||
const q = new URLSearchParams();
|
||||
q.set("src", form.src.trim());
|
||||
q.set("dst", form.dst.trim());
|
||||
q.set("proto", (form.proto || "tcp").trim().toLowerCase());
|
||||
const proto = (form.proto || "tcp").trim().toLowerCase();
|
||||
const ported = ["tcp", "udp", "6", "17"].includes(proto);
|
||||
const icmp = ["icmp", "ipv6-icmp", "1", "58"].includes(proto);
|
||||
if (ported && form.dport.trim()) q.set("dport", form.dport.trim());
|
||||
if (ported && form.sport.trim()) q.set("sport", form.sport.trim());
|
||||
if (icmp && form.icmptype.trim()) q.set("icmptype", form.icmptype.trim());
|
||||
if (form.iface && form.iface !== "auto") q.set("iface", form.iface);
|
||||
if (form.egress) q.set("egress", form.egressIface || "auto");
|
||||
if (form.scope === "group" && form.groupId) q.set("group", form.groupId);
|
||||
if (form.scope === "hosts" && form.hostIds.length) q.set("hosts", form.hostIds.join(","));
|
||||
return q;
|
||||
}
|
||||
|
||||
export default function AnalysisPage() {
|
||||
const { t } = useLanguage();
|
||||
return <Suspense fallback={<Loading>{t("common.loading")}</Loading>}><AnalysisContent /></Suspense>;
|
||||
}
|
||||
|
||||
function AnalysisContent() {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const key = params.toString();
|
||||
const [form, setForm] = useState<FormState>(() => fromParams(params));
|
||||
const [hosts, setHosts] = useState<Host[] | null>(null);
|
||||
const [groups, setGroups] = useState<DeviceGroup[]>([]);
|
||||
const [interfaces, setInterfaces] = useState<InterfaceOption[]>([]);
|
||||
const [results, setResults] = useState<AnalysisResult[] | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const refreshNext = useRef(false);
|
||||
const counter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.listHosts().then(setHosts).catch((e) => { setHosts([]); setError(errorMessage(e, "analysis.loadFailed")); });
|
||||
api.listGroups().then(setGroups).catch(() => setGroups([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Keep the form in step with the address bar (back/forward, shared links).
|
||||
useEffect(() => { setForm(fromParams(new URLSearchParams(key))); }, [key]);
|
||||
|
||||
const enabledHosts = useMemo(() => (hosts ?? []).filter((h) => h.enabled), [hosts]);
|
||||
const scopedHosts = useMemo(() => {
|
||||
if (form.scope === "hosts") return enabledHosts.filter((h) => form.hostIds.includes(h.id));
|
||||
if (form.scope === "group") { const g = groups.find((x) => x.id === form.groupId); return enabledHosts.filter((h) => g?.host_ids.includes(h.id)); }
|
||||
return enabledHosts;
|
||||
}, [enabledHosts, form.scope, form.hostIds, form.groupId, groups]);
|
||||
|
||||
// Interface choices: the union over the firewalls in scope (names differ per firewall; an unknown one is rejected per host).
|
||||
const scopeKey = scopedHosts.map((h) => h.id).join(",");
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.allSettled(scopedHosts.map((h) => api.getHostInterfaces(h.id))).then((all) => {
|
||||
if (!active) return;
|
||||
const merged = new Map<string, InterfaceOption>();
|
||||
for (const item of all) if (item.status === "fulfilled") for (const o of item.value) if (!merged.has(o.name)) merged.set(o.name, o);
|
||||
setInterfaces([...merged.values()]);
|
||||
});
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopeKey]);
|
||||
|
||||
// Run whenever the URL carries a query (initial load, submit, shared link).
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(key);
|
||||
const src = p.get("src"); const dst = p.get("dst");
|
||||
if (!src || !dst) { setResults(null); setError(null); return; }
|
||||
const mine = ++counter.current;
|
||||
const refresh = refreshNext.current;
|
||||
refreshNext.current = false;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
api.analyzeRule({
|
||||
src, dst, proto: p.get("proto") ?? undefined, dport: p.get("dport") ?? undefined, sport: p.get("sport") ?? undefined,
|
||||
icmptype: p.get("icmptype") ?? undefined, iface: p.get("iface") ?? undefined, egress: p.get("egress") ?? undefined,
|
||||
host_ids: p.get("hosts") ?? undefined, group_id: p.get("group") ?? undefined, refresh,
|
||||
})
|
||||
.then((list) => { if (mine === counter.current) setResults(list); })
|
||||
.catch((e) => { if (mine === counter.current) { setResults(null); setError(errorMessage(e, "analysis.queryFailed")); } })
|
||||
.finally(() => { if (mine === counter.current) setRunning(false); });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, tick]);
|
||||
|
||||
function submit(event?: FormEvent, refresh = false) {
|
||||
event?.preventDefault();
|
||||
if (!form.src.trim() || !form.dst.trim()) { setFormError(t("analysis.form.missing")); return; }
|
||||
if (form.scope === "hosts" && form.hostIds.length === 0) { setFormError(t("analysis.form.noHostsSelected")); return; }
|
||||
if (form.scope === "group" && !form.groupId) { setFormError(t("analysis.form.noGroupSelected")); return; }
|
||||
setFormError(null);
|
||||
const next = toParams(form).toString();
|
||||
refreshNext.current = refresh;
|
||||
if (next === key) setTick((n) => n + 1);
|
||||
else router.push(`/analysis?${next}`);
|
||||
}
|
||||
|
||||
const set = <K extends keyof FormState>(field: K, value: FormState[K]) => setForm((f) => ({ ...f, [field]: value }));
|
||||
const proto = (form.proto || "tcp").trim().toLowerCase();
|
||||
const ported = ["tcp", "udp", "6", "17"].includes(proto);
|
||||
const icmp = ["icmp", "ipv6-icmp", "1", "58"].includes(proto);
|
||||
const ifaceChoices = (current: string) => (current && current !== "auto" && !interfaces.some((o) => o.name === current) ? [...interfaces, { name: current, label: current }] : interfaces);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 flex items-center gap-2 text-2xl font-semibold text-slate-900">
|
||||
{t("analysis.title")}
|
||||
<InfoTip text={t("analysis.intro")} size={18} />
|
||||
</h1>
|
||||
|
||||
<Card className="mb-6 p-4 sm:p-5">
|
||||
<form onSubmit={submit} aria-label={t("analysis.form.title")} className="flex flex-col gap-4" noValidate>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FieldGroup label={t("analysis.form.scope")}>
|
||||
<Select value={form.scope} onChange={(e) => set("scope", e.target.value as Scope)}>
|
||||
<option value="all">{t("analysis.form.scopeAll")}</option>
|
||||
<option value="hosts">{t("analysis.form.scopeHosts")}</option>
|
||||
<option value="group">{t("analysis.form.scopeGroup")}</option>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
{form.scope === "group" && (
|
||||
<FieldGroup label={t("analysis.form.group")}>
|
||||
<Select value={form.groupId} onChange={(e) => set("groupId", e.target.value)}>
|
||||
<option value="">-</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
)}
|
||||
<FieldGroup label={t("analysis.form.src")} hint={t("analysis.form.srcHint")}>
|
||||
<Input value={form.src} onChange={(e) => set("src", e.target.value)} inputMode="text" autoComplete="off" spellCheck={false} placeholder="198.51.100.7" className="font-mono" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("analysis.form.dst")}>
|
||||
<Input value={form.dst} onChange={(e) => set("dst", e.target.value)} autoComplete="off" spellCheck={false} placeholder="203.0.113.9" className="font-mono" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("analysis.form.proto")} hint={t("analysis.form.protoHint")}>
|
||||
<Input value={form.proto} onChange={(e) => set("proto", e.target.value)} list="analysis-protocols" autoComplete="off" spellCheck={false} />
|
||||
<datalist id="analysis-protocols">{PROTOCOLS.map((p) => <option key={p} value={p} />)}</datalist>
|
||||
</FieldGroup>
|
||||
{ported && (
|
||||
<>
|
||||
<FieldGroup label={t("analysis.form.dport")} hint={t("analysis.form.dportHint")}>
|
||||
<Input value={form.dport} onChange={(e) => set("dport", e.target.value)} inputMode="numeric" autoComplete="off" placeholder="443" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("analysis.form.sport")} hint={t("analysis.form.sportHint")}>
|
||||
<Input value={form.sport} onChange={(e) => set("sport", e.target.value)} inputMode="numeric" autoComplete="off" />
|
||||
</FieldGroup>
|
||||
</>
|
||||
)}
|
||||
{icmp && (
|
||||
<FieldGroup label={t("analysis.form.icmptype")} hint={t("analysis.form.icmptypeHint")}>
|
||||
<Input value={form.icmptype} onChange={(e) => set("icmptype", e.target.value)} autoComplete="off" placeholder="echoreq" />
|
||||
</FieldGroup>
|
||||
)}
|
||||
<FieldGroup label={t("analysis.form.iface")} hint={t("analysis.form.ifaceHint")}>
|
||||
<Select value={form.iface} onChange={(e) => set("iface", e.target.value)}>
|
||||
<option value="auto">{t("analysis.form.ifaceAuto")}</option>
|
||||
{ifaceChoices(form.iface).map((o) => <option key={o.name} value={o.name}>{o.label || o.name}</option>)}
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
{form.scope === "hosts" && (
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="mb-1 text-sm font-medium text-slate-700">{t("analysis.form.hosts")}</legend>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{enabledHosts.map((h) => (
|
||||
<label key={h.id} className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.hostIds.includes(h.id)}
|
||||
onChange={(e) => set("hostIds", e.target.checked ? [...form.hostIds, h.id] : form.hostIds.filter((id) => id !== h.id))}
|
||||
/>
|
||||
{h.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
|
||||
<label className="inline-flex min-h-[2.5rem] items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={form.egress} onChange={(e) => set("egress", e.target.checked)} />
|
||||
{t("analysis.form.egress")}
|
||||
<InfoTip text={t("analysis.form.egressHint")} size={13} />
|
||||
</label>
|
||||
{form.egress && (
|
||||
<div className="w-full sm:w-56">
|
||||
<Label htmlFor="analysis-egress-iface">{t("analysis.form.egressIface")}</Label>
|
||||
<Select id="analysis-egress-iface" aria-label={t("analysis.form.egressIface")} value={form.egressIface} onChange={(e) => set("egressIface", e.target.value)}>
|
||||
<option value="auto">{t("analysis.form.ifaceAuto")}</option>
|
||||
{ifaceChoices(form.egressIface).map((o) => <option key={o.name} value={o.name}>{o.label || o.name}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => submit(undefined, true)} disabled={running} title={t("analysis.form.refreshHint")}>
|
||||
{t("analysis.form.refresh")}
|
||||
</Button>
|
||||
<Button type="submit" loading={running}>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
{t("analysis.form.run")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{formError && <p role="alert" className="text-sm text-red-600">{formError}</p>}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{error && <p role="alert" className="mb-4 text-sm text-red-600">{error}</p>}
|
||||
{running && results === null && <Loading>{t("analysis.form.run")}…</Loading>}
|
||||
{results === null && !running && !error && <EmptyState icon={Network} title={t("analysis.empty.title")} description={t("analysis.empty.description")} />}
|
||||
{results && results.length === 0 && <EmptyState icon={Network} title={t("analysis.noHosts.title")} description={t("analysis.noHosts.description")} />}
|
||||
{results && results.length > 0 && (
|
||||
<div className="grid gap-4 lg:grid-cols-2" aria-busy={running} role="region" aria-label={t("analysis.title")}>
|
||||
{results.map((result) => <RuleAnalysisCard key={result.host_id} result={result} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { api, AuditEvent, AuditPage } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Select } from "@/components/ui/Field";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification","system"];
|
||||
const actionKeys = ["deploy","confirm","rollback","remove","retry-rollback","delete","create","edit","reboot","update"];
|
||||
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification","gui_access","system"];
|
||||
const actionKeys = ["deploy","confirm","rollback","remove","retry-rollback","delete","create","edit","reboot","update","ticket","redeem","end"];
|
||||
const statusKeys = ["requested","accepted","rejected","interrupted","success","pending","error","reverted","revert_failed","removed","submitted","acknowledged","starting","unknown","denied"];
|
||||
export default function AuditHistory() {
|
||||
const {t,ts,errorMessage,locale}=useLanguage();
|
||||
|
||||
@@ -12,6 +12,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 { OpenFirewallUi } from "@/components/OpenFirewallUi";
|
||||
import { HostVariables } from "@/components/HostVariables";
|
||||
import { BackupDiffModal } from "@/components/BackupDiffModal";
|
||||
import Link from "next/link";
|
||||
@@ -144,13 +145,14 @@ export default function HostDetailPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 flex items-center gap-4 text-sm text-slate-500">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-slate-500">
|
||||
<span>{host.base_url}</span>
|
||||
<StatusDot status={host.status} />
|
||||
<Button href={host.base_url} target="_blank" rel="noopener noreferrer" variant="ghost">
|
||||
<ExternalLink size={14} />
|
||||
{t("hosts.openInOpnsense")}
|
||||
</Button>
|
||||
<OpenFirewallUi host={host} />
|
||||
</div>
|
||||
|
||||
<HostResourceUsage host={host} />
|
||||
|
||||
@@ -196,7 +196,7 @@ function Snapshots({ hostId, readOnly }: { hostId: string; readOnly: boolean })
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<div className="flex justify-end">
|
||||
{(row.active === "-" || row.active === "") && (
|
||||
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => remove(row)}>
|
||||
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} title={t("network.rowDelete")} onClick={() => remove(row)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -231,10 +231,10 @@ function RowActions({ kind, uuid, onChange }: { kind: NetworkKind; uuid: string;
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" aria-label={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid })}>
|
||||
<Button variant="ghost" aria-label={t("network.rowEdit")} title={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid })}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid })}>
|
||||
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} title={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid })}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -638,8 +638,8 @@ function RuleTable({
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
<span className="cursor-grab text-slate-400" title={t("ph.dragTitle")} aria-hidden="true"><GripVertical size={16} /></span>
|
||||
<Button variant="ghost" disabled={i === 0} onClick={() => onMove(rule, "up")} aria-label={t("ph.moveUp")}><ArrowUp size={14} /></Button>
|
||||
<Button variant="ghost" disabled={i === rules.length - 1} onClick={() => onMove(rule, "down")} aria-label={t("ph.moveDown")}><ArrowDown size={14} /></Button>
|
||||
<Button variant="ghost" disabled={i === 0} onClick={() => onMove(rule, "up")} aria-label={t("ph.moveUp")} title={t("ph.moveUp")}><ArrowUp size={14} /></Button>
|
||||
<Button variant="ghost" disabled={i === rules.length - 1} onClick={() => onMove(rule, "down")} aria-label={t("ph.moveDown")} title={t("ph.moveDown")}><ArrowDown size={14} /></Button>
|
||||
<Button variant="outline" onClick={() => onEdit(rule)} disabled={rule.deployments.some((d) => ["pending", "revert_failed"].includes(d.status))}><Pencil size={14} /></Button>
|
||||
<Button onClick={() => onReview(rule, "preview")}><Rocket size={14} />{t("rules.reviewPush")}</Button>
|
||||
<Button variant="ghost" onClick={() => onReview(rule, "history")} title={t("rules.history")}><History size={14} /></Button>
|
||||
|
||||
@@ -189,8 +189,8 @@ export default function RolloutsPage() {
|
||||
? <Button variant="outline" onClick={() => act(() => api.deactivateRolloutPlan(p.id), "rollouts.plan.deactivated")}><PowerOff size={14} />{t("rollouts.plan.deactivate")}</Button>
|
||||
: <Button variant="outline" onClick={() => openPreview(p, "activate")}><Power size={14} />{t("rollouts.plan.activate")}</Button>)}
|
||||
{p.auto_start && p.active && !p.fingerprint_ok && <Button variant="outline" onClick={() => openPreview(p, "activate")}><Power size={14} />{t("rollouts.plan.activate")}</Button>}
|
||||
<Button variant="outline" aria-label={t("rollouts.plan.edit")} onClick={() => setEditPlan(p)}><Pencil size={14} /></Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("common.delete")} onClick={() => confirm(t("rollouts.plan.deleteConfirm", { name: p.name })) && act(() => api.deleteRolloutPlan(p.id), "rollouts.plan.deleted")}><Trash2 size={14} /></Button>
|
||||
<Button variant="outline" aria-label={t("rollouts.plan.edit")} title={t("rollouts.plan.edit")} onClick={() => setEditPlan(p)}><Pencil size={14} /></Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("common.delete")} title={t("common.delete")} onClick={() => confirm(t("rollouts.plan.deleteConfirm", { name: p.name })) && act(() => api.deleteRolloutPlan(p.id), "rollouts.plan.deleted")}><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,8 +218,8 @@ export default function RolloutsPage() {
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" aria-label={t("mwindows.edit")} onClick={() => setEditWindow(w)}><Pencil size={14} /></Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("common.delete")} onClick={() => confirm(t("mwindows.deleteConfirm", { name: w.name })) && act(() => api.deleteMaintenanceWindow(w.id), "mwindows.deleted")}><Trash2 size={14} /></Button>
|
||||
<Button variant="outline" aria-label={t("mwindows.edit")} title={t("mwindows.edit")} onClick={() => setEditWindow(w)}><Pencil size={14} /></Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("common.delete")} title={t("common.delete")} onClick={() => confirm(t("mwindows.deleteConfirm", { name: w.name })) && act(() => api.deleteMaintenanceWindow(w.id), "mwindows.deleted")}><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FieldGroup, Input, Select } from "@/components/ui/Field";
|
||||
import { useNotAdmin, useReadOnly } from "@/lib/useReadOnly";
|
||||
import { OidcSection } from "@/components/OidcSection";
|
||||
import { BackupSettingsSection } from "@/components/BackupSettingsSection";
|
||||
import { GuiAccessSection } from "@/components/GuiAccessSection";
|
||||
import { AuditExportSection } from "@/components/AuditExportSection";
|
||||
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
|
||||
import { translate } from "@/lib/i18n";
|
||||
@@ -476,5 +477,5 @@ export default function SettingsPage(){
|
||||
async function saveTheme(value:Theme){setSaving(true);setError('');setNotice('');const old=theme;setTheme(value);try{setSettings(await api.saveSettings({theme:value,language}));setNotice(t('settings.appearanceSaved'));}catch(e){setTheme(old);setError(errorMessage(e));}finally{setSaving(false);}}
|
||||
async function saveLanguage(value:"de"|"en"){setSaving(true);setError('');setNotice('');const old=language;setLanguage(value);try{setSettings(await api.saveSettings({theme,language:value}));setNotice(translate(value,'settings.language.saved'));}catch(e){setLanguage(old);setError(errorMessage(e));}finally{setSaving(false);}}
|
||||
async function changePassword(e:React.FormEvent){e.preventDefault();setError('');setNotice('');if(password!==repeat){setError(t('settings.passwordMismatch'));return;}setSaving(true);try{await api.changePassword(current,password);setCurrent('');setPassword('');setRepeat('');setNotice(t('settings.passwordChanged'));}catch(e){setError(errorMessage(e,'settings.passwordFailed'));}finally{setSaving(false);}}
|
||||
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OidcSection/><BackupSettingsSection/><AuditExportSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
|
||||
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OidcSection/><BackupSettingsSection/><AuditExportSection/><GuiAccessSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Select } from "@/components/ui/Field";
|
||||
import { Tooltip } from "@/components/ui/Tooltip";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { labelFor } from "@/lib/i18n";
|
||||
import type { LinkDiff, LinkTarget } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
linkable: boolean;
|
||||
matches: LinkTarget[];
|
||||
similar: LinkTarget[];
|
||||
/** The object could also be adopted as a new central object (false e.g. for an alias whose name is taken). */
|
||||
canAdoptNew: boolean;
|
||||
busy: boolean;
|
||||
anyBusy: boolean;
|
||||
onLink: (objectId: string) => void;
|
||||
onAdoptNew: () => void;
|
||||
}
|
||||
|
||||
function shorten(value: string): string {
|
||||
const flat = value.replace(/\n/g, " ⏎ ");
|
||||
return flat.length > 120 ? `${flat.slice(0, 117)}…` : flat;
|
||||
}
|
||||
|
||||
function showValue(value: string, t: (key: string) => string): string {
|
||||
if (value === "true") return t("adopt.link.yes");
|
||||
if (value === "false") return t("adopt.link.no");
|
||||
return value === "" ? "–" : shorten(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with an object found on a firewall when central objects already exist: link it to its exact
|
||||
* twin (the default, no duplicate), adopt it as an own object, or — when there is only a similar object —
|
||||
* see exactly which fields differ. Nothing is ever linked without the user's click, and the server
|
||||
* compares again with the live state before it links.
|
||||
*/
|
||||
export function AdoptLinkChoice({ linkable, matches, similar, canAdoptNew, busy, anyBusy, onLink, onAdoptNew }: Props) {
|
||||
const { t } = useLanguage();
|
||||
const [chosen, setChosen] = useState("");
|
||||
const selected = matches.find((m) => m.id === chosen) ?? matches[0];
|
||||
|
||||
const fieldLabel = (diff: LinkDiff) => labelFor(t, "adopt.link.fields", diff.field);
|
||||
|
||||
if (linkable && selected) {
|
||||
return (
|
||||
<div className="mt-2 space-y-2 rounded-lg border border-[rgb(var(--tone-info-border))] bg-[rgb(var(--tone-info-bg))] p-3 text-sm text-[rgb(var(--tone-info-text))]">
|
||||
<p>{t("adopt.link.found", { name: selected.name, hosts: selected.targets.join(", ") || "–" })}</p>
|
||||
{matches.length > 1 && (
|
||||
<label className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{t("adopt.link.choose")}
|
||||
<Select className="w-auto" value={selected.id} onChange={(e) => setChosen(e.target.value)}>
|
||||
{matches.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.targets.join(", ") || "–"})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tooltip content={t("adopt.link.linkTip")}>
|
||||
<Button onClick={() => onLink(selected.id)} disabled={anyBusy} loading={busy}>
|
||||
{t("adopt.link.action")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={canAdoptNew ? t("adopt.link.newTip") : t("adopt.link.newBlockedTip")} focusable={!canAdoptNew}>
|
||||
<Button variant="outline" onClick={onAdoptNew} disabled={anyBusy || !canAdoptNew}>
|
||||
{t("adopt.link.adoptNew")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (similar.length > 0) {
|
||||
return (
|
||||
<div className="mt-2 space-y-1 rounded-lg border border-[rgb(var(--tone-warning-border))] bg-[rgb(var(--tone-warning-bg))] p-3 text-xs text-[rgb(var(--tone-warning-text))]">
|
||||
{similar.map((s) => (
|
||||
<div key={s.id}>
|
||||
<p className="font-medium">{t("adopt.link.similar", { name: s.name, hosts: s.targets.join(", ") || "–" })}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{s.diff.map((d) => (
|
||||
<li key={d.field} className="break-words">
|
||||
<span className="font-medium">{fieldLabel(d)}:</span>{" "}
|
||||
{d.field === "variables"
|
||||
? t("adopt.link.variableMissing", { names: d.central })
|
||||
: t("adopt.link.diffValues", { central: showValue(d.central, t), live: showValue(d.live, t) })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<p>{t("adopt.link.similarNext")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Modal } from "@/components/ui/Modal";
|
||||
import { Select } from "@/components/ui/Field";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { AdoptLinkChoice } from "@/components/AdoptLinkChoice";
|
||||
import { AdoptAllResult, AdoptKind, api, Host, UnmanagedObject } from "@/lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -52,6 +53,7 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<AdoptAllResult | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
function load(id: string) {
|
||||
setItems(null);
|
||||
@@ -68,6 +70,7 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
useEffect(() => {
|
||||
if (hostId) {
|
||||
setResult(null);
|
||||
setConfirming(false);
|
||||
load(hostId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -78,7 +81,10 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
[items]
|
||||
);
|
||||
const systemCount = (items ?? []).filter(isSystem).length;
|
||||
const bulkCount = shown.filter((i) => i.bulk_adoptable).length;
|
||||
const linkCount = shown.filter((i) => i.bulk_action === "link").length;
|
||||
const newCount = shown.filter((i) => i.bulk_action === "new").length;
|
||||
const leftCount = shown.length - linkCount - newCount;
|
||||
const bulkCount = linkCount + newCount;
|
||||
|
||||
function reasonText(code: string, details: Record<string, string>): string {
|
||||
return t(`adopt.reasons.${code}`, { detail: details[code] ?? "" });
|
||||
@@ -98,11 +104,11 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function adopt(item: UnmanagedObject) {
|
||||
async function adopt(item: UnmanagedObject, linkTo?: string) {
|
||||
setBusy(item.uuid);
|
||||
try {
|
||||
await api.adoptObject(kind, hostId, item.uuid);
|
||||
toast.show(t("adopt.adopted", { name: item.title }), "success");
|
||||
await api.adoptObject(kind, hostId, item.uuid, linkTo);
|
||||
toast.show(t(linkTo ? "adopt.linked" : "adopt.adopted", { name: item.title }), "success");
|
||||
onAdopted();
|
||||
load(hostId);
|
||||
} catch (e) {
|
||||
@@ -114,10 +120,11 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
|
||||
async function adoptAll() {
|
||||
setBusy("all");
|
||||
setConfirming(false);
|
||||
try {
|
||||
const done = await api.adoptAllObjects(kind, hostId);
|
||||
const done = await api.adoptAllObjects(kind, hostId, true);
|
||||
setResult(done);
|
||||
toast.show(t("adopt.allDone", { count: done.adopted.length, skipped: done.skipped.length }), done.adopted.length ? "success" : "info");
|
||||
toast.show(allDoneText(done), done.adopted.length ? "success" : "info");
|
||||
onAdopted();
|
||||
load(hostId);
|
||||
} catch (e) {
|
||||
@@ -127,6 +134,14 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function allDoneText(done: AdoptAllResult): string {
|
||||
return t("adopt.allDoneLinked", {
|
||||
count: done.adopted.length,
|
||||
linked: done.adopted.filter((a) => a.linked).length,
|
||||
skipped: done.skipped.length,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t(TITLE_KEY[kind])} onClose={onClose}>
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
||||
@@ -143,17 +158,31 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
{bulkCount > 0 && (
|
||||
<Button onClick={adoptAll} disabled={busy !== null} loading={busy === "all"}>
|
||||
<Button onClick={() => setConfirming(true)} disabled={busy !== null || confirming} loading={busy === "all"}>
|
||||
{t("adopt.all", { count: bulkCount })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{confirming && (
|
||||
<div role="alertdialog" aria-label={t("adopt.confirm.title")} className="space-y-2 rounded-lg border border-slate-300 bg-slate-50 p-3 text-sm text-slate-700">
|
||||
<p className="font-medium">{t("adopt.confirm.summary", { link: linkCount, created: newCount, left: leftCount })}</p>
|
||||
<p className="text-xs text-slate-500">{t("adopt.confirm.note")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={adoptAll} disabled={busy !== null} loading={busy === "all"}>
|
||||
{t("adopt.confirm.go")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirming(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
{items === null && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
|
||||
{items !== null && shown.length === 0 && !error && <p className="text-sm text-slate-500">{t("adopt.none")}</p>}
|
||||
{result && (
|
||||
<div role="status" className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
||||
<p className="font-medium">{t("adopt.allDone", { count: result.adopted.length, skipped: result.skipped.length })}</p>
|
||||
<p className="font-medium">{allDoneText(result)}</p>
|
||||
{result.skipped.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-500">
|
||||
{result.skipped.map((s) => (
|
||||
@@ -180,7 +209,9 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
)}
|
||||
{kind === "aliases" && item.info.description && <p className="mt-1 text-xs text-slate-500">{item.info.description}</p>}
|
||||
</div>
|
||||
{item.adoptable ? (
|
||||
{item.linkable ? (
|
||||
<Badge tone="info">{t("adopt.link.badge")}</Badge>
|
||||
) : item.adoptable ? (
|
||||
<Button variant="outline" onClick={() => adopt(item)} disabled={busy !== null} loading={busy === item.uuid}>
|
||||
{t("adopt.action")}
|
||||
</Button>
|
||||
@@ -190,7 +221,17 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!item.adoptable && (
|
||||
<AdoptLinkChoice
|
||||
linkable={item.linkable}
|
||||
matches={item.matches}
|
||||
similar={item.similar}
|
||||
canAdoptNew={item.adoptable}
|
||||
busy={busy === item.uuid}
|
||||
anyBusy={busy !== null}
|
||||
onLink={(objectId) => adopt(item, objectId)}
|
||||
onAdoptNew={() => adopt(item)}
|
||||
/>
|
||||
{!item.adoptable && !item.linkable && (
|
||||
<p className="mt-2 text-xs text-slate-500">{item.reasons.map((code) => reasonText(code, item.reason_details)).join(" · ")}</p>
|
||||
)}
|
||||
{item.kept.length > 0 && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { AdoptLinkChoice } from "@/components/AdoptLinkChoice";
|
||||
import { api, Host, UnmanagedRule } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -52,11 +53,11 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId]);
|
||||
|
||||
async function adopt(rule: UnmanagedRule) {
|
||||
async function adopt(rule: UnmanagedRule, linkTo?: string) {
|
||||
setBusy(rule.uuid);
|
||||
try {
|
||||
await api.adoptRule(hostId, rule.uuid);
|
||||
toast.show(t("rules.adopt.adopted", { name: rule.description }), "success");
|
||||
await api.adoptRule(hostId, rule.uuid, linkTo);
|
||||
toast.show(t(linkTo ? "adopt.linked" : "rules.adopt.adopted", { name: rule.description }), "success");
|
||||
onAdopted();
|
||||
load(hostId);
|
||||
} catch (e) {
|
||||
@@ -101,7 +102,9 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
|
||||
{rule.log ? ` · ${t("rules.logPackets")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{rule.adoptable ? (
|
||||
{rule.linkable ? (
|
||||
<Badge tone="info">{t("adopt.link.badge")}</Badge>
|
||||
) : rule.adoptable ? (
|
||||
<Button variant="outline" onClick={() => adopt(rule)} disabled={busy !== null} loading={busy === rule.uuid}>
|
||||
{t("rules.adopt.action")}
|
||||
</Button>
|
||||
@@ -109,7 +112,17 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
|
||||
<Badge tone="neutral">{t("rules.adopt.notPossible")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!rule.adoptable && (
|
||||
<AdoptLinkChoice
|
||||
linkable={rule.linkable}
|
||||
matches={rule.matches}
|
||||
similar={rule.similar}
|
||||
canAdoptNew={rule.adoptable}
|
||||
busy={busy === rule.uuid}
|
||||
anyBusy={busy !== null}
|
||||
onLink={(objectId) => adopt(rule, objectId)}
|
||||
onAdoptNew={() => adopt(rule)}
|
||||
/>
|
||||
{!rule.adoptable && !rule.linkable && (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{rule.reasons.map((code) => t(`rules.adopt.reasons.${code}`)).join(" · ")}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Field";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, GuiAccessSettings } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Switch and limits for opening a firewall's own web interface through OPNexus. Admin only: renders nothing for other roles. */
|
||||
export function GuiAccessSection() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [cfg, setCfg] = useState<GuiAccessSettings | null>(null);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [idle, setIdle] = useState(15);
|
||||
const [absolute, setAbsolute] = useState(60);
|
||||
const [slotCount, setSlotCount] = useState(4);
|
||||
|
||||
function apply(data: GuiAccessSettings) {
|
||||
setCfg(data);
|
||||
setEnabled(data.enabled);
|
||||
setIdle(data.idle_timeout_minutes);
|
||||
setAbsolute(data.absolute_timeout_minutes);
|
||||
setSlotCount(data.slot_count);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getGuiAccess().then(apply).catch(() => setHidden(true));
|
||||
}, []);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const idleOk = Number.isInteger(idle) && idle >= 1 && idle <= 240;
|
||||
const absoluteOk = Number.isInteger(absolute) && absolute >= 5 && absolute <= 720;
|
||||
const orderOk = !idleOk || !absoluteOk || idle <= absolute;
|
||||
const slotsOk = Number.isInteger(slotCount) && slotCount >= 1 && slotCount <= 4;
|
||||
const valid = idleOk && absoluteOk && orderOk && slotsOk;
|
||||
const unavailable = cfg ? !cfg.available : false;
|
||||
// Switching on is refused by the server while the setup is incomplete; switching off is always allowed.
|
||||
const cannotEnable = unavailable && !cfg?.enabled;
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!valid) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
apply(await api.updateGuiAccess({ enabled, idle_timeout_minutes: idle, absolute_timeout_minutes: absolute, slot_count: slotCount }));
|
||||
toast.show(t("guiAccess.saved"), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="gui-access" className="scroll-mt-4 space-y-4 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||
{t("guiAccess.title")}
|
||||
<InfoTip text={t("guiAccess.intro")} />
|
||||
{cfg && <Badge tone={cfg.enabled ? "success" : "neutral"}>{cfg.enabled ? t("guiAccess.on") : t("guiAccess.off")}</Badge>}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600">{t("guiAccess.defaultOff")}</p>
|
||||
{cfg && unavailable && (
|
||||
<div role="status" className="space-y-1 rounded-lg border border-[rgb(var(--tone-warning-border))] bg-[rgb(var(--tone-warning-bg))] p-3 text-sm text-[rgb(var(--tone-warning-text))]">
|
||||
<p className="font-medium">{t("guiAccess.notReady")}</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{cfg.unavailable_reasons.map((r) => <li key={r}>{t(`guiAccess.reason.${r}`)}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={enabled} disabled={!cfg || cannotEnable} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
{t("guiAccess.enabled")}
|
||||
<InfoTip text={t("guiAccess.enabledTip")} />
|
||||
</label>
|
||||
<fieldset disabled={!cfg} className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 flex items-center gap-1 font-medium text-slate-700">{t("guiAccess.idle")}<InfoTip text={t("guiAccess.idleTip")} /></span>
|
||||
<Input type="number" min={1} max={240} value={Number.isNaN(idle) ? "" : idle} onChange={(e) => setIdle(Number(e.target.value))} aria-invalid={!idleOk || !orderOk} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 flex items-center gap-1 font-medium text-slate-700">{t("guiAccess.absolute")}<InfoTip text={t("guiAccess.absoluteTip")} /></span>
|
||||
<Input type="number" min={5} max={720} value={Number.isNaN(absolute) ? "" : absolute} onChange={(e) => setAbsolute(Number(e.target.value))} aria-invalid={!absoluteOk || !orderOk} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 flex items-center gap-1 font-medium text-slate-700">{t("guiAccess.slotCount")}<InfoTip text={t("guiAccess.slotCountTip")} /></span>
|
||||
<Input type="number" min={1} max={4} value={Number.isNaN(slotCount) ? "" : slotCount} onChange={(e) => setSlotCount(Number(e.target.value))} aria-invalid={!slotsOk} />
|
||||
</label>
|
||||
</fieldset>
|
||||
{!orderOk && <p role="alert" className="text-sm text-red-600">{t("guiAccess.orderError")}</p>}
|
||||
{cfg && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1 font-medium text-slate-700">{t("guiAccess.activeSlots")}<InfoTip text={t("guiAccess.activeSlotsTip")} /></span>
|
||||
{cfg.slots.length === 0 ? <span>{t("guiAccess.noSlots")}</span> : cfg.slots.map((s) => <Badge key={s} tone="info">{s}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
{cfg?.updated_by && cfg.updated_at && (
|
||||
<p className="text-xs text-slate-500">{t("guiAccess.lastChange", { who: cfg.updated_by, when: new Date(cfg.updated_at).toLocaleString(locale) })}</p>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" disabled={busy || !cfg || !valid} loading={busy}>{t("common.save")}</Button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function HostVariables({ host }: { host: Host }) {
|
||||
<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")}>
|
||||
<Button type="button" variant="ghost" onClick={() => { setValues({ ...values, [name]: "" }); setAdded(added.filter((n) => n !== name)); }} aria-label={t("rules.delete")} title={t("rules.delete")}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function LoginInfoBanner() {
|
||||
)}
|
||||
{sessionExpires && <p className="text-xs opacity-80">{t("loginBanner.sessionUntil", { time: sessionExpires })}</p>}
|
||||
</div>
|
||||
<button onClick={() => setInfo(null)} aria-label={t("common.close")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
|
||||
<button onClick={() => setInfo(null)} aria-label={t("common.close")} title={t("common.close")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
RefreshCw,
|
||||
Route,
|
||||
Router,
|
||||
ScanSearch,
|
||||
ScrollText,
|
||||
Search,
|
||||
Server,
|
||||
@@ -68,6 +69,7 @@ export const navGroups: NavGroup[] = [
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ href: "/rules", label: "Regeln", icon: Shield, keywords: ["Firewall-Regeln", "Filter"] },
|
||||
{ href: "/analysis", label: "Regel-Analyse", icon: ScanSearch, keywords: ["Welche Regel", "Welche Regel trifft", "Pfad", "Simulation", "Firewall-Regel prüfen", "Paket-Test", "Rule match", "Which rule", "Rule analysis", "Packet path", "Trace"] },
|
||||
{ href: "/policy-hierarchy", label: "Policy-Hierarchie", icon: GitBranch, keywords: ["Pre-Rules", "Post-Rules", "Vererbung", "Inheritance", "Hierarchische Policies"] },
|
||||
{ href: "/groups", label: "Gruppen", icon: FolderTree, keywords: ["Gerätegruppen", "Device Groups"] },
|
||||
{ href: "/aliases", label: "Aliase", icon: Tags, keywords: ["Netzwerk-Objekte", "Netzwerkgruppen"] },
|
||||
|
||||
@@ -99,10 +99,10 @@ export function NetworkObjectSection({ hostId, kind, title, reloadKey, readOnly,
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" aria-label={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid: row.uuid })}>
|
||||
<Button variant="ghost" aria-label={t("network.rowEdit")} title={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid: row.uuid })}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid: row.uuid })}>
|
||||
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} title={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid: row.uuid })}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Tooltip } from "@/components/ui/Tooltip";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, GuiAccessSettings, Host } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { MonitorUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Why this firewall cannot be opened through OPNexus (null = eligible); mirrors the server's host_refusal(). */
|
||||
function hostReason(host: Host): "disabled" | "https" | "tls" | null {
|
||||
if (!host.enabled) return "disabled";
|
||||
if (!host.base_url.trim().toLowerCase().startsWith("https://")) return "https";
|
||||
if (!host.verify_tls) return "tls";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "Open interface" button on the host page. Admins only: other roles see nothing (the server refuses them as well). */
|
||||
export function OpenFirewallUi({ host }: { host: Host }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [admin, setAdmin] = useState(false);
|
||||
const [cfg, setCfg] = useState<GuiAccessSettings | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.me().then((me) => {
|
||||
if (!active || me.role !== "admin") return;
|
||||
setAdmin(true);
|
||||
api.getGuiAccess().then((c) => active && setCfg(c)).catch(() => {});
|
||||
}).catch(() => {});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
if (!admin) return null;
|
||||
|
||||
let reason: string | null = null;
|
||||
if (!cfg) reason = null;
|
||||
else if (!cfg.available) {
|
||||
// Plain-language explanation of the first missing prerequisite (with the next step); hint at the rest.
|
||||
reason = t(`guiOpen.reason.unavailable.${cfg.unavailable_reasons[0]}`);
|
||||
if (cfg.unavailable_reasons.length > 1) reason += ` ${t("guiOpen.reason.more")}`;
|
||||
}
|
||||
else if (!cfg.enabled) reason = t("guiOpen.reason.off");
|
||||
else {
|
||||
const r = hostReason(host);
|
||||
if (r) reason = t(`guiOpen.reason.host_${r}`);
|
||||
}
|
||||
|
||||
async function open() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const ticket = await api.createGuiTicket(host.id);
|
||||
window.open(ticket.url, "_blank", "noopener,noreferrer");
|
||||
toast.show(t("guiOpen.opened", { slot: ticket.slot }), "info");
|
||||
} catch (e) {
|
||||
toast.show(errorMessage(e, "guiOpen.failed"), "danger");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={reason ?? t("guiOpen.tip")} focusable={Boolean(reason)}>
|
||||
<Button type="button" variant="outline" onClick={open} loading={busy} disabled={!cfg || Boolean(reason)}>
|
||||
<MonitorUp size={14} aria-hidden="true" />
|
||||
{t("guiOpen.button")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -96,9 +96,9 @@ export function RolloutPlanEditor({ plan, groups, windows, onClose, onSaved }: {
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div className="mb-0.5 flex gap-1">
|
||||
<Button variant="ghost" aria-label={t("rollouts.plan.moveUp")} disabled={i === 0} onClick={() => moveWave(i, -1)}><ArrowUp size={14} /></Button>
|
||||
<Button variant="ghost" aria-label={t("rollouts.plan.moveDown")} disabled={i === form.waves.length - 1} onClick={() => moveWave(i, 1)}><ArrowDown size={14} /></Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("rollouts.plan.removeWave")} disabled={form.waves.length === 1}
|
||||
<Button variant="ghost" aria-label={t("rollouts.plan.moveUp")} title={t("rollouts.plan.moveUp")} disabled={i === 0} onClick={() => moveWave(i, -1)}><ArrowUp size={14} /></Button>
|
||||
<Button variant="ghost" aria-label={t("rollouts.plan.moveDown")} title={t("rollouts.plan.moveDown")} disabled={i === form.waves.length - 1} onClick={() => moveWave(i, 1)}><ArrowDown size={14} /></Button>
|
||||
<Button variant="dangerSubtle" aria-label={t("rollouts.plan.removeWave")} title={t("rollouts.plan.removeWave")} disabled={form.waves.length === 1}
|
||||
onClick={() => set({ waves: form.waves.filter((_, j) => j !== i) })}><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { Badge, BadgeTone } from "@/components/ui/Badge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Tooltip } from "@/components/ui/Tooltip";
|
||||
import type { AnalysisAction, AnalysisConfidence, AnalysisLeg, AnalysisResult, AnalysisRule, AnalysisStep, AnalysisTri } from "@/lib/api";
|
||||
import clsx from "clsx";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, CircleHelp, ExternalLink, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
|
||||
const ACTION_TONE: Record<AnalysisAction, BadgeTone> = { pass: "success", block: "danger", reject: "warning" };
|
||||
const CONFIDENCE_TONE: Record<AnalysisConfidence, BadgeTone> = { sure: "info", probable: "warning", unknown: "neutral" };
|
||||
|
||||
/** Text for an engine code; an unknown code is shown raw instead of breaking the card. */
|
||||
function useCodeText() {
|
||||
const { t } = useLanguage();
|
||||
return (family: "notes" | "unknown" | "assumptions", code: string) => {
|
||||
const label = t(`analysis.${family}.${code}`);
|
||||
if (label === `analysis.${family}.${code}`) return { label: code, hint: t("analysis.unknownCode", { code }) };
|
||||
return { label, hint: t(`analysis.${family}.${code}_hint`) };
|
||||
};
|
||||
}
|
||||
|
||||
function ConfidenceBadge({ confidence, unresolved, unknowns }: { confidence: AnalysisConfidence; unresolved?: boolean; unknowns: string[] }) {
|
||||
const { t } = useLanguage();
|
||||
const codeText = useCodeText();
|
||||
const reasons = unknowns.map((code) => codeText("unknown", code).label);
|
||||
const tip = (
|
||||
<span className="block">
|
||||
{unresolved ? t("analysis.confidence.tip.unresolved") : t(`analysis.confidence.tip.${confidence}`)}
|
||||
{reasons.length > 0 && <span className="mt-1 block">{reasons.join(", ")}</span>}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Tooltip content={tip} focusable>
|
||||
<Badge tone={CONFIDENCE_TONE[confidence]} className={clsx(confidence === "unknown" && "border-dashed")} aria-label={`${t("analysis.confidence.label")}: ${t(`analysis.confidence.${confidence}`)}`}>
|
||||
{confidence === "unknown" ? <CircleHelp size={12} aria-hidden="true" /> : <Check size={12} aria-hidden="true" />}
|
||||
{t(`analysis.confidence.${confidence}`)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ family, code }: { family: "notes" | "unknown" | "assumptions"; code: string }) {
|
||||
const codeText = useCodeText();
|
||||
const { label, hint } = codeText(family, code);
|
||||
return (
|
||||
<Tooltip content={hint} focusable>
|
||||
<Badge tone={family === "unknown" ? "warning" : family === "assumptions" ? "neutral" : "info"}>{label}</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ruleName(rule: AnalysisRule, t: (k: string, p?: Record<string, string | number>) => string) {
|
||||
const name = rule.description || rule.label || rule.uuid;
|
||||
if (name) return name;
|
||||
return rule.pf_numbers.length ? t("analysis.result.pfNumber", { number: rule.pf_numbers.join(", ") }) : "-";
|
||||
}
|
||||
|
||||
function endpoint(address: string, port: string | null) {
|
||||
return port ? `${address}:${port}` : address;
|
||||
}
|
||||
|
||||
function RuleBlock({ rule, host }: { rule: AnalysisRule; host: string }) {
|
||||
const { t } = useLanguage();
|
||||
const details = [
|
||||
[t("analysis.result.ruleSource"), endpoint(rule.source, rule.source_port)],
|
||||
[t("analysis.result.ruleDestination"), endpoint(rule.destination, rule.destination_port)],
|
||||
rule.gateway ? [t("analysis.result.ruleGateway"), rule.gateway] : null,
|
||||
rule.schedule ? [t("analysis.result.ruleSchedule"), rule.schedule] : null,
|
||||
].filter(Boolean) as string[][];
|
||||
const id = rule.uuid || (rule.pf_numbers.length ? `@${rule.pf_numbers[0]}` : "-");
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm" data-testid="decisive-rule">
|
||||
<p className="break-words font-medium text-slate-900">{ruleName(rule, t)}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<Badge tone="neutral">{t(`analysis.section.${rule.section}`) === `analysis.section.${rule.section}` ? rule.section : t(`analysis.section.${rule.section}`)}</Badge>
|
||||
{rule.pf_numbers.length > 0 && <Badge tone="neutral">{t("analysis.result.pfNumber", { number: rule.pf_numbers.join(", ") })}</Badge>}
|
||||
{rule.quick && <Tooltip content={t("analysis.result.quickHint")} focusable><Badge tone="accent">{t("analysis.result.quick")}</Badge></Tooltip>}
|
||||
{rule.automatic && <Badge tone="neutral">{t("analysis.result.automatic")}</Badge>}
|
||||
{rule.legacy && <Badge tone="neutral">{t("analysis.result.legacy")}</Badge>}
|
||||
</div>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-slate-700">
|
||||
{details.map(([key, value]) => (
|
||||
<div key={key} className="contents">
|
||||
<dt className="text-slate-500">{key}</dt>
|
||||
<dd className="break-words font-mono text-xs leading-5">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-2">
|
||||
{rule.editable ? (
|
||||
<Tooltip content={t("analysis.result.rulesLinkHint", { id, host })}>
|
||||
<Link href="/rules?tab=live" className="inline-flex items-center gap-1 text-sm font-medium text-brand-600 underline">
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
{t("analysis.result.rulesLink")}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">{t("analysis.result.noLink")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriIcon({ value, label }: { value: AnalysisTri; label: string }) {
|
||||
const { t } = useLanguage();
|
||||
const text = `${label}: ${t(`analysis.trace.result.${value}`)}`;
|
||||
const Icon = value === "true" ? Check : value === "false" ? X : CircleHelp;
|
||||
const tone = value === "true" ? "text-emerald-700" : value === "false" ? "text-slate-400" : "text-amber-700";
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-0.5 rounded border border-slate-200 px-1.5 py-0.5 text-xs", tone)}>
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
<span className="text-slate-700">{label}</span>
|
||||
<span className="sr-only">{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const EFFECT_TONE: Record<AnalysisStep["effect"], BadgeTone> = {
|
||||
decisive: "success", overridden: "neutral", possible: "warning", no_match: "neutral", not_loaded: "neutral", disabled: "neutral",
|
||||
};
|
||||
|
||||
function StepRow({ step }: { step: AnalysisStep }) {
|
||||
const { t } = useLanguage();
|
||||
const struck = step.effect === "overridden";
|
||||
const fieldLabel = (key: string) => (t(`analysis.trace.field.${key}`) === `analysis.trace.field.${key}` ? key : t(`analysis.trace.field.${key}`));
|
||||
return (
|
||||
<li className="rounded-lg border border-slate-200 p-2 text-sm" data-effect={step.effect}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge tone={EFFECT_TONE[step.effect]} className={clsx(step.effect === "possible" && "border-dashed")}>{t(`analysis.trace.effect.${step.effect}`)}</Badge>
|
||||
<span className={clsx("min-w-0 break-words font-medium text-slate-900", struck && "text-slate-500 line-through")}>{ruleName(step.rule, t)}</span>
|
||||
{step.rule.pf_numbers.length > 0 && <span className="text-xs text-slate-500">@{step.rule.pf_numbers.join(",")}</span>}
|
||||
{step.rule.quick && <Badge tone="accent">{t("analysis.result.quick")}</Badge>}
|
||||
</div>
|
||||
<p className={clsx("mt-0.5 break-words font-mono text-xs text-slate-500", struck && "line-through")}>
|
||||
{endpoint(step.rule.source, step.rule.source_port)} → {endpoint(step.rule.destination, step.rule.destination_port)}
|
||||
{step.rule.action ? ` (${step.rule.action})` : ""}
|
||||
</p>
|
||||
{Object.keys(step.fields).length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{Object.entries(step.fields).map(([key, value]) => <TriIcon key={key} value={value} label={fieldLabel(key)} />)}
|
||||
</div>
|
||||
)}
|
||||
{step.reasons.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1" aria-label={t("analysis.trace.reasonsTitle")}>
|
||||
{step.reasons.map((code) => <Chip key={code} family="unknown" code={code} />)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Trace({ result }: { result: AnalysisResult }) {
|
||||
const { t } = useLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [all, setAll] = useState(false);
|
||||
const panelId = useId();
|
||||
const trace = result.trace ?? [];
|
||||
if (trace.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex items-center gap-1 rounded-lg text-sm font-medium text-slate-700 hover:text-brand-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{open ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronRight size={16} aria-hidden="true" />}
|
||||
{t("analysis.trace.title")}
|
||||
</button>
|
||||
{open && (
|
||||
<div id={panelId} className="mt-2">
|
||||
<p className="mb-2 text-xs text-slate-500">{t("analysis.trace.hint")}</p>
|
||||
{trace.map((leg) => {
|
||||
const steps = all ? leg.steps : leg.steps.filter((s) => s.effect !== "no_match" && s.effect !== "disabled");
|
||||
return (
|
||||
<section key={leg.leg} className="mb-3">
|
||||
<h4 className="mb-1.5 text-sm font-semibold text-slate-800">
|
||||
{t("analysis.trace.leg", { direction: t(leg.direction === "out" ? "analysis.trace.dirOut" : "analysis.trace.dirIn"), interface: leg.interface })}
|
||||
</h4>
|
||||
{steps.length === 0 ? <p className="text-sm text-slate-500">{t("analysis.trace.noSteps")}</p> : (
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{steps.map((step, index) => <StepRow key={`${step.rule.uuid ?? step.rule.label ?? "rule"}-${index}`} step={step} />)}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{trace.some((leg) => leg.steps.some((s) => s.effect === "no_match" || s.effect === "disabled")) && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={all} onChange={(e) => setAll(e.target.checked)} />
|
||||
{t("analysis.trace.showAll", { count: trace.reduce((n, leg) => n + leg.steps.filter((s) => s.effect === "no_match" || s.effect === "disabled").length, 0) })}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegLine({ leg }: { leg: AnalysisLeg }) {
|
||||
const { t } = useLanguage();
|
||||
const label = t(leg.direction === "out" ? "analysis.result.legOut" : "analysis.result.legIn", { interface: leg.interface });
|
||||
const decided = leg.confidence !== "unknown" && leg.action;
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-1.5 text-sm text-slate-700">
|
||||
<span>{label}</span>
|
||||
{leg.interface_source === "guessed" && <span className="text-xs text-slate-500">({t("analysis.result.guessed")})</span>}
|
||||
{decided ? (
|
||||
<Badge tone={ACTION_TONE[leg.action as AnalysisAction]}>{t(`analysis.verdict.${leg.action}`)}</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral" className="border-dashed">{t("analysis.verdict.unknown")}</Badge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleAnalysisCard({ result }: { result: AnalysisResult }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const confidence = result.confidence ?? "unknown";
|
||||
const unresolved = !result.error && !result.action;
|
||||
const decided = !result.error && !unresolved && confidence !== "unknown" && result.action;
|
||||
const notes = result.notes ?? [];
|
||||
const unknowns = result.unknowns ?? [];
|
||||
const assumptions = result.query?.assumptions ?? [];
|
||||
const legs = result.legs ?? [];
|
||||
const showLegs = legs.length > 1 || legs.some((leg) => leg.interface_source === "guessed");
|
||||
|
||||
return (
|
||||
<Card className={clsx("p-4", !decided && "border-dashed")} data-testid="analysis-card" data-state={result.error ? "error" : decided ? "decided" : "undecided"}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-lg font-semibold text-slate-900">{result.host_name}</h3>
|
||||
{result.cluster_name && <p className="text-xs text-slate-500">{result.cluster_name}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{result.error ? (
|
||||
<Badge tone="danger"><AlertTriangle size={12} aria-hidden="true" />{t("analysis.result.hostError")}</Badge>
|
||||
) : decided ? (
|
||||
<>
|
||||
<Badge tone={ACTION_TONE[result.action as AnalysisAction]} className="px-3 py-1 text-sm" data-testid="verdict">{t(`analysis.verdict.${result.action}`)}</Badge>
|
||||
<ConfidenceBadge confidence={confidence} unknowns={unknowns} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Badge tone="neutral" className="border-dashed px-3 py-1 text-sm" data-testid="verdict">{unresolved ? t("analysis.result.unresolved") : t("analysis.result.undecided")}</Badge>
|
||||
<ConfidenceBadge confidence="unknown" unresolved={unresolved} unknowns={unknowns} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.error && <p role="alert" className="mt-2 text-sm text-red-600">{errorMessage(result.error, "analysis.queryFailed")}</p>}
|
||||
|
||||
{!result.error && unresolved && <p className="mt-2 text-sm text-slate-600">{t("analysis.result.unresolvedHint")}</p>}
|
||||
{!result.error && !unresolved && !decided && <p className="mt-2 text-sm text-slate-600">{t("analysis.result.undecidedHint")}</p>}
|
||||
|
||||
{!result.error && !unresolved && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{decided ? t("analysis.result.decisive") : t("analysis.result.decisiveIfKnown")}
|
||||
{!decided && result.action && <span className="ml-1 normal-case tracking-normal">({t(`analysis.verdict.${result.action}`)})</span>}
|
||||
</p>
|
||||
{result.decisive_rule ? (
|
||||
<RuleBlock rule={result.decisive_rule} host={result.host_name} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<p className="font-medium text-slate-900">{t("analysis.result.noRule")}</p>
|
||||
<p className="text-slate-600">{t("analysis.result.noRuleHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLegs && <ul className="mt-3 flex flex-col gap-1">{legs.map((leg, index) => <LegLine key={index} leg={leg} />)}</ul>}
|
||||
|
||||
{unknowns.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-slate-500">{t("analysis.result.openPoints")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">{unknowns.map((code) => <Chip key={code} family="unknown" code={code} />)}</div>
|
||||
</div>
|
||||
)}
|
||||
{(notes.length > 0 || assumptions.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-slate-500">{t("analysis.result.hints")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{notes.map((code) => <Chip key={`n-${code}`} family="notes" code={code} />)}
|
||||
{assumptions.map((code) => <Chip key={`a-${code}`} family="assumptions" code={code} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.error && <Trace result={result} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function UpdateBanner() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={dismiss} aria-label={t("updateBanner.dismiss")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
|
||||
<button onClick={dismiss} aria-label={t("updateBanner.dismiss")} title={t("updateBanner.dismiss")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Modal({ title, onClose, children }: ModalProps) {
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
aria-label={t("common.close")}
|
||||
aria-label={t("common.close")} title={t("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
@@ -77,7 +77,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<p className="flex-1">{item.message}</p>
|
||||
<button
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label={t("common.close")}
|
||||
aria-label={t("common.close")} title={t("common.close")}
|
||||
className="shrink-0 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={16} />
|
||||
|
||||
@@ -104,7 +104,7 @@ export function Tooltip({
|
||||
id={id}
|
||||
role="tooltip"
|
||||
style={{ left: pos?.left ?? 0, top: pos?.top ?? 0, visibility: pos ? "visible" : "hidden" }}
|
||||
className="pointer-events-none fixed z-[60] max-w-xs rounded-lg border border-[#3f3f46] bg-[#18181b] px-3 py-2 text-xs font-normal normal-case leading-snug tracking-normal text-[#f8fafc] shadow-lg"
|
||||
className="pointer-events-none fixed z-[60] max-w-xs [overflow-wrap:anywhere] rounded-lg border border-[#3f3f46] bg-[#18181b] px-3 py-2 text-xs font-normal normal-case leading-snug tracking-normal text-[#f8fafc] shadow-lg"
|
||||
>
|
||||
{content}
|
||||
</span>,
|
||||
|
||||
+90
-4
@@ -599,6 +599,26 @@ export interface UnmanagedRule {
|
||||
log: boolean;
|
||||
adoptable: boolean;
|
||||
reasons: string[];
|
||||
/** Existing central rules equal to this one (resolved for the host); first = preferred. */
|
||||
matches: LinkTarget[];
|
||||
/** Same description, different content: shown as a hint with the differing fields, never linked. */
|
||||
similar: LinkTarget[];
|
||||
linkable: boolean;
|
||||
}
|
||||
|
||||
export interface LinkDiff {
|
||||
/** Model field or "variables" (a {{VARIABLE}} of the central object has no value on this host). */
|
||||
field: string;
|
||||
central: string;
|
||||
live: string;
|
||||
}
|
||||
|
||||
/** An existing central object a live object could be linked to (exact match) or resembles (diff). */
|
||||
export interface LinkTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
targets: string[];
|
||||
diff: LinkDiff[];
|
||||
}
|
||||
|
||||
export type AdoptKind = "aliases" | "port-forward" | "one-to-one" | "outbound-nat";
|
||||
@@ -616,10 +636,15 @@ export interface UnmanagedObject {
|
||||
/** Settings OPNexus does not manage that stay untouched on the firewall. */
|
||||
kept: string[];
|
||||
info: Record<string, string>;
|
||||
matches: LinkTarget[];
|
||||
similar: LinkTarget[];
|
||||
linkable: boolean;
|
||||
/** What "adopt all with linking" does with it: link to its twin, adopt as new, or leave it. */
|
||||
bulk_action: "link" | "new" | "skip" | null;
|
||||
}
|
||||
|
||||
export interface AdoptAllResult {
|
||||
adopted: { uuid: string; title: string; id: string }[];
|
||||
adopted: { uuid: string; title: string; id: string; linked: boolean }[];
|
||||
skipped: { uuid: string; title: string; reasons: string[]; reason_details: Record<string, string> }[];
|
||||
}
|
||||
|
||||
@@ -773,6 +798,26 @@ export interface ComplianceHost {
|
||||
findings?: ComplianceFinding[];
|
||||
}
|
||||
|
||||
export interface GuiAccessSettings {
|
||||
enabled: boolean;
|
||||
idle_timeout_minutes: number;
|
||||
absolute_timeout_minutes: number;
|
||||
slot_count: number;
|
||||
available: boolean;
|
||||
unavailable_reasons: string[];
|
||||
slots: number[];
|
||||
updated_by: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export type GuiAccessUpdate = Pick<GuiAccessSettings, "enabled" | "idle_timeout_minutes" | "absolute_timeout_minutes" | "slot_count">;
|
||||
|
||||
export interface GuiTicket {
|
||||
url: string;
|
||||
slot: number;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface AuditExportSettings {
|
||||
syslog_enabled: boolean;
|
||||
syslog_host: string | null;
|
||||
@@ -1284,6 +1329,9 @@ export const api = {
|
||||
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
logSettings: () => request<LogSettings>("/logs/settings"),
|
||||
saveLogSettings: (data: LogSettingsUpdate) => request<LogSettings>("/logs/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
getGuiAccess: () => request<GuiAccessSettings>("/settings/gui-access"),
|
||||
updateGuiAccess: (data: GuiAccessUpdate) => request<GuiAccessSettings>("/settings/gui-access", { method: "PUT", body: JSON.stringify(data) }),
|
||||
createGuiTicket: (hostId: string) => request<GuiTicket>(`/hosts/${hostId}/gui-ticket`, { method: "POST" }),
|
||||
getAuditExport: () => request<AuditExportSettings>("/settings/audit-export"),
|
||||
updateAuditExport: (data: Partial<AuditExportUpdate>) => request<AuditExportSettings>("/settings/audit-export", { method: "PUT", body: JSON.stringify(data) }),
|
||||
testAuditExport: () => request<{ success: boolean }>("/settings/audit-export/test", { method: "POST" }),
|
||||
@@ -1450,9 +1498,9 @@ export const api = {
|
||||
createRule: (data: RuleCreate) => request<Rule>("/rules", { method: "POST", body: JSON.stringify(data) }),
|
||||
listUnmanagedRules: (hostId: string) => request<UnmanagedRule[]>(`/rules/unmanaged?host_id=${hostId}`),
|
||||
listUnmanagedObjects: (kind: AdoptKind, hostId: string) => request<UnmanagedObject[]>(`/${kind}/unmanaged?host_id=${hostId}`),
|
||||
adoptObject: (kind: AdoptKind, hostId: string, uuid: string) => request<{ id: string }>(`/${kind}/adopt`, { method: "POST", body: JSON.stringify({ host_id: hostId, uuid }) }),
|
||||
adoptAllObjects: (kind: AdoptKind, hostId: string) => request<AdoptAllResult>(`/${kind}/adopt-all`, { method: "POST", body: JSON.stringify({ host_id: hostId }) }),
|
||||
adoptRule: (hostId: string, ruleUuid: string) => request<Rule>("/rules/adopt", { method: "POST", body: JSON.stringify({ host_id: hostId, rule_uuid: ruleUuid }) }),
|
||||
adoptObject: (kind: AdoptKind, hostId: string, uuid: string, linkTo?: string) => request<{ id: string }>(`/${kind}/adopt`, { method: "POST", body: JSON.stringify({ host_id: hostId, uuid, link_to: linkTo ?? null }) }),
|
||||
adoptAllObjects: (kind: AdoptKind, hostId: string, linkExisting = true) => request<AdoptAllResult>(`/${kind}/adopt-all`, { method: "POST", body: JSON.stringify({ host_id: hostId, link_existing: linkExisting }) }),
|
||||
adoptRule: (hostId: string, ruleUuid: string, linkTo?: string) => request<Rule>("/rules/adopt", { method: "POST", body: JSON.stringify({ host_id: hostId, rule_uuid: ruleUuid, link_to: linkTo ?? null }) }),
|
||||
updateRule: (id: string, data: RuleCreate, expectedUpdatedAt: string) => request<Rule>(`/rules/${id}`, { method: "PUT", body: JSON.stringify({ ...data, expected_updated_at: expectedUpdatedAt }) }),
|
||||
previewRule: (id: string) => request<RulePreview>(`/rules/${id}/preview`),
|
||||
ruleRevisions: (id: string) => request<RuleRevision[]>(`/rules/${id}/revisions`),
|
||||
@@ -1517,6 +1565,11 @@ export const api = {
|
||||
|
||||
listCompliance: () => request<ComplianceHost[]>("/compliance"),
|
||||
getHostCompliance: (id: string) => request<ComplianceHost>(`/hosts/${id}/compliance`),
|
||||
analyzeRule: (query: AnalysisQuery) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) if (value !== undefined && value !== "" && value !== false) params.set(key, String(value));
|
||||
return request<AnalysisResult[]>(`/rule-analysis/match?${params.toString()}`);
|
||||
},
|
||||
getRuleHits: (id: string) => request<RuleHit[]>(`/hosts/${id}/rule-hits`),
|
||||
checkHostCompliance: (id: string) => request<ComplianceHost>(`/hosts/${id}/compliance/check`, { method: "POST" }),
|
||||
checkFleetCompliance: () => request<ComplianceHost[]>("/compliance/check", { method: "POST" }),
|
||||
@@ -2042,3 +2095,36 @@ export interface PolicyHierarchyRule extends PolicyHierarchyRuleCreate {
|
||||
deployments: DeploymentResult[];
|
||||
}
|
||||
export interface PolicyHierarchyRuleRevision { id: string; created_at: string; note: string; snapshot: PolicyHierarchyRuleCreate; }
|
||||
|
||||
export type AnalysisAction = "pass" | "block" | "reject";
|
||||
export type AnalysisConfidence = "sure" | "probable" | "unknown";
|
||||
export type AnalysisTri = "true" | "false" | "unknown";
|
||||
export interface AnalysisRule {
|
||||
uuid: string | null; label: string | null; description: string | null; section: string; sort_order: string | null;
|
||||
pf_numbers: number[]; action: AnalysisAction | null; quick: boolean | null; interfaces: string[]; interface_not: boolean;
|
||||
direction: string; automatic: boolean; legacy: boolean; loaded: boolean | null; editable: boolean;
|
||||
source: string; destination: string; source_port: string | null; destination_port: string | null;
|
||||
gateway: string | null; schedule: string | null;
|
||||
}
|
||||
export interface AnalysisStep {
|
||||
rule: AnalysisRule; result: AnalysisTri;
|
||||
effect: "decisive" | "overridden" | "possible" | "no_match" | "not_loaded" | "disabled";
|
||||
fields: Record<string, AnalysisTri>; reasons: string[];
|
||||
}
|
||||
export interface AnalysisLeg {
|
||||
direction: string; interface: string; interface_source: string; action: AnalysisAction | null;
|
||||
confidence: AnalysisConfidence; rule_confidence: AnalysisConfidence; decisive_rule: AnalysisRule | null;
|
||||
notes: string[]; unknowns: string[];
|
||||
}
|
||||
export interface AnalysisResult {
|
||||
host_id: string; host_name: string; cluster_name: string | null;
|
||||
error: { code: string; params?: Record<string, string> } | null;
|
||||
query?: { src: string; dst: string; proto: string; sport: number | null; dport: number | null; icmptype: string | null; direction: string; assumptions: string[] };
|
||||
action?: AnalysisAction | null; confidence?: AnalysisConfidence; rule_confidence?: AnalysisConfidence;
|
||||
decisive_rule?: AnalysisRule | null; notes?: string[]; unknowns?: string[];
|
||||
legs?: AnalysisLeg[]; trace?: { leg: number; direction: string; interface: string; steps: AnalysisStep[] }[];
|
||||
}
|
||||
export interface AnalysisQuery {
|
||||
src: string; dst: string; proto?: string; dport?: string; sport?: string; icmptype?: string;
|
||||
iface?: string; egress?: string; host_ids?: string; group_id?: string; refresh?: boolean;
|
||||
}
|
||||
+378
-29
@@ -74,7 +74,7 @@
|
||||
"rolloutProblems": "Rollout angehalten oder nicht automatisch gestartet"
|
||||
},
|
||||
"pollInterval": "Prüfintervall (Sekunden)",
|
||||
"pollHint": "Mindestens 60s, um die Firewalls nicht unnötig zu belasten.",
|
||||
"pollHint": "Mindestens 60 Sekunden, um die Firewalls nicht unnötig zu belasten.",
|
||||
"monitoring": "Schwellen und Entprellung",
|
||||
"monitoringHint": "Alarme für Gateways, Ressourcen und Dienste stützen sich auf die Messungen, die OPNexus jede Minute aufzeichnet. Ein Alarm entsteht erst, wenn die letzten Messungen alle schlecht sind, und endet erst, wenn die letzten Messungen alle wieder gut sind – Einzelspitzen und flackernde Verbindungen lösen keine Meldungen aus. 0 schaltet eine einzelne Schwelle ab.",
|
||||
"limit": {
|
||||
@@ -300,7 +300,8 @@
|
||||
"hostsystem-snapshots": "Snapshots",
|
||||
"hostsystem-diagnostics": "Diagnose",
|
||||
"setup": "Einrichtung",
|
||||
"rollouts": "Rollouts"
|
||||
"rollouts": "Rollouts",
|
||||
"analysis": "Regel-Analyse"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -523,6 +524,55 @@
|
||||
"all": "Alle übernehmbaren übernehmen ({count})",
|
||||
"adopted": "„{name}“ übernommen.",
|
||||
"allDone": "{count} übernommen, {skipped} übersprungen.",
|
||||
"linked": "„{name}“ mit vorhandenem Objekt verknüpft.",
|
||||
"allDoneLinked": "{count} übernommen (davon {linked} verknüpft), {skipped} übersprungen.",
|
||||
"confirm": {
|
||||
"title": "Alle übernehmbaren übernehmen",
|
||||
"summary": "{link} verknüpfen, {created} neu übernehmen, {left} nicht übernehmbar.",
|
||||
"note": "Verknüpft wird nur, was exakt einem vorhandenen zentralen Objekt entspricht. Auf der Firewall wird nichts geändert.",
|
||||
"go": "Bestätigen und übernehmen"
|
||||
},
|
||||
"link": {
|
||||
"badge": "Gibt es schon zentral",
|
||||
"found": "Entspricht dem vorhandenen Objekt „{name}“ (Firewalls: {hosts}). Verknüpfen trägt diese Firewall dort als weiteres Ziel ein, statt ein zweites Objekt anzulegen.",
|
||||
"choose": "Verknüpfen mit",
|
||||
"action": "Verknüpfen",
|
||||
"linkTip": "Trägt diese Firewall als weiteres Ziel des vorhandenen Objekts ein. Auf der Firewall wird nichts geändert; spätere Änderungen rollst du für alle Firewalls gemeinsam aus.",
|
||||
"adoptNew": "Als eigenes Objekt übernehmen",
|
||||
"newTip": "Legt ein zweites, unabhängiges Objekt an. Nur sinnvoll, wenn diese Firewall dauerhaft eigene Werte haben soll.",
|
||||
"newBlockedTip": "Ein Objekt mit diesem Namen gibt es schon, deshalb geht nur Verknüpfen. Willst du es getrennt führen, benenne es zuerst auf der Firewall um.",
|
||||
"similar": "Ähnlich: „{name}“ (Firewalls: {hosts}) unterscheidet sich und wird deshalb nicht verknüpft.",
|
||||
"similarNext": "Gleiche das Objekt auf der Firewall oder das zentrale Objekt an, dann lässt es sich verknüpfen. Oder übernimm es getrennt.",
|
||||
"diffValues": "zentral „{central}“, auf der Firewall „{live}“",
|
||||
"variableMissing": "Für diese Firewall fehlt der Wert der Variable {names}. Trage ihn bei den Host-Variablen ein.",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"type": "Typ",
|
||||
"content": "Inhalt",
|
||||
"description": "Beschreibung",
|
||||
"enabled": "Aktiv",
|
||||
"interface": "Schnittstelle",
|
||||
"direction": "Richtung",
|
||||
"ipprotocol": "IP-Version",
|
||||
"protocol": "Protokoll",
|
||||
"action": "Aktion",
|
||||
"source_net": "Quelle",
|
||||
"source_port": "Quellport",
|
||||
"destination_net": "Ziel",
|
||||
"destination_port": "Zielport",
|
||||
"log": "Protokollieren",
|
||||
"target": "Ziel-IP",
|
||||
"local_port": "Ziel-Port",
|
||||
"target_port": "Ziel-Port (NAT)",
|
||||
"natreflection": "NAT-Reflection",
|
||||
"external": "Externe Adresse",
|
||||
"source_not": "Quelle invertiert",
|
||||
"destination_not": "Ziel invertiert",
|
||||
"variables": "Variablen"
|
||||
}
|
||||
},
|
||||
"failed": "Übernahme fehlgeschlagen",
|
||||
"loadFailed": "Objekte konnten nicht geladen werden.",
|
||||
"notPossible": "Nicht übernehmbar",
|
||||
@@ -549,6 +599,7 @@
|
||||
"unsupported_type": "Typ „{detail}“ unterstützt OPNexus nicht",
|
||||
"name_invalid": "Der Name enthält Zeichen, die OPNexus nicht zulässt (nur Buchstaben, Ziffern, _)",
|
||||
"name_exists": "In OPNexus gibt es schon einen Alias mit diesem Namen",
|
||||
"twin_busy": "Das gleiche zentrale Objekt wartet noch auf eine Bestätigung. Danach erneut versuchen",
|
||||
"content_too_large": "Inhalt zu groß (über 8000 Zeichen)",
|
||||
"proto_set": "Hat eine IPv4/IPv6-Einschränkung, die OPNexus nicht abbildet",
|
||||
"value_too_long": "Ein Wert ist länger, als OPNexus speichert",
|
||||
@@ -721,6 +772,13 @@
|
||||
"OPEN_RESTORE_BLOCKS_DEPLOY": "Eine Wiederherstellung ist für diesen Host offen. Bitte zuerst bestätigen oder abbrechen.",
|
||||
"READ_ONLY_ACCESS": "Nur-Lese-Zugriff: Diese Aktion erfordert die Rolle „Admin“.",
|
||||
"CROSS_ORIGIN_BLOCKED": "Anfrage von einer fremden Herkunft blockiert",
|
||||
"CROSS_SITE_REQUEST_BLOCKED": "Anfrage aus einer anderen Website blockiert (Sec-Fetch-Site). Öffne OPNexus direkt über seine eigene Adresse.",
|
||||
"GUI_SESSION_REQUIRED": "Die Firewall-Oberfläche lässt sich nur aus einer angemeldeten Browsersitzung öffnen, nicht mit einem API-Token.",
|
||||
"GUI_GATEWAY_NOT_CONFIGURED": "Der GUI-Zugriff ist auf diesem Server nicht eingerichtet (HTTPS-Cookies, Gateway-Token und Slot-Adressen nötig).",
|
||||
"GUI_REQUIRES_HTTPS": "Der GUI-Zugriff setzt eine HTTPS-Anmeldung voraus (COOKIE_SECURE). Bitte neu anmelden.",
|
||||
"GUI_ACCESS_DISABLED": "Der GUI-Zugriff ist ausgeschaltet. Ein Admin kann ihn in den Einstellungen einschalten.",
|
||||
"GUI_HOST_NOT_ELIGIBLE": "Diese Firewall lässt sich nicht über OPNexus öffnen: Sie muss aktiv sein, per https:// erreichbar und mit geprüftem oder gepinntem Zertifikat.",
|
||||
"GUI_NO_FREE_SLOT": "Alle {slots} Plätze für Firewall-Oberflächen sind belegt. Schließe eine geöffnete Oberfläche oder warte, bis sie abläuft.",
|
||||
"TLS_PROBE_FAILED": "Das Zertifikat der Firewall konnte nicht abgerufen werden",
|
||||
"TLS_PEM_INVALID": "Kein gültiges PEM-Zertifikat",
|
||||
"TLS_PIN_MISMATCH": "Die Firewall präsentiert ein anderes Zertifikat als das festgesetzte",
|
||||
@@ -732,6 +790,10 @@
|
||||
"RULE_NOT_ADOPTABLE": "Diese Regel kann nicht übernommen werden ({reasons})",
|
||||
"OBJECT_NOT_ADOPTABLE": "Dieses Objekt kann nicht übernommen werden ({reasons})",
|
||||
"ADOPT_OBJECT_NOT_FOUND": "Das Objekt wurde auf der Firewall nicht gefunden. Liste neu laden.",
|
||||
"ADOPT_LINK_TARGET_NOT_FOUND": "Das zentrale Objekt gibt es nicht mehr. Liste neu laden.",
|
||||
"ADOPT_LINK_TARGET_CHANGED": "Das zentrale Objekt und die Firewall unterscheiden sich inzwischen ({fields}). Liste neu laden und prüfen.",
|
||||
"ADOPT_LINK_HOST_HAS_OBJECT": "Diese Firewall verwaltet dieses Objekt schon unter einer anderen UUID. Liste neu laden.",
|
||||
"ADOPT_LINK_BUSY": "Das Objekt hat eine offene Bestätigung oder einen offenen Rollback. Erst bestätigen oder klären, dann verknüpfen.",
|
||||
"OUTBOUND_MODE_BLOCKS_ADOPT": "{host}: Im Outbound-NAT-Modus „{mode}“ zeigt OPNsense keine manuellen Regeln an. Modus in OPNsense selbst auf Hybrid/Manuell umstellen, dann lassen sie sich übernehmen. OPNexus ändert diesen Modus nicht.",
|
||||
"NETWORK_ACK_REQUIRED": "Diese Änderung kann die Verbindung zur Firewall trennen und muss ausdrücklich bestätigt werden ({reasons})",
|
||||
"NETWORK_APPLY_UNCERTAIN": "Die Änderung wurde geschrieben, das Anwenden auf „{host}“ ist aber fehlgeschlagen oder unklar. Stand auf der Firewall prüfen; Backup: {backup_id}",
|
||||
@@ -787,7 +849,12 @@
|
||||
"ROLLOUT_ACTION_NOT_ALLOWED": "Im Zustand „{status}“ nicht möglich.",
|
||||
"ROLLOUT_HOST_NOT_IN_RUN": "Dieser Host gehört nicht zu diesem Rollout.",
|
||||
"ROLLOUT_HOST_RUNNING": "Auf {host} läuft die Aktion bereits; sie wird geführt zu Ende gebracht und kann nicht übersprungen werden.",
|
||||
"ROLLOUT_HOST_FAILED": "Fehlgeschlagen: {hosts}. Erst prüfen und den Host überspringen, dann fortsetzen."
|
||||
"ROLLOUT_HOST_FAILED": "Fehlgeschlagen: {hosts}. Erst prüfen und den Host überspringen, dann fortsetzen.",
|
||||
"ANALYSE_ADRESSE_UNGUELTIG": "Keine gültige IP-Adresse ({field}: {value}). Bitte eine einzelne IPv4- oder IPv6-Adresse angeben, keinen Hostnamen.",
|
||||
"ANALYSE_FAMILIEN_MISCHUNG": "Quelle und Ziel müssen beide IPv4 oder beide IPv6 sein.",
|
||||
"ANALYSE_INTERFACE_UNBEKANNT": "Interface „{interface}“ gibt es auf dieser Firewall nicht. Vorhanden: {known}.",
|
||||
"ANALYSE_PARAMETER_UNGUELTIG": "Ungültiger Wert für {field}: {value}.",
|
||||
"ANALYSE_FIREWALL_NICHT_LESBAR": "Die Regeln dieser Firewall konnten nicht gelesen werden. Ist sie erreichbar?"
|
||||
},
|
||||
"workflow": {
|
||||
"attention": "Fehler prüfen",
|
||||
@@ -797,7 +864,7 @@
|
||||
"disabled": "Ziel-Hosts deaktiviert",
|
||||
"draft": "Änderungen ausstehend",
|
||||
"synced": "Ausgerollt",
|
||||
"tooltip": "Stand der von OPNexus gespeicherten Deployments; kein Live-Abgleich"
|
||||
"tooltip": "Zeigt, was OPNexus zuletzt ausgerollt hat. Die Firewall wird dafür nicht live abgefragt."
|
||||
},
|
||||
"certStatus": {
|
||||
"expired": "Abgelaufen",
|
||||
@@ -872,7 +939,7 @@
|
||||
"status": "Status",
|
||||
"running": "läuft",
|
||||
"stopped": "gestoppt",
|
||||
"locked": "Von OPNsense gesperrt",
|
||||
"locked": "Dieser Dienst ist in OPNsense gesperrt und lässt sich hier nicht steuern.",
|
||||
"startTitle": "Starten",
|
||||
"stopTitle": "Stoppen",
|
||||
"restartTitle": "Neustarten"
|
||||
@@ -1418,9 +1485,9 @@
|
||||
"reasserting": "Reassert läuft…",
|
||||
"reassert": "Reihenfolge neu herstellen",
|
||||
"preRules": "Pre-Regeln",
|
||||
"preHint": "Ausgewertet, bevor die Post-Regeln greifen. Über mehrere Ebenen: root-most Gruppe zuerst.",
|
||||
"preHint": "Wird vor den Post-Regeln ausgewertet. Bei mehreren Ebenen zuerst die oberste Gruppe.",
|
||||
"postRules": "Post-Regeln",
|
||||
"postHint": "Ausgewertet nach den Pre-Regeln. Über mehrere Ebenen: leaf-most (spezifischste) Gruppe zuerst.",
|
||||
"postHint": "Wird nach den Pre-Regeln ausgewertet. Bei mehreren Ebenen zuerst die unterste (spezifischste) Gruppe.",
|
||||
"editGroup": "Gruppe bearbeiten",
|
||||
"parentGroup": "Übergeordnete Gruppe (optional)",
|
||||
"rootGroup": "(keine — Wurzelgruppe)",
|
||||
@@ -1490,7 +1557,7 @@
|
||||
"registration": "Registrierung: {state}",
|
||||
"registrationOk": "erfolgreich",
|
||||
"registrationFailed": "fehlgeschlagen",
|
||||
"pushFirst": "Zuerst pushen",
|
||||
"pushFirst": "Noch nicht auf der Firewall angelegt: Übertrage es zuerst mit „Push“.",
|
||||
"register": "Registrieren",
|
||||
"requests": "ACME-Zertifikatsanfragen",
|
||||
"newRequest": "Neue Anfrage",
|
||||
@@ -1527,7 +1594,7 @@
|
||||
"deleted": "Tunnel „{name}“ gelöscht",
|
||||
"title": "IPsec-VPN (Site-to-Site)",
|
||||
"new": "Neuer Tunnel",
|
||||
"intro": "Site-to-Site-Tunnel mit Pre-Shared-Key-Authentifizierung, zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt — analog zu den Firewall-Regeln, aber ohne deren automatisches Rollback-Sicherheitsnetz: ein fehlgeschlagener Dienst-Reload kann genau die Verbindung kappen, über die OPNexus den Host erreicht. v1 unterstützt nur PSK-Auth, ein Phase-2 (Child) pro Tunnel und einfache 1:1-Netz-zu-Netz-Tunnel — kein OpenVPN/WireGuard, keine Zertifikate/EAP, keine Pools.",
|
||||
"intro": "Site-to-Site-Tunnel mit Pre-Shared Key, zentral gepflegt und auf Firewalls oder Gerätegruppen ausgerollt. Anders als bei Firewall-Regeln gibt es hier kein automatisches Rollback: Ein fehlgeschlagener Dienst-Reload kann die Verbindung kappen, über die OPNexus die Firewall erreicht. Unterstützt werden ein Phase-2 pro Tunnel und einfache Netz-zu-Netz-Tunnel (keine Zertifikate/EAP, keine Pools).",
|
||||
"showAll": "Alle Tunnel anzeigen",
|
||||
"none": "Noch keine IPsec-Tunnel angelegt.",
|
||||
"createFirst": "Ersten Tunnel anlegen",
|
||||
@@ -1569,7 +1636,7 @@
|
||||
"deleted": "Instanz „{name}“ gelöscht",
|
||||
"title": "OpenVPN-Instanzen",
|
||||
"new": "Neue Instanz",
|
||||
"intro": "OpenVPN-Instanzen (Server- oder Client-Rolle), zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt — analog zum IPsec-Feature, aber ohne dessen automatisches Rollback-Sicherheitsnetz: ein fehlgeschlagener Dienst-Reload kann genau die Verbindung kappen, über die OPNexus den Host erreicht. v1 benötigt ein bereits auf jedem Ziel-Host vorhandenes Zertifikat/CA — auswählbar per Picker aus dem jeweiligen Host-Trust-Store, da eine Zertifikat-UUID host-lokal ist —, unterstützt optionales TLS-Hardening (tls-auth/tls-crypt) über ein Static-Key, aber keine zertifikatsfreie „Shared-Key“-Instanz (im modernen OPNsense-Modell nicht vorgesehen).",
|
||||
"intro": "OpenVPN-Instanzen (Server oder Client), zentral gepflegt und auf Firewalls oder Gerätegruppen ausgerollt. Wie bei IPsec gibt es kein automatisches Rollback: Ein fehlgeschlagener Dienst-Reload kann die Verbindung kappen, über die OPNexus die Firewall erreicht. Auf jeder Ziel-Firewall muss bereits ein Zertifikat samt CA vorhanden sein (Auswahl aus deren Zertifikatsspeicher); optional gibt es TLS-Absicherung (tls-auth/tls-crypt) per Static Key, aber keine Instanzen mit „Shared Key“ ohne Zertifikat.",
|
||||
"showAll": "Alle Instanzen anzeigen",
|
||||
"none": "Noch keine OpenVPN-Instanzen angelegt.",
|
||||
"createFirst": "Erste Instanz anlegen",
|
||||
@@ -1609,7 +1676,7 @@
|
||||
"created": "Tunnel „{name}“ angelegt",
|
||||
"deleteConfirm": "Diesen WireGuard-Tunnel wirklich löschen? Er wird auch von allen Ziel-Hosts entfernt (Peer, dann Server) und der WireGuard-Dienst neu geladen.",
|
||||
"title": "WireGuard-Tunnel",
|
||||
"intro": "WireGuard-Interconnects (ein Server-Interface + genau ein Peer pro Tunnel), zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt — analog zu IPsec/OpenVPN, ohne deren automatisches Rollback-Sicherheitsnetz: ein fehlgeschlagener Dienst-Reload kann genau die Verbindung kappen, über die OPNexus den Host erreicht. v1 verlangt Server-Privatschlüssel UND -Public-Key sowie den Peer-Public-Key manuell vom Betreiber (kein Schlüsselpaar-Generator), unterstützt optional WireGuards eigenen Pre-Shared-Key pro Peer, und bildet genau eine Server↔Peer-Verbindung ab (kein Multi-Peer-Hub).",
|
||||
"intro": "WireGuard-Verbindungen (ein Server-Interface und genau ein Peer pro Tunnel), zentral gepflegt und auf Firewalls oder Gerätegruppen ausgerollt. Wie bei IPsec gibt es kein automatisches Rollback: Ein fehlgeschlagener Dienst-Reload kann die Verbindung kappen, über die OPNexus die Firewall erreicht. Die Schlüssel erzeugst und trägst du selbst ein; ein Pre-Shared Key pro Peer ist optional.",
|
||||
"none": "Noch keine WireGuard-Tunnel angelegt.",
|
||||
"noPrivateKey": "Kein Privatschlüssel hinterlegt",
|
||||
"pskMissing": "PSK aktiviert, aber nicht hinterlegt",
|
||||
@@ -1704,7 +1771,7 @@
|
||||
},
|
||||
"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.",
|
||||
"intro": "Lässt OPNexus Firewalls verwalten, die keine öffentliche Adresse haben (hinter NAT, CGNAT, Mobilfunk). Jede Firewall wählt sich per WireGuard nach außen 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. Auf dem Server nötig: der Hub-Container (COMPOSE_PROFILES=overlay) und ein aus dem Internet erreichbarer UDP-Port.",
|
||||
"loadFailed": "Der Management-Tunnel konnte nicht geladen werden",
|
||||
"saved": "Gespeichert",
|
||||
"enable": "Management-Tunnel einschalten",
|
||||
@@ -2000,7 +2067,7 @@
|
||||
"colCreated": "Angelegt",
|
||||
"colLastLogin": "Letzte Anmeldung",
|
||||
"you": "(du)",
|
||||
"cannotDeleteSelf": "Der eigene Account kann nicht gelöscht werden",
|
||||
"cannotDeleteSelf": "Du kannst dein eigenes Konto nicht löschen – das muss ein anderer Admin tun.",
|
||||
"created": "Benutzer angelegt",
|
||||
"updated": "Benutzer „{name}“ aktualisiert",
|
||||
"createFailed": "Benutzer konnte nicht angelegt werden",
|
||||
@@ -2092,7 +2159,8 @@
|
||||
"certificate": "Zertifikate",
|
||||
"acme": "ACME",
|
||||
"notification": "Benachrichtigungen",
|
||||
"system": "OPNexus-System"
|
||||
"system": "OPNexus-System",
|
||||
"gui_access": "GUI-Zugriff"
|
||||
},
|
||||
"action": {
|
||||
"deploy": "Push",
|
||||
@@ -2129,7 +2197,10 @@
|
||||
"2fa/enable": "2FA aktivieren",
|
||||
"2fa/disable": "2FA deaktivieren",
|
||||
"reset-password": "Passwort zurücksetzen",
|
||||
"reset-2fa": "2FA zurücksetzen"
|
||||
"reset-2fa": "2FA zurücksetzen",
|
||||
"ticket": "Ticket ausgestellt",
|
||||
"redeem": "Sitzung gestartet",
|
||||
"end": "Sitzung beendet"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Angefordert",
|
||||
@@ -2225,19 +2296,19 @@
|
||||
},
|
||||
"noUpdate": {
|
||||
"title": "Kein installierbares Update",
|
||||
"text": "Die letzte Prüfung meldet kein stabiles Paketupdate."
|
||||
"text": "Die letzte Prüfung meldet kein stabiles Paketupdate. Starte bei Bedarf eine neue Prüfung."
|
||||
},
|
||||
"noMajor": {
|
||||
"title": "Kein installierbares Major-Upgrade",
|
||||
"text": "Die letzte Prüfung meldet kein verfügbares Major-Upgrade."
|
||||
"text": "Die letzte Prüfung meldet kein verfügbares Major-Upgrade. Starte bei Bedarf eine neue Prüfung."
|
||||
},
|
||||
"pending": {
|
||||
"title": "Wartung noch offen",
|
||||
"text": "Eine frühere Wartung muss nach der technischen Prüfung zuerst abgeschlossen werden."
|
||||
"text": "Eine frühere Wartung muss zuerst abgeschlossen werden: Prüfe das Ergebnis und schließe sie auf der Host-Seite ab."
|
||||
},
|
||||
"change": {
|
||||
"title": "Änderung noch offen",
|
||||
"text": "Ein Deployment oder Rollback im Cluster ist noch nicht abgeschlossen."
|
||||
"text": "Ein Deployment oder Rollback im Cluster läuft noch. Warte, bis es abgeschlossen ist."
|
||||
},
|
||||
"locked": "Update gesperrt"
|
||||
},
|
||||
@@ -2271,7 +2342,7 @@
|
||||
"intro": "Beim Öffnen wird automatisch auf allen geeigneten Hosts nach Updates gesucht. Major-Upgrades und HA-Rollenwechsel bleiben bewusste manuelle Schritte.",
|
||||
"checkAgain": "Erneut nach Updates suchen",
|
||||
"refreshView": "Anzeige aktualisieren",
|
||||
"readOnlyNote": "Nur-Lese-Zugriff: Status, Paketänderungen und Protokolle sind sichtbar; Prüfungen und Updates sind gesperrt.",
|
||||
"readOnlyNote": "Mit deiner Rolle (Nur-Lesen) kannst du Status, Paketänderungen und Protokolle ansehen, aber keine Prüfungen oder Updates starten.",
|
||||
"sumHosts": "Verwaltete Hosts",
|
||||
"sumStable": "Stabile Updates",
|
||||
"sumMajor": "Major-Upgrades",
|
||||
@@ -2338,10 +2409,10 @@
|
||||
"tipCheckAll": "Fragt alle geeigneten Firewalls nach neuen Updates. Es wird nichts installiert.",
|
||||
"tipRefreshView": "Lädt den zuletzt bekannten Status aller Hosts neu, ohne die Firewalls zu fragen.",
|
||||
"tipCheck": "Fragt diese Firewall nach verfügbaren Updates. Es wird nichts installiert.",
|
||||
"tipPrepare": "Zeigt zuerst eine Vorschau. Installiert wird erst nach Ihrer ausdrücklichen Bestätigung.",
|
||||
"tipPrepare": "Zeigt zuerst eine Vorschau. Installiert wird erst, nachdem du ausdrücklich bestätigst.",
|
||||
"tipPrepareMajor": "Wechsel auf eine neue Hauptversion: dauert länger und ist riskanter. Zuerst folgt eine Vorschau.",
|
||||
"tipView": "Status dieses Hosts neu laden",
|
||||
"tipFinish": "Prüfen Sie das Ergebnis auf der Firewall und dokumentieren Sie den Abschluss.",
|
||||
"tipFinish": "Prüfe das Ergebnis auf der Firewall und dokumentiere den Abschluss.",
|
||||
"tipCancel": "Schließt einen hängenden Vorgang, ohne einen Erfolg zu bestätigen. Nur wenn der erwartete Zustand nie eingetreten ist.",
|
||||
"tipPackages": "Pakete, die beim nächsten Update geändert werden",
|
||||
"tipLog": "Zeigt das Protokoll der letzten Update-Aktion auf der Firewall",
|
||||
@@ -2351,15 +2422,15 @@
|
||||
"tipRebootRequired": "Nach dem Update ist ein Neustart der Firewall nötig. Dabei ist sie kurz nicht erreichbar.",
|
||||
"tipCarpWarn": "CARP-Wartungsmodus",
|
||||
"majorWarnShort": "Wechsel auf eine neue Hauptversion",
|
||||
"majorWarnTip": "Länger dauernd und riskanter als ein Paketupdate, ggf. mit Verhaltensänderungen an Diensten und Konfiguration. Halten Sie ein frisches Backup und Konsolenzugang bereit.",
|
||||
"majorWarnTip": "Dauert länger und ist riskanter als ein Paketupdate; Dienste und Konfiguration können sich ändern. Halte ein frisches Backup und Konsolenzugang bereit.",
|
||||
"downtimeShort": "Der Host ist während der Installation zeitweise nicht erreichbar",
|
||||
"downtimeTip": "Verbindungen können abbrechen. Es gibt keinen automatischen Rollback.",
|
||||
"tipConfirmName": "Die Eingabe des Hostnamens verhindert, dass versehentlich der falsche Host aktualisiert wird.",
|
||||
"confirmRunDisabledName": "Hostnamen exakt eingeben, um fortzufahren",
|
||||
"confirmRunDisabledAck": "Bestätigen Sie zuerst das Wartungsfenster",
|
||||
"confirmRunDisabledName": "Gib den Hostnamen exakt ein, um fortzufahren.",
|
||||
"confirmRunDisabledAck": "Bestätige zuerst das Wartungsfenster (Häkchen im Dialog).",
|
||||
"cancelWarnShort": "Bestätigt keinen erfolgreichen Abschluss",
|
||||
"cancelWarnTip": "Der Vorgang wird nur geschlossen, damit Host oder Cluster nicht gesperrt bleiben. Nur nutzen, wenn der erwartete Zustand nachweislich nie eingetreten ist (z. B. hängender CARP-Rollenwechsel).",
|
||||
"finishNoteTip": "Diese Bestätigung dokumentiert Ihre Prüfung und löst keine Firewall-Aktion aus.",
|
||||
"finishNoteTip": "Diese Bestätigung dokumentiert deine Prüfung und löst keine Aktion auf der Firewall aus.",
|
||||
"reasonMinHint": "Mindestens 3 Zeichen",
|
||||
"validUntilShort": "Vorschau gültig bis {time}"
|
||||
},
|
||||
@@ -2386,8 +2457,8 @@
|
||||
"skipVerifyHelp": "Die Verbindung bleibt verschlüsselt, aber OPNexus kann nicht erkennen, ob wirklich die Firewall antwortet. Nur für Tests.",
|
||||
"probeFailed": "Zertifikat konnte nicht abgerufen werden",
|
||||
"pinnedTip": "Zertifikat festgesetzt · SHA-256 {fp}…",
|
||||
"systemTip": "Standard-Zertifikatsprüfung. Für selbstsignierte Firewalls das Zertifikat festsetzen.",
|
||||
"insecureTip": "Zertifikat wird nicht geprüft. Im Host bearbeiten festsetzen."
|
||||
"systemTip": "Das Zertifikat wird mit den Standard-Zertifizierungsstellen geprüft. Bei einem selbstsignierten Zertifikat: unter Bearbeiten festsetzen.",
|
||||
"insecureTip": "Das Zertifikat wird nicht geprüft. Setze es unter Bearbeiten fest, um die Verbindung abzusichern."
|
||||
},
|
||||
"twofa": {
|
||||
"title": "Zwei-Faktor-Anmeldung",
|
||||
@@ -2406,7 +2477,7 @@
|
||||
"enterCode": "6-stelliger Code",
|
||||
"activate": "Aktivieren",
|
||||
"codesTitle": "Wiederherstellungscodes – jetzt sichern",
|
||||
"codesHelp": "Jeder Code funktioniert genau einmal, falls du dein Gerät verlierst. Sie werden nur jetzt angezeigt.",
|
||||
"codesHelp": "Jeder Code funktioniert genau einmal, falls du dein Gerät verlierst. Die Codes werden nur jetzt angezeigt.",
|
||||
"copy": "Kopieren",
|
||||
"download": "Als Datei speichern",
|
||||
"savedAck": "Ich habe die Codes an einem sicheren Ort gespeichert.",
|
||||
@@ -3238,5 +3309,283 @@
|
||||
"6": "Sonntag"
|
||||
},
|
||||
"cutoffSummary": "neue Hosts nur in den ersten {minutes} min"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Regel-Analyse",
|
||||
"intro": "Beantwortet für eine Verbindung (Quelle, Ziel, Protokoll, Port), welche Firewall-Regel das erste Paket entscheidet. Es wird nur gelesen; nichts wird an einer Firewall geändert.",
|
||||
"loadFailed": "Hosts konnten nicht geladen werden.",
|
||||
"queryFailed": "Die Analyse ist fehlgeschlagen.",
|
||||
"form": {
|
||||
"title": "Verbindung",
|
||||
"scope": "Welche Firewalls",
|
||||
"scopeAll": "Alle aktivierten Hosts",
|
||||
"scopeHosts": "Ausgewählte Hosts",
|
||||
"scopeGroup": "Gerätegruppe",
|
||||
"hosts": "Hosts",
|
||||
"group": "Gerätegruppe",
|
||||
"src": "Quell-IP",
|
||||
"srcHint": "Eine einzelne IPv4- oder IPv6-Adresse, kein Hostname und kein Netz.",
|
||||
"dst": "Ziel-IP",
|
||||
"proto": "Protokoll",
|
||||
"protoHint": "tcp, udp, icmp oder ein anderer Name bzw. eine Protokollnummer (0-255).",
|
||||
"dport": "Zielport",
|
||||
"dportHint": "Ohne Port bleibt das Urteil bei Regeln mit Port-Einschränkung unbekannt.",
|
||||
"sport": "Quellport (optional)",
|
||||
"sportHint": "Leer = ein beliebiger Port aus dem dynamischen Bereich.",
|
||||
"icmptype": "ICMP-Typ (optional)",
|
||||
"icmptypeHint": "Name oder Nummer, z. B. echoreq oder 8.",
|
||||
"iface": "Eingangs-Interface",
|
||||
"ifaceHint": "Auf welchem Interface das Paket ankommt. Automatisch wird aus der Quell-IP geraten und als vermutlich gekennzeichnet.",
|
||||
"ifaceAuto": "automatisch",
|
||||
"egress": "Ausgangsseite prüfen",
|
||||
"egressHint": "Prüft zusätzlich die Regeln für ausgehenden Verkehr auf dem Interface zum Ziel. Der Verkehr gilt nur als erlaubt, wenn beide Seiten ihn durchlassen.",
|
||||
"egressIface": "Ausgangs-Interface",
|
||||
"run": "Analysieren",
|
||||
"refresh": "Neu von der Firewall lesen",
|
||||
"refreshHint": "Liest die Regeln frisch von der Firewall, statt bis zu 60 Sekunden alte Daten aus dem Zwischenspeicher zu nutzen.",
|
||||
"missing": "Quell- und Ziel-IP sind Pflicht.",
|
||||
"noHostsSelected": "Wähle mindestens einen Host.",
|
||||
"noGroupSelected": "Wähle eine Gerätegruppe."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Welche Regel trifft?",
|
||||
"description": "Gib Quelle und Ziel ein; das Ergebnis erscheint hier und die Abfrage steht in der Adresszeile, sodass du sie teilen kannst."
|
||||
},
|
||||
"noHosts": {
|
||||
"title": "Keine Firewalls",
|
||||
"description": "Für die Analyse muss mindestens eine Firewall angebunden sein."
|
||||
},
|
||||
"result": {
|
||||
"summary": "{src} → {dst}, {proto}",
|
||||
"hostError": "Dieser Host konnte nicht ausgewertet werden.",
|
||||
"decisive": "Entscheidende Regel",
|
||||
"decisiveIfKnown": "Wahrscheinlichste Regel, falls die offenen Punkte nicht greifen",
|
||||
"noRule": "Keine Regel trifft",
|
||||
"noRuleHint": "Kein Regeleintrag passt; der Paketfilter (pf) lässt das Paket dann durch.",
|
||||
"unresolved": "Kein Interface bestimmbar",
|
||||
"unresolvedHint": "Die Quell-IP gehört zu keinem Interface-Netz. Wähle das Eingangs-Interface von Hand.",
|
||||
"undecided": "Nicht sicher bestimmbar",
|
||||
"undecidedHint": "Mindestens eine unklare Regel könnte das Ergebnis ändern. Das ist kein Urteil; klär die offenen Punkte unten.",
|
||||
"openPoints": "Offene Punkte",
|
||||
"hints": "Hinweise",
|
||||
"assumptions": "Annahmen",
|
||||
"legIn": "Eingang {interface}",
|
||||
"legOut": "Ausgang {interface}",
|
||||
"guessed": "geraten",
|
||||
"rulesLink": "Live-Regeln öffnen",
|
||||
"rulesLinkHint": "Öffnet die Live-Regeln. Gesucht: Regel {id} auf {host}.",
|
||||
"noLink": "Automatische oder ältere Regel: nicht in OPNexus bearbeitbar",
|
||||
"ruleSource": "Quelle",
|
||||
"ruleDestination": "Ziel",
|
||||
"rulePorts": "Port",
|
||||
"ruleGateway": "Gateway",
|
||||
"ruleSchedule": "Zeitplan",
|
||||
"quick": "quick",
|
||||
"quickHint": "quick: Diese Regel entscheidet sofort, weitere Regeln werden nicht mehr geprüft.",
|
||||
"pfNumber": "pf-Regel @{number}",
|
||||
"automatic": "automatisch",
|
||||
"legacy": "älteres Format",
|
||||
"sortOrder": "Reihenfolge {order}"
|
||||
},
|
||||
"verdict": {
|
||||
"pass": "Erlaubt",
|
||||
"block": "Blockiert",
|
||||
"reject": "Abgewiesen",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"confidence": {
|
||||
"label": "Sicherheit des Urteils",
|
||||
"sure": "sicher",
|
||||
"probable": "vermutlich",
|
||||
"unknown": "unbekannt",
|
||||
"tip": {
|
||||
"sure": "Alle Regeln konnten eindeutig geprüft werden.",
|
||||
"probable": "Das Urteil stützt sich auf Annahmen oder eine unklare Regel, die den Ausgang nicht ändern würde.",
|
||||
"unknown": "Eine unklare Regel könnte das Ergebnis ändern. Es gibt kein belastbares Urteil.",
|
||||
"unresolved": "Ohne Eingangs-Interface kann nichts geprüft werden."
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"system": "System",
|
||||
"floating": "Floating",
|
||||
"group": "Interface-Gruppe",
|
||||
"interface": "Interface",
|
||||
"nat_auto": "Automatisch (NAT)",
|
||||
"invalid": "Automatisch (Ungültiges)",
|
||||
"antispoof": "Anti-Spoofing",
|
||||
"other": "Sonstige"
|
||||
},
|
||||
"trace": {
|
||||
"title": "Warum?",
|
||||
"hint": "Alle Regeln in der Reihenfolge, in der der Paketfilter (pf) sie prüft. Der letzte Treffer gewinnt, außer eine quick-Regel greift vorher.",
|
||||
"leg": "Prüfprotokoll {direction} auf {interface}",
|
||||
"dirIn": "ein",
|
||||
"dirOut": "aus",
|
||||
"showAll": "Auch nicht zutreffende Regeln zeigen ({count})",
|
||||
"noSteps": "Keine Regeln geprüft.",
|
||||
"rule": "Regel",
|
||||
"fields": "Prüfung",
|
||||
"result": {
|
||||
"true": "trifft zu",
|
||||
"false": "trifft nicht zu",
|
||||
"unknown": "unklar"
|
||||
},
|
||||
"effect": {
|
||||
"decisive": "entscheidet",
|
||||
"overridden": "überstimmt",
|
||||
"possible": "möglicher Treffer",
|
||||
"no_match": "kein Treffer",
|
||||
"not_loaded": "nicht geladen",
|
||||
"disabled": "deaktiviert"
|
||||
},
|
||||
"field": {
|
||||
"interface": "Interface",
|
||||
"direction": "Richtung",
|
||||
"family": "Adressfamilie",
|
||||
"protocol": "Protokoll",
|
||||
"source": "Quelle",
|
||||
"destination": "Ziel",
|
||||
"source_port": "Quellport",
|
||||
"destination_port": "Zielport",
|
||||
"ports": "Ports",
|
||||
"icmp_type": "ICMP-Typ",
|
||||
"tagged": "Markierung",
|
||||
"received_on": "Empfangen auf",
|
||||
"action": "Aktion"
|
||||
},
|
||||
"reasonsTitle": "Gründe"
|
||||
},
|
||||
"unknownCode": "Unbekannter Code: {code}",
|
||||
"notes": {
|
||||
"IFACE_GUESSED": "Interface geraten",
|
||||
"IFACE_GUESSED_hint": "Das Eingangs-Interface wurde aus der Quell-IP abgeleitet, nicht vorgegeben. Wähle es von Hand, um sicher zu sein.",
|
||||
"EGRESS_GUESSED": "Ausgang geraten",
|
||||
"EGRESS_GUESSED_hint": "Das Ausgangs-Interface wurde aus der Ziel-IP abgeleitet.",
|
||||
"IFACE_UNRESOLVED": "Interface unbekannt",
|
||||
"IFACE_UNRESOLVED_hint": "Für die Quell-IP konnte kein Interface bestimmt werden.",
|
||||
"EGRESS_UNRESOLVED": "Ausgang unbekannt",
|
||||
"EGRESS_UNRESOLVED_hint": "Für die Ziel-IP konnte kein Ausgangs-Interface bestimmt werden; die Ausgangsseite wurde nicht geprüft.",
|
||||
"NAT_NEARBY": "NAT in der Nähe",
|
||||
"NAT_NEARBY_hint": "Eine Portweiterleitung passt zu dieser Verbindung. Die Filterregeln sehen die Adresse nach der Umsetzung; das Urteil ist nur vermutlich richtig.",
|
||||
"NAT_UNAVAILABLE": "NAT nicht lesbar",
|
||||
"NAT_UNAVAILABLE_hint": "Die Portweiterleitungen der Firewall konnten nicht gelesen werden; eine Umsetzung ist nicht ausgeschlossen.",
|
||||
"POLICY_ROUTE": "Policy-Routing",
|
||||
"POLICY_ROUTE_hint": "Die entscheidende Regel schickt den Verkehr über ein bestimmtes Gateway.",
|
||||
"STATELESS": "Zustandslos",
|
||||
"STATELESS_hint": "Die Regel merkt sich keine Verbindung. Die Antwortpakete brauchen eine eigene Regel.",
|
||||
"SCHEDULE_NOW": "Zeitplan",
|
||||
"SCHEDULE_NOW_hint": "Die Regel hat einen Zeitplan und ist laut Firewall gerade geladen.",
|
||||
"RULES_NOT_LOADED": "Regeln nicht geladen",
|
||||
"RULES_NOT_LOADED_hint": "Aktivierte Regeln stehen nicht im geladenen Regelsatz (zum Beispiel wegen eines Zeitplans) und wurden übersprungen.",
|
||||
"PF_RULESET_UNAVAILABLE": "Regelsatz nicht lesbar",
|
||||
"PF_RULESET_UNAVAILABLE_hint": "Der geladene pf-Regelsatz war nicht lesbar. Reihenfolge und Ladezustand sind daher nur geschätzt.",
|
||||
"ORDER_MISMATCH": "Reihenfolge weicht ab",
|
||||
"ORDER_MISMATCH_hint": "Die Reihenfolge der Konfiguration stimmt nicht mit dem geladenen Regelsatz überein.",
|
||||
"ORDER_GUESSED": "Reihenfolge geschätzt",
|
||||
"ORDER_GUESSED_hint": "Die Reihenfolge einiger Regeln ließ sich nicht belegen und wurde geschätzt.",
|
||||
"TCP_FLAGS": "TCP-Flags",
|
||||
"TCP_FLAGS_hint": "Die Regel prüft TCP-Flags; das erste Paket (SYN) trifft sie vermutlich, aber nicht sicher.",
|
||||
"RATE_LIMIT": "Ratenbegrenzung",
|
||||
"RATE_LIMIT_hint": "Die Regel begrenzt Verbindungen; bei Überschreitung kann sie anders wirken.",
|
||||
"PF_DEFAULT_PASS": "Standard: durchlassen",
|
||||
"PF_DEFAULT_PASS_hint": "Keine Regel trifft; der Paketfilter (pf) lässt das Paket dann durch.",
|
||||
"QUICK_UNKNOWN": "quick unklar",
|
||||
"QUICK_UNKNOWN_hint": "Bei einer Regel ist unklar, ob sie sofort entscheidet.",
|
||||
"GROUPS_UNAVAILABLE": "Interface-Gruppen nicht lesbar",
|
||||
"GROUPS_UNAVAILABLE_hint": "Die Interface-Gruppen konnten nicht gelesen werden; Regeln für Gruppen sind unklar.",
|
||||
"ALIASES_UNAVAILABLE": "Aliase nicht lesbar",
|
||||
"ALIASES_UNAVAILABLE_hint": "Die Alias-Liste konnte nicht gelesen werden; Regeln mit Aliasen sind unklar.",
|
||||
"ALIAS_REFERENCES_UNAVAILABLE": "Alias-Abfrage nicht möglich",
|
||||
"ALIAS_REFERENCES_UNAVAILABLE_hint": "Ob die Adressen in Aliasen stehen, konnte die Firewall nicht beantworten."
|
||||
},
|
||||
"unknown": {
|
||||
"ALIAS_MISSING": "Alias fehlt",
|
||||
"ALIAS_MISSING_hint": "Eine Regel verweist auf einen Alias, den es auf der Firewall nicht gibt.",
|
||||
"ALIAS_TABLE_UNAVAILABLE": "Alias-Tabelle fehlt",
|
||||
"ALIAS_TABLE_UNAVAILABLE_hint": "Die pf-Tabelle des Alias konnte nicht abgefragt werden (z. B. Geo-IP oder URL-Liste).",
|
||||
"ALIAS_CYCLE": "Alias-Zyklus",
|
||||
"ALIAS_CYCLE_hint": "Aliase verweisen im Kreis aufeinander.",
|
||||
"ALIAS_TOO_DEEP": "Alias zu tief",
|
||||
"ALIAS_TOO_DEEP_hint": "Die Verschachtelung der Aliase ist zu tief für die Auswertung.",
|
||||
"ALIAS_EXCLUSION": "Alias mit Ausnahme",
|
||||
"ALIAS_EXCLUSION_hint": "Ein Alias enthält Ausnahmen, die sich nicht sicher auswerten lassen.",
|
||||
"FQDN_UNRESOLVED": "Hostname nicht aufgelöst",
|
||||
"FQDN_UNRESOLVED_hint": "Ein Hostname in einem Alias ließ sich nicht in Adressen auflösen.",
|
||||
"TAGGED": "Markierte Pakete",
|
||||
"TAGGED_hint": "Eine Regel gilt nur für markierte Pakete. Die Markierung setzt eine andere Regel, die hier nicht zutraf.",
|
||||
"PORT_NOT_GIVEN": "Zielport fehlt",
|
||||
"PORT_NOT_GIVEN_hint": "Ohne Zielport lässt sich nicht sagen, ob eine Regel mit Port-Einschränkung trifft.",
|
||||
"SRC_PORT_NOT_GIVEN": "Quellport fehlt",
|
||||
"SRC_PORT_NOT_GIVEN_hint": "Eine Regel schränkt den Quellport ein, der nicht angegeben ist.",
|
||||
"PORT_ALIAS_UNRESOLVED": "Port-Alias unklar",
|
||||
"PORT_ALIAS_UNRESOLVED_hint": "Ein Port-Alias ließ sich nicht auflösen.",
|
||||
"PORT_UNPARSEABLE": "Port nicht lesbar",
|
||||
"PORT_UNPARSEABLE_hint": "Eine Port-Angabe in einer Regel ließ sich nicht deuten.",
|
||||
"ICMP_TYPE_NOT_GIVEN": "ICMP-Typ fehlt",
|
||||
"ICMP_TYPE_NOT_GIVEN_hint": "Eine Regel gilt nur für bestimmte ICMP-Typen; der Typ ist nicht angegeben.",
|
||||
"VIP_AS_INTERFACE_ADDRESS": "Virtuelle IP",
|
||||
"VIP_AS_INTERFACE_ADDRESS_hint": "Eine virtuelle IP wurde als Adresse des Interfaces angegeben. Ob die Regel sie umfasst, ist unklar.",
|
||||
"QUICK_UNKNOWN": "quick unklar",
|
||||
"QUICK_UNKNOWN_hint": "Bei einer Regel ist unklar, ob sie sofort entscheidet.",
|
||||
"RECEIVED_ON": "Empfangs-Interface unklar",
|
||||
"RECEIVED_ON_hint": "Eine Regel gilt nur für Pakete, die auf einem bestimmten Interface ankamen; das ist auf der Ausgangsseite nicht bekannt.",
|
||||
"ACTION_UNKNOWN": "Aktion unklar",
|
||||
"ACTION_UNKNOWN_hint": "Die Aktion einer Regel ließ sich nicht bestimmen.",
|
||||
"IFACE_UNRESOLVED": "Interface-Gruppe unklar",
|
||||
"IFACE_UNRESOLVED_hint": "Eine Interface-Gruppe ließ sich nicht auf ihre Mitglieder abbilden.",
|
||||
"GROUPS_UNAVAILABLE": "Interface-Gruppen nicht lesbar",
|
||||
"GROUPS_UNAVAILABLE_hint": "Die Interface-Gruppen konnten nicht gelesen werden."
|
||||
},
|
||||
"assumptions": {
|
||||
"FIRST_PACKET": "Erstes Paket",
|
||||
"FIRST_PACKET_hint": "Bewertet wird das erste Paket einer neuen Verbindung. Antworten und bestehende Verbindungen folgen dem Zustand, nicht den Regeln.",
|
||||
"SRC_PORT_EPHEMERAL": "Dynamischer Quellport",
|
||||
"SRC_PORT_EPHEMERAL_hint": "Ohne Quellport wird ein beliebiger Port aus dem dynamischen Bereich angenommen."
|
||||
}
|
||||
},
|
||||
"guiAccess": {
|
||||
"title": "Firewall-Oberfläche über OPNexus",
|
||||
"intro": "Öffnet die Weboberfläche einer verwalteten Firewall in einem eigenen Browser-Tab über OPNexus, auch wenn die Firewall nur über den Management-Tunnel erreichbar ist. Nur Admins; die Anmeldung an der Firewall erfolgt wie gewohnt dort. Das Ticket gilt einmalig 30 Sekunden, jede Sitzung hat Zeitlimits und wird im Audit protokolliert. Einzelheiten: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
|
||||
"defaultOff": "Standardmäßig aus. Eingerichtet wird es auf dem Server (Compose-Profil „gui“, OPNEXUS_GUI_SITES, GUI_GATEWAY_TOKEN) – siehe Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
|
||||
"on": "An",
|
||||
"off": "Aus",
|
||||
"notReady": "Noch nicht eingerichtet. Es fehlt:",
|
||||
"reason": {
|
||||
"cookie_secure": "Sichere Anmeldung über HTTPS (Einstellung COOKIE_SECURE=true)",
|
||||
"gateway_token": "Zugangs-Token für den GUI-Gateway-Dienst (GUI_GATEWAY_TOKEN, mindestens 32 Zeichen)",
|
||||
"gui_sites": "Ports für die Firewall-Oberflächen (OPNEXUS_GUI_SITES) und das Compose-Profil „gui“"
|
||||
},
|
||||
"enabled": "Öffnen der Firewall-Oberfläche erlauben",
|
||||
"enabledTip": "Schaltet die Funktion für alle Admins ein. Beim Ausschalten werden offene Sitzungen beendet (binnen etwa 20 Sekunden).",
|
||||
"idle": "Inaktivität (Minuten)",
|
||||
"idleTip": "Nach so vielen Minuten ohne Aktivität wird die Sitzung beendet (1 bis 240). Darf nicht länger als das Gesamtlimit sein.",
|
||||
"absolute": "Gesamtdauer (Minuten)",
|
||||
"absoluteTip": "Spätestens nach dieser Zeit endet jede Sitzung, auch bei Aktivität (5 bis 720).",
|
||||
"slotCount": "Gleichzeitige Plätze",
|
||||
"slotCountTip": "Wie viele Firewall-Oberflächen gleichzeitig offen sein können (1 bis 4). Jeder Platz hat einen eigenen Port, damit sich die offenen Firewalls im Browser nicht gegenseitig stören.",
|
||||
"orderError": "Das Inaktivitätslimit darf nicht größer sein als die Gesamtdauer.",
|
||||
"activeSlots": "Aktive Plätze (Ports)",
|
||||
"activeSlotsTip": "Die in OPNEXUS_GUI_SITES festgelegten Ports, soweit sie bei der gewählten Anzahl genutzt werden. Welcher Platz gerade belegt ist, steht im Audit (Art „GUI-Zugriff“).",
|
||||
"noSlots": "keine konfiguriert",
|
||||
"lastChange": "Zuletzt geändert von {who} am {when}",
|
||||
"saved": "Einstellungen für die Firewall-Oberfläche gespeichert"
|
||||
},
|
||||
"guiOpen": {
|
||||
"button": "Oberfläche öffnen",
|
||||
"tip": "Öffnet die Weboberfläche dieser Firewall über OPNexus in einem neuen Tab.",
|
||||
"opened": "Oberfläche wird in einem neuen Tab geöffnet (Platz {slot}). Falls nichts passiert, Pop-ups für diese Seite erlauben.",
|
||||
"failed": "Oberfläche konnte nicht geöffnet werden",
|
||||
"reason": {
|
||||
"off": "Ausgeschaltet. Einschalten unter Einstellungen › Firewall-Oberfläche über OPNexus.",
|
||||
"host_disabled": "Diese Firewall ist deaktiviert. Aktiviere sie unter Hosts › Bearbeiten (Häkchen „Aktiv“).",
|
||||
"host_https": "Die Adresse dieser Firewall beginnt nicht mit https://. Ändere sie unter Hosts › Bearbeiten.",
|
||||
"host_tls": "Die Zertifikatsprüfung ist für diese Firewall ausgeschaltet. Schalte sie unter Hosts › Bearbeiten ein oder setze das Zertifikat fest.",
|
||||
"unavailable": {
|
||||
"cookie_secure": "Nicht eingerichtet: Die sichere HTTPS-Anmeldung ist auf dem Server nicht aktiv (Einstellung COOKIE_SECURE). Das muss ein Server-Admin ändern – Anleitung: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
|
||||
"gateway_token": "Nicht eingerichtet: Es fehlt das Zugangs-Token für den GUI-Gateway-Dienst (GUI_GATEWAY_TOKEN, mindestens 32 Zeichen). Das muss ein Server-Admin eintragen – Anleitung: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
|
||||
"gui_sites": "Nicht eingerichtet: Es sind keine Ports für die Firewall-Oberflächen festgelegt (OPNEXUS_GUI_SITES, Compose-Profil „gui“). Das muss ein Server-Admin eintragen – Anleitung: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“."
|
||||
},
|
||||
"more": "Weitere fehlende Punkte: Einstellungen › Firewall-Oberfläche über OPNexus."
|
||||
}
|
||||
}
|
||||
}
|
||||
+373
-24
@@ -74,7 +74,7 @@
|
||||
"rolloutProblems": "Rollout paused or not started automatically"
|
||||
},
|
||||
"pollInterval": "Check interval (seconds)",
|
||||
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily.",
|
||||
"pollHint": "At least 60 seconds, so the firewalls are not queried needlessly.",
|
||||
"monitoring": "Thresholds and debouncing",
|
||||
"monitoringHint": "Alerts for gateways, resources and services use the measurements OPNexus records every minute. An alert opens only when the latest measurements are all bad and closes only when they are all good again, so single spikes and flapping links do not send messages. 0 switches a single threshold off.",
|
||||
"limit": {
|
||||
@@ -300,7 +300,8 @@
|
||||
"hostsystem-snapshots": "Snapshots",
|
||||
"hostsystem-diagnostics": "Diagnostics",
|
||||
"setup": "Setup",
|
||||
"rollouts": "Rollouts"
|
||||
"rollouts": "Rollouts",
|
||||
"analysis": "Rule analysis"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -523,6 +524,55 @@
|
||||
"all": "Adopt all adoptable ({count})",
|
||||
"adopted": "Adopted “{name}”.",
|
||||
"allDone": "{count} adopted, {skipped} skipped.",
|
||||
"linked": "“{name}” linked to the existing object.",
|
||||
"allDoneLinked": "{count} adopted ({linked} of them linked), {skipped} skipped.",
|
||||
"confirm": {
|
||||
"title": "Adopt everything adoptable",
|
||||
"summary": "Link {link}, adopt {created} as new, {left} not adoptable.",
|
||||
"note": "Only objects that exactly equal an existing central object are linked. Nothing is changed on the firewall.",
|
||||
"go": "Confirm and adopt"
|
||||
},
|
||||
"link": {
|
||||
"badge": "Already exists centrally",
|
||||
"found": "Equals the existing object “{name}” (firewalls: {hosts}). Linking adds this firewall there as another target instead of creating a second object.",
|
||||
"choose": "Link to",
|
||||
"action": "Link",
|
||||
"linkTip": "Adds this firewall as another target of the existing object. Nothing is changed on the firewall; later changes are rolled out to all firewalls together.",
|
||||
"adoptNew": "Adopt as own object",
|
||||
"newTip": "Creates a second, independent object. Only useful if this firewall should keep its own values for good.",
|
||||
"newBlockedTip": "An object with this name already exists, so only linking is possible. To keep it separate, rename it on the firewall first.",
|
||||
"similar": "Similar: “{name}” (firewalls: {hosts}) differs and is therefore not linked.",
|
||||
"similarNext": "Make the object on the firewall or the central object match, then it can be linked. Or adopt it separately.",
|
||||
"diffValues": "central “{central}”, on the firewall “{live}”",
|
||||
"variableMissing": "This firewall has no value for the variable {names}. Enter it in the host variables.",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"content": "Content",
|
||||
"description": "Description",
|
||||
"enabled": "Enabled",
|
||||
"interface": "Interface",
|
||||
"direction": "Direction",
|
||||
"ipprotocol": "IP version",
|
||||
"protocol": "Protocol",
|
||||
"action": "Action",
|
||||
"source_net": "Source",
|
||||
"source_port": "Source port",
|
||||
"destination_net": "Destination",
|
||||
"destination_port": "Destination port",
|
||||
"log": "Log packets",
|
||||
"target": "Target IP",
|
||||
"local_port": "Target port",
|
||||
"target_port": "Target port (NAT)",
|
||||
"natreflection": "NAT reflection",
|
||||
"external": "External address",
|
||||
"source_not": "Source inverted",
|
||||
"destination_not": "Destination inverted",
|
||||
"variables": "Variables"
|
||||
}
|
||||
},
|
||||
"failed": "Adopting failed",
|
||||
"loadFailed": "Could not load objects.",
|
||||
"notPossible": "Cannot be adopted",
|
||||
@@ -549,6 +599,7 @@
|
||||
"unsupported_type": "OPNexus does not support type “{detail}”",
|
||||
"name_invalid": "The name contains characters OPNexus does not allow (letters, digits, _ only)",
|
||||
"name_exists": "OPNexus already has an alias with this name",
|
||||
"twin_busy": "The identical central object is still waiting for a confirmation. Try again afterwards",
|
||||
"content_too_large": "Content too large (over 8000 characters)",
|
||||
"proto_set": "Has an IPv4/IPv6 restriction OPNexus cannot represent",
|
||||
"value_too_long": "A value is longer than OPNexus stores",
|
||||
@@ -721,6 +772,13 @@
|
||||
"OPEN_RESTORE_BLOCKS_DEPLOY": "A restore is open for this host. Please confirm or cancel it first.",
|
||||
"READ_ONLY_ACCESS": "Read-only access: this action requires the “Admin” role.",
|
||||
"CROSS_ORIGIN_BLOCKED": "Request from a foreign origin blocked",
|
||||
"CROSS_SITE_REQUEST_BLOCKED": "Request from another site blocked (Sec-Fetch-Site). Open OPNexus directly at its own address.",
|
||||
"GUI_SESSION_REQUIRED": "The firewall interface can only be opened from a signed-in browser session, not with an API token.",
|
||||
"GUI_GATEWAY_NOT_CONFIGURED": "GUI access is not set up on this server (HTTPS cookies, gateway token and slot addresses required).",
|
||||
"GUI_REQUIRES_HTTPS": "GUI access requires an HTTPS sign-in (COOKIE_SECURE). Please sign in again.",
|
||||
"GUI_ACCESS_DISABLED": "GUI access is switched off. An admin can switch it on in the settings.",
|
||||
"GUI_HOST_NOT_ELIGIBLE": "This firewall cannot be opened through OPNexus: it must be enabled, reachable via https:// and use a verified or pinned certificate.",
|
||||
"GUI_NO_FREE_SLOT": "All {slots} slots for firewall interfaces are in use. Close an open interface or wait until it expires.",
|
||||
"TLS_PROBE_FAILED": "The firewall's certificate could not be retrieved",
|
||||
"TLS_PEM_INVALID": "Not a valid PEM certificate",
|
||||
"TLS_PIN_MISMATCH": "The firewall presents a different certificate than the pinned one",
|
||||
@@ -732,6 +790,10 @@
|
||||
"RULE_NOT_ADOPTABLE": "This rule cannot be adopted ({reasons})",
|
||||
"OBJECT_NOT_ADOPTABLE": "This object cannot be adopted ({reasons})",
|
||||
"ADOPT_OBJECT_NOT_FOUND": "The object was not found on the firewall. Reload the list.",
|
||||
"ADOPT_LINK_TARGET_NOT_FOUND": "The central object no longer exists. Reload the list.",
|
||||
"ADOPT_LINK_TARGET_CHANGED": "The central object and the firewall differ now ({fields}). Reload the list and check.",
|
||||
"ADOPT_LINK_HOST_HAS_OBJECT": "This firewall already manages this object under another UUID. Reload the list.",
|
||||
"ADOPT_LINK_BUSY": "The object has an open confirmation or rollback. Confirm or resolve it first, then link.",
|
||||
"OUTBOUND_MODE_BLOCKS_ADOPT": "{host}: in outbound NAT mode “{mode}” OPNsense does not list manual rules. Switch the mode to Hybrid/Manual in OPNsense itself, then they can be adopted. OPNexus does not change this mode.",
|
||||
"NETWORK_ACK_REQUIRED": "This change can cut the connection to the firewall and must be confirmed explicitly ({reasons})",
|
||||
"NETWORK_APPLY_UNCERTAIN": "The change was written, but applying it on “{host}” failed or is unclear. Check the firewall; backup: {backup_id}",
|
||||
@@ -787,7 +849,12 @@
|
||||
"ROLLOUT_ACTION_NOT_ALLOWED": "Not possible while the run is “{status}”.",
|
||||
"ROLLOUT_HOST_NOT_IN_RUN": "This host is not part of this rollout.",
|
||||
"ROLLOUT_HOST_RUNNING": "The action on {host} is already running; it is finished in a controlled way and cannot be skipped.",
|
||||
"ROLLOUT_HOST_FAILED": "Failed: {hosts}. Check it and skip the host first, then resume."
|
||||
"ROLLOUT_HOST_FAILED": "Failed: {hosts}. Check it and skip the host first, then resume.",
|
||||
"ANALYSE_ADRESSE_UNGUELTIG": "Not a valid IP address ({field}: {value}). Enter a single IPv4 or IPv6 address, not a host name.",
|
||||
"ANALYSE_FAMILIEN_MISCHUNG": "Source and destination must both be IPv4 or both IPv6.",
|
||||
"ANALYSE_INTERFACE_UNBEKANNT": "Interface \"{interface}\" does not exist on this firewall. Available: {known}.",
|
||||
"ANALYSE_PARAMETER_UNGUELTIG": "Invalid value for {field}: {value}.",
|
||||
"ANALYSE_FIREWALL_NICHT_LESBAR": "Could not read this firewall's rules. Is it reachable?"
|
||||
},
|
||||
"workflow": {
|
||||
"attention": "Check errors",
|
||||
@@ -797,7 +864,7 @@
|
||||
"disabled": "Target hosts disabled",
|
||||
"draft": "Changes pending",
|
||||
"synced": "Deployed",
|
||||
"tooltip": "State of the deployments stored by OPNexus; not a live comparison"
|
||||
"tooltip": "Shows what OPNexus rolled out last. The firewall is not queried live for this."
|
||||
},
|
||||
"certStatus": {
|
||||
"expired": "Expired",
|
||||
@@ -872,7 +939,7 @@
|
||||
"status": "Status",
|
||||
"running": "running",
|
||||
"stopped": "stopped",
|
||||
"locked": "Locked by OPNsense",
|
||||
"locked": "This service is locked in OPNsense and cannot be controlled here.",
|
||||
"startTitle": "Start",
|
||||
"stopTitle": "Stop",
|
||||
"restartTitle": "Restart"
|
||||
@@ -1418,9 +1485,9 @@
|
||||
"reasserting": "Reassert running…",
|
||||
"reassert": "Re-establish order",
|
||||
"preRules": "Pre rules",
|
||||
"preHint": "Evaluated before the post rules. Across several levels: root-most group first.",
|
||||
"preHint": "Evaluated before the post rules. With several levels, the topmost group comes first.",
|
||||
"postRules": "Post rules",
|
||||
"postHint": "Evaluated after the pre rules. Across several levels: leaf-most (most specific) group first.",
|
||||
"postHint": "Evaluated after the pre rules. With several levels, the lowest (most specific) group comes first.",
|
||||
"editGroup": "Edit group",
|
||||
"parentGroup": "Parent group (optional)",
|
||||
"rootGroup": "(none — root group)",
|
||||
@@ -1490,7 +1557,7 @@
|
||||
"registration": "Registration: {state}",
|
||||
"registrationOk": "successful",
|
||||
"registrationFailed": "failed",
|
||||
"pushFirst": "Push first",
|
||||
"pushFirst": "Not on the firewall yet: send it there first with “Push”.",
|
||||
"register": "Register",
|
||||
"requests": "ACME certificate requests",
|
||||
"newRequest": "New request",
|
||||
@@ -1527,7 +1594,7 @@
|
||||
"deleted": "Tunnel “{name}” deleted",
|
||||
"title": "IPsec VPN (site-to-site)",
|
||||
"new": "New tunnel",
|
||||
"intro": "Site-to-site tunnels with pre-shared-key authentication, maintained centrally and deployed to target hosts or device groups — analogous to the firewall rules, but without their automatic rollback safety net: a failed service reload can cut exactly the connection OPNexus uses to reach the host. v1 only supports PSK auth, one phase 2 (child) per tunnel and simple 1:1 network-to-network tunnels — no OpenVPN/WireGuard, no certificates/EAP, no pools.",
|
||||
"intro": "Site-to-site tunnels with a pre-shared key, maintained centrally and deployed to firewalls or device groups. Unlike firewall rules there is no automatic rollback: a failed service reload can cut the connection OPNexus uses to reach the firewall. One phase 2 per tunnel and simple network-to-network tunnels are supported (no certificates/EAP, no pools).",
|
||||
"showAll": "Show all tunnels",
|
||||
"none": "No IPsec tunnels created yet.",
|
||||
"createFirst": "Create first tunnel",
|
||||
@@ -1569,7 +1636,7 @@
|
||||
"deleted": "Instance “{name}” deleted",
|
||||
"title": "OpenVPN instances",
|
||||
"new": "New instance",
|
||||
"intro": "OpenVPN instances (server or client role), maintained centrally and deployed to target hosts or device groups — analogous to the IPsec feature, but without its automatic rollback safety net: a failed service reload can cut exactly the connection OPNexus uses to reach the host. v1 requires a certificate/CA that already exists on every target host — selectable via a picker from the respective host's trust store, since a certificate UUID is host-local —, supports optional TLS hardening (tls-auth/tls-crypt) via a static key, but no certificate-less “shared key” instance (not provided for in the modern OPNsense model).",
|
||||
"intro": "OpenVPN instances (server or client), maintained centrally and deployed to firewalls or device groups. As with IPsec there is no automatic rollback: a failed service reload can cut the connection OPNexus uses to reach the firewall. Every target firewall must already have a certificate and CA (selected from its certificate store); optional TLS protection (tls-auth/tls-crypt) via a static key is supported, but no “shared key” instances without a certificate.",
|
||||
"showAll": "Show all instances",
|
||||
"none": "No OpenVPN instances created yet.",
|
||||
"createFirst": "Create first instance",
|
||||
@@ -1609,7 +1676,7 @@
|
||||
"created": "Tunnel “{name}” created",
|
||||
"deleteConfirm": "Really delete this WireGuard tunnel? It will also be removed from all target hosts (peer, then server) and the WireGuard service will be reloaded.",
|
||||
"title": "WireGuard tunnels",
|
||||
"intro": "WireGuard interconnects (one server interface + exactly one peer per tunnel), maintained centrally and deployed to target hosts or device groups — analogous to IPsec/OpenVPN, without their automatic rollback safety net: a failed service reload can cut exactly the connection OPNexus uses to reach the host. v1 requires the server private key AND public key as well as the peer public key to be supplied manually by the operator (no key pair generator), optionally supports WireGuard's own pre-shared key per peer, and models exactly one server↔peer connection (no multi-peer hub).",
|
||||
"intro": "WireGuard connections (one server interface and exactly one peer per tunnel), maintained centrally and deployed to firewalls or device groups. As with IPsec there is no automatic rollback: a failed service reload can cut the connection OPNexus uses to reach the firewall. You generate and enter the keys yourself; a pre-shared key per peer is optional.",
|
||||
"none": "No WireGuard tunnels created yet.",
|
||||
"noPrivateKey": "No private key stored",
|
||||
"pskMissing": "PSK enabled but not stored",
|
||||
@@ -1704,7 +1771,7 @@
|
||||
},
|
||||
"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.",
|
||||
"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. Needed on the server: the hub container (COMPOSE_PROFILES=overlay) and a UDP port reachable from the internet.",
|
||||
"loadFailed": "Could not load the management tunnel",
|
||||
"saved": "Saved",
|
||||
"enable": "Enable the management tunnel",
|
||||
@@ -2000,7 +2067,7 @@
|
||||
"colCreated": "Created",
|
||||
"colLastLogin": "Last sign-in",
|
||||
"you": "(you)",
|
||||
"cannotDeleteSelf": "You cannot delete your own account",
|
||||
"cannotDeleteSelf": "You cannot delete your own account – another admin has to do that.",
|
||||
"created": "User created",
|
||||
"updated": "User “{name}” updated",
|
||||
"createFailed": "Could not create user",
|
||||
@@ -2092,7 +2159,8 @@
|
||||
"certificate": "Certificates",
|
||||
"acme": "ACME",
|
||||
"notification": "Notifications",
|
||||
"system": "OPNexus system"
|
||||
"system": "OPNexus system",
|
||||
"gui_access": "GUI access"
|
||||
},
|
||||
"action": {
|
||||
"deploy": "Push",
|
||||
@@ -2129,7 +2197,10 @@
|
||||
"2fa/enable": "2FA enable",
|
||||
"2fa/disable": "2FA disable",
|
||||
"reset-password": "Password reset",
|
||||
"reset-2fa": "2FA reset"
|
||||
"reset-2fa": "2FA reset",
|
||||
"ticket": "Ticket issued",
|
||||
"redeem": "Session started",
|
||||
"end": "Session ended"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Requested",
|
||||
@@ -2225,19 +2296,19 @@
|
||||
},
|
||||
"noUpdate": {
|
||||
"title": "No installable update",
|
||||
"text": "The last check reports no stable package update."
|
||||
"text": "The last check reports no stable package update. Run a new check if needed."
|
||||
},
|
||||
"noMajor": {
|
||||
"title": "No installable major upgrade",
|
||||
"text": "The last check reports no available major upgrade."
|
||||
"text": "The last check reports no available major upgrade. Run a new check if needed."
|
||||
},
|
||||
"pending": {
|
||||
"title": "Maintenance still open",
|
||||
"text": "An earlier maintenance must be completed first, after the technical check."
|
||||
"text": "An earlier maintenance must be completed first: check its result and finish it on the host page."
|
||||
},
|
||||
"change": {
|
||||
"title": "Change still open",
|
||||
"text": "A deployment or rollback in the cluster is not finished yet."
|
||||
"text": "A deployment or rollback in the cluster is still running. Wait until it has finished."
|
||||
},
|
||||
"locked": "Update blocked"
|
||||
},
|
||||
@@ -2271,7 +2342,7 @@
|
||||
"intro": "When opened, all eligible hosts are automatically checked for updates. Major upgrades and HA role switches remain deliberate manual steps.",
|
||||
"checkAgain": "Check for updates again",
|
||||
"refreshView": "Refresh view",
|
||||
"readOnlyNote": "Read-only access: status, package changes and logs are visible; checks and updates are blocked.",
|
||||
"readOnlyNote": "With your role (read-only) you can view status, package changes and logs, but you cannot start checks or updates.",
|
||||
"sumHosts": "Managed hosts",
|
||||
"sumStable": "Stable updates",
|
||||
"sumMajor": "Major upgrades",
|
||||
@@ -2355,8 +2426,8 @@
|
||||
"downtimeShort": "The host may be unreachable during installation",
|
||||
"downtimeTip": "Connections can drop. There is no automatic rollback.",
|
||||
"tipConfirmName": "Typing the host name prevents updating the wrong host by accident.",
|
||||
"confirmRunDisabledName": "Enter the host name exactly to continue",
|
||||
"confirmRunDisabledAck": "Confirm the maintenance window first",
|
||||
"confirmRunDisabledName": "Enter the host name exactly to continue.",
|
||||
"confirmRunDisabledAck": "Confirm the maintenance window first (tick the box in the dialog).",
|
||||
"cancelWarnShort": "Does not confirm a successful completion",
|
||||
"cancelWarnTip": "The operation is only closed so the host or cluster does not stay locked. Use only if the expected state demonstrably never occurred (e.g. a stuck CARP role switch).",
|
||||
"finishNoteTip": "This confirmation documents your check and triggers no firewall action.",
|
||||
@@ -2386,8 +2457,8 @@
|
||||
"skipVerifyHelp": "The connection stays encrypted, but OPNexus cannot tell whether the firewall is really answering. For testing only.",
|
||||
"probeFailed": "The certificate could not be retrieved",
|
||||
"pinnedTip": "Certificate pinned · SHA-256 {fp}…",
|
||||
"systemTip": "Standard certificate check. For self-signed firewalls, pin the certificate.",
|
||||
"insecureTip": "The certificate is not verified. Edit the host to pin it."
|
||||
"systemTip": "The certificate is checked against the standard certificate authorities. For a self-signed certificate, pin it under Edit.",
|
||||
"insecureTip": "The certificate is not verified. Pin it under Edit to secure the connection."
|
||||
},
|
||||
"twofa": {
|
||||
"title": "Two-factor sign-in",
|
||||
@@ -3238,5 +3309,283 @@
|
||||
"6": "Sunday"
|
||||
},
|
||||
"cutoffSummary": "new hosts only in the first {minutes} min"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Rule analysis",
|
||||
"intro": "Answers which firewall rule decides the first packet of a connection (source, destination, protocol, port). Read-only; nothing is changed on any firewall.",
|
||||
"loadFailed": "Could not load hosts.",
|
||||
"queryFailed": "The analysis failed.",
|
||||
"form": {
|
||||
"title": "Connection",
|
||||
"scope": "Which firewalls",
|
||||
"scopeAll": "All enabled hosts",
|
||||
"scopeHosts": "Selected hosts",
|
||||
"scopeGroup": "Device group",
|
||||
"hosts": "Hosts",
|
||||
"group": "Device group",
|
||||
"src": "Source IP",
|
||||
"srcHint": "A single IPv4 or IPv6 address, not a host name or network.",
|
||||
"dst": "Destination IP",
|
||||
"proto": "Protocol",
|
||||
"protoHint": "tcp, udp, icmp, another protocol name or a protocol number (0-255).",
|
||||
"dport": "Destination port",
|
||||
"dportHint": "Without a port, the verdict stays unknown for rules that restrict the port.",
|
||||
"sport": "Source port (optional)",
|
||||
"sportHint": "Empty = any port from the ephemeral range.",
|
||||
"icmptype": "ICMP type (optional)",
|
||||
"icmptypeHint": "Name or number, e.g. echoreq or 8.",
|
||||
"iface": "Ingress interface",
|
||||
"ifaceHint": "The interface the packet arrives on. Automatic guesses from the source IP and is marked as probable.",
|
||||
"ifaceAuto": "automatic",
|
||||
"egress": "Check the egress side",
|
||||
"egressHint": "Also checks the outbound rules on the interface towards the destination. Traffic only counts as allowed if both sides let it through.",
|
||||
"egressIface": "Egress interface",
|
||||
"run": "Analyze",
|
||||
"refresh": "Re-read from the firewall",
|
||||
"refreshHint": "Reads the rules fresh from the firewall instead of using data cached for up to 60 seconds.",
|
||||
"missing": "Source and destination IP are required.",
|
||||
"noHostsSelected": "Select at least one host.",
|
||||
"noGroupSelected": "Select a device group."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Which rule matches?",
|
||||
"description": "Enter source and destination; the result appears here and the query lives in the address bar so you can share it."
|
||||
},
|
||||
"noHosts": {
|
||||
"title": "No firewalls",
|
||||
"description": "At least one firewall must be connected for the analysis."
|
||||
},
|
||||
"result": {
|
||||
"summary": "{src} → {dst}, {proto}",
|
||||
"hostError": "This host could not be evaluated.",
|
||||
"decisive": "Deciding rule",
|
||||
"decisiveIfKnown": "Most likely rule if the open points do not apply",
|
||||
"noRule": "No rule matches",
|
||||
"noRuleHint": "No rule entry matches; the packet filter (pf) then lets the packet through.",
|
||||
"unresolved": "No interface could be determined",
|
||||
"unresolvedHint": "The source IP belongs to no interface network. Choose the ingress interface manually.",
|
||||
"undecided": "Cannot be determined reliably",
|
||||
"undecidedHint": "At least one unclear rule could change the result. This is not a verdict; resolve the open points below.",
|
||||
"openPoints": "Open points",
|
||||
"hints": "Notes",
|
||||
"assumptions": "Assumptions",
|
||||
"legIn": "Ingress {interface}",
|
||||
"legOut": "Egress {interface}",
|
||||
"guessed": "guessed",
|
||||
"rulesLink": "Open live rules",
|
||||
"rulesLinkHint": "Opens the live rules. Look for rule {id} on {host}.",
|
||||
"noLink": "Automatic or legacy rule: not editable in OPNexus",
|
||||
"ruleSource": "Source",
|
||||
"ruleDestination": "Destination",
|
||||
"rulePorts": "Port",
|
||||
"ruleGateway": "Gateway",
|
||||
"ruleSchedule": "Schedule",
|
||||
"quick": "quick",
|
||||
"quickHint": "quick: this rule decides immediately; no further rules are checked.",
|
||||
"pfNumber": "pf rule @{number}",
|
||||
"automatic": "automatic",
|
||||
"legacy": "legacy format",
|
||||
"sortOrder": "Order {order}"
|
||||
},
|
||||
"verdict": {
|
||||
"pass": "Allowed",
|
||||
"block": "Blocked",
|
||||
"reject": "Rejected",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"confidence": {
|
||||
"label": "Confidence",
|
||||
"sure": "sure",
|
||||
"probable": "probable",
|
||||
"unknown": "unknown",
|
||||
"tip": {
|
||||
"sure": "All rules could be checked unambiguously.",
|
||||
"probable": "The verdict relies on assumptions or an unclear rule that would not change the outcome.",
|
||||
"unknown": "An unclear rule could change the result. There is no reliable verdict.",
|
||||
"unresolved": "Nothing can be checked without an ingress interface."
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"system": "System",
|
||||
"floating": "Floating",
|
||||
"group": "Interface group",
|
||||
"interface": "Interface",
|
||||
"nat_auto": "Automatic (NAT)",
|
||||
"invalid": "Automatic (invalid)",
|
||||
"antispoof": "Anti-spoofing",
|
||||
"other": "Other"
|
||||
},
|
||||
"trace": {
|
||||
"title": "Why?",
|
||||
"hint": "All rules in the order the packet filter (pf) checks them. The last match wins unless a quick rule matches earlier.",
|
||||
"leg": "Trace {direction} on {interface}",
|
||||
"dirIn": "in",
|
||||
"dirOut": "out",
|
||||
"showAll": "Also show non-matching rules ({count})",
|
||||
"noSteps": "No rules checked.",
|
||||
"rule": "Rule",
|
||||
"fields": "Check",
|
||||
"result": {
|
||||
"true": "matches",
|
||||
"false": "does not match",
|
||||
"unknown": "unclear"
|
||||
},
|
||||
"effect": {
|
||||
"decisive": "decides",
|
||||
"overridden": "overridden",
|
||||
"possible": "possible match",
|
||||
"no_match": "no match",
|
||||
"not_loaded": "not loaded",
|
||||
"disabled": "disabled"
|
||||
},
|
||||
"field": {
|
||||
"interface": "Interface",
|
||||
"direction": "Direction",
|
||||
"family": "Address family",
|
||||
"protocol": "Protocol",
|
||||
"source": "Source",
|
||||
"destination": "Destination",
|
||||
"source_port": "Source port",
|
||||
"destination_port": "Destination port",
|
||||
"ports": "Ports",
|
||||
"icmp_type": "ICMP type",
|
||||
"tagged": "Tag",
|
||||
"received_on": "Received on",
|
||||
"action": "Action"
|
||||
},
|
||||
"reasonsTitle": "Reasons"
|
||||
},
|
||||
"unknownCode": "Unknown code: {code}",
|
||||
"notes": {
|
||||
"IFACE_GUESSED": "Interface guessed",
|
||||
"IFACE_GUESSED_hint": "The ingress interface was derived from the source IP, not given. Choose it manually to be sure.",
|
||||
"EGRESS_GUESSED": "Egress guessed",
|
||||
"EGRESS_GUESSED_hint": "The egress interface was derived from the destination IP.",
|
||||
"IFACE_UNRESOLVED": "Interface unresolved",
|
||||
"IFACE_UNRESOLVED_hint": "No interface could be determined for the source IP.",
|
||||
"EGRESS_UNRESOLVED": "Egress unresolved",
|
||||
"EGRESS_UNRESOLVED_hint": "No egress interface could be determined for the destination IP; the egress side was not checked.",
|
||||
"NAT_NEARBY": "NAT nearby",
|
||||
"NAT_NEARBY_hint": "A port forward matches this connection. Filter rules see the address after translation, so the verdict is only probable.",
|
||||
"NAT_UNAVAILABLE": "NAT unavailable",
|
||||
"NAT_UNAVAILABLE_hint": "The firewall's port forwards could not be read; a translation cannot be ruled out.",
|
||||
"POLICY_ROUTE": "Policy routing",
|
||||
"POLICY_ROUTE_hint": "The deciding rule sends the traffic through a specific gateway.",
|
||||
"STATELESS": "Stateless",
|
||||
"STATELESS_hint": "The rule keeps no state. Reply packets need a rule of their own.",
|
||||
"SCHEDULE_NOW": "Schedule",
|
||||
"SCHEDULE_NOW_hint": "The rule has a schedule and is currently loaded according to the firewall.",
|
||||
"RULES_NOT_LOADED": "Rules not loaded",
|
||||
"RULES_NOT_LOADED_hint": "Enabled rules are not in the loaded ruleset (for example because of a schedule) and were skipped.",
|
||||
"PF_RULESET_UNAVAILABLE": "Ruleset unavailable",
|
||||
"PF_RULESET_UNAVAILABLE_hint": "The loaded pf ruleset could not be read, so order and load state are only estimated.",
|
||||
"ORDER_MISMATCH": "Order mismatch",
|
||||
"ORDER_MISMATCH_hint": "The order in the configuration does not match the loaded ruleset.",
|
||||
"ORDER_GUESSED": "Order guessed",
|
||||
"ORDER_GUESSED_hint": "The order of some rules could not be proven and was estimated.",
|
||||
"TCP_FLAGS": "TCP flags",
|
||||
"TCP_FLAGS_hint": "The rule checks TCP flags; the first packet (SYN) probably matches it, but not certainly.",
|
||||
"RATE_LIMIT": "Rate limit",
|
||||
"RATE_LIMIT_hint": "The rule limits connections; above the limit it may behave differently.",
|
||||
"PF_DEFAULT_PASS": "Default: pass",
|
||||
"PF_DEFAULT_PASS_hint": "No rule matches; the packet filter (pf) then lets the packet through.",
|
||||
"QUICK_UNKNOWN": "quick unclear",
|
||||
"QUICK_UNKNOWN_hint": "For one rule it is unclear whether it decides immediately.",
|
||||
"GROUPS_UNAVAILABLE": "Interface groups unavailable",
|
||||
"GROUPS_UNAVAILABLE_hint": "Interface groups could not be read; rules for groups are unclear.",
|
||||
"ALIASES_UNAVAILABLE": "Aliases unavailable",
|
||||
"ALIASES_UNAVAILABLE_hint": "The alias list could not be read; rules using aliases are unclear.",
|
||||
"ALIAS_REFERENCES_UNAVAILABLE": "Alias lookup unavailable",
|
||||
"ALIAS_REFERENCES_UNAVAILABLE_hint": "The firewall could not tell whether the addresses are in aliases."
|
||||
},
|
||||
"unknown": {
|
||||
"ALIAS_MISSING": "Alias missing",
|
||||
"ALIAS_MISSING_hint": "A rule refers to an alias that does not exist on the firewall.",
|
||||
"ALIAS_TABLE_UNAVAILABLE": "Alias table missing",
|
||||
"ALIAS_TABLE_UNAVAILABLE_hint": "The alias's pf table could not be queried (for example Geo-IP or URL list).",
|
||||
"ALIAS_CYCLE": "Alias cycle",
|
||||
"ALIAS_CYCLE_hint": "Aliases refer to each other in a loop.",
|
||||
"ALIAS_TOO_DEEP": "Alias too deep",
|
||||
"ALIAS_TOO_DEEP_hint": "The alias nesting is too deep to evaluate.",
|
||||
"ALIAS_EXCLUSION": "Alias with exclusion",
|
||||
"ALIAS_EXCLUSION_hint": "An alias contains exclusions that cannot be evaluated reliably.",
|
||||
"FQDN_UNRESOLVED": "Host name unresolved",
|
||||
"FQDN_UNRESOLVED_hint": "A host name in an alias could not be resolved to addresses.",
|
||||
"TAGGED": "Tagged packets",
|
||||
"TAGGED_hint": "A rule only applies to tagged packets. The tag is set by another rule that did not match here.",
|
||||
"PORT_NOT_GIVEN": "Port not given",
|
||||
"PORT_NOT_GIVEN_hint": "Without a destination port it is unclear whether a rule restricted to ports matches.",
|
||||
"SRC_PORT_NOT_GIVEN": "Source port not given",
|
||||
"SRC_PORT_NOT_GIVEN_hint": "A rule restricts the source port, which was not given.",
|
||||
"PORT_ALIAS_UNRESOLVED": "Port alias unresolved",
|
||||
"PORT_ALIAS_UNRESOLVED_hint": "A port alias could not be resolved.",
|
||||
"PORT_UNPARSEABLE": "Port unparseable",
|
||||
"PORT_UNPARSEABLE_hint": "A port specification in a rule could not be interpreted.",
|
||||
"ICMP_TYPE_NOT_GIVEN": "ICMP type not given",
|
||||
"ICMP_TYPE_NOT_GIVEN_hint": "A rule only applies to certain ICMP types; the type is not given.",
|
||||
"VIP_AS_INTERFACE_ADDRESS": "Virtual IP",
|
||||
"VIP_AS_INTERFACE_ADDRESS_hint": "A virtual IP was used as the interface address. Whether the rule covers it is unclear.",
|
||||
"QUICK_UNKNOWN": "quick unclear",
|
||||
"QUICK_UNKNOWN_hint": "For one rule it is unclear whether it decides immediately.",
|
||||
"RECEIVED_ON": "Received-on unclear",
|
||||
"RECEIVED_ON_hint": "A rule only applies to packets received on a certain interface; that is not known on the egress side.",
|
||||
"ACTION_UNKNOWN": "Action unclear",
|
||||
"ACTION_UNKNOWN_hint": "The action of a rule could not be determined.",
|
||||
"IFACE_UNRESOLVED": "Interface group unclear",
|
||||
"IFACE_UNRESOLVED_hint": "An interface group could not be mapped to its members.",
|
||||
"GROUPS_UNAVAILABLE": "Interface groups unavailable",
|
||||
"GROUPS_UNAVAILABLE_hint": "Interface groups could not be read."
|
||||
},
|
||||
"assumptions": {
|
||||
"FIRST_PACKET": "First packet",
|
||||
"FIRST_PACKET_hint": "The first packet of a new connection is evaluated. Replies and established connections follow state, not rules.",
|
||||
"SRC_PORT_EPHEMERAL": "Ephemeral source port",
|
||||
"SRC_PORT_EPHEMERAL_hint": "Without a source port, any port from the ephemeral range is assumed."
|
||||
}
|
||||
},
|
||||
"guiAccess": {
|
||||
"title": "Firewall interface through OPNexus",
|
||||
"intro": "Opens the web interface of a managed firewall in its own browser tab through OPNexus, even when the firewall is only reachable over the management tunnel. Admins only; you still sign in on the firewall itself. The ticket works once for 30 seconds, every session has time limits and is logged in the audit. Details: manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
|
||||
"defaultOff": "Off by default. It is set up on the server (Compose profile “gui”, OPNEXUS_GUI_SITES, GUI_GATEWAY_TOKEN) – see the manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"notReady": "Not set up yet. Missing:",
|
||||
"reason": {
|
||||
"cookie_secure": "Secure HTTPS sign-in (setting COOKIE_SECURE=true)",
|
||||
"gateway_token": "Access token for the GUI gateway service (GUI_GATEWAY_TOKEN, at least 32 characters)",
|
||||
"gui_sites": "Ports for the firewall interfaces (OPNEXUS_GUI_SITES) and the Compose profile “gui”"
|
||||
},
|
||||
"enabled": "Allow opening the firewall interface",
|
||||
"enabledTip": "Turns the feature on for all admins. Turning it off ends open sessions (within about 20 seconds).",
|
||||
"idle": "Inactivity (minutes)",
|
||||
"idleTip": "A session ends after this many minutes without activity (1 to 240). Must not exceed the total duration.",
|
||||
"absolute": "Total duration (minutes)",
|
||||
"absoluteTip": "Every session ends after this time at the latest, even when active (5 to 720).",
|
||||
"slotCount": "Simultaneous slots",
|
||||
"slotCountTip": "How many firewall interfaces can be open at the same time (1 to 4). Each slot has its own port so the open firewalls cannot interfere with each other in the browser.",
|
||||
"orderError": "The inactivity limit must not exceed the total duration.",
|
||||
"activeSlots": "Active slots (ports)",
|
||||
"activeSlotsTip": "The ports defined in OPNEXUS_GUI_SITES that are used with the chosen count. Which slot is in use right now is shown in the audit (type “GUI access”).",
|
||||
"noSlots": "none configured",
|
||||
"lastChange": "Last changed by {who} on {when}",
|
||||
"saved": "Firewall interface settings saved"
|
||||
},
|
||||
"guiOpen": {
|
||||
"button": "Open interface",
|
||||
"tip": "Opens this firewall’s web interface through OPNexus in a new tab.",
|
||||
"opened": "Opening the interface in a new tab (slot {slot}). If nothing happens, allow pop-ups for this site.",
|
||||
"failed": "Could not open the interface",
|
||||
"reason": {
|
||||
"off": "Switched off. Turn it on under Settings › Firewall interface through OPNexus.",
|
||||
"host_disabled": "This firewall is disabled. Enable it under Hosts › Edit (tick “Active”).",
|
||||
"host_https": "This firewall’s address does not start with https://. Change it under Hosts › Edit.",
|
||||
"host_tls": "Certificate verification is switched off for this firewall. Turn it on under Hosts › Edit or pin the certificate.",
|
||||
"unavailable": {
|
||||
"cookie_secure": "Not set up: secure HTTPS sign-in is not active on the server (setting COOKIE_SECURE). A server admin has to change this – instructions: manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
|
||||
"gateway_token": "Not set up: the access token for the GUI gateway service is missing (GUI_GATEWAY_TOKEN, at least 32 characters). A server admin has to add it – instructions: manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
|
||||
"gui_sites": "Not set up: no ports are defined for the firewall interfaces (OPNEXUS_GUI_SITES, Compose profile “gui”). A server admin has to add them – instructions: manual (German), chapter “Firewall-Oberfläche über OPNexus”."
|
||||
},
|
||||
"more": "More missing items: Settings › Firewall interface through OPNexus."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.25.0";
|
||||
export const APP_CODENAME = "Goshawk";
|
||||
export const APP_VERSION = "1.28.0";
|
||||
export const APP_CODENAME = "Sparrowhawk";
|
||||
export const APP_MAINTAINER = "Julian Boldt";
|
||||
@@ -61,3 +61,39 @@ http:// {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
|
||||
# GUI remote access (optional, compose profile "gui"): every slot port is its
|
||||
# own browser origin for one firewall web interface, served by the gui-gateway
|
||||
# container. OPNEXUS_GUI_SITES lists the slot origins, e.g.
|
||||
# "https://10.0.0.5:9441, https://10.0.0.5:9442". Unset, a localhost
|
||||
# placeholder keeps the Caddyfile valid; without the gateway it answers 502.
|
||||
# Certificates come from the same internal CA (bare IPs work).
|
||||
{$OPNEXUS_GUI_SITES:https://localhost:9441} {
|
||||
# The gateway sets CSP, X-Frame-Options, no-store etc. itself; no `encode`
|
||||
# (it passes the firewall's own content encoding through) and no access log
|
||||
# (the redeem URL carries a one-time ticket in its query string).
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
-Server
|
||||
-X-Powered-By
|
||||
}
|
||||
|
||||
# The backend's internal gateway API is never reachable from outside, and
|
||||
# the gateway would not forward it either; refuse it here as well.
|
||||
handle /internal/* {
|
||||
respond 404
|
||||
}
|
||||
|
||||
handle {
|
||||
# Config restore uploads go through the firewall GUI: same limit as the
|
||||
# gateway (64 MB).
|
||||
request_body {
|
||||
max_size 64MB
|
||||
}
|
||||
reverse_proxy gui-gateway:8080 {
|
||||
# The slot is the port this connection actually arrived on (not the
|
||||
# client's Host header); any client-sent X-Onx-Slot is overwritten.
|
||||
header_up X-Onx-Slot {http.request.local.port}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ if [ "$multi" = 1 ]; then
|
||||
free_mb="$(df --output=avail -BM . | tail -1 | tr -dc 0-9)"
|
||||
[ "$free_mb" -ge 3500 ] || { echo "Nur ${free_mb} MB frei (für --multiarch werden ca. 3500 MB gebraucht): Platz schaffen (docker image prune, docker buildx prune)" >&2; exit 1; }
|
||||
# Emulation ist nach einem Neustart weg; ohne sie scheitert der arm64-Teil erst nach Minuten.
|
||||
docker buildx inspect "$builder" | grep -q 'linux/arm64' \
|
||||
platforms="$(docker buildx inspect "$builder" --bootstrap 2>/dev/null || true)" # nicht per Pipe an grep -q: SIGPIPE + pipefail
|
||||
grep -q 'linux/arm64' <<<"$platforms" \
|
||||
|| { echo "arm64 nicht verfügbar: docker run --privileged --rm tonistiigi/binfmt --install arm64" >&2; exit 1; }
|
||||
fi
|
||||
version="${tag#v}"
|
||||
|
||||
+44
-2
@@ -86,6 +86,12 @@ overlay_enabled() {
|
||||
[[ ",$profiles," == *",overlay,"* ]]
|
||||
}
|
||||
|
||||
gui_enabled() {
|
||||
local profiles
|
||||
profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
|
||||
[[ ",$profiles," == *",gui,"* ]]
|
||||
}
|
||||
|
||||
# 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() {
|
||||
@@ -93,6 +99,8 @@ up_targets() {
|
||||
printf '%s\n' "${SERVICES[@]}"
|
||||
# Der WireGuard-Hub (Profil "overlay") läuft mit dem Backend-Image und muss mit.
|
||||
overlay_enabled && echo wg-hub
|
||||
# Ebenso das GUI-Gateway (Profil "gui"): Backend-Image, muss zur Backend-Version passen.
|
||||
gui_enabled && echo gui-gateway
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@@ -108,9 +116,19 @@ running_version() {
|
||||
}
|
||||
|
||||
service_state() { # healthy | unhealthy | starting | running | (leer)
|
||||
local h
|
||||
local h st id started
|
||||
h=$(c ps "$1" --format '{{.Health}}' 2>/dev/null || true)
|
||||
if [ -n "$h" ]; then echo "$h"; else c ps "$1" --format '{{.State}}' 2>/dev/null || true; fi
|
||||
if [ -n "$h" ]; then echo "$h"; return; fi
|
||||
st=$(c ps "$1" --format '{{.State}}' 2>/dev/null || true)
|
||||
# Ohne Healthcheck (Proxy) heißt "running" nur: der Prozess wurde gerade gestartet. Ein Container, der nach
|
||||
# einer Sekunde wieder abstürzt, muss als "starting" gelten, sonst meldet das Update "fertig" bei kaputtem
|
||||
# Proxy (so geschehen mit 1.27.0). "Läuft seit mindestens 10 s" heißt: stabil.
|
||||
if [ "$st" = running ]; then
|
||||
id=$(c ps -q "$1" 2>/dev/null | head -n1 || true)
|
||||
started=$(docker inspect -f '{{.State.StartedAt}}' "$id" 2>/dev/null || true)
|
||||
if [ -n "$started" ] && [ $(( $(date +%s) - $(date -d "$started" +%s 2>/dev/null || echo 0) )) -lt 10 ]; then st=starting; fi
|
||||
fi
|
||||
echo "$st"
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
@@ -234,6 +252,29 @@ verify_signatures() {
|
||||
say "Signaturen geprüft."
|
||||
}
|
||||
|
||||
# Prüft VOR dem Wechsel, dass der Proxy des neuen Images mit der Konfiguration DIESER Installation startet
|
||||
# (Caddy validate mit den Umgebungsvariablen, die Compose dem Proxy gibt). Hintergrund: 1.27.0 lieferte eine
|
||||
# Compose-Datei, die OPNEXUS_GUI_SITES leer weitergab; der Proxy fiel in eine Neustart-Schleife, OPNexus war weg.
|
||||
# Genau dieser Fall wird hier selbst repariert (Platzhalter in die .env), alles andere bricht VOR dem Wechsel ab.
|
||||
preflight_proxy_config() {
|
||||
local target="$1" repo json args=() kv
|
||||
repo=$(env_get OPNEXUS_IMAGE_REPO)
|
||||
command -v python3 >/dev/null 2>&1 || { say "Proxy-Vorprüfung übersprungen (python3 fehlt)."; return 0; }
|
||||
proxy_env_json() { OPNEXUS_VERSION="$target" c config --format json 2>/dev/null \
|
||||
| python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["services"]["proxy"].get("environment") or {}))'; }
|
||||
json=$(proxy_env_json) || { say "Proxy-Vorprüfung übersprungen (Compose-Konfiguration nicht lesbar)."; return 0; }
|
||||
if python3 -c 'import json,sys; e=json.loads(sys.argv[1]); sys.exit(0 if e.get("OPNEXUS_GUI_SITES", "x") == "" else 1)' "$json"; then
|
||||
say "OPNEXUS_GUI_SITES ist leer (Compose-Datei aus 1.27.0): setze den Platzhalter https://localhost:9441 in $ENV_FILE."
|
||||
env_set OPNEXUS_GUI_SITES "https://localhost:9441"
|
||||
json=$(proxy_env_json) || return 0
|
||||
fi
|
||||
while IFS= read -r kv; do args+=(-e "$kv"); done < <(python3 -c 'import json,sys; [print("%s=%s" % i) for i in json.loads(sys.argv[1]).items()]' "$json")
|
||||
if ! docker run --rm "${args[@]}" "$repo/opnexus-proxy:$target" caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
|
||||
die "Die Proxy-Konfiguration des neuen Images ist mit dieser Installation ungültig — nichts wurde verändert. Prüfen: docker run --rm $(printf '%s ' "${args[@]}") $repo/opnexus-proxy:$target caddy validate --config /etc/caddy/Caddyfile"
|
||||
fi
|
||||
say "Proxy-Konfiguration geprüft."
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
require_release_mode
|
||||
local target="${1:-$(env_get OPNEXUS_VERSION)}"
|
||||
@@ -253,6 +294,7 @@ cmd_update() {
|
||||
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[@]}"
|
||||
preflight_proxy_config "$target"
|
||||
|
||||
started=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
cat > "$STATE_FILE" <<STATE
|
||||
|
||||
Reference in new issue
Block a user