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:
2026-10-12 14:28:45 +02:00
co-authored by Claude Opus 5.5
parent 00712dc6ce
commit 426d3ec95a
18 changed files with 690 additions and 39 deletions
+1
View File
@@ -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)
+3 -2
View File
@@ -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;
+17
View File
@@ -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])
}
+55 -31
View File
@@ -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
.split("\n")
.map((z) => z.trim())
.filter(Boolean),
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"
+7
View File
@@ -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),
+24
View File
@@ -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>
);
}
+2 -2
View File
@@ -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));
});
+13
View File
@@ -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 });
});
+20
View File
@@ -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 },
);
});
+5 -1
View File
@@ -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
+21
View File
@@ -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,
}));
},
};
+33
View File
@@ -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());
}
+18 -3
View File
@@ -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;
+100
View File
@@ -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,
);
});
}
+49
View File
@@ -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);
});
+157
View File
@@ -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();
});
});