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
@@ -5,37 +5,12 @@ import { Code } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { TerminalKarte, type TerminalErgebnis } from "@/components/terminal-karte";
import { TerminalKarte } from "@/components/terminal-karte";
import { apiAufruf } from "@/lib/api-client";
import { dauer } from "@/lib/formate";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
function ergebnisAus(a: AuftragSicht): TerminalErgebnis | null {
if (!ENDE.has(a.status)) return null;
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
const gesamt =
a.gestartetAm && a.beendetAm
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
: null;
const name = a.name ?? a.kurzId;
if (a.status !== "ERFOLGREICH") {
return {
ok: false,
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : "fehlgeschlagen"}`,
dauer: gesamt,
zeilen: [],
};
}
const zeilen: Array<[string, string]> = [];
if (a.vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(a.vmid)]);
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
}
/**
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
@@ -112,7 +87,7 @@ export function LiveAnsicht({
auftrag.status === "WARTEND"
? (["offen", "wartet auf den worker"] as const)
: STATUS_ANZEIGE[auftrag.status];
const ergebnis = ergebnisAus(auftrag);
const ergebnis = auftrag.abschluss;
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
return (
+8 -1
View File
@@ -1,12 +1,15 @@
import type { Metadata } from "next";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { schluesselFuer } from "@/server/services/schluessel";
import { tokensVon } from "@/server/services/tokens";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
import { TokenVerwaltung } from "./token-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel. Benutzer, CLI-Tokens und Standardwerte folgen mit #015. */
/** Einstellungen. Heute: SSH-Schlüssel und CLI-Tokens. Benutzer und Standardwerte folgen mit #015. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
return (
@@ -19,6 +22,10 @@ export default async function Einstellungen() {
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
<TokenVerwaltung
anfang={await tokensVon(benutzer.id)}
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
/>
</div>
);
}
@@ -0,0 +1,157 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Code, EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { zeitpunkt } from "@/lib/formate";
type Token = {
id: string;
name: string;
praefix: string;
erstelltAm: string;
zuletztGenutzt: string | null;
ablaufAm: string | null;
};
/**
* API-Tokens für die CLI. Das Token steht genau einmal da – direkt nach dem Anlegen; gespeichert
* wird nur sein Hash.
*/
export function TokenVerwaltung({ anfang, adresse }: { anfang: Token[]; adresse: string }) {
const router = useRouter();
const [neu, setNeu] = useState({ name: "", tage: "" });
const [frisch, setFrisch] = useState<{ name: string; token: string } | null>(null);
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
async function anlegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
const antwort = await apiAufruf<Token & { token: string }>("POST", "/api/v1/tokens", {
name: neu.name,
...(neu.tage ? { tage: Number(neu.tage) } : {}),
});
setFrisch({ name: antwort.name, token: antwort.token });
setNeu({ name: "", tage: "" });
setMeldung(null);
router.refresh();
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
async function widerrufen(t: Token) {
if (!window.confirm(`Token „${t.name}“ widerrufen? Die CLI mit diesem Token ist danach abgemeldet.`))
return;
try {
await apiAufruf("DELETE", `/api/v1/tokens/${t.id}`);
if (frisch?.name === t.name) setFrisch(null);
router.refresh();
} catch (fehler) {
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
}
}
return (
<Karte titel="API-Tokens für die CLI">
<div className="flex flex-col gap-5">
<p className="text-muted text-sm">
Am einfachsten meldet sich die CLI selbst an: <Code>{`proxdeploy login ${adresse}`}</Code>. Für
Skripte und CI ein Token hier anlegen und mit <Code>--token</Code> übergeben.
</p>
{frisch ? (
<div className="border-akzent flex flex-col gap-2 rounded-lg border p-4" role="status">
<Status art="erledigt">Token „{frisch.name}“ angelegt – es wird nur jetzt angezeigt.</Status>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Code className="flex-1 break-all">{frisch.token}</Code>
<Knopf
variante="sekundaer"
onClick={() => void navigator.clipboard.writeText(frisch.token)}
aria-label="Token kopieren"
>
Kopieren
</Knopf>
</div>
<p className="text-muted text-xs break-all">
proxdeploy login {adresse} --token {frisch.token.slice(0, 9)}…
</p>
</div>
) : null}
{anfang.length === 0 ? (
<Status art="offen">Noch keine Tokens.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((t) => (
<li key={t.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{t.name} <span className="text-muted font-mono text-sm">· {t.praefix}…</span>
</p>
<p className="text-muted text-xs">
angelegt {zeitpunkt(t.erstelltAm)} · zuletzt genutzt {zeitpunkt(t.zuletztGenutzt)}
{t.ablaufAm ? ` · läuft ab ${new Date(t.ablaufAm).toLocaleDateString("de-DE")}` : ""}
</p>
</div>
<Knopf
variante="gefahr"
onClick={() => void widerrufen(t)}
aria-label={`Token ${t.name} widerrufen`}
>
Widerrufen
</Knopf>
</li>
))}
</ul>
)}
<form onSubmit={anlegen} className="border-linie flex flex-col gap-4 border-t pt-5">
<div className="flex flex-col gap-4 sm:flex-row">
<Feld id="token-name" label="Name" fehler={meldung?.felder?.["name"]}>
<input
id="token-name"
className={`${EINGABE_KLASSE} sm:max-w-sm`}
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
placeholder="ci-pipeline"
/>
</Feld>
<Feld
id="token-tage"
label="Gültig (Tage)"
hilfe="Leer = unbefristet"
fehler={meldung?.felder?.["tage"]}
>
<input
id="token-tage"
inputMode="numeric"
className={`${EINGABE_KLASSE} sm:max-w-[10rem]`}
value={neu.tage}
onChange={(e) => setNeu({ ...neu, tage: e.target.value.replace(/\D/g, "") })}
placeholder="90"
/>
</Feld>
</div>
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={!neu.name}>
Token anlegen
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</div>
</Karte>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { mitPasswortAnmelden } from "@/server/services/anmeldung";
import { tokenAnlegen } from "@/server/services/tokens";
const Eingabe = z.object({
benutzername: z.string().trim().min(1, "Benutzername fehlt.").max(64),
passwort: z.string().min(1, "Passwort fehlt.").max(1024),
name: z.string().trim().min(1, "Name fehlt.").max(80),
tage: z.coerce.number().int().min(1).max(3650).optional(),
});
/**
* `proxdeploy login`: Benutzername + Passwort gegen ein API-Token tauschen. Öffentlich wie die
* Anmeldung, mit derselben Sperre gegen Durchprobieren.
*/
export const POST = api(async (anfrage: Request) => {
const eingabe = Eingabe.parse(await jsonLesen(anfrage));
const ip = aufruferAdresse(anfrage.headers);
const benutzer = await mitPasswortAnmelden(eingabe.benutzername, eingabe.passwort, ip, "CLI");
const token = await tokenAnlegen(
{ name: eingabe.name, ...(eingabe.tage ? { tage: eingabe.tage } : {}) },
{ benutzer, quelle: "CLI" },
ip,
);
return NextResponse.json(
{ ...token, benutzername: benutzer.benutzername, rolle: benutzer.rolle },
{ status: 201 },
);
});
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { openApi } from "@/server/openapi";
/** Die OpenAPI-Beschreibung (für Angemeldete – sie verrät nichts, aber auch niemandem etwas Fremdem). */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(openApi());
});
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { tokenLoeschen } from "@/server/services/tokens";
type Kontext = { params: Promise<{ id: string }> };
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await tokenLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { tokenAnlegen, tokensVon } from "@/server/services/tokens";
/** Die eigenen API-Tokens – ohne das Token selbst. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(await tokensVon(benutzer.id));
});
/** `{ name, tage? }` – die Antwort enthält das Token, und nur sie. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(
await tokenAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+40 -1
View File
@@ -1,5 +1,6 @@
import type { StatusArt } from "@/components/status";
import type { AuftragSicht } from "@/lib/schemas/auftrag";
import { dauer } from "@/lib/formate";
import type { Abschluss, AuftragSicht } from "@/lib/schemas/auftrag";
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
@@ -16,3 +17,41 @@ export const ART_TEXT: Record<AuftragSicht["art"], string> = {
TEMPLATE_BAUEN: "Template",
AUFRAEUMEN: "Aufräumen",
};
const ENDE = new Set<AuftragSicht["status"]>(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
/**
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
* und CLI dieselben Zeilen zeigen.
*/
export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | null {
if (!ENDE.has(a.status)) return null;
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
const gesamt =
a.gestartetAm && a.beendetAm
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
: null;
const name = a.name ?? (typeof e["template"] === "string" ? e["template"] : a.kurzId);
if (a.status !== "ERFOLGREICH") {
const f = (a.fehler ?? {}) as { phase?: unknown; ursache?: unknown; hinweis?: unknown };
const phase = a.phasen.find((p) => p.schluessel === f.phase)?.titel;
const zeilen: Array<[string, string]> = [];
if (phase) zeilen.push(["Phase", phase]);
if (typeof f.ursache === "string") zeilen.push(["Ursache", f.ursache]);
if (typeof f.hinweis === "string") zeilen.push(["Hinweis", f.hinweis]);
return {
ok: false,
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : a.status === "AUFGERAEUMT" ? "aufgeräumt" : "fehlgeschlagen"}`,
dauer: gesamt,
zeilen,
};
}
const zeilen: Array<[string, string]> = [];
const vmid = a.vmid ?? (typeof e["vmid"] === "number" ? e["vmid"] : null);
if (vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(vmid)]);
if (a.art === "TEMPLATE_BAUEN" && typeof e["knoten"] === "string") zeilen.push(["Node", e["knoten"]]);
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
}
+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 };
+9 -2
View File
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rechte";
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
import { benutzerAusToken } from "@/server/services/tokens";
/**
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
@@ -45,10 +46,16 @@ function cookieAusKopf(kopf: string | null, name: string): string | undefined {
}
/**
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
* landen hier, damit kein Handler zwei Wege kennen muss.
* Für Route Handler: Bearer-Token (CLI) oder Sitzungscookie (Web) – hier an einer Stelle, damit
* kein Handler zwei Wege kennen muss. Ein angegebenes, aber ungültiges Token gilt als nicht
* angemeldet, auch wenn zusätzlich ein Cookie mitkommt.
*/
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
const kopf = anfrage.headers.get("authorization");
if (kopf?.startsWith("Bearer ")) {
const ausToken = await benutzerAusToken(kopf.slice("Bearer ".length).trim());
return ausToken ? { benutzer: ausToken, quelle: "CLI" } : null;
}
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
return benutzer ? { benutzer, quelle: "WEB" } : null;
}
+243
View File
@@ -0,0 +1,243 @@
import { z } from "@/lib/validierung";
import * as A from "@/lib/schemas/api";
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { TemplateBauAnfrage } from "@/lib/schemas/template";
import { VERSION } from "@/lib/version";
/**
* OpenAPI 3.0 der REST-API, soweit die CLI sie nutzt – aus denselben zod-Schemas, mit denen die
* API prüft. `pnpm openapi` schreibt sie nach `cli/api/openapi.json`; daraus erzeugt
* oapi-codegen die Go-Typen. Ein Test vergleicht die Datei mit diesem Stand.
*/
type Schema = z.ZodType;
const KOMPONENTEN: Record<string, Schema> = {
FehlerAntwort: A.FehlerAntwort,
Ich: A.Ich,
TokenAnmeldung: A.TokenAnmeldung,
NeuesToken: A.NeuesToken,
Server: A.ServerEintrag,
Distribution: A.DistributionEintrag,
ProfilVariable: A.ProfilVariable,
Profil: A.ProfilEintrag,
Vorlage: A.VorlageEintrag,
Template: A.TemplateEintrag,
AusrollAnfrage,
TemplateBauAnfrage,
AuftragAngenommen: A.AuftragAngenommen,
Phase: A.PhaseSicht,
AuftragErgebnis: A.AuftragErgebnis,
Abschluss: A.Abschluss,
Auftrag: A.AuftragSicht,
LogZeile: A.LogZeile,
GeplanterAufruf: A.GeplanterAufruf,
Plan: A.Plan,
};
const ref = (name: keyof typeof KOMPONENTEN | string) => ({ $ref: `#/components/schemas/${name}` });
const json = (schema: object) => ({ content: { "application/json": { schema } } });
const liste = (name: string) => ({ type: "array", items: ref(name) });
type Operation = {
operationId: string;
summary: string;
parameters?: object[];
requestBody?: object;
responses: Record<string, object>;
security?: object[];
};
const FEHLER = { description: "Fehler", ...json(ref("FehlerAntwort")) };
const pfadParameter = (name: string, beschreibung: string) => ({
name,
in: "path",
required: true,
description: beschreibung,
schema: { type: "string" },
});
const PFADE: Record<string, Record<string, Operation>> = {
"/auth/token": {
post: {
operationId: "tokenAnmelden",
summary: "Benutzername und Passwort gegen ein API-Token tauschen (proxdeploy login)",
security: [],
requestBody: { required: true, ...json(ref("TokenAnmeldung")) },
responses: { "201": { description: "Token", ...json(ref("NeuesToken")) }, default: FEHLER },
},
},
"/ich": {
get: {
operationId: "ich",
summary: "Angemeldeter Benutzer, Rechte und Version",
responses: { "200": { description: "Benutzer", ...json(ref("Ich")) }, default: FEHLER },
},
},
"/server": {
get: {
operationId: "serverListe",
summary: "Alle Proxmox-Server mit dem letzten Verbindungstest",
responses: { "200": { description: "Server", ...json(liste("Server")) }, default: FEHLER },
},
},
"/server/{name}/templates": {
get: {
operationId: "templatesListe",
summary: "ProxDeploy-Templates eines Servers",
parameters: [pfadParameter("name", "Kurzname des Servers")],
responses: { "200": { description: "Templates", ...json(liste("Template")) }, default: FEHLER },
},
post: {
operationId: "templateBauen",
summary: "Template bauen (Auftrag)",
parameters: [pfadParameter("name", "Kurzname des Servers")],
requestBody: { required: true, ...json(ref("TemplateBauAnfrage")) },
responses: {
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
default: FEHLER,
},
},
},
"/distributionen": {
get: {
operationId: "distributionenListe",
summary: "Aktive Distributionen",
parameters: [
{ name: "typ", in: "query", required: false, schema: { type: "string", enum: ["vm", "lxc"] } },
],
responses: {
"200": { description: "Distributionen", ...json(liste("Distribution")) },
default: FEHLER,
},
},
},
"/profile": {
get: {
operationId: "profileListe",
summary: "Aktive Profile mit ihren Variablen",
responses: { "200": { description: "Profile", ...json(liste("Profil")) }, default: FEHLER },
},
},
"/vorlagen": {
get: {
operationId: "vorlagenListe",
summary: "Vorlagen (Presets)",
responses: { "200": { description: "Vorlagen", ...json(liste("Vorlage")) }, default: FEHLER },
},
},
"/auftraege": {
post: {
operationId: "ausrollen",
summary: "VM oder LXC ausrollen (Auftrag)",
requestBody: { required: true, ...json(ref("AusrollAnfrage")) },
responses: {
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
default: FEHLER,
},
},
},
"/auftraege/plan": {
post: {
operationId: "ausrollenPlanen",
summary: "Dry-Run: Phasen und geplante Proxmox-Aufrufe, ohne etwas anzulegen",
requestBody: { required: true, ...json(ref("AusrollAnfrage")) },
responses: { "200": { description: "Plan", ...json(ref("Plan")) }, default: FEHLER },
},
},
"/auftraege/{id}": {
get: {
operationId: "auftragHolen",
summary: "Stand eines Auftrags mit allen Phasen",
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
responses: { "200": { description: "Auftrag", ...json(ref("Auftrag")) }, default: FEHLER },
},
},
"/auftraege/{id}/log": {
get: {
operationId: "auftragLog",
summary: "Detail-Log ab einer Zeilen-ID",
parameters: [
pfadParameter("id", "ID oder Kurz-ID"),
{ name: "ab", in: "query", required: false, schema: { type: "string" } },
],
responses: { "200": { description: "Zeilen", ...json(liste("LogZeile")) }, default: FEHLER },
},
},
"/auftraege/{id}/ereignisse": {
get: {
operationId: "auftragEreignisse",
summary:
"Server-Sent Events: `stand` (Auftrag), `log` (LogZeile[], Event-ID = letzte Zeilen-ID), `ende`",
parameters: [
pfadParameter("id", "ID oder Kurz-ID"),
{ name: "Last-Event-ID", in: "header", required: false, schema: { type: "string" } },
],
responses: {
"200": {
description: "Ereignisstrom",
content: { "text/event-stream": { schema: { type: "string" } } },
},
default: FEHLER,
},
},
},
"/auftraege/{id}/abbrechen": {
post: {
operationId: "auftragAbbrechen",
summary: "Abbruch anfordern; der worker hält an der nächsten Phasengrenze an",
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
responses: { "202": { description: "Abbruch angefordert" }, default: FEHLER },
},
},
};
/**
* `additionalProperties: {}` (offene Objekte) heißt in JSON Schema dasselbe wie gar keine Angabe;
* ohne den Eintrag erzeugt oapi-codegen schlichte Structs statt Map-Hüllen.
*/
function aufraeumen(wert: unknown): unknown {
if (Array.isArray(wert)) return wert.map(aufraeumen);
if (!wert || typeof wert !== "object") return wert;
const ergebnis: Record<string, unknown> = {};
for (const [schluessel, inhalt] of Object.entries(wert)) {
if (schluessel === "$schema" || schluessel === "$id") continue;
if (
schluessel === "additionalProperties" &&
inhalt &&
typeof inhalt === "object" &&
!Object.keys(inhalt).length
)
continue;
ergebnis[schluessel] = aufraeumen(inhalt);
}
return ergebnis;
}
export function openApi(): object {
// Eine Registry: verschachtelte Schemas (Phase in Auftrag und Plan) werden zu $ref statt Kopien.
const registry = z.registry<{ id: string }>();
for (const [name, schema] of Object.entries(KOMPONENTEN)) registry.add(schema, { id: name });
// `input`: Anfragen beschreiben, was man schicken darf – Felder mit Standard sind optional.
const { schemas } = z.toJSONSchema(registry, {
target: "openapi-3.0",
io: "input",
unrepresentable: "any",
uri: (id) => `#/components/schemas/${id}`,
});
return {
openapi: "3.0.3",
info: {
title: "ProxDeploy API",
version: VERSION.replace(/-.*$/, ""),
description:
"REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`.",
},
servers: [{ url: "/api/v1" }],
security: [{ token: [] }],
components: {
securitySchemes: { token: { type: "http", scheme: "bearer", description: "API-Token (pd_…)" } },
schemas: aufraeumen(schemas),
},
paths: PFADE,
};
}
+3 -2
View File
@@ -29,6 +29,7 @@ export async function mitPasswortAnmelden(
benutzername: string,
passwort: string,
ip: string | null,
quelle: "WEB" | "CLI" = "WEB",
): Promise<Benutzer> {
const name = benutzername.trim().toLowerCase();
const schluessel = `${name}|${ip ?? "-"}`;
@@ -52,7 +53,7 @@ export async function mitPasswortAnmelden(
fehlversuche.get(schluessel)?.push(jetzt);
await protokollieren({
benutzerId: benutzer?.id ?? null,
quelle: "WEB",
quelle,
aktion: "Anmeldung fehlgeschlagen",
ziel: `benutzer:${name}`,
ip,
@@ -62,7 +63,7 @@ export async function mitPasswortAnmelden(
}
fehlversuche.delete(schluessel);
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
await protokollieren({ benutzerId: benutzer.id, quelle, aktion: "Angemeldet", ip });
return benutzer;
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { abschlussAus } from "@/lib/auftrag-text";
import { randomInt } from "node:crypto";
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
@@ -92,7 +93,7 @@ type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
const iso = (d: Date | null) => d?.toISOString() ?? null;
return {
const sicht: Omit<AuftragSicht, "abschluss"> = {
id: a.id,
kurzId: a.kurzId,
art: a.art,
@@ -123,6 +124,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht {
beendetAm: iso(p.beendetAm),
})),
};
return { ...sicht, abschluss: abschlussAus(sicht) };
}
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
+45 -3
View File
@@ -7,7 +7,7 @@ import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
import { AusrollAnfrage, cliBefehl, Zugang } from "@/lib/schemas/ausrollen";
import type { BauNetz } from "@/lib/schemas/server";
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { vorlageHolen } from "@/server/services/vorlagen";
@@ -15,6 +15,7 @@ import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server";
import { standardsFuer } from "@/server/services/standards";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { schluesselFuer } from "@/server/services/schluessel";
@@ -94,8 +95,49 @@ export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Pro
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
}
/**
* Füllt, was eine kurze Anfrage (CLI: `proxdeploy vm web01 --server rz1 --distro debian-13`) offen
* lässt – nach der Vorlage, damit deren Werte Vorrang vor den Standards haben:
*
* - SSH-Schlüssel: die hinterlegten (siehe `schluesselErgaenzen`)
* - Storage: der Standard des Servers, sonst der erste aktive für VM-Disks bzw. Container
* - Bridge: Standard des Servers, sonst vmbr0, sonst die erste des Nodes
* - Benutzer im Gast: der eigene Benutzername, wenn er als Unix-Name taugt
*
* Die Grundlage ist der letzte Verbindungstest; fehlt er, bleibt das Feld leer und die Prüfung
* meldet es.
*/
export async function anfrageErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
if (!roh || typeof roh !== "object") return roh;
let anfrage = roh as Record<string, unknown>;
if (typeof anfrage["vorlage"] === "string" && anfrage["vorlage"]) {
anfrage = vorlageAnwenden((await vorlageHolen(anfrage["vorlage"])).parameter, anfrage);
}
anfrage = (await schluesselErgaenzen(anfrage, aufrufer)) as Record<string, unknown>;
const zugang = { ...(anfrage["zugang"] as Record<string, unknown> | undefined) };
if (!zugang["benutzer"] && Zugang.shape.benutzer.safeParse(aufrufer.benutzer.benutzername).success) {
zugang["benutzer"] = aufrufer.benutzer.benutzername;
}
anfrage = { ...anfrage, zugang };
const server =
typeof anfrage["server"] === "string"
? await prisma.server.findUnique({ where: { name: anfrage["server"] } })
: null;
if (!server) return anfrage;
const standards = standardsFuer(server, {
knoten: typeof anfrage["knoten"] === "string" && anfrage["knoten"] ? anfrage["knoten"] : undefined,
inhalt: anfrage["typ"] === "lxc" ? "rootdir" : "images",
});
if (!anfrage["storage"] && standards.storage) anfrage = { ...anfrage, storage: standards.storage };
const netz = { ...(anfrage["netz"] as Record<string, unknown> | undefined) };
if (!netz["bridge"] && standards.bridge) netz["bridge"] = standards.bridge;
return { ...anfrage, netz };
}
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
const v = await ausrollenVorbereiten(await anfrageErgaenzen(roh, aufrufer));
return await auftragAnlegen(
{
art: "AUSROLLEN",
@@ -201,7 +243,7 @@ export type Plan = {
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
*/
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
const v = await ausrollenVorbereiten(await anfrageErgaenzen(roh, aufrufer));
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
const phasen: Plan["phasen"] = [];
let skript: string | null = null;
+29
View File
@@ -0,0 +1,29 @@
import type { Server } from "@/generated/prisma/client";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
/**
* Was eine kurze Anfrage offen lässt (Node, Storage, Bridge), aus den Standards des Servers und
* dem letzten Verbindungstest. Fehlt der Test, bleibt alles leer und die Prüfung meldet es.
*/
export function standardsFuer(
server: Pick<Server, "standards" | "letzterTest">,
wunsch: { knoten?: string | undefined; inhalt: "images" | "rootdir" },
): { knoten?: string; storage?: string; bridge?: string } {
const bericht = server.letzterTest as VerbindungsBericht | null;
if (!bericht) return {};
const standards = (server.standards ?? {}) as { knoten?: string; storage?: string; bridge?: string };
const online = bericht.knoten.filter((k) => k.online);
const knoten = wunsch.knoten
? online.find((k) => k.name === wunsch.knoten)
: (online.find((k) => k.name === standards.knoten) ?? online[0]);
if (!knoten) return {};
const passend = knoten.storages.filter((st) => st.aktiv && st.inhalte.includes(wunsch.inhalt));
const storage = (passend.find((st) => st.storage === standards.storage) ?? passend[0])?.storage;
const namen = knoten.bridges.map((b) => b.name);
const bridge = [standards.bridge, "vmbr0"].find((b) => b && namen.includes(b)) ?? namen[0];
return {
knoten: knoten.name,
...(storage ? { storage } : {}),
...(bridge ? { bridge } : {}),
};
}
+19 -1
View File
@@ -1,3 +1,4 @@
import type { Server } from "@/generated/prisma/client";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient } from "@/engine/proxmox/client";
@@ -8,6 +9,7 @@ import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server";
import { standardsFuer } from "@/server/services/standards";
export async function templateBauStarten(
serverName: string,
@@ -16,7 +18,7 @@ export async function templateBauStarten(
ip: string | null,
) {
const server = await serverHolen(serverName);
const anfrage = TemplateBauAnfrage.parse(roh);
const anfrage = TemplateBauAnfrage.parse(templateAnfrageErgaenzen(server, roh));
const distribution = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
if (!distribution || distribution.entferntAm || !distribution.aktiv) {
throw new AnwendungsFehler(
@@ -44,6 +46,22 @@ export async function templateBauStarten(
);
}
/** Node, Storage und Bridge aus den Standards, wenn die Anfrage (CLI) sie offen lässt. */
function templateAnfrageErgaenzen(server: Server, roh: unknown): unknown {
if (!roh || typeof roh !== "object") return roh;
const anfrage = roh as Record<string, unknown>;
const standards = standardsFuer(server, {
knoten: typeof anfrage["knoten"] === "string" && anfrage["knoten"] ? anfrage["knoten"] : undefined,
inhalt: "images",
});
return {
...anfrage,
knoten: anfrage["knoten"] || standards.knoten,
storage: anfrage["storage"] || standards.storage,
bridge: anfrage["bridge"] || standards.bridge,
};
}
/**
* Profile fürs Template: aufgelöst mit Standardwerten. Ein Profil mit Pflichtvariable (z. B. der
* Zabbix-Server) ist gerätespezifisch und gehört nicht ins Template.
+137
View File
@@ -0,0 +1,137 @@
import { createHash, randomBytes } from "node:crypto";
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, keinRecht, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/**
* API-Tokens für die CLI. Das Token selbst gibt es genau einmal – in der Antwort beim Anlegen.
* Gespeichert wird nur sein SHA-256.
*/
export const TOKEN_PRAEFIX = "pd_";
export type TokenSicht = {
id: string;
name: string;
praefix: string;
erstelltAm: string;
zuletztGenutzt: string | null;
ablaufAm: string | null;
};
export const TokenEingabe = z.object({
name: z.string().trim().min(1, "Name fehlt.").max(80),
/** Leer = unbefristet. */
tage: z.coerce.number().int().min(1).max(3650).optional(),
});
function hashen(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function sicht(t: {
id: string;
name: string;
praefix: string;
erstelltAm: Date;
zuletztGenutzt: Date | null;
ablaufAm: Date | null;
}): TokenSicht {
return {
id: t.id,
name: t.name,
praefix: t.praefix,
erstelltAm: t.erstelltAm.toISOString(),
zuletztGenutzt: t.zuletztGenutzt?.toISOString() ?? null,
ablaufAm: t.ablaufAm?.toISOString() ?? null,
};
}
export async function tokensVon(benutzerId: string): Promise<TokenSicht[]> {
const liste = await prisma.apiToken.findMany({ where: { benutzerId }, orderBy: { erstelltAm: "asc" } });
return liste.map(sicht);
}
export async function tokenAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<TokenSicht & { token: string }> {
const eingabe = TokenEingabe.parse(roh);
const token = TOKEN_PRAEFIX + randomBytes(32).toString("base64url");
const ablaufAm = eingabe.tage ? new Date(Date.now() + eingabe.tage * 86_400_000) : null;
try {
const zeile = await prisma.$transaction(async (tx) => {
const neu = await tx.apiToken.create({
data: {
benutzerId: aufrufer.benutzer.id,
name: eingabe.name,
praefix: token.slice(0, TOKEN_PRAEFIX.length + 6),
hash: hashen(token),
ablaufAm,
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "API-Token angelegt",
ziel: `token:${neu.id}`,
details: { name: eingabe.name, ablaufAm: ablaufAm?.toISOString() ?? null },
ip,
},
tx,
);
return neu;
});
return { ...sicht(zeile), token };
} catch (fehler) {
if ((fehler as { code?: string }).code === "P2002") {
throw new AnwendungsFehler(409, "vergeben", `Ein Token „${eingabe.name}“ gibt es schon.`, undefined, {
name: "Name schon vergeben.",
});
}
throw fehler;
}
}
export async function tokenLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const zeile = await prisma.apiToken.findUnique({ where: { id } });
if (!zeile) throw nichtGefunden("Das Token");
if (zeile.benutzerId !== aufrufer.benutzer.id) throw keinRecht();
await prisma.$transaction(async (tx) => {
await tx.apiToken.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "API-Token widerrufen",
ziel: `token:${id}`,
details: { name: zeile.name },
ip,
},
tx,
);
});
}
/** „Zuletzt genutzt“ höchstens einmal je Minute schreiben – nicht bei jedem SSE-Wiederverbinden. */
const NUTZUNG_TAKT_MS = 60_000;
/** Zum Bearer-Token den aktiven Benutzer – oder null, wenn unbekannt, abgelaufen oder gesperrt. */
export async function benutzerAusToken(token: string): Promise<Benutzer | null> {
if (!token.startsWith(TOKEN_PRAEFIX)) return null;
const zeile = await prisma.apiToken.findUnique({
where: { hash: hashen(token) },
include: { benutzer: true },
});
if (!zeile || !zeile.benutzer.aktiv) return null;
const jetzt = new Date();
if (zeile.ablaufAm && zeile.ablaufAm <= jetzt) return null;
if (!zeile.zuletztGenutzt || jetzt.getTime() - zeile.zuletztGenutzt.getTime() > NUTZUNG_TAKT_MS) {
await prisma.apiToken.update({ where: { id: zeile.id }, data: { zuletztGenutzt: jetzt } });
}
return zeile.benutzer;
}