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>
164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
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(" ");
|
||
}
|