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,218 @@
|
||||
import { createServer as createNetServer, type AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { aufgabeAbwarten, nodeAusUpid } from "@/engine/proxmox/aufgaben";
|
||||
import { parameterKodieren, pfad, ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { ProxmoxFehler, rechtHinweis } from "@/engine/proxmox/fehler";
|
||||
import { fingerabdruckNormalisieren, zertifikatHolen } from "@/engine/proxmox/tls";
|
||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||
|
||||
const mock = new ProxmoxMock();
|
||||
|
||||
function client(ueberschreiben: Partial<ConstructorParameters<typeof ProxmoxClient>[0]> = {}) {
|
||||
return new ProxmoxClient({
|
||||
url: mock.url,
|
||||
tokenId: MOCK_TOKEN_ID,
|
||||
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||
tlsPruefen: false,
|
||||
fingerabdruck: mock.fingerabdruck,
|
||||
...ueberschreiben,
|
||||
});
|
||||
}
|
||||
|
||||
async function fehlerVon(versprechen: Promise<unknown>): Promise<ProxmoxFehler> {
|
||||
try {
|
||||
await versprechen;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ProxmoxFehler) return fehler;
|
||||
throw fehler;
|
||||
}
|
||||
throw new Error("Es wurde ein Fehler erwartet.");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await mock.starten();
|
||||
mock
|
||||
.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2", repoid: "abc" } }))
|
||||
.route("GET", "/nodes/pve01/qemu/120/config", () => ({
|
||||
status: 403,
|
||||
statusText: "Permission check failed (/vms/120, VM.Audit)",
|
||||
}))
|
||||
.route("POST", "/nodes/pve01/qemu", () => ({
|
||||
status: 400,
|
||||
statusText: "Parameter verification failed.",
|
||||
errors: { memory: "value must have a minimum value of 16" },
|
||||
}))
|
||||
.route("POST", "/echo", (anfrage) => ({ data: Object.fromEntries(anfrage.rumpf) }));
|
||||
});
|
||||
afterAll(() => mock.stoppen());
|
||||
|
||||
describe("Fingerprint", () => {
|
||||
it("normalisiert übliche Schreibweisen", () => {
|
||||
const hex = "ab".repeat(32);
|
||||
const erwartet = "AB:".repeat(31) + "AB";
|
||||
expect(fingerabdruckNormalisieren(hex)).toBe(erwartet);
|
||||
expect(fingerabdruckNormalisieren(`SHA256: ${erwartet.toLowerCase()}`)).toBe(erwartet);
|
||||
expect(fingerabdruckNormalisieren("zu-kurz")).toBeNull();
|
||||
});
|
||||
|
||||
it("holt das Zertifikat des Servers mit Fingerprint und Namen", async () => {
|
||||
const info = await zertifikatHolen(mock.url);
|
||||
expect(info.fingerabdruck).toBe(mock.fingerabdruck);
|
||||
expect(info.subjekt).toContain("CN=pve01.test");
|
||||
expect(info.vertraut).toBe(false);
|
||||
expect(info.alternativeNamen).toContain("DNS:pve01.test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxmoxClient", () => {
|
||||
it("spricht mit gepinntem Fingerprint und API-Token", async () => {
|
||||
expect(await client().get("/version")).toMatchObject({ version: "9.2.1" });
|
||||
expect(mock.anfragen.at(-1)?.autorisierung).toBe(`PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`);
|
||||
});
|
||||
|
||||
it("trennt bei geändertem Fingerprint, bevor das Token gesendet wird", async () => {
|
||||
const vorher = mock.anfragen.length;
|
||||
const fehler = await fehlerVon(
|
||||
client({ fingerabdruck: ProxmoxMock.fremderFingerabdruck() }).get("/version"),
|
||||
);
|
||||
expect(fehler.art).toBe("fingerabdruck");
|
||||
expect(fehler.hinweis).toMatch(/erneuert/);
|
||||
expect(mock.anfragen.length).toBe(vorher);
|
||||
});
|
||||
|
||||
it("meldet ein selbst signiertes Zertifikat bei TLS-Prüfung ohne Pin verständlich", async () => {
|
||||
const fehler = await fehlerVon(client({ tlsPruefen: true, fingerabdruck: null }).get("/version"));
|
||||
expect(fehler.art).toBe("tls");
|
||||
expect(fehler.hinweis).toMatch(/Fingerprint holen/);
|
||||
});
|
||||
|
||||
it("verweigert eine Verbindung ganz ohne Prüfung", () => {
|
||||
expect(() => client({ tlsPruefen: false, fingerabdruck: null })).toThrow(/blind/);
|
||||
});
|
||||
|
||||
it("übersetzt 401 in einen Hinweis zum Token", async () => {
|
||||
const fehler = await fehlerVon(client({ tokenGeheimnis: "falsch" }).get("/version"));
|
||||
expect(fehler.art).toBe("anmeldung");
|
||||
expect(fehler.hinweis).toMatch(/Token-ID/);
|
||||
});
|
||||
|
||||
it("nennt bei 403 das fehlende Recht und den Pfad", async () => {
|
||||
const fehler = await fehlerVon(client().get(pfad`/nodes/${"pve01"}/qemu/${120}/config`));
|
||||
expect(fehler.art).toBe("recht");
|
||||
expect(fehler.hinweis).toContain("VM.Audit auf /vms/120");
|
||||
});
|
||||
|
||||
it("gibt abgelehnte Parameter einzeln zurück", async () => {
|
||||
const fehler = await fehlerVon(client().post("/nodes/pve01/qemu", { memory: 1 }));
|
||||
expect(fehler.art).toBe("parameter");
|
||||
expect(fehler.ursache).toContain("memory: value must have a minimum value of 16");
|
||||
});
|
||||
|
||||
it("meldet unbekannte Endpunkte als nicht unterstützt (alte PVE-Version)", async () => {
|
||||
const fehler = await fehlerVon(client().get("/gibt/es/nicht"));
|
||||
expect(fehler.art).toBe("nicht_gefunden");
|
||||
});
|
||||
|
||||
it("meldet einen nicht erreichbaren Host", async () => {
|
||||
// Einen freien Port belegen und sofort wieder freigeben – dort lauscht garantiert niemand.
|
||||
const frei = await new Promise<number>((erfuellen) => {
|
||||
const server = createNetServer().listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
server.close(() => erfuellen(port));
|
||||
});
|
||||
});
|
||||
const fehler = await fehlerVon(client({ url: `https://127.0.0.1:${frei}` }).get("/version"));
|
||||
expect(fehler.art).toBe("verbindung");
|
||||
expect(fehler.hinweis).toMatch(/8006/);
|
||||
});
|
||||
|
||||
it("kodiert Parameter wie pvesh: 1/0, Listen wiederholt, undefined weggelassen", async () => {
|
||||
expect(parameterKodieren({ a: true, b: false, c: ["x", "y"], d: undefined, e: 5 }).toString()).toBe(
|
||||
"a=1&b=0&c=x&c=y&e=5",
|
||||
);
|
||||
expect(await client().post("/echo", { name: "web 01", full: true })).toEqual({
|
||||
name: "web 01",
|
||||
full: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("setzt Pfadteile kodiert ein", () => {
|
||||
expect(pfad`/nodes/${"pve 01"}/storage/${"a/b"}`).toBe("/nodes/pve%2001/storage/a%2Fb");
|
||||
});
|
||||
|
||||
it("prüft das Format der Token-ID", () => {
|
||||
try {
|
||||
client({ tokenId: "root@pam" });
|
||||
expect.unreachable();
|
||||
} catch (fehler) {
|
||||
expect((fehler as ProxmoxFehler).hinweis).toMatch(/benutzer@realm!tokenname/);
|
||||
}
|
||||
});
|
||||
|
||||
it("meldet jeden Aufruf an einen Beobachter (für Log und Dry-Run)", async () => {
|
||||
const gesehen: string[] = [];
|
||||
const beobachtet = new ProxmoxClient(
|
||||
{
|
||||
url: mock.url,
|
||||
tokenId: MOCK_TOKEN_ID,
|
||||
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||
tlsPruefen: false,
|
||||
fingerabdruck: mock.fingerabdruck,
|
||||
},
|
||||
{
|
||||
beobachter: (aufruf, ergebnis) =>
|
||||
gesehen.push(`${aufruf.methode} ${aufruf.pfad} ${ergebnis.fehler?.art ?? "ok"}`),
|
||||
},
|
||||
);
|
||||
await beobachtet.get("/version");
|
||||
await beobachtet.get("/gibt/es/nicht").catch(() => undefined);
|
||||
expect(gesehen).toEqual(["GET /version ok", "GET /gibt/es/nicht nicht_gefunden"]);
|
||||
});
|
||||
|
||||
it("macht aus einem unbekannten Fehlertext einen allgemeinen Hinweis", () => {
|
||||
expect(rechtHinweis("irgendwas")).toMatch(/README/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Proxmox-Tasks (UPID)", () => {
|
||||
const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:";
|
||||
|
||||
it("liest den Node aus der UPID", () => {
|
||||
expect(nodeAusUpid(UPID)).toBe("pve01");
|
||||
expect(() => nodeAusUpid("kaputt")).toThrow(ProxmoxFehler);
|
||||
});
|
||||
|
||||
it("wartet, bis der Task fertig ist, und reicht Log-Zeilen weiter", async () => {
|
||||
let abfragen = 0;
|
||||
mock
|
||||
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => {
|
||||
abfragen += 1;
|
||||
return { data: abfragen < 3 ? { status: "running" } : { status: "stopped", exitstatus: "OK" } };
|
||||
})
|
||||
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, (anfrage) => {
|
||||
const start = Number(anfrage.abfrage.get("start"));
|
||||
return {
|
||||
data:
|
||||
start < abfragen ? [{ n: abfragen, t: `Zeile ${abfragen}` }] : [{ n: start, t: "no content" }],
|
||||
};
|
||||
});
|
||||
const zeilen: string[] = [];
|
||||
await aufgabeAbwarten(client(), UPID, { warten: async () => {}, beiLog: (neu) => zeilen.push(...neu) });
|
||||
expect(abfragen).toBe(3);
|
||||
expect(zeilen).toEqual(["Zeile 1", "Zeile 2", "Zeile 3"]);
|
||||
});
|
||||
|
||||
it("wirft mit dem Exitstatus und den letzten Log-Zeilen, wenn der Task scheitert", async () => {
|
||||
mock
|
||||
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => ({
|
||||
data: { status: "stopped", exitstatus: "unable to create VM 120 - already exists" },
|
||||
}))
|
||||
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, () => ({
|
||||
data: [{ n: 1, t: "TASK ERROR: already exists" }],
|
||||
}));
|
||||
const fehler = await fehlerVon(aufgabeAbwarten(client(), UPID, { warten: async () => {} }));
|
||||
expect(fehler.art).toBe("aufgabe");
|
||||
expect(fehler.ursache).toContain("already exists");
|
||||
expect(fehler.hinweis).toContain("TASK ERROR");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user