feat(ausrollen): Public Keys mit Trockenlauf und Backup ausrollen (#016)
Zuweisungen von Public Keys an Hosts oder Hostgruppen, ein Trockenlauf mit Diff je Ziel und das Ausrollen genau dieses Plans. Hat sich die Datei oder das Soll seit dem Trockenlauf geaendert, bricht das Ziel ab: es wird nie etwas anderes geschrieben, als jemand gesehen hat. Auf dem Host geschieht alles als Zielbenutzer, mit Sicherung, Temp- Datei, mv und 0700/0600; der Hash wird dort erneut geprueft. Der Deployment-Key gehoert immer zum Soll des Anmeldebenutzers. Integrationstests gegen echtes sshd, die Oberflaechenbausteine folgen mit der Schluesselseite aus #012. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"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 {
|
||||
VerwendungsFehler,
|
||||
ausrollenStarten,
|
||||
publicVerwendungAnlegen,
|
||||
publicVerwendungEntfernen,
|
||||
trockenlaufStarten,
|
||||
} from "@/server/services/verwendungen";
|
||||
|
||||
/** 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 async function trockenlaufAktion(schluesselId: string): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
return {
|
||||
vorgangId: await trockenlaufStarten(
|
||||
{ schluesselId: z.string().min(1).max(64).parse(schluesselId) },
|
||||
akteurVon(benutzer),
|
||||
),
|
||||
};
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Auswahl fuer „Public Key ausrollen auf …“ (#016): Hosts mit Bootstrap und Hostgruppen. */
|
||||
export async function zielAuswahl() {
|
||||
const [hosts, gruppen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: { id: true, name: true, bootstrapAm: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.hostgruppe.findMany({
|
||||
select: { id: true, name: true, _count: { select: { mitglieder: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
|
||||
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
|
||||
};
|
||||
}
|
||||
|
||||
export type ZielAuswahl = Awaited<ReturnType<typeof zielAuswahl>>;
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Host } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { SollEintrag } from "@/lib/ssh/authorized-keys";
|
||||
|
||||
/**
|
||||
* Der Soll-Zustand einer authorized_keys (#016): welche Public Keys gehoeren in den
|
||||
* verwalteten Block eines Benutzers auf einem Host – direkt zugewiesen oder ueber eine
|
||||
* Hostgruppe. Laeuft im Worker und im Web (Vorschau), deshalb hier und nicht in src/worker.
|
||||
*/
|
||||
|
||||
/** Dienstbenutzer auf den Hosts (Freigabe Frage 1); gleiche Regel wie im Worker. */
|
||||
export function anmeldebenutzerVon(host: Pick<Host, "anmeldeart">): string {
|
||||
return host.anmeldeart === "ROOT" ? "root" : "sshkv";
|
||||
}
|
||||
|
||||
/** Zustaende, in denen eine Verwendung zum Soll gehoert. */
|
||||
const IM_SOLL = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||
|
||||
export type Soll = {
|
||||
readonly eintraege: SollEintrag[];
|
||||
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
||||
readonly pflicht: string[];
|
||||
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
||||
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
||||
};
|
||||
|
||||
async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId">) {
|
||||
if (host.deploymentSchluesselId) {
|
||||
return prisma.schluessel.findUnique({
|
||||
where: { id: host.deploymentSchluesselId },
|
||||
select: { publicKey: true, fingerprint: true },
|
||||
});
|
||||
}
|
||||
const gruppe = await prisma.hostgruppeMitglied.findFirst({
|
||||
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||
select: {
|
||||
hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true, fingerprint: true } } } },
|
||||
},
|
||||
});
|
||||
if (gruppe?.hostgruppe.deploymentSchluessel) return gruppe.hostgruppe.deploymentSchluessel;
|
||||
return prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { publicKey: true, fingerprint: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
||||
const host = await prisma.host.findUniqueOrThrow({
|
||||
where: { id: hostId },
|
||||
select: { id: true, anmeldeart: true, deploymentSchluesselId: true },
|
||||
});
|
||||
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
zielbenutzer,
|
||||
OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }],
|
||||
zustand: { not: "ENTFERNT" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
optionen: true,
|
||||
zustand: true,
|
||||
angelegtAm: true,
|
||||
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
|
||||
},
|
||||
// Deterministische Reihenfolge im Block: aelteste Zuweisung zuerst.
|
||||
orderBy: [{ angelegtAm: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
const eintraege: SollEintrag[] = [];
|
||||
const pflicht: string[] = [];
|
||||
if (zielbenutzer === anmeldebenutzerVon(host)) {
|
||||
const deployment = await deploymentKeyFuer(host);
|
||||
if (deployment) {
|
||||
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
||||
pflicht.push(deployment.fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
const drin: string[] = [];
|
||||
const raus: string[] = [];
|
||||
for (const verwendung of verwendungen) {
|
||||
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
||||
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
|
||||
eintraege.push({
|
||||
publicKey: verwendung.schluessel.publicKey,
|
||||
fingerprint: verwendung.schluessel.fingerprint,
|
||||
optionen: verwendung.optionen,
|
||||
});
|
||||
drin.push(verwendung.id);
|
||||
} else {
|
||||
raus.push(verwendung.id);
|
||||
}
|
||||
}
|
||||
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
||||
}
|
||||
|
||||
export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
|
||||
|
||||
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
|
||||
export async function zieleVonVerwendungen(where: {
|
||||
schluesselId?: string;
|
||||
ids?: string[];
|
||||
}): Promise<Ziel[]> {
|
||||
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||
...(where.ids ? { id: { in: where.ids } } : {}),
|
||||
zustand: { not: "ENTFERNT" },
|
||||
},
|
||||
select: {
|
||||
zielbenutzer: true,
|
||||
hostId: true,
|
||||
hostgruppe: {
|
||||
select: { mitglieder: { select: { hostId: true, host: { select: { archiviertAm: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const ziele = new Map<string, Ziel>();
|
||||
for (const verwendung of verwendungen) {
|
||||
const hostIds = verwendung.hostId
|
||||
? [verwendung.hostId]
|
||||
: (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []);
|
||||
for (const hostId of hostIds) {
|
||||
ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, {
|
||||
hostId,
|
||||
zielbenutzer: verwendung.zielbenutzer,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...ziele.values()];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { zieleVonVerwendungen, type Ziel } from "@/server/services/soll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Public-Key-Zuweisungen (#016): anlegen, zum Entfernen vormerken, Trockenlauf und
|
||||
* Ausfuehren anstossen. Geschrieben wird nur im Worker.
|
||||
*/
|
||||
export class VerwendungsFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "VerwendungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
export type PublicZuweisung = {
|
||||
readonly schluesselId: string;
|
||||
readonly hostId?: string | null;
|
||||
readonly hostgruppeId?: string | null;
|
||||
readonly zielbenutzer: string;
|
||||
readonly optionen?: string | null;
|
||||
};
|
||||
|
||||
export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur: Akteur): Promise<string> {
|
||||
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer)) {
|
||||
throw new VerwendungsFehler(
|
||||
"Ungültiger Zielbenutzer (Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen).",
|
||||
);
|
||||
}
|
||||
if (Boolean(eingabe.hostId) === Boolean(eingabe.hostgruppeId)) {
|
||||
throw new VerwendungsFehler("Bitte genau einen Host oder eine Hostgruppe wählen.");
|
||||
}
|
||||
let optionen: string | null = null;
|
||||
try {
|
||||
optionen = optionenPruefen(eingabe.optionen ?? "") || null;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof OptionenFehler) throw new VerwendungsFehler(fehler.message);
|
||||
throw fehler;
|
||||
}
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: eingabe.schluesselId },
|
||||
select: { status: true, fingerprint: true, kategorie: true },
|
||||
});
|
||||
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (schluessel.status !== "AKTIV")
|
||||
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
||||
}
|
||||
const ziel = eingabe.hostId
|
||||
? await prisma.host.findUnique({
|
||||
where: { id: eingabe.hostId },
|
||||
select: { name: true, archiviertAm: true },
|
||||
})
|
||||
: await prisma.hostgruppe.findUnique({ where: { id: eingabe.hostgruppeId! }, select: { name: true } });
|
||||
if (!ziel || ("archiviertAm" in ziel && ziel.archiviertAm))
|
||||
throw new VerwendungsFehler("Das Ziel gibt es nicht (mehr).");
|
||||
|
||||
try {
|
||||
const verwendung = await prisma.publicVerwendung.create({
|
||||
data: {
|
||||
schluesselId: eingabe.schluesselId,
|
||||
hostId: eingabe.hostId ?? null,
|
||||
hostgruppeId: eingabe.hostgruppeId ?? null,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
optionen,
|
||||
angelegtVonId: akteur.id,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "verwendung.public.angelegt",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: eingabe.hostId ? ziel.name : null,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { hostgruppe: eingabe.hostgruppeId ? ziel.name : null, optionen },
|
||||
});
|
||||
return verwendung.id;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||
throw new VerwendungsFehler("Dieser Schlüssel ist dort für diesen Benutzer bereits zugewiesen.");
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt eine Verwendung zum Entfernen vor; wirksam mit dem naechsten Ausrollen. */
|
||||
export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Promise<void> {
|
||||
const verwendung = await prisma.publicVerwendung.findUnique({
|
||||
where: { id },
|
||||
include: { schluessel: { select: { fingerprint: true } }, host: { select: { name: true } } },
|
||||
});
|
||||
if (!verwendung || verwendung.zustand === "ENTFERNT")
|
||||
throw new VerwendungsFehler("Diese Zuweisung gibt es nicht.");
|
||||
// Nie ausgerollt: einfach weg, auf dem Host steht noch nichts.
|
||||
const nieAusgerollt = verwendung.zustand === "GEPLANT" && !verwendung.zuletztAngewendetAm;
|
||||
await prisma.publicVerwendung.update({
|
||||
where: { id },
|
||||
data: { zustand: nieAusgerollt ? "ENTFERNT" : "ENTFERNEN" },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "verwendung.public.entfernen",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: verwendung.host?.name ?? null,
|
||||
zielbenutzer: verwendung.zielbenutzer,
|
||||
fingerprint: verwendung.schluessel.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||
export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const ziele =
|
||||
auswahl.ziele ??
|
||||
(await zieleVonVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||
}));
|
||||
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
|
||||
return vorgangEinreihen({
|
||||
art: "TROCKENLAUF",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: auswahl.schluesselId ?? null,
|
||||
eingabe: { ziele },
|
||||
});
|
||||
}
|
||||
|
||||
export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): Promise<string> {
|
||||
const trockenlauf = await prisma.vorgang.findUnique({ where: { id: trockenlaufId } });
|
||||
if (!trockenlauf || trockenlauf.art !== "TROCKENLAUF" || trockenlauf.status !== "ERLEDIGT") {
|
||||
throw new VerwendungsFehler("Erst den Trockenlauf abwarten.");
|
||||
}
|
||||
return vorgangEinreihen({
|
||||
art: "AUSROLLEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: trockenlauf.schluesselId,
|
||||
eingabe: { trockenlaufId },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user