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>
223 lines
9.1 KiB
TypeScript
223 lines
9.1 KiB
TypeScript
import { generateKeyPairSync } from "node:crypto";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
BlockFehler,
|
||
authorizedKeysLesen,
|
||
blockSetzen,
|
||
schluesselZeilen,
|
||
type SollEintrag,
|
||
} from "@/lib/ssh/authorized-keys";
|
||
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||
import { fingerprintSha256 } from "@/lib/ssh/openssh";
|
||
import { diffVerdichten, zeilenDiff } from "@/lib/ssh/zeilen-diff";
|
||
|
||
/** authorized_keys-Parser und Merge (#015) – die Randfaelle aus der Vorgabe. */
|
||
|
||
function ed25519(kommentar: string): { zeile: string; fingerprint: string; base64: 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]);
|
||
const base64 = blob.toString("base64");
|
||
return { zeile: `ssh-ed25519 ${base64} ${kommentar}`, fingerprint: fingerprintSha256(blob), base64 };
|
||
}
|
||
|
||
const DEPLOY = ed25519("sshkv-deployment@sshkv");
|
||
const BACKUP = ed25519("sshkv:backup");
|
||
const FREMD = ed25519("admin@laptop");
|
||
|
||
const soll = (...keys: { zeile: string; fingerprint: string }[]): SollEintrag[] =>
|
||
keys.map((k) => ({ publicKey: k.zeile, fingerprint: k.fingerprint }));
|
||
|
||
describe("Lesen", () => {
|
||
it("erkennt Schluessel, Kommentare, Leerzeilen und den Block", () => {
|
||
const inhalt = [
|
||
"# eigener Kommentar",
|
||
"",
|
||
FREMD.zeile,
|
||
"# BEGIN SSHKV MANAGED – nicht manuell bearbeiten",
|
||
DEPLOY.zeile,
|
||
"# END SSHKV MANAGED",
|
||
"",
|
||
].join("\n");
|
||
const datei = authorizedKeysLesen(inhalt);
|
||
expect(datei.block).toEqual({ art: "ok", anfang: 3, ende: 5 });
|
||
expect(datei.zeilen.map((z) => z.art)).toEqual([
|
||
"kommentar",
|
||
"leer",
|
||
"schluessel",
|
||
"blockAnfang",
|
||
"schluessel",
|
||
"blockEnde",
|
||
]);
|
||
const keys = schluesselZeilen(inhalt);
|
||
expect(keys.map((k) => [k.fingerprint, k.imBlock])).toEqual([
|
||
[FREMD.fingerprint, false],
|
||
[DEPLOY.fingerprint, true],
|
||
]);
|
||
});
|
||
|
||
it("liest Optionen mit Anfuehrungszeichen, Kommas, Leerzeichen und Escapes", () => {
|
||
const zeile = `restrict,from="10.0.0.1,10.0.0.2",command="echo \\"hallo welt\\"" ${BACKUP.zeile}`;
|
||
const [key] = schluesselZeilen(zeile);
|
||
expect(key?.optionen).toBe(`restrict,from="10.0.0.1,10.0.0.2",command="echo \\"hallo welt\\""`);
|
||
expect(key?.fingerprint).toBe(BACKUP.fingerprint);
|
||
expect(key?.kommentar).toBe("sshkv:backup");
|
||
});
|
||
|
||
it("markiert kaputte Zeilen als ungueltig statt abzubrechen", () => {
|
||
const datei = authorizedKeysLesen(
|
||
[
|
||
"ssh-ed25519 kein-base64!!",
|
||
`ssh-rsa ${DEPLOY.base64} typ-passt-nicht`,
|
||
'from="offen ssh-ed25519 AAAA',
|
||
"irgendwas",
|
||
].join("\n"),
|
||
);
|
||
expect(datei.zeilen.every((z) => z.art === "ungueltig")).toBe(true);
|
||
});
|
||
|
||
it("erkennt beschaedigte Bloecke", () => {
|
||
expect(authorizedKeysLesen("# BEGIN SSHKV MANAGED\nx\n").block.art).toBe("beschaedigt");
|
||
expect(authorizedKeysLesen("# END SSHKV MANAGED\n").block.art).toBe("beschaedigt");
|
||
expect(
|
||
authorizedKeysLesen(
|
||
"# BEGIN SSHKV MANAGED\n# END SSHKV MANAGED\n# BEGIN SSHKV MANAGED\n# END SSHKV MANAGED\n",
|
||
).block.art,
|
||
).toBe("beschaedigt");
|
||
expect(authorizedKeysLesen("# END SSHKV MANAGED\n# BEGIN SSHKV MANAGED\n").block.art).toBe("beschaedigt");
|
||
});
|
||
});
|
||
|
||
describe("Block setzen", () => {
|
||
it("legt den Block in einer leeren Datei an", () => {
|
||
const ergebnis = blockSetzen("", soll(DEPLOY));
|
||
expect(ergebnis.inhalt).toBe(
|
||
`# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${DEPLOY.zeile}\n# END SSHKV MANAGED\n`,
|
||
);
|
||
expect(ergebnis.geaendert).toBe(true);
|
||
});
|
||
|
||
it("haengt den Block an und laesst alles andere bytegenau stehen", () => {
|
||
const vorher = `# Florian\n${FREMD.zeile}\n\n`;
|
||
const ergebnis = blockSetzen(vorher, soll(DEPLOY, BACKUP));
|
||
expect(ergebnis.inhalt.startsWith(vorher)).toBe(true);
|
||
expect(ergebnis.inhalt).toContain(`${DEPLOY.zeile}\n${BACKUP.zeile}\n# END SSHKV MANAGED\n`);
|
||
});
|
||
|
||
it("ersetzt nur den Block, Zeilen davor und danach bleiben unberuehrt", () => {
|
||
const davor = `# davor\n${FREMD.zeile}\n`;
|
||
const danach = "# danach\n";
|
||
const vorher = `${davor}# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${DEPLOY.zeile}\n# END SSHKV MANAGED\n${danach}`;
|
||
const ergebnis = blockSetzen(vorher, soll(DEPLOY, BACKUP));
|
||
expect(ergebnis.inhalt.startsWith(davor)).toBe(true);
|
||
expect(ergebnis.inhalt.endsWith(`# END SSHKV MANAGED\n${danach}`)).toBe(true);
|
||
expect(ergebnis.diff.filter((z) => z.art !== "kontext")).toEqual([{ art: "hinzu", text: BACKUP.zeile }]);
|
||
});
|
||
|
||
it("ist idempotent", () => {
|
||
const einmal = blockSetzen(FREMD.zeile, soll(DEPLOY)).inhalt;
|
||
const zweimal = blockSetzen(einmal, soll(DEPLOY));
|
||
expect(zweimal.inhalt).toBe(einmal);
|
||
expect(zweimal.geaendert).toBe(false);
|
||
});
|
||
|
||
it("behaelt CRLF, BOM und die fehlende letzte Zeilenschaltung fremder Zeilen", () => {
|
||
const vorher = `# Windows\r\n${FREMD.zeile}\r\n# BEGIN SSHKV MANAGED\r\n# END SSHKV MANAGED\r\n# ohne Ende`;
|
||
const ergebnis = blockSetzen(vorher, soll(DEPLOY));
|
||
expect(ergebnis.inhalt).toBe(
|
||
`# Windows\r\n${FREMD.zeile}\r\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\r\n${DEPLOY.zeile}\r\n# END SSHKV MANAGED\r\n# ohne Ende`,
|
||
);
|
||
});
|
||
|
||
it("setzt Optionen vor den Schluessel", () => {
|
||
const ergebnis = blockSetzen("", [{ ...soll(BACKUP)[0]!, optionen: 'restrict,from="10.0.0.12"' }]);
|
||
expect(ergebnis.inhalt).toContain(`restrict,from="10.0.0.12" ${BACKUP.zeile}\n`);
|
||
});
|
||
|
||
it("nimmt doppelte Soll-Eintraege nur einmal", () => {
|
||
const ergebnis = blockSetzen("", soll(DEPLOY, DEPLOY));
|
||
expect(ergebnis.inhalt.match(/sshkv-deployment/g)).toHaveLength(1);
|
||
});
|
||
|
||
it("meldet einen Schluessel, der zusaetzlich ausserhalb des Blocks steht", () => {
|
||
const ergebnis = blockSetzen(`${BACKUP.zeile}\n`, soll(BACKUP));
|
||
expect(ergebnis.hinweise.join(" ")).toMatch(/außerhalb des Blocks – dort ohne Einschränkungen/);
|
||
});
|
||
|
||
it("ueberschreibt einen beschaedigten Block nie", () => {
|
||
expect(() => blockSetzen(`# BEGIN SSHKV MANAGED\n${FREMD.zeile}\n`, soll(DEPLOY))).toThrow(BlockFehler);
|
||
});
|
||
|
||
it("schuetzt den Deployment-Key vor dem Entfernen (Aussperrschutz)", () => {
|
||
const vorher = blockSetzen("", soll(DEPLOY, BACKUP)).inhalt;
|
||
expect(() => blockSetzen(vorher, soll(BACKUP), [DEPLOY.fingerprint])).toThrow(/aussperren/);
|
||
// Ohne den Deployment-Key im Block (z. B. root-Konto bei Dienstbenutzer-Anmeldung) greift nichts.
|
||
expect(() => blockSetzen(blockSetzen("", soll(BACKUP)).inhalt, [], [DEPLOY.fingerprint])).not.toThrow();
|
||
});
|
||
|
||
it("verweigert Zeilenumbrueche in Eintraegen", () => {
|
||
expect(() =>
|
||
blockSetzen("", [{ publicKey: `${BACKUP.zeile}\n${FREMD.zeile}`, fingerprint: "x" }]),
|
||
).toThrow(BlockFehler);
|
||
});
|
||
|
||
it("entfernt fremde Zeilen innerhalb des Blocks – und zeigt das im Diff", () => {
|
||
const vorher = `# BEGIN SSHKV MANAGED\n${FREMD.zeile}\n${DEPLOY.zeile}\n# END SSHKV MANAGED\n`;
|
||
const ergebnis = blockSetzen(vorher, soll(DEPLOY));
|
||
expect(ergebnis.diff).toContainEqual({ art: "entfernt", text: FREMD.zeile });
|
||
});
|
||
});
|
||
|
||
describe("Optionen pruefen", () => {
|
||
it("normalisiert gueltige Optionen", () => {
|
||
expect(optionenPruefen(' restrict,FROM="10.0.0.1",command="ls -l" ')).toBe(
|
||
'restrict,from="10.0.0.1",command="ls -l"',
|
||
);
|
||
expect(optionenPruefen("")).toBe("");
|
||
});
|
||
|
||
it.each([
|
||
["unbekannte Option", "restriktiv"],
|
||
["Wert ohne Anfuehrungszeichen", "from=10.0.0.1"],
|
||
["fehlendes Anfuehrungszeichen", 'from="10.0.0.1'],
|
||
["Leerzeichen ausserhalb", "restrict no-pty"],
|
||
["Zeilenumbruch", 'command="ls\nrm -rf /"'],
|
||
["Schalter mit Wert", 'no-pty="ja"'],
|
||
["leerer Wert", 'from=""'],
|
||
])("weist ab: %s", (_, eingabe) => {
|
||
expect(() => optionenPruefen(eingabe)).toThrow(OptionenFehler);
|
||
});
|
||
});
|
||
|
||
describe("Diff", () => {
|
||
it("findet Einfuegungen und Entfernungen", () => {
|
||
expect(zeilenDiff(["a", "b", "c"], ["a", "x", "c"])).toEqual([
|
||
{ art: "kontext", text: "a" },
|
||
{ art: "entfernt", text: "b" },
|
||
{ art: "hinzu", text: "x" },
|
||
{ art: "kontext", text: "c" },
|
||
]);
|
||
});
|
||
|
||
it("verdichtet lange unveraenderte Strecken", () => {
|
||
const alt = Array.from({ length: 20 }, (_, i) => `z${i}`);
|
||
const neu = [...alt.slice(0, 10), "neu", ...alt.slice(10)];
|
||
const kurz = diffVerdichten(zeilenDiff(alt, neu));
|
||
expect(kurz.length).toBeLessThan(10);
|
||
expect(kurz).toContainEqual({ art: "hinzu", text: "neu" });
|
||
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([]);
|
||
});
|
||
});
|