Release v1.14.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s

Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:

- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
  ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
  Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
  Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
  Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
  falsch, weil `/hostsystem` mit `/hosts` beginnt.
This commit is contained in:
Julian Boldt committed 2026-10-09 22:49:10 +02:00
1 parent df0402b284
commit e788afab2f
35 files changed
+879 -58

No files matched your search

+14
View File
@@ -31,6 +31,20 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.14.0] "Eagle" — 2026-10-09
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
falsch, weil `/hostsystem` mit `/hosts` beginnt.
## [1.13.0] "Buzzard" — 2026-10-09
Mehr Verwaltung pro Firewall und ein Abgleich der Cluster-Knoten. Alles über
+3 -3
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
> **Stand:** `1.13.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
> **Stand:** `1.14.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,7 +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, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog und Snapshots, ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln lassen sich übernehmen.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln 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.
@@ -47,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.13.0
OPNEXUS_VERSION=1.14.0
```
```bash
+3 -3
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
> **Status:** `1.13.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
> **Status:** `1.14.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,7 +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, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog and snapshots, a comparison of cluster nodes, 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.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules 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.
@@ -49,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.13.0
OPNEXUS_VERSION=1.14.0
```
```bash
+2
View File
@@ -39,6 +39,7 @@ from app.routers import certificates as certificates_router
from app.routers import network as network_router
from app.routers import snapshots as snapshots_router
from app.routers import network_drift as network_drift_router
from app.routers import diagnostics as diagnostics_router
from app.routers import restore as restore_router
from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
@@ -396,5 +397,6 @@ app.include_router(services_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(snapshots_router.router, prefix="/api")
app.include_router(network_drift_router.router, prefix="/api")
app.include_router(diagnostics_router.router, prefix="/api")
app.include_router(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")
+68
View File
@@ -0,0 +1,68 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
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.services import diagnostics_service
router = APIRouter(prefix="/hosts/{host_id}/diagnostics", tags=["diagnostics"])
IpProto = Literal["inet", "inet6"]
class PingRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
count: int = Field(default=4, ge=1, le=10)
packetsize: int | None = Field(default=None, ge=1, le=9000)
source: str | None = Field(default=None, max_length=64)
class TracerouteRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
protocol: Literal["udp", "icmp"] = "icmp"
source: str | None = Field(default=None, max_length=64)
class PortprobeRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
port: int = Field(ge=1, le=65535)
ipproto: IpProto = "inet"
source: str | None = Field(default=None, max_length=64)
class DnsRequest(BaseModel):
name: str = Field(min_length=1, max_length=253)
server: str | None = Field(default=None, max_length=64)
async def _host(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("/ping")
async def ping(host_id: uuid.UUID, p: PingRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.ping(await _host(db, host_id), p.target, p.ipproto, p.count, p.packetsize, p.source)
@router.post("/traceroute")
async def traceroute(host_id: uuid.UUID, p: TracerouteRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.traceroute(await _host(db, host_id), p.target, p.ipproto, p.protocol, p.source)
@router.post("/portprobe")
async def portprobe(host_id: uuid.UUID, p: PortprobeRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.portprobe(await _host(db, host_id), p.target, p.port, p.ipproto, p.source)
@router.post("/dns")
async def dns(host_id: uuid.UUID, p: DnsRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.dns_lookup(await _host(db, host_id), p.name, p.server)
+1 -1
View File
@@ -136,7 +136,7 @@ class HostNetwork(BaseModel):
class NetworkChangeRequest(BaseModel):
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation",
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "dns_forward", "dhcp_range"]
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "gif", "vxlan", "dns_forward", "dhcp_range"]
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}$")
+142
View File
@@ -0,0 +1,142 @@
"""Network probes run FROM a firewall: ping, traceroute, TCP port probe, DNS lookup.
Admin-only by construction (every call is a POST, which the read-only role is
refused for) and audited with the target, because a firewall that probes
arbitrary addresses on request is also a scanning tool. Inputs are checked
here before they are sent (the firewall validates them again); results are
shown and never stored. Ping runs as a short job that is always stopped and
removed again, even when polling fails.
"""
import asyncio
import ipaddress
import re
import time
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, opnsense_client
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseDiagnosticRejected
TARGET = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.:_-]{0,251}[A-Za-z0-9])?$")
# At most a few probes at a time per OPNexus process, so the page cannot be used to hammer a firewall.
_SLOTS = asyncio.Semaphore(4)
def clean_target(value: str, field: str = "target") -> str:
value = (value or "").strip()
if not TARGET.match(value):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
return value
def clean_source(value: str | None) -> str:
value = (value or "").strip()
if not value:
return ""
try:
return str(ipaddress.ip_address(value))
except ValueError:
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="source") from None
async def _audit(host: OpnsenseHost, tool: str, target: str, ok: bool, details: dict, message: str | None = None) -> None:
await audit_service.persist(kind="diagnostic", action=f"diag.{tool}", status="ok" if ok else "failed",
entity_name=target, host_id=str(host.id), host_name=host.name, message=message, details=details)
def _rejected(exc: OpnsenseDiagnosticRejected):
field = next(iter(exc.validations), "target").split(".")[-1]
return api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
async def _guarded(host: OpnsenseHost, tool: str, target: str, params: dict, call):
async with _SLOTS:
try:
result = await call()
except OpnsenseDiagnosticRejected as exc:
await _audit(host, tool, target, False, params, "rejected: " + ", ".join(exc.validations))
raise _rejected(exc) from exc
except OpnsenseAPIError as exc:
await _audit(host, tool, target, False, params, str(exc)[:300])
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await _audit(host, tool, target, True, params)
return result
async def traceroute(host: OpnsenseHost, target: str, ipproto: str, protocol: str, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "ipproto": ipproto, "protocol": protocol, "source": source}
async def call():
data = await opnsense_client.diagnostic_set(
host, "/diagnostics/traceroute/set", "traceroute",
{"hostname": target, "ipproto": ipproto, "protocol": protocol, "source_address": source})
resp = data.get("response") or {}
hops = [{"ttl": str(r.get("ttl", "")), "address": str(r.get("address", "")), "host": str(r.get("host", "")),
"as": str(r.get("AS", "")), "probes": str(r.get("probes", ""))} for r in resp.get("rows", []) if isinstance(r, dict)]
return {"target": target, "notice": str(resp.get("notice", "")), "hops": hops}
return await _guarded(host, "traceroute", target, params, call)
async def portprobe(host: OpnsenseHost, target: str, port: int, ipproto: str, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "port": port, "ipproto": ipproto, "source": source}
async def call():
data = await opnsense_client.diagnostic_set(
host, "/diagnostics/portprobe/set", "portprobe",
{"hostname": target, "port": str(port), "ipproto": ipproto, "source_address": source, "source_port": "", "showtext": "0"})
message = str((data.get("response") or {}).get("message", ""))
# Never forward the remote payload (a service banner): only whether the connection worked.
return {"target": target, "port": port, "open": "succeeded" in message.lower(), "message": message}
return await _guarded(host, "portprobe", target, params, call)
async def dns_lookup(host: OpnsenseHost, name: str, server: str | None) -> dict:
name = clean_target(name, "name")
server = clean_source(server)
params = {"name": name, "server": server}
async def call():
data = await opnsense_client.diagnostic_set(host, "/diagnostics/dns_diagnostics/set", "dns", {"hostname": name, "server": server})
out = {}
for rtype, rec in (data.get("response") or {}).items():
if isinstance(rec, dict):
out[str(rtype)] = {"answers": [str(a) for a in rec.get("answers", [])], "query_time": str(rec.get("query_time", "")),
"server": str(rec.get("server", ""))}
return {"name": name, "records": out}
return await _guarded(host, "dns", name, params, call)
async def ping(host: OpnsenseHost, target: str, ipproto: str, count: int, packetsize: int | None, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "ipproto": ipproto, "count": count, "packetsize": packetsize, "source": source}
async def call():
job = await opnsense_client.ping_job_start(host, {
"hostname": target, "fam": "ip6" if ipproto == "inet6" else "ip", "source_address": source,
"packetsize": str(packetsize or ""), "disable_frag": "0", "interval": "1", "description": "OPNexus"})
try:
state = None
deadline = time.monotonic() + count + 6
while time.monotonic() < deadline:
await asyncio.sleep(1)
state = await opnsense_client.ping_job_state(host, job)
if state and int(state.get("send") or 0) >= count:
break
await opnsense_client.ping_job_stop(host, job)
await asyncio.sleep(0.5)
state = await opnsense_client.ping_job_state(host, job) or state
finally:
# Never leave a job running on the firewall.
try:
await opnsense_client.ping_job_stop(host, job)
await opnsense_client.ping_job_remove(host, job)
except OpnsenseAPIError:
pass
if not state:
raise OpnsenseAPIError("Ping: kein Ergebnis von der Firewall")
return {"target": target, "sent": int(state.get("send") or 0), "received": int(state.get("received") or 0),
"loss": str(state.get("loss", "")), "min": state.get("min"), "avg": state.get("avg"), "max": state.get("max"),
"stddev": state.get("std-dev"), "error": state.get("last_error")}
return await _guarded(host, "ping", target, params, call)
@@ -56,6 +56,8 @@ FIELDS: dict[str, set[str]] = {
"bridge": {"members", "linklocal", "enablestp", "proto", "descr"},
"loopback": {"deviceId", "description"},
"gre": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
"gif": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
"vxlan": {"deviceId", "vxlanid", "vxlanlocal", "vxlanlocalport", "vxlanremote", "vxlanremoteport", "vxlangroup", "vxlandev"},
"dns_forward": {"enabled", "type", "domain", "server", "port", "verify", "forward_tcp_upstream", "forward_first", "description"},
"dhcp_range": {"interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "domain_type", "domain", "description"},
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
@@ -76,11 +78,13 @@ OBJECT_COLUMNS: dict[str, list[str]] = {
"bridge": ["bridgeif", "members", "proto", "descr"],
"loopback": ["deviceId", "description"],
"gre": ["greif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
"gif": ["gifif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
"vxlan": ["deviceId", "vxlanid", "vxlanlocal", "vxlanremote", "vxlangroup", "vxlandev"],
"dns_forward": ["enabled", "type", "domain", "server", "port", "description"],
"dhcp_range": ["interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "description"],
}
# 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", "lagg", "bridge", "gre"}
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface", "lagg", "bridge", "gre", "gif", "vxlan"}
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
+55
View File
@@ -1119,6 +1119,10 @@ NETWORK_ITEM_APIS = {
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/loopback_settings/reconfigure"},
"gre": {"body_key": "gre", "base": "/interfaces/gre_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gre_settings/reconfigure"},
"vxlan": {"body_key": "vxlan", "base": "/interfaces/vxlan_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/vxlan_settings/reconfigure"},
"gif": {"body_key": "gif", "base": "/interfaces/gif_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gif_settings/reconfigure"},
"dns_forward": {"body_key": "dot", "base": "/unbound/settings", "add": "addForward", "set": "setForward",
"del": "delForward", "get": "getForward", "search": "searchForward",
"reconfigure": "/unbound/service/reconfigure"},
@@ -1230,3 +1234,54 @@ async def delete_snapshot(host: OpnsenseHost, uuid: str) -> None:
data = await _request(host, "POST", f"/core/snapshots/del/{uuid}")
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
raise OpnsenseAPIError(f"Snapshot löschen von Firewall nicht bestätigt: {redact_data(data)}")
# ── Diagnostics (Diagnostics/Api/{Ping,Traceroute,Portprobe,DnsDiagnostics}Controller, read + live 2026-10-09) ──
# Traceroute/portprobe/DNS answer synchronously to the same "set" call that validates the form:
# {"result": "ok", "response": {...}} or {"result": "failed", "validations": {"<model>.settings.<field>": "..."}}.
# Ping is a job: set (returns a uuid) -> start -> searchJobs shows live counters -> stop -> remove.
# A probe to a closed/blackholed port takes the firewall 10 s, so these calls need a longer timeout.
DIAGNOSTIC_TIMEOUT = 40.0
async def diagnostic_set(host: OpnsenseHost, path: str, model: str, settings: dict) -> dict:
data = await _request(host, "POST", path, json={model: {"settings": settings}}, timeout=DIAGNOSTIC_TIMEOUT)
if not isinstance(data, dict):
raise OpnsenseAPIError("Diagnose: keine auswertbare Antwort der Firewall")
if data.get("result") != "ok":
raise OpnsenseDiagnosticRejected(data.get("validations") or {})
return data
class OpnsenseDiagnosticRejected(OpnsenseAPIError):
"""The firewall refused the input (its own validation); carries {field: message}."""
def __init__(self, validations: dict):
super().__init__("Eingabe von der Firewall abgelehnt")
self.validations = validations
async def ping_job_start(host: OpnsenseHost, settings: dict) -> str:
data = await diagnostic_set(host, "/diagnostics/ping/set", "ping", settings)
job = data.get("uuid")
if not job:
raise OpnsenseAPIError("Ping: die Firewall hat keine Job-ID geliefert")
started = await _request(host, "POST", f"/diagnostics/ping/start/{job}", timeout=DIAGNOSTIC_TIMEOUT)
if not isinstance(started, dict) or started.get("status") != "ok":
await ping_job_remove(host, job)
raise OpnsenseAPIError("Ping: der Job konnte nicht gestartet werden")
return job
async def ping_job_state(host: OpnsenseHost, job: str) -> dict | None:
data = await _request(host, "GET", "/diagnostics/ping/searchJobs", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return next((r for r in rows if isinstance(r, dict) and r.get("id") == job), None)
async def ping_job_stop(host: OpnsenseHost, job: str) -> None:
await _request(host, "POST", f"/diagnostics/ping/stop/{job}")
async def ping_job_remove(host: OpnsenseHost, job: str) -> None:
await _request(host, "POST", f"/diagnostics/ping/remove/{job}")
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.13.0"
APP_CODENAME = "Buzzard"
APP_VERSION = "1.14.0"
APP_CODENAME = "Eagle"
+143
View File
@@ -0,0 +1,143 @@
"""Ping / traceroute / port probe / DNS lookup run from a firewall."""
import asyncio
import json
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
BASE = "https://10.20.30.85"
async def _host(client, respx_mock, name="fw-diag"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
return (await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})).json()["id"]
def _url(path):
return f"{BASE}/api{path}"
async def _events(host_id):
async with SessionLocal() as db:
rows = await db.scalars(select(AuditEvent).where(AuditEvent.host_id == host_id, AuditEvent.kind == "diagnostic").order_by(AuditEvent.created_at))
return list(rows.all())
@pytest.fixture
def fast_sleep(monkeypatch):
async def instant(_seconds):
return None
monkeypatch.setattr(asyncio, "sleep", instant)
@pytest.mark.asyncio
async def test_traceroute_is_parsed_and_audited(client, respx_mock):
host_id = await _host(client, respx_mock)
sent = respx_mock.post(_url("/diagnostics/traceroute/set")).mock(return_value=httpx.Response(200, json={
"result": "ok", "response": {"notice": "traceroute to x", "rows": [
{"address": "10.0.0.1", "ttl": "1", "AS": "AS0", "host": "gw.lan", "probes": "0.4 ms"}]}}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/traceroute", json={"target": "example.com", "protocol": "icmp"})
assert resp.status_code == 200, resp.text
assert resp.json()["hops"] == [{"ttl": "1", "address": "10.0.0.1", "host": "gw.lan", "as": "AS0", "probes": "0.4 ms"}]
assert json.loads(sent.calls.last.request.content)["traceroute"]["settings"]["hostname"] == "example.com"
events = await _events(host_id)
assert [(e.action, e.status, e.entity_name) for e in events] == [("diag.traceroute", "ok", "example.com")]
@pytest.mark.asyncio
async def test_bad_targets_never_reach_the_firewall(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag2")
route = respx_mock.post(_url("/diagnostics/traceroute/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {}}))
for bad in ("a;b", "x y", "$(reboot)", "-h", "a" * 300, "host/../x"):
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/traceroute", json={"target": bad})
assert resp.status_code == 422, bad
assert not route.called
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "x.test", "port": 70000})).status_code == 422
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "x.test", "count": 500})).status_code == 422
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "x.test", "source": "not-an-ip"})).status_code == 422
@pytest.mark.asyncio
async def test_the_firewalls_own_validation_is_reported_as_422_and_audited(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag3")
respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={
"result": "failed", "validations": {"dns.settings.server": "Provide a valid server."}}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com", "server": "1.2.3.4"})
assert resp.status_code == 422 and resp.json()["detail"]["params"]["field"] == "server"
assert [(e.status) for e in await _events(host_id)] == ["failed"]
@pytest.mark.asyncio
async def test_portprobe_reports_open_or_closed_and_never_the_banner(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag4")
answers = iter([
{"result": "ok", "response": {"message": "Connection to 10.0.0.1 443 port [tcp/https] succeeded!", "payload": "SSH-2.0-SECRET-BANNER"}},
{"result": "ok", "response": {"message": "nc: connect to 10.0.0.1 port 1 (tcp) failed: Operation timed out", "payload": ""}},
])
respx_mock.post(_url("/diagnostics/portprobe/set")).mock(side_effect=lambda r: httpx.Response(200, json=next(answers)))
opened = await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "10.0.0.1", "port": 443})
closed = await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "10.0.0.1", "port": 1})
assert opened.json()["open"] is True and closed.json()["open"] is False
assert "SECRET-BANNER" not in opened.text
@pytest.mark.asyncio
async def test_dns_lookup(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag5")
respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {
"A": {"answers": ["example.com.\t300\tIN\tA\t192.0.2.1"], "query_time": "42 msec", "server": "10.0.0.1"}}}))
body = (await client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com"})).json()
assert body["records"]["A"]["answers"][0].endswith("192.0.2.1") and body["records"]["A"]["query_time"] == "42 msec"
@pytest.mark.asyncio
async def test_ping_runs_a_job_and_always_cleans_it_up(client, respx_mock, fast_sleep):
host_id = await _host(client, respx_mock, "fw-diag6")
respx_mock.post(_url("/diagnostics/ping/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "uuid": "job-1"}))
start = respx_mock.post(_url("/diagnostics/ping/start/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
row = {"id": "job-1", "status": "running", "send": 4, "received": 4, "loss": "0.0%", "min": 0.3, "avg": 0.4, "max": 0.5, "std-dev": 0.05, "last_error": None}
respx_mock.get(_url("/diagnostics/ping/searchJobs")).mock(return_value=httpx.Response(200, json={"rows": [row]}))
stop = respx_mock.post(_url("/diagnostics/ping/stop/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
remove = respx_mock.post(_url("/diagnostics/ping/remove/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "10.0.0.1", "count": 4})
assert resp.status_code == 200, resp.text
body = resp.json()
assert (body["sent"], body["received"], body["loss"], body["avg"]) == (4, 4, "0.0%", 0.4)
assert start.called and stop.called and remove.called
@pytest.mark.asyncio
async def test_ping_job_is_removed_even_when_polling_fails(client, respx_mock, fast_sleep):
host_id = await _host(client, respx_mock, "fw-diag7")
respx_mock.post(_url("/diagnostics/ping/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "uuid": "job-2"}))
respx_mock.post(_url("/diagnostics/ping/start/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
respx_mock.get(_url("/diagnostics/ping/searchJobs")).mock(side_effect=httpx.ConnectError("gone"))
stop = respx_mock.post(_url("/diagnostics/ping/stop/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
remove = respx_mock.post(_url("/diagnostics/ping/remove/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "10.0.0.1"})
assert resp.status_code == 502
assert stop.called and remove.called # no orphaned job on the firewall
assert [e.status for e in await _events(host_id)] == ["failed"]
@pytest.mark.asyncio
async def test_only_admins_can_run_probes(anon_client, client, respx_mock):
from httpx import ASGITransport, AsyncClient
from app.main import app
host_id = await _host(client, respx_mock, "fw-diag8")
assert (await anon_client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com"})).status_code == 401
created = await client.post("/api/users", json={"username": "diag_ro", "password": "Diag-Read-Only-2026", "role": "read_only"})
assert created.status_code == 201, created.text
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ro:
assert (await ro.post("/api/auth/login", json={"username": "diag_ro", "password": "Diag-Read-Only-2026"})).status_code == 200
probe = respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {}}))
for tool, body in (("dns", {"name": "example.com"}), ("ping", {"target": "10.0.0.1"}), ("traceroute", {"target": "10.0.0.1"}), ("portprobe", {"target": "10.0.0.1", "port": 22})):
assert (await ro.post(f"/api/hosts/{host_id}/diagnostics/{tool}", json=body)).status_code == 403, tool
assert not probe.called
finally:
await client.delete(f"/api/users/{created.json()['id']}")
+19
View File
@@ -538,3 +538,22 @@ async def test_lagg_or_bridge_containing_the_management_device_needs_acknowledge
assert [r["code"] for r in mine["risks"]] == ["management_member"]
other = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet2,vtnet3", "proto": "failover"})).json()
assert other["risks"] == []
@pytest.mark.asyncio
async def test_vxlan_and_gif_are_listed_and_changed_like_the_other_devices(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o5")
respx_mock.get(_url("/interfaces/vxlan_settings/searchItem")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "x1", "vxlanif": "vxlan1", "vxlanid": "100", "vxlanlocal": "10.20.30.57", "vxlanremote": "10.20.30.58",
"vxlangroup": "", "vxlandev": "lan", "secret_like": "dropped"}]}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/vxlan")).json()
assert body["rows"][0]["vxlanid"] == "100" and "secret_like" not in body["rows"][0]
_backup(respx_mock)
add = respx_mock.post(_url("/interfaces/gif_settings/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "g1"}))
respx_mock.post(_url("/interfaces/gif_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/gif_settings/getItem/g1", "gif", {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"})
change = {"kind": "gif", "action": "add", "fields": {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"}}
plan = (await _preview(client, host_id, **change)).json()
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) == {"gif": change["fields"]}
+32 -1
View File
@@ -3131,7 +3131,7 @@ Vergabe von DHCP-Leases (Kea ist auf den Test-Firewalls deaktiviert).
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 automatisches Rolling-Apply auf CARP-Paaren (nur das
Übertragen auf den Partner per Klick), keine VXLAN-/GIF-Verwaltung, keine Kea-DDNS-Felder.
Übertragen auf den Partner per Klick), keine Kea-DDNS-Felder.
### Erweiterung 1.13.0: Geräte, Host-System, Abgleich im Cluster
@@ -3176,6 +3176,37 @@ Abgleich vor und nach dem Angleichen, Überschreiben und Löschen eines Tunables
Snapshot anlegen/löschen, alle neuen Seiten in Deutsch und Englisch (Dark Mode),
axe-core über die ganze App ohne Verstöße, Kopfzeile in einer Zeile ab 768 px.
### Erweiterung 1.14.0: Diagnose, VXLAN, GIF
- **Netzwerk › Geräte** kennt jetzt auch **VXLAN** und **GIF** (gleiche Engine wie LAGG, Bridge
und GRE: Vorschau, Backup, Anwenden, Zurücklesen, Audit).
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung (TCP) und DNS-Abfrage, die die
ausgewählte Firewall selbst ausführt (`/api/hosts/{id}/diagnostics/{ping|traceroute|portprobe|dns}`,
`services/diagnostics_service.py`). Sicherheitsmerkmale:
- nur für Admins (alles sind POSTs, die Nur-Lesen-Rolle bekommt 403),
- das Ziel wird vor dem Senden streng geprüft (nur Hostname-/IP-Zeichen, Quelladresse nur als IP,
Anzahl 1–10, Port 1–65535); die Firewall prüft zusätzlich selbst und lehnt Ungültiges mit 422 ab,
- jeder Lauf steht mit Tool und Ziel im Audit-Log (`kind=diagnostic`), Ergebnisse werden nicht gespeichert,
- höchstens vier Abfragen gleichzeitig pro OPNexus-Prozess,
- die Port-Prüfung gibt nie den Banner/Payload der Gegenstelle weiter, nur „offen/nicht erreichbar“,
- ein Ping läuft als kurzer Job und wird **immer** wieder gestoppt und entfernt, auch wenn das Abfragen
der Zwischenstände scheitert (live geprüft: danach keine Jobs auf den Firewalls).
Ein Port-Test auf einen geschlossenen oder blockierten Port dauert bei OPNsense 10 Sekunden; die
Diagnose-Aufrufe haben deshalb ein Zeitlimit von 40 s.
- **Bewusst nicht gebaut: Packet-Capture.** Mitschnitte enthalten Nutzdaten fremder Verbindungen und
Zugangsdaten im Klartext; das gehört nicht in eine Verwaltungsoberfläche ohne eigenes Aufbewahrungs-
und Berechtigungskonzept.
- **Fehlerkorrektur:** Die neue Route `/hostsystem` beginnt mit `/hosts`; die Navigation markierte
dadurch „Übersicht › Hosts“ und der Seitentitel zeigte „Hosts“. Der Pfadvergleich prüft jetzt auf
die ganze Seite oder Unterseiten.
**Verifikation 1.14.0 (live):** VXLAN und GIF auf test-fw-3 (anlegen, ändern, auflisten, löschen, Geräteliste
danach unverändert). Alle vier Diagnose-Werkzeuge auf test-fw-1 und -3 gegen das Gateway im Management-Netz
(Ping 4/4 und 3/3 verloren zu einer nicht erreichbaren Adresse, Traceroute, Port 443 offen und Port 1 nach
10 s „nicht erreichbar“, DNS mit mehreren Record-Typen, ungültige Eingabe → 422). Browser (1440 und 375 px):
alle vier Werkzeuge, VXLAN und GIF über die Geräteseite, Englisch/Dark Mode ohne deutsche Reste,
axe-core über die ganze App ohne Verstöße. 560 Backend-Tests grün.
## Definition of Done — Stand
- [x] `docker compose up -d --build` startet DB, Backend, Frontend
+4 -1
View File
@@ -13,6 +13,7 @@ import { useToast } from "@/components/ui/Toast";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { api, Host, HostSnapshot, NetworkKind } from "@/lib/api";
import { useReadOnly } from "@/lib/useReadOnly";
import { HostDiagnostics } from "@/components/HostDiagnostics";
import { HardDrive, Plus, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
@@ -24,7 +25,7 @@ const KIND_BY_TAB: Record<string, NetworkKind> = { tunables: "tunable", cron: "c
export default function HostSystemPage() {
const { t, errorMessage } = useLanguage();
const { tab } = useParams<{ tab: string }>();
const section = ["tunables", "cron", "syslog", "snapshots"].includes(tab) ? tab : "tunables";
const section = ["tunables", "cron", "syslog", "snapshots", "diagnostics"].includes(tab) ? tab : "tunables";
const readOnly = useReadOnly();
const [hosts, setHosts] = useState<Host[] | null>(null);
const [hostId, setHostId] = useState("");
@@ -91,6 +92,8 @@ export default function HostSystemPage() {
readOnly={readOnly}
onChange={setChange}
/>
) : section === "diagnostics" ? (
<HostDiagnostics hostId={hostId} readOnly={readOnly} />
) : (
<Snapshots hostId={hostId} readOnly={readOnly} />
)}
+2
View File
@@ -33,6 +33,8 @@ const EXTRA_SECTIONS: Partial<Record<Section, { kind: NetworkKind; titleKey: str
{ kind: "bridge", titleKey: "network.kinds.bridge" },
{ kind: "loopback", titleKey: "network.kinds.loopback" },
{ kind: "gre", titleKey: "network.kinds.gre" },
{ kind: "gif", titleKey: "network.kinds.gif" },
{ kind: "vxlan", titleKey: "network.kinds.vxlan" },
],
};
+2 -2
View File
@@ -10,7 +10,7 @@ import { APP_CODENAME, APP_VERSION } from "@/lib/version";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useState } from "react";
import { navGroups, navLabelKey } from "@/components/Nav";
import { matchesNav, navGroups, navLabelKey } from "@/components/Nav";
/**
* Single-role auth guard — there's exactly one protected "area" (everything
@@ -40,7 +40,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
// The app is a client-rendered SPA with one static metadata title; screen reader users rely on
// 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 item = navGroups.flatMap((g) => g.items).find((i) => matchesNav(pathname, i.href));
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>;
+185
View File
@@ -0,0 +1,185 @@
"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 { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { Tabs } from "@/components/ui/Tabs";
import { api, DnsLookupResult, PingResult, PortprobeResult, TracerouteResult } from "@/lib/api";
import { useState } from "react";
type Tool = "ping" | "traceroute" | "portprobe" | "dns";
type Result = { tool: "ping"; data: PingResult } | { tool: "traceroute"; data: TracerouteResult } | { tool: "portprobe"; data: PortprobeResult } | { tool: "dns"; data: DnsLookupResult };
/**
* Network probes run FROM the selected firewall. Results are only shown, never stored; every run is
* recorded in the audit log by the backend (target and tool).
*/
export function HostDiagnostics({ hostId, readOnly }: { hostId: string; readOnly: boolean }) {
const { t, errorMessage } = useLanguage();
const [tool, setTool] = useState<Tool>("ping");
const [target, setTarget] = useState("");
const [ipproto, setIpproto] = useState("inet");
const [source, setSource] = useState("");
const [count, setCount] = useState("4");
const [packetsize, setPacketsize] = useState("");
const [protocol, setProtocol] = useState("icmp");
const [port, setPort] = useState("443");
const [server, setServer] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<Result | null>(null);
async function run(e: React.FormEvent) {
e.preventDefault();
setRunning(true);
setError(null);
setResult(null);
try {
const src = source.trim() || undefined;
if (tool === "ping")
setResult({ tool, data: await api.runPing(hostId, { target: target.trim(), ipproto, count: Number(count), packetsize: packetsize ? Number(packetsize) : null, source: src }) });
else if (tool === "traceroute") setResult({ tool, data: await api.runTraceroute(hostId, { target: target.trim(), ipproto, protocol, source: src }) });
else if (tool === "portprobe") setResult({ tool, data: await api.runPortprobe(hostId, { target: target.trim(), port: Number(port), ipproto, source: src }) });
else setResult({ tool, data: await api.runDnsLookup(hostId, { name: target.trim(), server: server.trim() || undefined }) });
} catch (err) {
setError(errorMessage(err, "hostsystem.diag.failed"));
} finally {
setRunning(false);
}
}
const tabs = (["ping", "traceroute", "portprobe", "dns"] as Tool[]).map((k) => ({ key: k, label: t(`hostsystem.diag.tools.${k}`) }));
return (
<div>
<Tabs items={tabs} active={tool} onChange={(k) => { setTool(k as Tool); setResult(null); setError(null); }} className="overflow-x-auto px-3 [&>button]:whitespace-nowrap" />
<form onSubmit={run} className="space-y-4 p-5">
<fieldset disabled={readOnly} className="contents">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label={tool === "dns" ? t("hostsystem.diag.name") : t("hostsystem.diag.target")}>
<Input required value={target} onChange={(e) => setTarget(e.target.value)} autoComplete="off" placeholder={tool === "dns" ? "example.com" : "192.0.2.1"} />
</FieldGroup>
{tool === "portprobe" && (
<FieldGroup label={t("hostsystem.diag.port")}>
<Input required type="number" min={1} max={65535} value={port} onChange={(e) => setPort(e.target.value)} />
</FieldGroup>
)}
{tool === "ping" && (
<FieldGroup label={t("hostsystem.diag.count")}>
<Input required type="number" min={1} max={10} value={count} onChange={(e) => setCount(e.target.value)} />
</FieldGroup>
)}
{tool === "traceroute" && (
<FieldGroup label={t("hostsystem.diag.protocol")}>
<Select value={protocol} onChange={(e) => setProtocol(e.target.value)}>
<option value="icmp">ICMP</option>
<option value="udp">UDP</option>
</Select>
</FieldGroup>
)}
{tool === "dns" ? (
<FieldGroup label={t("hostsystem.diag.server")} hint={t("hostsystem.diag.serverHint")}>
<Input value={server} onChange={(e) => setServer(e.target.value)} autoComplete="off" />
</FieldGroup>
) : (
<>
<FieldGroup label={t("hostsystem.diag.ipproto")}>
<Select value={ipproto} onChange={(e) => setIpproto(e.target.value)}>
<option value="inet">IPv4</option>
<option value="inet6">IPv6</option>
</Select>
</FieldGroup>
<FieldGroup label={t("hostsystem.diag.source")} hint={t("hostsystem.diag.sourceHint")}>
<Input value={source} onChange={(e) => setSource(e.target.value)} autoComplete="off" />
</FieldGroup>
</>
)}
{tool === "ping" && (
<FieldGroup label={t("hostsystem.diag.packetsize")}>
<Input type="number" min={1} max={9000} value={packetsize} onChange={(e) => setPacketsize(e.target.value)} />
</FieldGroup>
)}
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={running || !target.trim()} loading={running}>
{running ? t("hostsystem.diag.running") : t("hostsystem.diag.run")}
</Button>
<span className="text-xs text-slate-500">{t("hostsystem.diag.auditNote")}</span>
</div>
</fieldset>
</form>
{error && <p role="alert" className="px-5 pb-4 text-sm text-red-600">{error}</p>}
{result && (
<div className="border-t border-slate-200 px-5 py-4" aria-live="polite">
{result.tool === "ping" && <PingView r={result.data} />}
{result.tool === "traceroute" && <TracerouteView r={result.data} />}
{result.tool === "portprobe" && <PortView r={result.data} />}
{result.tool === "dns" && <DnsView r={result.data} />}
</div>
)}
</div>
);
}
function PingView({ r }: { r: PingResult }) {
const { t } = useLanguage();
const ok = r.received > 0;
const ms = (v: number | null) => (v === null || v === undefined ? "–" : `${v} ms`);
return (
<div className="space-y-2 text-sm">
<Badge tone={ok ? "success" : "danger"}>{ok ? t("hostsystem.diag.reachable") : t("hostsystem.diag.unreachable")}</Badge>
<p>{t("hostsystem.diag.pingSummary", { sent: r.sent, received: r.received, loss: r.loss })}</p>
<p className="font-mono text-xs text-slate-600">min {ms(r.min)} · avg {ms(r.avg)} · max {ms(r.max)} · σ {ms(r.stddev)}</p>
{r.error && <p className="text-amber-700">{r.error}</p>}
</div>
);
}
function TracerouteView({ r }: { r: TracerouteResult }) {
const { t } = useLanguage();
return (
<div className="space-y-2">
<p className="font-mono text-xs text-slate-600">{r.notice}</p>
{r.hops.length === 0 ? <p className="text-sm text-slate-500">{t("network.empty")}</p> : (
<Table>
<THead><TRow><TH>{t("hostsystem.diag.hop")}</TH><TH>{t("network.col.address")}</TH><TH>{t("hostsystem.diag.hostname")}</TH><TH>AS</TH><TH>{t("hostsystem.diag.time")}</TH></TRow></THead>
<TBody>
{r.hops.map((h) => (
<TRow key={h.ttl + h.address}><TD>{h.ttl}</TD><TD className="font-mono text-xs">{h.address || "*"}</TD><TD className="font-mono text-xs">{h.host}</TD><TD>{h.as}</TD><TD className="font-mono text-xs">{h.probes}</TD></TRow>
))}
</TBody>
</Table>
)}
</div>
);
}
function PortView({ r }: { r: PortprobeResult }) {
const { t } = useLanguage();
return (
<div className="space-y-2 text-sm">
<Badge tone={r.open ? "success" : "danger"}>{r.open ? t("hostsystem.diag.open", { port: r.port }) : t("hostsystem.diag.closed", { port: r.port })}</Badge>
<p className="font-mono text-xs text-slate-600">{r.message}</p>
</div>
);
}
function DnsView({ r }: { r: DnsLookupResult }) {
const { t } = useLanguage();
const types = Object.keys(r.records);
if (types.length === 0) return <p className="text-sm text-slate-500">{t("hostsystem.diag.noRecords", { name: r.name })}</p>;
return (
<div className="space-y-3">
{types.map((type) => (
<div key={type}>
<p className="text-sm font-medium">{type} <span className="font-normal text-slate-500">· {r.records[type].server} · {r.records[type].query_time}</span></p>
<ul className="font-mono text-xs text-slate-700">
{r.records[type].answers.map((a, i) => (<li key={i}>{a.replace(/\t/g, " ")}</li>))}
</ul>
</div>
))}
</div>
);
}
+9 -2
View File
@@ -9,6 +9,7 @@ import {
Globe,
GitBranch,
HardDrive,
Activity,
Clock,
Camera,
Sliders,
@@ -94,6 +95,7 @@ export const navGroups: NavGroup[] = [
{ href: "/hostsystem/cron", label: "Cron", icon: Clock, keywords: ["Zeitplan", "Aufgaben", "Jobs", "geplante Aufgaben"] },
{ href: "/hostsystem/syslog", label: "Syslog", icon: ScrollText, keywords: ["Remote-Logging", "Log-Ziel", "Logserver"] },
{ href: "/hostsystem/snapshots", label: "Snapshots", icon: Camera, keywords: ["ZFS", "Boot-Umgebung", "Sicherung vor Änderung"] },
{ href: "/hostsystem/diagnostics", label: "Diagnose", icon: Activity, keywords: ["Ping", "Traceroute", "Port prüfen", "Portprobe", "DNS-Abfrage", "Erreichbarkeit", "Fehlersuche"] },
],
},
{
@@ -169,13 +171,18 @@ export function navLabelKey(href: string): string {
return `nav.items.${href.slice(1).replace(/\//g, "-")}`;
}
/** True when pathname is the nav item's page or below it ("/hostsystem/x" must NOT match "/hosts"). */
export function matchesNav(pathname: string | null | undefined, href: string): boolean {
return !!pathname && (pathname === href || pathname.startsWith(href + "/"));
}
export function Nav() {
const { t } = useLanguage();
const pathname = usePathname();
const router = useRouter();
const [readOnly, setReadOnly] = useState(false);
const activeGroup = navGroups.find((g) => g.items.some((i) => pathname?.startsWith(i.href))) ?? null;
const activeGroup = navGroups.find((g) => g.items.some((i) => matchesNav(pathname, i.href))) ?? null;
useEffect(() => {
api.me().then((state) => setReadOnly(state.role === "read_only")).catch(() => {});
@@ -246,7 +253,7 @@ export function Nav() {
<div className="border-t border-slate-100 bg-slate-50">
<nav aria-label={t("nav.subNavigation")} className="mx-auto flex max-w-6xl items-center gap-0.5 overflow-x-auto px-3 py-1.5 sm:px-6 sm:gap-1">
{activeGroup.items.map(({ href, icon: Icon }) => {
const active = pathname?.startsWith(href);
const active = matchesNav(pathname, href);
return (
<Link
key={href}
@@ -200,6 +200,24 @@ const FIELDS: Record<NetworkKind, FieldDef[]> = {
{ name: "domain", type: "text" },
{ name: "description", type: "text" },
],
gif: [
{ name: "local-addr", type: "text" },
{ name: "remote-addr", type: "text" },
{ name: "tunnel-local-addr", type: "text" },
{ name: "tunnel-remote-addr", type: "text" },
{ name: "tunnel-remote-net", type: "text" },
{ name: "descr", type: "text" },
],
vxlan: [
{ name: "deviceId", type: "text" },
{ name: "vxlanid", type: "text" },
{ name: "vxlanlocal", type: "text" },
{ name: "vxlanlocalport", type: "text" },
{ name: "vxlanremote", type: "text" },
{ name: "vxlanremoteport", type: "text" },
{ name: "vxlangroup", type: "text" },
{ name: "vxlandev", type: "select" },
],
route: [
{ name: "network", type: "text" },
{ name: "gateway", type: "select" },
+39 -1
View File
@@ -226,9 +226,39 @@ export interface NetRoute {
export type NetworkKind =
| "vlan" | "vip" | "gateway" | "route" | "interface" | "dns_host" | "dhcp_host" | "kea_subnet" | "kea_reservation"
| "tunable" | "cron_job" | "syslog_destination" | "gateway_group" | "lagg" | "bridge" | "loopback" | "gre"
| "tunable" | "cron_job" | "syslog_destination" | "gateway_group" | "lagg" | "bridge" | "loopback" | "gre" | "gif" | "vxlan"
| "dns_forward" | "dhcp_range";
export interface PingResult {
target: string;
sent: number;
received: number;
loss: string;
min: number | null;
avg: number | null;
max: number | null;
stddev: number | null;
error: string | null;
}
export interface TracerouteResult {
target: string;
notice: string;
hops: { ttl: string; address: string; host: string; as: string; probes: string }[];
}
export interface PortprobeResult {
target: string;
port: number;
open: boolean;
message: string;
}
export interface DnsLookupResult {
name: string;
records: Record<string, { answers: string[]; query_time: string; server: string }>;
}
export interface NetworkObjects {
available: boolean;
columns: string[];
@@ -1106,6 +1136,14 @@ export const api = {
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`),
runPing: (id: string, data: { target: string; ipproto: string; count: number; packetsize?: number | null; source?: string }) =>
request<PingResult>(`/hosts/${id}/diagnostics/ping`, { method: "POST", body: JSON.stringify(data) }),
runTraceroute: (id: string, data: { target: string; ipproto: string; protocol: string; source?: string }) =>
request<TracerouteResult>(`/hosts/${id}/diagnostics/traceroute`, { method: "POST", body: JSON.stringify(data) }),
runPortprobe: (id: string, data: { target: string; port: number; ipproto: string; source?: string }) =>
request<PortprobeResult>(`/hosts/${id}/diagnostics/portprobe`, { method: "POST", body: JSON.stringify(data) }),
runDnsLookup: (id: string, data: { name: string; server?: string }) =>
request<DnsLookupResult>(`/hosts/${id}/diagnostics/dns`, { method: "POST", body: JSON.stringify(data) }),
listNetworkObjects: (id: string, kind: NetworkKind) => request<NetworkObjects>(`/hosts/${id}/network/objects/${kind}`),
getNetworkDrift: () => request<{ clusters: ClusterNetworkDrift[] }>("/network/drift"),
listSnapshots: (id: string) => request<{ supported: boolean; rows: HostSnapshot[] }>(`/hosts/${id}/snapshots`),
+48 -4
View File
@@ -160,7 +160,8 @@
"hostsystem-tunables": "Tunables",
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots"
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnose"
}
},
"dashboard": {
@@ -1835,7 +1836,9 @@
"loopback": "Loopback",
"gre": "GRE-Tunnel",
"dns_forward": "DNS-Weiterleitungen",
"dhcp_range": "DHCP-Bereiche (dnsmasq)"
"dhcp_range": "DHCP-Bereiche (dnsmasq)",
"gif": "GIF-Tunnel",
"vxlan": "VXLAN"
},
"field": {
"if": "Eltern-Schnittstelle",
@@ -1963,7 +1966,15 @@
"start_addr": "Von",
"end_addr": "Bis",
"subnet_mask": "Subnetzmaske",
"domain_type": "Domain-Art"
"domain_type": "Domain-Art",
"vxlanid": "VNI",
"vxlanlocal": "Lokale Adresse",
"vxlanlocalport": "Lokaler Port",
"vxlanremote": "Gegenstelle",
"vxlanremoteport": "Port der Gegenstelle",
"vxlangroup": "Multicast-Gruppe",
"vxlandev": "Gerät",
"gifif": "Gerät"
},
"risks": {
"management_interface": "Diese Änderung betrifft {device}, die Schnittstelle, über die OPNexus die Firewall erreicht. Die Verbindung kann abbrechen.",
@@ -2069,7 +2080,8 @@
"tunables": "Systemparameter der Firewall (sysctl). Die Werte der Firewall selbst bleiben unberührt, eigene Einträge überschreiben sie.",
"cron": "Zeitgesteuerte Aufgaben, die die Firewall selbst ausführt.",
"syslog": "Entfernte Log-Ziele, an die die Firewall ihre Meldungen schickt.",
"snapshots": "ZFS-Snapshots der Firewall als zusätzliches Sicherheitsnetz vor größeren Änderungen. Aktivieren (mit Neustart) geschieht bewusst nur in OPNsense selbst."
"snapshots": "ZFS-Snapshots der Firewall als zusätzliches Sicherheitsnetz vor größeren Änderungen. Aktivieren (mit Neustart) geschieht bewusst nur in OPNsense selbst.",
"diagnostics": "Netzwerk-Abfragen, die die ausgewählte Firewall selbst ausführt (Erreichbarkeit, Wege, Ports, Namensauflösung). Ergebnisse werden nur angezeigt; jeder Lauf steht mit Ziel im Audit-Log."
},
"snapshots": {
"create": "Snapshot anlegen",
@@ -2088,6 +2100,38 @@
"deleted": "Snapshot „{name}“ gelöscht.",
"deleteFailed": "Snapshot konnte nicht gelöscht werden.",
"deleteConfirm": "Snapshot „{name}“ wirklich löschen?"
},
"diag": {
"tools": {
"ping": "Ping",
"traceroute": "Traceroute",
"portprobe": "Port prüfen",
"dns": "DNS-Abfrage"
},
"target": "Ziel (Host oder IP)",
"name": "Name",
"ipproto": "IP-Version",
"source": "Quelladresse (optional)",
"sourceHint": "Eigene Adresse der Firewall, von der aus abgefragt wird. Leer = automatisch.",
"count": "Anzahl (1–10)",
"packetsize": "Paketgröße (optional)",
"protocol": "Protokoll",
"port": "Port",
"server": "DNS-Server (optional)",
"serverHint": "Leer = die Resolver der Firewall.",
"run": "Ausführen",
"running": "Läuft…",
"auditNote": "Wird im Audit-Log festgehalten.",
"failed": "Die Abfrage ist fehlgeschlagen.",
"reachable": "Erreichbar",
"unreachable": "Nicht erreichbar",
"pingSummary": "{sent} gesendet, {received} empfangen, {loss} Verlust",
"hop": "Hop",
"hostname": "Name",
"time": "Zeit",
"open": "Port {port} offen",
"closed": "Port {port} nicht erreichbar",
"noRecords": "Keine Antwort für „{name}“."
}
}
}
+48 -4
View File
@@ -160,7 +160,8 @@
"hostsystem-tunables": "Tunables",
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots"
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnostics"
}
},
"dashboard": {
@@ -1835,7 +1836,9 @@
"loopback": "Loopback",
"gre": "GRE tunnel",
"dns_forward": "DNS forwards",
"dhcp_range": "DHCP ranges (dnsmasq)"
"dhcp_range": "DHCP ranges (dnsmasq)",
"gif": "GIF tunnel",
"vxlan": "VXLAN"
},
"field": {
"if": "Parent interface",
@@ -1963,7 +1966,15 @@
"start_addr": "From",
"end_addr": "To",
"subnet_mask": "Subnet mask",
"domain_type": "Domain type"
"domain_type": "Domain type",
"vxlanid": "VNI",
"vxlanlocal": "Local address",
"vxlanlocalport": "Local port",
"vxlanremote": "Remote address",
"vxlanremoteport": "Remote port",
"vxlangroup": "Multicast group",
"vxlandev": "Device",
"gifif": "Device"
},
"risks": {
"management_interface": "This change affects {device}, the interface OPNexus uses to reach the firewall. The connection may drop.",
@@ -2069,7 +2080,8 @@
"tunables": "System parameters of the firewall (sysctl). The firewall's own values stay untouched; your own entries override them.",
"cron": "Scheduled tasks the firewall runs itself.",
"syslog": "Remote log targets the firewall sends its messages to.",
"snapshots": "ZFS snapshots of the firewall as an extra safety net before bigger changes. Activating one (with a reboot) is deliberately left to OPNsense itself."
"snapshots": "ZFS snapshots of the firewall as an extra safety net before bigger changes. Activating one (with a reboot) is deliberately left to OPNsense itself.",
"diagnostics": "Network queries the selected firewall runs itself (reachability, paths, ports, name resolution). Results are only shown; every run is recorded with its target in the audit log."
},
"snapshots": {
"create": "Create snapshot",
@@ -2088,6 +2100,38 @@
"deleted": "Snapshot “{name}” deleted.",
"deleteFailed": "Could not delete the snapshot.",
"deleteConfirm": "Really delete snapshot “{name}”?"
},
"diag": {
"tools": {
"ping": "Ping",
"traceroute": "Traceroute",
"portprobe": "Port check",
"dns": "DNS lookup"
},
"target": "Target (host or IP)",
"name": "Name",
"ipproto": "IP version",
"source": "Source address (optional)",
"sourceHint": "The firewall's own address to query from. Empty = automatic.",
"count": "Count (1–10)",
"packetsize": "Packet size (optional)",
"protocol": "Protocol",
"port": "Port",
"server": "DNS server (optional)",
"serverHint": "Empty = the firewall's resolvers.",
"run": "Run",
"running": "Running…",
"auditNote": "Recorded in the audit log.",
"failed": "The query failed.",
"reachable": "Reachable",
"unreachable": "Unreachable",
"pingSummary": "{sent} sent, {received} received, {loss} loss",
"hop": "Hop",
"hostname": "Name",
"time": "Time",
"open": "Port {port} open",
"closed": "Port {port} not reachable",
"noRecords": "No answer for “{name}”."
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
// Manually bumped whenever a notable change ships — mirrors the dated
// entries already kept in the project README/CHANGELOG.md, no build
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
export const APP_VERSION = "1.13.0";
export const APP_CODENAME = "Buzzard";
export const APP_VERSION = "1.14.0";
export const APP_CODENAME = "Eagle";
export const APP_MAINTAINER = "Julian Boldt";
+1 -1
View File
@@ -19,7 +19,7 @@ const BASE = process.env.OPNEXUS_URL || 'http://localhost:3100';
const SESSION = process.env.OPNEXUS_SESSION;
if (!SESSION) { console.error('OPNEXUS_SESSION (value of the opnexus-session cookie) is required'); process.exit(2); }
const routes = ['/dashboard', '/hosts', '/cluster', '/rules', '/aliases', '/nat', '/groups', '/policy-hierarchy', '/ipsec', '/openvpn', '/wireguard', '/network/interfaces', '/network/vlans', '/network/vips', '/network/gateways', '/network/routes', '/network/dns', '/network/dhcp', '/network/devices', '/network/compare', '/hostsystem/tunables', '/hostsystem/cron', '/hostsystem/syslog', '/hostsystem/snapshots', '/certificates', '/logs', '/updates', '/audit', '/users', '/settings', '/about'];
const routes = ['/dashboard', '/hosts', '/cluster', '/rules', '/aliases', '/nat', '/groups', '/policy-hierarchy', '/ipsec', '/openvpn', '/wireguard', '/network/interfaces', '/network/vlans', '/network/vips', '/network/gateways', '/network/routes', '/network/dns', '/network/dhcp', '/network/devices', '/network/compare', '/hostsystem/tunables', '/hostsystem/cron', '/hostsystem/syslog', '/hostsystem/snapshots', '/hostsystem/diagnostics', '/certificates', '/logs', '/updates', '/audit', '/users', '/settings', '/about'];
const browser = await chromium.launch();
let total = 0;
for (const [theme, width] of [['light', 1440], ['dark', 1440], ['light', 375]]) {
+1 -1
View File
@@ -100,7 +100,7 @@ try {
const pages = [
["/dashboard"], ["/hosts", /^add host/i], ["/cluster"], ["/rules", /^new rule/i], ["/policy-hierarchy", /^new group/i],
["/network/interfaces"], ["/network/vlans"], ["/network/vips"], ["/network/gateways"], ["/network/routes"], ["/network/dns"], ["/network/dhcp"], ["/network/devices"], ["/network/compare"], ["/hostsystem/tunables"], ["/hostsystem/cron"], ["/hostsystem/syslog"], ["/hostsystem/snapshots"],
["/network/interfaces"], ["/network/vlans"], ["/network/vips"], ["/network/gateways"], ["/network/routes"], ["/network/dns"], ["/network/dhcp"], ["/network/devices"], ["/network/compare"], ["/hostsystem/tunables"], ["/hostsystem/cron"], ["/hostsystem/syslog"], ["/hostsystem/snapshots"], ["/hostsystem/diagnostics"],
["/groups", /^new group/i], ["/aliases", /^new alias/i], ["/nat", /^new rule/i], ["/ipsec", /^new tunnel/i],
["/openvpn", /^new instance/i], ["/wireguard", /^new tunnel/i], ["/certificates", /^new certificate/i], ["/logs"], ["/audit"],
["/users", /^new user/i], ["/settings"], ["/about"],
+10 -8
View File
@@ -27,7 +27,7 @@ export const content = {
},
nav: [["#funktionen", "Funktionen"], ["#ansichten", "Ansichten"], ["#mobil", "Mobil"], ["#sicherheit", "Sicherheit"], ["#installation", "Installation"], ["#faq", "FAQ"]],
hero: {
eyebrow: "Version 1.12.0 · Quellcode einsehbar · selbst gehostet",
eyebrow: "Version 1.13.0 · Quellcode einsehbar · selbst gehostet",
h1: `Alle OPNsense-Firewalls.<br><span class="grad">Eine Konsole.</span>`,
lead: "OPNexus pflegt Regeln, NAT, VPN, Zertifikate und Updates mehrerer OPNsense-Firewalls zentral – und rollt sie per API aus, mit Vorschau, Bestätigung und automatischem Rollback. Auf dem Desktop, Tablet und Smartphone.",
cta1: "Jetzt installieren", ctaHead: "Installieren", cta2: "Quellcode ansehen",
@@ -178,6 +178,7 @@ export const content = {
eyebrow: "Entwicklung", h2: "Regelmäßige Releases mit Raubvogel-Namen",
lead: "OPNexus folgt Semantic Versioning. Jede Minor-Version bekommt einen Codenamen – die letzten Stationen:",
items: [
["1.13.0", "Buzzard", "9. Okt. 2026", "Mehr Verwaltung pro Firewall: LAGG, Bridge, GRE, Gateway-Gruppen, Tunables, Cron, Syslog und Snapshots – dazu ein Abgleich der Cluster-Knoten mit Alarm und das Übertragen auf den Partner."],
["1.12.0", "Condor", "9. Okt. 2026", "Neuer Bereich „Netzwerk“: Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, DNS und DHCP je Firewall – mit Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen. Von Hand angelegte Regeln lassen sich übernehmen."],
["1.11.1", "Merlin", "9. Okt. 2026", "Fehlerkorrekturen aus dem Live-Test: Zertifikate, OpenVPN, WireGuard, IPsec und ACME auf echten Firewalls, bessere Fehlermeldungen, stabilere Alarme, Handy-Ansicht."],
["1.11.0", "Merlin", "7. Okt. 2026", "Sicherheits-Release: Sitzungen enden beim Passwortwechsel, SMTP prüft Zertifikate, Firewalls nur per HTTPS, Rollback-Spur auch bei unklarem Ausgang, mehr Audit-Einträge."],
@@ -193,7 +194,7 @@ export const content = {
faq: {
h2: "Häufige Fragen",
items: [
["Gibt es schon eine fertige Version?", "<p>Ja. Version 1.12.0 „Condor“ ist die aktuelle stabile Version – siehe <a href=\"#installation\">Installation</a>. Änderungen stehen im Changelog.</p>"],
["Gibt es schon eine fertige Version?", "<p>Ja. Version 1.13.0 „Buzzard“ ist die aktuelle stabile Version – siehe <a href=\"#installation\">Installation</a>. Änderungen stehen im Changelog.</p>"],
["Muss ich etwas auf den Firewalls installieren?", "<p>Nein. OPNexus nutzt die REST-API des OPNsense-Kerns. Die Filter- und Alias-API ist ohne zusätzliches Plugin vorhanden. Du hinterlegst pro Firewall einen API-Schlüssel.</p>"],
["Was passiert, wenn mich eine Regel aussperrt?", "<p>Jede Änderung gilt zunächst nur auf Probe. Bestätigst du sie nicht innerhalb des Zeitfensters (Standard: 3 Minuten), stellt OPNexus automatisch den vorherigen Stand her. Schlägt das selbst fehl, bleibt der Fehler sichtbar und wird gemeldet.</p>"],
["Wo liegen meine Daten?", "<p>Auf deinem eigenen Server (Docker Compose mit PostgreSQL). Es gibt keine Cloud und kein Konto bei uns. Der Versionshinweis ist standardmäßig aus; wenn du ihn aktivierst, geht höchstens alle 12 Stunden eine einzelne Anfrage an <code>updates.opnexus.dev</code> – ohne Instanz- oder Nutzerdaten.</p>"],
@@ -206,7 +207,7 @@ export const content = {
},
install: {
eyebrow: "Loslegen", h2: "Installation",
status: "<strong>Aktuelle Version: 1.12.0 „Condor“.</strong> Gegen echte OPNsense-Instanzen entwickelt und getestet; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Rückmeldungen sind willkommen.",
status: "<strong>Aktuelle Version: 1.13.0 „Buzzard“.</strong> Gegen echte OPNsense-Instanzen entwickelt und getestet; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Rückmeldungen sind willkommen.",
lead: "OPNexus läuft als Docker-Compose-Stack auf einem eigenen Server – deine Firewall-Daten bleiben bei dir.",
code: `<span class="c"># Voraussetzung: Docker mit Compose-Plugin</span>
git clone ${SOURCE_URL}.git
@@ -224,7 +225,7 @@ docker compose up -d --build`,
],
docs: "Dokumentation & Quellcode",
},
cta: { h2: "Jetzt ausprobieren", p: "Version 1.12.0 ist da: Funktionen und Screenshots ansehen oder gleich installieren.", b1: "Jetzt installieren", b2: "Screenshots ansehen" },
cta: { h2: "Jetzt ausprobieren", p: "Version 1.13.0 ist da: Funktionen und Screenshots ansehen oder gleich installieren.", b1: "Jetzt installieren", b2: "Screenshots ansehen" },
footer: {
tagline: "Zentrale Verwaltung für OPNsense-Firewalls. Kostenlos, selbst gehostet.",
langTitle: "Sprache",
@@ -273,7 +274,7 @@ docker compose up -d --build`,
},
nav: [["#features", "Features"], ["#views", "Views"], ["#mobile", "Mobile"], ["#security", "Security"], ["#install", "Install"], ["#faq", "FAQ"]],
hero: {
eyebrow: "Version 1.12.0 · Source available · self-hosted",
eyebrow: "Version 1.13.0 · Source available · self-hosted",
h1: `All your OPNsense firewalls.<br><span class="grad">One console.</span>`,
lead: "OPNexus manages rules, NAT, VPN, certificates and updates across your OPNsense firewalls from one place – and pushes them out over the API with preview, confirmation and automatic rollback. On desktop, tablet and phone.",
cta1: "Install now", ctaHead: "Install", cta2: "View source",
@@ -424,6 +425,7 @@ docker compose up -d --build`,
eyebrow: "Development", h2: "Regular releases with bird-of-prey names",
lead: "OPNexus follows semantic versioning. Every minor version gets a codename – the latest milestones:",
items: [
["1.13.0", "Buzzard", "9 Oct 2026", "More per-firewall management: LAGG, bridge, GRE, gateway groups, tunables, cron, syslog and snapshots – plus a comparison of cluster nodes with an alert and one-click transfer to the partner."],
["1.12.0", "Condor", "9 Oct 2026", "New “Network” area: interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP per firewall – with a preview, a backup before every change and a guard against cutting your own connection. Rules created by hand can be adopted."],
["1.11.1", "Merlin", "9 Oct 2026", "Fixes from live testing: certificates, OpenVPN, WireGuard, IPsec and ACME on real firewalls, clearer error messages, steadier alerts, mobile layout."],
["1.11.0", "Merlin", "7 Oct 2026", "Security release: sessions end on password change, SMTP verifies certificates, firewalls over HTTPS only, rollback trail even after an unclear outcome, more audit entries."],
@@ -439,7 +441,7 @@ docker compose up -d --build`,
faq: {
h2: "Frequently asked questions",
items: [
["Is there a finished version yet?", "<p>Yes. Version 1.12.0 “Condor” is the current stable version – see <a href=\"#install\">Installation</a>. Changes are listed in the changelog.</p>"],
["Is there a finished version yet?", "<p>Yes. Version 1.13.0 “Buzzard” is the current stable version – see <a href=\"#install\">Installation</a>. Changes are listed in the changelog.</p>"],
["Do I have to install anything on the firewalls?", "<p>No. OPNexus uses the OPNsense core REST API. The filter and alias API is available without an extra plugin. You store an API key per firewall.</p>"],
["What if a rule locks me out?", "<p>Every change is applied on probation. If you do not confirm within the time window (default: 3 minutes), OPNexus restores the previous state automatically. If that fails, the error stays visible and is reported.</p>"],
["Where is my data?", "<p>On your own server (Docker Compose with PostgreSQL). There is no cloud and no account with us. The version notice is off by default; if you enable it, at most one request every 12 hours goes to <code>updates.opnexus.dev</code> – with no instance or user data.</p>"],
@@ -452,7 +454,7 @@ docker compose up -d --build`,
},
install: {
eyebrow: "Get started", h2: "Installation",
status: "<strong>Current version: 1.12.0 “Condor”.</strong> Developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Feedback is welcome.",
status: "<strong>Current version: 1.13.0 “Buzzard”.</strong> Developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Feedback is welcome.",
lead: "OPNexus runs as a Docker Compose stack on your own server – your firewall data stays with you.",
code: `<span class="c"># Requires Docker with the Compose plugin</span>
git clone ${SOURCE_URL}.git
@@ -470,7 +472,7 @@ docker compose up -d --build`,
],
docs: "Documentation & source",
},
cta: { h2: "Try it now", p: "Version 1.12.0 is out: explore the features and screenshots, or install it right away.", b1: "Install now", b2: "See the screenshots" },
cta: { h2: "Try it now", p: "Version 1.13.0 is out: explore the features and screenshots, or install it right away.", b1: "Install now", b2: "See the screenshots" },
footer: {
tagline: "Central management for OPNsense firewalls. Free, self-hosted.",
langTitle: "Language",
+1 -1
View File
@@ -58,7 +58,7 @@
<div class="fine">
<p>OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p>
<p>Alle Screenshots zeigen fiktive Beispieldaten aus einer Demo-Umgebung.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>
+1 -1
View File
@@ -90,7 +90,7 @@ Inline-Skripte/-Styles aus.
Erst nach dem Veröffentlichen des Release (Tag gepusht, Images verfügbar):
```bash
scripts/make_update_feed.sh v1.13.0 # prüft Version/CHANGELOG, schreibt latest.json
scripts/make_update_feed.sh v1.14.0 # prüft Version/CHANGELOG, schreibt latest.json
```
Danach `website/` neu ausliefern. Vorabversionen werden vom Skript abgelehnt
+1 -1
View File
@@ -70,7 +70,7 @@
<div class="fine">
<p>OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p>
<p>Alle Screenshots zeigen fiktive Beispieldaten aus einer Demo-Umgebung.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>
+7 -7
View File
@@ -23,7 +23,7 @@
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"OPNexus","applicationCategory":"NetworkApplication","operatingSystem":"Linux (Docker)","softwareVersion":"1.12.0","license":"https://polyformproject.org/licenses/shield/1.0.0","isAccessibleForFree":true,"description":"Central management for OPNsense firewalls: rules, NAT, VPN, certificates and updates with preview, confirmation and automatic rollback.","url":"https://opnexus.dev/en/","inLanguage":"en","offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"codeRepository":"https://git.opnexus.dev/opnexus/opnexus"}</script>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"OPNexus","applicationCategory":"NetworkApplication","operatingSystem":"Linux (Docker)","softwareVersion":"1.13.0","license":"https://polyformproject.org/licenses/shield/1.0.0","isAccessibleForFree":true,"description":"Central management for OPNsense firewalls: rules, NAT, VPN, certificates and updates with preview, confirmation and automatic rollback.","url":"https://opnexus.dev/en/","inLanguage":"en","offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"codeRepository":"https://git.opnexus.dev/opnexus/opnexus"}</script>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
@@ -44,7 +44,7 @@
<main id="main">
<section class="hero center">
<div class="wrap">
<span class="badge">Version 1.12.0 · Source available · self-hosted</span>
<span class="badge">Version 1.13.0 · Source available · self-hosted</span>
<h1>All your OPNsense firewalls.<br><span class="grad">One console.</span></h1>
<p class="lead">OPNexus manages rules, NAT, VPN, certificates and updates across your OPNsense firewalls from one place – and pushes them out over the API with preview, confirmation and automatic rollback. On desktop, tablet and phone.</p>
<div class="btns">
@@ -217,14 +217,14 @@
<section id="releases">
<div class="wrap">
<div class="sec-head"><span class="eyebrow">Development</span><h2>Regular releases with bird-of-prey names</h2><p class="lead">OPNexus follows semantic versioning. Every minor version gets a codename – the latest milestones:</p></div>
<ol class="timeline"><li><div class="v">1.12.0 „Condor“<small>9 Oct 2026</small></div><p>New “Network” area: interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP per firewall – with a preview, a backup before every change and a guard against cutting your own connection. Rules created by hand can be adopted.</p></li><li><div class="v">1.11.1 „Merlin“<small>9 Oct 2026</small></div><p>Fixes from live testing: certificates, OpenVPN, WireGuard, IPsec and ACME on real firewalls, clearer error messages, steadier alerts, mobile layout.</p></li><li><div class="v">1.11.0 „Merlin“<small>7 Oct 2026</small></div><p>Security release: sessions end on password change, SMTP verifies certificates, firewalls over HTTPS only, rollback trail even after an unclear outcome, more audit entries.</p></li><li><div class="v">1.10.0 „Lanner“<small>5 Oct 2026</small></div><p>Updatability: release images, <code>opnexus update</code> and <code>rollback</code>, optional new-version notice.</p></li><li><div class="v">1.9.0 „Kite“<small>4 Oct 2026</small></div><p>Versioned schema migrations – upgrades without hand-editing the database.</p></li><li><div class="v">1.8.0 „Harris“<small>4 Oct 2026</small></div><p>Accessibility: WCAG 2.2 AA contrast, keyboard operation, screen-reader labels.</p></li><li><div class="v">1.7.0 „Goshawk“<small>4 Oct 2026</small></div><p>Secure transport: HTTPS proxy, pinned firewall certificates, two-factor sign-in.</p></li><li><div class="v">1.6.0 „Peregrine“<small>4 Oct 2026</small></div><p>A calmer interface: hints as tooltips, log size limit.</p></li><li><div class="v">1.5.0 „Osprey“<small>19 Sep 2026</small></div><p>Multilingual: the entire interface in German and English.</p></li></ol>
<ol class="timeline"><li><div class="v">1.13.0 „Buzzard“<small>9 Oct 2026</small></div><p>More per-firewall management: LAGG, bridge, GRE, gateway groups, tunables, cron, syslog and snapshots – plus a comparison of cluster nodes with an alert and one-click transfer to the partner.</p></li><li><div class="v">1.12.0 „Condor“<small>9 Oct 2026</small></div><p>New “Network” area: interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP per firewall – with a preview, a backup before every change and a guard against cutting your own connection. Rules created by hand can be adopted.</p></li><li><div class="v">1.11.1 „Merlin“<small>9 Oct 2026</small></div><p>Fixes from live testing: certificates, OpenVPN, WireGuard, IPsec and ACME on real firewalls, clearer error messages, steadier alerts, mobile layout.</p></li><li><div class="v">1.11.0 „Merlin“<small>7 Oct 2026</small></div><p>Security release: sessions end on password change, SMTP verifies certificates, firewalls over HTTPS only, rollback trail even after an unclear outcome, more audit entries.</p></li><li><div class="v">1.10.0 „Lanner“<small>5 Oct 2026</small></div><p>Updatability: release images, <code>opnexus update</code> and <code>rollback</code>, optional new-version notice.</p></li><li><div class="v">1.9.0 „Kite“<small>4 Oct 2026</small></div><p>Versioned schema migrations – upgrades without hand-editing the database.</p></li><li><div class="v">1.8.0 „Harris“<small>4 Oct 2026</small></div><p>Accessibility: WCAG 2.2 AA contrast, keyboard operation, screen-reader labels.</p></li><li><div class="v">1.7.0 „Goshawk“<small>4 Oct 2026</small></div><p>Secure transport: HTTPS proxy, pinned firewall certificates, two-factor sign-in.</p></li><li><div class="v">1.6.0 „Peregrine“<small>4 Oct 2026</small></div><p>A calmer interface: hints as tooltips, log size limit.</p></li><li><div class="v">1.5.0 „Osprey“<small>19 Sep 2026</small></div><p>Multilingual: the entire interface in German and English.</p></li></ol>
<p class="mt-s"><a href="https://git.opnexus.dev/opnexus/opnexus/src/branch/main/CHANGELOG.md">All versions in the changelog →</a></p>
</div>
</section>
<section class="alt" id="faq">
<div class="wrap">
<h2 class="center">Frequently asked questions</h2>
<div class="faq"><details><summary>Is there a finished version yet?</summary><div class="a"><p>Yes. Version 1.12.0 “Condor” is the current stable version – see <a href="#install">Installation</a>. Changes are listed in the changelog.</p></div></details><details><summary>Do I have to install anything on the firewalls?</summary><div class="a"><p>No. OPNexus uses the OPNsense core REST API. The filter and alias API is available without an extra plugin. You store an API key per firewall.</p></div></details><details><summary>What if a rule locks me out?</summary><div class="a"><p>Every change is applied on probation. If you do not confirm within the time window (default: 3 minutes), OPNexus restores the previous state automatically. If that fails, the error stays visible and is reported.</p></div></details><details><summary>Where is my data?</summary><div class="a"><p>On your own server (Docker Compose with PostgreSQL). There is no cloud and no account with us. The version notice is off by default; if you enable it, at most one request every 12 hours goes to <code>updates.opnexus.dev</code> – with no instance or user data.</p></div></details><details><summary>Who is OPNexus for?</summary><div class="a"><p>Anyone who runs several OPNsense firewalls of their own – from a homelab to a mid-sized company. Multi-tenancy for service providers with many customers is deliberately <em>not</em> a goal.</p></div></details><details><summary>Does OPNexus support high availability?</summary><div class="a"><p>Yes. CARP pairs are shown as clusters, role changes are logged, and updates run in a guided flow that starts with the BACKUP.</p></div></details><details><summary>What does OPNexus cost?</summary><div class="a"><p>Nothing. Downloading, reading the source, adapting and using it – including commercially for your own firewalls – is free. Not allowed: reselling OPNexus or derivatives, or offering them as a competing product or service. Licence: <a href="https://polyformproject.org/licenses/shield/1.0.0">PolyForm Shield 1.0.0</a> (source-available, not an open-source licence).</p></div></details><details><summary>Is there any connection to OPNsense or Deciso?</summary><div class="a"><p>No. OPNexus is an independent project and is not affiliated with Deciso B.V. or the OPNsense® project. OPNsense® is a trademark of Deciso B.V.</p></div></details><details><summary>How do I update OPNexus?</summary><div class="a"><p>With <code>opnexus update</code>: the tool pulls the new images, waits for “healthy” and rolls back automatically on problems; <code>opnexus rollback</code> also restores the database from the pre-upgrade backup. Ready-made release images (amd64) are available in the registry.</p></div></details></div>
<div class="faq"><details><summary>Is there a finished version yet?</summary><div class="a"><p>Yes. Version 1.13.0 “Buzzard” is the current stable version – see <a href="#install">Installation</a>. Changes are listed in the changelog.</p></div></details><details><summary>Do I have to install anything on the firewalls?</summary><div class="a"><p>No. OPNexus uses the OPNsense core REST API. The filter and alias API is available without an extra plugin. You store an API key per firewall.</p></div></details><details><summary>What if a rule locks me out?</summary><div class="a"><p>Every change is applied on probation. If you do not confirm within the time window (default: 3 minutes), OPNexus restores the previous state automatically. If that fails, the error stays visible and is reported.</p></div></details><details><summary>Where is my data?</summary><div class="a"><p>On your own server (Docker Compose with PostgreSQL). There is no cloud and no account with us. The version notice is off by default; if you enable it, at most one request every 12 hours goes to <code>updates.opnexus.dev</code> – with no instance or user data.</p></div></details><details><summary>Who is OPNexus for?</summary><div class="a"><p>Anyone who runs several OPNsense firewalls of their own – from a homelab to a mid-sized company. Multi-tenancy for service providers with many customers is deliberately <em>not</em> a goal.</p></div></details><details><summary>Does OPNexus support high availability?</summary><div class="a"><p>Yes. CARP pairs are shown as clusters, role changes are logged, and updates run in a guided flow that starts with the BACKUP.</p></div></details><details><summary>What does OPNexus cost?</summary><div class="a"><p>Nothing. Downloading, reading the source, adapting and using it – including commercially for your own firewalls – is free. Not allowed: reselling OPNexus or derivatives, or offering them as a competing product or service. Licence: <a href="https://polyformproject.org/licenses/shield/1.0.0">PolyForm Shield 1.0.0</a> (source-available, not an open-source licence).</p></div></details><details><summary>Is there any connection to OPNsense or Deciso?</summary><div class="a"><p>No. OPNexus is an independent project and is not affiliated with Deciso B.V. or the OPNsense® project. OPNsense® is a trademark of Deciso B.V.</p></div></details><details><summary>How do I update OPNexus?</summary><div class="a"><p>With <code>opnexus update</code>: the tool pulls the new images, waits for “healthy” and rolls back automatically on problems; <code>opnexus rollback</code> also restores the database from the pre-upgrade backup. Ready-made release images (amd64) are available in the registry.</p></div></details></div>
</div>
</section>
<section id="install">
@@ -233,7 +233,7 @@
<span class="eyebrow">Get started</span>
<h2>Installation</h2>
<p class="lead">OPNexus runs as a Docker Compose stack on your own server – your firewall data stays with you.</p>
<p class="callout" role="note"><strong>Current version: 1.12.0 “Condor”.</strong> Developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Feedback is welcome.</p>
<p class="callout" role="note"><strong>Current version: 1.13.0 “Buzzard”.</strong> Developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Feedback is welcome.</p>
<div class="code"><button type="button" class="copy-btn" data-copy data-done="Copied" data-fail="Could not copy automatically – text selected, press Ctrl+C" data-label="Copy"><svg class="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 012-2h9"/></svg><svg class="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span>Copy</span></button><pre tabindex="0"><code><span class="c"># Requires Docker with the Compose plugin</span>
git clone https://git.opnexus.dev/opnexus/opnexus.git
cd OPNexus
@@ -252,7 +252,7 @@ docker compose up -d --build</code></pre></div>
<section class="alt cta center">
<div class="wrap">
<h2>Try it now</h2>
<p class="lead">Version 1.12.0 is out: explore the features and screenshots, or install it right away.</p>
<p class="lead">Version 1.13.0 is out: explore the features and screenshots, or install it right away.</p>
<div class="btns"><a class="btn primary" href="#install">Install now</a><a class="btn ghost" href="/en/screenshots/">See the screenshots</a></div>
</div>
</section>
@@ -269,7 +269,7 @@ docker compose up -d --build</code></pre></div>
<div class="fine">
<p>OPNexus is an independent project and is not affiliated with Deciso B.V. or the OPNsense® project. OPNsense® is a trademark of Deciso B.V.</p>
<p>All screenshots show fictional sample data from a demo environment.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>
+1 -1
View File
@@ -128,7 +128,7 @@
<div class="fine">
<p>OPNexus is an independent project and is not affiliated with Deciso B.V. or the OPNsense® project. OPNsense® is a trademark of Deciso B.V.</p>
<p>All screenshots show fictional sample data from a demo environment.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="fine">
<p>OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p>
<p>Alle Screenshots zeigen fiktive Beispieldaten aus einer Demo-Umgebung.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>
+7 -7
View File
@@ -23,7 +23,7 @@
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"OPNexus","applicationCategory":"NetworkApplication","operatingSystem":"Linux (Docker)","softwareVersion":"1.12.0","license":"https://polyformproject.org/licenses/shield/1.0.0","isAccessibleForFree":true,"description":"Zentrale Verwaltung für OPNsense-Firewalls: Regeln, NAT, VPN, Zertifikate und Updates mit Vorschau, Bestätigung und automatischem Rollback.","url":"https://opnexus.dev/","inLanguage":"de","offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"codeRepository":"https://git.opnexus.dev/opnexus/opnexus"}</script>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"OPNexus","applicationCategory":"NetworkApplication","operatingSystem":"Linux (Docker)","softwareVersion":"1.13.0","license":"https://polyformproject.org/licenses/shield/1.0.0","isAccessibleForFree":true,"description":"Zentrale Verwaltung für OPNsense-Firewalls: Regeln, NAT, VPN, Zertifikate und Updates mit Vorschau, Bestätigung und automatischem Rollback.","url":"https://opnexus.dev/","inLanguage":"de","offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"codeRepository":"https://git.opnexus.dev/opnexus/opnexus"}</script>
</head>
<body>
<a class="skip" href="#main">Zum Inhalt springen</a>
@@ -44,7 +44,7 @@
<main id="main">
<section class="hero center">
<div class="wrap">
<span class="badge">Version 1.12.0 · Quellcode einsehbar · selbst gehostet</span>
<span class="badge">Version 1.13.0 · Quellcode einsehbar · selbst gehostet</span>
<h1>Alle OPNsense-Firewalls.<br><span class="grad">Eine Konsole.</span></h1>
<p class="lead">OPNexus pflegt Regeln, NAT, VPN, Zertifikate und Updates mehrerer OPNsense-Firewalls zentral – und rollt sie per API aus, mit Vorschau, Bestätigung und automatischem Rollback. Auf dem Desktop, Tablet und Smartphone.</p>
<div class="btns">
@@ -217,14 +217,14 @@
<section id="releases">
<div class="wrap">
<div class="sec-head"><span class="eyebrow">Entwicklung</span><h2>Regelmäßige Releases mit Raubvogel-Namen</h2><p class="lead">OPNexus folgt Semantic Versioning. Jede Minor-Version bekommt einen Codenamen – die letzten Stationen:</p></div>
<ol class="timeline"><li><div class="v">1.12.0 „Condor“<small>9. Okt. 2026</small></div><p>Neuer Bereich „Netzwerk“: Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, DNS und DHCP je Firewall – mit Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen. Von Hand angelegte Regeln lassen sich übernehmen.</p></li><li><div class="v">1.11.1 „Merlin“<small>9. Okt. 2026</small></div><p>Fehlerkorrekturen aus dem Live-Test: Zertifikate, OpenVPN, WireGuard, IPsec und ACME auf echten Firewalls, bessere Fehlermeldungen, stabilere Alarme, Handy-Ansicht.</p></li><li><div class="v">1.11.0 „Merlin“<small>7. Okt. 2026</small></div><p>Sicherheits-Release: Sitzungen enden beim Passwortwechsel, SMTP prüft Zertifikate, Firewalls nur per HTTPS, Rollback-Spur auch bei unklarem Ausgang, mehr Audit-Einträge.</p></li><li><div class="v">1.10.0 „Lanner“<small>5. Okt. 2026</small></div><p>Update-Fähigkeit: Release-Images, <code>opnexus update</code> und <code>rollback</code>, optionaler Hinweis auf neue Versionen.</p></li><li><div class="v">1.9.0 „Kite“<small>4. Okt. 2026</small></div><p>Versionierte Schema-Migrationen – Upgrades ohne Handarbeit an der Datenbank.</p></li><li><div class="v">1.8.0 „Harris“<small>4. Okt. 2026</small></div><p>Barrierefreiheit: WCAG-2.2-AA-Kontraste, Tastaturbedienung, Screenreader-Beschriftungen.</p></li><li><div class="v">1.7.0 „Goshawk“<small>4. Okt. 2026</small></div><p>Sichere Übertragung: HTTPS-Proxy, gepinnte Firewall-Zertifikate, Zwei-Faktor-Anmeldung.</p></li><li><div class="v">1.6.0 „Peregrine“<small>4. Okt. 2026</small></div><p>Ruhigere Oberfläche: Hinweise als Tooltips, Log-Speicherlimit.</p></li><li><div class="v">1.5.0 „Osprey“<small>19. Sep. 2026</small></div><p>Mehrsprachigkeit: die gesamte Oberfläche auf Deutsch und Englisch.</p></li></ol>
<ol class="timeline"><li><div class="v">1.13.0 „Buzzard“<small>9. Okt. 2026</small></div><p>Mehr Verwaltung pro Firewall: LAGG, Bridge, GRE, Gateway-Gruppen, Tunables, Cron, Syslog und Snapshots – dazu ein Abgleich der Cluster-Knoten mit Alarm und das Übertragen auf den Partner.</p></li><li><div class="v">1.12.0 „Condor“<small>9. Okt. 2026</small></div><p>Neuer Bereich „Netzwerk“: Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, DNS und DHCP je Firewall – mit Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen. Von Hand angelegte Regeln lassen sich übernehmen.</p></li><li><div class="v">1.11.1 „Merlin“<small>9. Okt. 2026</small></div><p>Fehlerkorrekturen aus dem Live-Test: Zertifikate, OpenVPN, WireGuard, IPsec und ACME auf echten Firewalls, bessere Fehlermeldungen, stabilere Alarme, Handy-Ansicht.</p></li><li><div class="v">1.11.0 „Merlin“<small>7. Okt. 2026</small></div><p>Sicherheits-Release: Sitzungen enden beim Passwortwechsel, SMTP prüft Zertifikate, Firewalls nur per HTTPS, Rollback-Spur auch bei unklarem Ausgang, mehr Audit-Einträge.</p></li><li><div class="v">1.10.0 „Lanner“<small>5. Okt. 2026</small></div><p>Update-Fähigkeit: Release-Images, <code>opnexus update</code> und <code>rollback</code>, optionaler Hinweis auf neue Versionen.</p></li><li><div class="v">1.9.0 „Kite“<small>4. Okt. 2026</small></div><p>Versionierte Schema-Migrationen – Upgrades ohne Handarbeit an der Datenbank.</p></li><li><div class="v">1.8.0 „Harris“<small>4. Okt. 2026</small></div><p>Barrierefreiheit: WCAG-2.2-AA-Kontraste, Tastaturbedienung, Screenreader-Beschriftungen.</p></li><li><div class="v">1.7.0 „Goshawk“<small>4. Okt. 2026</small></div><p>Sichere Übertragung: HTTPS-Proxy, gepinnte Firewall-Zertifikate, Zwei-Faktor-Anmeldung.</p></li><li><div class="v">1.6.0 „Peregrine“<small>4. Okt. 2026</small></div><p>Ruhigere Oberfläche: Hinweise als Tooltips, Log-Speicherlimit.</p></li><li><div class="v">1.5.0 „Osprey“<small>19. Sep. 2026</small></div><p>Mehrsprachigkeit: die gesamte Oberfläche auf Deutsch und Englisch.</p></li></ol>
<p class="mt-s"><a href="https://git.opnexus.dev/opnexus/opnexus/src/branch/main/CHANGELOG.md">Alle Versionen im Changelog →</a></p>
</div>
</section>
<section class="alt" id="faq">
<div class="wrap">
<h2 class="center">Häufige Fragen</h2>
<div class="faq"><details><summary>Gibt es schon eine fertige Version?</summary><div class="a"><p>Ja. Version 1.12.0 „Condor“ ist die aktuelle stabile Version – siehe <a href="#installation">Installation</a>. Änderungen stehen im Changelog.</p></div></details><details><summary>Muss ich etwas auf den Firewalls installieren?</summary><div class="a"><p>Nein. OPNexus nutzt die REST-API des OPNsense-Kerns. Die Filter- und Alias-API ist ohne zusätzliches Plugin vorhanden. Du hinterlegst pro Firewall einen API-Schlüssel.</p></div></details><details><summary>Was passiert, wenn mich eine Regel aussperrt?</summary><div class="a"><p>Jede Änderung gilt zunächst nur auf Probe. Bestätigst du sie nicht innerhalb des Zeitfensters (Standard: 3 Minuten), stellt OPNexus automatisch den vorherigen Stand her. Schlägt das selbst fehl, bleibt der Fehler sichtbar und wird gemeldet.</p></div></details><details><summary>Wo liegen meine Daten?</summary><div class="a"><p>Auf deinem eigenen Server (Docker Compose mit PostgreSQL). Es gibt keine Cloud und kein Konto bei uns. Der Versionshinweis ist standardmäßig aus; wenn du ihn aktivierst, geht höchstens alle 12 Stunden eine einzelne Anfrage an <code>updates.opnexus.dev</code> – ohne Instanz- oder Nutzerdaten.</p></div></details><details><summary>Für wen ist OPNexus gedacht?</summary><div class="a"><p>Für alle, die mehrere eigene OPNsense-Firewalls betreiben – vom Homelab bis zum mittelständischen Unternehmen. Mandantenfähigkeit für Dienstleister mit vielen Kunden ist bewusst <em>kein</em> Ziel.</p></div></details><details><summary>Unterstützt OPNexus Hochverfügbarkeit?</summary><div class="a"><p>Ja. CARP-Paare werden als Cluster dargestellt, Rollenwechsel protokolliert, und Updates laufen in einem geführten Ablauf zuerst auf dem BACKUP.</p></div></details><details><summary>Was kostet OPNexus?</summary><div class="a"><p>Nichts. Herunterladen, Quellcode ansehen, anpassen und einsetzen – auch geschäftlich für die eigenen Firewalls – ist kostenlos. Nicht erlaubt ist, OPNexus oder Ableitungen weiterzuverkaufen oder als konkurrierendes Produkt oder Dienst anzubieten. Lizenz: <a href="https://polyformproject.org/licenses/shield/1.0.0">PolyForm Shield 1.0.0</a> (source-available, keine Open-Source-Lizenz).</p></div></details><details><summary>Gibt es eine Verbindung zu OPNsense oder Deciso?</summary><div class="a"><p>Nein. OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p></div></details><details><summary>Wie aktualisiere ich OPNexus?</summary><div class="a"><p>Mit <code>opnexus update</code>: Das Werkzeug zieht die neuen Images, wartet auf „gesund“ und rollt bei Problemen automatisch zurück; <code>opnexus rollback</code> stellt auch die Datenbank aus der Sicherung vor dem Upgrade wieder her. Die fertigen Release-Images (amd64) liegen in der Registry.</p></div></details></div>
<div class="faq"><details><summary>Gibt es schon eine fertige Version?</summary><div class="a"><p>Ja. Version 1.13.0 „Buzzard“ ist die aktuelle stabile Version – siehe <a href="#installation">Installation</a>. Änderungen stehen im Changelog.</p></div></details><details><summary>Muss ich etwas auf den Firewalls installieren?</summary><div class="a"><p>Nein. OPNexus nutzt die REST-API des OPNsense-Kerns. Die Filter- und Alias-API ist ohne zusätzliches Plugin vorhanden. Du hinterlegst pro Firewall einen API-Schlüssel.</p></div></details><details><summary>Was passiert, wenn mich eine Regel aussperrt?</summary><div class="a"><p>Jede Änderung gilt zunächst nur auf Probe. Bestätigst du sie nicht innerhalb des Zeitfensters (Standard: 3 Minuten), stellt OPNexus automatisch den vorherigen Stand her. Schlägt das selbst fehl, bleibt der Fehler sichtbar und wird gemeldet.</p></div></details><details><summary>Wo liegen meine Daten?</summary><div class="a"><p>Auf deinem eigenen Server (Docker Compose mit PostgreSQL). Es gibt keine Cloud und kein Konto bei uns. Der Versionshinweis ist standardmäßig aus; wenn du ihn aktivierst, geht höchstens alle 12 Stunden eine einzelne Anfrage an <code>updates.opnexus.dev</code> – ohne Instanz- oder Nutzerdaten.</p></div></details><details><summary>Für wen ist OPNexus gedacht?</summary><div class="a"><p>Für alle, die mehrere eigene OPNsense-Firewalls betreiben – vom Homelab bis zum mittelständischen Unternehmen. Mandantenfähigkeit für Dienstleister mit vielen Kunden ist bewusst <em>kein</em> Ziel.</p></div></details><details><summary>Unterstützt OPNexus Hochverfügbarkeit?</summary><div class="a"><p>Ja. CARP-Paare werden als Cluster dargestellt, Rollenwechsel protokolliert, und Updates laufen in einem geführten Ablauf zuerst auf dem BACKUP.</p></div></details><details><summary>Was kostet OPNexus?</summary><div class="a"><p>Nichts. Herunterladen, Quellcode ansehen, anpassen und einsetzen – auch geschäftlich für die eigenen Firewalls – ist kostenlos. Nicht erlaubt ist, OPNexus oder Ableitungen weiterzuverkaufen oder als konkurrierendes Produkt oder Dienst anzubieten. Lizenz: <a href="https://polyformproject.org/licenses/shield/1.0.0">PolyForm Shield 1.0.0</a> (source-available, keine Open-Source-Lizenz).</p></div></details><details><summary>Gibt es eine Verbindung zu OPNsense oder Deciso?</summary><div class="a"><p>Nein. OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p></div></details><details><summary>Wie aktualisiere ich OPNexus?</summary><div class="a"><p>Mit <code>opnexus update</code>: Das Werkzeug zieht die neuen Images, wartet auf „gesund“ und rollt bei Problemen automatisch zurück; <code>opnexus rollback</code> stellt auch die Datenbank aus der Sicherung vor dem Upgrade wieder her. Die fertigen Release-Images (amd64) liegen in der Registry.</p></div></details></div>
</div>
</section>
<section id="installation">
@@ -233,7 +233,7 @@
<span class="eyebrow">Loslegen</span>
<h2>Installation</h2>
<p class="lead">OPNexus läuft als Docker-Compose-Stack auf einem eigenen Server – deine Firewall-Daten bleiben bei dir.</p>
<p class="callout" role="note"><strong>Aktuelle Version: 1.12.0 „Condor“.</strong> Gegen echte OPNsense-Instanzen entwickelt und getestet; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Rückmeldungen sind willkommen.</p>
<p class="callout" role="note"><strong>Aktuelle Version: 1.13.0 „Buzzard“.</strong> Gegen echte OPNsense-Instanzen entwickelt und getestet; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Rückmeldungen sind willkommen.</p>
<div class="code"><button type="button" class="copy-btn" data-copy data-done="Kopiert" data-fail="Automatisches Kopieren nicht möglich – Text markiert, bitte Strg+C drücken" data-label="Kopieren"><svg class="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 012-2h9"/></svg><svg class="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span>Kopieren</span></button><pre tabindex="0"><code><span class="c"># Voraussetzung: Docker mit Compose-Plugin</span>
git clone https://git.opnexus.dev/opnexus/opnexus.git
cd OPNexus
@@ -252,7 +252,7 @@ docker compose up -d --build</code></pre></div>
<section class="alt cta center">
<div class="wrap">
<h2>Jetzt ausprobieren</h2>
<p class="lead">Version 1.12.0 ist da: Funktionen und Screenshots ansehen oder gleich installieren.</p>
<p class="lead">Version 1.13.0 ist da: Funktionen und Screenshots ansehen oder gleich installieren.</p>
<div class="btns"><a class="btn primary" href="#installation">Jetzt installieren</a><a class="btn ghost" href="/screenshots/">Screenshots ansehen</a></div>
</div>
</section>
@@ -269,7 +269,7 @@ docker compose up -d --build</code></pre></div>
<div class="fine">
<p>OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p>
<p>Alle Screenshots zeigen fiktive Beispieldaten aus einer Demo-Umgebung.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>
+1 -1
View File
@@ -1,4 +1,4 @@
{
"tag_name": "v1.12.0",
"tag_name": "v1.13.0",
"html_url": "https://opnexus.dev/#install"
}
+1 -1
View File
@@ -128,7 +128,7 @@
<div class="fine">
<p>OPNexus ist ein unabhängiges Projekt und steht in keiner Verbindung zu Deciso B.V. oder dem OPNsense®-Projekt. OPNsense® ist eine Marke von Deciso B.V.</p>
<p>Alle Screenshots zeigen fiktive Beispieldaten aus einer Demo-Umgebung.</p>
<p>© 2026 Julian Boldt · OPNexus 1.12.0 „Condor“</p>
<p>© 2026 Julian Boldt · OPNexus 1.13.0 „Buzzard“</p>
</div>
</div>
</footer>