feat(schluessel): SSH-Schlüssel hinterlegen statt in jedes Formular
Benutzer hinterlegen ihre öffentlichen Schlüssel einmal, Admins zusätzlich Team-Schlüssel. Sie kommen in jeden Gast: im Assistenten vorausgewählt und abwählbar, per API und CLI automatisch, wenn die Anfrage keine nennt. Die Schlüssel kommen über die Schnittstelle SchluesselQuelle – dort lässt sich später eine externe Verwaltung anbinden. Vorgezogen aus #015 auf die Frage, ob Schlüssel ins Template gehören: nein, in die Verwaltung. Refs #015 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### Hinzugefügt
|
||||
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
|
||||
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
|
||||
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
|
||||
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
|
||||
|
||||
@@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle`
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||
- [ ] Audit-Log-Ansicht mit Filter
|
||||
|
||||
## Akzeptanzkriterien
|
||||
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SshSchluessel" (
|
||||
"id" TEXT NOT NULL,
|
||||
"benutzerId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"oeffentlich" TEXT NOT NULL,
|
||||
"fingerabdruck" TEXT NOT NULL,
|
||||
"quelle" TEXT NOT NULL DEFAULT 'lokal',
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SshSchluessel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SshSchluessel_benutzerId_fingerabdruck_key" ON "SshSchluessel"("benutzerId", "fingerabdruck");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SshSchluessel" ADD CONSTRAINT "SshSchluessel_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -39,6 +39,7 @@ model Benutzer {
|
||||
geaendertAm DateTime @updatedAt
|
||||
protokoll Protokoll[]
|
||||
auftraege Auftrag[]
|
||||
schluessel SshSchluessel[]
|
||||
}
|
||||
|
||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||
@@ -292,3 +293,19 @@ model Vorlage {
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
|
||||
model SshSchluessel {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String?
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
oeffentlich String
|
||||
/// SHA256:… wie ssh-keygen -lf
|
||||
fingerabdruck String
|
||||
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
|
||||
quelle String @default("lokal")
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
@@unique([benutzerId, fingerabdruck])
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ export type AssistentDaten = {
|
||||
benutzer: string;
|
||||
version: string;
|
||||
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
|
||||
schluessel: Array<{
|
||||
name: string;
|
||||
oeffentlich: string;
|
||||
fingerabdruck: string;
|
||||
bereich: "persoenlich" | "team";
|
||||
}>;
|
||||
};
|
||||
|
||||
type Plan = {
|
||||
@@ -94,6 +100,8 @@ type Werte = {
|
||||
suchdomain: string;
|
||||
benutzer: string;
|
||||
schluessel: string;
|
||||
/** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */
|
||||
ohneSchluessel: string[];
|
||||
profile: string[];
|
||||
variablen: Record<string, Record<string, string | boolean>>;
|
||||
templateBauen: boolean;
|
||||
@@ -128,9 +136,7 @@ function dekodieren(text: string | null): Partial<Werte> {
|
||||
}
|
||||
}
|
||||
|
||||
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
|
||||
|
||||
function anfrageAus(w: Werte): AusrollEingabe {
|
||||
function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe {
|
||||
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
||||
return {
|
||||
typ: w.typ,
|
||||
@@ -158,10 +164,13 @@ function anfrageAus(w: Werte): AusrollEingabe {
|
||||
},
|
||||
zugang: {
|
||||
benutzer: w.benutzer,
|
||||
sshSchluessel: w.schluessel
|
||||
sshSchluessel: [
|
||||
...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich),
|
||||
...w.schluessel
|
||||
.split("\n")
|
||||
.map((z) => z.trim())
|
||||
.filter(Boolean),
|
||||
],
|
||||
},
|
||||
profile: w.profile,
|
||||
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
||||
@@ -196,7 +205,7 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten:
|
||||
}
|
||||
|
||||
function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||
const a = anfrageAus(w);
|
||||
const a = anfrageAus(w, []);
|
||||
return {
|
||||
typ: a.typ,
|
||||
server: a.server,
|
||||
@@ -256,6 +265,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
suchdomain: "",
|
||||
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
||||
schluessel: "",
|
||||
ohneSchluessel: [],
|
||||
profile: ["base"],
|
||||
variablen: {},
|
||||
templateBauen: false,
|
||||
@@ -305,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
};
|
||||
|
||||
const gehe = (ziel: number) => {
|
||||
let naechste = werte;
|
||||
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
|
||||
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
|
||||
try {
|
||||
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
|
||||
if (gemerkt) {
|
||||
naechste = { ...werte, schluessel: gemerkt };
|
||||
setWerte(naechste);
|
||||
}
|
||||
} catch {
|
||||
// kein Speicher verfügbar – dann eben nicht
|
||||
}
|
||||
}
|
||||
const naechste = werte;
|
||||
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
||||
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
||||
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
||||
@@ -357,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
.filter(Boolean);
|
||||
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
||||
return "Gültigen Benutzernamen angeben (nicht root).";
|
||||
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
|
||||
const hinterlegt = daten.schluessel.filter((s) => !werte.ohneSchluessel.includes(s.fingerabdruck));
|
||||
if (schluessel.length + hinterlegt.length === 0)
|
||||
return "Mindestens einen öffentlichen SSH-Schlüssel wählen oder angeben.";
|
||||
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
|
||||
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
||||
return null;
|
||||
@@ -372,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand)));
|
||||
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand, daten.schluessel)));
|
||||
} catch (ursache) {
|
||||
setFehler(
|
||||
ursache instanceof ApiFehler
|
||||
@@ -388,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
try {
|
||||
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
|
||||
} catch {
|
||||
// egal
|
||||
}
|
||||
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
|
||||
const { kurzId } = await apiAufruf<{ kurzId: string }>(
|
||||
"POST",
|
||||
"/api/v1/auftraege",
|
||||
anfrageAus(werte, daten.schluessel),
|
||||
);
|
||||
router.push(`/auftraege/${kurzId}`);
|
||||
} catch (ursache) {
|
||||
setFehler(
|
||||
@@ -871,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Feld>
|
||||
{daten.schluessel.length > 0 ? (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Hinterlegte Schlüssel</legend>
|
||||
{daten.schluessel.map((s) => (
|
||||
<Schalter
|
||||
key={s.fingerabdruck}
|
||||
id={`schluessel-${s.fingerabdruck}`}
|
||||
label={`${s.name}${s.bereich === "team" ? " (Team)" : ""}`}
|
||||
hilfe={s.fingerabdruck}
|
||||
checked={!werte.ohneSchluessel.includes(s.fingerabdruck)}
|
||||
onChange={(an) =>
|
||||
setze({
|
||||
ohneSchluessel: an
|
||||
? werte.ohneSchluessel.filter((f) => f !== s.fingerabdruck)
|
||||
: [...werte.ohneSchluessel, s.fingerabdruck],
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
<Feld
|
||||
id="schluessel"
|
||||
label="Öffentliche SSH-Schlüssel"
|
||||
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
|
||||
label={
|
||||
daten.schluessel.length > 0
|
||||
? "Weitere öffentliche SSH-Schlüssel"
|
||||
: "Öffentliche SSH-Schlüssel"
|
||||
}
|
||||
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Dauerhaft hinterlegen: Einstellungen."
|
||||
>
|
||||
<textarea
|
||||
id="schluessel"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { versionText } from "@/lib/version";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { vorlagenListe } from "@/server/services/vorlagen";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { Assistent, type AssistentDaten } from "./assistent";
|
||||
|
||||
export const metadata: Metadata = { title: "Ausrollen" };
|
||||
@@ -75,6 +76,12 @@ export default async function Ausrollen() {
|
||||
benutzer: benutzer.benutzername,
|
||||
version: versionText(),
|
||||
vorlagen: await vorlagenListe(),
|
||||
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
|
||||
name: x.name,
|
||||
oeffentlich: x.oeffentlich,
|
||||
fingerabdruck: x.fingerabdruck,
|
||||
bereich: x.bereich,
|
||||
})),
|
||||
};
|
||||
const vorauswahl = aktiverServer(
|
||||
daten.server.map((s) => s.name),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
/** Einstellungen. Heute: SSH-Schlüssel. Benutzer, CLI-Tokens und Standardwerte folgen mit #015. */
|
||||
export default async function Einstellungen() {
|
||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
|
||||
</header>
|
||||
<SchluesselVerwaltung
|
||||
anfang={await schluesselFuer(benutzer)}
|
||||
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { EINGABE_KLASSE, Feld, Schalter } 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";
|
||||
|
||||
type Schluessel = {
|
||||
id: string;
|
||||
name: string;
|
||||
fingerabdruck: string;
|
||||
bereich: "persoenlich" | "team";
|
||||
quelle: string;
|
||||
oeffentlich: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Öffentliche SSH-Schlüssel, die automatisch in jeden Gast kommen – eigene und (von Admins
|
||||
* gepflegte) Team-Schlüssel. Im Assistenten lassen sie sich je Gast abwählen.
|
||||
*/
|
||||
export function SchluesselVerwaltung({
|
||||
anfang,
|
||||
teamErlaubt,
|
||||
}: {
|
||||
anfang: Schluessel[];
|
||||
teamErlaubt: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [neu, setNeu] = useState({ name: "", oeffentlich: "", team: false });
|
||||
const [meldung, setMeldung] = useState<{
|
||||
art: "erledigt" | "fehler";
|
||||
text: string;
|
||||
felder?: Record<string, string>;
|
||||
} | null>(null);
|
||||
|
||||
async function hinzufuegen(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
try {
|
||||
await apiAufruf("POST", "/api/v1/schluessel", neu);
|
||||
setNeu({ name: "", oeffentlich: "", team: false });
|
||||
setMeldung({ art: "erledigt", text: "Schlüssel hinzugefügt." });
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung({
|
||||
art: "fehler",
|
||||
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
|
||||
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function entfernen(s: Schluessel) {
|
||||
if (!window.confirm(`Schlüssel „${s.name}“ entfernen? Bereits ausgerollte Gäste behalten ihn.`)) return;
|
||||
try {
|
||||
await apiAufruf("DELETE", `/api/v1/schluessel/${s.id}`);
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Karte titel="SSH-Schlüssel">
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-muted text-sm">
|
||||
Diese öffentlichen Schlüssel kommen in jeden Gast, den du ausrollst – auch per CLI, wenn dort keine
|
||||
angegeben sind.
|
||||
</p>
|
||||
{anfang.length === 0 ? (
|
||||
<Status art="offen">Noch keine Schlüssel hinterlegt.</Status>
|
||||
) : (
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{anfang.map((s) => (
|
||||
<li key={s.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">
|
||||
{s.name}{" "}
|
||||
<span className="text-muted text-sm">
|
||||
· {s.bereich === "team" ? "Team" : "persönlich"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted font-mono text-xs break-all">{s.fingerabdruck}</p>
|
||||
</div>
|
||||
{s.bereich === "persoenlich" || teamErlaubt ? (
|
||||
<Knopf
|
||||
variante="gefahr"
|
||||
onClick={() => void entfernen(s)}
|
||||
aria-label={`Schlüssel ${s.name} entfernen`}
|
||||
>
|
||||
Entfernen
|
||||
</Knopf>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={hinzufuegen} className="border-linie flex flex-col gap-4 border-t pt-5">
|
||||
<Feld id="schluessel-name" label="Name" fehler={meldung?.felder?.["name"]}>
|
||||
<input
|
||||
id="schluessel-name"
|
||||
className={`${EINGABE_KLASSE} sm:max-w-sm`}
|
||||
value={neu.name}
|
||||
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
|
||||
placeholder="Laptop"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld
|
||||
id="schluessel-text"
|
||||
label="Öffentlicher Schlüssel"
|
||||
hilfe="Inhalt von ~/.ssh/id_ed25519.pub"
|
||||
fehler={meldung?.felder?.["oeffentlich"]}
|
||||
>
|
||||
<textarea
|
||||
id="schluessel-text"
|
||||
rows={3}
|
||||
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
|
||||
value={neu.oeffentlich}
|
||||
onChange={(e) => setNeu({ ...neu, oeffentlich: e.target.value })}
|
||||
placeholder="ssh-ed25519 AAAA… name@rechner"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Feld>
|
||||
{teamErlaubt ? (
|
||||
<Schalter
|
||||
id="schluessel-team"
|
||||
label="Team-Schlüssel"
|
||||
hilfe="Kommt in jeden Gast, egal wer ausrollt."
|
||||
checked={neu.team}
|
||||
onChange={(an) => setNeu({ ...neu, team: an })}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Knopf type="submit" disabled={!neu.name || !neu.oeffentlich}>
|
||||
Hinzufügen
|
||||
</Knopf>
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,6 @@ import { ausrollenPlanen } from "@/server/services/ausrollen";
|
||||
|
||||
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
|
||||
const aufrufer = await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
|
||||
});
|
||||
|
||||
@@ -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 { schluesselLoeschen } from "@/server/services/schluessel";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
await schluesselLoeschen((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 { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
|
||||
|
||||
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(await schluesselFuer(benutzer));
|
||||
});
|
||||
|
||||
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(
|
||||
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
@@ -58,9 +58,13 @@ export const Zugang = z.object({
|
||||
.trim()
|
||||
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
|
||||
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
|
||||
/** Leer = die hinterlegten Schlüssel des Benutzers und die Team-Schlüssel (beim Anlegen ergänzt). */
|
||||
sshSchluessel: z
|
||||
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
|
||||
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
|
||||
.min(
|
||||
1,
|
||||
"Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht. In den Einstellungen hinterlegen oder hier angeben.",
|
||||
),
|
||||
});
|
||||
|
||||
export const AusrollAnfrage = z
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { GastSchluessel, SchluesselQuelle } from "./quelle";
|
||||
|
||||
/** Schlüssel aus der eigenen Datenbank: die des Benutzers und die Team-Schlüssel. */
|
||||
export const lokaleQuelle: SchluesselQuelle = {
|
||||
name: "lokal",
|
||||
async schluesselFuer(benutzer) {
|
||||
const zeilen = await prisma.sshSchluessel.findMany({
|
||||
where: { OR: [{ benutzerId: benutzer.id }, { benutzerId: null }] },
|
||||
orderBy: [{ benutzerId: "asc" }, { name: "asc" }],
|
||||
});
|
||||
return zeilen.map((z): GastSchluessel => ({
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
oeffentlich: z.oeffentlich,
|
||||
fingerabdruck: z.fingerabdruck,
|
||||
bereich: z.benutzerId ? "persoenlich" : "team",
|
||||
quelle: z.quelle,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { SSH_SCHLUESSEL } from "@/lib/schemas/ausrollen";
|
||||
|
||||
/**
|
||||
* Woher die SSH-Schlüssel eines Benutzers kommen. Heute aus der eigenen Datenbank („lokal“); eine
|
||||
* externe Schlüsselverwaltung ist eine weitere Umsetzung dieses Interfaces, die in
|
||||
* `schluesselQuellen()` eingetragen wird – Assistent, API und CLI merken davon nichts.
|
||||
*/
|
||||
export type GastSchluessel = {
|
||||
id: string;
|
||||
name: string;
|
||||
oeffentlich: string;
|
||||
fingerabdruck: string;
|
||||
/** Persönlich oder für alle (Team). */
|
||||
bereich: "persoenlich" | "team";
|
||||
quelle: string;
|
||||
};
|
||||
|
||||
export interface SchluesselQuelle {
|
||||
readonly name: string;
|
||||
/** Schlüssel, die standardmäßig in Gäste dieses Benutzers kommen. */
|
||||
schluesselFuer(benutzer: { id: string; benutzername: string }): Promise<GastSchluessel[]>;
|
||||
}
|
||||
|
||||
/** Fingerprint wie `ssh-keygen -lf`: SHA256 über die Base64-dekodierten Schlüsseldaten. */
|
||||
export function fingerabdruck(oeffentlich: string): string {
|
||||
const daten = oeffentlich.trim().split(/\s+/)[1] ?? "";
|
||||
return `SHA256:${createHash("sha256").update(Buffer.from(daten, "base64")).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export function istOeffentlicherSchluessel(text: string): boolean {
|
||||
return SSH_SCHLUESSEL.test(text.trim());
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { auftragAnlegen } from "@/server/services/auftraege";
|
||||
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { vmidVorschlagen } from "@/server/services/vmid";
|
||||
|
||||
/**
|
||||
@@ -78,8 +79,22 @@ export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
||||
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anfrage keine Schlüssel an, kommen die hinterlegten des Benutzers und die Team-Schlüssel
|
||||
* hinein. Das passiert beim Anlegen, nicht im worker: dort ist bekannt, wer ausrollt, und die
|
||||
* Anfrage im Verlauf zeigt, welche Schlüssel tatsächlich im Gast gelandet sind.
|
||||
*/
|
||||
export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
|
||||
if (!roh || typeof roh !== "object") return roh;
|
||||
const anfrage = roh as { zugang?: { benutzer?: unknown; sshSchluessel?: unknown } };
|
||||
const angegeben = Array.isArray(anfrage.zugang?.sshSchluessel) ? anfrage.zugang.sshSchluessel : [];
|
||||
if (angegeben.length > 0) return roh;
|
||||
const hinterlegt = (await schluesselFuer(aufrufer.benutzer)).map((s) => s.oeffentlich);
|
||||
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
|
||||
}
|
||||
|
||||
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
|
||||
const v = await ausrollenVorbereiten(roh);
|
||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
||||
return await auftragAnlegen(
|
||||
{
|
||||
art: "AUSROLLEN",
|
||||
@@ -178,8 +193,8 @@ export type Plan = {
|
||||
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
|
||||
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||
*/
|
||||
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
||||
const v = await ausrollenVorbereiten(roh);
|
||||
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
|
||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(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,100 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { AnwendungsFehler, keinRecht, nichtGefunden } from "@/server/fehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { lokaleQuelle } from "@/server/schluessel/lokal";
|
||||
import {
|
||||
fingerabdruck,
|
||||
istOeffentlicherSchluessel,
|
||||
type GastSchluessel,
|
||||
type SchluesselQuelle,
|
||||
} from "@/server/schluessel/quelle";
|
||||
|
||||
/** Alle angebundenen Quellen. Eine externe Verwaltung kommt hier dazu. */
|
||||
export function schluesselQuellen(): SchluesselQuelle[] {
|
||||
return [lokaleQuelle];
|
||||
}
|
||||
|
||||
export async function schluesselFuer(benutzer: {
|
||||
id: string;
|
||||
benutzername: string;
|
||||
}): Promise<GastSchluessel[]> {
|
||||
const listen = await Promise.all(schluesselQuellen().map((q) => q.schluesselFuer(benutzer)));
|
||||
// Derselbe Schlüssel aus zwei Quellen nur einmal.
|
||||
const gesehen = new Set<string>();
|
||||
return listen.flat().filter((s) => !gesehen.has(s.fingerabdruck) && gesehen.add(s.fingerabdruck));
|
||||
}
|
||||
|
||||
const Eingabe = z.object({
|
||||
name: z.string().trim().min(1, "Name fehlt.").max(80),
|
||||
oeffentlich: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
istOeffentlicherSchluessel,
|
||||
"Kein öffentlicher SSH-Schlüssel (ssh-ed25519 …, ssh-rsa …, ecdsa-sha2-… ). Den .pub-Inhalt einfügen, nie den privaten Schlüssel.",
|
||||
),
|
||||
team: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function schluesselAnlegen(
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<GastSchluessel> {
|
||||
const eingabe = Eingabe.parse(roh);
|
||||
if (eingabe.team && !darf(aufrufer.benutzer.rolle, "benutzerVerwalten")) throw keinRecht();
|
||||
const abdruck = fingerabdruck(eingabe.oeffentlich);
|
||||
const benutzerId = eingabe.team ? null : aufrufer.benutzer.id;
|
||||
if (await prisma.sshSchluessel.findFirst({ where: { benutzerId, fingerabdruck: abdruck } })) {
|
||||
throw new AnwendungsFehler(409, "vergeben", "Diesen Schlüssel gibt es hier schon.");
|
||||
}
|
||||
const zeile = await prisma.$transaction(async (tx) => {
|
||||
const neu = await tx.sshSchluessel.create({
|
||||
data: { benutzerId, name: eingabe.name, oeffentlich: eingabe.oeffentlich, fingerabdruck: abdruck },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: eingabe.team ? "Team-Schlüssel hinzugefügt" : "SSH-Schlüssel hinzugefügt",
|
||||
ziel: `schluessel:${abdruck}`,
|
||||
details: { name: eingabe.name },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return neu;
|
||||
});
|
||||
return {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
oeffentlich: zeile.oeffentlich,
|
||||
fingerabdruck: abdruck,
|
||||
bereich: benutzerId ? "persoenlich" : "team",
|
||||
quelle: "lokal",
|
||||
};
|
||||
}
|
||||
|
||||
export async function schluesselLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
|
||||
const zeile = await prisma.sshSchluessel.findUnique({ where: { id } });
|
||||
if (!zeile) throw nichtGefunden("Den Schlüssel");
|
||||
const eigener = zeile.benutzerId === aufrufer.benutzer.id;
|
||||
if (!eigener && !(zeile.benutzerId === null && darf(aufrufer.benutzer.rolle, "benutzerVerwalten")))
|
||||
throw keinRecht();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.sshSchluessel.delete({ where: { id } });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: zeile.benutzerId ? "SSH-Schlüssel entfernt" : "Team-Schlüssel entfernt",
|
||||
ziel: `schluessel:${zeile.fingerabdruck}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||
|
||||
/** Je Lauf ein eigener Schlüssel – derselbe zweimal wäre für denselben Benutzer ein Duplikat. */
|
||||
function neuerSchluessel(): string {
|
||||
const ordner = mkdtempSync(join(tmpdir(), "pd-e2e-"));
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "e2e@test", "-f", join(ordner, "k")]);
|
||||
return readFileSync(join(ordner, "k.pub"), "utf8").trim();
|
||||
}
|
||||
|
||||
test("SSH-Schlüssel hinterlegen, im Assistenten vorausgewählt, wieder entfernen", async ({ page }) => {
|
||||
const name = `${E2E_PRAEFIX}-${test.info().project.name}`;
|
||||
await anmelden(page, "/einstellungen");
|
||||
// Reste eines abgebrochenen Laufs wegräumen.
|
||||
for (const alt of (await (await page.request.get("/api/v1/schluessel")).json()) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>) {
|
||||
if (alt.name.startsWith(E2E_PRAEFIX)) await page.request.delete(`/api/v1/schluessel/${alt.id}`);
|
||||
}
|
||||
await page.reload();
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByLabel("Öffentlicher Schlüssel").fill("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
||||
await expect(page.getByText(/nie den privaten Schlüssel/)).toBeVisible();
|
||||
await page.getByLabel("Öffentlicher Schlüssel").fill(neuerSchluessel());
|
||||
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
||||
await expect(page.getByText(name)).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
|
||||
await page.goto("/ausrollen");
|
||||
if (await page.getByText("Erst einen Server anlegen").isVisible())
|
||||
test.skip(true, "Kein getesteter Server vorhanden");
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
|
||||
await page.getByLabel("Name (Hostname)").fill("pd-e2e");
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
|
||||
await expect(page.getByLabel(name)).toBeChecked();
|
||||
// Mit hinterlegtem Schlüssel geht es ohne Eingabe weiter.
|
||||
await expect(page.getByRole("button", { name: "Weiter" })).toBeEnabled();
|
||||
|
||||
await page.goto("/einstellungen");
|
||||
page.once("dialog", (d) => void d.accept());
|
||||
await page.getByRole("button", { name: `Schlüssel ${name} entfernen` }).click();
|
||||
await expect(page.getByText(name)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import * as liste from "@/app/api/v1/schluessel/route";
|
||||
import * as einzeln from "@/app/api/v1/schluessel/[id]/route";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { schluesselErgaenzen, ausrollenVorbereiten } from "@/server/services/ausrollen";
|
||||
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||
import { fingerabdruck } from "@/server/schluessel/quelle";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||
|
||||
function neuerSchluessel(kommentar: string): string {
|
||||
const ordner = mkdtempSync(join(tmpdir(), "pd-key-"));
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", kommentar, "-f", join(ordner, "k")]);
|
||||
return readFileSync(join(ordner, "k.pub"), "utf8").trim();
|
||||
}
|
||||
|
||||
describe("Fingerprint", () => {
|
||||
it("entspricht ssh-keygen -lf", () => {
|
||||
const ordner = mkdtempSync(join(tmpdir(), "pd-key-"));
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(ordner, "k")]);
|
||||
const erwartet = execFileSync("ssh-keygen", ["-lf", join(ordner, "k.pub")])
|
||||
.toString()
|
||||
.split(" ")[1];
|
||||
expect(fingerabdruck(readFileSync(join(ordner, "k.pub"), "utf8"))).toBe(erwartet);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("SSH-Schlüssel", () => {
|
||||
let admin: { cookie: string; benutzer: { id: string; benutzername: string } };
|
||||
let operator: { cookie: string; benutzer: { id: string; benutzername: string } };
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
admin = await angemeldet("ADMIN");
|
||||
operator = await angemeldet("OPERATOR");
|
||||
});
|
||||
|
||||
const anlegen = async (cookie: string, rumpf: unknown) =>
|
||||
await liste.POST(anfrage("POST", "/api/v1/schluessel", cookie, rumpf));
|
||||
|
||||
it("eigene Schlüssel für alle, Team-Schlüssel nur für Admins; jeder sieht eigene + Team", async () => {
|
||||
expect(
|
||||
(await anlegen(operator.cookie, { name: "Laptop", oeffentlich: neuerSchluessel("op") })).status,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(await anlegen(operator.cookie, { name: "Team?", oeffentlich: neuerSchluessel("x"), team: true }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await anlegen(admin.cookie, { name: "Notfall", oeffentlich: neuerSchluessel("team"), team: true }))
|
||||
.status,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(await anlegen(admin.cookie, { name: "Admin", oeffentlich: neuerSchluessel("admin") })).status,
|
||||
).toBe(201);
|
||||
|
||||
const sicht = await (await liste.GET(anfrage("GET", "/api/v1/schluessel", operator.cookie))).json();
|
||||
expect(sicht.map((s: { name: string; bereich: string }) => `${s.name}:${s.bereich}`).sort()).toEqual([
|
||||
"Laptop:persoenlich",
|
||||
"Notfall:team",
|
||||
]);
|
||||
});
|
||||
|
||||
it("weist private Schlüssel und Doppelte ab", async () => {
|
||||
const privat = await anlegen(operator.cookie, {
|
||||
name: "x",
|
||||
oeffentlich: "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----",
|
||||
});
|
||||
expect(privat.status).toBe(400);
|
||||
expect((await privat.json()).fehler.felder.oeffentlich).toMatch(/nie den privaten/);
|
||||
const schluessel = neuerSchluessel("doppelt");
|
||||
await anlegen(operator.cookie, { name: "a", oeffentlich: schluessel });
|
||||
expect((await anlegen(operator.cookie, { name: "b", oeffentlich: schluessel })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("fremde Schlüssel darf niemand löschen, Team-Schlüssel nur Admins", async () => {
|
||||
const eigener = await (
|
||||
await anlegen(admin.cookie, { name: "Admin", oeffentlich: neuerSchluessel("a") })
|
||||
).json();
|
||||
const team = await (
|
||||
await anlegen(admin.cookie, { name: "Team", oeffentlich: neuerSchluessel("t"), team: true })
|
||||
).json();
|
||||
const loeschen = (id: string, cookie: string) =>
|
||||
einzeln.DELETE(anfrage("DELETE", `/api/v1/schluessel/${id}`, cookie), kontext({ id }));
|
||||
expect((await loeschen(eigener.id, operator.cookie)).status).toBe(403);
|
||||
expect((await loeschen(team.id, operator.cookie)).status).toBe(403);
|
||||
expect((await loeschen(team.id, admin.cookie)).status).toBe(204);
|
||||
});
|
||||
|
||||
it("Ausrollen ohne Schlüssel nimmt die hinterlegten (eigene + Team); angegebene gewinnen", async () => {
|
||||
await katalogSynchronisieren();
|
||||
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",
|
||||
},
|
||||
});
|
||||
const eigen = neuerSchluessel("eigen");
|
||||
const team = neuerSchluessel("team");
|
||||
await anlegen(operator.cookie, { name: "eigen", oeffentlich: eigen });
|
||||
await anlegen(admin.cookie, { name: "team", oeffentlich: team, team: true });
|
||||
const roh = {
|
||||
typ: "vm",
|
||||
server: "rz1",
|
||||
distribution: "debian-13",
|
||||
name: "web01",
|
||||
storage: "s",
|
||||
netz: { bridge: "vmbr0" },
|
||||
zugang: { benutzer: "florian" },
|
||||
};
|
||||
const aufrufer = { benutzer: { ...operator.benutzer } as never, quelle: "CLI" as const };
|
||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
||||
expect(v.anfrage.zugang.sshSchluessel.sort()).toEqual([eigen, team].sort());
|
||||
|
||||
const anders = neuerSchluessel("anders");
|
||||
const nurAngegeben = await schluesselErgaenzen(
|
||||
{ ...roh, zugang: { benutzer: "florian", sshSchluessel: [anders] } },
|
||||
aufrufer,
|
||||
);
|
||||
expect((nurAngegeben as { zugang: { sshSchluessel: string[] } }).zugang.sshSchluessel).toEqual([anders]);
|
||||
});
|
||||
|
||||
it("ohne hinterlegte und ohne angegebene Schlüssel: verständlicher Fehler", async () => {
|
||||
await katalogSynchronisieren();
|
||||
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",
|
||||
},
|
||||
});
|
||||
const roh = {
|
||||
typ: "vm",
|
||||
server: "rz1",
|
||||
distribution: "debian-13",
|
||||
name: "web01",
|
||||
storage: "s",
|
||||
netz: { bridge: "vmbr0" },
|
||||
zugang: { benutzer: "florian" },
|
||||
};
|
||||
await expect(
|
||||
ausrollenVorbereiten(
|
||||
await schluesselErgaenzen(roh, { benutzer: operator.benutzer as never, quelle: "CLI" }),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user