feat(api): API-Tokens, kurze Anfragen und OpenAPI für die CLI

- ApiToken (nur SHA-256), Bearer-Anmeldung, POST /auth/token für proxdeploy login, Verwaltung in den Einstellungen
- anfrageErgaenzen: Storage, Bridge, Node, Benutzer und Schlüssel aus Server-Standards, Verbindungstest und Anmeldung – auch beim Template-Bau
- Abschlussbox serverseitig (abschluss), Web und CLI zeigen dieselben Zeilen
- Antwortschemas als zod, daraus /api/v1/openapi.json und cli/api/openapi.json (pnpm openapi)

Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:08:27 +02:00
co-authored by Claude Opus 5.5
parent 1e195f9bca
commit 9fdced62db
27 changed files with 1386 additions and 58 deletions
+192
View File
@@ -0,0 +1,192 @@
import { z } from "@/lib/validierung";
/**
* Antwortformen der REST-API, soweit die CLI sie liest. Aus ihnen und den Anfrage-Schemas
* entsteht `openapi.json` und daraus die Go-Typen der CLI (`cli/internal/api`).
*
* Objekte sind offen (`looseObject`): die API darf Felder ergänzen, ohne dass eine ältere CLI
* bricht. Die Tests in `tests/openapi.test.ts` prüfen echte Antworten gegen diese Schemas.
*/
const Zeit = z.string().describe("ISO 8601");
export const FehlerAntwort = z.object({
fehler: z.looseObject({
code: z.string(),
nachricht: z.string(),
hinweis: z.string().optional(),
felder: z.record(z.string(), z.string()).optional(),
}),
});
export const Ich = z.looseObject({
benutzername: z.string(),
anzeigename: z.string(),
rolle: z.enum(["ADMIN", "OPERATOR"]),
rechte: z.array(z.string()),
version: z.string(),
});
export const NeuesToken = z.looseObject({
id: z.string(),
name: z.string(),
praefix: z.string(),
token: z.string(),
erstelltAm: Zeit,
ablaufAm: Zeit.nullable(),
});
export const TokenAnmeldung = z.object({
benutzername: z.string(),
passwort: z.string(),
name: z.string(),
tage: z.number().int().optional(),
});
export const ServerEintrag = z.looseObject({
name: z.string(),
titel: z.string(),
url: z.string(),
erreichbar: z.boolean().nullable(),
pveVersion: z.string().nullable(),
geprueftAm: Zeit.nullable(),
sshAktiv: z.boolean(),
letzterTest: z
.looseObject({
cluster: z.looseObject({ name: z.string() }).nullable(),
knoten: z.array(
z.looseObject({
name: z.string(),
online: z.boolean(),
speicherBelegt: z.number().nullable(),
speicherGesamt: z.number().nullable(),
}),
),
})
.nullable(),
});
export const DistributionEintrag = z.looseObject({
id: z.string(),
name: z.string(),
familie: z.string(),
aktiv: z.boolean(),
vm: z.boolean(),
lxc: z.boolean(),
});
export const ProfilVariable = z.looseObject({
name: z.string(),
label: z.string(),
type: z.enum(["string", "number", "boolean", "select"]),
required: z.boolean(),
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(z.string()).optional(),
help: z.string().optional(),
});
export const ProfilEintrag = z.looseObject({
id: z.string(),
name: z.string(),
beschreibung: z.string().nullable(),
familien: z.array(z.string()),
herkunft: z.enum(["KATALOG", "EIGEN"]),
aktiv: z.boolean(),
definition: z.looseObject({ variables: z.array(ProfilVariable).optional() }),
});
export const VorlageEintrag = z.looseObject({
name: z.string(),
titel: z.string(),
beschreibung: z.string().nullable(),
});
export const TemplateEintrag = z.looseObject({
vmid: z.number().int(),
name: z.string(),
knoten: z.string(),
distributionId: z.string().nullable(),
gebautAm: Zeit.nullable(),
profile: z.array(z.string()),
});
export const AuftragAngenommen = z.object({ id: z.string(), kurzId: z.string() });
const PhasenStatus = z.enum(["OFFEN", "LAEUFT", "ERLEDIGT", "FEHLER", "UEBERSPRUNGEN"]);
export const PhaseSicht = z.looseObject({
schluessel: z.string(),
titel: z.string(),
status: PhasenStatus,
detail: z.string().nullable(),
ursache: z.string().nullable(),
hinweis: z.string().nullable(),
gestartetAm: Zeit.nullable().optional(),
beendetAm: Zeit.nullable().optional(),
});
/** Ergebnis eines fertigen Auftrags – Inhalt der Abschlussbox. */
export const AuftragErgebnis = z.looseObject({
vmid: z.number().int().optional(),
knoten: z.string().optional(),
ip: z.string().nullable().optional(),
ssh: z.string().nullable().optional(),
/** Nur beim Template-Bau: Name des Templates. */
template: z.string().optional(),
});
export const Abschluss = z.looseObject({
ok: z.boolean(),
titel: z.string(),
dauer: z.string().nullable(),
/** Je Zeile `[Bezeichnung, Wert]`. */
zeilen: z.array(z.array(z.string()).length(2)),
});
export const AuftragSicht = z.looseObject({
id: z.string(),
kurzId: z.string(),
art: z.enum(["AUSROLLEN", "TEMPLATE_BAUEN", "AUFRAEUMEN"]),
status: z.enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]),
kopf: z.string(),
server: z.looseObject({ name: z.string(), titel: z.string() }),
knoten: z.string().nullable(),
gastTyp: z.enum(["VM", "LXC"]).nullable(),
vmid: z.number().int().nullable(),
name: z.string().nullable(),
distributionId: z.string().nullable(),
profile: z.array(z.string()),
ergebnis: AuftragErgebnis.nullable(),
fehler: z
.looseObject({ phase: z.string().optional(), ursache: z.string(), hinweis: z.string().optional() })
.nullable(),
erstelltVon: z.string(),
erstelltAm: Zeit,
gestartetAm: Zeit.nullable(),
beendetAm: Zeit.nullable(),
abschluss: Abschluss.nullable(),
phasen: z.array(PhaseSicht),
});
export const LogZeile = z.looseObject({
id: z.string(),
zeit: Zeit,
stufe: z.string(),
phase: z.string().nullable(),
nachricht: z.string(),
});
export const GeplanterAufruf = z.looseObject({
methode: z.string(),
pfad: z.string(),
parameter: z.record(z.string(), z.unknown()).optional(),
});
export const Plan = z.looseObject({
kopf: z.string(),
phasen: z.array(PhaseSicht),
aufrufe: z.array(GeplanterAufruf),
skript: z.string().nullable(),
cliBefehl: z.string(),
automatisch: z.array(z.string()),
ok: z.boolean(),
});
+9
View File
@@ -20,6 +20,8 @@ export type AuftragSicht = {
erstelltAm: string;
gestartetAm: string | null;
beendetAm: string | null;
/** Abschlussbox, sobald der Auftrag fertig ist. */
abschluss: Abschluss | null;
phasen: Array<{
schluessel: string;
titel: string;
@@ -32,4 +34,11 @@ export type AuftragSicht = {
}>;
};
export type Abschluss = {
ok: boolean;
titel: string;
dauer: string | null;
zeilen: Array<[string, string]>;
};
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };