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
+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;
}