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(wert: T) { return wert.templateVmidVon < wert.templateVmidBis; } function pinOderTls(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; /** 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; }; 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."), });