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:
+3
-1
@@ -26,7 +26,9 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:container": "tests/container/profile-pruefen.sh",
|
"test:container": "tests/container/profile-pruefen.sh",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
+36
-18
@@ -24,22 +24,23 @@ enum Quelle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Benutzer {
|
model Benutzer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
benutzername String @unique
|
benutzername String @unique
|
||||||
anzeigename String
|
anzeigename String
|
||||||
email String?
|
email String?
|
||||||
/// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden.
|
/// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden.
|
||||||
passwortHash String?
|
passwortHash String?
|
||||||
oidcSubjekt String? @unique
|
oidcSubjekt String? @unique
|
||||||
rolle Rolle @default(OPERATOR)
|
rolle Rolle @default(OPERATOR)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
/// Persönliche Standardwerte im Assistenten.
|
/// Persönliche Standardwerte im Assistenten.
|
||||||
standards Json?
|
standards Json?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
protokoll Protokoll[]
|
protokoll Protokoll[]
|
||||||
auftraege Auftrag[]
|
auftraege Auftrag[]
|
||||||
schluessel SshSchluessel[]
|
schluessel SshSchluessel[]
|
||||||
|
tokens ApiToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
/// 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.…).
|
/// Ein Proxmox-VE-Einzelhost oder -Cluster. Geheimnisse liegen AES-256-GCM-verschlüsselt (v1.…).
|
||||||
model Server {
|
model Server {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// Kurzname, auch für die CLI (`--server rz1`).
|
/// Kurzname, auch für die CLI (`--server rz1`).
|
||||||
name String @unique
|
name String @unique
|
||||||
/// Anzeigename, z. B. „Rechenzentrum 1“.
|
/// Anzeigename, z. B. „Rechenzentrum 1“.
|
||||||
titel String
|
titel String
|
||||||
/// https://host:8006
|
/// https://host:8006
|
||||||
@@ -77,19 +78,19 @@ model Server {
|
|||||||
tokenId String
|
tokenId String
|
||||||
tokenGeheimnis String
|
tokenGeheimnis String
|
||||||
/// Aus: nur ein gepinnter Fingerprint zählt. An: Systemvertrauen (+ optional Pin).
|
/// 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.
|
/// SHA-256 des Serverzertifikats, Großbuchstaben mit Doppelpunkten.
|
||||||
fingerabdruck String?
|
fingerabdruck String?
|
||||||
sshAktiv Boolean @default(false)
|
sshAktiv Boolean @default(false)
|
||||||
sshBenutzer String @default("root")
|
sshBenutzer String @default("root")
|
||||||
sshPort Int @default(22)
|
sshPort Int @default(22)
|
||||||
sshPrivat String?
|
sshPrivat String?
|
||||||
sshOeffentlich String?
|
sshOeffentlich String?
|
||||||
/// Gepinnte Host-Keys je Node: { "pve01": "SHA256:…" }.
|
/// Gepinnte Host-Keys je Node: { "pve01": "SHA256:…" }.
|
||||||
sshHostSchluessel Json?
|
sshHostSchluessel Json?
|
||||||
templateVmidVon Int @default(9000)
|
templateVmidVon Int @default(9000)
|
||||||
templateVmidBis Int @default(9999)
|
templateVmidBis Int @default(9999)
|
||||||
gastVmidVon Int @default(100)
|
gastVmidVon Int @default(100)
|
||||||
/// Statische Adresse für die Template-Vorbereitung ohne SSH: { bridge, vlan, ip, gateway }.
|
/// Statische Adresse für die Template-Vorbereitung ohne SSH: { bridge, vlan, ip, gateway }.
|
||||||
bauNetz Json?
|
bauNetz Json?
|
||||||
/// Standard-Node, -Storage, -Bridge für den Assistenten.
|
/// Standard-Node, -Storage, -Bridge für den Assistenten.
|
||||||
@@ -99,8 +100,8 @@ model Server {
|
|||||||
erreichbar Boolean?
|
erreichbar Boolean?
|
||||||
pveVersion String?
|
pveVersion String?
|
||||||
geprueftAm DateTime?
|
geprueftAm DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
auftraege Auftrag[]
|
auftraege Auftrag[]
|
||||||
templates Template[]
|
templates Template[]
|
||||||
}
|
}
|
||||||
@@ -309,3 +310,20 @@ model SshSchluessel {
|
|||||||
|
|
||||||
@@unique([benutzerId, fingerabdruck])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
Executable
+14
@@ -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."
|
||||||
@@ -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");
|
||||||
@@ -5,37 +5,12 @@ import { Code } from "@/components/formular";
|
|||||||
import { Knopf } from "@/components/knopf";
|
import { Knopf } from "@/components/knopf";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
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 { apiAufruf } from "@/lib/api-client";
|
||||||
import { dauer } from "@/lib/formate";
|
|
||||||
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||||
|
|
||||||
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
|
|
||||||
function ergebnisAus(a: AuftragSicht): TerminalErgebnis | null {
|
|
||||||
if (!ENDE.has(a.status)) return null;
|
|
||||||
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
|
||||||
const gesamt =
|
|
||||||
a.gestartetAm && a.beendetAm
|
|
||||||
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
|
|
||||||
: null;
|
|
||||||
const name = a.name ?? a.kurzId;
|
|
||||||
if (a.status !== "ERFOLGREICH") {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : "fehlgeschlagen"}`,
|
|
||||||
dauer: gesamt,
|
|
||||||
zeilen: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const zeilen: Array<[string, string]> = [];
|
|
||||||
if (a.vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(a.vmid)]);
|
|
||||||
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
|
||||||
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
|
||||||
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
|
||||||
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
|
* 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.
|
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
|
||||||
@@ -112,7 +87,7 @@ export function LiveAnsicht({
|
|||||||
auftrag.status === "WARTEND"
|
auftrag.status === "WARTEND"
|
||||||
? (["offen", "wartet auf den worker"] as const)
|
? (["offen", "wartet auf den worker"] as const)
|
||||||
: STATUS_ANZEIGE[auftrag.status];
|
: STATUS_ANZEIGE[auftrag.status];
|
||||||
const ergebnis = ergebnisAus(auftrag);
|
const ergebnis = auftrag.abschluss;
|
||||||
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
import { schluesselFuer } from "@/server/services/schluessel";
|
import { schluesselFuer } from "@/server/services/schluessel";
|
||||||
|
import { tokensVon } from "@/server/services/tokens";
|
||||||
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
||||||
|
import { TokenVerwaltung } from "./token-verwaltung";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
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() {
|
export default async function Einstellungen() {
|
||||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +22,10 @@ export default async function Einstellungen() {
|
|||||||
anfang={await schluesselFuer(benutzer)}
|
anfang={await schluesselFuer(benutzer)}
|
||||||
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
||||||
/>
|
/>
|
||||||
|
<TokenVerwaltung
|
||||||
|
anfang={await tokensVon(benutzer.id)}
|
||||||
|
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Code, EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
import { zeitpunkt } from "@/lib/formate";
|
||||||
|
|
||||||
|
type Token = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
praefix: string;
|
||||||
|
erstelltAm: string;
|
||||||
|
zuletztGenutzt: string | null;
|
||||||
|
ablaufAm: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-Tokens für die CLI. Das Token steht genau einmal da – direkt nach dem Anlegen; gespeichert
|
||||||
|
* wird nur sein Hash.
|
||||||
|
*/
|
||||||
|
export function TokenVerwaltung({ anfang, adresse }: { anfang: Token[]; adresse: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [neu, setNeu] = useState({ name: "", tage: "" });
|
||||||
|
const [frisch, setFrisch] = useState<{ name: string; token: string } | null>(null);
|
||||||
|
const [meldung, setMeldung] = useState<{
|
||||||
|
art: "erledigt" | "fehler";
|
||||||
|
text: string;
|
||||||
|
felder?: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
async function anlegen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
try {
|
||||||
|
const antwort = await apiAufruf<Token & { token: string }>("POST", "/api/v1/tokens", {
|
||||||
|
name: neu.name,
|
||||||
|
...(neu.tage ? { tage: Number(neu.tage) } : {}),
|
||||||
|
});
|
||||||
|
setFrisch({ name: antwort.name, token: antwort.token });
|
||||||
|
setNeu({ name: "", tage: "" });
|
||||||
|
setMeldung(null);
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung({
|
||||||
|
art: "fehler",
|
||||||
|
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
|
||||||
|
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function widerrufen(t: Token) {
|
||||||
|
if (!window.confirm(`Token „${t.name}“ widerrufen? Die CLI mit diesem Token ist danach abgemeldet.`))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await apiAufruf("DELETE", `/api/v1/tokens/${t.id}`);
|
||||||
|
if (frisch?.name === t.name) setFrisch(null);
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="API-Tokens für die CLI">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Am einfachsten meldet sich die CLI selbst an: <Code>{`proxdeploy login ${adresse}`}</Code>. Für
|
||||||
|
Skripte und CI ein Token hier anlegen und mit <Code>--token</Code> übergeben.
|
||||||
|
</p>
|
||||||
|
{frisch ? (
|
||||||
|
<div className="border-akzent flex flex-col gap-2 rounded-lg border p-4" role="status">
|
||||||
|
<Status art="erledigt">Token „{frisch.name}“ angelegt – es wird nur jetzt angezeigt.</Status>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Code className="flex-1 break-all">{frisch.token}</Code>
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(frisch.token)}
|
||||||
|
aria-label="Token kopieren"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted text-xs break-all">
|
||||||
|
proxdeploy login {adresse} --token {frisch.token.slice(0, 9)}…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{anfang.length === 0 ? (
|
||||||
|
<Status art="offen">Noch keine Tokens.</Status>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
|
{anfang.map((t) => (
|
||||||
|
<li key={t.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{t.name} <span className="text-muted font-mono text-sm">· {t.praefix}…</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
angelegt {zeitpunkt(t.erstelltAm)} · zuletzt genutzt {zeitpunkt(t.zuletztGenutzt)}
|
||||||
|
{t.ablaufAm ? ` · läuft ab ${new Date(t.ablaufAm).toLocaleDateString("de-DE")}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Knopf
|
||||||
|
variante="gefahr"
|
||||||
|
onClick={() => void widerrufen(t)}
|
||||||
|
aria-label={`Token ${t.name} widerrufen`}
|
||||||
|
>
|
||||||
|
Widerrufen
|
||||||
|
</Knopf>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<form onSubmit={anlegen} className="border-linie flex flex-col gap-4 border-t pt-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
|
<Feld id="token-name" label="Name" fehler={meldung?.felder?.["name"]}>
|
||||||
|
<input
|
||||||
|
id="token-name"
|
||||||
|
className={`${EINGABE_KLASSE} sm:max-w-sm`}
|
||||||
|
value={neu.name}
|
||||||
|
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
|
||||||
|
placeholder="ci-pipeline"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="token-tage"
|
||||||
|
label="Gültig (Tage)"
|
||||||
|
hilfe="Leer = unbefristet"
|
||||||
|
fehler={meldung?.felder?.["tage"]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="token-tage"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} sm:max-w-[10rem]`}
|
||||||
|
value={neu.tage}
|
||||||
|
onChange={(e) => setNeu({ ...neu, tage: e.target.value.replace(/\D/g, "") })}
|
||||||
|
placeholder="90"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf type="submit" disabled={!neu.name}>
|
||||||
|
Token anlegen
|
||||||
|
</Knopf>
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { tokenAnlegen, tokensVon } from "@/server/services/tokens";
|
||||||
|
|
||||||
|
/** Die eigenen API-Tokens – ohne das Token selbst. */
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||||
|
return NextResponse.json(await tokensVon(benutzer.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `{ name, tage? }` – die Antwort enthält das Token, und nur sie. */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||||
|
return NextResponse.json(
|
||||||
|
await tokenAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
});
|
||||||
+40
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { StatusArt } from "@/components/status";
|
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. */
|
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
|
||||||
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
|
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
|
||||||
@@ -16,3 +17,41 @@ export const ART_TEXT: Record<AuftragSicht["art"], string> = {
|
|||||||
TEMPLATE_BAUEN: "Template",
|
TEMPLATE_BAUEN: "Template",
|
||||||
AUFRAEUMEN: "Aufräumen",
|
AUFRAEUMEN: "Aufräumen",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ENDE = new Set<AuftragSicht["status"]>(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
|
||||||
|
* und CLI dieselben Zeilen zeigen.
|
||||||
|
*/
|
||||||
|
export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | null {
|
||||||
|
if (!ENDE.has(a.status)) return null;
|
||||||
|
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||||
|
const gesamt =
|
||||||
|
a.gestartetAm && a.beendetAm
|
||||||
|
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
|
||||||
|
: null;
|
||||||
|
const name = a.name ?? (typeof e["template"] === "string" ? e["template"] : a.kurzId);
|
||||||
|
if (a.status !== "ERFOLGREICH") {
|
||||||
|
const f = (a.fehler ?? {}) as { phase?: unknown; ursache?: unknown; hinweis?: unknown };
|
||||||
|
const phase = a.phasen.find((p) => p.schluessel === f.phase)?.titel;
|
||||||
|
const zeilen: Array<[string, string]> = [];
|
||||||
|
if (phase) zeilen.push(["Phase", phase]);
|
||||||
|
if (typeof f.ursache === "string") zeilen.push(["Ursache", f.ursache]);
|
||||||
|
if (typeof f.hinweis === "string") zeilen.push(["Hinweis", f.hinweis]);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : a.status === "AUFGERAEUMT" ? "aufgeräumt" : "fehlgeschlagen"}`,
|
||||||
|
dauer: gesamt,
|
||||||
|
zeilen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const zeilen: Array<[string, string]> = [];
|
||||||
|
const vmid = a.vmid ?? (typeof e["vmid"] === "number" ? e["vmid"] : null);
|
||||||
|
if (vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(vmid)]);
|
||||||
|
if (a.art === "TEMPLATE_BAUEN" && typeof e["knoten"] === "string") zeilen.push(["Node", e["knoten"]]);
|
||||||
|
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
||||||
|
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
||||||
|
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
||||||
|
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -20,6 +20,8 @@ export type AuftragSicht = {
|
|||||||
erstelltAm: string;
|
erstelltAm: string;
|
||||||
gestartetAm: string | null;
|
gestartetAm: string | null;
|
||||||
beendetAm: string | null;
|
beendetAm: string | null;
|
||||||
|
/** Abschlussbox, sobald der Auftrag fertig ist. */
|
||||||
|
abschluss: Abschluss | null;
|
||||||
phasen: Array<{
|
phasen: Array<{
|
||||||
schluessel: string;
|
schluessel: string;
|
||||||
titel: 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 };
|
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
|
|||||||
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||||
import { darf, type Recht } from "@/server/auth/rechte";
|
import { darf, type Recht } from "@/server/auth/rechte";
|
||||||
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
|
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
|
* 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
|
* Für Route Handler: Bearer-Token (CLI) oder Sitzungscookie (Web) – hier an einer Stelle, damit
|
||||||
* landen hier, damit kein Handler zwei Wege kennen muss.
|
* 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> {
|
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));
|
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
|
||||||
return benutzer ? { benutzer, quelle: "WEB" } : null;
|
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,
|
benutzername: string,
|
||||||
passwort: string,
|
passwort: string,
|
||||||
ip: string | null,
|
ip: string | null,
|
||||||
|
quelle: "WEB" | "CLI" = "WEB",
|
||||||
): Promise<Benutzer> {
|
): Promise<Benutzer> {
|
||||||
const name = benutzername.trim().toLowerCase();
|
const name = benutzername.trim().toLowerCase();
|
||||||
const schluessel = `${name}|${ip ?? "-"}`;
|
const schluessel = `${name}|${ip ?? "-"}`;
|
||||||
@@ -52,7 +53,7 @@ export async function mitPasswortAnmelden(
|
|||||||
fehlversuche.get(schluessel)?.push(jetzt);
|
fehlversuche.get(schluessel)?.push(jetzt);
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
benutzerId: benutzer?.id ?? null,
|
benutzerId: benutzer?.id ?? null,
|
||||||
quelle: "WEB",
|
quelle,
|
||||||
aktion: "Anmeldung fehlgeschlagen",
|
aktion: "Anmeldung fehlgeschlagen",
|
||||||
ziel: `benutzer:${name}`,
|
ziel: `benutzer:${name}`,
|
||||||
ip,
|
ip,
|
||||||
@@ -62,7 +63,7 @@ export async function mitPasswortAnmelden(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fehlversuche.delete(schluessel);
|
fehlversuche.delete(schluessel);
|
||||||
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
|
await protokollieren({ benutzerId: benutzer.id, quelle, aktion: "Angemeldet", ip });
|
||||||
return benutzer;
|
return benutzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { abschlussAus } from "@/lib/auftrag-text";
|
||||||
import { randomInt } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||||
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
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 {
|
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||||
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||||
return {
|
const sicht: Omit<AuftragSicht, "abschluss"> = {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
kurzId: a.kurzId,
|
kurzId: a.kurzId,
|
||||||
art: a.art,
|
art: a.art,
|
||||||
@@ -123,6 +124,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
|||||||
beendetAm: iso(p.beendetAm),
|
beendetAm: iso(p.beendetAm),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
return { ...sicht, abschluss: abschlussAus(sicht) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
/** 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 { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
import { prisma } from "@/lib/db";
|
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 type { BauNetz } from "@/lib/schemas/server";
|
||||||
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
||||||
import { vorlageHolen } from "@/server/services/vorlagen";
|
import { vorlageHolen } from "@/server/services/vorlagen";
|
||||||
@@ -15,6 +15,7 @@ import type { Aufrufer } from "@/server/auth/zugang";
|
|||||||
import { AnwendungsFehler } from "@/server/fehler";
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
import { auftragAnlegen } from "@/server/services/auftraege";
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
import { serverHolen, verbindungAus } from "@/server/services/server";
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
|
import { standardsFuer } from "@/server/services/standards";
|
||||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||||
import { schluesselFuer } from "@/server/services/schluessel";
|
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 } };
|
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) {
|
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(
|
return await auftragAnlegen(
|
||||||
{
|
{
|
||||||
art: "AUSROLLEN",
|
art: "AUSROLLEN",
|
||||||
@@ -201,7 +243,7 @@ export type Plan = {
|
|||||||
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||||
*/
|
*/
|
||||||
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
|
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 client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
|
||||||
const phasen: Plan["phasen"] = [];
|
const phasen: Plan["phasen"] = [];
|
||||||
let skript: string | null = null;
|
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 type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||||
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
@@ -8,6 +9,7 @@ import type { Aufrufer } from "@/server/auth/zugang";
|
|||||||
import { AnwendungsFehler } from "@/server/fehler";
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
import { auftragAnlegen } from "@/server/services/auftraege";
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
import { serverHolen, verbindungAus } from "@/server/services/server";
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
|
import { standardsFuer } from "@/server/services/standards";
|
||||||
|
|
||||||
export async function templateBauStarten(
|
export async function templateBauStarten(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
@@ -16,7 +18,7 @@ export async function templateBauStarten(
|
|||||||
ip: string | null,
|
ip: string | null,
|
||||||
) {
|
) {
|
||||||
const server = await serverHolen(serverName);
|
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 } });
|
const distribution = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
||||||
if (!distribution || distribution.entferntAm || !distribution.aktiv) {
|
if (!distribution || distribution.entferntAm || !distribution.aktiv) {
|
||||||
throw new AnwendungsFehler(
|
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
|
* 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.
|
* 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;
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
if (alt.name.startsWith(E2E_PRAEFIX)) await page.request.delete(`/api/v1/schluessel/${alt.id}`);
|
||||||
}
|
}
|
||||||
await page.reload();
|
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.getByLabel("Öffentlicher Schlüssel").fill("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||||
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
||||||
await expect(page.getByText(/nie den privaten Schlüssel/)).toBeVisible();
|
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 page.getByRole("button", { name: `Schlüssel ${name} entfernen` }).click();
|
||||||
await expect(page.getByText(name)).toHaveCount(0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import * as anmeldenToken from "@/app/api/v1/auth/token/route";
|
||||||
|
import * as ich from "@/app/api/v1/ich/route";
|
||||||
|
import * as liste from "@/app/api/v1/tokens/route";
|
||||||
|
import * as einzeln from "@/app/api/v1/tokens/[id]/route";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { passwortHashen } from "@/lib/passwort";
|
||||||
|
import * as A from "@/lib/schemas/api";
|
||||||
|
import { openApi } from "@/server/openapi";
|
||||||
|
import { sperrenZuruecksetzen } from "@/server/services/anmeldung";
|
||||||
|
import { anfrageErgaenzen } from "@/server/services/ausrollen";
|
||||||
|
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
const mitToken = (methode: string, pfad: string, token: string, rumpf?: unknown) => {
|
||||||
|
const a = anfrage(methode, pfad, null, rumpf);
|
||||||
|
a.headers.set("authorization", `Bearer ${token}`);
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OpenAPI", () => {
|
||||||
|
it("cli/api/openapi.json passt zum Code (sonst `pnpm openapi` und `go generate`)", () => {
|
||||||
|
const datei = JSON.parse(readFileSync("cli/api/openapi.json", "utf8"));
|
||||||
|
expect(datei).toEqual(JSON.parse(JSON.stringify(openApi())));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jede $ref zeigt auf ein vorhandenes Schema", () => {
|
||||||
|
const text = JSON.stringify(openApi());
|
||||||
|
const schemas = Object.keys((openApi() as { components: { schemas: object } }).components.schemas);
|
||||||
|
for (const [, name] of text.matchAll(/"#\/components\/schemas\/([^"]+)"/g))
|
||||||
|
expect(schemas).toContain(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("API-Tokens", () => {
|
||||||
|
let operator: Awaited<ReturnType<typeof angemeldet>>;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
sperrenZuruecksetzen();
|
||||||
|
operator = await angemeldet("OPERATOR", "florian");
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenAnlegen = async (rumpf: unknown = { name: "laptop" }) =>
|
||||||
|
await liste.POST(anfrage("POST", "/api/v1/tokens", operator.cookie, rumpf));
|
||||||
|
|
||||||
|
it("Token einmal zeigen, nur den Hash speichern, damit anmelden", async () => {
|
||||||
|
const antwort = await tokenAnlegen();
|
||||||
|
expect(antwort.status).toBe(201);
|
||||||
|
const neu = A.NeuesToken.parse(await antwort.json());
|
||||||
|
expect(neu.token).toMatch(/^pd_[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(neu.token.startsWith(neu.praefix)).toBe(true);
|
||||||
|
|
||||||
|
const zeile = await testPrisma.apiToken.findFirstOrThrow();
|
||||||
|
expect(zeile.hash).not.toContain(neu.token);
|
||||||
|
expect(
|
||||||
|
JSON.stringify(await (await liste.GET(anfrage("GET", "/api/v1/tokens", operator.cookie))).json()),
|
||||||
|
).not.toContain(neu.token);
|
||||||
|
|
||||||
|
const wer = await ich.GET(mitToken("GET", "/api/v1/ich", neu.token));
|
||||||
|
expect(wer.status).toBe(200);
|
||||||
|
expect(A.Ich.parse(await wer.json()).benutzername).toBe("florian");
|
||||||
|
expect((await testPrisma.apiToken.findFirstOrThrow()).zuletztGenutzt).not.toBeNull();
|
||||||
|
const protokoll = await testPrisma.protokoll.findMany({ where: { aktion: "API-Token angelegt" } });
|
||||||
|
expect(protokoll).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unbekannte, abgelaufene und widerrufene Tokens gelten nicht – auch nicht mit Cookie dabei", async () => {
|
||||||
|
expect((await ich.GET(mitToken("GET", "/api/v1/ich", "pd_falsch"))).status).toBe(401);
|
||||||
|
const falschMitCookie = mitToken("GET", "/api/v1/ich", "pd_falsch");
|
||||||
|
falschMitCookie.headers.set("cookie", operator.cookie);
|
||||||
|
expect((await ich.GET(falschMitCookie)).status).toBe(401);
|
||||||
|
|
||||||
|
const neu = A.NeuesToken.parse(await (await tokenAnlegen({ name: "kurz", tage: 1 })).json());
|
||||||
|
await testPrisma.apiToken.update({
|
||||||
|
where: { id: neu.id },
|
||||||
|
data: { ablaufAm: new Date(Date.now() - 1000) },
|
||||||
|
});
|
||||||
|
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(401);
|
||||||
|
|
||||||
|
const zweites = A.NeuesToken.parse(await (await tokenAnlegen({ name: "weg" })).json());
|
||||||
|
const loeschen = await einzeln.DELETE(
|
||||||
|
anfrage("DELETE", `/api/v1/tokens/${zweites.id}`, operator.cookie),
|
||||||
|
kontext({ id: zweites.id }),
|
||||||
|
);
|
||||||
|
expect(loeschen.status).toBe(204);
|
||||||
|
expect((await ich.GET(mitToken("GET", "/api/v1/ich", zweites.token))).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gesperrter Benutzer: Token wirkt nicht mehr; fremde Tokens löscht niemand", async () => {
|
||||||
|
const neu = A.NeuesToken.parse(await (await tokenAnlegen()).json());
|
||||||
|
const anderer = await angemeldet("ADMIN", "admin");
|
||||||
|
const fremd = await einzeln.DELETE(
|
||||||
|
anfrage("DELETE", `/api/v1/tokens/${neu.id}`, anderer.cookie),
|
||||||
|
kontext({ id: neu.id }),
|
||||||
|
);
|
||||||
|
expect(fremd.status).toBe(403);
|
||||||
|
await testPrisma.benutzer.update({ where: { id: operator.benutzer.id }, data: { aktiv: false } });
|
||||||
|
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doppelter Name ist ein Feldfehler", async () => {
|
||||||
|
await tokenAnlegen();
|
||||||
|
const doppelt = await tokenAnlegen();
|
||||||
|
expect(doppelt.status).toBe(409);
|
||||||
|
expect((await doppelt.json()).fehler.felder.name).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proxdeploy login: Passwort gegen Token, mit Sperre und Protokoll als CLI", async () => {
|
||||||
|
await testPrisma.benutzer.update({
|
||||||
|
where: { id: operator.benutzer.id },
|
||||||
|
data: { passwortHash: await passwortHashen("geheim-genug-123") },
|
||||||
|
});
|
||||||
|
const falsch = await anmeldenToken.POST(
|
||||||
|
anfrage("POST", "/api/v1/auth/token", null, { benutzername: "florian", passwort: "nein", name: "cli" }),
|
||||||
|
);
|
||||||
|
expect(falsch.status).toBe(401);
|
||||||
|
const richtig = await anmeldenToken.POST(
|
||||||
|
anfrage("POST", "/api/v1/auth/token", null, {
|
||||||
|
benutzername: "Florian",
|
||||||
|
passwort: "geheim-genug-123",
|
||||||
|
name: "cli@laptop",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(richtig.status).toBe(201);
|
||||||
|
const neu = A.NeuesToken.parse(await richtig.json());
|
||||||
|
expect((await ich.GET(mitToken("GET", "/api/v1/ich", neu.token))).status).toBe(200);
|
||||||
|
const eintraege = await testPrisma.protokoll.findMany({
|
||||||
|
where: { quelle: "CLI" },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
});
|
||||||
|
expect(eintraege.map((e) => e.aktion)).toEqual([
|
||||||
|
"Anmeldung fehlgeschlagen",
|
||||||
|
"Angemeldet",
|
||||||
|
"API-Token angelegt",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type Erg = { storage?: string; netz: { bridge?: string; vlan?: number }; zugang: { benutzer?: string } };
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Kurze Anfrage ergänzen (CLI)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await katalogSynchronisieren();
|
||||||
|
});
|
||||||
|
|
||||||
|
const bericht = {
|
||||||
|
knoten: [
|
||||||
|
{ name: "pve00", online: false, storages: [], bridges: [] },
|
||||||
|
{
|
||||||
|
name: "pve01",
|
||||||
|
online: true,
|
||||||
|
storages: [
|
||||||
|
{ storage: "local", aktiv: true, inhalte: ["iso", "vztmpl", "import"] },
|
||||||
|
{ storage: "aus", aktiv: false, inhalte: ["images"] },
|
||||||
|
{ storage: "local-zfs", aktiv: true, inhalte: ["images", "rootdir"] },
|
||||||
|
{ storage: "ct-only", aktiv: true, inhalte: ["rootdir"] },
|
||||||
|
],
|
||||||
|
bridges: [{ name: "vmbr1" }, { name: "vmbr0" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function server(standards: object | null = null) {
|
||||||
|
await testPrisma.server.create({
|
||||||
|
data: {
|
||||||
|
name: "rz1",
|
||||||
|
titel: "RZ 1",
|
||||||
|
url: "https://127.0.0.1:8006",
|
||||||
|
tokenId: "a@pve!b",
|
||||||
|
tokenGeheimnis: verschluesseln("x"),
|
||||||
|
fingerabdruck: "AA",
|
||||||
|
letzterTest: bericht,
|
||||||
|
...(standards ? { standards } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("füllt Storage, Bridge und Benutzer aus Verbindungstest und Anmeldung", async () => {
|
||||||
|
await server();
|
||||||
|
const { benutzer } = await angemeldet("OPERATOR", "florian");
|
||||||
|
const voll = (await anfrageErgaenzen(
|
||||||
|
{ typ: "vm", server: "rz1", distribution: "debian-13", name: "web01" },
|
||||||
|
{ benutzer, quelle: "CLI" },
|
||||||
|
)) as Erg;
|
||||||
|
expect(voll.storage).toBe("local-zfs");
|
||||||
|
expect(voll.netz.bridge).toBe("vmbr0");
|
||||||
|
expect(voll.zugang.benutzer).toBe("florian");
|
||||||
|
|
||||||
|
const ct = (await anfrageErgaenzen(
|
||||||
|
{ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01", storage: "", netz: { vlan: 20 } },
|
||||||
|
{ benutzer, quelle: "CLI" },
|
||||||
|
)) as Erg;
|
||||||
|
expect(ct.storage).toBe("local-zfs");
|
||||||
|
expect(ct.netz).toEqual({ vlan: 20, bridge: "vmbr0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Standards des Servers gehen vor; Angaben der Anfrage bleiben; root wird kein Gastbenutzer", async () => {
|
||||||
|
await server({ storage: "ct-only", bridge: "vmbr1" });
|
||||||
|
const { benutzer } = await angemeldet("ADMIN", "root");
|
||||||
|
const ct = (await anfrageErgaenzen(
|
||||||
|
{ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01" },
|
||||||
|
{ benutzer, quelle: "CLI" },
|
||||||
|
)) as Erg;
|
||||||
|
expect(ct.storage).toBe("ct-only");
|
||||||
|
expect(ct.netz.bridge).toBe("vmbr1");
|
||||||
|
expect(ct.zugang.benutzer).toBeUndefined();
|
||||||
|
|
||||||
|
const vm = (await anfrageErgaenzen(
|
||||||
|
{
|
||||||
|
typ: "vm",
|
||||||
|
server: "rz1",
|
||||||
|
distribution: "debian-13",
|
||||||
|
name: "web01",
|
||||||
|
storage: "x",
|
||||||
|
netz: { bridge: "y" },
|
||||||
|
},
|
||||||
|
{ benutzer, quelle: "CLI" },
|
||||||
|
)) as Erg;
|
||||||
|
expect([vm.storage, vm.netz.bridge]).toEqual(["x", "y"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import { istOeffentlich, OEFFENTLICH } from "@/proxy";
|
|||||||
*/
|
*/
|
||||||
const AUSNAHMEN: Record<string, string> = {
|
const AUSNAHMEN: Record<string, string> = {
|
||||||
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
|
"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/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/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
|
||||||
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
||||||
|
|||||||
Reference in New Issue
Block a user