Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20fd575420 |
No files matched your search
@@ -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
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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]}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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"] == []
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function NetworkIndex() {
|
||||
redirect("/network/interfaces");
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user