Reine Funktionen fuer Dry-Run und Ausrollen. Alles ausserhalb des Blocks bleibt bytegenau stehen, auch CRLF, BOM und kaputte Zeilen; der Block entsteht deterministisch aus dem Soll. Ein beschaedigter Block wird nie automatisch ueberschrieben, weil genau dort fremde Zeilen verschwinden koennten. Verwaltete Keys erkennt sshkv am Fingerprint, nicht am Kommentar. Der Deployment-Key kann nicht aus dem Block fallen (Aussperrschutz). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
8.6 KiB
TypeScript
212 lines
8.6 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/);
|
||
});
|
||
});
|