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/plans/:planId/abort", auditLog("ABORT_PLAN", "plan"), abortExecution);
|
||||||
app.post("/api/webhooks", registerWebhook);
|
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);
|
app.get("/api/plans/:planId/status/stream", streamPlanStatus);
|
||||||
|
|
||||||
// Error handling middleware
|
// 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