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 ``). */ 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; } 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 ``). */ 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 ``). */ 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 { 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); } }