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:
@@ -1,6 +1,9 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Seitenleiste } from "@/components/seitenleiste";
|
||||
import { ServerUmschalter } from "@/components/server-umschalter";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { NAVIGATION } from "@/lib/navigation";
|
||||
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
||||
import { versionText } from "@/lib/version";
|
||||
@@ -15,7 +18,16 @@ export default async function AppHuelle({ children }: { children: React.ReactNod
|
||||
const benutzer = await aktuellerBenutzer();
|
||||
if (!benutzer) redirect("/anmelden");
|
||||
|
||||
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
|
||||
const speicher = await cookies();
|
||||
const theme = themeAusCookie(speicher.get(THEME_COOKIE)?.value);
|
||||
const server = await prisma.server.findMany({
|
||||
select: { name: true, titel: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const aktiv = aktiverServer(
|
||||
server.map((eintrag) => eintrag.name),
|
||||
speicher.get(SERVER_COOKIE)?.value,
|
||||
);
|
||||
const eintraege = NAVIGATION.filter((eintrag) => !eintrag.ab && darf(benutzer.rolle, eintrag.recht));
|
||||
|
||||
return (
|
||||
@@ -25,7 +37,7 @@ export default async function AppHuelle({ children }: { children: React.ReactNod
|
||||
benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }}
|
||||
version={versionText()}
|
||||
theme={theme}
|
||||
serverUmschalter={null}
|
||||
serverUmschalter={<ServerUmschalter server={server} aktiv={aktiv} />}
|
||||
/>
|
||||
<main id="inhalt" className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-10">
|
||||
<div className="mx-auto max-w-6xl">{children}</div>
|
||||
|
||||
+30
-5
@@ -1,12 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import Link from "next/link";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { serverListe } from "@/server/services/server";
|
||||
import { ServerStatus } from "./server/server-status";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
export default async function Uebersicht() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/");
|
||||
const server = await serverListe();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -16,11 +20,32 @@ export default async function Uebersicht() {
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Karte titel="Server">
|
||||
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
||||
<p className="text-muted mt-2 text-sm">
|
||||
Server lassen sich anlegen, sobald die Server-Verwaltung (#002) fertig ist.
|
||||
</p>
|
||||
<Karte
|
||||
titel="Server"
|
||||
aktion={
|
||||
<Link href="/server" className="text-akzent text-sm">
|
||||
Alle
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{server.length === 0 ? (
|
||||
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
||||
) : (
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{server.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.name}
|
||||
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Link href={`/server/${eintrag.name}`} className="min-w-0 hover:underline">
|
||||
<span className="font-medium">{eintrag.titel}</span>{" "}
|
||||
<span className="text-muted font-mono text-sm">{eintrag.name}</span>
|
||||
</Link>
|
||||
<ServerStatus server={eintrag} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
<Karte titel="Letzte Deployments">
|
||||
<Status art="offen">Noch keine Deployments.</Status>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { serverAendern, serverHolen, serverLoeschen, serverSicht } from "@/server/services/server";
|
||||
|
||||
type Kontext = { params: Promise<{ name: string }> };
|
||||
|
||||
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(serverSicht(await serverHolen((await params).name)));
|
||||
});
|
||||
|
||||
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||
const server = await serverAendern(
|
||||
(await params).name,
|
||||
await jsonLesen(anfrage),
|
||||
aufrufer,
|
||||
aufruferAdresse(anfrage.headers),
|
||||
);
|
||||
return NextResponse.json(server);
|
||||
});
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||
await serverLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { sshSchluesselErzeugen } from "@/server/services/server";
|
||||
|
||||
type Kontext = { params: Promise<{ name: string }> };
|
||||
|
||||
/** Erzeugt oder erneuert das SSH-Schlüsselpaar; der private Teil verlässt den Server nie. */
|
||||
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||
return NextResponse.json(
|
||||
await sshSchluesselErzeugen((await params).name, aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { serverPruefen } from "@/server/services/server";
|
||||
|
||||
/**
|
||||
* Prüft Angaben vor dem Speichern: Zertifikat zum Bestätigen holen, optional ein erster Aufruf.
|
||||
* Nur für Admins – der Endpunkt baut Verbindungen zu beliebigen Adressen auf.
|
||||
*/
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "serverVerwalten");
|
||||
return NextResponse.json(await serverPruefen(await jsonLesen(anfrage)));
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { serverAnlegen, serverListe } from "@/server/services/server";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await serverListe());
|
||||
});
|
||||
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
|
||||
const server = await serverAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json(server, { status: 201 });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
|
||||
* 44 px hoch auf dem Handy.
|
||||
*/
|
||||
export const EINGABE_KLASSE =
|
||||
"bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 placeholder:text-muted focus:border-akzent disabled:opacity-60 sm:h-10";
|
||||
|
||||
export function Feld({
|
||||
id,
|
||||
label,
|
||||
hilfe,
|
||||
fehler,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hilfe?: React.ReactNode;
|
||||
fehler?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{fehler ? (
|
||||
<p id={`${id}-fehler`} className="text-fehler text-sm">
|
||||
<span aria-hidden="true">✗ </span>
|
||||
{fehler}
|
||||
</p>
|
||||
) : hilfe ? (
|
||||
<p id={`${id}-hilfe`} className="text-muted text-sm">
|
||||
{hilfe}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verknüpft ein Eingabefeld mit Hilfe- oder Fehlertext. */
|
||||
export function beschreibung(id: string, fehler?: string, hilfe?: unknown): Record<string, string | boolean> {
|
||||
if (fehler) return { "aria-describedby": `${id}-fehler`, "aria-invalid": true };
|
||||
if (hilfe) return { "aria-describedby": `${id}-hilfe` };
|
||||
return {};
|
||||
}
|
||||
|
||||
export function Schalter({
|
||||
id,
|
||||
label,
|
||||
hilfe,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hilfe?: React.ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (wert: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(ereignis) => onChange(ereignis.target.checked)}
|
||||
className="accent-akzent mt-0.5 h-5 w-5 shrink-0"
|
||||
{...(hilfe ? { "aria-describedby": `${id}-hilfe` } : {})}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{hilfe ? (
|
||||
<p id={`${id}-hilfe`} className="text-muted text-sm">
|
||||
{hilfe}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Text in JetBrains Mono mit Knopf zum Kopieren (Befehle, Fingerprints, Schlüssel). */
|
||||
export function Code({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<code
|
||||
className={clsx(
|
||||
"bg-grund border-linie block rounded-lg border px-3 py-2 font-mono text-xs break-all sm:text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
|
||||
/**
|
||||
* Welcher Server gerade im Blick ist. Steht im Cookie, damit Übersicht, Templates und Assistent
|
||||
* ihn serverseitig kennen; ein Wechsel lädt nur die Daten neu.
|
||||
*/
|
||||
export function ServerUmschalter({
|
||||
server,
|
||||
aktiv,
|
||||
}: {
|
||||
server: { name: string; titel: string }[];
|
||||
aktiv: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
if (server.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-2">
|
||||
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
||||
Server
|
||||
</label>
|
||||
<select
|
||||
id="server-umschalter"
|
||||
value={aktiv ?? ""}
|
||||
onChange={(ereignis) => {
|
||||
document.cookie = `${SERVER_COOKIE}=${encodeURIComponent(ereignis.target.value)}; path=/; max-age=31536000; samesite=lax`;
|
||||
router.refresh();
|
||||
}}
|
||||
className="bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 font-mono text-sm sm:h-10"
|
||||
>
|
||||
{server.map((eintrag) => (
|
||||
<option key={eintrag.name} value={eintrag.name}>
|
||||
{eintrag.name} · {eintrag.titel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { pfad, type ProxmoxClient } from "./client";
|
||||
import { ProxmoxFehler } from "./fehler";
|
||||
|
||||
/**
|
||||
* Viele Proxmox-Aufrufe (clone, create, download-url, start) antworten sofort mit einer UPID und
|
||||
* laufen im Hintergrund weiter. Fertig ist erst, wenn der Task `stopped` mit `exitstatus: OK` meldet.
|
||||
*/
|
||||
type TaskStatus = { status: "running" | "stopped"; exitstatus?: string };
|
||||
type LogZeile = { n: number; t: string };
|
||||
|
||||
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
|
||||
export function nodeAusUpid(upid: string): string {
|
||||
const teile = upid.split(":");
|
||||
if (teile[0] !== "UPID" || !teile[1]) {
|
||||
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
|
||||
}
|
||||
return teile[1];
|
||||
}
|
||||
|
||||
export type AbwartenOptionen = {
|
||||
intervallMs?: number;
|
||||
zeitlimitMs?: number;
|
||||
/** Wird mit neuen Log-Zeilen des Tasks aufgerufen (z. B. Download-Fortschritt). */
|
||||
beiLog?: (zeilen: string[]) => void;
|
||||
warten?: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||
|
||||
export async function aufgabeAbwarten(
|
||||
client: ProxmoxClient,
|
||||
upid: string,
|
||||
optionen: AbwartenOptionen = {},
|
||||
): Promise<void> {
|
||||
const node = nodeAusUpid(upid);
|
||||
const intervall = optionen.intervallMs ?? 1000;
|
||||
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
|
||||
const warten = optionen.warten ?? schlafen;
|
||||
const beginn = Date.now();
|
||||
let gelesen = 0;
|
||||
|
||||
const logLesen = async (): Promise<string[]> => {
|
||||
const zeilen = await client.get<LogZeile[]>(pfad`/nodes/${node}/tasks/${upid}/log`, {
|
||||
start: gelesen,
|
||||
limit: 500,
|
||||
});
|
||||
// Proxmox antwortet ohne neue Zeilen mit [{n:…, t:"no content"}].
|
||||
const neu = zeilen.filter((zeile) => zeile.n > gelesen && zeile.t !== "no content");
|
||||
if (neu.length > 0) gelesen = neu[neu.length - 1]!.n;
|
||||
return neu.map((zeile) => zeile.t);
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const status = await client.get<TaskStatus>(pfad`/nodes/${node}/tasks/${upid}/status`);
|
||||
if (optionen.beiLog) {
|
||||
const neu = await logLesen();
|
||||
if (neu.length > 0) optionen.beiLog(neu);
|
||||
}
|
||||
if (status.status === "stopped") {
|
||||
if (status.exitstatus === "OK" || status.exitstatus?.startsWith("WARNINGS")) return;
|
||||
const letzte = optionen.beiLog ? [] : (await logLesen()).slice(-5);
|
||||
throw new ProxmoxFehler(
|
||||
"aufgabe",
|
||||
`Proxmox-Task fehlgeschlagen: ${status.exitstatus ?? "unbekannt"}`,
|
||||
letzte.length > 0
|
||||
? `Letzte Zeilen: ${letzte.join(" | ")}`
|
||||
: "Task-Log im Detail-Log oder in Proxmox prüfen.",
|
||||
{ pfad: upid },
|
||||
);
|
||||
}
|
||||
if (Date.now() - beginn > zeitlimit) {
|
||||
throw new ProxmoxFehler(
|
||||
"zeitueberschreitung",
|
||||
`Proxmox-Task läuft länger als ${Math.round(zeitlimit / 60000)} min.`,
|
||||
"In Proxmox nachsehen, ob er hängt; ProxDeploy bricht ihn nicht selbst ab.",
|
||||
{ pfad: upid },
|
||||
);
|
||||
}
|
||||
await warten(intervall);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Agent, fetch } from "undici";
|
||||
import { TOKEN_ID_MUSTER } from "@/lib/schemas/server";
|
||||
import { antwortFehler, ProxmoxFehler, verbindungsFehler } from "./fehler";
|
||||
import { tlsConnector, type TlsEinstellung } from "./tls";
|
||||
|
||||
/**
|
||||
* Zugang zur Proxmox-VE-API mit einem API-Token. Bewusst schlank: Pfade und Parameter sind die
|
||||
* der Proxmox-API (siehe pve.proxmox.com/pve-docs/api-viewer), damit Dry-Run und Log genau das
|
||||
* zeigen, was Proxmox bekommt.
|
||||
*/
|
||||
export type ProxmoxVerbindung = TlsEinstellung & {
|
||||
url: string;
|
||||
tokenId: string;
|
||||
tokenGeheimnis: string;
|
||||
};
|
||||
|
||||
export type Parameter = Record<string, string | number | boolean | readonly string[] | undefined>;
|
||||
|
||||
export type Methode = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
/** Ein Aufruf, wie er im Dry-Run und im Detail-Log erscheint. */
|
||||
export type Aufruf = { methode: Methode; pfad: string; parameter?: Parameter };
|
||||
|
||||
export type AufrufBeobachter = (
|
||||
aufruf: Aufruf,
|
||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
||||
) => void;
|
||||
|
||||
const STANDARD_ZEITLIMIT_MS = 30_000;
|
||||
|
||||
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||
export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams {
|
||||
const kodiert = new URLSearchParams();
|
||||
for (const [name, wert] of Object.entries(parameter ?? {})) {
|
||||
if (wert === undefined) continue;
|
||||
if (Array.isArray(wert)) {
|
||||
for (const eintrag of wert) kodiert.append(name, eintrag);
|
||||
} else if (typeof wert === "boolean") {
|
||||
kodiert.append(name, wert ? "1" : "0");
|
||||
} else {
|
||||
kodiert.append(name, String(wert));
|
||||
}
|
||||
}
|
||||
return kodiert;
|
||||
}
|
||||
|
||||
/** Pfadteile wie Node-Namen oder Storage-IDs sicher einsetzen: `pfad\`/nodes/${n}\``. */
|
||||
export function pfad(teile: TemplateStringsArray, ...werte: Array<string | number>): string {
|
||||
return teile.reduce(
|
||||
(ergebnis, teil, index) =>
|
||||
ergebnis + teil + (index < werte.length ? encodeURIComponent(String(werte[index])) : ""),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
export class ProxmoxClient {
|
||||
readonly basis: string;
|
||||
private readonly agent: Agent;
|
||||
private readonly kopf: string;
|
||||
|
||||
constructor(
|
||||
private readonly verbindung: ProxmoxVerbindung,
|
||||
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
|
||||
) {
|
||||
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||
throw new ProxmoxFehler(
|
||||
"anmeldung",
|
||||
`„${verbindung.tokenId}“ ist keine gültige Token-ID.`,
|
||||
"Format: benutzer@realm!tokenname, z. B. proxdeploy@pve!deploy",
|
||||
);
|
||||
}
|
||||
this.basis = verbindung.url.replace(/\/+$/, "") + "/api2/json";
|
||||
this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 10_000 });
|
||||
this.kopf = `PVEAPIToken=${verbindung.tokenId}=${verbindung.tokenGeheimnis}`;
|
||||
}
|
||||
|
||||
async get<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "GET", pfad, parameter });
|
||||
}
|
||||
|
||||
async post<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "POST", pfad, parameter });
|
||||
}
|
||||
|
||||
async put<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "PUT", pfad, parameter });
|
||||
}
|
||||
|
||||
async delete<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
|
||||
}
|
||||
|
||||
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
|
||||
const beginn = Date.now();
|
||||
try {
|
||||
const ergebnis = await this.senden<T>(aufruf);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
||||
return ergebnis;
|
||||
} catch (fehler) {
|
||||
const proxmoxFehler =
|
||||
fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler });
|
||||
throw proxmoxFehler;
|
||||
}
|
||||
}
|
||||
|
||||
private async senden<T>(aufruf: Aufruf): Promise<T> {
|
||||
const kodiert = parameterKodieren(aufruf.parameter);
|
||||
const mitRumpf = aufruf.methode === "POST" || aufruf.methode === "PUT";
|
||||
const ziel = this.basis + aufruf.pfad + (!mitRumpf && kodiert.size > 0 ? `?${kodiert}` : "");
|
||||
|
||||
const antwort = await fetch(ziel, {
|
||||
method: aufruf.methode,
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
authorization: this.kopf,
|
||||
accept: "application/json",
|
||||
...(mitRumpf ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
||||
},
|
||||
body: mitRumpf ? kodiert.toString() : undefined,
|
||||
signal: AbortSignal.timeout(this.optionen.zeitlimitMs ?? STANDARD_ZEITLIMIT_MS),
|
||||
});
|
||||
|
||||
const text = await antwort.text();
|
||||
let koerper: unknown = null;
|
||||
try {
|
||||
koerper = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// Manche Fehlerantworten von pveproxy sind kein JSON; dann zählt die Statuszeile.
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
throw antwortFehler(antwort.status, antwort.statusText, koerper, aufruf.pfad);
|
||||
}
|
||||
return (koerper as { data: T } | null)?.data as T;
|
||||
}
|
||||
|
||||
async schliessen(): Promise<void> {
|
||||
await this.agent.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Fehler aus dem Gespräch mit Proxmox – mit Ursache und Lösungshinweis, so wie sie in Web und
|
||||
* CLI unter einer fehlgeschlagenen Phase stehen. Die Art bestimmt den Hinweis; der Text von
|
||||
* Proxmox selbst bleibt in `ursache` erhalten, weil er oft das Entscheidende enthält.
|
||||
*/
|
||||
export type ProxmoxFehlerArt =
|
||||
| "verbindung"
|
||||
| "tls"
|
||||
| "fingerabdruck"
|
||||
| "anmeldung"
|
||||
| "recht"
|
||||
| "nicht_gefunden"
|
||||
| "parameter"
|
||||
| "aufgabe"
|
||||
| "zeitueberschreitung"
|
||||
| "api";
|
||||
|
||||
export class ProxmoxFehler extends Error {
|
||||
constructor(
|
||||
readonly art: ProxmoxFehlerArt,
|
||||
readonly ursache: string,
|
||||
readonly hinweis: string,
|
||||
readonly details: { pfad?: string; status?: number; parameter?: Record<string, string> } = {},
|
||||
) {
|
||||
super(ursache);
|
||||
this.name = "ProxmoxFehler";
|
||||
}
|
||||
}
|
||||
|
||||
const TLS_CODES_SELBST_SIGNIERT = new Set([
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
]);
|
||||
|
||||
/** Übersetzt einen Netzwerk- oder TLS-Fehler von undici in einen ProxmoxFehler. */
|
||||
export function verbindungsFehler(fehler: unknown, url: string): ProxmoxFehler {
|
||||
if (fehler instanceof ProxmoxFehler) return fehler;
|
||||
// undici verpackt die eigentliche Ursache in `cause`, manchmal zweifach.
|
||||
let ursache: unknown = fehler;
|
||||
for (let i = 0; i < 3 && ursache instanceof Error && ursache.cause; i += 1) {
|
||||
if (ursache.cause instanceof ProxmoxFehler) return ursache.cause;
|
||||
ursache = ursache.cause;
|
||||
}
|
||||
const code = (ursache as { code?: string } | undefined)?.code ?? "";
|
||||
const text = ursache instanceof Error ? ursache.message : String(ursache);
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
if (TLS_CODES_SELBST_SIGNIERT.has(code)) {
|
||||
return new ProxmoxFehler(
|
||||
"tls",
|
||||
`Das Zertifikat von ${host} ist nicht vertrauenswürdig (${code}).`,
|
||||
"Bei einem selbst signierten Proxmox-Zertifikat „Fingerprint holen“ und den Fingerprint bestätigen.",
|
||||
);
|
||||
}
|
||||
if (code === "ERR_TLS_CERT_ALTNAME_INVALID") {
|
||||
return new ProxmoxFehler(
|
||||
"tls",
|
||||
`Das Zertifikat passt nicht zum Namen ${host}.`,
|
||||
"Die URL mit dem Namen aus dem Zertifikat angeben oder den Fingerprint pinnen.",
|
||||
);
|
||||
}
|
||||
if (code === "CERT_HAS_EXPIRED") {
|
||||
return new ProxmoxFehler(
|
||||
"tls",
|
||||
`Das Zertifikat von ${host} ist abgelaufen.`,
|
||||
"Zertifikat auf dem Node erneuern.",
|
||||
);
|
||||
}
|
||||
if (code === "ECONNREFUSED") {
|
||||
return new ProxmoxFehler(
|
||||
"verbindung",
|
||||
`${host} lehnt die Verbindung ab.`,
|
||||
"Läuft pveproxy? Stimmt der Port (Standard 8006)?",
|
||||
);
|
||||
}
|
||||
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
||||
return new ProxmoxFehler(
|
||||
"verbindung",
|
||||
`Der Name ${host} lässt sich nicht auflösen.`,
|
||||
"Hostnamen oder DNS prüfen.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "UND_ERR_CONNECT_TIMEOUT" ||
|
||||
code === "EHOSTUNREACH" ||
|
||||
code === "ENETUNREACH" ||
|
||||
(ursache instanceof Error && ursache.name === "TimeoutError")
|
||||
) {
|
||||
return new ProxmoxFehler(
|
||||
"zeitueberschreitung",
|
||||
`${host} antwortet nicht.`,
|
||||
"Ist der Host aus dem Netz von ProxDeploy erreichbar? Firewall und Routing prüfen.",
|
||||
);
|
||||
}
|
||||
return new ProxmoxFehler(
|
||||
"verbindung",
|
||||
`Verbindung zu ${host} fehlgeschlagen: ${text}`,
|
||||
"Adresse und Netzwerk prüfen.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxmox meldet fehlende Rechte als „Permission check failed (/vms/120, VM.Allocate)“. Daraus
|
||||
* wird ein Hinweis, der Pfad und Recht nennt – das spart den Blick in die Doku.
|
||||
*/
|
||||
export function rechtHinweis(meldung: string): string {
|
||||
const treffer = /Permission check failed \(([^,]+), ([^)]+)\)/.exec(meldung);
|
||||
if (treffer) {
|
||||
return (
|
||||
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
|
||||
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)."
|
||||
);
|
||||
}
|
||||
return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte).";
|
||||
}
|
||||
|
||||
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
|
||||
export function antwortFehler(
|
||||
status: number,
|
||||
statusText: string,
|
||||
koerper: unknown,
|
||||
pfad: string,
|
||||
): ProxmoxFehler {
|
||||
const parameter =
|
||||
koerper &&
|
||||
typeof koerper === "object" &&
|
||||
"errors" in koerper &&
|
||||
koerper.errors &&
|
||||
typeof koerper.errors === "object"
|
||||
? (koerper.errors as Record<string, string>)
|
||||
: undefined;
|
||||
// Proxmox schreibt die eigentliche Meldung in die Statuszeile, nicht in den Rumpf.
|
||||
const meldung = (statusText || "").trim() || `HTTP ${status}`;
|
||||
|
||||
if (status === 401) {
|
||||
return new ProxmoxFehler(
|
||||
"anmeldung",
|
||||
"Proxmox lehnt das API-Token ab.",
|
||||
"Token-ID (user@realm!tokenid) und Secret prüfen; ist das Token abgelaufen oder gelöscht?",
|
||||
{ pfad, status },
|
||||
);
|
||||
}
|
||||
if (status === 403) {
|
||||
return new ProxmoxFehler("recht", meldung, rechtHinweis(meldung), { pfad, status });
|
||||
}
|
||||
if (status === 400 && parameter) {
|
||||
const liste = Object.entries(parameter)
|
||||
.map(([name, text]) => `${name}: ${String(text).trim()}`)
|
||||
.join("; ");
|
||||
return new ProxmoxFehler("parameter", `Proxmox lehnt Parameter ab – ${liste}`, "Eingaben prüfen.", {
|
||||
pfad,
|
||||
status,
|
||||
parameter,
|
||||
});
|
||||
}
|
||||
if (status === 404 || /does not exist|not found/i.test(meldung)) {
|
||||
return new ProxmoxFehler("nicht_gefunden", meldung, "Gibt es Node, VM oder Storage noch?", {
|
||||
pfad,
|
||||
status,
|
||||
});
|
||||
}
|
||||
if (status === 501) {
|
||||
return new ProxmoxFehler(
|
||||
"nicht_gefunden",
|
||||
`Proxmox kennt ${pfad} nicht (${meldung}).`,
|
||||
"Die PVE-Version unterstützt diesen Aufruf nicht; ProxDeploy weicht dann auf SSH aus, wenn eingerichtet.",
|
||||
{ pfad, status },
|
||||
);
|
||||
}
|
||||
return new ProxmoxFehler("api", meldung, "Meldung von Proxmox prüfen; Details stehen im Log.", {
|
||||
pfad,
|
||||
status,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { connect, type PeerCertificate } from "node:tls";
|
||||
import { buildConnector } from "undici";
|
||||
import { ProxmoxFehler, verbindungsFehler } from "./fehler";
|
||||
|
||||
/**
|
||||
* TLS zu Proxmox. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; dann zählt nur der
|
||||
* gepinnte SHA-256-Fingerprint, den ein Admin beim Anlegen bestätigt hat (TOFU **mit**
|
||||
* Bestätigung). Ein globales `NODE_TLS_REJECT_UNAUTHORIZED=0` gibt es nicht.
|
||||
*/
|
||||
|
||||
/** „ab:cd:…“, „ABCD…“ oder „SHA256:…“-ähnliche Eingaben → „AB:CD:…“. */
|
||||
export function fingerabdruckNormalisieren(eingabe: string): string | null {
|
||||
const hex = eingabe
|
||||
.trim()
|
||||
.replace(/^sha-?256[:= ]*/i, "")
|
||||
.replace(/[^0-9a-f]/gi, "")
|
||||
.toUpperCase();
|
||||
if (hex.length !== 64) return null;
|
||||
return hex.match(/.{2}/g)!.join(":");
|
||||
}
|
||||
|
||||
export type ZertifikatInfo = {
|
||||
fingerabdruck: string;
|
||||
subjekt: string;
|
||||
aussteller: string;
|
||||
gueltigBis: string;
|
||||
alternativeNamen: string[];
|
||||
/** Ob das System dem Zertifikat ohnehin vertraut (z. B. ACME/Let's Encrypt). */
|
||||
vertraut: boolean;
|
||||
fehlerGrund: string | null;
|
||||
};
|
||||
|
||||
function namen(teil: PeerCertificate["subject"] | undefined): string {
|
||||
if (!teil) return "";
|
||||
return Object.entries(teil)
|
||||
.map(([schluessel, wert]) => `${schluessel}=${Array.isArray(wert) ? wert.join("+") : wert}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** Holt das Zertifikat ohne es zu prüfen – nur zum Anzeigen und Bestätigen. */
|
||||
export function zertifikatHolen(url: string, zeitlimitMs = 10_000): Promise<ZertifikatInfo> {
|
||||
const ziel = new URL(url);
|
||||
const port = Number(ziel.port || 443);
|
||||
return new Promise((erfuellen, ablehnen) => {
|
||||
const socket = connect({
|
||||
host: ziel.hostname,
|
||||
port,
|
||||
servername: /^[\d.]+$|:/.test(ziel.hostname) ? undefined : ziel.hostname,
|
||||
rejectUnauthorized: false,
|
||||
timeout: zeitlimitMs,
|
||||
});
|
||||
socket.once("secureConnect", () => {
|
||||
const zertifikat = socket.getPeerCertificate();
|
||||
socket.end();
|
||||
if (!zertifikat?.fingerprint256) {
|
||||
ablehnen(
|
||||
new ProxmoxFehler(
|
||||
"tls",
|
||||
"Der Server hat kein Zertifikat geschickt.",
|
||||
"Ist das wirklich Proxmox auf HTTPS?",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
erfuellen({
|
||||
fingerabdruck: fingerabdruckNormalisieren(zertifikat.fingerprint256)!,
|
||||
subjekt: namen(zertifikat.subject),
|
||||
aussteller: namen(zertifikat.issuer),
|
||||
gueltigBis: new Date(zertifikat.valid_to).toISOString(),
|
||||
alternativeNamen: (zertifikat.subjectaltname ?? "")
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean),
|
||||
vertraut: socket.authorized,
|
||||
fehlerGrund: socket.authorizationError ? String(socket.authorizationError) : null,
|
||||
});
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
ablehnen(verbindungsFehler(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), url));
|
||||
});
|
||||
socket.once("error", (fehler) => ablehnen(verbindungsFehler(fehler, url)));
|
||||
});
|
||||
}
|
||||
|
||||
export type TlsEinstellung = { tlsPruefen: boolean; fingerabdruck: string | null };
|
||||
|
||||
/**
|
||||
* Connector für undici.
|
||||
* - `tlsPruefen` ohne Pin: normale Prüfung gegen die Systemzertifikate.
|
||||
* - Pin gesetzt: die Kette wird nicht geprüft (selbst signiert), aber der Fingerprint muss
|
||||
* **exakt** stimmen – sonst wird die Verbindung vor dem ersten Byte getrennt.
|
||||
* - `tlsPruefen` **und** Pin: beides muss stimmen.
|
||||
*/
|
||||
export function tlsConnector(einstellung: TlsEinstellung): buildConnector.connector {
|
||||
const pin = einstellung.fingerabdruck ? fingerabdruckNormalisieren(einstellung.fingerabdruck) : null;
|
||||
if (!pin && !einstellung.tlsPruefen) {
|
||||
// Die Datenbank verbietet diesen Zustand (server_tls_oder_pin); hier zur Sicherheit noch einmal.
|
||||
throw new ProxmoxFehler(
|
||||
"tls",
|
||||
"Weder TLS-Prüfung noch Fingerprint – die Verbindung wäre blind.",
|
||||
"Fingerprint holen und bestätigen oder TLS-Prüfung einschalten.",
|
||||
);
|
||||
}
|
||||
// Ohne Sitzungs-Cache: eine wiederaufgenommene TLS-Sitzung schickt kein Zertifikat mit,
|
||||
// und der Pin ließe sich dann nicht prüfen.
|
||||
const basis = buildConnector({ rejectUnauthorized: einstellung.tlsPruefen, maxCachedSessions: 0 });
|
||||
if (!pin) return basis;
|
||||
|
||||
return (optionen, rueckruf) => {
|
||||
basis(optionen, (fehler, socket) => {
|
||||
if (fehler || !socket) {
|
||||
rueckruf(fehler ?? new Error("Keine Verbindung"), null);
|
||||
return;
|
||||
}
|
||||
const zertifikat = "getPeerCertificate" in socket ? socket.getPeerCertificate() : undefined;
|
||||
const gesehen = zertifikat?.fingerprint256
|
||||
? fingerabdruckNormalisieren(zertifikat.fingerprint256)
|
||||
: null;
|
||||
if (gesehen !== pin) {
|
||||
socket.destroy();
|
||||
rueckruf(
|
||||
new ProxmoxFehler(
|
||||
"fingerabdruck",
|
||||
`Der Fingerprint des Zertifikats hat sich geändert (erwartet ${pin.slice(0, 11)}…, gesehen ${gesehen?.slice(0, 11) ?? "keiner"}…).`,
|
||||
"Wurde das Zertifikat erneuert? Dann in den Server-Einstellungen neu holen und bestätigen. Sonst nicht verbinden – jemand könnte dazwischen sitzen.",
|
||||
),
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
rueckruf(null, socket);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Kleiner Helfer für Client-Komponenten, die die REST-API rufen. Wirft `ApiFehler` mit
|
||||
* Nachricht, Hinweis und Feldfehlern im Format der API.
|
||||
*/
|
||||
export class ApiFehler extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
nachricht: string,
|
||||
readonly hinweis?: string,
|
||||
readonly felder: Record<string, string> = {},
|
||||
) {
|
||||
super(nachricht);
|
||||
this.name = "ApiFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAufruf<T>(methode: string, pfad: string, rumpf?: unknown): Promise<T> {
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(pfad, {
|
||||
method: methode,
|
||||
headers: rumpf !== undefined ? { "content-type": "application/json" } : undefined,
|
||||
body: rumpf !== undefined ? JSON.stringify(rumpf) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiFehler(0, "netz", "ProxDeploy ist nicht erreichbar.");
|
||||
}
|
||||
if (antwort.status === 204) return undefined as T;
|
||||
const inhalt = (await antwort.json().catch(() => null)) as
|
||||
| (T & {
|
||||
fehler?: { code: string; nachricht: string; hinweis?: string; felder?: Record<string, string> };
|
||||
})
|
||||
| null;
|
||||
if (!antwort.ok) {
|
||||
const fehler = inhalt?.fehler;
|
||||
throw new ApiFehler(
|
||||
antwort.status,
|
||||
fehler?.code ?? "unbekannt",
|
||||
fehler?.nachricht ?? `Fehler ${antwort.status}`,
|
||||
fehler?.hinweis,
|
||||
fehler?.felder,
|
||||
);
|
||||
}
|
||||
return inhalt as T;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen", ab: "#002" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||
{
|
||||
pfad: "/einstellungen",
|
||||
titel: "Einstellungen",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Eingaben für einen Proxmox-Server. Liegt in `src/lib`, weil Formular (Client) und API (Server)
|
||||
* dieselben Regeln brauchen.
|
||||
*/
|
||||
export const SERVER_NAME_MUSTER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
export const TOKEN_ID_MUSTER = /^[^\s@!]+@[^\s@!]+![A-Za-z][A-Za-z0-9._-]*$/;
|
||||
|
||||
export const ServerUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Adresse fehlt.")
|
||||
.transform((wert, kontext) => {
|
||||
// „pve01:8006“ oder „pve01“ ohne Schema sind üblich – dann https und 8006 ergänzen.
|
||||
const mitSchema = /^[a-z]+:\/\//i.test(wert) ? wert : `https://${wert}`;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(mitSchema);
|
||||
} catch {
|
||||
kontext.addIssue({ code: "custom", message: "Keine gültige Adresse." });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
kontext.addIssue({ code: "custom", message: "Proxmox spricht nur HTTPS." });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (!url.port) url.port = "8006";
|
||||
return `${url.protocol}//${url.host}`;
|
||||
});
|
||||
|
||||
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
|
||||
|
||||
const Basis = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(SERVER_NAME_MUSTER, "Nur Kleinbuchstaben, Ziffern und Bindestrich, höchstens 32 Zeichen."),
|
||||
titel: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
|
||||
url: ServerUrl,
|
||||
tokenId: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(TOKEN_ID_MUSTER, "Format: benutzer@realm!tokenname, z. B. proxdeploy@pve!deploy"),
|
||||
tlsPruefen: z.boolean().default(false),
|
||||
fingerabdruck: z.string().trim().nullable().default(null),
|
||||
sshAktiv: z.boolean().default(false),
|
||||
sshBenutzer: z.string().trim().min(1).max(32).default("root"),
|
||||
sshPort: z.coerce.number().int().min(1).max(65535).default(22),
|
||||
templateVmidVon: Vmid.default(9000),
|
||||
templateVmidBis: Vmid.default(9999),
|
||||
gastVmidVon: Vmid.default(100),
|
||||
});
|
||||
|
||||
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
|
||||
return wert.templateVmidVon < wert.templateVmidBis;
|
||||
}
|
||||
|
||||
function pinOderTls<T extends { tlsPruefen: boolean; fingerabdruck: string | null }>(wert: T) {
|
||||
return wert.tlsPruefen || Boolean(wert.fingerabdruck);
|
||||
}
|
||||
|
||||
const BEREICH_MELDUNG = {
|
||||
message: "Der Template-Bereich muss mit der kleineren Zahl beginnen.",
|
||||
path: ["templateVmidBis"],
|
||||
};
|
||||
const PIN_MELDUNG = {
|
||||
message: "Ohne TLS-Prüfung muss der Fingerprint bestätigt sein.",
|
||||
path: ["fingerabdruck"],
|
||||
};
|
||||
|
||||
export const ServerAnlegen = Basis.extend({
|
||||
tokenGeheimnis: z.string().trim().min(1, "Secret fehlt."),
|
||||
})
|
||||
.refine(bereicheGueltig, BEREICH_MELDUNG)
|
||||
.refine(pinOderTls, PIN_MELDUNG);
|
||||
|
||||
/** Beim Bearbeiten bleibt ein leeres Secret das alte – es wird nie zurück an den Browser geschickt. */
|
||||
export const ServerAendern = Basis.extend({
|
||||
tokenGeheimnis: z.string().trim().optional(),
|
||||
})
|
||||
.refine(bereicheGueltig, BEREICH_MELDUNG)
|
||||
.refine(pinOderTls, PIN_MELDUNG);
|
||||
|
||||
export type ServerAnlegenEingabe = z.input<typeof ServerAnlegen>;
|
||||
|
||||
/** Prüfen vor dem Speichern: nur Adresse ist Pflicht, der Rest wird genutzt, wenn vorhanden. */
|
||||
export const ServerPruefen = z.object({
|
||||
url: ServerUrl,
|
||||
/** Bei bestehendem Server: Secret aus der Datenbank nehmen, wenn das Feld leer bleibt. */
|
||||
name: z.string().trim().optional(),
|
||||
tokenId: z.string().trim().optional(),
|
||||
tokenGeheimnis: z.string().trim().optional(),
|
||||
tlsPruefen: z.boolean().default(false),
|
||||
fingerabdruck: z.string().trim().nullable().default(null),
|
||||
});
|
||||
|
||||
/** Was die API über einen Server herausgibt – nie Secret oder privaten Schlüssel. */
|
||||
export type ServerSicht = {
|
||||
name: string;
|
||||
titel: string;
|
||||
url: string;
|
||||
tokenId: string;
|
||||
tlsPruefen: boolean;
|
||||
fingerabdruck: string | null;
|
||||
sshAktiv: boolean;
|
||||
sshBenutzer: string;
|
||||
sshPort: number;
|
||||
sshOeffentlich: string | null;
|
||||
templateVmidVon: number;
|
||||
templateVmidBis: number;
|
||||
gastVmidVon: number;
|
||||
erreichbar: boolean | null;
|
||||
pveVersion: string | null;
|
||||
geprueftAm: string | null;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const SERVER_COOKIE = "pd_server";
|
||||
|
||||
/** Der gewählte Server, sonst der erste – ein gelöschter Server im Cookie fällt still zurück. */
|
||||
export function aktiverServer(namen: string[], cookie: string | undefined): string | null {
|
||||
if (cookie && namen.includes(cookie)) return cookie;
|
||||
return namen[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { utils as sshUtils } from "ssh2";
|
||||
import type { Server } from "@/generated/prisma/client";
|
||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { ServerAendern, ServerAnlegen, ServerPruefen, type ServerSicht } from "@/lib/schemas/server";
|
||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
export function serverSicht(server: Server): ServerSicht {
|
||||
return {
|
||||
name: server.name,
|
||||
titel: server.titel,
|
||||
url: server.url,
|
||||
tokenId: server.tokenId,
|
||||
tlsPruefen: server.tlsPruefen,
|
||||
fingerabdruck: server.fingerabdruck,
|
||||
sshAktiv: server.sshAktiv,
|
||||
sshBenutzer: server.sshBenutzer,
|
||||
sshPort: server.sshPort,
|
||||
sshOeffentlich: server.sshOeffentlich,
|
||||
templateVmidVon: server.templateVmidVon,
|
||||
templateVmidBis: server.templateVmidBis,
|
||||
gastVmidVon: server.gastVmidVon,
|
||||
erreichbar: server.erreichbar,
|
||||
pveVersion: server.pveVersion,
|
||||
geprueftAm: server.geprueftAm?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Entschlüsselte Verbindungsdaten – nur für die Engine, nie in eine Antwort. */
|
||||
export function verbindungAus(server: Server): ProxmoxVerbindung {
|
||||
return {
|
||||
url: server.url,
|
||||
tokenId: server.tokenId,
|
||||
tokenGeheimnis: entschluesseln(server.tokenGeheimnis),
|
||||
tlsPruefen: server.tlsPruefen,
|
||||
fingerabdruck: server.fingerabdruck,
|
||||
};
|
||||
}
|
||||
|
||||
function fingerabdruckOderFehler(eingabe: string | null): string | null {
|
||||
if (!eingabe) return null;
|
||||
const normal = fingerabdruckNormalisieren(eingabe);
|
||||
if (!normal) {
|
||||
throw new AnwendungsFehler(400, "ungueltig", "Der Fingerprint ist kein SHA-256-Wert (64 Hex-Zeichen).");
|
||||
}
|
||||
return normal;
|
||||
}
|
||||
|
||||
export async function serverListe(): Promise<ServerSicht[]> {
|
||||
return (await prisma.server.findMany({ orderBy: { name: "asc" } })).map(serverSicht);
|
||||
}
|
||||
|
||||
export async function serverHolen(name: string): Promise<Server> {
|
||||
const server = await prisma.server.findUnique({ where: { name } });
|
||||
if (!server) throw nichtGefunden(`Den Server „${name}“`);
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function serverAnlegen(
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<ServerSicht> {
|
||||
const eingabe = ServerAnlegen.parse(roh);
|
||||
if (await prisma.server.findUnique({ where: { name: eingabe.name } })) {
|
||||
throw new AnwendungsFehler(409, "vergeben", `Den Namen „${eingabe.name}“ gibt es schon.`);
|
||||
}
|
||||
const server = await prisma.$transaction(async (tx) => {
|
||||
const angelegt = await tx.server.create({
|
||||
data: {
|
||||
...eingabe,
|
||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
|
||||
},
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Server angelegt",
|
||||
ziel: `server:${angelegt.name}`,
|
||||
details: { url: angelegt.url, tokenId: angelegt.tokenId },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return angelegt;
|
||||
});
|
||||
return serverSicht(server);
|
||||
}
|
||||
|
||||
export async function serverAendern(
|
||||
name: string,
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<ServerSicht> {
|
||||
const bisher = await serverHolen(name);
|
||||
const eingabe = ServerAendern.parse(roh);
|
||||
if (eingabe.name !== bisher.name && (await prisma.server.findUnique({ where: { name: eingabe.name } }))) {
|
||||
throw new AnwendungsFehler(409, "vergeben", `Den Namen „${eingabe.name}“ gibt es schon.`);
|
||||
}
|
||||
const { tokenGeheimnis, ...rest } = eingabe;
|
||||
const daten = {
|
||||
...rest,
|
||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
|
||||
};
|
||||
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
|
||||
const geaendert = Object.entries(daten)
|
||||
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== wert)
|
||||
.map(([feld]) => feld);
|
||||
|
||||
const server = await prisma.$transaction(async (tx) => {
|
||||
const neu = await tx.server.update({ where: { id: bisher.id }, data: daten });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Server geändert",
|
||||
ziel: `server:${neu.name}`,
|
||||
details: { felder: geaendert },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return neu;
|
||||
});
|
||||
return serverSicht(server);
|
||||
}
|
||||
|
||||
export async function serverLoeschen(name: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
|
||||
const server = await serverHolen(name);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.server.delete({ where: { id: server.id } });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Server gelöscht",
|
||||
ziel: `server:${server.name}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt (oder erneuert) das SSH-Schlüsselpaar, mit dem ProxDeploy sich optional auf den Nodes
|
||||
* anmeldet. ed25519 im OpenSSH-Format; der private Schlüssel liegt nur verschlüsselt vor.
|
||||
*/
|
||||
export async function sshSchluesselErzeugen(
|
||||
name: string,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<ServerSicht> {
|
||||
const server = await serverHolen(name);
|
||||
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||
const neu = await prisma.$transaction(async (tx) => {
|
||||
const aktualisiert = await tx.server.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
sshPrivat: verschluesseln(paar.private),
|
||||
sshOeffentlich: paar.public.trim(),
|
||||
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
||||
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
||||
},
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: server.sshOeffentlich ? "SSH-Schlüssel erneuert" : "SSH-Schlüssel erzeugt",
|
||||
ziel: `server:${server.name}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return aktualisiert;
|
||||
});
|
||||
return serverSicht(neu);
|
||||
}
|
||||
|
||||
export type PruefErgebnis = {
|
||||
zertifikat: ZertifikatInfo | null;
|
||||
/** Ob der angegebene Pin zum gesehenen Zertifikat passt (null: kein Pin angegeben). */
|
||||
pinPasst: boolean | null;
|
||||
verbindung:
|
||||
| { ok: true; version: string; release: string }
|
||||
| { ok: false; art: string; ursache: string; hinweis: string }
|
||||
| null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prüft vor dem Speichern: Zertifikat holen (zum Bestätigen) und – wenn Token-Angaben da sind –
|
||||
* einen ersten Aufruf von `/version`. Die vollständige Fähigkeitserkennung folgt in #003.
|
||||
*/
|
||||
export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
|
||||
const eingabe = ServerPruefen.parse(roh);
|
||||
let zertifikat: ZertifikatInfo | null = null;
|
||||
try {
|
||||
zertifikat = await zertifikatHolen(eingabe.url);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ProxmoxFehler) {
|
||||
return {
|
||||
zertifikat: null,
|
||||
pinPasst: null,
|
||||
verbindung: { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis },
|
||||
};
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const pin = eingabe.fingerabdruck ? fingerabdruckNormalisieren(eingabe.fingerabdruck) : null;
|
||||
const pinPasst = pin ? pin === zertifikat.fingerabdruck : null;
|
||||
|
||||
let geheimnis = eingabe.tokenGeheimnis;
|
||||
if (!geheimnis && eingabe.name) {
|
||||
const bestehend = await prisma.server.findUnique({ where: { name: eingabe.name } });
|
||||
if (bestehend) geheimnis = entschluesseln(bestehend.tokenGeheimnis);
|
||||
}
|
||||
if (!eingabe.tokenId || !geheimnis || (!eingabe.tlsPruefen && !pin)) {
|
||||
return { zertifikat, pinPasst, verbindung: null };
|
||||
}
|
||||
|
||||
const client = new ProxmoxClient(
|
||||
{
|
||||
url: eingabe.url,
|
||||
tokenId: eingabe.tokenId,
|
||||
tokenGeheimnis: geheimnis,
|
||||
tlsPruefen: eingabe.tlsPruefen,
|
||||
fingerabdruck: pin,
|
||||
},
|
||||
{ zeitlimitMs: 10_000 },
|
||||
);
|
||||
try {
|
||||
const version = await client.get<{ version: string; release: string }>("/version");
|
||||
return {
|
||||
zertifikat,
|
||||
pinPasst,
|
||||
verbindung: { ok: true, version: version.version, release: version.release },
|
||||
};
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ProxmoxFehler) {
|
||||
return {
|
||||
zertifikat,
|
||||
pinPasst,
|
||||
verbindung: { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis },
|
||||
};
|
||||
}
|
||||
throw fehler;
|
||||
} finally {
|
||||
await client.schliessen();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user