4f8a368c9e
- 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>
231 lines
8.6 KiB
TypeScript
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);
|
|
}
|
|
}
|