Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf326d2d17 | ||
|
|
8fccb1a547 | ||
|
|
cf59bdb38e |
No files matched your search
@@ -31,6 +31,25 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.16.0] "Hobby" — 2026-10-10
|
||||
|
||||
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
|
||||
Handbuch „Einrichtungsassistent und Passwort-Reset“:
|
||||
|
||||
- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
|
||||
Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
|
||||
verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
|
||||
Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
|
||||
Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
|
||||
überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
|
||||
oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
|
||||
(Migration `0005`).
|
||||
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
|
||||
(`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
|
||||
Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
|
||||
vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
|
||||
`python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
|
||||
|
||||
## [1.15.0] "Gyrfalcon" — 2026-10-10
|
||||
|
||||
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
|
||||
|
||||
+8
-5
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> **Stand:** `1.15.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.16.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -20,6 +20,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
|
||||
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
|
||||
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
|
||||
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
|
||||
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
|
||||
@@ -43,19 +44,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
|
||||
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -A5 "generated password"
|
||||
scripts/opnexus password
|
||||
```
|
||||
|
||||
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
|
||||
|
||||
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
|
||||
|
||||
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.15.0
|
||||
OPNEXUS_VERSION=1.16.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> **Status:** `1.15.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.16.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -20,10 +20,11 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
|
||||
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
|
||||
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
|
||||
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
|
||||
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
|
||||
|
||||

|
||||

|
||||
|
||||
All screenshots show fictitious sample data.
|
||||
|
||||
@@ -45,19 +46,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
|
||||
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -A5 "generated password"
|
||||
scripts/opnexus password
|
||||
```
|
||||
|
||||
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
|
||||
|
||||
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
|
||||
|
||||
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.15.0
|
||||
OPNEXUS_VERSION=1.16.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy import Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
@@ -9,3 +9,6 @@ class UserPreferences(Base):
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
|
||||
theme: Mapped[str] = mapped_column(String(10), default='system')
|
||||
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
|
||||
# Set once the user finished or dismissed the first-run setup assistant
|
||||
# (frontend /setup); it is then no longer offered automatically.
|
||||
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Recovery: give an account a new random password and print it once.
|
||||
|
||||
For a fresh install whose generated first-start password scrolled out of the
|
||||
log, and for an admin who forgot theirs. Needs shell access to the server,
|
||||
which is the trust boundary for this tool anyway (same as app.reset_2fa).
|
||||
All sessions of the account are ended. Normally called through the host
|
||||
CLI:
|
||||
|
||||
./opnexus password [username]
|
||||
|
||||
or directly:
|
||||
|
||||
docker compose exec backend python -m app.reset_password [username]
|
||||
|
||||
Without a username the configured ADMIN_USERNAME is used; if that account does
|
||||
not exist and there is exactly one admin, that one.
|
||||
"""
|
||||
import asyncio
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.user import AdminUser
|
||||
from app.services import auth_service
|
||||
|
||||
|
||||
async def main(username: str | None) -> int:
|
||||
async with SessionLocal() as db:
|
||||
if username is None:
|
||||
username = settings.admin_username
|
||||
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
|
||||
if user is None:
|
||||
admins = (await db.execute(select(AdminUser).where(AdminUser.role == "admin"))).scalars().all()
|
||||
if len(admins) != 1:
|
||||
names = ", ".join(sorted(a.username for a in admins)) or "-"
|
||||
print(f"No account named '{username}'. Pass the username (admins: {names}).", file=sys.stderr)
|
||||
return 1
|
||||
user = admins[0]
|
||||
else:
|
||||
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
|
||||
if user is None:
|
||||
print(f"No such user: {username}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
password = secrets.token_urlsafe(18)
|
||||
user.password_hash = await auth_service.hash_password_async(password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await db.commit()
|
||||
name = user.username
|
||||
has_totp = user.totp_enabled
|
||||
|
||||
print(f"Username: {name}")
|
||||
print(f"Password: {password}")
|
||||
if has_totp:
|
||||
print("Two-factor sign-in is still on (lost the code? python -m app.reset_2fa).", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 2:
|
||||
print("usage: python -m app.reset_password [username]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else None)))
|
||||
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
|
||||
class Preferences(BaseModel):
|
||||
theme: Literal['light','dark','system'] | None = None
|
||||
language: Literal['de','en'] | None = None
|
||||
setup_done: bool | None = None
|
||||
|
||||
@router.get('')
|
||||
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
|
||||
preferences=await db.get(UserPreferences,user.id)
|
||||
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
|
||||
'language':preferences.language if preferences else 'de',
|
||||
'setup_done':preferences.setup_done if preferences else False,
|
||||
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||
'session_lifetime_seconds':settings.session_lifetime}
|
||||
|
||||
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
|
||||
current = await db.get(UserPreferences, user.id)
|
||||
theme = payload.theme or (current.theme if current else 'system')
|
||||
language = payload.language or (current.language if current else 'de')
|
||||
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
|
||||
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
|
||||
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
|
||||
await db.commit()
|
||||
return {'username':username,'theme':theme,'language':language,
|
||||
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
|
||||
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||
'session_lifetime_seconds':settings.session_lifetime}
|
||||
@@ -367,7 +367,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
|
||||
logger.warning(
|
||||
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
|
||||
" generated password:\n\n %s\n\n"
|
||||
" Log in and change it now. This is shown only once.\n%s",
|
||||
" Log in and change it now. This is shown only once.\n"
|
||||
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
|
||||
"=" * 64, settings.admin_username, password, "=" * 64,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.15.0"
|
||||
APP_CODENAME = "Gyrfalcon"
|
||||
APP_VERSION = "1.16.0"
|
||||
APP_CODENAME = "Hobby"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Einrichtungsassistent: Merker, dass ein Nutzer ihn abgeschlossen oder übersprungen hat.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-10-10
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS setup_done boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE user_preferences DROP COLUMN IF EXISTS setup_done")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""First-run setup assistant: per-user "done" flag and the password reset tool."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import reset_password
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.user import AdminUser, Session
|
||||
from app.services import auth_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_done_defaults_to_false_and_persists(client):
|
||||
assert (await client.get("/api/settings")).json()["setup_done"] is False
|
||||
|
||||
resp = await client.put("/api/settings", json={"setup_done": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["setup_done"] is True
|
||||
assert (await client.get("/api/settings")).json()["setup_done"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_preference_changes_keep_setup_done(client):
|
||||
await client.put("/api/settings", json={"setup_done": True})
|
||||
resp = await client.put("/api/settings", json={"theme": "dark", "language": "en"})
|
||||
body = resp.json()
|
||||
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", True)
|
||||
|
||||
# ...and the other direction: saving setup_done leaves theme/language alone.
|
||||
resp = await client.put("/api/settings", json={"setup_done": False})
|
||||
body = resp.json()
|
||||
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_done_rejects_non_boolean(client):
|
||||
assert (await client.put("/api/settings", json={"setup_done": "maybe"})).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_password_prints_new_password_and_ends_sessions(client, capsys):
|
||||
async with SessionLocal() as db:
|
||||
before = (await db.execute(select(Session))).scalars().all()
|
||||
assert before, "the client fixture logged in, so a session must exist"
|
||||
|
||||
assert await reset_password.main(None) == 0
|
||||
out = capsys.readouterr().out
|
||||
lines = dict(line.split(": ", 1) for line in out.strip().splitlines())
|
||||
assert lines["Username"] == settings.admin_username
|
||||
new_password = lines["Password"]
|
||||
assert len(new_password) >= 20 and new_password != settings.admin_password
|
||||
|
||||
async with SessionLocal() as db:
|
||||
assert (await db.execute(select(Session))).scalars().all() == []
|
||||
user = (await db.execute(select(AdminUser).where(AdminUser.username == settings.admin_username))).scalar_one()
|
||||
assert await auth_service.verify_password_async(user.password_hash, new_password)
|
||||
assert not await auth_service.verify_password_async(user.password_hash, settings.admin_password)
|
||||
# Put the shared bootstrap admin back for the tests that follow.
|
||||
user.password_hash = await auth_service.hash_password_async(settings.admin_password)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_password_unknown_user_fails(capsys):
|
||||
assert await reset_password.main("does-not-exist") == 1
|
||||
assert "No such user" in capsys.readouterr().err
|
||||
+61
-50
@@ -157,6 +157,56 @@ Verkehrsunterbrechung auf dem falschen Knoten.
|
||||
|
||||
---
|
||||
|
||||
## Einrichtungsassistent und Passwort-Reset
|
||||
|
||||
**Einrichtungsassistent** (`/setup`, System › Einrichtung). Vier Schritte mit
|
||||
kurzen Erklärungen, damit eine frische Installation ohne Handbuch funktioniert:
|
||||
|
||||
1. **Willkommen:** was der Assistent tut, Sprache (Deutsch/Englisch). Er liest
|
||||
nur von der Firewall; geändert wird dort erst, wenn du selbst etwas ausrollst.
|
||||
2. **Konto:** eigenes Passwort setzen (optional, empfohlen, wenn OPNexus beim
|
||||
Start eines erzeugt hat) und Zwei-Faktor-Anmeldung einrichten (derselbe
|
||||
Baustein wie in den Einstellungen).
|
||||
3. **Firewall:** Anleitung für den API-Key in OPNsense (System › Access › Users,
|
||||
Benutzer, „API keys“, `apikey.txt`), dann Name, Adresse, Key und Secret
|
||||
eintragen, Zertifikat abrufen und den Fingerabdruck bestätigen (dasselbe
|
||||
Festsetzen wie beim Hinzufügen eines Hosts). Nach dem Anlegen zeigt der
|
||||
Schritt sofort, ob die Firewall antwortet, und bei „offline“ was man prüft.
|
||||
Weitere Firewalls, auch die Knoten eines HA-Paars, lassen sich im selben
|
||||
Schritt nacheinander eintragen.
|
||||
4. **Fertig:** Zusammenfassung (Firewalls erreichbar, Zwei-Faktor an/aus) und
|
||||
Verweise auf das, was danach sinnvoll ist: Regeln, Benachrichtigungen,
|
||||
Updates, Datenbank-Sicherung.
|
||||
|
||||
*Wann er sich öffnet:* nach der Anmeldung auf dem Dashboard, solange es **keinen
|
||||
Host** gibt und dieser Benutzer den Assistenten weder abgeschlossen noch
|
||||
übersprungen hat. Der Merker liegt pro Benutzer in `user_preferences.setup_done`
|
||||
(Migration `0005`, `PUT /api/settings {"setup_done": true}`). `read_only`-Benutzer
|
||||
bekommen ihn nicht angeboten. Wieder öffnen geht über System › Einrichtung, die
|
||||
Einstellungen und die Schnellsuche (Strg/⌘+K).
|
||||
|
||||
**Passwort-Reset** (`opnexus password [BENUTZER] [--yes]`). Setzt für das
|
||||
Admin-Konto (Standard: `ADMIN_USERNAME`; gibt es das nicht und genau einen Admin,
|
||||
dieser) ein neues Zufallspasswort, gibt es einmal aus und beendet alle Sitzungen
|
||||
des Kontos. Eine Rückfrage kommt nur, wenn sich schon jemand angemeldet hat;
|
||||
ohne Terminal ist `--yes` nötig. Dahinter steht
|
||||
`docker compose exec backend python -m app.reset_password [BENUTZER]`, wie
|
||||
`app.reset_2fa` ein Werkzeug mit Shell-Zugriff auf den Server als
|
||||
Vertrauensgrenze. Eine eingeschaltete Zwei-Faktor-Anmeldung bleibt bestehen
|
||||
(verloren: `app.reset_2fa`). Es gibt bewusst keine Anzeige des alten Passworts:
|
||||
Gespeichert wird nur der Argon2id-Hash.
|
||||
|
||||
*Verifikation 2026-10-10:* Backend-Tests (`test_setup_assistant.py`: Merker
|
||||
persistiert und bleibt bei anderen Einstellungen erhalten, Reset gibt Passwort
|
||||
aus, beendet Sitzungen und tauscht den Hash, unbekannter Benutzer scheitert;
|
||||
vollständige Suite 583 bestanden). Kommandozeile gegen einen Wegwerf-Stack:
|
||||
frische Installation ohne Rückfrage, nach einer Anmeldung Rückfrage bzw.
|
||||
`--yes`, ungültige Option/Benutzer mit Fehlercode. Browser (Chromium, 1440 und
|
||||
375 px, Wegwerf-Stack mit Mock-Firewall): Anmeldung landet auf `/setup`,
|
||||
Sprachwechsel, Passwortwechsel inklusive Fehlerfall, Host online und offline,
|
||||
Zusammenfassung, Abschließen merkt sich den Zustand, keine Konsolenfehler, kein
|
||||
horizontaler Überlauf.
|
||||
|
||||
## Barrierefreiheit (2026-10-04)
|
||||
|
||||
Version 1.8.0 „Harris“. Ziel: OPNexus per Tastatur und mit Screenreader
|
||||
@@ -183,10 +233,6 @@ fand.
|
||||
`aria-hidden`, Unternavigation als eigene Landmarke. Leere Tabellenköpfe
|
||||
tragen einen nur für Screenreader sichtbaren Text („Aktionen“). Der
|
||||
Host-Status (`StatusDot`) war fest deutsch und folgt jetzt der Sprache.
|
||||
- **Prüfen.** `scripts/verify_a11y.mjs` wiederholt den axe-Lauf (nur lesend:
|
||||
schreibende Anfragen werden im Browser lokal beantwortet, das Theme wird nur
|
||||
in der Antwort überschrieben). Aufruf siehe Skriptkopf; Exit-Code ≠ 0 bei
|
||||
Verstößen.
|
||||
|
||||
**Verifikation:** axe-core (WCAG 2.0/2.1/2.2 A + AA, Best Practices) auf allen
|
||||
18 Seiten, Hell/Dunkel bei 1440 px und Hell bei 375 px: 0 Verstöße. Zusätzlich
|
||||
@@ -379,14 +425,7 @@ unter `frontend/lib/i18n/` (`de.json`/`en.json`, ca. 1.400 Schlüssel).
|
||||
Firewall-Daten (Aliase, Regelbeschreibungen) und die deutschen Suchbegriffe der
|
||||
Befehlspalette (sie bleiben zusätzlich zu den englischen Seitentiteln nutzbar).
|
||||
- **Prüfwerkzeuge:** `python3 scripts/check_i18n.py` (Katalog-Parität,
|
||||
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel) und
|
||||
`scripts/verify_i18n.mjs` (Browser: Umschalten über die Einstellungen, alle
|
||||
lesenden Seiten samt „Neu“-Dialogen auf deutsche Restwörter prüfen, 375 px auf
|
||||
horizontalen Überlauf; schreibende Requests werden abgefangen; `/updates` wird
|
||||
ausgelassen, weil das Öffnen dort Update-Suchen auf den Firewalls startet).
|
||||
Das Skript braucht `OPNEXUS_TEST_PASSWORD` und als `OPNEXUS_BASE_URL` den in
|
||||
`FRONTEND_ORIGIN` konfigurierten Origin (sonst blockiert die CORS-Freigabe die
|
||||
Anmeldung).
|
||||
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel).
|
||||
|
||||
**Verifiziert 2026-09-19:** DE→EN→DE mit DB-Persistenz und Reload; 17 Seiten und
|
||||
alle „Neu“-Dialoge im Englischen ohne deutsche Restwörter (Treffer nur aus
|
||||
@@ -909,7 +948,7 @@ bestätigt, bevor mit dem Picker begonnen wurde).
|
||||
|
||||
## Restore, Stufe 1: Vorschau + manuelle Übergabe (2026-09-09)
|
||||
|
||||
Setzt `docs/restore-plan.md` (8. September) in seiner ersten Ausbaustufe um:
|
||||
Erste Ausbaustufe des Restore-Assistenten (geplant am 8. September):
|
||||
eine lokal gespeicherte OPNsense-Konfigurationssicherung (`backup_store.py`,
|
||||
bereits vorhandenes "Backups"-Panel) prüfen, sicher als Vorschau anzeigen und
|
||||
den Wechsel auf das OPNsense-Webinterface dokumentiert übergeben — **OPNexus
|
||||
@@ -2096,7 +2135,7 @@ Details: `docs/verification/log-search-*`.
|
||||
Tabelle werden durch einen Datenbank-Trigger blockiert; Datenbankadministratoren
|
||||
können technisch weiterhin das Schema ändern. Keine rückwirkende Rekonstruktion
|
||||
früherer Aktionen. XML-Backups, Passwörter und API-Secrets werden nicht protokolliert.
|
||||
- **Restore**: separat ausgearbeiteter [Restore-Plan](restore-plan.md).
|
||||
- **Restore**: separat ausgearbeitet, siehe Abschnitt „Restore, Stufe 1“.
|
||||
Es gibt keine implementierte Restore-Ausführung. Der Plan unterscheidet lokale
|
||||
OPNexus-XML-Dateien von bereits auf der Firewall vorhandenen Revisionen.
|
||||
|
||||
@@ -2415,9 +2454,12 @@ in die Backend-Logs geschrieben:
|
||||
docker compose logs backend | grep -A5 "generated password"
|
||||
```
|
||||
|
||||
Danach in `.env` ein eigenes `ADMIN_PASSWORD` setzen (empfohlen) oder das
|
||||
Passwort über die künftige "Passwort ändern"-Funktion (`POST
|
||||
/api/auth/change-password`, aktuell nur per API, kein UI-Formular) ändern.
|
||||
Ist das Passwort verloren oder nie gesehen worden, setzt `opnexus password`
|
||||
(im Repo-Checkout `scripts/opnexus password`) ein **neues** Zufallspasswort und
|
||||
zeigt es an; siehe "Einrichtungsassistent und Passwort-Reset" unten. Danach
|
||||
das Passwort im Einrichtungsassistenten oder unter Einstellungen › Konto
|
||||
ändern, oder in `.env` ein eigenes `ADMIN_PASSWORD` setzen (gilt nur beim
|
||||
Anlegen des ersten Kontos).
|
||||
Login-Versuche sind pro (IP, Benutzername) und pro IP rate-limitiert
|
||||
(Standard: 5 Fehlversuche / 5 Minuten Sperre).
|
||||
|
||||
@@ -2520,37 +2562,6 @@ Release-Seite angehängt (GitHub und Forgejo haben dafür verschiedene APIs, die
|
||||
CI lädt es deshalb nicht hoch). Anwender brauchen damit nur Docker und diese
|
||||
vier Dateien.
|
||||
|
||||
**Ein Release veröffentlichen:** Versionsdateien und `CHANGELOG.md` anpassen,
|
||||
committen, dann `git tag v1.9.1 && git push origin v1.9.1`. Der Release-
|
||||
Workflow prüft per `scripts/check_version.sh`, dass Tag, `version.py`,
|
||||
`version.ts` und CHANGELOG zusammenpassen, lässt die Tests laufen und pusht
|
||||
erst dann die Tags `1.9.1`, `1.9` und `1` (Vorabversionen wie `v1.10.0-rc1`
|
||||
nur mit vollem Tag).
|
||||
|
||||
**Entwicklungs-Repo und öffentliches Repo (seit 2026-10-05):** Entwickelt wird
|
||||
im privaten Repo (volle Historie). Öffentlich ist `git.opnexus.dev/opnexus/opnexus`,
|
||||
und dort landet pro Release nur ein sauberer Snapshot-Commit — nie die
|
||||
Entwicklungs-Historie (frühere Lizenzstände, interne Übergaben,
|
||||
Live-Verifikation mit echten Adressen). Ablauf eines Releases:
|
||||
|
||||
```bash
|
||||
git tag v1.11.0 # im Entwicklungs-Repo, nach check_version.sh
|
||||
scripts/publish_public.sh v1.11.0 # Trockenlauf: Snapshot nach .public-repo/ prüfen
|
||||
scripts/publish_public.sh v1.11.0 --push # Snapshot + Tag ins öffentliche Repo
|
||||
scripts/build_release_images.sh v1.11.0 --push # Images bauen + in die Registry (ohne CI-Runner)
|
||||
scripts/make_update_feed.sh v1.11.0 # erst jetzt: Feed für updates.opnexus.dev
|
||||
```
|
||||
|
||||
`publish_public.sh` exportiert den Tag (nicht den Working Tree), lässt
|
||||
`.claude/`, `docs/HANDOFF-*`, `docs/verification*` und `.env` weg und bricht
|
||||
ab, wenn im Export interne Adressen/Hostnamen (Muster stehen im Skript) oder
|
||||
Schlüsselblöcke stehen — ein Sicherheitsnetz, kein Ersatz fürs Durchsehen.
|
||||
Folge: Verweise auf `docs/verification*` laufen im öffentlichen Repo ins Leere
|
||||
(siehe Hinweis oben im Handbuch). Hinter dem Cloudflare-Proxy (Free) sind
|
||||
Uploads auf 100 MB pro Request begrenzt; der Push des 1.10.0-rc1 ging
|
||||
problemlos durch, bei größeren Layern die Registry-Domain auf „DNS only“
|
||||
stellen oder aus dem LAN pushen.
|
||||
|
||||
**Registry ist reine Konfiguration**, nicht Teil der Workflow-Datei —
|
||||
Repository-Variablen `REGISTRY` (Default `ghcr.io`) und `IMAGE_NAMESPACE`
|
||||
(Default: Repo-Besitzer), Secrets `REGISTRY_USERNAME`/`REGISTRY_TOKEN`. Für
|
||||
@@ -2566,8 +2577,8 @@ GitHub/Forgejo `releases/latest`, den man in `.env` einträgt:
|
||||
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
```
|
||||
|
||||
Das ist der offizielle Feed des Projekts (statische JSON-Datei aus
|
||||
`website/latest.json`, siehe `website/README.md`). Jeder andere Feed im
|
||||
Das ist der offizielle Feed des Projekts (eine statische JSON-Datei, ausgeliefert als
|
||||
`updates.opnexus.dev/latest.json`). Jeder andere Feed im
|
||||
selben Format funktioniert ebenso, etwa `…/api/v1/repos/<owner>/opnexus/releases/latest`
|
||||
auf einer eigenen Forgejo/GitHub-Instanz.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -18,6 +18,7 @@ import {
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { labelFor, type Language } from "@/lib/i18n";
|
||||
@@ -110,6 +111,7 @@ function certProblemLabel(context: Record<string, unknown>, t: (key: string, par
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { language, t, errorMessage } = useLanguage();
|
||||
const router = useRouter();
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
|
||||
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
|
||||
@@ -133,6 +135,15 @@ export default function DashboardPage() {
|
||||
.then(async (data) => {
|
||||
if (!active) return;
|
||||
setSummary(data);
|
||||
// Fresh install: nothing to manage yet and the setup assistant was
|
||||
// never finished or skipped, so open it instead of an empty dashboard.
|
||||
if (data.hosts.length === 0) {
|
||||
Promise.all([api.me(), api.settings()])
|
||||
.then(([me, prefs]) => {
|
||||
if (active && me.role !== "read_only" && !prefs.setup_done) router.replace("/setup");
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
const reachableHosts = data.hosts.filter((h) => h.status !== "offline" && h.status !== "disabled");
|
||||
const results = await Promise.all(
|
||||
reachableHosts.map(async (host) => {
|
||||
@@ -164,7 +175,7 @@ export default function DashboardPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [errorMessage]);
|
||||
}, [errorMessage, router]);
|
||||
|
||||
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
|
||||
const securityWarningsLoading = problems === null || firmwareWarnings === null;
|
||||
@@ -212,7 +223,7 @@ export default function DashboardPage() {
|
||||
{summary === null ? (
|
||||
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
|
||||
) : summary.hosts.length === 0 ? (
|
||||
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link>} />
|
||||
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<span className="flex flex-wrap justify-center gap-x-4 gap-y-1"><Link href="/setup" className="text-sm font-medium text-sky-700 hover:underline">{t("dashboard.startSetup")}</Link><Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link></span>} />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{summary.hosts.map((host) => (
|
||||
|
||||
@@ -12,6 +12,21 @@ import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
|
||||
import { translate } from "@/lib/i18n";
|
||||
|
||||
function SetupAssistantSection() {
|
||||
const { t } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
if (readOnly) return null;
|
||||
return (
|
||||
<section className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">{t("settings.setup.title")}</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">{t("settings.setup.intro")}</p>
|
||||
</div>
|
||||
<Button href="/setup" variant="outline">{t("settings.setup.open")}</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSection() {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
@@ -133,7 +148,7 @@ function NotificationsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<section id="notifications" className="scroll-mt-4 space-y-5 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">{t("settings.notify.title")}<InfoTip text={t("settings.notify.intro", { seconds: pollInterval })} /></h2>
|
||||
|
||||
<form onSubmit={save} className="space-y-5">
|
||||
@@ -377,5 +392,5 @@ export default function SettingsPage(){
|
||||
async function saveTheme(value:Theme){setSaving(true);setError('');setNotice('');const old=theme;setTheme(value);try{setSettings(await api.saveSettings({theme:value,language}));setNotice(t('settings.appearanceSaved'));}catch(e){setTheme(old);setError(errorMessage(e));}finally{setSaving(false);}}
|
||||
async function saveLanguage(value:"de"|"en"){setSaving(true);setError('');setNotice('');const old=language;setLanguage(value);try{setSettings(await api.saveSettings({theme,language:value}));setNotice(translate(value,'settings.language.saved'));}catch(e){setLanguage(old);setError(errorMessage(e));}finally{setSaving(false);}}
|
||||
async function changePassword(e:React.FormEvent){e.preventDefault();setError('');setNotice('');if(password!==repeat){setError(t('settings.passwordMismatch'));return;}setSaving(true);try{await api.changePassword(current,password);setCurrent('');setPassword('');setRepeat('');setNotice(t('settings.passwordChanged'));}catch(e){setError(errorMessage(e,'settings.passwordFailed'));}finally{setSaving(false);}}
|
||||
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
|
||||
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SetupWizard } from "@/components/SetupWizard";
|
||||
|
||||
export default function SetupPage() {
|
||||
return <SetupWizard />;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Network,
|
||||
RefreshCw,
|
||||
@@ -162,6 +163,12 @@ export const navGroups: NavGroup[] = [
|
||||
icon: Users,
|
||||
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
|
||||
},
|
||||
{
|
||||
href: "/setup",
|
||||
label: "Einrichtung",
|
||||
icon: ListChecks,
|
||||
keywords: ["Assistent", "Wizard", "Setup", "Erste Schritte", "Onboarding", "Firewall verbinden", "API-Key", "Einführung"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { TlsPinControl, TlsPinValue } from "@/components/TlsPinControl";
|
||||
import { TwoFactorSection } from "@/components/TwoFactorSection";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { FieldGroup, Input } from "@/components/ui/Field";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { api, AppSettings, Host } from "@/lib/api";
|
||||
import clsx from "clsx";
|
||||
import { ArrowLeft, ArrowRight, Bell, Check, DownloadCloud, ExternalLink, Shield, ShieldCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const STEPS = ["welcome", "account", "firewall", "done"] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
const emptyHostForm = { name: "", base_url: "", api_key: "", api_secret: "", cluster_name: "" };
|
||||
const noPin: TlsPinValue = { pem: null, insecure: false, clear: false };
|
||||
|
||||
function Stepper({ step, onGo }: { step: number; onGo: (index: number) => void }) {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<nav aria-label={t("setup.stepsLabel")}>
|
||||
<ol className="flex flex-wrap gap-2">
|
||||
{STEPS.map((key, index) => {
|
||||
const current = index === step;
|
||||
const done = index < step;
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={current ? "step" : undefined}
|
||||
onClick={() => onGo(index)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500",
|
||||
current ? "border-brand-500 bg-brand-500 text-white" : done ? "border-slate-300 text-slate-700 hover:bg-slate-100" : "border-slate-200 text-slate-500 hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx("flex h-5 w-5 items-center justify-center rounded-full text-xs", current ? "bg-white/25" : done ? "bg-emerald-100 text-emerald-700" : "bg-slate-100")}
|
||||
>
|
||||
{done ? <Check size={12} /> : index + 1}
|
||||
</span>
|
||||
{t(`setup.steps.${key}`)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeStep({ settings, onSettings }: { settings: AppSettings | null; onSettings: (s: AppSettings) => void }) {
|
||||
const { t, errorMessage, language, setLanguage } = useLanguage();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function pick(value: "de" | "en") {
|
||||
const previous = language;
|
||||
setLanguage(value);
|
||||
setError("");
|
||||
try {
|
||||
onSettings(await api.saveSettings({ language: value }));
|
||||
} catch (e) {
|
||||
setLanguage(previous);
|
||||
setError(errorMessage(e, "common.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-slate-600">{t("setup.welcome.intro")}</p>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
{(["account", "firewall", "done"] as const).map((key) => (
|
||||
<li key={key} className="flex items-start gap-2">
|
||||
<Check size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
|
||||
<span>{t(`setup.welcome.points.${key}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="flex items-start gap-2 rounded-xl bg-slate-50 p-3 text-sm text-slate-600">
|
||||
<ShieldCheck size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
|
||||
{t("setup.welcome.readOnlyNote")}
|
||||
</p>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-slate-700">{t("settings.language.label")}</legend>
|
||||
<div role="radiogroup" aria-label={t("settings.language.label")} className="flex flex-wrap gap-3">
|
||||
{(["de", "en"] as const).map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className={clsx("flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2.5 text-sm", language === value ? "border-sky-500 bg-sky-50 text-sky-700" : "border-slate-200")}
|
||||
>
|
||||
<input type="radio" name="setup-language" checked={language === value} disabled={!settings} onChange={() => void pick(value)} />
|
||||
{t(`settings.language.${value}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountStep() {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (password !== repeat) {
|
||||
setError(t("settings.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(current, password);
|
||||
setCurrent("");
|
||||
setPassword("");
|
||||
setRepeat("");
|
||||
setChanged(true);
|
||||
toast.show(t("settings.passwordChanged"), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "settings.passwordFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section aria-labelledby="setup-password" className="space-y-3">
|
||||
<h3 id="setup-password" className="flex items-center gap-2 text-base font-semibold text-slate-900">
|
||||
{t("setup.account.passwordTitle")}
|
||||
{changed && <Badge tone="success"><Check size={12} />{t("setup.account.passwordDone")}</Badge>}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">{t("setup.account.passwordIntro")}</p>
|
||||
<form onSubmit={submit} className="max-w-md space-y-3">
|
||||
<FieldGroup label={t("settings.currentPassword")} hint={t("setup.account.currentHint")}>
|
||||
<Input type="password" autoComplete="current-password" required value={current} onChange={(e) => setCurrent(e.target.value)} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("settings.newPassword")} hint={t("setup.account.newHint")}>
|
||||
<Input type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("settings.repeatPassword")}>
|
||||
<Input type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={(e) => setRepeat(e.target.value)} />
|
||||
</FieldGroup>
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" variant="secondary" loading={busy}>{t("settings.changePassword")}</Button>
|
||||
</form>
|
||||
</section>
|
||||
<section aria-labelledby="setup-2fa" className="space-y-3">
|
||||
<h3 id="setup-2fa" className="text-base font-semibold text-slate-900">{t("setup.account.twofaTitle")}</h3>
|
||||
<p className="text-sm text-slate-600">{t("setup.account.twofaIntro")}</p>
|
||||
<TwoFactorSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FirewallStep({ hosts, onHostsChanged }: { hosts: Host[] | null; onHostsChanged: () => Promise<void> }) {
|
||||
const { t, ts, errorMessage } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(emptyHostForm);
|
||||
const [pin, setPin] = useState<TlsPinValue>(noPin);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
|
||||
const firstHost = !hosts || hosts.length === 0;
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await api.createHost({
|
||||
name: form.name.trim(),
|
||||
base_url: form.base_url.trim(),
|
||||
api_key: form.api_key.trim(),
|
||||
api_secret: form.api_secret.trim(),
|
||||
verify_tls: !pin.insecure,
|
||||
tls_ca_pem: pin.pem,
|
||||
enabled: true,
|
||||
cluster_name: form.cluster_name.trim() || null,
|
||||
policy_hierarchy_group_id: null,
|
||||
});
|
||||
toast.show(t("hosts.created", { name: created.name }), "success");
|
||||
setForm(emptyHostForm);
|
||||
setPin(noPin);
|
||||
await onHostsChanged();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "hosts.createFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function recheck(id: string) {
|
||||
setChecking(id);
|
||||
try {
|
||||
await onHostsChanged();
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-slate-600">{t("setup.firewall.intro")}</p>
|
||||
|
||||
{hosts && hosts.length > 0 && (
|
||||
<section aria-labelledby="setup-hosts" className="space-y-2">
|
||||
<h3 id="setup-hosts" className="text-base font-semibold text-slate-900">{t("setup.firewall.connected")}</h3>
|
||||
<ul className="divide-y divide-slate-100 rounded-xl border border-slate-200">
|
||||
{hosts.map((host) => (
|
||||
<li key={host.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
|
||||
<Link href={`/hosts/${host.id}`} className="font-medium text-sky-700 hover:underline">{host.name}</Link>
|
||||
<span className="text-slate-500">{host.base_url}</span>
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<StatusDot status={host.status} />
|
||||
{host.status === "offline" && (
|
||||
<Button type="button" variant="outline" loading={checking === host.id} onClick={() => void recheck(host.id)}>
|
||||
{t("setup.firewall.recheck")}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
{host.status === "offline" && (
|
||||
<div className="basis-full space-y-0.5 text-xs">
|
||||
{host.status_detail && <p className="text-slate-500">{ts(host.status_detail).slice(0, 160)}</p>}
|
||||
<p className="text-flame-700">{t("setup.firewall.offlineHelp")}</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section aria-labelledby="setup-key" className="space-y-3 rounded-xl border border-slate-200 p-4">
|
||||
<h3 id="setup-key" className="text-base font-semibold text-slate-900">{t("setup.firewall.keyTitle")}</h3>
|
||||
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-slate-700">
|
||||
{(["menu", "user", "key", "rights"] as const).map((key) => (
|
||||
<li key={key}>{t(`setup.firewall.keySteps.${key}`)}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="flex items-start gap-1.5 text-xs text-slate-500">
|
||||
<DownloadCloud size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
{t("setup.firewall.keyFileNote")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form onSubmit={create} className="space-y-4" aria-labelledby="setup-add">
|
||||
<h3 id="setup-add" className="text-base font-semibold text-slate-900">
|
||||
{firstHost ? t("setup.firewall.addFirst") : t("setup.firewall.addAnother")}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldGroup label="Name" hint={t("setup.firewall.nameHint")}>
|
||||
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="fw-primary" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("hosts.baseUrl")} hint={t("setup.firewall.urlHint")}>
|
||||
<Input required value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} placeholder="https://192.168.1.1" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="API-Key">
|
||||
<Input required autoComplete="off" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="API-Secret">
|
||||
<Input required type="password" autoComplete="off" value={form.api_secret} onChange={(e) => setForm({ ...form, api_secret: e.target.value })} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("hosts.clusterOptional")} hint={t("setup.firewall.clusterHint")}>
|
||||
<Input value={form.cluster_name} onChange={(e) => setForm({ ...form, cluster_name: e.target.value })} placeholder={t("hosts.clusterPlaceholder")} />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<TlsPinControl baseUrl={form.base_url} value={pin} onChange={setPin} />
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" loading={saving}>{saving ? t("hosts.saving") : t("setup.firewall.connect")}</Button>
|
||||
<span className="text-xs text-slate-500">{t("setup.firewall.connectNote")}</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneStep({ hosts, settings }: { hosts: Host[] | null; settings: AppSettings | null }) {
|
||||
const { t } = useLanguage();
|
||||
const [twofa, setTwofa] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.twoFactorStatus().then((s) => setTwofa(s.enabled)).catch(() => setTwofa(null));
|
||||
}, []);
|
||||
|
||||
const online = hosts?.filter((h) => h.status === "online").length ?? 0;
|
||||
const total = hosts?.length ?? 0;
|
||||
const seconds = settings?.deploy_confirm_window_seconds ?? 0;
|
||||
|
||||
const next: { href: string; icon: typeof Shield; key: string; params?: Record<string, string | number> }[] = [
|
||||
{ href: "/rules", icon: Shield, key: "rules", params: { seconds } },
|
||||
{ href: "/settings#notifications", icon: Bell, key: "notify" },
|
||||
{ href: "/settings#opnexus-update", icon: DownloadCloud, key: "update" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<Badge tone={total > 0 && online === total ? "success" : total > 0 ? "warning" : "neutral"}>
|
||||
{total > 0 ? t("setup.done.hostsOnline", { online, total }) : t("setup.done.noHosts")}
|
||||
</Badge>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Badge tone={twofa ? "success" : "neutral"}>{twofa ? t("setup.done.twofaOn") : t("setup.done.twofaOff")}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section aria-labelledby="setup-next" className="space-y-3">
|
||||
<h3 id="setup-next" className="text-base font-semibold text-slate-900">{t("setup.done.nextTitle")}</h3>
|
||||
<ul className="grid gap-3 sm:grid-cols-3">
|
||||
{next.map(({ href, icon: Icon, key, params }) => (
|
||||
<li key={key}>
|
||||
<Link href={href} className="flex h-full flex-col gap-1 rounded-xl border border-slate-200 p-4 text-sm transition-colors hover:border-brand-500 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500">
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900"><Icon size={16} aria-hidden="true" />{t(`setup.done.next.${key}.title`)}</span>
|
||||
<span className="text-slate-600">{t(`setup.done.next.${key}.text`, params)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("setup.done.backupBefore")} <code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs">./opnexus backup</code>
|
||||
<InfoTip text={t("setup.done.backupHelp")} size={13} className="ml-1 align-middle" />
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<a href="https://opnexus.dev/docs/cli/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-sky-700 hover:underline">
|
||||
{t("setup.done.docs")} <ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupWizard() {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [hosts, setHosts] = useState<Host[] | null>(null);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const mounted = useRef(false);
|
||||
|
||||
async function loadHosts() {
|
||||
try {
|
||||
setHosts(await api.listHosts());
|
||||
} catch {
|
||||
setHosts([]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.me().then((s) => setRole(s.role ?? "admin")).catch(() => setRole("admin"));
|
||||
api.settings().then(setSettings).catch(() => {});
|
||||
void loadHosts();
|
||||
}, []);
|
||||
|
||||
// Move focus to the step heading on navigation so screen-reader users hear
|
||||
// where they are; skipped on the very first render.
|
||||
useEffect(() => {
|
||||
if (!mounted.current) {
|
||||
mounted.current = true;
|
||||
return;
|
||||
}
|
||||
headingRef.current?.focus();
|
||||
}, [index]);
|
||||
|
||||
async function leave(markDone: boolean) {
|
||||
setFinishing(true);
|
||||
setError("");
|
||||
try {
|
||||
if (markDone && !settings?.setup_done) await api.saveSettings({ setup_done: true });
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError(t("common.saveFailed"));
|
||||
setFinishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const step: Step = STEPS[index];
|
||||
const last = index === STEPS.length - 1;
|
||||
|
||||
if (role === "read_only") {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-3">
|
||||
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
|
||||
<p className="text-sm text-slate-600">{t("setup.readOnly")}</p>
|
||||
<Button href="/dashboard" variant="outline">{t("setup.toDashboard")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{t("setup.stepOf", { current: index + 1, total: STEPS.length })}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" loading={finishing} onClick={() => void leave(true)}>
|
||||
{t("setup.skip")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Stepper step={index} onGo={setIndex} />
|
||||
|
||||
<Card className="space-y-5 p-5 sm:p-6">
|
||||
<div>
|
||||
<h2 ref={headingRef} tabIndex={-1} className="text-xl font-semibold text-slate-900 outline-none">
|
||||
{t(`setup.${step}.title`)}
|
||||
</h2>
|
||||
</div>
|
||||
{step === "welcome" && <WelcomeStep settings={settings} onSettings={setSettings} />}
|
||||
{step === "account" && <AccountStep />}
|
||||
{step === "firewall" && <FirewallStep hosts={hosts} onHostsChanged={loadHosts} />}
|
||||
{step === "done" && <DoneStep hosts={hosts} settings={settings} />}
|
||||
</Card>
|
||||
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="outline" disabled={index === 0} onClick={() => setIndex(index - 1)}>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
{t("setup.back")}
|
||||
</Button>
|
||||
{last ? (
|
||||
<Button type="button" loading={finishing} onClick={() => void leave(true)}>
|
||||
{t("setup.finish")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={() => setIndex(index + 1)}>
|
||||
{t("setup.next")}
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1098,7 +1098,7 @@ export interface UpdaterStatus {
|
||||
export const api = {
|
||||
search: (q: string, options?: RequestInit) => request<SearchHit[]>(`/search?${new URLSearchParams({ q })}`, options),
|
||||
settings: () => request<AppSettings>("/settings"),
|
||||
saveSettings: (partial:{theme?:Theme;language?:Language}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
|
||||
saveSettings: (partial:{theme?:Theme;language?:Language;setup_done?:boolean}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
|
||||
notificationSettings: () => request<NotificationSettings>("/notifications/settings"),
|
||||
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
logSettings: () => request<LogSettings>("/logs/settings"),
|
||||
@@ -1527,7 +1527,7 @@ export interface CarpSwitchState {
|
||||
export interface CarpSwitchPreview {preview_id:string;expires_at:string;action:"carp_enter"|"carp_leave";state:CarpSwitchState;}
|
||||
|
||||
export type Theme="light"|"dark"|"system";
|
||||
export interface AppSettings {username:string;theme:Theme;language:Language;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
|
||||
export interface AppSettings {username:string;theme:Theme;language:Language;setup_done:boolean;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
|
||||
|
||||
export type SmtpSecurity = "none" | "starttls" | "ssl";
|
||||
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
},
|
||||
"badge": "Update verfügbar",
|
||||
"resultTime": "Ergebnis vom {time}"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Einrichtungsassistent",
|
||||
"intro": "Führt Schritt für Schritt durch Passwort, Zwei-Faktor-Anmeldung und das Verbinden einer Firewall.",
|
||||
"open": "Assistent öffnen"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
@@ -215,7 +220,8 @@
|
||||
"hostsystem-cron": "Cron",
|
||||
"hostsystem-syslog": "Syslog",
|
||||
"hostsystem-snapshots": "Snapshots",
|
||||
"hostsystem-diagnostics": "Diagnose"
|
||||
"hostsystem-diagnostics": "Diagnose",
|
||||
"setup": "Einrichtung"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -227,6 +233,7 @@
|
||||
"fleetHealth": "Fleet-/CARP-Gesundheit",
|
||||
"noHosts": "Noch keine Hosts angelegt",
|
||||
"toHosts": "Zu den Hosts",
|
||||
"startSetup": "Einrichtungsassistent starten",
|
||||
"nothingPending": "Nichts offen",
|
||||
"nothingPendingDescription": "Keine ausstehenden Bestätigungen, Fehler oder unklaren Wartungsvorgänge.",
|
||||
"securityAndExpiryWarnings": "Sicherheits- & Ablauf-Warnungen",
|
||||
@@ -2195,5 +2202,89 @@
|
||||
"closed": "Port {port} nicht erreichbar",
|
||||
"noRecords": "Keine Antwort für „{name}“."
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Einrichtungsassistent",
|
||||
"skip": "Assistent überspringen",
|
||||
"stepsLabel": "Einrichtungsschritte",
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Einrichtung abschließen",
|
||||
"readOnly": "Der Assistent richtet Konto und Firewalls ein und ist deshalb Administratoren vorbehalten.",
|
||||
"toDashboard": "Zum Dashboard",
|
||||
"steps": {
|
||||
"welcome": "Willkommen",
|
||||
"account": "Konto",
|
||||
"firewall": "Firewall",
|
||||
"done": "Fertig"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Willkommen bei OPNexus",
|
||||
"intro": "OPNexus verwaltet deine OPNsense-Firewalls zentral: Regeln, NAT, VPN, Zertifikate, Updates und HA-Cluster an einem Ort. Dieser Assistent richtet das Nötigste in wenigen Minuten ein. Du kannst ihn jederzeit überspringen und später unter System › Einrichtung wieder öffnen.",
|
||||
"points": {
|
||||
"account": "Konto absichern: eigenes Passwort und Zwei-Faktor-Anmeldung.",
|
||||
"firewall": "Erste Firewall verbinden: API-Key anlegen, Zertifikat prüfen, fertig.",
|
||||
"done": "Überblick, was als Nächstes sinnvoll ist."
|
||||
},
|
||||
"readOnlyNote": "Der Assistent liest nur von deiner Firewall. Geändert wird dort erst, wenn du später selbst etwas ausrollst."
|
||||
},
|
||||
"account": {
|
||||
"title": "Konto absichern",
|
||||
"passwordTitle": "Eigenes Passwort",
|
||||
"passwordDone": "Geändert",
|
||||
"passwordIntro": "Hast du beim Start kein ADMIN_PASSWORD gesetzt, hat OPNexus ein Zufallspasswort erzeugt. Setze jetzt ein eigenes. Dieser Schritt ist optional.",
|
||||
"currentHint": "Das Passwort, mit dem du dich gerade angemeldet hast. Verloren? Auf dem Server: ./opnexus password",
|
||||
"newHint": "Mindestens 10 Zeichen. Ein Passwortmanager ist am bequemsten.",
|
||||
"twofaTitle": "Zwei-Faktor-Anmeldung (empfohlen)",
|
||||
"twofaIntro": "OPNexus kann Firewalls verändern. Ein zweiter Faktor aus einer Authenticator-App schützt das Konto auch dann, wenn das Passwort bekannt wird."
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Erste Firewall verbinden",
|
||||
"intro": "OPNexus spricht über die REST-API mit deiner OPNsense. Dafür braucht es einen API-Key. Das Anlegen dauert eine Minute.",
|
||||
"connected": "Verbundene Firewalls",
|
||||
"recheck": "Erneut prüfen",
|
||||
"offlineHelp": "Prüfe Adresse und API-Key und ob die Firewall den Zugriff auf die Weboberfläche von diesem Server aus erlaubt.",
|
||||
"keyTitle": "So legst du den API-Key in OPNsense an",
|
||||
"keySteps": {
|
||||
"menu": "In OPNsense System › Access › Users öffnen.",
|
||||
"user": "Einen eigenen Benutzer (z. B. „opnexus“) anlegen oder einen vorhandenen bearbeiten.",
|
||||
"key": "Beim Benutzer unter „API keys“ auf das Plus klicken. Der Browser lädt eine Datei apikey.txt herunter.",
|
||||
"rights": "Dem Benutzer ausreichende Rechte geben, am einfachsten die Gruppe „admin“. Mit weniger Rechten schlagen einzelne Funktionen fehl."
|
||||
},
|
||||
"keyFileNote": "In apikey.txt steht der Key hinter key=, das Secret hinter secret=. Das Secret gibt es nur in dieser Datei.",
|
||||
"addFirst": "Firewall eintragen",
|
||||
"addAnother": "Weitere Firewall eintragen",
|
||||
"nameHint": "Frei wählbar, nur zur Anzeige. Bei einem HA-Paar z. B. fw-a und fw-b.",
|
||||
"urlHint": "Adresse der OPNsense-Weboberfläche, mit https:// und gegebenenfalls Port, z. B. https://192.168.1.1:8443.",
|
||||
"clusterHint": "Besteht die Firewall aus einem HA-Paar? Trage beide Knoten als eigene Firewall ein und gib bei beiden denselben Cluster-Namen an.",
|
||||
"connect": "Verbinden",
|
||||
"connectNote": "OPNexus prüft sofort, ob die Firewall antwortet."
|
||||
},
|
||||
"done": {
|
||||
"title": "Fertig",
|
||||
"hostsOnline": "{online} von {total} Firewalls erreichbar",
|
||||
"noHosts": "Noch keine Firewall verbunden",
|
||||
"twofaOn": "Zwei-Faktor aktiv",
|
||||
"twofaOff": "Zwei-Faktor nicht aktiv",
|
||||
"nextTitle": "Das kannst du als Nächstes tun",
|
||||
"next": {
|
||||
"rules": {
|
||||
"title": "Regeln anlegen",
|
||||
"text": "Neue Regeln rollst du mit Bestätigung aus: Bestätigst du nicht innerhalb von {seconds} Sekunden, rollt OPNexus automatisch zurück."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Benachrichtigungen",
|
||||
"text": "Per Webhook oder E-Mail melden lassen, wenn eine Firewall ausfällt oder ein Zertifikat abläuft."
|
||||
},
|
||||
"update": {
|
||||
"title": "Updates",
|
||||
"text": "Hinweis auf neue OPNexus-Versionen und Updates per Klick einrichten."
|
||||
}
|
||||
},
|
||||
"backupBefore": "Eine Sicherung der OPNexus-Datenbank erstellst du auf dem Server mit",
|
||||
"backupHelp": "Sichert die Datenbank (Regeln, Hosts, Verlauf). API-Secrets und Schlüssel liegen in eigenen Docker-Volumes und sind nicht im Dump. Details im Handbuch.",
|
||||
"docs": "Dokumentation zum Kommandozeilen-Werkzeug"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,11 @@
|
||||
},
|
||||
"badge": "Update available",
|
||||
"resultTime": "Result from {time}"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Setup assistant",
|
||||
"intro": "Walks you step by step through password, two-factor sign-in and connecting a firewall.",
|
||||
"open": "Open assistant"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
@@ -215,7 +220,8 @@
|
||||
"hostsystem-cron": "Cron",
|
||||
"hostsystem-syslog": "Syslog",
|
||||
"hostsystem-snapshots": "Snapshots",
|
||||
"hostsystem-diagnostics": "Diagnostics"
|
||||
"hostsystem-diagnostics": "Diagnostics",
|
||||
"setup": "Setup"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -227,6 +233,7 @@
|
||||
"fleetHealth": "Fleet/CARP health",
|
||||
"noHosts": "No hosts yet",
|
||||
"toHosts": "Go to hosts",
|
||||
"startSetup": "Start the setup assistant",
|
||||
"nothingPending": "Nothing pending",
|
||||
"nothingPendingDescription": "No pending confirmations, errors, or unclear maintenance operations.",
|
||||
"securityAndExpiryWarnings": "Security & expiry warnings",
|
||||
@@ -2195,5 +2202,89 @@
|
||||
"closed": "Port {port} not reachable",
|
||||
"noRecords": "No answer for “{name}”."
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Setup assistant",
|
||||
"skip": "Skip assistant",
|
||||
"stepsLabel": "Setup steps",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Finish setup",
|
||||
"readOnly": "The assistant sets up the account and firewalls, so it is reserved for administrators.",
|
||||
"toDashboard": "Go to dashboard",
|
||||
"steps": {
|
||||
"welcome": "Welcome",
|
||||
"account": "Account",
|
||||
"firewall": "Firewall",
|
||||
"done": "Done"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to OPNexus",
|
||||
"intro": "OPNexus manages your OPNsense firewalls in one place: rules, NAT, VPN, certificates, updates and HA clusters. This assistant sets up the essentials in a few minutes. You can skip it at any time and reopen it later under System › Setup.",
|
||||
"points": {
|
||||
"account": "Secure the account: your own password and two-factor sign-in.",
|
||||
"firewall": "Connect your first firewall: create an API key, check the certificate, done.",
|
||||
"done": "An overview of what makes sense next."
|
||||
},
|
||||
"readOnlyNote": "The assistant only reads from your firewall. Nothing there changes until you roll something out yourself later."
|
||||
},
|
||||
"account": {
|
||||
"title": "Secure the account",
|
||||
"passwordTitle": "Your own password",
|
||||
"passwordDone": "Changed",
|
||||
"passwordIntro": "If you did not set ADMIN_PASSWORD at startup, OPNexus generated a random password. Set your own now. This step is optional.",
|
||||
"currentHint": "The password you just signed in with. Lost it? On the server: ./opnexus password",
|
||||
"newHint": "At least 10 characters. A password manager is the most convenient way.",
|
||||
"twofaTitle": "Two-factor sign-in (recommended)",
|
||||
"twofaIntro": "OPNexus can change firewalls. A second factor from an authenticator app protects the account even if the password becomes known."
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Connect your first firewall",
|
||||
"intro": "OPNexus talks to your OPNsense through its REST API, which needs an API key. Creating one takes a minute.",
|
||||
"connected": "Connected firewalls",
|
||||
"recheck": "Check again",
|
||||
"offlineHelp": "Check the address and API key, and that the firewall allows web interface access from this server.",
|
||||
"keyTitle": "How to create the API key in OPNsense",
|
||||
"keySteps": {
|
||||
"menu": "In OPNsense open System › Access › Users.",
|
||||
"user": "Create a dedicated user (e.g. “opnexus”) or edit an existing one.",
|
||||
"key": "Under “API keys” on that user click the plus. Your browser downloads a file called apikey.txt.",
|
||||
"rights": "Give the user sufficient privileges, most simply the “admin” group. With fewer privileges individual features fail."
|
||||
},
|
||||
"keyFileNote": "In apikey.txt the key follows key= and the secret follows secret=. The secret exists only in this file.",
|
||||
"addFirst": "Add a firewall",
|
||||
"addAnother": "Add another firewall",
|
||||
"nameHint": "Free choice, display only. For an HA pair e.g. fw-a and fw-b.",
|
||||
"urlHint": "Address of the OPNsense web interface, with https:// and the port if needed, e.g. https://192.168.1.1:8443.",
|
||||
"clusterHint": "Is the firewall an HA pair? Add both nodes as separate firewalls and give both the same cluster name.",
|
||||
"connect": "Connect",
|
||||
"connectNote": "OPNexus checks right away whether the firewall answers."
|
||||
},
|
||||
"done": {
|
||||
"title": "Done",
|
||||
"hostsOnline": "{online} of {total} firewalls reachable",
|
||||
"noHosts": "No firewall connected yet",
|
||||
"twofaOn": "Two-factor on",
|
||||
"twofaOff": "Two-factor off",
|
||||
"nextTitle": "What you can do next",
|
||||
"next": {
|
||||
"rules": {
|
||||
"title": "Create rules",
|
||||
"text": "New rules are rolled out with a confirmation: if you do not confirm within {seconds} seconds, OPNexus rolls back automatically."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Notifications",
|
||||
"text": "Get told by webhook or email when a firewall goes down or a certificate expires."
|
||||
},
|
||||
"update": {
|
||||
"title": "Updates",
|
||||
"text": "Set up the notice about new OPNexus versions and one-click updates."
|
||||
}
|
||||
},
|
||||
"backupBefore": "You back up the OPNexus database on the server with",
|
||||
"backupHelp": "Covers the database (rules, hosts, history). API secrets and keys live in their own Docker volumes and are not in the dump. See the manual for details.",
|
||||
"docs": "Documentation for the command-line tool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||
// entries already kept in the project README/CHANGELOG.md, no build
|
||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
export const APP_VERSION = "1.15.0";
|
||||
export const APP_CODENAME = "Gyrfalcon";
|
||||
export const APP_VERSION = "1.16.0";
|
||||
export const APP_CODENAME = "Hobby";
|
||||
export const APP_MAINTAINER = "Julian Boldt";
|
||||
@@ -38,7 +38,9 @@ OPNexus $version
|
||||
|
||||
1. cp .env.example .env und POSTGRES_PASSWORD (und was sonst nötig ist) eintragen
|
||||
2. docker compose pull && docker compose up -d --no-build
|
||||
3. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
|
||||
3. Anmelden: ./opnexus password (zeigt das Admin-Passwort, wenn ADMIN_PASSWORD leer war)
|
||||
dann im Browser öffnen — der Einrichtungsassistent führt durch den Rest
|
||||
4. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
|
||||
./opnexus update 1.10.1 (feste Version)
|
||||
Zurück: ./opnexus rollback
|
||||
Sichern: ./opnexus backup
|
||||
|
||||
+30
-1
@@ -7,6 +7,8 @@
|
||||
# bei Fehler automatisch zurückrollen
|
||||
# opnexus rollback Letztes Update rückgängig machen
|
||||
# opnexus backup Sofort-Backup der OPNexus-Datenbank
|
||||
# opnexus password [NAME] Neues Zufallspasswort für den Admin setzen und
|
||||
# anzeigen (--yes: ohne Rückfrage)
|
||||
# opnexus status --porcelain Zustand als key=value-Zeilen (für den Updater)
|
||||
#
|
||||
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
|
||||
@@ -269,10 +271,37 @@ cmd_rollback() {
|
||||
phase done
|
||||
}
|
||||
|
||||
# Setzt ein neues Zufallspasswort und gibt es einmalig aus. Gedacht für die
|
||||
# frische Installation (das beim ersten Start erzeugte Passwort steht nur
|
||||
# einmal im Log) und für ein vergessenes Admin-Passwort. Alle Sitzungen des
|
||||
# Kontos enden. Rückfrage nur, wenn sich schon jemand angemeldet hat.
|
||||
cmd_password() {
|
||||
local user="" yes=0 arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) yes=1 ;;
|
||||
-*) die "Unbekannte Option: $arg" ;;
|
||||
*) [ -z "$user" ] || die "Nur ein Benutzername erlaubt."; user="$arg" ;;
|
||||
esac
|
||||
done
|
||||
c ps --status running --services 2>/dev/null | grep -qx backend \
|
||||
|| die "Das Backend läuft nicht. Erst starten: docker compose up -d"
|
||||
if [ "$yes" != 1 ]; then
|
||||
local logins; logins="$(psql_db -c 'SELECT count(*) FROM admin_users WHERE last_login_at IS NOT NULL' 2>/dev/null || echo 1)"
|
||||
if [ "$logins" != 0 ]; then
|
||||
[ -t 0 ] || die "Ohne Terminal nur mit --yes."
|
||||
read -r -p "Passwort von ${user:-dem Admin} neu setzen? Alle Sitzungen enden. [j/N] " answer
|
||||
case "$answer" in j|J|y|Y) ;; *) die "Abgebrochen." ;; esac
|
||||
fi
|
||||
fi
|
||||
c exec -T backend python -m app.reset_password ${user:+"$user"}
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
password) shift; cmd_password "$@" ;;
|
||||
status) if [ "${2:-}" = "--porcelain" ]; then cmd_status_porcelain; else cmd_status; fi ;;
|
||||
update) shift; cmd_update "${1:-}" ;;
|
||||
rollback) cmd_rollback ;;
|
||||
backup) cmd_backup ;;
|
||||
*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||
*) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||
esac
|
||||
Reference in new issue
Block a user