Compare commits

..
1 Commits
Author SHA1 Message Date
Julian Boldt 20fd575420 Release v1.12.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und
26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“:

- **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit
  Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge,
  DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle
  IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration,
  Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen.
- **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token,
  der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup
  gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen.
  OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen,
  die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway,
  Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche
  Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen**
  automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie
  seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg.
- **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die
  Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht.
  Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung.
- **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die
  Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die
  Änderung mit Begründung.
- **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden,
  lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt
  nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte
  Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund
  abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003),
  damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder
  (Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert.
- **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der
  Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert.
- **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach
  einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu
  angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung
  „angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin.
- **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer
  virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie
  bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen
  Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl,
  Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen,
  gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung.
- **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt
  es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle
  IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
2026-10-09 21:23:04 +02:00
38 changed files with 4065 additions and 31 deletions

No files matched your search

+49
View File
@@ -31,6 +31,55 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.12.0] "Condor" — 2026-10-09
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und
26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“:
- **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit
Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge,
DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle
IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration,
Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen.
- **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token,
der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup
gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen.
OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen,
die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway,
Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche
Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen**
automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie
seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg.
- **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die
Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht.
Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung.
- **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die
Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die
Änderung mit Begründung.
- **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden,
lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt
nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte
Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund
abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003),
damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder
(Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert.
- **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der
Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert.
- **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach
einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu
angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung
„angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin.
- **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer
virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie
bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen
Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl,
Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen,
gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung.
- **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt
es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle
IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
## [1.11.1] "Merlin" — 2026-10-09
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
+3 -2
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
> **Stand:** `1.11.1` 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.12.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -16,6 +16,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück.
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, DNS und DHCP je Firewall, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln lassen sich übernehmen.
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
@@ -46,7 +47,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.11.1
OPNEXUS_VERSION=1.12.0
```
```bash
+3 -2
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
> **Status:** `1.11.1` 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.12.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -16,6 +16,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back.
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP per firewall, with a diff preview, a backup before every change and a guard against cutting your own connection; rules created by hand on a firewall can be adopted.
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
@@ -48,7 +49,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.11.1
OPNEXUS_VERSION=1.12.0
```
```bash
+7
View File
@@ -99,6 +99,13 @@ class ErrorCode(str, Enum):
REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203 = "REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203"
REGEL_HAT_KEINE_ZIEL_HOSTS = "REGEL_HAT_KEINE_ZIEL_HOSTS"
REGEL_NICHT_GEFUNDEN = "REGEL_NICHT_GEFUNDEN"
NETWORK_ACK_REQUIRED = "NETWORK_ACK_REQUIRED"
NETWORK_APPLY_UNCERTAIN = "NETWORK_APPLY_UNCERTAIN"
NETWORK_BACKUP_FAILED = "NETWORK_BACKUP_FAILED"
NETWORK_CHANGE_BLOCKED = "NETWORK_CHANGE_BLOCKED"
NETWORK_FIELD_INVALID = "NETWORK_FIELD_INVALID"
NETWORK_ITEM_NOT_FOUND = "NETWORK_ITEM_NOT_FOUND"
RULE_NOT_ADOPTABLE = "RULE_NOT_ADOPTABLE"
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
+2
View File
@@ -36,6 +36,7 @@ from app.routers import ipsec as ipsec_router
from app.routers import openvpn as openvpn_router
from app.routers import wireguard as wireguard_router
from app.routers import certificates as certificates_router
from app.routers import network as network_router
from app.routers import restore as restore_router
from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
@@ -390,5 +391,6 @@ app.include_router(restore_router.router, prefix="/api")
app.include_router(dashboard_router.router, prefix="/api")
app.include_router(cluster_router.router, prefix="/api")
app.include_router(services_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy import Boolean, DateTime, String, false
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -29,6 +29,7 @@ class FirewallRule(Base):
destination_net: Mapped[str] = mapped_column(String(128), default="any")
destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
log: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+2
View File
@@ -155,6 +155,8 @@ async def update_host(host_id: uuid.UUID, payload: HostUpdate, db: AsyncSession
if existing:
raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS)
host.name = payload.name
if payload.base_url is not None:
host.base_url = payload.base_url
if payload.verify_tls is not None:
host.verify_tls = payload.verify_tls
if payload.clear_tls_pin:
+78
View File
@@ -0,0 +1,78 @@
import re
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.routers.hosts import _upstream_error
from app.schemas.network import (
HostNetwork, NetworkChangeExecute, NetworkChangePreview, NetworkChangeRequest, NetworkChangeResult,
)
from app.services import network_change_service, network_service
from app.services.policy_service import policy_lock
from app.services.opnsense_client import OpnsenseAPIError
router = APIRouter(prefix="/hosts/{host_id}/network", tags=["network"])
@router.get("", response_model=HostNetwork)
async def host_network(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await network_service.get_host_network(host)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.post("/changes/preview", response_model=NetworkChangePreview)
async def preview_change(host_id: uuid.UUID, payload: NetworkChangeRequest, db: AsyncSession = Depends(get_db)):
host = await _host_or_404(db, host_id)
try:
plan = await network_change_service.preview(db, host, payload.kind, payload.action, payload.uuid, payload.fields)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
return {k: v for k, v in plan.items() if not k.startswith("_")}
@router.post("/changes", response_model=NetworkChangeResult)
async def execute_change(host_id: uuid.UUID, payload: NetworkChangeExecute, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host_or_404(db, host_id)
return await network_change_service.execute(
db, host, payload.kind, payload.action, payload.uuid, payload.fields, payload.preview_token, payload.acknowledge)
@router.get("/template/{kind}")
async def change_template(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
"""Blank form for one kind, with the choices this firewall accepts for its select fields."""
if kind not in network_change_service.FIELDS:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.template(host, kind)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.get("/item/{kind}/{item_uuid}")
async def current_item(host_id: uuid.UUID, kind: str, item_uuid: str, db: AsyncSession = Depends(get_db)):
"""The object's current values on the firewall, for pre-filling an edit form."""
if kind not in network_change_service.FIELDS or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", item_uuid):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.item(host, kind, item_uuid)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
+40
View File
@@ -17,6 +17,8 @@ from app.services.alias_service import check_alias_consistency, describe_alias_c
from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
from app.services import rule_adopt_service
from app.schemas.rule import AdoptRequest, UnmanagedRule
router = APIRouter(prefix="/rules", tags=["rules"])
@@ -59,6 +61,7 @@ def _to_read(
destination_net=rule.destination_net,
destination_port=rule.destination_port,
enabled=rule.enabled,
log=rule.log,
created_at=rule.created_at,
updated_at=rule.updated_at,
target_host_ids=[host.id for host in rule.targets],
@@ -84,6 +87,42 @@ async def list_rules(db: AsyncSession = Depends(get_db)):
return [_to_read(rule, rule.targets, rule.deployments) for rule in rules]
@router.get("/unmanaged", response_model=list[UnmanagedRule])
async def list_unmanaged_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Rules found on the host that OPNexus does not manage yet (live read)."""
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await rule_adopt_service.list_unmanaged(db, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
@router.post("/adopt", response_model=RuleRead, status_code=201)
async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
"""Takes over a hand-made rule. Writes nothing to the firewall."""
await policy_lock(db)
host = await db.get(OpnsenseHost, payload.host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid)
except rule_adopt_service.RuleNotFoundOnHost as exc:
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
except rule_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
rule = (await db.execute(
select(FirewallRule).where(FirewallRule.id == rule.id).options(*_load_options())
.execution_options(populate_existing=True)
)).scalar_one()
return _to_read(rule, rule.targets, rule.deployments)
@router.post("", response_model=RuleRead, status_code=201)
async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
@@ -101,6 +140,7 @@ async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
destination_net=payload.destination_net,
destination_port=payload.destination_port,
enabled=payload.enabled,
log=payload.log,
targets=hosts,
groups=groups,
)
+7
View File
@@ -47,6 +47,8 @@ class HostUpdate(BaseModel):
recreate is still the way to rotate a host's API key for now."""
name: str | None = Field(default=None, min_length=1, max_length=120)
# New address of the firewall, e.g. after its management IP was changed.
base_url: str | None = Field(default=None, min_length=1, max_length=255)
verify_tls: bool | None = None
tls_ca_pem: str | None = Field(default=None, max_length=32768)
clear_tls_pin: bool = False
@@ -56,6 +58,11 @@ class HostUpdate(BaseModel):
policy_hierarchy_group_id: uuid.UUID | None = None
clear_policy_hierarchy_group_id: bool = False # same ambiguity as clear_cluster_name above
@field_validator("base_url")
@classmethod
def _base_url_is_safe(cls, value: str | None) -> str | None:
return validate_base_url(value) if value is not None else None
class HostRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+182
View File
@@ -0,0 +1,182 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class NetAddress(BaseModel):
address: str # CIDR, e.g. 192.168.1.2/24
# Set for CARP addresses only
vhid: str | None = None
carp_status: str | None = None
class NetInterface(BaseModel):
device: str
identifier: str | None # OPNsense's own name (wan, lan, opt3) — None when unassigned
description: str
status: str
enabled: bool
is_physical: bool
mtu: str | None
mac: str | None
media: str | None
vlan_tag: str | None
vlan_parent: str | None
ipv4_type: str | None # "Static IPv4" / "DHCP" ... (assigned interfaces only)
ipv6_type: str | None
addresses: list[NetAddress]
pending_action: str | None = None
# Listed in the firewall's interface assignment (wan, lan, optN) — only those can be edited or removed.
assigned: bool = False
# This is the interface the firewall answers OPNexus on. Changing it can cut the connection.
is_management: bool = False
class NetVlan(BaseModel):
uuid: str
parent: str
tag: str
pcp: str | None
description: str
device: str | None
class NetVip(BaseModel):
"""No CARP password here, by design: OPNsense returns it in plain text."""
uuid: str
interface: str
interface_label: str
mode: str
address: str
vhid: str | None
advbase: str | None
advskew: str | None
description: str
class NetGateway(BaseModel):
uuid: str
name: str
description: str
interface: str
interface_label: str
ip_protocol: str
address: str
is_default: bool
disabled: bool
priority: str | None
status: str
class NetRoute(BaseModel):
uuid: str
network: str
gateway: str
description: str
disabled: bool
class NetDnsHost(BaseModel):
uuid: str
hostname: str
domain: str
record_type: str
server: str
enabled: bool
description: str
class NetDhcpHost(BaseModel):
uuid: str
host: str
domain: str
ip: str
hwaddr: str
lease_time: str
description: str
class NetKeaSubnet(BaseModel):
uuid: str
subnet: str
pools: str
routers: str
dns_servers: str
description: str
class NetKeaReservation(BaseModel):
uuid: str
subnet: str
ip_address: str
hw_address: str
hostname: str
description: str
class HostNetwork(BaseModel):
host_id: str
host_name: str
interfaces: list[NetInterface]
vlans: list[NetVlan]
vips: list[NetVip]
gateways: list[NetGateway]
routes: list[NetRoute]
dns_hosts: list[NetDnsHost] = []
dhcp_hosts: list[NetDhcpHost] = []
kea_subnets: list[NetKeaSubnet] = []
kea_reservations: list[NetKeaReservation] = []
management_device: str | None
# The firewall's own API can set interface IPs/types (OPNsense >= ~26.7.6). Older 26.7.x
# only lists assignments. Detected from the data, not from a version number.
can_configure_interfaces: bool
# Sections the firewall could not deliver (e.g. older OPNsense without that API) — never silently empty.
unavailable: list[str]
class NetworkChangeRequest(BaseModel):
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation"]
action: Literal["add", "set", "del"]
# Goes into a URL path, so only plain identifiers.
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
fields: dict[str, Any] = Field(default_factory=dict)
class NetworkChangeExecute(NetworkChangeRequest):
preview_token: str = Field(min_length=64, max_length=64)
acknowledge: bool = False
class NetworkChangeRisk(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class NetworkFieldChange(BaseModel):
field: str
before: str | None
after: str | None
class NetworkChangePreview(BaseModel):
token: str
kind: str
action: str
uuid: str | None
host_id: str
host_name: str
changes: list[NetworkFieldChange]
risks: list[NetworkChangeRisk]
warnings: list[NetworkChangeRisk]
blocks: list[NetworkChangeRisk]
requires_acknowledgement: bool
management_device: str | None
can_execute: bool
class NetworkChangeResult(BaseModel):
status: str
uuid: str | None
verified: bool
mismatches: list[str]
backup_id: str
+26
View File
@@ -17,6 +17,7 @@ class RuleCreate(BaseModel):
destination_net: str = Field(default="any", min_length=1, max_length=128)
destination_port: str | None = Field(default=None, max_length=32)
enabled: bool = True
log: bool = False
target_host_ids: list[uuid.UUID] = Field(default_factory=list)
target_group_ids: list[uuid.UUID] = Field(default_factory=list)
@@ -56,6 +57,7 @@ class RuleRead(BaseModel):
destination_net: str
destination_port: str | None
enabled: bool
log: bool = False
created_at: datetime
updated_at: datetime
target_host_ids: list[uuid.UUID]
@@ -72,3 +74,27 @@ class RuleEdit(RuleCreate):
class DeployApproval(BaseModel):
preview_token: str = Field(min_length=64, max_length=64)
class AdoptRequest(BaseModel):
host_id: uuid.UUID
rule_uuid: str = Field(min_length=1, max_length=64)
class UnmanagedRule(BaseModel):
uuid: str
description: str
interface: str
interface_label: str
direction: str
action: str
protocol: str
source_net: str
source_port: str
destination_net: str
destination_port: str
enabled: bool
log: bool
adoptable: bool
# Codes such as "negated_source" or "not_quick"; the UI turns them into text.
reasons: list[str]
@@ -0,0 +1,390 @@
"""Changes to VLANs, virtual IPs, gateways and static routes of ONE firewall.
Unlike rules, these objects are specific to a firewall (own IPs, own VLAN
layout), so there is no central copy and no multi-host push: the firewall is the
source of truth and every change goes straight to it, but only through a
preview/execute pair:
preview reads the live state, computes the diff and the risks, and returns a
token that covers exactly that state.
execute re-reads the live state, refuses if it no longer matches the token,
refuses risky changes without an explicit acknowledgement, saves a
fresh config backup, writes the object, applies it, and reads it back
to check that the firewall really holds what was asked for.
The main risk is cutting the connection OPNexus itself uses (the interface or
address the firewall is reached on, the default route, a route that swallows the
management address). Those cases are detected from the firewall's own
interface list and need the acknowledgement. OPNexus cannot undo a change that
cuts it off, so there is deliberately NO automatic rollback here — the backup
taken before the change is the way back.
Secrets (the CARP password) are never returned, hashed into the token only, and
never written to the audit log.
"""
import hashlib
import ipaddress
import json
import re
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, backup_store, network_service, opnsense_client
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.opnsense_client import OpnsenseAPIError
# Only these fields can be sent; anything else is rejected, never forwarded.
FIELDS: dict[str, set[str]] = {
"vlan": {"if", "tag", "pcp", "proto", "descr"},
"vip": {"interface", "mode", "network", "vhid", "advbase", "advskew", "password", "descr", "nosync", "peer", "peer6"},
"gateway": {"disabled", "name", "descr", "interface", "ipprotocol", "gateway", "defaultgw", "fargw",
"monitor_disable", "monitor", "priority", "weight"},
"route": {"network", "gateway", "descr", "enabled"},
"dns_host": {"enabled", "hostname", "domain", "rr", "server", "mx", "mxprio", "addptr", "description"},
"dhcp_host": {"host", "domain", "local", "ip", "hwaddr", "client_id", "lease_time", "ignore", "descr"},
"kea_subnet": {"subnet", "pools", "valid_lifetime", "next_server", "description", "option_data.routers",
"option_data.domain_name_servers", "option_data.domain_name", "option_data.ntp_servers"},
"kea_reservation": {"subnet", "ip_address", "hw_address", "client_id", "hostname", "description", "next_server"},
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
"blockpriv", "blockbogons", "lock"},
}
SECRET_FIELDS: dict[str, set[str]] = {"vip": {"password"}}
# Section of HostNetwork that must be readable for the kind to be managed on this firewall.
SECTION = {"vlan": "vlans", "vip": "vips", "gateway": "gateways", "route": "routes", "interface": "assignments",
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
"kea_reservation": "kea_reservations"}
# These change how the network is wired; the others (DNS/DHCP data) cannot cut the connection to the firewall.
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface"}
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
def validate_fields(kind: str, fields: dict) -> dict[str, str]:
allowed = FIELDS[kind]
clean: dict[str, str] = {}
for key, value in fields.items():
if key not in allowed:
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=str(key)[:40])
if isinstance(value, bool):
value = "1" if value else "0"
if value is None:
value = ""
if not isinstance(value, (str, int)) or isinstance(value, bool):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
text = str(value).strip()
if len(text) > 255 or _CONTROL.search(text):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
clean[key] = text
return clean
def _explode(node: dict) -> dict:
"""Kea nests its DHCP options one level deep; treat them as "option_data.<name>" like the whitelist does."""
out: dict = {}
for key, value in node.items():
if key == "option_data" and isinstance(value, dict):
for child, inner in value.items():
out[f"option_data.{child}"] = inner
else:
out[key] = value
return out
def flatten(kind: str, node: dict) -> dict[str, str]:
"""OPNsense answers select fields as {option: {selected: 0|1}}; reduce to the selected value(s)."""
out: dict[str, str] = {}
for key, value in _explode(node).items():
if key in SECRET_FIELDS.get(kind, set()):
continue
if isinstance(value, dict):
selected = [k for k, o in value.items() if isinstance(o, dict) and o.get("selected") and k != ""]
if value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
out[key] = ",".join(selected)
continue
out[key] = "" if value is None else str(value)
return out
def _ip(text: str):
try:
return ipaddress.ip_interface(text.strip()).ip
except ValueError:
return None
def _network(text: str):
try:
return ipaddress.ip_network(text.strip(), strict=False)
except ValueError:
return None
def _risk(code: str, **params) -> dict:
return {"code": code, "params": params}
def assess(kind: str, action: str, uuid: str | None, before: dict, after: dict,
net, mgmt_addresses: set, peers: list[str], context: dict | None = None) -> tuple[list[dict], list[dict], list[dict]]:
"""Returns (risks needing acknowledgement, plain warnings, hard blocks)."""
risks: list[dict] = []
warnings: list[dict] = []
blocks: list[dict] = []
mgmt_device = net.management_device
mgmt_iface = next((i for i in net.interfaces if i.device == mgmt_device), None)
mgmt_ident = mgmt_iface.identifier if mgmt_iface else None
if SECTION[kind] in net.unavailable:
blocks.append(_risk("api_unavailable", section=SECTION[kind]))
if mgmt_device is None and kind in WIRING_KINDS:
# We cannot tell what this change could cut, so treat every change to existing objects as risky.
if action in ("set", "del"):
risks.append(_risk("management_unknown"))
context = context or {}
if kind == "interface":
if not net.can_configure_interfaces:
blocks.append(_risk("interface_api_unavailable"))
if context.get("pending"):
blocks.append(_risk("interface_changes_pending"))
if action in ("set", "del") and mgmt_ident and uuid == mgmt_ident:
risks.append(_risk("management_interface", device=mgmt_device))
if action == "del" and before.get("lock") == "1":
# OPNsense refuses to remove a locked interface; say so now instead of failing at apply time.
blocks.append(_risk("interface_locked"))
if action == "del":
rules = context.get("rules_using") or []
if rules:
risks.append(_risk("interface_rules_removed", count=len(rules), names=", ".join(rules[:5])))
if action in ("set", "del") and uuid in ("wan", "lan") and uuid != mgmt_ident:
warnings.append(_risk("core_interface", name=uuid))
if kind == "vlan" and action in ("set", "del"):
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
if device and device == mgmt_device:
risks.append(_risk("management_interface", device=device))
if kind == "vip":
for side in (before, after) if action != "add" else (after,):
ip = _ip(side.get("network", "")) if side.get("network") else None
if ip is not None and ip in mgmt_addresses:
risks.append(_risk("management_address", address=str(ip)))
break
if "carp" in (before.get("mode"), after.get("mode")):
warnings.append(_risk("carp_change"))
if kind == "gateway":
current = next((g for g in net.gateways if g.uuid == uuid), None)
if current is not None and action in ("set", "del"):
if current.is_default:
risks.append(_risk("default_gateway", name=current.name))
if mgmt_ident and current.interface == mgmt_ident:
risks.append(_risk("management_gateway", name=current.name))
if action in ("add", "set") and after.get("defaultgw") == "1" and (current is None or not current.is_default):
risks.append(_risk("default_gateway_change"))
if kind == "route":
for side in (before, after) if action != "add" else (after,):
network = _network(side.get("network", "")) if side.get("network") else None
if network is None:
continue
if network.prefixlen == 0:
risks.append(_risk("default_route", network=str(network)))
break
if any(a.version == network.version and a in network for a in mgmt_addresses):
risks.append(_risk("route_covers_management", network=str(network)))
break
if peers:
warnings.append(_risk("cluster_peers", hosts=", ".join(peers)))
# De-duplicate while keeping order.
seen, unique = set(), []
for r in risks:
key = json.dumps(r, sort_keys=True)
if key not in seen:
seen.add(key)
unique.append(r)
return unique, warnings, blocks
def _token(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict, before: dict, mgmt_device) -> str:
state = {
"host": str(host.id), "url": host.base_url, "kind": kind, "action": action, "uuid": uuid,
# hash, so a secret never sits in the token's input in the clear
"fields": hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest(),
"before": before, "mgmt": mgmt_device,
}
return hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest()
def _public(kind: str, fields: dict) -> dict:
return {k: ("(gesetzt)" if k in SECRET_FIELDS.get(kind, set()) and v else v) for k, v in fields.items()
if not (k in SECRET_FIELDS.get(kind, set()) and not v)}
async def _peers(db: AsyncSession, host: OpnsenseHost) -> list[str]:
if not host.cluster_name:
return []
rows = await db.scalars(select(OpnsenseHost.name).where(
OpnsenseHost.cluster_name == host.cluster_name, OpnsenseHost.id != host.id).order_by(OpnsenseHost.name))
return list(rows.all())
async def _rules_using(host: OpnsenseHost, identifier: str) -> list[str]:
"""Descriptions of the user-made rules that name this interface (OPNsense deletes them with it)."""
data = await opnsense_client.search_rules(host, all_rows=True)
rows = data.get("rows", []) if isinstance(data, dict) else []
return [str(r.get("description") or r.get("uuid")) for r in rows
if isinstance(r, dict) and not r.get("is_automatic")
and identifier in str(r.get("interface") or "").split(",")]
async def preview(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
fields = validate_fields(kind, fields)
if action in ("set", "del") and not uuid:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
net = await network_service.get_host_network(host)
before: dict[str, str] = {}
if action in ("set", "del"):
node = await opnsense_client.get_network_item(host, kind, uuid)
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
# Only the fields that may be sent: the rest (e.g. Kea's DDNS key secret) never reaches the token or the audit log.
before = {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
after = {} if action == "del" else ({**before, **fields} if action == "set" else dict(fields))
mgmt_addresses = await network_service.management_addresses(host)
context: dict = {}
if kind == "interface":
if net.can_configure_interfaces:
context["pending"] = await opnsense_client.interface_changes_pending(host)
if action == "del":
context["rules_using"] = await _rules_using(host, uuid)
risks, warnings, blocks = assess(kind, action, uuid, before, after, net, mgmt_addresses, await _peers(db, host), context)
secret = SECRET_FIELDS.get(kind, set())
changes = []
for key in sorted(set(before) | set(fields)):
old, new = before.get(key), fields.get(key) if action != "del" else None
if action == "del":
if old not in (None, "") and key in FIELDS[kind]:
changes.append({"field": key, "before": old, "after": None})
elif key in fields and (action == "add" or old != new):
if key in secret:
changes.append({"field": key, "before": None, "after": "(gesetzt)" if new else None})
else:
changes.append({"field": key, "before": old, "after": new})
token = _token(host, kind, action, uuid, fields, before, net.management_device)
return {
"token": token, "kind": kind, "action": action, "uuid": uuid, "host_id": str(host.id), "host_name": host.name,
"changes": changes, "risks": risks, "warnings": warnings, "blocks": blocks,
"requires_acknowledgement": bool(risks), "management_device": net.management_device,
"can_execute": not blocks and (action == "del" or bool(changes)),
"_fields": fields, "_before": before,
}
def _matches(expected: dict[str, str], actual: dict[str, str], secret: set[str]) -> list[str]:
return [k for k, v in expected.items() if k not in secret and actual.get(k, "") != v]
async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> tuple[bool, list[str]]:
try:
if action == "del":
present = await opnsense_client.list_network_item_uuids(host, kind)
return (uuid not in present), ([] if uuid not in present else ["still_present"])
actual = flatten(kind, await opnsense_client.get_network_item(host, kind, uuid))
mismatches = _matches(fields, actual, SECRET_FIELDS.get(kind, set()))
return (not mismatches), mismatches
except OpnsenseAPIError:
return False, ["unreadable"]
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
token: str, acknowledged: bool) -> dict:
await ensure_no_maintenance(db, [host])
try:
plan = await preview(db, host, kind, action, uuid, fields)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
if plan["token"] != token:
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
if plan["blocks"]:
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons=", ".join(b["code"] for b in plan["blocks"]))
if not plan["can_execute"]:
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="nothing_to_change")
if plan["requires_acknowledgement"] and not acknowledged:
raise api_error(409, ErrorCode.NETWORK_ACK_REQUIRED, reasons=", ".join(r["code"] for r in plan["risks"]))
fields = plan["_fields"]
send = dict(fields)
if kind == "vip" and action == "set" and plan["_before"].get("network"):
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
send.setdefault("network", plan["_before"]["network"])
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
expected = {**plan["_before"], **send} if action == "set" else dict(send)
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
audit = {"kind": "network", "action": f"{kind}.{action}"[:40], "entity_id": uuid, "entity_name": entity,
"host_id": str(host.id), "host_name": host.name}
details = {"before": plan["_before"], "after": _public(kind, fields) if action != "del" else None,
"risks": [r["code"] for r in plan["risks"]], "acknowledged": acknowledged}
# Way back: a config backup taken right before the change. No backup, no change.
try:
backup_id = backup_store.save_snapshot(host.id, await opnsense_client.download_backup(host))
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="aborted", message="Backup vor der Änderung fehlgeschlagen", details=details)
raise api_error(502, ErrorCode.NETWORK_BACKUP_FAILED, host=host.name) from exc
details["backup_id"] = backup_id
await audit_service.persist(**audit, status="started", message=None, details=details)
try:
result = await opnsense_client.write_network_item(host, kind, action, uuid, send)
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
try:
await opnsense_client.reconfigure_network(host, kind)
except OpnsenseAPIError as exc:
# Written but not (provably) applied: say so instead of reporting success.
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
await audit_service.persist(**audit, status="applied" if verified else "unverified",
message=None if verified else ", ".join(mismatches)[:500], details=details)
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
def _options(value: dict) -> list[dict]:
return [{"value": k, "label": str(o.get("value", k)) if isinstance(o, dict) else str(k), "selected": bool(isinstance(o, dict) and o.get("selected"))}
for k, o in value.items()]
async def template(host: OpnsenseHost, kind: str) -> dict:
"""The firewall's own blank form for this kind: field defaults and, for select fields, the choices it
accepts right now (e.g. only the gateways it considers valid for a route)."""
node = _explode(await opnsense_client.get_network_item(host, kind, None))
out: dict[str, dict] = {}
for key in sorted(FIELDS[kind]):
if key not in node:
continue
value = node[key]
if isinstance(value, dict) and value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
options = _options(value)
# Free-text list fields (IPs, MACs, DNS servers) arrive in this shape too; the form decides how to show them.
out[key] = {"value": ",".join(o["value"] for o in options if o["selected"] and o["value"] != ""), "options": options}
elif key in SECRET_FIELDS.get(kind, set()):
out[key] = {"value": "", "options": None}
else:
out[key] = {"value": "" if value is None or isinstance(value, dict) else str(value), "options": None}
return out
async def item(host: OpnsenseHost, kind: str, uuid: str) -> dict[str, str]:
"""One object as the firewall holds it right now, reduced to the fields that may be sent (no secrets)."""
node = await opnsense_client.get_network_item(host, kind, uuid)
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
return {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
+250
View File
@@ -0,0 +1,250 @@
"""Read-only network inventory of one firewall: interfaces, VLANs, virtual
IPs (CARP), gateways and static routes, straight from the firewall.
Also works out which interface the firewall answers OPNexus on. Every later
write feature in this area (VLAN/IP/gateway changes) must refuse or escalate
changes to that interface — an apply there can cut the very connection OPNexus
would need to undo it.
Shapes verified live on OPNsense 26.7.1 and 26.7.6 (see opnsense_client).
"""
import asyncio
import ipaddress
import socket
from urllib.parse import urlparse
from app.models.host import OpnsenseHost
from app.schemas.network import (
HostNetwork,
NetAddress,
NetGateway,
NetDhcpHost,
NetDnsHost,
NetInterface,
NetKeaReservation,
NetKeaSubnet,
NetRoute,
NetVip,
NetVlan,
)
from app.services import opnsense_client
from app.services.opnsense_client import OpnsenseAPIError
def _rows(data) -> list[dict]:
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict)]
def _flag(value) -> bool:
return value in ("1", 1, True)
def _text(value) -> str:
return "" if value is None else str(value)
def _opt(value) -> str | None:
"""OPNsense is inconsistent about number vs. string (a gateway priority came back as 254, not "254")."""
return None if value in (None, "") else str(value)
async def management_addresses(host: OpnsenseHost) -> set:
"""IP addresses OPNexus uses to reach this firewall (literal or resolved)."""
hostname = urlparse(host.base_url).hostname
if not hostname:
return set()
try:
return {ipaddress.ip_address(hostname)}
except ValueError:
pass
try:
infos = await asyncio.get_running_loop().getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except OSError:
return set()
found = set()
for info in infos:
try:
found.add(ipaddress.ip_address(info[4][0].split("%")[0]))
except ValueError:
continue
return found
def _addresses(row: dict) -> list[NetAddress]:
result = []
for key in ("ipv4", "ipv6"):
for entry in row.get(key) or []:
if isinstance(entry, dict) and entry.get("ipaddr"):
result.append(NetAddress(
address=str(entry["ipaddr"]),
vhid=_opt(entry.get("vhid")),
carp_status=_opt(entry.get("status")),
))
return result
def _address_ip(address: str):
try:
return ipaddress.ip_interface(address).ip
except ValueError:
return None
def _interface(row: dict, assignments: dict[str, dict], management: set) -> NetInterface:
identifier = row.get("identifier") or None
assigned = assignments.get(identifier or "") or {}
addresses = _addresses(row)
vlan = row.get("vlan") or {}
return NetInterface(
device=_text(row.get("device")),
identifier=identifier,
description=_text(row.get("description")),
status=_text(row.get("status")),
enabled=_flag(row.get("enabled", True)) if "enabled" in row else True,
is_physical=bool(row.get("is_physical")),
mtu=_opt(row.get("mtu")),
mac=_opt(row.get("macaddr")),
media=_opt(row.get("media")),
vlan_tag=_opt((vlan.get("tag") if isinstance(vlan, dict) else None) or row.get("vlan_tag")),
vlan_parent=_opt(vlan.get("parent")) if isinstance(vlan, dict) else None,
ipv4_type=assigned.get("%type4") or None,
ipv6_type=assigned.get("%type6") or None,
addresses=addresses,
pending_action=assigned.get("pending_action") or None,
assigned=bool(identifier and identifier in assignments),
is_management=any(_address_ip(a.address) in management for a in addresses if not a.vhid),
)
def _vlan(row: dict) -> NetVlan:
return NetVlan(
uuid=_text(row.get("uuid")),
parent=_text(row.get("if")),
tag=_text(row.get("tag")),
pcp=_opt(row.get("pcp")),
description=_text(row.get("descr")),
device=(row.get("vlanif") or "").split(" ")[0] or None,
)
def _vip(row: dict) -> NetVip:
# Deliberately picks fields by name: the row also carries the CARP password.
return NetVip(
uuid=_text(row.get("uuid")),
interface=_text(row.get("interface")),
interface_label=_text(row.get("%interface") or row.get("interface")),
mode=_text(row.get("mode")),
address=_text(row.get("address") or row.get("subnet")),
vhid=_opt(row.get("vhid")),
advbase=_opt(row.get("advbase")),
advskew=_opt(row.get("advskew")),
description=_text(row.get("descr")),
)
def _gateway(row: dict) -> NetGateway:
return NetGateway(
uuid=_text(row.get("uuid")),
name=_text(row.get("name")),
description=_text(row.get("descr")),
interface=_text(row.get("interface")),
interface_label=_text(row.get("interface_descr") or row.get("interface")),
ip_protocol=_text(row.get("ipprotocol")),
address=_text(row.get("gateway")),
is_default=bool(row.get("defaultgw")),
disabled=bool(row.get("disabled")),
priority=_opt(row.get("priority")),
status=_text(row.get("status")),
)
def _route(row: dict) -> NetRoute:
return NetRoute(
uuid=_text(row.get("uuid")),
network=_text(row.get("network")),
gateway=_text(row.get("gateway")),
description=_text(row.get("descr")),
# list rows carry "enabled" ("1"/"0"); older shapes a "disabled" flag
disabled=_flag(row.get("disabled")) or row.get("enabled") in ("0", 0, False),
)
def _dns_host(row: dict) -> NetDnsHost:
return NetDnsHost(
uuid=_text(row.get("uuid")), hostname=_text(row.get("hostname")), domain=_text(row.get("domain")),
record_type=_text(row.get("rr")), server=_text(row.get("server")), enabled=_flag(row.get("enabled")),
description=_text(row.get("description")),
)
def _dhcp_host(row: dict) -> NetDhcpHost:
return NetDhcpHost(
uuid=_text(row.get("uuid")), host=_text(row.get("host")), domain=_text(row.get("domain")),
ip=_text(row.get("ip")), hwaddr=_text(row.get("hwaddr")), lease_time=_text(row.get("lease_time")),
description=_text(row.get("descr")),
)
def _kea_subnet(row: dict) -> NetKeaSubnet:
# Picked by name on purpose: the row also carries the DDNS key secret.
return NetKeaSubnet(
uuid=_text(row.get("uuid")), subnet=_text(row.get("subnet")), pools=_text(row.get("pools")),
routers=_text(row.get("option_data.routers")), dns_servers=_text(row.get("option_data.domain_name_servers")),
description=_text(row.get("description")),
)
def _kea_reservation(row: dict) -> NetKeaReservation:
return NetKeaReservation(
uuid=_text(row.get("uuid")), subnet=_text(row.get("%subnet") or row.get("subnet")),
ip_address=_text(row.get("ip_address")), hw_address=_text(row.get("hw_address")),
hostname=_text(row.get("hostname")), description=_text(row.get("description")),
)
async def _optional(name: str, call, unavailable: list[str]) -> list[dict]:
try:
return _rows(await call)
except OpnsenseAPIError:
unavailable.append(name)
return []
async def get_host_network(host: OpnsenseHost) -> HostNetwork:
"""Raises OpnsenseAPIError when the interface list itself is unavailable
(host down); every other section degrades to `unavailable` instead."""
info_rows = _rows(await opnsense_client.get_interfaces_info(host))
unavailable: list[str] = []
(assignment_rows, vlan_rows, vip_rows, gateway_rows, route_rows,
dns_rows, dhcp_rows, kea_subnet_rows, kea_reservation_rows) = await asyncio.gather(
_optional("assignments", opnsense_client.search_interface_assignments(host), unavailable),
_optional("vlans", opnsense_client.search_vlans(host), unavailable),
_optional("vips", opnsense_client.search_vips(host), unavailable),
_optional("gateways", opnsense_client.search_gateways(host), unavailable),
_optional("routes", opnsense_client.search_static_routes(host), unavailable),
_optional("dns_hosts", opnsense_client.search_dns_hosts(host), unavailable),
_optional("dhcp_hosts", opnsense_client.search_dhcp_hosts(host), unavailable),
_optional("kea_subnets", opnsense_client.search_kea_subnets(host), unavailable),
_optional("kea_reservations", opnsense_client.search_kea_reservations(host), unavailable),
)
management = await management_addresses(host)
assignments = {_text(r.get("identifier")): r for r in assignment_rows if r.get("identifier")}
interfaces = [_interface(r, assignments, management) for r in info_rows]
management_device = next((i.device for i in interfaces if i.is_management), None)
return HostNetwork(
host_id=str(host.id),
host_name=host.name,
interfaces=interfaces,
vlans=[_vlan(r) for r in vlan_rows],
vips=[_vip(r) for r in vip_rows],
gateways=[_gateway(r) for r in gateway_rows],
routes=[_route(r) for r in route_rows],
dns_hosts=[_dns_host(r) for r in dns_rows if not r.get("isAlias")],
dhcp_hosts=[_dhcp_host(r) for r in dhcp_rows],
kea_subnets=[_kea_subnet(r) for r in kea_subnet_rows],
kea_reservations=[_kea_reservation(r) for r in kea_reservation_rows],
management_device=management_device,
can_configure_interfaces=any("type4" in r for r in assignment_rows),
unavailable=sorted(unavailable),
)
+151
View File
@@ -1027,3 +1027,154 @@ async def sign_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
against anything but a respx mock / fully-mocked write path in
tests/verification."""
return _acme_action_result(await _request(host, "POST", f"/acmeclient/certificates/sign/{uuid_}"), "ACME-Zertifikat ausstellen")
# ── Network inventory (read-only) ────────────────────────────────────────
# Interfaces\Api\{Overview,Assignment,VlanSettings,VipSettings}Controller,
# Routing\Api\SettingsController and Routes\Api\RoutesController, verified
# against core source (github.com/opnsense/core, master) AND live with real
# read-only GETs on OPNsense 26.7.1/26.7.6 (2026-10-09). Shapes: the search*
# calls return {"rows": [...]} (flat dicts with "%"-prefixed display
# counterparts); interfacesInfo rows carry device/identifier/status/ipv4[].
# Caution: vip_settings rows contain the CARP password in plain text — callers
# must drop it (see network_service).
async def get_interfaces_info(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/overview/interfacesInfo", params={"rowCount": -1})
async def search_interface_assignments(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/assignment/searchItem", params={"rowCount": -1})
async def search_vlans(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/vlan_settings/searchItem", params={"rowCount": -1})
async def search_vips(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/vip_settings/searchItem", params={"rowCount": -1})
async def search_gateways(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/routing/settings/searchGateway", params={"rowCount": -1})
async def search_static_routes(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/routes/routes/searchroute", params={"rowCount": -1})
# ── Network objects (write) ──────────────────────────────────────────────
# One table for the four config objects handled by network_change_service.
# Sources (core master, read 2026-10-09): Interfaces/Api/VlanSettingsController,
# VipSettingsController, Routing/Api/SettingsController (gateways) and
# Routes/Api/RoutesController. Each add/set takes {<body_key>: {fields}}; set/del
# address the item by UUID; nothing takes effect before the object's reconfigure
# call, which answers {"status": "ok"}. The VIP form posts address+prefix as ONE
# field called "network".
# The interface assignment (Interfaces/Api/AssignmentController, model NetworkInterface) is two-step:
# add/set/del only stage the change (a todo file on the firewall); reconfigure ("interface apply") makes
# it real. Its items are addressed by the interface identifier (wan, lan, opt1), not a UUID, and
# deleting one makes OPNsense itself drop every rule/NAT entry that references it.
NETWORK_ITEM_APIS = {
"vlan": {"body_key": "vlan", "base": "/interfaces/vlan_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem",
"reconfigure": "/interfaces/vlan_settings/reconfigure"},
"vip": {"body_key": "vip", "base": "/interfaces/vip_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem",
"reconfigure": "/interfaces/vip_settings/reconfigure"},
"gateway": {"body_key": "gateway_item", "base": "/routing/settings", "add": "addGateway", "set": "setGateway",
"del": "delGateway", "get": "getGateway", "search": "searchGateway",
"reconfigure": "/routing/settings/reconfigure"},
"route": {"body_key": "route", "base": "/routes/routes", "add": "addroute", "set": "setroute",
"del": "delroute", "get": "getroute", "search": "searchroute",
"reconfigure": "/routes/routes/reconfigure"},
"interface": {"body_key": "interface", "base": "/interfaces/assignment", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem",
"reconfigure": "/interfaces/assignment/reconfigure"},
# DNS / DHCP (Unbound/Api/SettingsController, Dnsmasq/Api/SettingsController, Kea/Api/Dhcpv4Controller;
# reconfigure is the service controller's inherited action). Read 2026-10-09.
"dns_host": {"body_key": "host", "base": "/unbound/settings", "add": "addHostOverride", "set": "setHostOverride",
"del": "delHostOverride", "get": "getHostOverride", "search": "searchHostOverride",
"reconfigure": "/unbound/service/reconfigure"},
"dhcp_host": {"body_key": "host", "base": "/dnsmasq/settings", "add": "addHost", "set": "setHost",
"del": "delHost", "get": "getHost", "search": "searchHost",
"reconfigure": "/dnsmasq/service/reconfigure"},
"kea_subnet": {"body_key": "subnet4", "base": "/kea/dhcpv4", "add": "addSubnet", "set": "setSubnet",
"del": "delSubnet", "get": "getSubnet", "search": "searchSubnet",
"reconfigure": "/kea/service/reconfigure"},
"kea_reservation": {"body_key": "reservation", "base": "/kea/dhcpv4", "add": "addReservation",
"set": "setReservation", "del": "delReservation", "get": "getReservation",
"search": "searchReservation", "reconfigure": "/kea/service/reconfigure"},
}
def _nest(fields: dict) -> dict:
"""{"option_data.routers": "x"} -> {"option_data": {"routers": "x"}} (the wire form of Kea's option block)."""
out: dict = {}
for key, value in fields.items():
head, _, tail = key.partition(".")
if tail:
out.setdefault(head, {})[tail] = value
else:
out[key] = value
return out
async def get_network_item(host: OpnsenseHost, kind: str, uuid: str | None) -> dict:
api = NETWORK_ITEM_APIS[kind]
path = f"{api['base']}/{api['get']}" + (f"/{uuid}" if uuid else "")
data = await _request(host, "GET", path)
item = data.get(api["body_key"]) if isinstance(data, dict) else None
return item if isinstance(item, dict) else {}
async def write_network_item(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
api = NETWORK_ITEM_APIS[kind]
if action == "add":
return _mutation_result(
await _request(host, "POST", f"{api['base']}/{api['add']}", json={api["body_key"]: _nest(fields)}),
"Objekt anlegen", {"saved"})
if action == "set":
return _mutation_result(
await _request(host, "POST", f"{api['base']}/{api['set']}/{uuid}", json={api["body_key"]: _nest(fields)}),
"Objekt ändern", {"saved"})
return _mutation_result(
await _request(host, "POST", f"{api['base']}/{api['del']}/{uuid}"), "Objekt entfernen", {"deleted"})
async def reconfigure_network(host: OpnsenseHost, kind: str) -> dict:
return _mutation_result(
await _request(host, "POST", NETWORK_ITEM_APIS[kind]["reconfigure"]), "Änderung anwenden", set())
async def list_network_item_uuids(host: OpnsenseHost, kind: str) -> list[str]:
api = NETWORK_ITEM_APIS[kind]
data = await _request(host, "GET", f"{api['base']}/{api['search']}", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return [str(r.get("uuid")) for r in rows if isinstance(r, dict) and r.get("uuid")]
async def interface_changes_pending(host: OpnsenseHost) -> bool:
"""True while staged interface changes wait for an apply (AssignmentController::pendingAction).
Our own apply would apply those too, so a change must not start on top of them."""
data = await _request(host, "GET", "/interfaces/assignment/pending")
return isinstance(data, dict) and data.get("status") == "pending"
async def _search_all(host: OpnsenseHost, path: str) -> dict:
return await _request(host, "GET", path, params={"rowCount": -1})
async def search_dns_hosts(host: OpnsenseHost) -> dict:
return await _search_all(host, "/unbound/settings/searchHostOverride")
async def search_dhcp_hosts(host: OpnsenseHost) -> dict:
return await _search_all(host, "/dnsmasq/settings/searchHost")
async def search_kea_subnets(host: OpnsenseHost) -> dict:
return await _search_all(host, "/kea/dhcpv4/searchSubnet")
async def search_kea_reservations(host: OpnsenseHost) -> dict:
return await _search_all(host, "/kea/dhcpv4/searchReservation")
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import text
from app.models.policy import RuleRevision
from app.services.rule_deploy_service import _build_payload
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled")
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
async def policy_lock(db):
+158
View File
@@ -0,0 +1,158 @@
"""Takes over a firewall rule that was created by hand on a host, so it can be
edited, versioned and deployed from OPNexus like any other rule.
Adoption itself writes NOTHING to the firewall: it records the rule as it is
right now (read fresh from the host at adoption time, never from what the
browser sent) together with its OPNsense UUID. The first change afterwards goes
through the normal preview → push → confirm/auto-revert flow.
Only rules OPNexus can describe without distortion are adopted. A rule using
something the model cannot express (several interfaces, an inverted source, a
non-quick rule, ...) is refused with the reason instead of being imported in a
way that would silently change its meaning on the next edit. Fields the model
does not know about (gateway, state options, schedules, tags, ...) are left
alone on the firewall: setRule only overwrites the fields it is sent.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
from app.services import opnsense_client
from app.services.policy_service import revision
from app.services.rule_deploy_service import _build_payload
class RuleNotFoundOnHost(Exception):
pass
class NotAdoptable(Exception):
def __init__(self, reasons: list[str]):
super().__init__(", ".join(reasons))
self.reasons = reasons
def _on(value) -> bool:
return value in ("1", 1, True)
def _clean(value) -> str:
return "" if value is None else str(value).strip()
def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
"""Machine-readable reasons (the UI translates them); empty = adoptable."""
reasons = []
if row.get("is_automatic"):
reasons.append("automatic")
if _clean(row.get("uuid")) in managed_uuids:
reasons.append("already_managed")
interface = _clean(row.get("interface"))
if not interface or "," in interface:
reasons.append("interface_not_single")
if _on(row.get("interfacenot")):
reasons.append("negated_interface")
if _on(row.get("source_not")):
reasons.append("negated_source")
if _on(row.get("destination_not")):
reasons.append("negated_destination")
if _clean(row.get("received-on")):
reasons.append("received_on")
if _clean(row.get("quick")) != "1":
reasons.append("not_quick")
if _clean(row.get("direction")) not in ("in", "out"):
reasons.append("direction_unsupported")
if _clean(row.get("action")) not in ("pass", "block", "reject"):
reasons.append("action_unsupported")
if _clean(row.get("ipprotocol")) not in ("inet", "inet6"):
reasons.append("ipprotocol_unsupported")
if not _clean(row.get("description")):
reasons.append("no_description")
return reasons
async def _managed_uuids(db: AsyncSession, host: OpnsenseHost) -> set[str]:
rows = await db.scalars(
select(RuleDeployment.opnsense_rule_uuid).where(
RuleDeployment.host_id == host.id, RuleDeployment.opnsense_rule_uuid.is_not(None)
)
)
return set(rows.all())
async def _live_rows(host: OpnsenseHost) -> list[dict]:
data = await opnsense_client.search_rules(host, all_rows=True)
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict) and r.get("uuid")]
async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
managed = await _managed_uuids(db, host)
result = []
for row in await _live_rows(host):
if row.get("is_automatic"):
continue # built-in/system rules never show up here at all
reasons = reasons_not_adoptable(row, managed)
if "already_managed" in reasons:
continue
result.append({
"uuid": _clean(row.get("uuid")),
"description": _clean(row.get("description")),
"interface": _clean(row.get("interface")),
"interface_label": _clean(row.get("%interface")) or _clean(row.get("interface")),
"direction": _clean(row.get("direction")),
"action": _clean(row.get("action")),
"protocol": _clean(row.get("protocol")) or "any",
"source_net": _clean(row.get("source_net")) or "any",
"source_port": _clean(row.get("source_port")),
"destination_net": _clean(row.get("destination_net")) or "any",
"destination_port": _clean(row.get("destination_port")),
"enabled": _on(row.get("enabled")),
"log": _on(row.get("log")),
"adoptable": not reasons,
"reasons": reasons,
})
return result
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> FirewallRule:
managed = await _managed_uuids(db, host)
row = next((r for r in await _live_rows(host) if _clean(r.get("uuid")) == rule_uuid), None)
if row is None:
raise RuleNotFoundOnHost(rule_uuid)
reasons = reasons_not_adoptable(row, managed)
if reasons:
raise NotAdoptable(reasons)
rule = FirewallRule(
description=_clean(row.get("description")),
interface=_clean(row.get("interface")),
direction=_clean(row.get("direction")),
ipprotocol=_clean(row.get("ipprotocol")),
protocol=_clean(row.get("protocol")) or "any",
action=_clean(row.get("action")),
source_net=_clean(row.get("source_net")) or "any",
source_port=_clean(row.get("source_port")) or None,
destination_net=_clean(row.get("destination_net")) or "any",
destination_port=_clean(row.get("destination_port")) or None,
enabled=_on(row.get("enabled")),
log=_on(row.get("log")),
targets=[host],
groups=[],
)
db.add(rule)
await db.flush()
revision(db, rule, "Von Firewall übernommen")
db.add(RuleDeployment(
rule_id=rule.id,
host_id=host.id,
status="success",
message="Von der Firewall übernommen",
opnsense_rule_uuid=rule_uuid,
last_payload=_build_payload(rule),
previous_payload=None,
))
await db.flush()
return rule
+1 -1
View File
@@ -50,7 +50,7 @@ def _build_payload(rule: FirewallRule) -> dict:
"destination_net": rule.destination_net,
"destination_port": rule.destination_port or "",
"description": rule.description,
"log": "0",
"log": "1" if rule.log else "0",
}
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.11.1"
APP_CODENAME = "Merlin"
APP_VERSION = "1.12.0"
APP_CODENAME = "Condor"
@@ -0,0 +1,24 @@
"""Regeln: Protokollieren-Flag (log).
Bisher schrieb jeder Push fest log=0. Damit eine von Hand angelegte Regel mit
Protokollierung übernommen und danach bearbeitet werden kann, ohne dass das
Flag still verloren geht, ist es jetzt ein Feld der Regel.
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-09
"""
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE firewall_rules ADD COLUMN IF NOT EXISTS log boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE firewall_rules DROP COLUMN IF EXISTS log")
+12
View File
@@ -575,3 +575,15 @@ async def test_firmware_info_broken_http_framing_gives_actionable_message(client
await opnsense_client.get_firmware_info(host)
assert "kern.ipc.tls.enable=0" in str(info.value)
assert "malformed chunk footer" in str(info.value)
@pytest.mark.asyncio
async def test_host_address_can_be_changed_after_the_firewall_moved(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = (await client.post("/api/hosts", json={"name": "fw-moved", "base_url": "https://10.9.9.1", "api_key": "k", "api_secret": "s"})).json()
resp = await client.patch(f"/api/hosts/{host['id']}", json={"base_url": "https://10.9.9.2"})
assert resp.status_code == 200, resp.text
assert resp.json()["base_url"] == "https://10.9.9.2" and resp.json()["id"] == host["id"]
for bad in ("ftp://x", "http://10.9.9.3", "https://user:[email protected]", "https://10.9.9.3/?a=b"):
assert (await client.patch(f"/api/hosts/{host['id']}", json={"base_url": bad})).status_code == 422, bad
assert next(h for h in (await client.get("/api/hosts")).json() if h["id"] == host["id"])["base_url"] == "https://10.9.9.2"
+178
View File
@@ -0,0 +1,178 @@
"""Read-only network inventory. Response shapes copied from real OPNsense
26.7.x test firewalls (CARP password replaced)."""
import httpx
import pytest
BASE = "https://fw-net.test"
INTERFACES_INFO = {"total": 3, "rowCount": 3, "current": 1, "rows": [
{"device": "vtnet0", "identifier": "wan", "description": "WAN", "status": "up", "is_physical": True,
"mtu": "1500", "macaddr": "bc:24:11:25:7e:b8", "media": "10Gbase-T <full-duplex>",
"ipv4": [{"ipaddr": "10.20.30.57/24"}], "ipv6": []},
{"device": "vtnet1_vlan10", "identifier": "lan", "description": "LAN", "status": "up", "is_physical": False,
"mtu": "1500", "ipv4": [{"ipaddr": "192.168.1.2/24"},
{"ipaddr": "192.168.1.1/24", "vhid": "10", "status": "MASTER"}],
"vlan": {"tag": "10", "proto": "802.1q", "pcp": "0", "parent": "vtnet1"}},
{"device": "vtnet1", "identifier": "", "description": "Unassigned Interface", "status": "up",
"is_physical": True, "ipv4": []},
]}
ASSIGNMENTS = {"rows": [
{"uuid": "wan", "identifier": "wan", "pending_action": "", "type4": "staticv4", "%type4": "Static IPv4", "%type6": "DHCPv6"},
{"uuid": "lan", "identifier": "lan", "pending_action": "", "type4": "staticv4", "%type4": "Static IPv4", "%type6": "Identity association"},
]}
VLANS = {"rows": [{"uuid": "v1", "if": "vtnet1", "tag": "10", "pcp": "0", "descr": "", "vlanif": "vtnet1_vlan10 [LAN]"}]}
VIPS = {"rows": [{"uuid": "vip1", "interface": "lan", "%interface": "LAN", "mode": "carp", "address": "192.168.1.1/24",
"vhid": "10", "advbase": "1", "advskew": "0", "descr": "CARP LAN", "password": "SUPER-SECRET-CARP"}]}
GATEWAYS = {"rows": [{"uuid": "g1", "name": "WAN_GW", "descr": "", "interface": "wan", "interface_descr": "WAN",
"ipprotocol": "inet", "gateway": "10.20.30.1", "defaultgw": True, "disabled": False,
"priority": "255", "status": "Online"}]}
DNS_HOSTS = {"rows": [
{"uuid": "d1", "enabled": "1", "hostname": "nas", "domain": "lan.example", "rr": "A", "server": "192.168.1.5", "description": "NAS", "isAlias": False},
{"uuid": "d1-a", "enabled": "1", "hostname": "files", "domain": "lan.example", "rr": "A", "server": "192.168.1.5", "isAlias": True}]}
DHCP_HOSTS = {"rows": [{"uuid": "h1", "host": "printer", "domain": "lan.example", "ip": "192.168.1.30", "hwaddr": "02:00:00:aa:bb:cc",
"lease_time": "", "descr": "Printer"}]}
KEA_SUBNETS = {"rows": [{"uuid": "s1", "subnet": "192.168.77.0/24", "pools": "192.168.77.100-192.168.77.150",
"option_data.routers": "192.168.77.1", "option_data.domain_name_servers": "",
"description": "lab", "ddns_domain_key_secret": "KEA-DDNS-SECRET"}]}
KEA_RESERVATIONS = {"rows": [{"uuid": "k1", "subnet": "s1", "%subnet": "192.168.77.0/24", "ip_address": "192.168.77.120",
"hw_address": "02:00:00:aa:bb:dd", "hostname": "kearsv", "description": "Reservation"}]}
ROUTES = {"rows": [{"uuid": "r1", "network": "10.9.0.0/16", "gateway": "WAN_GW", "descr": "lab", "disabled": "0"}]}
async def _host(client, respx_mock, base=BASE, name="fw-net"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
resp = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s"})
return resp.json()["id"]
def _mock(respx_mock, base=BASE, info=INTERFACES_INFO, **overrides):
routes = {
"/interfaces/overview/interfacesInfo": info,
"/interfaces/assignment/searchItem": ASSIGNMENTS,
"/interfaces/vlan_settings/searchItem": VLANS,
"/interfaces/vip_settings/searchItem": VIPS,
"/routing/settings/searchGateway": GATEWAYS,
"/routes/routes/searchroute": ROUTES,
"/unbound/settings/searchHostOverride": DNS_HOSTS,
"/dnsmasq/settings/searchHost": DHCP_HOSTS,
"/kea/dhcpv4/searchSubnet": KEA_SUBNETS,
"/kea/dhcpv4/searchReservation": KEA_RESERVATIONS,
}
for path, body in routes.items():
reply = overrides.get(path, httpx.Response(200, json=body))
respx_mock.get(f"{base}/api{path}").mock(return_value=reply)
@pytest.mark.asyncio
async def test_network_inventory_parses_real_shapes(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.57")
_mock(respx_mock, base="https://10.20.30.57")
resp = await client.get(f"/api/hosts/{host_id}/network")
assert resp.status_code == 200, resp.text
data = resp.json()
by_device = {i["device"]: i for i in data["interfaces"]}
assert by_device["vtnet0"]["identifier"] == "wan"
assert by_device["vtnet0"]["ipv4_type"] == "Static IPv4"
assert by_device["vtnet1"]["identifier"] is None
assert by_device["vtnet0"]["assigned"] is True and by_device["vtnet1"]["assigned"] is False
lan = by_device["vtnet1_vlan10"]
assert lan["vlan_tag"] == "10" and lan["vlan_parent"] == "vtnet1"
assert [a["carp_status"] for a in lan["addresses"]] == [None, "MASTER"]
assert data["vlans"][0]["device"] == "vtnet1_vlan10"
assert data["gateways"][0]["is_default"] is True
assert data["routes"][0]["network"] == "10.9.0.0/16"
assert data["unavailable"] == []
assert data["can_configure_interfaces"] is True
@pytest.mark.asyncio
async def test_network_marks_management_interface(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.57")
_mock(respx_mock, base="https://10.20.30.57")
data = (await client.get(f"/api/hosts/{host_id}/network")).json()
assert data["management_device"] == "vtnet0"
assert [i["device"] for i in data["interfaces"] if i["is_management"]] == ["vtnet0"]
@pytest.mark.asyncio
async def test_network_carp_address_is_not_management(client, respx_mock):
# Reached over the shared CARP address: that is not one interface's own IP, so no guess.
host_id = await _host(client, respx_mock, base="https://192.168.1.1", name="fw-carp")
_mock(respx_mock, base="https://192.168.1.1")
data = (await client.get(f"/api/hosts/{host_id}/network")).json()
assert data["management_device"] is None
@pytest.mark.asyncio
async def test_network_never_returns_carp_password(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.58", name="fw-pw")
_mock(respx_mock, base="https://10.20.30.58")
resp = await client.get(f"/api/hosts/{host_id}/network")
assert "SUPER-SECRET-CARP" not in resp.text
assert "password" not in resp.json()["vips"][0]
@pytest.mark.asyncio
async def test_network_missing_section_is_reported_not_hidden(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.59", name="fw-old")
_mock(respx_mock, base="https://10.20.30.59", **{
"/interfaces/assignment/searchItem": httpx.Response(404, json={"errorMessage": "Endpoint not found"}),
})
resp = await client.get(f"/api/hosts/{host_id}/network")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["unavailable"] == ["assignments"]
assert len(data["interfaces"]) == 3
@pytest.mark.asyncio
async def test_network_host_unreachable_returns_502(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.60", name="fw-down")
respx_mock.get(url__regex=r".*/interfaces/overview/interfacesInfo").mock(side_effect=httpx.ConnectError("refused"))
assert (await client.get(f"/api/hosts/{host_id}/network")).status_code == 502
@pytest.mark.asyncio
async def test_network_unknown_host_404(client):
resp = await client.get("/api/hosts/00000000-0000-0000-0000-000000000000/network")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_network_tolerates_numbers_where_strings_were_expected(client, respx_mock):
# Seen live on 26.7.x: a gateway's priority arrived as the integer 254.
host_id = await _host(client, respx_mock, base="https://10.20.30.61", name="fw-int")
gateways = {"rows": [{**GATEWAYS["rows"][0], "priority": 254}]}
_mock(respx_mock, base="https://10.20.30.61", **{
"/routing/settings/searchGateway": httpx.Response(200, json=gateways),
})
resp = await client.get(f"/api/hosts/{host_id}/network")
assert resp.status_code == 200, resp.text
assert resp.json()["gateways"][0]["priority"] == "254"
@pytest.mark.asyncio
async def test_network_older_26_7_assignment_list_cannot_configure(client, respx_mock):
# OPNsense 26.7.1: the assignment list only has identifier/descr/if, no type4/ipaddr.
host_id = await _host(client, respx_mock, base="https://10.20.30.62", name="fw-2671")
thin = {"rows": [{"uuid": "wan", "identifier": "wan", "descr": "", "if": "vtnet0"}]}
_mock(respx_mock, base="https://10.20.30.62", **{
"/interfaces/assignment/searchItem": httpx.Response(200, json=thin),
})
data = (await client.get(f"/api/hosts/{host_id}/network")).json()
assert data["can_configure_interfaces"] is False
assert data["unavailable"] == []
@pytest.mark.asyncio
async def test_network_lists_dns_and_dhcp_objects_without_secrets(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.63", name="fw-dns")
_mock(respx_mock, base="https://10.20.30.63")
resp = await client.get(f"/api/hosts/{host_id}/network")
assert "KEA-DDNS-SECRET" not in resp.text
data = resp.json()
assert [(d["hostname"], d["server"]) for d in data["dns_hosts"]] == [("nas", "192.168.1.5")] # alias children are not rows of their own
assert data["dhcp_hosts"][0]["hwaddr"] == "02:00:00:aa:bb:cc"
assert (data["kea_subnets"][0]["subnet"], data["kea_subnets"][0]["routers"]) == ("192.168.77.0/24", "192.168.77.1")
assert data["kea_reservations"][0]["subnet"] == "192.168.77.0/24"
assert data["unavailable"] == []
+498
View File
@@ -0,0 +1,498 @@
"""Writes to VLANs / virtual IPs / gateways / routes, and the safety around them."""
import json
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from tests.test_network import GATEWAYS, INTERFACES_INFO, _host, _mock
MGMT = "https://10.20.30.57"
def _url(path, base=MGMT):
return f"{base}/api{path}"
def _backup(respx_mock, base=MGMT, status=200):
return respx_mock.get(_url("/core/backup/download/this", base)).mock(
return_value=httpx.Response(status, content=b"<opnsense/>"))
def _item(respx_mock, path, key, node, base=MGMT):
respx_mock.get(_url(path, base)).mock(return_value=httpx.Response(200, json={key: node}))
async def _setup(client, respx_mock, name, base=MGMT, **overrides):
host_id = await _host(client, respx_mock, base=base, name=name)
_mock(respx_mock, base=base, **overrides)
return host_id
def _body(call):
return json.loads(call.request.content.decode())
async def _preview(client, host_id, **payload):
return await client.post(f"/api/hosts/{host_id}/network/changes/preview", json=payload)
async def _execute(client, host_id, token, ack=False, **payload):
return await client.post(f"/api/hosts/{host_id}/network/changes",
json={**payload, "preview_token": token, "acknowledge": ack})
async def _audit(host_id):
async with SessionLocal() as db:
rows = await db.scalars(select(AuditEvent).where(AuditEvent.host_id == host_id, AuditEvent.kind == "network")
.order_by(AuditEvent.created_at))
return list(rows.all())
VLAN_ADD = {"kind": "vlan", "action": "add", "fields": {"if": "vtnet1", "tag": "20", "descr": "guests"}}
@pytest.mark.asyncio
async def test_preview_add_is_read_only_and_shows_diff(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c1")
resp = await _preview(client, host_id, **VLAN_ADD)
assert resp.status_code == 200, resp.text
plan = resp.json()
assert plan["can_execute"] is True and plan["requires_acknowledgement"] is False
assert {c["field"] for c in plan["changes"]} == {"if", "tag", "descr"}
assert len(plan["token"]) == 64
assert not [c for c in respx_mock.calls if c.request.method == "POST" and "/api/hosts" not in str(c.request.url)
and "vlan_settings" in str(c.request.url)]
@pytest.mark.asyncio
async def test_execute_add_backs_up_writes_applies_and_verifies(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c2")
backup = _backup(respx_mock)
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "new-vlan"}))
reconf = respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(
return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/vlan_settings/getItem/new-vlan", "vlan",
{"if": {"vtnet1": {"selected": 1}}, "tag": "20", "descr": "guests"})
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 200, resp.text
result = resp.json()
assert result["uuid"] == "new-vlan" and result["verified"] is True and result["backup_id"].endswith(".xml")
assert backup.called and reconf.called
assert _body(add.calls.last) == {"vlan": {"if": "vtnet1", "tag": "20", "descr": "guests"}}
assert [e.status for e in await _audit(host_id)] == ["started", "applied"]
@pytest.mark.asyncio
async def test_stale_token_is_refused_and_nothing_is_written(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c3")
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
resp = await _execute(client, host_id, "0" * 64, **VLAN_ADD)
assert resp.status_code == 409
assert not add.called
@pytest.mark.asyncio
async def test_token_changes_when_the_fields_change(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c4")
one = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
other = dict(VLAN_ADD, fields={**VLAN_ADD["fields"], "tag": "21"})
two = (await _preview(client, host_id, **other)).json()["token"]
assert one != two
# a token for the first request cannot be used for the second
assert (await _execute(client, host_id, one, **other)).status_code == 409
@pytest.mark.asyncio
async def test_backup_failure_aborts_before_any_write(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c5")
_backup(respx_mock, status=500)
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 502
assert resp.json()["detail"]["code"] == "NETWORK_BACKUP_FAILED"
assert not add.called
assert [e.status for e in await _audit(host_id)] == ["aborted"]
@pytest.mark.asyncio
async def test_firewall_rejection_is_reported_and_not_applied(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c6")
_backup(respx_mock)
respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "failed", "validations": {"vlan.tag": "bad"}}))
reconf = respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(
return_value=httpx.Response(200, json={"status": "ok"}))
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "UPSTREAM_REJECTED"
assert not reconf.called
assert (await _audit(host_id))[-1].status == "rejected"
@pytest.mark.asyncio
async def test_apply_failure_is_never_reported_as_success(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c7")
_backup(respx_mock)
respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(side_effect=httpx.ReadTimeout("slow"))
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "NETWORK_APPLY_UNCERTAIN"
assert resp.json()["detail"]["params"]["backup_id"].endswith(".xml")
assert (await _audit(host_id))[-1].status == "apply_uncertain"
@pytest.mark.asyncio
async def test_unknown_field_is_rejected_not_forwarded(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c8")
resp = await _preview(client, host_id, kind="vlan", action="add", fields={"if": "vtnet1", "tag": "9", "evil": "x"})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "NETWORK_FIELD_INVALID"
@pytest.mark.asyncio
async def test_changing_the_management_vlan_needs_acknowledgement(client, respx_mock):
# fw reached on 192.168.1.2 = the LAN VLAN device
base = "https://192.168.1.2"
vlans = {"rows": [{"uuid": "v-mgmt", "if": "vtnet1", "tag": "10", "pcp": "0", "descr": "", "vlanif": "vtnet1_vlan10 [LAN]"}]}
host_id = await _setup(client, respx_mock, "fw-c9", base=base, **{
"/interfaces/vlan_settings/searchItem": httpx.Response(200, json=vlans)})
_item(respx_mock, "/interfaces/vlan_settings/getItem/v-mgmt", "vlan",
{"if": {"vtnet1": {"selected": 1}}, "tag": "10", "descr": "", "vlanif": "vtnet1_vlan10"}, base=base)
_backup(respx_mock, base)
respx_mock.post(_url("/interfaces/vlan_settings/setItem/v-mgmt", base)).mock(
return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/interfaces/vlan_settings/reconfigure", base)).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vlan", "action": "set", "uuid": "v-mgmt", "fields": {"descr": "renamed"}}
plan = (await _preview(client, host_id, **change)).json()
assert plan["requires_acknowledgement"] is True
assert plan["risks"][0]["code"] == "management_interface"
refused = await _execute(client, host_id, plan["token"], **change)
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "NETWORK_ACK_REQUIRED"
allowed = await _execute(client, host_id, plan["token"], ack=True, **change)
assert allowed.status_code == 200, allowed.text
assert (await _audit(host_id))[0].details["acknowledged"] is True
@pytest.mark.asyncio
async def test_vip_password_never_leaves_the_server(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c10")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.1/24",
"vhid": "10", "password": "STORED-SECRET", "descr": "CARP LAN", "advskew": "0"})
_backup(respx_mock)
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
resp = await _preview(client, host_id, **change)
assert "STORED-SECRET" not in resp.text and "NEW-SECRET" not in resp.text
plan = resp.json()
assert {"field": "password", "before": None, "after": "(gesetzt)"} in plan["changes"]
assert [w["code"] for w in plan["warnings"]] == ["carp_change"]
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200, done.text
assert "NEW-SECRET" not in done.text
assert _body(set_route.calls.last)["vip"]["password"] == "NEW-SECRET" # but it does reach the firewall
for event in await _audit(host_id):
assert "NEW-SECRET" not in json.dumps(event.details) and "STORED-SECRET" not in json.dumps(event.details)
@pytest.mark.asyncio
async def test_default_gateway_and_default_route_need_acknowledgement(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c11")
_item(respx_mock, "/routing/settings/getGateway/g1", "gateway_item",
{"name": "WAN_GW", "interface": {"wan": {"selected": 1}}, "gateway": "10.20.30.1", "priority": "255"})
plan = (await _preview(client, host_id, kind="gateway", action="del", uuid="g1", fields={})).json()
assert {r["code"] for r in plan["risks"]} >= {"default_gateway", "management_gateway"}
route = (await _preview(client, host_id, kind="route", action="add",
fields={"network": "0.0.0.0/0", "gateway": "WAN_GW"})).json()
assert [r["code"] for r in route["risks"]] == ["default_route"]
covers = (await _preview(client, host_id, kind="route", action="add",
fields={"network": "10.20.0.0/16", "gateway": "WAN_GW"})).json()
assert [r["code"] for r in covers["risks"]] == ["route_covers_management"]
harmless = (await _preview(client, host_id, kind="route", action="add",
fields={"network": "172.16.0.0/16", "gateway": "WAN_GW"})).json()
assert harmless["risks"] == [] and harmless["requires_acknowledgement"] is False
@pytest.mark.asyncio
async def test_delete_is_verified_by_absence(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c12")
_item(respx_mock, "/routes/routes/getroute/r1", "route", {"network": "10.9.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "descr": "lab", "enabled": "1"})
_backup(respx_mock)
respx_mock.post(_url("/routes/routes/delroute/r1")).mock(return_value=httpx.Response(200, json={"result": "deleted"}))
respx_mock.post(_url("/routes/routes/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
# after the delete the route list no longer contains it
respx_mock.get(_url("/routes/routes/searchroute")).mock(return_value=httpx.Response(200, json={"rows": []}))
change = {"kind": "route", "action": "del", "uuid": "r1", "fields": {}}
plan = (await _preview(client, host_id, **change)).json()
assert plan["risks"] == [] # 10.9.0.0/16 does not touch the management address
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200 and done.json()["verified"] is True
@pytest.mark.asyncio
async def test_missing_object_is_404(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c13")
respx_mock.get(_url("/interfaces/vlan_settings/getItem/ghost")).mock(return_value=httpx.Response(200, json={"vlan": {}}))
resp = await _preview(client, host_id, kind="vlan", action="del", uuid="ghost", fields={})
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_unknown_management_path_is_treated_as_risky(client, respx_mock):
# The address OPNexus uses belongs to no interface (NAT, or a CARP address): be careful, not optimistic.
base = "https://10.20.30.99"
host_id = await _setup(client, respx_mock, "fw-c14", base=base)
_item(respx_mock, "/routes/routes/getroute/r2", "route", {"network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, base=base)
plan = (await _preview(client, host_id, kind="route", action="set", uuid="r2", fields={"descr": "x"}, )).json()
assert [r["code"] for r in plan["risks"]] == ["management_unknown"]
add = (await _preview(client, host_id, kind="route", action="add", fields={"network": "172.17.0.0/16", "gateway": "WAN_GW"})).json()
assert add["risks"] == [] # a new, harmless object cannot cut anything
@pytest.mark.asyncio
async def test_template_returns_the_firewalls_own_choices(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c15")
_item(respx_mock, "/routes/routes/getroute", "route", {
"network": "", "descr": "", "enabled": "1", "secretless": "x",
"gateway": {"Null4": {"value": "Null4", "selected": 0}, "WAN_GW": {"value": "WAN_GW - 10.20.30.1", "selected": 1}}})
resp = await client.get(f"/api/hosts/{host_id}/network/template/route")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["gateway"]["value"] == "WAN_GW"
assert [o["value"] for o in body["gateway"]["options"]] == ["Null4", "WAN_GW"]
assert "secretless" not in body # only fields that may be sent
assert (await client.get(f"/api/hosts/{host_id}/network/template/bogus")).status_code == 404
@pytest.mark.asyncio
async def test_inventory_reads_route_enabled_flag(client, respx_mock):
# Real 26.7.x route rows say enabled "1"/"0", not disabled.
rows = {"rows": [{"uuid": "r1", "network": "10.9.0.0/16", "gateway": "WAN_GW", "descr": "on", "enabled": "1"},
{"uuid": "r2", "network": "10.8.0.0/16", "gateway": "WAN_GW", "descr": "off", "enabled": "0"}]}
host_id = await _setup(client, respx_mock, "fw-c16", **{"/routes/routes/searchroute": httpx.Response(200, json=rows)})
routes = (await client.get(f"/api/hosts/{host_id}/network")).json()["routes"]
assert [(r["network"], r["disabled"]) for r in routes] == [("10.9.0.0/16", False), ("10.8.0.0/16", True)]
@pytest.mark.asyncio
async def test_delete_preview_lists_only_meaningful_fields(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c17")
_item(respx_mock, "/interfaces/vlan_settings/getItem/v9", "vlan",
{"if": {"vtnet1": {"selected": 1}}, "tag": "40", "descr": "x", "vlanif": "vlan01", "pcp": ""})
plan = (await _preview(client, host_id, kind="vlan", action="del", uuid="v9", fields={})).json()
assert {c["field"] for c in plan["changes"]} == {"if", "tag", "descr"}
@pytest.mark.asyncio
async def test_item_endpoint_returns_current_values_without_secrets(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c18")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.1/24",
"vhid": "10", "password": "STORED-SECRET", "address": "192.168.1.1/24", "descr": "CARP LAN"})
resp = await client.get(f"/api/hosts/{host_id}/network/item/vip/vip1")
assert resp.status_code == 200, resp.text
assert "STORED-SECRET" not in resp.text
assert resp.json() == {"interface": "lan", "mode": "carp", "network": "192.168.1.1/24", "vhid": "10", "descr": "CARP LAN"}
assert (await client.get(f"/api/hosts/{host_id}/network/item/vip/..%2Fx")).status_code == 404
# ── interface assignment / IP configuration ──────────────────────────────
def _pending(respx_mock, status="ok", base=MGMT):
respx_mock.get(_url("/interfaces/assignment/pending", base)).mock(return_value=httpx.Response(200, json={"status": status}))
IF_ADD = {"kind": "interface", "action": "add", "fields": {"if": "vtnet1_vlan20", "descr": "GUEST", "enable": "1",
"type4": "staticv4", "ipaddr": "10.20.0.1/24"}}
@pytest.mark.asyncio
async def test_interface_add_stages_applies_and_verifies(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i1")
_pending(respx_mock)
_backup(respx_mock)
add = respx_mock.post(_url("/interfaces/assignment/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "opt1"}))
reconf = respx_mock.post(_url("/interfaces/assignment/reconfigure")).mock(
return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface", {
"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1",
"type4": {"staticv4": {"selected": 1}}, "ipaddr": "10.20.0.1/24"})
plan = (await _preview(client, host_id, **IF_ADD)).json()
assert plan["can_execute"] is True and plan["risks"] == []
done = await _execute(client, host_id, plan["token"], **IF_ADD)
assert done.status_code == 200, done.text
assert done.json()["uuid"] == "opt1" and done.json()["verified"] is True
assert _body(add.calls.last)["interface"]["ipaddr"] == "10.20.0.1/24"
assert reconf.called
@pytest.mark.asyncio
async def test_interface_changes_are_blocked_on_firewalls_without_the_api(client, respx_mock):
thin = {"rows": [{"uuid": "wan", "identifier": "wan", "descr": "", "if": "vtnet0"}]} # 26.7.1: no type4
host_id = await _setup(client, respx_mock, "fw-i2", **{"/interfaces/assignment/searchItem": httpx.Response(200, json=thin)})
plan = (await _preview(client, host_id, **IF_ADD)).json()
assert plan["can_execute"] is False
assert [b["code"] for b in plan["blocks"]] == ["interface_api_unavailable"]
resp = await _execute(client, host_id, plan["token"], **IF_ADD)
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "NETWORK_CHANGE_BLOCKED"
@pytest.mark.asyncio
async def test_staged_changes_from_someone_else_block_a_new_change(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i3")
_pending(respx_mock, status="pending")
plan = (await _preview(client, host_id, **IF_ADD)).json()
assert [b["code"] for b in plan["blocks"]] == ["interface_changes_pending"]
add = respx_mock.post(_url("/interfaces/assignment/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
assert (await _execute(client, host_id, plan["token"], **IF_ADD)).status_code == 409
assert not add.called
@pytest.mark.asyncio
async def test_deleting_an_interface_lists_the_rules_that_will_vanish(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i4")
_pending(respx_mock)
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface", {
"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1"})
respx_mock.get(_url("/firewall/filter/searchRule")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "a", "description": "guest out", "interface": "opt1", "is_automatic": False},
{"uuid": "b", "description": "multi", "interface": "lan,opt1", "is_automatic": False},
{"uuid": "c", "description": "other", "interface": "lan", "is_automatic": False},
{"uuid": "d", "description": "system", "interface": "opt1", "is_automatic": True}]}))
plan = (await _preview(client, host_id, kind="interface", action="del", uuid="opt1", fields={})).json()
risk = next(r for r in plan["risks"] if r["code"] == "interface_rules_removed")
assert risk["params"]["count"] == 2 and "guest out" in risk["params"]["names"] and "other" not in risk["params"]["names"]
assert plan["requires_acknowledgement"] is True
@pytest.mark.asyncio
async def test_changing_the_management_interface_needs_acknowledgement(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i5") # reached on 10.20.30.57 = wan
_pending(respx_mock)
_item(respx_mock, "/interfaces/assignment/getItem/wan", "interface", {
"if": {"vtnet0": {"selected": 1}}, "descr": "WAN", "enable": "1", "type4": {"staticv4": {"selected": 1}},
"ipaddr": "10.20.30.57/24"})
plan = (await _preview(client, host_id, kind="interface", action="set", uuid="wan", fields={"ipaddr": "10.20.30.77/24"})).json()
assert [r["code"] for r in plan["risks"]] == ["management_interface"]
assert plan["requires_acknowledgement"] is True
@pytest.mark.asyncio
async def test_locked_interface_cannot_be_deleted_and_says_why(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i6")
_pending(respx_mock)
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface",
{"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1", "lock": "1"})
respx_mock.get(_url("/firewall/filter/searchRule")).mock(return_value=httpx.Response(200, json={"rows": []}))
plan = (await _preview(client, host_id, kind="interface", action="del", uuid="opt1", fields={})).json()
assert [b["code"] for b in plan["blocks"]] == ["interface_locked"] and plan["can_execute"] is False
# ── DNS / DHCP objects ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_kea_subnet_options_are_sent_nested_and_read_back_flat(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d1")
_backup(respx_mock)
add = respx_mock.post(_url("/kea/dhcpv4/addSubnet")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "sn1"}))
respx_mock.post(_url("/kea/service/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/kea/dhcpv4/getSubnet/sn1", "subnet4", {
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
"option_data": {"routers": {"192.168.50.1": {"value": "192.168.50.1", "selected": 1}, "": {"value": "", "selected": 0}},
"domain_name": "lab.example"}})
change = {"kind": "kea_subnet", "action": "add", "fields": {
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
"option_data.routers": "192.168.50.1", "option_data.domain_name": "lab.example"}}
plan = (await _preview(client, host_id, **change)).json()
assert plan["risks"] == [] # DHCP data cannot cut the connection, even with an unknown management path
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200 and done.json()["verified"] is True, done.text
assert _body(add.calls.last) == {"subnet4": {
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
"option_data": {"routers": "192.168.50.1", "domain_name": "lab.example"}}}
@pytest.mark.asyncio
async def test_dns_override_on_unknown_management_path_is_not_treated_as_risky(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d2", base="https://10.20.30.98")
_item(respx_mock, "/unbound/settings/getHostOverride/h1", "host",
{"hostname": "nas", "domain": "lan.example", "server": "192.168.1.5", "rr": {"A": {"value": "A", "selected": 1}}},
base="https://10.20.30.98")
plan = (await _preview(client, host_id, kind="dns_host", action="set", uuid="h1", fields={"server": "192.168.1.6"})).json()
assert plan["risks"] == [] and plan["can_execute"] is True
assert plan["changes"] == [{"field": "server", "before": "192.168.1.5", "after": "192.168.1.6"}]
@pytest.mark.asyncio
async def test_template_turns_tokenizer_fields_into_comma_text(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d3")
_item(respx_mock, "/dnsmasq/settings/getHost", "host", {
"host": "", "domain": "", "local": "0",
"ip": {"": {"value": "", "selected": 1}},
"hwaddr": {"aa:bb:cc:dd:ee:ff": {"value": "aa:bb:cc:dd:ee:ff", "selected": 1}, "11:22:33:44:55:66": {"value": "11:22:33:44:55:66", "selected": 1}}})
body = (await client.get(f"/api/hosts/{host_id}/network/template/dhcp_host")).json()
assert body["ip"]["value"] == "" and body["hwaddr"]["value"] == "aa:bb:cc:dd:ee:ff,11:22:33:44:55:66"
@pytest.mark.asyncio
async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d4")
_backup(respx_mock)
_item(respx_mock, "/kea/dhcpv4/getSubnet/sn9", "subnet4", {
"subnet": "192.168.9.0/24", "description": "old", "ddns_domain_key_secret": "KEA-DDNS-SECRET"})
respx_mock.post(_url("/kea/dhcpv4/setSubnet/sn9")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/kea/service/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "kea_subnet", "action": "set", "uuid": "sn9", "fields": {"description": "new"}}
plan = (await _preview(client, host_id, **change)).json()
assert (await _execute(client, host_id, plan["token"], **change)).status_code == 200
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
@pytest.mark.asyncio
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
# OPNsense blanks a VIP's address when an update leaves out "network" (seen live on 26.7.6).
host_id = await _setup(client, respx_mock, "fw-v1")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
"vhid": "11", "advskew": "0", "descr": "x"})
_backup(respx_mock)
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
plan = (await _preview(client, host_id, **change)).json()
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200, done.text
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24"}
@pytest.mark.asyncio
async def test_verification_catches_a_field_that_changed_without_being_asked(client, respx_mock):
# The firewall dropped an untouched field while saving: that must not be reported as a clean success.
host_id = await _setup(client, respx_mock, "fw-v2")
state = iter([
{"descr": "old", "network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # preview (before)
{"descr": "old", "network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # execute re-preview
{"descr": "new", "network": "", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # read-back: network wiped
])
respx_mock.get(_url("/routes/routes/getroute/r5")).mock(side_effect=lambda req: httpx.Response(200, json={"route": next(state)}))
_backup(respx_mock)
respx_mock.post(_url("/routes/routes/setroute/r5")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/routes/routes/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "route", "action": "set", "uuid": "r5", "fields": {"descr": "new"}}
plan = (await _preview(client, host_id, **change)).json()
done = (await _execute(client, host_id, plan["token"], **change)).json()
assert done["verified"] is False and done["mismatches"] == ["network"]
+120
View File
@@ -0,0 +1,120 @@
"""Taking over rules that were created by hand on a firewall."""
import json
import httpx
import pytest
BASE = "https://fw-adopt.test"
def _row(uuid, **over):
row = {
"uuid": uuid, "description": "Manual SSH", "enabled": "1", "action": "pass", "quick": "1",
"interface": "lan", "%interface": "LAN", "interfacenot": "0", "direction": "in", "ipprotocol": "inet",
"protocol": "TCP", "source_net": "10.0.0.0/8", "source_not": "0", "source_port": "",
"destination_net": "any", "destination_not": "0", "destination_port": "22", "log": "1",
"received-on": "", "gateway": "WAN_GW", "is_automatic": False,
}
row.update(over)
return row
async def _host(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
r = await client.post("/api/hosts", json={"name": "fw-adopt", "base_url": BASE, "api_key": "k", "api_secret": "s"})
return r.json()["id"]
def _live(respx_mock, rows):
return respx_mock.get(f"{BASE}/api/firewall/filter/searchRule").mock(
return_value=httpx.Response(200, json={"rows": rows, "total": len(rows)})
)
@pytest.mark.asyncio
async def test_unmanaged_lists_adoptable_and_refused_with_reasons(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [
_row("u-ok"),
_row("u-neg", description="Not source", source_not="1"),
_row("u-multi", description="Two ifaces", interface="lan,opt1"),
_row("u-float", description="Late", quick="0"),
_row("u-auto", description="anti-lockout rule", is_automatic=True),
])
resp = await client.get(f"/api/rules/unmanaged?host_id={host_id}")
assert resp.status_code == 200, resp.text
by_uuid = {r["uuid"]: r for r in resp.json()}
assert "u-auto" not in by_uuid # system rules are never offered
assert by_uuid["u-ok"]["adoptable"] is True and by_uuid["u-ok"]["log"] is True
assert by_uuid["u-neg"]["reasons"] == ["negated_source"]
assert by_uuid["u-multi"]["reasons"] == ["interface_not_single"]
assert by_uuid["u-float"]["reasons"] == ["not_quick"]
@pytest.mark.asyncio
async def test_adopt_records_rule_without_writing_to_the_firewall(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-ok")])
resp = await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})
assert resp.status_code == 201, resp.text
rule = resp.json()
assert (rule["description"], rule["interface"], rule["source_net"], rule["destination_port"]) == (
"Manual SSH", "lan", "10.0.0.0/8", "22")
assert rule["log"] is True and rule["enabled"] is True
assert rule["target_host_ids"] == [host_id]
assert [d["status"] for d in rule["deployments"]] == ["success"]
assert rule["workflow_state"] == "synced"
assert all(call.request.method == "GET" for call in respx_mock.calls if "/filter/" in str(call.request.url))
# no longer offered afterwards
again = await client.get(f"/api/rules/unmanaged?host_id={host_id}")
assert again.json() == []
@pytest.mark.asyncio
async def test_adopt_refuses_unrepresentable_rule(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-neg", source_not="1")])
resp = await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-neg"})
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "RULE_NOT_ADOPTABLE"
assert "negated_source" in resp.json()["detail"]["params"]["reasons"]
assert (await client.get("/api/rules")).json() == [] or all(r["description"] != "Manual SSH" for r in (await client.get("/api/rules")).json())
@pytest.mark.asyncio
async def test_adopt_unknown_uuid_is_404_and_double_adopt_is_refused(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-ok")])
assert (await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "nope"})).status_code == 404
assert (await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})).status_code == 201
second = await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})
assert second.status_code == 409
assert "already_managed" in second.json()["detail"]["params"]["reasons"]
@pytest.mark.asyncio
async def test_edit_after_adopt_updates_same_remote_rule_and_keeps_log(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-ok")])
rule = (await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})).json()
edit = {
"description": "Manual SSH (edited)", "interface": "lan", "direction": "in", "ipprotocol": "inet",
"protocol": "TCP", "action": "pass", "source_net": "10.0.0.0/8", "source_port": None,
"destination_net": "any", "destination_port": "22", "enabled": True, "log": True,
"target_host_ids": [host_id], "target_group_ids": [], "expected_updated_at": rule["updated_at"],
}
assert (await client.put(f"/api/rules/{rule['id']}", json=edit)).status_code == 200
set_route = respx_mock.post(f"{BASE}/api/firewall/filter/setRule/u-ok").mock(
return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(f"{BASE}/api/firewall/filter/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
from tests.helpers import deploy_approved
resp = await deploy_approved(client, f"/api/rules/{rule['id']}/deploy")
assert resp.status_code == 200, resp.text
assert set_route.called
body = set_route.calls.last.request.content.decode()
sent = json.loads(body)["rule"]
assert sent["description"] == "Manual SSH (edited)"
assert sent["log"] == "1" # not silently reset by the push
assert "gateway" not in sent # fields OPNexus does not model are never sent, so they stay as they are
+111
View File
@@ -3023,6 +3023,117 @@ headless), inklusive Auth-Flow und Deploy-Sperrschutz:
- Keine Konsolen-Fehler außer erwarteten 401/502 nach Logout bzw. gegen
nicht erreichbare Test-Hosts.
## Netzwerk, DNS und DHCP (2026-10-09)
Neuer Bereich **Netzwerk** (`/network`). Je Firewall: Inventar und Änderungen für
Schnittstellen, VLANs, virtuelle IPs, Gateways, statische Routen, DNS-Einträge
(Unbound) und DHCP (dnsmasq-Hosts, Kea-Subnetze und -Reservierungen). Diese Objekte
sind firewall-spezifisch (eigene IPs, eigenes VLAN-Layout), deshalb gibt es hier
keine zentrale Kopie und keinen Multi-Host-Push: Die Firewall ist die Quelle der
Wahrheit, jede Änderung geht direkt dorthin, aber nur über Vorschau und Ausführung.
**Ablauf einer Änderung** (`services/network_change_service.py`):
1. *Vorschau* (`POST /api/hosts/{id}/network/changes/preview`): liest den Live-Stand,
berechnet Diff, Risiken, Warnungen und Sperren, liefert einen Token über genau
diesen Stand.
2. *Ausführen* (`POST /api/hosts/{id}/network/changes`): liest den Stand erneut und
verweigert bei abweichendem Token. Risikoreiche Änderungen brauchen
`acknowledge` (die UI verlangt zusätzlich die Eingabe des Firewall-Namens). Dann:
Konfig-Backup speichern (schlägt es fehl, wird nichts geändert), schreiben,
anwenden (`reconfigure`), zurücklesen und mit dem Gewünschten vergleichen. Jeder
Schritt landet im Audit-Log (`kind=network`: `started`, `applied`, `unverified`,
`apply_uncertain`, `rejected`, `aborted`).
3. Nur Felder aus einer festen Liste je Objektart werden akzeptiert (`FIELDS`),
alles andere ergibt 422. Auswahllisten im Formular kommen von der Firewall
selbst (`GET …/network/template/{kind}`): OPNsense bietet z. B. für eine Route
nur die Gateways an, die es für gültig hält.
**Schutz vor dem Kappen der eigenen Verbindung:** OPNexus ermittelt aus der
Schnittstellenliste der Firewall, über welche Schnittstelle sie unter der
hinterlegten Adresse erreichbar ist (`management_device`). Bestätigungspflichtig
sind: Änderung oder Löschen dieser Schnittstelle, ihres VLANs oder ihres Gateways,
Standard-Gateway ändern/löschen, eine Standardroute, eine Route, die die
Firewall-Adresse abdeckt, und jede Änderung bestehender Objekte, wenn sich der
Verbindungsweg nicht bestimmen lässt (NAT, CARP-Adresse). **Es gibt keinen
automatischen Rollback** (anders als bei Regeln): OPNexus kann eine Änderung nicht
zurücknehmen, die seine eigene Verbindung trennt. Der Rückweg ist das Backup vor
der Änderung. DNS- und DHCP-Daten können die Verbindung nicht kappen und lösen
diesen Schutz nicht aus.
**Schnittstellen-Zuweisung** (`/interfaces/assignment/*`): zweistufig. `set`/`add`/`del`
merken die Änderung nur vor (Datei auf der Firewall), erst `reconfigure` wendet sie
an. Deshalb blockiert OPNexus eine neue Änderung, solange dort schon etwas
vorgemerkt ist (`interface_changes_pending`), sonst würde das Anwenden fremde
Änderungen mitnehmen. Beim Löschen entfernt OPNsense selbst alle Regeln und NAT-
Einträge, die die Schnittstelle nutzen; die Vorschau listet sie. Gesperrte
Schnittstellen (`lock`) lassen sich nicht löschen, das sagt die Vorschau vorab.
Beschreibungen dürfen nur Buchstaben, Ziffern und Unterstriche enthalten.
**Die API kann die IP-Konfiguration erst ab OPNsense 26.7.6**; auf 26.7.1 liefert
die Zuweisungsliste nur Name und Gerät. OPNexus erkennt das an den Daten
(`can_configure_interfaces`) und sperrt die Änderung mit Begründung.
**Geheimnisse:** `vip_settings` liefert das CARP-Passwort im Klartext, Kea-Subnetze
den DDNS-Schlüssel. Beides wird nie zurückgegeben, gespeichert oder auditiert: Das
Inventar wählt Felder namentlich aus, der „Vorher“-Zustand wird auf die erlaubten
Felder begrenzt, ein neues CARP-Passwort geht nur beim Ausführen an die Firewall.
**Von der Firewall übernehmen** (`/rules` → „Von Firewall übernehmen“,
`services/rule_adopt_service.py`): Regeln, die von Hand in OPNsense angelegt wurden,
werden als verwaltete Regel mit der vorhandenen UUID erfasst. Die Übernahme liest
die Regel frisch von der Firewall und schreibt nichts dorthin; erst die erste
Änderung läuft durch den normalen Ablauf (Vorschau, Push, Bestätigung, Auto-Revert).
Nicht übernommen werden Regeln, die das Modell verfälschen würde: invertierte
Schnittstelle/Quelle/Ziel, mehrere oder keine Schnittstelle, „Empfangen auf“, keine
Quick-Regel, Richtung weder ein noch aus, IPv4+IPv6 in einer Regel, fehlende
Beschreibung, Systemregeln. Die Gründe zeigt die Oberfläche. Regeln haben jetzt ein
Protokollieren-Flag (`log`, Migration 0003), weil jeder Push bisher fest `log=0`
schrieb. Felder, die OPNexus nicht kennt (Gateway, Zustandsoptionen, Zeitpläne, …),
bleiben unverändert, denn `setRule` überschreibt nur mitgesendete Felder (live
bestätigt).
**Verifikation 2026-10-09:** Endpunkte gegen die Core-Quellen geprüft und live auf
drei Test-Firewalls (test-fw-1/-2 mit 26.7.6 als HA-Paar, test-fw-3 mit 26.7.1_1):
je anlegen, ändern, löschen mit Backup, Apply und Rücklesen für VLAN, virtuelle IP,
Gateway, Schnittstellen-Zuweisung inkl. IP, Unbound-Eintrag, dnsmasq-Host,
Kea-Subnetz mit verschachtelten Optionen und Kea-Reservierung; Übernahme einer
Handregel mit Gateway und `log=1`, danach Bearbeiten und Ausrollen ohne Verlust.
Der Guard verweigerte das Löschen des Standard-Gateways und die Änderung der
WAN-Schnittstelle ohne Bestätigung. Browser (1440 px und 375 px, keine
Konsolenfehler, kein Overflow): Anlegen und Löschen von VLAN, Schnittstelle und
DNS-Eintrag über die Oberfläche. Der Live-Test fand drei Fehler, die Mocks nicht
zeigten (Gateway-`priority` als Zahl, Routen-Feld `enabled` statt `disabled`,
Beschreibungsmuster der Schnittstelle), der Browser-Test einen weiteren (Dialog
verschwand nach dem Neuladen). 534 Backend-Tests grün.
**Weitere Verifikation (zweiter Durchlauf):** Der Bestätigungs-Pfad wurde live
ausgeführt: Beschreibung der WAN-Schnittstelle mit Bestätigung geändert, und die
WAN-IP von test-fw-2 auf eine freie Adresse im selben Netz gesetzt, womit OPNexus
den Weg zur Firewall verliert (Ergebnis „angewendet, nicht prüfbar“, Audit
`unverified`, Backup vorhanden). Wiederherstellung: neue Adresse unter Hosts ›
Bearbeiten eintragen, WAN-IP über die Engine zurücksetzen, Adresse zurücksetzen.
Außerdem: Routen-Lebenszyklus (aktivieren/deaktivieren), CARP-VIP auf dem HA-Paar
(Backup zuerst, dann Master; Advskew ändern), Übernahme von Regeln mit Alias-Quelle,
IPv6 und Block/Log, Ablehnung einer Regel mit invertierter Quelle, Auto-Revert einer
unbestätigten Änderung an einer übernommenen Regel (Status `reverted`, ursprüngliche
Beschreibung und `log=1` zurück), Übernahme auf beiden Knoten des HA-Paars,
Kea-Reservierung samt Subnetz, Unbound-Eintrag mit echter DNS-Abfrage (NXDOMAIN →
Antwort → NXDOMAIN). Browser: Anlegen, Bearbeiten und Löschen für VIP, Gateway, Route,
dnsmasq-Host, Kea-Subnetz und -Reservierung, Übernahme per Dialog, Nur-Lesen-Rolle
(alle Aktions-Buttons gesperrt), Englisch und Dark Mode ohne deutsche Reste,
axe-core-Prüfung der sieben Netzwerkseiten. Die Migration 0003 lief auf einer Kopie
der Prod-Datenbank (ohne Log-Tabelle): 0002 → 0003, bestehende Regeln `log=false`.
Lesend gegen die Prod-Firewalls (26.7.5): Inventar und Erkennung der
Verwaltungs-Schnittstelle (`vlan0110`) stimmen. **Nicht getestet:** die tatsächliche
Vergabe von DHCP-Leases (Kea ist auf den Test-Firewalls deaktiviert).
**Bekannte Grenzen:** Übernommene Regeln werden je Firewall einzeln übernommen. Wer
eine übernommene Regel zusätzlich auf einen weiteren Host als Ziel setzt, legt dort
eine zweite, doppelte Regel an (die Vorschau zeigt „create“). Keine Multi-Host-Vorlagen für Netzwerkobjekte, kein
Rolling-Apply auf CARP-Paaren (die Vorschau weist auf den Partner hin), keine
LAGG/Bridge/GRE/VXLAN-Verwaltung, keine dnsmasq-Bereiche, keine Kea-DDNS-Felder,
Gateway-Gruppen nicht enthalten.
## Definition of Done — Stand
- [x] `docker compose up -d --build` startet DB, Backend, Frontend
+5
View File
@@ -313,6 +313,7 @@ export default function HostsPage() {
function EditHostModal({ host, hierarchyGroups, onClose, onSaved }: { host: Host; hierarchyGroups: PolicyHierarchyGroup[]; onClose: () => void; onSaved: () => void }) {
const { t, errorMessage } = useLanguage();
const [name, setName] = useState(host.name);
const [baseUrl, setBaseUrl] = useState(host.base_url);
const [clusterName, setClusterName] = useState(host.cluster_name ?? "");
const [hierarchyGroupId, setHierarchyGroupId] = useState(host.policy_hierarchy_group_id ?? "");
const [pin, setPin] = useState<TlsPinValue>({ pem: null, insecure: !host.verify_tls, clear: false });
@@ -326,6 +327,7 @@ function EditHostModal({ host, hierarchyGroups, onClose, onSaved }: { host: Host
setError(null);
try {
const payload: HostUpdate = { name, verify_tls: !pin.insecure, enabled };
if (baseUrl.trim() !== host.base_url) payload.base_url = baseUrl.trim();
if (pin.pem) payload.tls_ca_pem = pin.pem;
if (pin.clear) payload.clear_tls_pin = true;
if (clusterName.trim()) {
@@ -353,6 +355,9 @@ function EditHostModal({ host, hierarchyGroups, onClose, onSaved }: { host: Host
<FieldGroup label="Name">
<Input required value={name} onChange={(e) => setName(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("hosts.baseUrl")} hint={t("hosts.baseUrlEditHint")}>
<Input required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://192.168.1.1" />
</FieldGroup>
<FieldGroup label={t("hosts.clusterOptional")}>
<Input
value={clusterName}
+523
View File
@@ -0,0 +1,523 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { Badge, BadgeTone } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { Skeleton } from "@/components/ui/Skeleton";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { NetworkChangeModal } from "@/components/NetworkChangeModal";
import { Button } from "@/components/ui/Button";
import { api, Host, HostNetwork, NetworkAction, NetworkKind } from "@/lib/api";
import { useReadOnly } from "@/lib/useReadOnly";
import { AlertTriangle, Cable, Pencil, Plus, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
type Section = "interfaces" | "vlans" | "vips" | "gateways" | "routes" | "dns" | "dhcp";
type Change = { kind: NetworkKind; action: NetworkAction; uuid?: string; preset?: Record<string, string> };
type OpenChange = (change: Change) => void;
const HOST_KEY = "opnexus-network-host";
const SECTION_KIND: Partial<Record<Section, NetworkKind>> = { interfaces: "interface", vlans: "vlan", vips: "vip", gateways: "gateway", routes: "route", dns: "dns_host" };
function statusTone(status: string): BadgeTone {
const s = status.toLowerCase();
if (s === "up" || s === "online") return "success";
if (s === "down" || s === "offline") return "danger";
return "neutral";
}
export default function NetworkPage() {
const { t, errorMessage } = useLanguage();
const [hosts, setHosts] = useState<Host[] | null>(null);
const [hostId, setHostId] = useState<string>("");
const [network, setNetwork] = useState<HostNetwork | null>(null);
const [error, setError] = useState<string | null>(null);
// The sub-navigation in the header picks the section; the path is the single source of truth.
const { tab } = useParams<{ tab: string }>();
const section: Section = (["interfaces", "vlans", "vips", "gateways", "routes", "dns", "dhcp"].includes(tab) ? tab : "interfaces") as Section;
const [change, setChange] = useState<Change | null>(null);
const readOnly = useReadOnly();
const [reloadKey, setReloadKey] = useState(0);
const shownHost = useRef("");
useEffect(() => {
api
.listHosts()
.then((list) => {
setHosts(list);
// The chosen firewall survives switching between the sub-pages.
let remembered = "";
try { remembered = sessionStorage.getItem(HOST_KEY) ?? ""; } catch { /* storage may be blocked */ }
setHostId(list.find((h) => h.id === remembered)?.id ?? list[0]?.id ?? "");
})
.catch((e) => setError(errorMessage(e, "network.loadFailed")));
}, [errorMessage]);
useEffect(() => {
if (!hostId) return;
let cancelled = false;
setError(null);
// Switching host clears the view; a reload after a change keeps it so an open dialog is not torn down.
if (shownHost.current !== hostId) {
setNetwork(null);
shownHost.current = hostId;
}
api
.getHostNetwork(hostId)
.then((data) => !cancelled && setNetwork(data))
.catch((e) => !cancelled && setError(errorMessage(e, "network.loadFailed")))
return () => {
cancelled = true;
};
}, [hostId, errorMessage, reloadKey]);
if (hosts && hosts.length === 0) {
return (
<div>
<h1 className="mb-6 text-2xl font-semibold text-slate-900">{t(`nav.items.network-${section}`)}</h1>
<EmptyState icon={Cable} title={t("drift.noHosts")} />
</div>
);
}
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900">{t(`nav.items.network-${section}`)}</h1>
{hosts && (
<label className="flex items-center gap-2 text-sm text-slate-600">
{t("network.host")}
<select
value={hostId}
onChange={(e) => {
setHostId(e.target.value);
try { sessionStorage.setItem(HOST_KEY, e.target.value); } catch { /* ignore */ }
}}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm"
>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</label>
)}
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
{!network && !error && <Skeleton className="h-64 w-full" />}
{network && (
<>
<Notices network={network} />
{SECTION_KIND[section] && (section !== "interfaces" || network.can_configure_interfaces) && (
<fieldset disabled={readOnly} className="contents">
<div className="mb-3 flex justify-end">
<Button variant="outline" onClick={() => setChange({ kind: SECTION_KIND[section]!, action: "add" })}>
<Plus size={16} />
{t("network.rowAdd")}
</Button>
</div>
</fieldset>
)}
<Card className="overflow-x-auto">
<fieldset disabled={readOnly} className="contents">
{section === "interfaces" && <InterfacesTable network={network} onChange={setChange} />}
{section === "vlans" && <VlansTable network={network} onChange={setChange} />}
{section === "vips" && <VipsTable network={network} onChange={setChange} />}
{section === "gateways" && <GatewaysTable network={network} onChange={setChange} />}
{section === "routes" && <RoutesTable network={network} onChange={setChange} />}
{section === "dns" && <DnsTable network={network} onChange={setChange} />}
{section === "dhcp" && <DhcpSections network={network} onChange={setChange} />}
</fieldset>
</Card>
</>
)}
{change && (
<NetworkChangeModal
hostId={hostId}
hostName={hosts?.find((h) => h.id === hostId)?.name ?? ""}
kind={change.kind}
action={change.action}
uuid={change.uuid}
preset={change.preset}
onClose={() => setChange(null)}
onDone={() => setReloadKey((k) => k + 1)}
/>
)}
</div>
);
}
function Notices({ network }: { network: HostNetwork }) {
const { t } = useLanguage();
return (
<div className="mb-4 space-y-2">
{network.management_device ? (
<p className="flex items-start gap-2 text-sm text-slate-600">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" aria-hidden />
<span>{t("network.managementNote", { device: network.management_device })}</span>
</p>
) : (
<p className="text-sm text-slate-500">{t("network.managementUnknown")}</p>
)}
{network.unavailable.length > 0 && (
<p className="text-sm text-amber-700">
{t("network.unavailable", { sections: network.unavailable.map((s) => t(`network.sections.${s}`)).join(", ") })}
</p>
)}
{!network.can_configure_interfaces && (
<p className="text-sm text-slate-500">{t("network.cannotConfigure")}</p>
)}
</div>
);
}
function RowActions({ kind, uuid, onChange }: { kind: NetworkKind; uuid: string; onChange: OpenChange }) {
const { t } = useLanguage();
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" aria-label={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid })}>
<Pencil size={14} />
</Button>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid })}>
<Trash2 size={14} />
</Button>
</div>
);
}
function Empty({ text }: { text: string }) {
return <p className="px-5 py-6 text-sm text-slate-500">{text}</p>;
}
function InterfacesTable({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
if (network.interfaces.length === 0) return <Empty text={t("network.empty")} />;
return (
<Table>
<THead>
<TRow>
<TH>{t("network.col.device")}</TH>
<TH>{t("network.col.name")}</TH>
<TH>{t("network.col.status")}</TH>
<TH>{t("network.col.addresses")}</TH>
<TH>{t("network.col.type")}</TH>
<TH>{t("network.col.vlan")}</TH>
{network.can_configure_interfaces && <TH><span className="sr-only">{t("common.actions")}</span></TH>}
</TRow>
</THead>
<TBody>
{network.interfaces.map((i) => (
<TRow key={i.device}>
<TD className="font-mono">
{i.device}
{i.is_management && (
<Badge tone="warning" className="ml-2">
{t("network.management")}
</Badge>
)}
</TD>
<TD>
{i.identifier ? `${i.description || i.identifier} (${i.identifier})` : <span className="text-slate-400">{t("network.unassigned")}</span>}
</TD>
<TD>
<Badge tone={statusTone(i.status)}>{i.status}</Badge>
</TD>
<TD className="font-mono text-xs">
{i.addresses.length === 0 ? "–" : i.addresses.map((a) => (
<div key={a.address + (a.vhid ?? "")}>
{a.address}
{a.vhid && (
<span className="ml-2 text-slate-500">
CARP vhid {a.vhid}{a.carp_status ? ` · ${a.carp_status}` : ""}
</span>
)}
</div>
))}
</TD>
<TD>{[i.ipv4_type, i.ipv6_type].filter(Boolean).join(" / ") || "–"}</TD>
<TD>{i.vlan_tag ? `${i.vlan_tag} (${i.vlan_parent ?? "?"})` : "–"}</TD>
{network.can_configure_interfaces && (
<TD>{i.assigned && i.identifier ? <RowActions kind="interface" uuid={i.identifier} onChange={onChange} /> : null}</TD>
)}
</TRow>
))}
</TBody>
</Table>
);
}
function VlansTable({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
if (network.vlans.length === 0) return <Empty text={t("network.empty")} />;
return (
<Table>
<THead>
<TRow>
<TH>{t("network.col.device")}</TH>
<TH>{t("network.col.parent")}</TH>
<TH>{t("network.col.tag")}</TH>
<TH>{t("network.col.pcp")}</TH>
<TH>{t("common.description")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.vlans.map((v) => (
<TRow key={v.uuid}>
<TD className="font-mono">{v.device ?? "–"}</TD>
<TD className="font-mono">{v.parent}</TD>
<TD>{v.tag}</TD>
<TD>{v.pcp ?? "–"}</TD>
<TD>{v.description || "–"}</TD>
<TD><RowActions kind="vlan" uuid={v.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
);
}
function VipsTable({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
if (network.vips.length === 0) return <Empty text={t("network.empty")} />;
return (
<Table>
<THead>
<TRow>
<TH>{t("network.col.address")}</TH>
<TH>{t("network.col.interface")}</TH>
<TH>{t("network.col.mode")}</TH>
<TH>VHID</TH>
<TH>{t("network.col.advskew")}</TH>
<TH>{t("common.description")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.vips.map((v) => (
<TRow key={v.uuid}>
<TD className="font-mono">{v.address}</TD>
<TD>{v.interface_label}</TD>
<TD>{v.mode.toUpperCase()}</TD>
<TD>{v.vhid ?? "–"}</TD>
<TD>{v.advskew ?? "–"}</TD>
<TD>{v.description || "–"}</TD>
<TD><RowActions kind="vip" uuid={v.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
);
}
function GatewaysTable({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
if (network.gateways.length === 0) return <Empty text={t("network.empty")} />;
return (
<Table>
<THead>
<TRow>
<TH>{t("network.col.name")}</TH>
<TH>{t("network.col.interface")}</TH>
<TH>{t("network.col.address")}</TH>
<TH>{t("network.col.priority")}</TH>
<TH>{t("network.col.status")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.gateways.map((g) => (
<TRow key={g.uuid}>
<TD>
{g.name}
{g.is_default && (
<Badge tone="info" className="ml-2">
{t("network.default")}
</Badge>
)}
{g.disabled && (
<Badge tone="neutral" className="ml-2">
{t("common.statusDisabled")}
</Badge>
)}
</TD>
<TD>{g.interface_label}</TD>
<TD className="font-mono">{g.address || "–"}</TD>
<TD>{g.priority ?? "–"}</TD>
<TD>
<Badge tone={statusTone(g.status)}>{g.status || "–"}</Badge>
</TD>
<TD><RowActions kind="gateway" uuid={g.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
);
}
function RoutesTable({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
if (network.routes.length === 0) return <Empty text={t("network.empty")} />;
return (
<Table>
<THead>
<TRow>
<TH>{t("network.col.network")}</TH>
<TH>{t("network.col.gateway")}</TH>
<TH>{t("common.description")}</TH>
<TH>{t("network.col.status")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.routes.map((r) => (
<TRow key={r.uuid}>
<TD className="font-mono">{r.network}</TD>
<TD>{r.gateway}</TD>
<TD>{r.description || "–"}</TD>
<TD>
<Badge tone={r.disabled ? "neutral" : "success"}>{r.disabled ? t("common.statusDisabled") : t("network.active")}</Badge>
</TD>
<TD><RowActions kind="route" uuid={r.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
);
}
function DnsTable({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
if (network.dns_hosts.length === 0) return <Empty text={t("network.empty")} />;
return (
<Table>
<THead>
<TRow>
<TH>{t("network.field.hostname")}</TH>
<TH>{t("network.field.domain")}</TH>
<TH>{t("network.field.rr")}</TH>
<TH>{t("network.field.server")}</TH>
<TH>{t("common.description")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.dns_hosts.map((d) => (
<TRow key={d.uuid}>
<TD>
{d.hostname}
{!d.enabled && <Badge tone="neutral" className="ml-2">{t("common.statusDisabled")}</Badge>}
</TD>
<TD>{d.domain}</TD>
<TD>{d.record_type}</TD>
<TD className="font-mono">{d.server || "–"}</TD>
<TD>{d.description || "–"}</TD>
<TD><RowActions kind="dns_host" uuid={d.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
);
}
function SubHeader({ title, kind, onChange }: { title: string; kind: NetworkKind; onChange: OpenChange }) {
const { t } = useLanguage();
return (
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
<h2 className="text-sm font-semibold text-slate-700">{title}</h2>
<Button variant="outline" onClick={() => onChange({ kind, action: "add" })}>
<Plus size={16} />
{t("network.rowAdd")}
</Button>
</div>
);
}
function DhcpSections({ network, onChange }: { network: HostNetwork; onChange: OpenChange }) {
const { t } = useLanguage();
return (
<div>
<SubHeader title={t("network.dhcp.dnsmasq")} kind="dhcp_host" onChange={onChange} />
{network.dhcp_hosts.length === 0 ? <Empty text={t("network.empty")} /> : (
<Table>
<THead>
<TRow>
<TH>{t("network.field.host")}</TH>
<TH>{t("network.field.ip")}</TH>
<TH>{t("network.field.hwaddr")}</TH>
<TH>{t("common.description")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.dhcp_hosts.map((h) => (
<TRow key={h.uuid}>
<TD>{h.host}{h.domain ? `.${h.domain}` : ""}</TD>
<TD className="font-mono">{h.ip || "–"}</TD>
<TD className="font-mono text-xs">{h.hwaddr || "–"}</TD>
<TD>{h.description || "–"}</TD>
<TD><RowActions kind="dhcp_host" uuid={h.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
)}
<SubHeader title={t("network.dhcp.keaSubnets")} kind="kea_subnet" onChange={onChange} />
{network.kea_subnets.length === 0 ? <Empty text={t("network.empty")} /> : (
<Table>
<THead>
<TRow>
<TH>{t("network.field.subnet")}</TH>
<TH>{t("network.field.pools")}</TH>
<TH>{t("network.field.option_data.routers")}</TH>
<TH>{t("common.description")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.kea_subnets.map((s) => (
<TRow key={s.uuid}>
<TD className="font-mono">{s.subnet}</TD>
<TD className="font-mono text-xs">{s.pools || "–"}</TD>
<TD className="font-mono">{s.routers || "–"}</TD>
<TD>{s.description || "–"}</TD>
<TD><RowActions kind="kea_subnet" uuid={s.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
)}
<SubHeader title={t("network.dhcp.keaReservations")} kind="kea_reservation" onChange={onChange} />
{network.kea_reservations.length === 0 ? <Empty text={t("network.empty")} /> : (
<Table>
<THead>
<TRow>
<TH>{t("network.field.ip_address")}</TH>
<TH>{t("network.field.hw_address")}</TH>
<TH>{t("network.field.hostname")}</TH>
<TH>{t("network.field.subnet")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{network.kea_reservations.map((r) => (
<TRow key={r.uuid}>
<TD className="font-mono">{r.ip_address}</TD>
<TD className="font-mono text-xs">{r.hw_address || "–"}</TD>
<TD>{r.hostname || "–"}</TD>
<TD className="font-mono">{r.subnet}</TD>
<TD><RowActions kind="kea_reservation" uuid={r.uuid} onChange={onChange} /></TD>
</TRow>
))}
</TBody>
</Table>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function NetworkIndex() {
redirect("/network/interfaces");
}
+19 -5
View File
@@ -14,6 +14,7 @@ import { Modal } from "@/components/ui/Modal";
import { Tabs } from "@/components/ui/Tabs";
import { useToast } from "@/components/ui/Toast";
import { LiveRulesDrift } from "@/components/LiveRulesDrift";
import { AdoptRulesModal } from "@/components/AdoptRulesModal";
import { useReadOnly } from "@/lib/useReadOnly";
import {
api,
@@ -32,6 +33,7 @@ import {
AlertTriangle,
CheckCircle2,
Clock,
Download,
Plus,
Pencil,
History,
@@ -105,6 +107,7 @@ const emptyForm: RuleCreate = {
destination_net: "any",
destination_port: null,
enabled: true,
log: false,
target_host_ids: [],
target_group_ids: [],
};
@@ -128,6 +131,7 @@ function RulesContent() {
const [hosts, setHosts] = useState<Host[]>([]);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [showAdopt, setShowAdopt] = useState(false);
const [form, setForm] = useState<RuleCreate>(emptyForm);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
@@ -395,10 +399,16 @@ function RulesContent() {
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900 flex items-center gap-2">{selectedGroup ? t("rules.groupTitle", { name: selectedGroup.name }) : t("rules.title")}<InfoTip text={t("rules.subtitle")} size={18} /></h1>
{activeTab === "managed" && (
<Button onClick={() => openEditor(null)} disabled={readOnly}>
<Plus size={16} />
{t("rules.new")}
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowAdopt(true)} disabled={readOnly}>
<Download size={16} />
{t("rules.adopt.button")}
</Button>
<Button onClick={() => openEditor(null)} disabled={readOnly}>
<Plus size={16} />
{t("rules.new")}
</Button>
</div>
)}
</div>
@@ -504,6 +514,7 @@ function RulesContent() {
)}
<fieldset disabled={readOnly} className="contents">
{showAdopt && <AdoptRulesModal hosts={hosts} onClose={() => setShowAdopt(false)} onAdopted={load} />}
{review && <RuleReview rule={review.rule} mode={review.mode} onClose={() => setReview(null)} onDeployed={() => { setReview(null); load(); }} onChanged={load} onRestore={snapshot => openEditor(review.rule, false, snapshot)} />}
{showModal && (
<Modal title={editing ? t("rules.editDraft") : t("rules.new")} onClose={() => setShowModal(false)}>
@@ -629,7 +640,10 @@ function RulesContent() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label={t("nat.ipVersion")}><Select value={form.ipprotocol} onChange={e => setForm({ ...form, ipprotocol: e.target.value })}><option value="inet">IPv4</option><option value="inet6">IPv6</option></Select></FieldGroup>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={e => setForm({ ...form, enabled: e.target.checked })} />{t("rules.active")}</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={e => setForm({ ...form, enabled: e.target.checked })} />{t("rules.active")}</label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={!!form.log} onChange={e => setForm({ ...form, log: e.target.checked })} />{t("rules.logPackets")}</label>
</div>
</div>
<FieldGroup label={t("rules.deviceGroups")}>
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { useLanguage } from "@/components/LanguageProvider";
import { api, Host, UnmanagedRule } from "@/lib/api";
import { useEffect, useState } from "react";
interface Props {
hosts: Host[];
onClose: () => void;
onAdopted: () => void;
}
function summary(rule: UnmanagedRule): string {
const port = (value: string) => (value ? `:${value}` : "");
return `${rule.protocol} ${rule.source_net}${port(rule.source_port)} → ${rule.destination_net}${port(rule.destination_port)}`;
}
/**
* Lists the rules that exist on a firewall but are not managed by OPNexus yet and
* lets the operator take them over. Taking over only records the rule — nothing is
* written to the firewall until the rule is changed and pushed the normal way.
*/
export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const enabledHosts = hosts.filter((h) => h.enabled);
// Derived rather than initialised once: the host list may still be loading when the dialog opens.
const [chosenHostId, setHostId] = useState("");
const hostId = chosenHostId || enabledHosts[0]?.id || "";
const [rules, setRules] = useState<UnmanagedRule[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
function load(id: string) {
setRules(null);
setError(null);
api
.listUnmanagedRules(id)
.then(setRules)
.catch((e) => {
setRules([]);
setError(errorMessage(e, "rules.adopt.loadFailed"));
});
}
useEffect(() => {
if (hostId) load(hostId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
async function adopt(rule: UnmanagedRule) {
setBusy(rule.uuid);
try {
await api.adoptRule(hostId, rule.uuid);
toast.show(t("rules.adopt.adopted", { name: rule.description }), "success");
onAdopted();
load(hostId);
} catch (e) {
toast.show(errorMessage(e, "rules.adopt.failed"), "danger");
} finally {
setBusy(null);
}
}
return (
<Modal title={t("rules.adopt.title")} onClose={onClose}>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
<p className="text-sm text-slate-600">{t("rules.adopt.intro")}</p>
<label className="flex items-center gap-2 text-sm text-slate-600">
{t("rules.adopt.host")}
<select
value={hostId}
onChange={(e) => setHostId(e.target.value)}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm"
>
{enabledHosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</label>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
{rules === null && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{rules !== null && rules.length === 0 && !error && (
<p className="text-sm text-slate-500">{t("rules.adopt.none")}</p>
)}
<ul className="space-y-2">
{(rules ?? []).map((rule) => (
<li key={rule.uuid} className="rounded-lg border border-slate-200 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-slate-900">{rule.description}</p>
<p className="text-xs text-slate-500">
{rule.interface_label} · {rule.direction} · {rule.action} · {summary(rule)}
{!rule.enabled ? ` · ${t("common.statusDisabled")}` : ""}
{rule.log ? ` · ${t("rules.logPackets")}` : ""}
</p>
</div>
{rule.adoptable ? (
<Button variant="outline" onClick={() => adopt(rule)} disabled={busy !== null} loading={busy === rule.uuid}>
{t("rules.adopt.action")}
</Button>
) : (
<Badge tone="neutral">{t("rules.adopt.notPossible")}</Badge>
)}
</div>
{!rule.adoptable && (
<p className="mt-2 text-xs text-slate-500">
{rule.reasons.map((code) => t(`rules.adopt.reasons.${code}`)).join(" · ")}
</p>
)}
</li>
))}
</ul>
<div className="flex justify-end">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.close")}
</Button>
</div>
</div>
</Modal>
);
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { APP_CODENAME, APP_VERSION } from "@/lib/version";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useState } from "react";
import { navGroups } from "@/components/Nav";
import { navGroups, navLabelKey } from "@/components/Nav";
/**
* Single-role auth guard — there's exactly one protected "area" (everything
@@ -41,7 +41,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
// the title (and its change) to know a navigation happened and where they are. A rendered
// <title> is hoisted into <head> by React 19 and wins over the static metadata one.
const item = navGroups.flatMap((g) => g.items).find((i) => pathname?.startsWith(i.href));
const pageName = item ? t(`nav.items.${item.href.slice(1)}`) : pathname === "/login" ? t("login.signIn") : pathname?.startsWith("/about") ? t("settings.about") : null;
const pageName = item ? t(navLabelKey(item.href)) : pathname === "/login" ? t("login.signIn") : pathname?.startsWith("/about") ? t("settings.about") : null;
const title = <title>{pageName ? pageName.includes("OPNexus") ? pageName : `${pageName} · OPNexus` : "OPNexus"}</title>;
if (pathname === "/login") return <>{title}{children}</>;
+2 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { navGroups } from "@/components/Nav";
import { navGroups, navLabelKey } from "@/components/Nav";
import { useLanguage } from "@/components/LanguageProvider";
import { api, Host, SearchHit } from "@/lib/api";
import { OPEN_COMMAND_PALETTE_EVENT } from "@/lib/events";
@@ -88,7 +88,7 @@ export function CommandPalette() {
() =>
navGroups.flatMap((group) =>
group.items.map((item) => {
const label = t(`nav.items.${item.href.slice(1)}`);
const label = t(navLabelKey(item.href));
const groupLabel = t(`nav.groups.${group.key}`);
return {
key: `nav:${item.href}`,
+28 -5
View File
@@ -6,6 +6,7 @@ import clsx from "clsx";
import {
Cable,
FolderTree,
Globe,
GitBranch,
KeyRound,
LayoutDashboard,
@@ -13,8 +14,11 @@ import {
LogOut,
Network,
RefreshCw,
Route,
Router,
ScrollText,
Search,
Server,
Settings,
Shield,
ShieldCheck,
@@ -59,6 +63,20 @@ export const navGroups: NavGroup[] = [
{ href: "/nat", label: "NAT", icon: Network, keywords: ["Port Forward", "Portweiterleitung", "1:1 NAT", "Outbound NAT", "Masquerade"] },
],
},
{
key: "network",
label: "Netzwerk",
icon: Cable,
items: [
{ href: "/network/interfaces", label: "Schnittstellen", icon: Cable, keywords: ["Interfaces", "IP-Adressen", "WAN", "LAN", "Zuweisung"] },
{ href: "/network/vlans", label: "VLANs", icon: Layers, keywords: ["VLAN-Tag", "802.1q"] },
{ href: "/network/vips", label: "Virtuelle IPs", icon: Tags, keywords: ["CARP-VIP", "IP-Alias", "VHID"] },
{ href: "/network/gateways", label: "Gateways", icon: Router, keywords: ["Standard-Gateway", "Uplink"] },
{ href: "/network/routes", label: "Routen", icon: Route, keywords: ["Routing", "Statische Routen"] },
{ href: "/network/dns", label: "DNS", icon: Globe, keywords: ["Unbound", "Host-Override", "Hostnamen"] },
{ href: "/network/dhcp", label: "DHCP", icon: Server, keywords: ["Kea", "dnsmasq", "Reservierung", "Statische Zuweisung", "Subnetz"] },
],
},
{
key: "vpn",
label: "VPN",
@@ -127,6 +145,11 @@ export const navGroups: NavGroup[] = [
},
];
/** i18n key for a nav item: "/network/vlans" -> nav.items.network-vlans (a slash would read as a nested path). */
export function navLabelKey(href: string): string {
return `nav.items.${href.slice(1).replace(/\//g, "-")}`;
}
export function Nav() {
const { t } = useLanguage();
const pathname = usePathname();
@@ -194,10 +217,10 @@ export function Nav() {
<button
onClick={handleLogout}
title={t("nav.logout")}
className="flex shrink-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 sm:px-3"
aria-label={t("nav.logout")}
className="flex shrink-0 items-center rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<LogOut size={16} aria-hidden="true" />
<span className="hidden sm:inline">{t("nav.logout")}</span>
</button>
</div>
{activeGroup && activeGroup.items.length > 1 && (
@@ -209,8 +232,8 @@ export function Nav() {
<Link
key={href}
href={href}
title={t(`nav.items.${href.slice(1)}`)}
aria-label={t(`nav.items.${href.slice(1)}`)}
title={t(navLabelKey(href))}
aria-label={t(navLabelKey(href))}
aria-current={active ? "page" : undefined}
className={clsx(
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-colors",
@@ -218,7 +241,7 @@ export function Nav() {
)}
>
<Icon size={14} aria-hidden="true" />
<span className="hidden sm:inline">{t(`nav.items.${href.slice(1)}`)}</span>
<span className="hidden sm:inline">{t(navLabelKey(href))}</span>
</Link>
);
})}
+374
View File
@@ -0,0 +1,374 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import {
api,
NetworkAction,
NetworkChangePreview,
NetworkChangeResult,
NetworkKind,
NetworkTemplate,
} from "@/lib/api";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
type FieldType = "text" | "select" | "check" | "password";
interface FieldDef {
name: string;
type: FieldType;
// Only shown for these values of another field (e.g. CARP-only settings).
when?: { field: string; value: string | string[] };
// i18n key of a short note shown next to the label
hint?: string;
// The firewall only accepts values matching this; checked here so the error comes before the apply step.
pattern?: RegExp;
}
const FIELDS: Record<NetworkKind, FieldDef[]> = {
vlan: [
{ name: "if", type: "select" },
{ name: "tag", type: "text" },
{ name: "pcp", type: "select" },
{ name: "descr", type: "text" },
],
vip: [
{ name: "interface", type: "select" },
{ name: "mode", type: "select" },
{ name: "network", type: "text" },
{ name: "vhid", type: "text", when: { field: "mode", value: "carp" } },
{ name: "advbase", type: "text", when: { field: "mode", value: "carp" } },
{ name: "advskew", type: "text", when: { field: "mode", value: "carp" } },
{ name: "password", type: "password", when: { field: "mode", value: "carp" } },
{ name: "descr", type: "text" },
],
gateway: [
{ name: "name", type: "text" },
{ name: "interface", type: "select" },
{ name: "ipprotocol", type: "select" },
{ name: "gateway", type: "text" },
{ name: "defaultgw", type: "check" },
{ name: "monitor_disable", type: "check" },
{ name: "priority", type: "text" },
{ name: "disabled", type: "check" },
{ name: "descr", type: "text" },
],
interface: [
{ name: "if", type: "select" },
{ name: "descr", type: "text", hint: "network.hint.interfaceDescr", pattern: /^[A-Za-z0-9_]{1,255}$/ },
{ name: "enable", type: "check" },
{ name: "type4", type: "select" },
{ name: "ipaddr", type: "text", when: { field: "type4", value: "staticv4" } },
{ name: "gateway", type: "select", when: { field: "type4", value: "staticv4" } },
{ name: "type6", type: "select" },
{ name: "ipaddrv6", type: "text", when: { field: "type6", value: "staticv6" } },
{ name: "gatewayv6", type: "select", when: { field: "type6", value: "staticv6" } },
{ name: "mtu", type: "text" },
{ name: "blockpriv", type: "check" },
{ name: "blockbogons", type: "check" },
{ name: "lock", type: "check" },
],
dns_host: [
{ name: "enabled", type: "check" },
{ name: "hostname", type: "text" },
{ name: "domain", type: "text" },
{ name: "rr", type: "select" },
{ name: "server", type: "text", when: { field: "rr", value: ["A", "AAAA"] } },
{ name: "mx", type: "text", when: { field: "rr", value: "MX" } },
{ name: "mxprio", type: "text", when: { field: "rr", value: "MX" } },
{ name: "addptr", type: "check" },
{ name: "description", type: "text" },
],
dhcp_host: [
{ name: "host", type: "text" },
{ name: "domain", type: "text" },
{ name: "ip", type: "text" },
{ name: "hwaddr", type: "text" },
{ name: "client_id", type: "text" },
{ name: "lease_time", type: "text" },
{ name: "local", type: "check" },
{ name: "ignore", type: "check" },
{ name: "descr", type: "text" },
],
kea_subnet: [
{ name: "subnet", type: "text" },
{ name: "pools", type: "text" },
{ name: "option_data.routers", type: "text" },
{ name: "option_data.domain_name_servers", type: "text" },
{ name: "option_data.domain_name", type: "text" },
{ name: "option_data.ntp_servers", type: "text" },
{ name: "valid_lifetime", type: "text" },
{ name: "next_server", type: "text" },
{ name: "description", type: "text" },
],
kea_reservation: [
{ name: "subnet", type: "select" },
{ name: "ip_address", type: "text" },
{ name: "hw_address", type: "text" },
{ name: "client_id", type: "text" },
{ name: "hostname", type: "text" },
{ name: "next_server", type: "text" },
{ name: "description", type: "text" },
],
route: [
{ name: "network", type: "text" },
{ name: "gateway", type: "select" },
{ name: "enabled", type: "check" },
{ name: "descr", type: "text" },
],
};
interface Props {
hostId: string;
hostName: string;
kind: NetworkKind;
action: NetworkAction;
uuid?: string;
/** Values to start a new object with (e.g. the device being assigned). */
preset?: Record<string, string>;
onClose: () => void;
onDone: () => void;
}
type Step = "form" | "review" | "done";
function isVisible(f: FieldDef, values: Record<string, string>): boolean {
if (!f.when) return true;
const wanted = Array.isArray(f.when.value) ? f.when.value : [f.when.value];
return wanted.includes(values[f.when.field] ?? "");
}
export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, preset, onClose, onDone }: Props) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [step, setStep] = useState<Step>(action === "del" ? "review" : "form");
const [template, setTemplate] = useState<NetworkTemplate | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
// What the firewall holds right now (edit only) — the baseline for "what did the user change".
const [initial, setInitial] = useState<Record<string, string>>({});
const [plan, setPlan] = useState<NetworkChangePreview | null>(null);
const [result, setResult] = useState<NetworkChangeResult | null>(null);
const [acknowledged, setAcknowledged] = useState(false);
const [typedHost, setTypedHost] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The form starts from what the firewall itself offers for a new object (defaults + valid choices).
useEffect(() => {
if (action === "del") return;
Promise.all([
api.getNetworkTemplate(hostId, kind),
action === "set" && uuid ? api.getNetworkItem(hostId, kind, uuid) : Promise.resolve({} as Record<string, string>),
])
.then(([tpl, current]) => {
setTemplate(tpl);
const base = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, v.value]));
setInitial(current);
setValues({ ...base, ...(preset ?? {}), ...current });
})
.catch((e) => setError(errorMessage(e, "network.change.loadFailed")));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, kind, action]);
useEffect(() => {
if (action === "del") void loadPreview({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** For an edit only the fields that really changed are sent. */
function changedFields(): Record<string, string> {
const visible = FIELDS[kind].filter((f) => isVisible(f, values));
const out: Record<string, string> = {};
for (const f of visible) {
const current = values[f.name] ?? "";
if (action === "set" && f.type !== "password" && current === (initial[f.name] ?? "")) continue;
if (f.type === "password" && !current) continue;
if (action === "add" && current === "" && f.type !== "check") continue;
out[f.name] = current;
}
return out;
}
async function loadPreview(fields: Record<string, string>) {
setBusy(true);
setError(null);
try {
setPlan(await api.previewNetworkChange(hostId, { kind, action, uuid, fields }));
setStep("review");
} catch (e) {
setError(errorMessage(e, "network.change.previewFailed"));
} finally {
setBusy(false);
}
}
async function apply() {
if (!plan) return;
setBusy(true);
setError(null);
try {
const fields = action === "del" ? {} : changedFields();
const res = await api.executeNetworkChange(hostId, { kind, action, uuid, fields }, plan.token, acknowledged);
setResult(res);
setStep("done");
toast.show(t("network.change.applied"), "success");
onDone();
} catch (e) {
setError(errorMessage(e, "network.change.applyFailed"));
} finally {
setBusy(false);
}
}
// First visible field whose value the firewall would refuse (only fields the user typed into).
const invalidField = FIELDS[kind].find(
(f) => f.pattern && isVisible(f, values) && (values[f.name] ?? "") !== "" && !f.pattern.test(values[f.name]),
);
const needsTypedHost = !!plan?.requires_acknowledgement;
const canApply = !!plan && plan.can_execute && !busy && (!needsTypedHost || (acknowledged && typedHost === hostName));
return (
<Modal title={t(`network.change.title.${action}`, { kind: t(`network.kinds.${kind}`) })} onClose={onClose}>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
{step === "form" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void loadPreview(changedFields());
}}
>
{!template && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{template &&
FIELDS[kind]
.filter((f) => template[f.name] !== undefined && isVisible(f, values))
.map((f) => (
<FieldGroup key={f.name} label={t(`network.field.${f.name}`)} hint={f.hint ? t(f.hint) : undefined}>
{f.type === "select" ? (
<Select value={values[f.name] ?? ""} onChange={(e) => setValues({ ...values, [f.name]: e.target.value })}>
{(template[f.name].options ?? []).map((o) => (
<option key={o.value} value={o.value}>{o.label || o.value}</option>
))}
</Select>
) : f.type === "check" ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={values[f.name] === "1"}
onChange={(e) => setValues({ ...values, [f.name]: e.target.checked ? "1" : "0" })}
/>
{t(`network.field.${f.name}Hint`)}
</label>
) : (
<Input
type={f.type === "password" ? "password" : "text"}
autoComplete="off"
value={values[f.name] ?? ""}
placeholder={f.type === "password" && action === "set" ? t("network.change.keepUnchanged") : undefined}
onChange={(e) => setValues({ ...values, [f.name]: e.target.value })}
/>
)}
</FieldGroup>
))}
{invalidField && <p role="alert" className="text-sm text-red-600">{t(invalidField.hint ?? "network.change.invalidValue")}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" disabled={!template || busy || !!invalidField} loading={busy}>{t("network.change.toPreview")}</Button>
</div>
</form>
)}
{step === "review" && plan && (
<div className="space-y-4">
{plan.blocks.map((b) => (
<p key={b.code} className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800">
{t(`network.blocks.${b.code}`, b.params)}
</p>
))}
{plan.risks.length > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
<p className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle size={16} aria-hidden /> {t("network.change.riskTitle")}
</p>
<ul className="list-disc space-y-1 pl-5">
{plan.risks.map((r) => <li key={r.code}>{t(`network.risks.${r.code}`, r.params)}</li>)}
</ul>
</div>
)}
{plan.warnings.length > 0 && (
<ul className="list-disc space-y-1 pl-5 text-sm text-slate-600">
{plan.warnings.map((w) => <li key={w.code}>{t(`network.warnings.${w.code}`, w.params)}</li>)}
</ul>
)}
{plan.changes.length === 0 ? (
<p className="text-sm text-slate-500">{t("network.change.noChanges")}</p>
) : (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase text-slate-500">
<tr>
<th className="py-1 pr-3">{t("network.change.col.field")}</th>
<th className="py-1 pr-3">{t("network.change.col.before")}</th>
<th className="py-1">{t("network.change.col.after")}</th>
</tr>
</thead>
<tbody>
{plan.changes.map((c) => (
<tr key={c.field} className="border-t border-slate-100">
<td className="py-1 pr-3">{t(`network.field.${c.field}`)}</td>
<td className="py-1 pr-3 font-mono text-xs text-slate-500">{c.before ?? "–"}</td>
<td className="py-1 font-mono text-xs">{c.after ?? "–"}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-xs text-slate-500">{t("network.change.backupNote")}</p>
{needsTypedHost && (
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" className="mt-1" checked={acknowledged} onChange={(e) => setAcknowledged(e.target.checked)} />
<span>{t("network.change.ack", { host: hostName })}</span>
</label>
<FieldGroup label={t("network.change.typeHost", { host: hostName })}>
<Input value={typedHost} onChange={(e) => setTypedHost(e.target.value)} autoComplete="off" />
</FieldGroup>
</div>
)}
<div className="flex justify-end gap-2">
{action !== "del" && <Button type="button" variant="ghost" onClick={() => setStep("form")}>{t("network.change.back")}</Button>}
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="button" onClick={apply} disabled={!canApply} loading={busy}>{t("network.change.apply")}</Button>
</div>
</div>
)}
{step === "done" && result && (
<div className="space-y-3">
<p className="flex items-center gap-2 text-sm">
<Badge tone={result.verified ? "success" : "warning"}>
{result.verified ? t("network.change.verified") : t("network.change.notVerified")}
</Badge>
</p>
{!result.verified && (
<p className="text-sm text-amber-700">
{result.mismatches.includes("unreadable")
? t("network.change.unreachable", { host: hostName })
: t("network.change.mismatch", { fields: result.mismatches.join(", ") })}
</p>
)}
<p className="text-xs text-slate-500">{t("network.change.backupId", { id: result.backup_id })}</p>
<div className="flex justify-end">
<Button type="button" onClick={onClose}>{t("common.close")}</Button>
</div>
</div>
)}
</div>
</Modal>
);
}
+198
View File
@@ -144,6 +144,7 @@ export interface TlsProbe {
export interface HostUpdate {
name?: string;
base_url?: string;
verify_tls?: boolean;
tls_ca_pem?: string | null;
clear_tls_pin?: boolean;
@@ -154,6 +155,171 @@ export interface HostUpdate {
clear_policy_hierarchy_group_id?: boolean;
}
export interface NetAddress {
address: string;
vhid: string | null;
carp_status: string | null;
}
export interface NetInterface {
device: string;
identifier: string | null;
description: string;
status: string;
enabled: boolean;
is_physical: boolean;
mtu: string | null;
mac: string | null;
media: string | null;
vlan_tag: string | null;
vlan_parent: string | null;
ipv4_type: string | null;
ipv6_type: string | null;
addresses: NetAddress[];
pending_action: string | null;
assigned: boolean;
is_management: boolean;
}
export interface NetVlan {
uuid: string;
parent: string;
tag: string;
pcp: string | null;
description: string;
device: string | null;
}
export interface NetVip {
uuid: string;
interface: string;
interface_label: string;
mode: string;
address: string;
vhid: string | null;
advbase: string | null;
advskew: string | null;
description: string;
}
export interface NetGateway {
uuid: string;
name: string;
description: string;
interface: string;
interface_label: string;
ip_protocol: string;
address: string;
is_default: boolean;
disabled: boolean;
priority: string | null;
status: string;
}
export interface NetRoute {
uuid: string;
network: string;
gateway: string;
description: string;
disabled: boolean;
}
export type NetworkKind = "vlan" | "vip" | "gateway" | "route" | "interface" | "dns_host" | "dhcp_host" | "kea_subnet" | "kea_reservation";
export type NetworkAction = "add" | "set" | "del";
export interface NetworkChangeRequest {
kind: NetworkKind;
action: NetworkAction;
uuid?: string;
fields: Record<string, string>;
}
export interface NetworkRisk {
code: string;
params: Record<string, string>;
}
export interface NetworkChangePreview {
token: string;
kind: NetworkKind;
action: NetworkAction;
uuid: string | null;
host_id: string;
host_name: string;
changes: { field: string; before: string | null; after: string | null }[];
risks: NetworkRisk[];
warnings: NetworkRisk[];
blocks: NetworkRisk[];
requires_acknowledgement: boolean;
management_device: string | null;
can_execute: boolean;
}
export interface NetworkChangeResult {
status: string;
uuid: string | null;
verified: boolean;
mismatches: string[];
backup_id: string;
}
export type NetworkTemplate = Record<string, { value: string; options: { value: string; label: string; selected: boolean }[] | null }>;
export interface NetDnsHost {
uuid: string;
hostname: string;
domain: string;
record_type: string;
server: string;
enabled: boolean;
description: string;
}
export interface NetDhcpHost {
uuid: string;
host: string;
domain: string;
ip: string;
hwaddr: string;
lease_time: string;
description: string;
}
export interface NetKeaSubnet {
uuid: string;
subnet: string;
pools: string;
routers: string;
dns_servers: string;
description: string;
}
export interface NetKeaReservation {
uuid: string;
subnet: string;
ip_address: string;
hw_address: string;
hostname: string;
description: string;
}
export interface HostNetwork {
host_id: string;
host_name: string;
interfaces: NetInterface[];
vlans: NetVlan[];
vips: NetVip[];
gateways: NetGateway[];
routes: NetRoute[];
dns_hosts: NetDnsHost[];
dhcp_hosts: NetDhcpHost[];
kea_subnets: NetKeaSubnet[];
kea_reservations: NetKeaReservation[];
management_device: string | null;
can_configure_interfaces: boolean;
unavailable: string[];
}
export interface LiveRule {
uuid: string;
description: string;
@@ -290,6 +456,7 @@ export interface Rule {
destination_net: string;
destination_port: string | null;
enabled: boolean;
log: boolean;
created_at: string;
updated_at: string;
target_host_ids: string[];
@@ -312,10 +479,29 @@ export interface RuleCreate {
destination_net: string;
destination_port: string | null;
enabled: boolean;
log?: boolean;
target_host_ids: string[];
target_group_ids: string[];
}
export interface UnmanagedRule {
uuid: string;
description: string;
interface: string;
interface_label: string;
direction: string;
action: string;
protocol: string;
source_net: string;
source_port: string;
destination_net: string;
destination_port: string;
enabled: boolean;
log: boolean;
adoptable: boolean;
reasons: string[];
}
export type PolicyAliasType = "host" | "network" | "port" | "networkgroup" | "mac" | "asn";
export interface PolicyAlias {
@@ -884,6 +1070,16 @@ export const api = {
request<Host>(`/hosts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteHost: (id: string) => request<void>(`/hosts/${id}`, { method: "DELETE" }),
getLiveRules: (id: string) => request<LiveRule[]>(`/hosts/${id}/live-rules`),
getHostNetwork: (id: string) => request<HostNetwork>(`/hosts/${id}/network`),
getNetworkItem: (id: string, kind: NetworkKind, uuid: string) => request<Record<string, string>>(`/hosts/${id}/network/item/${kind}/${uuid}`),
getNetworkTemplate: (id: string, kind: NetworkKind) => request<NetworkTemplate>(`/hosts/${id}/network/template/${kind}`),
previewNetworkChange: (id: string, data: NetworkChangeRequest) =>
request<NetworkChangePreview>(`/hosts/${id}/network/changes/preview`, { method: "POST", body: JSON.stringify(data) }),
executeNetworkChange: (id: string, data: NetworkChangeRequest, previewToken: string, acknowledge: boolean) =>
request<NetworkChangeResult>(`/hosts/${id}/network/changes`, {
method: "POST",
body: JSON.stringify({ ...data, preview_token: previewToken, acknowledge }),
}),
getHostPortForwards: (id: string) => request<NatLiveSplit>(`/port-forward/hosts/${id}/live`),
getHostOneToOne: (id: string) => request<NatLiveSplit>(`/one-to-one/hosts/${id}/live`),
getHostOutboundNat: (id: string) => request<NatLiveSplit>(`/outbound-nat/hosts/${id}/live`),
@@ -910,6 +1106,8 @@ export const api = {
listRules: () => request<Rule[]>("/rules"),
createRule: (data: RuleCreate) => request<Rule>("/rules", { method: "POST", body: JSON.stringify(data) }),
listUnmanagedRules: (hostId: string) => request<UnmanagedRule[]>(`/rules/unmanaged?host_id=${hostId}`),
adoptRule: (hostId: string, ruleUuid: string) => request<Rule>("/rules/adopt", { method: "POST", body: JSON.stringify({ host_id: hostId, rule_uuid: ruleUuid }) }),
updateRule: (id: string, data: RuleCreate, expectedUpdatedAt: string) => request<Rule>(`/rules/${id}`, { method: "PUT", body: JSON.stringify({ ...data, expected_updated_at: expectedUpdatedAt }) }),
previewRule: (id: string) => request<RulePreview>(`/rules/${id}/preview`),
ruleRevisions: (id: string) => request<RuleRevision[]>(`/rules/${id}/revisions`),
+241 -3
View File
@@ -122,6 +122,7 @@
"groups": {
"overview": "Übersicht",
"policy": "Regelwerk",
"network": "Netzwerk",
"vpn": "VPN",
"certificates": "Zertifikate",
"monitoring": "Monitoring",
@@ -136,6 +137,13 @@
"groups": "Gruppen",
"aliases": "Aliase",
"nat": "NAT",
"network-interfaces": "Schnittstellen",
"network-vlans": "VLANs",
"network-vips": "Virtuelle IPs",
"network-gateways": "Gateways",
"network-routes": "Routen",
"network-dns": "DNS",
"network-dhcp": "DHCP",
"ipsec": "IPsec-VPN",
"openvpn": "OpenVPN",
"wireguard": "WireGuard",
@@ -231,7 +239,8 @@
"saving": "Speichern…",
"create": "Host anlegen",
"editTitle": "Host bearbeiten: {name}",
"active": "Aktiv"
"active": "Aktiv",
"baseUrlEditHint": "Neue Adresse eintragen, wenn die Firewall eine andere IP bekommen hat (z. B. nach einer Änderung der Verwaltungs-Schnittstelle). Verlauf und Zuordnungen bleiben erhalten."
},
"rules": {
"title": "Firewall-Regeln",
@@ -310,7 +319,34 @@
"actionBlock": "blockieren",
"actionReject": "ablehnen",
"dirIn": "eingehend",
"dirOut": "ausgehend"
"dirOut": "ausgehend",
"adopt": {
"button": "Von Firewall übernehmen",
"title": "Regeln von der Firewall übernehmen",
"intro": "Hier stehen Regeln, die direkt in OPNsense angelegt wurden. Nach der Übernahme kannst du sie in OPNexus bearbeiten und ausrollen. Die Übernahme selbst ändert nichts auf der Firewall.",
"host": "Firewall",
"none": "Keine nicht verwalteten Regeln gefunden.",
"action": "Übernehmen",
"adopted": "„{name}“ übernommen.",
"failed": "Übernahme fehlgeschlagen",
"loadFailed": "Regeln konnten nicht geladen werden.",
"notPossible": "Nicht übernehmbar",
"reasons": {
"automatic": "Systemregel",
"already_managed": "Wird bereits verwaltet",
"interface_not_single": "Mehrere oder keine Schnittstelle (Floating-Regel)",
"negated_interface": "Schnittstelle ist invertiert",
"negated_source": "Quelle ist invertiert (NOT)",
"negated_destination": "Ziel ist invertiert (NOT)",
"received_on": "Nutzt „Empfangen auf“",
"not_quick": "Keine Quick-Regel (letzter Treffer gewinnt)",
"direction_unsupported": "Richtung ist weder ein noch aus",
"action_unsupported": "Aktion nicht unterstützt",
"ipprotocol_unsupported": "IPv4 und IPv6 in einer Regel",
"no_description": "Beschreibung fehlt – bitte in OPNsense setzen"
}
},
"logPackets": "Protokollieren"
},
"login": {
"prompt": "Bitte anmelden",
@@ -457,7 +493,14 @@
"TOTP_INVALID": "Ungültiger Zwei-Faktor-Code",
"TOTP_ALREADY_ENABLED": "Zwei-Faktor-Anmeldung ist bereits aktiv",
"TOTP_NOT_ENABLED": "Zwei-Faktor-Anmeldung ist nicht aktiv",
"TOTP_SETUP_MISSING": "Es wurde keine Einrichtung gestartet"
"TOTP_SETUP_MISSING": "Es wurde keine Einrichtung gestartet",
"RULE_NOT_ADOPTABLE": "Diese Regel kann nicht übernommen werden ({reasons})",
"NETWORK_ACK_REQUIRED": "Diese Änderung kann die Verbindung zur Firewall trennen und muss ausdrücklich bestätigt werden ({reasons})",
"NETWORK_APPLY_UNCERTAIN": "Die Änderung wurde geschrieben, das Anwenden auf „{host}“ ist aber fehlgeschlagen oder unklar. Stand auf der Firewall prüfen; Backup: {backup_id}",
"NETWORK_BACKUP_FAILED": "Backup von „{host}“ vor der Änderung fehlgeschlagen – es wurde nichts geändert",
"NETWORK_CHANGE_BLOCKED": "Die Änderung ist nicht möglich ({reasons})",
"NETWORK_FIELD_INVALID": "Ungültiges Feld: {field}",
"NETWORK_ITEM_NOT_FOUND": "Das Objekt existiert auf der Firewall nicht (mehr)"
},
"workflow": {
"attention": "Fehler prüfen",
@@ -1723,5 +1766,200 @@
"resetTip": "Entfernt die Zwei-Faktor-Anmeldung dieses Benutzers, z. B. wenn das Gerät verloren ging.",
"resetConfirm": "Zwei-Faktor-Anmeldung für {name} zurücksetzen?",
"resetDone": "Zwei-Faktor für {name} zurückgesetzt."
},
"network": {
"host": "Firewall",
"loadFailed": "Netzwerkdaten konnten nicht geladen werden.",
"empty": "Keine Einträge.",
"unassigned": "nicht zugewiesen",
"management": "Verwaltung",
"default": "Standard",
"active": "Aktiv",
"managementNote": "OPNexus erreicht diese Firewall über {device}. Änderungen an dieser Schnittstelle können die Verbindung trennen und werden später besonders abgesichert.",
"managementUnknown": "Über welche Schnittstelle OPNexus diese Firewall erreicht, lässt sich nicht bestimmen (z. B. über NAT oder eine CARP-Adresse).",
"unavailable": "Nicht abrufbar von dieser Firewall: {sections}.",
"cannotConfigure": "Diese OPNsense-Version erlaubt keine IP-Konfiguration von Schnittstellen per API (ab ca. 26.7.6).",
"sections": {
"assignments": "Schnittstellen-Zuweisung",
"vlans": "VLANs",
"vips": "Virtuelle IPs",
"gateways": "Gateways",
"routes": "Routen",
"dns_hosts": "DNS-Einträge",
"dhcp_hosts": "DHCP-Hosts",
"kea_subnets": "DHCP-Subnetze",
"kea_reservations": "DHCP-Reservierungen"
},
"col": {
"device": "Gerät",
"name": "Name",
"status": "Status",
"addresses": "Adressen",
"type": "Typ",
"vlan": "VLAN",
"parent": "Eltern-Gerät",
"tag": "Tag",
"pcp": "Priorität (PCP)",
"address": "Adresse",
"interface": "Schnittstelle",
"mode": "Modus",
"advskew": "Advskew",
"priority": "Priorität",
"network": "Netzwerk",
"gateway": "Gateway"
},
"kinds": {
"vlan": "VLAN",
"vip": "Virtuelle IP",
"gateway": "Gateway",
"route": "Route",
"interface": "Schnittstelle",
"dns_host": "DNS-Eintrag",
"dhcp_host": "DHCP-Host (dnsmasq)",
"kea_subnet": "DHCP-Subnetz (Kea)",
"kea_reservation": "DHCP-Reservierung (Kea)"
},
"field": {
"if": "Eltern-Schnittstelle",
"tag": "VLAN-Tag",
"pcp": "Priorität (PCP)",
"descr": "Beschreibung",
"interface": "Schnittstelle",
"mode": "Modus",
"network": "Adresse / Präfix (z. B. 192.168.1.50/24)",
"vhid": "VHID",
"advbase": "Advbase",
"advskew": "Advskew",
"password": "CARP-Passwort",
"name": "Name",
"ipprotocol": "IP-Version",
"gateway": "Gateway",
"defaultgw": "Standard-Gateway",
"defaultgwHint": "Als Standard-Gateway verwenden",
"monitor_disable": "Überwachung",
"monitor_disableHint": "Gateway-Überwachung deaktivieren",
"priority": "Priorität",
"disabled": "Deaktiviert",
"disabledHint": "Gateway deaktivieren",
"enabled": "Aktiv",
"enabledHint": "Route aktiv",
"fargw": "Fernes Gateway",
"force_down": "Als ausgefallen markieren",
"monitor": "Monitor-IP",
"weight": "Gewicht",
"nosync": "Nicht synchronisieren",
"peer": "Peer",
"peer6": "Peer (IPv6)",
"proto": "Protokoll",
"enable": "Aktiviert",
"enableHint": "Schnittstelle aktivieren",
"type4": "IPv4-Typ",
"ipaddr": "IPv4-Adresse / Präfix",
"type6": "IPv6-Typ",
"ipaddrv6": "IPv6-Adresse / Präfix",
"gatewayv6": "IPv6-Gateway",
"mtu": "MTU",
"blockpriv": "Private Netze sperren",
"blockprivHint": "Private Netzwerke blockieren",
"blockbogons": "Bogon-Netze sperren",
"blockbogonsHint": "Bogon-Netzwerke blockieren",
"lock": "Sperre",
"lockHint": "Löschen verhindern (Schnittstelle sperren)",
"hostname": "Hostname",
"domain": "Domain",
"rr": "Eintragstyp",
"server": "Ziel-Adresse",
"mx": "Mail-Server (MX)",
"mxprio": "MX-Priorität",
"addptr": "PTR-Eintrag",
"addptrHint": "Passenden PTR-Eintrag anlegen",
"description": "Beschreibung",
"host": "Host",
"ip": "IP-Adresse(n)",
"hwaddr": "MAC-Adresse(n)",
"client_id": "Client-ID",
"lease_time": "Lease-Dauer (Sekunden)",
"local": "Lokal",
"localHint": "Nur lokal auflösen",
"ignore": "Ignorieren",
"ignoreHint": "Anfragen dieses Geräts ignorieren",
"subnet": "Subnetz",
"pools": "Adress-Pool (z. B. 192.168.1.100-192.168.1.200)",
"valid_lifetime": "Gültigkeit der Leases (Sekunden)",
"next_server": "Next-Server (TFTP)",
"ip_address": "IP-Adresse",
"hw_address": "MAC-Adresse",
"option_data": {
"routers": "Gateway (Router)",
"domain_name_servers": "DNS-Server",
"domain_name": "Domain-Name",
"ntp_servers": "NTP-Server"
}
},
"risks": {
"management_interface": "Diese Änderung betrifft {device}, die Schnittstelle, über die OPNexus die Firewall erreicht. Die Verbindung kann abbrechen.",
"management_address": "Die Adresse {address} ist die, unter der OPNexus die Firewall erreicht.",
"management_unknown": "Über welche Schnittstelle OPNexus diese Firewall erreicht, ist nicht bestimmbar (NAT oder CARP-Adresse). Die Auswirkung kann nicht eingeschätzt werden.",
"default_gateway": "{name} ist das Standard-Gateway. Ohne es kann der Verkehr ins Netz ausfallen.",
"default_gateway_change": "Das Standard-Gateway wird gewechselt oder gesetzt. Der gesamte ausgehende Verkehr nimmt danach einen anderen Weg.",
"management_gateway": "{name} hängt an der Schnittstelle, über die OPNexus die Firewall erreicht.",
"default_route": "Eine Standardroute ({network}) verändert den Weg für fast den gesamten Verkehr.",
"route_covers_management": "Die Route {network} deckt die Adresse der Firewall ab. Die Antworten an OPNexus können einen anderen Weg nehmen.",
"interface_rules_removed": "Beim Entfernen löscht OPNsense selbst {count} Regel(n), die diese Schnittstelle nutzen: {names}"
},
"warnings": {
"carp_change": "CARP-Änderungen können einen Wechsel zwischen Master und Backup auslösen.",
"cluster_peers": "Diese Firewall gehört zu einem Cluster. Dieselbe Änderung muss ggf. auch auf {hosts} erfolgen. VIPs und Gateways werden nicht automatisch synchronisiert.",
"core_interface": "{name} ist eine Kern-Schnittstelle der Firewall. Änderungen wirken sich auf alle Regeln und Dienste darauf aus."
},
"blocks": {
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
"nothing_to_change": "Es gibt nichts zu ändern.",
"interface_api_unavailable": "Diese OPNsense-Version kann Schnittstellen nicht per API konfigurieren (ab ca. 26.7.6).",
"interface_changes_pending": "Auf der Firewall warten bereits vorgemerkte Schnittstellen-Änderungen auf das Anwenden. Zuerst dort prüfen und anwenden oder verwerfen.",
"interface_locked": "Die Schnittstelle ist gesperrt. Zuerst die Sperre aufheben, dann löschen."
},
"change": {
"title": {
"add": "{kind} hinzufügen",
"set": "{kind} bearbeiten",
"del": "{kind} löschen"
},
"loadFailed": "Das Formular konnte nicht geladen werden.",
"previewFailed": "Die Vorschau konnte nicht erstellt werden.",
"applyFailed": "Die Änderung konnte nicht angewendet werden.",
"keepUnchanged": "unverändert lassen…",
"toPreview": "Weiter zur Vorschau",
"back": "Zurück",
"apply": "Jetzt anwenden",
"riskTitle": "Achtung, diese Änderung kann die Verbindung kappen",
"noChanges": "Keine Änderungen.",
"backupNote": "Vor dem Anwenden wird ein Backup der Firewall-Konfiguration gespeichert. Ein automatisches Zurückrollen gibt es nicht: Wenn die Verbindung abbricht, kann OPNexus die Änderung nicht selbst rückgängig machen.",
"ack": "Ich habe verstanden, dass diese Änderung die Verbindung zu {host} trennen kann und nicht automatisch zurückgerollt wird.",
"typeHost": "Zur Bestätigung den Namen der Firewall eingeben: {host}",
"applied": "Änderung angewendet.",
"verified": "Angewendet und geprüft",
"notVerified": "Angewendet, Prüfung weicht ab",
"mismatch": "Die Firewall meldet abweichende Werte: {fields}",
"backupId": "Backup vor der Änderung: {id}",
"col": {
"field": "Feld",
"before": "Vorher",
"after": "Nachher"
},
"invalidValue": "Ungültiger Wert.",
"unreachable": "Die Änderung wurde angewendet, aber „{host}“ antwortet unter der bisherigen Adresse nicht mehr. Wurde die Verwaltungs-Adresse geändert, die neue Adresse unter Hosts › Bearbeiten eintragen. Das Backup vor der Änderung bleibt gespeichert."
},
"rowAdd": "Hinzufügen",
"rowEdit": "Bearbeiten",
"rowDelete": "Löschen",
"hint": {
"interfaceDescr": "Nur Buchstaben, Ziffern und Unterstriche (keine Leerzeichen oder Bindestriche)."
},
"dhcp": {
"dnsmasq": "Hosts (dnsmasq: statisches DHCP und DNS)",
"keaSubnets": "Subnetze (Kea)",
"keaReservations": "Reservierungen (Kea)"
}
}
}
+241 -3
View File
@@ -122,6 +122,7 @@
"groups": {
"overview": "Overview",
"policy": "Policy",
"network": "Network",
"vpn": "VPN",
"certificates": "Certificates",
"monitoring": "Monitoring",
@@ -136,6 +137,13 @@
"groups": "Groups",
"aliases": "Aliases",
"nat": "NAT",
"network-interfaces": "Interfaces",
"network-vlans": "VLANs",
"network-vips": "Virtual IPs",
"network-gateways": "Gateways",
"network-routes": "Routes",
"network-dns": "DNS",
"network-dhcp": "DHCP",
"ipsec": "IPsec VPN",
"openvpn": "OpenVPN",
"wireguard": "WireGuard",
@@ -231,7 +239,8 @@
"saving": "Saving…",
"create": "Create host",
"editTitle": "Edit host: {name}",
"active": "Active"
"active": "Active",
"baseUrlEditHint": "Enter the new address if the firewall got a different IP (e.g. after changing its management interface). History and assignments are kept."
},
"rules": {
"title": "Firewall rules",
@@ -310,7 +319,34 @@
"actionBlock": "block",
"actionReject": "reject",
"dirIn": "inbound",
"dirOut": "outbound"
"dirOut": "outbound",
"adopt": {
"button": "Adopt from firewall",
"title": "Adopt rules from the firewall",
"intro": "These rules were created directly in OPNsense. Once adopted you can edit and deploy them from OPNexus. Adopting itself changes nothing on the firewall.",
"host": "Firewall",
"none": "No unmanaged rules found.",
"action": "Adopt",
"adopted": "Adopted “{name}”.",
"failed": "Adopting failed",
"loadFailed": "Could not load rules.",
"notPossible": "Cannot be adopted",
"reasons": {
"automatic": "System rule",
"already_managed": "Already managed",
"interface_not_single": "Several or no interface (floating rule)",
"negated_interface": "Interface is inverted",
"negated_source": "Source is inverted (NOT)",
"negated_destination": "Destination is inverted (NOT)",
"received_on": "Uses “received on”",
"not_quick": "Not a quick rule (last match wins)",
"direction_unsupported": "Direction is neither in nor out",
"action_unsupported": "Action not supported",
"ipprotocol_unsupported": "IPv4 and IPv6 in one rule",
"no_description": "Description missing – please set one in OPNsense"
}
},
"logPackets": "Log matching packets"
},
"login": {
"prompt": "Please sign in",
@@ -457,7 +493,14 @@
"TOTP_INVALID": "Invalid two-factor code",
"TOTP_ALREADY_ENABLED": "Two-factor authentication is already enabled",
"TOTP_NOT_ENABLED": "Two-factor authentication is not enabled",
"TOTP_SETUP_MISSING": "No setup has been started"
"TOTP_SETUP_MISSING": "No setup has been started",
"RULE_NOT_ADOPTABLE": "This rule cannot be adopted ({reasons})",
"NETWORK_ACK_REQUIRED": "This change can cut the connection to the firewall and must be confirmed explicitly ({reasons})",
"NETWORK_APPLY_UNCERTAIN": "The change was written, but applying it on “{host}” failed or is unclear. Check the firewall; backup: {backup_id}",
"NETWORK_BACKUP_FAILED": "Backup of “{host}” before the change failed – nothing was changed",
"NETWORK_CHANGE_BLOCKED": "The change is not possible ({reasons})",
"NETWORK_FIELD_INVALID": "Invalid field: {field}",
"NETWORK_ITEM_NOT_FOUND": "The object does not exist on the firewall (any more)"
},
"workflow": {
"attention": "Check errors",
@@ -1723,5 +1766,200 @@
"resetTip": "Removes this user's two-factor sign-in, e.g. when the device was lost.",
"resetConfirm": "Reset two-factor sign-in for {name}?",
"resetDone": "Two-factor reset for {name}."
},
"network": {
"host": "Firewall",
"loadFailed": "Could not load network data.",
"empty": "No entries.",
"unassigned": "unassigned",
"management": "Management",
"default": "Default",
"active": "Active",
"managementNote": "OPNexus reaches this firewall through {device}. Changes to this interface can cut the connection and will get extra safeguards later.",
"managementUnknown": "Which interface OPNexus uses to reach this firewall cannot be determined (e.g. via NAT or a CARP address).",
"unavailable": "Not available from this firewall: {sections}.",
"cannotConfigure": "This OPNsense version does not allow configuring interface IPs through its API (from about 26.7.6).",
"sections": {
"assignments": "interface assignments",
"vlans": "VLANs",
"vips": "virtual IPs",
"gateways": "gateways",
"routes": "routes",
"dns_hosts": "DNS entries",
"dhcp_hosts": "DHCP hosts",
"kea_subnets": "DHCP subnets",
"kea_reservations": "DHCP reservations"
},
"col": {
"device": "Device",
"name": "Name",
"status": "Status",
"addresses": "Addresses",
"type": "Type",
"vlan": "VLAN",
"parent": "Parent device",
"tag": "Tag",
"pcp": "Priority (PCP)",
"address": "Address",
"interface": "Interface",
"mode": "Mode",
"advskew": "Advskew",
"priority": "Priority",
"network": "Network",
"gateway": "Gateway"
},
"kinds": {
"vlan": "VLAN",
"vip": "Virtual IP",
"gateway": "Gateway",
"route": "Route",
"interface": "Interface",
"dns_host": "DNS entry",
"dhcp_host": "DHCP host (dnsmasq)",
"kea_subnet": "DHCP subnet (Kea)",
"kea_reservation": "DHCP reservation (Kea)"
},
"field": {
"if": "Parent interface",
"tag": "VLAN tag",
"pcp": "Priority (PCP)",
"descr": "Description",
"interface": "Interface",
"mode": "Mode",
"network": "Address / prefix (e.g. 192.168.1.50/24)",
"vhid": "VHID",
"advbase": "Advbase",
"advskew": "Advskew",
"password": "CARP password",
"name": "Name",
"ipprotocol": "IP version",
"gateway": "Gateway",
"defaultgw": "Default gateway",
"defaultgwHint": "Use as default gateway",
"monitor_disable": "Monitoring",
"monitor_disableHint": "Disable gateway monitoring",
"priority": "Priority",
"disabled": "Disabled",
"disabledHint": "Disable gateway",
"enabled": "Enabled",
"enabledHint": "Route enabled",
"fargw": "Far gateway",
"force_down": "Mark as down",
"monitor": "Monitor IP",
"weight": "Weight",
"nosync": "Do not sync",
"peer": "Peer",
"peer6": "Peer (IPv6)",
"proto": "Protocol",
"enable": "Enabled",
"enableHint": "Enable interface",
"type4": "IPv4 type",
"ipaddr": "IPv4 address / prefix",
"type6": "IPv6 type",
"ipaddrv6": "IPv6 address / prefix",
"gatewayv6": "IPv6 gateway",
"mtu": "MTU",
"blockpriv": "Block private networks",
"blockprivHint": "Block private networks",
"blockbogons": "Block bogon networks",
"blockbogonsHint": "Block bogon networks",
"lock": "Lock",
"lockHint": "Prevent removal (lock interface)",
"hostname": "Hostname",
"domain": "Domain",
"rr": "Record type",
"server": "Target address",
"mx": "Mail server (MX)",
"mxprio": "MX priority",
"addptr": "PTR record",
"addptrHint": "Create the matching PTR record",
"description": "Description",
"host": "Host",
"ip": "IP address(es)",
"hwaddr": "MAC address(es)",
"client_id": "Client ID",
"lease_time": "Lease time (seconds)",
"local": "Local",
"localHint": "Resolve locally only",
"ignore": "Ignore",
"ignoreHint": "Ignore requests from this device",
"subnet": "Subnet",
"pools": "Address pool (e.g. 192.168.1.100-192.168.1.200)",
"valid_lifetime": "Lease validity (seconds)",
"next_server": "Next server (TFTP)",
"ip_address": "IP address",
"hw_address": "MAC address",
"option_data": {
"routers": "Gateway (router)",
"domain_name_servers": "DNS servers",
"domain_name": "Domain name",
"ntp_servers": "NTP servers"
}
},
"risks": {
"management_interface": "This change affects {device}, the interface OPNexus uses to reach the firewall. The connection may drop.",
"management_address": "The address {address} is the one OPNexus uses to reach the firewall.",
"management_unknown": "Which interface OPNexus uses to reach this firewall cannot be determined (NAT or a CARP address). The impact cannot be judged.",
"default_gateway": "{name} is the default gateway. Without it, traffic to the internet may fail.",
"default_gateway_change": "The default gateway is being set or switched. All outbound traffic will take a different path afterwards.",
"management_gateway": "{name} sits on the interface OPNexus uses to reach the firewall.",
"default_route": "A default route ({network}) changes the path for almost all traffic.",
"route_covers_management": "The route {network} covers the firewall's own address. Replies to OPNexus may take a different path.",
"interface_rules_removed": "Removing it makes OPNsense itself delete {count} rule(s) that use this interface: {names}"
},
"warnings": {
"carp_change": "CARP changes can trigger a switch between master and backup.",
"cluster_peers": "This firewall is part of a cluster. The same change may be needed on {hosts} as well. VIPs and gateways are not synchronised automatically.",
"core_interface": "{name} is a core interface of the firewall. Changes affect every rule and service on it."
},
"blocks": {
"api_unavailable": "This firewall does not offer an API for this ({section}).",
"nothing_to_change": "There is nothing to change.",
"interface_api_unavailable": "This OPNsense version cannot configure interfaces through its API (from about 26.7.6).",
"interface_changes_pending": "Interface changes are already staged on the firewall and waiting to be applied. Check and apply or discard them there first.",
"interface_locked": "The interface is locked. Remove the lock first, then delete."
},
"change": {
"title": {
"add": "Add {kind}",
"set": "Edit {kind}",
"del": "Delete {kind}"
},
"loadFailed": "The form could not be loaded.",
"previewFailed": "The preview could not be created.",
"applyFailed": "The change could not be applied.",
"keepUnchanged": "leave unchanged…",
"toPreview": "Continue to preview",
"back": "Back",
"apply": "Apply now",
"riskTitle": "Careful, this change can cut the connection",
"noChanges": "No changes.",
"backupNote": "A backup of the firewall configuration is saved before applying. There is no automatic rollback: if the connection drops, OPNexus cannot undo the change itself.",
"ack": "I understand that this change can cut the connection to {host} and is not rolled back automatically.",
"typeHost": "To confirm, type the firewall's name: {host}",
"applied": "Change applied.",
"verified": "Applied and checked",
"notVerified": "Applied, check differs",
"mismatch": "The firewall reports different values: {fields}",
"backupId": "Backup before the change: {id}",
"col": {
"field": "Field",
"before": "Before",
"after": "After"
},
"invalidValue": "Invalid value.",
"unreachable": "The change was applied, but “{host}” no longer answers on its previous address. If the management address was changed, enter the new one under Hosts › Edit. The backup taken before the change is kept."
},
"rowAdd": "Add",
"rowEdit": "Edit",
"rowDelete": "Delete",
"hint": {
"interfaceDescr": "Letters, digits and underscores only (no spaces or hyphens)."
},
"dhcp": {
"dnsmasq": "Hosts (dnsmasq: static DHCP and DNS)",
"keaSubnets": "Subnets (Kea)",
"keaReservations": "Reservations (Kea)"
}
}
}
+2 -2
View File
@@ -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.11.1";
export const APP_CODENAME = "Merlin";
export const APP_VERSION = "1.12.0";
export const APP_CODENAME = "Condor";
export const APP_MAINTAINER = "Julian Boldt";