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]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### 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)
|
- 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)
|
- 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)
|
- 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
|
## Aufgaben
|
||||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
- [ ] 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
|
- [ ] Audit-Log-Ansicht mit Filter
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
|||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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
|
geaendertAm DateTime @updatedAt
|
||||||
protokoll Protokoll[]
|
protokoll Protokoll[]
|
||||||
auftraege Auftrag[]
|
auftraege Auftrag[]
|
||||||
|
schluessel SshSchluessel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||||
@@ -292,3 +293,19 @@ model Vorlage {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
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;
|
benutzer: string;
|
||||||
version: string;
|
version: string;
|
||||||
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
|
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
|
||||||
|
schluessel: Array<{
|
||||||
|
name: string;
|
||||||
|
oeffentlich: string;
|
||||||
|
fingerabdruck: string;
|
||||||
|
bereich: "persoenlich" | "team";
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Plan = {
|
type Plan = {
|
||||||
@@ -94,6 +100,8 @@ type Werte = {
|
|||||||
suchdomain: string;
|
suchdomain: string;
|
||||||
benutzer: string;
|
benutzer: string;
|
||||||
schluessel: string;
|
schluessel: string;
|
||||||
|
/** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */
|
||||||
|
ohneSchluessel: string[];
|
||||||
profile: string[];
|
profile: string[];
|
||||||
variablen: Record<string, Record<string, string | boolean>>;
|
variablen: Record<string, Record<string, string | boolean>>;
|
||||||
templateBauen: boolean;
|
templateBauen: boolean;
|
||||||
@@ -128,9 +136,7 @@ function dekodieren(text: string | null): Partial<Werte> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
|
function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe {
|
||||||
|
|
||||||
function anfrageAus(w: Werte): AusrollEingabe {
|
|
||||||
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
||||||
return {
|
return {
|
||||||
typ: w.typ,
|
typ: w.typ,
|
||||||
@@ -158,10 +164,13 @@ function anfrageAus(w: Werte): AusrollEingabe {
|
|||||||
},
|
},
|
||||||
zugang: {
|
zugang: {
|
||||||
benutzer: w.benutzer,
|
benutzer: w.benutzer,
|
||||||
sshSchluessel: w.schluessel
|
sshSchluessel: [
|
||||||
.split("\n")
|
...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich),
|
||||||
.map((z) => z.trim())
|
...w.schluessel
|
||||||
.filter(Boolean),
|
.split("\n")
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
profile: w.profile,
|
profile: w.profile,
|
||||||
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
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 {
|
function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||||
const a = anfrageAus(w);
|
const a = anfrageAus(w, []);
|
||||||
return {
|
return {
|
||||||
typ: a.typ,
|
typ: a.typ,
|
||||||
server: a.server,
|
server: a.server,
|
||||||
@@ -256,6 +265,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
suchdomain: "",
|
suchdomain: "",
|
||||||
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
||||||
schluessel: "",
|
schluessel: "",
|
||||||
|
ohneSchluessel: [],
|
||||||
profile: ["base"],
|
profile: ["base"],
|
||||||
variablen: {},
|
variablen: {},
|
||||||
templateBauen: false,
|
templateBauen: false,
|
||||||
@@ -305,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
};
|
};
|
||||||
|
|
||||||
const gehe = (ziel: number) => {
|
const gehe = (ziel: number) => {
|
||||||
let naechste = werte;
|
const 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
||||||
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
||||||
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
||||||
@@ -357,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
||||||
return "Gültigen Benutzernamen angeben (nicht 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)))
|
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
|
||||||
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
||||||
return null;
|
return null;
|
||||||
@@ -372,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
setLaeuft(true);
|
setLaeuft(true);
|
||||||
setFehler(null);
|
setFehler(null);
|
||||||
try {
|
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) {
|
} catch (ursache) {
|
||||||
setFehler(
|
setFehler(
|
||||||
ursache instanceof ApiFehler
|
ursache instanceof ApiFehler
|
||||||
@@ -388,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
setLaeuft(true);
|
setLaeuft(true);
|
||||||
setFehler(null);
|
setFehler(null);
|
||||||
try {
|
try {
|
||||||
try {
|
const { kurzId } = await apiAufruf<{ kurzId: string }>(
|
||||||
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
|
"POST",
|
||||||
} catch {
|
"/api/v1/auftraege",
|
||||||
// egal
|
anfrageAus(werte, daten.schluessel),
|
||||||
}
|
);
|
||||||
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
|
|
||||||
router.push(`/auftraege/${kurzId}`);
|
router.push(`/auftraege/${kurzId}`);
|
||||||
} catch (ursache) {
|
} catch (ursache) {
|
||||||
setFehler(
|
setFehler(
|
||||||
@@ -871,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</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
|
<Feld
|
||||||
id="schluessel"
|
id="schluessel"
|
||||||
label="Öffentliche SSH-Schlüssel"
|
label={
|
||||||
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
|
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
|
<textarea
|
||||||
id="schluessel"
|
id="schluessel"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { versionText } from "@/lib/version";
|
|||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||||
import { vorlagenListe } from "@/server/services/vorlagen";
|
import { vorlagenListe } from "@/server/services/vorlagen";
|
||||||
|
import { schluesselFuer } from "@/server/services/schluessel";
|
||||||
import { Assistent, type AssistentDaten } from "./assistent";
|
import { Assistent, type AssistentDaten } from "./assistent";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Ausrollen" };
|
export const metadata: Metadata = { title: "Ausrollen" };
|
||||||
@@ -75,6 +76,12 @@ export default async function Ausrollen() {
|
|||||||
benutzer: benutzer.benutzername,
|
benutzer: benutzer.benutzername,
|
||||||
version: versionText(),
|
version: versionText(),
|
||||||
vorlagen: await vorlagenListe(),
|
vorlagen: await vorlagenListe(),
|
||||||
|
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
|
||||||
|
name: x.name,
|
||||||
|
oeffentlich: x.oeffentlich,
|
||||||
|
fingerabdruck: x.fingerabdruck,
|
||||||
|
bereich: x.bereich,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const vorauswahl = aktiverServer(
|
const vorauswahl = aktiverServer(
|
||||||
daten.server.map((s) => s.name),
|
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. */
|
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
|
||||||
export const POST = api(async (anfrage: Request) => {
|
export const POST = api(async (anfrage: Request) => {
|
||||||
await rechtErforderlich(anfrage, "lesen");
|
const aufrufer = await rechtErforderlich(anfrage, "lesen");
|
||||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
|
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()
|
.trim()
|
||||||
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
|
.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."),
|
.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
|
sshSchluessel: z
|
||||||
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
|
.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
|
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 { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
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 { vmidVorschlagen } from "@/server/services/vmid";
|
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 };
|
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) {
|
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(
|
return await auftragAnlegen(
|
||||||
{
|
{
|
||||||
art: "AUSROLLEN",
|
art: "AUSROLLEN",
|
||||||
@@ -178,8 +193,8 @@ export type Plan = {
|
|||||||
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
|
* 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.
|
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||||
*/
|
*/
|
||||||
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
|
||||||
const v = await ausrollenVorbereiten(roh);
|
const v = await ausrollenVorbereiten(await schluesselErgaenzen(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,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