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:
@@ -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.");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ssh2 from "ssh2";
|
|
||||||
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
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 };
|
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
||||||
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
||||||
}
|
}
|
||||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
|
const paar = ed25519Erzeugen(KOMMENTAR);
|
||||||
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
|
const neu = { privat: verschluesseln(paar.privat), oeffentlich: paar.oeffentlich };
|
||||||
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
||||||
await prisma.einstellung.upsert({
|
await prisma.einstellung.upsert({
|
||||||
where: { schluessel: SCHLUESSEL },
|
where: { schluessel: SCHLUESSEL },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ssh2 from "ssh2";
|
|
||||||
import { Prisma, type Server } from "@/generated/prisma/client";
|
import { Prisma, type Server } from "@/generated/prisma/client";
|
||||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -180,13 +180,13 @@ export async function sshSchluesselErzeugen(
|
|||||||
ip: string | null,
|
ip: string | null,
|
||||||
): Promise<ServerSicht> {
|
): Promise<ServerSicht> {
|
||||||
const server = await serverHolen(name);
|
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 neu = await prisma.$transaction(async (tx) => {
|
||||||
const aktualisiert = await tx.server.update({
|
const aktualisiert = await tx.server.update({
|
||||||
where: { id: server.id },
|
where: { id: server.id },
|
||||||
data: {
|
data: {
|
||||||
sshPrivat: verschluesseln(paar.private),
|
sshPrivat: verschluesseln(paar.privat),
|
||||||
sshOeffentlich: paar.public.trim(),
|
sshOeffentlich: paar.oeffentlich,
|
||||||
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
// 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.
|
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { utils } from "ssh2";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
import { erkunden } from "@/engine/proxmox/erkundung";
|
import { erkunden } from "@/engine/proxmox/erkundung";
|
||||||
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
|
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
|
||||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
|
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
|
||||||
import { SshMock } from "../ssh-mock";
|
import { SshMock, testPaar } from "../ssh-mock";
|
||||||
|
|
||||||
const aufraeumen: Array<() => Promise<void>> = [];
|
const aufraeumen: Array<() => Promise<void>> = [];
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -102,7 +101,7 @@ describe("Verbindungstest (Erkundung)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("SSH", () => {
|
describe("SSH", () => {
|
||||||
const paar = utils.generateKeyPairSync("ed25519");
|
const paar = testPaar();
|
||||||
|
|
||||||
async function sshServer() {
|
async function sshServer() {
|
||||||
const ssh = await new SshMock(paar.public).starten();
|
const ssh = await new SshMock(paar.public).starten();
|
||||||
@@ -147,7 +146,7 @@ describe("SSH", () => {
|
|||||||
|
|
||||||
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
|
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
|
||||||
const ssh = await sshServer();
|
const ssh = await sshServer();
|
||||||
const fremd = utils.generateKeyPairSync("ed25519");
|
const fremd = testPaar();
|
||||||
const fehler = (await sshAusfuehren(
|
const fehler = (await sshAusfuehren(
|
||||||
{
|
{
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { utils } from "ssh2";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
|
|
||||||
|
describe("ed25519Erzeugen", () => {
|
||||||
|
it("liefert nur Schlüssel, die ssh2 selbst lesen kann (roh scheitern etwa 4 von 1000)", () => {
|
||||||
|
for (let i = 0; i < 1500; i++) {
|
||||||
|
const paar = ed25519Erzeugen("test");
|
||||||
|
expect(utils.parseKey(paar.privat)).not.toBeInstanceOf(Error);
|
||||||
|
expect(paar.oeffentlich).toMatch(/^ssh-ed25519 \S+ test$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { Server, utils } from "ssh2";
|
import { Server, utils } from "ssh2";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
|
import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,7 @@ export class SshMock {
|
|||||||
|
|
||||||
async starten(): Promise<this> {
|
async starten(): Promise<this> {
|
||||||
// ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
|
// ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
|
||||||
const hostPem = utils.generateKeyPairSync("ed25519").private;
|
const hostPem = ed25519Erzeugen().privat;
|
||||||
const host = utils.parseKey(hostPem);
|
const host = utils.parseKey(hostPem);
|
||||||
if (host instanceof Error) throw host;
|
if (host instanceof Error) throw host;
|
||||||
this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
|
this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
|
||||||
@@ -64,3 +65,9 @@ export class SshMock {
|
|||||||
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
|
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Schlüsselpaar für Tests im Format von ssh2 (`public`/`private`) – immer lesbar. */
|
||||||
|
export function testPaar(): { public: string; private: string } {
|
||||||
|
const paar = ed25519Erzeugen();
|
||||||
|
return { public: paar.oeffentlich, private: paar.privat };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { utils } from "ssh2";
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
|
import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
|
||||||
import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
|
import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
|
||||||
import { verschluesseln } from "@/lib/geheimnisse";
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
|
||||||
import { szenarioEinrichten } from "./proxmox-mock/szenario";
|
import { szenarioEinrichten } from "./proxmox-mock/szenario";
|
||||||
import { SshMock } from "./ssh-mock";
|
import { SshMock, testPaar } from "./ssh-mock";
|
||||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
const mock = new ProxmoxMock();
|
const mock = new ProxmoxMock();
|
||||||
const paar = utils.generateKeyPairSync("ed25519");
|
const paar = testPaar();
|
||||||
let ssh: SshMock;
|
let ssh: SshMock;
|
||||||
|
|
||||||
describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
|
describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user