Files
florian.wildeandClaude Opus 5.5 48e2a5cb8f feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox
liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es
vor dem Speichern und bestätigt den SHA-256-Fingerprint.

Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine
fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch
Pin ist im Schema, im Client und per CHECK ausgeschlossen.

Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der
Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock
und im gebauten Stand auf Desktop und 390 px.

Closes #002

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:35:10 +02:00

154 lines
5.5 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>;
};
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);
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(":");
}
}