/** * Proxmox API BFF client. * * Proxmox's API (https://proxmox-api.d-bis.org) sits behind Cloudflare * Access. Browsers cannot carry CF-Access JWTs without completing an SSO * flow, so the portal calls our Express orchestrator and we forward * requests with a Cloudflare Access Service Token. * * Required env: * PROXMOX_API_URL - upstream base URL (e.g. https://proxmox-api.d-bis.org) * PROXMOX_CF_ACCESS_CLIENT_ID - CF Access service token ID * PROXMOX_CF_ACCESS_CLIENT_SECRET - CF Access service token secret * * When any of these are missing, the client returns null/empty responses * and the HTTP layer surfaces a 503 with an actionable body so the portal * knows to stay in its mocked state. */ import { logger } from "../logging/logger"; export interface ProxmoxEnv { baseUrl: string | undefined; clientId: string | undefined; clientSecret: string | undefined; } export function readProxmoxEnv(): ProxmoxEnv { return { baseUrl: process.env.PROXMOX_API_URL, clientId: process.env.PROXMOX_CF_ACCESS_CLIENT_ID, clientSecret: process.env.PROXMOX_CF_ACCESS_CLIENT_SECRET, }; } export function isProxmoxConfigured(env: ProxmoxEnv = readProxmoxEnv()): boolean { return !!(env.baseUrl && env.clientId && env.clientSecret); } /** * Forwards a GET request to Proxmox through the CF Access service token. * Returns the upstream JSON and status verbatim. Throws on network failure. */ export async function proxmoxForwardGet( path: string, env: ProxmoxEnv = readProxmoxEnv(), ): Promise<{ status: number; body: unknown }> { if (!isProxmoxConfigured(env)) { throw new Error("PROXMOX_NOT_CONFIGURED"); } const url = new URL(path.startsWith("/") ? path : `/${path}`, env.baseUrl).toString(); const res = await fetch(url, { method: "GET", headers: { accept: "application/json", // Cloudflare Access service token headers. "CF-Access-Client-Id": env.clientId!, "CF-Access-Client-Secret": env.clientSecret!, }, }); const contentType = res.headers.get("content-type") ?? ""; const body = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text(); return { status: res.status, body }; } export interface ClusterHealth { source: "proxmox"; online: boolean; nodes: Array<{ name: string; status: string; uptime: number | null }>; lastChecked: string; } /** * Convenience wrapper — returns an aggregated cluster health summary from * the Proxmox `/api2/json/cluster/status` endpoint. Surfaces a degraded * state when configuration is missing rather than throwing so callers can * render a consistent payload. */ export async function getClusterHealth(): Promise { const env = readProxmoxEnv(); if (!isProxmoxConfigured(env)) { return { source: "proxmox", online: false, nodes: [], lastChecked: new Date().toISOString(), }; } try { const { status, body } = await proxmoxForwardGet("/api2/json/cluster/status", env); if (status >= 400 || !body || typeof body !== "object") { logger.warn({ status, body }, "proxmox cluster status non-2xx"); return { source: "proxmox", online: false, nodes: [], lastChecked: new Date().toISOString() }; } const data = (body as { data?: unknown }).data; if (!Array.isArray(data)) { return { source: "proxmox", online: true, nodes: [], lastChecked: new Date().toISOString() }; } const nodes = data .filter((n: { type?: string }) => n.type === "node") .map((n: { name?: string; status?: string; uptime?: number }) => ({ name: n.name ?? "unknown", status: n.status ?? "unknown", uptime: typeof n.uptime === "number" ? n.uptime : null, })); return { source: "proxmox", online: true, nodes, lastChecked: new Date().toISOString() }; } catch (err) { logger.error({ err }, "proxmox cluster status fetch failed"); return { source: "proxmox", online: false, nodes: [], lastChecked: new Date().toISOString() }; } }