Files
proxdeploy/src/lib/schemas/ausrollen.ts
T
florian.wildeandClaude Opus 5.5 426d3ec95a feat(schluessel): SSH-Schlüssel hinterlegen statt in jedes Formular
Benutzer hinterlegen ihre öffentlichen Schlüssel einmal, Admins
zusätzlich Team-Schlüssel. Sie kommen in jeden Gast: im Assistenten
vorausgewählt und abwählbar, per API und CLI automatisch, wenn die
Anfrage keine nennt. Die Schlüssel kommen über die Schnittstelle
SchluesselQuelle – dort lässt sich später eine externe Verwaltung
anbinden.

Vorgezogen aus #015 auf die Frage, ob Schlüssel ins Template gehören:
nein, in die Verwaltung.

Refs #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:28:45 +02:00

164 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof AusrollAnfrage>;
export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
/**
* 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(" ");
}