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 `