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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:02:11 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit e518e75022
24 changed files with 1334 additions and 21 deletions
+139
View File
@@ -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<string, string> = {
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<string, { text: string; variante: "ok" | "neutral" | "gefahr" }> = {
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 (
<>
<Karte
titel="Abweichungen"
untertitel="Unterschiede zwischen Soll und Host beim letzten Abgleich"
aktionen={
darfAbgleichen ? (
<VorgangsKnopf aktion={abgleichAktion.bind(null, hostId)} beschriftung="Jetzt abgleichen" />
) : null
}
>
{zustand.abweichungen.length === 0 ? (
<p className="text-ok text-[14px]">● Keine offenen Abweichungen.</p>
) : (
<ul className="flex flex-col">
{zustand.abweichungen.map((a) => (
<li
key={a.id}
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0"
>
<span className="text-danger text-[14px] font-medium">
● {ART_TEXT[a.art] ?? a.art}
{a.zielbenutzer && (
<span className="text-muted font-mono text-[13px]"> · {a.zielbenutzer}</span>
)}
</span>
<span className="text-text text-[14px]">{a.text}</span>
<span className="text-muted font-mono text-[12px] break-all">
{a.fingerprint ? `${a.fingerprint} · ` : ""}seit {datumZeit(a.eroeffnetAm)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte
titel="Ist-Zustand je Benutzer"
untertitel="Alle Schlüssel in authorized_keys – auch die, die sshkv nicht verwaltet"
>
{zustand.beobachtungen.length === 0 ? (
<Leerzustand
titel="Noch nicht abgeglichen"
text="Nach dem ersten Abgleich steht hier, was wirklich auf dem Host liegt."
/>
) : (
<div className="flex flex-col gap-6">
{zustand.beobachtungen.map((b) => (
<section key={b.zielbenutzer} className="flex flex-col gap-2">
<h3 className="text-text font-mono text-[14px] font-medium">
{b.zielbenutzer}{" "}
<span className="text-muted text-[12px]">· {datumZeit(b.beobachtetAm)}</span>
</h3>
{b.eintraege.length === 0 ? (
<p className="text-muted text-[13.5px]">Datei leer.</p>
) : (
<ul className="flex flex-col gap-2">
{b.eintraege.map((e) => {
const herkunft = HERKUNFT[e.herkunft]!;
return (
<li
key={`${e.fingerprint}-${e.imBlock}`}
className="border-border rounded-box flex flex-col gap-2 border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="font-mono text-[12.5px] break-all">
<span className="text-muted">{e.typ}</span> {e.fingerprint}
</p>
<p className="text-muted font-mono text-[12px] break-all">
{e.kommentar || "(ohne Kommentar)"}
{e.optionen ? ` · ${e.optionen}` : ""}
{e.imBlock ? " · im verwalteten Block" : ""}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variante={herkunft.variante}>{herkunft.text}</Badge>
{e.herkunft === "fremd" && darfUebernehmen && (
<UebernehmenDialog
hostId={hostId}
zielbenutzer={b.zielbenutzer}
fingerprint={e.fingerprint}
kommentar={e.kommentar}
personen={zustand.personen}
darfVerwalten={darfVerwalten}
/>
)}
</div>
</li>
);
})}
</ul>
)}
</section>
))}
</div>
)}
</Karte>
</>
);
}