From 61a52d4538f1b5a736f22cd0a7b58ea7dcd73d96 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:50:43 +0200 Subject: [PATCH] feat(ausrollen): Verbindungstest von Host A nach Host B (#020) Auf A als Zielbenutzer ssh mit BatchMode und StrictHostKeyChecking: keine Rueckfrage, keine stille Annahme eines Host-Keys. Der Test beweist damit genau das, was ein Cronjob auf A spaeter braucht. Die letzte Meldung von ssh landet in Vorgang und Protokoll; ein Erfolg setzt die letzte Verwendung des Schluessels. Co-Authored-By: Claude Opus 5.5 --- src/worker/ablaeufe/verbindungstest.ts | 74 ++++++++++++++++++++++++++ src/worker/handler.ts | 2 + tests/integration/hilfen/host.ts | 2 +- tests/integration/host-zu-host.test.ts | 13 +++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/worker/ablaeufe/verbindungstest.ts diff --git a/src/worker/ablaeufe/verbindungstest.ts b/src/worker/ablaeufe/verbindungstest.ts new file mode 100644 index 0000000..b1773d9 --- /dev/null +++ b/src/worker/ablaeufe/verbindungstest.ts @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/db"; +import { shellQuote } from "@/lib/shell"; +import { privatPfadPruefen } from "@/lib/ssh/ssh-config"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; + +/** + * Verbindungstest Host A → Host B (#020): auf A als Zielbenutzer + * `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes true`. + * + * BatchMode verhindert jede Rueckfrage (Passwort, Passphrase), StrictHostKeyChecking jede + * stille Annahme eines Host-Keys – der Test beweist also genau das, was ein Cronjob auf A + * spaeter auch braucht. + */ +export const verbindungstestHandler: Handler = async ({ vorgang }) => { + const verwendungId = (vorgang.eingabe as { privatVerwendungId?: unknown } | null)?.privatVerwendungId; + if (typeof verwendungId !== "string") throw new VorgangsFehler("Kein Private-Key-Ziel angegeben."); + const verwendung = await prisma.privateVerwendung.findUnique({ + where: { id: verwendungId }, + include: { host: true, schluessel: { select: { id: true, fingerprint: true } } }, + }); + if (!verwendung || verwendung.zustand !== "AUSGEROLLT") { + throw new VorgangsFehler("Der Private Key ist auf diesem Host (noch) nicht ausgerollt."); + } + const konfiguration = verwendung.sshConfig as { + alias?: string; + hostName?: string; + benutzer?: string; + port?: number; + } | null; + const ziel = konfiguration?.alias + ? shellQuote(konfiguration.alias) + : (() => { + throw new VorgangsFehler("Für den Test braucht die Zuweisung einen ~/.ssh/config-Eintrag (Alias)."); + })(); + privatPfadPruefen(verwendung.pfad); + + const ssh = `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10 ${ziel} true`; + const praefix = verwendung.host.anmeldeart === "ROOT" ? "" : "sudo -n "; + const befehl = `${praefix}runuser -u ${shellQuote(verwendung.zielbenutzer)} -- sh -c ${shellQuote(`cd ~ && ${ssh}`)}`; + + const verbindung = await hostVerbinden(verwendung.host); + let ergebnis; + try { + ergebnis = await befehlAusfuehren(verbindung, befehl); + } finally { + verbindung.end(); + } + + const erfolg = ergebnis.code === 0; + // Die letzte Zeile von ssh auf stderr ist die aussagekraeftige (Permission denied, Host key …). + const meldung = ergebnis.stderr.trim().split("\n").filter(Boolean).pop()?.slice(0, 300) ?? ""; + await protokollieren({ + akteur: "System", + aktion: "verbindungstest", + ergebnis: erfolg ? "ERFOLG" : "FEHLER", + hostName: verwendung.host.name, + zielbenutzer: verwendung.zielbenutzer, + fingerprint: verwendung.schluessel.fingerprint, + details: { alias: konfiguration?.alias ?? null, meldung }, + }); + if (!erfolg) { + throw new VorgangsFehler( + `Verbindung von ${verwendung.zielbenutzer}@${verwendung.host.name} nach ${konfiguration?.alias} fehlgeschlagen: ${meldung || `Exit-Code ${ergebnis.code}`}`, + ); + } + await prisma.schluessel.update({ + where: { id: verwendung.schluessel.id }, + data: { zuletztVerwendetAm: new Date() }, + }); + return { ergebnis: { erfolg: true, alias: konfiguration?.alias ?? null } }; +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index dae834e..18d37c9 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -2,6 +2,7 @@ import type { HandlerTabelle } from "@/worker/ausfuehren"; import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen"; import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; +import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest"; /** * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) … @@ -12,4 +13,5 @@ export const HANDLER: HandlerTabelle = { BOOTSTRAP: bootstrapHandler, TROCKENLAUF: trockenlaufHandler, AUSROLLEN: ausrollenHandler, + VERBINDUNGSTEST: verbindungstestHandler, }; diff --git a/tests/integration/hilfen/host.ts b/tests/integration/hilfen/host.ts index 455352b..2c5035e 100644 --- a/tests/integration/hilfen/host.ts +++ b/tests/integration/hilfen/host.ts @@ -21,7 +21,7 @@ export function masterKeyAnlegen(): () => void { } export async function vorgangLaufen( - art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN", + art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST", daten: { hostId?: string; eingabe?: object; diff --git a/tests/integration/host-zu-host.test.ts b/tests/integration/host-zu-host.test.ts index e1bf09f..93fc699 100644 --- a/tests/integration/host-zu-host.test.ts +++ b/tests/integration/host-zu-host.test.ts @@ -112,6 +112,19 @@ describe("Host-zu-Host", () => { expect(test.stdout.trim()).toBe("root"); expect(await prisma.privateVerwendung.count({ where: { zustand: "AUSGEROLLT" } })).toBe(1); + + // Verbindungstest als Vorgang (#020). + const verwendung = await prisma.privateVerwendung.findFirstOrThrow(); + const test2 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); + expect(test2.fehler).toBeNull(); + expect( + (await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).zuletztVerwendetAm, + ).not.toBeNull(); + + // Ohne Public Key auf B scheitert der Test verstaendlich. + await befehlAlsRoot(zielB, "rm -f /root/.ssh/authorized_keys"); + const test3 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); + expect(test3.fehler).toMatch(/Permission denied/); const protokoll = await prisma.protokoll.findFirst({ where: { aktion: "privatekey.ausgerollt" } }); expect(protokoll?.diff).toContain("anlegen /root/.ssh/id_ed25519_backup-a-b"); expect(JSON.stringify(protokoll?.details)).not.toContain("PRIVATE");