Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH. Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt werden kann nur ein Schlüssel, den der letzte Test gesehen hat. Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im Web-Subset von JetBrains Mono. Refs #003 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||
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;
|
||
letzterTest: VerbindungsBericht | null;
|
||
/** Gepinnte SSH-Host-Keys je Node – öffentlich, kein Geheimnis. */
|
||
sshHostSchluessel: Record<string, string>;
|
||
};
|
||
|
||
export const HostSchluesselBestaetigen = z.object({
|
||
knoten: z.string().trim().min(1),
|
||
hostSchluessel: z
|
||
.string()
|
||
.trim()
|
||
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Kein SHA256-Fingerprint eines Host-Keys."),
|
||
});
|