import { z } from "@/lib/validierung"; /** * Die Anfrage „Ausrollen“ – eine Form für Web-Assistent, CLI (`proxdeploy vm …`), Batch und * Vorlagen. Sie liegt in `src/lib`, weil Formular (Client) und API (Server) dieselben Regeln * brauchen. Felder sind deutsch; die CLI übersetzt ihre Flags darauf (`--memory` → `speicherMb`). */ /** Hostname nach RFC 1123, ein Label: so heißt der Gast in Proxmox und im Netz. */ export const GAST_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; const Ipv4Cidr = z .string() .trim() .regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.20.15/24") .refine((wert) => { const [ip, praefix] = wert.split("/"); return ( ip!.split(".").every((teil) => Number(teil) <= 255) && Number(praefix) >= 1 && Number(praefix) <= 32 ); }, "Ungültige Adresse oder Präfixlänge."); const Ipv4 = z .string() .trim() .regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.20.1") .refine((ip) => ip.split(".").every((teil) => Number(teil) <= 255), "Ungültige Adresse."); export const SSH_SCHLUESSEL = /^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( [^\r\n]*)?$/; export const Netz = z .object({ bridge: z .string() .trim() .min(1, "Bridge fehlt.") .regex(/^[A-Za-z0-9_.-]+$/, "Ungültiger Bridge-Name."), vlan: z.coerce.number().int().min(1).max(4094).optional(), modus: z.enum(["dhcp", "statisch"]).default("dhcp"), ip: Ipv4Cidr.optional(), gateway: Ipv4.optional(), dns: z.array(Ipv4).max(3).default([]), suchdomain: z .string() .trim() .regex(/^[a-z0-9.-]*$/i, "Ungültige Suchdomain.") .optional(), }) .refine((n) => n.modus === "dhcp" || Boolean(n.ip), { message: "Bei statischer Adresse fehlt die IP.", path: ["ip"], }); export const Zugang = z.object({ benutzer: z .string() .trim() .regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -") .refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."), /** Leer = die hinterlegten Schlüssel des Benutzers und die Team-Schlüssel (beim Anlegen ergänzt). */ sshSchluessel: z .array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel.")) .min( 1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht. In den Einstellungen hinterlegen oder hier angeben.", ), }); export const AusrollAnfrage = z .object({ typ: z.enum(["vm", "lxc"]), server: z.string().trim().min(1, "Server fehlt."), /** Leer = ProxDeploy wählt: Node mit dem Template, sonst der mit dem meisten freien RAM. */ knoten: z.string().trim().optional(), distribution: z.string().trim().min(1, "Distribution fehlt."), name: z .string() .trim() .toLowerCase() .regex(GAST_NAME, "Name: Kleinbuchstaben, Ziffern und Bindestrich (Hostname)."), /** Leer = nächste freie ID. */ vmid: z.coerce.number().int().min(100).max(999_999_999).optional(), tags: z .array( z .string() .trim() .regex(/^[a-z0-9_+.-]+$/i, "Tags: Buchstaben, Ziffern, _ + . -"), ) .max(10) .default([]), cores: z.coerce.number().int().min(1).max(128).default(2), speicherMb: z.coerce.number().int().min(256).max(1_048_576).default(2048), diskGb: z.coerce.number().int().min(1).max(65_536).default(20), storage: z.string().trim().min(1, "Storage fehlt."), /** LXC */ swapMb: z.coerce.number().int().min(0).max(1_048_576).default(512), unprivilegiert: z.boolean().default(true), features: z .object({ nesting: z.boolean().default(false), keyctl: z.boolean().default(false) }) .default({ nesting: false, keyctl: false }), netz: Netz, zugang: Zugang, profile: z.array(z.string()).default(["base"]), variablen: z .record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))) .default({}), /** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */ templateBauen: z.boolean().default(false), startBeiBoot: z.boolean().default(true), /** Name der Vorlage, die angewendet wurde (nur zur Nachverfolgung; angewendet wird vorher). */ vorlage: z.string().optional(), }) .refine((a) => a.typ === "lxc" || a.diskGb >= 4, { message: "Mindestens 4 GB für eine VM.", path: ["diskGb"], }); export type AusrollAnfrage = z.infer; export type AusrollEingabe = z.input; /** * Der passende CLI-Befehl zu einer Anfrage – im Assistenten zum Kopieren angezeigt. Nur Flags, die * vom Standard abweichen, damit der Befehl lesbar bleibt. */ export function cliBefehl(a: AusrollAnfrage): string { const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`]; if (a.vorlage) teile.push(`--preset ${a.vorlage}`); if (a.knoten) teile.push(`--node ${a.knoten}`); teile.push(`--distro ${a.distribution}`); if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base")) teile.push(`--profile ${a.profile.join(",")}`); if (a.netz.modus === "statisch" && a.netz.ip) { teile.push(`--ip ${a.netz.ip}`); if (a.netz.gateway) teile.push(`--gw ${a.netz.gateway}`); } if (a.netz.dns.length > 0) teile.push(`--dns ${a.netz.dns.join(",")}`); if (a.netz.suchdomain) teile.push(`--search-domain ${a.netz.suchdomain}`); if (a.netz.bridge !== "vmbr0") teile.push(`--bridge ${a.netz.bridge}`); if (a.netz.vlan) teile.push(`--vlan ${a.netz.vlan}`); if (a.vmid) teile.push(`--vmid ${a.vmid}`); if (a.cores !== 2) teile.push(`--cores ${a.cores}`); if (a.speicherMb !== 2048) teile.push(`--memory ${a.speicherMb}`); if (a.diskGb !== 20) teile.push(`--disk ${a.diskGb}G`); teile.push(`--storage ${a.storage}`); teile.push(`--user ${a.zugang.benutzer}`); if (a.tags.length > 0) teile.push(`--tags ${a.tags.join(",")}`); for (const [profil, werte] of Object.entries(a.variablen)) { for (const [name, wert] of Object.entries(werte)) { const text = String(wert); teile.push( `--var ${profil}.${name}=${/^[\w.,:/@-]+$/.test(text) ? text : `'${text.replace(/'/g, `'"'"'`)}'`}`, ); } } if (a.typ === "lxc") { if (a.swapMb !== 512) teile.push(`--swap ${a.swapMb}`); if (!a.unprivilegiert) teile.push("--privileged"); } if (a.templateBauen) teile.push("--build-template"); return teile.join(" "); }