feat(server): Verbindungstest mit Fähigkeitserkennung

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:44:27 +02:00
co-authored by Claude Opus 5.5
parent 48e2a5cb8f
commit 86b650e20a
26 changed files with 2094 additions and 18 deletions
+94
View File
@@ -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/<name>`) | VMs und Container anlegen, konfigurieren, starten, Guest-Agent |
| `/storage/<id>` 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/<node>` | `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)
@@ -0,0 +1,2 @@
-- Umbenennen statt neu anlegen: der Inhalt bleibt erhalten.
ALTER TABLE "Server" RENAME COLUMN "faehigkeiten" TO "letzterTest";
+2 -2
View File
@@ -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?
+10 -1
View File
@@ -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<Metadata> {
return { title: (await params).name };
@@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</dl>
</Karte>
<Verbindungsbericht
name={server.name}
bericht={server.letzterTest}
geprueftAm={server.geprueftAm}
verwalten={verwalten}
/>
<Karte titel="SSH zu den Nodes (optional)">
{!server.sshAktiv ? (
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
@@ -80,7 +88,8 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</p>
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
<p className="text-muted">
Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003).
Host-Keys erscheinen nach dem Verbindungstest beim jeweiligen Node und werden erst nach
Bestätigung genutzt.
</p>
</div>
) : (
@@ -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<Strategie["weg"], { art: StatusArt; text: string }> = {
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<TestErgebnis>("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 (
<div className="flex flex-col gap-6">
<Karte
titel="Verbindungstest"
aktion={
<Knopf variante="sekundaer" onClick={testen} disabled={laeuft}>
{laeuft ? "Testet …" : "Verbindung testen"}
</Knopf>
}
>
<div aria-live="polite" className="flex flex-col gap-3 text-sm">
{laeuft ? (
<Status art="laeuft">Liest Version, Nodes, Storages, Netzwerk und Rechte …</Status>
) : null}
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1">{fehler.hinweis}</p> : null}
</div>
) : null}
{!bericht && !fehler && !laeuft ? <Status art="offen">Noch nicht getestet.</Status> : null}
{bericht ? (
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[max-content_1fr]">
<dt className="text-muted">Proxmox VE</dt>
<dd className="font-mono">{bericht.version}</dd>
<dt className="text-muted">Cluster</dt>
<dd>
{bericht.cluster ? (
<>
<span className="font-mono">{bericht.cluster.name}</span> ·{" "}
{bericht.cluster.quorate ? "Quorum vorhanden" : "kein Quorum"}
</>
) : (
"Einzelhost"
)}
</dd>
<dt className="text-muted">Geprüft</dt>
<dd>{zeitpunkt(zeit)}</dd>
</dl>
) : null}
{bericht?.warnungen.map((warnung) => (
<Status key={warnung} art="laeuft">
{warnung}
</Status>
))}
</div>
</Karte>
{bericht ? (
<>
<Karte titel="Was geht auf diesem Server">
<ul className="flex flex-col gap-3 text-sm">
{VORGAENGE.map(([schluessel, titel]) => {
const strategie = bericht.faehigkeiten[schluessel];
const anzeige = WEG_STATUS[strategie.weg];
return (
<li key={schluessel} className="grid gap-1 sm:grid-cols-[14rem_8rem_1fr] sm:gap-3">
<span className="font-medium">{titel}</span>
<Status art={anzeige.art}>{anzeige.text}</Status>
<span className="text-muted">{strategie.grund}</span>
</li>
);
})}
</ul>
</Karte>
<Karte titel="Rechte des API-Tokens">
{pflichtFehlend.length === 0 ? (
<Status art="erledigt">Alle nötigen Rechte vorhanden.</Status>
) : (
<div className="flex flex-col gap-3 text-sm">
<Status art="fehler">
{pflichtFehlend.length === 1 ? "Ein Recht fehlt" : `${pflichtFehlend.length} Rechte fehlen`}{" "}
– die Rolle „ProxDeploy“ steht vollständig in docs/proxmox-rechte.md.
</Status>
<RechteListe rechte={pflichtFehlend} />
</div>
)}
{optionalFehlend.length > 0 ? (
<details className="mt-4 text-sm">
<summary className="text-muted cursor-pointer">
{optionalFehlend.length} optionale Rechte fehlen
</summary>
<div className="mt-2">
<RechteListe rechte={optionalFehlend} />
</div>
</details>
) : null}
</Karte>
{bericht.knoten.map((knoten) => (
<Karte
key={knoten.name}
titel={
<span className="font-mono">
{knoten.name}
{knoten.ip ? <span className="text-muted font-normal"> · {knoten.ip}</span> : null}
</span>
}
aktion={
knoten.online ? <Status art="erledigt">online</Status> : <Status art="fehler">offline</Status>
}
>
{knoten.online ? (
<div className="flex flex-col gap-5 text-sm">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
<dt className="text-muted">Version</dt>
<dd className="font-mono">{knoten.version ?? "–"}</dd>
<dt className="text-muted">CPU</dt>
<dd>
{knoten.cpus ?? "–"} Kerne · {prozent(knoten.cpuAuslastung)}
</dd>
<dt className="text-muted">RAM</dt>
<dd>
{bytes(knoten.speicherBelegt)} / {bytes(knoten.speicherGesamt)}
</dd>
</dl>
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-left">
<caption className="text-muted mb-2 text-left text-xs font-medium tracking-wide uppercase">
Storages
</caption>
<thead className="text-muted">
<tr>
<th className="py-1 pr-4 font-medium">Name</th>
<th className="py-1 pr-4 font-medium">Typ</th>
<th className="py-1 pr-4 font-medium">Inhalte</th>
<th className="py-1 font-medium">Frei</th>
</tr>
</thead>
<tbody>
{knoten.storages.map((s) => (
<tr key={s.storage} className="border-linie border-t">
<td className="py-1.5 pr-4 font-mono">{s.storage}</td>
<td className="py-1.5 pr-4">{s.typ}</td>
<td className="text-muted py-1.5 pr-4">{s.inhalte.join(", ")}</td>
<td className="py-1.5">
{bytes(s.frei)} / {bytes(s.gesamt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-muted mb-2 text-xs font-medium tracking-wide uppercase">Bridges</p>
<ul className="flex flex-wrap gap-2">
{knoten.bridges.map((b) => (
<li key={b.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{b.name}</span>
<span className="text-muted">
{b.vlanBewusst ? " · VLAN-fähig" : ""}
{b.cidr ? ` · ${b.cidr}` : ""}
{b.kommentar ? ` · ${b.kommentar}` : ""}
</span>
</li>
))}
</ul>
</div>
{knoten.ssh ? (
<div className="flex flex-col gap-2">
<p className="text-muted text-xs font-medium tracking-wide uppercase">SSH</p>
{knoten.ssh.zustand === "ok" ? (
<Status art="erledigt">Verbunden als {knoten.ssh.host} · Host-Key bestätigt</Status>
) : knoten.ssh.zustand === "nicht_bestaetigt" ? (
<div className="flex flex-col gap-2">
<Status art="laeuft">
Host-Key noch nicht bestätigt – bitte mit dem Node vergleichen:
</Status>
<code className="bg-grund border-linie rounded-lg border px-3 py-2 font-mono text-xs break-all">
{knoten.ssh.hostSchluessel}
</code>
<p className="text-muted">
Auf dem Node:{" "}
<span className="font-mono">
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span>
</p>
{verwalten && knoten.ssh.hostSchluessel ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => bestaetigen(knoten.name, knoten.ssh!.hostSchluessel!)}
>
Host-Key von {knoten.name} bestätigen
</Knopf>
) : null}
</div>
) : (
<Status art="fehler">{knoten.ssh.meldung ?? "SSH nicht möglich."}</Status>
)}
</div>
) : null}
</div>
) : (
<p className="text-muted text-sm">Node ist offline – keine Details.</p>
)}
</Karte>
))}
{bericht.vnets && bericht.vnets.length > 0 ? (
<Karte titel="SDN-VNets">
<ul className="flex flex-wrap gap-2 text-sm">
{bericht.vnets.map((v) => (
<li key={v.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{v.name}</span>
<span className="text-muted">
{" "}
· Zone {v.zone}
{v.tag ? ` · VLAN ${v.tag}` : ""}
{v.alias ? ` · ${v.alias}` : ""}
</span>
</li>
))}
</ul>
</Karte>
) : null}
</>
) : null}
</div>
);
}
function RechteListe({ rechte }: { rechte: VerbindungsBericht["fehlendeRechte"] }) {
return (
<ul className="flex flex-col gap-1.5">
{rechte.map((r) => (
<li key={`${r.recht}${r.pfad}`} className="grid gap-x-3 sm:grid-cols-[16rem_10rem_1fr]">
<span className="font-mono">{r.recht}</span>
<span className="text-muted font-mono">{r.pfad}</span>
<span className="text-muted">{r.wofuer}</span>
</li>
))}
</ul>
);
}
+3 -3
View File
@@ -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"] ? (
<p className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{felder["fingerabdruck"]}
</p>
) : null}
@@ -295,7 +295,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld>
</div>
<p className="text-muted text-sm">
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).
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -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),
),
);
});
@@ -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)),
);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { clsx } from "clsx";
import { StatusSymbol } from "@/components/status";
/**
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
@@ -30,7 +31,7 @@ export function Feld({
{children}
{fehler ? (
<p id={`${id}-fehler`} className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{fehler}
</p>
) : hilfe ? (
+31 -6
View File
@@ -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<StatusArt, string> = {
erledigt: "✓",
laeuft: "◐",
@@ -20,6 +22,31 @@ const STATUS_FARBE: Record<StatusArt, string> = {
offen: "text-muted",
};
export function StatusSymbol({ art, className }: { art: StatusArt; className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={clsx("inline-block h-[1em] w-[1em] shrink-0", className)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{art === "erledigt" ? <path d="M3 8.5 6.5 12 13 4.5" /> : null}
{art === "fehler" ? <path d="M4 4 12 12M12 4 4 12" /> : null}
{art === "offen" ? <circle cx="8" cy="8" r="5.5" strokeWidth="1.6" /> : null}
{art === "laeuft" ? (
<>
<circle cx="8" cy="8" r="5.5" strokeWidth="1.6" />
<path d="M8 2.5a5.5 5.5 0 0 1 0 11Z" fill="currentColor" stroke="none" />
</>
) : null}
</svg>
);
}
export function Status({
art,
children,
@@ -30,10 +57,8 @@ export function Status({
className?: string;
}) {
return (
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
<span aria-hidden="true" className="font-mono">
{STATUS_SYMBOL[art]}
</span>
<span className={clsx("inline-flex items-baseline gap-1.5", STATUS_FARBE[art], className)}>
<StatusSymbol art={art} className="translate-y-[0.125em]" />
<span>{children}</span>
</span>
);
+319
View File
@@ -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<string, string>;
};
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<T>(warnungen: string[], text: string, aufruf: () => Promise<T>): Promise<T | null> {
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<VerbindungsBericht> {
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<ClusterStatusEintrag[]>("/cluster/status"),
);
const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster");
const nodeIps = new Map(
(clusterStatus ?? [])
.filter((e): e is Extract<ClusterStatusEintrag, { type: "node" }> => e.type === "node")
.map((e) => [e.name, e.ip ?? null]),
);
const nodes = await client.get<NodeEintrag[]>("/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<StorageEintrag[]>(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<NetzEintrag[]>(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<Array<{ vnet: string; zone: string; tag?: number; alias?: string }>>("/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<RechteJePfad>("/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<PveVersion>(
(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,
};
}
+287
View File
@@ -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<string, Record<string, number | boolean>>;
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.",
},
};
}
+2 -2
View File
@@ -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. */
+145
View File
@@ -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:<base64 ohne Padding>. */
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<SshErgebnis> {
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);
},
});
});
}
+37
View File
@@ -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`;
}
+12
View File
@@ -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<string, string>;
};
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."),
});
+119 -1
View File
@@ -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<string, string> | null) ?? {},
};
}
@@ -259,3 +268,112 @@ export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
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<TestErgebnis> {
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<string, string> | 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<ServerSicht> {
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<string, string> | 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);
}
+2 -1
View File
@@ -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<void>> {
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();
+4 -1
View File
@@ -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 }) => {
+6
View File
@@ -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);
+186
View File
@@ -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<void>> = [];
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" });
});
});
+163
View File
@@ -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");
});
});
+119
View File
@@ -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 } };
});
}
+25
View File
@@ -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);
});
});
+66
View File
@@ -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<this> {
// 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<void>((erfuellen) => this.server!.listen(0, "127.0.0.1", erfuellen));
this.port = (this.server.address() as AddressInfo).port;
return this;
}
async stoppen(): Promise<void> {
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
}
}
+104
View File
@@ -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);
});
});