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
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:
40
orchestrator/src/api/proxmox.ts
Normal file
40
orchestrator/src/api/proxmox.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
111
orchestrator/src/integrations/proxmox.ts
Normal file
111
orchestrator/src/integrations/proxmox.ts
Normal 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() };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user