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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user