Compare commits
5
Commits
543fc1c8b4
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21ac97dbb | ||
|
|
6d6b0ff121 | ||
|
|
99357d59bd | ||
|
|
7f6cf00a0e | ||
|
|
8dca30e254 |
+4
-1
@@ -22,12 +22,15 @@ ADMIN_BENUTZER=admin
|
|||||||
ADMIN_PASSWORT=
|
ADMIN_PASSWORT=
|
||||||
|
|
||||||
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
|
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
|
||||||
|
# Beim IdP als Redirect-URI eintragen: <APP_URL>/api/v1/auth/oidc/rueckruf
|
||||||
|
# Client-Typ „confidential“, Scopes openid profile email; der Gruppen-Claim muss im ID-Token stehen.
|
||||||
OIDC_AKTIV=false
|
OIDC_AKTIV=false
|
||||||
OIDC_ISSUER=
|
OIDC_ISSUER=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_ANZEIGENAME=SSO
|
OIDC_ANZEIGENAME=SSO
|
||||||
# Gruppen-Claim und Gruppennamen für die Rollen.
|
# Gruppen-Claim und Gruppennamen für die Rollen. Wer in keiner der beiden Gruppen ist, kommt nicht
|
||||||
|
# hinein. Beide leer = jeder beim IdP angemeldete Benutzer wird Operator.
|
||||||
OIDC_GRUPPEN_CLAIM=groups
|
OIDC_GRUPPEN_CLAIM=groups
|
||||||
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||||
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||||
|
|||||||
@@ -17,6 +17,25 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.5.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Anmeldung per OIDC (z. B. authentik): PKCE, State und Nonce, Rolle aus dem Gruppen-Claim bei jeder Anmeldung, neue Konten beim ersten Anmelden, Verknüpfen eines lokalen Kontos aus den Einstellungen (#016)
|
||||||
|
- Benutzerverwaltung für Admins: anlegen, Rolle ändern, sperren und entsperren, Passwort neu setzen; der letzte Admin und das eigene Konto sind geschützt (#015)
|
||||||
|
- Eigenes Passwort in den Einstellungen ändern (#015)
|
||||||
|
- Audit-Log-Ansicht für Admins, filterbar nach Benutzer, Quelle, Text und Zeitraum; auch `GET /api/v1/protokoll` (#015)
|
||||||
|
- Verlauf filterbar nach Server, Typ, Distribution und Status, mit Seitenblättern; die Filter stehen in der URL. Auch per API und `proxdeploy history` (#014)
|
||||||
|
- „Wiederholen“: der Assistent übernimmt alle Werte eines früheren Auftrags, Name, VMID und feste IP gibt man neu an (#014)
|
||||||
|
- „Aufräumen“ nach Fehlschlag oder Abbruch: löscht nach Bestätigung den halb angelegten Gast – nur, wenn seine Beschreibung auf den Auftrag verweist; Templates nie. Web, API und `proxdeploy cleanup` (#014)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- „Einstellungen“ fehlte in der Navigation (#015)
|
||||||
|
- Erzeugte SSH-Schlüssel (je Server und Bereitstellungsschlüssel) waren in etwa 4 von 1000 Fällen für ssh2 selbst nicht lesbar; jetzt wird geprüft und neu erzeugt (#014)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- SSO übernimmt nie ein lokales Konto über den Benutzernamen; Verknüpfen geht nur angemeldet (#016)
|
||||||
|
- Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
|
||||||
|
|
||||||
## [0.4.0] – 2026-10-12
|
## [0.4.0] – 2026-10-12
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 5 h 20 min (Sitzung 1–3)
|
**Gesamt:** 6 h 59 min (Sitzung 1–3)
|
||||||
|
|
||||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||||
|
|
||||||
@@ -24,6 +24,9 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
||||||
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
||||||
| #013 CLI und API-Tokens | 0 h 42 min |
|
| #013 CLI und API-Tokens | 0 h 42 min |
|
||||||
|
| #014 Verlauf, Wiederholen, Aufräumen | 1 h 21 min |
|
||||||
|
| #015 Benutzer, Passwort, Audit-Log | 0 h 10 min |
|
||||||
|
| #016 OIDC | 0 h 08 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -31,12 +34,13 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||||
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
||||||
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
||||||
|
| v0.5.0 | 1 h 39 min | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-10-12 · Sitzung 3 · 14:39–15:21
|
## 2026-10-12 · Sitzung 3 · 14:39–17:00
|
||||||
**Issue:** #013
|
**Issue:** #013–#016
|
||||||
**Version:** 0.4.0
|
**Version:** 0.4.0, 0.5.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- v0.3.0 freigegeben (Abnahme #021 bleibt offen, braucht eine freie IP)
|
- v0.3.0 freigegeben (Abnahme #021 bleibt offen, braucht eine freie IP)
|
||||||
@@ -47,11 +51,19 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- Abnahme auf pve-demo per `batch`: pd-cli01 (VM 107, Debian 13 aus Template 9002, docker läuft) und pd-cli02 (CT 108, Ubuntu 24.04) parallel; das Verfolgen überstand einen App-Neustart mitten im Lauf
|
- Abnahme auf pve-demo per `batch`: pd-cli01 (VM 107, Debian 13 aus Template 9002, docker läuft) und pd-cli02 (CT 108, Ubuntu 24.04) parallel; das Verfolgen überstand einen App-Neustart mitten im Lauf
|
||||||
- 267 Vitest, 20 Playwright-Abläufe, Go-Tests grün
|
- 267 Vitest, 20 Playwright-Abläufe, Go-Tests grün
|
||||||
|
|
||||||
|
- Etappe 5 (v0.5.0): Verlauf mit Filtern in der URL, Wiederholen, Aufräumen (auf pve-demo: CT 109 nach Abbruch gelöscht, Original „aufgeräumt“); Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht; OIDC gegen Mock-IdP
|
||||||
|
- CLI: `history` und `cleanup`
|
||||||
|
- Gefunden: ssh2 erzeugt in ~0,4 % unlesbare ed25519-Schlüssel (war die Ursache des wackligen SSH-Tests) – behoben; „Einstellungen“ fehlte seit v0.3.0 in der Navigation
|
||||||
|
- 308 Vitest, 26 Playwright-Abläufe grün
|
||||||
|
|
||||||
### Entscheidungen
|
### Entscheidungen
|
||||||
- **Anfragen als Map, Antworten typisiert:** Go-Structs aus oapi-codegen schickten leere Pflichtfelder und überschrieben Vorlagenwerte.
|
- **Anfragen als Map, Antworten typisiert:** Go-Structs aus oapi-codegen schickten leere Pflichtfelder und überschrieben Vorlagenwerte.
|
||||||
- **`[instances.<name>]`** in der Konfiguration statt `profiles` – „profile“ ist schon `--profile`.
|
- **`[instances.<name>]`** in der Konfiguration statt `profiles` – „profile“ ist schon `--profile`.
|
||||||
- **Strg+C hängt nur ab**, Abbruch nur mit `proxdeploy cancel`.
|
- **Strg+C hängt nur ab**, Abbruch nur mit `proxdeploy cancel`.
|
||||||
- **Ohne Terminal zeilenweise** (je Phase eine Zeile), damit CI-Logs lesbar bleiben.
|
- **Ohne Terminal zeilenweise** (je Phase eine Zeile), damit CI-Logs lesbar bleiben.
|
||||||
|
- **Aufräumen löscht nur, was die Beschreibung „(Auftrag <kurzId>)“ trägt** – deshalb setzt das Klonen die Beschreibung jetzt sofort; Templates nie.
|
||||||
|
- **Sitzungen beenden über `Benutzer.sitzungenAb`** statt einer Sitzungstabelle.
|
||||||
|
- **SSO übernimmt nie ein lokales Konto über den Namen**; die Rolle kommt bei jeder Anmeldung aus den Gruppen.
|
||||||
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
|
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der
|
|||||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
||||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ◐ als Nächstes |
|
| 5 · v0.5.0 | Verlauf, Aufräumen, Audit-Log, Benutzer, OIDC | ✓ fertig (Aufräumen auf PVE 9.2 geprüft, OIDC gegen Mock-IdP) |
|
||||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ◐ als Nächstes |
|
||||||
|
|
||||||
## Was ProxDeploy auf einem Server kann
|
## Was ProxDeploy auf einem Server kann
|
||||||
|
|
||||||
@@ -53,6 +53,8 @@ proxdeploy template build --server rz1 --distro debian-13 --profile base,docker
|
|||||||
proxdeploy batch gaeste.yaml
|
proxdeploy batch gaeste.yaml
|
||||||
proxdeploy status a-7f3k
|
proxdeploy status a-7f3k
|
||||||
proxdeploy logs a-7f3k --follow
|
proxdeploy logs a-7f3k --follow
|
||||||
|
proxdeploy history --type lxc --status fehlgeschlagen
|
||||||
|
proxdeploy cleanup a-7f3k # halb angelegten Gast nach Fehlschlag löschen (mit Rückfrage)
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -103,6 +105,30 @@ guests:
|
|||||||
|
|
||||||
Bauen: `cli/bauen.sh 0.4.0` legt die sechs Binaries und `SHA256SUMS` unter `cli/bin/` ab (Go 1.27).
|
Bauen: `cli/bauen.sh 0.4.0` legt die sechs Binaries und `SHA256SUMS` unter `cli/bin/` ab (Go 1.27).
|
||||||
|
|
||||||
|
## Anmeldung per OIDC (z. B. authentik)
|
||||||
|
|
||||||
|
Optional zusätzlich zu lokalen Konten. Beim Identity Provider einen vertraulichen Client anlegen,
|
||||||
|
Redirect-URI `<APP_URL>/api/v1/auth/oidc/rueckruf`, Scopes `openid profile email`, den
|
||||||
|
Gruppen-Claim ins ID-Token geben. Dann in der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_AKTIV=true
|
||||||
|
OIDC_ISSUER=https://auth.example/application/o/proxdeploy/
|
||||||
|
OIDC_CLIENT_ID=…
|
||||||
|
OIDC_CLIENT_SECRET=…
|
||||||
|
OIDC_ANZEIGENAME=authentik
|
||||||
|
OIDC_GRUPPEN_CLAIM=groups
|
||||||
|
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||||
|
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ablauf: Authorization Code mit PKCE, State und Nonce; das ID-Token wird gegen die Schlüssel des IdP geprüft.
|
||||||
|
- Die Rolle kommt bei **jeder** Anmeldung aus den Gruppen; wer in keiner der beiden Gruppen ist, kommt nicht hinein.
|
||||||
|
- Neue SSO-Benutzer werden beim ersten Anmelden angelegt. Ein vorhandenes lokales Konto wird **nie**
|
||||||
|
über den Namen übernommen; verknüpfen geht angemeldet unter Einstellungen → „Mit … verknüpfen“.
|
||||||
|
- Gesperrte Konten bleiben gesperrt, auch per SSO. Getestet gegen einen Mock-IdP (`tests/oidc.test.ts`);
|
||||||
|
ein echter IdP folgt beim Feldtest.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
||||||
|
|||||||
+175
-1
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "ProxDeploy API",
|
"title": "ProxDeploy API",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`."
|
"description": "REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
@@ -927,6 +927,33 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"aufraeumbar": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"typ": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"VM",
|
||||||
|
"LXC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vmid": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"knoten": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"typ",
|
||||||
|
"vmid",
|
||||||
|
"knoten"
|
||||||
|
]
|
||||||
|
},
|
||||||
"phasen": {
|
"phasen": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -954,9 +981,42 @@
|
|||||||
"gestartetAm",
|
"gestartetAm",
|
||||||
"beendetAm",
|
"beendetAm",
|
||||||
"abschluss",
|
"abschluss",
|
||||||
|
"aufraeumbar",
|
||||||
"phasen"
|
"phasen"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"Verlauf": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"eintraege": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Auftrag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gesamt": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"seite": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"seiten": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"eintraege",
|
||||||
|
"gesamt",
|
||||||
|
"seite",
|
||||||
|
"seiten"
|
||||||
|
]
|
||||||
|
},
|
||||||
"LogZeile": {
|
"LogZeile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1347,6 +1407,81 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/auftraege": {
|
"/auftraege": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "verlauf",
|
||||||
|
"summary": "Verlauf, neueste zuerst, 25 je Seite",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "typ",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"vm",
|
||||||
|
"lxc",
|
||||||
|
"template",
|
||||||
|
"aufraeumen"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "distribution",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "seite",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Seite des Verlaufs",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Verlauf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Fehler",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FehlerAntwort"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "ausrollen",
|
"operationId": "ausrollen",
|
||||||
"summary": "VM oder LXC ausrollen (Auftrag)",
|
"summary": "VM oder LXC ausrollen (Auftrag)",
|
||||||
@@ -1558,6 +1693,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/auftraege/{id}/aufraeumen": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "auftragAufraeumen",
|
||||||
|
"summary": "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "ID oder Kurz-ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Auftrag angenommen",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AuftragAngenommen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Fehler",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FehlerAntwort"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auftraege/{id}/abbrechen": {
|
"/auftraege/{id}/abbrechen": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "auftragAbbrechen",
|
"operationId": "auftragAbbrechen",
|
||||||
|
|||||||
@@ -200,6 +200,27 @@ func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) {
|
|||||||
return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen)
|
return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verlauf: eine Seite, gefiltert. Leere Filter fallen weg.
|
||||||
|
func (c *Client) Verlauf(ctx context.Context, filter map[string]string) (Verlauf, error) {
|
||||||
|
werte := url.Values{}
|
||||||
|
for k, v := range filter {
|
||||||
|
if v != "" {
|
||||||
|
werte.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pfad := "/auftraege"
|
||||||
|
if len(werte) > 0 {
|
||||||
|
pfad += "?" + werte.Encode()
|
||||||
|
}
|
||||||
|
var v Verlauf
|
||||||
|
return v, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Aufraeumen(ctx context.Context, id string) (AuftragAngenommen, error) {
|
||||||
|
var a AuftragAngenommen
|
||||||
|
return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/aufraeumen", nil, &a)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Abbrechen(ctx context.Context, id string) error {
|
func (c *Client) Abbrechen(ctx context.Context, id string) error {
|
||||||
return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil)
|
return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,18 +30,36 @@ func (e AuftragArt) Valid() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defines values for AuftragAufraeumbarTyp.
|
||||||
|
const (
|
||||||
|
AuftragAufraeumbarTypLXC AuftragAufraeumbarTyp = "LXC"
|
||||||
|
AuftragAufraeumbarTypVM AuftragAufraeumbarTyp = "VM"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the AuftragAufraeumbarTyp enum.
|
||||||
|
func (e AuftragAufraeumbarTyp) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case AuftragAufraeumbarTypLXC:
|
||||||
|
return true
|
||||||
|
case AuftragAufraeumbarTypVM:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Defines values for AuftragGastTyp.
|
// Defines values for AuftragGastTyp.
|
||||||
const (
|
const (
|
||||||
LXC AuftragGastTyp = "LXC"
|
AuftragGastTypLXC AuftragGastTyp = "LXC"
|
||||||
VM AuftragGastTyp = "VM"
|
AuftragGastTypVM AuftragGastTyp = "VM"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid indicates whether the value is a known member of the AuftragGastTyp enum.
|
// Valid indicates whether the value is a known member of the AuftragGastTyp enum.
|
||||||
func (e AuftragGastTyp) Valid() bool {
|
func (e AuftragGastTyp) Valid() bool {
|
||||||
switch e {
|
switch e {
|
||||||
case LXC:
|
case AuftragGastTypLXC:
|
||||||
return true
|
return true
|
||||||
case VM:
|
case AuftragGastTypVM:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -201,6 +219,30 @@ func (e ProfilVariableType) Valid() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defines values for VerlaufParamsTyp.
|
||||||
|
const (
|
||||||
|
VerlaufParamsTypAufraeumen VerlaufParamsTyp = "aufraeumen"
|
||||||
|
VerlaufParamsTypLxc VerlaufParamsTyp = "lxc"
|
||||||
|
VerlaufParamsTypTemplate VerlaufParamsTyp = "template"
|
||||||
|
VerlaufParamsTypVm VerlaufParamsTyp = "vm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the VerlaufParamsTyp enum.
|
||||||
|
func (e VerlaufParamsTyp) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case VerlaufParamsTypAufraeumen:
|
||||||
|
return true
|
||||||
|
case VerlaufParamsTypLxc:
|
||||||
|
return true
|
||||||
|
case VerlaufParamsTypTemplate:
|
||||||
|
return true
|
||||||
|
case VerlaufParamsTypVm:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Defines values for DistributionenListeParamsTyp.
|
// Defines values for DistributionenListeParamsTyp.
|
||||||
const (
|
const (
|
||||||
DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc"
|
DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc"
|
||||||
@@ -231,6 +273,11 @@ type Abschluss struct {
|
|||||||
type Auftrag struct {
|
type Auftrag struct {
|
||||||
Abschluss *Abschluss `json:"abschluss"`
|
Abschluss *Abschluss `json:"abschluss"`
|
||||||
Art AuftragArt `json:"art"`
|
Art AuftragArt `json:"art"`
|
||||||
|
Aufraeumbar *struct {
|
||||||
|
Knoten *string `json:"knoten"`
|
||||||
|
Typ AuftragAufraeumbarTyp `json:"typ"`
|
||||||
|
Vmid int `json:"vmid"`
|
||||||
|
} `json:"aufraeumbar"`
|
||||||
|
|
||||||
// BeendetAm ISO 8601
|
// BeendetAm ISO 8601
|
||||||
BeendetAm *string `json:"beendetAm"`
|
BeendetAm *string `json:"beendetAm"`
|
||||||
@@ -267,6 +314,9 @@ type Auftrag struct {
|
|||||||
// AuftragArt defines model for Auftrag.Art.
|
// AuftragArt defines model for Auftrag.Art.
|
||||||
type AuftragArt string
|
type AuftragArt string
|
||||||
|
|
||||||
|
// AuftragAufraeumbarTyp defines model for Auftrag.Aufraeumbar.Typ.
|
||||||
|
type AuftragAufraeumbarTyp string
|
||||||
|
|
||||||
// AuftragGastTyp defines model for Auftrag.GastTyp.
|
// AuftragGastTyp defines model for Auftrag.GastTyp.
|
||||||
type AuftragGastTyp string
|
type AuftragGastTyp string
|
||||||
|
|
||||||
@@ -537,6 +587,14 @@ type TokenAnmeldung struct {
|
|||||||
Tage *int `json:"tage,omitempty"`
|
Tage *int `json:"tage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verlauf defines model for Verlauf.
|
||||||
|
type Verlauf struct {
|
||||||
|
Eintraege []Auftrag `json:"eintraege"`
|
||||||
|
Gesamt int `json:"gesamt"`
|
||||||
|
Seite int `json:"seite"`
|
||||||
|
Seiten int `json:"seiten"`
|
||||||
|
}
|
||||||
|
|
||||||
// Vorlage defines model for Vorlage.
|
// Vorlage defines model for Vorlage.
|
||||||
type Vorlage struct {
|
type Vorlage struct {
|
||||||
Beschreibung *string `json:"beschreibung"`
|
Beschreibung *string `json:"beschreibung"`
|
||||||
@@ -544,6 +602,18 @@ type Vorlage struct {
|
|||||||
Titel string `json:"titel"`
|
Titel string `json:"titel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerlaufParams defines parameters for Verlauf.
|
||||||
|
type VerlaufParams struct {
|
||||||
|
Server *string `form:"server,omitempty" json:"server,omitempty"`
|
||||||
|
Typ *VerlaufParamsTyp `form:"typ,omitempty" json:"typ,omitempty"`
|
||||||
|
Distribution *string `form:"distribution,omitempty" json:"distribution,omitempty"`
|
||||||
|
Status *string `form:"status,omitempty" json:"status,omitempty"`
|
||||||
|
Seite *int `form:"seite,omitempty" json:"seite,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerlaufParamsTyp defines parameters for Verlauf.
|
||||||
|
type VerlaufParamsTyp string
|
||||||
|
|
||||||
// AuftragEreignisseParams defines parameters for AuftragEreignisse.
|
// AuftragEreignisseParams defines parameters for AuftragEreignisse.
|
||||||
type AuftragEreignisseParams struct {
|
type AuftragEreignisseParams struct {
|
||||||
LastEventID *string `json:"Last-Event-ID,omitempty"`
|
LastEventID *string `json:"Last-Event-ID,omitempty"`
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package befehle
|
package befehle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||||
)
|
)
|
||||||
@@ -172,3 +175,146 @@ func (u *umgebung) cancelBefehl() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Statustexte wie in der Web-GUI.
|
||||||
|
var statusText = map[api.AuftragStatus]string{
|
||||||
|
api.AuftragStatusWARTEND: "wartet", api.AuftragStatusLAEUFT: "läuft", api.AuftragStatusERFOLGREICH: "erfolgreich",
|
||||||
|
api.AuftragStatusFEHLGESCHLAGEN: "fehlgeschlagen", api.AuftragStatusABGEBROCHEN: "abgebrochen",
|
||||||
|
api.AuftragStatusAUFGERAEUMT: "aufgeräumt",
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusAusText nimmt „fehlgeschlagen“, „läuft“, „aufgeräumt“ oder den API-Wert.
|
||||||
|
func statusAusText(text string) string {
|
||||||
|
for wert, t := range statusText {
|
||||||
|
if strings.EqualFold(t, text) || strings.EqualFold(string(wert), text) {
|
||||||
|
return string(wert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ToUpper(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *umgebung) historyBefehl() *cobra.Command {
|
||||||
|
var server, typ, distro, status string
|
||||||
|
var seite int
|
||||||
|
befehl := &cobra.Command{
|
||||||
|
Use: "history",
|
||||||
|
Aliases: []string{"verlauf"},
|
||||||
|
Short: "Verlauf der Aufträge, neueste zuerst",
|
||||||
|
Example: " proxdeploy history --type lxc --status FEHLGESCHLAGEN\n proxdeploy history --server rz1 --page 2",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
c, _, err := u.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filter := map[string]string{"server": server, "typ": typ, "distribution": distro, "status": ""}
|
||||||
|
if status != "" {
|
||||||
|
filter["status"] = statusAusText(status)
|
||||||
|
}
|
||||||
|
if seite > 1 {
|
||||||
|
filter["seite"] = fmt.Sprint(seite)
|
||||||
|
}
|
||||||
|
v, err := c.Verlauf(cmd.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.opt.json {
|
||||||
|
return u.jsonAusgeben(v)
|
||||||
|
}
|
||||||
|
s := u.stil()
|
||||||
|
if len(v.Eintraege) == 0 {
|
||||||
|
fmt.Fprintln(u.aus, s.Muted("Keine Aufträge."))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
symbole := map[api.AuftragStatus]string{
|
||||||
|
api.AuftragStatusERFOLGREICH: "✓", api.AuftragStatusFEHLGESCHLAGEN: "✗", api.AuftragStatusABGEBROCHEN: "✗",
|
||||||
|
api.AuftragStatusLAEUFT: "◐", api.AuftragStatusWARTEND: "○", api.AuftragStatusAUFGERAEUMT: "○",
|
||||||
|
}
|
||||||
|
zeilen := [][]string{}
|
||||||
|
for _, a := range v.Eintraege {
|
||||||
|
art := map[api.AuftragArt]string{api.AUSROLLEN: "VM", api.TEMPLATEBAUEN: "Template", api.AUFRAEUMEN: "Aufräumen"}[a.Art]
|
||||||
|
if a.Art == api.AUSROLLEN && a.GastTyp != nil && *a.GastTyp == api.AuftragGastTypLXC {
|
||||||
|
art = "LXC"
|
||||||
|
}
|
||||||
|
id := "–"
|
||||||
|
if a.Vmid != nil {
|
||||||
|
id = fmt.Sprint(*a.Vmid)
|
||||||
|
}
|
||||||
|
zustand := symbole[a.Status] + " " + statusText[a.Status]
|
||||||
|
if s.ASCII {
|
||||||
|
zustand = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "◐", "[..]", "○", "[--]").Replace(zustand)
|
||||||
|
}
|
||||||
|
zeit := a.ErstelltAm
|
||||||
|
if t, ok := zeitAus(&a.ErstelltAm); ok {
|
||||||
|
zeit = t.Local().Format("02.01. 15:04")
|
||||||
|
}
|
||||||
|
name := wert(a.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "–"
|
||||||
|
}
|
||||||
|
zeilen = append(zeilen, []string{a.KurzId, name, art, a.Server.Name, wert(a.DistributionId), id, zustand, zeit})
|
||||||
|
}
|
||||||
|
u.tabelle(s, []string{"AUFTRAG", "NAME", "TYP", "SERVER", "DISTRIBUTION", "ID", "STATUS", "ZEIT"}, zeilen)
|
||||||
|
if v.Seiten > 1 {
|
||||||
|
fmt.Fprintln(u.aus, s.Muted(fmt.Sprintf("Seite %d von %d · %d Aufträge · weiter mit --page %d", v.Seite, v.Seiten, v.Gesamt, min(v.Seite+1, v.Seiten))))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := befehl.Flags()
|
||||||
|
f.StringVar(&server, "server", "", "nur dieser Server")
|
||||||
|
f.StringVar(&typ, "type", "", "vm, lxc, template oder aufraeumen")
|
||||||
|
f.StringVar(&distro, "distro", "", "nur diese Distribution")
|
||||||
|
f.StringVar(&status, "status", "", "z. B. fehlgeschlagen, erfolgreich, laeuft")
|
||||||
|
f.IntVar(&seite, "page", 1, "Seite (25 je Seite)")
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *umgebung) cleanupBefehl() *cobra.Command {
|
||||||
|
var ja, nichtWarten bool
|
||||||
|
befehl := &cobra.Command{
|
||||||
|
Use: "cleanup <job-id>",
|
||||||
|
Short: "Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen",
|
||||||
|
Long: "Stoppt und löscht die VM bzw. den Container, den ein fehlgeschlagener oder abgebrochener\n" +
|
||||||
|
"Auftrag angelegt hat. Gelöscht wird nur, was laut Beschreibung zu diesem Auftrag gehört.",
|
||||||
|
Args: eineID("cleanup"),
|
||||||
|
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||||
|
c, _, err := u.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a, err := c.Auftrag(cmd.Context(), argumente[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Aufraeumbar == nil {
|
||||||
|
return bedienfehler("bei %s gibt es nichts aufzuräumen (Status %s)", a.KurzId, statusText[a.Status])
|
||||||
|
}
|
||||||
|
z := a.Aufraeumbar
|
||||||
|
wort := map[api.AuftragAufraeumbarTyp]string{api.AuftragAufraeumbarTypVM: "VM", api.AuftragAufraeumbarTypLXC: "Container"}[z.Typ]
|
||||||
|
ziel := fmt.Sprintf("%s %d", wort, z.Vmid)
|
||||||
|
if z.Knoten != nil {
|
||||||
|
ziel += " auf " + *z.Knoten
|
||||||
|
}
|
||||||
|
if !ja {
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
return bedienfehler("%s löschen? Ohne Terminal mit --yes bestätigen", ziel)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(u.fehler, "%s stoppen und mit allen Disks löschen? [j/N] ", ziel)
|
||||||
|
antwort, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if a := strings.ToLower(strings.TrimSpace(antwort)); a != "j" && a != "ja" && a != "y" && a != "yes" {
|
||||||
|
fmt.Fprintln(u.fehler, u.stil().Muted("Nichts gelöscht."))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
angenommen, err := c.Aufraeumen(cmd.Context(), a.KurzId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
befehl.Flags().BoolVarP(&ja, "yes", "y", false, "ohne Rückfrage")
|
||||||
|
befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
|
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
|
||||||
@@ -78,6 +79,8 @@ type fakeAPI struct {
|
|||||||
plan map[string]any
|
plan map[string]any
|
||||||
status int
|
status int
|
||||||
antwort any
|
antwort any
|
||||||
|
verlauf map[string]any
|
||||||
|
einzeln map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||||
@@ -88,6 +91,11 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
|||||||
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
|
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.anfragen == nil {
|
||||||
|
f.anfragen = map[string][]map[string]any{}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
if r.Body != nil && r.Method == http.MethodPost {
|
if r.Body != nil && r.Method == http.MethodPost {
|
||||||
var rumpf map[string]any
|
var rumpf map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&rumpf)
|
_ = json.NewDecoder(r.Body).Decode(&rumpf)
|
||||||
@@ -112,6 +120,15 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
|||||||
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
|
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
|
||||||
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
|
||||||
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
|
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
|
||||||
|
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodGet:
|
||||||
|
f.mu.Lock()
|
||||||
|
f.anfragen["GET "+r.URL.RawQuery] = nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
schreiben(200, f.verlauf)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/aufraeumen"):
|
||||||
|
schreiben(202, map[string]any{"id": "c2", "kurzId": "c-9x9x9"})
|
||||||
|
case r.URL.Path == "/api/v1/auftraege/d-kaputt":
|
||||||
|
schreiben(200, f.einzeln)
|
||||||
case r.URL.Path == "/api/v1/auftraege/plan":
|
case r.URL.Path == "/api/v1/auftraege/plan":
|
||||||
schreiben(200, f.plan)
|
schreiben(200, f.plan)
|
||||||
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
|
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
|
||||||
@@ -323,3 +340,48 @@ func TestLoginMitToken(t *testing.T) {
|
|||||||
t.Error("falsches Token hat die Konfiguration verändert")
|
t.Error("falsches Token hat die Konfiguration verändert")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHistory(t *testing.T) {
|
||||||
|
ok := auftrag("ERFOLGREICH", alle, nil)
|
||||||
|
ok["erstelltAm"] = "2026-10-12T08:05:00.000Z"
|
||||||
|
ct := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||||
|
ct["kurzId"], ct["name"], ct["gastTyp"], ct["vmid"], ct["distributionId"] = "d-ct001", "dns01", "LXC", 121, "ubuntu-24.04"
|
||||||
|
ct["erstelltAm"] = "2026-10-12T08:00:00.000Z"
|
||||||
|
f := &fakeAPI{verlauf: map[string]any{"eintraege": []any{ok, ct}, "gesamt": 30, "seite": 1, "seiten": 2}}
|
||||||
|
alt := time.Local
|
||||||
|
time.Local = time.UTC
|
||||||
|
t.Cleanup(func() { time.Local = alt })
|
||||||
|
aus, fehler, code := lauf(t, f, "history", "--type", "lxc", "--status", "fehlgeschlagen")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Exit %d: %s", code, fehler)
|
||||||
|
}
|
||||||
|
golden(t, "history.golden", aus)
|
||||||
|
if _, gesehen := f.anfragen["GET status=FEHLGESCHLAGEN&typ=lxc"]; !gesehen {
|
||||||
|
t.Errorf("Filter nicht gesendet: %v", f.anfragen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup(t *testing.T) {
|
||||||
|
kaputt := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||||
|
kaputt["kurzId"] = "d-kaputt"
|
||||||
|
kaputt["aufraeumbar"] = map[string]any{"typ": "VM", "vmid": 120, "knoten": "pve01"}
|
||||||
|
f := &fakeAPI{einzeln: kaputt}
|
||||||
|
// Ohne Terminal keine Rückfrage möglich: ohne --yes wird nichts gelöscht.
|
||||||
|
_, fehler, code := lauf(t, f, "cleanup", "d-kaputt")
|
||||||
|
if code != ExitBedienung || !strings.Contains(fehler, "VM 120 auf pve01 löschen?") {
|
||||||
|
t.Errorf("ohne --yes: Exit %d, %s", code, fehler)
|
||||||
|
}
|
||||||
|
if len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 0 {
|
||||||
|
t.Error("ohne Bestätigung aufgeräumt")
|
||||||
|
}
|
||||||
|
aus, _, code := lauf(t, f, "cleanup", "d-kaputt", "--yes", "--no-wait")
|
||||||
|
if code != 0 || strings.TrimSpace(aus) != "c-9x9x9" || len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 1 {
|
||||||
|
t.Errorf("mit --yes: Exit %d, %q", code, aus)
|
||||||
|
}
|
||||||
|
|
||||||
|
kaputt["aufraeumbar"] = nil
|
||||||
|
_, fehler, code = lauf(t, f, "cleanup", "d-kaputt", "--yes")
|
||||||
|
if code != ExitBedienung || !strings.Contains(fehler, "nichts aufzuräumen") {
|
||||||
|
t.Errorf("nicht aufräumbar: Exit %d, %s", code, fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
AUFTRAG NAME TYP SERVER DISTRIBUTION ID STATUS ZEIT
|
||||||
|
a-7f3k web01 VM rz1 debian-13 120 ✓ erfolgreich 12.10. 08:05
|
||||||
|
d-ct001 dns01 LXC rz1 ubuntu-24.04 121 ✗ fehlgeschlagen 12.10. 08:00
|
||||||
|
Seite 1 von 2 · 30 Aufträge · weiter mit --page 2
|
||||||
@@ -140,6 +140,8 @@ func (u *umgebung) wurzel() *cobra.Command {
|
|||||||
u.statusBefehl(),
|
u.statusBefehl(),
|
||||||
u.logsBefehl(),
|
u.logsBefehl(),
|
||||||
u.cancelBefehl(),
|
u.cancelBefehl(),
|
||||||
|
u.historyBefehl(),
|
||||||
|
u.cleanupBefehl(),
|
||||||
)
|
)
|
||||||
hilfeEindeutschen(wurzel)
|
hilfeEindeutschen(wurzel)
|
||||||
return wurzel
|
return wurzel
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
# #014 Verlauf und Wiederholen
|
# #014 Verlauf und Wiederholen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 1 h 21 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Alle Aufträge filterbar, mit gleichen Parametern wiederholen.
|
Alle Aufträge filterbar, mit gleichen Parametern wiederholen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Filter Server/Typ/Distribution/Status, Seitenblättern
|
- [x] Filter Server/Typ/Distribution/Status, Seitenblättern (Web, `GET /api/v1/auftraege?…`, `proxdeploy history`)
|
||||||
- [ ] Wiederholen (neue VMID/Name abfragen)
|
- [x] Wiederholen (neue VMID/Name abfragen): `/ausrollen?wiederholen=<kurzId>`, Name, VMID und feste IP leer
|
||||||
|
- [x] Aufräumen nach Fehlschlag/Abbruch mit Bestätigung (Vorgabe „Allgemeine Anforderungen“): Web-Knopf, `POST …/aufraeumen`, `proxdeploy cleanup`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Filter bleiben in der URL
|
- [x] Filter bleiben in der URL (Playwright)
|
||||||
|
- [x] Aufräumen auf pve-demo: CT 109 nach Abbruch erkannt, gestoppt, gelöscht; Original danach „aufgeräumt“
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008
|
#008
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Gelöscht wird nur, was nachweislich zum Auftrag gehört:** die Beschreibung des Gastes enthält
|
||||||
|
„(Auftrag <kurzId>)“. Dafür setzt das Klonen die Beschreibung jetzt sofort (vorher erst in der
|
||||||
|
Hardware-Phase). Templates, gesperrte Gäste und fremde Beschreibungen: Abbruch, nichts angefasst.
|
||||||
|
- Wiederholen leert auch eine feste IP – dieselbe Adresse zweimal ist fast nie gewollt.
|
||||||
|
- Nebenbei gefunden: ssh2 erzeugt in ~0,4 % der Fälle ed25519-Schlüssel, die es selbst nicht
|
||||||
|
lesen kann (Ursache des „wackligen“ SSH-Tests). Behoben mit `ed25519Erzeugen`.
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature, sicherheit
|
**Labels:** feature, sicherheit
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
- [x] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) – seit #001, jetzt mit den neuen Routen
|
||||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
- [x] Benutzerverwaltung (anlegen, Rolle, sperren, Passwort setzen), eigenes Passwort ändern; CLI-Tokens seit v0.4.0
|
||||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||||
- [ ] Audit-Log-Ansicht mit Filter
|
- [x] Audit-Log-Ansicht mit Filter (Benutzer, Quelle, Text, Zeitraum), auch `GET /api/v1/protokoll`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Operator kann keine Server/Katalog/Benutzer ändern (Tests)
|
- [x] Operator kann keine Server/Katalog/Benutzer ändern (Tests in `tests/benutzer.test.ts`)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Sitzungen beenden ohne Sitzungstabelle:** `Benutzer.sitzungenAb`; ältere JWTs gelten nicht mehr.
|
||||||
|
Gesetzt bei Sperre, Passwort-Reset und eigenem Passwortwechsel (die eigene Sitzung bekommt ein
|
||||||
|
neues Cookie). Auf volle Sekunden, weil `iat` Sekunden hat.
|
||||||
|
- **Schutzregeln serverseitig:** der letzte aktive Admin bleibt Admin und aktiv (Zeilensperre gegen
|
||||||
|
zwei gleichzeitige Herabstufungen), das eigene Konto lässt sich nicht sperren.
|
||||||
|
- Benutzer werden nicht gelöscht, nur gesperrt – Aufträge und Audit-Log verweisen auf sie.
|
||||||
|
- „Einstellungen“ fehlte seit v0.3.0 in der Navigation (`ab: "#015"`) – jetzt sichtbar, dazu „Audit-Log“ für Admins.
|
||||||
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
||||||
|
|||||||
+13
-5
@@ -1,20 +1,28 @@
|
|||||||
# #016 Anmeldung per OIDC
|
# #016 Anmeldung per OIDC
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature, sicherheit
|
**Labels:** feature, sicherheit
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 08 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim.
|
Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] openid-client mit PKCE, Discovery, Gruppen → Rolle, Konto-Verknüpfung
|
- [x] openid-client 6.8.8 mit PKCE, State, Nonce, Discovery; Gruppen → Rolle bei jeder Anmeldung; Konto-Verknüpfung aus den Einstellungen
|
||||||
|
- [x] Anmeldeseite mit „Mit … anmelden“ und verständlichen Fehlermeldungen (`?sso=…`)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Tests gegen einen Mock-IdP (echter IdP später)
|
- [x] Tests gegen einen Mock-IdP (`tests/oidc-mock.ts`): Anlegen, Rollenwechsel, keine Gruppe, keine Übernahme per Name, Verknüpfen, gesperrt, gefälschter State, fehlendes Cookie, falsche Nonce, fremde Audience – echter IdP beim Feldtest (#020)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#015
|
#015
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Keine automatische Übernahme lokaler Konten über den Namen** – sonst übernähme, wer beim IdP
|
||||||
|
„admin“ heißt, den lokalen Admin. Bei Namensgleichheit bekommt das SSO-Konto „name-2“.
|
||||||
|
- **IdP maßgeblich für die Rolle:** bei jeder Anmeldung aus den Gruppen; Wechsel kommt ins Audit-Log.
|
||||||
|
- Ablaufzustand (Verifier, State, Nonce, Ziel) in einem kurzlebigen signierten Cookie
|
||||||
|
(`pd_oidc`, 10 min, nur `/api/v1/auth/oidc`, SameSite=Lax) statt in der Datenbank.
|
||||||
|
- `redirect_uri` und die Rückruf-URL werden aus `APP_URL` gebildet – hinter dem Reverse-Proxy
|
||||||
|
trägt die Anfrage den internen Host.
|
||||||
|
- Client-Anmeldung `client_secret_post` (Standard von openid-client); authentik und Keycloak können beides.
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
|
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
|
||||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
||||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
|
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | 1 h 21 min |
|
||||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
|
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | 0 h 10 min |
|
||||||
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
|
| [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | 0 h 08 min |
|
||||||
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
||||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
||||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxdeploy",
|
"name": "proxdeploy",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
"next": "16.4.0",
|
"next": "16.4.0",
|
||||||
|
"openid-client": "6.8.8",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"pg-boss": "12.37.1",
|
"pg-boss": "12.37.1",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
|
|||||||
Generated
+16
@@ -193,6 +193,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.4.0
|
specifier: 16.4.0
|
||||||
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||||
|
openid-client:
|
||||||
|
specifier: 6.8.8
|
||||||
|
version: 6.8.8
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.23.0
|
specifier: ^8.23.0
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -3183,6 +3186,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.8:
|
||||||
|
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3226,6 +3232,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
openid-client@6.8.8:
|
||||||
|
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -6784,6 +6793,8 @@ snapshots:
|
|||||||
|
|
||||||
non-error@0.1.0: {}
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.8: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -6832,6 +6843,11 @@ snapshots:
|
|||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
|
openid-client@6.8.8:
|
||||||
|
dependencies:
|
||||||
|
jose: 6.2.12
|
||||||
|
oauth4webapi: 3.8.8
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3);
|
||||||
@@ -33,6 +33,8 @@ model Benutzer {
|
|||||||
oidcSubjekt String? @unique
|
oidcSubjekt String? @unique
|
||||||
rolle Rolle @default(OPERATOR)
|
rolle Rolle @default(OPERATOR)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
/// Sitzungen, die vorher ausgestellt wurden, gelten nicht mehr (Passwortwechsel, Sperre).
|
||||||
|
sitzungenAb DateTime?
|
||||||
/// Persönliche Standardwerte im Assistenten.
|
/// Persönliche Standardwerte im Assistenten.
|
||||||
standards Json?
|
standards Json?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Code } from "@/components/formular";
|
import { Code } from "@/components/formular";
|
||||||
import { Knopf } from "@/components/knopf";
|
import { Knopf } from "@/components/knopf";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||||
import { TerminalKarte } from "@/components/terminal-karte";
|
import { TerminalKarte } from "@/components/terminal-karte";
|
||||||
import { apiAufruf } from "@/lib/api-client";
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||||
|
|
||||||
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
@@ -19,13 +21,15 @@ export function LiveAnsicht({
|
|||||||
anfang,
|
anfang,
|
||||||
anfangsLog,
|
anfangsLog,
|
||||||
version,
|
version,
|
||||||
abbrechenErlaubt,
|
darfAusrollen,
|
||||||
}: {
|
}: {
|
||||||
anfang: AuftragSicht;
|
anfang: AuftragSicht;
|
||||||
anfangsLog: LogZeile[];
|
anfangsLog: LogZeile[];
|
||||||
version: string;
|
version: string;
|
||||||
abbrechenErlaubt: boolean;
|
darfAusrollen: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [aktionFehler, setAktionFehler] = useState<string | null>(null);
|
||||||
const [auftrag, setAuftrag] = useState(anfang);
|
const [auftrag, setAuftrag] = useState(anfang);
|
||||||
const [log, setLog] = useState(anfangsLog);
|
const [log, setLog] = useState(anfangsLog);
|
||||||
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
|
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
|
||||||
@@ -90,6 +94,25 @@ export function LiveAnsicht({
|
|||||||
const ergebnis = auftrag.abschluss;
|
const ergebnis = auftrag.abschluss;
|
||||||
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
||||||
|
|
||||||
|
async function aufraeumen() {
|
||||||
|
const ziel = auftrag.aufraeumbar;
|
||||||
|
if (!ziel) return;
|
||||||
|
const wort = ziel.typ === "LXC" ? "Container" : "VM";
|
||||||
|
const frage =
|
||||||
|
`${wort} ${ziel.vmid}${ziel.knoten ? ` auf ${ziel.knoten}` : ""} stoppen und mit allen Disks löschen?\n\n` +
|
||||||
|
`ProxDeploy löscht nur, wenn die Beschreibung des Gastes auf ${auftrag.kurzId} verweist.`;
|
||||||
|
if (!window.confirm(frage)) return;
|
||||||
|
try {
|
||||||
|
const neu = await apiAufruf<{ kurzId: string }>(
|
||||||
|
"POST",
|
||||||
|
`/api/v1/auftraege/${auftrag.kurzId}/aufraeumen`,
|
||||||
|
);
|
||||||
|
router.push(`/auftraege/${neu.kurzId}`);
|
||||||
|
} catch (fehler) {
|
||||||
|
setAktionFehler(fehler instanceof ApiFehler ? fehler.message : "Aufräumen ließ sich nicht starten.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
@@ -105,9 +128,22 @@ export function LiveAnsicht({
|
|||||||
: (auftrag.name ?? "Ausrollen")}
|
: (auftrag.name ?? "Ausrollen")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
|
||||||
<Status art={art}>{text}</Status>
|
<Status art={art}>{text}</Status>
|
||||||
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
|
{darfAusrollen && auftrag.aufraeumbar ? (
|
||||||
|
<Knopf variante="gefahr" onClick={() => void aufraeumen()}>
|
||||||
|
Aufräumen
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
{darfAusrollen && auftrag.art === "AUSROLLEN" && ENDE.has(auftrag.status) ? (
|
||||||
|
<Link
|
||||||
|
href={`/ausrollen?wiederholen=${auftrag.kurzId}`}
|
||||||
|
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
Wiederholen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{darfAusrollen && !ENDE.has(auftrag.status) ? (
|
||||||
<Knopf
|
<Knopf
|
||||||
variante="gefahr"
|
variante="gefahr"
|
||||||
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
||||||
@@ -118,6 +154,8 @@ export function LiveAnsicht({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{aktionFehler ? <Status art="fehler">{aktionFehler}</Status> : null}
|
||||||
|
|
||||||
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
||||||
|
|
||||||
{typeof ssh === "string" ? (
|
{typeof ssh === "string" ? (
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default async function AuftragSeite({ params }: { params: Promise<{ kurzI
|
|||||||
anfang={auftrag}
|
anfang={auftrag}
|
||||||
anfangsLog={log}
|
anfangsLog={log}
|
||||||
version={versionText()}
|
version={versionText()}
|
||||||
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
|
darfAusrollen={darf(benutzer.rolle, "ausrollen")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
|
||||||
|
type Auswahl = Array<{ wert: string; text: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter des Verlaufs. Der Stand steht in der URL – Neuladen und Weitergeben behalten ihn; jede
|
||||||
|
* Änderung springt auf Seite 1.
|
||||||
|
*/
|
||||||
|
export function FilterLeiste({ server, distributionen }: { server: Auswahl; distributionen: Auswahl }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pfad = usePathname();
|
||||||
|
const parameter = useSearchParams();
|
||||||
|
|
||||||
|
const setze = (name: string, wert: string) => {
|
||||||
|
const neu = new URLSearchParams(parameter);
|
||||||
|
if (wert) neu.set(name, wert);
|
||||||
|
else neu.delete(name);
|
||||||
|
neu.delete("seite");
|
||||||
|
const text = neu.toString();
|
||||||
|
router.push(text ? `${pfad}?${text}` : pfad);
|
||||||
|
};
|
||||||
|
|
||||||
|
const felder: Array<{ name: string; label: string; optionen: Auswahl }> = [
|
||||||
|
{ name: "server", label: "Server", optionen: server },
|
||||||
|
{
|
||||||
|
name: "typ",
|
||||||
|
label: "Typ",
|
||||||
|
optionen: [
|
||||||
|
{ wert: "vm", text: "VM" },
|
||||||
|
{ wert: "lxc", text: "LXC" },
|
||||||
|
{ wert: "template", text: "Template" },
|
||||||
|
{ wert: "aufraeumen", text: "Aufräumen" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "distribution", label: "Distribution", optionen: distributionen },
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: "Status",
|
||||||
|
optionen: [
|
||||||
|
{ wert: "LAEUFT", text: "läuft" },
|
||||||
|
{ wert: "WARTEND", text: "wartet" },
|
||||||
|
{ wert: "ERFOLGREICH", text: "erfolgreich" },
|
||||||
|
{ wert: "FEHLGESCHLAGEN", text: "fehlgeschlagen" },
|
||||||
|
{ wert: "ABGEBROCHEN", text: "abgebrochen" },
|
||||||
|
{ wert: "AUFGERAEUMT", text: "aufgeräumt" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const aktiv = felder.some((f) => parameter.get(f.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:flex lg:items-end">
|
||||||
|
{felder.map((f) => (
|
||||||
|
<Feld key={f.name} id={`filter-${f.name}`} label={f.label}>
|
||||||
|
<select
|
||||||
|
id={`filter-${f.name}`}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-44`}
|
||||||
|
value={parameter.get(f.name) ?? ""}
|
||||||
|
onChange={(e) => setze(f.name, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{f.optionen.map((o) => (
|
||||||
|
<option key={o.wert} value={o.wert}>
|
||||||
|
{o.text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
))}
|
||||||
|
{aktiv ? (
|
||||||
|
<Knopf variante="sekundaer" className="col-span-2 sm:col-span-1" onClick={() => router.push(pfad)}>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,25 +3,56 @@ import Link from "next/link";
|
|||||||
import { Karte } from "@/components/karte";
|
import { Karte } from "@/components/karte";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { zeitpunkt } from "@/lib/formate";
|
import { zeitpunkt } from "@/lib/formate";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
import { auftraegeListe } from "@/server/services/auftraege";
|
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
|
||||||
|
import { distributionenListe } from "@/server/services/katalog";
|
||||||
|
import { FilterLeiste } from "./filter-leiste";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Verlauf" };
|
export const metadata: Metadata = { title: "Verlauf" };
|
||||||
|
|
||||||
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
|
type Suche = Record<string, string | string[] | undefined>;
|
||||||
export default async function Verlauf() {
|
|
||||||
|
/** Verlauf aller Aufträge, filterbar nach Server, Typ, Distribution und Status (Stand in der URL). */
|
||||||
|
export default async function Verlauf({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||||
await seiteSchuetzen("lesen", "/auftraege");
|
await seiteSchuetzen("lesen", "/auftraege");
|
||||||
const auftraege = await auftraegeListe({ grenze: 100 });
|
const suche = await searchParams;
|
||||||
|
const filter = verlaufFilterAus(suche);
|
||||||
|
const [{ eintraege, gesamt, seite, seiten }, server, distributionen] = await Promise.all([
|
||||||
|
auftraegeSuchen(filter),
|
||||||
|
prisma.server.findMany({ select: { name: true, titel: true }, orderBy: { name: "asc" } }),
|
||||||
|
distributionenListe(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const seitenLink = (ziel: number) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
|
||||||
|
if (ziel > 1) p.set("seite", String(ziel));
|
||||||
|
const text = p.toString();
|
||||||
|
return text ? `/auftraege?${text}` : "/auftraege";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
{gesamt} {gesamt === 1 ? "Auftrag" : "Aufträge"}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<FilterLeiste
|
||||||
|
server={server.map((s) => ({ wert: s.name, text: s.titel }))}
|
||||||
|
distributionen={distributionen.map((d) => ({ wert: d.id, text: d.name }))}
|
||||||
|
/>
|
||||||
<Karte>
|
<Karte>
|
||||||
{auftraege.length === 0 ? (
|
{eintraege.length === 0 ? (
|
||||||
<Status art="offen">Noch keine Aufträge.</Status>
|
<Status art="offen">
|
||||||
|
{gesamt === 0 && seite === 1 ? "Keine Aufträge." : "Keine Aufträge auf dieser Seite."}
|
||||||
|
</Status>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-linie flex flex-col divide-y">
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
{auftraege.map((a) => {
|
{eintraege.map((a) => {
|
||||||
const [art, text] = STATUS_ANZEIGE[a.status];
|
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||||
return (
|
return (
|
||||||
<li key={a.id}>
|
<li key={a.id}>
|
||||||
@@ -34,9 +65,15 @@ export default async function Verlauf() {
|
|||||||
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||||
<span className="text-muted text-sm">
|
<span className="text-muted text-sm">
|
||||||
{" "}
|
{" "}
|
||||||
· {ART_TEXT[a.art]} · {a.server.name}
|
·{" "}
|
||||||
|
{a.art === "AUSROLLEN" && a.gastTyp
|
||||||
|
? a.gastTyp === "LXC"
|
||||||
|
? "LXC"
|
||||||
|
: "VM"
|
||||||
|
: ART_TEXT[a.art]}{" "}
|
||||||
|
· {a.server.name}
|
||||||
{a.distributionId ? ` · ${a.distributionId}` : ""}
|
{a.distributionId ? ` · ${a.distributionId}` : ""}
|
||||||
{a.vmid ? ` · ${a.vmid}` : ""}
|
{a.vmid ? ` · ${a.vmid}` : ""} · {a.erstelltVon}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
||||||
@@ -50,6 +87,27 @@ export default async function Verlauf() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
{seiten > 1 ? (
|
||||||
|
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
{seite > 1 ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||||
|
← Neuere
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="text-muted">
|
||||||
|
Seite {seite} von {seiten}
|
||||||
|
</span>
|
||||||
|
{seite < seiten ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||||
|
Ältere →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Knopf } from "@/components/knopf";
|
|||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollAnfrage, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||||
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
||||||
|
|
||||||
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -204,6 +204,53 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen früheren Auftrag in die Formularwerte übernehmen („Wiederholen“). Name, VMID und eine
|
||||||
|
* statische IP gehören zum alten Gast und werden geleert – sie muss man neu angeben.
|
||||||
|
*/
|
||||||
|
function wiederholungUebernehmen(w: Werte, a: AusrollAnfrage, daten: AssistentDaten): Werte {
|
||||||
|
const hinterlegt = new Set(daten.schluessel.map((x) => x.oeffentlich));
|
||||||
|
const gewaehlt = new Set(a.zugang.sshSchluessel);
|
||||||
|
const variablen: Werte["variablen"] = {};
|
||||||
|
for (const [profil, werte] of Object.entries(a.variablen)) {
|
||||||
|
variablen[profil] = Object.fromEntries(
|
||||||
|
Object.entries(werte).map(([k, v]) => [k, typeof v === "boolean" ? v : String(v)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const server = daten.server.some((x) => x.name === a.server) ? a.server : w.server;
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
server,
|
||||||
|
knoten: a.knoten ?? "",
|
||||||
|
typ: a.typ,
|
||||||
|
distribution: a.distribution,
|
||||||
|
name: "",
|
||||||
|
vmid: "",
|
||||||
|
tags: a.tags.join(","),
|
||||||
|
cores: String(a.cores),
|
||||||
|
speicherMb: String(a.speicherMb),
|
||||||
|
diskGb: String(a.diskGb),
|
||||||
|
storage: a.storage,
|
||||||
|
swapMb: String(a.swapMb),
|
||||||
|
unprivilegiert: a.unprivilegiert,
|
||||||
|
nesting: a.features.nesting,
|
||||||
|
bridge: a.netz.bridge,
|
||||||
|
vlan: a.netz.vlan ? String(a.netz.vlan) : "",
|
||||||
|
modus: a.netz.modus,
|
||||||
|
ip: "",
|
||||||
|
gateway: a.netz.gateway ?? "",
|
||||||
|
dns: a.netz.dns.join(","),
|
||||||
|
suchdomain: a.netz.suchdomain ?? "",
|
||||||
|
benutzer: a.zugang.benutzer,
|
||||||
|
schluessel: a.zugang.sshSchluessel.filter((k) => !hinterlegt.has(k)).join("\n"),
|
||||||
|
ohneSchluessel: daten.schluessel.filter((x) => !gewaehlt.has(x.oeffentlich)).map((x) => x.fingerabdruck),
|
||||||
|
profile: a.profile,
|
||||||
|
variablen,
|
||||||
|
templateBauen: a.templateBauen,
|
||||||
|
vorlage: a.vorlage ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function werteZuVorlage(w: Werte): VorlagenParameter {
|
function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||||
const a = anfrageAus(w, []);
|
const a = anfrageAus(w, []);
|
||||||
return {
|
return {
|
||||||
@@ -231,7 +278,16 @@ function werteZuVorlage(w: Werte): VorlagenParameter {
|
|||||||
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
||||||
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
||||||
*/
|
*/
|
||||||
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
|
export function Assistent({
|
||||||
|
daten,
|
||||||
|
vorauswahl,
|
||||||
|
wiederholung = null,
|
||||||
|
}: {
|
||||||
|
daten: AssistentDaten;
|
||||||
|
vorauswahl: string | null;
|
||||||
|
/** `?wiederholen=<kurzId>`: Anfrage eines früheren Auftrags als Vorbelegung. */
|
||||||
|
wiederholung?: { kurzId: string; anfrage: AusrollAnfrage } | null;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pfad = usePathname();
|
const pfad = usePathname();
|
||||||
const parameter = useSearchParams();
|
const parameter = useSearchParams();
|
||||||
@@ -279,6 +335,8 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
const vorlage = !parameter.get("d")
|
const vorlage = !parameter.get("d")
|
||||||
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
|
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (!parameter.get("d") && wiederholung)
|
||||||
|
return wiederholungUebernehmen(ausUrl, wiederholung.anfrage, daten);
|
||||||
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
|
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
|
||||||
});
|
});
|
||||||
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
|
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
|
||||||
@@ -421,6 +479,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
||||||
</p>
|
</p>
|
||||||
|
{wiederholung ? (
|
||||||
|
<Status art="offen" className="mt-2 text-sm">
|
||||||
|
Wiederholung von {wiederholung.kurzId} – Name, VMID und eine feste IP neu angeben.
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Karte } from "@/components/karte";
|
|||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
import { versionText } from "@/lib/version";
|
import { versionText } from "@/lib/version";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
@@ -15,7 +16,11 @@ import { Assistent, type AssistentDaten } from "./assistent";
|
|||||||
export const metadata: Metadata = { title: "Ausrollen" };
|
export const metadata: Metadata = { title: "Ausrollen" };
|
||||||
|
|
||||||
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
||||||
export default async function Ausrollen() {
|
export default async function Ausrollen({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ wiederholen?: string }>;
|
||||||
|
}) {
|
||||||
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
||||||
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
||||||
const mitTest = server.filter((s) => s.letzterTest);
|
const mitTest = server.filter((s) => s.letzterTest);
|
||||||
@@ -87,5 +92,22 @@ export default async function Ausrollen() {
|
|||||||
daten.server.map((s) => s.name),
|
daten.server.map((s) => s.name),
|
||||||
(await cookies()).get(SERVER_COOKIE)?.value,
|
(await cookies()).get(SERVER_COOKIE)?.value,
|
||||||
);
|
);
|
||||||
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
|
return (
|
||||||
|
<Assistent
|
||||||
|
daten={daten}
|
||||||
|
vorauswahl={vorauswahl}
|
||||||
|
wiederholung={await wiederholungLaden((await searchParams).wiederholen)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Anfrage eines früheren Ausroll-Auftrags – nur, wenn sie noch zum heutigen Schema passt. */
|
||||||
|
async function wiederholungLaden(kennung: string | undefined) {
|
||||||
|
if (!kennung) return null;
|
||||||
|
const auftrag = await prisma.auftrag.findFirst({
|
||||||
|
where: { OR: [{ kurzId: kennung }, { id: kennung }], art: "AUSROLLEN" },
|
||||||
|
select: { kurzId: true, anfrage: true },
|
||||||
|
});
|
||||||
|
const anfrage = AusrollAnfrage.safeParse(auftrag?.anfrage);
|
||||||
|
return auftrag && anfrage.success ? { kurzId: auftrag.kurzId, anfrage: anfrage.data } : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
|
||||||
|
type Benutzer = {
|
||||||
|
id: string;
|
||||||
|
benutzername: string;
|
||||||
|
anzeigename: string;
|
||||||
|
email: string | null;
|
||||||
|
rolle: "ADMIN" | "OPERATOR";
|
||||||
|
aktiv: boolean;
|
||||||
|
passwort: boolean;
|
||||||
|
oidc: boolean;
|
||||||
|
tokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Meldung = { art: "erledigt" | "fehler"; text: string; felder?: Record<string, string> } | null;
|
||||||
|
|
||||||
|
const leer = { benutzername: "", anzeigename: "", email: "", rolle: "OPERATOR" as const, passwort: "" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzer anlegen, Rolle ändern, sperren, Passwort neu setzen. Admin: Server, Katalog, Benutzer;
|
||||||
|
* Operator: ansehen und ausrollen. Das eigene Konto lässt sich nicht sperren, der letzte Admin
|
||||||
|
* nicht herabstufen – das prüft der Server.
|
||||||
|
*/
|
||||||
|
export function BenutzerVerwaltung({ anfang, ich }: { anfang: Benutzer[]; ich: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [neu, setNeu] = useState<{
|
||||||
|
benutzername: string;
|
||||||
|
anzeigename: string;
|
||||||
|
email: string;
|
||||||
|
rolle: "ADMIN" | "OPERATOR";
|
||||||
|
passwort: string;
|
||||||
|
}>(leer);
|
||||||
|
const [meldung, setMeldung] = useState<Meldung>(null);
|
||||||
|
const [passwortFuer, setPasswortFuer] = useState<{ id: string; passwort: string } | null>(null);
|
||||||
|
|
||||||
|
const fehlerZeigen = (fehler: unknown) =>
|
||||||
|
setMeldung({
|
||||||
|
art: "fehler",
|
||||||
|
text:
|
||||||
|
fehler instanceof ApiFehler ? [fehler.message, fehler.hinweis].filter(Boolean).join(" ") : "Fehler.",
|
||||||
|
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function aendern(b: Benutzer, aenderung: Partial<Pick<Benutzer, "rolle" | "aktiv">>, text: string) {
|
||||||
|
try {
|
||||||
|
await apiAufruf("PATCH", `/api/v1/benutzer/${b.id}`, aenderung);
|
||||||
|
setMeldung({ art: "erledigt", text });
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlerZeigen(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anlegen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
try {
|
||||||
|
await apiAufruf("POST", "/api/v1/benutzer", neu);
|
||||||
|
setMeldung({ art: "erledigt", text: `${neu.benutzername} angelegt.` });
|
||||||
|
setNeu(leer);
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlerZeigen(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function passwortSetzen(ereignis: React.FormEvent, b: Benutzer) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
if (!passwortFuer) return;
|
||||||
|
try {
|
||||||
|
await apiAufruf("POST", `/api/v1/benutzer/${b.id}/passwort`, { passwort: passwortFuer.passwort });
|
||||||
|
setPasswortFuer(null);
|
||||||
|
setMeldung({
|
||||||
|
art: "erledigt",
|
||||||
|
text: `Passwort von ${b.benutzername} gesetzt – alle Sitzungen beendet.`,
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlerZeigen(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Benutzer">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
|
{anfang.map((b) => (
|
||||||
|
<li key={b.id} className="flex flex-col gap-3 py-3 first:pt-0">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{b.anzeigename} <span className="text-muted font-mono text-sm">· {b.benutzername}</span>
|
||||||
|
{b.benutzername === ich ? <span className="text-muted text-sm"> · du</span> : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{[
|
||||||
|
b.email,
|
||||||
|
b.passwort ? "Passwort" : null,
|
||||||
|
b.oidc ? "OIDC" : null,
|
||||||
|
b.tokens > 0 ? `${b.tokens} API-Token${b.tokens === 1 ? "" : "s"}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "–"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!b.aktiv ? <Status art="fehler">gesperrt</Status> : null}
|
||||||
|
<label className="sr-only" htmlFor={`rolle-${b.id}`}>
|
||||||
|
Rolle von {b.benutzername}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`rolle-${b.id}`}
|
||||||
|
className={`${EINGABE_KLASSE} w-36`}
|
||||||
|
value={b.rolle}
|
||||||
|
onChange={(e) =>
|
||||||
|
void aendern(
|
||||||
|
b,
|
||||||
|
{ rolle: e.target.value as Benutzer["rolle"] },
|
||||||
|
`${b.benutzername} ist jetzt ${e.target.value === "ADMIN" ? "Admin" : "Operator"}.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="OPERATOR">Operator</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
onClick={() =>
|
||||||
|
setPasswortFuer(passwortFuer?.id === b.id ? null : { id: b.id, passwort: "" })
|
||||||
|
}
|
||||||
|
aria-label={`Passwort von ${b.benutzername} setzen`}
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</Knopf>
|
||||||
|
{b.benutzername !== ich ? (
|
||||||
|
<Knopf
|
||||||
|
variante={b.aktiv ? "gefahr" : "sekundaer"}
|
||||||
|
onClick={() =>
|
||||||
|
void aendern(
|
||||||
|
b,
|
||||||
|
{ aktiv: !b.aktiv },
|
||||||
|
b.aktiv
|
||||||
|
? `${b.benutzername} gesperrt – Sitzungen beendet.`
|
||||||
|
: `${b.benutzername} entsperrt.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label={`${b.benutzername} ${b.aktiv ? "sperren" : "entsperren"}`}
|
||||||
|
>
|
||||||
|
{b.aktiv ? "Sperren" : "Entsperren"}
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{passwortFuer?.id === b.id ? (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void passwortSetzen(e, b)}
|
||||||
|
className="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
id={`pw-${b.id}`}
|
||||||
|
label={`Neues Passwort für ${b.benutzername}`}
|
||||||
|
hilfe="Mindestens 12 Zeichen"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`pw-${b.id}`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={`${EINGABE_KLASSE} sm:w-72`}
|
||||||
|
value={passwortFuer.passwort}
|
||||||
|
onChange={(e) => setPasswortFuer({ id: b.id, passwort: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Knopf type="submit" disabled={passwortFuer.passwort.length < 12}>
|
||||||
|
Setzen
|
||||||
|
</Knopf>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void anlegen(e)} className="border-linie flex flex-col gap-4 border-t pt-5">
|
||||||
|
<p className="font-semibold">Neuer Benutzer</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="b-name" label="Benutzername" fehler={meldung?.felder?.["benutzername"]}>
|
||||||
|
<input
|
||||||
|
id="b-name"
|
||||||
|
autoComplete="off"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.benutzername}
|
||||||
|
onChange={(e) => setNeu({ ...neu, benutzername: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="b-anzeige" label="Anzeigename" fehler={meldung?.felder?.["anzeigename"]}>
|
||||||
|
<input
|
||||||
|
id="b-anzeige"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.anzeigename}
|
||||||
|
onChange={(e) => setNeu({ ...neu, anzeigename: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="b-email" label="E-Mail (optional)" fehler={meldung?.felder?.["email"]}>
|
||||||
|
<input
|
||||||
|
id="b-email"
|
||||||
|
type="email"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.email}
|
||||||
|
onChange={(e) => setNeu({ ...neu, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="b-rolle" label="Rolle">
|
||||||
|
<select
|
||||||
|
id="b-rolle"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.rolle}
|
||||||
|
onChange={(e) => setNeu({ ...neu, rolle: e.target.value as Benutzer["rolle"] })}
|
||||||
|
>
|
||||||
|
<option value="OPERATOR">Operator – ansehen und ausrollen</option>
|
||||||
|
<option value="ADMIN">Admin – auch Server, Katalog, Benutzer</option>
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="b-passwort"
|
||||||
|
label="Erstes Passwort"
|
||||||
|
hilfe="Mindestens 12 Zeichen; der Benutzer kann es in den Einstellungen ändern."
|
||||||
|
fehler={meldung?.felder?.["passwort"]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="b-passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.passwort}
|
||||||
|
onChange={(e) => setNeu({ ...neu, passwort: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Knopf type="submit" disabled={!neu.benutzername || !neu.anzeigename || neu.passwort.length < 12}>
|
||||||
|
Benutzer anlegen
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,70 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
import { benutzerListe } from "@/server/services/benutzer";
|
||||||
import { schluesselFuer } from "@/server/services/schluessel";
|
import { schluesselFuer } from "@/server/services/schluessel";
|
||||||
import { tokensVon } from "@/server/services/tokens";
|
import { tokensVon } from "@/server/services/tokens";
|
||||||
|
import { BenutzerVerwaltung } from "./benutzer-verwaltung";
|
||||||
|
import { PasswortAendern } from "./passwort-aendern";
|
||||||
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
||||||
import { TokenVerwaltung } from "./token-verwaltung";
|
import { TokenVerwaltung } from "./token-verwaltung";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
|
|
||||||
/** Einstellungen. Heute: SSH-Schlüssel und CLI-Tokens. Benutzer und Standardwerte folgen mit #015. */
|
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */
|
||||||
export default async function Einstellungen() {
|
export default async function Einstellungen({ searchParams }: { searchParams: Promise<{ sso?: string }> }) {
|
||||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||||
|
const sso = oidcEinstellungen();
|
||||||
|
const ssoFehler = (await searchParams).sso;
|
||||||
|
const admin = darf(benutzer.rolle, "benutzerVerwalten");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||||
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
|
<p className="text-muted text-sm">
|
||||||
|
Angemeldet als {benutzer.benutzername} · {benutzer.rolle === "ADMIN" ? "Admin" : "Operator"}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<SchluesselVerwaltung
|
<SchluesselVerwaltung anfang={await schluesselFuer(benutzer)} teamErlaubt={admin} />
|
||||||
anfang={await schluesselFuer(benutzer)}
|
|
||||||
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
|
||||||
/>
|
|
||||||
<TokenVerwaltung
|
<TokenVerwaltung
|
||||||
anfang={await tokensVon(benutzer.id)}
|
anfang={await tokensVon(benutzer.id)}
|
||||||
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
||||||
/>
|
/>
|
||||||
|
{benutzer.passwortHash ? <PasswortAendern /> : null}
|
||||||
|
{sso ? (
|
||||||
|
<Karte titel="Anmeldung per SSO">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{ssoFehler === "schon_verknuepft" ? (
|
||||||
|
<Status art="fehler">Dieses SSO-Konto gehört schon zu einem anderen Benutzer.</Status>
|
||||||
|
) : null}
|
||||||
|
{benutzer.oidcSubjekt ? (
|
||||||
|
<Status art="erledigt">
|
||||||
|
Mit {sso.anzeigename} verknüpft – die Rolle kommt bei jeder SSO-Anmeldung aus den Gruppen.
|
||||||
|
</Status>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Dein Konto ist noch nicht mit {sso.anzeigename} verknüpft. Danach kannst du dich auch dort
|
||||||
|
anmelden; die Rolle kommt dann aus den Gruppen beim Identity Provider.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/api/v1/auth/oidc/start?verknuepfen=1"
|
||||||
|
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
Mit {sso.anzeigename} verknüpfen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
|
||||||
|
/** Eigenes Passwort ändern. Andere Sitzungen (andere Geräte) enden, diese bleibt angemeldet. */
|
||||||
|
export function PasswortAendern() {
|
||||||
|
const [werte, setWerte] = useState({ alt: "", neu: "", wiederholt: "" });
|
||||||
|
const [meldung, setMeldung] = useState<{
|
||||||
|
art: "erledigt" | "fehler";
|
||||||
|
text: string;
|
||||||
|
felder?: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
const abweichend = werte.wiederholt !== "" && werte.neu !== werte.wiederholt;
|
||||||
|
|
||||||
|
async function speichern(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
try {
|
||||||
|
await apiAufruf("POST", "/api/v1/ich/passwort", { alt: werte.alt, neu: werte.neu });
|
||||||
|
setWerte({ alt: "", neu: "", wiederholt: "" });
|
||||||
|
setMeldung({ art: "erledigt", text: "Passwort geändert. Andere Sitzungen sind beendet." });
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung({
|
||||||
|
art: "fehler",
|
||||||
|
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
|
||||||
|
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Passwort">
|
||||||
|
<form onSubmit={(e) => void speichern(e)} className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld id="pw-alt" label="Bisheriges Passwort" fehler={meldung?.felder?.["alt"]}>
|
||||||
|
<input
|
||||||
|
id="pw-alt"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.alt}
|
||||||
|
onChange={(e) => setWerte({ ...werte, alt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="pw-neu"
|
||||||
|
label="Neues Passwort"
|
||||||
|
hilfe="Mindestens 12 Zeichen"
|
||||||
|
fehler={meldung?.felder?.["neu"]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="pw-neu"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.neu}
|
||||||
|
onChange={(e) => setWerte({ ...werte, neu: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="pw-wieder"
|
||||||
|
label="Neues Passwort wiederholen"
|
||||||
|
fehler={abweichend ? "Stimmt nicht überein." : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="pw-wieder"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.wiederholt}
|
||||||
|
onChange={(e) => setWerte({ ...werte, wiederholt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
type="submit"
|
||||||
|
disabled={!werte.alt || werte.neu.length < 12 || werte.neu !== werte.wiederholt}
|
||||||
|
>
|
||||||
|
Passwort ändern
|
||||||
|
</Knopf>
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Audit-Log" };
|
||||||
|
|
||||||
|
type Suche = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
const QUELLEN = { WEB: "Web", CLI: "CLI", API: "API", SYSTEM: "System" } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log: jede Aktion mit Benutzer, Quelle und Adresse. Nur lesbar – die Datenbank verweigert
|
||||||
|
* Ändern und Löschen per Trigger. Der Filter ist ein schlichtes GET-Formular, der Stand steht in der URL.
|
||||||
|
*/
|
||||||
|
export default async function Protokoll({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||||
|
await seiteSchuetzen("protokollLesen", "/protokoll");
|
||||||
|
const filter = protokollFilterAus(await searchParams);
|
||||||
|
const [{ eintraege, gesamt, seite, seiten }, benutzer] = await Promise.all([
|
||||||
|
protokollSuchen(filter),
|
||||||
|
prisma.benutzer.findMany({ select: { benutzername: true }, orderBy: { benutzername: "asc" } }),
|
||||||
|
]);
|
||||||
|
const seitenLink = (ziel: number) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
|
||||||
|
if (ziel > 1) p.set("seite", String(ziel));
|
||||||
|
return `/protokoll${p.size > 0 ? `?${p.toString()}` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Audit-Log</h1>
|
||||||
|
<p className="text-muted text-sm">{gesamt} Einträge</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
action="/protokoll"
|
||||||
|
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:flex lg:items-end"
|
||||||
|
>
|
||||||
|
<Feld id="p-suche" label="Suche" hilfe="Aktion oder Ziel">
|
||||||
|
<input
|
||||||
|
id="p-suche"
|
||||||
|
name="suche"
|
||||||
|
defaultValue={filter.suche ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-48`}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-benutzer" label="Benutzer">
|
||||||
|
<select
|
||||||
|
id="p-benutzer"
|
||||||
|
name="benutzer"
|
||||||
|
defaultValue={filter.benutzer ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||||
|
>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{benutzer.map((b) => (
|
||||||
|
<option key={b.benutzername}>{b.benutzername}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-quelle" label="Quelle">
|
||||||
|
<select
|
||||||
|
id="p-quelle"
|
||||||
|
name="quelle"
|
||||||
|
defaultValue={filter.quelle ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-32`}
|
||||||
|
>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{Object.entries(QUELLEN).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-von" label="Von">
|
||||||
|
<input
|
||||||
|
id="p-von"
|
||||||
|
name="von"
|
||||||
|
type="date"
|
||||||
|
defaultValue={filter.von ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-bis" label="Bis">
|
||||||
|
<input
|
||||||
|
id="p-bis"
|
||||||
|
name="bis"
|
||||||
|
type="date"
|
||||||
|
defaultValue={filter.bis ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold hover:opacity-90 sm:h-10"
|
||||||
|
>
|
||||||
|
Filtern
|
||||||
|
</button>
|
||||||
|
<Link href="/protokoll" className="text-muted hover:text-text text-sm">
|
||||||
|
Zurücksetzen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Karte>
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<Status art="offen">Keine Einträge.</Status>
|
||||||
|
) : (
|
||||||
|
<ol className="divide-linie flex flex-col divide-y" aria-label="Einträge">
|
||||||
|
{eintraege.map((e) => (
|
||||||
|
<li key={e.id} className="flex flex-col gap-1 py-3 first:pt-0 sm:flex-row sm:gap-4">
|
||||||
|
<time className="text-muted shrink-0 font-mono text-xs sm:w-36 sm:pt-0.5" dateTime={e.zeit}>
|
||||||
|
{new Date(e.zeit).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" })}
|
||||||
|
</time>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">{e.aktion}</span>
|
||||||
|
{e.ziel ? (
|
||||||
|
<span className="text-muted font-mono text-sm break-all"> · {e.ziel}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{e.benutzer ?? "–"} · {QUELLEN[e.quelle]}
|
||||||
|
{e.ip ? ` · ${e.ip}` : ""}
|
||||||
|
</p>
|
||||||
|
{e.details ? (
|
||||||
|
<details className="mt-1">
|
||||||
|
<summary className="text-muted cursor-pointer text-xs">Details</summary>
|
||||||
|
<pre className="bg-grund border-linie mt-1 overflow-x-auto rounded-lg border p-2 font-mono text-xs">
|
||||||
|
{JSON.stringify(e.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{seiten > 1 ? (
|
||||||
|
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
{seite > 1 ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||||
|
← Neuere
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="text-muted">
|
||||||
|
Seite {seite} von {seiten}
|
||||||
|
</span>
|
||||||
|
{seite < seiten ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||||
|
Ältere →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
||||||
|
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
||||||
import { Anmeldeformular } from "./anmeldeformular";
|
import { Anmeldeformular } from "./anmeldeformular";
|
||||||
|
|
||||||
@@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string {
|
|||||||
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SSO_MELDUNGEN: Record<string, string> = {
|
||||||
|
abgelaufen: "Die SSO-Anmeldung hat zu lange gedauert. Bitte noch einmal.",
|
||||||
|
idp: "Der Identity Provider hat die Anmeldung nicht bestätigt.",
|
||||||
|
keine_gruppe: "Dein SSO-Konto ist für ProxDeploy nicht freigegeben – es fehlt die Gruppe.",
|
||||||
|
gesperrt: "Dein Konto ist gesperrt.",
|
||||||
|
name_vergeben: "Für dein SSO-Konto ließ sich kein Benutzername vergeben.",
|
||||||
|
aus: "Die Anmeldung per SSO ist nicht eingerichtet.",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function Anmelden({
|
export default async function Anmelden({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | string[]>>;
|
searchParams: Promise<Record<string, string | string[]>>;
|
||||||
}) {
|
}) {
|
||||||
const ziel = sicheresZiel((await searchParams)["ziel"]);
|
const parameter = await searchParams;
|
||||||
|
const ziel = sicheresZiel(parameter["ziel"]);
|
||||||
|
const sso = oidcEinstellungen();
|
||||||
|
const ssoFehler =
|
||||||
|
typeof parameter["sso"] === "string" ? (SSO_MELDUNGEN[parameter["sso"]] ?? SSO_MELDUNGEN["idp"]) : null;
|
||||||
if (await aktuellerBenutzer()) redirect(ziel);
|
if (await aktuellerBenutzer()) redirect(ziel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +44,27 @@ export default async function Anmelden({
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
||||||
</div>
|
</div>
|
||||||
|
{ssoFehler ? (
|
||||||
|
<Status art="fehler" className="mb-4 text-sm">
|
||||||
|
{ssoFehler}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
<Anmeldeformular ziel={ziel} />
|
<Anmeldeformular ziel={ziel} />
|
||||||
|
{sso ? (
|
||||||
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
|
<div className="text-muted flex items-center gap-3 text-xs" aria-hidden="true">
|
||||||
|
<span className="bg-linie h-px flex-1" />
|
||||||
|
oder
|
||||||
|
<span className="bg-linie h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/api/v1/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
|
||||||
|
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center justify-center rounded-lg border px-4 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Mit {sso.anzeigename} anmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { aufraeumenStarten } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen → neuer Auftrag (202). */
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||||
|
const auftrag = await aufraeumenStarten((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
|
||||||
|
});
|
||||||
@@ -3,12 +3,13 @@ import { aufruferAdresse } from "@/lib/adressen";
|
|||||||
import { api, jsonLesen } from "@/server/api";
|
import { api, jsonLesen } from "@/server/api";
|
||||||
import { ausrollenStarten } from "@/server/services/ausrollen";
|
import { ausrollenStarten } from "@/server/services/ausrollen";
|
||||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
import { auftraegeListe } from "@/server/services/auftraege";
|
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
|
||||||
|
|
||||||
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
|
/** Verlauf: `?server=&typ=vm|lxc|template|aufraeumen&distribution=&status=&seite=`. */
|
||||||
export const GET = api(async (anfrage: Request) => {
|
export const GET = api(async (anfrage: Request) => {
|
||||||
await rechtErforderlich(anfrage, "lesen");
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
return NextResponse.json(await auftraegeListe());
|
const filter = verlaufFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
return NextResponse.json(await auftraegeSuchen(filter));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse, istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import {
|
||||||
|
OIDC_COOKIE,
|
||||||
|
oidcAbschliessen,
|
||||||
|
oidcEinstellungen,
|
||||||
|
OidcFehler,
|
||||||
|
RUECKRUF_PFAD,
|
||||||
|
} from "@/server/auth/oidc";
|
||||||
|
|
||||||
|
function cookieLesen(kopf: string | null, name: string): string | undefined {
|
||||||
|
for (const teil of (kopf ?? "").split(";")) {
|
||||||
|
const [schluessel, ...wert] = teil.trim().split("=");
|
||||||
|
if (schluessel === name) return decodeURIComponent(wert.join("="));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlich: Rückkehr vom Identity Provider. Erfolg → Sitzungscookie und Weiterleitung zum Ziel;
|
||||||
|
* Fehler → Anmeldeseite mit Code (`?sso=keine_gruppe` …), nie eine Fehlerseite mit Interna.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: Request): Promise<Response> {
|
||||||
|
const herkunft = new URL(anfrage.url).origin;
|
||||||
|
const e = oidcEinstellungen();
|
||||||
|
const zurAnmeldung = (code: string) =>
|
||||||
|
NextResponse.redirect(oeffentlicheAdresse(`/anmelden?sso=${code}`, herkunft), 302);
|
||||||
|
if (!e) return zurAnmeldung("aus");
|
||||||
|
|
||||||
|
// Die öffentliche Adresse: genau die, die als redirect_uri beim IdP registriert ist.
|
||||||
|
const aktuell = oeffentlicheAdresse(RUECKRUF_PFAD + new URL(anfrage.url).search, herkunft);
|
||||||
|
let antwort: NextResponse;
|
||||||
|
try {
|
||||||
|
const { benutzer, ziel } = await oidcAbschliessen(
|
||||||
|
e,
|
||||||
|
aktuell,
|
||||||
|
cookieLesen(anfrage.headers.get("cookie"), OIDC_COOKIE),
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
antwort = NextResponse.redirect(oeffentlicheAdresse(ziel, herkunft), 302);
|
||||||
|
antwort.cookies.set(
|
||||||
|
SITZUNG_COOKIE,
|
||||||
|
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "oidc" }),
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: istHttps(anfrage.headers, anfrage.url),
|
||||||
|
path: "/",
|
||||||
|
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (!(fehler instanceof OidcFehler)) logger.error({ err: fehler }, "OIDC-Rückruf fehlgeschlagen");
|
||||||
|
const code = fehler instanceof OidcFehler ? fehler.code : "idp";
|
||||||
|
// Beim Verknüpfen ist man schon angemeldet – die Meldung gehört dann in die Einstellungen.
|
||||||
|
antwort =
|
||||||
|
code === "schon_verknuepft"
|
||||||
|
? NextResponse.redirect(oeffentlicheAdresse(`/einstellungen?sso=${code}`, herkunft), 302)
|
||||||
|
: zurAnmeldung(code);
|
||||||
|
}
|
||||||
|
antwort.cookies.set(OIDC_COOKIE, "", { path: "/api/v1/auth/oidc", maxAge: 0 });
|
||||||
|
return antwort;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { anfrageAufrufer } from "@/server/auth/zugang";
|
||||||
|
import { OIDC_COOKIE, oidcEinstellungen, oidcStarten, RUECKRUF_PFAD } from "@/server/auth/oidc";
|
||||||
|
import { AnwendungsFehler, nichtAngemeldet } from "@/server/fehler";
|
||||||
|
|
||||||
|
/** Nur relative Ziele – sonst wäre `?ziel=` eine offene Weiterleitung. */
|
||||||
|
function sicheresZiel(ziel: string | null): string {
|
||||||
|
return ziel && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlich: leitet zum Identity Provider. `?verknuepfen=1` (angemeldet) hängt das SSO-Konto an
|
||||||
|
* das eigene lokale Konto statt anzumelden.
|
||||||
|
*/
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
const e = oidcEinstellungen();
|
||||||
|
if (!e) throw new AnwendungsFehler(404, "oidc_aus", "Die Anmeldung per SSO ist nicht eingerichtet.");
|
||||||
|
const abfrage = new URL(anfrage.url).searchParams;
|
||||||
|
let verknuepfen: string | null = null;
|
||||||
|
if (abfrage.get("verknuepfen") === "1") {
|
||||||
|
const aufrufer = await anfrageAufrufer(anfrage);
|
||||||
|
if (!aufrufer) throw nichtAngemeldet();
|
||||||
|
verknuepfen = aufrufer.benutzer.id;
|
||||||
|
}
|
||||||
|
const herkunft = new URL(anfrage.url).origin;
|
||||||
|
const { weiter, cookie } = await oidcStarten(
|
||||||
|
e,
|
||||||
|
oeffentlicheAdresse(RUECKRUF_PFAD, herkunft),
|
||||||
|
sicheresZiel(abfrage.get("ziel")),
|
||||||
|
verknuepfen,
|
||||||
|
);
|
||||||
|
const antwort = NextResponse.redirect(weiter, 302);
|
||||||
|
antwort.cookies.set(OIDC_COOKIE, cookie, {
|
||||||
|
httpOnly: true,
|
||||||
|
// Lax: der Rückruf ist eine Top-Level-Navigation vom IdP zurück, da kommt das Cookie mit.
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: istHttps(anfrage.headers, anfrage.url),
|
||||||
|
path: "/api/v1/auth/oidc",
|
||||||
|
maxAge: 600,
|
||||||
|
});
|
||||||
|
return antwort;
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { passwortZuruecksetzen } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Admin setzt ein neues Passwort `{ passwort }`; alle Sitzungen des Benutzers enden. */
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
await passwortZuruecksetzen(
|
||||||
|
(await params).id,
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { benutzerAendern } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** `{ anzeigename?, email?, rolle?, aktiv? }` – Sperren beendet alle Sitzungen. */
|
||||||
|
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
return NextResponse.json(
|
||||||
|
await benutzerAendern(
|
||||||
|
(await params).id,
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { benutzerAnlegen, benutzerListe } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
return NextResponse.json(await benutzerListe());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `{ benutzername, anzeigename, email?, rolle, passwort }` */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
return NextResponse.json(
|
||||||
|
await benutzerAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse, istHttps } from "@/lib/adressen";
|
||||||
|
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { eigenesPasswortAendern } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenes Passwort ändern `{ alt, neu }`. Alle anderen Sitzungen enden; die aufrufende Web-Sitzung
|
||||||
|
* bekommt ein frisches Cookie, damit man angemeldet bleibt.
|
||||||
|
*/
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||||
|
await eigenesPasswortAendern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
const antwort = new NextResponse(null, { status: 204 });
|
||||||
|
if (aufrufer.quelle === "WEB") {
|
||||||
|
antwort.cookies.set(
|
||||||
|
SITZUNG_COOKIE,
|
||||||
|
await sitzungErzeugen({ benutzerId: aufrufer.benutzer.id, herkunft: "passwort" }),
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: istHttps(anfrage.headers, anfrage.url),
|
||||||
|
path: "/",
|
||||||
|
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return antwort;
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/** Audit-Log, neueste zuerst: `?benutzer=&quelle=&suche=&von=YYYY-MM-DD&bis=YYYY-MM-DD&seite=`. Nur Admins. */
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "protokollLesen");
|
||||||
|
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
return NextResponse.json(await protokollSuchen(filter));
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -33,6 +34,7 @@ const SYMBOLE: Record<
|
|||||||
vorlagen: FileStack,
|
vorlagen: FileStack,
|
||||||
katalog: BookOpen,
|
katalog: BookOpen,
|
||||||
server: Server,
|
server: Server,
|
||||||
|
protokoll: ScrollText,
|
||||||
einstellungen: Settings,
|
einstellungen: Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
|
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufräumen nach einem fehlgeschlagenen oder abgebrochenen Auftrag: den halb angelegten Gast
|
||||||
|
* stoppen und löschen. Gelöscht wird nur, was **nachweislich zu diesem Auftrag gehört** – die
|
||||||
|
* Beschreibung trägt „(Auftrag <kurzId>)“, gesetzt schon beim Anlegen bzw. Klonen. Templates
|
||||||
|
* werden nie gelöscht. Passt etwas nicht, bricht der Ablauf ab, ohne etwas anzufassen.
|
||||||
|
*/
|
||||||
|
export type AufraeumEingabe = {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
typ: "VM" | "LXC";
|
||||||
|
vmid: number;
|
||||||
|
/** Node laut Auftrag; liegt der Gast inzwischen woanders (Migration), gilt der tatsächliche. */
|
||||||
|
knoten: string | null;
|
||||||
|
/** Kurz-ID des Auftrags, der den Gast angelegt hat. */
|
||||||
|
auftragKurzId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AufraeumStand = { knoten?: string; vorhanden?: boolean; laeuft?: boolean; name?: string };
|
||||||
|
|
||||||
|
type Ressource = {
|
||||||
|
vmid?: number;
|
||||||
|
node?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
name?: string;
|
||||||
|
template?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function aufraeumSchritte(e: AufraeumEingabe, stand: AufraeumStand = {}): PhasenSchritt[] {
|
||||||
|
const { client } = e;
|
||||||
|
const art = e.typ === "LXC" ? "lxc" : "qemu";
|
||||||
|
const wort = e.typ === "LXC" ? "Container" : "VM";
|
||||||
|
const gastPfad = () => pfad`/nodes/${stand.knoten!}/${art}/${e.vmid}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
schluessel: "pruefungen",
|
||||||
|
titel: "Prüfungen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
const ressourcen = await client.get<Ressource[]>("/cluster/resources", { type: "vm" });
|
||||||
|
const gast = ressourcen.find((r) => r.vmid === e.vmid);
|
||||||
|
if (!gast) {
|
||||||
|
stand.vorhanden = false;
|
||||||
|
return { detail: `${wort} ${e.vmid} gibt es nicht mehr – nichts zu löschen` };
|
||||||
|
}
|
||||||
|
if (gast.type !== art) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Unter ${e.vmid} liegt kein ${wort}, sondern ${gast.type === "lxc" ? "ein Container" : "eine VM"}.`,
|
||||||
|
"Nichts gelöscht. Die ID wurde inzwischen anders vergeben – von Hand prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stand.knoten = gast.node!;
|
||||||
|
const konfig = await client.get<Record<string, unknown>>(`${gastPfad()}/config`);
|
||||||
|
if (gast.template === 1 || konfig["template"] === 1) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${e.vmid} ist ein Template.`,
|
||||||
|
"Templates räumt ProxDeploy nie auf. Unter „Templates“ bzw. in Proxmox entfernen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const beschreibung = String(konfig["description"] ?? "");
|
||||||
|
if (!beschreibung.includes(`(Auftrag ${e.auftragKurzId})`)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${wort} ${e.vmid} gehört nicht erkennbar zu Auftrag ${e.auftragKurzId}.`,
|
||||||
|
"Nichts gelöscht. In Proxmox prüfen und von Hand entfernen, falls es der halb angelegte Gast ist.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (konfig["lock"]) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${wort} ${e.vmid} ist gesperrt (${String(konfig["lock"])}).`,
|
||||||
|
`Läuft dort noch eine Aufgabe? Sonst auf dem Node „${e.typ === "LXC" ? "pct" : "qm"} unlock ${e.vmid}“ und erneut aufräumen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stand.vorhanden = true;
|
||||||
|
stand.laeuft = gast.status === "running";
|
||||||
|
stand.name = gast.name ?? String(konfig["name"] ?? konfig["hostname"] ?? "");
|
||||||
|
return {
|
||||||
|
detail: `${wort} ${e.vmid}${stand.name ? ` (${stand.name})` : ""} auf ${stand.knoten} gehört zu ${e.auftragKurzId}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "stoppen",
|
||||||
|
titel: "Stoppen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
|
||||||
|
if (!stand.laeuft) return { uebersprungen: true, detail: "läuft nicht" };
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(`${gastPfad()}/status/stop`), {
|
||||||
|
zeitlimitMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return { detail: `${wort} ${e.vmid} gestoppt` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "loeschen",
|
||||||
|
titel: "Löschen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.delete<string>(gastPfad(), { "destroy-unreferenced-disks": true }),
|
||||||
|
{ zeitlimitMs: 15 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
return { detail: `${wort} ${e.vmid} mit Disks gelöscht` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -92,6 +92,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
const { client, anfrage: a, distribution: d } = e;
|
const { client, anfrage: a, distribution: d } = e;
|
||||||
const warten = e.warten ?? schlafen;
|
const warten = e.warten ?? schlafen;
|
||||||
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||||
|
const beschreibung = () =>
|
||||||
|
`Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`;
|
||||||
const templateStand: TemplateBauStand = {};
|
const templateStand: TemplateBauStand = {};
|
||||||
|
|
||||||
const schritte: PhasenSchritt[] = [
|
const schritte: PhasenSchritt[] = [
|
||||||
@@ -238,6 +240,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
||||||
newid: vmid,
|
newid: vmid,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
// Schon beim Klonen: daran erkennt „Aufräumen“, dass die VM zu diesem Auftrag gehört.
|
||||||
|
description: beschreibung(),
|
||||||
full: true,
|
full: true,
|
||||||
target: stand.knoten!,
|
target: stand.knoten!,
|
||||||
storage: a.storage,
|
storage: a.storage,
|
||||||
@@ -284,7 +288,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
||||||
onboot: a.startBeiBoot,
|
onboot: a.startBeiBoot,
|
||||||
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
||||||
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
|
description: beschreibung(),
|
||||||
});
|
});
|
||||||
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
||||||
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import ssh2 from "ssh2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein ed25519-Schlüsselpaar im OpenSSH-Format, das ssh2 auch selbst wieder lesen kann.
|
||||||
|
*
|
||||||
|
* `ssh2.utils.generateKeyPairSync("ed25519")` liefert in etwa 4 von 1000 Fällen einen privaten
|
||||||
|
* Schlüssel, an dem der eigene Parser mit „Malformed OpenSSH private key“ scheitert (gemessen mit
|
||||||
|
* ssh2 1.17.0). Gespeichert wäre so ein Server-Schlüssel dauerhaft unbrauchbar – deshalb wird
|
||||||
|
* geprüft und notfalls neu erzeugt.
|
||||||
|
*/
|
||||||
|
export function ed25519Erzeugen(kommentar?: string): { privat: string; oeffentlich: string } {
|
||||||
|
for (let versuch = 0; versuch < 20; versuch += 1) {
|
||||||
|
const paar = ssh2.utils.generateKeyPairSync("ed25519", kommentar ? { comment: kommentar } : {});
|
||||||
|
if (!(ssh2.utils.parseKey(paar.private) instanceof Error)) {
|
||||||
|
return { privat: paar.private, oeffentlich: paar.public.trim() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Kein lesbarer ed25519-Schlüssel erzeugt.");
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const ENDE = new Set<AuftragSicht["status"]>(["ERFOLGREICH", "FEHLGESCHLAGEN", "
|
|||||||
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
|
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
|
||||||
* und CLI dieselben Zeilen zeigen.
|
* und CLI dieselben Zeilen zeigen.
|
||||||
*/
|
*/
|
||||||
export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | null {
|
export function abschlussAus(a: Omit<AuftragSicht, "abschluss" | "aufraeumbar">): Abschluss | null {
|
||||||
if (!ENDE.has(a.status)) return null;
|
if (!ENDE.has(a.status)) return null;
|
||||||
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||||
const gesamt =
|
const gesamt =
|
||||||
@@ -53,5 +53,10 @@ export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | nu
|
|||||||
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
||||||
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
||||||
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
||||||
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
return {
|
||||||
|
ok: true,
|
||||||
|
titel: `${name} ${a.art === "AUFRAEUMEN" ? "gelöscht" : "bereit"}`,
|
||||||
|
dauer: gesamt,
|
||||||
|
zeilen,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export type NavigationsEintrag = {
|
|||||||
| "vorlagen"
|
| "vorlagen"
|
||||||
| "katalog"
|
| "katalog"
|
||||||
| "server"
|
| "server"
|
||||||
|
| "protokoll"
|
||||||
| "einstellungen";
|
| "einstellungen";
|
||||||
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
|
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge" | "protokollLesen";
|
||||||
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
||||||
ab?: string;
|
ab?: string;
|
||||||
};
|
};
|
||||||
@@ -28,13 +29,8 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
|||||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
||||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||||
{
|
{ pfad: "/protokoll", titel: "Audit-Log", symbol: "protokoll", recht: "protokollLesen" },
|
||||||
pfad: "/einstellungen",
|
{ pfad: "/einstellungen", titel: "Einstellungen", symbol: "einstellungen", recht: "eigeneZugaenge" },
|
||||||
titel: "Einstellungen",
|
|
||||||
symbol: "einstellungen",
|
|
||||||
recht: "eigeneZugaenge",
|
|
||||||
ab: "#015",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ export const AuftragSicht = z.looseObject({
|
|||||||
gestartetAm: Zeit.nullable(),
|
gestartetAm: Zeit.nullable(),
|
||||||
beendetAm: Zeit.nullable(),
|
beendetAm: Zeit.nullable(),
|
||||||
abschluss: Abschluss.nullable(),
|
abschluss: Abschluss.nullable(),
|
||||||
|
aufraeumbar: z
|
||||||
|
.looseObject({ typ: z.enum(["VM", "LXC"]), vmid: z.number().int(), knoten: z.string().nullable() })
|
||||||
|
.nullable(),
|
||||||
phasen: z.array(PhaseSicht),
|
phasen: z.array(PhaseSicht),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,3 +193,10 @@ export const Plan = z.looseObject({
|
|||||||
automatisch: z.array(z.string()),
|
automatisch: z.array(z.string()),
|
||||||
ok: z.boolean(),
|
ok: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Verlauf = z.looseObject({
|
||||||
|
eintraege: z.array(AuftragSicht),
|
||||||
|
gesamt: z.number().int(),
|
||||||
|
seite: z.number().int(),
|
||||||
|
seiten: z.number().int(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type AuftragSicht = {
|
|||||||
beendetAm: string | null;
|
beendetAm: string | null;
|
||||||
/** Abschlussbox, sobald der Auftrag fertig ist. */
|
/** Abschlussbox, sobald der Auftrag fertig ist. */
|
||||||
abschluss: Abschluss | null;
|
abschluss: Abschluss | null;
|
||||||
|
/** Bei Fehlschlag/Abbruch: was „Aufräumen“ löschen würde. */
|
||||||
|
aufraeumbar: { typ: "VM" | "LXC"; vmid: number; knoten: string | null } | null;
|
||||||
phasen: Array<{
|
phasen: Array<{
|
||||||
schluessel: string;
|
schluessel: string;
|
||||||
titel: string;
|
titel: string;
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||||
*/
|
*/
|
||||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.4.0";
|
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.5.0";
|
||||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||||
|
|
||||||
export function versionText(): string {
|
export function versionText(): string {
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { jwtVerify, SignJWT } from "jose";
|
||||||
|
import * as client from "openid-client";
|
||||||
|
import type { Benutzer, Rolle } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung per OpenID Connect (z. B. authentik): Authorization Code Flow mit PKCE, State und
|
||||||
|
* Nonce. Die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim – der IdP ist maßgeblich.
|
||||||
|
*
|
||||||
|
* Konten: per `oidcSubjekt` (sub) zugeordnet. Ein lokales Konto wird **nie automatisch** über den
|
||||||
|
* Benutzernamen übernommen – sonst könnte, wer beim IdP „admin“ heißt, den lokalen Admin
|
||||||
|
* übernehmen. Verknüpfen geht nur angemeldet, aus den Einstellungen heraus.
|
||||||
|
*/
|
||||||
|
export type OidcEinstellungen = {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
anzeigename: string;
|
||||||
|
gruppenClaim: string;
|
||||||
|
gruppeAdmin: string;
|
||||||
|
gruppeOperator: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function oidcEinstellungen(
|
||||||
|
umgebung: Record<string, string | undefined> = process.env,
|
||||||
|
): OidcEinstellungen | null {
|
||||||
|
if (umgebung["OIDC_AKTIV"] !== "true") return null;
|
||||||
|
const issuer = umgebung["OIDC_ISSUER"]?.trim();
|
||||||
|
const clientId = umgebung["OIDC_CLIENT_ID"]?.trim();
|
||||||
|
if (!issuer || !clientId) {
|
||||||
|
logger.warn("OIDC_AKTIV=true, aber OIDC_ISSUER oder OIDC_CLIENT_ID fehlt – OIDC bleibt aus.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
clientId,
|
||||||
|
clientSecret: umgebung["OIDC_CLIENT_SECRET"] ?? "",
|
||||||
|
anzeigename: umgebung["OIDC_ANZEIGENAME"]?.trim() || "SSO",
|
||||||
|
gruppenClaim: umgebung["OIDC_GRUPPEN_CLAIM"]?.trim() || "groups",
|
||||||
|
gruppeAdmin: umgebung["OIDC_GRUPPE_ADMIN"]?.trim() ?? "",
|
||||||
|
gruppeOperator: umgebung["OIDC_GRUPPE_OPERATOR"]?.trim() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let konfiguration: { schluessel: string; wert: Promise<client.Configuration> } | undefined;
|
||||||
|
|
||||||
|
/** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */
|
||||||
|
async function konfigurationHolen(e: OidcEinstellungen): Promise<client.Configuration> {
|
||||||
|
const schluessel = `${e.issuer}|${e.clientId}`;
|
||||||
|
if (konfiguration?.schluessel !== schluessel) {
|
||||||
|
const url = new URL(e.issuer);
|
||||||
|
const wert = client.discovery(
|
||||||
|
url,
|
||||||
|
e.clientId,
|
||||||
|
e.clientSecret || undefined,
|
||||||
|
e.clientSecret ? undefined : client.None(),
|
||||||
|
// http nur für Tests und Labore auf localhost – sonst verlangt openid-client zu Recht https.
|
||||||
|
url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
|
||||||
|
? { execute: [client.allowInsecureRequests] }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
konfiguration = { schluessel, wert };
|
||||||
|
wert.catch(() => {
|
||||||
|
if (konfiguration?.wert === wert) konfiguration = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await konfiguration.wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests. */
|
||||||
|
export function oidcZwischenspeicherLeeren(): void {
|
||||||
|
konfiguration = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zustand zwischen Start und Rückruf: kurzlebiges, signiertes Cookie ───────────────────────────
|
||||||
|
|
||||||
|
export const OIDC_COOKIE = "pd_oidc";
|
||||||
|
const OIDC_LAUFZEIT_S = 600;
|
||||||
|
|
||||||
|
type Ablauf = { verifier: string; state: string; nonce: string; ziel: string; verknuepfen: string | null };
|
||||||
|
|
||||||
|
function geheimnis(): Uint8Array {
|
||||||
|
return new TextEncoder().encode(process.env["SITZUNG_GEHEIMNIS"] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUECKRUF_PFAD = "/api/v1/auth/oidc/rueckruf";
|
||||||
|
|
||||||
|
/** Startet die Anmeldung: Weiterleitungsziel beim IdP und das Cookie für den Rückruf. */
|
||||||
|
export async function oidcStarten(
|
||||||
|
e: OidcEinstellungen,
|
||||||
|
rueckruf: URL,
|
||||||
|
ziel: string,
|
||||||
|
verknuepfen: string | null,
|
||||||
|
): Promise<{ weiter: URL; cookie: string }> {
|
||||||
|
const config = await konfigurationHolen(e);
|
||||||
|
const verifier = client.randomPKCECodeVerifier();
|
||||||
|
const ablauf: Ablauf = {
|
||||||
|
verifier,
|
||||||
|
state: client.randomState(),
|
||||||
|
nonce: client.randomNonce(),
|
||||||
|
ziel,
|
||||||
|
verknuepfen,
|
||||||
|
};
|
||||||
|
const weiter = client.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: rueckruf.href,
|
||||||
|
scope: "openid profile email",
|
||||||
|
code_challenge: await client.calculatePKCECodeChallenge(verifier),
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: ablauf.state,
|
||||||
|
nonce: ablauf.nonce,
|
||||||
|
});
|
||||||
|
const cookie = await new SignJWT(ablauf)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setAudience("proxdeploy-oidc")
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${OIDC_LAUFZEIT_S}s`)
|
||||||
|
.sign(geheimnis());
|
||||||
|
return { weiter, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OidcFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: "abgelaufen" | "idp" | "keine_gruppe" | "gesperrt" | "name_vergeben" | "schon_verknuepft",
|
||||||
|
nachricht: string,
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "OidcFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ansprueche = Record<string, unknown> & { sub: string };
|
||||||
|
|
||||||
|
export function rolleAusGruppen(e: OidcEinstellungen, ansprueche: Ansprueche): Rolle | null {
|
||||||
|
const roh = ansprueche[e.gruppenClaim];
|
||||||
|
const gruppen = Array.isArray(roh) ? roh.map(String) : typeof roh === "string" ? [roh] : [];
|
||||||
|
if (e.gruppeAdmin && gruppen.includes(e.gruppeAdmin)) return "ADMIN";
|
||||||
|
if (e.gruppeOperator && gruppen.includes(e.gruppeOperator)) return "OPERATOR";
|
||||||
|
// Ohne konfigurierte Gruppen genügt die Anmeldung beim IdP für „Operator“.
|
||||||
|
if (!e.gruppeAdmin && !e.gruppeOperator) return "OPERATOR";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aus preferred_username/E-Mail einen gültigen, freien Benutzernamen machen. */
|
||||||
|
async function freierName(ansprueche: Ansprueche): Promise<string> {
|
||||||
|
const quelle = String(
|
||||||
|
ansprueche["preferred_username"] ?? String(ansprueche["email"] ?? "").split("@")[0] ?? "",
|
||||||
|
);
|
||||||
|
let basis = quelle
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9._-]/g, "-")
|
||||||
|
.replace(/^[^a-z]+/, "")
|
||||||
|
.slice(0, 28);
|
||||||
|
if (basis.length < 2) basis = "sso-benutzer";
|
||||||
|
for (let n = 0; n < 50; n++) {
|
||||||
|
const name = n === 0 ? basis : `${basis}-${n + 1}`;
|
||||||
|
if (!(await prisma.benutzer.findUnique({ where: { benutzername: name } }))) return name;
|
||||||
|
}
|
||||||
|
throw new OidcFehler("name_vergeben", "Kein freier Benutzername gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückruf vom IdP: Code tauschen, ID-Token prüfen (Signatur, Issuer, Audience, Nonce), Konto
|
||||||
|
* finden, anlegen oder verknüpfen, Rolle aus den Gruppen setzen.
|
||||||
|
*/
|
||||||
|
export async function oidcAbschliessen(
|
||||||
|
e: OidcEinstellungen,
|
||||||
|
aktuelleUrl: URL,
|
||||||
|
cookie: string | undefined,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<{ benutzer: Benutzer; ziel: string }> {
|
||||||
|
let ablauf: Ablauf;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(cookie ?? "", geheimnis(), {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
audience: "proxdeploy-oidc",
|
||||||
|
});
|
||||||
|
ablauf = payload as unknown as Ablauf;
|
||||||
|
} catch {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"abgelaufen",
|
||||||
|
"Die Anmeldung hat zu lange gedauert oder kam nicht von hier. Bitte neu starten.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await konfigurationHolen(e);
|
||||||
|
let ansprueche: Ansprueche;
|
||||||
|
try {
|
||||||
|
const antwort = await client.authorizationCodeGrant(config, aktuelleUrl, {
|
||||||
|
pkceCodeVerifier: ablauf.verifier,
|
||||||
|
expectedState: ablauf.state,
|
||||||
|
expectedNonce: ablauf.nonce,
|
||||||
|
idTokenExpected: true,
|
||||||
|
});
|
||||||
|
ansprueche = antwort.claims() as Ansprueche;
|
||||||
|
} catch (fehler) {
|
||||||
|
logger.warn({ err: fehler }, "OIDC-Rückruf abgelehnt");
|
||||||
|
throw new OidcFehler("idp", "Der Identity Provider hat die Anmeldung nicht bestätigt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = rolleAusGruppen(e, ansprueche);
|
||||||
|
const anzeigename = String(ansprueche["name"] ?? ansprueche["preferred_username"] ?? "SSO-Benutzer").slice(
|
||||||
|
0,
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
const email = typeof ansprueche["email"] === "string" ? ansprueche["email"] : null;
|
||||||
|
|
||||||
|
// Verknüpfen: ein angemeldeter lokaler Benutzer hängt sein IdP-Konto an.
|
||||||
|
if (ablauf.verknuepfen) {
|
||||||
|
const vorhanden = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||||
|
if (vorhanden && vorhanden.id !== ablauf.verknuepfen) {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"schon_verknuepft",
|
||||||
|
`Dieses SSO-Konto gehört schon zu „${vorhanden.benutzername}“.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const benutzer = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.update({
|
||||||
|
where: { id: ablauf.verknuepfen! },
|
||||||
|
data: { oidcSubjekt: ansprueche.sub },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: b.id,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "SSO-Konto verknüpft",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return { benutzer, ziel: "/einstellungen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rolle) {
|
||||||
|
await protokollieren({
|
||||||
|
benutzerId: null,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "SSO-Anmeldung ohne passende Gruppe",
|
||||||
|
ziel: `oidc:${ansprueche.sub}`,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
throw new OidcFehler(
|
||||||
|
"keine_gruppe",
|
||||||
|
"Dein SSO-Konto ist für ProxDeploy nicht freigegeben (Gruppe fehlt).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bekannt = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||||
|
if (bekannt) {
|
||||||
|
if (!bekannt.aktiv) throw new OidcFehler("gesperrt", "Dein Konto ist gesperrt.");
|
||||||
|
const benutzer = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.update({
|
||||||
|
where: { id: bekannt.id },
|
||||||
|
data: { rolle, anzeigename, ...(email ? { email } : {}) },
|
||||||
|
});
|
||||||
|
if (bekannt.rolle !== rolle) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: b.id,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "Rolle aus SSO-Gruppen übernommen",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
details: { rolle: [bekannt.rolle, rolle] },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await protokollieren({ benutzerId: b.id, quelle: "WEB", aktion: "Angemeldet (SSO)", ip }, tx);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return { benutzer, ziel: ablauf.ziel };
|
||||||
|
}
|
||||||
|
|
||||||
|
const benutzername = await freierName(ansprueche);
|
||||||
|
const benutzer = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.create({
|
||||||
|
data: { benutzername, anzeigename, email, rolle, oidcSubjekt: ansprueche.sub },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: b.id,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "Benutzer per SSO angelegt",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
details: { rolle },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return { benutzer, ziel: ablauf.ziel };
|
||||||
|
}
|
||||||
@@ -19,7 +19,10 @@ async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | n
|
|||||||
const sitzung = await sitzungLesen(jwt);
|
const sitzung = await sitzungLesen(jwt);
|
||||||
if (!sitzung) return null;
|
if (!sitzung) return null;
|
||||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||||
return benutzer?.aktiv ? benutzer : null;
|
if (!benutzer?.aktiv) return null;
|
||||||
|
// Nach Passwortwechsel oder Sperre gelten ältere Sitzungen nicht mehr.
|
||||||
|
if (benutzer.sitzungenAb && sitzung.ausgestelltAm * 1000 < benutzer.sitzungenAb.getTime()) return null;
|
||||||
|
return benutzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Für Server Components. */
|
/** Für Server Components. */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const KOMPONENTEN: Record<string, Schema> = {
|
|||||||
AuftragErgebnis: A.AuftragErgebnis,
|
AuftragErgebnis: A.AuftragErgebnis,
|
||||||
Abschluss: A.Abschluss,
|
Abschluss: A.Abschluss,
|
||||||
Auftrag: A.AuftragSicht,
|
Auftrag: A.AuftragSicht,
|
||||||
|
Verlauf: A.Verlauf,
|
||||||
LogZeile: A.LogZeile,
|
LogZeile: A.LogZeile,
|
||||||
GeplanterAufruf: A.GeplanterAufruf,
|
GeplanterAufruf: A.GeplanterAufruf,
|
||||||
Plan: A.Plan,
|
Plan: A.Plan,
|
||||||
@@ -126,6 +127,23 @@ const PFADE: Record<string, Record<string, Operation>> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/auftraege": {
|
"/auftraege": {
|
||||||
|
get: {
|
||||||
|
operationId: "verlauf",
|
||||||
|
summary: "Verlauf, neueste zuerst, 25 je Seite",
|
||||||
|
parameters: [
|
||||||
|
{ name: "server", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
{
|
||||||
|
name: "typ",
|
||||||
|
in: "query",
|
||||||
|
required: false,
|
||||||
|
schema: { type: "string", enum: ["vm", "lxc", "template", "aufraeumen"] },
|
||||||
|
},
|
||||||
|
{ name: "distribution", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
{ name: "status", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
{ name: "seite", in: "query", required: false, schema: { type: "integer", minimum: 1 } },
|
||||||
|
],
|
||||||
|
responses: { "200": { description: "Seite des Verlaufs", ...json(ref("Verlauf")) }, default: FEHLER },
|
||||||
|
},
|
||||||
post: {
|
post: {
|
||||||
operationId: "ausrollen",
|
operationId: "ausrollen",
|
||||||
summary: "VM oder LXC ausrollen (Auftrag)",
|
summary: "VM oder LXC ausrollen (Auftrag)",
|
||||||
@@ -181,6 +199,17 @@ const PFADE: Record<string, Record<string, Operation>> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/auftraege/{id}/aufraeumen": {
|
||||||
|
post: {
|
||||||
|
operationId: "auftragAufraeumen",
|
||||||
|
summary: "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)",
|
||||||
|
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||||
|
responses: {
|
||||||
|
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||||
|
default: FEHLER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/auftraege/{id}/abbrechen": {
|
"/auftraege/{id}/abbrechen": {
|
||||||
post: {
|
post: {
|
||||||
operationId: "auftragAbbrechen",
|
operationId: "auftragAbbrechen",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { abschlussAus } from "@/lib/auftrag-text";
|
|
||||||
import { randomInt } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||||
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
||||||
|
import { abschlussAus } from "@/lib/auftrag-text";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
import type { Aufrufer } from "@/server/auth/zugang";
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
import { melden } from "@/server/ereignisse/senke";
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
@@ -91,9 +92,21 @@ const MIT = {
|
|||||||
|
|
||||||
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
|
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
|
||||||
|
|
||||||
|
/** Was „Aufräumen“ löschen würde – nur bei Fehlschlag oder Abbruch und nur, wenn etwas angelegt wurde. */
|
||||||
|
function aufraeumbarAus(
|
||||||
|
a: Pick<Auftrag, "art" | "status" | "angelegt" | "gastTyp" | "knoten">,
|
||||||
|
): AuftragSicht["aufraeumbar"] {
|
||||||
|
if (a.art === "AUFRAEUMEN" || (a.status !== "FEHLGESCHLAGEN" && a.status !== "ABGEBROCHEN")) return null;
|
||||||
|
const angelegt = (a.angelegt ?? {}) as { vmid?: unknown; knoten?: unknown; typ?: unknown };
|
||||||
|
if (typeof angelegt.vmid !== "number") return null;
|
||||||
|
const typ = angelegt.typ === "LXC" || a.gastTyp === "LXC" ? "LXC" : "VM";
|
||||||
|
const knoten = typeof angelegt.knoten === "string" ? angelegt.knoten : a.knoten;
|
||||||
|
return { typ, vmid: angelegt.vmid, knoten };
|
||||||
|
}
|
||||||
|
|
||||||
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||||
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||||
const sicht: Omit<AuftragSicht, "abschluss"> = {
|
const sicht: Omit<AuftragSicht, "abschluss" | "aufraeumbar"> = {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
kurzId: a.kurzId,
|
kurzId: a.kurzId,
|
||||||
art: a.art,
|
art: a.art,
|
||||||
@@ -124,7 +137,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
|||||||
beendetAm: iso(p.beendetAm),
|
beendetAm: iso(p.beendetAm),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return { ...sicht, abschluss: abschlussAus(sicht) };
|
return { ...sicht, abschluss: abschlussAus(sicht), aufraeumbar: aufraeumbarAus(a) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
||||||
@@ -137,16 +150,66 @@ export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
|
|||||||
return auftragSicht(auftrag);
|
return auftragSicht(auftrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function auftraegeListe(
|
/** Filter des Verlaufs – dieselben in Web (`/auftraege?…`) und API (`GET /api/v1/auftraege?…`). */
|
||||||
filter: { serverId?: string; grenze?: number } = {},
|
export const VerlaufFilter = z.object({
|
||||||
): Promise<AuftragSicht[]> {
|
server: z.string().trim().optional(),
|
||||||
const zeilen = await prisma.auftrag.findMany({
|
typ: z.enum(["vm", "lxc", "template", "aufraeumen"]).optional(),
|
||||||
where: filter.serverId ? { serverId: filter.serverId } : {},
|
distribution: z.string().trim().optional(),
|
||||||
|
status: z
|
||||||
|
.enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"])
|
||||||
|
.optional(),
|
||||||
|
seite: z.coerce.number().int().min(1).max(10_000).default(1),
|
||||||
|
});
|
||||||
|
export type VerlaufFilter = z.infer<typeof VerlaufFilter>;
|
||||||
|
|
||||||
|
export const VERLAUF_PRO_SEITE = 25;
|
||||||
|
|
||||||
|
/** Liest Filter aus Suchparametern; Unbekanntes und Leeres fällt still weg statt zu stören. */
|
||||||
|
export function verlaufFilterAus(parameter: Record<string, string | string[] | undefined>): VerlaufFilter {
|
||||||
|
const roh: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(parameter)) {
|
||||||
|
const wert = Array.isArray(v) ? v[0] : v;
|
||||||
|
if (wert) roh[k] = wert;
|
||||||
|
}
|
||||||
|
const geprueft: Record<string, unknown> = {};
|
||||||
|
for (const schluessel of Object.keys(VerlaufFilter.shape) as Array<keyof VerlaufFilter>) {
|
||||||
|
const feld = VerlaufFilter.shape[schluessel].safeParse(roh[schluessel]);
|
||||||
|
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
|
||||||
|
}
|
||||||
|
return VerlaufFilter.parse(geprueft);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auftraegeSuchen(filter: VerlaufFilter): Promise<{
|
||||||
|
eintraege: AuftragSicht[];
|
||||||
|
gesamt: number;
|
||||||
|
seite: number;
|
||||||
|
seiten: number;
|
||||||
|
}> {
|
||||||
|
const where: Prisma.AuftragWhereInput = {
|
||||||
|
...(filter.server ? { server: { name: filter.server } } : {}),
|
||||||
|
...(filter.distribution ? { distributionId: filter.distribution } : {}),
|
||||||
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
|
...(filter.typ === "vm" ? { art: "AUSROLLEN", gastTyp: "VM" } : {}),
|
||||||
|
...(filter.typ === "lxc" ? { art: "AUSROLLEN", gastTyp: "LXC" } : {}),
|
||||||
|
...(filter.typ === "template" ? { art: "TEMPLATE_BAUEN" } : {}),
|
||||||
|
...(filter.typ === "aufraeumen" ? { art: "AUFRAEUMEN" } : {}),
|
||||||
|
};
|
||||||
|
const [gesamt, zeilen] = await prisma.$transaction([
|
||||||
|
prisma.auftrag.count({ where }),
|
||||||
|
prisma.auftrag.findMany({
|
||||||
|
where,
|
||||||
include: MIT,
|
include: MIT,
|
||||||
orderBy: { erstelltAm: "desc" },
|
orderBy: { erstelltAm: "desc" },
|
||||||
take: filter.grenze ?? 50,
|
skip: (filter.seite - 1) * VERLAUF_PRO_SEITE,
|
||||||
});
|
take: VERLAUF_PRO_SEITE,
|
||||||
return zeilen.map(auftragSicht);
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
eintraege: zeilen.map(auftragSicht),
|
||||||
|
gesamt,
|
||||||
|
seite: filter.seite,
|
||||||
|
seiten: Math.max(1, Math.ceil(gesamt / VERLAUF_PRO_SEITE)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
|
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
|
||||||
@@ -167,6 +230,50 @@ export async function logAb(auftragId: string, abId: bigint, grenze = 500): Prom
|
|||||||
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
|
|
||||||
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
|
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
|
||||||
|
/**
|
||||||
|
* Aufräumen starten: eigener Auftrag, der den halb angelegten Gast eines fehlgeschlagenen bzw.
|
||||||
|
* abgebrochenen Auftrags löscht. Was genau gelöscht wird, prüft der worker gegen Proxmox.
|
||||||
|
*/
|
||||||
|
export async function aufraeumenStarten(
|
||||||
|
kennung: string,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<Auftrag> {
|
||||||
|
const sicht = await auftragHolen(kennung);
|
||||||
|
if (!sicht.aufraeumbar) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
409,
|
||||||
|
"nicht_aufraeumbar",
|
||||||
|
sicht.status === "AUFGERAEUMT"
|
||||||
|
? "Dieser Auftrag ist schon aufgeräumt."
|
||||||
|
: "Hier gibt es nichts aufzuräumen – nur fehlgeschlagene oder abgebrochene Aufträge, die etwas angelegt haben.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const laufend = await prisma.auftrag.findFirst({
|
||||||
|
where: { art: "AUFRAEUMEN", bezugId: sicht.id, status: { in: ["WARTEND", "LAEUFT"] } },
|
||||||
|
select: { kurzId: true },
|
||||||
|
});
|
||||||
|
if (laufend) {
|
||||||
|
throw new AnwendungsFehler(409, "laeuft_schon", `Das Aufräumen läuft schon (${laufend.kurzId}).`);
|
||||||
|
}
|
||||||
|
const original = await prisma.auftrag.findUniqueOrThrow({ where: { id: sicht.id } });
|
||||||
|
const { typ, vmid, knoten } = sicht.aufraeumbar;
|
||||||
|
return await auftragAnlegen(
|
||||||
|
{
|
||||||
|
art: "AUFRAEUMEN",
|
||||||
|
serverId: original.serverId,
|
||||||
|
...(knoten ? { knoten } : {}),
|
||||||
|
gastTyp: typ,
|
||||||
|
...(original.name ? { name: original.name } : {}),
|
||||||
|
...(original.distributionId ? { distributionId: original.distributionId } : {}),
|
||||||
|
bezugId: original.id,
|
||||||
|
anfrage: { auftrag: original.kurzId, typ, vmid, knoten },
|
||||||
|
},
|
||||||
|
aufrufer,
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function auftragAbbrechen(
|
export async function auftragAbbrechen(
|
||||||
kennung: string,
|
kennung: string,
|
||||||
aufrufer: Aufrufer,
|
aufrufer: Aufrufer,
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { Benutzer, Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (nur Admins) und das eigene Passwort. Zwei Schutzregeln: der letzte aktive
|
||||||
|
* Admin bleibt Admin und aktiv, und niemand sperrt sich selbst aus. Passwortwechsel und Sperre
|
||||||
|
* beenden alle bestehenden Sitzungen (`sitzungenAb`); API-Tokens prüfen den Aktiv-Status je Anfrage.
|
||||||
|
*/
|
||||||
|
export type BenutzerSicht = {
|
||||||
|
id: string;
|
||||||
|
benutzername: string;
|
||||||
|
anzeigename: string;
|
||||||
|
email: string | null;
|
||||||
|
rolle: "ADMIN" | "OPERATOR";
|
||||||
|
aktiv: boolean;
|
||||||
|
/** Lokales Passwort gesetzt (sonst nur OIDC). */
|
||||||
|
passwort: boolean;
|
||||||
|
oidc: boolean;
|
||||||
|
erstelltAm: string;
|
||||||
|
tokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Passwort = z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`)
|
||||||
|
.max(1024, "Höchstens 1024 Zeichen.");
|
||||||
|
|
||||||
|
export const BenutzerAnlegen = z.object({
|
||||||
|
benutzername: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(
|
||||||
|
/^[a-z][a-z0-9._-]{1,31}$/,
|
||||||
|
"2–32 Zeichen: Kleinbuchstaben, Ziffern, Punkt, _ und -; vorne ein Buchstabe.",
|
||||||
|
),
|
||||||
|
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
|
||||||
|
email: z
|
||||||
|
.email("Keine gültige E-Mail-Adresse.")
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("").transform(() => undefined)),
|
||||||
|
rolle: z.enum(["ADMIN", "OPERATOR"]).default("OPERATOR"),
|
||||||
|
passwort: Passwort,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BenutzerAendern = z.object({
|
||||||
|
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80).optional(),
|
||||||
|
email: z
|
||||||
|
.email("Keine gültige E-Mail-Adresse.")
|
||||||
|
.trim()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("").transform(() => null)),
|
||||||
|
rolle: z.enum(["ADMIN", "OPERATOR"]).optional(),
|
||||||
|
aktiv: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MIT_TOKENS = { _count: { select: { tokens: true } } } as const;
|
||||||
|
|
||||||
|
function sicht(b: Benutzer & { _count: { tokens: number } }): BenutzerSicht {
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
benutzername: b.benutzername,
|
||||||
|
anzeigename: b.anzeigename,
|
||||||
|
email: b.email,
|
||||||
|
rolle: b.rolle,
|
||||||
|
aktiv: b.aktiv,
|
||||||
|
passwort: Boolean(b.passwortHash),
|
||||||
|
oidc: Boolean(b.oidcSubjekt),
|
||||||
|
erstelltAm: b.erstelltAm.toISOString(),
|
||||||
|
tokens: b._count.tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auf volle Sekunden: JWT-`iat` hat Sekunden, eine im selben Augenblick neu ausgestellte Sitzung gilt. */
|
||||||
|
const jetztSekunde = () => new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
|
||||||
|
export async function benutzerListe(): Promise<BenutzerSicht[]> {
|
||||||
|
const liste = await prisma.benutzer.findMany({ include: MIT_TOKENS, orderBy: { benutzername: "asc" } });
|
||||||
|
return liste.map(sicht);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benutzerAnlegen(
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<BenutzerSicht> {
|
||||||
|
const e = BenutzerAnlegen.parse(roh);
|
||||||
|
try {
|
||||||
|
const neu = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.create({
|
||||||
|
data: {
|
||||||
|
benutzername: e.benutzername,
|
||||||
|
anzeigename: e.anzeigename,
|
||||||
|
email: e.email ?? null,
|
||||||
|
rolle: e.rolle,
|
||||||
|
passwortHash: await passwortHashen(e.passwort),
|
||||||
|
},
|
||||||
|
include: MIT_TOKENS,
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Benutzer angelegt",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
details: { rolle: b.rolle },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return sicht(neu);
|
||||||
|
} catch (fehler) {
|
||||||
|
if ((fehler as { code?: string }).code === "P2002") {
|
||||||
|
throw new AnwendungsFehler(409, "vergeben", `„${e.benutzername}“ gibt es schon.`, undefined, {
|
||||||
|
benutzername: "Benutzername schon vergeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function letzterAdmin(tx: Prisma.TransactionClient, ohne: string): Promise<boolean> {
|
||||||
|
return (await tx.benutzer.count({ where: { rolle: "ADMIN", aktiv: true, id: { not: ohne } } })) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benutzerAendern(
|
||||||
|
id: string,
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<BenutzerSicht> {
|
||||||
|
const e = BenutzerAendern.parse(roh);
|
||||||
|
const ergebnis = await prisma.$transaction(async (tx) => {
|
||||||
|
// Sperre auf die Admin-Zeilen: zwei gleichzeitige Herabstufungen dürfen nicht beide durchgehen.
|
||||||
|
await tx.$queryRaw`SELECT id FROM "Benutzer" WHERE rolle = 'ADMIN' FOR UPDATE`;
|
||||||
|
const alt = await tx.benutzer.findUnique({ where: { id } });
|
||||||
|
if (!alt) throw nichtGefunden("Den Benutzer");
|
||||||
|
const selbst = alt.id === aufrufer.benutzer.id;
|
||||||
|
if (selbst && e.aktiv === false) {
|
||||||
|
throw new AnwendungsFehler(409, "selbst", "Das eigene Konto lässt sich nicht sperren.");
|
||||||
|
}
|
||||||
|
const verliertAdmin = alt.rolle === "ADMIN" && alt.aktiv && (e.rolle === "OPERATOR" || e.aktiv === false);
|
||||||
|
if (verliertAdmin && (await letzterAdmin(tx, alt.id))) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
409,
|
||||||
|
"letzter_admin",
|
||||||
|
"Das ist der letzte aktive Admin.",
|
||||||
|
"Erst einen anderen Benutzer zum Admin machen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const gesperrt = e.aktiv === false && alt.aktiv;
|
||||||
|
const neu = await tx.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(e.anzeigename !== undefined ? { anzeigename: e.anzeigename } : {}),
|
||||||
|
...(e.email !== undefined ? { email: e.email } : {}),
|
||||||
|
...(e.rolle !== undefined ? { rolle: e.rolle } : {}),
|
||||||
|
...(e.aktiv !== undefined ? { aktiv: e.aktiv } : {}),
|
||||||
|
...(gesperrt ? { sitzungenAb: jetztSekunde() } : {}),
|
||||||
|
},
|
||||||
|
include: MIT_TOKENS,
|
||||||
|
});
|
||||||
|
const aenderungen: Record<string, unknown> = {};
|
||||||
|
if (e.rolle !== undefined && e.rolle !== alt.rolle) aenderungen["rolle"] = [alt.rolle, e.rolle];
|
||||||
|
if (e.aktiv !== undefined && e.aktiv !== alt.aktiv) aenderungen["aktiv"] = [alt.aktiv, e.aktiv];
|
||||||
|
if (e.anzeigename !== undefined && e.anzeigename !== alt.anzeigename) aenderungen["anzeigename"] = true;
|
||||||
|
if (e.email !== undefined && e.email !== alt.email) aenderungen["email"] = true;
|
||||||
|
if (Object.keys(aenderungen).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: gesperrt
|
||||||
|
? "Benutzer gesperrt"
|
||||||
|
: e.aktiv === true && !alt.aktiv
|
||||||
|
? "Benutzer entsperrt"
|
||||||
|
: "Benutzer geändert",
|
||||||
|
ziel: `benutzer:${alt.benutzername}`,
|
||||||
|
details: aenderungen as Prisma.InputJsonValue,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
return sicht(ergebnis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin setzt ein neues Passwort (z. B. vergessen); alle Sitzungen des Benutzers enden. */
|
||||||
|
export async function passwortZuruecksetzen(
|
||||||
|
id: string,
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { passwort } = z.object({ passwort: Passwort }).parse(roh);
|
||||||
|
const ziel = await prisma.benutzer.findUnique({ where: { id } });
|
||||||
|
if (!ziel) throw nichtGefunden("Den Benutzer");
|
||||||
|
const hashWert = await passwortHashen(passwort);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Passwort zurückgesetzt",
|
||||||
|
ziel: `benutzer:${ziel.benutzername}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenes Passwort ändern – mit dem alten. Andere Sitzungen enden; die aufrufende stellt der Handler neu aus. */
|
||||||
|
export async function eigenesPasswortAendern(
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const e = z
|
||||||
|
.object({ alt: z.string().min(1, "Bisheriges Passwort fehlt.").max(1024), neu: Passwort })
|
||||||
|
.parse(roh);
|
||||||
|
const ich = await prisma.benutzer.findUniqueOrThrow({ where: { id: aufrufer.benutzer.id } });
|
||||||
|
if (!ich.passwortHash || !(await passwortPruefen(ich.passwortHash, e.alt))) {
|
||||||
|
throw new AnwendungsFehler(400, "ungueltig", "Das bisherige Passwort stimmt nicht.", undefined, {
|
||||||
|
alt: "Stimmt nicht.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (e.alt === e.neu) {
|
||||||
|
throw new AnwendungsFehler(400, "ungueltig", "Das neue Passwort ist das alte.", undefined, {
|
||||||
|
neu: "Bitte ein anderes Passwort wählen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hashWert = await passwortHashen(e.neu);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.benutzer.update({
|
||||||
|
where: { id: ich.id },
|
||||||
|
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: ich.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Eigenes Passwort geändert",
|
||||||
|
ziel: `benutzer:${ich.benutzername}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ssh2 from "ssh2";
|
|
||||||
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ export async function bereitstellungsschluessel(): Promise<Bereitstellungsschlue
|
|||||||
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
||||||
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
||||||
}
|
}
|
||||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
|
const paar = ed25519Erzeugen(KOMMENTAR);
|
||||||
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
|
const neu = { privat: verschluesseln(paar.privat), oeffentlich: paar.oeffentlich };
|
||||||
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
||||||
await prisma.einstellung.upsert({
|
await prisma.einstellung.upsert({
|
||||||
where: { schluessel: SCHLUESSEL },
|
where: { schluessel: SCHLUESSEL },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Quelle } from "@/generated/prisma/enums";
|
import type { Quelle } from "@/generated/prisma/enums";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
@@ -28,3 +29,92 @@ export async function protokollieren(eintrag: ProtokollEintrag, client: Client =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filter der Audit-Log-Ansicht (`/protokoll?…`, `GET /api/v1/protokoll?…`). */
|
||||||
|
export const ProtokollFilter = z.object({
|
||||||
|
benutzer: z.string().trim().optional(),
|
||||||
|
quelle: z.enum(["WEB", "CLI", "API", "SYSTEM"]).optional(),
|
||||||
|
/** Teilwort in Aktion oder Ziel, z. B. „Server“ oder „d-7f3k2“. */
|
||||||
|
suche: z.string().trim().max(100).optional(),
|
||||||
|
von: z.iso.date().optional(),
|
||||||
|
bis: z.iso.date().optional(),
|
||||||
|
seite: z.coerce.number().int().min(1).max(100_000).default(1),
|
||||||
|
});
|
||||||
|
export type ProtokollFilter = z.infer<typeof ProtokollFilter>;
|
||||||
|
|
||||||
|
export const PROTOKOLL_PRO_SEITE = 50;
|
||||||
|
|
||||||
|
export type ProtokollZeile = {
|
||||||
|
id: string;
|
||||||
|
zeit: string;
|
||||||
|
benutzer: string | null;
|
||||||
|
quelle: Quelle;
|
||||||
|
aktion: string;
|
||||||
|
ziel: string | null;
|
||||||
|
details: unknown;
|
||||||
|
ip: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wie beim Verlauf: Gültiges übernehmen, Unsinn still fallen lassen. */
|
||||||
|
export function protokollFilterAus(
|
||||||
|
parameter: Record<string, string | string[] | undefined>,
|
||||||
|
): ProtokollFilter {
|
||||||
|
const geprueft: Record<string, unknown> = {};
|
||||||
|
for (const schluessel of Object.keys(ProtokollFilter.shape) as Array<keyof ProtokollFilter>) {
|
||||||
|
const roh = parameter[schluessel];
|
||||||
|
const wert = Array.isArray(roh) ? roh[0] : roh;
|
||||||
|
if (!wert) continue;
|
||||||
|
const feld = ProtokollFilter.shape[schluessel].safeParse(wert);
|
||||||
|
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
|
||||||
|
}
|
||||||
|
return ProtokollFilter.parse(geprueft);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function protokollSuchen(filter: ProtokollFilter): Promise<{
|
||||||
|
eintraege: ProtokollZeile[];
|
||||||
|
gesamt: number;
|
||||||
|
seite: number;
|
||||||
|
seiten: number;
|
||||||
|
}> {
|
||||||
|
const zeit: Prisma.DateTimeFilter = {};
|
||||||
|
if (filter.von) zeit.gte = new Date(`${filter.von}T00:00:00`);
|
||||||
|
if (filter.bis) zeit.lt = new Date(new Date(`${filter.bis}T00:00:00`).getTime() + 86_400_000);
|
||||||
|
const where: Prisma.ProtokollWhereInput = {
|
||||||
|
...(filter.benutzer ? { benutzer: { benutzername: filter.benutzer } } : {}),
|
||||||
|
...(filter.quelle ? { quelle: filter.quelle } : {}),
|
||||||
|
...(filter.suche
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ aktion: { contains: filter.suche, mode: "insensitive" as const } },
|
||||||
|
{ ziel: { contains: filter.suche, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(filter.von || filter.bis ? { zeit } : {}),
|
||||||
|
};
|
||||||
|
const [gesamt, zeilen] = await prisma.$transaction([
|
||||||
|
prisma.protokoll.count({ where }),
|
||||||
|
prisma.protokoll.findMany({
|
||||||
|
where,
|
||||||
|
include: { benutzer: { select: { benutzername: true } } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
skip: (filter.seite - 1) * PROTOKOLL_PRO_SEITE,
|
||||||
|
take: PROTOKOLL_PRO_SEITE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
eintraege: zeilen.map((z) => ({
|
||||||
|
id: z.id.toString(),
|
||||||
|
zeit: z.zeit.toISOString(),
|
||||||
|
benutzer: z.benutzer?.benutzername ?? null,
|
||||||
|
quelle: z.quelle,
|
||||||
|
aktion: z.aktion,
|
||||||
|
ziel: z.ziel,
|
||||||
|
details: z.details,
|
||||||
|
ip: z.ip,
|
||||||
|
})),
|
||||||
|
gesamt,
|
||||||
|
seite: filter.seite,
|
||||||
|
seiten: Math.max(1, Math.ceil(gesamt / PROTOKOLL_PRO_SEITE)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ssh2 from "ssh2";
|
|
||||||
import { Prisma, type Server } from "@/generated/prisma/client";
|
import { Prisma, type Server } from "@/generated/prisma/client";
|
||||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -180,13 +180,13 @@ export async function sshSchluesselErzeugen(
|
|||||||
ip: string | null,
|
ip: string | null,
|
||||||
): Promise<ServerSicht> {
|
): Promise<ServerSicht> {
|
||||||
const server = await serverHolen(name);
|
const server = await serverHolen(name);
|
||||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
const paar = ed25519Erzeugen(`proxdeploy@${server.name}`);
|
||||||
const neu = await prisma.$transaction(async (tx) => {
|
const neu = await prisma.$transaction(async (tx) => {
|
||||||
const aktualisiert = await tx.server.update({
|
const aktualisiert = await tx.server.update({
|
||||||
where: { id: server.id },
|
where: { id: server.id },
|
||||||
data: {
|
data: {
|
||||||
sshPrivat: verschluesseln(paar.private),
|
sshPrivat: verschluesseln(paar.privat),
|
||||||
sshOeffentlich: paar.public.trim(),
|
sshOeffentlich: paar.oeffentlich,
|
||||||
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
||||||
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */
|
/** Meldet alle Abläufe beim worker an. */
|
||||||
import "./ablaeufe/ausrollen";
|
import "./ablaeufe/ausrollen";
|
||||||
import "./ablaeufe/template";
|
import "./ablaeufe/template";
|
||||||
|
import "./ablaeufe/aufraeumen";
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||||
|
import { aufraeumSchritte, type AufraeumStand } from "@/engine/ablaeufe/aufraeumen";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
|
import { verbindungAus } from "@/server/services/server";
|
||||||
|
import { ablaufRegistrieren } from "../ausfuehren";
|
||||||
|
import { aufrufLogger } from "../aufruf-log";
|
||||||
|
|
||||||
|
const Anfrage = z.object({
|
||||||
|
auftrag: z.string(),
|
||||||
|
typ: z.enum(["VM", "LXC"]),
|
||||||
|
vmid: z.number().int(),
|
||||||
|
knoten: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke, abbruchGewuenscht }) => {
|
||||||
|
const anfrage = Anfrage.parse(auftrag.anfrage);
|
||||||
|
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
|
||||||
|
await prisma.auftrag.update({
|
||||||
|
where: { id: auftrag.id },
|
||||||
|
data: {
|
||||||
|
vmid: anfrage.vmid,
|
||||||
|
kopf: `Server ${server.name}${anfrage.knoten ? ` · Node ${anfrage.knoten}` : ""} · ${
|
||||||
|
anfrage.typ === "LXC" ? "CT" : "VM"
|
||||||
|
} ${anfrage.vmid} aus ${anfrage.auftrag}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await melden(auftrag.id);
|
||||||
|
|
||||||
|
const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) });
|
||||||
|
const stand: AufraeumStand = {};
|
||||||
|
try {
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
aufraeumSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
typ: anfrage.typ,
|
||||||
|
vmid: anfrage.vmid,
|
||||||
|
knoten: anfrage.knoten,
|
||||||
|
auftragKurzId: anfrage.auftrag,
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
senke,
|
||||||
|
{ abbruchGewuenscht },
|
||||||
|
);
|
||||||
|
if (!ergebnis.ok) return ergebnis;
|
||||||
|
// Der ursprüngliche Auftrag ist damit erledigt – im Verlauf als „aufgeräumt“ sichtbar.
|
||||||
|
if (auftrag.bezugId) {
|
||||||
|
await prisma.auftrag.update({ where: { id: auftrag.bezugId }, data: { status: "AUFGERAEUMT" } });
|
||||||
|
await melden(auftrag.bezugId);
|
||||||
|
}
|
||||||
|
return { ok: true, ergebnis: { vmid: anfrage.vmid, ...(stand.knoten ? { knoten: stand.knoten } : {}) } };
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as benutzerListe from "@/app/api/v1/benutzer/route";
|
||||||
|
import * as benutzerEinzeln from "@/app/api/v1/benutzer/[id]/route";
|
||||||
|
import * as benutzerPasswort from "@/app/api/v1/benutzer/[id]/passwort/route";
|
||||||
|
import * as ich from "@/app/api/v1/ich/route";
|
||||||
|
import * as ichPasswort from "@/app/api/v1/ich/passwort/route";
|
||||||
|
import * as profile from "@/app/api/v1/profile/route";
|
||||||
|
import * as protokoll from "@/app/api/v1/protokoll/route";
|
||||||
|
import * as server from "@/app/api/v1/server/route";
|
||||||
|
import * as tokens from "@/app/api/v1/tokens/route";
|
||||||
|
import { passwortHashen } from "@/lib/passwort";
|
||||||
|
import { SITZUNG_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import { protokollFilterAus } from "@/server/services/protokoll";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
/** Eine Sitzung, die vor zehn Sekunden ausgestellt wurde – damit `sitzungenAb` sie sicher trifft. */
|
||||||
|
async function alteSitzung(benutzerId: string): Promise<string> {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() - 10_000 });
|
||||||
|
try {
|
||||||
|
return `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId, herkunft: "passwort" })}`;
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wer = async (cookie: string) => (await ich.GET(anfrage("GET", "/api/v1/ich", cookie))).status;
|
||||||
|
|
||||||
|
describe("Audit-Log-Filter", () => {
|
||||||
|
it("übernimmt Gültiges, verwirft Unsinn", () => {
|
||||||
|
expect(protokollFilterAus({ quelle: "CLI", von: "2026-10-01", suche: "Server", seite: "2" })).toEqual({
|
||||||
|
quelle: "CLI",
|
||||||
|
von: "2026-10-01",
|
||||||
|
suche: "Server",
|
||||||
|
seite: 2,
|
||||||
|
});
|
||||||
|
expect(protokollFilterAus({ quelle: "FAX", von: "gestern" })).toEqual({ seite: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Benutzer, Rollen, Audit-Log", () => {
|
||||||
|
let admin: Awaited<ReturnType<typeof angemeldet>>;
|
||||||
|
let operator: Awaited<ReturnType<typeof angemeldet>>;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
admin = await angemeldet("ADMIN", "chefin");
|
||||||
|
operator = await angemeldet("OPERATOR", "florian");
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = (cookie: string, id: string, rumpf: unknown) =>
|
||||||
|
benutzerEinzeln.PATCH(anfrage("PATCH", `/api/v1/benutzer/${id}`, cookie, rumpf), kontext({ id }));
|
||||||
|
|
||||||
|
it("Operator darf weder Benutzer noch Server noch Katalog ändern und kein Audit-Log lesen", async () => {
|
||||||
|
const c = operator.cookie;
|
||||||
|
const versuche = [
|
||||||
|
await benutzerListe.GET(anfrage("GET", "/api/v1/benutzer", c)),
|
||||||
|
await benutzerListe.POST(
|
||||||
|
anfrage("POST", "/api/v1/benutzer", c, {
|
||||||
|
benutzername: "x",
|
||||||
|
anzeigename: "x",
|
||||||
|
passwort: "x".repeat(12),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
await patch(c, admin.benutzer.id, { rolle: "OPERATOR" }),
|
||||||
|
await patch(c, operator.benutzer.id, { rolle: "ADMIN" }),
|
||||||
|
await benutzerPasswort.POST(
|
||||||
|
anfrage("POST", `/api/v1/benutzer/${admin.benutzer.id}/passwort`, c, { passwort: "y".repeat(12) }),
|
||||||
|
kontext({ id: admin.benutzer.id }),
|
||||||
|
),
|
||||||
|
await server.POST(anfrage("POST", "/api/v1/server", c, { name: "x" })),
|
||||||
|
await profile.POST(anfrage("POST", "/api/v1/profile", c, { quelltext: "id: x" })),
|
||||||
|
await protokoll.GET(anfrage("GET", "/api/v1/protokoll", c)),
|
||||||
|
];
|
||||||
|
expect(versuche.map((v) => v.status)).toEqual([403, 403, 403, 403, 403, 403, 403, 403]);
|
||||||
|
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: operator.benutzer.id } })).rolle).toBe(
|
||||||
|
"OPERATOR",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Admin legt an, ändert die Rolle; doppelte Namen und kurze Passwörter sind Feldfehler", async () => {
|
||||||
|
const neu = await benutzerListe.POST(
|
||||||
|
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
|
||||||
|
benutzername: "Max.Muster",
|
||||||
|
anzeigename: "Max Muster",
|
||||||
|
email: "",
|
||||||
|
rolle: "OPERATOR",
|
||||||
|
passwort: "ein-langes-passwort",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(neu.status).toBe(201);
|
||||||
|
const sicht = (await neu.json()) as { id: string; benutzername: string; passwort: boolean; email: null };
|
||||||
|
expect(sicht).toMatchObject({ benutzername: "max.muster", passwort: true, email: null });
|
||||||
|
expect(JSON.stringify(sicht)).not.toContain("argon2");
|
||||||
|
|
||||||
|
const doppelt = await benutzerListe.POST(
|
||||||
|
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
|
||||||
|
benutzername: "max.muster",
|
||||||
|
anzeigename: "x",
|
||||||
|
passwort: "ein-langes-passwort",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(doppelt.status).toBe(409);
|
||||||
|
expect((await doppelt.json()).fehler.felder.benutzername).toBeTruthy();
|
||||||
|
const kurz = await benutzerListe.POST(
|
||||||
|
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
|
||||||
|
benutzername: "kurz",
|
||||||
|
anzeigename: "x",
|
||||||
|
passwort: "123",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await kurz.json()).fehler.felder.passwort).toMatch(/12 Zeichen/);
|
||||||
|
|
||||||
|
expect((await patch(admin.cookie, sicht.id, { rolle: "ADMIN" })).status).toBe(200);
|
||||||
|
const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Benutzer geändert" } });
|
||||||
|
expect(eintrag.details).toEqual({ rolle: ["OPERATOR", "ADMIN"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("der letzte Admin bleibt Admin; niemand sperrt sich selbst", async () => {
|
||||||
|
const herab = await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" });
|
||||||
|
expect(herab.status).toBe(409);
|
||||||
|
expect((await herab.json()).fehler.code).toBe("letzter_admin");
|
||||||
|
expect((await patch(admin.cookie, admin.benutzer.id, { aktiv: false })).status).toBe(409);
|
||||||
|
|
||||||
|
// Mit zweitem Admin geht es.
|
||||||
|
await patch(admin.cookie, operator.benutzer.id, { rolle: "ADMIN" });
|
||||||
|
expect((await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Sperren beendet Sitzungen und Tokens; Entsperren lässt neue Anmeldungen zu", async () => {
|
||||||
|
const alt = await alteSitzung(operator.benutzer.id);
|
||||||
|
const token = (await (
|
||||||
|
await tokens.POST(anfrage("POST", "/api/v1/tokens", alt, { name: "cli" }))
|
||||||
|
).json()) as {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
const mitToken = anfrage("GET", "/api/v1/ich", null);
|
||||||
|
mitToken.headers.set("authorization", `Bearer ${token.token}`);
|
||||||
|
expect(await wer(alt)).toBe(200);
|
||||||
|
|
||||||
|
expect((await patch(admin.cookie, operator.benutzer.id, { aktiv: false })).status).toBe(200);
|
||||||
|
expect(await wer(alt)).toBe(401);
|
||||||
|
expect((await ich.GET(mitToken)).status).toBe(401);
|
||||||
|
|
||||||
|
await patch(admin.cookie, operator.benutzer.id, { aktiv: true });
|
||||||
|
expect(await wer(alt)).toBe(401); // die alte Sitzung bleibt beendet
|
||||||
|
const frisch = `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId: operator.benutzer.id, herkunft: "passwort" })}`;
|
||||||
|
expect(await wer(frisch)).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await testPrisma.protokoll.findMany({ where: { ziel: "benutzer:florian" }, orderBy: { id: "asc" } })
|
||||||
|
).map((p) => p.aktion),
|
||||||
|
).toEqual(["Benutzer gesperrt", "Benutzer entsperrt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Passwort zurücksetzen (Admin) und selbst ändern beenden alte Sitzungen", async () => {
|
||||||
|
await testPrisma.benutzer.update({
|
||||||
|
where: { id: operator.benutzer.id },
|
||||||
|
data: { passwortHash: await passwortHashen("das-alte-passwort") },
|
||||||
|
});
|
||||||
|
const alt = await alteSitzung(operator.benutzer.id);
|
||||||
|
|
||||||
|
const falsch = await ichPasswort.POST(
|
||||||
|
anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "falsch", neu: "das-neue-passwort" }),
|
||||||
|
);
|
||||||
|
expect(falsch.status).toBe(400);
|
||||||
|
expect((await falsch.json()).fehler.felder.alt).toBeTruthy();
|
||||||
|
|
||||||
|
const geaendert = await ichPasswort.POST(
|
||||||
|
anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "das-alte-passwort", neu: "das-neue-passwort" }),
|
||||||
|
);
|
||||||
|
expect(geaendert.status).toBe(204);
|
||||||
|
const neuesCookie = geaendert.headers.get("set-cookie")!.split(";")[0]!;
|
||||||
|
expect(await wer(alt)).toBe(401);
|
||||||
|
expect(await wer(neuesCookie)).toBe(200);
|
||||||
|
|
||||||
|
const alt2 = await alteSitzung(operator.benutzer.id);
|
||||||
|
// alt2 ist älter als der Passwortwechsel – schon ungültig. Nach dem Zurücksetzen auch das neue Cookie:
|
||||||
|
expect(await wer(alt2)).toBe(401);
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 2000 });
|
||||||
|
try {
|
||||||
|
const zurueck = await benutzerPasswort.POST(
|
||||||
|
anfrage("POST", `/api/v1/benutzer/${operator.benutzer.id}/passwort`, admin.cookie, {
|
||||||
|
passwort: "vom-admin-gesetzt",
|
||||||
|
}),
|
||||||
|
kontext({ id: operator.benutzer.id }),
|
||||||
|
);
|
||||||
|
expect(zurueck.status).toBe(204);
|
||||||
|
expect(await wer(neuesCookie)).toBe(401);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Audit-Log: neueste zuerst, filterbar nach Benutzer, Quelle und Text", async () => {
|
||||||
|
await testPrisma.protokoll.createMany({
|
||||||
|
data: [
|
||||||
|
{ benutzerId: admin.benutzer.id, quelle: "WEB", aktion: "Server angelegt", ziel: "server:rz1" },
|
||||||
|
{
|
||||||
|
benutzerId: operator.benutzer.id,
|
||||||
|
quelle: "CLI",
|
||||||
|
aktion: "Ausrollen gestartet",
|
||||||
|
ziel: "auftrag:d-abcde",
|
||||||
|
},
|
||||||
|
{ benutzerId: null, quelle: "SYSTEM", aktion: "Erster Administrator angelegt" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const holen = async (abfrage: string) =>
|
||||||
|
(await (await protokoll.GET(anfrage("GET", `/api/v1/protokoll?${abfrage}`, admin.cookie))).json()) as {
|
||||||
|
eintraege: Array<{ aktion: string; benutzer: string | null }>;
|
||||||
|
gesamt: number;
|
||||||
|
};
|
||||||
|
expect((await holen("")).eintraege.map((e) => e.aktion)).toEqual([
|
||||||
|
"Erster Administrator angelegt",
|
||||||
|
"Ausrollen gestartet",
|
||||||
|
"Server angelegt",
|
||||||
|
]);
|
||||||
|
expect((await holen("benutzer=florian")).eintraege).toEqual([
|
||||||
|
expect.objectContaining({ aktion: "Ausrollen gestartet", benutzer: "florian" }),
|
||||||
|
]);
|
||||||
|
expect((await holen("quelle=SYSTEM")).gesamt).toBe(1);
|
||||||
|
expect((await holen("suche=d-abc")).gesamt).toBe(1);
|
||||||
|
expect((await holen("suche=server")).gesamt).toBe(1);
|
||||||
|
// Der Filter rechnet in lokaler Zeit – also auch „heute“ lokal bilden.
|
||||||
|
const d = new Date();
|
||||||
|
const heute = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
expect((await holen(`von=${heute}&bis=${heute}`)).gesamt).toBe(3);
|
||||||
|
expect((await holen("bis=2000-01-01")).gesamt).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||||
|
|
||||||
|
test("Admin legt einen Benutzer an, stuft ihn hoch, sperrt ihn; das Audit-Log zeigt es", async ({ page }) => {
|
||||||
|
const name = `${E2E_PRAEFIX}-benutzer-${test.info().project.name}`;
|
||||||
|
await anmelden(page, "/einstellungen");
|
||||||
|
|
||||||
|
const vorhanden = (await (await page.request.get("/api/v1/benutzer")).json()) as Array<{
|
||||||
|
id: string;
|
||||||
|
benutzername: string;
|
||||||
|
}>;
|
||||||
|
const alt = vorhanden.find((b) => b.benutzername === name);
|
||||||
|
if (alt) {
|
||||||
|
// Vom letzten Lauf: zurück auf Operator und aktiv.
|
||||||
|
await page.request.patch(`/api/v1/benutzer/${alt.id}`, { data: { rolle: "OPERATOR", aktiv: true } });
|
||||||
|
await page.reload();
|
||||||
|
} else {
|
||||||
|
await page.locator("#b-name").fill(name);
|
||||||
|
await page.locator("#b-anzeige").fill("E2E Test");
|
||||||
|
await page.locator("#b-passwort").fill("e2e-passwort-lang");
|
||||||
|
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
|
||||||
|
await expect(page.getByText(`${name} angelegt.`)).toBeVisible();
|
||||||
|
}
|
||||||
|
await keinQuerscrollen(page);
|
||||||
|
|
||||||
|
await page.getByLabel(`Rolle von ${name}`).selectOption("ADMIN");
|
||||||
|
await expect(page.getByText(`${name} ist jetzt Admin.`)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: `${name} sperren` }).click();
|
||||||
|
await expect(page.getByText(`${name} gesperrt – Sitzungen beendet.`)).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: `${name} entsperren` })).toBeVisible();
|
||||||
|
|
||||||
|
// Am Handy steckt die Navigation in der Schublade.
|
||||||
|
const menue = page.getByRole("button", { name: "Navigation öffnen" });
|
||||||
|
if (await menue.isVisible()) await menue.click();
|
||||||
|
await page.getByRole("link", { name: "Audit-Log" }).filter({ visible: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/protokoll/);
|
||||||
|
await page.getByLabel("Suche").fill(`benutzer:${name}`);
|
||||||
|
await page.getByRole("button", { name: "Filtern" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`suche=benutzer%3A${name}`));
|
||||||
|
await expect(page.getByText("Benutzer gesperrt").first()).toBeVisible();
|
||||||
|
await keinQuerscrollen(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { anmelden, keinQuerscrollen } from "./helfer";
|
||||||
|
|
||||||
|
test("Verlauf: Filter stehen in der URL und überleben das Neuladen", async ({ page }) => {
|
||||||
|
await anmelden(page, "/auftraege");
|
||||||
|
await expect(page.getByRole("heading", { name: "Verlauf" })).toBeVisible();
|
||||||
|
await page.getByLabel("Typ").selectOption("vm");
|
||||||
|
await expect(page).toHaveURL(/typ=vm/);
|
||||||
|
await page.getByLabel("Status").selectOption("ERFOLGREICH");
|
||||||
|
await expect(page).toHaveURL(/typ=vm.*status=ERFOLGREICH|status=ERFOLGREICH.*typ=vm/);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel("Typ")).toHaveValue("vm");
|
||||||
|
await expect(page.getByLabel("Status")).toHaveValue("ERFOLGREICH");
|
||||||
|
await keinQuerscrollen(page);
|
||||||
|
await page.getByRole("button", { name: "Filter zurücksetzen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/auftraege$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wiederholen füllt den Assistenten mit den alten Werten, Name leer", async ({ page }) => {
|
||||||
|
await anmelden(page, "/auftraege?typ=vm&status=ERFOLGREICH");
|
||||||
|
const erster = page.locator("main ul li a").first();
|
||||||
|
if ((await erster.count()) === 0) test.skip(true, "Kein erfolgreicher VM-Auftrag vorhanden");
|
||||||
|
await erster.click();
|
||||||
|
await page.getByRole("link", { name: "Wiederholen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/ausrollen\?wiederholen=/);
|
||||||
|
await expect(page.getByText(/Wiederholung von d-/)).toBeVisible();
|
||||||
|
await keinQuerscrollen(page);
|
||||||
|
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
|
||||||
|
await expect(page.getByLabel("Name (Hostname)")).toHaveValue("");
|
||||||
|
await expect(page.getByRole("button", { name: "Weiter" })).toBeDisabled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { aufraeumSchritte } from "@/engine/ablaeufe/aufraeumen";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
|
import { szenarioEinrichten } from "../proxmox-mock/szenario";
|
||||||
|
|
||||||
|
const UPID = (art: string) => `UPID:pve02:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`;
|
||||||
|
|
||||||
|
const aufraeumen: Array<() => Promise<void>> = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Gast = {
|
||||||
|
type: "qemu" | "lxc";
|
||||||
|
node: string;
|
||||||
|
status: string;
|
||||||
|
template?: number;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function aufbauen(gast: Gast | null) {
|
||||||
|
const mock = await new ProxmoxMock().starten();
|
||||||
|
szenarioEinrichten(mock, { version: "9.2.1" });
|
||||||
|
mock
|
||||||
|
.route("GET", "/cluster/resources", () => ({
|
||||||
|
data: [
|
||||||
|
{ vmid: 100, type: "qemu", node: "pve01", status: "running", name: "anderer" },
|
||||||
|
...(gast
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
vmid: 120,
|
||||||
|
type: gast.type,
|
||||||
|
node: gast.node,
|
||||||
|
status: gast.status,
|
||||||
|
name: "web01",
|
||||||
|
template: gast.template ?? 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.route("GET", /^\/nodes\/pve02\/(qemu|lxc)\/120\/config$/, () => ({ data: gast?.config ?? {} }))
|
||||||
|
.route("POST", /^\/nodes\/pve02\/(qemu|lxc)\/120\/status\/stop$/, () => ({ data: UPID("qmstop") }))
|
||||||
|
.route("DELETE", /^\/nodes\/pve02\/(qemu|lxc)\/120$/, () => ({ data: UPID("qmdestroy") }))
|
||||||
|
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }));
|
||||||
|
const client = new ProxmoxClient({
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
});
|
||||||
|
aufraeumen.push(async () => {
|
||||||
|
await client.schliessen();
|
||||||
|
await mock.stoppen();
|
||||||
|
});
|
||||||
|
const phasen: Record<string, { status: string; detail?: string; ursache?: string }> = {};
|
||||||
|
const senke: EreignisSenke = {
|
||||||
|
phasenAnlegen: async () => {},
|
||||||
|
phaseGestartet: async () => {},
|
||||||
|
phaseDetail: async () => {},
|
||||||
|
phaseBeendet: async (s, status, a) => {
|
||||||
|
phasen[s] = { status, detail: a.detail, ursache: a.fehler?.ursache };
|
||||||
|
},
|
||||||
|
log: async () => {},
|
||||||
|
};
|
||||||
|
const ausfuehren = (typ: "VM" | "LXC" = "VM") =>
|
||||||
|
phasenAusfuehren(
|
||||||
|
aufraeumSchritte({ client, typ, vmid: 120, knoten: "pve01", auftragKurzId: "d-abcde" }),
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
const schreibend = () =>
|
||||||
|
mock.anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`);
|
||||||
|
return { mock, ausfuehren, phasen, schreibend };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EIGEN = "Ausgerollt mit ProxDeploy (Auftrag d-abcde)\nDistribution: Debian 13";
|
||||||
|
|
||||||
|
describe("Aufräumen", () => {
|
||||||
|
it("stoppt und löscht den eigenen Gast – auch wenn er inzwischen auf einem anderen Node liegt", async () => {
|
||||||
|
const t = await aufbauen({
|
||||||
|
type: "qemu",
|
||||||
|
node: "pve02",
|
||||||
|
status: "running",
|
||||||
|
config: { name: "web01", description: EIGEN },
|
||||||
|
});
|
||||||
|
expect((await t.ausfuehren()).ok).toBe(true);
|
||||||
|
expect(t.schreibend()).toEqual([
|
||||||
|
"POST /nodes/pve02/qemu/120/status/stop",
|
||||||
|
"DELETE /nodes/pve02/qemu/120",
|
||||||
|
]);
|
||||||
|
const loeschen = t.mock.anfragen.find((a) => a.methode === "DELETE")!;
|
||||||
|
expect(loeschen.abfrage.get("destroy-unreferenced-disks")).toBe("1");
|
||||||
|
expect(t.phasen["loeschen"]!.detail).toBe("VM 120 mit Disks gelöscht");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Container, der nicht läuft: Stoppen übersprungen", async () => {
|
||||||
|
const t = await aufbauen({
|
||||||
|
type: "lxc",
|
||||||
|
node: "pve02",
|
||||||
|
status: "stopped",
|
||||||
|
config: { hostname: "ct01", description: EIGEN },
|
||||||
|
});
|
||||||
|
expect((await t.ausfuehren("LXC")).ok).toBe(true);
|
||||||
|
expect(t.phasen["stoppen"]!.status).toBe("UEBERSPRUNGEN");
|
||||||
|
expect(t.schreibend()).toEqual(["DELETE /nodes/pve02/lxc/120"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schon weg: Erfolg ohne Löschen", async () => {
|
||||||
|
const t = await aufbauen(null);
|
||||||
|
expect((await t.ausfuehren()).ok).toBe(true);
|
||||||
|
expect(t.phasen["pruefungen"]!.detail).toMatch(/gibt es nicht mehr/);
|
||||||
|
expect(t.schreibend()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"fremde Beschreibung",
|
||||||
|
{ type: "qemu", node: "pve02", status: "running", config: { description: "Produktiv-DB" } },
|
||||||
|
/gehört nicht erkennbar/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"anderer Auftrag",
|
||||||
|
{
|
||||||
|
type: "qemu",
|
||||||
|
node: "pve02",
|
||||||
|
status: "running",
|
||||||
|
config: { description: "Ausgerollt mit ProxDeploy (Auftrag d-zzzzz)" },
|
||||||
|
},
|
||||||
|
/gehört nicht erkennbar/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Template",
|
||||||
|
{
|
||||||
|
type: "qemu",
|
||||||
|
node: "pve02",
|
||||||
|
status: "stopped",
|
||||||
|
template: 1,
|
||||||
|
config: { description: EIGEN, template: 1 },
|
||||||
|
},
|
||||||
|
/ist ein Template/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"gesperrt",
|
||||||
|
{ type: "qemu", node: "pve02", status: "stopped", config: { description: EIGEN, lock: "clone" } },
|
||||||
|
/gesperrt \(clone\)/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"falscher Typ",
|
||||||
|
{ type: "lxc", node: "pve02", status: "stopped", config: { description: EIGEN } },
|
||||||
|
/kein VM/,
|
||||||
|
],
|
||||||
|
] as Array<[string, Gast, RegExp]>)("%s: bricht ab und fasst nichts an", async (_name, gast, meldung) => {
|
||||||
|
const t = await aufbauen(gast);
|
||||||
|
const ergebnis = await t.ausfuehren("VM");
|
||||||
|
expect(ergebnis.ok).toBe(false);
|
||||||
|
expect(t.phasen["pruefungen"]!.ursache).toMatch(meldung);
|
||||||
|
expect(t.schreibend()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { utils } from "ssh2";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
import { erkunden } from "@/engine/proxmox/erkundung";
|
import { erkunden } from "@/engine/proxmox/erkundung";
|
||||||
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
|
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
|
||||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
|
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
|
||||||
import { SshMock } from "../ssh-mock";
|
import { SshMock, testPaar } from "../ssh-mock";
|
||||||
|
|
||||||
const aufraeumen: Array<() => Promise<void>> = [];
|
const aufraeumen: Array<() => Promise<void>> = [];
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -102,7 +101,7 @@ describe("Verbindungstest (Erkundung)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("SSH", () => {
|
describe("SSH", () => {
|
||||||
const paar = utils.generateKeyPairSync("ed25519");
|
const paar = testPaar();
|
||||||
|
|
||||||
async function sshServer() {
|
async function sshServer() {
|
||||||
const ssh = await new SshMock(paar.public).starten();
|
const ssh = await new SshMock(paar.public).starten();
|
||||||
@@ -147,7 +146,7 @@ describe("SSH", () => {
|
|||||||
|
|
||||||
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
|
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
|
||||||
const ssh = await sshServer();
|
const ssh = await sshServer();
|
||||||
const fremd = utils.generateKeyPairSync("ed25519");
|
const fremd = testPaar();
|
||||||
const fehler = (await sshAusfuehren(
|
const fehler = (await sshAusfuehren(
|
||||||
{
|
{
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { utils } from "ssh2";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
|
|
||||||
|
describe("ed25519Erzeugen", () => {
|
||||||
|
it("liefert nur Schlüssel, die ssh2 selbst lesen kann (roh scheitern etwa 4 von 1000)", () => {
|
||||||
|
for (let i = 0; i < 1500; i++) {
|
||||||
|
const paar = ed25519Erzeugen("test");
|
||||||
|
expect(utils.parseKey(paar.privat)).not.toBeInstanceOf(Error);
|
||||||
|
expect(paar.oeffentlich).toMatch(/^ssh-ed25519 \S+ test$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -183,7 +183,15 @@ describe("VM ausrollen", () => {
|
|||||||
expect(angelegt).toContainEqual({ vmid: 101 });
|
expect(angelegt).toContainEqual({ vmid: 101 });
|
||||||
|
|
||||||
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
|
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
|
||||||
expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" });
|
expect(klon).toMatchObject({
|
||||||
|
newid: "101",
|
||||||
|
name: "web01",
|
||||||
|
full: "1",
|
||||||
|
target: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
});
|
||||||
|
// Schon beim Klonen erkennbar als Gast dieses Auftrags – davon hängt „Aufräumen“ ab.
|
||||||
|
expect(klon["description"]).toMatch(/^Ausgerollt mit ProxDeploy \(Auftrag d-test1\)/);
|
||||||
|
|
||||||
const configs = mock.anfragen
|
const configs = mock.anfragen
|
||||||
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
|
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { exportJWK, generateKeyPair, SignJWT, type JWK } from "jose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Identity Provider für Tests: Discovery, JWKS, Autorisierung (leitet sofort mit Code zurück,
|
||||||
|
* als hätte sich jemand angemeldet) und Token-Endpunkt mit PKCE-Prüfung und RS256-ID-Token.
|
||||||
|
* `naechsteAnsprueche` legt fest, wer sich „anmeldet“; `manipulieren` verfälscht Antworten.
|
||||||
|
*/
|
||||||
|
export class OidcMock {
|
||||||
|
readonly clientId = "proxdeploy";
|
||||||
|
readonly clientSecret = "geheim-fuer-tests";
|
||||||
|
naechsteAnsprueche: Record<string, unknown> = { sub: "u-1", preferred_username: "anna" };
|
||||||
|
manipulieren: { nonce?: string; aud?: string } = {};
|
||||||
|
private server: Server | undefined;
|
||||||
|
private codes = new Map<
|
||||||
|
string,
|
||||||
|
{ nonce: string; challenge: string; redirect: string; ansprueche: Record<string, unknown> }
|
||||||
|
>();
|
||||||
|
private schluessel: Awaited<ReturnType<typeof generateKeyPair>> | undefined;
|
||||||
|
private jwk: JWK | undefined;
|
||||||
|
url = "";
|
||||||
|
|
||||||
|
async starten(): Promise<this> {
|
||||||
|
this.schluessel = await generateKeyPair("RS256");
|
||||||
|
this.jwk = { ...(await exportJWK(this.schluessel.publicKey)), kid: "k1", alg: "RS256", use: "sig" };
|
||||||
|
this.server = createServer((anfrage, antwort) => void this.bearbeiten(anfrage, antwort));
|
||||||
|
await new Promise<void>((fertig) => this.server!.listen(0, "127.0.0.1", fertig));
|
||||||
|
this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stoppen(): Promise<void> {
|
||||||
|
await new Promise<void>((fertig) => this.server?.close(() => fertig()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bearbeiten(
|
||||||
|
anfrage: import("node:http").IncomingMessage,
|
||||||
|
antwort: import("node:http").ServerResponse,
|
||||||
|
): Promise<void> {
|
||||||
|
const url = new URL(anfrage.url ?? "/", this.url);
|
||||||
|
const json = (status: number, daten: unknown) => {
|
||||||
|
antwort.writeHead(status, { "content-type": "application/json" });
|
||||||
|
antwort.end(JSON.stringify(daten));
|
||||||
|
};
|
||||||
|
if (url.pathname === "/.well-known/openid-configuration") {
|
||||||
|
return json(200, {
|
||||||
|
issuer: this.url,
|
||||||
|
authorization_endpoint: `${this.url}/authorize`,
|
||||||
|
token_endpoint: `${this.url}/token`,
|
||||||
|
jwks_uri: `${this.url}/jwks`,
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
subject_types_supported: ["public"],
|
||||||
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/jwks") return json(200, { keys: [this.jwk] });
|
||||||
|
if (url.pathname === "/authorize") {
|
||||||
|
const p = url.searchParams;
|
||||||
|
if (p.get("client_id") !== this.clientId || p.get("code_challenge_method") !== "S256") {
|
||||||
|
return json(400, { error: "invalid_request" });
|
||||||
|
}
|
||||||
|
const code = randomBytes(16).toString("hex");
|
||||||
|
this.codes.set(code, {
|
||||||
|
nonce: p.get("nonce") ?? "",
|
||||||
|
challenge: p.get("code_challenge") ?? "",
|
||||||
|
redirect: p.get("redirect_uri") ?? "",
|
||||||
|
ansprueche: this.naechsteAnsprueche,
|
||||||
|
});
|
||||||
|
const zurueck = new URL(p.get("redirect_uri")!);
|
||||||
|
zurueck.searchParams.set("code", code);
|
||||||
|
zurueck.searchParams.set("state", p.get("state") ?? "");
|
||||||
|
antwort.writeHead(302, { location: zurueck.href });
|
||||||
|
antwort.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/token" && anfrage.method === "POST") {
|
||||||
|
const rumpf = new URLSearchParams(
|
||||||
|
await new Promise<string>((fertig) => {
|
||||||
|
let text = "";
|
||||||
|
anfrage.on("data", (stueck) => (text += stueck));
|
||||||
|
anfrage.on("end", () => fertig(text));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const erwartet = `Basic ${Buffer.from(`${encodeURIComponent(this.clientId)}:${encodeURIComponent(this.clientSecret)}`).toString("base64")}`;
|
||||||
|
// Beide gängigen Arten der Client-Anmeldung, wie authentik und Keycloak auch.
|
||||||
|
const perPost =
|
||||||
|
rumpf.get("client_id") === this.clientId && rumpf.get("client_secret") === this.clientSecret;
|
||||||
|
if (anfrage.headers.authorization !== erwartet && !perPost)
|
||||||
|
return json(401, { error: "invalid_client" });
|
||||||
|
const eintrag = this.codes.get(rumpf.get("code") ?? "");
|
||||||
|
this.codes.delete(rumpf.get("code") ?? "");
|
||||||
|
const challenge = createHash("sha256")
|
||||||
|
.update(rumpf.get("code_verifier") ?? "")
|
||||||
|
.digest("base64url");
|
||||||
|
if (!eintrag || eintrag.challenge !== challenge || eintrag.redirect !== rumpf.get("redirect_uri")) {
|
||||||
|
return json(400, { error: "invalid_grant" });
|
||||||
|
}
|
||||||
|
const idToken = await new SignJWT({
|
||||||
|
...eintrag.ansprueche,
|
||||||
|
nonce: this.manipulieren.nonce ?? eintrag.nonce,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "RS256", kid: "k1" })
|
||||||
|
.setIssuer(this.url)
|
||||||
|
.setAudience(this.manipulieren.aud ?? this.clientId)
|
||||||
|
.setSubject(String(eintrag.ansprueche["sub"]))
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("5m")
|
||||||
|
.sign(this.schluessel!.privateKey);
|
||||||
|
return json(200, { access_token: "zugang", token_type: "Bearer", expires_in: 300, id_token: idToken });
|
||||||
|
}
|
||||||
|
json(404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import * as ich from "@/app/api/v1/ich/route";
|
||||||
|
import * as rueckruf from "@/app/api/v1/auth/oidc/rueckruf/route";
|
||||||
|
import * as start from "@/app/api/v1/auth/oidc/start/route";
|
||||||
|
import { oidcEinstellungen, oidcZwischenspeicherLeeren, rolleAusGruppen } from "@/server/auth/oidc";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet } from "./helpers/anfragen";
|
||||||
|
import { OidcMock } from "./oidc-mock";
|
||||||
|
|
||||||
|
const basis = {
|
||||||
|
issuer: "https://idp",
|
||||||
|
clientId: "x",
|
||||||
|
clientSecret: "",
|
||||||
|
anzeigename: "SSO",
|
||||||
|
gruppenClaim: "groups",
|
||||||
|
gruppeAdmin: "pd-admins",
|
||||||
|
gruppeOperator: "pd-ops",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Rollen aus Gruppen", () => {
|
||||||
|
it("Admin vor Operator, ohne passende Gruppe kein Zugang, ohne konfigurierte Gruppen Operator", () => {
|
||||||
|
expect(rolleAusGruppen(basis, { sub: "a", groups: ["pd-ops", "pd-admins"] })).toBe("ADMIN");
|
||||||
|
expect(rolleAusGruppen(basis, { sub: "a", groups: "pd-ops" })).toBe("OPERATOR");
|
||||||
|
expect(rolleAusGruppen(basis, { sub: "a", groups: ["andere"] })).toBeNull();
|
||||||
|
expect(rolleAusGruppen(basis, { sub: "a" })).toBeNull();
|
||||||
|
expect(rolleAusGruppen({ ...basis, gruppeAdmin: "", gruppeOperator: "" }, { sub: "a" })).toBe("OPERATOR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("OIDC nur mit OIDC_AKTIV=true und Issuer + Client-ID", () => {
|
||||||
|
expect(
|
||||||
|
oidcEinstellungen({ OIDC_AKTIV: "false", OIDC_ISSUER: "https://i", OIDC_CLIENT_ID: "c" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(oidcEinstellungen({ OIDC_AKTIV: "true", OIDC_ISSUER: "https://i" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
oidcEinstellungen({ OIDC_AKTIV: "true", OIDC_ISSUER: "https://i", OIDC_CLIENT_ID: "c" }),
|
||||||
|
).toMatchObject({
|
||||||
|
anzeigename: "SSO",
|
||||||
|
gruppenClaim: "groups",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Anmeldung per OIDC gegen einen Mock-IdP", () => {
|
||||||
|
const idp = new OidcMock();
|
||||||
|
const umgebung = { ...process.env };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await idp.starten();
|
||||||
|
Object.assign(process.env, {
|
||||||
|
OIDC_AKTIV: "true",
|
||||||
|
OIDC_ISSUER: idp.url,
|
||||||
|
OIDC_CLIENT_ID: idp.clientId,
|
||||||
|
OIDC_CLIENT_SECRET: idp.clientSecret,
|
||||||
|
OIDC_GRUPPEN_CLAIM: "groups",
|
||||||
|
OIDC_GRUPPE_ADMIN: "pd-admins",
|
||||||
|
OIDC_GRUPPE_OPERATOR: "pd-ops",
|
||||||
|
APP_URL: "http://proxdeploy.test",
|
||||||
|
});
|
||||||
|
oidcZwischenspeicherLeeren();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
process.env = umgebung;
|
||||||
|
oidcZwischenspeicherLeeren();
|
||||||
|
await idp.stoppen();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
idp.manipulieren = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieAus = (antwort: Response, name: string) =>
|
||||||
|
antwort.headers
|
||||||
|
.getSetCookie()
|
||||||
|
.map((c) => c.split(";")[0]!)
|
||||||
|
.find((c) => c.startsWith(`${name}=`) && c.length > name.length + 1);
|
||||||
|
|
||||||
|
/** Der ganze Weg: Start → IdP → Rückruf. Liefert die Antwort des Rückrufs. */
|
||||||
|
async function durchlaufen(
|
||||||
|
ansprueche: Record<string, unknown>,
|
||||||
|
o: { sitzung?: string; verknuepfen?: boolean; stateFaelschen?: boolean; ohneCookie?: boolean } = {},
|
||||||
|
) {
|
||||||
|
idp.naechsteAnsprueche = ansprueche;
|
||||||
|
const anfang = await start.GET(
|
||||||
|
new Request(
|
||||||
|
`http://intern:3000/api/v1/auth/oidc/start?ziel=/server${o.verknuepfen ? "&verknuepfen=1" : ""}`,
|
||||||
|
{
|
||||||
|
headers: o.sitzung ? { cookie: o.sitzung } : {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(anfang.status).toBe(302);
|
||||||
|
const weiter = new URL(anfang.headers.get("location")!);
|
||||||
|
expect(weiter.origin).toBe(idp.url);
|
||||||
|
// Die redirect_uri ist die öffentliche Adresse (APP_URL), nicht der interne Host.
|
||||||
|
expect(weiter.searchParams.get("redirect_uri")).toBe("http://proxdeploy.test/api/v1/auth/oidc/rueckruf");
|
||||||
|
expect(weiter.searchParams.get("code_challenge_method")).toBe("S256");
|
||||||
|
const ablauf = cookieAus(anfang, "pd_oidc")!;
|
||||||
|
|
||||||
|
const beimIdp = await fetch(weiter, { redirect: "manual" });
|
||||||
|
const zurueck = new URL(beimIdp.headers.get("location")!);
|
||||||
|
if (o.stateFaelschen) zurueck.searchParams.set("state", "gefaelscht");
|
||||||
|
// Der Reverse-Proxy reicht die Anfrage mit internem Host durch.
|
||||||
|
return await rueckruf.GET(
|
||||||
|
new Request(`http://intern:3000${zurueck.pathname}${zurueck.search}`, {
|
||||||
|
headers: o.ohneCookie ? {} : { cookie: [ablauf, o.sitzung].filter(Boolean).join("; ") },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("legt beim ersten Mal ein Konto mit Rolle aus den Gruppen an und meldet an", async () => {
|
||||||
|
const antwort = await durchlaufen({
|
||||||
|
sub: "u-1",
|
||||||
|
preferred_username: "Anna.Ä",
|
||||||
|
name: "Anna Ä",
|
||||||
|
groups: ["pd-admins"],
|
||||||
|
});
|
||||||
|
expect(antwort.status).toBe(302);
|
||||||
|
expect(antwort.headers.get("location")).toBe("http://proxdeploy.test/server");
|
||||||
|
const sitzung = cookieAus(antwort, "pd_sitzung")!;
|
||||||
|
const wer = await (
|
||||||
|
await ich.GET(new Request("http://x/api/v1/ich", { headers: { cookie: sitzung } }))
|
||||||
|
).json();
|
||||||
|
expect(wer).toMatchObject({ benutzername: "anna.a", rolle: "ADMIN" });
|
||||||
|
const b = await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-1" } });
|
||||||
|
expect(b.passwortHash).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt bei jeder Anmeldung die Rolle aus den Gruppen und protokolliert den Wechsel", async () => {
|
||||||
|
await durchlaufen({ sub: "u-1", preferred_username: "anna", groups: ["pd-admins"] });
|
||||||
|
await durchlaufen({ sub: "u-1", preferred_username: "anna", groups: ["pd-ops"] });
|
||||||
|
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-1" } })).rolle).toBe(
|
||||||
|
"OPERATOR",
|
||||||
|
);
|
||||||
|
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { aktion: "Rolle aus SSO-Gruppen übernommen" },
|
||||||
|
});
|
||||||
|
expect(eintrag.details).toEqual({ rolle: ["ADMIN", "OPERATOR"] });
|
||||||
|
expect(await testPrisma.benutzer.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne passende Gruppe: kein Konto, Hinweis auf der Anmeldeseite", async () => {
|
||||||
|
const antwort = await durchlaufen({ sub: "u-2", preferred_username: "gast", groups: ["andere"] });
|
||||||
|
expect(antwort.headers.get("location")).toBe("http://proxdeploy.test/anmelden?sso=keine_gruppe");
|
||||||
|
expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined();
|
||||||
|
expect(await testPrisma.benutzer.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt nie ein lokales Konto über den Namen", async () => {
|
||||||
|
const lokal = await angemeldet("ADMIN", "admin");
|
||||||
|
await durchlaufen({ sub: "u-3", preferred_username: "admin", groups: ["pd-ops"] });
|
||||||
|
const neu = await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-3" } });
|
||||||
|
expect(neu.benutzername).toBe("admin-2");
|
||||||
|
expect(neu.rolle).toBe("OPERATOR");
|
||||||
|
expect(
|
||||||
|
(await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).oidcSubjekt,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verknüpfen nur angemeldet; danach landet die SSO-Anmeldung im lokalen Konto", async () => {
|
||||||
|
const lokal = await angemeldet("OPERATOR", "florian");
|
||||||
|
const ohne = await start.GET(new Request("http://intern:3000/api/v1/auth/oidc/start?verknuepfen=1"));
|
||||||
|
expect(ohne.status).toBe(401);
|
||||||
|
|
||||||
|
const verknuepft = await durchlaufen(
|
||||||
|
{ sub: "u-4", preferred_username: "fw", groups: ["pd-admins"] },
|
||||||
|
{ sitzung: lokal.cookie, verknuepfen: true },
|
||||||
|
);
|
||||||
|
expect(verknuepft.headers.get("location")).toBe("http://proxdeploy.test/einstellungen");
|
||||||
|
expect(
|
||||||
|
(await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).oidcSubjekt,
|
||||||
|
).toBe("u-4");
|
||||||
|
|
||||||
|
await durchlaufen({ sub: "u-4", preferred_username: "fw", groups: ["pd-admins"] });
|
||||||
|
expect(await testPrisma.benutzer.count()).toBe(1);
|
||||||
|
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).rolle).toBe(
|
||||||
|
"ADMIN",
|
||||||
|
);
|
||||||
|
|
||||||
|
const anderer = await angemeldet("OPERATOR", "zweiter");
|
||||||
|
const doppelt = await durchlaufen(
|
||||||
|
{ sub: "u-4", groups: ["pd-ops"] },
|
||||||
|
{ sitzung: anderer.cookie, verknuepfen: true },
|
||||||
|
);
|
||||||
|
expect(doppelt.headers.get("location")).toBe("http://proxdeploy.test/einstellungen?sso=schon_verknuepft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gesperrt bleibt gesperrt", async () => {
|
||||||
|
await durchlaufen({ sub: "u-5", preferred_username: "max", groups: ["pd-ops"] });
|
||||||
|
await testPrisma.benutzer.update({ where: { oidcSubjekt: "u-5" }, data: { aktiv: false } });
|
||||||
|
const antwort = await durchlaufen({ sub: "u-5", preferred_username: "max", groups: ["pd-ops"] });
|
||||||
|
expect(antwort.headers.get("location")).toContain("sso=gesperrt");
|
||||||
|
expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["gefälschter State", { stateFaelschen: true }, {}, "idp"],
|
||||||
|
["fehlendes Ablauf-Cookie", { ohneCookie: true }, {}, "abgelaufen"],
|
||||||
|
["falsche Nonce im ID-Token", {}, { nonce: "fremd" }, "idp"],
|
||||||
|
["ID-Token für einen anderen Client", {}, { aud: "anderer-client" }, "idp"],
|
||||||
|
] as const)("%s wird abgewiesen", async (_name, optionen, manipulation, code) => {
|
||||||
|
idp.manipulieren = { ...manipulation };
|
||||||
|
const antwort = await durchlaufen(
|
||||||
|
{ sub: "u-6", preferred_username: "eve", groups: ["pd-admins"] },
|
||||||
|
optionen,
|
||||||
|
);
|
||||||
|
expect(antwort.headers.get("location")).toBe(`http://proxdeploy.test/anmelden?sso=${code}`);
|
||||||
|
expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined();
|
||||||
|
expect(await testPrisma.benutzer.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { Server, utils } from "ssh2";
|
import { Server, utils } from "ssh2";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
|
import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,7 @@ export class SshMock {
|
|||||||
|
|
||||||
async starten(): Promise<this> {
|
async starten(): Promise<this> {
|
||||||
// ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
|
// ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
|
||||||
const hostPem = utils.generateKeyPairSync("ed25519").private;
|
const hostPem = ed25519Erzeugen().privat;
|
||||||
const host = utils.parseKey(hostPem);
|
const host = utils.parseKey(hostPem);
|
||||||
if (host instanceof Error) throw host;
|
if (host instanceof Error) throw host;
|
||||||
this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
|
this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
|
||||||
@@ -64,3 +65,9 @@ export class SshMock {
|
|||||||
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
|
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Schlüsselpaar für Tests im Format von ssh2 (`public`/`private`) – immer lesbar. */
|
||||||
|
export function testPaar(): { public: string; private: string } {
|
||||||
|
const paar = ed25519Erzeugen();
|
||||||
|
return { public: paar.oeffentlich, private: paar.privat };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { utils } from "ssh2";
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
|
import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
|
||||||
import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
|
import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
|
||||||
import { verschluesseln } from "@/lib/geheimnisse";
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
|
||||||
import { szenarioEinrichten } from "./proxmox-mock/szenario";
|
import { szenarioEinrichten } from "./proxmox-mock/szenario";
|
||||||
import { SshMock } from "./ssh-mock";
|
import { SshMock, testPaar } from "./ssh-mock";
|
||||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
const mock = new ProxmoxMock();
|
const mock = new ProxmoxMock();
|
||||||
const paar = utils.generateKeyPairSync("ed25519");
|
const paar = testPaar();
|
||||||
let ssh: SshMock;
|
let ssh: SshMock;
|
||||||
|
|
||||||
describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
|
describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import * as liste from "@/app/api/v1/auftraege/route";
|
||||||
|
import * as aufraeumenRoute from "@/app/api/v1/auftraege/[id]/aufraeumen/route";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { auftragHolen, verlaufFilterAus, VERLAUF_PRO_SEITE } from "@/server/services/auftraege";
|
||||||
|
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
describe("Verlaufsfilter aus der URL", () => {
|
||||||
|
it("übernimmt Gültiges und lässt Unsinn still fallen", () => {
|
||||||
|
expect(verlaufFilterAus({ typ: "lxc", status: "FEHLGESCHLAGEN", seite: "3", server: "rz1" })).toEqual({
|
||||||
|
typ: "lxc",
|
||||||
|
status: "FEHLGESCHLAGEN",
|
||||||
|
seite: 3,
|
||||||
|
server: "rz1",
|
||||||
|
});
|
||||||
|
expect(verlaufFilterAus({ typ: "quatsch", status: "x", seite: "-4", distribution: "" })).toEqual({
|
||||||
|
seite: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Verlauf und Aufräumen", () => {
|
||||||
|
let cookie: string;
|
||||||
|
let benutzerId: string;
|
||||||
|
let serverIds: Record<string, string>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await katalogSynchronisieren();
|
||||||
|
const op = await angemeldet("OPERATOR");
|
||||||
|
cookie = op.cookie;
|
||||||
|
benutzerId = op.benutzer.id;
|
||||||
|
serverIds = {};
|
||||||
|
for (const name of ["rz1", "rz2"]) {
|
||||||
|
serverIds[name] = (
|
||||||
|
await testPrisma.server.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
titel: name,
|
||||||
|
url: "https://127.0.0.1:8006",
|
||||||
|
tokenId: "a@pve!b",
|
||||||
|
tokenGeheimnis: verschluesseln("x"),
|
||||||
|
fingerabdruck: "AA",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let nr = 0;
|
||||||
|
const auftrag = async (daten: Record<string, unknown>) =>
|
||||||
|
await testPrisma.auftrag.create({
|
||||||
|
data: {
|
||||||
|
kurzId: `d-t${String(nr++).padStart(4, "0")}`,
|
||||||
|
art: "AUSROLLEN",
|
||||||
|
status: "ERFOLGREICH",
|
||||||
|
quelle: "WEB",
|
||||||
|
serverId: serverIds["rz1"]!,
|
||||||
|
erstelltVonId: benutzerId,
|
||||||
|
anfrage: {},
|
||||||
|
...daten,
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert nach Server, Typ, Distribution und Status und blättert", async () => {
|
||||||
|
await auftrag({ gastTyp: "VM", distributionId: "debian-13" });
|
||||||
|
await auftrag({ gastTyp: "LXC", distributionId: "ubuntu-24.04", status: "FEHLGESCHLAGEN" });
|
||||||
|
await auftrag({ gastTyp: "LXC", distributionId: "debian-13", serverId: serverIds["rz2"] });
|
||||||
|
await auftrag({ art: "TEMPLATE_BAUEN", gastTyp: "VM", distributionId: "debian-13" });
|
||||||
|
|
||||||
|
const holen = async (abfrage: string) =>
|
||||||
|
(await (await liste.GET(anfrage("GET", `/api/v1/auftraege?${abfrage}`, cookie))).json()) as {
|
||||||
|
eintraege: Array<{ kurzId: string; art: string; gastTyp: string }>;
|
||||||
|
gesamt: number;
|
||||||
|
seiten: number;
|
||||||
|
};
|
||||||
|
expect((await holen("")).gesamt).toBe(4);
|
||||||
|
expect((await holen("typ=lxc")).gesamt).toBe(2);
|
||||||
|
expect((await holen("typ=vm")).gesamt).toBe(1);
|
||||||
|
expect((await holen("typ=template")).eintraege[0]!.art).toBe("TEMPLATE_BAUEN");
|
||||||
|
expect((await holen("server=rz2")).gesamt).toBe(1);
|
||||||
|
expect((await holen("distribution=debian-13&typ=lxc")).gesamt).toBe(1);
|
||||||
|
expect((await holen("status=FEHLGESCHLAGEN")).gesamt).toBe(1);
|
||||||
|
|
||||||
|
for (let i = 0; i < VERLAUF_PRO_SEITE; i++) await auftrag({ gastTyp: "VM" });
|
||||||
|
const erste = await holen("");
|
||||||
|
const zweite = await holen("seite=2");
|
||||||
|
expect(erste.seiten).toBe(2);
|
||||||
|
expect(erste.eintraege).toHaveLength(VERLAUF_PRO_SEITE);
|
||||||
|
expect(zweite.eintraege).toHaveLength(4);
|
||||||
|
expect(new Set([...erste.eintraege, ...zweite.eintraege].map((e) => e.kurzId)).size).toBe(
|
||||||
|
VERLAUF_PRO_SEITE + 4,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const starten = async (kurzId: string) =>
|
||||||
|
await aufraeumenRoute.POST(
|
||||||
|
anfrage("POST", `/api/v1/auftraege/${kurzId}/aufraeumen`, cookie),
|
||||||
|
kontext({ id: kurzId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Aufräumen nur bei Fehlschlag/Abbruch mit angelegtem Gast, nie doppelt", async () => {
|
||||||
|
const ok = await auftrag({ gastTyp: "VM", angelegt: { vmid: 120, knoten: "pve01", typ: "VM" } });
|
||||||
|
const nichts = await auftrag({ gastTyp: "VM", status: "FEHLGESCHLAGEN", angelegt: { knoten: "pve01" } });
|
||||||
|
const kaputt = await auftrag({
|
||||||
|
gastTyp: "LXC",
|
||||||
|
name: "ct01",
|
||||||
|
status: "ABGEBROCHEN",
|
||||||
|
angelegt: { vmid: 121, knoten: "pve01", typ: "LXC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await auftragHolen(ok.kurzId)).aufraeumbar).toBeNull();
|
||||||
|
expect((await auftragHolen(nichts.kurzId)).aufraeumbar).toBeNull();
|
||||||
|
expect((await auftragHolen(kaputt.kurzId)).aufraeumbar).toEqual({
|
||||||
|
typ: "LXC",
|
||||||
|
vmid: 121,
|
||||||
|
knoten: "pve01",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await starten(ok.kurzId)).status).toBe(409);
|
||||||
|
expect((await starten(nichts.kurzId)).status).toBe(409);
|
||||||
|
const gestartet = await starten(kaputt.kurzId);
|
||||||
|
expect(gestartet.status).toBe(202);
|
||||||
|
const { kurzId } = (await gestartet.json()) as { kurzId: string };
|
||||||
|
expect(kurzId).toMatch(/^c-/);
|
||||||
|
const neu = await testPrisma.auftrag.findUniqueOrThrow({ where: { kurzId } });
|
||||||
|
expect(neu).toMatchObject({ art: "AUFRAEUMEN", bezugId: kaputt.id, gastTyp: "LXC", name: "ct01" });
|
||||||
|
expect(neu.anfrage).toEqual({ auftrag: kaputt.kurzId, typ: "LXC", vmid: 121, knoten: "pve01" });
|
||||||
|
|
||||||
|
const doppelt = await starten(kaputt.kurzId);
|
||||||
|
expect(doppelt.status).toBe(409);
|
||||||
|
expect((await doppelt.json()).fehler.nachricht).toContain(kurzId);
|
||||||
|
expect(await testPrisma.protokoll.count({ where: { aktion: "Aufräumen gestartet" } })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,10 @@ const AUSNAHMEN: Record<string, string> = {
|
|||||||
"src/app/api/v1/auth/token/route.ts":
|
"src/app/api/v1/auth/token/route.ts":
|
||||||
"proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)",
|
"proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)",
|
||||||
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
|
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
|
||||||
|
"src/app/api/v1/auth/oidc/start/route.ts":
|
||||||
|
"SSO-Start ist der Weg zur Anmeldung; Verknüpfen prüft die Sitzung selbst",
|
||||||
|
"src/app/api/v1/auth/oidc/rueckruf/route.ts":
|
||||||
|
"Rückkehr vom IdP; prüft State, PKCE, Nonce und ID-Token selbst",
|
||||||
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
|
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
|
||||||
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
||||||
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
|
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
|
||||||
|
|||||||
Reference in New Issue
Block a user