Compare commits

...
3 Commits
Author SHA1 Message Date
Julian Boldt 5e5d51b57f Release v1.23.5
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
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
  und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
  bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
  darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
2026-10-20 14:22:12 +02:00
Julian Boldt 1aba618ad5 Release v1.23.4
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
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
  im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
  anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
  Prüfung weicht ab“.
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
  einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
2026-10-20 13:56:25 +02:00
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
17 changed files with 227 additions and 20 deletions

No files matched your search

+23
View File
@@ -31,6 +31,29 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.23.5] "Harrier" — 2026-10-20
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
## [1.23.4] "Harrier" — 2026-10-20
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
Prüfung weicht ab“.
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
## [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
+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.2` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
> **Stand:** `1.23.5` 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.2
OPNEXUS_VERSION=1.23.5
```
```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.2` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
> **Status:** `1.23.5` 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.2
OPNEXUS_VERSION=1.23.5
```
```bash
@@ -32,6 +32,14 @@ def _extract_carp_status(data: dict | None) -> str | None:
return None
def _vip_states(data: dict | None) -> dict[str, str]:
"""Per-VHID CARP state ("MASTER", "BACKUP", "DISABLED", "INIT", ...). The host-level carp_status above
collapses these, so one VHID that is down on a node is invisible there."""
rows = data.get("rows") if isinstance(data, dict) else None
return {str(row.get("vhid")): str(row.get("status", "")).upper()
for row in rows or [] if isinstance(row, dict) and row.get("vhid") not in (None, "")}
async def _status_for(host: OpnsenseHost) -> dict:
if not host.enabled:
return {"status": "disabled", "carp_status": None, "status_detail": None}
@@ -43,6 +51,7 @@ async def _status_for(host: OpnsenseHost) -> dict:
return {
"status": "online",
"carp_status": _extract_carp_status(data),
"carp_vips": _vip_states(data),
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
"carp_allowed": _truthy(carp.get("allow"), default=True),
"carp_demotion": carp.get("demotion"),
+35 -1
View File
@@ -22,6 +22,7 @@ taken before the change is the way back.
Secrets (the CARP password) are never returned, hashed into the token only, and
never written to the audit log.
"""
import asyncio
import hashlib
import ipaddress
import json
@@ -362,6 +363,20 @@ async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None,
return False, ["unreadable"]
async def _carp_vip_active(host: OpnsenseHost, vhid: str, attempts: int = 5) -> bool:
"""True once the firewall reports the VHID as MASTER or BACKUP, i.e. the VIP really is on the interface and
taking part in CARP (not DISABLED/INIT). The config can look right while the live VIP is gone."""
for attempt in range(attempts):
if attempt:
await asyncio.sleep(2)
data = await opnsense_client.get_vip_status(host)
rows = data.get("rows") if isinstance(data, dict) else None
states = {str(r.get("vhid")): str(r.get("status", "")).upper() for r in rows or [] if isinstance(r, dict)}
if states.get(vhid) in ("MASTER", "BACKUP"):
return True
return False
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
token: str, acknowledged: bool) -> dict:
await ensure_no_maintenance(db, [host])
@@ -384,6 +399,12 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
send.setdefault("network", plan["_before"]["network"])
# VipSettingsController::setItemAction queues "delete the old address" whenever the posted interface differs
# from the stored one, and a partial update that leaves "interface" out counts as different. The following
# reconfigure then removes the live VIP and, working from a stale address list, never puts it back (found
# live 2026-10-20: CARP VIP stuck on DISABLED after a description edit). So repeat the interface as well.
if plan["_before"].get("interface"):
send.setdefault("interface", plan["_before"]["interface"])
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
expected = {**plan["_before"], **send} if action == "set" else dict(send)
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
@@ -407,14 +428,27 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
# A description is not wired into anything, so there is nothing to re-apply for it.
cosmetic = kind == "vip" and action == "set" and {c["field"] for c in plan["changes"]} <= {"descr"}
carp_live = kind == "vip" and action != "del" and expected.get("mode") == "carp" and not cosmetic
try:
await opnsense_client.reconfigure_network(host, kind)
if not cosmetic:
await opnsense_client.reconfigure_network(host, kind)
live_ok = True
if carp_live:
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
if not live_ok: # one more apply usually brings it back up
await opnsense_client.reconfigure_network(host, kind)
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
except OpnsenseAPIError as exc:
# Written but not (provably) applied: say so instead of reporting success.
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
if not live_ok:
verified = False
mismatches = [*mismatches, "carp_vip_inactive"]
await audit_service.persist(**audit, status="applied" if verified else "unverified",
message=None if verified else ", ".join(mismatches)[:500], details=details)
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
@@ -236,6 +236,28 @@ async def check_carp_health(db: AsyncSession) -> dict[str, dict]:
"condition": "no_master",
"host_names": sorted(h.name for h in online),
}
problems.update(_carp_vip_problems(cluster_name, online, statuses, split_brain=len(masters) == 2))
return problems
def _carp_vip_problems(cluster_name: str, online: list[OpnsenseHost], statuses: dict, split_brain: bool) -> dict[str, dict]:
"""Per-VHID checks. The host-level role is "MASTER" as soon as one VIP is, so a VIP that is down on a
node (a node without the VIP never advertises it, the peer takes it over) looks healthy in the totals."""
problems: dict[str, dict] = {}
for host in online:
vips = statuses.get(host.id, {}).get("carp_vips") or {}
down = sorted(v for v, state in vips.items() if state not in ("MASTER", "BACKUP"))
if down:
problems[f"{cluster_name}:vip_down:{host.name}"] = {
"cluster_name": cluster_name, "condition": "vip_down", "host_names": [host.name], "vhids": down,
}
# One node MASTER on some VHIDs and BACKUP on others: a partial failover. Already covered by the
# split-brain alert when both nodes hold a MASTER.
if not split_brain and {"MASTER", "BACKUP"} <= set(vips.values()):
problems[f"{cluster_name}:role_mismatch:{host.name}"] = {
"cluster_name": cluster_name, "condition": "role_mismatch", "host_names": [host.name],
"vhids": sorted(v for v, state in vips.items() if state == "MASTER"),
}
return problems
+23 -6
View File
@@ -225,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:
@@ -268,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)
+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.2"
APP_VERSION = "1.23.5"
APP_CODENAME = "Harrier"
+37
View File
@@ -69,6 +69,43 @@ async def test_check_carp_health_healthy_reports_nothing(client, respx_mock):
assert problems == {}
def _vips(**states: str) -> dict:
return {"rows": [{"vhid": v, "status": st} for v, st in states.items()],
"carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
@pytest.mark.asyncio
async def test_check_carp_health_detects_a_vip_that_is_down_on_one_node(client, respx_mock):
# 2026-10-20: VHID 130 was DISABLED on the primary and taken over by the secondary; per host it looked fine.
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "DISABLED"})))
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
async with SessionLocal() as db:
problems = await notification_checks.check_carp_health(db)
assert {p["condition"] for p in problems.values()} == {"vip_down"}
assert next(iter(problems.values()))["vhids"] == ["130"]
@pytest.mark.asyncio
async def test_check_carp_health_detects_a_partial_failover(client, respx_mock):
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "BACKUP"})))
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
async with SessionLocal() as db:
problems = await notification_checks.check_carp_health(db)
assert [p["condition"] for p in problems.values()] == ["role_mismatch"]
@pytest.mark.asyncio
async def test_check_carp_health_ignores_single_reachable_host(client, respx_mock):
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(return_value=httpx.Response(200, json=_vip("MASTER")))
+44 -1
View File
@@ -192,6 +192,7 @@ async def test_vip_password_never_leaves_the_server(client, respx_mock):
_backup(respx_mock)
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "10", "status": "BACKUP"}]}))
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
resp = await _preview(client, host_id, **change)
@@ -462,6 +463,45 @@ async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mo
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
@pytest.mark.asyncio
async def test_vip_description_edit_does_not_reapply_the_vip(client, respx_mock):
# Re-applying a CARP VIP for a description edit left it in INIT on both prod nodes (2026-10-20).
host_id = await _setup(client, respx_mock, "fw-v2")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
"vhid": "12", "advskew": "0", "descr": "old"})
_backup(respx_mock)
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"descr": "new"}}
plan = (await _preview(client, host_id, **change)).json()
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200, done.text
assert not reconf.called
@pytest.mark.asyncio
async def test_vip_edit_reports_a_vip_that_is_not_live(client, respx_mock, monkeypatch):
async def no_sleep(_):
return None
monkeypatch.setattr("app.services.network_change_service.asyncio.sleep", no_sleep)
host_id = await _setup(client, respx_mock, "fw-v3")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
"vhid": "13", "advskew": "0", "descr": "x"})
_backup(respx_mock)
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(
return_value=httpx.Response(200, json={"rows": [{"vhid": "13", "status": "DISABLED"}]}))
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
plan = (await _preview(client, host_id, **change)).json()
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200, done.text
assert done.json()["verified"] is False and "carp_vip_inactive" in done.json()["mismatches"]
assert reconf.call_count == 2 # one retry
@pytest.mark.asyncio
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
# OPNsense blanks a VIP's address when an update leaves out "network" (seen live on 26.7.6).
@@ -471,12 +511,15 @@ async def test_vip_edit_always_repeats_the_address(client, respx_mock):
"vhid": "11", "advskew": "0", "descr": "x"})
_backup(respx_mock)
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "11", "status": "BACKUP"}]}))
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
plan = (await _preview(client, host_id, **change)).json()
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200, done.text
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24"}
# ...and the interface: OPNsense queues a delete of the live address when the posted interface differs
# from the stored one, and the reconfigure then drops the VIP (seen live 2026-10-20).
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24", "interface": "lan"}
@pytest.mark.asyncio
+13
View File
@@ -166,6 +166,19 @@ 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)
+5 -5
View File
@@ -248,11 +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) und gegen ein echtes Keycloak (Anmeldung,
Rollen aus Gruppen, Rollenentzug, abgefangener Callback); andere 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), brauchen zusätzlich den Scope `groups`.
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
@@ -825,8 +825,8 @@ 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 und gegen einen laufenden S3-Server mit echter
Signaturprüfung (Garage: Upload, Download, Löschen, Sonderzeichen im Schlüssel, falsches Geheimnis und falsche Region
werden abgelehnt) geprüft; bei AWS selbst, Backblaze, Wasabi & Co. zeigt dir „Ziel testen“ sofort, ob dein Anbieter funktioniert.
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
@@ -468,6 +468,8 @@ export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, prese
<p className="text-sm text-amber-700">
{result.mismatches.includes("unreadable")
? t("network.change.unreachable", { host: hostName })
: result.mismatches.includes("carp_vip_inactive")
? t("network.change.carpInactive")
: t("network.change.mismatch", { fields: result.mismatches.join(", ") })}
</p>
)}
+4 -1
View File
@@ -141,9 +141,12 @@ export function OpnexusUpdateSection() {
<p className="font-medium">{t("settings.update.notEnabledTitle")}</p>
<p>{t("settings.update.notEnabledBody")}</p>
<p>{t("settings.update.notEnabledStep1")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">COMPOSE_PROFILES=updater</pre>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">
{status.feed_enabled ? "" : "UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json\n"}COMPOSE_PROFILES=updater
</pre>
<p>{t("settings.update.notEnabledStep2")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">docker compose up -d</pre>
<p className="text-slate-500">{t("settings.update.notEnabledImages")}</p>
<p className="text-slate-500">{t("settings.update.notEnabledCli")}</p>
</>
) : (
+2
View File
@@ -128,6 +128,7 @@
"notEnabledBody": "Dafür läuft ein eigener kleiner Dienst (Updater) mit Zugriff auf Docker — er ist bewusst optional. Aktivieren:",
"notEnabledStep1": "In der .env auf dem Server eintragen:",
"notEnabledStep2": "Dann ausführen:",
"notEnabledImages": "Das geht nur mit Release-Images (Release-Bundle oder OPNEXUS_IMAGE_REPO in der .env). Wer OPNexus selbst aus dem Quellcode baut, aktualisiert mit „git pull“ und „docker compose up -d --build“.",
"notEnabledCli": "Bis dahin geht das Update auf dem Server mit „opnexus update“.",
"unreachable": "Der Updater antwortet nicht. Auf dem Server prüfen: docker compose ps updater",
"localMode": "Diese Installation baut OPNexus lokal aus dem Quellcode (kein Release-Image). Aktualisieren auf dem Server mit git pull und docker compose up -d --build.",
@@ -2482,6 +2483,7 @@
"applied": "Änderung angewendet.",
"verified": "Angewendet und geprüft",
"notVerified": "Angewendet, Prüfung weicht ab",
"carpInactive": "Die Änderung ist gespeichert, aber das CARP-VIP ist auf der Firewall nicht aktiv (Status nicht MASTER/BACKUP). Sofort unter Hosts › Status prüfen und die VIPs neu anwenden.",
"mismatch": "Die Firewall meldet abweichende Werte: {fields}",
"backupId": "Backup vor der Änderung: {id}",
"col": {
+2
View File
@@ -128,6 +128,7 @@
"notEnabledBody": "They need a small separate service (the updater) with access to Docker — deliberately optional. To enable it:",
"notEnabledStep1": "Add to the .env on the server:",
"notEnabledStep2": "Then run:",
"notEnabledImages": "This only works with release images (release bundle, or OPNEXUS_IMAGE_REPO in the .env). If you build OPNexus from source, update with “git pull” and “docker compose up -d --build”.",
"notEnabledCli": "Until then, update on the server with “opnexus update”.",
"unreachable": "The updater does not respond. Check on the server: docker compose ps updater",
"localMode": "This installation builds OPNexus locally from source (no release image). Update on the server with git pull and docker compose up -d --build.",
@@ -2482,6 +2483,7 @@
"applied": "Change applied.",
"verified": "Applied and checked",
"notVerified": "Applied, check differs",
"carpInactive": "The change is saved, but the CARP VIP is not active on the firewall (state is not MASTER/BACKUP). Check Hosts › Status right away and re-apply the VIPs.",
"mismatch": "The firewall reports different values: {fields}",
"backupId": "Backup before the change: {id}",
"col": {
+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.2";
export const APP_VERSION = "1.23.5";
export const APP_CODENAME = "Harrier";
export const APP_MAINTAINER = "Julian Boldt";