Files
sshkeyvault/src/server/actions/verwendungen.ts
T
florian.wildeandClaude Opus 5.5 cab6f45510 feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen.
Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per
Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen,
alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen.

Der Nachfolger steht während der Rotation in Block und Pflichtliste,
damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur
nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:27:09 +02:00

187 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server";
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 { instanzZiele } from "@/server/services/deploymentkey";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
export type VorgangsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
function alsAntwort(fehler: unknown): { fehler: string } {
if (
fehler instanceof VerwendungsFehler ||
fehler instanceof RechteFehler ||
fehler instanceof BestaetigungFehler
) {
return { fehler: fehler.message };
}
throw fehler;
}
export async function publicZuweisenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
try {
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
// „host:<id>“ oder „gruppe:<id>“ aus einer einzigen Auswahlliste.
const ziel = textfeld(formular, "ziel") ?? "";
const [art, id] = ziel.split(":");
if (!id || (art !== "host" && art !== "gruppe"))
return { feldfehler: { ziel: "Bitte ein Ziel wählen." } };
await publicVerwendungAnlegen(
{
schluesselId,
hostId: art === "host" ? id : null,
hostgruppeId: art === "gruppe" ? id : null,
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
optionen: textfeld(formular, "optionen") ?? null,
},
akteurVon(benutzer),
);
revalidatePath(`/schluessel/${schluesselId}`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function publicEntfernenAktion(
verwendungId: string,
schluesselId: string,
): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
await publicVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
revalidatePath(`/schluessel/${schluesselId}`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export type AusrollAuswahl =
| { readonly schluesselId: string }
| { readonly hostId: string }
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
| { readonly schluesselIds: readonly string[] }
/** Alle Hosts mit dem Instanz-Deployment-Key – fuer dessen Rotation (#031). */
| { readonly deploymentKey: true };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
try {
if ("deploymentKey" in auswahl) {
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
return { vorgangId: await trockenlaufStarten({ ziele: await instanzZiele() }, akteurVon(benutzer)) };
}
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const id = z.string().min(1).max(64);
return {
vorgangId: await trockenlaufStarten(
"schluesselId" in auswahl
? { schluesselId: id.parse(auswahl.schluesselId) }
: "schluesselIds" in auswahl
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
: { 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) {
return alsAntwort(fehler);
}
}
export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAntwort> {
try {
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
return {
vorgangId: await ausrollenStarten(z.string().min(1).max(64).parse(trockenlaufId), akteurVon(benutzer)),
};
} catch (fehler) {
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);
}
}