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:
@@ -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());
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <ModuleAccessGate moduleSlug="proxmox">{children}</ModuleAccessGate>;
|
||||
}
|
||||
@@ -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<ProxmoxServer[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listServers()
|
||||
.then((data) => {
|
||||
if (!cancelled) setServers(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(t('loadError'));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{!error && servers === null && (
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||
{t('emptyState')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && servers !== null && servers.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{servers.map((server) => (
|
||||
<li
|
||||
key={server.id}
|
||||
className="rounded-lg border border-border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{server.name}</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{server.productType}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{server.baseUrl}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
proxmox: {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/proxmox/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<CreateProxmoxServerPayload>;
|
||||
|
||||
/** GET /modules/proxmox/servers — Serverliste samt Zwischenlager. */
|
||||
export async function listServers(): Promise<ProxmoxServer[]> {
|
||||
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<ProxmoxServer> {
|
||||
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<unknown> {
|
||||
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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user