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)}
+
+
+
+
+
+
+ Storages
+
+
+
+ Name
+ Typ
+ Inhalte
+ Frei
+
+
+
+ {knoten.storages.map((s) => (
+
+ {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 `
`, Fehlermeldung per `aria-describedby` verbunden,
@@ -30,7 +31,7 @@ export function Feld({
{children}
{fehler ? (
- ✗
+
{fehler}
) : hilfe ? (
diff --git a/src/components/status.tsx b/src/components/status.tsx
index 224689b..42a6e24 100644
--- a/src/components/status.tsx
+++ b/src/components/status.tsx
@@ -1,11 +1,13 @@
import { clsx } from "clsx";
/**
- * Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
- * Dieselben Symbole wie in der CLI-Ausgabe.
+ * Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut). Die Symbole
+ * entsprechen ✓ ◐ ✗ ○ der CLI, sind hier aber gezeichnet: ◐ und ○ fehlen im Web-Subset von
+ * JetBrains Mono, der Browser fiele auf eine beliebige Schrift mit winzigem Glyph zurück.
*/
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
+/** Die Zeichen der CLI – für Textausgaben (Terminal-Karte, Kopiertext). */
export const STATUS_SYMBOL: Record = {
erledigt: "✓",
laeuft: "◐",
@@ -20,6 +22,31 @@ const STATUS_FARBE: Record = {
offen: "text-muted",
};
+export function StatusSymbol({ art, className }: { art: StatusArt; className?: string }) {
+ return (
+
+ {art === "erledigt" ? : null}
+ {art === "fehler" ? : null}
+ {art === "offen" ? : null}
+ {art === "laeuft" ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
+
export function Status({
art,
children,
@@ -30,10 +57,8 @@ export function Status({
className?: string;
}) {
return (
-
-
- {STATUS_SYMBOL[art]}
-
+
+
{children}
);
diff --git a/src/engine/proxmox/erkundung.ts b/src/engine/proxmox/erkundung.ts
new file mode 100644
index 0000000..5457b8a
--- /dev/null
+++ b/src/engine/proxmox/erkundung.ts
@@ -0,0 +1,319 @@
+import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
+import { pfad, type ProxmoxClient } from "./client";
+import {
+ faehigkeitenAbleiten,
+ pfadFuer,
+ rechtePruefen,
+ versionLesen,
+ type Faehigkeiten,
+ type FehlendesRecht,
+ type PveVersion,
+ type RechteJePfad,
+ type SshZustand,
+ type StorageInfo,
+} from "./faehigkeiten";
+import { ProxmoxFehler } from "./fehler";
+
+/**
+ * Der Verbindungstest: liest alles, was ProxDeploy über einen Server wissen muss, und leitet die
+ * Fähigkeiten ab. Nur lesende Aufrufe. Fehlt für einen Teil ein Recht, wird das als Warnung
+ * vermerkt statt den ganzen Test abzubrechen – der Bericht soll gerade dann helfen.
+ */
+export type BridgeInfo = {
+ name: string;
+ typ: string;
+ vlanBewusst: boolean;
+ aktiv: boolean;
+ cidr: string | null;
+ kommentar: string | null;
+};
+
+export type SshKnoten = {
+ zustand: SshZustand;
+ host: string;
+ hostSchluessel: string | null;
+ bestaetigt: boolean;
+ meldung: string | null;
+};
+
+export type KnotenInfo = {
+ name: string;
+ online: boolean;
+ ip: string | null;
+ version: string | null;
+ cpuAuslastung: number | null;
+ cpus: number | null;
+ speicherBelegt: number | null;
+ speicherGesamt: number | null;
+ storages: StorageInfo[];
+ bridges: BridgeInfo[];
+ ssh: SshKnoten | null;
+};
+
+export type VnetInfo = { name: string; zone: string; tag: number | null; alias: string | null };
+
+export type VerbindungsBericht = {
+ geprueftAm: string;
+ version: string;
+ release: string;
+ cluster: { name: string; quorate: boolean } | null;
+ knoten: KnotenInfo[];
+ vnets: VnetInfo[] | null;
+ fehlendeRechte: FehlendesRecht[];
+ ssh: SshZustand;
+ faehigkeiten: Faehigkeiten;
+ warnungen: string[];
+};
+
+export type SshZugang = {
+ benutzer: string;
+ port: number;
+ privatSchluessel: string | null;
+ hostSchluessel: Record;
+};
+
+type ClusterStatusEintrag =
+ | { type: "cluster"; name: string; quorate: number; nodes: number }
+ | { type: "node"; name: string; online: number; ip?: string; local?: number };
+
+type NodeEintrag = {
+ node: string;
+ status: string;
+ cpu?: number;
+ maxcpu?: number;
+ mem?: number;
+ maxmem?: number;
+};
+
+type StorageEintrag = {
+ storage: string;
+ type: string;
+ content: string;
+ active?: number;
+ enabled?: number;
+ shared?: number;
+ avail?: number;
+ total?: number;
+};
+
+type NetzEintrag = {
+ iface: string;
+ type: string;
+ active?: number;
+ bridge_vlan_aware?: number;
+ cidr?: string;
+ comments?: string;
+};
+
+async function versuchen(warnungen: string[], text: string, aufruf: () => Promise): Promise {
+ try {
+ return await aufruf();
+ } catch (fehler) {
+ if (fehler instanceof ProxmoxFehler && (fehler.art === "recht" || fehler.art === "nicht_gefunden")) {
+ warnungen.push(`${text}: ${fehler.ursache}`);
+ return null;
+ }
+ throw fehler;
+ }
+}
+
+export async function erkunden(
+ client: ProxmoxClient,
+ optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
+): Promise {
+ const warnungen: string[] = [];
+ const version = await client.get<{ version: string; release: string }>("/version");
+ const pveVersion: PveVersion = versionLesen(version.version) ?? {
+ major: 0,
+ minor: 0,
+ patch: 0,
+ text: version.version,
+ };
+ if (pveVersion.major < 8) {
+ warnungen.push(`PVE ${version.version} wird nicht unterstützt – ProxDeploy braucht 8.0 oder neuer.`);
+ }
+
+ // Cluster und Nodes. /cluster/status braucht Sys.Audit; ohne bleibt die Node-Liste.
+ const clusterStatus = await versuchen(warnungen, "Cluster-Status", () =>
+ client.get("/cluster/status"),
+ );
+ const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster");
+ const nodeIps = new Map(
+ (clusterStatus ?? [])
+ .filter((e): e is Extract => e.type === "node")
+ .map((e) => [e.name, e.ip ?? null]),
+ );
+ const nodes = await client.get("/nodes");
+
+ const knoten: KnotenInfo[] = [];
+ for (const node of [...nodes].sort((a, b) => a.node.localeCompare(b.node))) {
+ const online = node.status === "online";
+ let storages: StorageInfo[] = [];
+ let bridges: BridgeInfo[] = [];
+ let nodeVersion: string | null = null;
+ if (online) {
+ const v = await versuchen(warnungen, `Version von ${node.node}`, () =>
+ client.get<{ version: string }>(pfad`/nodes/${node.node}/version`),
+ );
+ nodeVersion = v?.version ?? null;
+ storages = (
+ (await versuchen(warnungen, `Storages von ${node.node}`, () =>
+ client.get(pfad`/nodes/${node.node}/storage`),
+ )) ?? []
+ )
+ .filter((s) => s.enabled !== 0)
+ .map((s) => ({
+ storage: s.storage,
+ typ: s.type,
+ inhalte: s.content.split(",").filter(Boolean),
+ aktiv: s.active === 1,
+ geteilt: s.shared === 1,
+ frei: s.avail ?? null,
+ gesamt: s.total ?? null,
+ }))
+ .sort((a, b) => a.storage.localeCompare(b.storage));
+ bridges = (
+ (await versuchen(warnungen, `Netzwerk von ${node.node}`, () =>
+ client.get(pfad`/nodes/${node.node}/network`),
+ )) ?? []
+ )
+ .filter((n) => n.type === "bridge" || n.type === "OVSBridge")
+ .map((n) => ({
+ name: n.iface,
+ typ: n.type,
+ vlanBewusst: n.bridge_vlan_aware === 1 || n.type === "OVSBridge",
+ aktiv: n.active === 1,
+ cidr: n.cidr ?? null,
+ kommentar: n.comments?.trim() || null,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+ knoten.push({
+ name: node.node,
+ online,
+ ip: nodeIps.get(node.node) ?? null,
+ version: nodeVersion,
+ cpuAuslastung: node.cpu ?? null,
+ cpus: node.maxcpu ?? null,
+ speicherBelegt: node.mem ?? null,
+ speicherGesamt: node.maxmem ?? null,
+ storages,
+ bridges,
+ ssh: null,
+ });
+ }
+
+ const vnetsRoh = await versuchen(warnungen, "SDN-VNets", () =>
+ client.get>("/cluster/sdn/vnets"),
+ );
+ const vnets =
+ vnetsRoh?.map((v) => ({ name: v.vnet, zone: v.zone, tag: v.tag ?? null, alias: v.alias ?? null })) ??
+ null;
+
+ // Rechte: effektiv je Pfad. Storage-Rechte nur für Storages, die Gäste aufnehmen können.
+ const gastStorages = [
+ ...new Set(
+ knoten.flatMap((k) =>
+ k.storages
+ .filter((s) => s.inhalte.includes("images") || s.inhalte.includes("rootdir"))
+ .map((s) => s.storage),
+ ),
+ ),
+ ];
+ const onlineNodes = knoten.filter((k) => k.online).map((k) => k.name);
+ const pfade = [
+ "/",
+ "/vms",
+ pfadFuer("sdn", {}),
+ ...onlineNodes.map((node) => pfadFuer("node", { node })),
+ ...gastStorages.map((storage) => pfadFuer("storage", { storage })),
+ ];
+ const rechte: RechteJePfad = {};
+ for (const p of pfade) {
+ const antwort = await versuchen(warnungen, `Rechte auf ${p}`, () =>
+ client.get("/access/permissions", { path: p }),
+ );
+ rechte[p] = antwort?.[p] ?? {};
+ }
+ const fehlendeRechte = rechtePruefen(pveVersion, rechte, { storages: gastStorages, nodes: onlineNodes });
+
+ // SSH je Node: nur wenn eingerichtet. Ohne gepinnten Host-Key wird erkundet, nicht genutzt.
+ let sshGesamt: SshZustand = "aus";
+ if (optionen.ssh) {
+ if (!optionen.ssh.privatSchluessel) {
+ sshGesamt = "kein_schluessel";
+ } else {
+ const zustaende: SshZustand[] = [];
+ for (const k of knoten.filter((k) => k.online)) {
+ // Einzelhost ohne Cluster-Status: die Adresse aus der Server-URL.
+ const host = k.ip ?? (knoten.length === 1 ? optionen.serverHost : k.name);
+ const pin = optionen.ssh.hostSchluessel[k.name] ?? null;
+ try {
+ const ergebnis = await sshAusfuehren(
+ {
+ host,
+ port: optionen.ssh.port,
+ benutzer: optionen.ssh.benutzer,
+ privatSchluessel: optionen.ssh.privatSchluessel,
+ hostSchluessel: pin,
+ },
+ "pveversion",
+ { erkunden: !pin, zeitlimitMs: 15_000 },
+ );
+ const zustand: SshZustand = pin ? "ok" : "nicht_bestaetigt";
+ k.ssh = {
+ zustand,
+ host,
+ hostSchluessel: ergebnis.hostSchluessel,
+ bestaetigt: Boolean(pin),
+ meldung: ergebnis.stdout.trim() || null,
+ };
+ zustaende.push(zustand);
+ } catch (fehler) {
+ if (!(fehler instanceof SshFehler)) throw fehler;
+ k.ssh = {
+ zustand: "fehler",
+ host,
+ hostSchluessel: fehler.hostSchluessel ?? null,
+ bestaetigt: Boolean(pin),
+ meldung: `${fehler.ursache} ${fehler.hinweis}`,
+ };
+ zustaende.push("fehler");
+ }
+ }
+ sshGesamt = zustaende.includes("fehler")
+ ? "fehler"
+ : zustaende.includes("nicht_bestaetigt")
+ ? "nicht_bestaetigt"
+ : zustaende.length > 0
+ ? "ok"
+ : "fehler";
+ }
+ }
+
+ // Im gemischten Cluster zählt die älteste Version.
+ const versionen = knoten.map((k) => (k.version ? versionLesen(k.version) : null)).filter((v) => v !== null);
+ const aelteste = versionen.reduce(
+ (alt, v) => (v.major < alt.major || (v.major === alt.major && v.minor < alt.minor) ? v : alt),
+ pveVersion,
+ );
+ const alleStorages = knoten.flatMap((k) => k.storages);
+
+ return {
+ geprueftAm: (optionen.jetzt?.() ?? new Date()).toISOString(),
+ version: version.version,
+ release: version.release,
+ cluster: clusterEintrag ? { name: clusterEintrag.name, quorate: clusterEintrag.quorate === 1 } : null,
+ knoten,
+ vnets,
+ fehlendeRechte,
+ ssh: sshGesamt,
+ faehigkeiten: faehigkeitenAbleiten({
+ version: aelteste,
+ storages: alleStorages,
+ ssh: sshGesamt,
+ rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
+ }),
+ warnungen,
+ };
+}
diff --git a/src/engine/proxmox/faehigkeiten.ts b/src/engine/proxmox/faehigkeiten.ts
new file mode 100644
index 0000000..41e7622
--- /dev/null
+++ b/src/engine/proxmox/faehigkeiten.ts
@@ -0,0 +1,287 @@
+/**
+ * Was geht auf diesem Server per API, was nur per SSH, was gar nicht – als reine Funktion aus
+ * den erkundeten Daten. Grundlage ist die Prüfung der Proxmox-API vom 2026-10-12
+ * (docs/architektur.md, Abschnitt 3).
+ */
+
+export type PveVersion = { major: number; minor: number; patch: number; text: string };
+
+export function versionLesen(text: string): PveVersion | null {
+ const treffer = /^(\d+)\.(\d+)(?:[.-](\d+))?/.exec(text.trim());
+ if (!treffer) return null;
+ return {
+ major: Number(treffer[1]),
+ minor: Number(treffer[2]),
+ patch: Number(treffer[3] ?? 0),
+ text: text.trim(),
+ };
+}
+
+export function mindestens(version: PveVersion, major: number, minor: number): boolean {
+ return version.major > major || (version.major === major && version.minor >= minor);
+}
+
+// ── Rechte ──────────────────────────────────────────────────────────────────────────────────────
+
+export type RechtBereich = "vms" | "storage" | "sdn" | "node" | "wurzel";
+
+export type RechtAnforderung = {
+ recht: string;
+ bereich: RechtBereich;
+ /** Wofür – erscheint in der Oberfläche neben einem fehlenden Recht. */
+ wofuer: string;
+ pflicht: boolean;
+};
+
+const VM_RECHTE: Array<[string, string]> = [
+ ["VM.Allocate", "VMs und Container anlegen und 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", "CPU einstellen"],
+ ["VM.Config.Memory", "RAM einstellen"],
+ ["VM.Config.Network", "Netzwerk einstellen"],
+ ["VM.Config.HWType", "Maschinentyp, BIOS, SCSI-Controller"],
+ ["VM.Config.Options", "Name, Tags, Agent, Start bei Boot"],
+ ["VM.Config.Cloudinit", "cloud-init (Benutzer, Keys, IP)"],
+ ["VM.PowerMgmt", "Starten und Stoppen"],
+];
+
+/**
+ * Die Rolle `ProxDeploy` je Hauptversion. PVE 9 hat `VM.Monitor` gestrichen und den Guest-Agent
+ * in eigene Rechte aufgeteilt; `Sys.AccessNetwork` (für download-url) gibt es seit 8.2.
+ */
+export function benoetigteRechte(version: PveVersion): RechtAnforderung[] {
+ const liste: RechtAnforderung[] = VM_RECHTE.map(([recht, wofuer]) => ({
+ recht,
+ bereich: "vms",
+ wofuer,
+ pflicht: true,
+ }));
+
+ if (version.major >= 9) {
+ liste.push(
+ {
+ recht: "VM.GuestAgent.Audit",
+ bereich: "vms",
+ wofuer: "IP-Adressen über den Guest-Agent",
+ pflicht: true,
+ },
+ {
+ recht: "VM.GuestAgent.Unrestricted",
+ bereich: "vms",
+ wofuer: "Provisionierung über den Guest-Agent (exec, file-write)",
+ pflicht: true,
+ },
+ );
+ } else {
+ liste.push({ recht: "VM.Monitor", bereich: "vms", wofuer: "Guest-Agent (in PVE 8)", pflicht: true });
+ }
+
+ liste.push(
+ {
+ recht: "Datastore.AllocateSpace",
+ bereich: "storage",
+ wofuer: "Disks auf dem Storage anlegen",
+ pflicht: true,
+ },
+ {
+ recht: "Datastore.AllocateTemplate",
+ bereich: "storage",
+ wofuer: "Images und LXC-Templates herunterladen",
+ pflicht: true,
+ },
+ { recht: "Datastore.Audit", bereich: "storage", wofuer: "Storage-Inhalt lesen", pflicht: true },
+ { recht: "SDN.Use", bereich: "sdn", wofuer: "Bridges und VNets an Gäste hängen", pflicht: true },
+ { recht: "Sys.Audit", bereich: "wurzel", wofuer: "Cluster-Status und Rechte lesen", pflicht: true },
+ );
+
+ if (mindestens(version, 8, 2)) {
+ liste.push({
+ recht: "Sys.AccessNetwork",
+ bereich: "node",
+ wofuer: "Images per download-url laden",
+ pflicht: true,
+ });
+ } else {
+ liste.push({
+ recht: "Sys.Modify",
+ bereich: "wurzel",
+ wofuer: "Images per download-url laden (vor PVE 8.2)",
+ pflicht: true,
+ });
+ }
+
+ liste.push(
+ {
+ recht: "Datastore.Allocate",
+ bereich: "storage",
+ wofuer: "Heruntergeladene Images beim Aufräumen löschen",
+ pflicht: false,
+ },
+ { recht: "SDN.Audit", bereich: "sdn", wofuer: "VNets anzeigen", pflicht: false },
+ { recht: "VM.Console", bereich: "vms", wofuer: "Konsole öffnen (Link aus ProxDeploy)", pflicht: false },
+ );
+ if (version.major >= 9) {
+ liste.push({
+ recht: "Sys.Modify",
+ bereich: "wurzel",
+ wofuer: "Privilegierte Container anlegen (seit PVE 9)",
+ pflicht: false,
+ });
+ }
+ return liste;
+}
+
+/** Effektive Rechte je ACL-Pfad, wie `GET /access/permissions?path=…` sie liefert. */
+export type RechteJePfad = Record>;
+
+export type FehlendesRecht = { recht: string; pfad: string; wofuer: string; pflicht: boolean };
+
+export function pfadFuer(bereich: RechtBereich, kontext: { storage?: string; node?: string }): string {
+ switch (bereich) {
+ case "vms":
+ return "/vms";
+ case "storage":
+ return kontext.storage ? `/storage/${kontext.storage}` : "/storage";
+ case "sdn":
+ return "/sdn/zones/localnetwork";
+ case "node":
+ return kontext.node ? `/nodes/${kontext.node}` : "/nodes";
+ case "wurzel":
+ return "/";
+ }
+}
+
+/**
+ * Vergleicht die effektiven Rechte mit der Anforderung. Storage-Rechte werden je Storage geprüft,
+ * der Gäste aufnehmen kann – gilt ein Recht auf keinem davon, fehlt es.
+ */
+export function rechtePruefen(
+ version: PveVersion,
+ rechte: RechteJePfad,
+ kontext: { storages: string[]; nodes: string[] },
+): FehlendesRecht[] {
+ const hat = (pfad: string, recht: string) => Boolean(rechte[pfad]?.[recht]);
+ const fehlend: FehlendesRecht[] = [];
+
+ for (const anforderung of benoetigteRechte(version)) {
+ let pfade: string[];
+ if (anforderung.bereich === "storage") {
+ pfade = kontext.storages.map((storage) => pfadFuer("storage", { storage }));
+ } else if (anforderung.bereich === "node") {
+ pfade = kontext.nodes.map((node) => pfadFuer("node", { node }));
+ } else {
+ pfade = [pfadFuer(anforderung.bereich, {})];
+ }
+ if (pfade.length === 0) pfade = [pfadFuer(anforderung.bereich, {})];
+ // Storage: es genügt, wenn ein Storage passt. Nodes: jeder Node braucht es.
+ const erfuellt =
+ anforderung.bereich === "storage"
+ ? pfade.some((p) => hat(p, anforderung.recht))
+ : pfade.every((p) => hat(p, anforderung.recht));
+ if (!erfuellt) {
+ const ohne =
+ anforderung.bereich === "storage" ? pfade : pfade.filter((p) => !hat(p, anforderung.recht));
+ fehlend.push({
+ recht: anforderung.recht,
+ pfad: ohne.join(", "),
+ wofuer: anforderung.wofuer,
+ pflicht: anforderung.pflicht,
+ });
+ }
+ }
+ return fehlend;
+}
+
+// ── Strategien ──────────────────────────────────────────────────────────────────────────────────
+
+export type StorageInfo = {
+ storage: string;
+ typ: string;
+ inhalte: string[];
+ aktiv: boolean;
+ geteilt: boolean;
+ frei: number | null;
+ gesamt: number | null;
+};
+
+export type SshZustand = "aus" | "kein_schluessel" | "nicht_bestaetigt" | "fehler" | "ok";
+
+export type Weg = "api" | "ssh" | "nicht_moeglich";
+
+export type Strategie = { weg: Weg; grund: string };
+
+export type Faehigkeiten = {
+ templateBau: Strategie;
+ vmProvisionierung: Strategie;
+ lxcProvisionierung: Strategie & { ueber?: "node" | "gast" };
+ snippets: Strategie;
+ lxcFeaturesErweitert: Strategie;
+};
+
+/**
+ * Leitet je Vorgang den Weg ab. Grundsatz: API, wo es geht; SSH nur, wo Proxmox keine API hat
+ * oder die Version zu alt ist.
+ */
+export function faehigkeitenAbleiten(eingabe: {
+ version: PveVersion;
+ storages: StorageInfo[];
+ ssh: SshZustand;
+ rechteFehlen: boolean;
+}): Faehigkeiten {
+ const { version, storages, ssh } = eingabe;
+ const sshOk = ssh === "ok";
+ const importSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("import"));
+ const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
+
+ let templateBau: Strategie;
+ if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
+ templateBau = {
+ weg: "api",
+ grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
+ };
+ } else if (sshOk) {
+ templateBau = {
+ weg: "ssh",
+ grund: mindestens(version, 8, 4)
+ ? "Kein Storage mit Inhaltstyp „import“ – Download und Import laufen per SSH."
+ : `PVE ${version.text} kann Disk-Images erst ab 8.4 per API laden – Download und Import laufen per SSH.`,
+ };
+ } else {
+ templateBau = {
+ weg: "nicht_moeglich",
+ grund: mindestens(version, 8, 4)
+ ? "Einem Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
+ : `PVE ${version.text} lädt Disk-Images erst ab 8.4 per API – SSH einrichten oder auf 8.4+ aktualisieren.`,
+ };
+ }
+
+ const snippets: Strategie =
+ sshOk && snippetSpeicher.length > 0
+ ? { weg: "ssh", grund: `Snippets per SSH auf ${snippetSpeicher[0]!.storage}` }
+ : sshOk
+ ? { weg: "nicht_moeglich", grund: "Kein Storage mit Inhaltstyp „Snippets“." }
+ : { weg: "nicht_moeglich", grund: "Proxmox hat keine API für Snippets – nur per SSH." };
+
+ return {
+ templateBau,
+ vmProvisionierung: { weg: "api", grund: "Über den Guest-Agent (im Template eingebaut)." },
+ lxcProvisionierung: sshOk
+ ? { weg: "ssh", ueber: "node", grund: "pct exec über SSH zum Node." }
+ : {
+ weg: "ssh",
+ ueber: "gast",
+ grund:
+ "Proxmox hat keine Exec-API für Container – Profile laufen per SSH direkt in den Container (Netzweg nötig).",
+ },
+ snippets,
+ lxcFeaturesErweitert: sshOk
+ ? { weg: "ssh", grund: "keyctl, fuse usw. per pct set über SSH." }
+ : {
+ weg: "nicht_moeglich",
+ grund: "Außer „nesting“ darf nur root@pam Container-Features setzen – per Token nicht.",
+ },
+ };
+}
diff --git a/src/engine/proxmox/fehler.ts b/src/engine/proxmox/fehler.ts
index 5baaf3c..536c741 100644
--- a/src/engine/proxmox/fehler.ts
+++ b/src/engine/proxmox/fehler.ts
@@ -117,10 +117,10 @@ export function rechtHinweis(meldung: string): string {
if (treffer) {
return (
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
- "Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)."
+ "Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe docs/proxmox-rechte.md)."
);
}
- return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte).";
+ return "Rechte des API-Tokens prüfen (siehe docs/proxmox-rechte.md).";
}
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
diff --git a/src/engine/ssh/verbindung.ts b/src/engine/ssh/verbindung.ts
new file mode 100644
index 0000000..8710abd
--- /dev/null
+++ b/src/engine/ssh/verbindung.ts
@@ -0,0 +1,145 @@
+import { createHash } from "node:crypto";
+import { Client } from "ssh2";
+
+/**
+ * SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
+ * Pin bekannt, darf nur „erkundet“ werden (der Schlüssel wird gemeldet und muss bestätigt
+ * werden); für alles Schreibende ist ein Pin Pflicht.
+ */
+export type SshZiel = {
+ host: string;
+ port: number;
+ benutzer: string;
+ privatSchluessel: string;
+ /** „SHA256:…“ wie ssh-keygen -lf; null nur beim Erkunden. */
+ hostSchluessel: string | null;
+};
+
+export type SshErgebnis = { code: number | null; stdout: string; stderr: string; hostSchluessel: string };
+
+export class SshFehler extends Error {
+ constructor(
+ readonly art: "verbindung" | "anmeldung" | "host_schluessel" | "zeitueberschreitung" | "befehl",
+ readonly ursache: string,
+ readonly hinweis: string,
+ readonly hostSchluessel?: string,
+ ) {
+ super(ursache);
+ this.name = "SshFehler";
+ }
+}
+
+/** Fingerprint im Format von ssh-keygen: SHA256:. */
+export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
+ return `SHA256:${createHash("sha256").update(schluessel).digest("base64").replace(/=+$/, "")}`;
+}
+
+export function sshAusfuehren(
+ ziel: SshZiel,
+ befehl: string,
+ optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
+): Promise {
+ if (!ziel.hostSchluessel && !optionen.erkunden) {
+ return Promise.reject(
+ new SshFehler(
+ "host_schluessel",
+ `Für ${ziel.host} ist noch kein Host-Key bestätigt.`,
+ "Verbindungstest ausführen und den angezeigten Host-Key bestätigen.",
+ ),
+ );
+ }
+ const zeitlimit = optionen.zeitlimitMs ?? 60_000;
+
+ return new Promise((erfuellen, ablehnen) => {
+ const client = new Client();
+ let gesehen = "";
+ let erledigt = false;
+ const ende = (fehler: SshFehler | null, ergebnis?: SshErgebnis) => {
+ if (erledigt) return;
+ erledigt = true;
+ clearTimeout(uhr);
+ client.end();
+ if (fehler) ablehnen(fehler);
+ else erfuellen(ergebnis!);
+ };
+ const uhr = setTimeout(
+ () =>
+ ende(
+ new SshFehler(
+ "zeitueberschreitung",
+ `SSH zu ${ziel.host} hat nach ${Math.round(zeitlimit / 1000)} s nicht geantwortet.`,
+ "Erreichbarkeit von Port " + ziel.port + " prüfen.",
+ gesehen || undefined,
+ ),
+ ),
+ zeitlimit,
+ );
+
+ client
+ .on("ready", () => {
+ client.exec(befehl, (fehler, kanal) => {
+ if (fehler) {
+ ende(
+ new SshFehler(
+ "befehl",
+ `Befehl ließ sich nicht starten: ${fehler.message}`,
+ "SSH-Benutzer prüfen.",
+ gesehen,
+ ),
+ );
+ return;
+ }
+ let stdout = "";
+ let stderr = "";
+ let code: number | null = null;
+ kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
+ kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
+ kanal.on("exit", (exitCode: number | null) => (code = exitCode));
+ kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
+ if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
+ else kanal.end();
+ });
+ })
+ .on("error", (fehler: Error & { level?: string; code?: string }) => {
+ if (fehler.level === "client-authentication") {
+ ende(
+ new SshFehler(
+ "anmeldung",
+ `${ziel.benutzer}@${ziel.host} nimmt den Schlüssel von ProxDeploy nicht an.`,
+ "Öffentlichen Schlüssel aus den Server-Einstellungen in ~/.ssh/authorized_keys des Nodes eintragen.",
+ gesehen || undefined,
+ ),
+ );
+ } else if (fehler.message.includes("Host denied")) {
+ ende(
+ new SshFehler(
+ "host_schluessel",
+ `Der Host-Key von ${ziel.host} hat sich geändert (gesehen ${gesehen}).`,
+ "Wurde der Node neu installiert? Dann den Host-Key neu bestätigen. Sonst nicht verbinden.",
+ gesehen,
+ ),
+ );
+ } else {
+ ende(
+ new SshFehler(
+ "verbindung",
+ `SSH zu ${ziel.host}:${ziel.port} fehlgeschlagen: ${fehler.code ?? fehler.message}`,
+ "Läuft sshd? Ist der Port aus dem Netz von ProxDeploy erreichbar?",
+ gesehen || undefined,
+ ),
+ );
+ }
+ })
+ .connect({
+ host: ziel.host,
+ port: ziel.port,
+ username: ziel.benutzer,
+ privateKey: ziel.privatSchluessel,
+ readyTimeout: Math.min(zeitlimit, 20_000),
+ hostVerifier: (schluessel: Buffer) => {
+ gesehen = hostSchluesselFingerabdruck(schluessel);
+ return ziel.hostSchluessel ? gesehen === ziel.hostSchluessel : Boolean(optionen.erkunden);
+ },
+ });
+ });
+}
diff --git a/src/lib/formate.ts b/src/lib/formate.ts
new file mode 100644
index 0000000..f812f96
--- /dev/null
+++ b/src/lib/formate.ts
@@ -0,0 +1,37 @@
+/** Formate für die Anzeige – deutsch, gleich in Web und (sinngemäß) CLI. */
+
+export function bytes(wert: number | null | undefined): string {
+ if (wert === null || wert === undefined) return "–";
+ const einheiten = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
+ let zahl = wert;
+ let stufe = 0;
+ while (zahl >= 1024 && stufe < einheiten.length - 1) {
+ zahl /= 1024;
+ stufe += 1;
+ }
+ return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: zahl < 10 && stufe > 0 ? 1 : 0 })} ${einheiten[stufe]}`;
+}
+
+export function prozent(anteil: number | null | undefined): string {
+ if (anteil === null || anteil === undefined) return "–";
+ return `${Math.round(anteil * 100)} %`;
+}
+
+/** „vor 3 min“, „vor 2 h“, sonst Datum mit Uhrzeit. */
+export function zeitpunkt(iso: string | null | undefined, jetzt = new Date()): string {
+ if (!iso) return "nie";
+ const datum = new Date(iso);
+ const sekunden = Math.round((jetzt.getTime() - datum.getTime()) / 1000);
+ if (sekunden < 60) return "gerade eben";
+ if (sekunden < 3600) return `vor ${Math.round(sekunden / 60)} min`;
+ if (sekunden < 86_400) return `vor ${Math.round(sekunden / 3600)} h`;
+ return datum.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
+}
+
+/** Dauer wie in der CLI: „18s“, „1m 12s“, „1h 03m“. */
+export function dauer(ms: number): string {
+ const s = Math.max(0, Math.round(ms / 1000));
+ if (s < 60) return `${s}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
+ return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
+}
diff --git a/src/lib/schemas/server.ts b/src/lib/schemas/server.ts
index eb31e30..140ea33 100644
--- a/src/lib/schemas/server.ts
+++ b/src/lib/schemas/server.ts
@@ -1,3 +1,4 @@
+import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { z } from "@/lib/validierung";
/**
@@ -114,4 +115,15 @@ export type ServerSicht = {
erreichbar: boolean | null;
pveVersion: string | null;
geprueftAm: string | null;
+ letzterTest: VerbindungsBericht | null;
+ /** Gepinnte SSH-Host-Keys je Node – öffentlich, kein Geheimnis. */
+ sshHostSchluessel: Record;
};
+
+export const HostSchluesselBestaetigen = z.object({
+ knoten: z.string().trim().min(1),
+ hostSchluessel: z
+ .string()
+ .trim()
+ .regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Kein SHA256-Fingerprint eines Host-Keys."),
+});
diff --git a/src/server/services/server.ts b/src/server/services/server.ts
index a363947..55ebf95 100644
--- a/src/server/services/server.ts
+++ b/src/server/services/server.ts
@@ -1,11 +1,18 @@
import { utils as sshUtils } from "ssh2";
import type { Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
+import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
-import { ServerAendern, ServerAnlegen, ServerPruefen, type ServerSicht } from "@/lib/schemas/server";
+import {
+ HostSchluesselBestaetigen,
+ ServerAendern,
+ ServerAnlegen,
+ ServerPruefen,
+ type ServerSicht,
+} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import type { Aufrufer } from "@/server/auth/zugang";
import { protokollieren } from "@/server/services/protokoll";
@@ -28,6 +35,8 @@ export function serverSicht(server: Server): ServerSicht {
erreichbar: server.erreichbar,
pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null,
+ letzterTest: (server.letzterTest as VerbindungsBericht | null) ?? null,
+ sshHostSchluessel: (server.sshHostSchluessel as Record | null) ?? {},
};
}
@@ -259,3 +268,112 @@ export async function serverPruefen(roh: unknown): Promise {
await client.schliessen();
}
}
+
+export type TestErgebnis =
+ { ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
+
+/**
+ * Der Verbindungstest (#003): erkundet den Server, speichert den Bericht und die Erreichbarkeit.
+ * Ein nicht erreichbarer Server ist kein Fehler der Anfrage, sondern ein Ergebnis.
+ */
+export async function serverTesten(
+ name: string,
+ aufrufer: Aufrufer,
+ ip: string | null,
+): Promise {
+ const server = await serverHolen(name);
+ const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 15_000 });
+ let ergebnis: TestErgebnis;
+ try {
+ const bericht = await erkunden(client, {
+ serverHost: new URL(server.url).hostname,
+ ssh: server.sshAktiv
+ ? {
+ benutzer: server.sshBenutzer,
+ port: server.sshPort,
+ privatSchluessel: server.sshPrivat ? entschluesseln(server.sshPrivat) : null,
+ hostSchluessel: (server.sshHostSchluessel as Record | null) ?? {},
+ }
+ : null,
+ });
+ ergebnis = { ok: true, bericht };
+ } catch (fehler) {
+ if (!(fehler instanceof ProxmoxFehler)) throw fehler;
+ ergebnis = { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis };
+ } finally {
+ await client.schliessen();
+ }
+
+ await prisma.$transaction(async (tx) => {
+ await tx.server.update({
+ where: { id: server.id },
+ data: {
+ erreichbar: ergebnis.ok,
+ geprueftAm: new Date(),
+ ...(ergebnis.ok ? { pveVersion: ergebnis.bericht.version, letzterTest: ergebnis.bericht } : {}),
+ },
+ });
+ await protokollieren(
+ {
+ benutzerId: aufrufer.benutzer.id,
+ quelle: aufrufer.quelle,
+ aktion: "Verbindung getestet",
+ ziel: `server:${server.name}`,
+ details: ergebnis.ok
+ ? { version: ergebnis.bericht.version, fehlendeRechte: ergebnis.bericht.fehlendeRechte.length }
+ : { fehler: ergebnis.ursache },
+ ip,
+ },
+ tx,
+ );
+ });
+ return ergebnis;
+}
+
+/**
+ * Pinnt den SSH-Host-Key eines Nodes – nur einen, den der letzte Test tatsächlich gesehen hat.
+ * So kann niemand über die API einen beliebigen Schlüssel unterschieben.
+ */
+export async function hostSchluesselBestaetigen(
+ name: string,
+ roh: unknown,
+ aufrufer: Aufrufer,
+ ip: string | null,
+): Promise {
+ const server = await serverHolen(name);
+ const eingabe = HostSchluesselBestaetigen.parse(roh);
+ const bericht = server.letzterTest as VerbindungsBericht | null;
+ const gesehen = bericht?.knoten.find((k) => k.name === eingabe.knoten)?.ssh?.hostSchluessel;
+ if (!gesehen || gesehen !== eingabe.hostSchluessel) {
+ throw new AnwendungsFehler(
+ 409,
+ "nicht_gesehen",
+ "Dieser Host-Key wurde beim letzten Verbindungstest nicht gesehen.",
+ "Verbindung erneut testen und den dann angezeigten Host-Key bestätigen.",
+ );
+ }
+ const neu = await prisma.$transaction(async (tx) => {
+ const aktualisiert = await tx.server.update({
+ where: { id: server.id },
+ data: {
+ sshHostSchluessel: {
+ ...((server.sshHostSchluessel as Record | null) ?? {}),
+ [eingabe.knoten]: eingabe.hostSchluessel,
+ },
+ },
+ });
+ await protokollieren(
+ {
+ benutzerId: aufrufer.benutzer.id,
+ quelle: aufrufer.quelle,
+ aktion: "SSH-Host-Key bestätigt",
+ ziel: `server:${server.name}`,
+ details: { knoten: eingabe.knoten, hostSchluessel: eingabe.hostSchluessel },
+ ip,
+ },
+ tx,
+ );
+ return aktualisiert;
+ });
+ return serverSicht(neu);
+}
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts
index 81b20e6..85bebdc 100644
--- a/tests/e2e/global-setup.ts
+++ b/tests/e2e/global-setup.ts
@@ -1,4 +1,5 @@
import { ProxmoxMock } from "../proxmox-mock/mock";
+import { szenarioEinrichten } from "../proxmox-mock/szenario";
/**
* Startet für die Abläufe einen Proxmox-Mock auf festem Port. Die gebaute Anwendung verbindet
@@ -8,7 +9,7 @@ export const MOCK_PORT = 18006;
export default async function aufbau(): Promise<() => Promise> {
const mock = new ProxmoxMock();
- mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
+ szenarioEinrichten(mock, { version: "9.2.1", importStorage: true });
await mock.starten(MOCK_PORT);
return async () => {
await mock.stoppen();
diff --git a/tests/e2e/grundgeruest.spec.ts b/tests/e2e/grundgeruest.spec.ts
index 91b3c3d..69e553b 100644
--- a/tests/e2e/grundgeruest.spec.ts
+++ b/tests/e2e/grundgeruest.spec.ts
@@ -14,7 +14,10 @@ test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }
await page.getByRole("button", { name: "Anmelden" }).click();
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
- await expect(page.getByText("✗")).toBeVisible();
+ // Symbol + Text: das gezeichnete ✗ steht im selben Element wie die Meldung.
+ await expect(
+ page.getByText("Benutzername oder Passwort ist falsch.").locator("xpath=..").locator("svg"),
+ ).toBeVisible();
});
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
diff --git a/tests/e2e/server.spec.ts b/tests/e2e/server.spec.ts
index 957d007..36384c1 100644
--- a/tests/e2e/server.spec.ts
+++ b/tests/e2e/server.spec.ts
@@ -28,6 +28,12 @@ test("Server anlegen: Zertifikat pinnen, Verbindung prüfen, SSH-Schlüssel, lö
await expect(page).toHaveURL(new RegExp(`/server/${name}$`));
await expect(page.getByRole("heading", { name: "E2E-Testserver" })).toBeVisible();
+ await page.getByRole("button", { name: "Verbindung testen" }).click();
+ await expect(page.getByRole("heading", { name: "Was geht auf diesem Server" })).toBeVisible();
+ await expect(page.getByText("Alle nötigen Rechte vorhanden.")).toBeVisible();
+ await expect(page.getByText("vmbr0", { exact: true })).toBeVisible();
+ await page.screenshot({ path: `test-results/server-test-${test.info().project.name}.png`, fullPage: true });
+
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
await keinQuerscrollen(page);
diff --git a/tests/engine/erkundung.test.ts b/tests/engine/erkundung.test.ts
new file mode 100644
index 0000000..1394e8f
--- /dev/null
+++ b/tests/engine/erkundung.test.ts
@@ -0,0 +1,186 @@
+import { utils } from "ssh2";
+import { afterEach, describe, expect, it } from "vitest";
+import { ProxmoxClient } from "@/engine/proxmox/client";
+import { erkunden } from "@/engine/proxmox/erkundung";
+import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
+import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
+import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
+import { SshMock } from "../ssh-mock";
+
+const aufraeumen: Array<() => Promise> = [];
+afterEach(async () => {
+ while (aufraeumen.length) await aufraeumen.pop()!();
+});
+
+async function mitSzenario(szenario: Szenario) {
+ const mock = await new ProxmoxMock().starten();
+ szenarioEinrichten(mock, szenario);
+ const client = new ProxmoxClient({
+ url: mock.url,
+ tokenId: MOCK_TOKEN_ID,
+ tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
+ tlsPruefen: false,
+ fingerabdruck: mock.fingerabdruck,
+ });
+ aufraeumen.push(async () => {
+ await client.schliessen();
+ await mock.stoppen();
+ });
+ return { mock, client };
+}
+
+describe("Verbindungstest (Erkundung)", () => {
+ it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
+ const { client } = await mitSzenario({ version: "9.2.1", importStorage: true });
+ const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
+ expect(bericht.version).toBe("9.2.1");
+ expect(bericht.cluster).toBeNull();
+ expect(bericht.knoten).toHaveLength(1);
+ expect(bericht.knoten[0]!.storages.map((s) => s.storage)).toEqual(["local", "local-zfs"]);
+ expect(bericht.knoten[0]!.bridges).toEqual([
+ expect.objectContaining({ name: "vmbr0", vlanBewusst: true, cidr: "10.0.10.2/24", kommentar: "LAN" }),
+ expect.objectContaining({ name: "vmbr1", vlanBewusst: false }),
+ ]);
+ expect(bericht.vnets).toEqual([{ name: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }]);
+ expect(bericht.fehlendeRechte.filter((r) => r.pflicht)).toEqual([]);
+ expect(bericht.faehigkeiten.templateBau.weg).toBe("api");
+ expect(bericht.warnungen).toEqual([]);
+ });
+
+ it("Cluster: Name, Quorum und Node-IPs", async () => {
+ const { client } = await mitSzenario({
+ version: "9.2.1",
+ cluster: {
+ name: "rz1",
+ nodes: [
+ { name: "pve01", ip: "10.0.0.1" },
+ { name: "pve02", ip: "10.0.0.2" },
+ { name: "pve03", ip: "10.0.0.3", online: false },
+ ],
+ },
+ });
+ const bericht = await erkunden(client, { serverHost: "10.0.0.1", ssh: null });
+ expect(bericht.cluster).toEqual({ name: "rz1", quorate: true });
+ expect(bericht.knoten.map((k) => `${k.name}:${k.online}:${k.ip}`)).toEqual([
+ "pve01:true:10.0.0.1",
+ "pve02:true:10.0.0.2",
+ "pve03:false:10.0.0.3",
+ ]);
+ expect(bericht.knoten[2]!.storages).toEqual([]);
+ });
+
+ it("PVE 8.3: Template-Bau braucht SSH, VM.Monitor wird verlangt", async () => {
+ const { client } = await mitSzenario({
+ version: "8.3.5",
+ importStorage: true,
+ ohneRechte: ["VM.Monitor"],
+ });
+ const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
+ expect(bericht.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
+ expect(bericht.fehlendeRechte).toContainEqual(
+ expect.objectContaining({ recht: "VM.Monitor", pfad: "/vms" }),
+ );
+ });
+
+ it("fehlende Leserechte werden zu Warnungen statt zum Abbruch", async () => {
+ const { client } = await mitSzenario({
+ version: "9.2.1",
+ ohneClusterStatus: true,
+ ohneRechte: ["SDN.Audit"],
+ });
+ const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
+ expect(bericht.knoten).toHaveLength(1);
+ expect(bericht.vnets).toBeNull();
+ expect(bericht.warnungen.join("\n")).toMatch(/Cluster-Status.*Sys\.Audit/);
+ });
+
+ it("warnt vor PVE 7", async () => {
+ const { client } = await mitSzenario({ version: "7.4.17" });
+ const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
+ expect(bericht.warnungen.join()).toMatch(/nicht unterstützt/);
+ });
+});
+
+describe("SSH", () => {
+ const paar = utils.generateKeyPairSync("ed25519");
+
+ async function sshServer() {
+ const ssh = await new SshMock(paar.public).starten();
+ aufraeumen.push(() => ssh.stoppen());
+ return ssh;
+ }
+
+ it("Erkunden liefert den Host-Key; danach nur noch mit Pin", async () => {
+ const ssh = await sshServer();
+ const ziel = {
+ host: "127.0.0.1",
+ port: ssh.port,
+ benutzer: "root",
+ privatSchluessel: paar.private,
+ hostSchluessel: null,
+ };
+ const erkundet = await sshAusfuehren(ziel, "pveversion", { erkunden: true });
+ expect(erkundet.hostSchluessel).toBe(ssh.hostSchluessel);
+ expect(erkundet.stdout).toContain("pve-manager/9.2.1");
+
+ await expect(sshAusfuehren(ziel, "pveversion")).rejects.toMatchObject({ art: "host_schluessel" });
+ const mitPin = await sshAusfuehren({ ...ziel, hostSchluessel: ssh.hostSchluessel }, "echo hallo");
+ expect(mitPin.code).toBe(0);
+ });
+
+ it("trennt bei geändertem Host-Key", async () => {
+ const ssh = await sshServer();
+ const fehler = await sshAusfuehren(
+ {
+ host: "127.0.0.1",
+ port: ssh.port,
+ benutzer: "root",
+ privatSchluessel: paar.private,
+ hostSchluessel: "SHA256:falsch",
+ },
+ "pveversion",
+ ).catch((f: unknown) => f);
+ expect(fehler).toBeInstanceOf(SshFehler);
+ expect((fehler as SshFehler).art).toBe("host_schluessel");
+ expect(ssh.befehle).toEqual([]);
+ });
+
+ it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
+ const ssh = await sshServer();
+ const fremd = utils.generateKeyPairSync("ed25519");
+ const fehler = (await sshAusfuehren(
+ {
+ host: "127.0.0.1",
+ port: ssh.port,
+ benutzer: "root",
+ privatSchluessel: fremd.private,
+ hostSchluessel: ssh.hostSchluessel,
+ },
+ "pveversion",
+ ).catch((f: unknown) => f)) as SshFehler;
+ expect(fehler.art).toBe("anmeldung");
+ expect(fehler.hinweis).toMatch(/authorized_keys/);
+ });
+
+ it("Erkundung mit SSH: unbestätigt, dann bestätigt → Snippets und pct exec möglich", async () => {
+ const ssh = await sshServer();
+ const { client } = await mitSzenario({ version: "8.3.5", importStorage: true });
+ const zugang = { benutzer: "root", port: ssh.port, privatSchluessel: paar.private, hostSchluessel: {} };
+
+ const erst = await erkunden(client, { serverHost: "127.0.0.1", ssh: zugang });
+ expect(erst.ssh).toBe("nicht_bestaetigt");
+ expect(erst.knoten[0]!.ssh).toMatchObject({
+ zustand: "nicht_bestaetigt",
+ hostSchluessel: ssh.hostSchluessel,
+ });
+ expect(erst.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
+
+ const dann = await erkunden(client, {
+ serverHost: "127.0.0.1",
+ ssh: { ...zugang, hostSchluessel: { pve01: ssh.hostSchluessel } },
+ });
+ expect(dann.ssh).toBe("ok");
+ expect(dann.faehigkeiten.templateBau.weg).toBe("ssh");
+ expect(dann.faehigkeiten.lxcProvisionierung).toMatchObject({ ueber: "node" });
+ });
+});
diff --git a/tests/engine/faehigkeiten.test.ts b/tests/engine/faehigkeiten.test.ts
new file mode 100644
index 0000000..fcbb404
--- /dev/null
+++ b/tests/engine/faehigkeiten.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+import {
+ benoetigteRechte,
+ faehigkeitenAbleiten,
+ rechtePruefen,
+ versionLesen,
+ type StorageInfo,
+} from "@/engine/proxmox/faehigkeiten";
+
+const v = (text: string) => versionLesen(text)!;
+const storage = (name: string, inhalte: string[]): StorageInfo => ({
+ storage: name,
+ typ: "dir",
+ inhalte,
+ aktiv: true,
+ geteilt: false,
+ frei: null,
+ gesamt: null,
+});
+
+describe("PVE-Version", () => {
+ it("liest übliche Schreibweisen", () => {
+ expect(v("9.2.1")).toMatchObject({ major: 9, minor: 2, patch: 1 });
+ expect(v("8.4")).toMatchObject({ major: 8, minor: 4, patch: 0 });
+ expect(versionLesen("kaputt")).toBeNull();
+ });
+});
+
+describe("Rolle ProxDeploy je Version", () => {
+ const namen = (text: string) =>
+ benoetigteRechte(v(text))
+ .filter((r) => r.pflicht)
+ .map((r) => r.recht);
+
+ it("PVE 9 nutzt die Guest-Agent-Rechte statt VM.Monitor", () => {
+ expect(namen("9.2.1")).toEqual(
+ expect.arrayContaining(["VM.GuestAgent.Audit", "VM.GuestAgent.Unrestricted"]),
+ );
+ expect(namen("9.2.1")).not.toContain("VM.Monitor");
+ });
+
+ it("PVE 8 braucht VM.Monitor für den Guest-Agent", () => {
+ expect(namen("8.4.1")).toContain("VM.Monitor");
+ expect(namen("8.4.1")).not.toContain("VM.GuestAgent.Unrestricted");
+ });
+
+ it("download-url: Sys.AccessNetwork ab 8.2, davor Sys.Modify", () => {
+ expect(namen("8.2.0")).toContain("Sys.AccessNetwork");
+ expect(namen("8.1.4")).toContain("Sys.Modify");
+ expect(namen("8.1.4")).not.toContain("Sys.AccessNetwork");
+ });
+});
+
+describe("Rechteprüfung", () => {
+ const version = v("9.2.1");
+ const voll = Object.fromEntries(benoetigteRechte(version).map((r) => [r.recht, 1]));
+
+ it("meldet nichts, wenn alles da ist", () => {
+ const rechte = {
+ "/": voll,
+ "/vms": voll,
+ "/sdn/zones/localnetwork": voll,
+ "/nodes/pve01": voll,
+ "/storage/local-zfs": voll,
+ };
+ expect(rechtePruefen(version, rechte, { storages: ["local-zfs"], nodes: ["pve01"] })).toEqual([]);
+ });
+
+ it("nennt fehlende Rechte mit Pfad und Zweck", () => {
+ const ohneAgent = { ...voll, "VM.GuestAgent.Unrestricted": 0 };
+ const rechte = {
+ "/": voll,
+ "/vms": ohneAgent,
+ "/sdn/zones/localnetwork": voll,
+ "/nodes/pve01": voll,
+ "/storage/local-zfs": voll,
+ };
+ expect(rechtePruefen(version, rechte, { storages: ["local-zfs"], nodes: ["pve01"] })).toEqual([
+ expect.objectContaining({ recht: "VM.GuestAgent.Unrestricted", pfad: "/vms", pflicht: true }),
+ ]);
+ });
+
+ it("Storage-Rechte: einer reicht; Node-Rechte: jeder Node braucht sie", () => {
+ const rechte = {
+ "/": voll,
+ "/vms": voll,
+ "/sdn/zones/localnetwork": voll,
+ "/nodes/pve01": voll,
+ "/nodes/pve02": {},
+ "/storage/a": voll,
+ "/storage/b": {},
+ };
+ const fehlend = rechtePruefen(version, rechte, { storages: ["a", "b"], nodes: ["pve01", "pve02"] });
+ expect(fehlend.filter((f) => f.pflicht).map((f) => `${f.recht} ${f.pfad}`)).toEqual([
+ "Sys.AccessNetwork /nodes/pve02",
+ ]);
+ });
+});
+
+describe("Strategien", () => {
+ const mitImport = [storage("local", ["iso", "import", "snippets"])];
+ const ohneImport = [storage("local", ["iso", "snippets"])];
+
+ it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => {
+ const f = faehigkeitenAbleiten({
+ version: v("9.2.1"),
+ storages: mitImport,
+ ssh: "aus",
+ rechteFehlen: false,
+ });
+ expect(f.templateBau.weg).toBe("api");
+ expect(f.vmProvisionierung.weg).toBe("api");
+ expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
+ expect(f.snippets.weg).toBe("nicht_moeglich");
+ });
+
+ it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => {
+ expect(
+ faehigkeitenAbleiten({ version: v("8.3.5"), storages: mitImport, ssh: "ok", rechteFehlen: false })
+ .templateBau.weg,
+ ).toBe("ssh");
+ const ohne = faehigkeitenAbleiten({
+ version: v("8.3.5"),
+ storages: mitImport,
+ ssh: "aus",
+ rechteFehlen: false,
+ });
+ expect(ohne.templateBau).toMatchObject({ weg: "nicht_moeglich" });
+ expect(ohne.templateBau.grund).toContain("8.4");
+ });
+
+ it("PVE 8.4 ohne import-Storage nennt den Ausweg", () => {
+ const f = faehigkeitenAbleiten({
+ version: v("8.4.1"),
+ storages: ohneImport,
+ ssh: "aus",
+ rechteFehlen: false,
+ });
+ expect(f.templateBau.grund).toMatch(/Inhaltstyp „Import“/);
+ });
+
+ it("mit SSH: Snippets, pct exec und erweiterte LXC-Features", () => {
+ const f = faehigkeitenAbleiten({
+ version: v("9.2.1"),
+ storages: mitImport,
+ ssh: "ok",
+ rechteFehlen: false,
+ });
+ expect(f.snippets.weg).toBe("ssh");
+ expect(f.lxcProvisionierung).toMatchObject({ ueber: "node" });
+ expect(f.lxcFeaturesErweitert.weg).toBe("ssh");
+ });
+
+ it("unbestätigter Host-Key zählt nicht als SSH", () => {
+ const f = faehigkeitenAbleiten({
+ version: v("8.3.5"),
+ storages: mitImport,
+ ssh: "nicht_bestaetigt",
+ rechteFehlen: false,
+ });
+ expect(f.templateBau.weg).toBe("nicht_moeglich");
+ });
+});
diff --git a/tests/proxmox-mock/szenario.ts b/tests/proxmox-mock/szenario.ts
new file mode 100644
index 0000000..1ae9e11
--- /dev/null
+++ b/tests/proxmox-mock/szenario.ts
@@ -0,0 +1,119 @@
+import { benoetigteRechte, versionLesen } from "@/engine/proxmox/faehigkeiten";
+import type { ProxmoxMock } from "./mock";
+
+/**
+ * Typische Proxmox-Zustände für Tests. Die Antworten folgen dem Format der echten API
+ * (geprüft gegen das API-Schema von PVE 9.2).
+ */
+export type Szenario = {
+ version: string;
+ cluster?: { name: string; nodes: Array<{ name: string; ip: string; online?: boolean }> };
+ /** Storage `local` mit Inhaltstyp import? (ab 8.4 nötig für Template-Bau per API) */
+ importStorage?: boolean;
+ /** Rechte, die dem Token fehlen sollen. */
+ ohneRechte?: string[];
+ /** Ohne Sys.Audit auf / antwortet /cluster/status mit 403. */
+ ohneClusterStatus?: boolean;
+};
+
+export function szenarioEinrichten(mock: ProxmoxMock, szenario: Szenario): void {
+ const version = versionLesen(szenario.version)!;
+ const nodes = szenario.cluster?.nodes ?? [{ name: "pve01", ip: "127.0.0.1", online: true }];
+ const ohne = new Set(szenario.ohneRechte ?? []);
+ const alle = benoetigteRechte(version).map((r) => r.recht);
+ const rechte = Object.fromEntries(alle.filter((r) => !ohne.has(r)).map((r) => [r, 1]));
+
+ mock.route("GET", "/version", () => ({
+ data: { version: szenario.version, release: `${version.major}.${version.minor}`, repoid: "abcdef" },
+ }));
+ mock.route("GET", "/cluster/status", () =>
+ szenario.ohneClusterStatus
+ ? { status: 403, statusText: "Permission check failed (/, Sys.Audit)" }
+ : {
+ data: [
+ ...(szenario.cluster
+ ? [
+ {
+ type: "cluster",
+ name: szenario.cluster.name,
+ quorate: 1,
+ nodes: nodes.length,
+ version: 3,
+ },
+ ]
+ : []),
+ ...nodes.map((n, i) => ({
+ type: "node",
+ name: n.name,
+ online: n.online === false ? 0 : 1,
+ ip: n.ip,
+ local: i === 0 ? 1 : 0,
+ nodeid: i + 1,
+ })),
+ ],
+ },
+ );
+ mock.route("GET", "/nodes", () => ({
+ data: nodes.map((n) => ({
+ node: n.name,
+ status: n.online === false ? "offline" : "online",
+ cpu: 0.05,
+ maxcpu: 16,
+ mem: 8 * 1024 ** 3,
+ maxmem: 64 * 1024 ** 3,
+ uptime: 3600,
+ })),
+ }));
+ mock.route("GET", /^\/nodes\/([^/]+)\/version$/, () => ({
+ data: { version: szenario.version, release: `${version.major}.${version.minor}`, repoid: "abcdef" },
+ }));
+ mock.route("GET", /^\/nodes\/([^/]+)\/storage$/, () => ({
+ data: [
+ {
+ storage: "local",
+ type: "dir",
+ content: ["iso", "vztmpl", "backup", "snippets", ...(szenario.importStorage ? ["import"] : [])].join(
+ ",",
+ ),
+ active: 1,
+ enabled: 1,
+ shared: 0,
+ avail: 80 * 1024 ** 3,
+ total: 100 * 1024 ** 3,
+ },
+ {
+ storage: "local-zfs",
+ type: "zfspool",
+ content: "images,rootdir",
+ active: 1,
+ enabled: 1,
+ shared: 0,
+ avail: 800 * 1024 ** 3,
+ total: 1000 * 1024 ** 3,
+ },
+ ],
+ }));
+ mock.route("GET", /^\/nodes\/([^/]+)\/network$/, () => ({
+ data: [
+ { iface: "eno1", type: "eth", active: 1 },
+ {
+ iface: "vmbr0",
+ type: "bridge",
+ active: 1,
+ bridge_vlan_aware: 1,
+ cidr: "10.0.10.2/24",
+ comments: "LAN\n",
+ },
+ { iface: "vmbr1", type: "bridge", active: 1 },
+ ],
+ }));
+ mock.route("GET", "/cluster/sdn/vnets", () =>
+ ohne.has("SDN.Audit")
+ ? { status: 403, statusText: "Permission check failed (/sdn, SDN.Audit)" }
+ : { data: [{ vnet: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }] },
+ );
+ mock.route("GET", "/access/permissions", (anfrage) => {
+ const p = anfrage.abfrage.get("path") ?? "/";
+ return { data: { [p]: rechte } };
+ });
+}
diff --git a/tests/proxmox-rechte-doku.test.ts b/tests/proxmox-rechte-doku.test.ts
new file mode 100644
index 0000000..5a413c0
--- /dev/null
+++ b/tests/proxmox-rechte-doku.test.ts
@@ -0,0 +1,25 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { benoetigteRechte, versionLesen } from "@/engine/proxmox/faehigkeiten";
+
+/** Die Befehle in der Doku müssen genau die Rechte nennen, die der Verbindungstest verlangt. */
+const doku = readFileSync("docs/proxmox-rechte.md", "utf8");
+
+function befehlNach(ueberschrift: string): string[] {
+ const abschnitt = doku.slice(doku.indexOf(ueberschrift));
+ const treffer = /--privs "([^"]+)"/.exec(abschnitt);
+ return treffer![1]!.split(" ").sort();
+}
+
+describe("Doku der Proxmox-Rechte", () => {
+ it.each([
+ ["## Rolle anlegen (PVE 9.x)", "9.2.1"],
+ ["## Rolle anlegen (PVE 8.x)", "8.4.1"],
+ ])("%s entspricht der Pflichtliste", (ueberschrift, version) => {
+ const pflicht = benoetigteRechte(versionLesen(version)!)
+ .filter((r) => r.pflicht)
+ .map((r) => r.recht)
+ .sort();
+ expect(befehlNach(ueberschrift)).toEqual(pflicht);
+ });
+});
diff --git a/tests/ssh-mock.ts b/tests/ssh-mock.ts
new file mode 100644
index 0000000..bcb1cb4
--- /dev/null
+++ b/tests/ssh-mock.ts
@@ -0,0 +1,66 @@
+import type { AddressInfo } from "node:net";
+import { Server, utils } from "ssh2";
+import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
+
+/**
+ * Ein SSH-Server für Tests (ssh2 bringt ihn mit). Er nimmt genau einen öffentlichen Schlüssel an
+ * und beantwortet Befehle über eine frei definierbare Funktion.
+ */
+export class SshMock {
+ private server: Server | undefined;
+ port = 0;
+ hostSchluessel = "";
+ befehle: string[] = [];
+
+ constructor(
+ private readonly erlaubterSchluessel: string,
+ private readonly antwort: (befehl: string) => {
+ stdout?: string;
+ stderr?: string;
+ code?: number;
+ } = () => ({
+ stdout: "pve-manager/9.2.1/abcdef (running kernel: 6.14.8-2-pve)\n",
+ }),
+ ) {}
+
+ async starten(): Promise {
+ // ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
+ const hostPem = utils.generateKeyPairSync("ed25519").private;
+ const host = utils.parseKey(hostPem);
+ if (host instanceof Error) throw host;
+ this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
+ const erlaubt = utils.parseKey(this.erlaubterSchluessel);
+ if (erlaubt instanceof Error) throw erlaubt;
+ const erlaubtSsh = (Array.isArray(erlaubt) ? erlaubt[0]! : erlaubt).getPublicSSH();
+
+ this.server = new Server({ hostKeys: [hostPem] }, (client) => {
+ client
+ .on("authentication", (kontext) => {
+ if (kontext.method === "publickey" && kontext.key.data.equals(erlaubtSsh)) kontext.accept();
+ else kontext.reject(["publickey"]);
+ })
+ .on("ready", () => {
+ client.on("session", (annehmen) => {
+ const sitzung = annehmen();
+ sitzung.on("exec", (annehmenExec, _ablehnen, info) => {
+ const kanal = annehmenExec();
+ this.befehle.push(info.command);
+ const ergebnis = this.antwort(info.command);
+ if (ergebnis.stdout) kanal.write(ergebnis.stdout);
+ if (ergebnis.stderr) kanal.stderr.write(ergebnis.stderr);
+ kanal.exit(ergebnis.code ?? 0);
+ kanal.end();
+ });
+ });
+ })
+ .on("error", () => undefined);
+ });
+ await new Promise((erfuellen) => this.server!.listen(0, "127.0.0.1", erfuellen));
+ this.port = (this.server.address() as AddressInfo).port;
+ return this;
+ }
+
+ async stoppen(): Promise {
+ await new Promise((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
+ }
+}
diff --git a/tests/verbindungstest.test.ts b/tests/verbindungstest.test.ts
new file mode 100644
index 0000000..ecfa667
--- /dev/null
+++ b/tests/verbindungstest.test.ts
@@ -0,0 +1,104 @@
+import { utils } from "ssh2";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
+import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
+import { verschluesseln } from "@/lib/geheimnisse";
+import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
+import { szenarioEinrichten } from "./proxmox-mock/szenario";
+import { SshMock } from "./ssh-mock";
+import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
+import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
+
+const mock = new ProxmoxMock();
+const paar = utils.generateKeyPairSync("ed25519");
+let ssh: SshMock;
+
+describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
+ beforeAll(async () => {
+ await mock.starten();
+ szenarioEinrichten(mock, { version: "8.3.5", importStorage: true });
+ ssh = await new SshMock(paar.public).starten();
+ });
+ afterAll(async () => {
+ await mock.stoppen();
+ await ssh.stoppen();
+ });
+
+ let admin: string;
+ let operator: string;
+ beforeEach(async () => {
+ await datenbankLeeren();
+ admin = (await angemeldet("ADMIN")).cookie;
+ operator = (await angemeldet("OPERATOR")).cookie;
+ await testPrisma.server.create({
+ data: {
+ name: "rz1",
+ titel: "RZ 1",
+ url: mock.url,
+ tokenId: MOCK_TOKEN_ID,
+ tokenGeheimnis: verschluesseln(MOCK_TOKEN_GEHEIMNIS),
+ fingerabdruck: mock.fingerabdruck,
+ sshAktiv: true,
+ sshPort: ssh.port,
+ sshPrivat: verschluesseln(paar.private),
+ sshOeffentlich: paar.public,
+ },
+ });
+ });
+
+ const testen = async (cookie: string) =>
+ await testPost(anfrage("POST", "/api/v1/server/rz1/test", cookie), kontext({ name: "rz1" }));
+
+ it("speichert Bericht, Version und Erreichbarkeit; Operator darf testen", async () => {
+ const antwort = await testen(operator);
+ expect(antwort.status).toBe(200);
+ const ergebnis = await antwort.json();
+ expect(ergebnis.ok).toBe(true);
+ expect(ergebnis.bericht.ssh).toBe("nicht_bestaetigt");
+ const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
+ expect(server).toMatchObject({ erreichbar: true, pveVersion: "8.3.5" });
+ expect(await testPrisma.protokoll.count({ where: { aktion: "Verbindung getestet" } })).toBe(1);
+ });
+
+ it("Host-Key bestätigen: nur der gesehene, nur durch Admins – danach geht SSH", async () => {
+ const erst = await (await testen(admin)).json();
+ const gesehen = erst.bericht.knoten[0].ssh.hostSchluessel as string;
+ const pfad = "/api/v1/server/rz1/ssh/hostschluessel";
+
+ expect(
+ (
+ await hostSchluesselPost(
+ anfrage("POST", pfad, operator, { knoten: "pve01", hostSchluessel: gesehen }),
+ kontext({ name: "rz1" }),
+ )
+ ).status,
+ ).toBe(403);
+
+ const untergeschoben = "SHA256:" + "A".repeat(43);
+ const abgelehnt = await hostSchluesselPost(
+ anfrage("POST", pfad, admin, { knoten: "pve01", hostSchluessel: untergeschoben }),
+ kontext({ name: "rz1" }),
+ );
+ expect(abgelehnt.status).toBe(409);
+
+ const ok = await hostSchluesselPost(
+ anfrage("POST", pfad, admin, { knoten: "pve01", hostSchluessel: gesehen }),
+ kontext({ name: "rz1" }),
+ );
+ expect(ok.status).toBe(200);
+ expect((await ok.json()).sshHostSchluessel).toEqual({ pve01: gesehen });
+
+ const dann = await (await testen(admin)).json();
+ expect(dann.bericht.ssh).toBe("ok");
+ expect(dann.bericht.faehigkeiten.templateBau.weg).toBe("ssh");
+ });
+
+ it("ein nicht erreichbarer Server ist ein Ergebnis, kein Fehler", async () => {
+ await testPrisma.server.update({ where: { name: "rz1" }, data: { url: "https://127.0.0.1:9" } });
+ const antwort = await testen(admin);
+ expect(antwort.status).toBe(200);
+ const ergebnis = await antwort.json();
+ expect(ergebnis).toMatchObject({ ok: false });
+ expect((await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } })).erreichbar).toBe(false);
+ });
+});