From e518e750229c4f66f0006963c2a346b283e20608 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 13:02:11 +0200 Subject: [PATCH] feat(abgleich): Drift-Erkennung, Zeitplan und Uebernahme (#021, #022) Ein Abgleich liest je Host alle authorized_keys in einem SSH-Aufruf, als jeweiliger Benutzer, und vergleicht sie mit dem Soll: fehlende, von Hand geaenderte und unbekannte Keys, falsche Rechte, beschaedigte Bloecke und veraenderte Private Keys. Befunde werden geoeffnet und wieder geschlossen, Zustaende von Verwendungen und Host nachgezogen. Auf dem Host aendert der Abgleich nichts. Der Zeitplan liegt in pg-boss und feuert auch bei mehreren Workern nur einmal. Auf der Hostseite stehen Abweichungen und der Ist-Zustand je Benutzer; unbekannte Keys lassen sich uebernehmen - nur bekannt machen oder mit Dry-Run in den verwalteten Block holen. Co-Authored-By: Claude Opus 5.5 --- docs/issues/021-abgleich-drift.md | 17 +- docs/issues/022-import.md | 12 +- .../migration.sql | 2 + prisma/schema.prisma | 3 + src/app/(app)/hosts/[id]/page.tsx | 13 + src/app/(app)/page.tsx | 5 +- src/components/abgleich/AbgleichKarten.tsx | 139 ++++++++ .../abgleich/AlleAbgleichenKnopf.tsx | 33 ++ src/components/abgleich/UebernehmenDialog.tsx | 91 +++++ src/lib/ssh/abgleich.ts | 165 +++++++++ src/lib/ssh/authorized-keys.ts | 21 +- src/server/actions/abgleich.ts | 58 ++++ src/server/queries/abgleich.ts | 46 +++ src/server/services/abgleich.ts | 32 ++ src/server/services/soll.ts | 7 +- src/server/services/uebernahme.ts | 105 ++++++ src/worker/ablaeufe/abgleich.ts | 314 ++++++++++++++++++ src/worker/ablaeufe/ausrollen.ts | 4 +- src/worker/handler.ts | 4 +- src/worker/index.ts | 20 +- tests/integration/abgleich.test.ts | 169 ++++++++++ tests/integration/hilfen/host.ts | 3 +- tests/ssh/abgleich.test.ts | 81 +++++ tests/ssh/authorized-keys.test.ts | 11 + 24 files changed, 1334 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20261012131000_verwendung_ausserhalb_entfernen/migration.sql create mode 100644 src/components/abgleich/AbgleichKarten.tsx create mode 100644 src/components/abgleich/AlleAbgleichenKnopf.tsx create mode 100644 src/components/abgleich/UebernehmenDialog.tsx create mode 100644 src/lib/ssh/abgleich.ts create mode 100644 src/server/actions/abgleich.ts create mode 100644 src/server/queries/abgleich.ts create mode 100644 src/server/services/abgleich.ts create mode 100644 src/server/services/uebernahme.ts create mode 100644 src/worker/ablaeufe/abgleich.ts create mode 100644 tests/integration/abgleich.test.ts create mode 100644 tests/ssh/abgleich.test.ts 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([]); + }); +});