From 48e2a5cb8f17b99c8396e6436896578b4a36d6e4 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:35:10 +0200 Subject: [PATCH] feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 + DEVLOG.md | 6 +- docs/issues/002-verschluesselung-server.md | 28 +- docs/issues/README.md | 2 +- package.json | 3 + playwright.config.ts | 1 + pnpm-lock.yaml | 88 ++++ pnpm-workspace.yaml | 5 + .../20261012072448_server/migration.sql | 44 ++ prisma/schema.prisma | 39 ++ scripts/neu-starten.sh | 14 + src/app/(app)/layout.tsx | 16 +- src/app/(app)/page.tsx | 35 +- .../(app)/server/[name]/bearbeiten/page.tsx | 21 + src/app/(app)/server/[name]/page.tsx | 103 +++++ .../(app)/server/[name]/server-aktionen.tsx | 78 ++++ src/app/(app)/server/neu/page.tsx | 15 + src/app/(app)/server/page.tsx | 63 +++ src/app/(app)/server/server-formular.tsx | 422 ++++++++++++++++++ src/app/(app)/server/server-status.tsx | 13 + src/app/api/v1/server/[name]/route.ts | 29 ++ src/app/api/v1/server/[name]/ssh/route.ts | 15 + src/app/api/v1/server/pruefen/route.ts | 13 + src/app/api/v1/server/route.ts | 16 + src/components/formular.tsx | 104 +++++ src/components/server-umschalter.tsx | 42 ++ src/engine/proxmox/aufgaben.ts | 81 ++++ src/engine/proxmox/client.ts | 140 ++++++ src/engine/proxmox/fehler.ts | 183 ++++++++ src/engine/proxmox/tls.ts | 135 ++++++ src/lib/api-client.ts | 46 ++ src/lib/navigation.ts | 2 +- src/lib/schemas/server.ts | 117 +++++ src/lib/server-auswahl.ts | 7 + src/server/services/server.ts | 261 +++++++++++ tests/e2e/global-setup.ts | 16 + tests/e2e/grundgeruest.spec.ts | 22 +- tests/e2e/helfer.ts | 24 + tests/e2e/server.spec.ts | 43 ++ tests/engine/proxmox-client.test.ts | 218 +++++++++ tests/helpers/anfragen.ts | 26 ++ tests/proxmox-mock/mock.ts | 153 +++++++ tests/server.test.ts | 192 ++++++++ 43 files changed, 2847 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/20261012072448_server/migration.sql create mode 100755 scripts/neu-starten.sh create mode 100644 src/app/(app)/server/[name]/bearbeiten/page.tsx create mode 100644 src/app/(app)/server/[name]/page.tsx create mode 100644 src/app/(app)/server/[name]/server-aktionen.tsx create mode 100644 src/app/(app)/server/neu/page.tsx create mode 100644 src/app/(app)/server/page.tsx create mode 100644 src/app/(app)/server/server-formular.tsx create mode 100644 src/app/(app)/server/server-status.tsx create mode 100644 src/app/api/v1/server/[name]/route.ts create mode 100644 src/app/api/v1/server/[name]/ssh/route.ts create mode 100644 src/app/api/v1/server/pruefen/route.ts create mode 100644 src/app/api/v1/server/route.ts create mode 100644 src/components/formular.tsx create mode 100644 src/components/server-umschalter.tsx create mode 100644 src/engine/proxmox/aufgaben.ts create mode 100644 src/engine/proxmox/client.ts create mode 100644 src/engine/proxmox/fehler.ts create mode 100644 src/engine/proxmox/tls.ts create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/schemas/server.ts create mode 100644 src/lib/server-auswahl.ts create mode 100644 src/server/services/server.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/helfer.ts create mode 100644 tests/e2e/server.spec.ts create mode 100644 tests/engine/proxmox-client.test.ts create mode 100644 tests/helpers/anfragen.ts create mode 100644 tests/proxmox-mock/mock.ts create mode 100644 tests/server.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2b1b5..91e3810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - Seitenleiste mit Wortmarke, auf dem Handy als Schublade; Übersichtsseite (#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 @@ -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) - 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) +- 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) diff --git a/DEVLOG.md b/DEVLOG.md index efff27d..f7bf2ae 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,13 +2,14 @@ ## 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. | Issue | Zeit | |---|---| | #001 Projekt-Setup | 0 h 50 min | +| #002 Server-Verwaltung | 0 h 11 min | | 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 - 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 +- #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) ### 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). - **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. +- **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). diff --git a/docs/issues/002-verschluesselung-server.md b/docs/issues/002-verschluesselung-server.md index 4e29817..3492ec3 100644 --- a/docs/issues/002-verschluesselung-server.md +++ b/docs/issues/002-verschluesselung-server.md @@ -1,26 +1,32 @@ # #002 Server-Verwaltung und Verschlüsselung -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** feature, sicherheit -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 11 min ## Ziel Proxmox-Server anlegen, bearbeiten, löschen; Geheimnisse verschlüsselt. ## Aufgaben -- [ ] AES-256-GCM-Helfer mit Versionspräfix, Tests (falscher Schlüssel, manipulierte Daten) -- [ ] Modell `Server`, Services, REST `/api/v1/server`, Formular mit Token-ID/Secret -- [ ] TLS: Fingerprint holen (`POST /server/pruefen`), anzeigen, bestätigen, pinnen -- [ ] Proxmox-Client mit undici-Agent und Fingerprint-Vergleich, UPID-Warten -- [ ] SSH optional: Schlüsselpaar erzeugen, Einzeiler anzeigen, Host-Key pinnen -- [ ] Protokolleintrag je Änderung +- [x] AES-256-GCM-Helfer mit Versionspräfix, Tests (falscher Schlüssel, manipulierte Daten) – mit #001 +- [x] Modell `Server`, Services, REST `/api/v1/server`, Formular mit Token-ID/Secret +- [x] TLS: Fingerprint holen (`POST /server/pruefen`), anzeigen, bestätigen, pinnen +- [x] Proxmox-Client mit undici-Agent und Fingerprint-Vergleich, UPID-Warten +- [x] SSH optional: Schlüsselpaar erzeugen, Einzeiler anzeigen (Host-Key-Pin folgt mit dem SSH-Test in #003) +- [x] Protokolleintrag je Änderung (geänderte Felder, nie Werte von Geheimnissen) +- [x] Server-Umschalter in der Seitenleiste, Serverliste auf der Übersicht ## Akzeptanzkriterien -- [ ] Secret erscheint nie in API-Antworten oder Logs -- [ ] Falscher Fingerprint → klare Fehlermeldung, keine Verbindung +- [x] Secret erscheint nie in API-Antworten, Protokoll oder Logs (Test) +- [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 #001 ## 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. diff --git a/docs/issues/README.md b/docs/issues/README.md index 3e758ef..d75141f 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -5,7 +5,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | 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 | -| [#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 | – | | [#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 | – | diff --git a/package.json b/package.json index 25e7aa3..afa8e25 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "pino": "10.4.0", "react": "19.3.0", "react-dom": "19.3.0", + "ssh2": "1.17.0", + "undici": "8.11.2", "zod": "^4.6.5" }, "devDependencies": { @@ -51,6 +53,7 @@ "@types/pg": "^8.23.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "@types/ssh2": "1.15.6", "dotenv": "^17.4.2", "esbuild": "^0.27.0", "eslint": "^9.39.5", diff --git a/playwright.config.ts b/playwright.config.ts index 00ce444..bc59fc3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,6 +8,7 @@ const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3310"; export default defineConfig({ testDir: "./tests/e2e", + globalSetup: "./tests/e2e/global-setup.ts", fullyParallel: false, workers: 1, forbidOnly: Boolean(process.env["CI"]), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc95cd3..11255ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,12 @@ importers: react-dom: specifier: 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: specifier: ^4.6.5 version: 4.6.5 @@ -233,6 +239,9 @@ importers: '@types/react-dom': specifier: ^19.3.0 version: 19.3.0(@types/react@19.3.0) + '@types/ssh2': + specifier: 1.15.6 + version: 1.15.6 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -1600,6 +1609,9 @@ packages: '@types/lodash@4.17.26': resolution: {integrity: sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@24.19.2': resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} @@ -1614,6 +1626,9 @@ packages: '@types/react@19.3.0': 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': resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1913,6 +1928,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1956,6 +1974,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} @@ -1975,6 +1996,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -2077,6 +2102,10 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3087,6 +3116,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nan@2.29.0: + resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3545,6 +3577,10 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -3677,6 +3713,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3713,9 +3752,16 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.24.6: 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: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -4966,6 +5012,10 @@ snapshots: '@types/lodash@4.17.26': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@24.19.2': dependencies: undici-types: 7.24.6 @@ -4984,6 +5034,10 @@ snapshots: dependencies: 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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5334,6 +5388,10 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -5358,6 +5416,10 @@ snapshots: baseline-browser-mapping@2.11.28: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + better-result@2.10.0: {} brace-expansion@1.1.21: @@ -5381,6 +5443,9 @@ snapshots: node-releases: 2.0.58 update-browserslist-db: 1.3.4(browserslist@4.29.3) + buildcheck@0.0.7: + optional: true + c12@3.3.4: dependencies: chokidar: 5.0.0 @@ -5483,6 +5548,12 @@ snapshots: optionalDependencies: 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: dependencies: path-key: 3.1.1 @@ -6611,6 +6682,9 @@ snapshots: dependencies: lru.min: 1.1.5 + nan@2.29.0: + optional: true + nanoid@3.3.20: {} napi-postinstall@0.3.4: {} @@ -7120,6 +7194,14 @@ snapshots: 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: {} stackback@0.0.2: {} @@ -7260,6 +7342,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7316,8 +7400,12 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@7.24.6: {} + undici@8.11.2: {} + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2dc4906..fa2099f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,8 @@ allowBuilds: esbuild: true prisma: 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 diff --git a/prisma/migrations/20261012072448_server/migration.sql b/prisma/migrations/20261012072448_server/migration.sql new file mode 100644 index 0000000..46a3199 --- /dev/null +++ b/prisma/migrations/20261012072448_server/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 063aac6..9bbe1bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,3 +61,42 @@ model Einstellung { wert Json 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 +} diff --git a/scripts/neu-starten.sh b/scripts/neu-starten.sh new file mode 100755 index 0000000..d4c73bb --- /dev/null +++ b/scripts/neu-starten.sh @@ -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 diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 2f7a6b5..ad89df9 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,6 +1,9 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; 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 { THEME_COOKIE, themeAusCookie } from "@/lib/theme"; import { versionText } from "@/lib/version"; @@ -15,7 +18,16 @@ export default async function AppHuelle({ children }: { children: React.ReactNod const benutzer = await aktuellerBenutzer(); 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)); return ( @@ -25,7 +37,7 @@ export default async function AppHuelle({ children }: { children: React.ReactNod benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }} version={versionText()} theme={theme} - serverUmschalter={null} + serverUmschalter={} />
{children}
diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 3cd88fa..42fdbfd 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,12 +1,16 @@ import type { Metadata } from "next"; import { Karte } from "@/components/karte"; import { Status } from "@/components/status"; +import Link from "next/link"; 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 default async function Uebersicht() { const benutzer = await seiteSchuetzen("lesen", "/"); + const server = await serverListe(); return (
@@ -16,11 +20,32 @@ export default async function Uebersicht() {
- - Noch kein Proxmox-Server angelegt. -

- Server lassen sich anlegen, sobald die Server-Verwaltung (#002) fertig ist. -

+ + Alle + + } + > + {server.length === 0 ? ( + Noch kein Proxmox-Server angelegt. + ) : ( +
    + {server.map((eintrag) => ( +
  • + + {eintrag.titel}{" "} + {eintrag.name} + + +
  • + ))} +
+ )}
Noch keine Deployments. diff --git a/src/app/(app)/server/[name]/bearbeiten/page.tsx b/src/app/(app)/server/[name]/bearbeiten/page.tsx new file mode 100644 index 0000000..658dafa --- /dev/null +++ b/src/app/(app)/server/[name]/bearbeiten/page.tsx @@ -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 ( +
+

{server.titel} bearbeiten

+ +
+ ); +} diff --git a/src/app/(app)/server/[name]/page.tsx b/src/app/(app)/server/[name]/page.tsx new file mode 100644 index 0000000..1b84432 --- /dev/null +++ b/src/app/(app)/server/[name]/page.tsx @@ -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 { + 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 ( +
+
+
+

{server.name}

+

{server.titel}

+
+ {verwalten ? ( + +
+ + }> +
+
Adresse
+
{server.url}
+
Token-ID
+
{server.tokenId}
+
TLS
+
+ {server.fingerabdruck ? ( + + Fingerprint gepinnt{server.tlsPruefen ? " und Kette geprüft" : ""} + {server.fingerabdruck} + + ) : ( + "Kette gegen Systemzertifikate geprüft" + )} +
+
Templates
+
+ {server.templateVmidVon}–{server.templateVmidBis} +
+
Gäste ab
+
{server.gastVmidVon}
+
+
+ + + {!server.sshAktiv ? ( + Nicht genutzt. Deployments laufen dann nur über die API. + ) : server.sshOeffentlich ? ( +
+

+ Diesen Schlüssel auf jedem Node für {server.sshBenutzer}{" "} + hinterlegen. Im Cluster genügt ein Node:{" "} + /root/.ssh/authorized_keys zeigt dort auf die gemeinsame + Datei in /etc/pve/priv/. +

+ {`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`} +

+ Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003). +

+
+ ) : ( + SSH ist eingeschaltet, aber es gibt noch keinen Schlüssel. + )} + {verwalten && server.sshAktiv ? ( +
+ +
+ ) : null} +
+ + {verwalten ? ( + + + + ) : null} +
+ ); +} diff --git a/src/app/(app)/server/[name]/server-aktionen.tsx b/src/app/(app)/server/[name]/server-aktionen.tsx new file mode 100644 index 0000000..f8bdee7 --- /dev/null +++ b/src/app/(app)/server/[name]/server-aktionen.tsx @@ -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(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 ( +
+ {art === "loeschen" ? ( +

+ Entfernt nur den Eintrag in ProxDeploy. VMs und Container auf dem Server bleiben unberührt. +

+ ) : null} +
+ + {laeuft ? "Bitte warten …" : text} + + {bestaetigen ? ( + setBestaetigen(false)}> + Abbrechen + + ) : null} +
+
{fehler ? {fehler} : null}
+
+ ); +} diff --git a/src/app/(app)/server/neu/page.tsx b/src/app/(app)/server/neu/page.tsx new file mode 100644 index 0000000..83924da --- /dev/null +++ b/src/app/(app)/server/neu/page.tsx @@ -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 ( +
+

Server anlegen

+ +
+ ); +} diff --git a/src/app/(app)/server/page.tsx b/src/app/(app)/server/page.tsx new file mode 100644 index 0000000..0016138 --- /dev/null +++ b/src/app/(app)/server/page.tsx @@ -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 ( +
+
+
+

Server

+

Proxmox-VE-Hosts und -Cluster, auf die ProxDeploy ausrollt.

+
+ {verwalten ? ( + +
+ + {liste.length === 0 ? ( + + Noch kein Server angelegt. + {!verwalten ?

Server legt ein Administrator an.

: null} +
+ ) : ( +
    + {liste.map((server) => ( +
  • + +
    +
    +

    {server.titel}

    +

    {server.name}

    +
    + +
    +

    {server.url}

    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/server/server-formular.tsx b/src/app/(app)/server/server-formular.tsx new file mode 100644 index 0000000..688fef0 --- /dev/null +++ b/src/app/(app)/server/server-formular.tsx @@ -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(() => anfangswerte(server)); + const [pruefung, setPruefung] = useState(null); + const [prueft, setPrueft] = useState<"zertifikat" | "verbindung" | null>(null); + const [speichert, setSpeichert] = useState(false); + const [felder, setFelder] = useState>({}); + const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null); + + const setze = (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("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("PATCH", `/api/v1/server/${server!.name}`, rumpf) + : await apiAufruf("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 ( +
+ +
+ + setze("name", e.target.value)} + autoComplete="off" + spellCheck={false} + {...beschreibung("name", felder["name"], true)} + /> + + + setze("titel", e.target.value)} + placeholder="Rechenzentrum 1" + {...beschreibung("titel", felder["titel"])} + /> + +
+
+ + +
+
+ + setze("url", e.target.value)} + placeholder="https://pve01:8006" + inputMode="url" + spellCheck={false} + {...beschreibung("url", felder["url"], true)} + /> + + pruefen("zertifikat")} + disabled={!werte.url || prueft !== null} + className="sm:mb-[1.625rem]" + > + {prueft === "zertifikat" ? "Holt …" : "Zertifikat holen"} + +
+ + {zertifikat ? ( +
+ + {zertifikat.vertraut + ? "Das System vertraut diesem Zertifikat." + : "Selbst signiertes Zertifikat – bitte den Fingerprint vergleichen."} + +
+
Subjekt
+
{zertifikat.subjekt}
+
Aussteller
+
{zertifikat.aussteller}
+
Gültig bis
+
{new Date(zertifikat.gueltigBis).toLocaleDateString("de-DE")}
+
+
+ SHA-256-Fingerprint + {zertifikat.fingerabdruck} +

+ Zum Vergleichen auf dem Node: pvenode cert info oder in + der Proxmox-Oberfläche unter Node → System → Zertifikate. +

+
+ + setWerte((alt) => ({ ...alt, fingerabdruck: an ? zertifikat.fingerabdruck : null })) + } + /> +
+ ) : gepinnt ? ( +
+ + + + + {werte.fingerabdruck!} +
+ ) : null} + + {felder["fingerabdruck"] ? ( +

+ + {felder["fingerabdruck"]} +

+ ) : null} + + setze("tlsPruefen", an)} + /> +
+
+ + +
+
+ + setze("tokenId", e.target.value)} + placeholder="proxdeploy@pve!deploy" + autoComplete="off" + spellCheck={false} + {...beschreibung("tokenId", felder["tokenId"], true)} + /> + + + setze("tokenGeheimnis", e.target.value)} + placeholder={bearbeiten ? "unverändert" : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"} + autoComplete="new-password" + {...beschreibung("tokenGeheimnis", felder["tokenGeheimnis"], bearbeiten)} + /> + +
+

+ Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“. +

+ +
+ pruefen("verbindung")} + disabled={ + prueft !== null || + !werte.url || + !werte.tokenId || + (!werte.tokenGeheimnis && !bearbeiten) || + (!werte.tlsPruefen && !gepinnt) + } + > + {prueft === "verbindung" ? "Prüft …" : "Verbindung prüfen"} + +
+ {pruefung?.verbindung?.ok ? ( + + Verbunden · Proxmox VE {pruefung.verbindung.version} + + ) : pruefung?.verbindung && !pruefung.verbindung.ok ? ( +
+ {pruefung.verbindung.ursache} +

{pruefung.verbindung.hinweis}

+
+ ) : null} +
+
+
+
+ + +
+
+ + setze("templateVmidVon", e.target.value)} + /> + + + setze("templateVmidBis", e.target.value)} + /> + + + setze("gastVmidVon", e.target.value)} + /> + +
+ + setze("sshAktiv", an)} + /> + {werte.sshAktiv ? ( +
+ + setze("sshBenutzer", e.target.value)} + {...beschreibung("sshBenutzer", undefined, true)} + /> + + + setze("sshPort", e.target.value)} + /> + +
+ ) : null} +
+
+ +
+
+ {meldung ? ( +
+ {meldung.text} + {meldung.hinweis ?

{meldung.hinweis}

: null} +
+ ) : null} +
+ router.back()}> + Abbrechen + + + {speichert ? "Speichert …" : bearbeiten ? "Änderungen speichern" : "Server anlegen"} + +
+
+ ); +} + +function fehlerText(fehler: unknown): { text: string; hinweis?: string } { + if (fehler instanceof ApiFehler) return { text: fehler.message, hinweis: fehler.hinweis }; + return { text: "Unerwarteter Fehler." }; +} diff --git a/src/app/(app)/server/server-status.tsx b/src/app/(app)/server/server-status.tsx new file mode 100644 index 0000000..79e98eb --- /dev/null +++ b/src/app/(app)/server/server-status.tsx @@ -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 }) { + if (server.erreichbar === null) return ungeprüft; + if (!server.erreichbar) return nicht erreichbar; + return ( + + PVE {server.pveVersion} + + ); +} diff --git a/src/app/api/v1/server/[name]/route.ts b/src/app/api/v1/server/[name]/route.ts new file mode 100644 index 0000000..bfd01f4 --- /dev/null +++ b/src/app/api/v1/server/[name]/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/server/[name]/ssh/route.ts b/src/app/api/v1/server/[name]/ssh/route.ts new file mode 100644 index 0000000..36b88a2 --- /dev/null +++ b/src/app/api/v1/server/[name]/ssh/route.ts @@ -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)), + ); +}); diff --git a/src/app/api/v1/server/pruefen/route.ts b/src/app/api/v1/server/pruefen/route.ts new file mode 100644 index 0000000..ad033f9 --- /dev/null +++ b/src/app/api/v1/server/pruefen/route.ts @@ -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))); +}); diff --git a/src/app/api/v1/server/route.ts b/src/app/api/v1/server/route.ts new file mode 100644 index 0000000..1deeb72 --- /dev/null +++ b/src/app/api/v1/server/route.ts @@ -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 }); +}); diff --git a/src/components/formular.tsx b/src/components/formular.tsx new file mode 100644 index 0000000..2e848f6 --- /dev/null +++ b/src/components/formular.tsx @@ -0,0 +1,104 @@ +import { clsx } from "clsx"; + +/** + * Formular-Bausteine: echtes `