Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste Bau-Adresse ins Template: cloud-init setzt Adresse und den Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet sich per SSH in der VM an, installiert den Agent und entfernt danach Schlüssel, Adresse und cloud-init-Angaben. Dabei korrigiert: der Verbindungstest meldete „Template-Bau per API“ auch ohne Weg, den Agent einzubauen. Im Protokoll erscheinen unveränderte JSON-Felder nicht mehr als geändert. Refs #021 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
18 KiB
TypeScript
472 lines
18 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, StatusSymbol } 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;
|
||
bauIp: string;
|
||
bauGateway: string;
|
||
bauDns: 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),
|
||
bauIp: server?.bauNetz?.ip ?? "",
|
||
bauGateway: server?.bauNetz?.gateway ?? "",
|
||
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
|
||
const rumpf = {
|
||
...ohneBau,
|
||
sshPort: Number(werte.sshPort),
|
||
templateVmidVon: Number(werte.templateVmidVon),
|
||
templateVmidBis: Number(werte.templateVmidBis),
|
||
gastVmidVon: Number(werte.gastVmidVon),
|
||
bauNetz: bauIp.trim()
|
||
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
|
||
: null,
|
||
};
|
||
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> –
|
||
maßgeblich ist <span className="font-mono">pveproxy-ssl.pem</span>, falls vorhanden, sonst{" "}
|
||
<span className="font-mono">pve-ssl.pem</span> (nicht die Root-CA).
|
||
</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">
|
||
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
|
||
{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 in der Doku „Proxmox-Rechte“ (docs/proxmox-rechte.md).
|
||
</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>
|
||
|
||
<fieldset className="flex flex-col gap-3">
|
||
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
|
||
<p className="text-muted text-sm">
|
||
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
|
||
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
|
||
Internetzugang.
|
||
</p>
|
||
<div className="grid gap-4 sm:grid-cols-3">
|
||
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
|
||
<input
|
||
id="bauIp"
|
||
className={`${EINGABE_KLASSE} font-mono`}
|
||
value={werte.bauIp}
|
||
onChange={(e) => setze("bauIp", e.target.value)}
|
||
placeholder="10.0.10.250/24"
|
||
/>
|
||
</Feld>
|
||
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
|
||
<input
|
||
id="bauGateway"
|
||
className={`${EINGABE_KLASSE} font-mono`}
|
||
value={werte.bauGateway}
|
||
onChange={(e) => setze("bauGateway", e.target.value)}
|
||
placeholder="10.0.10.1"
|
||
/>
|
||
</Feld>
|
||
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
|
||
<input
|
||
id="bauDns"
|
||
className={`${EINGABE_KLASSE} font-mono`}
|
||
value={werte.bauDns}
|
||
onChange={(e) => setze("bauDns", e.target.value)}
|
||
placeholder="10.0.10.1"
|
||
/>
|
||
</Feld>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<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." };
|
||
}
|