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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:50:43 +02:00
co-authored by Claude Opus 5.5
parent edc38d0c27
commit 61a52d4538
4 changed files with 90 additions and 1 deletions
+74
View File
@@ -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 <alias> 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 } };
};
+2
View File
@@ -2,6 +2,7 @@ import type { HandlerTabelle } from "@/worker/ausfuehren";
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen"; import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
/** /**
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) … * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
@@ -12,4 +13,5 @@ export const HANDLER: HandlerTabelle = {
BOOTSTRAP: bootstrapHandler, BOOTSTRAP: bootstrapHandler,
TROCKENLAUF: trockenlaufHandler, TROCKENLAUF: trockenlaufHandler,
AUSROLLEN: ausrollenHandler, AUSROLLEN: ausrollenHandler,
VERBINDUNGSTEST: verbindungstestHandler,
}; };
+1 -1
View File
@@ -21,7 +21,7 @@ export function masterKeyAnlegen(): () => void {
} }
export async function vorgangLaufen( export async function vorgangLaufen(
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN", art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
daten: { daten: {
hostId?: string; hostId?: string;
eingabe?: object; eingabe?: object;
+13
View File
@@ -112,6 +112,19 @@ describe("Host-zu-Host", () => {
expect(test.stdout.trim()).toBe("root"); expect(test.stdout.trim()).toBe("root");
expect(await prisma.privateVerwendung.count({ where: { zustand: "AUSGEROLLT" } })).toBe(1); 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" } }); const protokoll = await prisma.protokoll.findFirst({ where: { aktion: "privatekey.ausgerollt" } });
expect(protokoll?.diff).toContain("anlegen /root/.ssh/id_ed25519_backup-a-b"); expect(protokoll?.diff).toContain("anlegen /root/.ssh/id_ed25519_backup-a-b");
expect(JSON.stringify(protokoll?.details)).not.toContain("PRIVATE"); expect(JSON.stringify(protokoll?.details)).not.toContain("PRIVATE");