Compare commits

..
7 Commits
Author SHA1 Message Date
Julian Boldt 23d3a00ae1 Release v1.23.10
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
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
  Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
  installiert ist. Anleitung: Handbuch, „Images prüfen“.
2026-10-20 15:38:35 +02:00
Julian Boldt 4379a17b15 Release v1.23.9
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
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
  statt „baut aus dem Quellcode“.
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
2026-10-20 15:16:51 +02:00
Julian Boldt 983e05a816 Release v1.23.8
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
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
  die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
  `docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
2026-10-20 14:52:10 +02:00
Julian Boldt dd6a64d953 Release v1.23.7
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
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
2026-10-20 14:43:39 +02:00
Julian Boldt 6964ec6537 Release v1.23.6
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
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
  und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
  `UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
2026-10-20 14:38:19 +02:00
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
30 changed files with 629 additions and 38 deletions

No files matched your search

+3 -1
View File
@@ -73,6 +73,8 @@ TRUST_PROXY_HEADERS=false
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
# (optional, siehe unten) per Klick im Webinterface.
# Einfacher: im Webinterface unter Einstellungen › OPNexus aktualisieren einschalten.
# Ein Wert hier hat Vorrang und sperrt den Schalter dort.
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
# UPDATE_CHECK_INTERVAL_HOURS=12
@@ -80,7 +82,7 @@ TRUST_PROXY_HEADERS=false
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch, "Updates per Klick".
# AUS. Braucht außerdem die Versionsprüfung (Webinterface oder UPDATE_CHECK_URL, oben). Siehe Handbuch, "Updates per Klick".
# COMPOSE_PROFILES=updater
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
+45
View File
@@ -31,6 +31,51 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.23.10] "Harrier" — 2026-10-20
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
installiert ist. Anleitung: Handbuch, „Images prüfen“.
## [1.23.9] "Harrier" — 2026-10-20
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
statt „baut aus dem Quellcode“.
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
## [1.23.8] "Harrier" — 2026-10-20
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
## [1.23.7] "Harrier" — 2026-10-20
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
## [1.23.6] "Harrier" — 2026-10-20
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
`UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
## [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
+3 -3
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.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.
> **Stand:** `1.23.10` 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.3
OPNEXUS_VERSION=1.23.10
```
```bash
@@ -73,7 +73,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
## Datenschutz
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) ist **aus**, solange du ihn nicht setzt; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (Schalter unter Einstellungen › OPNexus aktualisieren, oder `UPDATE_CHECK_URL` in der `.env`) ist **aus**, solange du ihn nicht einschaltest; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
## Lizenz
+3 -3
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.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.
> **Status:** `1.23.10` 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.3
OPNEXUS_VERSION=1.23.10
```
```bash
@@ -77,7 +77,7 @@ docker compose pull && docker compose up -d --no-build
## Privacy
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (switch under Settings › Update OPNexus, or `UPDATE_CHECK_URL` in the `.env`) is **off** unless you turn it on; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
## License
+3
View File
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
# keine Instanz- oder Nutzerdaten.
update_check_url: str = ""
update_check_interval_hours: int = 12
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
opnexus_image_repo: str = ""
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
+2
View File
@@ -11,6 +11,8 @@ class ErrorCode(str, Enum):
UPDATER_REJECTED = "UPDATER_REJECTED"
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
UPDATE_CHECK_MANAGED_BY_SERVER = "UPDATE_CHECK_MANAGED_BY_SERVER"
UPDATE_CHECK_URL_INVALID = "UPDATE_CHECK_URL_INVALID"
ADMIN_REQUIRED = "ADMIN_REQUIRED"
BACKUP_EMPTY = "BACKUP_EMPTY"
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
@@ -0,0 +1,18 @@
"""Version check configured in the web UI (singleton row, id fixed at 1).
Off by default: OPNexus makes no outgoing request until an admin switches it on. An
UPDATE_CHECK_URL in the server's .env still wins over this row (server-managed)."""
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
class UpdateCheckSettings(Base):
__tablename__ = "update_check_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
feed_url: Mapped[str] = mapped_column(String(500), nullable=False, default=DEFAULT_FEED_URL, server_default=DEFAULT_FEED_URL)
+60 -6
View File
@@ -1,6 +1,9 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import current_user
@@ -10,10 +13,10 @@ router = APIRouter(prefix="/system", tags=["system"])
@router.get("/version-check")
async def version_check() -> dict:
async def version_check(db: AsyncSession = Depends(get_db)) -> dict:
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
return await update_check_service.check()
return await update_check_service.check(db)
def _admin(user: AdminUser) -> AdminUser:
@@ -31,16 +34,63 @@ def _major(version: str | None) -> int | None:
return None
class UpdateCheckRead(BaseModel):
enabled: bool
feed_url: str
managed_by_server: bool
class UpdateCheckUpdate(BaseModel):
enabled: bool | None = None
feed_url: str | None = Field(default=None, max_length=500)
async def _update_check_settings(db: AsyncSession) -> UpdateCheckRead:
row = await update_check_service.get_settings(db)
managed = update_check_service.managed_by_server()
return UpdateCheckRead(
enabled=True if managed else row.enabled,
feed_url=settings.update_check_url.strip() if managed else row.feed_url,
managed_by_server=managed,
)
@router.get("/update-check", response_model=UpdateCheckRead)
async def read_update_check(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Versionsprüfung für „Einstellungen › OPNexus aktualisieren“ (nur Admins)."""
_admin(user)
return await _update_check_settings(db)
@router.put("/update-check", response_model=UpdateCheckRead)
async def update_update_check(payload: UpdateCheckUpdate, db: AsyncSession = Depends(get_db),
user: AdminUser = Depends(current_user)):
_admin(user)
if update_check_service.managed_by_server():
raise api_error(409, ErrorCode.UPDATE_CHECK_MANAGED_BY_SERVER)
row = await update_check_service.get_settings(db)
if payload.feed_url is not None:
url = payload.feed_url.strip()
if not update_check_service.valid_feed_url(url):
raise api_error(422, ErrorCode.UPDATE_CHECK_URL_INVALID)
row.feed_url = url
if payload.enabled is not None:
row.enabled = payload.enabled
await db.commit()
update_check_service.reset_cache()
return await _update_check_settings(db)
class UpdateRequest(BaseModel):
version: str = Field(min_length=5, max_length=20)
@router.get("/updater")
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)) -> dict:
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
_admin(user)
check = await update_check_service.check()
check = await update_check_service.check(db)
result: dict = {
"available": False, "reason": None, "mode": None,
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
@@ -54,6 +104,9 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
updater = await updater_client.status()
except updater_client.UpdaterUnavailable as exc:
result["reason"] = str(exc)
# Without the updater the install mode is still known from the image registry setting; the UI uses it
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
return result
except updater_client.UpdaterRejected:
result["reason"] = "unreachable"
@@ -77,11 +130,12 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
@router.post("/updater/update", status_code=202)
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
async def updater_update(payload: UpdateRequest, db: AsyncSession = Depends(get_db),
user: AdminUser = Depends(current_user)) -> dict:
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
Version. Beliebige Versionen werden bewusst nicht angenommen."""
_admin(user)
check = await update_check_service.check()
check = await update_check_service.check(db)
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
if _major(check["latest"]) != _major(check["current"]):
@@ -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
+44 -7
View File
@@ -17,8 +17,10 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.update_check_settings import DEFAULT_FEED_URL, UpdateCheckSettings
from app.version import APP_VERSION
logger = logging.getLogger(__name__)
@@ -38,6 +40,7 @@ class _State:
latest: str | None = None
release_url: str | None = None
error: str | None = None
url: str | None = None
_state = _State()
@@ -45,11 +48,41 @@ _lock = asyncio.Lock()
def reset_cache() -> None:
"""Nur für Tests."""
"""Verwirft den gemerkten Stand (Tests, geänderter Feed)."""
global _state
_state = _State()
_SINGLETON_ID = 1
_URL = re.compile(r"^https?://[^\s<>\"']+$")
def valid_feed_url(value: str) -> bool:
return bool(_URL.match(value)) and len(value) <= 500
async def get_settings(db: AsyncSession) -> UpdateCheckSettings:
row = await db.get(UpdateCheckSettings, _SINGLETON_ID)
if row is None:
row = UpdateCheckSettings(id=_SINGLETON_ID, enabled=False, feed_url=DEFAULT_FEED_URL)
db.add(row)
await db.flush()
return row
def managed_by_server() -> bool:
"""UPDATE_CHECK_URL in the server's .env wins over the setting in the web UI."""
return bool(settings.update_check_url.strip())
async def feed_url(db: AsyncSession) -> str:
"""The feed to ask, or "" while the check is off."""
if managed_by_server():
return settings.update_check_url.strip()
row = await get_settings(db)
return row.feed_url.strip() if row.enabled else ""
def _parse(tag: str) -> tuple[int, int, int] | None:
match = _STABLE_TAG.match(tag.strip())
return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value]
@@ -62,10 +95,10 @@ def _safe_url(value: object) -> str | None:
return None
async def _fetch() -> tuple[str, str | None]:
async def _fetch(url: str) -> tuple[str, str | None]:
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True, max_redirects=3) as http:
response = await http.get(
settings.update_check_url,
url,
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
)
response.raise_for_status()
@@ -78,9 +111,10 @@ async def _fetch() -> tuple[str, str | None]:
return tag, _safe_url(data.get("html_url"))
async def check() -> dict:
async def check(db: AsyncSession) -> dict:
"""Aktueller Stand für GET /api/system/version-check."""
enabled = bool(settings.update_check_url.strip())
url = await feed_url(db)
enabled = bool(url)
current = _parse(APP_VERSION)
result = {
"enabled": enabled,
@@ -95,6 +129,9 @@ async def check() -> dict:
return result
async with _lock:
if _state.url != url: # Feed geändert: alter Stand gehört nicht mehr dazu
reset_cache()
_state.url = url
now = datetime.now(timezone.utc)
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
fresh = _state.checked_at is not None and now - _state.checked_at < interval
@@ -102,12 +139,12 @@ async def check() -> dict:
if not fresh and not backing_off:
_state.attempted_at = now
try:
tag, url = await _fetch()
tag, release_url = await _fetch(url)
if _parse(tag) is None:
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
_state.latest, _state.release_url = None, None
else:
_state.latest, _state.release_url = tag.lstrip("v"), url
_state.latest, _state.release_url = tag.lstrip("v"), release_url
_state.checked_at, _state.error = now, None
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
+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.3"
APP_VERSION = "1.23.10"
APP_CODENAME = "Harrier"
@@ -0,0 +1,31 @@
"""Versionsprüfung im Webinterface einstellbar.
Revision ID: 0019
Revises: 0018
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
_DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
def upgrade() -> None:
if sa.inspect(op.get_bind()).has_table("update_check_settings"):
return
op.create_table(
"update_check_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default="false", nullable=False),
sa.Column("feed_url", sa.String(length=500), server_default=_DEFAULT_FEED_URL, nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS update_check_settings")
+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
+72
View File
@@ -112,3 +112,75 @@ async def test_unreachable_feed_keeps_last_known_result(client, respx_mock, monk
@pytest.mark.asyncio
async def test_requires_login(anon_client):
assert (await anon_client.get("/api/system/version-check")).status_code == 401
# ── Einstellung im Webinterface (statt .env) ────────────────────────────
WEB_FEED = "https://mirror.test/latest.json"
@pytest.fixture
def _env_unset(monkeypatch):
monkeypatch.setattr(settings, "update_check_url", "")
async def _reset_row(client):
await client.put("/api/system/update-check", json={"enabled": False, "feed_url": "https://updates.opnexus.dev/latest.json"})
@pytest.mark.asyncio
async def test_check_is_off_by_default_and_can_be_switched_on_in_the_ui(client, respx_mock, _env_unset):
await _reset_row(client)
body = (await client.get("/api/system/update-check")).json()
assert body == {"enabled": False, "feed_url": "https://updates.opnexus.dev/latest.json", "managed_by_server": False}
assert (await client.get("/api/system/version-check")).json()["enabled"] is False
assert len(respx_mock.calls) == 0
newer = _bump(APP_VERSION, minor=1)
respx_mock.get(WEB_FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{newer}"}))
resp = await client.put("/api/system/update-check", json={"enabled": True, "feed_url": f" {WEB_FEED} "})
assert resp.status_code == 200 and resp.json()["feed_url"] == WEB_FEED and resp.json()["enabled"] is True
check = (await client.get("/api/system/version-check")).json()
assert check["enabled"] is True and check["latest"] == newer and check["update_available"] is True
await _reset_row(client)
@pytest.mark.asyncio
async def test_switching_the_check_off_again_stops_requests(client, respx_mock, _env_unset):
respx_mock.get(WEB_FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{_bump(APP_VERSION, minor=1)}"}))
await client.put("/api/system/update-check", json={"enabled": True, "feed_url": WEB_FEED})
assert (await client.get("/api/system/version-check")).json()["latest"]
await client.put("/api/system/update-check", json={"enabled": False})
after = (await client.get("/api/system/version-check")).json()
assert after["enabled"] is False and after["latest"] is None
await _reset_row(client)
@pytest.mark.asyncio
@pytest.mark.parametrize("url", ["", "ftp://x.test/f", "javascript:alert(1)", "https://a b.test/", "https://x.test/" + "a" * 500])
async def test_invalid_feed_urls_are_rejected(client, _env_unset, url):
resp = await client.put("/api/system/update-check", json={"feed_url": url})
assert resp.status_code in (422, 400)
if resp.status_code == 422 and isinstance(resp.json().get("detail"), dict):
assert resp.json()["detail"]["code"] == "UPDATE_CHECK_URL_INVALID"
@pytest.mark.asyncio
async def test_env_setting_wins_and_locks_the_ui(client, respx_mock):
# the autouse fixture sets UPDATE_CHECK_URL = FEED, i.e. the server decides
body = (await client.get("/api/system/update-check")).json()
assert body == {"enabled": True, "feed_url": FEED, "managed_by_server": True}
resp = await client.put("/api/system/update-check", json={"enabled": False})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_CHECK_MANAGED_BY_SERVER"
@pytest.mark.asyncio
async def test_only_admins_can_read_or_change_the_setting(client, _env_unset):
from tests.test_users import _login_as
await client.post("/api/users", json={"username": "viewer-upc", "password": "password-123456", "role": "read_only"})
viewer = await _login_as("viewer-upc", "password-123456")
try:
assert (await viewer.get("/api/system/update-check")).status_code == 403
assert (await viewer.put("/api/system/update-check", json={"enabled": True})).status_code == 403
finally:
await viewer.aclose()
+11
View File
@@ -58,6 +58,17 @@ async def test_status_without_token_is_unavailable_not_an_error(client, respx_mo
assert body["update_available"] is True # der Hinweis bleibt trotzdem
@pytest.mark.asyncio
@pytest.mark.parametrize("repo,mode", [("", "local"), ("git.example.test/opnexus", "release")])
async def test_install_mode_is_known_without_the_updater(client, respx_mock, monkeypatch, tmp_path, repo, mode):
# The panel must not tell a source-built install to enable an updater that could not update it anyway.
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
monkeypatch.setattr(settings, "opnexus_image_repo", repo)
_feed(respx_mock, _bump(minor=1))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "not_enabled" and body["mode"] == mode
@pytest.mark.asyncio
async def test_status_unreachable_updater(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA4pZWkNMkAtCGc54M2wuWy7CXwkT
3PCysv1/1IbkyJRPdM8+DFf26fBxWG9P9PLKfwavhqVYtHILxxzxKmtjeQ==
-----END PUBLIC KEY-----
+1
View File
@@ -116,6 +116,7 @@ services:
COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-}
OPNEXUS_IMAGE_REPO: ${OPNEXUS_IMAGE_REPO:-}
ALLOW_INSECURE_FIREWALL_URLS: ${ALLOW_INSECURE_FIREWALL_URLS:-false}
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
+34 -7
View File
@@ -74,6 +74,31 @@ Alternativ lassen sich auch aus einem Checkout die fertigen Images verwenden: in
der `.env` `OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus` und `OPNEXUS_VERSION=<version>`
setzen, dann `docker compose pull && docker compose up -d --no-build`.
### Images prüfen (Signatur)
Die Release-Images sind mit [cosign](https://github.com/sigstore/cosign) signiert (ab 1.23.9).
Damit lässt sich prüfen, dass ein Image wirklich von OPNexus stammt und seit dem Bau nicht
verändert wurde. Der öffentliche Schlüssel liegt als `cosign.pub` im Repository und im
Release-Bundle:
```bash
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
git.opnexus.dev/opnexus/opnexus-backend:<version>
```
Das Gleiche gilt für `opnexus-frontend`, `opnexus-proxy` und `opnexus-updater`. Eine
erfolgreiche Prüfung gibt die Signatur als JSON aus; bei einem fremden oder fehlenden
Schlüssel endet der Befehl mit einem Fehler. `--insecure-ignore-tlog=true` ist hier gewollt:
OPNexus lädt Signaturen nicht in das öffentliche Transparenzlog, damit die Prüfung auch in
abgeschotteten Netzen funktioniert. Die Signatur gilt dem Inhalt (Digest) des Images, nicht
dem Tag.
`opnexus update` prüft die Signaturen selbst, bevor es etwas wechselt, sobald `cosign`
installiert ist und `cosign.pub` neben der `docker-compose.yml` liegt (`cosign.pub` liegt im Bundle
bereits dabei, `cosign` selbst musst du installieren). Schlägt die Prüfung fehl, bricht das
Update ab und es wurde nichts verändert. Fehlt `cosign`, sagt `opnexus update` das und
macht ohne Prüfung weiter.
### Erster Start und Passwort
Ohne `ADMIN_PASSWORD` in der `.env` erzeugt OPNexus beim ersten Start ein
@@ -206,7 +231,7 @@ Alle Einstellungen stehen in der `.env` neben der `docker-compose.yml`. Nach ein
| `BIND_ADDRESS` | Schnittstelle für Backend und Frontend (Standard `127.0.0.1`). |
| `BACKEND_PORT`, `FRONTEND_PORT` | Host-Ports (Standard 8100 und 3100), z. B. für eine zweite Instanz. |
| `OPNEXUS_IMAGE_REPO`, `OPNEXUS_VERSION` | Registry und Version der Release-Images. `OPNEXUS_VERSION` ist die „Spur“, siehe [Updates](#opnexus-aktualisieren). |
| `UPDATE_CHECK_URL`, `UPDATE_CHECK_INTERVAL_HOURS` | Hinweis auf neue Versionen, standardmäßig **aus**. |
| `UPDATE_CHECK_URL`, `UPDATE_CHECK_INTERVAL_HOURS` | Hinweis auf neue Versionen, standardmäßig **aus**. Einstellbar auch im Webinterface; ein Wert in der `.env` hat Vorrang. |
| `COMPOSE_PROFILES=updater` | Aktiviert den optionalen Updater für Updates per Klick. |
| `ALLOW_INSECURE_FIREWALL_URLS` | Erlaubt `http://`-Adressen für Firewalls. Nur für isolierte Labore. |
| `SMTP_CA_FILE`, `ALLOW_INSECURE_SMTP_AUTH` | Eigene CA für den Mailserver bzw. SMTP-Anmeldung ohne Verschlüsselung (bleibt standardmäßig gesperrt). |
@@ -1160,11 +1185,11 @@ die Sicherung. Installationen bis 1.8.x gehen zuerst auf 1.9.x.
### Hinweis auf neue Versionen
Optional zeigt OPNexus Administratoren oben einen Hinweis, wenn eine neuere Version
veröffentlicht ist:
```bash
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
```
veröffentlicht ist. Eingeschaltet wird das unter **Einstellungen › OPNexus aktualisieren**
(Schalter „Versionsprüfung aktivieren“, Feed-Adresse `https://updates.opnexus.dev/latest.json`)
— ein Eingriff in die `.env` ist nicht nötig. Wer die Prüfung zentral festlegen will, kann
stattdessen `UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json` in die `.env`
schreiben; dann ist der Schalter im Webinterface gesperrt.
Standardmäßig ist das **aus**, dann macht OPNexus keine ausgehende Anfrage. Eingeschaltet
geht höchstens alle 12 Stunden (`UPDATE_CHECK_INTERVAL_HOURS`) ein einzelnes `GET` mit
@@ -1184,8 +1209,10 @@ Das ist **bewusst aus** und muss aktiviert werden, weil es Docker-Zugriff brauch
```bash
# .env
COMPOSE_PROFILES=updater
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
```
Die Versionsprüfung (Feed) schaltest du dazu im Webinterface ein, siehe oben.
```bash
docker compose pull && docker compose up -d --no-build
```
@@ -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>
)}
+80 -3
View File
@@ -2,9 +2,11 @@
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { FieldGroup, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, UpdaterStatus } from "@/lib/api";
import { api, UpdateCheckSettings, UpdaterStatus } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useNotAdmin } from "@/lib/useReadOnly";
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
@@ -14,6 +16,74 @@ import { useCallback, useEffect, useRef, useState } from "react";
const POLL_MS = 2000;
const RELOAD_DELAY_MS = 3500;
/** Versionsprüfung an/aus und Feed-Adresse — statt UPDATE_CHECK_URL in der .env (die weiterhin Vorrang hat). */
function FeedSettings({ onSaved }: { onSaved: () => void }) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [feed, setFeed] = useState<UpdateCheckSettings | null>(null);
const [url, setUrl] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.updateCheck().then((next) => {
setFeed(next);
setUrl(next.feed_url);
}).catch(() => setFeed(null));
}, []);
if (!feed) return null;
async function save(body: { enabled?: boolean; feed_url?: string }) {
setSaving(true);
try {
const next = await api.updateUpdateCheck(body);
setFeed(next);
setUrl(next.feed_url);
toast.show(t("settings.update.feedSaved"), "success");
onSaved();
} catch (e) {
toast.show(errorMessage(e, "common.requestFailed"), "danger");
} finally {
setSaving(false);
}
}
return (
<div className="space-y-3 rounded-lg border border-slate-200 p-3 text-sm">
<p className="flex items-center gap-2 font-medium">
{t("settings.update.feedTitle")}
<InfoTip text={t("settings.update.feedHint")} />
</p>
{feed.managed_by_server ? (
<>
<p className="text-slate-600">{t("settings.update.feedManaged")}</p>
<code className="block overflow-x-auto rounded bg-slate-50 p-2 text-xs">{feed.feed_url}</code>
</>
) : (
<>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={feed.enabled}
disabled={saving}
onChange={(e) => void save({ enabled: e.target.checked })}
/>
{t("settings.update.feedToggle")}
</label>
<FieldGroup label={t("settings.update.feedUrl")}>
<div className="flex gap-2">
<Input value={url} onChange={(e) => setUrl(e.target.value)} inputMode="url" spellCheck={false} />
<Button variant="secondary" disabled={saving || url.trim() === feed.feed_url} onClick={() => void save({ feed_url: url })}>
{t("settings.update.feedSave")}
</Button>
</div>
</FieldGroup>
</>
)}
</div>
);
}
/**
* Settings card "OPNexus aktualisieren": zeigt die verfügbare Version und löst
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
@@ -125,6 +195,8 @@ export function OpnexusUpdateSection() {
</dd>
</dl>
<FeedSettings onSaved={() => void load()} />
{!status.feed_enabled && <p className="text-sm text-slate-600">{t("settings.update.feedOff")}</p>}
{status.feed_enabled && status.feed_error && <p className="text-sm text-amber-700">{t("settings.update.feedError")}</p>}
{status.feed_enabled && !status.feed_error && !status.update_available && (
@@ -136,14 +208,19 @@ export function OpnexusUpdateSection() {
{!status.available && (
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
{status.reason === "not_enabled" || status.reason === "unauthorized" ? (
{(status.reason === "not_enabled" || status.reason === "unauthorized") && status.mode === "local" ? (
<p>{t("settings.update.localMode")}</p>
) : status.reason === "not_enabled" || status.reason === "unauthorized" ? (
<>
<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">
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>
</>
) : (
+3
View File
@@ -1229,6 +1229,7 @@ export interface UpdaterJob {
from_version: string | null;
}
export type UpdateCheckSettings = { enabled: boolean; feed_url: string; managed_by_server: boolean };
export interface UpdaterStatus {
/** false = Updater nicht aktiviert/erreichbar (`reason` sagt warum). */
available: boolean;
@@ -1287,6 +1288,8 @@ export const api = {
audit: (filters: Record<string,string | number> = {}) => request<AuditPage>(`/audit?${new URLSearchParams(Object.entries(filters).filter(([,v])=>v!=="").map(([k,v])=>[k,String(v)]))}`),
dashboard: () => request<DashboardSummary>("/dashboard"),
versionCheck: () => request<VersionCheck>("/system/version-check"),
updateCheck: () => request<UpdateCheckSettings>("/system/update-check"),
updateUpdateCheck: (body: { enabled?: boolean; feed_url?: string }) => request<UpdateCheckSettings>("/system/update-check", { method: "PUT", body: JSON.stringify(body) }),
updaterStatus: () => request<UpdaterStatus>("/system/updater"),
updaterUpdate: (version: string) => request<{ job: UpdaterJob }>("/system/updater/update", { method: "POST", body: JSON.stringify({ version }) }),
updaterRollback: () => request<{ job: UpdaterJob }>("/system/updater/rollback", { method: "POST" }),
+13 -2
View File
@@ -122,15 +122,23 @@
"upToDate": "OPNexus ist auf dem neuesten Stand.",
"notes": "Release-Notes",
"checked": "Zuletzt geprüft: {time}",
"feedOff": "Die Versionsprüfung ist aus. Trage UPDATE_CHECK_URL in der .env des Servers ein, damit OPNexus neue Versionen erkennt.",
"feedOff": "Die Versionsprüfung ist aus. Schalte sie oben ein, damit OPNexus neue Versionen erkennt.",
"feedTitle": "Auf neue Versionen prüfen",
"feedHint": "Fragt regelmäßig den Release-Feed ab, ob eine neuere Version erschienen ist. Gesendet wird nur eine einfache Anfrage (GET) mit der eigenen Versionsnummer, nichts sonst. Standardmäßig aus.",
"feedToggle": "Versionsprüfung aktivieren",
"feedUrl": "Feed-Adresse",
"feedSave": "Speichern",
"feedSaved": "Gespeichert.",
"feedManaged": "Die Versionsprüfung ist durch UPDATE_CHECK_URL in der .env des Servers festgelegt und lässt sich hier nicht ändern.",
"feedError": "Der Release-Feed ist gerade nicht erreichbar.",
"notEnabledTitle": "Updates aus dem Webinterface sind nicht aktiviert.",
"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.",
"localMode": "Diese Installation nutzt keine Release-Images (OPNEXUS_IMAGE_REPO ist in der .env nicht gesetzt). Aktualisieren auf dem Server mit „git pull“ und „docker compose up -d --build“.",
"majorChange": "Version {latest} ist ein Hauptversions-Sprung. Bitte erst die Release-Notes lesen und dann auf dem Server mit „opnexus update {latest}“ aktualisieren.",
"button": "Auf {latest} aktualisieren",
"confirmTitle": "Auf {latest} aktualisieren?",
@@ -599,6 +607,8 @@
"UPDATER_REJECTED": "Der Updater hat die Anfrage abgelehnt ({reason}).",
"UPDATE_NOT_OFFERED": "Diese Version wird nicht zur Installation angeboten — nur die neueste stabile Version.",
"UPDATE_MAJOR_CHANGE": "Hauptversions-Sprünge bitte bewusst auf dem Server mit „opnexus update“ ausführen.",
"UPDATE_CHECK_MANAGED_BY_SERVER": "Die Versionsprüfung ist durch UPDATE_CHECK_URL in der .env des Servers festgelegt und lässt sich hier nicht ändern.",
"UPDATE_CHECK_URL_INVALID": "Die Feed-Adresse muss mit http:// oder https:// beginnen und darf keine Leerzeichen enthalten.",
"ADMIN_REQUIRED": "Diese Funktion erfordert die Rolle „Admin“.",
"API_TOKEN_LIMIT_REACHED": "Es sind höchstens {limit} API-Tokens pro Benutzer möglich. Widerrufe zuerst nicht mehr benötigte Tokens.",
"BACKUP_UNLESBAR": "Das Backup lässt sich nicht lesen: {reason}",
@@ -2482,6 +2492,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": {
+13 -2
View File
@@ -122,15 +122,23 @@
"upToDate": "OPNexus is up to date.",
"notes": "Release notes",
"checked": "Last checked: {time}",
"feedOff": "The version check is off. Set UPDATE_CHECK_URL in the server's .env so OPNexus can detect new versions.",
"feedOff": "The version check is off. Switch it on above so OPNexus can detect new versions.",
"feedTitle": "Check for new versions",
"feedHint": "Regularly asks the release feed whether a newer version has been published. Only a plain request (GET) carrying your own version number is sent, nothing else. Off by default.",
"feedToggle": "Enable version check",
"feedUrl": "Feed address",
"feedSave": "Save",
"feedSaved": "Saved.",
"feedManaged": "The version check is fixed by UPDATE_CHECK_URL in the server's .env and cannot be changed here.",
"feedError": "The release feed is currently unreachable.",
"notEnabledTitle": "Updates from the web interface are not enabled.",
"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.",
"localMode": "This installation does not use release images (OPNEXUS_IMAGE_REPO is not set in the .env). Update on the server with “git pull” and “docker compose up -d --build”.",
"majorChange": "Version {latest} is a major version jump. Please read the release notes first, then update on the server with “opnexus update {latest}”.",
"button": "Update to {latest}",
"confirmTitle": "Update to {latest}?",
@@ -599,6 +607,8 @@
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
"UPDATE_NOT_OFFERED": "This version is not offered for installation — only the latest stable version.",
"UPDATE_MAJOR_CHANGE": "Please run major version jumps deliberately on the server with “opnexus update”.",
"UPDATE_CHECK_MANAGED_BY_SERVER": "The version check is fixed by UPDATE_CHECK_URL in the server's .env and cannot be changed here.",
"UPDATE_CHECK_URL_INVALID": "The feed address must start with http:// or https:// and must not contain spaces.",
"ADMIN_REQUIRED": "This function requires the “Admin” role.",
"API_TOKEN_LIMIT_REACHED": "At most {limit} API tokens are allowed per user. Revoke tokens you no longer need first.",
"BACKUP_UNLESBAR": "The backup cannot be read: {reason}",
@@ -2482,6 +2492,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.3";
export const APP_VERSION = "1.23.10";
export const APP_CODENAME = "Harrier";
export const APP_MAINTAINER = "Julian Boldt";
+16
View File
@@ -9,6 +9,8 @@
# scripts/build_release_images.sh v1.10.0 --push # bauen und pushen
# scripts/build_release_images.sh v1.10.0 --push --multiarch # amd64+arm64 (braucht QEMU/buildx)
#
# Signieren (cosign): OPNEXUS_SIGN=1 COSIGN_KEY=/pfad/cosign.key [COSIGN_PASSWORD=…] — der öffentliche
# Schlüssel liegt als cosign.pub im Repo; die Registry muss ihren Token-Realm per https melden.
# REPO: Umgebungsvariable OPNEXUS_IMAGE_REPO (Standard git.opnexus.dev/opnexus).
# Hinweis: Hinter dem Cloudflare-Proxy (Free) scheitern Pushes mit Layern > 100 MB —
# dann die Registry-Domain auf "DNS only" stellen oder aus dem LAN pushen.
@@ -45,5 +47,19 @@ for svc in backend frontend proxy updater; do
docker build "${targs[@]}" "$src/$svc"
if [ "$push" = 1 ]; then for t in "${tags[@]}"; do docker push "$image:$t"; done; fi
fi
if [ "$push" = 1 ] && [ "${OPNEXUS_SIGN:-0}" = 1 ]; then
# Signiert wird der Digest (nicht der veränderliche Tag). Ein Schlüssel: COSIGN_KEY (+ COSIGN_PASSWORD).
[ -n "${COSIGN_KEY:-}" ] || { echo "OPNEXUS_SIGN=1, aber COSIGN_KEY ist nicht gesetzt" >&2; exit 1; }
if [ "$multi" = 1 ]; then
digest="$(docker buildx imagetools inspect "$image:$version" --format '{{json .Manifest.Digest}}' | tr -d '"')"
else
digest="$(docker image inspect "$image:$version" --format '{{json .RepoDigests}}' | tr ',' '\n' | grep -F "$image@" | tr -d '[]"')"; digest="${digest##*@}"
fi
[ -n "$digest" ] || { echo "Digest von $image:$version nicht gefunden (Push fehlgeschlagen?)" >&2; exit 1; }
# Ohne öffentliches Transparenzlog (Rekor): Prüfen soll auch in abgeschotteten Netzen gehen.
cosign sign --yes --use-signing-config=false --tlog-upload=false --key "$COSIGN_KEY" "$image@$digest"
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$image@$digest" >/dev/null
echo " signiert: $image@$digest"
fi
done
[ "$push" = 1 ] && echo "Fertig und gepusht: $repo (${tags[*]})" || echo "Fertig (lokal gebaut, nicht gepusht)."
+1
View File
@@ -33,6 +33,7 @@ sed -e "s|^# OPNEXUS_IMAGE_REPO=.*|OPNEXUS_IMAGE_REPO=$repo|" \
cp scripts/opnexus "$out/opnexus"
cp LICENSE "$out/"
cp cosign.pub "$out/"
cat > "$out/INSTALL.txt" <<TXT
OPNexus $version
+18
View File
@@ -217,6 +217,23 @@ cmd_restore() {
say "Wiederhergestellt."
}
# Prüft die Signatur der gezogenen Images (cosign), bevor irgendetwas gewechselt wird. Nur wenn `cosign`
# installiert ist und cosign.pub neben der docker-compose.yml liegt; sonst wird das gesagt und übersprungen.
verify_signatures() {
local target="$1"; shift
local repo; repo=$(env_get OPNEXUS_IMAGE_REPO)
if ! command -v cosign >/dev/null 2>&1 || [ ! -f cosign.pub ]; then
say "Signaturprüfung übersprungen (cosign oder cosign.pub fehlt — siehe Handbuch, „Images prüfen“)."
return 0
fi
local svc
for svc in "$@"; do
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$repo/opnexus-$svc:$target" >/dev/null 2>&1 \
|| die "Signatur von $repo/opnexus-$svc:$target ist ungültig oder fehlt — nichts wurde verändert."
done
say "Signaturen geprüft."
}
cmd_update() {
require_release_mode
local target="${1:-$(env_get OPNEXUS_VERSION)}"
@@ -235,6 +252,7 @@ cmd_update() {
local pull_services=("${SERVICES[@]}")
if updater_enabled; then pull_services+=(updater); fi
OPNEXUS_VERSION="$target" c pull "${pull_services[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
verify_signatures "$target" "${pull_services[@]}"
started=$(date -u +%Y%m%dT%H%M%SZ)
cat > "$STATE_FILE" <<STATE