diff --git a/apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql b/apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql new file mode 100644 index 0000000..f27c75c --- /dev/null +++ b/apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql @@ -0,0 +1,94 @@ +-- 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01). +-- +-- Zweck: zwei neue Tabellen fuer das Proxmox-Modul. `ProxmoxServer` traegt +-- die vom Administrator eingetragenen Server (Name, Typ, Adresse, Zugang, +-- verschluesselt) — mehrere Zeilen je Mandant, Vorbild `CalendarSource`, +-- NICHT `DkvModuleConfig` (Singleton je Mandant). `ProxmoxServerStatus` ist +-- das Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt +-- diese Zeile, die Modulseite liest ausschliesslich daraus. +-- +-- Von Hand geschrieben (Vorbild 20260923120000_dashboard_tabs), von Hand +-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt. +-- +-- Zeilenschutz (D-08, Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): +-- beide Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE +-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus +-- `DkvModuleConfig`, Migration 20260909140000) — Proxmox-Server sind +-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines einzelnen +-- Benutzers. +-- +-- Zusaetzlich NUR auf "ProxmoxServer" eine `system_read_policy` (Form aus +-- 20260914120000_rls_system_context_read): der Hintergrunddienst aus +-- Aufgabe 4 muss beim Start ueber `forSystem()` die aktiven Server ALLER +-- Mandanten sehen, um je Mandant einen eigenen Cron-Auftrag zu registrieren +-- (Muster DKV-/Tender-Planer). "ProxmoxServerStatus" bekommt diese Regel +-- BEWUSST NICHT — geschrieben wird dort ausschliesslich je Zeile +-- mandantengebunden (`forTenant(prisma, tenantId)`), ein Systemlesezugriff +-- auf das Zwischenlager hat keinen Aufrufer. +-- +-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber +-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu +-- tun. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter +-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md). + +-- 1) ProxmoxServer +CREATE TABLE "ProxmoxServer" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "productType" TEXT NOT NULL, + "baseUrl" TEXT NOT NULL, + "authMethod" TEXT NOT NULL, + "tokenId" TEXT, + "encryptedTokenSecret" TEXT, + "username" TEXT, + "encryptedPassword" TEXT, + "tlsRejectUnauthorized" BOOLEAN NOT NULL DEFAULT true, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "pollIntervalMin" INTEGER NOT NULL DEFAULT 5, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProxmoxServer_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ProxmoxServer_tenantId_idx" ON "ProxmoxServer"("tenantId"); + +ALTER TABLE "ProxmoxServer" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ProxmoxServer" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "ProxmoxServer" + USING ("tenantId" = current_tenant_id()); +CREATE POLICY system_read_policy ON "ProxmoxServer" + FOR SELECT USING (is_system_context()); + +-- 2) ProxmoxServerStatus — Zwischenlager, 1:1 je Server, Loeschweitergabe. +CREATE TABLE "ProxmoxServerStatus" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "lastPolledAt" TIMESTAMP(3), + "lastOkAt" TIMESTAMP(3), + "reachable" BOOLEAN NOT NULL DEFAULT false, + "errorKind" TEXT, + "errorDetail" TEXT, + "metrics" JSONB, + "rawSample" JSONB, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProxmoxServerStatus_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProxmoxServerStatus_serverId_key" ON "ProxmoxServerStatus"("serverId"); +CREATE INDEX "ProxmoxServerStatus_tenantId_idx" ON "ProxmoxServerStatus"("tenantId"); + +ALTER TABLE "ProxmoxServerStatus" ADD CONSTRAINT "ProxmoxServerStatus_serverId_fkey" + FOREIGN KEY ("serverId") REFERENCES "ProxmoxServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ProxmoxServerStatus" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ProxmoxServerStatus" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "ProxmoxServerStatus" + USING ("tenantId" = current_tenant_id()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 22bb910..1fc6bbb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -651,3 +651,54 @@ model TenderRssFeedSource { @@unique([userId, url]) @@index([userId]) } + +// Quick-Auftrag 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01). +// +// Vorbild ist `CalendarSource` (mehrere verschluesselte Fremdsystem-Zugaenge +// je Mandant), NICHT `DkvModuleConfig` (Singleton je Mandant): ein Mandant +// traegt hier beliebig viele Server ein. `authMethod` waehlt zwischen einem +// API-Token (`tokenId`/`encryptedTokenSecret`) und Benutzer/Passwort +// (`username`/`encryptedPassword`); PMG kennt laut Recherche nur Letzteres +// (DTO lehnt Token bei PMG serverseitig ab, D-03). `tlsRejectUnauthorized` +// ist woertlich der Feldname aus `LdapConfig` — Voreinstellung "pruefen", +// pro Zeile umschaltbar, nie global (D-04). +model ProxmoxServer { + id String @id @default(uuid()) + tenantId String + name String + productType String // 'pve' | 'pbs' | 'pmg' + baseUrl String + authMethod String // 'token' | 'password' + tokenId String? + encryptedTokenSecret String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource.encryptedPassword + username String? + encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex) + tlsRejectUnauthorized Boolean @default(true) + isActive Boolean @default(true) + pollIntervalMin Int @default(5) + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + status ProxmoxServerStatus? + + @@index([tenantId]) +} + +// Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt diese +// Zeile, die Modulseite liest ausschliesslich daraus — nie live bei Proxmox. +model ProxmoxServerStatus { + id String @id @default(uuid()) + serverId String @unique + server ProxmoxServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + tenantId String + lastPolledAt DateTime? + lastOkAt DateTime? + reachable Boolean @default(false) + errorKind String? + errorDetail String? + metrics Json? + rawSample Json? + updatedAt DateTime @updatedAt + + @@index([tenantId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e41c428..99339a0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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() diff --git a/apps/api/src/proxmox/dto/proxmox-server.dto.ts b/apps/api/src/proxmox/dto/proxmox-server.dto.ts new file mode 100644 index 0000000..69101bc --- /dev/null +++ b/apps/api/src/proxmox/dto/proxmox-server.dto.ts @@ -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; +} diff --git a/apps/api/src/proxmox/proxmox-auth.ts b/apps/api/src/proxmox/proxmox-auth.ts new file mode 100644 index 0000000..431bed9 --- /dev/null +++ b/apps/api/src/proxmox/proxmox-auth.ts @@ -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 `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.', + ); +} diff --git a/apps/api/src/proxmox/proxmox-client.service.ts b/apps/api/src/proxmox/proxmox-client.service.ts new file mode 100644 index 0000000..c4c5a6a --- /dev/null +++ b/apps/api/src/proxmox/proxmox-client.service.ts @@ -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 ``). + */ +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; +} + +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 ``). + */ +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 ``). + */ +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 { + 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); + } +} diff --git a/apps/api/src/proxmox/proxmox.controller.ts b/apps/api/src/proxmox/proxmox.controller.ts new file mode 100644 index 0000000..769f546 --- /dev/null +++ b/apps/api/src/proxmox/proxmox.controller.ts @@ -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); + } +} diff --git a/apps/api/src/proxmox/proxmox.module.ts b/apps/api/src/proxmox/proxmox.module.ts new file mode 100644 index 0000000..6883f06 --- /dev/null +++ b/apps/api/src/proxmox/proxmox.module.ts @@ -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 { + try { + await seedProxmoxModule(this.moduleRegistryService); + this.logger.log('Proxmox module seeded in registry'); + } catch (error) { + this.logger.error('Failed to seed proxmox module', error); + } + } +} diff --git a/apps/api/src/proxmox/proxmox.seed.ts b/apps/api/src/proxmox/proxmox.seed.ts new file mode 100644 index 0000000..d0c251f --- /dev/null +++ b/apps/api/src/proxmox/proxmox.seed.ts @@ -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 { + 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, + }); +} diff --git a/apps/api/src/proxmox/proxmox.service.spec.ts b/apps/api/src/proxmox/proxmox.service.spec.ts new file mode 100644 index 0000000..d7f364d --- /dev/null +++ b/apps/api/src/proxmox/proxmox.service.spec.ts @@ -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(); + const statuses = new Map(); // key: serverId + + function applySelect(row: any, select: Record | undefined) { + if (!select) return { ...row }; + const out: Record = {}; + 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; + update: Record; + }) => { + 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> = {}) { + 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).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(); + }); +}); diff --git a/apps/api/src/proxmox/proxmox.service.ts b/apps/api/src/proxmox/proxmox.service.ts new file mode 100644 index 0000000..7cf6ae4 --- /dev/null +++ b/apps/api/src/proxmox/proxmox.service.ts @@ -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 => + 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; + 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 } + | { 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/proxmox/proxmox.types.ts b/apps/api/src/proxmox/proxmox.types.ts new file mode 100644 index 0000000..d233a24 --- /dev/null +++ b/apps/api/src/proxmox/proxmox.types.ts @@ -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; +} diff --git a/apps/web/src/app/(portal)/modules/proxmox/layout.tsx b/apps/web/src/app/(portal)/modules/proxmox/layout.tsx new file mode 100644 index 0000000..3d0d222 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/layout.tsx @@ -0,0 +1,6 @@ +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; +import type { ReactNode } from 'react'; + +export default function ProxmoxLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/(portal)/modules/proxmox/page.tsx b/apps/web/src/app/(portal)/modules/proxmox/page.tsx new file mode 100644 index 0000000..cdb79e2 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { listServers, type ProxmoxServer } from '@/lib/proxmox-api'; + +/** + * Modulseite (Aufgabe 1: duenne Liste — Name, Typ, Adresse; Aufgabe 6 + * ergaenzt die produktabhaengige Auslastungsanzeige ueber `ServerCard`). + * Liest ausschliesslich aus dem Zwischenlager, das `GET servers` liefert — + * kein Live-Zugriff bei Proxmox von hier aus (D-05). + */ +export default function ProxmoxPage() { + const t = useTranslations('proxmox'); + const [servers, setServers] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + listServers() + .then((data) => { + if (!cancelled) setServers(data); + }) + .catch(() => { + if (!cancelled) setError(t('loadError')); + }); + return () => { + cancelled = true; + }; + }, [t]); + + return ( +
+
+

{t('title')}

+

{t('description')}

+
+ + {error &&

{error}

} + + {!error && servers === null && ( +

{t('loading')}

+ )} + + {!error && servers !== null && servers.length === 0 && ( +
+ {t('emptyState')} +
+ )} + + {!error && servers !== null && servers.length > 0 && ( +
    + {servers.map((server) => ( +
  • +
    + {server.name} + + {server.productType} + +
    +

    {server.baseUrl}

    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/module-loader.ts b/apps/web/src/lib/module-loader.ts index 72c409a..fb03723 100644 --- a/apps/web/src/lib/module-loader.ts +++ b/apps/web/src/lib/module-loader.ts @@ -53,6 +53,12 @@ export const MODULE_REGISTRY: Record = { { ssr: false }, ), }, + proxmox: { + component: dynamic( + () => import('@/app/(portal)/modules/proxmox/page'), + { ssr: false }, + ), + }, }; /** diff --git a/apps/web/src/lib/proxmox-api.ts b/apps/web/src/lib/proxmox-api.ts new file mode 100644 index 0000000..622e844 --- /dev/null +++ b/apps/web/src/lib/proxmox-api.ts @@ -0,0 +1,102 @@ +/** + * Proxmox Module API client (260923-dhh). Konsumiert `/modules/proxmox/*`. + * Vorbild `dkv-api.ts`: `credentials: 'include'` fuer Cookie-Auth, + * `NEXT_PUBLIC_API_URL` als Basis. + * + * Sicherheit (T-DHH-01): keine Antwort dieses Clients enthaelt jemals ein + * Geheimnisfeld — der Server waehlt `encryptedTokenSecret`/`encryptedPassword` + * per `select` gar nicht erst aus. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export type ProxmoxProductType = 'pve' | 'pbs' | 'pmg'; +export type ProxmoxAuthMethod = 'token' | 'password'; +export type ProxmoxErrorKind = + | 'netz' + | 'zugang' + | 'rechte' + | 'zertifikat' + | 'antwortform' + | 'server' + | 'unbekannt'; + +export interface ProxmoxServerStatus { + id: string; + serverId: string; + lastPolledAt: string | null; + lastOkAt: string | null; + reachable: boolean; + errorKind: ProxmoxErrorKind | null; + errorDetail: string | null; + metrics: unknown; + rawSample: unknown; + updatedAt: string; +} + +export interface ProxmoxServer { + id: string; + tenantId: string; + name: string; + productType: ProxmoxProductType; + baseUrl: string; + authMethod: ProxmoxAuthMethod; + tokenId: string | null; + username: string | null; + tlsRejectUnauthorized: boolean; + isActive: boolean; + pollIntervalMin: number; + position: number; + createdAt: string; + updatedAt: string; + status: ProxmoxServerStatus | null; +} + +export interface CreateProxmoxServerPayload { + name: string; + productType: ProxmoxProductType; + baseUrl: string; + authMethod: ProxmoxAuthMethod; + tokenId?: string; + tokenSecret?: string; + username?: string; + password?: string; + tlsRejectUnauthorized?: boolean; + pollIntervalMin?: number; + isActive?: boolean; +} + +export type UpdateProxmoxServerPayload = Partial; + +/** GET /modules/proxmox/servers — Serverliste samt Zwischenlager. */ +export async function listServers(): Promise { + const res = await fetch(`${API_URL}/modules/proxmox/servers`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch proxmox servers'); + return res.json(); +} + +/** POST /modules/proxmox/servers — Server anlegen (ADMIN/SUPER_ADMIN). */ +export async function createServer( + payload: CreateProxmoxServerPayload, +): Promise { + const res = await fetch(`${API_URL}/modules/proxmox/servers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to create proxmox server'); + return res.json(); +} + +/** POST /modules/proxmox/servers/:id/poll — sofortige Abfrage (ADMIN/SUPER_ADMIN). */ +export async function pollServer(id: string): Promise { + const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/poll`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to poll proxmox server'); + return res.json(); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 47da4a7..2ed9a07 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -703,6 +703,13 @@ "checking": "Prüfe...", "error": "Fehler bei der Prüfung" }, + "proxmox": { + "title": "Proxmox", + "description": "Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick — Tessera schaut nur zu, es verändert nichts.", + "loading": "Lade Serverliste...", + "loadError": "Die Serverliste konnte nicht geladen werden.", + "emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an." + }, "dkvFleet": { "pageTitle": "DKV-Rechnung", "checkNow": "Jetzt prüfen", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 87f7053..b400079 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -703,6 +703,13 @@ "checking": "Checking...", "error": "Error checking domain" }, + "proxmox": { + "title": "Proxmox", + "description": "State of your Proxmox servers (PVE/PBS/PMG) at a glance — Tessera only observes, it never changes anything.", + "loading": "Loading server list...", + "loadError": "Could not load the server list.", + "emptyState": "No server configured yet. Add one in the module settings." + }, "dkvFleet": { "pageTitle": "DKV Invoice", "checkNow": "Check Now", diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 00f200a..bc23b26 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -175,9 +175,10 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | | bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | -| **Summe** | **61** | **197** | **6** | **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| proxmox | 0 | 4 | 0 | neu (quick-260923-dhh, Aufgabe 1) — `proxmox.service.ts` bringt vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode). Nachgemessen mit der Gate-Schleife. Aufgabe 4 ergaenzt einen System-Rohtreffer fuer den Planer-Startpfad (`loadActiveServersForScheduler()`) — siehe dortigen Nachtrag | +| **Summe** | **61** | **201** | **6** | **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 75 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare) Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar @@ -342,11 +343,15 @@ entnommen (30 Zusicherungen, darunter der Wachhund | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 38 | +| muss-mandantengebunden | 40 | | keine-mandantengebundene-tabelle | 21 | | beides | 14 | | bewusst-uebergreifend | 2 | -| **Summe** | **75** | +| **Summe** | **77** | + +quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer` +und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht +abgeschrieben. ## Der Hintergrunddienst als Falle — sechs Fälle @@ -757,6 +762,8 @@ werden. | apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. | | apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). | | apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". | +| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1 — vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `createServer`/`listWithStatus`/`pollServer` holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId);`. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). Aufgabe 4 ergaenzt den Startpfad des Planers (`loadActiveServersForScheduler()`) ueber `forSystem()` — dieser eine Zusatzaufruf hebt den Stand auf `system-gebunden`, siehe dortigen Nachtrag. | +| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad in Aufgabe 4 liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. | ## Was diese Etappe NICHT entscheidet