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
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from "vitest";
import { verschluesseln } from "@/lib/geheimnisse";
import * as A from "@/lib/schemas/api";
import { auftragAnlegen, auftragHolen } from "@/server/services/auftraege";
import { distributionenListe, katalogSynchronisieren, profileListe } from "@/server/services/katalog";
import { serverListe } from "@/server/services/server";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet } from "./helpers/anfragen";
/** Die Antwortschemas (Quelle der Go-Typen) müssen zu dem passen, was die Services wirklich liefern. */
describe.runIf(datenbankVerfuegbar)("Antwortschemas gegen echte Antworten", () => {
beforeEach(async () => {
await datenbankLeeren();
await katalogSynchronisieren();
});
it("Katalog, Server und Auftrag", async () => {
for (const d of await distributionenListe({ nurAktive: true })) A.DistributionEintrag.parse(d);
for (const p of await profileListe({ nurAktive: true })) A.ProfilEintrag.parse(p);
const server = await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
for (const s of await serverListe()) A.ServerEintrag.parse(s);
const { benutzer } = await angemeldet("OPERATOR");
const auftrag = await auftragAnlegen(
{
art: "AUSROLLEN",
serverId: server.id,
knoten: "pve01",
gastTyp: "VM",
distributionId: "debian-13",
name: "web01",
profile: ["base", "docker"],
anfrage: {},
},
{ benutzer, quelle: "CLI" },
null,
async () => {},
);
A.AuftragSicht.parse(await auftragHolen(auftrag.kurzId));
await testPrisma.auftrag.update({
where: { id: auftrag.id },
data: {
status: "ERFOLGREICH",
vmid: 120,
gestartetAm: new Date(Date.now() - 114_000),
beendetAm: new Date(),
ergebnis: { vmid: 120, knoten: "pve01", ip: "10.0.20.15", ssh: "ssh florian@10.0.20.15" },
},
});
const fertig = A.AuftragSicht.parse(await auftragHolen(auftrag.kurzId));
expect(fertig.abschluss).toEqual({
ok: true,
titel: "web01 bereit",
dauer: "1m 54s",
zeilen: [
["VMID", "120"],
["IP", "10.0.20.15"],
["Profile", "base, docker"],
["SSH", "ssh florian@10.0.20.15"],
],
});
});
});
+31 -1
View File
@@ -23,7 +23,7 @@ test("SSH-Schlüssel hinterlegen, im Assistenten vorausgewählt, wieder entferne
if (alt.name.startsWith(E2E_PRAEFIX)) await page.request.delete(`/api/v1/schluessel/${alt.id}`);
}
await page.reload();
await page.getByLabel("Name").fill(name);
await page.locator("#schluessel-name").fill(name);
await page.getByLabel("Öffentlicher Schlüssel").fill("-----BEGIN OPENSSH PRIVATE KEY-----");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText(/nie den privaten Schlüssel/)).toBeVisible();
@@ -47,3 +47,33 @@ test("SSH-Schlüssel hinterlegen, im Assistenten vorausgewählt, wieder entferne
await page.getByRole("button", { name: `Schlüssel ${name} entfernen` }).click();
await expect(page.getByText(name)).toHaveCount(0);
});
test("API-Token anlegen, einmal sehen, damit die API nutzen, widerrufen", async ({ page }) => {
const name = `${E2E_PRAEFIX}-token-${test.info().project.name}`;
await anmelden(page, "/einstellungen");
for (const alt of (await (await page.request.get("/api/v1/tokens")).json()) as Array<{
id: string;
name: string;
}>) {
if (alt.name.startsWith(E2E_PRAEFIX)) await page.request.delete(`/api/v1/tokens/${alt.id}`);
}
await page.reload();
await page.locator("#token-name").fill(name);
await page.getByRole("button", { name: "Token anlegen" }).click();
const anzeige = page.getByRole("status").filter({ hasText: "nur jetzt angezeigt" });
await expect(anzeige).toBeVisible();
const token = (await anzeige.locator("code").first().innerText()).trim();
expect(token).toMatch(/^pd_/);
await keinQuerscrollen(page);
const ich = await page.request.get("/api/v1/ich", {
headers: { authorization: `Bearer ${token}`, cookie: "" },
});
expect(ich.status()).toBe(200);
page.once("dialog", (d) => void d.accept());
await page.getByRole("button", { name: `Token ${name} widerrufen` }).click();
await expect(page.getByText(`${name} ·`)).toHaveCount(0);
const danach = await page.request.get("/api/v1/ich", { headers: { authorization: `Bearer ${token}` } });
expect(danach.status()).toBe(401);
});
+224
View File
@@ -0,0 +1,224 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it } from "vitest";
import * as anmeldenToken from "@/app/api/v1/auth/token/route";
import * as ich from "@/app/api/v1/ich/route";
import * as liste from "@/app/api/v1/tokens/route";
import * as einzeln from "@/app/api/v1/tokens/[id]/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { passwortHashen } from "@/lib/passwort";
import * as A from "@/lib/schemas/api";
import { openApi } from "@/server/openapi";
import { sperrenZuruecksetzen } from "@/server/services/anmeldung";
import { anfrageErgaenzen } from "@/server/services/ausrollen";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
const mitToken = (methode: string, pfad: string, token: string, rumpf?: unknown) => {
const a = anfrage(methode, pfad, null, rumpf);
a.headers.set("authorization", `Bearer ${token}`);
return a;
};
describe("OpenAPI", () => {
it("cli/api/openapi.json passt zum Code (sonst `pnpm openapi` und `go generate`)", () => {
const datei = JSON.parse(readFileSync("cli/api/openapi.json", "utf8"));
expect(datei).toEqual(JSON.parse(JSON.stringify(openApi())));
});
it("jede $ref zeigt auf ein vorhandenes Schema", () => {
const text = JSON.stringify(openApi());
const schemas = Object.keys((openApi() as { components: { schemas: object } }).components.schemas);
for (const [, name] of text.matchAll(/"#\/components\/schemas\/([^"]+)"/g))
expect(schemas).toContain(name);
});
});
describe.runIf(datenbankVerfuegbar)("API-Tokens", () => {
let operator: Awaited<ReturnType<typeof angemeldet>>;
beforeEach(async () => {
await datenbankLeeren();
sperrenZuruecksetzen();
operator = await angemeldet("OPERATOR", "florian");
});
const tokenAnlegen = async (rumpf: unknown = { name: "laptop" }) =>
await liste.POST(anfrage("POST", "/api/v1/tokens", operator.cookie, rumpf));
it("Token einmal zeigen, nur den Hash speichern, damit anmelden", async () => {
const antwort = await tokenAnlegen();
expect(antwort.status).toBe(201);
const neu = A.NeuesToken.parse(await antwort.json());
expect(neu.token).toMatch(/^pd_[A-Za-z0-9_-]{43}$/);
expect(neu.token.startsWith(neu.praefix)).toBe(true);
const zeile = await testPrisma.apiToken.findFirstOrThrow();
expect(zeile.hash).not.toContain(neu.token);
expect(
JSON.stringify(await (await liste.GET(anfrage("GET", "/api/v1/tokens", operator.cookie))).json()),
).not.toContain(neu.token);
const wer = await ich.GET(mitToken("GET", "/api/v1/ich", neu.token));
expect(wer.status).toBe(200);
expect(A.Ich.parse(await wer.json()).benutzername).toBe("florian");
expect((await testPrisma.apiToken.findFirstOrThrow()).zuletztGenutzt).not.toBeNull();
const protokoll = await testPrisma.protokoll.findMany({ where: { aktion: "API-Token angelegt" } });
expect(protokoll).toHaveLength(1);
});
it("unbekannte, abgelaufene und widerrufene Tokens gelten nicht – auch nicht mit Cookie dabei", async () => {
expect((await ich.GET(mitToken("GET", "/api/v1/ich", "pd_falsch"))).status).toBe(401);
const falschMitCookie = mitToken("GET", "/api/v1/ich", "pd_falsch");
falschMitCookie.headers.set("cookie", operator.cookie);
expect((await ich.GET(falschMitCookie)).status).toBe(401);
const neu = A.NeuesToken.parse(await (await tokenAnlegen({ name: "kurz", tage: 1 })).json());
await testPrisma.apiToken.update({
where: { id: neu.id },
data: { ablaufAm: new Date(Date.now() - 1000) },
});
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(401);
const zweites = A.NeuesToken.parse(await (await tokenAnlegen({ name: "weg" })).json());
const loeschen = await einzeln.DELETE(
anfrage("DELETE", `/api/v1/tokens/${zweites.id}`, operator.cookie),
kontext({ id: zweites.id }),
);
expect(loeschen.status).toBe(204);
expect((await ich.GET(mitToken("GET", "/api/v1/ich", zweites.token))).status).toBe(401);
});
it("gesperrter Benutzer: Token wirkt nicht mehr; fremde Tokens löscht niemand", async () => {
const neu = A.NeuesToken.parse(await (await tokenAnlegen()).json());
const anderer = await angemeldet("ADMIN", "admin");
const fremd = await einzeln.DELETE(
anfrage("DELETE", `/api/v1/tokens/${neu.id}`, anderer.cookie),
kontext({ id: neu.id }),
);
expect(fremd.status).toBe(403);
await testPrisma.benutzer.update({ where: { id: operator.benutzer.id }, data: { aktiv: false } });
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(401);
});
it("doppelter Name ist ein Feldfehler", async () => {
await tokenAnlegen();
const doppelt = await tokenAnlegen();
expect(doppelt.status).toBe(409);
expect((await doppelt.json()).fehler.felder.name).toBeTruthy();
});
it("proxdeploy login: Passwort gegen Token, mit Sperre und Protokoll als CLI", async () => {
await testPrisma.benutzer.update({
where: { id: operator.benutzer.id },
data: { passwortHash: await passwortHashen("geheim-genug-123") },
});
const falsch = await anmeldenToken.POST(
anfrage("POST", "/api/v1/auth/token", null, { benutzername: "florian", passwort: "nein", name: "cli" }),
);
expect(falsch.status).toBe(401);
const richtig = await anmeldenToken.POST(
anfrage("POST", "/api/v1/auth/token", null, {
benutzername: "Florian",
passwort: "geheim-genug-123",
name: "cli@laptop",
}),
);
expect(richtig.status).toBe(201);
const neu = A.NeuesToken.parse(await richtig.json());
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(200);
const eintraege = await testPrisma.protokoll.findMany({
where: { quelle: "CLI" },
orderBy: { id: "asc" },
});
expect(eintraege.map((e) => e.aktion)).toEqual([
"Anmeldung fehlgeschlagen",
"Angemeldet",
"API-Token angelegt",
]);
});
});
type Erg = { storage?: string; netz: { bridge?: string; vlan?: number }; zugang: { benutzer?: string } };
describe.runIf(datenbankVerfuegbar)("Kurze Anfrage ergänzen (CLI)", () => {
beforeEach(async () => {
await datenbankLeeren();
await katalogSynchronisieren();
});
const bericht = {
knoten: [
{ name: "pve00", online: false, storages: [], bridges: [] },
{
name: "pve01",
online: true,
storages: [
{ storage: "local", aktiv: true, inhalte: ["iso", "vztmpl", "import"] },
{ storage: "aus", aktiv: false, inhalte: ["images"] },
{ storage: "local-zfs", aktiv: true, inhalte: ["images", "rootdir"] },
{ storage: "ct-only", aktiv: true, inhalte: ["rootdir"] },
],
bridges: [{ name: "vmbr1" }, { name: "vmbr0" }],
},
],
};
async function server(standards: object | null = null) {
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
letzterTest: bericht,
...(standards ? { standards } : {}),
},
});
}
it("füllt Storage, Bridge und Benutzer aus Verbindungstest und Anmeldung", async () => {
await server();
const { benutzer } = await angemeldet("OPERATOR", "florian");
const voll = (await anfrageErgaenzen(
{ typ: "vm", server: "rz1", distribution: "debian-13", name: "web01" },
{ benutzer, quelle: "CLI" },
)) as Erg;
expect(voll.storage).toBe("local-zfs");
expect(voll.netz.bridge).toBe("vmbr0");
expect(voll.zugang.benutzer).toBe("florian");
const ct = (await anfrageErgaenzen(
{ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01", storage: "", netz: { vlan: 20 } },
{ benutzer, quelle: "CLI" },
)) as Erg;
expect(ct.storage).toBe("local-zfs");
expect(ct.netz).toEqual({ vlan: 20, bridge: "vmbr0" });
});
it("Standards des Servers gehen vor; Angaben der Anfrage bleiben; root wird kein Gastbenutzer", async () => {
await server({ storage: "ct-only", bridge: "vmbr1" });
const { benutzer } = await angemeldet("ADMIN", "root");
const ct = (await anfrageErgaenzen(
{ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01" },
{ benutzer, quelle: "CLI" },
)) as Erg;
expect(ct.storage).toBe("ct-only");
expect(ct.netz.bridge).toBe("vmbr1");
expect(ct.zugang.benutzer).toBeUndefined();
const vm = (await anfrageErgaenzen(
{
typ: "vm",
server: "rz1",
distribution: "debian-13",
name: "web01",
storage: "x",
netz: { bridge: "y" },
},
{ benutzer, quelle: "CLI" },
)) as Erg;
expect([vm.storage, vm.netz.bridge]).toEqual(["x", "y"]);
});
});
+2
View File
@@ -10,6 +10,8 @@ import { istOeffentlich, OEFFENTLICH } from "@/proxy";
*/
const AUSNAHMEN: Record<string, string> = {
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
"src/app/api/v1/auth/token/route.ts":
"proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)",
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
"src/app/anmelden/page.tsx": "Anmeldeseite",