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>
423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
"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." };
|
||
}
|