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
+81
View File
@@ -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",
);
});
});
+11
View File
@@ -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([]);
});
});