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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:17:33 +02:00
parent ec9c77956d
commit 3a1bfd943e
19 changed files with 1528 additions and 4 deletions
+312
View File
@@ -0,0 +1,312 @@
import { Injectable, Logger } from '@nestjs/common';
import type { ProxmoxServer } from '@prisma/client';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { buildTokenAuthHeader } from './proxmox-auth';
import { proxmoxGet } from './proxmox-client.service';
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import type {
ProxmoxErrorKind,
ProxmoxPollResult,
ProxmoxPveMetrics,
ProxmoxPveNodeMetric,
} from './proxmox.types';
/**
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
* hex, Doppelpunkt-getrennt). Fuer Proxmox als NEUES Feature ab Tag 1
* irrelevant (keine Altdaten), aber derselbe defensive Riegel wie ueberall
* sonst im Projekt.
*/
const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i;
/** Rohantwort wird auf hoechstens diese Zeichenzahl gekuerzt in `rawSample` abgelegt. */
const RAW_SAMPLE_MAX_CHARS = 20000;
/**
* `select` OHNE die beiden Geheimnisfelder — die Felder verlassen die
* Datenbank gar nicht erst, statt nachtraeglich maskiert zu werden
* (T-DHH-01, `must_haves.truths`).
*/
const SAFE_SERVER_SELECT = {
id: true,
tenantId: true,
name: true,
productType: true,
baseUrl: true,
authMethod: true,
tokenId: true,
username: true,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: true,
position: true,
createdAt: true,
updatedAt: true,
status: true,
} as const;
function readNumberOrNull(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
/**
* Grundauswertung von `/api2/json/cluster/resources` (Aufgabe 1: nur PVE,
* nur Knotenzahl/Gaestezahl/je-Knoten-Auslastung — Aufgabe 3 baut PBS/PMG
* daneben und zieht diese Funktion nach `proxmox-normalize.ts` um). Jeder
* Einzelwert nachsichtig gelesen: fehlt er, ist er `null` — nie `0`, nie
* `NaN`, nie ein Wurf (der Nutzer prueft dieses Modul an seinen echten
* Servern; ein Wurf zeigt ihm eine leere Seite statt eines Hinweises).
*/
function normalizePveResources(body: unknown): ProxmoxPveMetrics {
const list =
body && typeof body === 'object' && Array.isArray((body as { data?: unknown }).data)
? ((body as { data: unknown[] }).data as unknown[])
: [];
const isRecord = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === 'object';
const nodeEntries = list.filter((e) => isRecord(e) && e.type === 'node');
const guestEntries = list.filter(
(e) => isRecord(e) && (e.type === 'qemu' || e.type === 'lxc'),
);
const running = guestEntries.filter((g) => isRecord(g) && g.status === 'running').length;
const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((entry) => {
const n = entry as Record<string, unknown>;
return {
node: typeof n.node === 'string' ? n.node : 'unbekannt',
cpu: readNumberOrNull(n.cpu),
maxcpu: readNumberOrNull(n.maxcpu),
mem: readNumberOrNull(n.mem),
maxmem: readNumberOrNull(n.maxmem),
};
});
return {
productType: 'pve',
nodeCount: nodeEntries.length,
guestsRunning: running,
guestsStopped: guestEntries.length - running,
nodes,
};
}
function truncateRaw(body: unknown): string {
let text: string;
try {
text = JSON.stringify(body) ?? String(body);
} catch {
text = String(body);
}
return text.length > RAW_SAMPLE_MAX_CHARS ? text.slice(0, RAW_SAMPLE_MAX_CHARS) : text;
}
type AuthHeaderResult =
| { ok: true; headers: Record<string, string> }
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
@Injectable()
export class ProxmoxService {
private readonly logger = new Logger(ProxmoxService.name);
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
) {}
/**
* Entschluesselt fuer den internen Gebrauch in GENAU dieser einen
* privaten Methode (Vorbild `LdapConfigService.decryptBindPassword`) —
* ein Wert, der nicht in `iv:authTag:ciphertext`-Form ist, wird
* unveraendert durchgereicht.
*/
private decryptSecret(stored: string | null): string | null {
if (!stored) return null;
if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored;
return this.crypto.decrypt(stored);
}
/**
* Server anlegen (Aufgabe 1: nur `pve`+Token gepflegt vom Aufrufer;
* Aufgabe 2 ergaenzt den Passwort-Zweig, Aufgabe 5 das Bearbeiten). Legt
* zugleich eine leere Zwischenlagerzeile an, damit `listWithStatus` immer
* eine Statuszeile findet.
*/
async createServer(tenantId: string, dto: CreateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.create({
data: {
tenantId,
name: dto.name,
productType: dto.productType,
baseUrl: dto.baseUrl,
authMethod: dto.authMethod,
tokenId: dto.authMethod === 'token' ? (dto.tokenId ?? null) : null,
encryptedTokenSecret:
dto.authMethod === 'token' && dto.tokenSecret
? this.crypto.encrypt(dto.tokenSecret)
: null,
username: dto.authMethod === 'password' ? (dto.username ?? null) : null,
encryptedPassword:
dto.authMethod === 'password' && dto.password
? this.crypto.encrypt(dto.password)
: null,
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
pollIntervalMin: dto.pollIntervalMin ?? 5,
isActive: dto.isActive ?? true,
status: { create: { tenantId, reachable: false } },
},
select: SAFE_SERVER_SELECT,
});
}
/**
* Serverliste samt Zwischenlager, OHNE jedes Geheimnisfeld (T-DHH-01).
* Liest ausschliesslich aus dem Zwischenlager — kein Live-Zugriff bei
* Proxmox (D-05).
*/
async listWithStatus(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.findMany({
where: { tenantId },
orderBy: { position: 'asc' },
select: SAFE_SERVER_SELECT,
});
}
/** Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber `proxmox-auth.ts` (D-03). */
private buildAuthHeaders(server: ProxmoxServer): AuthHeaderResult {
if (server.authMethod === 'token') {
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
if (!server.tokenId || !tokenSecret) {
return {
ok: false,
errorKind: 'zugang',
errorDetail: 'Kein Token hinterlegt.',
};
}
return {
ok: true,
headers: buildTokenAuthHeader(
server.productType as 'pve' | 'pbs' | 'pmg',
server.tokenId,
tokenSecret,
),
};
}
// Benutzer/Passwort-Zweig (Ticket-Anmeldung) folgt in Aufgabe 2.
return {
ok: false,
errorKind: 'unbekannt',
errorDetail: 'Benutzer/Passwort-Zugang wird in dieser Aufgabe noch nicht unterstuetzt.',
};
}
/**
* EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe nur
* `pve` mit Token — Aufgabe 2 ergaenzt Benutzer/Passwort und die
* Fehlerklassen, Aufgabe 3 ergaenzt `pbs`/`pmg`.
*/
private async pollOne(server: ProxmoxServer): Promise<ProxmoxPollResult> {
const authHeaders = this.buildAuthHeaders(server);
if (!authHeaders.ok) {
return {
reachable: false,
errorKind: authHeaders.errorKind,
errorDetail: authHeaders.errorDetail,
metrics: null,
rawSample: null,
};
}
if (server.productType !== 'pve') {
// PBS/PMG folgen in Aufgabe 3.
return {
reachable: false,
errorKind: 'unbekannt',
errorDetail: 'Dieser Produkttyp wird in dieser Aufgabe noch nicht abgefragt.',
metrics: null,
rawSample: null,
};
}
const result = await proxmoxGet(
{
baseUrl: server.baseUrl,
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
headers: authHeaders.headers,
},
'/api2/json/cluster/resources',
);
if (!result.ok) {
return {
reachable: false,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: null,
rawSample: result.body === null ? null : truncateRaw(result.body),
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalizePveResources(result.body),
rawSample: truncateRaw(result.body),
};
}
/**
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
* Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert.
*/
async pollServer(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const server = await tenantPrisma.proxmoxServer.findUnique({
where: { id: serverId },
});
if (!server || server.tenantId !== tenantId) {
return null;
}
const result = await this.pollOne(server);
const now = new Date();
await tenantPrisma.proxmoxServerStatus.upsert({
where: { serverId },
create: {
serverId,
tenantId,
lastPolledAt: now,
lastOkAt: result.reachable ? now : null,
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
update: {
lastPolledAt: now,
...(result.reachable ? { lastOkAt: now } : {}),
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
});
return result;
}
}