import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import type { ProxmoxServer } from '@prisma/client'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth'; import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service'; import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize'; import type { CreateProxmoxServerDto, TestProxmoxServerDto, UpdateProxmoxServerDto, } from './dto/proxmox-server.dto'; import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types'; /** * Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung * Befund 1) — `pollOne`/`buildAuthHeaders`/`getWithRetry` nehmen diesen * schmalen Ausschnitt statt der vollen `ProxmoxServer`-Zeile entgegen, damit * `resolveEffectiveTestServer` unten eine rein im Speicher gebaute Mischung * aus Formular- und gespeicherten Werten uebergeben kann, ohne eine * vollstaendige Datenbankzeile vorzutaeuschen. */ type ProxmoxCredentialSource = Pick< ProxmoxServer, | 'productType' | 'baseUrl' | 'authMethod' | 'tokenId' | 'encryptedTokenSecret' | 'username' | 'encryptedPassword' | 'tlsRejectUnauthorized' >; /** * 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; /** * Deckel der Folgeabfragen je PBS-Durchlauf (Aufgabe 3, ``): ein * PBS-Server mit vielen Datenspeichern soll den Planer nicht mit * unbegrenzt vielen Anfragen belasten — hoechstens diese Zahl an * `/snapshots`-Abfragen je Poll-Durchlauf, unabhaengig davon, wie viele * Datenspeicher der Server tatsaechlich hat. */ const PBS_SNAPSHOT_QUERY_CAP = 10; /** * Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick * auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die * Fremd-API werden. Regulaer fragt ohnehin nur der Planer mit begrenzter * Frequenz ab (D-05). */ const POLL_LOCK_MS = 10_000; 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 } | { 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, }); } /** * Bearbeiten (Aufgabe 5). Dieselbe Regel wie * `LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld * laesst den gespeicherten Wert unveraendert, eine LEERE Zeichenkette * bedeutet "loeschen", ein gefuellter Wert wird neu verschluesselt. Die * Ablehnung "PMG plus Token" gilt auch hier — geprueft gegen den * EFFEKTIVEN Stand nach dem Zusammenfuehren mit der vorhandenen Zeile, * nicht nur gegen die gesendeten Felder (ein Teil-Update, das nur * `authMethod` aendert, wuerde die DTO-eigene Pruefung sonst umgehen, * weil `productType` in diesem Aufruf gar nicht gesendet wird). */ async updateServer(tenantId: string, serverId: string, dto: UpdateProxmoxServerDto) { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } }); if (!existing || existing.tenantId !== tenantId) { return null; } const effectiveProductType = dto.productType ?? existing.productType; const effectiveAuthMethod = dto.authMethod ?? existing.authMethod; if (effectiveProductType === 'pmg' && effectiveAuthMethod === 'token') { throw new BadRequestException( 'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.', ); } const data: Record = {}; if (dto.name !== undefined) data.name = dto.name; if (dto.productType !== undefined) data.productType = dto.productType; if (dto.baseUrl !== undefined) data.baseUrl = dto.baseUrl; if (dto.authMethod !== undefined) data.authMethod = dto.authMethod; if (dto.tokenId !== undefined) data.tokenId = dto.tokenId || null; if (dto.tokenSecret !== undefined) { data.encryptedTokenSecret = dto.tokenSecret ? this.crypto.encrypt(dto.tokenSecret) : null; } if (dto.username !== undefined) data.username = dto.username || null; if (dto.password !== undefined) { data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null; } if (dto.tlsRejectUnauthorized !== undefined) { data.tlsRejectUnauthorized = dto.tlsRejectUnauthorized; } if (dto.pollIntervalMin !== undefined) data.pollIntervalMin = dto.pollIntervalMin; if (dto.isActive !== undefined) data.isActive = dto.isActive; return tenantPrisma.proxmoxServer.update({ where: { id: serverId }, data, select: SAFE_SERVER_SELECT, }); } /** * Loeschen (Aufgabe 5) — entfernt den Server samt Zwischenlagerzeile * (Fremdschluessel mit Loeschweitergabe, `onDelete: Cascade`). Liefert * `false`, wenn der Server unter diesem Mandanten nicht existiert. */ async deleteServer(tenantId: string, serverId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } }); if (!existing || existing.tenantId !== tenantId) { return false; } await tenantPrisma.proxmoxServer.delete({ where: { id: serverId } }); return true; } /** * Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber * `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine * Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls, * D-01) — deshalb `async`. */ private async buildAuthHeaders(server: ProxmoxCredentialSource): Promise { 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, ), }; } const password = this.decryptSecret(server.encryptedPassword); if (!server.username || !password) { return { ok: false, errorKind: 'zugang', errorDetail: 'Kein Benutzer/Passwort hinterlegt.', }; } const login = await loginTicket( { baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized }, server.productType as 'pve' | 'pbs' | 'pmg', server.username, password, ); if (!login.ok) { return { ok: false, errorKind: login.errorKind, errorDetail: login.errorDetail }; } return { ok: true, headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket), }; } /** * Fragt EINEN Proxmox-Pfad ab, gebunden an die Kopfzeilen dieses * Poll-Durchlaufs. Ticket-Erneuerung (Aufgabe 2, ``): laeuft * der Zugang ueber `password` und antwortet Proxmox mit 401 * (`errorKind: 'zugang'`), wird GENAU EINMAL je Durchlauf neu angemeldet * (nicht je Aufruf — ein PBS-Durchlauf mit mehreren Folgeabfragen soll * nicht mehrfach neu einloggen) und die Abfrage wiederholt; die neuen * Kopfzeilen gelten danach fuer den Rest des Durchlaufs. Bei einer * Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle * zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`. */ private async getWithRetry( server: ProxmoxCredentialSource, session: { headers: Record; retried: boolean }, path: string, ): Promise { const target = { baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized, headers: session.headers, }; let result = await proxmoxGet(target, path); if ( !result.ok && result.errorKind === 'zugang' && server.authMethod === 'password' && !session.retried ) { session.retried = true; const retryHeaders = await this.buildAuthHeaders(server); if (retryHeaders.ok) { session.headers = retryHeaders.headers; result = await proxmoxGet({ ...target, headers: retryHeaders.headers }, path); } } return result; } /** * EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1), * `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort * (Aufgabe 2). */ private async pollOne(server: ProxmoxCredentialSource): Promise { const authHeaders = await this.buildAuthHeaders(server); if (!authHeaders.ok) { return { reachable: false, errorKind: authHeaders.errorKind, errorDetail: authHeaders.errorDetail, metrics: null, rawSample: null, }; } const session = { headers: authHeaders.headers, retried: false }; const productType = server.productType as ProxmoxProductType; if (productType === 'pve') { const result = await this.getWithRetry(server, session, '/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), }; } const normalized = normalizePve(result.body); if (normalized.errorKind) { return { reachable: false, errorKind: normalized.errorKind, errorDetail: 'Die Antwort hatte nicht die erwartete Form.', metrics: null, rawSample: truncateRaw(result.body), }; } return { reachable: true, errorKind: null, errorDetail: null, metrics: normalized.metrics, rawSample: truncateRaw(result.body), }; } if (productType === 'pbs') { const usageResult = await this.getWithRetry(server, session, '/api2/json/status/datastore-usage'); if (!usageResult.ok) { return { reachable: false, errorKind: usageResult.errorKind, errorDetail: usageResult.errorDetail, metrics: null, rawSample: usageResult.body === null ? null : truncateRaw(usageResult.body), }; } const storeNames = listPbsDatastoreNames(usageResult.body).slice(0, PBS_SNAPSHOT_QUERY_CAP); const snapshotsByStore: Record = {}; for (const storeName of storeNames) { const snapResult = await this.getWithRetry( server, session, `/api2/json/admin/datastore/${encodeURIComponent(storeName)}/snapshots`, ); if (snapResult.ok) { snapshotsByStore[storeName] = snapResult.body; } } const normalized = normalizePbs(usageResult.body, snapshotsByStore); const rawSample = truncateRaw({ usage: usageResult.body, snapshots: snapshotsByStore }); if (normalized.errorKind) { return { reachable: false, errorKind: normalized.errorKind, errorDetail: 'Die Antwort hatte nicht die erwartete Form.', metrics: null, rawSample, }; } return { reachable: true, errorKind: null, errorDetail: null, metrics: normalized.metrics, rawSample, }; } // pmg const result = await this.getWithRetry(server, session, '/api2/json/statistics/mail'); if (!result.ok) { return { reachable: false, errorKind: result.errorKind, errorDetail: result.errorDetail, metrics: null, rawSample: result.body === null ? null : truncateRaw(result.body), }; } const normalized = normalizePmg(result.body); if (normalized.errorKind) { return { reachable: false, errorKind: normalized.errorKind, errorDetail: 'Die Antwort hatte nicht die erwartete Form.', metrics: null, rawSample: truncateRaw(result.body), }; } return { reachable: true, errorKind: null, errorDetail: null, metrics: normalized.metrics, 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. * * Zehn-Sekunden-Sperre (Aufgabe 4, ``, T-DHH-06): ein zweiter * Durchlauf innerhalb von zehn Sekunden nach dem letzten fragt Proxmox * NICHT erneut, sondern liefert den vorhandenen Zwischenlagerstand — * Schutz davor, dass ein Klick in der Oberflaeche zu ungebremsten * Anfragen gegen die Fremd-API wird. */ async pollServer(tenantId: string, serverId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId }, include: { status: true }, }); if (!server || server.tenantId !== tenantId) { return null; } const cachedStatus = server.status; if (cachedStatus?.lastPolledAt) { const ageMs = Date.now() - cachedStatus.lastPolledAt.getTime(); if (ageMs < POLL_LOCK_MS) { return { reachable: cachedStatus.reachable, errorKind: cachedStatus.errorKind as ProxmoxErrorKind | null, errorDetail: cachedStatus.errorDetail, metrics: cachedStatus.metrics as ProxmoxPollResult['metrics'], rawSample: cachedStatus.rawSample, }; } } 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; } /** * Mischt Formularwerte (`dto`, ungespeichert) mit dem gespeicherten Server * (`existing`, `null` bei der Neuanlage) zu genau den Feldern, die eine * Abfrage braucht (Nachbesserung Befund 1). Zwei Regeln, je nachdem, ob * das Formular das Feld beim Laden vorbefuellt (Vorbild `serverToForm`): * * - Normale Felder (`productType`, `baseUrl`, `authMethod`, `tokenId`, * `username`, `tlsRejectUnauthorized`): das Formular zeigt immer den * zuletzt gespeicherten Wert an, bis der Nutzer ihn aendert — ein vom * Aufrufer GESENDETES Feld gilt also als Formularwert, auch wenn es * absichtlich geleert wurde (`tokenId: ''` -> `null`). Nur ein NICHT * gesendetes Feld (Aufrufer ohne diesen Schluessel im Body) faellt auf * den gespeicherten Wert zurueck. * - Geheimnisfelder (`tokenSecret`/`password`): `ServerForm` befuellt * diese beim Laden bewusst NIE aus der Datenbank (Geheimnis nie im * Klartext anzeigen). Ein leeres Feld bedeutet hier deshalb NICHT * "Nutzer will loeschen", sondern "Nutzer hat nichts eingetippt" -> * gespeicherten (verschluesselten) Wert weiterverwenden. Ein gefuelltes * Feld ist der eingetippte Klartext und wird unveraendert durchgereicht; * `decryptSecret()` erkennt anhand der Form `iv:authTag:ciphertext` * automatisch, ob entschluesselt werden muss, und laesst Klartext sonst * unangetastet. */ private resolveEffectiveTestServer( existing: ProxmoxServer | null, dto: TestProxmoxServerDto, ): ProxmoxCredentialSource { return { productType: dto.productType ?? existing?.productType ?? 'pve', baseUrl: dto.baseUrl ?? existing?.baseUrl ?? '', authMethod: dto.authMethod ?? existing?.authMethod ?? 'token', tokenId: dto.tokenId !== undefined ? dto.tokenId || null : (existing?.tokenId ?? null), encryptedTokenSecret: dto.tokenSecret ? dto.tokenSecret : (existing?.encryptedTokenSecret ?? null), username: dto.username !== undefined ? dto.username || null : (existing?.username ?? null), encryptedPassword: dto.password ? dto.password : (existing?.encryptedPassword ?? null), tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? existing?.tlsRejectUnauthorized ?? true, }; } /** * Verbindungstest fuer einen GESPEICHERTEN Server (Aufgabe 4, ``, * `POST servers/:id/test`; Nachbesserung Befund 1: prueft jetzt die * Formularwerte aus `dto`, nicht mehr blind den gespeicherten Stand). * Benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der * Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den * zuletzt gemessenen Stand nicht ueberschreiben (Vorbild * `TenderEmailConfigService.testConnection`/LDAP-Test). Keine * Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein * automatisierter Auffrischungsweg. */ async testConnection( tenantId: string, serverId: string, dto: TestProxmoxServerDto = {}, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } }); if (!server || server.tenantId !== tenantId) { return null; } return this.pollOne(this.resolveEffectiveTestServer(server, dto)); } /** * Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1, * `POST servers/test`, ohne `:id`) — es gibt noch keinen gespeicherten * Server, also ausschliesslich die Formularwerte aus `dto`. Fehlende * Pflichtangaben (z. B. kein Geheimnis) fuehren zum selben Fehlerschluessel * wie beim Abfragen eines gespeicherten Servers ohne Zugang (`zugang`). */ async testDraftConnection(dto: TestProxmoxServerDto): Promise { return this.pollOne(this.resolveEffectiveTestServer(null, dto)); } /** * Aktive Server-IDs eines Mandanten fuer den Planer-Tick (gebunden). */ async listActiveServerIdsForTenant(tenantId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const rows = await tenantPrisma.proxmoxServer.findMany({ where: { tenantId, isActive: true }, select: { id: true }, }); return rows.map((r) => r.id); } /** * Abfrageintervalle der aktiven Server eines Mandanten (gebunden) — der * Controller ruft dies nach jedem Anlegen/Speichern, um den Planer * sofort nachzuziehen (`ProxmoxSchedulerService.refreshTenant`). */ async loadActiveServersForTenantScheduling( tenantId: string, ): Promise<{ pollIntervalMin: number }[]> { const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma.proxmoxServer.findMany({ where: { tenantId, isActive: true }, select: { pollIntervalMin: true }, }); } /** * Startpfad des Planers — der EINZIGE Systemkontext-Aufruf dieses Moduls * (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`, Aufgabe 4): * `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE * `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst * keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden * (Muster `DkvSchedulerService`, einmal lesen, * viele bedienen). */ async loadActiveServersForScheduler(): Promise< { id: string; tenantId: string; pollIntervalMin: number }[] > { const systemPrisma = forSystem(this.prisma); return systemPrisma.proxmoxServer.findMany({ where: { isActive: true }, select: { id: true, tenantId: true, pollIntervalMin: true }, }); } }