diff --git a/src/components/ausrollen/AusrollenPanel.tsx b/src/components/ausrollen/AusrollenPanel.tsx new file mode 100644 index 0000000..fc4a82b --- /dev/null +++ b/src/components/ausrollen/AusrollenPanel.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Play, ScanSearch } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; +import { Button } from "@/components/ui/Button"; +import { DiffBox } from "@/components/ui/DiffBox"; +import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang"; +import { ausrollenAktion, trockenlaufAktion } from "@/server/actions/verwendungen"; +import type { DiffZeile } from "@/lib/ssh/zeilen-diff"; + +/** + * Dry-Run-Vorschau und Ausfuehren (#016). Was angezeigt wird, ist genau das, was der Worker + * schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf + * dem Host oder an den Zuweisungen etwas geaendert hat. + */ +type PlanZiel = { + hostName: string; + zielbenutzer: string; + pfad?: string; + geaendert: boolean; + diff: DiffZeile[]; + hinweise: string[]; + fehler?: string; +}; + +export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string; offen: number }) { + const router = useRouter(); + const [trockenlaufId, setTrockenlaufId] = useState(); + const [ausrollenId, setAusrollenId] = useState(); + const [fehler, setFehler] = useState(); + const [startet, starten] = useTransition(); + const trockenlauf = useVorgang(trockenlaufId); + const ausrollen = useVorgang(ausrollenId); + + useEffect(() => { + if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh(); + }, [ausrollen.stand, router]); + + const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele; + const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0; + const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand); + const fertig = ausrollen.stand?.status === "ERLEDIGT"; + + return ( +
+
+ + {offen > 0 && !plan && ( + {offen} Zuweisung(en) noch nicht ausgerollt. + )} +
+ + {(fehler || trockenlauf.fehler || trockenlauf.stand?.status === "FEHLGESCHLAGEN") && ( +

+ {fehler ?? trockenlauf.fehler ?? trockenlauf.stand?.fehler} +

+ )} + + {plan?.map((ziel) => ( +
+ {ziel.fehler ? ( +

+ {ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler} +

+ ) : ( + + )} + {ziel.hinweise.map((h) => ( +

+ ▲ {h} +

+ ))} +
+ ))} + + {plan && aenderungen > 0 && !fertig && ( +
+ +
+ + +
+
+ )} + {plan && aenderungen === 0 && ( +

Alles ist bereits auf dem Soll-Stand.

+ )} + {ausrollen.stand?.status === "FEHLGESCHLAGEN" && ( +

+ {ausrollen.stand.fehler} +

+ )} + {fertig &&

Ausgerollt.

} +
+ ); +} diff --git a/src/components/ausrollen/PublicZuweisenFormular.tsx b/src/components/ausrollen/PublicZuweisenFormular.tsx new file mode 100644 index 0000000..2ff5080 --- /dev/null +++ b/src/components/ausrollen/PublicZuweisenFormular.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Button, Feld, Formularfehler } from "@/components/ui"; +import { Auswahlliste } from "@/components/ui/Feld"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { publicZuweisenAktion } from "@/server/actions/verwendungen"; +import type { ZielAuswahl } from "@/server/queries/ziele"; + +/** + * Public Key einem Host oder einer Hostgruppe zuweisen (#016). Die Zuweisung allein aendert + * noch nichts auf dem Host – erst Trockenlauf und Ausrollen. + */ +export function PublicZuweisenFormular({ + schluesselId, + auswahl, + beiErfolg, +}: { + schluesselId: string; + auswahl: ZielAuswahl; + beiErfolg?: () => void; +}) { + const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => { + const ergebnis = await publicZuweisenAktion(vorher, formular); + if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.(); + return ergebnis; + }); + return ( +
+ + + + + + {auswahl.hosts.map((h) => ( + + ))} + + {auswahl.gruppen.length > 0 && ( + + {auswahl.gruppen.map((g) => ( + + ))} + + )} + + {zustand.feldfehler?.["ziel"] && ( +

{zustand.feldfehler["ziel"]}

+ )} + + + + + ); +} diff --git a/src/components/vorgang/useVorgang.ts b/src/components/vorgang/useVorgang.ts new file mode 100644 index 0000000..6560463 --- /dev/null +++ b/src/components/vorgang/useVorgang.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** Was /api/vorgaenge/[id] liefert (#009, #016). */ +export type VorgangsStand = { + readonly id: string; + readonly status: string; + readonly fehler: string | null; + readonly plan: unknown; + readonly ergebnis: unknown; +}; + +const LAUFEND = new Set(["WARTEND", "LAEUFT"]); + +export function laeuftNoch(stand: VorgangsStand | undefined): boolean { + return stand !== undefined && LAUFEND.has(stand.status); +} + +/** Fragt einen Vorgang jede Sekunde ab, bis er fertig ist. */ +export function useVorgang(vorgangId: string | undefined): { stand?: VorgangsStand; fehler?: string } { + // Der Stand merkt sich seine Vorgangs-ID: so zeigt ein neuer Vorgang nie den alten Stand, + // ohne dass der Effekt synchron zuruecksetzen muss. + const [stand, setStand] = useState(); + const [fehler, setFehler] = useState<{ id: string; text: string }>(); + + useEffect(() => { + if (!vorgangId) return; + let abgebrochen = false; + void (async () => { + while (!abgebrochen) { + const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" }); + if (!antwort.ok) { + setFehler({ id: vorgangId, text: "Status des Vorgangs nicht abrufbar." }); + return; + } + const neu = (await antwort.json()) as VorgangsStand; + if (abgebrochen) return; + setStand(neu); + if (!LAUFEND.has(neu.status)) return; + await new Promise((weiter) => setTimeout(weiter, 1000)); + } + })(); + return () => { + abgebrochen = true; + }; + }, [vorgangId]); + + return { + ...(stand && stand.id === vorgangId ? { stand } : {}), + ...(fehler && fehler.id === vorgangId ? { fehler: fehler.text } : {}), + }; +} diff --git a/src/server/actions/verwendungen.ts b/src/server/actions/verwendungen.ts new file mode 100644 index 0000000..a88ac82 --- /dev/null +++ b/src/server/actions/verwendungen.ts @@ -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 { + try { + const { benutzer } = await rechtErforderlich("publicKeyAusrollen"); + const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId")); + // „host:“ oder „gruppe:“ 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 { + 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 { + 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); + } +} diff --git a/src/server/queries/ziele.ts b/src/server/queries/ziele.ts new file mode 100644 index 0000000..a30b48b --- /dev/null +++ b/src/server/queries/ziele.ts @@ -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>; diff --git a/src/server/services/soll.ts b/src/server/services/soll.ts new file mode 100644 index 0000000..7ca83ba --- /dev/null +++ b/src/server/services/soll.ts @@ -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): 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) { + 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 { + 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 { + 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(); + 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()]; +} diff --git a/src/server/services/verwendungen.ts b/src/server/services/verwendungen.ts new file mode 100644 index 0000000..6e5e466 --- /dev/null +++ b/src/server/services/verwendungen.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }, + }); +} diff --git a/src/worker/ablaeufe/ausrollen.ts b/src/worker/ablaeufe/ausrollen.ts new file mode 100644 index 0000000..e1ed08f --- /dev/null +++ b/src/worker/ablaeufe/ausrollen.ts @@ -0,0 +1,267 @@ +import { createHash } from "node:crypto"; +import type { Client } from "ssh2"; +import type { Host } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { BlockFehler, blockSetzen } from "@/lib/ssh/authorized-keys"; +import { diffVerdichten, type DiffZeile } from "@/lib/ssh/zeilen-diff"; +import { sollFuer, type Ziel } from "@/server/services/soll"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { authorizedKeysHolen, authorizedKeysSchreiben, inhaltHash } from "@/worker/ssh/authorized-keys-datei"; +import { hostVerbinden } from "@/worker/ssh/host"; + +/** + * Public Keys ausrollen (#016) in zwei Schritten: + * + * 1. TROCKENLAUF: je Ziel (Host, Benutzer) die Datei lesen, den Block aus dem Soll setzen und + * Diff, Hinweise und beide Hashes festhalten. Auf dem Host aendert sich nichts. + * 2. AUSROLLEN: genau diesen Plan ausfuehren. Hat sich die Datei oder das Soll seitdem + * geaendert, bricht das Ziel ab (optimistische Sperre ueber die Hashes) – der Mensch + * soll nie etwas anderes schreiben, als er gesehen hat. + */ + +/** So lange darf ein Trockenlauf alt sein, bevor er ausgefuehrt wird. */ +export const TROCKENLAUF_GUELTIG_MS = 15 * 60 * 1000; + +export type PlanZiel = { + readonly hostId: string; + readonly hostName: string; + readonly zielbenutzer: string; + readonly pfad?: string; + readonly vorher?: string; + readonly nachher?: string; + readonly geaendert: boolean; + readonly diff: DiffZeile[]; + readonly hinweise: string[]; + readonly fehler?: string; +}; + +export type Plan = { readonly ziele: PlanZiel[] }; + +function hashVon(text: string): string { + return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex"); +} + +function fehlerText(fehler: unknown): string { + if (fehler instanceof VorgangsFehler || fehler instanceof BlockFehler) return fehler.message; + return "Interner Fehler – Details im Worker-Log."; +} + +function zieleLesen(eingabe: unknown): Ziel[] { + const ziele = (eingabe as { ziele?: unknown } | null)?.ziele; + if (!Array.isArray(ziele) || ziele.length === 0) + throw new VorgangsFehler("Der Trockenlauf nennt keine Ziele."); + return ziele.map((z) => { + const ziel = z as Partial; + if (typeof ziel.hostId !== "string" || typeof ziel.zielbenutzer !== "string") { + throw new VorgangsFehler("Ungültiges Ziel im Trockenlauf."); + } + return { hostId: ziel.hostId, zielbenutzer: ziel.zielbenutzer }; + }); +} + +/** Ziele nach Host gruppieren: je Host genau eine SSH-Verbindung. */ +async function jeHost( + ziele: readonly Ziel[], + arbeit: (host: Host, verbindung: Client, benutzer: string) => Promise, + beiFehler: (host: Pick, benutzer: string, fehler: unknown) => T, +): Promise { + const nachHost = new Map(); + for (const ziel of ziele) + nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel.zielbenutzer]); + const ergebnisse: T[] = []; + for (const [hostId, benutzerListe] of nachHost) { + const host = await prisma.host.findUnique({ where: { id: hostId } }); + if (!host || host.archiviertAm) { + for (const benutzer of benutzerListe) { + ergebnisse.push( + beiFehler( + { id: hostId, name: host?.name ?? "?" }, + benutzer, + new VorgangsFehler("Host ist archiviert oder fehlt."), + ), + ); + } + continue; + } + let verbindung: Client; + try { + verbindung = await hostVerbinden(host); + } catch (fehler) { + for (const benutzer of benutzerListe) ergebnisse.push(beiFehler(host, benutzer, fehler)); + continue; + } + try { + for (const benutzer of benutzerListe) { + try { + ergebnisse.push(await arbeit(host, verbindung, benutzer)); + } catch (fehler) { + ergebnisse.push(beiFehler(host, benutzer, fehler)); + } + } + } finally { + verbindung.end(); + } + } + return ergebnisse; +} + +export const trockenlaufHandler: Handler = async ({ vorgang }) => { + const ziele = zieleLesen(vorgang.eingabe); + const planZiele = await jeHost( + ziele, + async (host, verbindung, benutzer) => { + const stand = await authorizedKeysHolen(verbindung, host, benutzer); + const soll = await sollFuer(host.id, benutzer); + const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht); + return { + hostId: host.id, + hostName: host.name, + zielbenutzer: benutzer, + pfad: stand.pfad, + vorher: stand.sha256, + nachher: hashVon(ergebnis.inhalt), + geaendert: ergebnis.geaendert || !stand.vorhanden, + diff: diffVerdichten(ergebnis.diff), + hinweise: ergebnis.hinweise, + }; + }, + (host, benutzer, fehler) => ({ + hostId: host.id, + hostName: host.name, + zielbenutzer: benutzer, + geaendert: false, + diff: [], + hinweise: [], + fehler: fehlerText(fehler), + }), + ); + return { plan: { ziele: planZiele }, ergebnis: { ziele: planZiele.length } }; +}; + +export const ausrollenHandler: Handler = async ({ vorgang }) => { + const trockenlaufId = (vorgang.eingabe as { trockenlaufId?: unknown } | null)?.trockenlaufId; + if (typeof trockenlaufId !== "string") throw new VorgangsFehler("Kein Trockenlauf angegeben."); + const trockenlauf = await prisma.vorgang.findUnique({ where: { id: trockenlaufId } }); + if ( + !trockenlauf || + trockenlauf.art !== "TROCKENLAUF" || + trockenlauf.status !== "ERLEDIGT" || + !trockenlauf.plan + ) { + throw new VorgangsFehler("Der Trockenlauf ist nicht abgeschlossen."); + } + if (Date.now() - (trockenlauf.beendetAm ?? trockenlauf.angelegtAm).getTime() > TROCKENLAUF_GUELTIG_MS) { + throw new VorgangsFehler("Der Trockenlauf ist älter als 15 Minuten. Bitte neu starten."); + } + const bereitsAusgefuehrt = await prisma.vorgang.findFirst({ + where: { + art: "AUSROLLEN", + id: { not: vorgang.id }, + status: { in: ["LAEUFT", "ERLEDIGT"] }, + eingabe: { equals: { trockenlaufId } }, + }, + }); + if (bereitsAusgefuehrt) throw new VorgangsFehler("Dieser Trockenlauf wurde bereits ausgeführt."); + + const plan = trockenlauf.plan as unknown as Plan; + const auszufuehren = plan.ziele.filter((ziel) => ziel.geaendert && !ziel.fehler); + const akteur = vorgang.angefordertVonId + ? await prisma.benutzer.findUnique({ + where: { id: vorgang.angefordertVonId }, + select: { id: true, name: true }, + }) + : null; + + type Ausgang = { + readonly hostId: string; + readonly zielbenutzer: string; + readonly erfolg: boolean; + readonly fehler?: string; + }; + const geplant = new Map(auszufuehren.map((z) => [`${z.hostId}\u0000${z.zielbenutzer}`, z])); + + const ausgaenge = await jeHost( + auszufuehren, + async (host, verbindung, benutzer) => { + const ziel = geplant.get(`${host.id}\u0000${benutzer}`)!; + const stand = await authorizedKeysHolen(verbindung, host, benutzer); + if (stand.sha256 !== ziel.vorher) { + throw new VorgangsFehler( + `authorized_keys von ${benutzer}@${host.name} wurde seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`, + ); + } + const soll = await sollFuer(host.id, benutzer); + const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht); + if (hashVon(ergebnis.inhalt) !== ziel.nachher) { + throw new VorgangsFehler( + `Die Zuweisungen für ${benutzer}@${host.name} haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`, + ); + } + await authorizedKeysSchreiben(verbindung, host, benutzer, ergebnis.inhalt, stand.sha256); + await protokollieren({ + akteur: akteur ?? "System", + aktion: "authorized_keys.geschrieben", + ergebnis: "ERFOLG", + hostName: host.name, + zielbenutzer: benutzer, + diff: ergebnis.diff + .filter((z) => z.art !== "kontext") + .map((z) => `${z.art === "hinzu" ? "+" : "-"} ${z.text}`) + .join("\n"), + details: { + vorher: stand.sha256, + nachher: inhaltHash(Buffer.from(ergebnis.inhalt, "utf8")), + trockenlaufId, + }, + }); + return { hostId: host.id, zielbenutzer: benutzer, erfolg: true }; + }, + (host, benutzer, fehler) => ({ + hostId: host.id, + zielbenutzer: benutzer, + erfolg: false, + fehler: fehlerText(fehler), + }), + ); + + // Zustand der Verwendungen nachziehen – auch fuer Ziele ohne Aenderung, die schon stimmten. + const fehlgeschlagen = new Set( + ausgaenge.filter((a) => !a.erfolg).map((a) => `${a.hostId}\u0000${a.zielbenutzer}`), + ); + // Fehlschlaege zuletzt: eine Gruppen-Verwendung, die auf einem Host scheitert, soll + // FEHLGESCHLAGEN bleiben, auch wenn ein anderer Host danach erfolgreich war. + const reihenfolge = plan.ziele + .filter((z) => !z.fehler) + .sort( + (a, b) => + Number(fehlgeschlagen.has(`${a.hostId}\u0000${a.zielbenutzer}`)) - + Number(fehlgeschlagen.has(`${b.hostId}\u0000${b.zielbenutzer}`)), + ); + for (const ziel of reihenfolge) { + const schluessel = `${ziel.hostId}\u0000${ziel.zielbenutzer}`; + const soll = await sollFuer(ziel.hostId, ziel.zielbenutzer); + const jetzt = new Date(); + if (fehlgeschlagen.has(schluessel)) { + await prisma.publicVerwendung.updateMany({ + where: { id: { in: soll.verwendungIds.drin } }, + data: { zustand: "FEHLGESCHLAGEN" }, + }); + continue; + } + await prisma.publicVerwendung.updateMany({ + where: { id: { in: soll.verwendungIds.drin } }, + data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, + }); + await prisma.publicVerwendung.updateMany({ + where: { id: { in: soll.verwendungIds.raus }, zustand: "ENTFERNEN" }, + data: { zustand: "ENTFERNT", zuletztAngewendetAm: jetzt }, + }); + } + + const fehler = ausgaenge.filter((a) => !a.erfolg); + if (fehler.length > 0 && fehler.length === ausgaenge.length) { + throw new VorgangsFehler(fehler.map((f) => f.fehler).join(" ")); + } + return { ergebnis: { geschrieben: ausgaenge.filter((a) => a.erfolg).length, fehler } }; +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index 0596c06..dae834e 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -1,12 +1,15 @@ import type { HandlerTabelle } from "@/worker/ausfuehren"; +import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen"; import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; /** - * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) … + * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) … */ export const HANDLER: HandlerTabelle = { HOSTKEY_ABRUFEN: hostKeyAbrufenHandler, ERREICHBARKEIT: erreichbarkeitHandler, BOOTSTRAP: bootstrapHandler, + TROCKENLAUF: trockenlaufHandler, + AUSROLLEN: ausrollenHandler, }; diff --git a/src/worker/ssh/authorized-keys-datei.ts b/src/worker/ssh/authorized-keys-datei.ts new file mode 100644 index 0000000..db2226d --- /dev/null +++ b/src/worker/ssh/authorized-keys-datei.ts @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import type { Client } from "ssh2"; +import type { Host } from "@/generated/prisma/client"; +import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell"; +import { VorgangsFehler } from "@/worker/ausfuehren"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; + +/** + * authorized_keys eines Benutzers auf einem Host lesen und schreiben (#016). + * + * Wie beim Bootstrap: alles im Home des Zielbenutzers geschieht **als dieser Benutzer** + * (`runuser`), damit ein Symlink root nie eine fremde Datei lesen oder schreiben laesst. + * Der Hash der Datei wird auf dem Host berechnet und beim Schreiben dort erneut geprueft – + * hat sich die Datei seit dem Trockenlauf geaendert, bricht der Vorgang ab. + */ + +/** Hash fuer „Datei gibt es nicht“. */ +export const KEINE_DATEI = "keine-datei"; +/** So viele Sicherungen bleiben je Datei liegen. */ +const SICHERUNGEN_BEHALTEN = 5; + +const MARKE_FEHLT = "SSHKV-BENUTZER-FEHLT"; +const MARKE_GEAENDERT = "SSHKV-GEAENDERT"; +const MARKE_OK = "SSHKV-GESCHRIEBEN"; + +export type DateiStand = { + readonly pfad: string; + readonly vorhanden: boolean; + readonly sha256: string; + readonly inhalt: string; +}; + +function praefix(host: Pick): string { + return host.anmeldeart === "ROOT" ? "" : "sudo -n "; +} + +function benutzerPruefen(benutzer: string): void { + if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new VorgangsFehler(`Ungültiger Zielbenutzer „${benutzer}“.`); +} + +export function inhaltHash(inhalt: Buffer | null): string { + return inhalt === null ? KEINE_DATEI : createHash("sha256").update(inhalt).digest("hex"); +} + +/** + * Liest die Datei. Ausgabeformat des Skripts: erste Zeile ` `, danach die Datei + * unveraendert – so kommen auch Bytes an, die kein Text sind. + */ +export async function authorizedKeysHolen( + verbindung: Client, + host: Host, + benutzer: string, +): Promise { + benutzerPruefen(benutzer); + const skript = `set -eu +B=${shellQuote(benutzer)} +getent passwd "$B" >/dev/null || { echo ${MARKE_FEHLT}; exit 3; } +H=$(getent passwd "$B" | cut -d: -f6) +D="$H/.ssh/authorized_keys" +if runuser -u "$B" -- test -e "$D"; then echo "$H ja"; runuser -u "$B" -- cat "$D"; else echo "$H nein"; fi`; + const ergebnis = await befehlAusfuehren( + verbindung, + `${praefix(host)}sh -c ${shellQuote(skript)}`, + undefined, + true, + ); + const roh = ergebnis.rohStdout ?? Buffer.alloc(0); + if (roh.toString("utf8").startsWith(MARKE_FEHLT)) { + throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`); + } + if (ergebnis.code !== 0) { + throw new VorgangsFehler(`authorized_keys von ${benutzer}@${host.name} ließ sich nicht lesen.`); + } + const zeilenende = roh.indexOf(0x0a); + const kopf = roh.subarray(0, zeilenende).toString("utf8"); + const vorhanden = kopf.endsWith(" ja"); + const home = kopf.replace(/ (ja|nein)$/, ""); + const inhalt = roh.subarray(zeilenende + 1); + // Nur gueltiges UTF-8 wird bearbeitet: sonst gingen fremde Bytes beim Zurueckschreiben + // verloren, und „bytegenau“ waere gebrochen. + if (!Buffer.from(inhalt.toString("utf8"), "utf8").equals(inhalt)) { + throw new VorgangsFehler( + `authorized_keys von ${benutzer}@${host.name} enthält ungültiges UTF-8. Bitte von Hand prüfen.`, + ); + } + return { + pfad: `${home}/.ssh/authorized_keys`, + vorhanden, + sha256: inhaltHash(vorhanden ? inhalt : null), + inhalt: inhalt.toString("utf8"), + }; +} + +/** + * Schreibt die Datei, wenn ihr Hash auf dem Host noch `erwartet` ist: Sicherung, Temp-Datei, + * Rechte, `mv`. Alte Sicherungen ueber SICHERUNGEN_BEHALTEN hinaus werden entfernt. + */ +export async function authorizedKeysSchreiben( + verbindung: Client, + host: Host, + benutzer: string, + inhalt: string, + erwartet: string, +): Promise<{ sicherung: boolean }> { + benutzerPruefen(benutzer); + if (!/^([0-9a-f]{64}|keine-datei)$/.test(erwartet)) throw new VorgangsFehler("Ungültiger Datei-Hash."); + const skript = `set -eu +umask 077 +B=${shellQuote(benutzer)} +ERWARTET=${shellQuote(erwartet)} +getent passwd "$B" >/dev/null || { echo ${MARKE_FEHLT}; exit 3; } +H=$(getent passwd "$B" | cut -d: -f6) +als() { runuser -u "$B" -- "$@"; } +als mkdir -p "$H/.ssh" +als chmod 0700 "$H/.ssh" +D="$H/.ssh/authorized_keys" +if als test -e "$D"; then IST=$(als cat "$D" | sha256sum | cut -d' ' -f1); else IST=${KEINE_DATEI}; fi +[ "$IST" = "$ERWARTET" ] || { echo ${MARKE_GEAENDERT}; exit 4; } +if als test -e "$D"; then + als cp -p "$D" "$D.sshkv.bak.$(date +%Y%m%d-%H%M%S)" + als sh -c 'ls -1t "$1".sshkv.bak.* 2>/dev/null | tail -n +${SICHERUNGEN_BEHALTEN + 1} | while read -r alt; do rm -f -- "$alt"; done' sh "$D" +fi +N=$(als mktemp "$H/.ssh/.sshkv.tmp.XXXXXX") +als tee "$N" >/dev/null +als chmod 0600 "$N" +als mv "$N" "$D" +echo ${MARKE_OK}`; + const ergebnis = await befehlAusfuehren(verbindung, `${praefix(host)}sh -c ${shellQuote(skript)}`, inhalt); + if (ergebnis.stdout.includes(MARKE_GEAENDERT)) { + throw new VorgangsFehler( + `authorized_keys von ${benutzer}@${host.name} wurde seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`, + ); + } + if (ergebnis.stdout.includes(MARKE_FEHLT)) { + throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`); + } + if (ergebnis.code !== 0 || !ergebnis.stdout.includes(MARKE_OK)) { + throw new VorgangsFehler(`authorized_keys von ${benutzer}@${host.name} ließ sich nicht schreiben.`); + } + return { sicherung: erwartet !== KEINE_DATEI }; +} diff --git a/src/worker/ssh/verbindung.ts b/src/worker/ssh/verbindung.ts index 8f1f972..c00ebf9 100644 --- a/src/worker/ssh/verbindung.ts +++ b/src/worker/ssh/verbindung.ts @@ -189,7 +189,13 @@ function verbindungsFehler(fehler: unknown, ziel: Ziel): VorgangsFehler { return new VorgangsFehler(`SSH-Verbindung zu ${ziel.adresse} fehlgeschlagen.`, { cause: fehler }); } -export type Befehlsergebnis = { readonly stdout: string; readonly stderr: string; readonly code: number }; +export type Befehlsergebnis = { + readonly stdout: string; + readonly stderr: string; + readonly code: number; + /** Nur mit `roh = true`: stdout als Bytes, fuer Dateien, die exakt ankommen muessen. */ + readonly rohStdout?: Buffer; +}; /** * Fuehrt einen Befehl aus. `stdin` wird geschrieben und geschlossen – so reisen Dateiinhalte @@ -199,6 +205,7 @@ export function befehlAusfuehren( client: Client, befehl: string, stdin?: string | Buffer, + roh = false, ): Promise { return new Promise((erfuellen, ablehnen) => { client.exec(befehl, (fehler, strom) => { @@ -227,8 +234,10 @@ export function befehlAusfuehren( if (laenge > AUSGABE_HOECHSTENS) { return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet.")); } + const stdout = Buffer.concat(ausgabe); erfuellen({ - stdout: Buffer.concat(ausgabe).toString("utf8"), + ...(roh ? { rohStdout: stdout } : {}), + stdout: stdout.toString("utf8"), stderr: Buffer.concat(fehlerAusgabe).toString("utf8"), code: code ?? -1, }); diff --git a/tests/integration/ausrollen.test.ts b/tests/integration/ausrollen.test.ts new file mode 100644 index 0000000..47e714c --- /dev/null +++ b/tests/integration/ausrollen.test.ts @@ -0,0 +1,195 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { schluesselpaarErzeugen } from "@/lib/ssh/openssh"; +import { publicVerwendungAnlegen, publicVerwendungEntfernen } from "@/server/services/verwendungen"; +import { deploymentKeySicherstellen } from "@/worker/geheimnisse"; +import type { Plan } from "@/worker/ablaeufe/ausrollen"; +import { datenbankLeeren } from "../hilfen/db"; +import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; +import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele"; + +/** Public Keys ausrollen gegen ein echtes sshd (#016). */ + +const AKTEUR = { id: "test", name: "Test" }; +let aufraeumen: () => void; + +beforeAll(async () => { + await zielBereit(zielA); + aufraeumen = masterKeyAnlegen(); +}); +afterAll(() => aufraeumen()); + +beforeEach(async () => { + await datenbankLeeren(); + await deploymentKeySicherstellen(); + await befehlAlsRoot( + zielA, + "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys* /home/test/.ssh/authorized_keys*; true", + ); +}); + +async function schluesselAnlegen(name: string) { + const paar = await schluesselpaarErzeugen("ED25519", { kommentar: `sshkv:${name}` }); + return prisma.schluessel.create({ + data: { + name, + slug: name, + typ: "ED25519", + kategorie: "DIENST", + publicKey: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + kommentar: `sshkv:${name}`, + }, + }); +} + +async function trockenlauf(schluesselId: string) { + const vorgang = await vorgangLaufen("TROCKENLAUF", { + eingabe: { + ziele: ( + await prisma.publicVerwendung.findMany({ + where: { schluesselId }, + include: { hostgruppe: { include: { mitglieder: true } } }, + }) + ).flatMap((v) => + v.hostId + ? [{ hostId: v.hostId, zielbenutzer: v.zielbenutzer }] + : (v.hostgruppe?.mitglieder.map((m) => ({ hostId: m.hostId, zielbenutzer: v.zielbenutzer })) ?? []), + ), + }, + }); + expect(vorgang.status).toBe("ERLEDIGT"); + return { id: vorgang.id, plan: vorgang.plan as unknown as Plan }; +} + +const datei = async (pfad: string) => (await befehlAlsRoot(zielA, `cat ${pfad} 2>/dev/null || true`)).stdout; + +describe("Public Key ausrollen", () => { + it("zeigt im Trockenlauf die Aenderung, schreibt erst beim Ausrollen", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && printf '# von Hand\\n' > /root/.ssh/authorized_keys"); + const schluessel = await schluesselAnlegen("backup"); + const verwendungId = await publicVerwendungAnlegen( + { + schluesselId: schluessel.id, + hostId: host.id, + zielbenutzer: "root", + optionen: 'restrict,from="10.0.0.12"', + }, + AKTEUR, + ); + + const { id, plan } = await trockenlauf(schluessel.id); + expect(plan.ziele).toHaveLength(1); + expect(plan.ziele[0]).toMatchObject({ + zielbenutzer: "root", + geaendert: true, + pfad: "/root/.ssh/authorized_keys", + }); + expect(plan.ziele[0]!.diff).toContainEqual({ + art: "hinzu", + text: `restrict,from="10.0.0.12" ${schluessel.publicKey}`, + }); + expect(await datei("/root/.ssh/authorized_keys")).toBe("# von Hand\n"); + + const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); + expect(ausrollen.fehler).toBeNull(); + const inhalt = await datei("/root/.ssh/authorized_keys"); + expect(inhalt).toBe( + `# von Hand\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nrestrict,from="10.0.0.12" ${schluessel.publicKey}\n# END SSHKV MANAGED\n`, + ); + expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: verwendungId } })).zustand).toBe( + "AUSGEROLLT", + ); + expect((await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c sshkv.bak")).stdout.trim()).toBe("1"); + expect((await befehlAlsRoot(zielA, "stat -c %a /root/.ssh/authorized_keys")).stdout.trim()).toBe("600"); + const eintrag = await prisma.protokoll.findFirst({ where: { aktion: "authorized_keys.geschrieben" } }); + expect(eintrag?.diff).toContain("+ restrict"); + + // Derselbe Trockenlauf nicht zweimal. + const nochmal = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); + expect(nochmal.fehler).toMatch(/bereits ausgeführt/); + }); + + it("bricht ab, wenn die Datei seit dem Trockenlauf geaendert wurde", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + const schluessel = await schluesselAnlegen("backup"); + await publicVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" }, + AKTEUR, + ); + const { id } = await trockenlauf(schluessel.id); + await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && echo '# dazwischen' >> /root/.ssh/authorized_keys"); + const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); + expect(ausrollen.status).toBe("FEHLGESCHLAGEN"); + expect(ausrollen.fehler).toMatch(/seit dem Trockenlauf geändert/); + expect(await datei("/root/.ssh/authorized_keys")).toBe("# dazwischen\n"); + }); + + it("bricht ab, wenn sich die Zuweisungen seit dem Trockenlauf geaendert haben", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + const a = await schluesselAnlegen("a"); + const b = await schluesselAnlegen("b"); + await publicVerwendungAnlegen({ schluesselId: a.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR); + const { id } = await trockenlauf(a.id); + await publicVerwendungAnlegen({ schluesselId: b.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR); + const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); + expect(ausrollen.fehler).toMatch(/Zuweisungen .* geändert/); + }); + + it("entfernt einen Schluessel wieder, und der Deployment-Key bleibt", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + const schluessel = await schluesselAnlegen("weg"); + const id = await publicVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "sshkv" }, + AKTEUR, + ); + const erster = await trockenlauf(schluessel.id); + // Im Block des Dienstbenutzers steht der Deployment-Key schon; nur der neue kommt hinzu. + expect(erster.plan.ziele[0]!.diff.filter((z) => z.art !== "kontext")).toEqual([ + { art: "hinzu", text: schluessel.publicKey }, + ]); + await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: erster.id } }); + expect(await datei("/home/sshkv/.ssh/authorized_keys")).toContain(schluessel.publicKey); + + await publicVerwendungEntfernen(id, AKTEUR); + const zweiter = await trockenlauf(schluessel.id); + expect(zweiter.plan.ziele[0]!.diff).toContainEqual({ art: "entfernt", text: schluessel.publicKey }); + await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: zweiter.id } }); + const inhalt = await datei("/home/sshkv/.ssh/authorized_keys"); + expect(inhalt).not.toContain(schluessel.publicKey); + expect(inhalt).toContain("sshkv-deployment@"); + expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id } })).zustand).toBe("ENTFERNT"); + }); + + it("rollt ueber eine Hostgruppe fuer einen gewoehnlichen Benutzer aus", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + const gruppe = await prisma.hostgruppe.create({ + data: { name: "Test", mitglieder: { create: { hostId: host.id } } }, + }); + const schluessel = await schluesselAnlegen("gruppe"); + await publicVerwendungAnlegen( + { schluesselId: schluessel.id, hostgruppeId: gruppe.id, zielbenutzer: "test" }, + AKTEUR, + ); + const { id } = await trockenlauf(schluessel.id); + await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); + expect(await datei("/home/test/.ssh/authorized_keys")).toContain(schluessel.publicKey); + const besitz = await befehlAlsRoot( + zielA, + "stat -c '%U %a' /home/test/.ssh /home/test/.ssh/authorized_keys", + ); + expect(besitz.stdout).toBe("test 700\ntest 600\n"); + }); + + it("meldet einen fehlenden Benutzer im Trockenlauf je Ziel", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + const schluessel = await schluesselAnlegen("niemand"); + await publicVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "gibtsnicht" }, + AKTEUR, + ); + const { plan } = await trockenlauf(schluessel.id); + expect(plan.ziele[0]!.fehler).toMatch(/gibt es auf ziel-a nicht/); + }); +}); diff --git a/tests/integration/hilfen/host.ts b/tests/integration/hilfen/host.ts new file mode 100644 index 0000000..455352b --- /dev/null +++ b/tests/integration/hilfen/host.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { prisma } from "@/lib/db"; +import { verschluesseln } from "@/lib/geheimnisse"; +import { vorgangAusfuehren } from "@/worker/ausfuehren"; +import { masterKeyVergessen } from "@/worker/geheimnisse"; +import { HANDLER } from "@/worker/handler"; +import { rootPasswort, type Ziel } from "./ziele"; + +/** Gemeinsame Vorbereitung fuer Integrationstests: Master-Key, Host mit Bootstrap (#016). */ + +export function masterKeyAnlegen(): () => void { + const verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-")); + const datei = path.join(verzeichnis, "master.key"); + writeFileSync(datei, randomBytes(32), { mode: 0o600 }); + process.env["SSHKV_MASTER_KEY_FILE"] = datei; + masterKeyVergessen(); + return () => rmSync(verzeichnis, { recursive: true, force: true }); +} + +export async function vorgangLaufen( + art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN", + daten: { + hostId?: string; + eingabe?: object; + geheim?: string; + angefordertVonId?: string; + }, +) { + const vorgang = await prisma.vorgang.create({ + data: { + art, + hostId: daten.hostId ?? null, + eingabe: daten.eingabe ?? {}, + angefordertVonId: daten.angefordertVonId ?? null, + }, + }); + await vorgangAusfuehren(vorgang.id, HANDLER, daten.geheim); + return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } }); +} + +/** Host anlegen, Host-Key vertrauen, Bootstrap per root-Passwort. */ +export async function eingerichteterHost( + ziel: Ziel, + name: string, + anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER", +) { + const host = await prisma.host.create({ data: { name, adresse: ziel.host, port: ziel.port, anmeldeart } }); + await vorgangLaufen("HOSTKEY_ABRUFEN", { hostId: host.id }); + await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } }); + const bootstrap = await vorgangLaufen("BOOTSTRAP", { + hostId: host.id, + eingabe: { modus: "passwort", benutzer: "root" }, + geheim: verschluesseln(rootPasswort()), + }); + if (bootstrap.status !== "ERLEDIGT") throw new Error(`Bootstrap fehlgeschlagen: ${bootstrap.fehler}`); + return prisma.host.findUniqueOrThrow({ where: { id: host.id } }); +}