Some checks failed
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Frontend Lint (push) Has started running
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Failing after 4s
Security Scan / OWASP ZAP Scan (push) Has been cancelled
Co-authored-by: Nakamoto, S <nsatoshi2007@hotmail.com> Co-committed-by: Nakamoto, S <nsatoshi2007@hotmail.com>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
/**
|
|
* 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() };
|
|
}
|
|
}
|