feat(orchestrator): Proxmox BFF route (CF-Access service token proxy)
Some checks failed
Code Quality / SonarQube Analysis (pull_request) Failing after 26s
Code Quality / Code Quality Checks (pull_request) Failing after 6s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 3s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 3s

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.
This commit is contained in:
2026-04-19 08:29:13 +00:00
parent b118b2be9c
commit ecd5412923
3 changed files with 158 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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<ClusterHealth> {
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() };
}
}