feat(server): Verbindungstest mit Fähigkeitserkennung
Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH. Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt werden kann nur ein Schlüssel, den der letzte Test gesehen hat. Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im Web-Subset von JetBrains Mono. Refs #003 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { ProxmoxMock } from "../proxmox-mock/mock";
|
||||
import { szenarioEinrichten } from "../proxmox-mock/szenario";
|
||||
|
||||
/**
|
||||
* Startet für die Abläufe einen Proxmox-Mock auf festem Port. Die gebaute Anwendung verbindet
|
||||
@@ -8,7 +9,7 @@ export const MOCK_PORT = 18006;
|
||||
|
||||
export default async function aufbau(): Promise<() => Promise<void>> {
|
||||
const mock = new ProxmoxMock();
|
||||
mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
|
||||
szenarioEinrichten(mock, { version: "9.2.1", importStorage: true });
|
||||
await mock.starten(MOCK_PORT);
|
||||
return async () => {
|
||||
await mock.stoppen();
|
||||
|
||||
@@ -14,7 +14,10 @@ test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
|
||||
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
|
||||
await expect(page.getByText("✗")).toBeVisible();
|
||||
// Symbol + Text: das gezeichnete ✗ steht im selben Element wie die Meldung.
|
||||
await expect(
|
||||
page.getByText("Benutzername oder Passwort ist falsch.").locator("xpath=..").locator("svg"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
|
||||
|
||||
@@ -28,6 +28,12 @@ test("Server anlegen: Zertifikat pinnen, Verbindung prüfen, SSH-Schlüssel, lö
|
||||
await expect(page).toHaveURL(new RegExp(`/server/${name}$`));
|
||||
await expect(page.getByRole("heading", { name: "E2E-Testserver" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Verbindung testen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Was geht auf diesem Server" })).toBeVisible();
|
||||
await expect(page.getByText("Alle nötigen Rechte vorhanden.")).toBeVisible();
|
||||
await expect(page.getByText("vmbr0", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: `test-results/server-test-${test.info().project.name}.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { utils } from "ssh2";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { erkunden } from "@/engine/proxmox/erkundung";
|
||||
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
|
||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
|
||||
import { SshMock } from "../ssh-mock";
|
||||
|
||||
const aufraeumen: Array<() => Promise<void>> = [];
|
||||
afterEach(async () => {
|
||||
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||
});
|
||||
|
||||
async function mitSzenario(szenario: Szenario) {
|
||||
const mock = await new ProxmoxMock().starten();
|
||||
szenarioEinrichten(mock, szenario);
|
||||
const client = new ProxmoxClient({
|
||||
url: mock.url,
|
||||
tokenId: MOCK_TOKEN_ID,
|
||||
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||
tlsPruefen: false,
|
||||
fingerabdruck: mock.fingerabdruck,
|
||||
});
|
||||
aufraeumen.push(async () => {
|
||||
await client.schliessen();
|
||||
await mock.stoppen();
|
||||
});
|
||||
return { mock, client };
|
||||
}
|
||||
|
||||
describe("Verbindungstest (Erkundung)", () => {
|
||||
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
|
||||
const { client } = await mitSzenario({ version: "9.2.1", importStorage: true });
|
||||
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
|
||||
expect(bericht.version).toBe("9.2.1");
|
||||
expect(bericht.cluster).toBeNull();
|
||||
expect(bericht.knoten).toHaveLength(1);
|
||||
expect(bericht.knoten[0]!.storages.map((s) => s.storage)).toEqual(["local", "local-zfs"]);
|
||||
expect(bericht.knoten[0]!.bridges).toEqual([
|
||||
expect.objectContaining({ name: "vmbr0", vlanBewusst: true, cidr: "10.0.10.2/24", kommentar: "LAN" }),
|
||||
expect.objectContaining({ name: "vmbr1", vlanBewusst: false }),
|
||||
]);
|
||||
expect(bericht.vnets).toEqual([{ name: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }]);
|
||||
expect(bericht.fehlendeRechte.filter((r) => r.pflicht)).toEqual([]);
|
||||
expect(bericht.faehigkeiten.templateBau.weg).toBe("api");
|
||||
expect(bericht.warnungen).toEqual([]);
|
||||
});
|
||||
|
||||
it("Cluster: Name, Quorum und Node-IPs", async () => {
|
||||
const { client } = await mitSzenario({
|
||||
version: "9.2.1",
|
||||
cluster: {
|
||||
name: "rz1",
|
||||
nodes: [
|
||||
{ name: "pve01", ip: "10.0.0.1" },
|
||||
{ name: "pve02", ip: "10.0.0.2" },
|
||||
{ name: "pve03", ip: "10.0.0.3", online: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
const bericht = await erkunden(client, { serverHost: "10.0.0.1", ssh: null });
|
||||
expect(bericht.cluster).toEqual({ name: "rz1", quorate: true });
|
||||
expect(bericht.knoten.map((k) => `${k.name}:${k.online}:${k.ip}`)).toEqual([
|
||||
"pve01:true:10.0.0.1",
|
||||
"pve02:true:10.0.0.2",
|
||||
"pve03:false:10.0.0.3",
|
||||
]);
|
||||
expect(bericht.knoten[2]!.storages).toEqual([]);
|
||||
});
|
||||
|
||||
it("PVE 8.3: Template-Bau braucht SSH, VM.Monitor wird verlangt", async () => {
|
||||
const { client } = await mitSzenario({
|
||||
version: "8.3.5",
|
||||
importStorage: true,
|
||||
ohneRechte: ["VM.Monitor"],
|
||||
});
|
||||
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
|
||||
expect(bericht.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
|
||||
expect(bericht.fehlendeRechte).toContainEqual(
|
||||
expect.objectContaining({ recht: "VM.Monitor", pfad: "/vms" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fehlende Leserechte werden zu Warnungen statt zum Abbruch", async () => {
|
||||
const { client } = await mitSzenario({
|
||||
version: "9.2.1",
|
||||
ohneClusterStatus: true,
|
||||
ohneRechte: ["SDN.Audit"],
|
||||
});
|
||||
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
|
||||
expect(bericht.knoten).toHaveLength(1);
|
||||
expect(bericht.vnets).toBeNull();
|
||||
expect(bericht.warnungen.join("\n")).toMatch(/Cluster-Status.*Sys\.Audit/);
|
||||
});
|
||||
|
||||
it("warnt vor PVE 7", async () => {
|
||||
const { client } = await mitSzenario({ version: "7.4.17" });
|
||||
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
|
||||
expect(bericht.warnungen.join()).toMatch(/nicht unterstützt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSH", () => {
|
||||
const paar = utils.generateKeyPairSync("ed25519");
|
||||
|
||||
async function sshServer() {
|
||||
const ssh = await new SshMock(paar.public).starten();
|
||||
aufraeumen.push(() => ssh.stoppen());
|
||||
return ssh;
|
||||
}
|
||||
|
||||
it("Erkunden liefert den Host-Key; danach nur noch mit Pin", async () => {
|
||||
const ssh = await sshServer();
|
||||
const ziel = {
|
||||
host: "127.0.0.1",
|
||||
port: ssh.port,
|
||||
benutzer: "root",
|
||||
privatSchluessel: paar.private,
|
||||
hostSchluessel: null,
|
||||
};
|
||||
const erkundet = await sshAusfuehren(ziel, "pveversion", { erkunden: true });
|
||||
expect(erkundet.hostSchluessel).toBe(ssh.hostSchluessel);
|
||||
expect(erkundet.stdout).toContain("pve-manager/9.2.1");
|
||||
|
||||
await expect(sshAusfuehren(ziel, "pveversion")).rejects.toMatchObject({ art: "host_schluessel" });
|
||||
const mitPin = await sshAusfuehren({ ...ziel, hostSchluessel: ssh.hostSchluessel }, "echo hallo");
|
||||
expect(mitPin.code).toBe(0);
|
||||
});
|
||||
|
||||
it("trennt bei geändertem Host-Key", async () => {
|
||||
const ssh = await sshServer();
|
||||
const fehler = await sshAusfuehren(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: ssh.port,
|
||||
benutzer: "root",
|
||||
privatSchluessel: paar.private,
|
||||
hostSchluessel: "SHA256:falsch",
|
||||
},
|
||||
"pveversion",
|
||||
).catch((f: unknown) => f);
|
||||
expect(fehler).toBeInstanceOf(SshFehler);
|
||||
expect((fehler as SshFehler).art).toBe("host_schluessel");
|
||||
expect(ssh.befehle).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
|
||||
const ssh = await sshServer();
|
||||
const fremd = utils.generateKeyPairSync("ed25519");
|
||||
const fehler = (await sshAusfuehren(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: ssh.port,
|
||||
benutzer: "root",
|
||||
privatSchluessel: fremd.private,
|
||||
hostSchluessel: ssh.hostSchluessel,
|
||||
},
|
||||
"pveversion",
|
||||
).catch((f: unknown) => f)) as SshFehler;
|
||||
expect(fehler.art).toBe("anmeldung");
|
||||
expect(fehler.hinweis).toMatch(/authorized_keys/);
|
||||
});
|
||||
|
||||
it("Erkundung mit SSH: unbestätigt, dann bestätigt → Snippets und pct exec möglich", async () => {
|
||||
const ssh = await sshServer();
|
||||
const { client } = await mitSzenario({ version: "8.3.5", importStorage: true });
|
||||
const zugang = { benutzer: "root", port: ssh.port, privatSchluessel: paar.private, hostSchluessel: {} };
|
||||
|
||||
const erst = await erkunden(client, { serverHost: "127.0.0.1", ssh: zugang });
|
||||
expect(erst.ssh).toBe("nicht_bestaetigt");
|
||||
expect(erst.knoten[0]!.ssh).toMatchObject({
|
||||
zustand: "nicht_bestaetigt",
|
||||
hostSchluessel: ssh.hostSchluessel,
|
||||
});
|
||||
expect(erst.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
|
||||
|
||||
const dann = await erkunden(client, {
|
||||
serverHost: "127.0.0.1",
|
||||
ssh: { ...zugang, hostSchluessel: { pve01: ssh.hostSchluessel } },
|
||||
});
|
||||
expect(dann.ssh).toBe("ok");
|
||||
expect(dann.faehigkeiten.templateBau.weg).toBe("ssh");
|
||||
expect(dann.faehigkeiten.lxcProvisionierung).toMatchObject({ ueber: "node" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
benoetigteRechte,
|
||||
faehigkeitenAbleiten,
|
||||
rechtePruefen,
|
||||
versionLesen,
|
||||
type StorageInfo,
|
||||
} from "@/engine/proxmox/faehigkeiten";
|
||||
|
||||
const v = (text: string) => versionLesen(text)!;
|
||||
const storage = (name: string, inhalte: string[]): StorageInfo => ({
|
||||
storage: name,
|
||||
typ: "dir",
|
||||
inhalte,
|
||||
aktiv: true,
|
||||
geteilt: false,
|
||||
frei: null,
|
||||
gesamt: null,
|
||||
});
|
||||
|
||||
describe("PVE-Version", () => {
|
||||
it("liest übliche Schreibweisen", () => {
|
||||
expect(v("9.2.1")).toMatchObject({ major: 9, minor: 2, patch: 1 });
|
||||
expect(v("8.4")).toMatchObject({ major: 8, minor: 4, patch: 0 });
|
||||
expect(versionLesen("kaputt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rolle ProxDeploy je Version", () => {
|
||||
const namen = (text: string) =>
|
||||
benoetigteRechte(v(text))
|
||||
.filter((r) => r.pflicht)
|
||||
.map((r) => r.recht);
|
||||
|
||||
it("PVE 9 nutzt die Guest-Agent-Rechte statt VM.Monitor", () => {
|
||||
expect(namen("9.2.1")).toEqual(
|
||||
expect.arrayContaining(["VM.GuestAgent.Audit", "VM.GuestAgent.Unrestricted"]),
|
||||
);
|
||||
expect(namen("9.2.1")).not.toContain("VM.Monitor");
|
||||
});
|
||||
|
||||
it("PVE 8 braucht VM.Monitor für den Guest-Agent", () => {
|
||||
expect(namen("8.4.1")).toContain("VM.Monitor");
|
||||
expect(namen("8.4.1")).not.toContain("VM.GuestAgent.Unrestricted");
|
||||
});
|
||||
|
||||
it("download-url: Sys.AccessNetwork ab 8.2, davor Sys.Modify", () => {
|
||||
expect(namen("8.2.0")).toContain("Sys.AccessNetwork");
|
||||
expect(namen("8.1.4")).toContain("Sys.Modify");
|
||||
expect(namen("8.1.4")).not.toContain("Sys.AccessNetwork");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rechteprüfung", () => {
|
||||
const version = v("9.2.1");
|
||||
const voll = Object.fromEntries(benoetigteRechte(version).map((r) => [r.recht, 1]));
|
||||
|
||||
it("meldet nichts, wenn alles da ist", () => {
|
||||
const rechte = {
|
||||
"/": voll,
|
||||
"/vms": voll,
|
||||
"/sdn/zones/localnetwork": voll,
|
||||
"/nodes/pve01": voll,
|
||||
"/storage/local-zfs": voll,
|
||||
};
|
||||
expect(rechtePruefen(version, rechte, { storages: ["local-zfs"], nodes: ["pve01"] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("nennt fehlende Rechte mit Pfad und Zweck", () => {
|
||||
const ohneAgent = { ...voll, "VM.GuestAgent.Unrestricted": 0 };
|
||||
const rechte = {
|
||||
"/": voll,
|
||||
"/vms": ohneAgent,
|
||||
"/sdn/zones/localnetwork": voll,
|
||||
"/nodes/pve01": voll,
|
||||
"/storage/local-zfs": voll,
|
||||
};
|
||||
expect(rechtePruefen(version, rechte, { storages: ["local-zfs"], nodes: ["pve01"] })).toEqual([
|
||||
expect.objectContaining({ recht: "VM.GuestAgent.Unrestricted", pfad: "/vms", pflicht: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("Storage-Rechte: einer reicht; Node-Rechte: jeder Node braucht sie", () => {
|
||||
const rechte = {
|
||||
"/": voll,
|
||||
"/vms": voll,
|
||||
"/sdn/zones/localnetwork": voll,
|
||||
"/nodes/pve01": voll,
|
||||
"/nodes/pve02": {},
|
||||
"/storage/a": voll,
|
||||
"/storage/b": {},
|
||||
};
|
||||
const fehlend = rechtePruefen(version, rechte, { storages: ["a", "b"], nodes: ["pve01", "pve02"] });
|
||||
expect(fehlend.filter((f) => f.pflicht).map((f) => `${f.recht} ${f.pfad}`)).toEqual([
|
||||
"Sys.AccessNetwork /nodes/pve02",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Strategien", () => {
|
||||
const mitImport = [storage("local", ["iso", "import", "snippets"])];
|
||||
const ohneImport = [storage("local", ["iso", "snippets"])];
|
||||
|
||||
it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => {
|
||||
const f = faehigkeitenAbleiten({
|
||||
version: v("9.2.1"),
|
||||
storages: mitImport,
|
||||
ssh: "aus",
|
||||
rechteFehlen: false,
|
||||
});
|
||||
expect(f.templateBau.weg).toBe("api");
|
||||
expect(f.vmProvisionierung.weg).toBe("api");
|
||||
expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
|
||||
expect(f.snippets.weg).toBe("nicht_moeglich");
|
||||
});
|
||||
|
||||
it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => {
|
||||
expect(
|
||||
faehigkeitenAbleiten({ version: v("8.3.5"), storages: mitImport, ssh: "ok", rechteFehlen: false })
|
||||
.templateBau.weg,
|
||||
).toBe("ssh");
|
||||
const ohne = faehigkeitenAbleiten({
|
||||
version: v("8.3.5"),
|
||||
storages: mitImport,
|
||||
ssh: "aus",
|
||||
rechteFehlen: false,
|
||||
});
|
||||
expect(ohne.templateBau).toMatchObject({ weg: "nicht_moeglich" });
|
||||
expect(ohne.templateBau.grund).toContain("8.4");
|
||||
});
|
||||
|
||||
it("PVE 8.4 ohne import-Storage nennt den Ausweg", () => {
|
||||
const f = faehigkeitenAbleiten({
|
||||
version: v("8.4.1"),
|
||||
storages: ohneImport,
|
||||
ssh: "aus",
|
||||
rechteFehlen: false,
|
||||
});
|
||||
expect(f.templateBau.grund).toMatch(/Inhaltstyp „Import“/);
|
||||
});
|
||||
|
||||
it("mit SSH: Snippets, pct exec und erweiterte LXC-Features", () => {
|
||||
const f = faehigkeitenAbleiten({
|
||||
version: v("9.2.1"),
|
||||
storages: mitImport,
|
||||
ssh: "ok",
|
||||
rechteFehlen: false,
|
||||
});
|
||||
expect(f.snippets.weg).toBe("ssh");
|
||||
expect(f.lxcProvisionierung).toMatchObject({ ueber: "node" });
|
||||
expect(f.lxcFeaturesErweitert.weg).toBe("ssh");
|
||||
});
|
||||
|
||||
it("unbestätigter Host-Key zählt nicht als SSH", () => {
|
||||
const f = faehigkeitenAbleiten({
|
||||
version: v("8.3.5"),
|
||||
storages: mitImport,
|
||||
ssh: "nicht_bestaetigt",
|
||||
rechteFehlen: false,
|
||||
});
|
||||
expect(f.templateBau.weg).toBe("nicht_moeglich");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { benoetigteRechte, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||||
import type { ProxmoxMock } from "./mock";
|
||||
|
||||
/**
|
||||
* Typische Proxmox-Zustände für Tests. Die Antworten folgen dem Format der echten API
|
||||
* (geprüft gegen das API-Schema von PVE 9.2).
|
||||
*/
|
||||
export type Szenario = {
|
||||
version: string;
|
||||
cluster?: { name: string; nodes: Array<{ name: string; ip: string; online?: boolean }> };
|
||||
/** Storage `local` mit Inhaltstyp import? (ab 8.4 nötig für Template-Bau per API) */
|
||||
importStorage?: boolean;
|
||||
/** Rechte, die dem Token fehlen sollen. */
|
||||
ohneRechte?: string[];
|
||||
/** Ohne Sys.Audit auf / antwortet /cluster/status mit 403. */
|
||||
ohneClusterStatus?: boolean;
|
||||
};
|
||||
|
||||
export function szenarioEinrichten(mock: ProxmoxMock, szenario: Szenario): void {
|
||||
const version = versionLesen(szenario.version)!;
|
||||
const nodes = szenario.cluster?.nodes ?? [{ name: "pve01", ip: "127.0.0.1", online: true }];
|
||||
const ohne = new Set(szenario.ohneRechte ?? []);
|
||||
const alle = benoetigteRechte(version).map((r) => r.recht);
|
||||
const rechte = Object.fromEntries(alle.filter((r) => !ohne.has(r)).map((r) => [r, 1]));
|
||||
|
||||
mock.route("GET", "/version", () => ({
|
||||
data: { version: szenario.version, release: `${version.major}.${version.minor}`, repoid: "abcdef" },
|
||||
}));
|
||||
mock.route("GET", "/cluster/status", () =>
|
||||
szenario.ohneClusterStatus
|
||||
? { status: 403, statusText: "Permission check failed (/, Sys.Audit)" }
|
||||
: {
|
||||
data: [
|
||||
...(szenario.cluster
|
||||
? [
|
||||
{
|
||||
type: "cluster",
|
||||
name: szenario.cluster.name,
|
||||
quorate: 1,
|
||||
nodes: nodes.length,
|
||||
version: 3,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...nodes.map((n, i) => ({
|
||||
type: "node",
|
||||
name: n.name,
|
||||
online: n.online === false ? 0 : 1,
|
||||
ip: n.ip,
|
||||
local: i === 0 ? 1 : 0,
|
||||
nodeid: i + 1,
|
||||
})),
|
||||
],
|
||||
},
|
||||
);
|
||||
mock.route("GET", "/nodes", () => ({
|
||||
data: nodes.map((n) => ({
|
||||
node: n.name,
|
||||
status: n.online === false ? "offline" : "online",
|
||||
cpu: 0.05,
|
||||
maxcpu: 16,
|
||||
mem: 8 * 1024 ** 3,
|
||||
maxmem: 64 * 1024 ** 3,
|
||||
uptime: 3600,
|
||||
})),
|
||||
}));
|
||||
mock.route("GET", /^\/nodes\/([^/]+)\/version$/, () => ({
|
||||
data: { version: szenario.version, release: `${version.major}.${version.minor}`, repoid: "abcdef" },
|
||||
}));
|
||||
mock.route("GET", /^\/nodes\/([^/]+)\/storage$/, () => ({
|
||||
data: [
|
||||
{
|
||||
storage: "local",
|
||||
type: "dir",
|
||||
content: ["iso", "vztmpl", "backup", "snippets", ...(szenario.importStorage ? ["import"] : [])].join(
|
||||
",",
|
||||
),
|
||||
active: 1,
|
||||
enabled: 1,
|
||||
shared: 0,
|
||||
avail: 80 * 1024 ** 3,
|
||||
total: 100 * 1024 ** 3,
|
||||
},
|
||||
{
|
||||
storage: "local-zfs",
|
||||
type: "zfspool",
|
||||
content: "images,rootdir",
|
||||
active: 1,
|
||||
enabled: 1,
|
||||
shared: 0,
|
||||
avail: 800 * 1024 ** 3,
|
||||
total: 1000 * 1024 ** 3,
|
||||
},
|
||||
],
|
||||
}));
|
||||
mock.route("GET", /^\/nodes\/([^/]+)\/network$/, () => ({
|
||||
data: [
|
||||
{ iface: "eno1", type: "eth", active: 1 },
|
||||
{
|
||||
iface: "vmbr0",
|
||||
type: "bridge",
|
||||
active: 1,
|
||||
bridge_vlan_aware: 1,
|
||||
cidr: "10.0.10.2/24",
|
||||
comments: "LAN\n",
|
||||
},
|
||||
{ iface: "vmbr1", type: "bridge", active: 1 },
|
||||
],
|
||||
}));
|
||||
mock.route("GET", "/cluster/sdn/vnets", () =>
|
||||
ohne.has("SDN.Audit")
|
||||
? { status: 403, statusText: "Permission check failed (/sdn, SDN.Audit)" }
|
||||
: { data: [{ vnet: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }] },
|
||||
);
|
||||
mock.route("GET", "/access/permissions", (anfrage) => {
|
||||
const p = anfrage.abfrage.get("path") ?? "/";
|
||||
return { data: { [p]: rechte } };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { benoetigteRechte, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||||
|
||||
/** Die Befehle in der Doku müssen genau die Rechte nennen, die der Verbindungstest verlangt. */
|
||||
const doku = readFileSync("docs/proxmox-rechte.md", "utf8");
|
||||
|
||||
function befehlNach(ueberschrift: string): string[] {
|
||||
const abschnitt = doku.slice(doku.indexOf(ueberschrift));
|
||||
const treffer = /--privs "([^"]+)"/.exec(abschnitt);
|
||||
return treffer![1]!.split(" ").sort();
|
||||
}
|
||||
|
||||
describe("Doku der Proxmox-Rechte", () => {
|
||||
it.each([
|
||||
["## Rolle anlegen (PVE 9.x)", "9.2.1"],
|
||||
["## Rolle anlegen (PVE 8.x)", "8.4.1"],
|
||||
])("%s entspricht der Pflichtliste", (ueberschrift, version) => {
|
||||
const pflicht = benoetigteRechte(versionLesen(version)!)
|
||||
.filter((r) => r.pflicht)
|
||||
.map((r) => r.recht)
|
||||
.sort();
|
||||
expect(befehlNach(ueberschrift)).toEqual(pflicht);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Server, utils } from "ssh2";
|
||||
import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
|
||||
|
||||
/**
|
||||
* Ein SSH-Server für Tests (ssh2 bringt ihn mit). Er nimmt genau einen öffentlichen Schlüssel an
|
||||
* und beantwortet Befehle über eine frei definierbare Funktion.
|
||||
*/
|
||||
export class SshMock {
|
||||
private server: Server | undefined;
|
||||
port = 0;
|
||||
hostSchluessel = "";
|
||||
befehle: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly erlaubterSchluessel: string,
|
||||
private readonly antwort: (befehl: string) => {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
} = () => ({
|
||||
stdout: "pve-manager/9.2.1/abcdef (running kernel: 6.14.8-2-pve)\n",
|
||||
}),
|
||||
) {}
|
||||
|
||||
async starten(): Promise<this> {
|
||||
// ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
|
||||
const hostPem = utils.generateKeyPairSync("ed25519").private;
|
||||
const host = utils.parseKey(hostPem);
|
||||
if (host instanceof Error) throw host;
|
||||
this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
|
||||
const erlaubt = utils.parseKey(this.erlaubterSchluessel);
|
||||
if (erlaubt instanceof Error) throw erlaubt;
|
||||
const erlaubtSsh = (Array.isArray(erlaubt) ? erlaubt[0]! : erlaubt).getPublicSSH();
|
||||
|
||||
this.server = new Server({ hostKeys: [hostPem] }, (client) => {
|
||||
client
|
||||
.on("authentication", (kontext) => {
|
||||
if (kontext.method === "publickey" && kontext.key.data.equals(erlaubtSsh)) kontext.accept();
|
||||
else kontext.reject(["publickey"]);
|
||||
})
|
||||
.on("ready", () => {
|
||||
client.on("session", (annehmen) => {
|
||||
const sitzung = annehmen();
|
||||
sitzung.on("exec", (annehmenExec, _ablehnen, info) => {
|
||||
const kanal = annehmenExec();
|
||||
this.befehle.push(info.command);
|
||||
const ergebnis = this.antwort(info.command);
|
||||
if (ergebnis.stdout) kanal.write(ergebnis.stdout);
|
||||
if (ergebnis.stderr) kanal.stderr.write(ergebnis.stderr);
|
||||
kanal.exit(ergebnis.code ?? 0);
|
||||
kanal.end();
|
||||
});
|
||||
});
|
||||
})
|
||||
.on("error", () => undefined);
|
||||
});
|
||||
await new Promise<void>((erfuellen) => this.server!.listen(0, "127.0.0.1", erfuellen));
|
||||
this.port = (this.server.address() as AddressInfo).port;
|
||||
return this;
|
||||
}
|
||||
|
||||
async stoppen(): Promise<void> {
|
||||
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { utils } from "ssh2";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
|
||||
import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
|
||||
import { szenarioEinrichten } from "./proxmox-mock/szenario";
|
||||
import { SshMock } from "./ssh-mock";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||
|
||||
const mock = new ProxmoxMock();
|
||||
const paar = utils.generateKeyPairSync("ed25519");
|
||||
let ssh: SshMock;
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
|
||||
beforeAll(async () => {
|
||||
await mock.starten();
|
||||
szenarioEinrichten(mock, { version: "8.3.5", importStorage: true });
|
||||
ssh = await new SshMock(paar.public).starten();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await mock.stoppen();
|
||||
await ssh.stoppen();
|
||||
});
|
||||
|
||||
let admin: string;
|
||||
let operator: string;
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
admin = (await angemeldet("ADMIN")).cookie;
|
||||
operator = (await angemeldet("OPERATOR")).cookie;
|
||||
await testPrisma.server.create({
|
||||
data: {
|
||||
name: "rz1",
|
||||
titel: "RZ 1",
|
||||
url: mock.url,
|
||||
tokenId: MOCK_TOKEN_ID,
|
||||
tokenGeheimnis: verschluesseln(MOCK_TOKEN_GEHEIMNIS),
|
||||
fingerabdruck: mock.fingerabdruck,
|
||||
sshAktiv: true,
|
||||
sshPort: ssh.port,
|
||||
sshPrivat: verschluesseln(paar.private),
|
||||
sshOeffentlich: paar.public,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const testen = async (cookie: string) =>
|
||||
await testPost(anfrage("POST", "/api/v1/server/rz1/test", cookie), kontext({ name: "rz1" }));
|
||||
|
||||
it("speichert Bericht, Version und Erreichbarkeit; Operator darf testen", async () => {
|
||||
const antwort = await testen(operator);
|
||||
expect(antwort.status).toBe(200);
|
||||
const ergebnis = await antwort.json();
|
||||
expect(ergebnis.ok).toBe(true);
|
||||
expect(ergebnis.bericht.ssh).toBe("nicht_bestaetigt");
|
||||
const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
|
||||
expect(server).toMatchObject({ erreichbar: true, pveVersion: "8.3.5" });
|
||||
expect(await testPrisma.protokoll.count({ where: { aktion: "Verbindung getestet" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("Host-Key bestätigen: nur der gesehene, nur durch Admins – danach geht SSH", async () => {
|
||||
const erst = await (await testen(admin)).json();
|
||||
const gesehen = erst.bericht.knoten[0].ssh.hostSchluessel as string;
|
||||
const pfad = "/api/v1/server/rz1/ssh/hostschluessel";
|
||||
|
||||
expect(
|
||||
(
|
||||
await hostSchluesselPost(
|
||||
anfrage("POST", pfad, operator, { knoten: "pve01", hostSchluessel: gesehen }),
|
||||
kontext({ name: "rz1" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
|
||||
const untergeschoben = "SHA256:" + "A".repeat(43);
|
||||
const abgelehnt = await hostSchluesselPost(
|
||||
anfrage("POST", pfad, admin, { knoten: "pve01", hostSchluessel: untergeschoben }),
|
||||
kontext({ name: "rz1" }),
|
||||
);
|
||||
expect(abgelehnt.status).toBe(409);
|
||||
|
||||
const ok = await hostSchluesselPost(
|
||||
anfrage("POST", pfad, admin, { knoten: "pve01", hostSchluessel: gesehen }),
|
||||
kontext({ name: "rz1" }),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
expect((await ok.json()).sshHostSchluessel).toEqual({ pve01: gesehen });
|
||||
|
||||
const dann = await (await testen(admin)).json();
|
||||
expect(dann.bericht.ssh).toBe("ok");
|
||||
expect(dann.bericht.faehigkeiten.templateBau.weg).toBe("ssh");
|
||||
});
|
||||
|
||||
it("ein nicht erreichbarer Server ist ein Ergebnis, kein Fehler", async () => {
|
||||
await testPrisma.server.update({ where: { name: "rz1" }, data: { url: "https://127.0.0.1:9" } });
|
||||
const antwort = await testen(admin);
|
||||
expect(antwort.status).toBe(200);
|
||||
const ergebnis = await antwort.json();
|
||||
expect(ergebnis).toMatchObject({ ok: false });
|
||||
expect((await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } })).erreichbar).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user