fix(ssh): nur ed25519-Schlüssel speichern, die ssh2 lesen kann

ssh2.utils.generateKeyPairSync liefert in etwa 4 von 1000 Fällen einen privaten Schlüssel, an dem der eigene Parser scheitert. Ein so erzeugter Server-Schlüssel wäre dauerhaft unbrauchbar gewesen; in den Tests war es die Ursache des gelegentlich roten SSH-Tests.

Refs #014

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:39:54 +02:00
co-authored by Claude Opus 5.5
parent 543fc1c8b4
commit 8dca30e254
7 changed files with 52 additions and 15 deletions
+19
View File
@@ -0,0 +1,19 @@
import ssh2 from "ssh2";
/**
* Ein ed25519-Schlüsselpaar im OpenSSH-Format, das ssh2 auch selbst wieder lesen kann.
*
* `ssh2.utils.generateKeyPairSync("ed25519")` liefert in etwa 4 von 1000 Fällen einen privaten
* Schlüssel, an dem der eigene Parser mit „Malformed OpenSSH private key“ scheitert (gemessen mit
* ssh2 1.17.0). Gespeichert wäre so ein Server-Schlüssel dauerhaft unbrauchbar – deshalb wird
* geprüft und notfalls neu erzeugt.
*/
export function ed25519Erzeugen(kommentar?: string): { privat: string; oeffentlich: string } {
for (let versuch = 0; versuch < 20; versuch += 1) {
const paar = ssh2.utils.generateKeyPairSync("ed25519", kommentar ? { comment: kommentar } : {});
if (!(ssh2.utils.parseKey(paar.private) instanceof Error)) {
return { privat: paar.private, oeffentlich: paar.public.trim() };
}
}
throw new Error("Kein lesbarer ed25519-Schlüssel erzeugt.");
}
+3 -3
View File
@@ -1,5 +1,5 @@
import ssh2 from "ssh2";
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
@@ -18,8 +18,8 @@ export async function bereitstellungsschluessel(): Promise<Bereitstellungsschlue
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
}
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
const paar = ed25519Erzeugen(KOMMENTAR);
const neu = { privat: verschluesseln(paar.privat), oeffentlich: paar.oeffentlich };
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
+4 -4
View File
@@ -1,7 +1,7 @@
import ssh2 from "ssh2";
import { Prisma, type Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
import { prisma } from "@/lib/db";
@@ -180,13 +180,13 @@ export async function sshSchluesselErzeugen(
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
const paar = ed25519Erzeugen(`proxdeploy@${server.name}`);
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
data: {
sshPrivat: verschluesseln(paar.private),
sshOeffentlich: paar.public.trim(),
sshPrivat: verschluesseln(paar.privat),
sshOeffentlich: paar.oeffentlich,
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
},