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:
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { darf, type Recht } from "@/server/auth/rechte";
|
||||
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
|
||||
import { benutzerAusToken } from "@/server/services/tokens";
|
||||
|
||||
/**
|
||||
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
|
||||
@@ -45,10 +46,16 @@ function cookieAusKopf(kopf: string | null, name: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
|
||||
* landen hier, damit kein Handler zwei Wege kennen muss.
|
||||
* Für Route Handler: Bearer-Token (CLI) oder Sitzungscookie (Web) – hier an einer Stelle, damit
|
||||
* kein Handler zwei Wege kennen muss. Ein angegebenes, aber ungültiges Token gilt als nicht
|
||||
* angemeldet, auch wenn zusätzlich ein Cookie mitkommt.
|
||||
*/
|
||||
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
|
||||
const kopf = anfrage.headers.get("authorization");
|
||||
if (kopf?.startsWith("Bearer ")) {
|
||||
const ausToken = await benutzerAusToken(kopf.slice("Bearer ".length).trim());
|
||||
return ausToken ? { benutzer: ausToken, quelle: "CLI" } : null;
|
||||
}
|
||||
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
|
||||
return benutzer ? { benutzer, quelle: "WEB" } : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
import * as A from "@/lib/schemas/api";
|
||||
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||
import { VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* OpenAPI 3.0 der REST-API, soweit die CLI sie nutzt – aus denselben zod-Schemas, mit denen die
|
||||
* API prüft. `pnpm openapi` schreibt sie nach `cli/api/openapi.json`; daraus erzeugt
|
||||
* oapi-codegen die Go-Typen. Ein Test vergleicht die Datei mit diesem Stand.
|
||||
*/
|
||||
type Schema = z.ZodType;
|
||||
|
||||
const KOMPONENTEN: Record<string, Schema> = {
|
||||
FehlerAntwort: A.FehlerAntwort,
|
||||
Ich: A.Ich,
|
||||
TokenAnmeldung: A.TokenAnmeldung,
|
||||
NeuesToken: A.NeuesToken,
|
||||
Server: A.ServerEintrag,
|
||||
Distribution: A.DistributionEintrag,
|
||||
ProfilVariable: A.ProfilVariable,
|
||||
Profil: A.ProfilEintrag,
|
||||
Vorlage: A.VorlageEintrag,
|
||||
Template: A.TemplateEintrag,
|
||||
AusrollAnfrage,
|
||||
TemplateBauAnfrage,
|
||||
AuftragAngenommen: A.AuftragAngenommen,
|
||||
Phase: A.PhaseSicht,
|
||||
AuftragErgebnis: A.AuftragErgebnis,
|
||||
Abschluss: A.Abschluss,
|
||||
Auftrag: A.AuftragSicht,
|
||||
LogZeile: A.LogZeile,
|
||||
GeplanterAufruf: A.GeplanterAufruf,
|
||||
Plan: A.Plan,
|
||||
};
|
||||
|
||||
const ref = (name: keyof typeof KOMPONENTEN | string) => ({ $ref: `#/components/schemas/${name}` });
|
||||
const json = (schema: object) => ({ content: { "application/json": { schema } } });
|
||||
const liste = (name: string) => ({ type: "array", items: ref(name) });
|
||||
|
||||
type Operation = {
|
||||
operationId: string;
|
||||
summary: string;
|
||||
parameters?: object[];
|
||||
requestBody?: object;
|
||||
responses: Record<string, object>;
|
||||
security?: object[];
|
||||
};
|
||||
|
||||
const FEHLER = { description: "Fehler", ...json(ref("FehlerAntwort")) };
|
||||
const pfadParameter = (name: string, beschreibung: string) => ({
|
||||
name,
|
||||
in: "path",
|
||||
required: true,
|
||||
description: beschreibung,
|
||||
schema: { type: "string" },
|
||||
});
|
||||
|
||||
const PFADE: Record<string, Record<string, Operation>> = {
|
||||
"/auth/token": {
|
||||
post: {
|
||||
operationId: "tokenAnmelden",
|
||||
summary: "Benutzername und Passwort gegen ein API-Token tauschen (proxdeploy login)",
|
||||
security: [],
|
||||
requestBody: { required: true, ...json(ref("TokenAnmeldung")) },
|
||||
responses: { "201": { description: "Token", ...json(ref("NeuesToken")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/ich": {
|
||||
get: {
|
||||
operationId: "ich",
|
||||
summary: "Angemeldeter Benutzer, Rechte und Version",
|
||||
responses: { "200": { description: "Benutzer", ...json(ref("Ich")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/server": {
|
||||
get: {
|
||||
operationId: "serverListe",
|
||||
summary: "Alle Proxmox-Server mit dem letzten Verbindungstest",
|
||||
responses: { "200": { description: "Server", ...json(liste("Server")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/server/{name}/templates": {
|
||||
get: {
|
||||
operationId: "templatesListe",
|
||||
summary: "ProxDeploy-Templates eines Servers",
|
||||
parameters: [pfadParameter("name", "Kurzname des Servers")],
|
||||
responses: { "200": { description: "Templates", ...json(liste("Template")) }, default: FEHLER },
|
||||
},
|
||||
post: {
|
||||
operationId: "templateBauen",
|
||||
summary: "Template bauen (Auftrag)",
|
||||
parameters: [pfadParameter("name", "Kurzname des Servers")],
|
||||
requestBody: { required: true, ...json(ref("TemplateBauAnfrage")) },
|
||||
responses: {
|
||||
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||
default: FEHLER,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/distributionen": {
|
||||
get: {
|
||||
operationId: "distributionenListe",
|
||||
summary: "Aktive Distributionen",
|
||||
parameters: [
|
||||
{ name: "typ", in: "query", required: false, schema: { type: "string", enum: ["vm", "lxc"] } },
|
||||
],
|
||||
responses: {
|
||||
"200": { description: "Distributionen", ...json(liste("Distribution")) },
|
||||
default: FEHLER,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/profile": {
|
||||
get: {
|
||||
operationId: "profileListe",
|
||||
summary: "Aktive Profile mit ihren Variablen",
|
||||
responses: { "200": { description: "Profile", ...json(liste("Profil")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/vorlagen": {
|
||||
get: {
|
||||
operationId: "vorlagenListe",
|
||||
summary: "Vorlagen (Presets)",
|
||||
responses: { "200": { description: "Vorlagen", ...json(liste("Vorlage")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/auftraege": {
|
||||
post: {
|
||||
operationId: "ausrollen",
|
||||
summary: "VM oder LXC ausrollen (Auftrag)",
|
||||
requestBody: { required: true, ...json(ref("AusrollAnfrage")) },
|
||||
responses: {
|
||||
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||
default: FEHLER,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/auftraege/plan": {
|
||||
post: {
|
||||
operationId: "ausrollenPlanen",
|
||||
summary: "Dry-Run: Phasen und geplante Proxmox-Aufrufe, ohne etwas anzulegen",
|
||||
requestBody: { required: true, ...json(ref("AusrollAnfrage")) },
|
||||
responses: { "200": { description: "Plan", ...json(ref("Plan")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/auftraege/{id}": {
|
||||
get: {
|
||||
operationId: "auftragHolen",
|
||||
summary: "Stand eines Auftrags mit allen Phasen",
|
||||
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||
responses: { "200": { description: "Auftrag", ...json(ref("Auftrag")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/auftraege/{id}/log": {
|
||||
get: {
|
||||
operationId: "auftragLog",
|
||||
summary: "Detail-Log ab einer Zeilen-ID",
|
||||
parameters: [
|
||||
pfadParameter("id", "ID oder Kurz-ID"),
|
||||
{ name: "ab", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: { "200": { description: "Zeilen", ...json(liste("LogZeile")) }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
"/auftraege/{id}/ereignisse": {
|
||||
get: {
|
||||
operationId: "auftragEreignisse",
|
||||
summary:
|
||||
"Server-Sent Events: `stand` (Auftrag), `log` (LogZeile[], Event-ID = letzte Zeilen-ID), `ende`",
|
||||
parameters: [
|
||||
pfadParameter("id", "ID oder Kurz-ID"),
|
||||
{ name: "Last-Event-ID", in: "header", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Ereignisstrom",
|
||||
content: { "text/event-stream": { schema: { type: "string" } } },
|
||||
},
|
||||
default: FEHLER,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/auftraege/{id}/abbrechen": {
|
||||
post: {
|
||||
operationId: "auftragAbbrechen",
|
||||
summary: "Abbruch anfordern; der worker hält an der nächsten Phasengrenze an",
|
||||
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||
responses: { "202": { description: "Abbruch angefordert" }, default: FEHLER },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `additionalProperties: {}` (offene Objekte) heißt in JSON Schema dasselbe wie gar keine Angabe;
|
||||
* ohne den Eintrag erzeugt oapi-codegen schlichte Structs statt Map-Hüllen.
|
||||
*/
|
||||
function aufraeumen(wert: unknown): unknown {
|
||||
if (Array.isArray(wert)) return wert.map(aufraeumen);
|
||||
if (!wert || typeof wert !== "object") return wert;
|
||||
const ergebnis: Record<string, unknown> = {};
|
||||
for (const [schluessel, inhalt] of Object.entries(wert)) {
|
||||
if (schluessel === "$schema" || schluessel === "$id") continue;
|
||||
if (
|
||||
schluessel === "additionalProperties" &&
|
||||
inhalt &&
|
||||
typeof inhalt === "object" &&
|
||||
!Object.keys(inhalt).length
|
||||
)
|
||||
continue;
|
||||
ergebnis[schluessel] = aufraeumen(inhalt);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
export function openApi(): object {
|
||||
// Eine Registry: verschachtelte Schemas (Phase in Auftrag und Plan) werden zu $ref statt Kopien.
|
||||
const registry = z.registry<{ id: string }>();
|
||||
for (const [name, schema] of Object.entries(KOMPONENTEN)) registry.add(schema, { id: name });
|
||||
// `input`: Anfragen beschreiben, was man schicken darf – Felder mit Standard sind optional.
|
||||
const { schemas } = z.toJSONSchema(registry, {
|
||||
target: "openapi-3.0",
|
||||
io: "input",
|
||||
unrepresentable: "any",
|
||||
uri: (id) => `#/components/schemas/${id}`,
|
||||
});
|
||||
return {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "ProxDeploy API",
|
||||
version: VERSION.replace(/-.*$/, ""),
|
||||
description:
|
||||
"REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`.",
|
||||
},
|
||||
servers: [{ url: "/api/v1" }],
|
||||
security: [{ token: [] }],
|
||||
components: {
|
||||
securitySchemes: { token: { type: "http", scheme: "bearer", description: "API-Token (pd_…)" } },
|
||||
schemas: aufraeumen(schemas),
|
||||
},
|
||||
paths: PFADE,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user