From f66d87ad11e1ece604ea5610574a65f3ec43ffc9 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:17:35 +0200 Subject: [PATCH] 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 --- src/components/ausrollen/AusrollenPanel.tsx | 133 +++++++++ .../ausrollen/PublicZuweisenFormular.tsx | 77 +++++ src/components/vorgang/useVorgang.ts | 53 ++++ src/server/actions/verwendungen.ts | 96 +++++++ src/server/queries/ziele.ts | 22 ++ src/server/services/soll.ts | 131 +++++++++ src/server/services/verwendungen.ts | 149 ++++++++++ src/worker/ablaeufe/ausrollen.ts | 267 ++++++++++++++++++ src/worker/handler.ts | 5 +- src/worker/ssh/authorized-keys-datei.ts | 141 +++++++++ src/worker/ssh/verbindung.ts | 13 +- tests/integration/ausrollen.test.ts | 195 +++++++++++++ tests/integration/hilfen/host.ts | 60 ++++ 13 files changed, 1339 insertions(+), 3 deletions(-) create mode 100644 src/components/ausrollen/AusrollenPanel.tsx create mode 100644 src/components/ausrollen/PublicZuweisenFormular.tsx create mode 100644 src/components/vorgang/useVorgang.ts create mode 100644 src/server/actions/verwendungen.ts create mode 100644 src/server/queries/ziele.ts create mode 100644 src/server/services/soll.ts create mode 100644 src/server/services/verwendungen.ts create mode 100644 src/worker/ablaeufe/ausrollen.ts create mode 100644 src/worker/ssh/authorized-keys-datei.ts create mode 100644 tests/integration/ausrollen.test.ts create mode 100644 tests/integration/hilfen/host.ts 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 } }); +}