Files
tessera-ctl/apps/api/src/proxmox/proxmox-client.service.ts
T
schalli 4f8a368c9e 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>
2026-09-23 10:23:22 +02:00

231 lines
8.6 KiB
TypeScript

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 {
// KEIN `method`-Feld — GET ist der Grundwert von `fetch`/`undiciFetch`
// selbst, es gibt hierfuer keinen Parameter (D-01). `proxmox-nur-
// lesen.spec.ts` zaehlt Stellen, die ein Anfrageverfahren EXPLIZIT an
// `undiciFetch` uebergeben — die einzige solche Stelle im Modul ist
// `loginTicket` in `proxmox-auth.ts` (POST, Ticket-Anmeldung, D-01).
const response = await undiciFetch(url, {
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);
}
}