Files
proxdeploy/src/app/(app)/server/server-formular.tsx
T
florian.wildeandClaude Opus 5.5 48e2a5cb8f feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox
liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es
vor dem Speichern und bestätigt den SHA-256-Fingerprint.

Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine
fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch
Pin ist im Schema, im Client und per CHECK ausgeschlossen.

Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der
Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock
und im gebauten Stand auf Desktop und 390 px.

Closes #002

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:35:10 +02:00

423 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import type { ServerSicht } from "@/lib/schemas/server";
type Zertifikat = {
fingerabdruck: string;
subjekt: string;
aussteller: string;
gueltigBis: string;
alternativeNamen: string[];
vertraut: boolean;
fehlerGrund: string | null;
};
type PruefErgebnis = {
zertifikat: Zertifikat | null;
pinPasst: boolean | null;
verbindung:
| { ok: true; version: string; release: string }
| { ok: false; art: string; ursache: string; hinweis: string }
| null;
};
type Werte = {
name: string;
titel: string;
url: string;
tokenId: string;
tokenGeheimnis: string;
tlsPruefen: boolean;
fingerabdruck: string | null;
sshAktiv: boolean;
sshBenutzer: string;
sshPort: string;
templateVmidVon: string;
templateVmidBis: string;
gastVmidVon: string;
};
function anfangswerte(server?: ServerSicht): Werte {
return {
name: server?.name ?? "",
titel: server?.titel ?? "",
url: server?.url ?? "",
tokenId: server?.tokenId ?? "",
tokenGeheimnis: "",
tlsPruefen: server?.tlsPruefen ?? false,
fingerabdruck: server?.fingerabdruck ?? null,
sshAktiv: server?.sshAktiv ?? false,
sshBenutzer: server?.sshBenutzer ?? "root",
sshPort: String(server?.sshPort ?? 22),
templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100),
};
}
/**
* Anlegen und Bearbeiten eines Proxmox-Servers. Ablauf: Adresse → Zertifikat ansehen und
* bestätigen → Token → Verbindung prüfen → speichern. Speichern geht auch ohne erfolgreiche
* Prüfung (der Host kann gerade aus sein), aber nie ohne bestätigten Fingerprint oder TLS-Prüfung.
*/
export function ServerFormular({ server }: { server?: ServerSicht }) {
const router = useRouter();
const bearbeiten = Boolean(server);
const [werte, setWerte] = useState<Werte>(() => anfangswerte(server));
const [pruefung, setPruefung] = useState<PruefErgebnis | null>(null);
const [prueft, setPrueft] = useState<"zertifikat" | "verbindung" | null>(null);
const [speichert, setSpeichert] = useState(false);
const [felder, setFelder] = useState<Record<string, string>>({});
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => {
setWerte((alt) => ({ ...alt, [feld]: wert }));
// Eine neue Adresse macht das gesehene Zertifikat und den Pin wertlos.
if (feld === "url") {
setPruefung(null);
setWerte((alt) => ({ ...alt, fingerabdruck: null }));
}
};
async function pruefen(was: "zertifikat" | "verbindung") {
setPrueft(was);
setMeldung(null);
try {
const ergebnis = await apiAufruf<PruefErgebnis>("POST", "/api/v1/server/pruefen", {
url: werte.url,
name: server?.name,
tokenId: was === "verbindung" ? werte.tokenId : undefined,
tokenGeheimnis: was === "verbindung" ? werte.tokenGeheimnis : undefined,
tlsPruefen: werte.tlsPruefen,
fingerabdruck: werte.fingerabdruck,
});
setPruefung(ergebnis);
// Ein vertrauenswürdiges Zertifikat (ACME) braucht keinen Pin – TLS-Prüfung genügt.
if (was === "zertifikat" && ergebnis.zertifikat?.vertraut && !werte.fingerabdruck) {
setWerte((alt) => ({ ...alt, tlsPruefen: true }));
}
} catch (fehler) {
setMeldung(fehlerText(fehler));
if (fehler instanceof ApiFehler) setFelder(fehler.felder);
} finally {
setPrueft(null);
}
}
async function speichern(ereignis: React.FormEvent) {
ereignis.preventDefault();
setSpeichert(true);
setFelder({});
setMeldung(null);
const rumpf = {
...werte,
sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon),
};
try {
const gespeichert = bearbeiten
? await apiAufruf<ServerSicht>("PATCH", `/api/v1/server/${server!.name}`, rumpf)
: await apiAufruf<ServerSicht>("POST", "/api/v1/server", rumpf);
router.push(`/server/${gespeichert.name}`);
router.refresh();
} catch (fehler) {
setMeldung(fehlerText(fehler));
if (fehler instanceof ApiFehler) setFelder(fehler.felder);
} finally {
setSpeichert(false);
}
}
const zertifikat = pruefung?.zertifikat;
const gepinnt = Boolean(werte.fingerabdruck);
return (
<form onSubmit={speichern} className="flex flex-col gap-6" noValidate>
<Karte titel="Kennung">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="name" label="Kurzname" hilfe="Für die CLI: --server rz1" fehler={felder["name"]}>
<input
id="name"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.name}
onChange={(e) => setze("name", e.target.value)}
autoComplete="off"
spellCheck={false}
{...beschreibung("name", felder["name"], true)}
/>
</Feld>
<Feld id="titel" label="Anzeigename" fehler={felder["titel"]}>
<input
id="titel"
className={EINGABE_KLASSE}
value={werte.titel}
onChange={(e) => setze("titel", e.target.value)}
placeholder="Rechenzentrum 1"
{...beschreibung("titel", felder["titel"])}
/>
</Feld>
</div>
</Karte>
<Karte titel="Adresse und Zertifikat">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<Feld
id="url"
label="Adresse"
hilfe="Ein Node des Clusters, z. B. https://pve01.example:8006"
fehler={felder["url"]}
className="flex-1"
>
<input
id="url"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.url}
onChange={(e) => setze("url", e.target.value)}
placeholder="https://pve01:8006"
inputMode="url"
spellCheck={false}
{...beschreibung("url", felder["url"], true)}
/>
</Feld>
<Knopf
variante="sekundaer"
onClick={() => pruefen("zertifikat")}
disabled={!werte.url || prueft !== null}
className="sm:mb-[1.625rem]"
>
{prueft === "zertifikat" ? "Holt …" : "Zertifikat holen"}
</Knopf>
</div>
{zertifikat ? (
<div className="border-linie flex flex-col gap-3 rounded-lg border p-4">
<Status art={zertifikat.vertraut ? "erledigt" : "laeuft"}>
{zertifikat.vertraut
? "Das System vertraut diesem Zertifikat."
: "Selbst signiertes Zertifikat – bitte den Fingerprint vergleichen."}
</Status>
<dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
<dt className="text-muted">Subjekt</dt>
<dd className="break-all">{zertifikat.subjekt}</dd>
<dt className="text-muted">Aussteller</dt>
<dd className="break-all">{zertifikat.aussteller}</dd>
<dt className="text-muted">Gültig bis</dt>
<dd>{new Date(zertifikat.gueltigBis).toLocaleDateString("de-DE")}</dd>
</dl>
<div className="flex flex-col gap-1.5">
<span className="text-muted text-sm">SHA-256-Fingerprint</span>
<Code>{zertifikat.fingerabdruck}</Code>
<p className="text-muted text-sm">
Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> oder in
der Proxmox-Oberfläche unter Node → System → Zertifikate.
</p>
</div>
<Schalter
id="pin"
label="Fingerprint stimmt – Zertifikat pinnen"
hilfe="ProxDeploy verbindet sich dann nur, solange genau dieses Zertifikat ausgeliefert wird."
checked={werte.fingerabdruck === zertifikat.fingerabdruck}
onChange={(an) =>
setWerte((alt) => ({ ...alt, fingerabdruck: an ? zertifikat.fingerabdruck : null }))
}
/>
</div>
) : gepinnt ? (
<div className="flex flex-col gap-1.5">
<Status art="erledigt">
<span className="inline-flex items-center gap-1.5">
<ShieldCheck size={16} aria-hidden="true" /> Gepinnter Fingerprint
</span>
</Status>
<Code>{werte.fingerabdruck!}</Code>
</div>
) : null}
{felder["fingerabdruck"] ? (
<p className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
{felder["fingerabdruck"]}
</p>
) : null}
<Schalter
id="tlsPruefen"
label="Zertifikatskette prüfen"
hilfe="Nur einschalten, wenn der Node ein Zertifikat einer vertrauenswürdigen CA hat (z. B. ACME)."
checked={werte.tlsPruefen}
onChange={(an) => setze("tlsPruefen", an)}
/>
</div>
</Karte>
<Karte titel="API-Token">
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="tokenId" label="Token-ID" hilfe="benutzer@realm!tokenname" fehler={felder["tokenId"]}>
<input
id="tokenId"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.tokenId}
onChange={(e) => setze("tokenId", e.target.value)}
placeholder="proxdeploy@pve!deploy"
autoComplete="off"
spellCheck={false}
{...beschreibung("tokenId", felder["tokenId"], true)}
/>
</Feld>
<Feld
id="tokenGeheimnis"
label="Secret"
hilfe={bearbeiten ? "Leer lassen, um das gespeicherte Secret zu behalten." : undefined}
fehler={felder["tokenGeheimnis"]}
>
<input
id="tokenGeheimnis"
type="password"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.tokenGeheimnis}
onChange={(e) => setze("tokenGeheimnis", e.target.value)}
placeholder={bearbeiten ? "unverändert" : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
autoComplete="new-password"
{...beschreibung("tokenGeheimnis", felder["tokenGeheimnis"], bearbeiten)}
/>
</Feld>
</div>
<p className="text-muted text-sm">
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Knopf
variante="sekundaer"
onClick={() => pruefen("verbindung")}
disabled={
prueft !== null ||
!werte.url ||
!werte.tokenId ||
(!werte.tokenGeheimnis && !bearbeiten) ||
(!werte.tlsPruefen && !gepinnt)
}
>
{prueft === "verbindung" ? "Prüft …" : "Verbindung prüfen"}
</Knopf>
<div aria-live="polite" className="text-sm">
{pruefung?.verbindung?.ok ? (
<Status art="erledigt">
Verbunden · Proxmox VE <span className="font-mono">{pruefung.verbindung.version}</span>
</Status>
) : pruefung?.verbindung && !pruefung.verbindung.ok ? (
<div>
<Status art="fehler">{pruefung.verbindung.ursache}</Status>
<p className="text-muted mt-1">{pruefung.verbindung.hinweis}</p>
</div>
) : null}
</div>
</div>
</div>
</Karte>
<Karte titel="Erweitert">
<div className="flex flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="templateVmidVon" label="Templates ab VMID" fehler={felder["templateVmidVon"]}>
<input
id="templateVmidVon"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.templateVmidVon}
onChange={(e) => setze("templateVmidVon", e.target.value)}
/>
</Feld>
<Feld id="templateVmidBis" label="Templates bis VMID" fehler={felder["templateVmidBis"]}>
<input
id="templateVmidBis"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.templateVmidBis}
onChange={(e) => setze("templateVmidBis", e.target.value)}
/>
</Feld>
<Feld id="gastVmidVon" label="Gäste ab VMID" fehler={felder["gastVmidVon"]}>
<input
id="gastVmidVon"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.gastVmidVon}
onChange={(e) => setze("gastVmidVon", e.target.value)}
/>
</Feld>
</div>
<Schalter
id="sshAktiv"
label="SSH zu den Nodes nutzen"
hilfe="Optional. Nötig für cloud-init-Snippets, Profile in LXC-Containern ohne Netzweg und Template-Bau vor PVE 8.4."
checked={werte.sshAktiv}
onChange={(an) => setze("sshAktiv", an)}
/>
{werte.sshAktiv ? (
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="sshBenutzer"
label="SSH-Benutzer"
hilfe="root – für pct und /var/lib/vz/snippets nötig."
>
<input
id="sshBenutzer"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.sshBenutzer}
onChange={(e) => setze("sshBenutzer", e.target.value)}
{...beschreibung("sshBenutzer", undefined, true)}
/>
</Feld>
<Feld id="sshPort" label="SSH-Port" fehler={felder["sshPort"]}>
<input
id="sshPort"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.sshPort}
onChange={(e) => setze("sshPort", e.target.value)}
/>
</Feld>
</div>
) : null}
</div>
</Karte>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end">
<div aria-live="polite" className="text-sm sm:mr-auto">
{meldung ? (
<div>
<Status art="fehler">{meldung.text}</Status>
{meldung.hinweis ? <p className="text-muted mt-1">{meldung.hinweis}</p> : null}
</div>
) : null}
</div>
<Knopf variante="sekundaer" onClick={() => router.back()}>
Abbrechen
</Knopf>
<Knopf type="submit" disabled={speichert}>
{speichert ? "Speichert …" : bearbeiten ? "Änderungen speichern" : "Server anlegen"}
</Knopf>
</div>
</form>
);
}
function fehlerText(fehler: unknown): { text: string; hinweis?: string } {
if (fehler instanceof ApiFehler) return { text: fehler.message, hinweis: fehler.hinweis };
return { text: "Unerwarteter Fehler." };
}