Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4379a17b15 | ||
|
|
983e05a816 | ||
|
|
dd6a64d953 | ||
|
|
6964ec6537 | ||
|
|
5e5d51b57f | ||
|
|
1aba618ad5 | ||
|
|
ba954c05bf | ||
|
|
63d014b14c |
No files matched your search
+3
-1
@@ -73,6 +73,8 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
||||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
||||||
# (optional, siehe unten) per Klick im Webinterface.
|
# (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_URL=https://updates.opnexus.dev/latest.json
|
||||||
# UPDATE_CHECK_INTERVAL_HOURS=12
|
# UPDATE_CHECK_INTERVAL_HOURS=12
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
||||||
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
|
# 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
|
# 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
|
# COMPOSE_PROFILES=updater
|
||||||
|
|
||||||
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||||
|
|||||||
@@ -31,6 +31,57 @@ den Einstellungen.
|
|||||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||||
ansteht): Merlin …
|
ansteht): Merlin …
|
||||||
|
|
||||||
|
## [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
|
||||||
|
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
|
||||||
|
Garage, MinIO, RustFS und Versity S3 Gateway.
|
||||||
|
|
||||||
|
## [1.23.2] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
|
||||||
|
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
|
||||||
|
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
|
||||||
|
|
||||||
## [1.23.1] "Harrier" — 2026-10-20
|
## [1.23.1] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Stand:** `1.23.1` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
> **Stand:** `1.23.9` 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
|
## Funktionen
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.23.1
|
OPNEXUS_VERSION=1.23.9
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -73,7 +73,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
|
|||||||
|
|
||||||
## Datenschutz
|
## 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
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Status:** `1.23.1` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
> **Status:** `1.23.9` 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
|
## What it does
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.23.1
|
OPNEXUS_VERSION=1.23.9
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -77,7 +77,7 @@ docker compose pull && docker compose up -d --no-build
|
|||||||
|
|
||||||
## Privacy
|
## 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
|||||||
# keine Instanz- oder Nutzerdaten.
|
# keine Instanz- oder Nutzerdaten.
|
||||||
update_check_url: str = ""
|
update_check_url: str = ""
|
||||||
update_check_interval_hours: int = 12
|
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) ─────
|
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
|
||||||
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class ErrorCode(str, Enum):
|
|||||||
UPDATER_REJECTED = "UPDATER_REJECTED"
|
UPDATER_REJECTED = "UPDATER_REJECTED"
|
||||||
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
||||||
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
|
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"
|
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
||||||
BACKUP_EMPTY = "BACKUP_EMPTY"
|
BACKUP_EMPTY = "BACKUP_EMPTY"
|
||||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class OidcSettings(Base):
|
|||||||
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email groups")
|
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
|
||||||
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
||||||
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
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.errors import ErrorCode, api_error
|
||||||
from app.models.user import AdminUser
|
from app.models.user import AdminUser
|
||||||
from app.routers.auth import current_user
|
from app.routers.auth import current_user
|
||||||
@@ -10,10 +13,10 @@ router = APIRouter(prefix="/system", tags=["system"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/version-check")
|
@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
|
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
||||||
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
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:
|
def _admin(user: AdminUser) -> AdminUser:
|
||||||
@@ -31,16 +34,63 @@ def _major(version: str | None) -> int | None:
|
|||||||
return 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):
|
class UpdateRequest(BaseModel):
|
||||||
version: str = Field(min_length=5, max_length=20)
|
version: str = Field(min_length=5, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/updater")
|
@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:
|
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
||||||
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
||||||
_admin(user)
|
_admin(user)
|
||||||
check = await update_check_service.check()
|
check = await update_check_service.check(db)
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"available": False, "reason": None, "mode": None,
|
"available": False, "reason": None, "mode": None,
|
||||||
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
"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()
|
updater = await updater_client.status()
|
||||||
except updater_client.UpdaterUnavailable as exc:
|
except updater_client.UpdaterUnavailable as exc:
|
||||||
result["reason"] = str(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
|
return result
|
||||||
except updater_client.UpdaterRejected:
|
except updater_client.UpdaterRejected:
|
||||||
result["reason"] = "unreachable"
|
result["reason"] = "unreachable"
|
||||||
@@ -77,11 +130,12 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/updater/update", status_code=202)
|
@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
|
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
||||||
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
||||||
_admin(user)
|
_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"]:
|
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
||||||
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
||||||
if _major(check["latest"]) != _major(check["current"]):
|
if _major(check["latest"]) != _major(check["current"]):
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ def _extract_carp_status(data: dict | None) -> str | None:
|
|||||||
return 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:
|
async def _status_for(host: OpnsenseHost) -> dict:
|
||||||
if not host.enabled:
|
if not host.enabled:
|
||||||
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
||||||
@@ -43,6 +51,7 @@ async def _status_for(host: OpnsenseHost) -> dict:
|
|||||||
return {
|
return {
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"carp_status": _extract_carp_status(data),
|
"carp_status": _extract_carp_status(data),
|
||||||
|
"carp_vips": _vip_states(data),
|
||||||
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
||||||
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
||||||
"carp_demotion": carp.get("demotion"),
|
"carp_demotion": carp.get("demotion"),
|
||||||
|
|||||||
@@ -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
|
Secrets (the CARP password) are never returned, hashed into the token only, and
|
||||||
never written to the audit log.
|
never written to the audit log.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
@@ -362,6 +363,20 @@ async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None,
|
|||||||
return False, ["unreadable"]
|
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,
|
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
||||||
token: str, acknowledged: bool) -> dict:
|
token: str, acknowledged: bool) -> dict:
|
||||||
await ensure_no_maintenance(db, [host])
|
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
|
# 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).
|
# 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"])
|
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.
|
# 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)
|
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
|
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)
|
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
|
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)
|
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:
|
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:
|
except OpnsenseAPIError as exc:
|
||||||
# Written but not (provably) applied: say so instead of reporting success.
|
# 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)
|
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
|
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)
|
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",
|
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
||||||
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
||||||
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
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",
|
"condition": "no_master",
|
||||||
"host_names": sorted(h.name for h in online),
|
"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
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async def get_settings(db: AsyncSession) -> OidcSettings:
|
|||||||
if row is None:
|
if row is None:
|
||||||
row = OidcSettings(
|
row = OidcSettings(
|
||||||
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
|
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
|
||||||
scopes="openid profile email groups", groups_claim="groups",
|
scopes="openid profile email", groups_claim="groups",
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -225,14 +225,31 @@ def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
async def _userinfo(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||||
if cfg.groups_claim in claims:
|
"""The provider's userinfo answer for THIS identity ({} if there is none or it belongs to somebody else)."""
|
||||||
return _as_groups(claims[cfg.groups_claim])
|
|
||||||
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
|
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
|
||||||
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
|
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
|
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
|
||||||
return _as_groups(info.get(cfg.groups_claim))
|
return info
|
||||||
return []
|
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:
|
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")))
|
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
|
||||||
if role is None:
|
if role is None:
|
||||||
raise OidcError("no_role", "Kein zugeordnete Gruppe")
|
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)
|
||||||
@@ -17,8 +17,10 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.models.update_check_settings import DEFAULT_FEED_URL, UpdateCheckSettings
|
||||||
from app.version import APP_VERSION
|
from app.version import APP_VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,6 +40,7 @@ class _State:
|
|||||||
latest: str | None = None
|
latest: str | None = None
|
||||||
release_url: str | None = None
|
release_url: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
_state = _State()
|
_state = _State()
|
||||||
@@ -45,11 +48,41 @@ _lock = asyncio.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def reset_cache() -> None:
|
def reset_cache() -> None:
|
||||||
"""Nur für Tests."""
|
"""Verwirft den gemerkten Stand (Tests, geänderter Feed)."""
|
||||||
global _state
|
global _state
|
||||||
_state = _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:
|
def _parse(tag: str) -> tuple[int, int, int] | None:
|
||||||
match = _STABLE_TAG.match(tag.strip())
|
match = _STABLE_TAG.match(tag.strip())
|
||||||
return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value]
|
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
|
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:
|
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True, max_redirects=3) as http:
|
||||||
response = await http.get(
|
response = await http.get(
|
||||||
settings.update_check_url,
|
url,
|
||||||
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
|
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -78,9 +111,10 @@ async def _fetch() -> tuple[str, str | None]:
|
|||||||
return tag, _safe_url(data.get("html_url"))
|
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."""
|
"""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)
|
current = _parse(APP_VERSION)
|
||||||
result = {
|
result = {
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
@@ -95,6 +129,9 @@ async def check() -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async with _lock:
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
|
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
|
||||||
fresh = _state.checked_at is not None and now - _state.checked_at < interval
|
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:
|
if not fresh and not backing_off:
|
||||||
_state.attempted_at = now
|
_state.attempted_at = now
|
||||||
try:
|
try:
|
||||||
tag, url = await _fetch()
|
tag, release_url = await _fetch(url)
|
||||||
if _parse(tag) is None:
|
if _parse(tag) is None:
|
||||||
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
|
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
|
||||||
_state.latest, _state.release_url = None, None
|
_state.latest, _state.release_url = None, None
|
||||||
else:
|
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
|
_state.checked_at, _state.error = now, None
|
||||||
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
|
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
|
||||||
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
|
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
|||||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "1.23.1"
|
APP_VERSION = "1.23.9"
|
||||||
APP_CODENAME = "Harrier"
|
APP_CODENAME = "Harrier"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""OIDC: Standard-Scopes ohne "groups" (Keycloak und Entra ID lehnen den Scope als ungültig ab).
|
||||||
|
|
||||||
|
Revision ID: 0018
|
||||||
|
Revises: 0017
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0018"
|
||||||
|
down_revision = "0017"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Only rows still carrying the old default; a value somebody typed on purpose stays.
|
||||||
|
op.execute("UPDATE oidc_settings SET scopes = 'openid profile email' WHERE scopes = 'openid profile email groups'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -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")
|
||||||
@@ -69,6 +69,43 @@ async def test_check_carp_health_healthy_reports_nothing(client, respx_mock):
|
|||||||
assert problems == {}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_check_carp_health_ignores_single_reachable_host(client, respx_mock):
|
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")))
|
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(return_value=httpx.Response(200, json=_vip("MASTER")))
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ async def test_vip_password_never_leaves_the_server(client, respx_mock):
|
|||||||
_backup(respx_mock)
|
_backup(respx_mock)
|
||||||
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
||||||
return_value=httpx.Response(200, json={"result": "saved"}))
|
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"}))
|
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"}}
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
|
||||||
resp = await _preview(client, host_id, **change)
|
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)])
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
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).
|
# 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"})
|
"vhid": "11", "advskew": "0", "descr": "x"})
|
||||||
_backup(respx_mock)
|
_backup(respx_mock)
|
||||||
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
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"}))
|
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"}}
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||||
plan = (await _preview(client, host_id, **change)).json()
|
plan = (await _preview(client, host_id, **change)).json()
|
||||||
done = await _execute(client, host_id, plan["token"], **change)
|
done = await _execute(client, host_id, plan["token"], **change)
|
||||||
assert done.status_code == 200, done.text
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -166,6 +166,19 @@ async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(clien
|
|||||||
assert _error(resp) == "no_role"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
|
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
|
||||||
await _configure(client)
|
await _configure(client)
|
||||||
|
|||||||
@@ -112,3 +112,75 @@ async def test_unreachable_feed_keeps_last_known_result(client, respx_mock, monk
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_requires_login(anon_client):
|
async def test_requires_login(anon_client):
|
||||||
assert (await anon_client.get("/api/system/version-check")).status_code == 401
|
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()
|
||||||
@@ -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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_status_unreachable_updater(client, respx_mock):
|
async def test_status_unreachable_updater(client, respx_mock):
|
||||||
_feed(respx_mock, _bump(minor=1))
|
_feed(respx_mock, _bump(minor=1))
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA4pZWkNMkAtCGc54M2wuWy7CXwkT
|
||||||
|
3PCysv1/1IbkyJRPdM8+DFf26fBxWG9P9PLKfwavhqVYtHILxxzxKmtjeQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -116,6 +116,7 @@ services:
|
|||||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
|
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
|
||||||
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-}
|
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-}
|
||||||
|
OPNEXUS_IMAGE_REPO: ${OPNEXUS_IMAGE_REPO:-}
|
||||||
ALLOW_INSECURE_FIREWALL_URLS: ${ALLOW_INSECURE_FIREWALL_URLS:-false}
|
ALLOW_INSECURE_FIREWALL_URLS: ${ALLOW_INSECURE_FIREWALL_URLS:-false}
|
||||||
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
|
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
|
||||||
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
|
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
|
||||||
|
|||||||
+17
-11
@@ -206,7 +206,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`). |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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). |
|
| `SMTP_CA_FILE`, `ALLOW_INSECURE_SMTP_AUTH` | Eigene CA für den Mailserver bzw. SMTP-Anmeldung ohne Verschlüsselung (bleibt standardmäßig gesperrt). |
|
||||||
@@ -248,8 +248,11 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
|
|||||||
- **Geprüft** werden Signatur (nur asymmetrische Verfahren, nie `none` oder HMAC), Aussteller, Zielgruppe, Ablauf
|
- **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
|
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
|
ID-Token, ersatzweise aus dem Userinfo-Endpunkt. Aussteller und Endpunkte müssen `https://` sein. Das ist
|
||||||
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token), noch nicht gegen jeden echten
|
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token) und gegen echte Server von Keycloak 26 und
|
||||||
Anbieter: melde, was bei deinem nicht klappt.
|
Authelia 4.39 (Anmeldung, Rollen aus Gruppen, Rollenentzug, abgefangener Callback); andere Anbieter: melde, was bei deinem nicht klappt.
|
||||||
|
Die Standard-Scopes sind `openid profile email`; die Gruppen müssen als Claim im ID-Token oder Userinfo ankommen
|
||||||
|
(Keycloak: Mapper „Group Membership“ am Client; Entra ID: optionaler Gruppen-Claim). Anbieter, die Gruppen nur auf
|
||||||
|
Anfrage senden (z. B. Okta, Authelia), brauchen zusätzlich den Scope `groups`.
|
||||||
|
|
||||||
**Aufgabentrennung.** Einstellungen › Aufgabentrennung: Schaltest du „Operatoren dürfen Änderungen selbst
|
**Aufgabentrennung.** Einstellungen › Aufgabentrennung: Schaltest du „Operatoren dürfen Änderungen selbst
|
||||||
ausrollen“ aus, bereiten Operatoren Änderungen nur vor (anlegen, bearbeiten, Vorschau), und **nur ein Admin
|
ausrollen“ aus, bereiten Operatoren Änderungen nur vor (anlegen, bearbeiten, Vorschau), und **nur ein Admin
|
||||||
@@ -821,8 +824,9 @@ docker compose exec -T -e BACKUP_PASSPHRASE='…' backend python -m app.backup_d
|
|||||||
|
|
||||||
Die Benachrichtigung „Kein aktuelles Backup“ meldet sich, wenn ein Host drei Intervalle (mindestens
|
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
|
6 Stunden) ohne Sicherung war, „Offsite-Backup fehlgeschlagen“ bei Problemen mit dem Ziel. Die
|
||||||
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele geprüft, aber noch nicht gegen einen
|
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele und gegen einen laufenden S3-Server mit echter
|
||||||
laufenden S3-Anbieter; „Ziel testen“ zeigt dir 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
|
**Ä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
|
vorherigen geändert hat. Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als
|
||||||
@@ -1156,11 +1160,11 @@ die Sicherung. Installationen bis 1.8.x gehen zuerst auf 1.9.x.
|
|||||||
### Hinweis auf neue Versionen
|
### Hinweis auf neue Versionen
|
||||||
|
|
||||||
Optional zeigt OPNexus Administratoren oben einen Hinweis, wenn eine neuere Version
|
Optional zeigt OPNexus Administratoren oben einen Hinweis, wenn eine neuere Version
|
||||||
veröffentlicht ist:
|
veröffentlicht ist. Eingeschaltet wird das unter **Einstellungen › OPNexus aktualisieren**
|
||||||
|
(Schalter „Versionsprüfung aktivieren“, Feed-Adresse `https://updates.opnexus.dev/latest.json`)
|
||||||
```bash
|
— ein Eingriff in die `.env` ist nicht nötig. Wer die Prüfung zentral festlegen will, kann
|
||||||
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
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
|
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
|
geht höchstens alle 12 Stunden (`UPDATE_CHECK_INTERVAL_HOURS`) ein einzelnes `GET` mit
|
||||||
@@ -1180,8 +1184,10 @@ Das ist **bewusst aus** und muss aktiviert werden, weil es Docker-Zugriff brauch
|
|||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
COMPOSE_PROFILES=updater
|
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
|
```bash
|
||||||
docker compose pull && docker compose up -d --no-build
|
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">
|
<p className="text-sm text-amber-700">
|
||||||
{result.mismatches.includes("unreadable")
|
{result.mismatches.includes("unreadable")
|
||||||
? t("network.change.unreachable", { host: hostName })
|
? t("network.change.unreachable", { host: hostName })
|
||||||
|
: result.mismatches.includes("carp_vip_inactive")
|
||||||
|
? t("network.change.carpInactive")
|
||||||
: t("network.change.mismatch", { fields: result.mismatches.join(", ") })}
|
: t("network.change.mismatch", { fields: result.mismatches.join(", ") })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function OidcSection() {
|
|||||||
const [issuer, setIssuer] = useState("");
|
const [issuer, setIssuer] = useState("");
|
||||||
const [clientId, setClientId] = useState("");
|
const [clientId, setClientId] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [scopes, setScopes] = useState("openid profile email groups");
|
const [scopes, setScopes] = useState("openid profile email");
|
||||||
const [claim, setClaim] = useState("groups");
|
const [claim, setClaim] = useState("groups");
|
||||||
const [admin, setAdmin] = useState("");
|
const [admin, setAdmin] = useState("");
|
||||||
const [operator, setOperator] = useState("");
|
const [operator, setOperator] = useState("");
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
|
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { FieldGroup, Input } from "@/components/ui/Field";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { InfoTip } from "@/components/ui/Tooltip";
|
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 { useLanguage } from "@/components/LanguageProvider";
|
||||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||||
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
|
||||||
@@ -14,6 +16,74 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
const RELOAD_DELAY_MS = 3500;
|
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
|
* Settings card "OPNexus aktualisieren": zeigt die verfügbare Version und löst
|
||||||
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
|
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
|
||||||
@@ -125,6 +195,8 @@ export function OpnexusUpdateSection() {
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<FeedSettings onSaved={() => void load()} />
|
||||||
|
|
||||||
{!status.feed_enabled && <p className="text-sm text-slate-600">{t("settings.update.feedOff")}</p>}
|
{!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 && <p className="text-sm text-amber-700">{t("settings.update.feedError")}</p>}
|
||||||
{status.feed_enabled && !status.feed_error && !status.update_available && (
|
{status.feed_enabled && !status.feed_error && !status.update_available && (
|
||||||
@@ -136,14 +208,19 @@ export function OpnexusUpdateSection() {
|
|||||||
|
|
||||||
{!status.available && (
|
{!status.available && (
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
<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 className="font-medium">{t("settings.update.notEnabledTitle")}</p>
|
||||||
<p>{t("settings.update.notEnabledBody")}</p>
|
<p>{t("settings.update.notEnabledBody")}</p>
|
||||||
<p>{t("settings.update.notEnabledStep1")}</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>
|
<p>{t("settings.update.notEnabledStep2")}</p>
|
||||||
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">docker compose up -d</pre>
|
<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>
|
<p className="text-slate-500">{t("settings.update.notEnabledCli")}</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1229,6 +1229,7 @@ export interface UpdaterJob {
|
|||||||
from_version: string | null;
|
from_version: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateCheckSettings = { enabled: boolean; feed_url: string; managed_by_server: boolean };
|
||||||
export interface UpdaterStatus {
|
export interface UpdaterStatus {
|
||||||
/** false = Updater nicht aktiviert/erreichbar (`reason` sagt warum). */
|
/** false = Updater nicht aktiviert/erreichbar (`reason` sagt warum). */
|
||||||
available: boolean;
|
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)]))}`),
|
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"),
|
dashboard: () => request<DashboardSummary>("/dashboard"),
|
||||||
versionCheck: () => request<VersionCheck>("/system/version-check"),
|
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"),
|
updaterStatus: () => request<UpdaterStatus>("/system/updater"),
|
||||||
updaterUpdate: (version: string) => request<{ job: UpdaterJob }>("/system/updater/update", { method: "POST", body: JSON.stringify({ version }) }),
|
updaterUpdate: (version: string) => request<{ job: UpdaterJob }>("/system/updater/update", { method: "POST", body: JSON.stringify({ version }) }),
|
||||||
updaterRollback: () => request<{ job: UpdaterJob }>("/system/updater/rollback", { method: "POST" }),
|
updaterRollback: () => request<{ job: UpdaterJob }>("/system/updater/rollback", { method: "POST" }),
|
||||||
|
|||||||
@@ -122,15 +122,23 @@
|
|||||||
"upToDate": "OPNexus ist auf dem neuesten Stand.",
|
"upToDate": "OPNexus ist auf dem neuesten Stand.",
|
||||||
"notes": "Release-Notes",
|
"notes": "Release-Notes",
|
||||||
"checked": "Zuletzt geprüft: {time}",
|
"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.",
|
"feedError": "Der Release-Feed ist gerade nicht erreichbar.",
|
||||||
"notEnabledTitle": "Updates aus dem Webinterface sind nicht aktiviert.",
|
"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:",
|
"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:",
|
"notEnabledStep1": "In der .env auf dem Server eintragen:",
|
||||||
"notEnabledStep2": "Dann ausführen:",
|
"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“.",
|
"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",
|
"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.",
|
"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",
|
"button": "Auf {latest} aktualisieren",
|
||||||
"confirmTitle": "Auf {latest} aktualisieren?",
|
"confirmTitle": "Auf {latest} aktualisieren?",
|
||||||
@@ -599,6 +607,8 @@
|
|||||||
"UPDATER_REJECTED": "Der Updater hat die Anfrage abgelehnt ({reason}).",
|
"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_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_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“.",
|
"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.",
|
"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}",
|
"BACKUP_UNLESBAR": "Das Backup lässt sich nicht lesen: {reason}",
|
||||||
@@ -1604,7 +1614,7 @@
|
|||||||
"scopes": "Scopes",
|
"scopes": "Scopes",
|
||||||
"claim": "Gruppen-Claim",
|
"claim": "Gruppen-Claim",
|
||||||
"mapping": "Welche Gruppe welche Rolle bekommt",
|
"mapping": "Welche Gruppe welche Rolle bekommt",
|
||||||
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert.",
|
"mappingHint": "Gruppennamen so eintragen, wie der Anbieter sie sendet. Die höchste passende Rolle gilt; wer in keiner Gruppe ist, kann sich nicht anmelden. Die Rolle von SSO-Konten wird beim Anbieter gepflegt und bei jeder Anmeldung aktualisiert. Anbieter, die Gruppen nur auf Anfrage senden (z. B. Okta), brauchen zusätzlich den Scope „groups“ in den Scopes.",
|
||||||
"test": "Anbieter testen",
|
"test": "Anbieter testen",
|
||||||
"dutiesTitle": "Aufgabentrennung",
|
"dutiesTitle": "Aufgabentrennung",
|
||||||
"dutiesIntro": "Ist es ausgeschaltet, können Operatoren Änderungen vorbereiten (bearbeiten, Vorschau), aber nur ein Administrator rollt sie aus: ausrollen, ein Deployment entfernen oder zurückrollen, Wartung ausführen oder planen und Netzwerkänderungen anwenden. Die Ablehnung steht im Audit.",
|
"dutiesIntro": "Ist es ausgeschaltet, können Operatoren Änderungen vorbereiten (bearbeiten, Vorschau), aber nur ein Administrator rollt sie aus: ausrollen, ein Deployment entfernen oder zurückrollen, Wartung ausführen oder planen und Netzwerkänderungen anwenden. Die Ablehnung steht im Audit.",
|
||||||
@@ -2482,6 +2492,7 @@
|
|||||||
"applied": "Änderung angewendet.",
|
"applied": "Änderung angewendet.",
|
||||||
"verified": "Angewendet und geprüft",
|
"verified": "Angewendet und geprüft",
|
||||||
"notVerified": "Angewendet, Prüfung weicht ab",
|
"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}",
|
"mismatch": "Die Firewall meldet abweichende Werte: {fields}",
|
||||||
"backupId": "Backup vor der Änderung: {id}",
|
"backupId": "Backup vor der Änderung: {id}",
|
||||||
"col": {
|
"col": {
|
||||||
|
|||||||
@@ -122,15 +122,23 @@
|
|||||||
"upToDate": "OPNexus is up to date.",
|
"upToDate": "OPNexus is up to date.",
|
||||||
"notes": "Release notes",
|
"notes": "Release notes",
|
||||||
"checked": "Last checked: {time}",
|
"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.",
|
"feedError": "The release feed is currently unreachable.",
|
||||||
"notEnabledTitle": "Updates from the web interface are not enabled.",
|
"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:",
|
"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:",
|
"notEnabledStep1": "Add to the .env on the server:",
|
||||||
"notEnabledStep2": "Then run:",
|
"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”.",
|
"notEnabledCli": "Until then, update on the server with “opnexus update”.",
|
||||||
"unreachable": "The updater does not respond. Check on the server: docker compose ps updater",
|
"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}”.",
|
"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}",
|
"button": "Update to {latest}",
|
||||||
"confirmTitle": "Update to {latest}?",
|
"confirmTitle": "Update to {latest}?",
|
||||||
@@ -599,6 +607,8 @@
|
|||||||
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
|
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
|
||||||
"UPDATE_NOT_OFFERED": "This version is not offered for installation — only the latest stable version.",
|
"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_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.",
|
"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.",
|
"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}",
|
"BACKUP_UNLESBAR": "The backup cannot be read: {reason}",
|
||||||
@@ -1604,7 +1614,7 @@
|
|||||||
"scopes": "Scopes",
|
"scopes": "Scopes",
|
||||||
"claim": "Groups claim",
|
"claim": "Groups claim",
|
||||||
"mapping": "Which group gets which role",
|
"mapping": "Which group gets which role",
|
||||||
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login.",
|
"mappingHint": "Enter the group names as the provider sends them. The most privileged matching role wins; a person in none of them cannot sign in. Roles of SSO accounts are managed at the provider and refreshed at every login. Providers that only send groups when asked (e.g. Okta) need the scope \"groups\" added to the scopes.",
|
||||||
"test": "Test the provider",
|
"test": "Test the provider",
|
||||||
"dutiesTitle": "Separation of duties",
|
"dutiesTitle": "Separation of duties",
|
||||||
"dutiesIntro": "When switched off, operators can prepare changes (edit, preview) but only an administrator may roll them out: deploy, remove or roll back a deployment, run or schedule maintenance and apply network changes. The refusal is recorded in the audit.",
|
"dutiesIntro": "When switched off, operators can prepare changes (edit, preview) but only an administrator may roll them out: deploy, remove or roll back a deployment, run or schedule maintenance and apply network changes. The refusal is recorded in the audit.",
|
||||||
@@ -2482,6 +2492,7 @@
|
|||||||
"applied": "Change applied.",
|
"applied": "Change applied.",
|
||||||
"verified": "Applied and checked",
|
"verified": "Applied and checked",
|
||||||
"notVerified": "Applied, check differs",
|
"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}",
|
"mismatch": "The firewall reports different values: {fields}",
|
||||||
"backupId": "Backup before the change: {id}",
|
"backupId": "Backup before the change: {id}",
|
||||||
"col": {
|
"col": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||||
// entries already kept in the project README/CHANGELOG.md, no build
|
// entries already kept in the project README/CHANGELOG.md, no build
|
||||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
export const APP_VERSION = "1.23.1";
|
export const APP_VERSION = "1.23.9";
|
||||||
export const APP_CODENAME = "Harrier";
|
export const APP_CODENAME = "Harrier";
|
||||||
export const APP_MAINTAINER = "Julian Boldt";
|
export const APP_MAINTAINER = "Julian Boldt";
|
||||||
@@ -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 # bauen und pushen
|
||||||
# scripts/build_release_images.sh v1.10.0 --push --multiarch # amd64+arm64 (braucht QEMU/buildx)
|
# 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).
|
# REPO: Umgebungsvariable OPNEXUS_IMAGE_REPO (Standard git.opnexus.dev/opnexus).
|
||||||
# Hinweis: Hinter dem Cloudflare-Proxy (Free) scheitern Pushes mit Layern > 100 MB —
|
# 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.
|
# 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"
|
docker build "${targs[@]}" "$src/$svc"
|
||||||
if [ "$push" = 1 ]; then for t in "${tags[@]}"; do docker push "$image:$t"; done; fi
|
if [ "$push" = 1 ]; then for t in "${tags[@]}"; do docker push "$image:$t"; done; fi
|
||||||
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
|
done
|
||||||
[ "$push" = 1 ] && echo "Fertig und gepusht: $repo (${tags[*]})" || echo "Fertig (lokal gebaut, nicht gepusht)."
|
[ "$push" = 1 ] && echo "Fertig und gepusht: $repo (${tags[*]})" || echo "Fertig (lokal gebaut, nicht gepusht)."
|
||||||
Reference in new issue
Block a user