Files
proxdeploy/src/app/(app)/server/server-formular.tsx
T
florian.wildeandClaude Opus 5.5 21edff2141 feat(templates): Template-Bau ohne SSH über eine Bau-Adresse
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>
2026-10-12 14:36:38 +02:00

472 lines
18 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, 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." };
}