From 9fdced62dbc53115a356da7f4ae89c8614d22003 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 15:08:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20API-Tokens,=20kurze=20Anfragen=20u?= =?UTF-8?q?nd=20OpenAPI=20f=C3=BCr=20die=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 4 +- .../20261012124127_api_token/migration.sql | 22 ++ prisma/schema.prisma | 54 ++-- scripts/cli-pruefen.sh | 14 + scripts/openapi.ts | 9 + .../(app)/auftraege/[kurzId]/live-ansicht.tsx | 29 +-- src/app/(app)/einstellungen/page.tsx | 9 +- .../(app)/einstellungen/token-verwaltung.tsx | 157 +++++++++++ src/app/api/v1/auth/token/route.ts | 32 +++ src/app/api/v1/openapi.json/route.ts | 10 + src/app/api/v1/tokens/[id]/route.ts | 13 + src/app/api/v1/tokens/route.ts | 20 ++ src/lib/auftrag-text.ts | 41 ++- src/lib/schemas/api.ts | 192 ++++++++++++++ src/lib/schemas/auftrag.ts | 9 + src/server/auth/zugang.ts | 11 +- src/server/openapi.ts | 243 ++++++++++++++++++ src/server/services/anmeldung.ts | 5 +- src/server/services/auftraege.ts | 4 +- src/server/services/ausrollen.ts | 48 +++- src/server/services/standards.ts | 29 +++ src/server/services/templates.ts | 20 +- src/server/services/tokens.ts | 137 ++++++++++ tests/api-schemas.test.ts | 74 ++++++ tests/e2e/einstellungen.spec.ts | 32 ++- tests/tokens.test.ts | 224 ++++++++++++++++ tests/zugang.test.ts | 2 + 27 files changed, 1386 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/20261012124127_api_token/migration.sql create mode 100755 scripts/cli-pruefen.sh create mode 100644 scripts/openapi.ts create mode 100644 src/app/(app)/einstellungen/token-verwaltung.tsx create mode 100644 src/app/api/v1/auth/token/route.ts create mode 100644 src/app/api/v1/openapi.json/route.ts create mode 100644 src/app/api/v1/tokens/[id]/route.ts create mode 100644 src/app/api/v1/tokens/route.ts create mode 100644 src/lib/schemas/api.ts create mode 100644 src/server/openapi.ts create mode 100644 src/server/services/standards.ts create mode 100644 src/server/services/tokens.ts create mode 100644 tests/api-schemas.test.ts create mode 100644 tests/tokens.test.ts diff --git a/package.json b/package.json index 02df0ac..ec6e466 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "test:e2e": "playwright test", "test:container": "tests/container/profile-pruefen.sh", "typecheck": "tsc --noEmit", - "worker": "tsx src/worker/main.ts" + "worker": "tsx src/worker/main.ts", + "openapi": "tsx scripts/openapi.ts", + "cli:pruefen": "scripts/cli-pruefen.sh" }, "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.3.0", diff --git a/prisma/migrations/20261012124127_api_token/migration.sql b/prisma/migrations/20261012124127_api_token/migration.sql new file mode 100644 index 0000000..1037542 --- /dev/null +++ b/prisma/migrations/20261012124127_api_token/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "ApiToken" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "praefix" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zuletztGenutzt" TIMESTAMP(3), + "ablaufAm" TIMESTAMP(3), + + CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiToken_hash_key" ON "ApiToken"("hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiToken_benutzerId_name_key" ON "ApiToken"("benutzerId", "name"); + +-- AddForeignKey +ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 27c1ddb..92c521a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,22 +24,23 @@ enum Quelle { } model Benutzer { - id String @id @default(cuid()) - benutzername String @unique + id String @id @default(cuid()) + benutzername String @unique anzeigename String email String? /// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden. passwortHash String? - oidcSubjekt String? @unique - rolle Rolle @default(OPERATOR) - aktiv Boolean @default(true) + oidcSubjekt String? @unique + rolle Rolle @default(OPERATOR) + aktiv Boolean @default(true) /// Persönliche Standardwerte im Assistenten. standards Json? - erstelltAm DateTime @default(now()) - geaendertAm DateTime @updatedAt + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt protokoll Protokoll[] auftraege Auftrag[] schluessel SshSchluessel[] + tokens ApiToken[] } /// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE. @@ -66,9 +67,9 @@ model Einstellung { /// Ein Proxmox-VE-Einzelhost oder -Cluster. Geheimnisse liegen AES-256-GCM-verschlüsselt (v1.…). model Server { - id String @id @default(cuid()) + id String @id @default(cuid()) /// Kurzname, auch für die CLI (`--server rz1`). - name String @unique + name String @unique /// Anzeigename, z. B. „Rechenzentrum 1“. titel String /// https://host:8006 @@ -77,19 +78,19 @@ model Server { tokenId String tokenGeheimnis String /// Aus: nur ein gepinnter Fingerprint zählt. An: Systemvertrauen (+ optional Pin). - tlsPruefen Boolean @default(false) + tlsPruefen Boolean @default(false) /// SHA-256 des Serverzertifikats, Großbuchstaben mit Doppelpunkten. fingerabdruck String? - sshAktiv Boolean @default(false) - sshBenutzer String @default("root") - sshPort Int @default(22) + sshAktiv Boolean @default(false) + sshBenutzer String @default("root") + sshPort Int @default(22) sshPrivat String? sshOeffentlich String? /// Gepinnte Host-Keys je Node: { "pve01": "SHA256:…" }. sshHostSchluessel Json? - templateVmidVon Int @default(9000) - templateVmidBis Int @default(9999) - gastVmidVon Int @default(100) + templateVmidVon Int @default(9000) + templateVmidBis Int @default(9999) + gastVmidVon Int @default(100) /// Statische Adresse für die Template-Vorbereitung ohne SSH: { bridge, vlan, ip, gateway }. bauNetz Json? /// Standard-Node, -Storage, -Bridge für den Assistenten. @@ -99,8 +100,8 @@ model Server { erreichbar Boolean? pveVersion String? geprueftAm DateTime? - erstelltAm DateTime @default(now()) - geaendertAm DateTime @updatedAt + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt auftraege Auftrag[] templates Template[] } @@ -309,3 +310,20 @@ model SshSchluessel { @@unique([benutzerId, fingerabdruck]) } + +/// API-Token der CLI (`Authorization: Bearer pd_…`). Gespeichert wird nur der SHA-256 des Tokens – +/// es hat 256 Bit Zufall, ein langsamer Hash brächte nichts. Rolle kommt je Anfrage vom Benutzer. +model ApiToken { + id String @id @default(cuid()) + benutzerId String + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + name String + /// Die ersten Zeichen zur Wiedererkennung („pd_AbC1…“). + praefix String + hash String @unique + erstelltAm DateTime @default(now()) + zuletztGenutzt DateTime? + ablaufAm DateTime? + + @@unique([benutzerId, name]) +} diff --git a/scripts/cli-pruefen.sh b/scripts/cli-pruefen.sh new file mode 100755 index 0000000..a6da2e5 --- /dev/null +++ b/scripts/cli-pruefen.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Prüft, dass openapi.json und die erzeugten Go-Typen zum Code passen, und testet die CLI. +# Schlägt fehl, wenn `pnpm openapi` oder `go generate` etwas ändern würden. +set -euo pipefail +cd "$(dirname "$0")/.." +export PATH="$HOME/.local/go/bin:$PATH" +pnpm openapi >/dev/null +(cd cli && go generate ./... && go vet ./... && go test ./...) +if ! git diff --quiet -- cli/api/openapi.json cli/internal/api/typen.gen.go; then + echo "openapi.json oder typen.gen.go sind veraltet – die Änderungen oben einchecken." >&2 + git diff --stat -- cli/api/openapi.json cli/internal/api/typen.gen.go >&2 + exit 1 +fi +echo "CLI aktuell und grün." diff --git a/scripts/openapi.ts b/scripts/openapi.ts new file mode 100644 index 0000000..3852788 --- /dev/null +++ b/scripts/openapi.ts @@ -0,0 +1,9 @@ +/** + * Schreibt cli/api/openapi.json aus den zod-Schemas. Danach in cli/: `go generate ./...`. + * tests/openapi.test.ts schlägt fehl, wenn die Datei nicht zum Code passt. + */ +import { writeFileSync } from "node:fs"; +import { openApi } from "@/server/openapi"; + +writeFileSync("cli/api/openapi.json", `${JSON.stringify(openApi(), null, 2)}\n`); +console.log("cli/api/openapi.json geschrieben"); diff --git a/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx b/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx index 9785bf4..b750d33 100644 --- a/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx +++ b/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx @@ -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; - 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 | null)?.["ssh"]; return ( diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index 296a219..15f977d 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -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")} /> + ); } diff --git a/src/app/(app)/einstellungen/token-verwaltung.tsx b/src/app/(app)/einstellungen/token-verwaltung.tsx new file mode 100644 index 0000000..03aff63 --- /dev/null +++ b/src/app/(app)/einstellungen/token-verwaltung.tsx @@ -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; + } | null>(null); + + async function anlegen(ereignis: React.FormEvent) { + ereignis.preventDefault(); + try { + const antwort = await apiAufruf("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 ( + +
+

+ Am einfachsten meldet sich die CLI selbst an: {`proxdeploy login ${adresse}`}. Für + Skripte und CI ein Token hier anlegen und mit --token übergeben. +

+ {frisch ? ( +
+ Token „{frisch.name}“ angelegt – es wird nur jetzt angezeigt. +
+ {frisch.token} + void navigator.clipboard.writeText(frisch.token)} + aria-label="Token kopieren" + > + Kopieren + +
+

+ proxdeploy login {adresse} --token {frisch.token.slice(0, 9)}… +

+
+ ) : null} + {anfang.length === 0 ? ( + Noch keine Tokens. + ) : ( +
    + {anfang.map((t) => ( +
  • +
    +

    + {t.name} · {t.praefix}… +

    +

    + angelegt {zeitpunkt(t.erstelltAm)} · zuletzt genutzt {zeitpunkt(t.zuletztGenutzt)} + {t.ablaufAm ? ` · läuft ab ${new Date(t.ablaufAm).toLocaleDateString("de-DE")}` : ""} +

    +
    + void widerrufen(t)} + aria-label={`Token ${t.name} widerrufen`} + > + Widerrufen + +
  • + ))} +
+ )} +
+
+ + setNeu({ ...neu, name: e.target.value })} + placeholder="ci-pipeline" + /> + + + setNeu({ ...neu, tage: e.target.value.replace(/\D/g, "") })} + placeholder="90" + /> + +
+
+ + Token anlegen + +
+ {meldung ? {meldung.text} : null} +
+
+
+
+
+ ); +} diff --git a/src/app/api/v1/auth/token/route.ts b/src/app/api/v1/auth/token/route.ts new file mode 100644 index 0000000..9f0573f --- /dev/null +++ b/src/app/api/v1/auth/token/route.ts @@ -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 }, + ); +}); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..394dad7 --- /dev/null +++ b/src/app/api/v1/openapi.json/route.ts @@ -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()); +}); diff --git a/src/app/api/v1/tokens/[id]/route.ts b/src/app/api/v1/tokens/[id]/route.ts new file mode 100644 index 0000000..c7e5c55 --- /dev/null +++ b/src/app/api/v1/tokens/[id]/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/tokens/route.ts b/src/app/api/v1/tokens/route.ts new file mode 100644 index 0000000..eedacaa --- /dev/null +++ b/src/app/api/v1/tokens/route.ts @@ -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 }, + ); +}); diff --git a/src/lib/auftrag-text.ts b/src/lib/auftrag-text.ts index 75da405..e593f84 100644 --- a/src/lib/auftrag-text.ts +++ b/src/lib/auftrag-text.ts @@ -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 = { @@ -16,3 +17,41 @@ export const ART_TEXT: Record = { TEMPLATE_BAUEN: "Template", AUFRAEUMEN: "Aufräumen", }; + +const ENDE = new Set(["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): Abschluss | null { + if (!ENDE.has(a.status)) return null; + const e = (a.ergebnis ?? {}) as Record; + 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 }; +} diff --git a/src/lib/schemas/api.ts b/src/lib/schemas/api.ts new file mode 100644 index 0000000..18aca1d --- /dev/null +++ b/src/lib/schemas/api.ts @@ -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(), +}); diff --git a/src/lib/schemas/auftrag.ts b/src/lib/schemas/auftrag.ts index 1207dd6..db6b72a 100644 --- a/src/lib/schemas/auftrag.ts +++ b/src/lib/schemas/auftrag.ts @@ -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 }; diff --git a/src/server/auth/zugang.ts b/src/server/auth/zugang.ts index 9ad8204..e6546e7 100644 --- a/src/server/auth/zugang.ts +++ b/src/server/auth/zugang.ts @@ -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 { + 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; } diff --git a/src/server/openapi.ts b/src/server/openapi.ts new file mode 100644 index 0000000..a60c0fa --- /dev/null +++ b/src/server/openapi.ts @@ -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 = { + 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; + 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> = { + "/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 = {}; + 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, + }; +} diff --git a/src/server/services/anmeldung.ts b/src/server/services/anmeldung.ts index adfc7cf..0559c08 100644 --- a/src/server/services/anmeldung.ts +++ b/src/server/services/anmeldung.ts @@ -29,6 +29,7 @@ export async function mitPasswortAnmelden( benutzername: string, passwort: string, ip: string | null, + quelle: "WEB" | "CLI" = "WEB", ): Promise { 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; } diff --git a/src/server/services/auftraege.ts b/src/server/services/auftraege.ts index 46b4d7d..cf2ea7e 100644 --- a/src/server/services/auftraege.ts +++ b/src/server/services/auftraege.ts @@ -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 = { 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. */ diff --git a/src/server/services/ausrollen.ts b/src/server/services/ausrollen.ts index 41ee1e7..f2ac012 100644 --- a/src/server/services/ausrollen.ts +++ b/src/server/services/ausrollen.ts @@ -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 { + if (!roh || typeof roh !== "object") return roh; + let anfrage = roh as Record; + if (typeof anfrage["vorlage"] === "string" && anfrage["vorlage"]) { + anfrage = vorlageAnwenden((await vorlageHolen(anfrage["vorlage"])).parameter, anfrage); + } + anfrage = (await schluesselErgaenzen(anfrage, aufrufer)) as Record; + + const zugang = { ...(anfrage["zugang"] as Record | 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 | 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 { - 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; diff --git a/src/server/services/standards.ts b/src/server/services/standards.ts new file mode 100644 index 0000000..9972574 --- /dev/null +++ b/src/server/services/standards.ts @@ -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, + 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 } : {}), + }; +} diff --git a/src/server/services/templates.ts b/src/server/services/templates.ts index d4f91aa..fd8b8eb 100644 --- a/src/server/services/templates.ts +++ b/src/server/services/templates.ts @@ -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; + 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. diff --git a/src/server/services/tokens.ts b/src/server/services/tokens.ts new file mode 100644 index 0000000..5a278f3 --- /dev/null +++ b/src/server/services/tokens.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/tests/api-schemas.test.ts b/tests/api-schemas.test.ts new file mode 100644 index 0000000..a06ff9b --- /dev/null +++ b/tests/api-schemas.test.ts @@ -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"], + ], + }); + }); +}); diff --git a/tests/e2e/einstellungen.spec.ts b/tests/e2e/einstellungen.spec.ts index 5323f18..554750a 100644 --- a/tests/e2e/einstellungen.spec.ts +++ b/tests/e2e/einstellungen.spec.ts @@ -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); +}); diff --git a/tests/tokens.test.ts b/tests/tokens.test.ts new file mode 100644 index 0000000..5b164d2 --- /dev/null +++ b/tests/tokens.test.ts @@ -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>; + 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"]); + }); +}); diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts index 8565621..e152cd1 100644 --- a/tests/zugang.test.ts +++ b/tests/zugang.test.ts @@ -10,6 +10,8 @@ import { istOeffentlich, OEFFENTLICH } from "@/proxy"; */ const AUSNAHMEN: Record = { "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",