feat(260923-dhh): Proxmox-Modul Aufgabe 1 - PVE per Token, Ende-zu-Ende
- ProxmoxServer/ProxmoxServerStatus mit RLS (tenant_isolation_policy +
system_read_policy auf ProxmoxServer fuer den kommenden Planer)
- proxmox-auth.ts (Token-Kopfzeilen PVE/PBS), proxmox-client.service.ts
(proxmoxGet, ausschliesslich lesend, Dispatcher je Aufruf aus
tlsRejectUnauthorized, nie global)
- proxmox.service.ts: Server anlegen (Geheimnis verschluesselt,
select ohne Geheimnisfelder), Serverliste, PVE-Abfrage mit
nachsichtiger Grundauswertung (Knoten/Gaeste)
- Controller/Modul/Seed nach Domaincheck-Vorbild, Kategorie
"infrastructure", @UseModule('proxmox') + @Roles auf Schreibwegen
- Modulseite (duenne Liste) + proxmox-api.ts + Registrierung in
MODULE_REGISTRY
- Zugriffsklassifikation nachgezogen (rls-access-inventory.spec.ts gruen)
Tore: api 1247/1247 (>=1240), web 693/693, type-check 4/4, lint 5/5,
Biome apps/web 53 Warnungen (unveraendert).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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 {
|
||||
const response = await undiciFetch(url, {
|
||||
method: 'GET', // fest verdrahtet — D-01, kein Parameter dafuer
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user