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