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:
2026-10-12 09:35:10 +02:00
co-authored by Claude Opus 5.5
parent 871bb3a77c
commit 48e2a5cb8f
43 changed files with 2847 additions and 39 deletions
+46
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+117
View File
@@ -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;
};
+7
View File
@@ -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;
}