feat(schluessel): Zuweisen und Ausrollen auf der Schluesselseite (#017)

Auf der Seite eines Schluessels: Public Key einem Host oder einer
Gruppe zuweisen, Private Key (nur Admin) auf einen Quell-Host legen -
mit ~/.ssh/config-Eintrag und dem Host-Key des Ziels in known_hosts -,
Dry-Run fuer alle Ziele, anwenden und die Verbindung von A nach B
testen. Der Kern-Anwendungsfall laeuft damit im Browser durch (E2E).

Der Dry-Run nimmt Private-Key-Ziele nur fuer Admins auf: sonst koennte
ein Operator einen vorbereiteten Private Key ausrollen lassen. Die
Vorschau zeigt je Datei die Aktion; der Inhalt eines Private Keys
erreicht den Browser nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:05:03 +02:00
co-authored by Claude Opus 5.5
parent 68136e8f07
commit 12603eb145
15 changed files with 660 additions and 39 deletions
+72
View File
@@ -4,13 +4,17 @@ import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import {
VerwendungsFehler,
ausrollenStarten,
privateVerwendungAnlegen,
privateVerwendungEntfernen,
publicVerwendungAnlegen,
publicVerwendungEntfernen,
trockenlaufStarten,
} from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
@@ -82,6 +86,8 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
? { schluesselId: id.parse(auswahl.schluesselId) }
: { hostId: id.parse(auswahl.hostId) },
akteurVon(benutzer),
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
darf(benutzer.rolle, "privateKeyAusrollen"),
),
};
} catch (fehler) {
@@ -99,3 +105,69 @@ export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAn
return alsAntwort(fehler);
}
}
/** Private Key einem Quell-Host zuweisen (#017) – nur Admins. */
export async function privateZuweisenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
const alias = (textfeld(formular, "alias") ?? "").trim();
const zielHostId = textfeld(formular, "zielHostId");
await privateVerwendungAnlegen(
{
schluesselId,
hostId: textfeld(formular, "hostId") ?? "",
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
pfad: textfeld(formular, "pfad") ?? "",
sshConfig: alias
? {
alias,
hostName: (textfeld(formular, "hostName") ?? "").trim(),
benutzer: (textfeld(formular, "sshBenutzer") ?? "").trim(),
port: Number(textfeld(formular, "port") ?? "22"),
}
: null,
knownHostsFuer: zielHostId ? [zielHostId] : [],
beimWiderruf: textfeld(formular, "beimWiderruf") === "UMBENENNEN" ? "UMBENENNEN" : "LOESCHEN",
},
akteurVon(benutzer),
);
revalidatePath(`/schluessel/${schluesselId}`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function privateEntfernenAktion(
verwendungId: string,
schluesselId: string,
): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
await privateVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
revalidatePath(`/schluessel/${schluesselId}`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
/** Verbindungstest A → B (#020). Harmlos, aber nur fuer Rollen, die Hosts verwalten. */
export async function verbindungTestenAktion(privatVerwendungId: string): Promise<VorgangsAntwort> {
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
return {
vorgangId: await vorgangEinreihen({
art: "VERBINDUNGSTEST",
angefordertVonId: benutzer.id,
eingabe: { privatVerwendungId: z.string().min(1).max(64).parse(privatVerwendungId) },
}),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
+2 -1
View File
@@ -5,7 +5,7 @@ export async function zielAuswahl() {
const [hosts, gruppen] = await Promise.all([
prisma.host.findMany({
where: { archiviertAm: null },
select: { id: true, name: true, bootstrapAm: true },
select: { id: true, name: true, bootstrapAm: true, adresse: true, port: true },
orderBy: { name: "asc" },
}),
prisma.hostgruppe.findMany({
@@ -15,6 +15,7 @@ export async function zielAuswahl() {
]);
return {
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
hostsDetails: hosts.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port })),
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
};
}
+3 -1
View File
@@ -120,6 +120,8 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
export async function trockenlaufStarten(
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
akteur: Akteur,
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
mitPrivat = false,
): Promise<string> {
const ziele = auswahl.ziele ?? [
...(await zieleVonVerwendungen({
@@ -128,7 +130,7 @@ export async function trockenlaufStarten(
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
})),
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
...(auswahl.verwendungIds
...(auswahl.verwendungIds || !mitPrivat
? []
: await zieleVonPrivatVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),