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>
170 lines
7.1 KiB
TypeScript
170 lines
7.1 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||
import { prisma } from "@/lib/db";
|
||
import { schluesselpaarErzeugen } from "@/lib/ssh/openssh";
|
||
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
|
||
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
|
||
import { keyUebernehmen } from "@/server/services/uebernahme";
|
||
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||
import { datenbankLeeren } from "../hilfen/db";
|
||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||
import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele";
|
||
|
||
/** Abgleich und Drift-Erkennung gegen ein echtes sshd (#021). */
|
||
|
||
const AKTEUR = { id: "test", name: "Test" };
|
||
let aufraeumen: () => void;
|
||
|
||
beforeAll(async () => {
|
||
await zielBereit(zielA);
|
||
aufraeumen = masterKeyAnlegen();
|
||
});
|
||
afterAll(() => aufraeumen());
|
||
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
await deploymentKeySicherstellen();
|
||
await befehlAlsRoot(
|
||
zielA,
|
||
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true",
|
||
);
|
||
});
|
||
|
||
async function ausgerollterHost() {
|
||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:abgleich" });
|
||
const schluessel = await prisma.schluessel.create({
|
||
data: {
|
||
name: "Abgleich",
|
||
slug: "abgleich",
|
||
typ: "ED25519",
|
||
kategorie: "DIENST",
|
||
publicKey: paar.publicKeyZeile,
|
||
fingerprint: paar.fingerprint,
|
||
kommentar: "sshkv:abgleich",
|
||
},
|
||
});
|
||
await publicVerwendungAnlegen(
|
||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
|
||
AKTEUR,
|
||
);
|
||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
|
||
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId: schluessel.id }) },
|
||
});
|
||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
|
||
return { host, schluessel };
|
||
}
|
||
|
||
const abgleichen = (hostId: string) => vorgangLaufen("ABGLEICH", { hostId });
|
||
// Postgres sortiert Enums nach Deklaration; fuer lesbare Erwartungen alphabetisch in JS.
|
||
const offen = async (hostId: string) =>
|
||
(await prisma.abweichung.findMany({ where: { hostId, behobenAm: null } })).sort((a, b) =>
|
||
a.art.localeCompare(b.art),
|
||
);
|
||
|
||
describe("Abgleich", () => {
|
||
it("ist nach dem Ausrollen synchron und haelt den Ist-Zustand je Benutzer fest", async () => {
|
||
const { host } = await ausgerollterHost();
|
||
const vorgang = await abgleichen(host.id);
|
||
expect(vorgang.fehler).toBeNull();
|
||
expect(await offen(host.id)).toEqual([]);
|
||
const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||
expect(aktuell.driftZustand).toBe("SYNCHRON");
|
||
expect(aktuell.letzterAbgleichAm).not.toBeNull();
|
||
const beobachtet = await prisma.beobachtung.findMany({
|
||
where: { hostId: host.id },
|
||
orderBy: { zielbenutzer: "asc" },
|
||
});
|
||
expect(beobachtet.map((b) => b.zielbenutzer)).toEqual(["root", "sshkv"]);
|
||
expect(JSON.stringify(beobachtet[0]?.eintraege)).toContain('"herkunft":"verwaltet"');
|
||
});
|
||
|
||
it("findet fremde Keys, fehlende Keys und falsche Rechte – und schliesst behobene Befunde", async () => {
|
||
const { host } = await ausgerollterHost();
|
||
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "fremd@laptop" });
|
||
await befehlAlsRoot(
|
||
zielA,
|
||
`echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys && sed -i '/sshkv:abgleich/d' /root/.ssh/authorized_keys && chmod 777 /root/.ssh`,
|
||
);
|
||
await abgleichen(host.id);
|
||
const arten = (await offen(host.id)).map((a) => a.art);
|
||
expect(arten).toEqual(["FEHLENDER_SCHLUESSEL", "RECHTE", "UNBEKANNTER_SCHLUESSEL"]);
|
||
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT");
|
||
expect((await prisma.publicVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
|
||
|
||
// Zweiter Lauf ohne Aenderung: keine doppelten Befunde.
|
||
await abgleichen(host.id);
|
||
expect(await offen(host.id)).toHaveLength(3);
|
||
|
||
// Von Hand bereinigen: fremden Key raus, Rechte zurueck – der fehlende Key bleibt Befund.
|
||
await befehlAlsRoot(zielA, "sed -i '/fremd@laptop/d' /root/.ssh/authorized_keys && chmod 700 /root/.ssh");
|
||
await abgleichen(host.id);
|
||
expect((await offen(host.id)).map((a) => a.art)).toEqual(["FEHLENDER_SCHLUESSEL"]);
|
||
});
|
||
|
||
it("bemerkt einen veraenderten Private Key", async () => {
|
||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:privat");
|
||
const schluessel = await prisma.schluessel.create({
|
||
data: {
|
||
id: neu.id,
|
||
name: "Privat",
|
||
slug: "privat",
|
||
typ: "ED25519",
|
||
kategorie: "DIENST",
|
||
publicKey: neu.publicKeyZeile,
|
||
fingerprint: neu.fingerprint,
|
||
kommentar: "sshkv:privat",
|
||
...neu.versiegelt,
|
||
},
|
||
});
|
||
await privateVerwendungAnlegen(
|
||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad: "~/.ssh/id_privat" },
|
||
AKTEUR,
|
||
);
|
||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
|
||
eingabe: { ziele: await zieleVonPrivatVerwendungen({ schluesselId: schluessel.id }) },
|
||
});
|
||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
|
||
await abgleichen(host.id);
|
||
expect(await offen(host.id)).toEqual([]);
|
||
|
||
await befehlAlsRoot(zielA, "echo kaputt > /root/.ssh/id_privat");
|
||
await abgleichen(host.id);
|
||
expect((await offen(host.id)).map((a) => a.art)).toEqual(["PRIVATE_KEY_FEHLT"]);
|
||
expect((await prisma.privateVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
|
||
});
|
||
|
||
it("uebernimmt einen fremden Key in den verwalteten Block (#022)", async () => {
|
||
const { host } = await ausgerollterHost();
|
||
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "anna@laptop" });
|
||
await befehlAlsRoot(zielA, `sed -i '1i no-pty ${fremd.publicKeyZeile}' /root/.ssh/authorized_keys`);
|
||
await abgleichen(host.id);
|
||
expect((await offen(host.id)).map((a) => a.art)).toEqual(["UNBEKANNTER_SCHLUESSEL"]);
|
||
|
||
const admin = await prisma.benutzer.create({ data: { id: "test", name: "Test", rolle: "ADMIN" } });
|
||
const { schluesselId } = await keyUebernehmen(
|
||
{
|
||
hostId: host.id,
|
||
zielbenutzer: "root",
|
||
fingerprint: fremd.fingerprint,
|
||
name: "Laptop Anna",
|
||
kategorie: "PERSON",
|
||
personId: null,
|
||
verwalten: true,
|
||
},
|
||
{ id: admin.id, name: admin.name },
|
||
);
|
||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
|
||
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId }) },
|
||
});
|
||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
|
||
const datei = (await befehlAlsRoot(zielA, "cat /root/.ssh/authorized_keys")).stdout;
|
||
expect(datei.startsWith("# BEGIN SSHKV MANAGED")).toBe(true);
|
||
expect(datei.match(/anna@laptop/g)).toHaveLength(1);
|
||
expect(datei).toContain(`no-pty ${fremd.publicKeyZeile}`);
|
||
|
||
await abgleichen(host.id);
|
||
expect(await offen(host.id)).toEqual([]);
|
||
});
|
||
});
|