feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es vor dem Speichern und bestätigt den SHA-256-Fingerprint. Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch Pin ist im Schema, im Client und per CHECK ausgeschlossen. Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock und im gebauten Stand auf Desktop und 390 px. Closes #002 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- Anmeldung mit lokalem Benutzer; der erste Administrator entsteht aus `ADMIN_BENUTZER`/`ADMIN_PASSWORT` (#001)
|
- Anmeldung mit lokalem Benutzer; der erste Administrator entsteht aus `ADMIN_BENUTZER`/`ADMIN_PASSWORT` (#001)
|
||||||
- Seitenleiste mit Wortmarke, auf dem Handy als Schublade; Übersichtsseite (#001)
|
- Seitenleiste mit Wortmarke, auf dem Handy als Schublade; Übersichtsseite (#001)
|
||||||
- `/api/v1/gesundheit` und `/api/v1/ich` (#001)
|
- `/api/v1/gesundheit` und `/api/v1/ich` (#001)
|
||||||
|
- Server-Verwaltung: Proxmox-Server anlegen, bearbeiten, löschen; Zertifikat ansehen und Fingerprint pinnen; Verbindung vor dem Speichern prüfen (#002)
|
||||||
|
- Optionales SSH-Schlüsselpaar je Server mit Einzeiler zum Hinterlegen auf den Nodes (#002)
|
||||||
|
- Server-Umschalter in der Seitenleiste, Serverliste auf der Übersicht (#002)
|
||||||
|
- Proxmox-Client mit verständlichen Fehlern (Ursache + Hinweis, fehlendes Recht samt Pfad) und Warten auf Proxmox-Tasks (#002)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
@@ -25,3 +29,4 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001)
|
- Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001)
|
||||||
- Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001)
|
- Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001)
|
||||||
- Das Audit-Log ist per Datenbank-Trigger nur anhängbar (#001)
|
- Das Audit-Log ist per Datenbank-Trigger nur anhängbar (#001)
|
||||||
|
- Proxmox-Token-Secret und SSH-Privatschlüssel liegen nur verschlüsselt vor und verlassen den Server nie; TLS ohne Prüfung oder Pin ist ausgeschlossen (#002)
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 0 h 50 min (laufende Sitzung)
|
**Gesamt:** 1 h 01 min (laufende Sitzung)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| #001 Projekt-Setup | 0 h 50 min |
|
| #001 Projekt-Setup | 0 h 50 min |
|
||||||
|
| #002 Server-Verwaltung | 0 h 11 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -27,6 +28,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft
|
- Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft
|
||||||
- Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt
|
- Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt
|
||||||
- Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt
|
- Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt
|
||||||
|
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
|
||||||
|
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
|
||||||
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
|
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
|
||||||
|
|
||||||
### Entscheidungen
|
### Entscheidungen
|
||||||
@@ -39,4 +42,5 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- **Konfiguration aus Laufzettel übernommen** (gleicher Stack, Versionen dort bereits erprobt); Next auf 16.4.0 wie ProxLedger (Sicherheitsstand).
|
- **Konfiguration aus Laufzettel übernommen** (gleicher Stack, Versionen dort bereits erprobt); Next auf 16.4.0 wie ProxLedger (Sicherheitsstand).
|
||||||
- **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern.
|
- **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern.
|
||||||
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
||||||
|
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
|
||||||
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
# #002 Server-Verwaltung und Verschlüsselung
|
# #002 Server-Verwaltung und Verschlüsselung
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** feature, sicherheit
|
**Labels:** feature, sicherheit
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 11 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Proxmox-Server anlegen, bearbeiten, löschen; Geheimnisse verschlüsselt.
|
Proxmox-Server anlegen, bearbeiten, löschen; Geheimnisse verschlüsselt.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] AES-256-GCM-Helfer mit Versionspräfix, Tests (falscher Schlüssel, manipulierte Daten)
|
- [x] AES-256-GCM-Helfer mit Versionspräfix, Tests (falscher Schlüssel, manipulierte Daten) – mit #001
|
||||||
- [ ] Modell `Server`, Services, REST `/api/v1/server`, Formular mit Token-ID/Secret
|
- [x] Modell `Server`, Services, REST `/api/v1/server`, Formular mit Token-ID/Secret
|
||||||
- [ ] TLS: Fingerprint holen (`POST /server/pruefen`), anzeigen, bestätigen, pinnen
|
- [x] TLS: Fingerprint holen (`POST /server/pruefen`), anzeigen, bestätigen, pinnen
|
||||||
- [ ] Proxmox-Client mit undici-Agent und Fingerprint-Vergleich, UPID-Warten
|
- [x] Proxmox-Client mit undici-Agent und Fingerprint-Vergleich, UPID-Warten
|
||||||
- [ ] SSH optional: Schlüsselpaar erzeugen, Einzeiler anzeigen, Host-Key pinnen
|
- [x] SSH optional: Schlüsselpaar erzeugen, Einzeiler anzeigen (Host-Key-Pin folgt mit dem SSH-Test in #003)
|
||||||
- [ ] Protokolleintrag je Änderung
|
- [x] Protokolleintrag je Änderung (geänderte Felder, nie Werte von Geheimnissen)
|
||||||
|
- [x] Server-Umschalter in der Seitenleiste, Serverliste auf der Übersicht
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Secret erscheint nie in API-Antworten oder Logs
|
- [x] Secret erscheint nie in API-Antworten, Protokoll oder Logs (Test)
|
||||||
- [ ] Falscher Fingerprint → klare Fehlermeldung, keine Verbindung
|
- [x] Falscher Fingerprint → klare Fehlermeldung, Verbindung getrennt **bevor** das Token gesendet wird (Test)
|
||||||
|
- [x] Playwright: Anlegen gegen Proxmox-Mock mit selbst signiertem Zertifikat, Desktop und 390 px
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **TLS-Sitzungs-Cache aus:** eine wiederaufgenommene TLS-Sitzung schickt kein Zertifikat, der Pin wäre nicht prüfbar.
|
||||||
|
- **„Weder TLS-Prüfung noch Pin“ ist unmöglich** – im Schema, im Client und als CHECK in der Datenbank.
|
||||||
|
- **Adresse ohne Schema/Port** wird zu `https://host:8006` ergänzt.
|
||||||
|
- **ssh2 ohne native Beschleuniger** (`allowBuilds: false`): reines JavaScript reicht, kein Compiler im Image.
|
||||||
|
- Proxmox schreibt Fehlermeldungen in die HTTP-Statuszeile; `Permission check failed (/pfad, Recht)` wird zu einem Hinweis mit Recht und Pfad.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
|
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
|
||||||
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | offen | v0.1.0 | 6 h | – |
|
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
|
||||||
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – |
|
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – |
|
||||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
|
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
|
||||||
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
|
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
|
"ssh2": "1.17.0",
|
||||||
|
"undici": "8.11.2",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/ssh2": "1.15.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3310";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
|
globalSetup: "./tests/e2e/global-setup.ts",
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
forbidOnly: Boolean(process.env["CI"]),
|
forbidOnly: Boolean(process.env["CI"]),
|
||||||
|
|||||||
Generated
+88
@@ -205,6 +205,12 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.3.0
|
specifier: 19.3.0
|
||||||
version: 19.3.0(react@19.3.0)
|
version: 19.3.0(react@19.3.0)
|
||||||
|
ssh2:
|
||||||
|
specifier: 1.17.0
|
||||||
|
version: 1.17.0
|
||||||
|
undici:
|
||||||
|
specifier: 8.11.2
|
||||||
|
version: 8.11.2
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.6.5
|
specifier: ^4.6.5
|
||||||
version: 4.6.5
|
version: 4.6.5
|
||||||
@@ -233,6 +239,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.3.0
|
specifier: ^19.3.0
|
||||||
version: 19.3.0(@types/react@19.3.0)
|
version: 19.3.0(@types/react@19.3.0)
|
||||||
|
'@types/ssh2':
|
||||||
|
specifier: 1.15.6
|
||||||
|
version: 1.15.6
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.4.2
|
specifier: ^17.4.2
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
@@ -1600,6 +1609,9 @@ packages:
|
|||||||
'@types/lodash@4.17.26':
|
'@types/lodash@4.17.26':
|
||||||
resolution: {integrity: sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==}
|
resolution: {integrity: sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||||
|
|
||||||
'@types/node@24.19.2':
|
'@types/node@24.19.2':
|
||||||
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
||||||
|
|
||||||
@@ -1614,6 +1626,9 @@ packages:
|
|||||||
'@types/react@19.3.0':
|
'@types/react@19.3.0':
|
||||||
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
||||||
|
|
||||||
|
'@types/ssh2@1.15.6':
|
||||||
|
resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.71.1':
|
'@typescript-eslint/eslint-plugin@8.71.1':
|
||||||
resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==}
|
resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1913,6 +1928,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1956,6 +1974,9 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||||
|
|
||||||
better-result@2.10.0:
|
better-result@2.10.0:
|
||||||
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
|
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
|
||||||
|
|
||||||
@@ -1975,6 +1996,10 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
buildcheck@0.0.7:
|
||||||
|
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
c12@3.3.4:
|
c12@3.3.4:
|
||||||
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2077,6 +2102,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3087,6 +3116,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
nan@2.29.0:
|
||||||
|
resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==}
|
||||||
|
|
||||||
nanoid@3.3.20:
|
nanoid@3.3.20:
|
||||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -3545,6 +3577,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
ssh2@1.17.0:
|
||||||
|
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||||
|
engines: {node: '>=10.16.0'}
|
||||||
|
|
||||||
stable-hash@0.0.5:
|
stable-hash@0.0.5:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
|
|
||||||
@@ -3677,6 +3713,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tweetnacl@0.14.5:
|
||||||
|
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3713,9 +3752,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
undici-types@5.26.5:
|
||||||
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici-types@7.24.6:
|
undici-types@7.24.6:
|
||||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||||
|
|
||||||
|
undici@8.11.2:
|
||||||
|
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
|
||||||
|
engines: {node: '>=22.19.0'}
|
||||||
|
|
||||||
unrs-resolver@1.12.2:
|
unrs-resolver@1.12.2:
|
||||||
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
|
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
|
||||||
|
|
||||||
@@ -4966,6 +5012,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/lodash@4.17.26': {}
|
'@types/lodash@4.17.26': {}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
|
||||||
'@types/node@24.19.2':
|
'@types/node@24.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.24.6
|
undici-types: 7.24.6
|
||||||
@@ -4984,6 +5034,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/ssh2@1.15.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 18.19.130
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
|
'@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -5334,6 +5388,10 @@ snapshots:
|
|||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
is-array-buffer: 3.0.5
|
is-array-buffer: 3.0.5
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
ast-types-flow@0.0.8: {}
|
ast-types-flow@0.0.8: {}
|
||||||
@@ -5358,6 +5416,10 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.11.28: {}
|
baseline-browser-mapping@2.11.28: {}
|
||||||
|
|
||||||
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
tweetnacl: 0.14.5
|
||||||
|
|
||||||
better-result@2.10.0: {}
|
better-result@2.10.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.21:
|
brace-expansion@1.1.21:
|
||||||
@@ -5381,6 +5443,9 @@ snapshots:
|
|||||||
node-releases: 2.0.58
|
node-releases: 2.0.58
|
||||||
update-browserslist-db: 1.3.4(browserslist@4.29.3)
|
update-browserslist-db: 1.3.4(browserslist@4.29.3)
|
||||||
|
|
||||||
|
buildcheck@0.0.7:
|
||||||
|
optional: true
|
||||||
|
|
||||||
c12@3.3.4:
|
c12@3.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
@@ -5483,6 +5548,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
dependencies:
|
||||||
|
buildcheck: 0.0.7
|
||||||
|
nan: 2.29.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -6611,6 +6682,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru.min: 1.1.5
|
lru.min: 1.1.5
|
||||||
|
|
||||||
|
nan@2.29.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nanoid@3.3.20: {}
|
nanoid@3.3.20: {}
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
@@ -7120,6 +7194,14 @@ snapshots:
|
|||||||
|
|
||||||
sqlstring@2.3.3: {}
|
sqlstring@2.3.3: {}
|
||||||
|
|
||||||
|
ssh2@1.17.0:
|
||||||
|
dependencies:
|
||||||
|
asn1: 0.2.6
|
||||||
|
bcrypt-pbkdf: 1.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
cpu-features: 0.0.10
|
||||||
|
nan: 2.29.0
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
@@ -7260,6 +7342,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
tweetnacl@0.14.5: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -7316,8 +7400,12 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@7.24.6: {}
|
undici-types@7.24.6: {}
|
||||||
|
|
||||||
|
undici@8.11.2: {}
|
||||||
|
|
||||||
unrs-resolver@1.12.2:
|
unrs-resolver@1.12.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
napi-postinstall: 0.3.4
|
napi-postinstall: 0.3.4
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
|
# ssh2 und cpu-features übersetzen nur optionale native Beschleuniger. Ohne sie läuft ssh2
|
||||||
|
# in reinem JavaScript – das reicht für ein paar Verbindungen je Deployment und erspart
|
||||||
|
# einen Compiler im Docker-Image.
|
||||||
|
cpu-features: false
|
||||||
|
ssh2: false
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Server" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"tokenId" TEXT NOT NULL,
|
||||||
|
"tokenGeheimnis" TEXT NOT NULL,
|
||||||
|
"tlsPruefen" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"fingerabdruck" TEXT,
|
||||||
|
"sshAktiv" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"sshBenutzer" TEXT NOT NULL DEFAULT 'root',
|
||||||
|
"sshPort" INTEGER NOT NULL DEFAULT 22,
|
||||||
|
"sshPrivat" TEXT,
|
||||||
|
"sshOeffentlich" TEXT,
|
||||||
|
"sshHostSchluessel" JSONB,
|
||||||
|
"templateVmidVon" INTEGER NOT NULL DEFAULT 9000,
|
||||||
|
"templateVmidBis" INTEGER NOT NULL DEFAULT 9999,
|
||||||
|
"gastVmidVon" INTEGER NOT NULL DEFAULT 100,
|
||||||
|
"bauNetz" JSONB,
|
||||||
|
"standards" JSONB,
|
||||||
|
"faehigkeiten" JSONB,
|
||||||
|
"erreichbar" BOOLEAN,
|
||||||
|
"pveVersion" TEXT,
|
||||||
|
"geprueftAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Server_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Server_name_key" ON "Server"("name");
|
||||||
|
|
||||||
|
-- Bereiche müssen sinnvoll sein: Proxmox erlaubt VMIDs von 100 bis 999999999.
|
||||||
|
ALTER TABLE "Server"
|
||||||
|
ADD CONSTRAINT "server_vmid_bereiche" CHECK (
|
||||||
|
"templateVmidVon" >= 100 AND "templateVmidVon" < "templateVmidBis" AND "templateVmidBis" <= 999999999
|
||||||
|
AND "gastVmidVon" >= 100 AND "gastVmidVon" <= 999999999
|
||||||
|
),
|
||||||
|
ADD CONSTRAINT "server_name_format" CHECK ("name" ~ '^[a-z0-9][a-z0-9-]{0,31}$'),
|
||||||
|
ADD CONSTRAINT "server_ssh_port" CHECK ("sshPort" BETWEEN 1 AND 65535),
|
||||||
|
-- Ohne TLS-Prüfung ist ein gepinnter Fingerprint Pflicht – sonst wäre jede Verbindung blind.
|
||||||
|
ADD CONSTRAINT "server_tls_oder_pin" CHECK ("tlsPruefen" OR "fingerabdruck" IS NOT NULL);
|
||||||
@@ -61,3 +61,42 @@ model Einstellung {
|
|||||||
wert Json
|
wert Json
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Proxmox-VE-Einzelhost oder -Cluster. Geheimnisse liegen AES-256-GCM-verschlüsselt (v1.…).
|
||||||
|
model Server {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Kurzname, auch für die CLI (`--server rz1`).
|
||||||
|
name String @unique
|
||||||
|
/// Anzeigename, z. B. „Rechenzentrum 1“.
|
||||||
|
titel String
|
||||||
|
/// https://host:8006
|
||||||
|
url String
|
||||||
|
/// user@realm!tokenid
|
||||||
|
tokenId String
|
||||||
|
tokenGeheimnis String
|
||||||
|
/// Aus: nur ein gepinnter Fingerprint zählt. An: Systemvertrauen (+ optional Pin).
|
||||||
|
tlsPruefen Boolean @default(false)
|
||||||
|
/// SHA-256 des Serverzertifikats, Großbuchstaben mit Doppelpunkten.
|
||||||
|
fingerabdruck String?
|
||||||
|
sshAktiv Boolean @default(false)
|
||||||
|
sshBenutzer String @default("root")
|
||||||
|
sshPort Int @default(22)
|
||||||
|
sshPrivat String?
|
||||||
|
sshOeffentlich String?
|
||||||
|
/// Gepinnte Host-Keys je Node: { "pve01": "SHA256:…" }.
|
||||||
|
sshHostSchluessel Json?
|
||||||
|
templateVmidVon Int @default(9000)
|
||||||
|
templateVmidBis Int @default(9999)
|
||||||
|
gastVmidVon Int @default(100)
|
||||||
|
/// Statische Adresse für die Template-Vorbereitung ohne SSH: { bridge, vlan, ip, gateway }.
|
||||||
|
bauNetz Json?
|
||||||
|
/// Standard-Node, -Storage, -Bridge für den Assistenten.
|
||||||
|
standards Json?
|
||||||
|
/// Ergebnis der letzten Fähigkeitserkennung (#003).
|
||||||
|
faehigkeiten Json?
|
||||||
|
erreichbar Boolean?
|
||||||
|
pveVersion String?
|
||||||
|
geprueftAm DateTime?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
geaendertAm DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut und startet den gebauten Stand auf Port 3310 neu (für Playwright-Abläufe).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
alt=$(ss -ltnp 2>/dev/null | awk '/127.0.0.1:3310|:3310 /' | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u || true)
|
||||||
|
[ -n "$alt" ] && kill $alt && sleep 1
|
||||||
|
pnpm build > /tmp/proxdeploy-build.log 2>&1 || { tail -30 /tmp/proxdeploy-build.log; exit 1; }
|
||||||
|
PORT=3310 APP_URL=http://127.0.0.1:3310 nohup pnpm start > /tmp/proxdeploy-start.log 2>&1 &
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -sf http://127.0.0.1:3310/api/v1/gesundheit > /dev/null && { echo "läuft auf :3310"; exit 0; }
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "Start fehlgeschlagen:"; tail -20 /tmp/proxdeploy-start.log; exit 1
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Seitenleiste } from "@/components/seitenleiste";
|
import { Seitenleiste } from "@/components/seitenleiste";
|
||||||
|
import { ServerUmschalter } from "@/components/server-umschalter";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
import { NAVIGATION } from "@/lib/navigation";
|
import { NAVIGATION } from "@/lib/navigation";
|
||||||
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
||||||
import { versionText } from "@/lib/version";
|
import { versionText } from "@/lib/version";
|
||||||
@@ -15,7 +18,16 @@ export default async function AppHuelle({ children }: { children: React.ReactNod
|
|||||||
const benutzer = await aktuellerBenutzer();
|
const benutzer = await aktuellerBenutzer();
|
||||||
if (!benutzer) redirect("/anmelden");
|
if (!benutzer) redirect("/anmelden");
|
||||||
|
|
||||||
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
|
const speicher = await cookies();
|
||||||
|
const theme = themeAusCookie(speicher.get(THEME_COOKIE)?.value);
|
||||||
|
const server = await prisma.server.findMany({
|
||||||
|
select: { name: true, titel: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
const aktiv = aktiverServer(
|
||||||
|
server.map((eintrag) => eintrag.name),
|
||||||
|
speicher.get(SERVER_COOKIE)?.value,
|
||||||
|
);
|
||||||
const eintraege = NAVIGATION.filter((eintrag) => !eintrag.ab && darf(benutzer.rolle, eintrag.recht));
|
const eintraege = NAVIGATION.filter((eintrag) => !eintrag.ab && darf(benutzer.rolle, eintrag.recht));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -25,7 +37,7 @@ export default async function AppHuelle({ children }: { children: React.ReactNod
|
|||||||
benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }}
|
benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }}
|
||||||
version={versionText()}
|
version={versionText()}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
serverUmschalter={null}
|
serverUmschalter={<ServerUmschalter server={server} aktiv={aktiv} />}
|
||||||
/>
|
/>
|
||||||
<main id="inhalt" className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-10">
|
<main id="inhalt" className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-10">
|
||||||
<div className="mx-auto max-w-6xl">{children}</div>
|
<div className="mx-auto max-w-6xl">{children}</div>
|
||||||
|
|||||||
+29
-4
@@ -1,12 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Karte } from "@/components/karte";
|
import { Karte } from "@/components/karte";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
|
import Link from "next/link";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { serverListe } from "@/server/services/server";
|
||||||
|
import { ServerStatus } from "./server/server-status";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Übersicht" };
|
export const metadata: Metadata = { title: "Übersicht" };
|
||||||
|
|
||||||
export default async function Uebersicht() {
|
export default async function Uebersicht() {
|
||||||
const benutzer = await seiteSchuetzen("lesen", "/");
|
const benutzer = await seiteSchuetzen("lesen", "/");
|
||||||
|
const server = await serverListe();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -16,11 +20,32 @@ export default async function Uebersicht() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Karte titel="Server">
|
<Karte
|
||||||
|
titel="Server"
|
||||||
|
aktion={
|
||||||
|
<Link href="/server" className="text-akzent text-sm">
|
||||||
|
Alle
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{server.length === 0 ? (
|
||||||
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
||||||
<p className="text-muted mt-2 text-sm">
|
) : (
|
||||||
Server lassen sich anlegen, sobald die Server-Verwaltung (#002) fertig ist.
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
</p>
|
{server.map((eintrag) => (
|
||||||
|
<li
|
||||||
|
key={eintrag.name}
|
||||||
|
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<Link href={`/server/${eintrag.name}`} className="min-w-0 hover:underline">
|
||||||
|
<span className="font-medium">{eintrag.titel}</span>{" "}
|
||||||
|
<span className="text-muted font-mono text-sm">{eintrag.name}</span>
|
||||||
|
</Link>
|
||||||
|
<ServerStatus server={eintrag} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
<Karte titel="Letzte Deployments">
|
<Karte titel="Letzte Deployments">
|
||||||
<Status art="offen">Noch keine Deployments.</Status>
|
<Status art="offen">Noch keine Deployments.</Status>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { serverSicht } from "@/server/services/server";
|
||||||
|
import { ServerFormular } from "../../server-formular";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Server bearbeiten" };
|
||||||
|
|
||||||
|
export default async function ServerBearbeiten({ params }: { params: Promise<{ name: string }> }) {
|
||||||
|
const { name } = await params;
|
||||||
|
await seiteSchuetzen("serverVerwalten", `/server/${name}/bearbeiten`);
|
||||||
|
const server = await prisma.server.findUnique({ where: { name } });
|
||||||
|
if (!server) notFound();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{server.titel} bearbeiten</h1>
|
||||||
|
<ServerFormular server={serverSicht(server)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Code } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { serverSicht } from "@/server/services/server";
|
||||||
|
import { ServerStatus } from "../server-status";
|
||||||
|
import { ServerAktionen } from "./server-aktionen";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
|
||||||
|
return { title: (await params).name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServerDetail({ params }: { params: Promise<{ name: string }> }) {
|
||||||
|
const { name } = await params;
|
||||||
|
const benutzer = await seiteSchuetzen("lesen", `/server/${name}`);
|
||||||
|
const datensatz = await prisma.server.findUnique({ where: { name } });
|
||||||
|
if (!datensatz) notFound();
|
||||||
|
const server = serverSicht(datensatz);
|
||||||
|
const verwalten = darf(benutzer.rolle, "serverVerwalten");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-muted font-mono text-sm">{server.name}</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{server.titel}</h1>
|
||||||
|
</div>
|
||||||
|
{verwalten ? (
|
||||||
|
<Link
|
||||||
|
href={`/server/${server.name}/bearbeiten`}
|
||||||
|
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center gap-2 rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
<Pencil size={16} aria-hidden="true" /> Bearbeiten
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Karte titel="Verbindung" aktion={<ServerStatus server={server} />}>
|
||||||
|
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_1fr]">
|
||||||
|
<dt className="text-muted">Adresse</dt>
|
||||||
|
<dd className="font-mono break-all">{server.url}</dd>
|
||||||
|
<dt className="text-muted">Token-ID</dt>
|
||||||
|
<dd className="font-mono break-all">{server.tokenId}</dd>
|
||||||
|
<dt className="text-muted">TLS</dt>
|
||||||
|
<dd>
|
||||||
|
{server.fingerabdruck ? (
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span>Fingerprint gepinnt{server.tlsPruefen ? " und Kette geprüft" : ""}</span>
|
||||||
|
<span className="text-muted font-mono text-xs break-all">{server.fingerabdruck}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Kette gegen Systemzertifikate geprüft"
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Templates</dt>
|
||||||
|
<dd className="font-mono">
|
||||||
|
{server.templateVmidVon}–{server.templateVmidBis}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Gäste ab</dt>
|
||||||
|
<dd className="font-mono">{server.gastVmidVon}</dd>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="SSH zu den Nodes (optional)">
|
||||||
|
{!server.sshAktiv ? (
|
||||||
|
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
|
||||||
|
) : server.sshOeffentlich ? (
|
||||||
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Diesen Schlüssel auf jedem Node für <span className="font-mono">{server.sshBenutzer}</span>{" "}
|
||||||
|
hinterlegen. Im Cluster genügt ein Node:{" "}
|
||||||
|
<span className="font-mono">/root/.ssh/authorized_keys</span> zeigt dort auf die gemeinsame
|
||||||
|
Datei in <span className="font-mono">/etc/pve/priv/</span>.
|
||||||
|
</p>
|
||||||
|
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
|
||||||
|
<p className="text-muted">
|
||||||
|
Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Status art="laeuft">SSH ist eingeschaltet, aber es gibt noch keinen Schlüssel.</Status>
|
||||||
|
)}
|
||||||
|
{verwalten && server.sshAktiv ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<ServerAktionen name={server.name} art="ssh" erneuern={Boolean(server.sshOeffentlich)} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{verwalten ? (
|
||||||
|
<Karte titel="Gefahrenzone">
|
||||||
|
<ServerAktionen name={server.name} art="loeschen" />
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
|
||||||
|
/** Schreibende Knöpfe der Serverseite. Löschen und Erneuern verlangen eine zweite Bestätigung. */
|
||||||
|
export function ServerAktionen({
|
||||||
|
name,
|
||||||
|
art,
|
||||||
|
erneuern = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
art: "ssh" | "loeschen";
|
||||||
|
erneuern?: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [bestaetigen, setBestaetigen] = useState(false);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function ausfuehren() {
|
||||||
|
if ((art === "loeschen" || erneuern) && !bestaetigen) {
|
||||||
|
setBestaetigen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLaeuft(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
if (art === "loeschen") {
|
||||||
|
await apiAufruf("DELETE", `/api/v1/server/${name}`);
|
||||||
|
router.push("/server");
|
||||||
|
} else {
|
||||||
|
await apiAufruf("POST", `/api/v1/server/${name}/ssh`);
|
||||||
|
}
|
||||||
|
setBestaetigen(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch (ursache) {
|
||||||
|
setFehler(ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.");
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text =
|
||||||
|
art === "loeschen"
|
||||||
|
? bestaetigen
|
||||||
|
? "Wirklich löschen"
|
||||||
|
: "Server löschen"
|
||||||
|
: erneuern
|
||||||
|
? bestaetigen
|
||||||
|
? "Neuen Schlüssel erzeugen – der alte wird ungültig"
|
||||||
|
: "Schlüssel erneuern"
|
||||||
|
: "Schlüssel erzeugen";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{art === "loeschen" ? (
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Entfernt nur den Eintrag in ProxDeploy. VMs und Container auf dem Server bleiben unberührt.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf variante={art === "loeschen" ? "gefahr" : "sekundaer"} onClick={ausfuehren} disabled={laeuft}>
|
||||||
|
{laeuft ? "Bitte warten …" : text}
|
||||||
|
</Knopf>
|
||||||
|
{bestaetigen ? (
|
||||||
|
<Knopf variante="sekundaer" onClick={() => setBestaetigen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div aria-live="polite">{fehler ? <Status art="fehler">{fehler}</Status> : null}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { ServerFormular } from "../server-formular";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Server anlegen" };
|
||||||
|
|
||||||
|
export default async function ServerNeu() {
|
||||||
|
await seiteSchuetzen("serverVerwalten", "/server/neu");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server anlegen</h1>
|
||||||
|
<ServerFormular />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { serverListe } from "@/server/services/server";
|
||||||
|
import { ServerStatus } from "./server-status";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Server" };
|
||||||
|
|
||||||
|
export default async function ServerSeite() {
|
||||||
|
const benutzer = await seiteSchuetzen("lesen", "/server");
|
||||||
|
const liste = await serverListe();
|
||||||
|
const verwalten = darf(benutzer.rolle, "serverVerwalten");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server</h1>
|
||||||
|
<p className="text-muted text-sm">Proxmox-VE-Hosts und -Cluster, auf die ProxDeploy ausrollt.</p>
|
||||||
|
</div>
|
||||||
|
{verwalten ? (
|
||||||
|
<Link
|
||||||
|
href="/server/neu"
|
||||||
|
className="bg-akzent text-akzent-text inline-flex h-11 items-center gap-2 rounded-lg px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
<Plus size={18} aria-hidden="true" /> Server anlegen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
<Karte>
|
||||||
|
<Status art="offen">Noch kein Server angelegt.</Status>
|
||||||
|
{!verwalten ? <p className="text-muted mt-2 text-sm">Server legt ein Administrator an.</p> : null}
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 md:grid-cols-2">
|
||||||
|
{liste.map((server) => (
|
||||||
|
<li key={server.name}>
|
||||||
|
<Link
|
||||||
|
href={`/server/${server.name}`}
|
||||||
|
className="bg-panel border-linie rounded-karte hover:border-muted flex flex-col gap-3 border p-5 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold">{server.titel}</p>
|
||||||
|
<p className="text-muted truncate font-mono text-sm">{server.name}</p>
|
||||||
|
</div>
|
||||||
|
<ServerStatus server={server} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted truncate font-mono text-xs">{server.url}</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } 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";
|
||||||
|
import type { ServerSicht } from "@/lib/schemas/server";
|
||||||
|
|
||||||
|
type Zertifikat = {
|
||||||
|
fingerabdruck: string;
|
||||||
|
subjekt: string;
|
||||||
|
aussteller: string;
|
||||||
|
gueltigBis: string;
|
||||||
|
alternativeNamen: string[];
|
||||||
|
vertraut: boolean;
|
||||||
|
fehlerGrund: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PruefErgebnis = {
|
||||||
|
zertifikat: Zertifikat | null;
|
||||||
|
pinPasst: boolean | null;
|
||||||
|
verbindung:
|
||||||
|
| { ok: true; version: string; release: string }
|
||||||
|
| { ok: false; art: string; ursache: string; hinweis: string }
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Werte = {
|
||||||
|
name: string;
|
||||||
|
titel: string;
|
||||||
|
url: string;
|
||||||
|
tokenId: string;
|
||||||
|
tokenGeheimnis: string;
|
||||||
|
tlsPruefen: boolean;
|
||||||
|
fingerabdruck: string | null;
|
||||||
|
sshAktiv: boolean;
|
||||||
|
sshBenutzer: string;
|
||||||
|
sshPort: string;
|
||||||
|
templateVmidVon: string;
|
||||||
|
templateVmidBis: string;
|
||||||
|
gastVmidVon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function anfangswerte(server?: ServerSicht): Werte {
|
||||||
|
return {
|
||||||
|
name: server?.name ?? "",
|
||||||
|
titel: server?.titel ?? "",
|
||||||
|
url: server?.url ?? "",
|
||||||
|
tokenId: server?.tokenId ?? "",
|
||||||
|
tokenGeheimnis: "",
|
||||||
|
tlsPruefen: server?.tlsPruefen ?? false,
|
||||||
|
fingerabdruck: server?.fingerabdruck ?? null,
|
||||||
|
sshAktiv: server?.sshAktiv ?? false,
|
||||||
|
sshBenutzer: server?.sshBenutzer ?? "root",
|
||||||
|
sshPort: String(server?.sshPort ?? 22),
|
||||||
|
templateVmidVon: String(server?.templateVmidVon ?? 9000),
|
||||||
|
templateVmidBis: String(server?.templateVmidBis ?? 9999),
|
||||||
|
gastVmidVon: String(server?.gastVmidVon ?? 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anlegen und Bearbeiten eines Proxmox-Servers. Ablauf: Adresse → Zertifikat ansehen und
|
||||||
|
* bestätigen → Token → Verbindung prüfen → speichern. Speichern geht auch ohne erfolgreiche
|
||||||
|
* Prüfung (der Host kann gerade aus sein), aber nie ohne bestätigten Fingerprint oder TLS-Prüfung.
|
||||||
|
*/
|
||||||
|
export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const bearbeiten = Boolean(server);
|
||||||
|
const [werte, setWerte] = useState<Werte>(() => anfangswerte(server));
|
||||||
|
const [pruefung, setPruefung] = useState<PruefErgebnis | null>(null);
|
||||||
|
const [prueft, setPrueft] = useState<"zertifikat" | "verbindung" | null>(null);
|
||||||
|
const [speichert, setSpeichert] = useState(false);
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({});
|
||||||
|
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||||
|
|
||||||
|
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => {
|
||||||
|
setWerte((alt) => ({ ...alt, [feld]: wert }));
|
||||||
|
// Eine neue Adresse macht das gesehene Zertifikat und den Pin wertlos.
|
||||||
|
if (feld === "url") {
|
||||||
|
setPruefung(null);
|
||||||
|
setWerte((alt) => ({ ...alt, fingerabdruck: null }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function pruefen(was: "zertifikat" | "verbindung") {
|
||||||
|
setPrueft(was);
|
||||||
|
setMeldung(null);
|
||||||
|
try {
|
||||||
|
const ergebnis = await apiAufruf<PruefErgebnis>("POST", "/api/v1/server/pruefen", {
|
||||||
|
url: werte.url,
|
||||||
|
name: server?.name,
|
||||||
|
tokenId: was === "verbindung" ? werte.tokenId : undefined,
|
||||||
|
tokenGeheimnis: was === "verbindung" ? werte.tokenGeheimnis : undefined,
|
||||||
|
tlsPruefen: werte.tlsPruefen,
|
||||||
|
fingerabdruck: werte.fingerabdruck,
|
||||||
|
});
|
||||||
|
setPruefung(ergebnis);
|
||||||
|
// Ein vertrauenswürdiges Zertifikat (ACME) braucht keinen Pin – TLS-Prüfung genügt.
|
||||||
|
if (was === "zertifikat" && ergebnis.zertifikat?.vertraut && !werte.fingerabdruck) {
|
||||||
|
setWerte((alt) => ({ ...alt, tlsPruefen: true }));
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung(fehlerText(fehler));
|
||||||
|
if (fehler instanceof ApiFehler) setFelder(fehler.felder);
|
||||||
|
} finally {
|
||||||
|
setPrueft(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
setSpeichert(true);
|
||||||
|
setFelder({});
|
||||||
|
setMeldung(null);
|
||||||
|
const rumpf = {
|
||||||
|
...werte,
|
||||||
|
sshPort: Number(werte.sshPort),
|
||||||
|
templateVmidVon: Number(werte.templateVmidVon),
|
||||||
|
templateVmidBis: Number(werte.templateVmidBis),
|
||||||
|
gastVmidVon: Number(werte.gastVmidVon),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const gespeichert = bearbeiten
|
||||||
|
? await apiAufruf<ServerSicht>("PATCH", `/api/v1/server/${server!.name}`, rumpf)
|
||||||
|
: await apiAufruf<ServerSicht>("POST", "/api/v1/server", rumpf);
|
||||||
|
router.push(`/server/${gespeichert.name}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung(fehlerText(fehler));
|
||||||
|
if (fehler instanceof ApiFehler) setFelder(fehler.felder);
|
||||||
|
} finally {
|
||||||
|
setSpeichert(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zertifikat = pruefung?.zertifikat;
|
||||||
|
const gepinnt = Boolean(werte.fingerabdruck);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={speichern} className="flex flex-col gap-6" noValidate>
|
||||||
|
<Karte titel="Kennung">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="name" label="Kurzname" hilfe="Für die CLI: --server rz1" fehler={felder["name"]}>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.name}
|
||||||
|
onChange={(e) => setze("name", e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
{...beschreibung("name", felder["name"], true)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="titel" label="Anzeigename" fehler={felder["titel"]}>
|
||||||
|
<input
|
||||||
|
id="titel"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.titel}
|
||||||
|
onChange={(e) => setze("titel", e.target.value)}
|
||||||
|
placeholder="Rechenzentrum 1"
|
||||||
|
{...beschreibung("titel", felder["titel"])}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Adresse und Zertifikat">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<Feld
|
||||||
|
id="url"
|
||||||
|
label="Adresse"
|
||||||
|
hilfe="Ein Node des Clusters, z. B. https://pve01.example:8006"
|
||||||
|
fehler={felder["url"]}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="url"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.url}
|
||||||
|
onChange={(e) => setze("url", e.target.value)}
|
||||||
|
placeholder="https://pve01:8006"
|
||||||
|
inputMode="url"
|
||||||
|
spellCheck={false}
|
||||||
|
{...beschreibung("url", felder["url"], true)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
onClick={() => pruefen("zertifikat")}
|
||||||
|
disabled={!werte.url || prueft !== null}
|
||||||
|
className="sm:mb-[1.625rem]"
|
||||||
|
>
|
||||||
|
{prueft === "zertifikat" ? "Holt …" : "Zertifikat holen"}
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zertifikat ? (
|
||||||
|
<div className="border-linie flex flex-col gap-3 rounded-lg border p-4">
|
||||||
|
<Status art={zertifikat.vertraut ? "erledigt" : "laeuft"}>
|
||||||
|
{zertifikat.vertraut
|
||||||
|
? "Das System vertraut diesem Zertifikat."
|
||||||
|
: "Selbst signiertes Zertifikat – bitte den Fingerprint vergleichen."}
|
||||||
|
</Status>
|
||||||
|
<dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
|
||||||
|
<dt className="text-muted">Subjekt</dt>
|
||||||
|
<dd className="break-all">{zertifikat.subjekt}</dd>
|
||||||
|
<dt className="text-muted">Aussteller</dt>
|
||||||
|
<dd className="break-all">{zertifikat.aussteller}</dd>
|
||||||
|
<dt className="text-muted">Gültig bis</dt>
|
||||||
|
<dd>{new Date(zertifikat.gueltigBis).toLocaleDateString("de-DE")}</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted text-sm">SHA-256-Fingerprint</span>
|
||||||
|
<Code>{zertifikat.fingerabdruck}</Code>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> oder in
|
||||||
|
der Proxmox-Oberfläche unter Node → System → Zertifikate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Schalter
|
||||||
|
id="pin"
|
||||||
|
label="Fingerprint stimmt – Zertifikat pinnen"
|
||||||
|
hilfe="ProxDeploy verbindet sich dann nur, solange genau dieses Zertifikat ausgeliefert wird."
|
||||||
|
checked={werte.fingerabdruck === zertifikat.fingerabdruck}
|
||||||
|
onChange={(an) =>
|
||||||
|
setWerte((alt) => ({ ...alt, fingerabdruck: an ? zertifikat.fingerabdruck : null }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : gepinnt ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Status art="erledigt">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ShieldCheck size={16} aria-hidden="true" /> Gepinnter Fingerprint
|
||||||
|
</span>
|
||||||
|
</Status>
|
||||||
|
<Code>{werte.fingerabdruck!}</Code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{felder["fingerabdruck"] ? (
|
||||||
|
<p className="text-fehler text-sm">
|
||||||
|
<span aria-hidden="true">✗ </span>
|
||||||
|
{felder["fingerabdruck"]}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Schalter
|
||||||
|
id="tlsPruefen"
|
||||||
|
label="Zertifikatskette prüfen"
|
||||||
|
hilfe="Nur einschalten, wenn der Node ein Zertifikat einer vertrauenswürdigen CA hat (z. B. ACME)."
|
||||||
|
checked={werte.tlsPruefen}
|
||||||
|
onChange={(an) => setze("tlsPruefen", an)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="API-Token">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="tokenId" label="Token-ID" hilfe="benutzer@realm!tokenname" fehler={felder["tokenId"]}>
|
||||||
|
<input
|
||||||
|
id="tokenId"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.tokenId}
|
||||||
|
onChange={(e) => setze("tokenId", e.target.value)}
|
||||||
|
placeholder="proxdeploy@pve!deploy"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
{...beschreibung("tokenId", felder["tokenId"], true)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="tokenGeheimnis"
|
||||||
|
label="Secret"
|
||||||
|
hilfe={bearbeiten ? "Leer lassen, um das gespeicherte Secret zu behalten." : undefined}
|
||||||
|
fehler={felder["tokenGeheimnis"]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="tokenGeheimnis"
|
||||||
|
type="password"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.tokenGeheimnis}
|
||||||
|
onChange={(e) => setze("tokenGeheimnis", e.target.value)}
|
||||||
|
placeholder={bearbeiten ? "unverändert" : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...beschreibung("tokenGeheimnis", felder["tokenGeheimnis"], bearbeiten)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
onClick={() => pruefen("verbindung")}
|
||||||
|
disabled={
|
||||||
|
prueft !== null ||
|
||||||
|
!werte.url ||
|
||||||
|
!werte.tokenId ||
|
||||||
|
(!werte.tokenGeheimnis && !bearbeiten) ||
|
||||||
|
(!werte.tlsPruefen && !gepinnt)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{prueft === "verbindung" ? "Prüft …" : "Verbindung prüfen"}
|
||||||
|
</Knopf>
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{pruefung?.verbindung?.ok ? (
|
||||||
|
<Status art="erledigt">
|
||||||
|
Verbunden · Proxmox VE <span className="font-mono">{pruefung.verbindung.version}</span>
|
||||||
|
</Status>
|
||||||
|
) : pruefung?.verbindung && !pruefung.verbindung.ok ? (
|
||||||
|
<div>
|
||||||
|
<Status art="fehler">{pruefung.verbindung.ursache}</Status>
|
||||||
|
<p className="text-muted mt-1">{pruefung.verbindung.hinweis}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Erweitert">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld id="templateVmidVon" label="Templates ab VMID" fehler={felder["templateVmidVon"]}>
|
||||||
|
<input
|
||||||
|
id="templateVmidVon"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.templateVmidVon}
|
||||||
|
onChange={(e) => setze("templateVmidVon", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="templateVmidBis" label="Templates bis VMID" fehler={felder["templateVmidBis"]}>
|
||||||
|
<input
|
||||||
|
id="templateVmidBis"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.templateVmidBis}
|
||||||
|
onChange={(e) => setze("templateVmidBis", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="gastVmidVon" label="Gäste ab VMID" fehler={felder["gastVmidVon"]}>
|
||||||
|
<input
|
||||||
|
id="gastVmidVon"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.gastVmidVon}
|
||||||
|
onChange={(e) => setze("gastVmidVon", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Schalter
|
||||||
|
id="sshAktiv"
|
||||||
|
label="SSH zu den Nodes nutzen"
|
||||||
|
hilfe="Optional. Nötig für cloud-init-Snippets, Profile in LXC-Containern ohne Netzweg und Template-Bau vor PVE 8.4."
|
||||||
|
checked={werte.sshAktiv}
|
||||||
|
onChange={(an) => setze("sshAktiv", an)}
|
||||||
|
/>
|
||||||
|
{werte.sshAktiv ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
id="sshBenutzer"
|
||||||
|
label="SSH-Benutzer"
|
||||||
|
hilfe="root – für pct und /var/lib/vz/snippets nötig."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="sshBenutzer"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.sshBenutzer}
|
||||||
|
onChange={(e) => setze("sshBenutzer", e.target.value)}
|
||||||
|
{...beschreibung("sshBenutzer", undefined, true)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="sshPort" label="SSH-Port" fehler={felder["sshPort"]}>
|
||||||
|
<input
|
||||||
|
id="sshPort"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.sshPort}
|
||||||
|
onChange={(e) => setze("sshPort", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
<div aria-live="polite" className="text-sm sm:mr-auto">
|
||||||
|
{meldung ? (
|
||||||
|
<div>
|
||||||
|
<Status art="fehler">{meldung.text}</Status>
|
||||||
|
{meldung.hinweis ? <p className="text-muted mt-1">{meldung.hinweis}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Knopf variante="sekundaer" onClick={() => router.back()}>
|
||||||
|
Abbrechen
|
||||||
|
</Knopf>
|
||||||
|
<Knopf type="submit" disabled={speichert}>
|
||||||
|
{speichert ? "Speichert …" : bearbeiten ? "Änderungen speichern" : "Server anlegen"}
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehlerText(fehler: unknown): { text: string; hinweis?: string } {
|
||||||
|
if (fehler instanceof ApiFehler) return { text: fehler.message, hinweis: fehler.hinweis };
|
||||||
|
return { text: "Unerwarteter Fehler." };
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Status } from "@/components/status";
|
||||||
|
import type { ServerSicht } from "@/lib/schemas/server";
|
||||||
|
|
||||||
|
/** Erreichbarkeit nach der letzten Prüfung – „ungeprüft“, solange es keine gab. */
|
||||||
|
export function ServerStatus({ server }: { server: Pick<ServerSicht, "erreichbar" | "pveVersion"> }) {
|
||||||
|
if (server.erreichbar === null) return <Status art="offen">ungeprüft</Status>;
|
||||||
|
if (!server.erreichbar) return <Status art="fehler">nicht erreichbar</Status>;
|
||||||
|
return (
|
||||||
|
<Status art="erledigt">
|
||||||
|
PVE <span className="font-mono">{server.pveVersion}</span>
|
||||||
|
</Status>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { serverAendern, serverHolen, serverLoeschen, serverSicht } from "@/server/services/server";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ name: string }> };
|
||||||
|
|
||||||
|
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(serverSicht(await serverHolen((await params).name)));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||||
|
const server = await serverAendern(
|
||||||
|
(await params).name,
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
return NextResponse.json(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||||
|
await serverLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { sshSchluesselErzeugen } from "@/server/services/server";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ name: string }> };
|
||||||
|
|
||||||
|
/** Erzeugt oder erneuert das SSH-Schlüsselpaar; der private Teil verlässt den Server nie. */
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||||
|
return NextResponse.json(
|
||||||
|
await sshSchluesselErzeugen((await params).name, aufrufer, aufruferAdresse(anfrage.headers)),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { serverPruefen } from "@/server/services/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft Angaben vor dem Speichern: Zertifikat zum Bestätigen holen, optional ein erster Aufruf.
|
||||||
|
* Nur für Admins – der Endpunkt baut Verbindungen zu beliebigen Adressen auf.
|
||||||
|
*/
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "serverVerwalten");
|
||||||
|
return NextResponse.json(await serverPruefen(await jsonLesen(anfrage)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { serverAnlegen, serverListe } from "@/server/services/server";
|
||||||
|
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(await serverListe());
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||||
|
const server = await serverAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
return NextResponse.json(server, { status: 201 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
|
||||||
|
* 44 px hoch auf dem Handy.
|
||||||
|
*/
|
||||||
|
export const EINGABE_KLASSE =
|
||||||
|
"bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 placeholder:text-muted focus:border-akzent disabled:opacity-60 sm:h-10";
|
||||||
|
|
||||||
|
export function Feld({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hilfe,
|
||||||
|
fehler,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hilfe?: React.ReactNode;
|
||||||
|
fehler?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||||
|
<label htmlFor={id} className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{fehler ? (
|
||||||
|
<p id={`${id}-fehler`} className="text-fehler text-sm">
|
||||||
|
<span aria-hidden="true">✗ </span>
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
) : hilfe ? (
|
||||||
|
<p id={`${id}-hilfe`} className="text-muted text-sm">
|
||||||
|
{hilfe}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verknüpft ein Eingabefeld mit Hilfe- oder Fehlertext. */
|
||||||
|
export function beschreibung(id: string, fehler?: string, hilfe?: unknown): Record<string, string | boolean> {
|
||||||
|
if (fehler) return { "aria-describedby": `${id}-fehler`, "aria-invalid": true };
|
||||||
|
if (hilfe) return { "aria-describedby": `${id}-hilfe` };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Schalter({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hilfe,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hilfe?: React.ReactNode;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (wert: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(ereignis) => onChange(ereignis.target.checked)}
|
||||||
|
className="accent-akzent mt-0.5 h-5 w-5 shrink-0"
|
||||||
|
{...(hilfe ? { "aria-describedby": `${id}-hilfe` } : {})}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={id} className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{hilfe ? (
|
||||||
|
<p id={`${id}-hilfe`} className="text-muted text-sm">
|
||||||
|
{hilfe}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text in JetBrains Mono mit Knopf zum Kopieren (Befehle, Fingerprints, Schlüssel). */
|
||||||
|
export function Code({ children, className }: { children: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className={clsx(
|
||||||
|
"bg-grund border-linie block rounded-lg border px-3 py-2 font-mono text-xs break-all sm:text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcher Server gerade im Blick ist. Steht im Cookie, damit Übersicht, Templates und Assistent
|
||||||
|
* ihn serverseitig kennen; ein Wechsel lädt nur die Daten neu.
|
||||||
|
*/
|
||||||
|
export function ServerUmschalter({
|
||||||
|
server,
|
||||||
|
aktiv,
|
||||||
|
}: {
|
||||||
|
server: { name: string; titel: string }[];
|
||||||
|
aktiv: string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
if (server.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 px-2">
|
||||||
|
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
||||||
|
Server
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="server-umschalter"
|
||||||
|
value={aktiv ?? ""}
|
||||||
|
onChange={(ereignis) => {
|
||||||
|
document.cookie = `${SERVER_COOKIE}=${encodeURIComponent(ereignis.target.value)}; path=/; max-age=31536000; samesite=lax`;
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
className="bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 font-mono text-sm sm:h-10"
|
||||||
|
>
|
||||||
|
{server.map((eintrag) => (
|
||||||
|
<option key={eintrag.name} value={eintrag.name}>
|
||||||
|
{eintrag.name} · {eintrag.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { pfad, type ProxmoxClient } from "./client";
|
||||||
|
import { ProxmoxFehler } from "./fehler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viele Proxmox-Aufrufe (clone, create, download-url, start) antworten sofort mit einer UPID und
|
||||||
|
* laufen im Hintergrund weiter. Fertig ist erst, wenn der Task `stopped` mit `exitstatus: OK` meldet.
|
||||||
|
*/
|
||||||
|
type TaskStatus = { status: "running" | "stopped"; exitstatus?: string };
|
||||||
|
type LogZeile = { n: number; t: string };
|
||||||
|
|
||||||
|
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
|
||||||
|
export function nodeAusUpid(upid: string): string {
|
||||||
|
const teile = upid.split(":");
|
||||||
|
if (teile[0] !== "UPID" || !teile[1]) {
|
||||||
|
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
|
||||||
|
}
|
||||||
|
return teile[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AbwartenOptionen = {
|
||||||
|
intervallMs?: number;
|
||||||
|
zeitlimitMs?: number;
|
||||||
|
/** Wird mit neuen Log-Zeilen des Tasks aufgerufen (z. B. Download-Fortschritt). */
|
||||||
|
beiLog?: (zeilen: string[]) => void;
|
||||||
|
warten?: (ms: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||||
|
|
||||||
|
export async function aufgabeAbwarten(
|
||||||
|
client: ProxmoxClient,
|
||||||
|
upid: string,
|
||||||
|
optionen: AbwartenOptionen = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const node = nodeAusUpid(upid);
|
||||||
|
const intervall = optionen.intervallMs ?? 1000;
|
||||||
|
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
|
||||||
|
const warten = optionen.warten ?? schlafen;
|
||||||
|
const beginn = Date.now();
|
||||||
|
let gelesen = 0;
|
||||||
|
|
||||||
|
const logLesen = async (): Promise<string[]> => {
|
||||||
|
const zeilen = await client.get<LogZeile[]>(pfad`/nodes/${node}/tasks/${upid}/log`, {
|
||||||
|
start: gelesen,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
// Proxmox antwortet ohne neue Zeilen mit [{n:…, t:"no content"}].
|
||||||
|
const neu = zeilen.filter((zeile) => zeile.n > gelesen && zeile.t !== "no content");
|
||||||
|
if (neu.length > 0) gelesen = neu[neu.length - 1]!.n;
|
||||||
|
return neu.map((zeile) => zeile.t);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const status = await client.get<TaskStatus>(pfad`/nodes/${node}/tasks/${upid}/status`);
|
||||||
|
if (optionen.beiLog) {
|
||||||
|
const neu = await logLesen();
|
||||||
|
if (neu.length > 0) optionen.beiLog(neu);
|
||||||
|
}
|
||||||
|
if (status.status === "stopped") {
|
||||||
|
if (status.exitstatus === "OK" || status.exitstatus?.startsWith("WARNINGS")) return;
|
||||||
|
const letzte = optionen.beiLog ? [] : (await logLesen()).slice(-5);
|
||||||
|
throw new ProxmoxFehler(
|
||||||
|
"aufgabe",
|
||||||
|
`Proxmox-Task fehlgeschlagen: ${status.exitstatus ?? "unbekannt"}`,
|
||||||
|
letzte.length > 0
|
||||||
|
? `Letzte Zeilen: ${letzte.join(" | ")}`
|
||||||
|
: "Task-Log im Detail-Log oder in Proxmox prüfen.",
|
||||||
|
{ pfad: upid },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Date.now() - beginn > zeitlimit) {
|
||||||
|
throw new ProxmoxFehler(
|
||||||
|
"zeitueberschreitung",
|
||||||
|
`Proxmox-Task läuft länger als ${Math.round(zeitlimit / 60000)} min.`,
|
||||||
|
"In Proxmox nachsehen, ob er hängt; ProxDeploy bricht ihn nicht selbst ab.",
|
||||||
|
{ pfad: upid },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await warten(intervall);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Agent, fetch } from "undici";
|
||||||
|
import { TOKEN_ID_MUSTER } from "@/lib/schemas/server";
|
||||||
|
import { antwortFehler, ProxmoxFehler, verbindungsFehler } from "./fehler";
|
||||||
|
import { tlsConnector, type TlsEinstellung } from "./tls";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugang zur Proxmox-VE-API mit einem API-Token. Bewusst schlank: Pfade und Parameter sind die
|
||||||
|
* der Proxmox-API (siehe pve.proxmox.com/pve-docs/api-viewer), damit Dry-Run und Log genau das
|
||||||
|
* zeigen, was Proxmox bekommt.
|
||||||
|
*/
|
||||||
|
export type ProxmoxVerbindung = TlsEinstellung & {
|
||||||
|
url: string;
|
||||||
|
tokenId: string;
|
||||||
|
tokenGeheimnis: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Parameter = Record<string, string | number | boolean | readonly string[] | undefined>;
|
||||||
|
|
||||||
|
export type Methode = "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
|
||||||
|
/** Ein Aufruf, wie er im Dry-Run und im Detail-Log erscheint. */
|
||||||
|
export type Aufruf = { methode: Methode; pfad: string; parameter?: Parameter };
|
||||||
|
|
||||||
|
export type AufrufBeobachter = (
|
||||||
|
aufruf: Aufruf,
|
||||||
|
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const STANDARD_ZEITLIMIT_MS = 30_000;
|
||||||
|
|
||||||
|
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||||
|
export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams {
|
||||||
|
const kodiert = new URLSearchParams();
|
||||||
|
for (const [name, wert] of Object.entries(parameter ?? {})) {
|
||||||
|
if (wert === undefined) continue;
|
||||||
|
if (Array.isArray(wert)) {
|
||||||
|
for (const eintrag of wert) kodiert.append(name, eintrag);
|
||||||
|
} else if (typeof wert === "boolean") {
|
||||||
|
kodiert.append(name, wert ? "1" : "0");
|
||||||
|
} else {
|
||||||
|
kodiert.append(name, String(wert));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kodiert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pfadteile wie Node-Namen oder Storage-IDs sicher einsetzen: `pfad\`/nodes/${n}\``. */
|
||||||
|
export function pfad(teile: TemplateStringsArray, ...werte: Array<string | number>): string {
|
||||||
|
return teile.reduce(
|
||||||
|
(ergebnis, teil, index) =>
|
||||||
|
ergebnis + teil + (index < werte.length ? encodeURIComponent(String(werte[index])) : ""),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxmoxClient {
|
||||||
|
readonly basis: string;
|
||||||
|
private readonly agent: Agent;
|
||||||
|
private readonly kopf: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly verbindung: ProxmoxVerbindung,
|
||||||
|
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
|
||||||
|
) {
|
||||||
|
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||||
|
throw new ProxmoxFehler(
|
||||||
|
"anmeldung",
|
||||||
|
`„${verbindung.tokenId}“ ist keine gültige Token-ID.`,
|
||||||
|
"Format: benutzer@realm!tokenname, z. B. proxdeploy@pve!deploy",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.basis = verbindung.url.replace(/\/+$/, "") + "/api2/json";
|
||||||
|
this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 10_000 });
|
||||||
|
this.kopf = `PVEAPIToken=${verbindung.tokenId}=${verbindung.tokenGeheimnis}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||||
|
return await this.aufrufen<T>({ methode: "GET", pfad, parameter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||||
|
return await this.aufrufen<T>({ methode: "POST", pfad, parameter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||||
|
return await this.aufrufen<T>({ methode: "PUT", pfad, parameter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||||
|
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
|
||||||
|
const beginn = Date.now();
|
||||||
|
try {
|
||||||
|
const ergebnis = await this.senden<T>(aufruf);
|
||||||
|
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
||||||
|
return ergebnis;
|
||||||
|
} catch (fehler) {
|
||||||
|
const proxmoxFehler =
|
||||||
|
fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url);
|
||||||
|
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler });
|
||||||
|
throw proxmoxFehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async senden<T>(aufruf: Aufruf): Promise<T> {
|
||||||
|
const kodiert = parameterKodieren(aufruf.parameter);
|
||||||
|
const mitRumpf = aufruf.methode === "POST" || aufruf.methode === "PUT";
|
||||||
|
const ziel = this.basis + aufruf.pfad + (!mitRumpf && kodiert.size > 0 ? `?${kodiert}` : "");
|
||||||
|
|
||||||
|
const antwort = await fetch(ziel, {
|
||||||
|
method: aufruf.methode,
|
||||||
|
dispatcher: this.agent,
|
||||||
|
headers: {
|
||||||
|
authorization: this.kopf,
|
||||||
|
accept: "application/json",
|
||||||
|
...(mitRumpf ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
||||||
|
},
|
||||||
|
body: mitRumpf ? kodiert.toString() : undefined,
|
||||||
|
signal: AbortSignal.timeout(this.optionen.zeitlimitMs ?? STANDARD_ZEITLIMIT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await antwort.text();
|
||||||
|
let koerper: unknown = null;
|
||||||
|
try {
|
||||||
|
koerper = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
// Manche Fehlerantworten von pveproxy sind kein JSON; dann zählt die Statuszeile.
|
||||||
|
}
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw antwortFehler(antwort.status, antwort.statusText, koerper, aufruf.pfad);
|
||||||
|
}
|
||||||
|
return (koerper as { data: T } | null)?.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async schliessen(): Promise<void> {
|
||||||
|
await this.agent.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Fehler aus dem Gespräch mit Proxmox – mit Ursache und Lösungshinweis, so wie sie in Web und
|
||||||
|
* CLI unter einer fehlgeschlagenen Phase stehen. Die Art bestimmt den Hinweis; der Text von
|
||||||
|
* Proxmox selbst bleibt in `ursache` erhalten, weil er oft das Entscheidende enthält.
|
||||||
|
*/
|
||||||
|
export type ProxmoxFehlerArt =
|
||||||
|
| "verbindung"
|
||||||
|
| "tls"
|
||||||
|
| "fingerabdruck"
|
||||||
|
| "anmeldung"
|
||||||
|
| "recht"
|
||||||
|
| "nicht_gefunden"
|
||||||
|
| "parameter"
|
||||||
|
| "aufgabe"
|
||||||
|
| "zeitueberschreitung"
|
||||||
|
| "api";
|
||||||
|
|
||||||
|
export class ProxmoxFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly art: ProxmoxFehlerArt,
|
||||||
|
readonly ursache: string,
|
||||||
|
readonly hinweis: string,
|
||||||
|
readonly details: { pfad?: string; status?: number; parameter?: Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
super(ursache);
|
||||||
|
this.name = "ProxmoxFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TLS_CODES_SELBST_SIGNIERT = new Set([
|
||||||
|
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||||
|
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||||
|
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||||
|
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Übersetzt einen Netzwerk- oder TLS-Fehler von undici in einen ProxmoxFehler. */
|
||||||
|
export function verbindungsFehler(fehler: unknown, url: string): ProxmoxFehler {
|
||||||
|
if (fehler instanceof ProxmoxFehler) return fehler;
|
||||||
|
// undici verpackt die eigentliche Ursache in `cause`, manchmal zweifach.
|
||||||
|
let ursache: unknown = fehler;
|
||||||
|
for (let i = 0; i < 3 && ursache instanceof Error && ursache.cause; i += 1) {
|
||||||
|
if (ursache.cause instanceof ProxmoxFehler) return ursache.cause;
|
||||||
|
ursache = ursache.cause;
|
||||||
|
}
|
||||||
|
const code = (ursache as { code?: string } | undefined)?.code ?? "";
|
||||||
|
const text = ursache instanceof Error ? ursache.message : String(ursache);
|
||||||
|
const host = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (TLS_CODES_SELBST_SIGNIERT.has(code)) {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"tls",
|
||||||
|
`Das Zertifikat von ${host} ist nicht vertrauenswürdig (${code}).`,
|
||||||
|
"Bei einem selbst signierten Proxmox-Zertifikat „Fingerprint holen“ und den Fingerprint bestätigen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === "ERR_TLS_CERT_ALTNAME_INVALID") {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"tls",
|
||||||
|
`Das Zertifikat passt nicht zum Namen ${host}.`,
|
||||||
|
"Die URL mit dem Namen aus dem Zertifikat angeben oder den Fingerprint pinnen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === "CERT_HAS_EXPIRED") {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"tls",
|
||||||
|
`Das Zertifikat von ${host} ist abgelaufen.`,
|
||||||
|
"Zertifikat auf dem Node erneuern.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === "ECONNREFUSED") {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"verbindung",
|
||||||
|
`${host} lehnt die Verbindung ab.`,
|
||||||
|
"Läuft pveproxy? Stimmt der Port (Standard 8006)?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"verbindung",
|
||||||
|
`Der Name ${host} lässt sich nicht auflösen.`,
|
||||||
|
"Hostnamen oder DNS prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
code === "ETIMEDOUT" ||
|
||||||
|
code === "UND_ERR_CONNECT_TIMEOUT" ||
|
||||||
|
code === "EHOSTUNREACH" ||
|
||||||
|
code === "ENETUNREACH" ||
|
||||||
|
(ursache instanceof Error && ursache.name === "TimeoutError")
|
||||||
|
) {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"zeitueberschreitung",
|
||||||
|
`${host} antwortet nicht.`,
|
||||||
|
"Ist der Host aus dem Netz von ProxDeploy erreichbar? Firewall und Routing prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"verbindung",
|
||||||
|
`Verbindung zu ${host} fehlgeschlagen: ${text}`,
|
||||||
|
"Adresse und Netzwerk prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxmox meldet fehlende Rechte als „Permission check failed (/vms/120, VM.Allocate)“. Daraus
|
||||||
|
* wird ein Hinweis, der Pfad und Recht nennt – das spart den Blick in die Doku.
|
||||||
|
*/
|
||||||
|
export function rechtHinweis(meldung: string): string {
|
||||||
|
const treffer = /Permission check failed \(([^,]+), ([^)]+)\)/.exec(meldung);
|
||||||
|
if (treffer) {
|
||||||
|
return (
|
||||||
|
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
|
||||||
|
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte).";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
|
||||||
|
export function antwortFehler(
|
||||||
|
status: number,
|
||||||
|
statusText: string,
|
||||||
|
koerper: unknown,
|
||||||
|
pfad: string,
|
||||||
|
): ProxmoxFehler {
|
||||||
|
const parameter =
|
||||||
|
koerper &&
|
||||||
|
typeof koerper === "object" &&
|
||||||
|
"errors" in koerper &&
|
||||||
|
koerper.errors &&
|
||||||
|
typeof koerper.errors === "object"
|
||||||
|
? (koerper.errors as Record<string, string>)
|
||||||
|
: undefined;
|
||||||
|
// Proxmox schreibt die eigentliche Meldung in die Statuszeile, nicht in den Rumpf.
|
||||||
|
const meldung = (statusText || "").trim() || `HTTP ${status}`;
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"anmeldung",
|
||||||
|
"Proxmox lehnt das API-Token ab.",
|
||||||
|
"Token-ID (user@realm!tokenid) und Secret prüfen; ist das Token abgelaufen oder gelöscht?",
|
||||||
|
{ pfad, status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return new ProxmoxFehler("recht", meldung, rechtHinweis(meldung), { pfad, status });
|
||||||
|
}
|
||||||
|
if (status === 400 && parameter) {
|
||||||
|
const liste = Object.entries(parameter)
|
||||||
|
.map(([name, text]) => `${name}: ${String(text).trim()}`)
|
||||||
|
.join("; ");
|
||||||
|
return new ProxmoxFehler("parameter", `Proxmox lehnt Parameter ab – ${liste}`, "Eingaben prüfen.", {
|
||||||
|
pfad,
|
||||||
|
status,
|
||||||
|
parameter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (status === 404 || /does not exist|not found/i.test(meldung)) {
|
||||||
|
return new ProxmoxFehler("nicht_gefunden", meldung, "Gibt es Node, VM oder Storage noch?", {
|
||||||
|
pfad,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (status === 501) {
|
||||||
|
return new ProxmoxFehler(
|
||||||
|
"nicht_gefunden",
|
||||||
|
`Proxmox kennt ${pfad} nicht (${meldung}).`,
|
||||||
|
"Die PVE-Version unterstützt diesen Aufruf nicht; ProxDeploy weicht dann auf SSH aus, wenn eingerichtet.",
|
||||||
|
{ pfad, status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new ProxmoxFehler("api", meldung, "Meldung von Proxmox prüfen; Details stehen im Log.", {
|
||||||
|
pfad,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { connect, type PeerCertificate } from "node:tls";
|
||||||
|
import { buildConnector } from "undici";
|
||||||
|
import { ProxmoxFehler, verbindungsFehler } from "./fehler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS zu Proxmox. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; dann zählt nur der
|
||||||
|
* gepinnte SHA-256-Fingerprint, den ein Admin beim Anlegen bestätigt hat (TOFU **mit**
|
||||||
|
* Bestätigung). Ein globales `NODE_TLS_REJECT_UNAUTHORIZED=0` gibt es nicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** „ab:cd:…“, „ABCD…“ oder „SHA256:…“-ähnliche Eingaben → „AB:CD:…“. */
|
||||||
|
export function fingerabdruckNormalisieren(eingabe: string): string | null {
|
||||||
|
const hex = eingabe
|
||||||
|
.trim()
|
||||||
|
.replace(/^sha-?256[:= ]*/i, "")
|
||||||
|
.replace(/[^0-9a-f]/gi, "")
|
||||||
|
.toUpperCase();
|
||||||
|
if (hex.length !== 64) return null;
|
||||||
|
return hex.match(/.{2}/g)!.join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZertifikatInfo = {
|
||||||
|
fingerabdruck: string;
|
||||||
|
subjekt: string;
|
||||||
|
aussteller: string;
|
||||||
|
gueltigBis: string;
|
||||||
|
alternativeNamen: string[];
|
||||||
|
/** Ob das System dem Zertifikat ohnehin vertraut (z. B. ACME/Let's Encrypt). */
|
||||||
|
vertraut: boolean;
|
||||||
|
fehlerGrund: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function namen(teil: PeerCertificate["subject"] | undefined): string {
|
||||||
|
if (!teil) return "";
|
||||||
|
return Object.entries(teil)
|
||||||
|
.map(([schluessel, wert]) => `${schluessel}=${Array.isArray(wert) ? wert.join("+") : wert}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holt das Zertifikat ohne es zu prüfen – nur zum Anzeigen und Bestätigen. */
|
||||||
|
export function zertifikatHolen(url: string, zeitlimitMs = 10_000): Promise<ZertifikatInfo> {
|
||||||
|
const ziel = new URL(url);
|
||||||
|
const port = Number(ziel.port || 443);
|
||||||
|
return new Promise((erfuellen, ablehnen) => {
|
||||||
|
const socket = connect({
|
||||||
|
host: ziel.hostname,
|
||||||
|
port,
|
||||||
|
servername: /^[\d.]+$|:/.test(ziel.hostname) ? undefined : ziel.hostname,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: zeitlimitMs,
|
||||||
|
});
|
||||||
|
socket.once("secureConnect", () => {
|
||||||
|
const zertifikat = socket.getPeerCertificate();
|
||||||
|
socket.end();
|
||||||
|
if (!zertifikat?.fingerprint256) {
|
||||||
|
ablehnen(
|
||||||
|
new ProxmoxFehler(
|
||||||
|
"tls",
|
||||||
|
"Der Server hat kein Zertifikat geschickt.",
|
||||||
|
"Ist das wirklich Proxmox auf HTTPS?",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
erfuellen({
|
||||||
|
fingerabdruck: fingerabdruckNormalisieren(zertifikat.fingerprint256)!,
|
||||||
|
subjekt: namen(zertifikat.subject),
|
||||||
|
aussteller: namen(zertifikat.issuer),
|
||||||
|
gueltigBis: new Date(zertifikat.valid_to).toISOString(),
|
||||||
|
alternativeNamen: (zertifikat.subjectaltname ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
vertraut: socket.authorized,
|
||||||
|
fehlerGrund: socket.authorizationError ? String(socket.authorizationError) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.once("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
ablehnen(verbindungsFehler(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), url));
|
||||||
|
});
|
||||||
|
socket.once("error", (fehler) => ablehnen(verbindungsFehler(fehler, url)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TlsEinstellung = { tlsPruefen: boolean; fingerabdruck: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connector für undici.
|
||||||
|
* - `tlsPruefen` ohne Pin: normale Prüfung gegen die Systemzertifikate.
|
||||||
|
* - Pin gesetzt: die Kette wird nicht geprüft (selbst signiert), aber der Fingerprint muss
|
||||||
|
* **exakt** stimmen – sonst wird die Verbindung vor dem ersten Byte getrennt.
|
||||||
|
* - `tlsPruefen` **und** Pin: beides muss stimmen.
|
||||||
|
*/
|
||||||
|
export function tlsConnector(einstellung: TlsEinstellung): buildConnector.connector {
|
||||||
|
const pin = einstellung.fingerabdruck ? fingerabdruckNormalisieren(einstellung.fingerabdruck) : null;
|
||||||
|
if (!pin && !einstellung.tlsPruefen) {
|
||||||
|
// Die Datenbank verbietet diesen Zustand (server_tls_oder_pin); hier zur Sicherheit noch einmal.
|
||||||
|
throw new ProxmoxFehler(
|
||||||
|
"tls",
|
||||||
|
"Weder TLS-Prüfung noch Fingerprint – die Verbindung wäre blind.",
|
||||||
|
"Fingerprint holen und bestätigen oder TLS-Prüfung einschalten.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ohne Sitzungs-Cache: eine wiederaufgenommene TLS-Sitzung schickt kein Zertifikat mit,
|
||||||
|
// und der Pin ließe sich dann nicht prüfen.
|
||||||
|
const basis = buildConnector({ rejectUnauthorized: einstellung.tlsPruefen, maxCachedSessions: 0 });
|
||||||
|
if (!pin) return basis;
|
||||||
|
|
||||||
|
return (optionen, rueckruf) => {
|
||||||
|
basis(optionen, (fehler, socket) => {
|
||||||
|
if (fehler || !socket) {
|
||||||
|
rueckruf(fehler ?? new Error("Keine Verbindung"), null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zertifikat = "getPeerCertificate" in socket ? socket.getPeerCertificate() : undefined;
|
||||||
|
const gesehen = zertifikat?.fingerprint256
|
||||||
|
? fingerabdruckNormalisieren(zertifikat.fingerprint256)
|
||||||
|
: null;
|
||||||
|
if (gesehen !== pin) {
|
||||||
|
socket.destroy();
|
||||||
|
rueckruf(
|
||||||
|
new ProxmoxFehler(
|
||||||
|
"fingerabdruck",
|
||||||
|
`Der Fingerprint des Zertifikats hat sich geändert (erwartet ${pin.slice(0, 11)}…, gesehen ${gesehen?.slice(0, 11) ?? "keiner"}…).`,
|
||||||
|
"Wurde das Zertifikat erneuert? Dann in den Server-Einstellungen neu holen und bestätigen. Sonst nicht verbinden – jemand könnte dazwischen sitzen.",
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rueckruf(null, socket);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Kleiner Helfer für Client-Komponenten, die die REST-API rufen. Wirft `ApiFehler` mit
|
||||||
|
* Nachricht, Hinweis und Feldfehlern im Format der API.
|
||||||
|
*/
|
||||||
|
export class ApiFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
nachricht: string,
|
||||||
|
readonly hinweis?: string,
|
||||||
|
readonly felder: Record<string, string> = {},
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "ApiFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAufruf<T>(methode: string, pfad: string, rumpf?: unknown): Promise<T> {
|
||||||
|
let antwort: Response;
|
||||||
|
try {
|
||||||
|
antwort = await fetch(pfad, {
|
||||||
|
method: methode,
|
||||||
|
headers: rumpf !== undefined ? { "content-type": "application/json" } : undefined,
|
||||||
|
body: rumpf !== undefined ? JSON.stringify(rumpf) : undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiFehler(0, "netz", "ProxDeploy ist nicht erreichbar.");
|
||||||
|
}
|
||||||
|
if (antwort.status === 204) return undefined as T;
|
||||||
|
const inhalt = (await antwort.json().catch(() => null)) as
|
||||||
|
| (T & {
|
||||||
|
fehler?: { code: string; nachricht: string; hinweis?: string; felder?: Record<string, string> };
|
||||||
|
})
|
||||||
|
| null;
|
||||||
|
if (!antwort.ok) {
|
||||||
|
const fehler = inhalt?.fehler;
|
||||||
|
throw new ApiFehler(
|
||||||
|
antwort.status,
|
||||||
|
fehler?.code ?? "unbekannt",
|
||||||
|
fehler?.nachricht ?? `Fehler ${antwort.status}`,
|
||||||
|
fehler?.hinweis,
|
||||||
|
fehler?.felder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inhalt as T;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
|||||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
||||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
||||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen", ab: "#002" },
|
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||||
{
|
{
|
||||||
pfad: "/einstellungen",
|
pfad: "/einstellungen",
|
||||||
titel: "Einstellungen",
|
titel: "Einstellungen",
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingaben für einen Proxmox-Server. Liegt in `src/lib`, weil Formular (Client) und API (Server)
|
||||||
|
* dieselben Regeln brauchen.
|
||||||
|
*/
|
||||||
|
export const SERVER_NAME_MUSTER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||||
|
export const TOKEN_ID_MUSTER = /^[^\s@!]+@[^\s@!]+![A-Za-z][A-Za-z0-9._-]*$/;
|
||||||
|
|
||||||
|
export const ServerUrl = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Adresse fehlt.")
|
||||||
|
.transform((wert, kontext) => {
|
||||||
|
// „pve01:8006“ oder „pve01“ ohne Schema sind üblich – dann https und 8006 ergänzen.
|
||||||
|
const mitSchema = /^[a-z]+:\/\//i.test(wert) ? wert : `https://${wert}`;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(mitSchema);
|
||||||
|
} catch {
|
||||||
|
kontext.addIssue({ code: "custom", message: "Keine gültige Adresse." });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
kontext.addIssue({ code: "custom", message: "Proxmox spricht nur HTTPS." });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
if (!url.port) url.port = "8006";
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
|
||||||
|
|
||||||
|
const Basis = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(SERVER_NAME_MUSTER, "Nur Kleinbuchstaben, Ziffern und Bindestrich, höchstens 32 Zeichen."),
|
||||||
|
titel: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
|
||||||
|
url: ServerUrl,
|
||||||
|
tokenId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(TOKEN_ID_MUSTER, "Format: benutzer@realm!tokenname, z. B. proxdeploy@pve!deploy"),
|
||||||
|
tlsPruefen: z.boolean().default(false),
|
||||||
|
fingerabdruck: z.string().trim().nullable().default(null),
|
||||||
|
sshAktiv: z.boolean().default(false),
|
||||||
|
sshBenutzer: z.string().trim().min(1).max(32).default("root"),
|
||||||
|
sshPort: z.coerce.number().int().min(1).max(65535).default(22),
|
||||||
|
templateVmidVon: Vmid.default(9000),
|
||||||
|
templateVmidBis: Vmid.default(9999),
|
||||||
|
gastVmidVon: Vmid.default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
|
||||||
|
return wert.templateVmidVon < wert.templateVmidBis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinOderTls<T extends { tlsPruefen: boolean; fingerabdruck: string | null }>(wert: T) {
|
||||||
|
return wert.tlsPruefen || Boolean(wert.fingerabdruck);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEREICH_MELDUNG = {
|
||||||
|
message: "Der Template-Bereich muss mit der kleineren Zahl beginnen.",
|
||||||
|
path: ["templateVmidBis"],
|
||||||
|
};
|
||||||
|
const PIN_MELDUNG = {
|
||||||
|
message: "Ohne TLS-Prüfung muss der Fingerprint bestätigt sein.",
|
||||||
|
path: ["fingerabdruck"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerAnlegen = Basis.extend({
|
||||||
|
tokenGeheimnis: z.string().trim().min(1, "Secret fehlt."),
|
||||||
|
})
|
||||||
|
.refine(bereicheGueltig, BEREICH_MELDUNG)
|
||||||
|
.refine(pinOderTls, PIN_MELDUNG);
|
||||||
|
|
||||||
|
/** Beim Bearbeiten bleibt ein leeres Secret das alte – es wird nie zurück an den Browser geschickt. */
|
||||||
|
export const ServerAendern = Basis.extend({
|
||||||
|
tokenGeheimnis: z.string().trim().optional(),
|
||||||
|
})
|
||||||
|
.refine(bereicheGueltig, BEREICH_MELDUNG)
|
||||||
|
.refine(pinOderTls, PIN_MELDUNG);
|
||||||
|
|
||||||
|
export type ServerAnlegenEingabe = z.input<typeof ServerAnlegen>;
|
||||||
|
|
||||||
|
/** Prüfen vor dem Speichern: nur Adresse ist Pflicht, der Rest wird genutzt, wenn vorhanden. */
|
||||||
|
export const ServerPruefen = z.object({
|
||||||
|
url: ServerUrl,
|
||||||
|
/** Bei bestehendem Server: Secret aus der Datenbank nehmen, wenn das Feld leer bleibt. */
|
||||||
|
name: z.string().trim().optional(),
|
||||||
|
tokenId: z.string().trim().optional(),
|
||||||
|
tokenGeheimnis: z.string().trim().optional(),
|
||||||
|
tlsPruefen: z.boolean().default(false),
|
||||||
|
fingerabdruck: z.string().trim().nullable().default(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Was die API über einen Server herausgibt – nie Secret oder privaten Schlüssel. */
|
||||||
|
export type ServerSicht = {
|
||||||
|
name: string;
|
||||||
|
titel: string;
|
||||||
|
url: string;
|
||||||
|
tokenId: string;
|
||||||
|
tlsPruefen: boolean;
|
||||||
|
fingerabdruck: string | null;
|
||||||
|
sshAktiv: boolean;
|
||||||
|
sshBenutzer: string;
|
||||||
|
sshPort: number;
|
||||||
|
sshOeffentlich: string | null;
|
||||||
|
templateVmidVon: number;
|
||||||
|
templateVmidBis: number;
|
||||||
|
gastVmidVon: number;
|
||||||
|
erreichbar: boolean | null;
|
||||||
|
pveVersion: string | null;
|
||||||
|
geprueftAm: string | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const SERVER_COOKIE = "pd_server";
|
||||||
|
|
||||||
|
/** Der gewählte Server, sonst der erste – ein gelöschter Server im Cookie fällt still zurück. */
|
||||||
|
export function aktiverServer(namen: string[], cookie: string | undefined): string | null {
|
||||||
|
if (cookie && namen.includes(cookie)) return cookie;
|
||||||
|
return namen[0] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { utils as sshUtils } from "ssh2";
|
||||||
|
import type { Server } from "@/generated/prisma/client";
|
||||||
|
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
|
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { ServerAendern, ServerAnlegen, ServerPruefen, type ServerSicht } from "@/lib/schemas/server";
|
||||||
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
export function serverSicht(server: Server): ServerSicht {
|
||||||
|
return {
|
||||||
|
name: server.name,
|
||||||
|
titel: server.titel,
|
||||||
|
url: server.url,
|
||||||
|
tokenId: server.tokenId,
|
||||||
|
tlsPruefen: server.tlsPruefen,
|
||||||
|
fingerabdruck: server.fingerabdruck,
|
||||||
|
sshAktiv: server.sshAktiv,
|
||||||
|
sshBenutzer: server.sshBenutzer,
|
||||||
|
sshPort: server.sshPort,
|
||||||
|
sshOeffentlich: server.sshOeffentlich,
|
||||||
|
templateVmidVon: server.templateVmidVon,
|
||||||
|
templateVmidBis: server.templateVmidBis,
|
||||||
|
gastVmidVon: server.gastVmidVon,
|
||||||
|
erreichbar: server.erreichbar,
|
||||||
|
pveVersion: server.pveVersion,
|
||||||
|
geprueftAm: server.geprueftAm?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entschlüsselte Verbindungsdaten – nur für die Engine, nie in eine Antwort. */
|
||||||
|
export function verbindungAus(server: Server): ProxmoxVerbindung {
|
||||||
|
return {
|
||||||
|
url: server.url,
|
||||||
|
tokenId: server.tokenId,
|
||||||
|
tokenGeheimnis: entschluesseln(server.tokenGeheimnis),
|
||||||
|
tlsPruefen: server.tlsPruefen,
|
||||||
|
fingerabdruck: server.fingerabdruck,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerabdruckOderFehler(eingabe: string | null): string | null {
|
||||||
|
if (!eingabe) return null;
|
||||||
|
const normal = fingerabdruckNormalisieren(eingabe);
|
||||||
|
if (!normal) {
|
||||||
|
throw new AnwendungsFehler(400, "ungueltig", "Der Fingerprint ist kein SHA-256-Wert (64 Hex-Zeichen).");
|
||||||
|
}
|
||||||
|
return normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverListe(): Promise<ServerSicht[]> {
|
||||||
|
return (await prisma.server.findMany({ orderBy: { name: "asc" } })).map(serverSicht);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverHolen(name: string): Promise<Server> {
|
||||||
|
const server = await prisma.server.findUnique({ where: { name } });
|
||||||
|
if (!server) throw nichtGefunden(`Den Server „${name}“`);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverAnlegen(
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<ServerSicht> {
|
||||||
|
const eingabe = ServerAnlegen.parse(roh);
|
||||||
|
if (await prisma.server.findUnique({ where: { name: eingabe.name } })) {
|
||||||
|
throw new AnwendungsFehler(409, "vergeben", `Den Namen „${eingabe.name}“ gibt es schon.`);
|
||||||
|
}
|
||||||
|
const server = await prisma.$transaction(async (tx) => {
|
||||||
|
const angelegt = await tx.server.create({
|
||||||
|
data: {
|
||||||
|
...eingabe,
|
||||||
|
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||||
|
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Server angelegt",
|
||||||
|
ziel: `server:${angelegt.name}`,
|
||||||
|
details: { url: angelegt.url, tokenId: angelegt.tokenId },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return angelegt;
|
||||||
|
});
|
||||||
|
return serverSicht(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverAendern(
|
||||||
|
name: string,
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<ServerSicht> {
|
||||||
|
const bisher = await serverHolen(name);
|
||||||
|
const eingabe = ServerAendern.parse(roh);
|
||||||
|
if (eingabe.name !== bisher.name && (await prisma.server.findUnique({ where: { name: eingabe.name } }))) {
|
||||||
|
throw new AnwendungsFehler(409, "vergeben", `Den Namen „${eingabe.name}“ gibt es schon.`);
|
||||||
|
}
|
||||||
|
const { tokenGeheimnis, ...rest } = eingabe;
|
||||||
|
const daten = {
|
||||||
|
...rest,
|
||||||
|
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||||
|
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
|
||||||
|
};
|
||||||
|
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
|
||||||
|
const geaendert = Object.entries(daten)
|
||||||
|
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== wert)
|
||||||
|
.map(([feld]) => feld);
|
||||||
|
|
||||||
|
const server = await prisma.$transaction(async (tx) => {
|
||||||
|
const neu = await tx.server.update({ where: { id: bisher.id }, data: daten });
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Server geändert",
|
||||||
|
ziel: `server:${neu.name}`,
|
||||||
|
details: { felder: geaendert },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
return serverSicht(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverLoeschen(name: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
|
||||||
|
const server = await serverHolen(name);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.server.delete({ where: { id: server.id } });
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Server gelöscht",
|
||||||
|
ziel: `server:${server.name}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt (oder erneuert) das SSH-Schlüsselpaar, mit dem ProxDeploy sich optional auf den Nodes
|
||||||
|
* anmeldet. ed25519 im OpenSSH-Format; der private Schlüssel liegt nur verschlüsselt vor.
|
||||||
|
*/
|
||||||
|
export async function sshSchluesselErzeugen(
|
||||||
|
name: string,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<ServerSicht> {
|
||||||
|
const server = await serverHolen(name);
|
||||||
|
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||||
|
const neu = await prisma.$transaction(async (tx) => {
|
||||||
|
const aktualisiert = await tx.server.update({
|
||||||
|
where: { id: server.id },
|
||||||
|
data: {
|
||||||
|
sshPrivat: verschluesseln(paar.private),
|
||||||
|
sshOeffentlich: paar.public.trim(),
|
||||||
|
// 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.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: server.sshOeffentlich ? "SSH-Schlüssel erneuert" : "SSH-Schlüssel erzeugt",
|
||||||
|
ziel: `server:${server.name}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return aktualisiert;
|
||||||
|
});
|
||||||
|
return serverSicht(neu);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PruefErgebnis = {
|
||||||
|
zertifikat: ZertifikatInfo | null;
|
||||||
|
/** Ob der angegebene Pin zum gesehenen Zertifikat passt (null: kein Pin angegeben). */
|
||||||
|
pinPasst: boolean | null;
|
||||||
|
verbindung:
|
||||||
|
| { ok: true; version: string; release: string }
|
||||||
|
| { ok: false; art: string; ursache: string; hinweis: string }
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft vor dem Speichern: Zertifikat holen (zum Bestätigen) und – wenn Token-Angaben da sind –
|
||||||
|
* einen ersten Aufruf von `/version`. Die vollständige Fähigkeitserkennung folgt in #003.
|
||||||
|
*/
|
||||||
|
export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
|
||||||
|
const eingabe = ServerPruefen.parse(roh);
|
||||||
|
let zertifikat: ZertifikatInfo | null = null;
|
||||||
|
try {
|
||||||
|
zertifikat = await zertifikatHolen(eingabe.url);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof ProxmoxFehler) {
|
||||||
|
return {
|
||||||
|
zertifikat: null,
|
||||||
|
pinPasst: null,
|
||||||
|
verbindung: { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = eingabe.fingerabdruck ? fingerabdruckNormalisieren(eingabe.fingerabdruck) : null;
|
||||||
|
const pinPasst = pin ? pin === zertifikat.fingerabdruck : null;
|
||||||
|
|
||||||
|
let geheimnis = eingabe.tokenGeheimnis;
|
||||||
|
if (!geheimnis && eingabe.name) {
|
||||||
|
const bestehend = await prisma.server.findUnique({ where: { name: eingabe.name } });
|
||||||
|
if (bestehend) geheimnis = entschluesseln(bestehend.tokenGeheimnis);
|
||||||
|
}
|
||||||
|
if (!eingabe.tokenId || !geheimnis || (!eingabe.tlsPruefen && !pin)) {
|
||||||
|
return { zertifikat, pinPasst, verbindung: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new ProxmoxClient(
|
||||||
|
{
|
||||||
|
url: eingabe.url,
|
||||||
|
tokenId: eingabe.tokenId,
|
||||||
|
tokenGeheimnis: geheimnis,
|
||||||
|
tlsPruefen: eingabe.tlsPruefen,
|
||||||
|
fingerabdruck: pin,
|
||||||
|
},
|
||||||
|
{ zeitlimitMs: 10_000 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const version = await client.get<{ version: string; release: string }>("/version");
|
||||||
|
return {
|
||||||
|
zertifikat,
|
||||||
|
pinPasst,
|
||||||
|
verbindung: { ok: true, version: version.version, release: version.release },
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof ProxmoxFehler) {
|
||||||
|
return {
|
||||||
|
zertifikat,
|
||||||
|
pinPasst,
|
||||||
|
verbindung: { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet für die Abläufe einen Proxmox-Mock auf festem Port. Die gebaute Anwendung verbindet
|
||||||
|
* sich dorthin wie zu einem echten Host – mit selbst signiertem Zertifikat.
|
||||||
|
*/
|
||||||
|
export const MOCK_PORT = 18006;
|
||||||
|
|
||||||
|
export default async function aufbau(): Promise<() => Promise<void>> {
|
||||||
|
const mock = new ProxmoxMock();
|
||||||
|
mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
|
||||||
|
await mock.starten(MOCK_PORT);
|
||||||
|
return async () => {
|
||||||
|
await mock.stoppen();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { anmelden as anmeldenZu, keinQuerscrollen } from "./helfer";
|
||||||
|
|
||||||
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
|
async function anmelden(page: import("@playwright/test").Page) {
|
||||||
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
|
await anmeldenZu(page);
|
||||||
|
|
||||||
async function anmelden(page: Page) {
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page).toHaveURL(/\/anmelden/);
|
|
||||||
await page.getByLabel("Benutzername").fill(BENUTZER);
|
|
||||||
await page.getByLabel("Passwort").fill(PASSWORT);
|
|
||||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
|
||||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function keinQuerscrollen(page: Page) {
|
|
||||||
const breiten = await page.evaluate(() => ({
|
|
||||||
inhalt: document.documentElement.scrollWidth,
|
|
||||||
fenster: window.innerWidth,
|
|
||||||
}));
|
|
||||||
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
|
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
|
||||||
await page.goto("/anmelden");
|
await page.goto("/anmelden");
|
||||||
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
|
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
|
||||||
|
export const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
|
||||||
|
|
||||||
|
export async function anmelden(page: Page, ziel = "/") {
|
||||||
|
await page.goto(ziel);
|
||||||
|
await expect(page).toHaveURL(/\/anmelden/);
|
||||||
|
await page.getByLabel("Benutzername").fill(BENUTZER);
|
||||||
|
await page.getByLabel("Passwort").fill(PASSWORT);
|
||||||
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||||
|
await expect(page).not.toHaveURL(/\/anmelden/);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function keinQuerscrollen(page: Page) {
|
||||||
|
const breiten = await page.evaluate(() => ({
|
||||||
|
inhalt: document.documentElement.scrollWidth,
|
||||||
|
fenster: window.innerWidth,
|
||||||
|
}));
|
||||||
|
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mit dem Präfix zz-e2e lassen sich Testdaten erkennen und aufräumen. */
|
||||||
|
export const E2E_PRAEFIX = "zz-e2e";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID } from "../proxmox-mock/mock";
|
||||||
|
import { MOCK_PORT } from "./global-setup";
|
||||||
|
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||||
|
|
||||||
|
test("Server anlegen: Zertifikat pinnen, Verbindung prüfen, SSH-Schlüssel, löschen", async ({ page }) => {
|
||||||
|
const name = `${E2E_PRAEFIX}-${test.info().project.name}`;
|
||||||
|
await anmelden(page, "/server/neu");
|
||||||
|
await expect(page.getByRole("heading", { name: "Server anlegen" })).toBeVisible();
|
||||||
|
await keinQuerscrollen(page);
|
||||||
|
|
||||||
|
await page.getByLabel("Kurzname").fill(name);
|
||||||
|
await page.getByLabel("Anzeigename").fill("E2E-Testserver");
|
||||||
|
await page.getByLabel("Adresse").fill(`127.0.0.1:${MOCK_PORT}`);
|
||||||
|
await page.getByRole("button", { name: "Zertifikat holen" }).click();
|
||||||
|
await expect(page.getByText("Selbst signiertes Zertifikat")).toBeVisible();
|
||||||
|
await page.getByLabel("Fingerprint stimmt – Zertifikat pinnen").check();
|
||||||
|
|
||||||
|
await page.getByLabel("Token-ID").fill(MOCK_TOKEN_ID);
|
||||||
|
await page.getByLabel("Secret").fill(MOCK_TOKEN_GEHEIMNIS);
|
||||||
|
await page.getByRole("button", { name: "Verbindung prüfen" }).click();
|
||||||
|
await expect(page.getByText("Verbunden · Proxmox VE")).toBeVisible();
|
||||||
|
await expect(page.getByText("9.2.1")).toBeVisible();
|
||||||
|
await page.screenshot({ path: `test-results/server-neu-${test.info().project.name}.png`, fullPage: true });
|
||||||
|
|
||||||
|
await page.getByLabel("SSH zu den Nodes nutzen").check();
|
||||||
|
await page.getByRole("button", { name: "Server anlegen" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/server/${name}$`));
|
||||||
|
await expect(page.getByRole("heading", { name: "E2E-Testserver" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||||
|
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
|
||||||
|
await keinQuerscrollen(page);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `test-results/server-detail-${test.info().project.name}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Server löschen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Wirklich löschen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/server$/);
|
||||||
|
await expect(page.getByText(name)).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { createServer as createNetServer, type AddressInfo } from "node:net";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { aufgabeAbwarten, nodeAusUpid } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { parameterKodieren, pfad, ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { ProxmoxFehler, rechtHinweis } from "@/engine/proxmox/fehler";
|
||||||
|
import { fingerabdruckNormalisieren, zertifikatHolen } from "@/engine/proxmox/tls";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
|
|
||||||
|
const mock = new ProxmoxMock();
|
||||||
|
|
||||||
|
function client(ueberschreiben: Partial<ConstructorParameters<typeof ProxmoxClient>[0]> = {}) {
|
||||||
|
return new ProxmoxClient({
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
...ueberschreiben,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fehlerVon(versprechen: Promise<unknown>): Promise<ProxmoxFehler> {
|
||||||
|
try {
|
||||||
|
await versprechen;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof ProxmoxFehler) return fehler;
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
throw new Error("Es wurde ein Fehler erwartet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mock.starten();
|
||||||
|
mock
|
||||||
|
.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2", repoid: "abc" } }))
|
||||||
|
.route("GET", "/nodes/pve01/qemu/120/config", () => ({
|
||||||
|
status: 403,
|
||||||
|
statusText: "Permission check failed (/vms/120, VM.Audit)",
|
||||||
|
}))
|
||||||
|
.route("POST", "/nodes/pve01/qemu", () => ({
|
||||||
|
status: 400,
|
||||||
|
statusText: "Parameter verification failed.",
|
||||||
|
errors: { memory: "value must have a minimum value of 16" },
|
||||||
|
}))
|
||||||
|
.route("POST", "/echo", (anfrage) => ({ data: Object.fromEntries(anfrage.rumpf) }));
|
||||||
|
});
|
||||||
|
afterAll(() => mock.stoppen());
|
||||||
|
|
||||||
|
describe("Fingerprint", () => {
|
||||||
|
it("normalisiert übliche Schreibweisen", () => {
|
||||||
|
const hex = "ab".repeat(32);
|
||||||
|
const erwartet = "AB:".repeat(31) + "AB";
|
||||||
|
expect(fingerabdruckNormalisieren(hex)).toBe(erwartet);
|
||||||
|
expect(fingerabdruckNormalisieren(`SHA256: ${erwartet.toLowerCase()}`)).toBe(erwartet);
|
||||||
|
expect(fingerabdruckNormalisieren("zu-kurz")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holt das Zertifikat des Servers mit Fingerprint und Namen", async () => {
|
||||||
|
const info = await zertifikatHolen(mock.url);
|
||||||
|
expect(info.fingerabdruck).toBe(mock.fingerabdruck);
|
||||||
|
expect(info.subjekt).toContain("CN=pve01.test");
|
||||||
|
expect(info.vertraut).toBe(false);
|
||||||
|
expect(info.alternativeNamen).toContain("DNS:pve01.test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProxmoxClient", () => {
|
||||||
|
it("spricht mit gepinntem Fingerprint und API-Token", async () => {
|
||||||
|
expect(await client().get("/version")).toMatchObject({ version: "9.2.1" });
|
||||||
|
expect(mock.anfragen.at(-1)?.autorisierung).toBe(`PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trennt bei geändertem Fingerprint, bevor das Token gesendet wird", async () => {
|
||||||
|
const vorher = mock.anfragen.length;
|
||||||
|
const fehler = await fehlerVon(
|
||||||
|
client({ fingerabdruck: ProxmoxMock.fremderFingerabdruck() }).get("/version"),
|
||||||
|
);
|
||||||
|
expect(fehler.art).toBe("fingerabdruck");
|
||||||
|
expect(fehler.hinweis).toMatch(/erneuert/);
|
||||||
|
expect(mock.anfragen.length).toBe(vorher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet ein selbst signiertes Zertifikat bei TLS-Prüfung ohne Pin verständlich", async () => {
|
||||||
|
const fehler = await fehlerVon(client({ tlsPruefen: true, fingerabdruck: null }).get("/version"));
|
||||||
|
expect(fehler.art).toBe("tls");
|
||||||
|
expect(fehler.hinweis).toMatch(/Fingerprint holen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verweigert eine Verbindung ganz ohne Prüfung", () => {
|
||||||
|
expect(() => client({ tlsPruefen: false, fingerabdruck: null })).toThrow(/blind/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übersetzt 401 in einen Hinweis zum Token", async () => {
|
||||||
|
const fehler = await fehlerVon(client({ tokenGeheimnis: "falsch" }).get("/version"));
|
||||||
|
expect(fehler.art).toBe("anmeldung");
|
||||||
|
expect(fehler.hinweis).toMatch(/Token-ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt bei 403 das fehlende Recht und den Pfad", async () => {
|
||||||
|
const fehler = await fehlerVon(client().get(pfad`/nodes/${"pve01"}/qemu/${120}/config`));
|
||||||
|
expect(fehler.art).toBe("recht");
|
||||||
|
expect(fehler.hinweis).toContain("VM.Audit auf /vms/120");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt abgelehnte Parameter einzeln zurück", async () => {
|
||||||
|
const fehler = await fehlerVon(client().post("/nodes/pve01/qemu", { memory: 1 }));
|
||||||
|
expect(fehler.art).toBe("parameter");
|
||||||
|
expect(fehler.ursache).toContain("memory: value must have a minimum value of 16");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet unbekannte Endpunkte als nicht unterstützt (alte PVE-Version)", async () => {
|
||||||
|
const fehler = await fehlerVon(client().get("/gibt/es/nicht"));
|
||||||
|
expect(fehler.art).toBe("nicht_gefunden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen nicht erreichbaren Host", async () => {
|
||||||
|
// Einen freien Port belegen und sofort wieder freigeben – dort lauscht garantiert niemand.
|
||||||
|
const frei = await new Promise<number>((erfuellen) => {
|
||||||
|
const server = createNetServer().listen(0, "127.0.0.1", () => {
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
server.close(() => erfuellen(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const fehler = await fehlerVon(client({ url: `https://127.0.0.1:${frei}` }).get("/version"));
|
||||||
|
expect(fehler.art).toBe("verbindung");
|
||||||
|
expect(fehler.hinweis).toMatch(/8006/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kodiert Parameter wie pvesh: 1/0, Listen wiederholt, undefined weggelassen", async () => {
|
||||||
|
expect(parameterKodieren({ a: true, b: false, c: ["x", "y"], d: undefined, e: 5 }).toString()).toBe(
|
||||||
|
"a=1&b=0&c=x&c=y&e=5",
|
||||||
|
);
|
||||||
|
expect(await client().post("/echo", { name: "web 01", full: true })).toEqual({
|
||||||
|
name: "web 01",
|
||||||
|
full: "1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Pfadteile kodiert ein", () => {
|
||||||
|
expect(pfad`/nodes/${"pve 01"}/storage/${"a/b"}`).toBe("/nodes/pve%2001/storage/a%2Fb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prüft das Format der Token-ID", () => {
|
||||||
|
try {
|
||||||
|
client({ tokenId: "root@pam" });
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (fehler) {
|
||||||
|
expect((fehler as ProxmoxFehler).hinweis).toMatch(/benutzer@realm!tokenname/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet jeden Aufruf an einen Beobachter (für Log und Dry-Run)", async () => {
|
||||||
|
const gesehen: string[] = [];
|
||||||
|
const beobachtet = new ProxmoxClient(
|
||||||
|
{
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
beobachter: (aufruf, ergebnis) =>
|
||||||
|
gesehen.push(`${aufruf.methode} ${aufruf.pfad} ${ergebnis.fehler?.art ?? "ok"}`),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await beobachtet.get("/version");
|
||||||
|
await beobachtet.get("/gibt/es/nicht").catch(() => undefined);
|
||||||
|
expect(gesehen).toEqual(["GET /version ok", "GET /gibt/es/nicht nicht_gefunden"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("macht aus einem unbekannten Fehlertext einen allgemeinen Hinweis", () => {
|
||||||
|
expect(rechtHinweis("irgendwas")).toMatch(/README/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Proxmox-Tasks (UPID)", () => {
|
||||||
|
const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:";
|
||||||
|
|
||||||
|
it("liest den Node aus der UPID", () => {
|
||||||
|
expect(nodeAusUpid(UPID)).toBe("pve01");
|
||||||
|
expect(() => nodeAusUpid("kaputt")).toThrow(ProxmoxFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wartet, bis der Task fertig ist, und reicht Log-Zeilen weiter", async () => {
|
||||||
|
let abfragen = 0;
|
||||||
|
mock
|
||||||
|
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => {
|
||||||
|
abfragen += 1;
|
||||||
|
return { data: abfragen < 3 ? { status: "running" } : { status: "stopped", exitstatus: "OK" } };
|
||||||
|
})
|
||||||
|
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, (anfrage) => {
|
||||||
|
const start = Number(anfrage.abfrage.get("start"));
|
||||||
|
return {
|
||||||
|
data:
|
||||||
|
start < abfragen ? [{ n: abfragen, t: `Zeile ${abfragen}` }] : [{ n: start, t: "no content" }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const zeilen: string[] = [];
|
||||||
|
await aufgabeAbwarten(client(), UPID, { warten: async () => {}, beiLog: (neu) => zeilen.push(...neu) });
|
||||||
|
expect(abfragen).toBe(3);
|
||||||
|
expect(zeilen).toEqual(["Zeile 1", "Zeile 2", "Zeile 3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft mit dem Exitstatus und den letzten Log-Zeilen, wenn der Task scheitert", async () => {
|
||||||
|
mock
|
||||||
|
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => ({
|
||||||
|
data: { status: "stopped", exitstatus: "unable to create VM 120 - already exists" },
|
||||||
|
}))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, () => ({
|
||||||
|
data: [{ n: 1, t: "TASK ERROR: already exists" }],
|
||||||
|
}));
|
||||||
|
const fehler = await fehlerVon(aufgabeAbwarten(client(), UPID, { warten: async () => {} }));
|
||||||
|
expect(fehler.art).toBe("aufgabe");
|
||||||
|
expect(fehler.ursache).toContain("already exists");
|
||||||
|
expect(fehler.hinweis).toContain("TASK ERROR");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
|
import { SITZUNG_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import { testPrisma } from "./db";
|
||||||
|
|
||||||
|
/** Legt einen Benutzer an und liefert ein passendes Sitzungscookie. */
|
||||||
|
export async function angemeldet(rolle: Rolle, benutzername = rolle.toLowerCase()) {
|
||||||
|
const benutzer = await testPrisma.benutzer.create({
|
||||||
|
data: { benutzername, anzeigename: benutzername, rolle },
|
||||||
|
});
|
||||||
|
const cookie = `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "passwort" })}`;
|
||||||
|
return { benutzer, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut eine Anfrage an die API mit Cookie und optional JSON-Rumpf. */
|
||||||
|
export function anfrage(methode: string, pfad: string, cookie: string | null, rumpf?: unknown): Request {
|
||||||
|
return new Request(`http://localhost${pfad}`, {
|
||||||
|
method: methode,
|
||||||
|
headers: {
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
...(rumpf !== undefined ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: rumpf !== undefined ? JSON.stringify(rumpf) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kontext = <T extends Record<string, string>>(params: T) => ({ params: Promise.resolve(params) });
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createHash, X509Certificate } from "node:crypto";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { createServer, type Server } from "node:https";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Proxmox-Ersatz für Tests: HTTPS mit selbst signiertem Zertifikat (wie Proxmox ab Werk),
|
||||||
|
* Token-Prüfung und frei definierbaren Routen. Er zeichnet jeden Aufruf auf, damit Tests prüfen
|
||||||
|
* können, was ProxDeploy geschickt hätte.
|
||||||
|
*
|
||||||
|
* Die Zustände einzelner PVE-Versionen (8.3, 8.4, 9.2) kommen mit #003 dazu.
|
||||||
|
*/
|
||||||
|
export type MockAnfrage = {
|
||||||
|
methode: string;
|
||||||
|
pfad: string;
|
||||||
|
abfrage: URLSearchParams;
|
||||||
|
rumpf: URLSearchParams;
|
||||||
|
autorisierung: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockAntwort = {
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
data?: unknown;
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
export type MockRoute = (
|
||||||
|
anfrage: MockAnfrage,
|
||||||
|
treffer: RegExpExecArray,
|
||||||
|
) => MockAntwort | Promise<MockAntwort>;
|
||||||
|
|
||||||
|
export const MOCK_TOKEN_ID = "proxdeploy@pve!test";
|
||||||
|
export const MOCK_TOKEN_GEHEIMNIS = "00000000-1111-2222-3333-444444444444";
|
||||||
|
|
||||||
|
let zertifikatCache: { schluessel: string; zertifikat: string; fingerabdruck: string } | undefined;
|
||||||
|
|
||||||
|
/** Ein selbst signiertes Zertifikat per openssl – einmal je Testlauf. */
|
||||||
|
export function testZertifikat(): { schluessel: string; zertifikat: string; fingerabdruck: string } {
|
||||||
|
if (zertifikatCache) return zertifikatCache;
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), "proxdeploy-mock-"));
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"openssl",
|
||||||
|
[
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"ec",
|
||||||
|
"-pkeyopt",
|
||||||
|
"ec_paramgen_curve:prime256v1",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
join(ordner, "key.pem"),
|
||||||
|
"-out",
|
||||||
|
join(ordner, "cert.pem"),
|
||||||
|
"-days",
|
||||||
|
"2",
|
||||||
|
"-subj",
|
||||||
|
"/CN=pve01.test/O=PVE Cluster Node",
|
||||||
|
"-addext",
|
||||||
|
"subjectAltName=DNS:pve01.test,IP:127.0.0.1",
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
const zertifikat = readFileSync(join(ordner, "cert.pem"), "utf8");
|
||||||
|
const fingerabdruck = new X509Certificate(zertifikat).fingerprint256;
|
||||||
|
zertifikatCache = {
|
||||||
|
schluessel: readFileSync(join(ordner, "key.pem"), "utf8"),
|
||||||
|
zertifikat,
|
||||||
|
fingerabdruck,
|
||||||
|
};
|
||||||
|
return zertifikatCache;
|
||||||
|
} finally {
|
||||||
|
rmSync(ordner, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxmoxMock {
|
||||||
|
readonly anfragen: MockAnfrage[] = [];
|
||||||
|
private readonly routen: Array<{ methode: string; muster: RegExp; route: MockRoute }> = [];
|
||||||
|
private server: Server | undefined;
|
||||||
|
url = "";
|
||||||
|
fingerabdruck = "";
|
||||||
|
|
||||||
|
route(methode: string, muster: RegExp | string, route: MockRoute): this {
|
||||||
|
const regex =
|
||||||
|
typeof muster === "string" ? new RegExp(`^${muster.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) : muster;
|
||||||
|
this.routen.unshift({ methode, muster: regex, route });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async starten(port = 0): Promise<this> {
|
||||||
|
const { schluessel, zertifikat, fingerabdruck } = testZertifikat();
|
||||||
|
this.fingerabdruck = fingerabdruck;
|
||||||
|
this.server = createServer({ key: schluessel, cert: zertifikat }, (req, res) => {
|
||||||
|
const teile: Buffer[] = [];
|
||||||
|
req.on("data", (teil: Buffer) => teile.push(teil));
|
||||||
|
req.on("end", async () => {
|
||||||
|
const adresse = new URL(req.url ?? "/", "https://localhost");
|
||||||
|
const anfrage: MockAnfrage = {
|
||||||
|
methode: req.method ?? "GET",
|
||||||
|
pfad: decodeURIComponent(adresse.pathname.replace(/^\/api2\/json/, "")),
|
||||||
|
abfrage: adresse.searchParams,
|
||||||
|
rumpf: new URLSearchParams(Buffer.concat(teile).toString("utf8")),
|
||||||
|
autorisierung: req.headers.authorization,
|
||||||
|
};
|
||||||
|
this.anfragen.push(anfrage);
|
||||||
|
|
||||||
|
const senden = (status: number, statusText: string, koerper: unknown) => {
|
||||||
|
res.statusMessage = statusText;
|
||||||
|
res.writeHead(status, { "content-type": "application/json;charset=UTF-8" });
|
||||||
|
res.end(JSON.stringify(koerper));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (anfrage.autorisierung !== `PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`) {
|
||||||
|
senden(401, "authentication failure", { data: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { methode, muster, route } of this.routen) {
|
||||||
|
const treffer = methode === anfrage.methode ? muster.exec(anfrage.pfad) : null;
|
||||||
|
if (!treffer) continue;
|
||||||
|
try {
|
||||||
|
const antwort = await route(anfrage, treffer);
|
||||||
|
senden(antwort.status ?? 200, antwort.statusText ?? "OK", {
|
||||||
|
data: antwort.data ?? null,
|
||||||
|
...(antwort.errors ? { errors: antwort.errors } : {}),
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
senden(500, String(fehler), { data: null });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
senden(501, `Method '${anfrage.methode} ${anfrage.pfad}' not implemented`, { data: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((erfuellen) => this.server!.listen(port, "127.0.0.1", erfuellen));
|
||||||
|
this.url = `https://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stoppen(): Promise<void> {
|
||||||
|
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Fingerprint eines **anderen** Zertifikats – für den Fall „Zertifikat ausgetauscht“. */
|
||||||
|
static fremderFingerabdruck(): string {
|
||||||
|
const hex = createHash("sha256").update("anderes-zertifikat").digest("hex").toUpperCase();
|
||||||
|
return hex.match(/.{2}/g)!.join(":");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import * as einzeln from "@/app/api/v1/server/[name]/route";
|
||||||
|
import { POST as sshPost } from "@/app/api/v1/server/[name]/ssh/route";
|
||||||
|
import { POST as pruefenPost } from "@/app/api/v1/server/pruefen/route";
|
||||||
|
import * as liste from "@/app/api/v1/server/route";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
const mock = new ProxmoxMock();
|
||||||
|
let admin: string;
|
||||||
|
let operator: string;
|
||||||
|
|
||||||
|
function eingabe(ueberschreiben: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
name: "rz1",
|
||||||
|
titel: "Rechenzentrum 1",
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
...ueberschreiben,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Server-Verwaltung", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mock.starten();
|
||||||
|
mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
|
||||||
|
});
|
||||||
|
afterAll(() => mock.stoppen());
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
admin = (await angemeldet("ADMIN")).cookie;
|
||||||
|
operator = (await angemeldet("OPERATOR")).cookie;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Admin legt an; das Secret liegt verschlüsselt und kommt nie zurück", async () => {
|
||||||
|
const antwort = await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
expect(antwort.status).toBe(201);
|
||||||
|
const text = await antwort.text();
|
||||||
|
expect(text).not.toContain(MOCK_TOKEN_GEHEIMNIS);
|
||||||
|
expect(JSON.parse(text)).toMatchObject({ name: "rz1", fingerabdruck: mock.fingerabdruck });
|
||||||
|
|
||||||
|
const gespeichert = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
|
||||||
|
expect(gespeichert.tokenGeheimnis).toMatch(/^v1\./);
|
||||||
|
expect(entschluesseln(gespeichert.tokenGeheimnis)).toBe(MOCK_TOKEN_GEHEIMNIS);
|
||||||
|
|
||||||
|
const lesen = await liste.GET(anfrage("GET", "/api/v1/server", operator));
|
||||||
|
expect(await lesen.text()).not.toContain("v1.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Operator darf lesen, aber nicht anlegen, ändern oder löschen", async () => {
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
expect(
|
||||||
|
(await liste.POST(anfrage("POST", "/api/v1/server", operator, eingabe({ name: "rz2" })))).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await einzeln.PATCH(
|
||||||
|
anfrage("PATCH", "/api/v1/server/rz1", operator, eingabe()),
|
||||||
|
kontext({ name: "rz1" }),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await einzeln.DELETE(anfrage("DELETE", "/api/v1/server/rz1", operator), kontext({ name: "rz1" })))
|
||||||
|
.status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await einzeln.GET(anfrage("GET", "/api/v1/server/rz1", operator), kontext({ name: "rz1" }))).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ergänzt Schema und Port und normalisiert den Fingerprint", async () => {
|
||||||
|
const ohneSchema = mock.url.replace("https://", "");
|
||||||
|
const antwort = await liste.POST(
|
||||||
|
anfrage(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/server",
|
||||||
|
admin,
|
||||||
|
eingabe({ url: ohneSchema, fingerabdruck: mock.fingerabdruck.replaceAll(":", "").toLowerCase() }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await antwort.json()).toMatchObject({ url: mock.url, fingerabdruck: mock.fingerabdruck });
|
||||||
|
const nurHost = await liste.POST(
|
||||||
|
anfrage("POST", "/api/v1/server", admin, eingabe({ name: "rz2", url: "pve02.example" })),
|
||||||
|
);
|
||||||
|
expect((await nurHost.json()).url).toBe("https://pve02.example:8006");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet ungültige Felder einzeln", async () => {
|
||||||
|
const antwort = await liste.POST(
|
||||||
|
anfrage(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/server",
|
||||||
|
admin,
|
||||||
|
eingabe({ name: "RZ 1", tokenId: "root@pam", fingerabdruck: null }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(antwort.status).toBe(400);
|
||||||
|
const { fehler } = await antwort.json();
|
||||||
|
expect(Object.keys(fehler.felder)).toEqual(expect.arrayContaining(["name", "tokenId", "fingerabdruck"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt doppelte Namen ab", async () => {
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
expect((await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()))).status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ändern ohne Secret behält das alte; geänderte Felder stehen im Protokoll, Werte von Geheimnissen nicht", async () => {
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
const antwort = await einzeln.PATCH(
|
||||||
|
anfrage("PATCH", "/api/v1/server/rz1", admin, eingabe({ titel: "RZ eins", tokenGeheimnis: "" })),
|
||||||
|
kontext({ name: "rz1" }),
|
||||||
|
);
|
||||||
|
expect(antwort.status).toBe(200);
|
||||||
|
const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
|
||||||
|
expect(entschluesseln(server.tokenGeheimnis)).toBe(MOCK_TOKEN_GEHEIMNIS);
|
||||||
|
expect(server.titel).toBe("RZ eins");
|
||||||
|
|
||||||
|
const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Server geändert" } });
|
||||||
|
expect(eintrag.details).toEqual({ felder: ["titel"] });
|
||||||
|
const alles = JSON.stringify(await testPrisma.protokoll.findMany(), (_, wert) =>
|
||||||
|
typeof wert === "bigint" ? wert.toString() : wert,
|
||||||
|
);
|
||||||
|
expect(alles).not.toContain(MOCK_TOKEN_GEHEIMNIS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löscht und protokolliert", async () => {
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
expect(
|
||||||
|
(await einzeln.DELETE(anfrage("DELETE", "/api/v1/server/rz1", admin), kontext({ name: "rz1" }))).status,
|
||||||
|
).toBe(204);
|
||||||
|
expect(await testPrisma.server.count()).toBe(0);
|
||||||
|
expect(await testPrisma.protokoll.count({ where: { aktion: "Server gelöscht" } })).toBe(1);
|
||||||
|
expect(
|
||||||
|
(await einzeln.GET(anfrage("GET", "/api/v1/server/rz1", admin), kontext({ name: "rz1" }))).status,
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Prüfen: liefert das Zertifikat zum Bestätigen, dann mit Pin die PVE-Version", async () => {
|
||||||
|
const ohnePin = await (
|
||||||
|
await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", admin, { url: mock.url }))
|
||||||
|
).json();
|
||||||
|
expect(ohnePin.zertifikat.fingerabdruck).toBe(mock.fingerabdruck);
|
||||||
|
expect(ohnePin.zertifikat.vertraut).toBe(false);
|
||||||
|
expect(ohnePin.verbindung).toBeNull();
|
||||||
|
|
||||||
|
const mitPin = await (
|
||||||
|
await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", admin, { ...eingabe(), name: undefined }))
|
||||||
|
).json();
|
||||||
|
expect(mitPin.pinPasst).toBe(true);
|
||||||
|
expect(mitPin.verbindung).toEqual({ ok: true, version: "9.2.1", release: "9.2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Prüfen: falsches Secret ergibt Ursache und Hinweis statt Fehlerseite", async () => {
|
||||||
|
const ergebnis = await (
|
||||||
|
await pruefenPost(
|
||||||
|
anfrage("POST", "/api/v1/server/pruefen", admin, eingabe({ tokenGeheimnis: "falsch" })),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
expect(ergebnis.verbindung).toMatchObject({ ok: false, art: "anmeldung" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Prüfen: bei bestehendem Server reicht der Name statt des Secrets", async () => {
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
const ergebnis = await (
|
||||||
|
await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", admin, eingabe({ tokenGeheimnis: "" })))
|
||||||
|
).json();
|
||||||
|
expect(ergebnis.verbindung).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Prüfen ist nur für Admins (baut Verbindungen zu beliebigen Adressen auf)", async () => {
|
||||||
|
expect(
|
||||||
|
(await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", operator, { url: mock.url }))).status,
|
||||||
|
).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt ein SSH-Schlüsselpaar; nur der öffentliche Teil ist sichtbar", async () => {
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
|
||||||
|
const antwort = await sshPost(anfrage("POST", "/api/v1/server/rz1/ssh", admin), kontext({ name: "rz1" }));
|
||||||
|
const sicht = await antwort.json();
|
||||||
|
expect(sicht.sshOeffentlich).toMatch(/^ssh-ed25519 AAAA\S+ proxdeploy@rz1$/);
|
||||||
|
expect(JSON.stringify(sicht)).not.toContain("PRIVATE");
|
||||||
|
const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
|
||||||
|
expect(entschluesseln(server.sshPrivat!)).toContain("BEGIN OPENSSH PRIVATE KEY");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user