From 86b650e20a996d31159c6fc25d5ec49f5849c31a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:44:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20Verbindungstest=20mit=20F=C3=A4?= =?UTF-8?q?higkeitserkennung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH. Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt werden kann nur ein Schlüssel, den der letzte Test gesehen hat. Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im Web-Subset von JetBrains Mono. Refs #003 Co-Authored-By: Claude Opus 5.5 --- docs/proxmox-rechte.md | 94 +++++ .../20261012073909_letzter_test/migration.sql | 2 + prisma/schema.prisma | 4 +- src/app/(app)/server/[name]/page.tsx | 11 +- .../server/[name]/verbindungsbericht.tsx | 320 ++++++++++++++++++ src/app/(app)/server/server-formular.tsx | 6 +- .../server/[name]/ssh/hostschluessel/route.ts | 19 ++ src/app/api/v1/server/[name]/test/route.ts | 15 + src/components/formular.tsx | 3 +- src/components/status.tsx | 37 +- src/engine/proxmox/erkundung.ts | 319 +++++++++++++++++ src/engine/proxmox/faehigkeiten.ts | 287 ++++++++++++++++ src/engine/proxmox/fehler.ts | 4 +- src/engine/ssh/verbindung.ts | 145 ++++++++ src/lib/formate.ts | 37 ++ src/lib/schemas/server.ts | 12 + src/server/services/server.ts | 120 ++++++- tests/e2e/global-setup.ts | 3 +- tests/e2e/grundgeruest.spec.ts | 5 +- tests/e2e/server.spec.ts | 6 + tests/engine/erkundung.test.ts | 186 ++++++++++ tests/engine/faehigkeiten.test.ts | 163 +++++++++ tests/proxmox-mock/szenario.ts | 119 +++++++ tests/proxmox-rechte-doku.test.ts | 25 ++ tests/ssh-mock.ts | 66 ++++ tests/verbindungstest.test.ts | 104 ++++++ 26 files changed, 2094 insertions(+), 18 deletions(-) create mode 100644 docs/proxmox-rechte.md create mode 100644 prisma/migrations/20261012073909_letzter_test/migration.sql create mode 100644 src/app/(app)/server/[name]/verbindungsbericht.tsx create mode 100644 src/app/api/v1/server/[name]/ssh/hostschluessel/route.ts create mode 100644 src/app/api/v1/server/[name]/test/route.ts create mode 100644 src/engine/proxmox/erkundung.ts create mode 100644 src/engine/proxmox/faehigkeiten.ts create mode 100644 src/engine/ssh/verbindung.ts create mode 100644 src/lib/formate.ts create mode 100644 tests/engine/erkundung.test.ts create mode 100644 tests/engine/faehigkeiten.test.ts create mode 100644 tests/proxmox-mock/szenario.ts create mode 100644 tests/proxmox-rechte-doku.test.ts create mode 100644 tests/ssh-mock.ts create mode 100644 tests/verbindungstest.test.ts diff --git a/docs/proxmox-rechte.md b/docs/proxmox-rechte.md new file mode 100644 index 0000000..b0a1bac --- /dev/null +++ b/docs/proxmox-rechte.md @@ -0,0 +1,94 @@ +# Proxmox-Rechte für das API-Token + +ProxDeploy braucht auf jedem Proxmox-Server ein API-Token. Es sollte **nicht** `root@pam` gehören, +sondern einem eigenen Benutzer mit einer eigenen Rolle `ProxDeploy`, die nur das Nötige erlaubt. +Der Verbindungstest in ProxDeploy vergleicht die tatsächlichen Rechte mit dieser Liste und nennt +fehlende Rechte einzeln, mit Pfad und Zweck. + +## Rolle anlegen (PVE 9.x) + +Auf einem Node des Clusters als root: + +```bash +pveum role add ProxDeploy --privs "VM.Allocate VM.Clone VM.Audit VM.Config.Disk VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.Cloudinit VM.PowerMgmt VM.GuestAgent.Audit VM.GuestAgent.Unrestricted Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit SDN.Use Sys.Audit Sys.AccessNetwork" +``` + +## Rolle anlegen (PVE 8.x) + +```bash +pveum role add ProxDeploy --privs "VM.Allocate VM.Clone VM.Audit VM.Config.Disk VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.Cloudinit VM.PowerMgmt VM.Monitor Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit SDN.Use Sys.Audit Sys.AccessNetwork" +``` + +Vor PVE 8.2 gibt es `Sys.AccessNetwork` noch nicht; dort statt dessen `Sys.Modify` eintragen. + +Nach einem Upgrade von 8 auf 9 die Rolle anpassen: `VM.Monitor` gibt es in PVE 9 nicht mehr, der +Guest-Agent hat eigene Rechte bekommen. + +```bash +pveum role modify ProxDeploy --privs "…Liste für PVE 9.x…" +``` + +## Benutzer, Rechte und Token + +```bash +pveum user add proxdeploy@pve --comment "ProxDeploy" +pveum acl modify / --users proxdeploy@pve --roles ProxDeploy +pveum user token add proxdeploy@pve deploy --privsep 0 +``` + +Die letzte Zeile gibt das Secret **einmal** aus – zusammen mit der Token-ID +`proxdeploy@pve!deploy` in ProxDeploy unter *Server → Server anlegen* eintragen. + +`--privsep 0` lässt das Token die Rechte seines Benutzers erben. Mit Privilege Separation +(`--privsep 1`, Standard in der Oberfläche) braucht das Token die Rolle selbst: +`pveum acl modify / --tokens 'proxdeploy@pve!deploy' --roles ProxDeploy`. + +## Enger gefasst + +Statt der Rolle auf `/` genügen diese Pfade: + +| Pfad | Wofür | +|---|---| +| `/vms` (oder ein Pool `/pool/`) | VMs und Container anlegen, konfigurieren, starten, Guest-Agent | +| `/storage/` je Storage für Gäste und Images | Disks anlegen, Images und LXC-Templates laden | +| `/sdn/zones/localnetwork` (bzw. die genutzten SDN-Zonen) | Bridges und VNets an Gäste hängen | +| `/nodes/` | `Sys.AccessNetwork` für `download-url` | +| `/` | `Sys.Audit` – Cluster-Status und Rechte lesen | + +## Wozu welches Recht + +| Recht | Wofür | +|---|---| +| `VM.Allocate` | VMs und Container anlegen und (beim Aufräumen) löschen | +| `VM.Clone` | Templates klonen | +| `VM.Audit` | Konfiguration lesen | +| `VM.Config.Disk` | Disks anlegen, importieren, vergrößern | +| `VM.Config.CDROM` | cloud-init-Laufwerk | +| `VM.Config.CPU`, `VM.Config.Memory` | Ressourcen | +| `VM.Config.Network` | Netzwerkkarten | +| `VM.Config.HWType` | Maschinentyp q35, UEFI, SCSI-Controller | +| `VM.Config.Options` | Name, Tags, Guest-Agent, Start bei Boot | +| `VM.Config.Cloudinit` | Benutzer, SSH-Keys, IP-Konfiguration | +| `VM.PowerMgmt` | Starten und Stoppen | +| `VM.GuestAgent.Audit` (9.x) | IP-Adressen über den Guest-Agent | +| `VM.GuestAgent.Unrestricted` (9.x) | Provisionierung über den Guest-Agent (`exec`, `file-write`) | +| `VM.Monitor` (8.x) | Guest-Agent in PVE 8 | +| `Datastore.AllocateSpace` | Disks auf dem Storage | +| `Datastore.AllocateTemplate` | Cloud-Images und LXC-Templates herunterladen | +| `Datastore.Audit` | Storage-Inhalt lesen | +| `SDN.Use` | Bridges und VNets nutzen | +| `Sys.Audit` | Cluster-Status, Rechte | +| `Sys.AccessNetwork` (ab 8.2) | `download-url` | + +**Optional:** `Datastore.Allocate` (heruntergeladene Images beim Aufräumen löschen), `SDN.Audit` +(VNets anzeigen), `VM.Console` (Konsole), `Sys.Modify` (privilegierte Container ab PVE 9). + +## Was ein Token grundsätzlich nicht kann + +Diese Dinge erlaubt Proxmox nur `root@pam` mit Passwort-Anmeldung, nie einem Token. ProxDeploy +erledigt sie deshalb per SSH, wenn SSH für den Server eingerichtet ist: + +- Container-Features außer `nesting` (`keyctl`, `fuse`, `mknod`) +- cloud-init-Snippets schreiben (es gibt dafür keine API) +- Befehle in Containern ausführen (`pct exec`; es gibt keine Exec-API für Container) +- Disk-Images von einem absoluten Pfad importieren (vor PVE 8.4 nötig) diff --git a/prisma/migrations/20261012073909_letzter_test/migration.sql b/prisma/migrations/20261012073909_letzter_test/migration.sql new file mode 100644 index 0000000..7ed3638 --- /dev/null +++ b/prisma/migrations/20261012073909_letzter_test/migration.sql @@ -0,0 +1,2 @@ +-- Umbenennen statt neu anlegen: der Inhalt bleibt erhalten. +ALTER TABLE "Server" RENAME COLUMN "faehigkeiten" TO "letzterTest"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9bbe1bf..4bab628 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,8 +92,8 @@ model Server { bauNetz Json? /// Standard-Node, -Storage, -Bridge für den Assistenten. standards Json? - /// Ergebnis der letzten Fähigkeitserkennung (#003). - faehigkeiten Json? + /// Bericht des letzten Verbindungstests inkl. Fähigkeiten (VerbindungsBericht, #003). + letzterTest Json? erreichbar Boolean? pveVersion String? geprueftAm DateTime? diff --git a/src/app/(app)/server/[name]/page.tsx b/src/app/(app)/server/[name]/page.tsx index 1b84432..185a38d 100644 --- a/src/app/(app)/server/[name]/page.tsx +++ b/src/app/(app)/server/[name]/page.tsx @@ -11,6 +11,7 @@ import { seiteSchuetzen } from "@/server/auth/zugang"; import { serverSicht } from "@/server/services/server"; import { ServerStatus } from "../server-status"; import { ServerAktionen } from "./server-aktionen"; +import { Verbindungsbericht } from "./verbindungsbericht"; export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise { return { title: (await params).name }; @@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name: + + {!server.sshAktiv ? ( Nicht genutzt. Deployments laufen dann nur über die API. @@ -80,7 +88,8 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:

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

- Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003). + Host-Keys erscheinen nach dem Verbindungstest beim jeweiligen Node und werden erst nach + Bestätigung genutzt.

) : ( diff --git a/src/app/(app)/server/[name]/verbindungsbericht.tsx b/src/app/(app)/server/[name]/verbindungsbericht.tsx new file mode 100644 index 0000000..50ebaac --- /dev/null +++ b/src/app/(app)/server/[name]/verbindungsbericht.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Karte } from "@/components/karte"; +import { Knopf } from "@/components/knopf"; +import { Status, type StatusArt } from "@/components/status"; +import type { VerbindungsBericht } from "@/engine/proxmox/erkundung"; +import type { Strategie } from "@/engine/proxmox/faehigkeiten"; +import { apiAufruf, ApiFehler } from "@/lib/api-client"; +import { bytes, prozent, zeitpunkt } from "@/lib/formate"; + +type TestErgebnis = + { ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string }; + +const WEG_STATUS: Record = { + api: { art: "erledigt", text: "per API" }, + ssh: { art: "laeuft", text: "per SSH" }, + nicht_moeglich: { art: "fehler", text: "nicht möglich" }, +}; + +const VORGAENGE: Array<[keyof VerbindungsBericht["faehigkeiten"], string]> = [ + ["templateBau", "Template bauen"], + ["vmProvisionierung", "VM provisionieren"], + ["lxcProvisionierung", "LXC provisionieren"], + ["snippets", "cloud-init-Snippets"], + ["lxcFeaturesErweitert", "LXC-Features außer nesting"], +]; + +/** + * Ergebnis des Verbindungstests: Version, Nodes, Storages, Bridges, fehlende Rechte und – am + * wichtigsten – welcher Vorgang auf welchem Weg geht. Host-Keys lassen sich hier bestätigen. + */ +export function Verbindungsbericht({ + name, + bericht: anfangsBericht, + geprueftAm, + verwalten, +}: { + name: string; + bericht: VerbindungsBericht | null; + geprueftAm: string | null; + verwalten: boolean; +}) { + const router = useRouter(); + const [bericht, setBericht] = useState(anfangsBericht); + const [fehler, setFehler] = useState<{ ursache: string; hinweis?: string } | null>(null); + const [laeuft, setLaeuft] = useState(false); + const [zeit, setZeit] = useState(geprueftAm); + + async function testen() { + setLaeuft(true); + setFehler(null); + try { + const ergebnis = await apiAufruf("POST", `/api/v1/server/${name}/test`); + setZeit(new Date().toISOString()); + if (ergebnis.ok) setBericht(ergebnis.bericht); + else setFehler({ ursache: ergebnis.ursache, hinweis: ergebnis.hinweis }); + router.refresh(); + } catch (ursache) { + setFehler({ ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler." }); + } finally { + setLaeuft(false); + } + } + + async function bestaetigen(knoten: string, hostSchluessel: string) { + try { + await apiAufruf("POST", `/api/v1/server/${name}/ssh/hostschluessel`, { knoten, hostSchluessel }); + await testen(); + } catch (ursache) { + setFehler({ + ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.", + hinweis: ursache instanceof ApiFehler ? ursache.hinweis : undefined, + }); + } + } + + const pflichtFehlend = bericht?.fehlendeRechte.filter((r) => r.pflicht) ?? []; + const optionalFehlend = bericht?.fehlendeRechte.filter((r) => !r.pflicht) ?? []; + + return ( +
+ + {laeuft ? "Testet …" : "Verbindung testen"} + + } + > +
+ {laeuft ? ( + Liest Version, Nodes, Storages, Netzwerk und Rechte … + ) : null} + {fehler ? ( +
+ {fehler.ursache} + {fehler.hinweis ?

{fehler.hinweis}

: null} +
+ ) : null} + {!bericht && !fehler && !laeuft ? Noch nicht getestet. : null} + {bericht ? ( +
+
Proxmox VE
+
{bericht.version}
+
Cluster
+
+ {bericht.cluster ? ( + <> + {bericht.cluster.name} ·{" "} + {bericht.cluster.quorate ? "Quorum vorhanden" : "kein Quorum"} + + ) : ( + "Einzelhost" + )} +
+
Geprüft
+
{zeitpunkt(zeit)}
+
+ ) : null} + {bericht?.warnungen.map((warnung) => ( + + {warnung} + + ))} +
+
+ + {bericht ? ( + <> + +
    + {VORGAENGE.map(([schluessel, titel]) => { + const strategie = bericht.faehigkeiten[schluessel]; + const anzeige = WEG_STATUS[strategie.weg]; + return ( +
  • + {titel} + {anzeige.text} + {strategie.grund} +
  • + ); + })} +
+
+ + + {pflichtFehlend.length === 0 ? ( + Alle nötigen Rechte vorhanden. + ) : ( +
+ + {pflichtFehlend.length === 1 ? "Ein Recht fehlt" : `${pflichtFehlend.length} Rechte fehlen`}{" "} + – die Rolle „ProxDeploy“ steht vollständig in docs/proxmox-rechte.md. + + +
+ )} + {optionalFehlend.length > 0 ? ( +
+ + {optionalFehlend.length} optionale Rechte fehlen + +
+ +
+
+ ) : null} +
+ + {bericht.knoten.map((knoten) => ( + + {knoten.name} + {knoten.ip ? · {knoten.ip} : null} + + } + aktion={ + knoten.online ? online : offline + } + > + {knoten.online ? ( +
+
+
Version
+
{knoten.version ?? "–"}
+
CPU
+
+ {knoten.cpus ?? "–"} Kerne · {prozent(knoten.cpuAuslastung)} +
+
RAM
+
+ {bytes(knoten.speicherBelegt)} / {bytes(knoten.speicherGesamt)} +
+
+ +
+ + + + + + + + + + + + {knoten.storages.map((s) => ( + + + + + + + ))} + +
+ Storages +
NameTypInhalteFrei
{s.storage}{s.typ}{s.inhalte.join(", ")} + {bytes(s.frei)} / {bytes(s.gesamt)} +
+
+ +
+

Bridges

+
    + {knoten.bridges.map((b) => ( +
  • + {b.name} + + {b.vlanBewusst ? " · VLAN-fähig" : ""} + {b.cidr ? ` · ${b.cidr}` : ""} + {b.kommentar ? ` · ${b.kommentar}` : ""} + +
  • + ))} +
+
+ + {knoten.ssh ? ( +
+

SSH

+ {knoten.ssh.zustand === "ok" ? ( + Verbunden als {knoten.ssh.host} · Host-Key bestätigt + ) : knoten.ssh.zustand === "nicht_bestaetigt" ? ( +
+ + Host-Key noch nicht bestätigt – bitte mit dem Node vergleichen: + + + {knoten.ssh.hostSchluessel} + +

+ Auf dem Node:{" "} + + ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub + +

+ {verwalten && knoten.ssh.hostSchluessel ? ( + bestaetigen(knoten.name, knoten.ssh!.hostSchluessel!)} + > + Host-Key von {knoten.name} bestätigen + + ) : null} +
+ ) : ( + {knoten.ssh.meldung ?? "SSH nicht möglich."} + )} +
+ ) : null} +
+ ) : ( +

Node ist offline – keine Details.

+ )} +
+ ))} + + {bericht.vnets && bericht.vnets.length > 0 ? ( + +
    + {bericht.vnets.map((v) => ( +
  • + {v.name} + + {" "} + · Zone {v.zone} + {v.tag ? ` · VLAN ${v.tag}` : ""} + {v.alias ? ` · ${v.alias}` : ""} + +
  • + ))} +
+
+ ) : null} + + ) : null} +
+ ); +} + +function RechteListe({ rechte }: { rechte: VerbindungsBericht["fehlendeRechte"] }) { + return ( +
    + {rechte.map((r) => ( +
  • + {r.recht} + {r.pfad} + {r.wofuer} +
  • + ))} +
+ ); +} diff --git a/src/app/(app)/server/server-formular.tsx b/src/app/(app)/server/server-formular.tsx index 688fef0..2874bd0 100644 --- a/src/app/(app)/server/server-formular.tsx +++ b/src/app/(app)/server/server-formular.tsx @@ -6,7 +6,7 @@ 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 { Status, StatusSymbol } from "@/components/status"; import { apiAufruf, ApiFehler } from "@/lib/api-client"; import type { ServerSicht } from "@/lib/schemas/server"; @@ -246,7 +246,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) { {felder["fingerabdruck"] ? (

- + {felder["fingerabdruck"]}

) : null} @@ -295,7 +295,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {

- Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“. + Welche Rechte das Token braucht, steht in der Doku „Proxmox-Rechte“ (docs/proxmox-rechte.md).

diff --git a/src/app/api/v1/server/[name]/ssh/hostschluessel/route.ts b/src/app/api/v1/server/[name]/ssh/hostschluessel/route.ts new file mode 100644 index 0000000..8dd7506 --- /dev/null +++ b/src/app/api/v1/server/[name]/ssh/hostschluessel/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { aufruferAdresse } from "@/lib/adressen"; +import { api, jsonLesen } from "@/server/api"; +import { rechtErforderlich } from "@/server/auth/zugang"; +import { hostSchluesselBestaetigen } from "@/server/services/server"; + +type Kontext = { params: Promise<{ name: string }> }; + +export const POST = api(async (anfrage: Request, { params }: Kontext) => { + const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten"); + return NextResponse.json( + await hostSchluesselBestaetigen( + (await params).name, + await jsonLesen(anfrage), + aufrufer, + aufruferAdresse(anfrage.headers), + ), + ); +}); diff --git a/src/app/api/v1/server/[name]/test/route.ts b/src/app/api/v1/server/[name]/test/route.ts new file mode 100644 index 0000000..33b62b3 --- /dev/null +++ b/src/app/api/v1/server/[name]/test/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 { serverTesten } from "@/server/services/server"; + +type Kontext = { params: Promise<{ name: string }> }; + +/** Verbindungstest: liest nur. Auch Operatoren dürfen ihn auslösen, um vor dem Ausrollen zu prüfen. */ +export const POST = api(async (anfrage: Request, { params }: Kontext) => { + const aufrufer = await rechtErforderlich(anfrage, "lesen"); + return NextResponse.json( + await serverTesten((await params).name, aufrufer, aufruferAdresse(anfrage.headers)), + ); +}); diff --git a/src/components/formular.tsx b/src/components/formular.tsx index 2e848f6..78bdaa9 100644 --- a/src/components/formular.tsx +++ b/src/components/formular.tsx @@ -1,4 +1,5 @@ import { clsx } from "clsx"; +import { StatusSymbol } from "@/components/status"; /** * Formular-Bausteine: echtes `