diff --git a/docs/issues/021-abgleich-drift.md b/docs/issues/021-abgleich-drift.md index b75aae0..790103e 100644 --- a/docs/issues/021-abgleich-drift.md +++ b/docs/issues/021-abgleich-drift.md @@ -1,20 +1,27 @@ # #021 Abgleich und Drift-Erkennung -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.4.0 **Labels:** backend -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min ## Ziel Periodischer und manueller Abgleich, Befunde je Host/Benutzer. ## Aufgaben -- [ ] Zeitplan konfigurierbar -- [ ] Befundarten +- [x] Zeitplan konfigurierbar +- [x] Befundarten ## Akzeptanzkriterien -- [ ] Integrationstest +- [x] Integrationstest ## Abhängigkeiten #016 ## Notizen / Entscheidungen +- **Ein SSH-Aufruf je Host** liest `authorized_keys` aller Benutzer mit UID 0 oder ≥ 1000 plus aller Benutzer mit Zuweisungen – als jeweiliger Benutzer, base64-kodiert (Zeilenenden und fremde Bytes kommen unversehrt an). Dazu Rechte und Eigentümer von `~/.ssh` und Datei. +- **Befunde** (rein, `src/lib/ssh/abgleich.ts`): fehlender Key im Block, Block von Hand geändert (zusätzlicher Key oder andere Optionen), unbekannter Key außerhalb (bekannte Keys außerhalb sind kein Befund), Rechte (für Gruppe/Welt beschreibbar oder fremder Eigentümer), beschädigter Block, Private Key fehlt oder verändert (Hash gegen den versiegelten Key). +- **Abweichungen** werden geöffnet und – sobald behoben – geschlossen, nie doppelt. Host-Key-Abweichungen bleiben bei #009. +- **Zustände:** Public-Verwendungen AUSGEROLLT ↔ ABWEICHUNG, GEPLANT bleibt bis zum Ausrollen; Host SYNCHRON/DRIFT, letzter Abgleich, erreichbar. +- **Zeitplan** über pg-boss (`SSHKV_SYNC_CRON`, Standard `30 2 * * *`, Europe/Berlin) – liegt in der Datenbank, feuert also auch bei mehreren Workern nur einmal. Hosts mit laufendem Abgleich werden übersprungen. +- **Ändert nichts auf dem Host.** Korrekturen laufen über Dry-Run und Ausrollen. +- `DATEI_GEAENDERT` meldet nur einen beschädigten Block; eine bloße Hash-Änderung ist kein Befund, weil sshkv selbst schreibt. diff --git a/docs/issues/022-import.md b/docs/issues/022-import.md index 4b60741..6c09f13 100644 --- a/docs/issues/022-import.md +++ b/docs/issues/022-import.md @@ -1,19 +1,23 @@ # #022 Import bestehender Schlüssel -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.4.0 **Labels:** backend, ui -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min ## Ziel Unbekannte Schlüssel eines Hosts übernehmen und zuordnen. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] wird zu Beginn der Phase verfeinert ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] wird zu Beginn der Phase verfeinert ## Abhängigkeiten #021 ## Notizen / Entscheidungen +- **Grundlage ist die letzte Beobachtung** des Abgleichs: Übernommen wird nur, was sshkv dort wirklich gesehen hat. +- **Zwei Wege:** „bekannt“ (nur als Schlüssel aufnehmen – der Befund verschwindet, auf dem Host ändert sich nichts) oder „verwalten“ (Zuweisung mit `ausserhalbEntfernen`: beim nächsten Ausrollen wandert die Zeile mit ihren Optionen in den Block, die alte Zeile außerhalb verschwindet – mit Dry-Run). +- Neues Feld `PublicVerwendung.ausserhalbEntfernen` (Migration `20261012131000`). Ohne dieses Feld fasst sshkv Zeilen außerhalb des Blocks weiterhin nie an. +- Optionen der Zeile werden mit `optionenPruefen` übernommen; unbekannte Optionen verhindern das Verwalten mit klarer Meldung. diff --git a/prisma/migrations/20261012131000_verwendung_ausserhalb_entfernen/migration.sql b/prisma/migrations/20261012131000_verwendung_ausserhalb_entfernen/migration.sql new file mode 100644 index 0000000..f9f6586 --- /dev/null +++ b/prisma/migrations/20261012131000_verwendung_ausserhalb_entfernen/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PublicVerwendung" ADD COLUMN "ausserhalbEntfernen" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38d6161..f372c12 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -333,6 +333,9 @@ model PublicVerwendung { zielbenutzer String /// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12". optionen String? + /// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene + /// Keys (#022), sonst fasst sshkv fremde Zeilen nie an. + ausserhalbEntfernen Boolean @default(false) zustand VerwendungsZustand @default(GEPLANT) zuletztAngewendetAm DateTime? angelegtVonId String? diff --git a/src/app/(app)/hosts/[id]/page.tsx b/src/app/(app)/hosts/[id]/page.tsx index 8ddf26d..ba0a910 100644 --- a/src/app/(app)/hosts/[id]/page.tsx +++ b/src/app/(app)/hosts/[id]/page.tsx @@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { hostDetail, type HostDetail } from "@/server/queries/hosts"; import { BootstrapAktionen } from "./BootstrapAktionen"; import { sshdHinweise } from "@/lib/sshd-hinweise"; +import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; +import { istZustand } from "@/server/queries/abgleich"; import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand"; import { verwendungenAufHost } from "@/server/queries/ziele"; import { HostKeyAktionen } from "./HostKeyAktionen"; @@ -100,6 +102,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert; const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert; const verwendungen = await verwendungenAufHost(host.id); + const ist = await istZustand(host.id); const jetzt = new Date(); return ( @@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri )} + {host.bootstrapAm && ( + + )} + {verwalten && (
diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 775a60f..bd5151c 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react"; +import { ArrowRight, Plus, Route } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { @@ -10,6 +10,7 @@ import { letzterAbgleich, type AktionsArt, } from "@/beispiel/uebersicht"; +import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Karte } from "@/components/ui/Karte"; @@ -52,7 +53,7 @@ export default async function Uebersicht() { } aktionen={ <> - + diff --git a/src/components/abgleich/AbgleichKarten.tsx b/src/components/abgleich/AbgleichKarten.tsx new file mode 100644 index 0000000..80c43f2 --- /dev/null +++ b/src/components/abgleich/AbgleichKarten.tsx @@ -0,0 +1,139 @@ +import { Badge } from "@/components/ui/Badge"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf"; +import { datumZeit } from "@/lib/format"; +import { abgleichAktion } from "@/server/actions/abgleich"; +import type { IstZustand } from "@/server/queries/abgleich"; +import { UebernehmenDialog } from "./UebernehmenDialog"; + +/** Abweichungen und Ist-Zustand eines Hosts (#021, #022). */ +const ART_TEXT: Record = { + UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel", + FEHLENDER_SCHLUESSEL: "Schlüssel fehlt", + ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert", + DATEI_GEAENDERT: "Block beschädigt", + RECHTE: "Rechte", + HOSTKEY_GEAENDERT: "Host-Key geändert", + PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert", +}; + +const HERKUNFT: Record = { + verwaltet: { text: "verwaltet", variante: "ok" }, + bekannt: { text: "bekannt", variante: "neutral" }, + fremd: { text: "unbekannt", variante: "gefahr" }, +}; + +export function AbgleichKarten({ + hostId, + zustand, + darfAbgleichen, + darfUebernehmen, + darfVerwalten, +}: { + hostId: string; + zustand: IstZustand; + darfAbgleichen: boolean; + darfUebernehmen: boolean; + darfVerwalten: boolean; +}) { + return ( + <> + + ) : null + } + > + {zustand.abweichungen.length === 0 ? ( +

● Keine offenen Abweichungen.

+ ) : ( +
    + {zustand.abweichungen.map((a) => ( +
  • + + ● {ART_TEXT[a.art] ?? a.art} + {a.zielbenutzer && ( + · {a.zielbenutzer} + )} + + {a.text} + + {a.fingerprint ? `${a.fingerprint} · ` : ""}seit {datumZeit(a.eroeffnetAm)} + +
  • + ))} +
+ )} +
+ + + {zustand.beobachtungen.length === 0 ? ( + + ) : ( +
+ {zustand.beobachtungen.map((b) => ( +
+

+ {b.zielbenutzer}{" "} + · {datumZeit(b.beobachtetAm)} +

+ {b.eintraege.length === 0 ? ( +

Datei leer.

+ ) : ( +
    + {b.eintraege.map((e) => { + const herkunft = HERKUNFT[e.herkunft]!; + return ( +
  • +
    +

    + {e.typ} {e.fingerprint} +

    +

    + {e.kommentar || "(ohne Kommentar)"} + {e.optionen ? ` · ${e.optionen}` : ""} + {e.imBlock ? " · im verwalteten Block" : ""} +

    +
    +
    + {herkunft.text} + {e.herkunft === "fremd" && darfUebernehmen && ( + + )} +
    +
  • + ); + })} +
+ )} +
+ ))} +
+ )} +
+ + ); +} diff --git a/src/components/abgleich/AlleAbgleichenKnopf.tsx b/src/components/abgleich/AlleAbgleichenKnopf.tsx new file mode 100644 index 0000000..1a1e0c0 --- /dev/null +++ b/src/components/abgleich/AlleAbgleichenKnopf.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/Button"; +import { alleAbgleichenAktion } from "@/server/actions/abgleich"; + +/** „Jetzt abgleichen“ auf der Uebersicht (#021): reiht fuer jeden Host einen Abgleich ein. */ +export function AlleAbgleichenKnopf() { + const [meldung, setMeldung] = useState(); + const [laeuft, starten] = useTransition(); + return ( + + + {meldung && ( + + {meldung} + + )} + + ); +} diff --git a/src/components/abgleich/UebernehmenDialog.tsx b/src/components/abgleich/UebernehmenDialog.tsx new file mode 100644 index 0000000..3923b45 --- /dev/null +++ b/src/components/abgleich/UebernehmenDialog.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui"; +import { Auswahlliste } from "@/components/ui/Feld"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { uebernehmenAktion } from "@/server/actions/abgleich"; + +/** Fremden Key aus dem Ist-Zustand uebernehmen (#022). */ +export function UebernehmenDialog({ + hostId, + zielbenutzer, + fingerprint, + kommentar, + personen, + darfVerwalten, +}: { + hostId: string; + zielbenutzer: string; + fingerprint: string; + kommentar: string; + personen: { id: string; name: string }[]; + darfVerwalten: boolean; +}) { + const dialog = useRef(null); + const [offen, setOffen] = useState(false); + const [verwalten, setVerwalten] = useState(darfVerwalten); + const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => { + const ergebnis = await uebernehmenAktion(vorher, formular); + if (!ergebnis.fehler && !ergebnis.feldfehler) setOffen(false); + return ergebnis; + }); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (offen && !element.open) element.showModal(); + if (!offen && element.open) element.close(); + }, [offen]); + + return ( + <> + + setOffen(false)} + className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6" + > +

Schlüssel übernehmen

+

+ {zielbenutzer} · {fingerprint} +

+
+ + + + + + + + + + + + {personen.map((p) => ( + + ))} + + {darfVerwalten && ( + setVerwalten(e.target.checked)} + beschriftung="In den verwalteten Block holen" + erklaerung="Beim nächsten Ausrollen wandert die Zeile in den Block, die alte Zeile außerhalb wird entfernt – mit Dry-Run." + /> + )} + + +
+ + ); +} diff --git a/src/lib/ssh/abgleich.ts b/src/lib/ssh/abgleich.ts new file mode 100644 index 0000000..6adef5f --- /dev/null +++ b/src/lib/ssh/abgleich.ts @@ -0,0 +1,165 @@ +import { + authorizedKeysLesen, + blockSetzen, + type SchluesselZeile, + type SollEintrag, +} from "@/lib/ssh/authorized-keys"; + +/** + * Abgleich einer authorized_keys mit dem Soll (#021) – rein, damit jede Befundregel einzeln + * testbar ist. Der Worker liefert Inhalt und Rechte, die Datenbank Soll und bekannte Keys. + * + * Befundarten: + * - FEHLENDER_SCHLUESSEL: ein zugewiesener Key fehlt im verwalteten Block. + * - ZUSAETZLICH_IM_BLOCK: im Block steht etwas, das nicht (mehr) zugewiesen ist – oder mit + * anderen Optionen. Jemand hat den Block von Hand bearbeitet. + * - UNBEKANNTER_SCHLUESSEL: ausserhalb des Blocks steht ein Key, den sshkv gar nicht kennt. + * Bekannte Keys ausserhalb des Blocks sind kein Befund (sie wurden bewusst dort gelassen). + * - RECHTE: ~/.ssh oder die Datei sind fuer Gruppe/Welt beschreibbar (sshd ignoriert sie dann + * bei StrictModes) oder gehoeren nicht dem Benutzer. + * - DATEI_GEAENDERT: der verwaltete Block ist beschaedigt (Anfang ohne Ende o. Ae.). + */ +export type Befund = { + readonly art: + "FEHLENDER_SCHLUESSEL" | "ZUSAETZLICH_IM_BLOCK" | "UNBEKANNTER_SCHLUESSEL" | "RECHTE" | "DATEI_GEAENDERT"; + readonly fingerprint?: string; + readonly text: string; +}; + +/** Was der Hostdetail-Seite je Zeile gezeigt wird – nur oeffentliche Angaben. */ +export type IstEintrag = { + readonly fingerprint: string; + readonly typ: string; + readonly kommentar: string; + readonly optionen: string | null; + readonly imBlock: boolean; + /** verwaltet = im Soll; bekannt = in sshkv, aber hier nicht zugewiesen; fremd = unbekannt. */ + readonly herkunft: "verwaltet" | "bekannt" | "fremd"; + /** Die ganze Zeile, damit sich ein fremder Key uebernehmen laesst (#022). */ + readonly zeile: string; +}; + +export type DateiRechte = { + /** Oktal wie `stat -c %a`, z. B. "700". */ + readonly sshVerzeichnis: string | null; + readonly datei: string | null; + readonly eigentuemerPasst: boolean; +}; + +export type AbgleichErgebnis = { + readonly befunde: Befund[]; + readonly eintraege: IstEintrag[]; + /** Fingerprints des Solls, die im Block korrekt stehen. */ + readonly vorhanden: string[]; +}; + +function beschreibbarFuerAndere(modus: string | null): boolean { + if (!modus) return false; + const zahl = Number.parseInt(modus, 8); + return Number.isFinite(zahl) && (zahl & 0o022) !== 0; +} + +function zeileText(zeile: SchluesselZeile): string { + return zeile.roh.endsWith("\r") ? zeile.roh.slice(0, -1) : zeile.roh; +} + +export function authorizedKeysAbgleichen( + inhalt: string | null, + soll: readonly SollEintrag[], + bekannteFingerprints: ReadonlySet, + rechte?: DateiRechte, +): AbgleichErgebnis { + const befunde: Befund[] = []; + const datei = authorizedKeysLesen(inhalt ?? ""); + + if (datei.block.art === "beschaedigt") { + befunde.push({ art: "DATEI_GEAENDERT", text: `Verwalteter Block beschädigt: ${datei.block.grund}` }); + } + if (rechte) { + if (beschreibbarFuerAndere(rechte.sshVerzeichnis)) { + befunde.push({ art: "RECHTE", text: `~/.ssh hat die Rechte ${rechte.sshVerzeichnis} (erwartet 700).` }); + } + if (beschreibbarFuerAndere(rechte.datei)) { + befunde.push({ art: "RECHTE", text: `authorized_keys hat die Rechte ${rechte.datei} (erwartet 600).` }); + } + if (!rechte.eigentuemerPasst) { + befunde.push({ art: "RECHTE", text: "~/.ssh oder authorized_keys gehört nicht dem Benutzer." }); + } + } + + const sollNachFingerprint = new Map(soll.map((e) => [e.fingerprint, e])); + const schluessel = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel"); + const imBlock = schluessel.filter((z) => z.imBlock); + + // Erwartete Zeile im Block, genau wie blockSetzen sie schreibt – dann faellt auch eine + // geaenderte Option auf. + const erwartet = new Map(); + if (soll.length) { + const muster = authorizedKeysLesen(blockSetzen("", soll).inhalt).zeilen.filter( + (z): z is SchluesselZeile => z.art === "schluessel", + ); + for (const zeile of muster) erwartet.set(zeile.fingerprint, zeileText(zeile)); + } + + const vorhanden: string[] = []; + for (const eintrag of soll) { + const treffer = imBlock.find((z) => z.fingerprint === eintrag.fingerprint); + if (!treffer) { + befunde.push({ + art: "FEHLENDER_SCHLUESSEL", + fingerprint: eintrag.fingerprint, + text: "Zugewiesener Schlüssel fehlt im verwalteten Block.", + }); + } else if (zeileText(treffer) !== erwartet.get(eintrag.fingerprint)) { + befunde.push({ + art: "ZUSAETZLICH_IM_BLOCK", + fingerprint: eintrag.fingerprint, + text: "Schlüssel steht mit anderen Optionen im Block als zugewiesen.", + }); + } else { + vorhanden.push(eintrag.fingerprint); + } + } + for (const zeile of imBlock) { + if (!sollNachFingerprint.has(zeile.fingerprint)) { + befunde.push({ + art: "ZUSAETZLICH_IM_BLOCK", + fingerprint: zeile.fingerprint, + text: "Im verwalteten Block steht ein Schlüssel, der hier nicht zugewiesen ist.", + }); + } + } + for (const zeile of schluessel.filter((z) => !z.imBlock)) { + if (!bekannteFingerprints.has(zeile.fingerprint)) { + befunde.push({ + art: "UNBEKANNTER_SCHLUESSEL", + fingerprint: zeile.fingerprint, + text: `Unbekannter Schlüssel außerhalb des Blocks${zeile.kommentar ? ` („${zeile.kommentar}“)` : ""}.`, + }); + } + } + + const eintraege: IstEintrag[] = schluessel.map((zeile) => ({ + fingerprint: zeile.fingerprint, + typ: zeile.typ, + kommentar: zeile.kommentar, + optionen: zeile.optionen, + imBlock: zeile.imBlock, + herkunft: sollNachFingerprint.has(zeile.fingerprint) + ? "verwaltet" + : bekannteFingerprints.has(zeile.fingerprint) + ? "bekannt" + : "fremd", + zeile: zeileText(zeile), + })); + + // Doppelte Befunde (derselbe Key zweimal ausserhalb) nur einmal. + const gesehen = new Set(); + const eindeutig = befunde.filter((b) => { + const schluesselText = `${b.art}\u0000${b.fingerprint ?? b.text}`; + if (gesehen.has(schluesselText)) return false; + gesehen.add(schluesselText); + return true; + }); + return { befunde: eindeutig, eintraege, vorhanden }; +} diff --git a/src/lib/ssh/authorized-keys.ts b/src/lib/ssh/authorized-keys.ts index b033d31..d553d6c 100644 --- a/src/lib/ssh/authorized-keys.ts +++ b/src/lib/ssh/authorized-keys.ts @@ -197,6 +197,12 @@ export function blockSetzen( inhalt: string, soll: readonly SollEintrag[], pflicht: readonly string[] = [], + /** + * Fingerprints, deren Zeilen ausserhalb des Blocks entfernt werden (#022): nur fuer Keys, + * die ein Mensch ausdruecklich in die Verwaltung uebernommen hat. Sonst gilt: fremde + * Zeilen bleiben bytegenau stehen. + */ + ausserhalbEntfernen: readonly string[] = [], ): BlockErgebnis { const datei = authorizedKeysLesen(inhalt); if (datei.block.art === "beschaedigt") { @@ -227,11 +233,18 @@ export function blockSetzen( let rohNeu: string[]; let endetMitZeilenende = datei.endetMitZeilenende; + const entfernen = new Set(ausserhalbEntfernen); + const bleibt = (z: Zeile) => !(z.art === "schluessel" && !z.imBlock && entfernen.has(z.fingerprint)); const roh = datei.zeilen.map((z) => z.roh); + const rohOhne = (von: number, bis?: number) => + datei.zeilen + .slice(von, bis) + .filter(bleibt) + .map((z) => z.roh); if (datei.block.art === "ok") { - rohNeu = [...roh.slice(0, datei.block.anfang), ...neuerBlock, ...roh.slice(datei.block.ende + 1)]; + rohNeu = [...rohOhne(0, datei.block.anfang), ...neuerBlock, ...rohOhne(datei.block.ende + 1)]; } else { - rohNeu = [...roh, ...neuerBlock]; + rohNeu = [...rohOhne(0), ...neuerBlock]; // Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer // ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks. endetMitZeilenende = true; @@ -241,7 +254,9 @@ export function blockSetzen( const hinweise: string[] = []; const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock); for (const eintrag of eintraege) { - const doppelt = ausserhalb.find((z) => z.fingerprint === eintrag.fingerprint); + const doppelt = ausserhalb.find( + (z) => z.fingerprint === eintrag.fingerprint && !entfernen.has(z.fingerprint), + ); if (doppelt) { hinweise.push( `${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`, diff --git a/src/server/actions/abgleich.ts b/src/server/actions/abgleich.ts new file mode 100644 index 0000000..32100f7 --- /dev/null +++ b/src/server/actions/abgleich.ts @@ -0,0 +1,58 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { abgleichAnfordern, alleHostsAbgleichen } from "@/server/services/abgleich"; +import { keyUebernehmen } from "@/server/services/uebernahme"; + +/** Abgleich anstossen und vorhandene Keys uebernehmen (#021, #022). */ + +export async function abgleichAktion(hostId: string): Promise<{ vorgangId?: string; fehler?: string }> { + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + return { vorgangId: await abgleichAnfordern(z.string().min(1).max(64).parse(hostId), benutzer.id) }; + } catch (fehler) { + if (fehler instanceof RechteFehler) return { fehler: fehler.message }; + throw fehler; + } +} + +export async function alleAbgleichenAktion(): Promise<{ anzahl?: number; fehler?: string }> { + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + return { anzahl: await alleHostsAbgleichen(benutzer.id) }; + } catch (fehler) { + if (fehler instanceof RechteFehler) return { fehler: fehler.message }; + throw fehler; + } +} + +export async function uebernehmenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const verwalten = formular.get("verwalten") === "ja"; + const hostId = textfeld(formular, "hostId") ?? ""; + try { + // Aufnehmen ist Schluesselverwaltung; in den Block holen ist zusaetzlich Ausrollen. + const { benutzer } = await rechtErforderlich(verwalten ? "publicKeyAusrollen" : "schluesselVerwalten"); + await keyUebernehmen( + { + hostId, + zielbenutzer: textfeld(formular, "zielbenutzer") ?? "", + fingerprint: textfeld(formular, "fingerprint") ?? "", + name: (textfeld(formular, "name") ?? "").trim(), + kategorie: textfeld(formular, "kategorie") === "DIENST" ? "DIENST" : "PERSON", + personId: textfeld(formular, "personId") ?? null, + verwalten, + }, + akteurVon(benutzer), + ); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath(`/hosts/${hostId}`); + return {}; +} diff --git a/src/server/queries/abgleich.ts b/src/server/queries/abgleich.ts new file mode 100644 index 0000000..d1e59cd --- /dev/null +++ b/src/server/queries/abgleich.ts @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/db"; +import type { IstEintrag } from "@/lib/ssh/abgleich"; + +/** Ist-Zustand und offene Abweichungen eines Hosts (#021) – nur oeffentliche Angaben. */ +export async function istZustand(hostId: string) { + const [beobachtungen, abweichungen, personen] = await Promise.all([ + prisma.beobachtung.findMany({ + where: { hostId }, + select: { zielbenutzer: true, beobachtetAm: true, eintraege: true }, + orderBy: { zielbenutzer: "asc" }, + }), + prisma.abweichung.findMany({ + where: { hostId, behobenAm: null }, + select: { + id: true, + art: true, + zielbenutzer: true, + fingerprint: true, + details: true, + eroeffnetAm: true, + }, + orderBy: { eroeffnetAm: "asc" }, + }), + prisma.person.findMany({ + where: { archiviertAm: null }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + ]); + return { + beobachtungen: beobachtungen.map((b) => ({ + zielbenutzer: b.zielbenutzer, + beobachtetAm: b.beobachtetAm, + eintraege: b.eintraege as unknown as IstEintrag[], + })), + abweichungen: abweichungen.map((a) => ({ + ...a, + text: + ((a.details ?? {}) as { text?: string; erwartet?: string }).text ?? + (a.art === "HOSTKEY_GEAENDERT" ? "Host-Key hat sich geändert." : a.art), + })), + personen, + }; +} + +export type IstZustand = Awaited>; diff --git a/src/server/services/abgleich.ts b/src/server/services/abgleich.ts new file mode 100644 index 0000000..c4eb9de --- /dev/null +++ b/src/server/services/abgleich.ts @@ -0,0 +1,32 @@ +import { prisma } from "@/lib/db"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Zeitplan (#021): reiht fuer jeden eingerichteten Host einen Abgleich ein – ausser fuer Hosts, + * deren letzter Abgleich noch wartet oder laeuft. + */ +export async function alleHostsAbgleichen(angefordertVonId: string | null): Promise { + const hosts = await prisma.host.findMany({ + where: { archiviertAm: null, bootstrapAm: { not: null } }, + select: { id: true }, + }); + const laufend = new Set( + ( + await prisma.vorgang.findMany({ + where: { art: "ABGLEICH", status: { in: ["WARTEND", "LAEUFT"] } }, + select: { hostId: true }, + }) + ).map((v) => v.hostId), + ); + let anzahl = 0; + for (const host of hosts) { + if (laufend.has(host.id)) continue; + await vorgangEinreihen({ art: "ABGLEICH", angefordertVonId, hostId: host.id }); + anzahl += 1; + } + return anzahl; +} + +export async function abgleichAnfordern(hostId: string, angefordertVonId: string): Promise { + return vorgangEinreihen({ art: "ABGLEICH", angefordertVonId, hostId }); +} diff --git a/src/server/services/soll.ts b/src/server/services/soll.ts index 9c312a3..727904c 100644 --- a/src/server/services/soll.ts +++ b/src/server/services/soll.ts @@ -20,6 +20,8 @@ export type Soll = { readonly eintraege: SollEintrag[]; /** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */ readonly pflicht: string[]; + /** Uebernommene Keys (#022), deren Zeile ausserhalb des Blocks verschwinden soll. */ + readonly ausserhalbEntfernen: string[]; /** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */ readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] }; }; @@ -58,6 +60,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise { + const beobachtung = await prisma.beobachtung.findUnique({ + where: { + hostId_zielbenutzer_datei: { + hostId: eingabe.hostId, + zielbenutzer: eingabe.zielbenutzer, + datei: ".ssh/authorized_keys", + }, + }, + include: { host: { select: { name: true } } }, + }); + const eintrag = ((beobachtung?.eintraege ?? []) as unknown as IstEintrag[]).find( + (e) => e.fingerprint === eingabe.fingerprint, + ); + if (!beobachtung || !eintrag) { + throw new FachFehler( + "Diesen Schlüssel hat der letzte Abgleich dort nicht gesehen. Bitte erneut abgleichen.", + ); + } + const zeile = authorizedKeysLesen(eintrag.zeile).zeilen.find( + (z): z is SchluesselZeile => z.art === "schluessel", + ); + if (!zeile) throw new FachFehler("Die Zeile ließ sich nicht lesen."); + + let optionen: string | null = null; + if (eingabe.verwalten && zeile.optionen) { + try { + optionen = optionenPruefen(zeile.optionen) || null; + } catch (fehler) { + if (fehler instanceof OptionenFehler) { + throw new FachFehler(`Die Optionen der Zeile lassen sich nicht übernehmen: ${fehler.message}`); + } + throw fehler; + } + } + + const neu = await publicKeyAnlegen( + { + name: eingabe.name, + slug: null, + kategorie: eingabe.kategorie, + publicKey: `${zeile.typ} ${zeile.base64}${zeile.kommentar ? ` ${zeile.kommentar}` : ""}`, + kommentar: null, + tags: ["übernommen"], + ablaufAm: null, + personId: eingabe.personId, + vorlageId: null, + }, + akteur, + "schluessel.uebernommen", + ); + + if (eingabe.verwalten) { + await prisma.publicVerwendung.create({ + data: { + schluesselId: neu.id, + hostId: eingabe.hostId, + zielbenutzer: eingabe.zielbenutzer, + optionen, + ausserhalbEntfernen: true, + angelegtVonId: akteur.id, + }, + }); + } + await protokollieren({ + akteur, + aktion: eingabe.verwalten ? "uebernahme.verwalten" : "uebernahme.bekannt", + ergebnis: "ERFOLG", + hostName: beobachtung.host.name, + zielbenutzer: eingabe.zielbenutzer, + fingerprint: neu.fingerprint, + details: { optionen }, + }); + return { schluesselId: neu.id }; +} diff --git a/src/worker/ablaeufe/abgleich.ts b/src/worker/ablaeufe/abgleich.ts new file mode 100644 index 0000000..793130f --- /dev/null +++ b/src/worker/ablaeufe/abgleich.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; +import type { Client } from "ssh2"; +import type { Host, Prisma } from "@/generated/prisma/client"; +import type { AbweichungsArt } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; +import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell"; +import { authorizedKeysAbgleichen, type Befund, type DateiRechte } from "@/lib/ssh/abgleich"; +import { anmeldebenutzerVon, sollFuer } from "@/server/services/soll"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { hostLaden } from "@/worker/ablaeufe/hostkey"; +import { privateKeyLaden } from "@/worker/geheimnisse"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { relativenPfadPruefen } from "@/worker/ssh/ssh-dateien"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; + +/** + * Abgleich eines Hosts (#021): Ist-Zustand lesen, mit dem Soll vergleichen, Befunde und + * Zustaende festhalten. Aendert auf dem Host nichts – Korrekturen laufen ueber Dry-Run und + * Ausrollen, damit ein Mensch sie sieht. + */ + +type GelesenerBenutzer = { + readonly benutzer: string; + readonly inhalt: string | null; + readonly rechte?: DateiRechte; +}; + +/** + * Liest authorized_keys aller Benutzer mit UID 0 oder ≥ 1000 sowie der ausdruecklich + * genannten. Inhalt als base64, damit Zeilenenden und fremde Bytes unversehrt ankommen; gelesen + * wird als der jeweilige Benutzer (Symlink-Schutz wie beim Schreiben). + */ +async function authorizedKeysAllerBenutzer( + verbindung: Client, + host: Host, + zusaetzlich: string[], +): Promise { + const namen = [...new Set(zusaetzlich)].filter((n) => BENUTZERNAME_MUSTER.test(n)); + const skript = `set -u +LISTE=$( { getent passwd | awk -F: '($3==0 || $3>=1000) && $3!=65534 {print $1}'; printf '%s\\n' ${namen.map(shellQuote).join(" ")}; } | sort -u) +for B in $LISTE; do + H=$(getent passwd "$B" | cut -d: -f6) || continue + [ -n "$H" ] || continue + D="$H/.ssh/authorized_keys" + if runuser -u "$B" -- test -e "$D" 2>/dev/null; then + RD=$(stat -c '%a:%U' "$H/.ssh" 2>/dev/null || echo "-:-") + RF=$(stat -c '%a:%U' "$D" 2>/dev/null || echo "-:-") + INHALT=$(runuser -u "$B" -- base64 -w0 "$D" 2>/dev/null || echo FEHLER) + echo "SSHKV-DATEI|$B|$RD|$RF|$INHALT" + else + echo "SSHKV-KEINE|$B" + fi +done +echo SSHKV-ENDE`; + const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n "; + const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`); + if (!ergebnis.stdout.includes("SSHKV-ENDE")) + throw new VorgangsFehler(`Abgleich auf ${host.name} ließ sich nicht ausführen.`); + + const gelesen: GelesenerBenutzer[] = []; + for (const zeile of ergebnis.stdout.split("\n")) { + const teile = zeile.split("|"); + if (teile[0] === "SSHKV-KEINE" && teile[1]) gelesen.push({ benutzer: teile[1], inhalt: null }); + if (teile[0] === "SSHKV-DATEI" && teile.length === 5) { + const [, benutzer, rd, rf, inhalt] = teile as [string, string, string, string, string]; + const [modusD, besitzerD] = rd.split(":"); + const [modusF, besitzerF] = rf.split(":"); + gelesen.push({ + benutzer, + inhalt: inhalt === "FEHLER" ? null : Buffer.from(inhalt, "base64").toString("utf8"), + rechte: { + sshVerzeichnis: modusD && modusD !== "-" ? modusD : null, + datei: modusF && modusF !== "-" ? modusF : null, + eigentuemerPasst: besitzerD === benutzer && besitzerF === benutzer, + }, + }); + } + } + return gelesen; +} + +/** Prueft ausgerollte Private Keys: Datei da und unveraendert? */ +async function privateKeysPruefen( + verbindung: Client, + host: Host, +): Promise<{ befunde: (Befund & { benutzer: string })[]; ok: string[]; fehlt: string[] }> { + const verwendungen = await prisma.privateVerwendung.findMany({ + where: { hostId: host.id, zustand: { in: ["AUSGEROLLT", "ABWEICHUNG"] } }, + select: { + id: true, + zielbenutzer: true, + pfad: true, + schluesselId: true, + schluessel: { select: { fingerprint: true, masterKeyVersion: true } }, + }, + }); + const befunde: (Befund & { benutzer: string })[] = []; + const ok: string[] = []; + const fehlt: string[] = []; + const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n "; + for (const v of verwendungen) { + const relativ = relativenPfadPruefen(v.pfad); + const skript = `H=$(getent passwd ${shellQuote(v.zielbenutzer)} | cut -d: -f6); runuser -u ${shellQuote(v.zielbenutzer)} -- sha256sum "$H/"${shellQuote(relativ)} 2>/dev/null | cut -d' ' -f1`; + const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`); + const ist = ergebnis.stdout.trim(); + let erwartet: string | null = null; + // Ist der Private Key aus sshkv geloescht, laesst sich nur noch das Vorhandensein pruefen. + if (v.schluessel.masterKeyVersion !== null) { + const klartext = await privateKeyLaden(v.schluesselId); + erwartet = createHash("sha256").update(klartext).digest("hex"); + klartext.fill(0); + } + if (!ist || (erwartet && ist !== erwartet)) { + fehlt.push(v.id); + befunde.push({ + benutzer: v.zielbenutzer, + art: "FEHLENDER_SCHLUESSEL", + fingerprint: v.schluessel.fingerprint, + text: ist ? `Private Key unter ${v.pfad} wurde verändert.` : `Private Key unter ${v.pfad} fehlt.`, + }); + } else { + ok.push(v.id); + } + } + return { befunde, ok, fehlt }; +} + +function befundSchluessel( + art: string, + benutzer: string | null, + fingerprint: string | null, + text: string, +): string { + return [art, benutzer ?? "", fingerprint ?? text].join("\u0000"); +} + +export async function hostAbgleichen(host: Host): Promise<{ befunde: number; benutzer: number }> { + const publicVerwendungen = await prisma.publicVerwendung.findMany({ + where: { + zustand: { not: "ENTFERNT" }, + OR: [{ hostId: host.id }, { hostgruppe: { mitglieder: { some: { hostId: host.id } } } }], + }, + select: { zielbenutzer: true }, + }); + const sollBenutzer = [ + ...new Set([anmeldebenutzerVon(host), ...publicVerwendungen.map((v) => v.zielbenutzer)]), + ]; + + const verbindung = await hostVerbinden(host); + let gelesen: GelesenerBenutzer[]; + let privat: Awaited>; + try { + gelesen = await authorizedKeysAllerBenutzer(verbindung, host, sollBenutzer); + privat = await privateKeysPruefen(verbindung, host); + } finally { + verbindung.end(); + } + + const bekannt = new Set( + (await prisma.schluessel.findMany({ select: { fingerprint: true } })).map((s) => s.fingerprint), + ); + const aktuell = new Map< + string, + { art: AbweichungsArt; benutzer: string | null; fingerprint: string | null; text: string } + >(); + const jetzt = new Date(); + + for (const { benutzer, inhalt, rechte } of gelesen) { + const soll = await sollFuer(host.id, benutzer); + // Benutzer ohne Datei und ohne Soll interessieren nicht. + if (inhalt === null && soll.eintraege.length === 0) continue; + const ergebnis = authorizedKeysAbgleichen(inhalt, soll.eintraege, bekannt, rechte); + for (const befund of ergebnis.befunde) { + aktuell.set(befundSchluessel(befund.art, benutzer, befund.fingerprint ?? null, befund.text), { + art: befund.art, + benutzer, + fingerprint: befund.fingerprint ?? null, + text: befund.text, + }); + } + const datei = ".ssh/authorized_keys"; + await prisma.beobachtung.upsert({ + where: { hostId_zielbenutzer_datei: { hostId: host.id, zielbenutzer: benutzer, datei } }, + create: { + hostId: host.id, + zielbenutzer: benutzer, + datei, + sha256: inhalt === null ? "keine-datei" : createHash("sha256").update(inhalt).digest("hex"), + eintraege: ergebnis.eintraege as unknown as Prisma.InputJsonValue, + beobachtetAm: jetzt, + }, + update: { + sha256: inhalt === null ? "keine-datei" : createHash("sha256").update(inhalt).digest("hex"), + eintraege: ergebnis.eintraege as unknown as Prisma.InputJsonValue, + beobachtetAm: jetzt, + }, + }); + + // Zustand der Public-Verwendungen nachziehen: was im Block stimmt, ist synchron; was fehlt, + // obwohl es ausgerollt war, ist Drift. GEPLANT bleibt GEPLANT, bis jemand ausrollt. + const vorhanden = new Set(ergebnis.vorhanden); + const verwendungen = await prisma.publicVerwendung.findMany({ + where: { + zielbenutzer: benutzer, + id: { in: soll.verwendungIds.drin }, + }, + select: { id: true, zustand: true, schluessel: { select: { fingerprint: true } } }, + }); + for (const v of verwendungen) { + const da = vorhanden.has(v.schluessel.fingerprint); + if (da && v.zustand === "ABWEICHUNG") { + await prisma.publicVerwendung.update({ where: { id: v.id }, data: { zustand: "AUSGEROLLT" } }); + } else if (!da && v.zustand === "AUSGEROLLT") { + await prisma.publicVerwendung.update({ where: { id: v.id }, data: { zustand: "ABWEICHUNG" } }); + } + } + } + // Benutzer, die nicht mehr gelesen wurden (Datei weg), verlieren ihre Beobachtung. + await prisma.beobachtung.deleteMany({ + where: { + hostId: host.id, + zielbenutzer: { notIn: gelesen.filter((g) => g.inhalt !== null).map((g) => g.benutzer) }, + }, + }); + + for (const befund of privat.befunde) { + aktuell.set( + befundSchluessel("PRIVATE_KEY_FEHLT", befund.benutzer, befund.fingerprint ?? null, befund.text), + { + art: "PRIVATE_KEY_FEHLT", + benutzer: befund.benutzer, + fingerprint: befund.fingerprint ?? null, + text: befund.text, + }, + ); + } + await prisma.privateVerwendung.updateMany({ + where: { id: { in: privat.fehlt } }, + data: { zustand: "ABWEICHUNG" }, + }); + await prisma.privateVerwendung.updateMany({ + where: { id: { in: privat.ok }, zustand: "ABWEICHUNG" }, + data: { zustand: "AUSGEROLLT" }, + }); + + // Abweichungen: neue oeffnen, verschwundene schliessen. Host-Key-Abweichungen verwaltet #009. + const offen = await prisma.abweichung.findMany({ + where: { hostId: host.id, behobenAm: null, art: { not: "HOSTKEY_GEAENDERT" } }, + }); + const offenSchluessel = new Map( + offen.map((a) => [ + befundSchluessel( + a.art, + a.zielbenutzer, + a.fingerprint, + ((a.details ?? {}) as { text?: string }).text ?? "", + ), + a, + ]), + ); + for (const [schluessel, befund] of aktuell) { + if (offenSchluessel.has(schluessel)) continue; + await prisma.abweichung.create({ + data: { + hostId: host.id, + zielbenutzer: befund.benutzer, + art: befund.art, + fingerprint: befund.fingerprint, + details: { text: befund.text }, + }, + }); + } + const behoben = [...offenSchluessel] + .filter(([schluessel]) => !aktuell.has(schluessel)) + .map(([, a]) => a.id); + await prisma.abweichung.updateMany({ where: { id: { in: behoben } }, data: { behobenAm: jetzt } }); + + const hostKeyOffen = await prisma.abweichung.count({ + where: { hostId: host.id, behobenAm: null, art: "HOSTKEY_GEAENDERT" }, + }); + await prisma.host.update({ + where: { id: host.id }, + data: { + driftZustand: aktuell.size > 0 || hostKeyOffen > 0 ? "DRIFT" : "SYNCHRON", + letzterAbgleichAm: jetzt, + erreichbar: true, + zuletztGeprueftAm: jetzt, + }, + }); + return { befunde: aktuell.size, benutzer: gelesen.filter((g) => g.inhalt !== null).length }; +} + +export const abgleichHandler: Handler = async ({ vorgang }) => { + const host = await hostLaden(vorgang.hostId); + if (!host.bootstrapAm) throw new VorgangsFehler("Der Host ist noch nicht eingerichtet (Bootstrap fehlt)."); + try { + const ergebnis = await hostAbgleichen(host); + await protokollieren({ + akteur: "System", + aktion: "abgleich", + ergebnis: "ERFOLG", + hostName: host.name, + details: ergebnis, + }); + return { ergebnis }; + } catch (fehler) { + await prisma.host.update({ + where: { id: host.id }, + data: { erreichbar: false, zuletztGeprueftAm: new Date() }, + }); + throw fehler; + } +}; diff --git a/src/worker/ablaeufe/ausrollen.ts b/src/worker/ablaeufe/ausrollen.ts index 332f80f..1f932ec 100644 --- a/src/worker/ablaeufe/ausrollen.ts +++ b/src/worker/ablaeufe/ausrollen.ts @@ -146,7 +146,7 @@ export const trockenlaufHandler: Handler = async ({ vorgang }) => { return privatPlanZiel(host, benutzer, await privatPlanen(verbindung, host, benutzer)); const stand = await authorizedKeysHolen(verbindung, host, benutzer); const soll = await sollFuer(host.id, benutzer); - const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht); + const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht, soll.ausserhalbEntfernen); return { art: "public", hostId: host.id, @@ -249,7 +249,7 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => { ); } const soll = await sollFuer(host.id, benutzer); - const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht); + const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht, soll.ausserhalbEntfernen); 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.`, diff --git a/src/worker/handler.ts b/src/worker/handler.ts index ebe8e06..6c00277 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -1,4 +1,5 @@ import type { HandlerTabelle } from "@/worker/ausfuehren"; +import { abgleichHandler } from "@/worker/ablaeufe/abgleich"; import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen"; import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; @@ -8,7 +9,7 @@ import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest"; import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto"; /** - * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) … + * Welche Vorgangsart wer ausfuehrt. */ export const HANDLER: HandlerTabelle = { HOSTKEY_ABRUFEN: hostKeyAbrufenHandler, @@ -21,6 +22,7 @@ export const HANDLER: HandlerTabelle = { PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler, PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler, VERBINDUNGSTEST: verbindungstestHandler, + ABGLEICH: abgleichHandler, ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler, ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler, }; diff --git a/src/worker/index.ts b/src/worker/index.ts index 219e5b3..3cf7d37 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,7 +1,13 @@ import "dotenv/config"; import type { Job } from "pg-boss"; import { VERSION } from "@/lib/version"; -import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange"; +import { alleHostsAbgleichen } from "@/server/services/abgleich"; +import { + WARTESCHLANGE, + ZEITPLAN_ABGLEICH, + warteschlangeHolen, + type VorgangsJob, +} from "@/server/vorgaenge/warteschlange"; import { vorgangAusfuehren } from "@/worker/ausfuehren"; import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey"; import { deploymentKeySicherstellen } from "@/worker/geheimnisse"; @@ -45,7 +51,17 @@ async function starten(): Promise { await aufraeumen(); const takt = setInterval(() => void aufraeumen(), 30_000); - console.log(`sshkv-worker ${VERSION} bereit.`); + // Periodischer Abgleich (#021). Der Zeitplan liegt in pg-boss und damit in der Datenbank: + // laufen spaeter mehrere Worker, feuert er trotzdem nur einmal. + const zeitplan = process.env["SSHKV_SYNC_CRON"]?.trim() || "30 2 * * *"; + await boss.createQueue(ZEITPLAN_ABGLEICH); + await boss.schedule(ZEITPLAN_ABGLEICH, zeitplan, null, { tz: "Europe/Berlin" }); + await boss.work(ZEITPLAN_ABGLEICH, async () => { + const anzahl = await alleHostsAbgleichen(null); + console.log(`Zeitplan: ${anzahl} Abgleich(e) eingereiht.`); + }); + + console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}).`); const beenden = async (signal: string) => { console.log(`${signal} empfangen, beende laufende Vorgänge …`); diff --git a/tests/integration/abgleich.test.ts b/tests/integration/abgleich.test.ts new file mode 100644 index 0000000..f5eea9f --- /dev/null +++ b/tests/integration/abgleich.test.ts @@ -0,0 +1,169 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { schluesselpaarErzeugen } from "@/lib/ssh/openssh"; +import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; +import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen"; +import { keyUebernehmen } from "@/server/services/uebernahme"; +import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; +import { datenbankLeeren } from "../hilfen/db"; +import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; +import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele"; + +/** Abgleich und Drift-Erkennung gegen ein echtes sshd (#021). */ + +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; rm -rf /root/.ssh; true", + ); +}); + +async function ausgerollterHost() { + const host = await eingerichteterHost(zielA, "ziel-a"); + const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:abgleich" }); + const schluessel = await prisma.schluessel.create({ + data: { + name: "Abgleich", + slug: "abgleich", + typ: "ED25519", + kategorie: "DIENST", + publicKey: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + kommentar: "sshkv:abgleich", + }, + }); + await publicVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" }, + AKTEUR, + ); + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { + eingabe: { ziele: await zieleVonVerwendungen({ schluesselId: schluessel.id }) }, + }); + await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } }); + return { host, schluessel }; +} + +const abgleichen = (hostId: string) => vorgangLaufen("ABGLEICH", { hostId }); +// Postgres sortiert Enums nach Deklaration; fuer lesbare Erwartungen alphabetisch in JS. +const offen = async (hostId: string) => + (await prisma.abweichung.findMany({ where: { hostId, behobenAm: null } })).sort((a, b) => + a.art.localeCompare(b.art), + ); + +describe("Abgleich", () => { + it("ist nach dem Ausrollen synchron und haelt den Ist-Zustand je Benutzer fest", async () => { + const { host } = await ausgerollterHost(); + const vorgang = await abgleichen(host.id); + expect(vorgang.fehler).toBeNull(); + expect(await offen(host.id)).toEqual([]); + const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } }); + expect(aktuell.driftZustand).toBe("SYNCHRON"); + expect(aktuell.letzterAbgleichAm).not.toBeNull(); + const beobachtet = await prisma.beobachtung.findMany({ + where: { hostId: host.id }, + orderBy: { zielbenutzer: "asc" }, + }); + expect(beobachtet.map((b) => b.zielbenutzer)).toEqual(["root", "sshkv"]); + expect(JSON.stringify(beobachtet[0]?.eintraege)).toContain('"herkunft":"verwaltet"'); + }); + + it("findet fremde Keys, fehlende Keys und falsche Rechte – und schliesst behobene Befunde", async () => { + const { host } = await ausgerollterHost(); + const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "fremd@laptop" }); + await befehlAlsRoot( + zielA, + `echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys && sed -i '/sshkv:abgleich/d' /root/.ssh/authorized_keys && chmod 777 /root/.ssh`, + ); + await abgleichen(host.id); + const arten = (await offen(host.id)).map((a) => a.art); + expect(arten).toEqual(["FEHLENDER_SCHLUESSEL", "RECHTE", "UNBEKANNTER_SCHLUESSEL"]); + expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT"); + expect((await prisma.publicVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG"); + + // Zweiter Lauf ohne Aenderung: keine doppelten Befunde. + await abgleichen(host.id); + expect(await offen(host.id)).toHaveLength(3); + + // Von Hand bereinigen: fremden Key raus, Rechte zurueck – der fehlende Key bleibt Befund. + await befehlAlsRoot(zielA, "sed -i '/fremd@laptop/d' /root/.ssh/authorized_keys && chmod 700 /root/.ssh"); + await abgleichen(host.id); + expect((await offen(host.id)).map((a) => a.art)).toEqual(["FEHLENDER_SCHLUESSEL"]); + }); + + it("bemerkt einen veraenderten Private Key", async () => { + const host = await eingerichteterHost(zielA, "ziel-a"); + const neu = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:privat"); + const schluessel = await prisma.schluessel.create({ + data: { + id: neu.id, + name: "Privat", + slug: "privat", + typ: "ED25519", + kategorie: "DIENST", + publicKey: neu.publicKeyZeile, + fingerprint: neu.fingerprint, + kommentar: "sshkv:privat", + ...neu.versiegelt, + }, + }); + await privateVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad: "~/.ssh/id_privat" }, + AKTEUR, + ); + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { + eingabe: { ziele: await zieleVonPrivatVerwendungen({ schluesselId: schluessel.id }) }, + }); + await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } }); + await abgleichen(host.id); + expect(await offen(host.id)).toEqual([]); + + await befehlAlsRoot(zielA, "echo kaputt > /root/.ssh/id_privat"); + await abgleichen(host.id); + expect((await offen(host.id)).map((a) => a.art)).toEqual(["PRIVATE_KEY_FEHLT"]); + expect((await prisma.privateVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG"); + }); + + it("uebernimmt einen fremden Key in den verwalteten Block (#022)", async () => { + const { host } = await ausgerollterHost(); + const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "anna@laptop" }); + await befehlAlsRoot(zielA, `sed -i '1i no-pty ${fremd.publicKeyZeile}' /root/.ssh/authorized_keys`); + await abgleichen(host.id); + expect((await offen(host.id)).map((a) => a.art)).toEqual(["UNBEKANNTER_SCHLUESSEL"]); + + const admin = await prisma.benutzer.create({ data: { id: "test", name: "Test", rolle: "ADMIN" } }); + const { schluesselId } = await keyUebernehmen( + { + hostId: host.id, + zielbenutzer: "root", + fingerprint: fremd.fingerprint, + name: "Laptop Anna", + kategorie: "PERSON", + personId: null, + verwalten: true, + }, + { id: admin.id, name: admin.name }, + ); + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { + eingabe: { ziele: await zieleVonVerwendungen({ schluesselId }) }, + }); + await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } }); + const datei = (await befehlAlsRoot(zielA, "cat /root/.ssh/authorized_keys")).stdout; + expect(datei.startsWith("# BEGIN SSHKV MANAGED")).toBe(true); + expect(datei.match(/anna@laptop/g)).toHaveLength(1); + expect(datei).toContain(`no-pty ${fremd.publicKeyZeile}`); + + await abgleichen(host.id); + expect(await offen(host.id)).toEqual([]); + }); +}); diff --git a/tests/integration/hilfen/host.ts b/tests/integration/hilfen/host.ts index b8e5a0f..9dc3c5c 100644 --- a/tests/integration/hilfen/host.ts +++ b/tests/integration/hilfen/host.ts @@ -28,7 +28,8 @@ export async function vorgangLaufen( | "AUSROLLEN" | "VERBINDUNGSTEST" | "ZIELKONTO_ANLEGEN" - | "ZIELKONTO_ENTFERNEN", + | "ZIELKONTO_ENTFERNEN" + | "ABGLEICH", daten: { hostId?: string; eingabe?: object; diff --git a/tests/ssh/abgleich.test.ts b/tests/ssh/abgleich.test.ts new file mode 100644 index 0000000..9a8678e --- /dev/null +++ b/tests/ssh/abgleich.test.ts @@ -0,0 +1,81 @@ +import { generateKeyPairSync } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { authorizedKeysAbgleichen } from "@/lib/ssh/abgleich"; +import { blockSetzen, type SollEintrag } from "@/lib/ssh/authorized-keys"; +import { fingerprintSha256 } from "@/lib/ssh/openssh"; + +/** Abgleich einer authorized_keys mit dem Soll (#021). */ + +function key(kommentar: string) { + const { publicKey } = generateKeyPairSync("ed25519"); + const roh = publicKey.export({ format: "der", type: "spki" }).subarray(-32); + const typ = Buffer.from("ssh-ed25519"); + const blob = Buffer.concat([Buffer.from([0, 0, 0, typ.length]), typ, Buffer.from([0, 0, 0, 32]), roh]); + return { + publicKey: `ssh-ed25519 ${blob.toString("base64")} ${kommentar}`, + fingerprint: fingerprintSha256(blob), + }; +} + +const DEPLOY = key("deploy"); +const BACKUP = key("backup"); +const FREMD = key("hacker@irgendwo"); +const BEKANNT = key("bekannt"); + +const soll: SollEintrag[] = [DEPLOY, { ...BACKUP, optionen: 'from="10.0.0.1"' }]; +const bekannt = new Set([DEPLOY.fingerprint, BACKUP.fingerprint, BEKANNT.fingerprint]); + +describe("authorized_keys abgleichen", () => { + it("ist ohne Befund, wenn der Block dem Soll entspricht", () => { + const inhalt = blockSetzen(`${BEKANNT.publicKey}\n`, soll).inhalt; + const ergebnis = authorizedKeysAbgleichen(inhalt, soll, bekannt, { + sshVerzeichnis: "700", + datei: "600", + eigentuemerPasst: true, + }); + expect(ergebnis.befunde).toEqual([]); + expect(ergebnis.vorhanden).toEqual([DEPLOY.fingerprint, BACKUP.fingerprint]); + expect(ergebnis.eintraege.map((e) => e.herkunft)).toEqual(["bekannt", "verwaltet", "verwaltet"]); + }); + + it("findet fehlende, geaenderte und zusaetzliche Keys im Block", () => { + const inhalt = blockSetzen("", [DEPLOY, BACKUP, FREMD]).inhalt; // BACKUP ohne Optionen, FREMD zu viel + const arten = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde.map((b) => [b.art, b.fingerprint]); + expect(arten).toEqual([ + ["ZUSAETZLICH_IM_BLOCK", BACKUP.fingerprint], + ["ZUSAETZLICH_IM_BLOCK", FREMD.fingerprint], + ]); + const leer = authorizedKeysAbgleichen(null, soll, bekannt).befunde.map((b) => b.art); + expect(leer).toEqual(["FEHLENDER_SCHLUESSEL", "FEHLENDER_SCHLUESSEL"]); + }); + + it("meldet nur unbekannte Keys ausserhalb des Blocks", () => { + const inhalt = blockSetzen(`${FREMD.publicKey}\n${FREMD.publicKey}\n${BEKANNT.publicKey}\n`, soll).inhalt; + const befunde = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde; + expect(befunde).toHaveLength(1); + expect(befunde[0]).toMatchObject({ art: "UNBEKANNTER_SCHLUESSEL", fingerprint: FREMD.fingerprint }); + expect(befunde[0]?.text).toContain("hacker@irgendwo"); + }); + + it("prueft Rechte und Eigentuemer", () => { + const befunde = authorizedKeysAbgleichen("", [], bekannt, { + sshVerzeichnis: "777", + datei: "664", + eigentuemerPasst: false, + }).befunde; + expect(befunde.map((b) => b.art)).toEqual(["RECHTE", "RECHTE", "RECHTE"]); + expect( + authorizedKeysAbgleichen("", [], bekannt, { + sshVerzeichnis: "755", + datei: "644", + eigentuemerPasst: true, + }).befunde, + ).toEqual([]); + }); + + it("meldet einen beschaedigten Block", () => { + expect(authorizedKeysAbgleichen("# BEGIN SSHKV MANAGED\n", [], bekannt).befunde[0]?.art).toBe( + "DATEI_GEAENDERT", + ); + }); +}); diff --git a/tests/ssh/authorized-keys.test.ts b/tests/ssh/authorized-keys.test.ts index d4adfca..a6b3556 100644 --- a/tests/ssh/authorized-keys.test.ts +++ b/tests/ssh/authorized-keys.test.ts @@ -209,3 +209,14 @@ describe("Diff", () => { expect(kurz[0]?.text).toMatch(/8 unveränderte Zeile/); }); }); + +describe("uebernommene Keys (#022)", () => { + it("entfernt nur ausdruecklich genannte Zeilen ausserhalb des Blocks", () => { + const vorher = `# Kommentar\n${FREMD.zeile}\n${BACKUP.zeile}\n`; + const ergebnis = blockSetzen(vorher, soll(FREMD), [], [FREMD.fingerprint]); + expect(ergebnis.inhalt).toBe( + `# Kommentar\n${BACKUP.zeile}\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${FREMD.zeile}\n# END SSHKV MANAGED\n`, + ); + expect(ergebnis.hinweise).toEqual([]); + }); +});