Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,65 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [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.27.2` 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.27.2
|
||||
```
|
||||
|
||||
```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.27.2` 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.27.2
|
||||
```
|
||||
|
||||
```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"
|
||||
@@ -200,6 +207,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)
|
||||
@@ -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)))
|
||||
@@ -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", "")):
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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.27.2"
|
||||
APP_CODENAME = "Buzzard"
|
||||
@@ -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,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
|
||||
@@ -1100,6 +1177,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} />
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"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) reason = t(`guiAccess.reason.${cfg.unavailable_reasons[0]}`);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -773,6 +773,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 +1304,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" }),
|
||||
@@ -1517,6 +1540,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 +2070,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;
|
||||
}
|
||||
+293
-4
@@ -300,7 +300,8 @@
|
||||
"hostsystem-snapshots": "Snapshots",
|
||||
"hostsystem-diagnostics": "Diagnose",
|
||||
"setup": "Einrichtung",
|
||||
"rollouts": "Rollouts"
|
||||
"rollouts": "Rollouts",
|
||||
"analysis": "Regel-Analyse"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -721,6 +722,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",
|
||||
@@ -787,7 +795,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",
|
||||
@@ -2092,7 +2105,8 @@
|
||||
"certificate": "Zertifikate",
|
||||
"acme": "ACME",
|
||||
"notification": "Benachrichtigungen",
|
||||
"system": "OPNexus-System"
|
||||
"system": "OPNexus-System",
|
||||
"gui_access": "GUI-Zugriff"
|
||||
},
|
||||
"action": {
|
||||
"deploy": "Push",
|
||||
@@ -2129,7 +2143,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",
|
||||
@@ -3238,5 +3255,277 @@
|
||||
"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": "Ohne dies werden die Regeln bis zu 60 Sekunden zwischengespeichert.",
|
||||
"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; 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: die erste Übereinstimmung entscheidet sofort.",
|
||||
"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 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; 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. Zum Einrichten braucht der Server das Compose-Profil „gui“, OPNEXUS_GUI_SITES und GUI_GATEWAY_TOKEN (siehe Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“).",
|
||||
"on": "An",
|
||||
"off": "Aus",
|
||||
"notReady": "Noch nicht eingerichtet. Es fehlt:",
|
||||
"reason": {
|
||||
"cookie_secure": "HTTPS-Anmeldung (COOKIE_SECURE=true)",
|
||||
"gateway_token": "GUI_GATEWAY_TOKEN (mindestens 32 Zeichen)",
|
||||
"gui_sites": "OPNEXUS_GUI_SITES mit den Slot-Adressen (und 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 und damit einen eigenen Browser-Ursprung.",
|
||||
"orderError": "Das Inaktivitätslimit darf nicht größer sein als die Gesamtdauer.",
|
||||
"activeSlots": "Aktive Plätze (Ports)",
|
||||
"activeSlotsTip": "Die konfigurierten Slot-Ports aus OPNEXUS_GUI_SITES, 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. Ein Admin kann die Funktion unter Einstellungen einschalten.",
|
||||
"host_disabled": "Diese Firewall ist deaktiviert.",
|
||||
"host_https": "Nur Firewalls mit https://-Adresse lassen sich öffnen.",
|
||||
"host_tls": "Nur mit geprüftem oder gepinntem Zertifikat möglich (TLS-Prüfung ist bei dieser Firewall aus)."
|
||||
}
|
||||
}
|
||||
}
|
||||
+293
-4
@@ -300,7 +300,8 @@
|
||||
"hostsystem-snapshots": "Snapshots",
|
||||
"hostsystem-diagnostics": "Diagnostics",
|
||||
"setup": "Setup",
|
||||
"rollouts": "Rollouts"
|
||||
"rollouts": "Rollouts",
|
||||
"analysis": "Rule analysis"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -721,6 +722,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",
|
||||
@@ -787,7 +795,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",
|
||||
@@ -2092,7 +2105,8 @@
|
||||
"certificate": "Certificates",
|
||||
"acme": "ACME",
|
||||
"notification": "Notifications",
|
||||
"system": "OPNexus system"
|
||||
"system": "OPNexus system",
|
||||
"gui_access": "GUI access"
|
||||
},
|
||||
"action": {
|
||||
"deploy": "Push",
|
||||
@@ -2129,7 +2143,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",
|
||||
@@ -3238,5 +3255,277 @@
|
||||
"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": "Without this, rules are 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; 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: the first match decides immediately.",
|
||||
"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 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; 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. Setting it up needs the Compose profile “gui”, OPNEXUS_GUI_SITES and GUI_GATEWAY_TOKEN on the server (see the manual, chapter “Firewall-Oberfläche über OPNexus”).",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"notReady": "Not set up yet. Missing:",
|
||||
"reason": {
|
||||
"cookie_secure": "HTTPS sign-in (COOKIE_SECURE=true)",
|
||||
"gateway_token": "GUI_GATEWAY_TOKEN (at least 32 characters)",
|
||||
"gui_sites": "OPNEXUS_GUI_SITES with the slot addresses (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 and therefore its own browser origin.",
|
||||
"orderError": "The inactivity limit must not exceed the total duration.",
|
||||
"activeSlots": "Active slots (ports)",
|
||||
"activeSlotsTip": "The slot ports configured 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. An admin can turn the feature on under Settings.",
|
||||
"host_disabled": "This firewall is disabled.",
|
||||
"host_https": "Only firewalls with an https:// address can be opened.",
|
||||
"host_tls": "Only possible with a verified or pinned certificate (TLS verification is off for this firewall)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.27.2";
|
||||
export const APP_CODENAME = "Buzzard";
|
||||
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