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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user