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 { 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,
};