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:
@@ -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,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user