Files
proxdeploy/src/lib/schemas/server.ts
T
florian.wildeandClaude Opus 5.5 86b650e20a feat(server): Verbindungstest mit Fähigkeitserkennung
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>
2026-10-12 09:44:27 +02:00

130 lines
4.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 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."),
});