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:
@@ -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