Files
florian.wildeandClaude Opus 5.5 64859b3637 fix(proxmox): vorübergehende Störungen wiederholen statt abbrechen
Der Feldtest auf einem ausgelasteten pve-demo scheiterte an Verbindungswacklern, nicht an Proxmox: pveproxy meldete „Broken pipe“ (Backend weg), schloss Keep-Alive-Verbindungen, Storage-Abfragen dauerten 15 s. GET wird bis zu dreimal wiederholt, Schreibendes nur bei sicher nicht angekommener Anfrage; Schreibaufrufe dürfen 120 s dauern, Keep-Alive 2 s.

Refs #020

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:14:47 +02:00

160 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync } from "node:child_process";
import { createHash, X509Certificate } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createServer, type Server } from "node:https";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* Ein Proxmox-Ersatz für Tests: HTTPS mit selbst signiertem Zertifikat (wie Proxmox ab Werk),
* Token-Prüfung und frei definierbaren Routen. Er zeichnet jeden Aufruf auf, damit Tests prüfen
* können, was ProxDeploy geschickt hätte.
*
* Die Zustände einzelner PVE-Versionen (8.3, 8.4, 9.2) kommen mit #003 dazu.
*/
export type MockAnfrage = {
methode: string;
pfad: string;
abfrage: URLSearchParams;
rumpf: URLSearchParams;
autorisierung: string | undefined;
};
export type MockAntwort = {
status?: number;
statusText?: string;
data?: unknown;
errors?: Record<string, string>;
/** Verbindung ohne Antwort hart trennen – wie ein pveproxy, der eine Keep-Alive-Verbindung schließt. */
trennen?: boolean;
};
export type MockRoute = (
anfrage: MockAnfrage,
treffer: RegExpExecArray,
) => MockAntwort | Promise<MockAntwort>;
export const MOCK_TOKEN_ID = "proxdeploy@pve!test";
export const MOCK_TOKEN_GEHEIMNIS = "00000000-1111-2222-3333-444444444444";
let zertifikatCache: { schluessel: string; zertifikat: string; fingerabdruck: string } | undefined;
/** Ein selbst signiertes Zertifikat per openssl – einmal je Testlauf. */
export function testZertifikat(): { schluessel: string; zertifikat: string; fingerabdruck: string } {
if (zertifikatCache) return zertifikatCache;
const ordner = mkdtempSync(join(tmpdir(), "proxdeploy-mock-"));
try {
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"ec",
"-pkeyopt",
"ec_paramgen_curve:prime256v1",
"-nodes",
"-keyout",
join(ordner, "key.pem"),
"-out",
join(ordner, "cert.pem"),
"-days",
"2",
"-subj",
"/CN=pve01.test/O=PVE Cluster Node",
"-addext",
"subjectAltName=DNS:pve01.test,IP:127.0.0.1",
],
{ stdio: "ignore" },
);
const zertifikat = readFileSync(join(ordner, "cert.pem"), "utf8");
const fingerabdruck = new X509Certificate(zertifikat).fingerprint256;
zertifikatCache = {
schluessel: readFileSync(join(ordner, "key.pem"), "utf8"),
zertifikat,
fingerabdruck,
};
return zertifikatCache;
} finally {
rmSync(ordner, { recursive: true, force: true });
}
}
export class ProxmoxMock {
readonly anfragen: MockAnfrage[] = [];
private readonly routen: Array<{ methode: string; muster: RegExp; route: MockRoute }> = [];
private server: Server | undefined;
url = "";
fingerabdruck = "";
route(methode: string, muster: RegExp | string, route: MockRoute): this {
const regex =
typeof muster === "string" ? new RegExp(`^${muster.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) : muster;
this.routen.unshift({ methode, muster: regex, route });
return this;
}
async starten(port = 0): Promise<this> {
const { schluessel, zertifikat, fingerabdruck } = testZertifikat();
this.fingerabdruck = fingerabdruck;
this.server = createServer({ key: schluessel, cert: zertifikat }, (req, res) => {
const teile: Buffer[] = [];
req.on("data", (teil: Buffer) => teile.push(teil));
req.on("end", async () => {
const adresse = new URL(req.url ?? "/", "https://localhost");
const anfrage: MockAnfrage = {
methode: req.method ?? "GET",
pfad: decodeURIComponent(adresse.pathname.replace(/^\/api2\/json/, "")),
abfrage: adresse.searchParams,
rumpf: new URLSearchParams(Buffer.concat(teile).toString("utf8")),
autorisierung: req.headers.authorization,
};
this.anfragen.push(anfrage);
const senden = (status: number, statusText: string, koerper: unknown) => {
res.statusMessage = statusText;
res.writeHead(status, { "content-type": "application/json;charset=UTF-8" });
res.end(JSON.stringify(koerper));
};
if (anfrage.autorisierung !== `PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`) {
senden(401, "authentication failure", { data: null });
return;
}
for (const { methode, muster, route } of this.routen) {
const treffer = methode === anfrage.methode ? muster.exec(anfrage.pfad) : null;
if (!treffer) continue;
try {
const antwort = await route(anfrage, treffer);
if (antwort.trennen) {
res.socket?.destroy();
return;
}
senden(antwort.status ?? 200, antwort.statusText ?? "OK", {
data: antwort.data ?? null,
...(antwort.errors ? { errors: antwort.errors } : {}),
});
} catch (fehler) {
senden(500, String(fehler), { data: null });
}
return;
}
senden(501, `Method '${anfrage.methode} ${anfrage.pfad}' not implemented`, { data: null });
});
});
await new Promise<void>((erfuellen) => this.server!.listen(port, "127.0.0.1", erfuellen));
this.url = `https://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
return this;
}
async stoppen(): Promise<void> {
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
}
/** Der Fingerprint eines **anderen** Zertifikats – für den Fall „Zertifikat ausgetauscht“. */
static fremderFingerabdruck(): string {
const hex = createHash("sha256").update("anderes-zertifikat").digest("hex").toUpperCase();
return hex.match(/.{2}/g)!.join(":");
}
}