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>
160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
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(":");
|
||
}
|
||
}
|