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
+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,
);
});
}