From ecd54129234037159e84d3112df9bbb0cdfe7dcc Mon Sep 17 00:00:00 2001 From: "Nakamoto, S" Date: Sun, 19 Apr 2026 08:29:13 +0000 Subject: [PATCH] feat(orchestrator): Proxmox BFF route (CF-Access service token proxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a narrow, safelisted BFF surface so the Solace Bank Group PLC portal (and other browser clients) can reach the Cloudflare Access protected Proxmox API without requiring the user to complete a CF-Access SSO flow in-browser. Endpoints: GET /api/proxmox/health — configuration probe (503 when unset) GET /api/proxmox/cluster/status — aggregated cluster node status Required orchestrator env: PROXMOX_API_URL PROXMOX_CF_ACCESS_CLIENT_ID PROXMOX_CF_ACCESS_CLIENT_SECRET When env is missing the endpoints return 503 with an actionable JSON body and the frontend stays in its mocked state — no crashes, no partial deploys. --- orchestrator/src/api/proxmox.ts | 40 ++++++++ orchestrator/src/index.ts | 7 ++ orchestrator/src/integrations/proxmox.ts | 111 +++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 orchestrator/src/api/proxmox.ts create mode 100644 orchestrator/src/integrations/proxmox.ts diff --git a/orchestrator/src/api/proxmox.ts b/orchestrator/src/api/proxmox.ts new file mode 100644 index 0000000..8222e04 --- /dev/null +++ b/orchestrator/src/api/proxmox.ts @@ -0,0 +1,40 @@ +/** + * Proxmox BFF API routes — proxies browser requests to the Cloudflare + * Access protected Proxmox API using a server-side service token. + * + * These routes intentionally expose a **narrow, safelisted** surface to + * the browser — we don't want to proxy arbitrary Proxmox endpoints. + * + * Current endpoints: + * GET /api/proxmox/health — upstream reachability check + * GET /api/proxmox/cluster/status — aggregated cluster node status + */ +import type { Request, Response } from "express"; +import { getClusterHealth, isProxmoxConfigured, readProxmoxEnv } from "../integrations/proxmox"; + +export async function proxmoxHealth(_req: Request, res: Response) { + const env = readProxmoxEnv(); + if (!isProxmoxConfigured(env)) { + return res.status(503).json({ + status: "unconfigured", + message: + "PROXMOX_API_URL / PROXMOX_CF_ACCESS_CLIENT_ID / PROXMOX_CF_ACCESS_CLIENT_SECRET not set on the orchestrator.", + required: ["PROXMOX_API_URL", "PROXMOX_CF_ACCESS_CLIENT_ID", "PROXMOX_CF_ACCESS_CLIENT_SECRET"], + }); + } + return res.json({ status: "configured", baseUrl: env.baseUrl }); +} + +export async function proxmoxClusterStatus(_req: Request, res: Response) { + const env = readProxmoxEnv(); + if (!isProxmoxConfigured(env)) { + return res.status(503).json({ + status: "unconfigured", + online: false, + nodes: [], + message: "Proxmox BFF not configured. See GET /api/proxmox/health for required env vars.", + }); + } + const health = await getClusterHealth(); + return res.status(health.online ? 200 : 502).json(health); +} diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 4c6376c..c97d892 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -99,6 +99,13 @@ app.get("/api/plans/:planId/status", getExecutionStatus); app.post("/api/plans/:planId/abort", auditLog("ABORT_PLAN", "plan"), abortExecution); app.post("/api/webhooks", registerWebhook); +// Proxmox BFF — forwards browser requests to the CF-Access protected +// Proxmox API using a server-side service token. See +// orchestrator/src/integrations/proxmox.ts for required env. +import { proxmoxHealth, proxmoxClusterStatus } from "./api/proxmox"; +app.get("/api/proxmox/health", proxmoxHealth); +app.get("/api/proxmox/cluster/status", proxmoxClusterStatus); + app.get("/api/plans/:planId/status/stream", streamPlanStatus); // Error handling middleware diff --git a/orchestrator/src/integrations/proxmox.ts b/orchestrator/src/integrations/proxmox.ts new file mode 100644 index 0000000..0d3c93e --- /dev/null +++ b/orchestrator/src/integrations/proxmox.ts @@ -0,0 +1,111 @@ +/** + * 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() }; + } +}