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