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
+6 -1
View File
@@ -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<So
select: {
id: true,
optionen: true,
ausserhalbEntfernen: true,
zustand: true,
angelegtAm: true,
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
@@ -78,6 +81,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
const drin: string[] = [];
const raus: string[] = [];
const ausserhalbEntfernen: string[] = [];
for (const verwendung of verwendungen) {
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
@@ -87,11 +91,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
optionen: verwendung.optionen,
});
drin.push(verwendung.id);
if (verwendung.ausserhalbEntfernen) ausserhalbEntfernen.push(verwendung.schluessel.fingerprint);
} else {
raus.push(verwendung.id);
}
}
return { eintraege, pflicht, verwendungIds: { drin, raus } };
return { eintraege, pflicht, ausserhalbEntfernen, verwendungIds: { drin, raus } };
}
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */