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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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(":");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user