Compare commits

..
3 Commits
Author SHA1 Message Date
Julian Boldt ba954c05bf Release v1.23.3
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
Release / publish (updater) (push) Canceled after 0s
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
  Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
  Garage, MinIO, RustFS und Versity S3 Gateway.
2026-10-20 12:56:39 +02:00
Julian Boldt 63d014b14c Release v1.23.2
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
Release / publish (updater) (push) Canceled after 0s
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
  lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
  `0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
2026-10-20 09:17:31 +02:00
Julian Boldt 4838dad588 Release v1.23.1
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
Release / publish (updater) (push) Canceled after 0s
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
  - Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
  - Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
  - SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
  - Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
  - Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
  - Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
  - Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
2026-10-20 02:24:15 +02:00
31 changed files with 948 additions and 61 deletions

No files matched your search

+24
View File
@@ -31,6 +31,30 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.23.3] "Harrier" — 2026-10-20
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
Garage, MinIO, RustFS und Versity S3 Gateway.
## [1.23.2] "Harrier" — 2026-10-20
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
## [1.23.1] "Harrier" — 2026-10-20
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
## [1.23.0] "Harrier" — 2026-10-20
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
+2 -2
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.23.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.23.3` 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
@@ -62,7 +62,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.23.0
OPNEXUS_VERSION=1.23.3
```
```bash
+2 -2
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.23.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.23.3` 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
@@ -64,7 +64,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.23.0
OPNEXUS_VERSION=1.23.3
```
```bash
+1 -1
View File
@@ -22,7 +22,7 @@ class OidcSettings(Base):
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email groups")
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+6
View File
@@ -99,6 +99,12 @@ class Hub:
code, out = self.run("iptables", "-I", *rule)
if code != 0:
return f"iptables: {out}"[:300]
# The hub only needs its own proxy: never route between peers or into the compose network.
fwd = ("FORWARD", "-j", "DROP")
if self.run("iptables", "-C", *fwd)[0] != 0:
code, out = self.run("iptables", "-I", *fwd)
if code != 0:
return f"iptables: {out}"[:300]
return None
def status(self, error: str | None) -> dict:
+3 -2
View File
@@ -30,11 +30,11 @@ def client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _set_session_cookie(response: Response, token: str) -> None:
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=settings.session_lifetime,
max_age=max_age or settings.session_lifetime,
httponly=True,
samesite="lax",
secure=settings.cookie_secure,
@@ -305,6 +305,7 @@ async def change_password(
# an attacker may hold) and the replacement session commit as one unit.
user.password_hash = await auth_service.hash_password_async(payload.new_password)
await auth_service.revoke_sessions(db, user.id, commit=False)
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
token, _ = await auth_service.create_session(
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
)
+29 -6
View File
@@ -1,5 +1,7 @@
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
import logging
import re
import secrets
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Request
@@ -10,6 +12,7 @@ from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import _set_session_cookie, client_ip, current_user
from app.config import settings
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
from app.security_middleware import _origin_allowed
from app.services import auth_service, notification_secret_store, oidc_service
@@ -18,10 +21,21 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["oidc"])
CALLBACK_PATH = "/api/auth/oidc/callback"
STATE_COOKIE = "opnexus-oidc-state"
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
# must not keep a 30-day session.
SSO_SESSION_SECONDS = 12 * 3600
def _login_error(code: str) -> RedirectResponse:
return RedirectResponse(f"/login?sso_error={code}", status_code=302)
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
return response
def _printable(value: str) -> str:
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
@router.get("/auth/oidc/config")
@@ -42,11 +56,15 @@ async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
return _login_error("disabled")
try:
url = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
except oidc_service.OidcError as exc:
logger.warning("OIDC start fehlgeschlagen: %s", exc)
return _login_error(exc.code)
return RedirectResponse(url, status_code=302)
response = RedirectResponse(url, status_code=302)
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
# code+state pair is useless without the cookie.
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
return response
@router.get("/auth/oidc/callback")
@@ -59,7 +77,9 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
return _login_error("disabled")
try:
if error or not code or not state:
raise oidc_service.OidcError("provider", f"Anbieter meldet {error or 'keinen Code'}")
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
user = await oidc_service.complete(db, cfg, code, state)
except oidc_service.OidcError as exc:
auth_service.throttle.record_failure(ip, "oidc")
@@ -67,9 +87,12 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
return _login_error(exc.code)
auth_service.throttle.reset(ip, "oidc")
await auth_service.finalize_login(db, user, ip)
token, _ = await auth_service.create_session(db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip)
token, _ = await auth_service.create_session(
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
)
response = RedirectResponse("/dashboard", status_code=302)
_set_session_cookie(response, token)
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
return response
+2 -2
View File
@@ -81,9 +81,9 @@ async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession
"""Replace this host's variables with exactly the given set."""
host = await _host(db, host_id)
for name, value in payload.variables.items():
if not variables_service.NAME_RE.match(name):
if not variables_service.NAME_RE.fullmatch(name):
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
if not variables_service.VALUE_RE.match(value):
if not variables_service.VALUE_RE.fullmatch(value):
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
existing = {v.name: v for v in host.variables}
for name in set(existing) - set(payload.variables):
+17 -4
View File
@@ -82,14 +82,27 @@ def operator_denies(method: str, path: str) -> bool:
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
# maintenance (reboot/update/CARP switch), scheduling it, and applying network changes.
_ROLLOUT = re.compile(
r"/(deploy|deployments/[^/]+/(remove|retry-rollback)|maintenance/(execute|carp-execute|schedule)|network/changes)$"
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
_ROLLOUT_POST = re.compile(
r"/(deploy|push|sign|poll|register|reassert-ordering"
r"|deployments/[^/]+/(remove|retry-rollback)"
r"|maintenance/(execute|carp-execute|schedule)"
r"|network/changes"
r"|services/[^/]+/(start|stop|restart))$"
)
_ROLLOUT_DELETE = re.compile(
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
)
def is_rollout(method: str, path: str) -> bool:
return method == "POST" and bool(_ROLLOUT.search(path))
if method == "POST":
return bool(_ROLLOUT_POST.search(path))
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
def bearer_from_header(header: str) -> str | None:
+2 -1
View File
@@ -236,6 +236,7 @@ async def create_session(
user_agent: str | None = None,
ip_address: str | None = None,
verified_hash: str | None = None,
lifetime_seconds: int | None = None,
) -> tuple[str, datetime]:
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
@@ -253,7 +254,7 @@ async def create_session(
await db.rollback()
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
session = Session(
user_id=user_id,
token_hash=_hash_token(token),
+11 -6
View File
@@ -7,6 +7,8 @@ but its value never leaves the server. Output is a unified diff, capped in size.
"""
import difflib
import hashlib
import hmac
import os
import re
from xml.etree.ElementTree import Element, indent, tostring
@@ -14,7 +16,7 @@ import defusedxml.ElementTree as safe_ET
MAX_LINES = 3000
MAX_BYTES = 32 * 1024 * 1024
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
_SECRET = re.compile(r"pass|pwd|psk|secret|key|token|prv|hash|community|bindpw|privpass|otp|seed|cred", re.I)
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
_BLOB = 120
@@ -23,15 +25,17 @@ class InvalidConfig(ValueError):
pass
def _redact(root: Element) -> None:
def _redact(root: Element, salt: bytes) -> None:
for el in root.iter():
if len(el) == 0 and el.text and el.text.strip():
text = el.text.strip()
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
# Salted per diff: both sides of one diff share the salt (so a change shows), but the value cannot be
# looked up in a dictionary or compared across hosts/requests.
el.text = "‹" + hmac.new(salt, text.encode(), hashlib.sha256).hexdigest()[:12] + "›"
def normalise(xml_bytes: bytes) -> list[str]:
def normalise(xml_bytes: bytes, salt: bytes) -> list[str]:
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
raise InvalidConfig("leer oder zu groß")
try:
@@ -42,13 +46,14 @@ def normalise(xml_bytes: bytes) -> list[str]:
raise InvalidConfig("keine OPNsense-Konfiguration")
for volatile in root.findall("revision"):
root.remove(volatile)
_redact(root)
_redact(root, salt)
indent(root, space=" ")
return tostring(root, encoding="unicode").splitlines()
def diff(old: bytes, new: bytes) -> dict:
a, b = normalise(old), normalise(new)
salt = os.urandom(16)
a, b = normalise(old, salt), normalise(new, salt)
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
+8 -4
View File
@@ -12,6 +12,7 @@ in ``filter/rule`` (legacy). Both are normalised to the same shape below.
decided. The score starts at 100 and loses a penalty per failed check, so one
critical finding (an any-any pass rule on the WAN) already turns the host red.
"""
import asyncio
import re
from datetime import datetime, timezone
from xml.etree.ElementTree import Element
@@ -276,15 +277,18 @@ async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
await rule_hit_service.sample_host(db, host)
except OpnsenseAPIError:
pass # usage data is a bonus; the check does not depend on it
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age,
unused_rules=await rule_hit_service.unused_summary(db, host.id))
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
findings = await asyncio.to_thread(
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
unused_rules=await rule_hit_service.unused_summary(db, host.id),
)
row.findings, row.score, row.error = findings, score(findings), None
except OpnsenseAPIError as exc:
row.findings, row.score, row.error = [], None, "unreachable"
row.error_detail = str(exc)[:300]
except ValueError as exc:
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
row.findings, row.score, row.error = [], None, "invalid_config"
row.error_detail = str(exc)[:300]
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
await db.commit()
return row
+28 -10
View File
@@ -50,7 +50,7 @@ async def get_settings(db: AsyncSession) -> OidcSettings:
if row is None:
row = OidcSettings(
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
scopes="openid profile email groups", groups_claim="groups",
scopes="openid profile email", groups_claim="groups",
)
db.add(row)
await db.flush()
@@ -130,7 +130,7 @@ def _challenge(verifier: str) -> str:
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> tuple[str, str]:
"""Remember a fresh login attempt and return the provider URL to send the browser to."""
doc = await discovery(cfg.issuer)
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(24), secrets.token_urlsafe(48)
@@ -141,7 +141,7 @@ async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
"response_type": "code", "client_id": cfg.client_id, "redirect_uri": redirect_uri, "scope": cfg.scopes,
"state": state, "nonce": nonce, "code_challenge": _challenge(verifier), "code_challenge_method": "S256",
})
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}"
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}", state
async def _consume_state(db: AsyncSession, state: str) -> OidcLoginState:
@@ -207,7 +207,8 @@ async def _verify_id_token(cfg: OidcSettings, doc: dict, id_token: str, nonce: s
def _as_groups(value) -> list[str]:
if isinstance(value, str):
return [g for g in re.split(r"[,\s]+", value) if g]
# Group names may contain spaces ("Network Admins"): a string claim is split on commas and line breaks only.
return [g.strip() for g in re.split(r"[,\n]+", value) if g.strip()]
if isinstance(value, list):
return [str(g) for g in value]
return []
@@ -224,14 +225,31 @@ def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
return None
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
if cfg.groups_claim in claims:
return _as_groups(claims[cfg.groups_claim])
async def _userinfo(doc: dict, claims: dict, access_token: str | None) -> dict:
"""The provider's userinfo answer for THIS identity ({} if there is none or it belongs to somebody else)."""
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
return _as_groups(info.get(cfg.groups_claim))
return []
return info
return {}
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
if cfg.groups_claim in claims:
return _as_groups(claims[cfg.groups_claim])
return _as_groups((await _userinfo(doc, claims, access_token)).get(cfg.groups_claim))
async def _with_display_name(doc: dict, claims: dict, access_token: str | None) -> dict:
"""Some providers (Authelia) keep preferred_username/email out of the ID token: ask userinfo, else the user would be named
after the opaque ``sub``. Best effort: a failing userinfo call must not block a login that already has its role."""
if claims.get("preferred_username") or claims.get("email"):
return claims
try:
info = await _userinfo(doc, claims, access_token)
except OidcError:
return claims
return {**claims, **{k: info[k] for k in ("preferred_username", "email") if info.get(k)}}
def _username(claims: dict) -> str:
@@ -267,4 +285,4 @@ async def complete(db: AsyncSession, cfg: OidcSettings, code: str, state_value:
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
if role is None:
raise OidcError("no_role", "Kein zugeordnete Gruppe")
return await provision(db, cfg, claims, role)
return await provision(db, cfg, await _with_display_name(doc, claims, tokens.get("access_token")), role)
+2 -1
View File
@@ -104,7 +104,8 @@ def parse_subnet(value: str) -> ipaddress.IPv4Network:
net = ipaddress.ip_network(value.strip(), strict=True)
except ValueError:
raise OverlayError("subnet", "kein gültiges Netz") from None
if not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29):
if (not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29)
or net.is_loopback or net.is_link_local or net.is_multicast or net.is_unspecified):
raise OverlayError("subnet", "ein privates IPv4-Netz zwischen /16 und /29 ist nötig")
return net
+6 -1
View File
@@ -58,7 +58,12 @@ class S3Client:
if parts.scheme == "http" and not settings.allow_insecure_offsite:
# Credentials and (encrypted, but still) backups must not travel in the clear.
raise S3Error("Der Endpunkt muss https:// sein")
self.scheme, self.netloc = parts.scheme, parts.netloc
# httpx leaves a scheme's default port out of the Host header, so the signed host must not carry it either.
port = parts.port
default = {"https": 443, "http": 80}[parts.scheme]
host = parts.hostname if ":" not in parts.hostname else f"[{parts.hostname}]"
self.scheme = parts.scheme
self.netloc = host if port in (None, default) else f"{host}:{port}"
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
self.access_key, self.secret_key = access_key, secret_key
+1 -1
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.23.0"
APP_VERSION = "1.23.3"
APP_CODENAME = "Harrier"
@@ -0,0 +1,21 @@
"""OIDC: Standard-Scopes ohne "groups" (Keycloak und Entra ID lehnen den Scope als ungültig ab).
Revision ID: 0018
Revises: 0017
Create Date: 2026-10-20
"""
from alembic import op
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Only rows still carrying the old default; a value somebody typed on purpose stays.
op.execute("UPDATE oidc_settings SET scopes = 'openid profile email' WHERE scopes = 'openid profile email groups'")
def downgrade() -> None:
pass
+15
View File
@@ -279,3 +279,18 @@ async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, a
assert (await api.get("/api/openapi.json")).status_code == 200
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
assert (await client.get("/docs")).status_code != 200
@pytest.mark.asyncio
async def test_changing_your_own_password_revokes_your_api_tokens(client):
assert (await client.post("/api/users", json={"username": "pwtok", "password": "first-password-12", "role": "operator"})).status_code == 201
user = await _login("pwtok", "first-password-12")
try:
token = (await user.post("/api/tokens", json={"name": "ci", "scope": "read", "current_password": "first-password-12"})).json()["token"]
async with _bearer(token) as api:
assert (await api.get("/api/hosts")).status_code == 200
resp = await user.post("/api/auth/change-password", json={"current_password": "first-password-12", "new_password": "second-password-34"})
assert resp.status_code == 200, resp.text
assert (await api.get("/api/hosts")).status_code == 401
finally:
await user.aclose()
+25 -3
View File
@@ -374,9 +374,9 @@ async def test_settings_changes_are_audited_without_secrets(client):
# ── config diff ──────────────────────────────────────────────────────────
def _xml(*, hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
def _xml(*, otp="OTPSEED1", hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey></user>"
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey><otp_seed>{otp}</otp_seed><pwd>PW-{password}</pwd></user>"
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
@@ -392,7 +392,7 @@ async def test_diff_shows_changes_and_hides_every_secret(client):
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
assert secret not in text, secret
assert "‹sha256:" in text # a changed secret is still visible as a change
assert "‹" in text # a changed secret is still visible as a change
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
@@ -433,3 +433,25 @@ async def test_diff_is_capped(client):
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
assert body["truncated"] is True and len(body["lines"]) == 3000
@pytest.mark.asyncio
async def test_diff_hides_otp_seeds_and_uses_a_salt_per_request(client):
host = await _create_host(client, "fw1", "https://fw1.test")
backup_store.save_snapshot(host["id"], _xml())
backup_store.save_snapshot(host["id"], _xml(otp="OTPSEED2", password="p9", dns="9.9.9.9"))
first = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
second = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
text = "\n".join(first["lines"])
for secret in ("OTPSEED1", "OTPSEED2", "PW-p9", "PW-p1"):
assert secret not in text, secret
import hashlib
assert hashlib.sha256(b"OTPSEED1").hexdigest()[:12] not in text # no unsalted hash to run a dictionary against
assert first["lines"] != second["lines"] # a fresh salt per request: hashes cannot be matched across requests
def test_signed_host_has_no_default_port_but_keeps_a_custom_one():
plain = s3_client.S3Client(endpoint="https://s3.test:443", region="r", bucket="b", access_key="a", secret_key="s")
assert plain.netloc == "s3.test" and plain._target("k")[0] == "s3.test"
custom = s3_client.S3Client(endpoint="https://s3.test:9000", region="r", bucket="b", access_key="a", secret_key="s")
assert custom.netloc == "s3.test:9000"
+16
View File
@@ -363,3 +363,19 @@ async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client
await client.post(f"/api/hosts/{host['id']}/compliance/check")
async with SessionLocal() as db:
assert await notification_checks.check_compliance(db) == {}
@pytest.mark.asyncio
async def test_one_truncated_config_does_not_stop_the_others(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
bad = await _create_host(client, "a-bad", "https://bad.test")
good = await _create_host(client, "b-good", "https://good.test")
for base, body in (("bad", b"<opnsense><system><webgui>"), ("good", _config())):
respx_mock.get(f"https://{base}.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=body))
respx_mock.get(f"https://{base}.test/api/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": "none"}))
respx_mock.get(f"https://{base}.test/api/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
respx_mock.get(f"https://{base}.test/api/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
resp = await client.post("/api/compliance/check")
assert resp.status_code == 200, resp.text
by_name = {h["host_name"]: h for h in resp.json()}
assert by_name["a-bad"]["error"] == "invalid_config" and by_name["b-good"]["score"] is not None
+68 -1
View File
@@ -166,11 +166,24 @@ async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(clien
assert _error(resp) == "no_role"
@pytest.mark.asyncio
async def test_the_username_comes_from_userinfo_if_the_id_token_has_none_and_a_failing_userinfo_does_not_block_the_login(client, anon_client, respx_mock):
await _configure(client)
# Authelia keeps preferred_username/email out of the ID token; without userinfo the account would be named after the opaque sub.
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "preferred_username": "alice", "email": "[email protected]"})
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None))
assert resp.headers["location"] == "/dashboard" and list(await _users("alice")) == ["alice"]
respx_mock.clear()
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "preferred_username": "mallory"})
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None, sub="user-2"))
assert resp.headers["location"] == "/dashboard" and "mallory" not in await _users("x") and "user-2" in await _users("x")
@pytest.mark.asyncio
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers bar"))
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers"))
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "read_only"
@@ -342,3 +355,57 @@ async def test_failed_callbacks_are_throttled(client, anon_client, respx_mock):
await _configure(client)
results = [_error(await anon_client.get("/api/auth/oidc/callback", params={"code": "x", "state": f"s{i}"})) for i in range(12)]
assert "throttled" in results
@pytest.mark.asyncio
async def test_a_string_group_claim_keeps_names_with_spaces_together(client, anon_client, respx_mock):
await _configure(client, admin_group="Network Admins", operator_group="Admins", read_only_group="")
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network Admins"))
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "admin" # not "Admins" = operator, not split
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network, Team"))
assert _error(resp) == "no_role"
@pytest.mark.asyncio
async def test_the_callback_only_works_in_the_browser_that_started_the_login(client, anon_client, respx_mock):
from httpx import ASGITransport, AsyncClient
from app.main import app
await _configure(client)
query = await _start(anon_client, respx_mock)
# Somebody else (another browser, no state cookie) opens the callback URL: refused, and the state is not burned for the owner.
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as stranger:
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={"id_token": _id_token(_claims(query["nonce"]))}))
resp = await stranger.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
assert _error(resp) == "state"
assert await _users("alice") == {}
@pytest.mark.asyncio
async def test_sso_sessions_are_short_and_the_state_cookie_is_httponly(client, anon_client, respx_mock):
from datetime import datetime, timedelta, timezone
from app.models.user import Session
await _configure(client)
_mock_idp(respx_mock)
start = await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})
cookie = start.headers["set-cookie"].lower()
assert "opnexus-oidc-state=" in cookie and "httponly" in cookie and "samesite=lax" in cookie and "path=/api/auth/oidc" in cookie
query = {k: v[0] for k, v in parse_qs(urlsplit(start.headers["location"]).query).items()}
resp, _ = await _callback(anon_client, respx_mock, query)
assert resp.headers["location"] == "/dashboard"
assert "max-age=43200" in resp.headers["set-cookie"].lower()
async with SessionLocal() as db:
user = (await _users("alice"))["alice"]
session = (await db.scalars(select(Session).where(Session.user_id == user.id))).one()
assert session.expires_at - datetime.now(timezone.utc) < timedelta(hours=12, minutes=1)
@pytest.mark.asyncio
async def test_error_text_from_the_provider_cannot_forge_log_lines(client, anon_client, caplog):
await _configure(client)
import logging
with caplog.at_level(logging.WARNING):
await anon_client.get("/api/auth/oidc/callback", params={"error": "x\r\nFAKE LOG LINE\x1b[31m", "state": "s"})
text = "\n".join(r.getMessage() for r in caplog.records)
assert "FAKE LOG LINE" in text and "\r" not in text and "\x1b" not in text and "\nFAKE" not in text
+17
View File
@@ -113,6 +113,23 @@ async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
("POST", "/api/hosts/abc/maintenance/carp-execute", True),
("POST", "/api/hosts/abc/maintenance/schedule", True),
("POST", "/api/hosts/abc/network/changes", True),
("DELETE", "/api/rules/abc", True),
("DELETE", "/api/aliases/abc", True),
("DELETE", "/api/port-forward/abc", True),
("DELETE", "/api/one-to-one/abc", True),
("DELETE", "/api/outbound-nat/abc", True),
("DELETE", "/api/policy-hierarchy/rules/abc", True),
("DELETE", "/api/ipsec/tunnels/abc", True),
("DELETE", "/api/certificates/managed/abc", True),
("DELETE", "/api/hosts/abc/snapshots/def", True),
("POST", "/api/certificates/managed/abc/push", True),
("POST", "/api/acme/accounts/abc/register", True),
("POST", "/api/acme/certificates/abc/sign", True),
("POST", "/api/acme/certificates/abc/poll", True),
("POST", "/api/policy-hierarchy/groups/abc/reassert-ordering", True),
("POST", "/api/hosts/abc/services/unbound/restart", True),
("DELETE", "/api/groups/abc", False),
("DELETE", "/api/rules/abc/extra", False),
("POST", "/api/rules/abc/preview", False),
("POST", "/api/rules", False),
("PUT", "/api/rules/abc", False),
+2 -1
View File
@@ -56,7 +56,7 @@ def test_public_key_validation(value, ok):
def test_subnets():
assert str(svc.parse_subnet("10.99.0.0/24")) == "10.99.0.0/24"
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", ""):
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", "", "127.0.0.0/24", "169.254.1.0/24"):
with pytest.raises(svc.OverlayError):
svc.parse_subnet(bad)
@@ -259,6 +259,7 @@ def test_hub_applies_the_interface_peers_and_the_inbound_drop_rule(tmp_path):
cmds = [c[:3] for c in runner.calls]
assert ("ip", "link", "add") in cmds and ("ip", "address", "replace") in cmds and ("wg", "syncconf", "wg0") in cmds
assert any(c[0] == "iptables" and c[1] == "-I" and "-i" in c and "NEW" in c and c[-1] == "DROP" for c in runner.calls)
assert ("iptables", "-I", "FORWARD", "-j", "DROP") in runner.calls # no routing between peers or into the compose network
assert not list(tmp_path.glob(".wg0.conf")) # the private key file does not linger
assert hub.subnet == ipaddress.ip_network("10.99.0.0/24")
# An existing interface is reused, not re-created.
+4 -4
View File
@@ -46,11 +46,11 @@ def test_values_are_inserted_literally_not_interpreted():
def test_value_and_name_validation():
assert vs.NAME_RE.match("LAN_NET2") and not vs.NAME_RE.match("lan") and not vs.NAME_RE.match("2LAN") and not vs.NAME_RE.match("A" * 41)
assert vs.NAME_RE.fullmatch("LAN_NET2") and not vs.NAME_RE.fullmatch("lan") and not vs.NAME_RE.fullmatch("2LAN") and not vs.NAME_RE.fullmatch("A" * 41)
for ok in ("192.168.7.0/24", "fw-nord.example.com", "10.0.0.1,10.0.0.2", "Filiale Nord", "a@b+c"):
assert vs.VALUE_RE.match(ok)
for bad in ("", "{{X}}", "a\nb", 'a"b', "a;b", "a" * 256, "<x>"):
assert not vs.VALUE_RE.match(bad)
assert vs.VALUE_RE.fullmatch(ok)
for bad in ("", "{{X}}", "a\nb", "ok\n", 'a"b', "a;b", "a" * 256, "<x>"):
assert not vs.VALUE_RE.fullmatch(bad)
# ── API ──────────────────────────────────────────────────────────────────
+2
View File
@@ -218,6 +218,8 @@ services:
command: ["python", "-m", "app.overlay_hub"]
cap_add:
- NET_ADMIN
sysctls:
net.ipv4.ip_forward: "0"
environment:
OVERLAY_DIR: /data/overlay
volumes:
+13 -5
View File
@@ -238,7 +238,7 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
- **Rolle aus Gruppen, bei jeder Anmeldung:** gilt die höchste passende Rolle; wer in keiner der Gruppen ist,
kommt nicht hinein (es wird auch kein Konto angelegt). Wird jemand aus der Gruppe entfernt, endet der Zugang
bei der nächsten Anmeldung (laufende Sitzungen enden nach ihrer Laufzeit; beim Anbieter gesperrte Personen
bei der nächsten Anmeldung (laufende SSO-Sitzungen enden spätestens nach 12 Stunden; beim Anbieter gesperrte Personen
daher zusätzlich in der Benutzerliste löschen).
- **Konten:** SSO-Benutzer werden bei der ersten Anmeldung angelegt (Kennzeichen „SSO“). Rolle und Passwort
ändern sich nur beim Anbieter; die Zwei-Faktor-Anmeldung übernimmt der Anbieter. Ein SSO-Konto wird nie mit
@@ -248,8 +248,11 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
- **Geprüft** werden Signatur (nur asymmetrische Verfahren, nie `none` oder HMAC), Aussteller, Zielgruppe, Ablauf
und Nonce des ID-Tokens; jeder Anmeldeversuch (State) gilt genau einmal und 10 Minuten. Gruppen kommen aus dem
ID-Token, ersatzweise aus dem Userinfo-Endpunkt. Aussteller und Endpunkte müssen `https://` sein. Das ist
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token), noch nicht gegen jeden echten
Anbieter: melde, was bei deinem nicht klappt.
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token) und gegen echte Server von Keycloak 26 und
Authelia 4.39 (Anmeldung, Rollen aus Gruppen, Rollenentzug, abgefangener Callback); andere Anbieter: melde, was bei deinem nicht klappt.
Die Standard-Scopes sind `openid profile email`; die Gruppen müssen als Claim im ID-Token oder Userinfo ankommen
(Keycloak: Mapper „Group Membership“ am Client; Entra ID: optionaler Gruppen-Claim). Anbieter, die Gruppen nur auf
Anfrage senden (z. B. Okta, Authelia), brauchen zusätzlich den Scope `groups`.
**Aufgabentrennung.** Einstellungen › Aufgabentrennung: Schaltest du „Operatoren dürfen Änderungen selbst
ausrollen“ aus, bereiten Operatoren Änderungen nur vor (anlegen, bearbeiten, Vorschau), und **nur ein Admin
@@ -821,8 +824,9 @@ docker compose exec -T -e BACKUP_PASSPHRASE='…' backend python -m app.backup_d
Die Benachrichtigung „Kein aktuelles Backup“ meldet sich, wenn ein Host drei Intervalle (mindestens
6 Stunden) ohne Sicherung war, „Offsite-Backup fehlgeschlagen“ bei Problemen mit dem Ziel. Die
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele geprüft, aber noch nicht gegen einen
laufenden S3-Anbieter; „Ziel testen“ zeigt dir sofort, ob dein Anbieter funktioniert.
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele und gegen einen laufenden S3-Server mit echter
Signaturprüfung (Garage, MinIO, RustFS und Versity S3 Gateway: Upload, Download, Löschen, 20-MB-Objekt, Sonderzeichen im
Schlüssel, falsches Geheimnis wird abgelehnt) geprüft; bei AWS selbst, Backblaze, Wasabi & Co. zeigt dir „Ziel testen“ sofort, ob dein Anbieter funktioniert.
**Änderungen ansehen:** Der Knopf „Änderungen“ neben einer Sicherung zeigt, was sich gegenüber der
vorherigen geändert hat. Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als
@@ -1031,6 +1035,10 @@ scrape_configs:
| `opnexus_problems_open{type}` | Offene Probleme der Benachrichtigungen |
| `opnexus_scheduled_maintenance_pending` | Noch nicht gelaufene geplante Wartungen |
Ein fertiges **Grafana-Dashboard** liegt unter [`docs/grafana-opnexus.json`](grafana-opnexus.json)
(Grafana › Dashboards › Importieren, Prometheus-Datenquelle wählen): erreichbare Firewalls, offene Aktionen
und Probleme, Sicherheitswert und Befunde, Last, Speicher, Platte und CARP-Rolle.
### KI-Assistenten (MCP)
OPNexus spricht das Model Context Protocol, damit ein KI-Assistent die Flotte
+617
View File
@@ -0,0 +1,617 @@
{
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__requires": [
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
}
],
"title": "OPNexus",
"uid": "opnexus-fleet",
"tags": [
"opnexus",
"opnsense"
],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "1m",
"time": {
"from": "now-24h",
"to": "now"
},
"annotations": {
"list": []
},
"templating": {
"list": []
},
"panels": [
{
"id": 1,
"title": "Firewalls erreichbar / Reachable firewalls",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(opnexus_host_up)",
"legendFormat": "up",
"instant": true
},
{
"refId": "B",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_hosts",
"legendFormat": "total",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
},
{
"id": 2,
"title": "Offline",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 6,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "count(opnexus_host_up == 0) or vector(0)",
"legendFormat": "offline",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background"
}
},
{
"id": 3,
"title": "Offene Aktionen / Pending actions",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 10,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(opnexus_pending_actions) or vector(0)",
"legendFormat": "pending",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background"
}
},
{
"id": 4,
"title": "Offene Probleme / Open problems",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 14,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(opnexus_problems_open) or vector(0)",
"legendFormat": "problems",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "background"
}
},
{
"id": 5,
"title": "Geplante Wartung / Scheduled maintenance",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 18,
"y": 0,
"w": 3,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_scheduled_maintenance_pending",
"legendFormat": "pending",
"instant": true
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
}
}
},
{
"id": 6,
"title": "Version",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 21,
"y": 0,
"w": 3,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_info",
"legendFormat": "{{version}}",
"instant": true
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "name"
}
},
{
"id": 7,
"title": "Sicherheitswert / Security score",
"type": "bargauge",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_compliance_score",
"legendFormat": "{{host}}",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 70
},
{
"color": "green",
"value": 90
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"orientation": "horizontal",
"displayMode": "gradient"
}
},
{
"id": 8,
"title": "Befunde / Findings",
"type": "table",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_compliance_findings{severity=~\"critical|high|medium|low\"} > 0",
"legendFormat": "",
"instant": true
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"showHeader": true
}
},
{
"id": 9,
"title": "CPU-Last (1 min) / Load",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 12,
"w": 8,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_load1",
"legendFormat": "{{host}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {}
},
{
"id": 10,
"title": "Speicher / Memory %",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 8,
"y": 12,
"w": 8,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_memory_used_percent",
"legendFormat": "{{host}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"max": 100,
"min": 0
},
"overrides": []
},
"options": {}
},
{
"id": 11,
"title": "Platte / Disk %",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 16,
"y": 12,
"w": 8,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_disk_used_percent",
"legendFormat": "{{host}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"max": 100,
"min": 0
},
"overrides": []
},
"options": {}
},
{
"id": 12,
"title": "CARP (1 = MASTER)",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 20,
"w": 12,
"h": 6
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_carp_master",
"legendFormat": "{{host}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"custom": {
"drawStyle": "line",
"lineInterpolation": "stepAfter"
}
},
"overrides": []
},
"options": {}
},
{
"id": 13,
"title": "Erreichbarkeit / Up",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 12,
"y": 20,
"w": 12,
"h": 6
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_up",
"legendFormat": "{{host}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"custom": {
"lineInterpolation": "stepAfter"
}
},
"overrides": []
},
"options": {}
}
],
"description": "Fleet overview from the OPNexus /api/metrics endpoint (see the OPNexus handbook, chapter Automation)."
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function OidcSection() {
const [issuer, setIssuer] = useState("");
const [clientId, setClientId] = useState("");
const [secret, setSecret] = useState("");
const [scopes, setScopes] = useState("openid profile email groups");
const [scopes, setScopes] = useState("openid profile email");
const [claim, setClaim] = useState("groups");
const [admin, setAdmin] = useState("");
const [operator, setOperator] = useState("");
+1 -1
View File
@@ -1604,7 +1604,7 @@
"scopes": "Scopes",
"claim": "Gruppen-Claim",
"mapping": "Welche Gruppe welche Rolle bekommt",
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert.",
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert. Anbieter, die Gruppen nur auf Anfrage senden (z. B. Okta), brauchen zusätzlich den Scope „groups“ in den Scopes.",
"test": "Anbieter testen",
"dutiesTitle": "Aufgabentrennung",
"dutiesIntro": "Ist es ausgeschaltet, können Operatoren Änderungen vorbereiten (bearbeiten, Vorschau), aber nur ein Administrator rollt sie aus: ausrollen, ein Deployment entfernen oder zurückrollen, Wartung ausführen oder planen und Netzwerkänderungen anwenden. Die Ablehnung steht im Audit.",
+1 -1
View File
@@ -1604,7 +1604,7 @@
"scopes": "Scopes",
"claim": "Groups claim",
"mapping": "Which group gets which role",
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login.",
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login. Providers that only send groups when asked (e.g. Okta) need the scope \"groups\" added to the scopes.",
"test": "Test the provider",
"dutiesTitle": "Separation of duties",
"dutiesIntro": "When switched off, operators can prepare changes (edit, preview) but only an administrator may roll them out: deploy, remove or roll back a deployment, run or schedule maintenance and apply network changes. The refusal is recorded in the audit.",
+1 -1
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.23.0";
export const APP_VERSION = "1.23.3";
export const APP_CODENAME = "Harrier";
export const APP_MAINTAINER = "Julian Boldt";