feat(260923-dhh): Proxmox-Modul Aufgabe 1 - PVE per Token, Ende-zu-Ende

- ProxmoxServer/ProxmoxServerStatus mit RLS (tenant_isolation_policy +
  system_read_policy auf ProxmoxServer fuer den kommenden Planer)
- proxmox-auth.ts (Token-Kopfzeilen PVE/PBS), proxmox-client.service.ts
  (proxmoxGet, ausschliesslich lesend, Dispatcher je Aufruf aus
  tlsRejectUnauthorized, nie global)
- proxmox.service.ts: Server anlegen (Geheimnis verschluesselt,
  select ohne Geheimnisfelder), Serverliste, PVE-Abfrage mit
  nachsichtiger Grundauswertung (Knoten/Gaeste)
- Controller/Modul/Seed nach Domaincheck-Vorbild, Kategorie
  "infrastructure", @UseModule('proxmox') + @Roles auf Schreibwegen
- Modulseite (duenne Liste) + proxmox-api.ts + Registrierung in
  MODULE_REGISTRY
- Zugriffsklassifikation nachgezogen (rls-access-inventory.spec.ts gruen)

Tore: api 1247/1247 (>=1240), web 693/693, type-check 4/4, lint 5/5,
Biome apps/web 53 Warnungen (unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:17:33 +02:00
parent ec9c77956d
commit 3a1bfd943e
19 changed files with 1528 additions and 4 deletions
+2
View File
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
@Module({
imports: [
@@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
FavoritesModule,
TendersModule,
BugReportsModule,
ProxmoxModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
@@ -0,0 +1,149 @@
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
Max,
Min,
Validate,
ValidateIf,
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/**
* D-03: PMG kennt laut Recherche keinen API-Token (Annahme A1) — ein Server
* vom Typ `pmg` mit `authMethod: 'token'` wird bereits beim Speichern mit
* einer deutschen Klartextmeldung abgelehnt (400), nicht erst beim
* Abfragen. Angebracht am Feld `authMethod`, liest aber `productType`
* desselben Objekts (`args.object`) — class-validator erlaubt das.
*/
@ValidatorConstraint({ name: 'pmgOhneToken', async: false })
class PmgOhneTokenConstraint implements ValidatorConstraintInterface {
validate(_value: unknown, args: ValidationArguments): boolean {
const obj = args.object as { productType?: string; authMethod?: string };
return !(obj.productType === 'pmg' && obj.authMethod === 'token');
}
defaultMessage(): string {
return 'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.';
}
}
/**
* DTO fuer das Anlegen eines Proxmox-Servers (Aufgabe 1). Pflichtfelder je
* `authMethod` mit `@ValidateIf` (Aufgabe 2): ein Token-Zugang verlangt
* `tokenId`/`tokenSecret`, ein Passwort-Zugang `username`/`password`.
*/
export class CreateProxmoxServerDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsIn(['pve', 'pbs', 'pmg'])
productType!: 'pve' | 'pbs' | 'pmg';
// require_tld: false — interne Namen wie "pve.intern" sind sonst abgelehnt.
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
baseUrl!: string;
@IsIn(['token', 'password'])
@Validate(PmgOhneTokenConstraint)
authMethod!: 'token' | 'password';
@ValidateIf((o) => o.authMethod === 'token')
@IsString()
@IsNotEmpty()
tokenId?: string;
@ValidateIf((o) => o.authMethod === 'token')
@IsString()
@IsNotEmpty()
tokenSecret?: string;
@ValidateIf((o) => o.authMethod === 'password')
@IsString()
@IsNotEmpty()
username?: string;
@ValidateIf((o) => o.authMethod === 'password')
@IsString()
@IsNotEmpty()
password?: string;
@IsBoolean()
@IsOptional()
tlsRejectUnauthorized?: boolean;
@IsInt()
@Min(1)
@Max(1440)
@IsOptional()
pollIntervalMin?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
/**
* DTO fuer das Bearbeiten (Aufgabe 5). Alle Felder optional; ein NICHT
* gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert, eine
* LEERE Zeichenkette bedeutet "loeschen" (Muster `LdapConfigService.updateConfig`)
* — diese Unterscheidung lebt im Service, nicht im DTO, deshalb bleiben
* `tokenSecret`/`password` hier einfache optionale Zeichenketten ohne
* `IsNotEmpty`.
*/
export class UpdateProxmoxServerDto {
@IsString()
@IsNotEmpty()
@IsOptional()
name?: string;
@IsIn(['pve', 'pbs', 'pmg'])
@IsOptional()
productType?: 'pve' | 'pbs' | 'pmg';
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
@IsOptional()
baseUrl?: string;
@IsIn(['token', 'password'])
@Validate(PmgOhneTokenConstraint)
@IsOptional()
authMethod?: 'token' | 'password';
@IsString()
@IsOptional()
tokenId?: string;
@IsString()
@IsOptional()
tokenSecret?: string;
@IsString()
@IsOptional()
username?: string;
@IsString()
@IsOptional()
password?: string;
@IsBoolean()
@IsOptional()
tlsRejectUnauthorized?: boolean;
@IsInt()
@Min(1)
@Max(1440)
@IsOptional()
pollIntervalMin?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
+35
View File
@@ -0,0 +1,35 @@
import type { ProxmoxProductType } from './proxmox.types';
/**
* Die EINZIGE Stelle im gesamten Modul, die Anmeldeinformationen in
* Kopfzeilen (und ab Aufgabe 2 Cookies) uebersetzt (D-03, key_link):
* Klient, Verbindungstest und Planer rufen ausschliesslich diese Funktionen
* — keiner baut eine Kopfzeile nach. Jede Funktion nimmt Klartext entgegen
* und gibt nur die Kopfzeile zurueck; keine protokolliert das Geheimnis,
* keine wirft es in eine Fehlermeldung (T-DHH-01).
*/
/**
* API-Token-Kopfzeile. PVE und PBS teilen sich das Schema `<Produkt>APIToken`,
* unterscheiden sich aber im Trennzeichen vor dem Geheimnis (Recherche,
* Block 1): PVE nutzt ein Gleichheitszeichen, PBS einen Doppelpunkt. PMG
* kennt laut Recherche (Annahme A1, Forenbeleg, kein Primaerbeleg) kein
* Token-Schema — ein Aufruf mit `productType: 'pmg'` ist ein Programmierfehler
* (das DTO lehnt einen PMG-Token-Zugang bereits beim Speichern ab, siehe
* Aufgabe 2) und wirft deshalb statt still eine unbrauchbare Kopfzeile zu bauen.
*/
export function buildTokenAuthHeader(
productType: ProxmoxProductType,
tokenId: string,
tokenSecret: string,
): { Authorization: string } {
if (productType === 'pve') {
return { Authorization: `PVEAPIToken=${tokenId}=${tokenSecret}` };
}
if (productType === 'pbs') {
return { Authorization: `PBSAPIToken=${tokenId}:${tokenSecret}` };
}
throw new Error(
'PMG unterstuetzt keinen API-Token-Zugang (Annahme A1 der Recherche) — dieser Aufruf haette bereits beim Speichern des Servers abgelehnt werden muessen.',
);
}
@@ -0,0 +1,226 @@
import { Agent, fetch as undiciFetch } from 'undici';
import type { ProxmoxErrorKind } from './proxmox.types';
/**
* Der HTTP-Zugang dieses Moduls, und ausschliesslich lesend (D-01). Genau
* EINE oeffentliche Datenabruf-Funktion `proxmoxGet` — das Anfrageverfahren
* ist fest auf GET verdrahtet, es gibt dafuer keinen Parameter und kein
* Durchreichen von aussen. `proxmox-nur-lesen.spec.ts` (Aufgabe 2) zaehlt
* maschinell nach, dass dies im gesamten Modul die einzige Stelle ist, die
* ein Anfrageverfahren an `undiciFetch` uebergibt.
*
* Zwingend `undiciFetch` aus dem `undici`-Paket, NICHT das globale `fetch`:
* Nodes globales `fetch` ignoriert einen `Agent`-Dispatcher aus dem
* npm-Paket (andere Klasse) — gemessen und dokumentiert in
* `apps/api/src/favorites/icon-discovery.service.ts:33-40`. Wer hier aus
* Gewohnheit zum globalen `fetch` wechselt, bekommt keinen Fehler beim
* Kompilieren, sondern eine zur Laufzeit STILLSCHWEIGEND ignorierte Option
* — ein selbstsigniertes Zertifikat wuerde trotz `tlsRejectUnauthorized:
* false` weiter abgelehnt.
*
* Der Dispatcher wird JE AUFRUF aus dem `tlsRejectUnauthorized`-Feld GENAU
* DIESER Serverzeile gebaut (D-04, T-DHH-03): ist es wahr (Vorgabe), wird
* KEIN Dispatcher uebergeben — echte Zertifikatspruefung, der Normalweg.
* Ist es falsch, ein FRISCHER `new Agent({ connect: { rejectUnauthorized:
* false } } )` NUR fuer diesen einen Aufruf. Ausdruecklich KEINE
* Modulkonstante wie `LENIENT_TLS_AGENT` in `icon-discovery.service.ts`
* (die Ausnahme eines Servers darf nie auf einen zweiten wirken) und
* ausdruecklich KEINE Node-Umgebungsvariable, die mit `NODE_TLS_` beginnt.
*
* Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
* per Definition im privaten Netz, eine solche Pruefung wuerde jede reale
* Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur
* ein Administrator (`@Roles(ADMIN, SUPER_ADMIN)`) Adressen eintragen darf
* — siehe Bedrohungsmodell T-DHH-02 im Plan.
*/
/** 8 Sekunden — Proxmox-Server stehen im lokalen Netz, eine laengere Wartezeit deutet auf "nicht erreichbar". */
const REQUEST_TIMEOUT_MS = 8000;
/** Deckel fuer `errorDetail` — niemals mehr als das, und nie ein Geheimnis (T-DHH-01). */
const ERROR_DETAIL_MAX_CHARS = 500;
/**
* Bekannte Zertifikatsfehlerkennungen von Node/undici. Ein Treffer wird zu
* `errorKind: 'zertifikat'`; im Zweifel (keine dieser Kennungen erkannt)
* bleibt es bei `'netz'` — eine Verwechslung in die falsche Richtung waere
* hier schlimmer als ein zu vorsichtiges "nicht erreichbar" (Aufgabe 2 `<behavior>`).
*/
const CERTIFICATE_ERROR_CODES = new Set([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
'CERT_SIGNATURE_FAILURE',
'CERT_NOT_YET_VALID',
]);
export interface ProxmoxGetTarget {
baseUrl: string;
tlsRejectUnauthorized: boolean;
/** Fertige Kopfzeilen — gebaut ausschliesslich von `proxmox-auth.ts` (D-03). */
headers: Record<string, string>;
}
export interface ProxmoxGetResult {
ok: boolean;
status: number | null;
body: unknown;
errorKind: ProxmoxErrorKind | null;
errorDetail: string | null;
}
/**
* Nachsichtiges JSON-Parsen: eine Antwort, die kein JSON ist (HTML-
* Anmeldeseite, leerer Rumpf), fuehrt zu `{ ok: false }` — kein geworfener
* Parserfehler, kein Absturz (Aufgabe 2 `<behavior>`).
*/
export function parseJsonLenient(text: string): { ok: true; data: unknown } | { ok: false } {
if (!text || text.trim().length === 0) {
return { ok: false };
}
try {
return { ok: true, data: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function isCertificateError(err: unknown): boolean {
const code = (err as { code?: unknown; cause?: { code?: unknown } })?.code;
const causeCode = (err as { cause?: { code?: unknown } })?.cause?.code;
if (typeof code === 'string' && CERTIFICATE_ERROR_CODES.has(code)) return true;
if (typeof causeCode === 'string' && CERTIFICATE_ERROR_CODES.has(causeCode)) return true;
const message = err instanceof Error ? err.message : String(err ?? '');
for (const known of CERTIFICATE_ERROR_CODES) {
if (message.includes(known)) return true;
}
return false;
}
/**
* Reine Fehler-Uebersetzung: liefert genau eine der sieben Werte aus
* `ProxmoxErrorKind`. `status` ist gesetzt, wenn Proxmox geantwortet hat;
* `thrownError` ist gesetzt, wenn der Aufruf selbst fehlgeschlagen ist
* (kein HTTP-Status, z. B. `ECONNREFUSED`/Timeout/DNS-Fehler).
*
* 401 -> 'zugang', 403 -> 'rechte', 404 -> 'antwortform' (falsche Adresse
* vermutet), 5xx -> 'server'. Ein geworfener Fehler ohne Antwort ist
* 'netz' — ausser die Fehlerkennung ist eindeutig eine Zertifikatskennung,
* dann 'zertifikat' (Aufgabe 2 `<behavior>`).
*/
export function classifyFailure(
status: number | null,
thrownError: unknown,
): ProxmoxErrorKind {
if (status === null) {
if (thrownError !== null && thrownError !== undefined && isCertificateError(thrownError)) {
return 'zertifikat';
}
return 'netz';
}
if (status === 401) return 'zugang';
if (status === 403) return 'rechte';
if (status === 404) return 'antwortform';
if (status >= 500 && status < 600) return 'server';
return 'unbekannt';
}
/**
* Kurze, deutsche Ergaenzung aus Statuszahl und — falls vorhanden und JSON
* — dem `errors`-Feld der Proxmox-Antwort. Auf `ERROR_DETAIL_MAX_CHARS`
* gekuerzt; niemals die gesendete Kopfzeile, niemals ein Geheimnis
* (T-DHH-01).
*/
function buildHttpErrorDetail(status: number, bodyText: string): string {
let detail = `Proxmox antwortete mit Status ${status}`;
const parsed = parseJsonLenient(bodyText);
if (parsed.ok && parsed.data && typeof parsed.data === 'object' && 'errors' in parsed.data) {
try {
const errorsText = JSON.stringify((parsed.data as { errors: unknown }).errors);
detail += `: ${errorsText}`;
} catch {
/* errors-Feld liess sich nicht serialisieren — Statuszahl allein reicht */
}
}
return detail.slice(0, ERROR_DETAIL_MAX_CHARS);
}
function buildThrownErrorDetail(err: unknown): string {
const message = err instanceof Error ? err.message : String(err ?? 'unbekannter Fehler');
return `Verbindung fehlgeschlagen: ${message}`.slice(0, ERROR_DETAIL_MAX_CHARS);
}
/**
* Die einzige Datenabruf-Funktion dieses Moduls (D-01). Wirft nach aussen
* NICHTS — jeder Fehlerfall (Netz, Zertifikat, HTTP-Status, kein JSON)
* landet als Ergebniswert in `errorKind`/`errorDetail`, damit ein
* Aufrufer nie mit einem unbehandelten Wurf abbricht.
*/
export async function proxmoxGet(
target: ProxmoxGetTarget,
path: string,
): Promise<ProxmoxGetResult> {
const dispatcher = target.tlsRejectUnauthorized
? undefined // Normalweg: echte Zertifikatspruefung, kein Sonderfall
: new Agent({ connect: { rejectUnauthorized: false } }); // NUR fuer diesen einen Aufruf (D-04)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const url = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
try {
const response = await undiciFetch(url, {
method: 'GET', // fest verdrahtet — D-01, kein Parameter dafuer
dispatcher,
signal: controller.signal,
headers: target.headers,
});
const text = await response.text();
if (!response.ok) {
return {
ok: false,
status: response.status,
body: null,
errorKind: classifyFailure(response.status, null),
errorDetail: buildHttpErrorDetail(response.status, text),
};
}
const parsed = parseJsonLenient(text);
if (!parsed.ok) {
return {
ok: false,
status: response.status,
body: null,
errorKind: 'antwortform',
errorDetail: 'Die Antwort war kein JSON (z. B. eine Anmeldeseite oder ein leerer Rumpf).',
};
}
return {
ok: true,
status: response.status,
body: parsed.data,
errorKind: null,
errorDetail: null,
};
} catch (err) {
return {
ok: false,
status: null,
body: null,
errorKind: classifyFailure(null, err),
errorDetail: buildThrownErrorDetail(err),
};
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,46 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import { ProxmoxService } from './proxmox.service';
/**
* `@UseModule('proxmox')` auf Klassenebene (D-09, Vorbild
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`)
* zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05).
*/
@Controller('modules/proxmox')
@UseModule('proxmox')
export class ProxmoxController {
constructor(private readonly proxmoxService: ProxmoxService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('servers')
async list(@Req() req: AuthenticatedRequest) {
return this.proxmoxService.listWithStatus(this.requireTenantId(req));
}
@Post('servers')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
return this.proxmoxService.createServer(this.requireTenantId(req), dto);
}
@Post('servers/:id/poll')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { ProxmoxController } from './proxmox.controller';
import { seedProxmoxModule } from './proxmox.seed';
import { ProxmoxService } from './proxmox.service';
/**
* NestJS module for the Proxmox feature (260923-dhh). Vorbild
* `DomaincheckModule`: seeds itself into the module registry on startup.
* `ScheduleModule` ist bereits global in `app.module.ts` registriert — der
* Planer aus Aufgabe 4 braucht hier nichts zusaetzlich.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [ProxmoxController],
providers: [ProxmoxService],
})
export class ProxmoxModule implements OnModuleInit {
private readonly logger = new Logger(ProxmoxModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedProxmoxModule(this.moduleRegistryService);
this.logger.log('Proxmox module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed proxmox module', error);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Seeds the proxmox module into the module registry (D-09).
* Vorbild `domaincheck.seed.ts`. Kategorie `infrastructure` — die erste
* Kachel/Modul in dieser Kategorie.
*/
export async function seedProxmoxModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'proxmox',
name: 'Proxmox',
version: '1.0.0',
category: 'infrastructure',
description: {
de: 'Proxmox VE/PBS/PMG beobachten — nur lesend',
en: 'Observe Proxmox VE/PBS/PMG — read-only',
},
isSystem: true,
});
}
@@ -0,0 +1,266 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz geht (Vorbild
* `icon-discovery.service.spec.ts`) — die Mock-Klasse zeichnet nur die
* uebergebenen `options` auf, `fetch` delegiert zur Laufzeit an
* `globalThis.fetch`, damit `vi.stubGlobal('fetch', …)` je Test greift.
*/
vi.mock('undici', () => ({
Agent: class Agent {
constructor(public readonly options: unknown) {}
},
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe, Signatur folgt dem Original
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
}));
// `forTenant` gibt in diesem Test denselben Client zurueck — Mandantenbindung
// selbst ist nicht Gegenstand dieser Datei (siehe rls-access-inventory.spec.ts).
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forSystem: vi.fn((p: unknown) => p),
}));
import { Agent } from 'undici';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { ProxmoxService } from './proxmox.service';
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
/** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */
const crypto = {
encrypt: vi.fn((plaintext: string) =>
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
),
decrypt: vi.fn((stored: string) => {
const [, , ciphertext] = stored.split(':');
return Buffer.from(ciphertext, 'hex').toString('utf8');
}),
};
function makeFakePrisma() {
const servers = new Map<string, any>();
const statuses = new Map<string, any>(); // key: serverId
function applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (key === 'status') {
out.status = statuses.get(row.id) ?? null;
continue;
}
if (select[key]) out[key] = row[key];
}
return out;
}
const proxmoxServer = {
create: vi.fn(async ({ data, select }: { data: any; select?: any }) => {
const id = `srv-${servers.size + 1}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
delete row.status; // nested create handled below
servers.set(id, row);
if (data.status?.create) {
statuses.set(id, { id: `status-${id}`, serverId: id, updatedAt: new Date(), ...data.status.create });
}
return applySelect(row, select);
}),
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
let rows = [...servers.values()];
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
return rows.map((r) => applySelect(r, select));
}),
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
const row = servers.get(where.id);
return row ? { ...row } : null;
}),
};
const proxmoxServerStatus = {
upsert: vi.fn(
async ({
where,
create,
update,
}: {
where: { serverId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const existing = statuses.get(where.serverId);
const record = existing
? { ...existing, ...update }
: { id: `status-${where.serverId}`, updatedAt: new Date(), ...create };
statuses.set(where.serverId, record);
return { ...record };
},
),
};
return { proxmoxServer, proxmoxServerStatus, __servers: servers, __statuses: statuses };
}
const TOKEN_DTO: CreateProxmoxServerDto = {
name: 'pve-1',
productType: 'pve',
baseUrl: 'https://pve.intern:8006',
authMethod: 'token',
tokenId: 'root@pam!tessera',
tokenSecret: 'geheimes-token-secret',
};
function pveResourcesBody(overrides: Partial<Record<string, unknown>> = {}) {
return {
data: [
{ type: 'node', node: 'pve1', cpu: 0.12, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 },
{ type: 'qemu', node: 'pve1', vmid: 100, status: 'running' },
{ type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped' },
{ type: 'lxc', node: 'pve1', vmid: 200, status: 'running' },
],
...overrides,
};
}
describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('legt einen Server verschluesselt an und liefert nie das Geheimnis zurueck', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
expect((created as any).encryptedTokenSecret).toBeUndefined();
expect((created as any).encryptedPassword).toBeUndefined();
const storedRow = [...prisma.__servers.values()][0];
expect(storedRow.encryptedTokenSecret).not.toBe(TOKEN_DTO.tokenSecret);
expect(storedRow.encryptedTokenSecret).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
});
it('listWithStatus liefert weder encryptedTokenSecret noch encryptedPassword', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
const list = await service.listWithStatus('tenant-a');
expect(list).toHaveLength(1);
expect(JSON.stringify(list)).not.toContain(TOKEN_DTO.tokenSecret);
expect('encryptedTokenSecret' in (list[0] as object)).toBe(false);
expect('encryptedPassword' in (list[0] as object)).toBe(false);
});
it('pollServer fragt PVE ab, normalisiert nachsichtig und schreibt das Zwischenlager', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const fetchSpy = vi.fn(async (url: string) => {
expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources');
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
expect(result?.metrics).toMatchObject({
productType: 'pve',
nodeCount: 1,
guestsRunning: 2,
guestsStopped: 1,
});
const status = prisma.__statuses.get((created as any).id);
expect(status.reachable).toBe(true);
expect(status.metrics).toMatchObject({ nodeCount: 1 });
expect(status.rawSample).toContain('"node":"pve1"');
});
it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let capturedAuth: string | null = null;
const fetchSpy = vi.fn(async (_url: string, options: RequestInit) => {
capturedAuth = (options.headers as Record<string, string>).Authorization;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
await service.pollServer('tenant-a', (created as any).id);
expect(capturedAuth).toBe(
`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`,
);
});
it('uebergibt bei tlsRejectUnauthorized=true KEINEN Dispatcher, bei false genau einen mit abgeschalteter Pruefung', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const strictServer = await service.createServer('tenant-a', TOKEN_DTO);
const lenientServer = await service.createServer('tenant-a', {
...TOKEN_DTO,
name: 'pve-2',
tlsRejectUnauthorized: false,
});
const dispatchers: unknown[] = [];
const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
dispatchers.push(options.dispatcher);
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
});
vi.stubGlobal('fetch', fetchSpy);
await service.pollServer('tenant-a', (strictServer as any).id);
await service.pollServer('tenant-a', (lenientServer as any).id);
expect(dispatchers[0]).toBeUndefined();
expect(dispatchers[1]).toBeInstanceOf(Agent);
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
expect((dispatchers[1] as any).options).toEqual({
connect: { rejectUnauthorized: false },
});
});
it('ein fehlendes Feld der Antwort fuehrt zu null, nicht zu einem Wurf', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const bodyWithMissingFields = {
data: [{ type: 'node', node: 'pve1' /* cpu/maxcpu/mem/maxmem fehlen */ }],
};
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(bodyWithMissingFields), { status: 200 })),
);
const result = await service.pollServer('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
const metrics = result?.metrics as { nodes: { cpu: unknown; maxcpu: unknown; mem: unknown; maxmem: unknown }[] };
expect(metrics.nodes[0]).toEqual({
node: 'pve1',
cpu: null,
maxcpu: null,
mem: null,
maxmem: null,
});
});
it('nutzt forTenant fuer jeden Datenbankzugriff (D-08)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
await service.listWithStatus('tenant-a');
expect(forTenant).toHaveBeenCalled();
});
});
+312
View File
@@ -0,0 +1,312 @@
import { Injectable, Logger } from '@nestjs/common';
import type { ProxmoxServer } from '@prisma/client';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { buildTokenAuthHeader } from './proxmox-auth';
import { proxmoxGet } from './proxmox-client.service';
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import type {
ProxmoxErrorKind,
ProxmoxPollResult,
ProxmoxPveMetrics,
ProxmoxPveNodeMetric,
} from './proxmox.types';
/**
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
* hex, Doppelpunkt-getrennt). Fuer Proxmox als NEUES Feature ab Tag 1
* irrelevant (keine Altdaten), aber derselbe defensive Riegel wie ueberall
* sonst im Projekt.
*/
const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i;
/** Rohantwort wird auf hoechstens diese Zeichenzahl gekuerzt in `rawSample` abgelegt. */
const RAW_SAMPLE_MAX_CHARS = 20000;
/**
* `select` OHNE die beiden Geheimnisfelder — die Felder verlassen die
* Datenbank gar nicht erst, statt nachtraeglich maskiert zu werden
* (T-DHH-01, `must_haves.truths`).
*/
const SAFE_SERVER_SELECT = {
id: true,
tenantId: true,
name: true,
productType: true,
baseUrl: true,
authMethod: true,
tokenId: true,
username: true,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: true,
position: true,
createdAt: true,
updatedAt: true,
status: true,
} as const;
function readNumberOrNull(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
/**
* Grundauswertung von `/api2/json/cluster/resources` (Aufgabe 1: nur PVE,
* nur Knotenzahl/Gaestezahl/je-Knoten-Auslastung — Aufgabe 3 baut PBS/PMG
* daneben und zieht diese Funktion nach `proxmox-normalize.ts` um). Jeder
* Einzelwert nachsichtig gelesen: fehlt er, ist er `null` — nie `0`, nie
* `NaN`, nie ein Wurf (der Nutzer prueft dieses Modul an seinen echten
* Servern; ein Wurf zeigt ihm eine leere Seite statt eines Hinweises).
*/
function normalizePveResources(body: unknown): ProxmoxPveMetrics {
const list =
body && typeof body === 'object' && Array.isArray((body as { data?: unknown }).data)
? ((body as { data: unknown[] }).data as unknown[])
: [];
const isRecord = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === 'object';
const nodeEntries = list.filter((e) => isRecord(e) && e.type === 'node');
const guestEntries = list.filter(
(e) => isRecord(e) && (e.type === 'qemu' || e.type === 'lxc'),
);
const running = guestEntries.filter((g) => isRecord(g) && g.status === 'running').length;
const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((entry) => {
const n = entry as Record<string, unknown>;
return {
node: typeof n.node === 'string' ? n.node : 'unbekannt',
cpu: readNumberOrNull(n.cpu),
maxcpu: readNumberOrNull(n.maxcpu),
mem: readNumberOrNull(n.mem),
maxmem: readNumberOrNull(n.maxmem),
};
});
return {
productType: 'pve',
nodeCount: nodeEntries.length,
guestsRunning: running,
guestsStopped: guestEntries.length - running,
nodes,
};
}
function truncateRaw(body: unknown): string {
let text: string;
try {
text = JSON.stringify(body) ?? String(body);
} catch {
text = String(body);
}
return text.length > RAW_SAMPLE_MAX_CHARS ? text.slice(0, RAW_SAMPLE_MAX_CHARS) : text;
}
type AuthHeaderResult =
| { ok: true; headers: Record<string, string> }
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
@Injectable()
export class ProxmoxService {
private readonly logger = new Logger(ProxmoxService.name);
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
) {}
/**
* Entschluesselt fuer den internen Gebrauch in GENAU dieser einen
* privaten Methode (Vorbild `LdapConfigService.decryptBindPassword`) —
* ein Wert, der nicht in `iv:authTag:ciphertext`-Form ist, wird
* unveraendert durchgereicht.
*/
private decryptSecret(stored: string | null): string | null {
if (!stored) return null;
if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored;
return this.crypto.decrypt(stored);
}
/**
* Server anlegen (Aufgabe 1: nur `pve`+Token gepflegt vom Aufrufer;
* Aufgabe 2 ergaenzt den Passwort-Zweig, Aufgabe 5 das Bearbeiten). Legt
* zugleich eine leere Zwischenlagerzeile an, damit `listWithStatus` immer
* eine Statuszeile findet.
*/
async createServer(tenantId: string, dto: CreateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.create({
data: {
tenantId,
name: dto.name,
productType: dto.productType,
baseUrl: dto.baseUrl,
authMethod: dto.authMethod,
tokenId: dto.authMethod === 'token' ? (dto.tokenId ?? null) : null,
encryptedTokenSecret:
dto.authMethod === 'token' && dto.tokenSecret
? this.crypto.encrypt(dto.tokenSecret)
: null,
username: dto.authMethod === 'password' ? (dto.username ?? null) : null,
encryptedPassword:
dto.authMethod === 'password' && dto.password
? this.crypto.encrypt(dto.password)
: null,
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
pollIntervalMin: dto.pollIntervalMin ?? 5,
isActive: dto.isActive ?? true,
status: { create: { tenantId, reachable: false } },
},
select: SAFE_SERVER_SELECT,
});
}
/**
* Serverliste samt Zwischenlager, OHNE jedes Geheimnisfeld (T-DHH-01).
* Liest ausschliesslich aus dem Zwischenlager — kein Live-Zugriff bei
* Proxmox (D-05).
*/
async listWithStatus(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.findMany({
where: { tenantId },
orderBy: { position: 'asc' },
select: SAFE_SERVER_SELECT,
});
}
/** Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber `proxmox-auth.ts` (D-03). */
private buildAuthHeaders(server: ProxmoxServer): AuthHeaderResult {
if (server.authMethod === 'token') {
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
if (!server.tokenId || !tokenSecret) {
return {
ok: false,
errorKind: 'zugang',
errorDetail: 'Kein Token hinterlegt.',
};
}
return {
ok: true,
headers: buildTokenAuthHeader(
server.productType as 'pve' | 'pbs' | 'pmg',
server.tokenId,
tokenSecret,
),
};
}
// Benutzer/Passwort-Zweig (Ticket-Anmeldung) folgt in Aufgabe 2.
return {
ok: false,
errorKind: 'unbekannt',
errorDetail: 'Benutzer/Passwort-Zugang wird in dieser Aufgabe noch nicht unterstuetzt.',
};
}
/**
* EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe nur
* `pve` mit Token — Aufgabe 2 ergaenzt Benutzer/Passwort und die
* Fehlerklassen, Aufgabe 3 ergaenzt `pbs`/`pmg`.
*/
private async pollOne(server: ProxmoxServer): Promise<ProxmoxPollResult> {
const authHeaders = this.buildAuthHeaders(server);
if (!authHeaders.ok) {
return {
reachable: false,
errorKind: authHeaders.errorKind,
errorDetail: authHeaders.errorDetail,
metrics: null,
rawSample: null,
};
}
if (server.productType !== 'pve') {
// PBS/PMG folgen in Aufgabe 3.
return {
reachable: false,
errorKind: 'unbekannt',
errorDetail: 'Dieser Produkttyp wird in dieser Aufgabe noch nicht abgefragt.',
metrics: null,
rawSample: null,
};
}
const result = await proxmoxGet(
{
baseUrl: server.baseUrl,
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
headers: authHeaders.headers,
},
'/api2/json/cluster/resources',
);
if (!result.ok) {
return {
reachable: false,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: null,
rawSample: result.body === null ? null : truncateRaw(result.body),
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalizePveResources(result.body),
rawSample: truncateRaw(result.body),
};
}
/**
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
* Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert.
*/
async pollServer(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const server = await tenantPrisma.proxmoxServer.findUnique({
where: { id: serverId },
});
if (!server || server.tenantId !== tenantId) {
return null;
}
const result = await this.pollOne(server);
const now = new Date();
await tenantPrisma.proxmoxServerStatus.upsert({
where: { serverId },
create: {
serverId,
tenantId,
lastPolledAt: now,
lastOkAt: result.reachable ? now : null,
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
update: {
lastPolledAt: now,
...(result.reachable ? { lastOkAt: now } : {}),
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
});
return result;
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Gemeinsame Typen des Proxmox-Moduls (260923-dhh). Diese Datei enthaelt
* ausschliesslich Typen — keine Logik, kein Prisma-Bezug — und wird von
* `proxmox-auth.ts`, `proxmox-client.service.ts`, `proxmox-normalize.ts`
* und `proxmox.service.ts` gleichermassen gelesen.
*/
/** Drei Proxmox-Produkte, die dieses Modul beobachtet (D-01). */
export type ProxmoxProductType = 'pve' | 'pbs' | 'pmg';
/** PMG kennt nur `password` (Recherche, Annahme A1) — DTO lehnt `token` fuer PMG ab. */
export type ProxmoxAuthMethod = 'token' | 'password';
/**
* Sieben stabile Fehlerschluessel. Sie landen so in der Datenbank
* (`ProxmoxServerStatus.errorKind`) und werden ERST im Frontend uebersetzt
* (`proxmox.errors.*`) — stabile Schluessel, uebersetzbarer Text (D-06).
* Eine Erweiterung dieser Liste ist eine bewusste Entscheidung, keine
* beilaeufige — siehe `proxmox-nur-lesen.spec.ts` fuer den maschinellen
* Riegel auf D-01, der denselben Gedanken fuer den Anfrageweg durchsetzt.
*/
export type ProxmoxErrorKind =
| 'netz'
| 'zugang'
| 'rechte'
| 'zertifikat'
| 'antwortform'
| 'server'
| 'unbekannt';
/** Ergebnis EINES Abfragedurchlaufs — was `proxmox.service.ts` ins Zwischenlager schreibt. */
export interface ProxmoxPollResult {
reachable: boolean;
errorKind: ProxmoxErrorKind | null;
errorDetail: string | null;
metrics: ProxmoxMetrics | null;
rawSample: unknown;
}
/**
* Messwertform je Produkt (Aufgabe 3 fuellt `pbs`/`pmg`; hier bereits als
* unterscheidbare Union angelegt, damit das Frontend ab Aufgabe 6 ueber
* `productType` typsicher verzweigen kann, D-Recherche "unterscheidbare Union").
*/
export type ProxmoxMetrics = ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
export interface ProxmoxPveNodeMetric {
node: string;
cpu: number | null; // Anteil 0..1
maxcpu: number | null;
mem: number | null; // Bytes
maxmem: number | null;
}
export interface ProxmoxPveMetrics {
productType: 'pve';
nodeCount: number;
guestsRunning: number;
guestsStopped: number;
nodes: ProxmoxPveNodeMetric[];
}
export interface ProxmoxPbsDatastoreMetric {
name: string;
total: number | null;
used: number | null;
free: number | null;
lastBackupAt: number | null; // Unix-Sekunden, wie Proxmox sie liefert
lastVerifyState: string | null;
}
export interface ProxmoxPbsMetrics {
productType: 'pbs';
datastores: ProxmoxPbsDatastoreMetric[];
}
export interface ProxmoxPmgMetrics {
productType: 'pmg';
countIn: number | null;
countOut: number | null;
spamCount: number | null;
virusCount: number | null;
}