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