test(260923-dhh): Proxmox-Modul Aufgabe 2 - Benutzer/Passwort, Fehlerklassen, Nur-Lesen-Riegel

- proxmox-auth.ts: loginTicket (die einzige nicht-lesende Anfrage im
  Modul, POST /access/ticket) und buildTicketCookieHeader je Produkt
  (Cookie-Namen als benannte Konstante, Annahme A2 kommentiert)
- proxmox-client.service.ts: classifyFailure (401->zugang, 403->rechte,
  404->antwortform, 5xx->server, Netzfehler->netz, Zertifikatsfehler->
  zertifikat) und parseJsonLenient (kein Wurf bei Nicht-JSON); kein
  explizites method-Feld mehr an proxmoxGet (GET ist Grundwert)
- proxmox.service.ts: Passwort-Zweig via Ticket-Anmeldung, genau ein
  zweiter Versuch nach 401 (Ticket-Ablauf alle zwei Stunden kein
  Fehlalarm)
- proxmox-nur-lesen.spec.ts: maschinischer Riegel zu D-01 — genau eine
  Stelle (proxmox-auth.ts) uebergibt ein Anfrageverfahren an
  undiciFetch, jeder Proxmox-Pfad ausserhalb laeuft ueber proxmoxGet

Tore: api 1270/1270 (>=1240), type-check 4/4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:23:22 +02:00
parent 3a1bfd943e
commit 4f8a368c9e
5 changed files with 729 additions and 24 deletions
+63 -22
View File
@@ -3,7 +3,7 @@ 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 { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
import { proxmoxGet } from './proxmox-client.service';
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import type {
@@ -183,8 +183,13 @@ export class ProxmoxService {
});
}
/** Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber `proxmox-auth.ts` (D-03). */
private buildAuthHeaders(server: ProxmoxServer): AuthHeaderResult {
/**
* 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: ProxmoxServer): Promise<AuthHeaderResult> {
if (server.authMethod === 'token') {
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
if (!server.tokenId || !tokenSecret) {
@@ -203,31 +208,42 @@ export class ProxmoxService {
),
};
}
// Benutzer/Passwort-Zweig (Ticket-Anmeldung) folgt in Aufgabe 2.
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: false,
errorKind: 'unbekannt',
errorDetail: 'Benutzer/Passwort-Zugang wird in dieser Aufgabe noch nicht unterstuetzt.',
ok: true,
headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket),
};
}
/**
* 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`.
* EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe `pve`
* mit Token ODER Benutzer/Passwort (Aufgabe 3 ergaenzt `pbs`/`pmg`).
*
* Ticket-Erneuerung (Aufgabe 2, `<behavior>`): laeuft der Zugang ueber
* `password` und antwortet Proxmox mit 401 (`errorKind: 'zugang'`), wird
* GENAU EINMAL neu angemeldet und die Abfrage wiederholt — bei einer
* Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
*/
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 {
@@ -239,7 +255,18 @@ export class ProxmoxService {
};
}
const result = await proxmoxGet(
const authHeaders = await this.buildAuthHeaders(server);
if (!authHeaders.ok) {
return {
reachable: false,
errorKind: authHeaders.errorKind,
errorDetail: authHeaders.errorDetail,
metrics: null,
rawSample: null,
};
}
let result = await proxmoxGet(
{
baseUrl: server.baseUrl,
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
@@ -248,6 +275,20 @@ export class ProxmoxService {
'/api2/json/cluster/resources',
);
if (!result.ok && result.errorKind === 'zugang' && server.authMethod === 'password') {
const retryHeaders = await this.buildAuthHeaders(server);
if (retryHeaders.ok) {
result = await proxmoxGet(
{
baseUrl: server.baseUrl,
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
headers: retryHeaders.headers,
},
'/api2/json/cluster/resources',
);
}
}
if (!result.ok) {
return {
reachable: false,