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>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { serverSicht } from "@/server/services/server";
|
||||
import { ServerFormular } from "../../server-formular";
|
||||
|
||||
export const metadata: Metadata = { title: "Server bearbeiten" };
|
||||
|
||||
export default async function ServerBearbeiten({ params }: { params: Promise<{ name: string }> }) {
|
||||
const { name } = await params;
|
||||
await seiteSchuetzen("serverVerwalten", `/server/${name}/bearbeiten`);
|
||||
const server = await prisma.server.findUnique({ where: { name } });
|
||||
if (!server) notFound();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{server.titel} bearbeiten</h1>
|
||||
<ServerFormular server={serverSicht(server)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Pencil } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Code } from "@/components/formular";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { serverSicht } from "@/server/services/server";
|
||||
import { ServerStatus } from "../server-status";
|
||||
import { ServerAktionen } from "./server-aktionen";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
|
||||
return { title: (await params).name };
|
||||
}
|
||||
|
||||
export default async function ServerDetail({ params }: { params: Promise<{ name: string }> }) {
|
||||
const { name } = await params;
|
||||
const benutzer = await seiteSchuetzen("lesen", `/server/${name}`);
|
||||
const datensatz = await prisma.server.findUnique({ where: { name } });
|
||||
if (!datensatz) notFound();
|
||||
const server = serverSicht(datensatz);
|
||||
const verwalten = darf(benutzer.rolle, "serverVerwalten");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted font-mono text-sm">{server.name}</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{server.titel}</h1>
|
||||
</div>
|
||||
{verwalten ? (
|
||||
<Link
|
||||
href={`/server/${server.name}/bearbeiten`}
|
||||
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center gap-2 rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
<Pencil size={16} aria-hidden="true" /> Bearbeiten
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<Karte titel="Verbindung" aktion={<ServerStatus server={server} />}>
|
||||
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_1fr]">
|
||||
<dt className="text-muted">Adresse</dt>
|
||||
<dd className="font-mono break-all">{server.url}</dd>
|
||||
<dt className="text-muted">Token-ID</dt>
|
||||
<dd className="font-mono break-all">{server.tokenId}</dd>
|
||||
<dt className="text-muted">TLS</dt>
|
||||
<dd>
|
||||
{server.fingerabdruck ? (
|
||||
<span className="flex flex-col gap-1">
|
||||
<span>Fingerprint gepinnt{server.tlsPruefen ? " und Kette geprüft" : ""}</span>
|
||||
<span className="text-muted font-mono text-xs break-all">{server.fingerabdruck}</span>
|
||||
</span>
|
||||
) : (
|
||||
"Kette gegen Systemzertifikate geprüft"
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted">Templates</dt>
|
||||
<dd className="font-mono">
|
||||
{server.templateVmidVon}–{server.templateVmidBis}
|
||||
</dd>
|
||||
<dt className="text-muted">Gäste ab</dt>
|
||||
<dd className="font-mono">{server.gastVmidVon}</dd>
|
||||
</dl>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="SSH zu den Nodes (optional)">
|
||||
{!server.sshAktiv ? (
|
||||
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
|
||||
) : server.sshOeffentlich ? (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p>
|
||||
Diesen Schlüssel auf jedem Node für <span className="font-mono">{server.sshBenutzer}</span>{" "}
|
||||
hinterlegen. Im Cluster genügt ein Node:{" "}
|
||||
<span className="font-mono">/root/.ssh/authorized_keys</span> zeigt dort auf die gemeinsame
|
||||
Datei in <span className="font-mono">/etc/pve/priv/</span>.
|
||||
</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).
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Status art="laeuft">SSH ist eingeschaltet, aber es gibt noch keinen Schlüssel.</Status>
|
||||
)}
|
||||
{verwalten && server.sshAktiv ? (
|
||||
<div className="mt-4">
|
||||
<ServerAktionen name={server.name} art="ssh" erneuern={Boolean(server.sshOeffentlich)} />
|
||||
</div>
|
||||
) : null}
|
||||
</Karte>
|
||||
|
||||
{verwalten ? (
|
||||
<Karte titel="Gefahrenzone">
|
||||
<ServerAktionen name={server.name} art="loeschen" />
|
||||
</Karte>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
/** Schreibende Knöpfe der Serverseite. Löschen und Erneuern verlangen eine zweite Bestätigung. */
|
||||
export function ServerAktionen({
|
||||
name,
|
||||
art,
|
||||
erneuern = false,
|
||||
}: {
|
||||
name: string;
|
||||
art: "ssh" | "loeschen";
|
||||
erneuern?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [bestaetigen, setBestaetigen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function ausfuehren() {
|
||||
if ((art === "loeschen" || erneuern) && !bestaetigen) {
|
||||
setBestaetigen(true);
|
||||
return;
|
||||
}
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
if (art === "loeschen") {
|
||||
await apiAufruf("DELETE", `/api/v1/server/${name}`);
|
||||
router.push("/server");
|
||||
} else {
|
||||
await apiAufruf("POST", `/api/v1/server/${name}/ssh`);
|
||||
}
|
||||
setBestaetigen(false);
|
||||
router.refresh();
|
||||
} catch (ursache) {
|
||||
setFehler(ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
const text =
|
||||
art === "loeschen"
|
||||
? bestaetigen
|
||||
? "Wirklich löschen"
|
||||
: "Server löschen"
|
||||
: erneuern
|
||||
? bestaetigen
|
||||
? "Neuen Schlüssel erzeugen – der alte wird ungültig"
|
||||
: "Schlüssel erneuern"
|
||||
: "Schlüssel erzeugen";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{art === "loeschen" ? (
|
||||
<p className="text-muted text-sm">
|
||||
Entfernt nur den Eintrag in ProxDeploy. VMs und Container auf dem Server bleiben unberührt.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Knopf variante={art === "loeschen" ? "gefahr" : "sekundaer"} onClick={ausfuehren} disabled={laeuft}>
|
||||
{laeuft ? "Bitte warten …" : text}
|
||||
</Knopf>
|
||||
{bestaetigen ? (
|
||||
<Knopf variante="sekundaer" onClick={() => setBestaetigen(false)}>
|
||||
Abbrechen
|
||||
</Knopf>
|
||||
) : null}
|
||||
</div>
|
||||
<div aria-live="polite">{fehler ? <Status art="fehler">{fehler}</Status> : null}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ServerFormular } from "../server-formular";
|
||||
|
||||
export const metadata: Metadata = { title: "Server anlegen" };
|
||||
|
||||
export default async function ServerNeu() {
|
||||
await seiteSchuetzen("serverVerwalten", "/server/neu");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server anlegen</h1>
|
||||
<ServerFormular />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { serverListe } from "@/server/services/server";
|
||||
import { ServerStatus } from "./server-status";
|
||||
|
||||
export const metadata: Metadata = { title: "Server" };
|
||||
|
||||
export default async function ServerSeite() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/server");
|
||||
const liste = await serverListe();
|
||||
const verwalten = darf(benutzer.rolle, "serverVerwalten");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server</h1>
|
||||
<p className="text-muted text-sm">Proxmox-VE-Hosts und -Cluster, auf die ProxDeploy ausrollt.</p>
|
||||
</div>
|
||||
{verwalten ? (
|
||||
<Link
|
||||
href="/server/neu"
|
||||
className="bg-akzent text-akzent-text inline-flex h-11 items-center gap-2 rounded-lg px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
<Plus size={18} aria-hidden="true" /> Server anlegen
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{liste.length === 0 ? (
|
||||
<Karte>
|
||||
<Status art="offen">Noch kein Server angelegt.</Status>
|
||||
{!verwalten ? <p className="text-muted mt-2 text-sm">Server legt ein Administrator an.</p> : null}
|
||||
</Karte>
|
||||
) : (
|
||||
<ul className="grid gap-4 md:grid-cols-2">
|
||||
{liste.map((server) => (
|
||||
<li key={server.name}>
|
||||
<Link
|
||||
href={`/server/${server.name}`}
|
||||
className="bg-panel border-linie rounded-karte hover:border-muted flex flex-col gap-3 border p-5 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold">{server.titel}</p>
|
||||
<p className="text-muted truncate font-mono text-sm">{server.name}</p>
|
||||
</div>
|
||||
<ServerStatus server={server} />
|
||||
</div>
|
||||
<p className="text-muted truncate font-mono text-xs">{server.url}</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"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." };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Status } from "@/components/status";
|
||||
import type { ServerSicht } from "@/lib/schemas/server";
|
||||
|
||||
/** Erreichbarkeit nach der letzten Prüfung – „ungeprüft“, solange es keine gab. */
|
||||
export function ServerStatus({ server }: { server: Pick<ServerSicht, "erreichbar" | "pveVersion"> }) {
|
||||
if (server.erreichbar === null) return <Status art="offen">ungeprüft</Status>;
|
||||
if (!server.erreichbar) return <Status art="fehler">nicht erreichbar</Status>;
|
||||
return (
|
||||
<Status art="erledigt">
|
||||
PVE <span className="font-mono">{server.pveVersion}</span>
|
||||
</Status>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user