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={
<>
-
}>Jetzt abgleichen
+
}>
Host-zu-Host-Assistent
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.
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+
+ >
+ );
+}
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 (
+
+ }
+ disabled={laeuft}
+ onClick={() =>
+ starten(async () => {
+ const antwort = await alleAbgleichenAktion();
+ setMeldung(antwort.fehler ?? `${antwort.anzahl ?? 0} Abgleich(e) eingereiht.`);
+ })
+ }
+ >
+ Jetzt abgleichen
+
+ {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(true)}>
+ Übernehmen
+
+ 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}
+
+
+
+ >
+ );
+}
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/e2e/abgleich.spec.ts b/tests/e2e/abgleich.spec.ts
new file mode 100644
index 0000000..1a3d389
--- /dev/null
+++ b/tests/e2e/abgleich.spec.ts
@@ -0,0 +1,74 @@
+import { execFileSync } from "node:child_process";
+import { expect, test } from "@playwright/test";
+import "dotenv/config";
+import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
+
+/**
+ * Abgleich und Uebernahme im Browser (#021, #022), gebauter Stand + Worker, Testziel B:
+ * fremder Key → „Jetzt abgleichen“ zeigt ihn → übernehmen → Dry-Run → anwenden → synchron.
+ */
+const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
+const PORT_B = (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222";
+
+test.setTimeout(180_000);
+
+function aufZielB(befehl: string): string {
+ return execFileSync(
+ "docker",
+ ["compose", "-f", "docker-compose.test.yml", "exec", "-T", "ziel-b", "sh", "-c", befehl],
+ {
+ encoding: "utf8",
+ },
+ );
+}
+
+test.beforeAll(() => {
+ aufZielB(
+ "userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true",
+ );
+});
+
+test("fremden Schlüssel finden und übernehmen", async ({ page }) => {
+ const name = `zz-e2e-${Date.now().toString(36)}`;
+ await page.goto("/anmelden");
+ await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
+ await page.locator('button[data-rolle="admin"]').click();
+ await page.getByRole("button", { name: "Anmelden", exact: true }).click();
+
+ await page.goto("/hosts/neu");
+ await page.getByLabel("Name").fill(name);
+ await page.getByLabel("Adresse").fill("127.0.0.1");
+ await page.getByLabel("Port").fill(PORT_B);
+ await page.getByRole("button", { name: "Host anlegen" }).click();
+ await page.getByRole("button", { name: "Host-Key abrufen" }).click();
+ await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
+ await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
+ await page.getByRole("button", { name: "Zugang einrichten" }).click();
+ await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
+
+ const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: `${name}@laptop` });
+ aufZielB(`mkdir -p /root/.ssh && echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys`);
+
+ await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
+ await expect(page.getByText(/● Unbekannter Schlüssel/)).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByText("unbekannt", { exact: true })).toBeVisible();
+ await page.screenshot({ path: "docs/anleitung/bilder/abgleich-desktop.png", fullPage: true });
+
+ await page.getByRole("button", { name: "Übernehmen", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Schlüssel übernehmen" });
+ await dialog.getByLabel("Zweck / Name").fill(`${name} Laptop`);
+ await dialog.getByRole("button", { name: "Übernehmen" }).click();
+ await expect(dialog).toBeHidden();
+
+ await page.getByRole("button", { name: "Dry-Run starten" }).click();
+ await expect(page.getByText(`root@${name}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
+ await page.getByRole("button", { name: /Änderung anwenden/ }).click();
+ await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
+ expect(aufZielB("grep -c laptop /root/.ssh/authorized_keys").trim()).toBe("1");
+
+ await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
+ await expect(page.getByText("Keine offenen Abweichungen.")).toBeVisible({ timeout: 30_000 });
+
+ await page.getByRole("button", { name: "Host archivieren" }).click();
+ await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
+});
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([]);
+ });
+});