import type { ClusterEndpoint, NodeConfig } from '../types/nodes'; import type { APITokenRecord } from '../types/api'; import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; import { arrayOrUndefined, finiteNumberOrUndefined, optionalTrimmedString, strictBoolean, trimmedString, } from './responseUtils'; type RawClusterEndpoint = Partial; const normalizeClusterEndpoint = (endpoint: RawClusterEndpoint): ClusterEndpoint => ({ nodeId: trimmedString(endpoint.nodeId), nodeIdentity: trimmedString(endpoint.nodeIdentity), nativeNodeId: finiteNumberOrUndefined(endpoint.nativeNodeId), nodeName: trimmedString(endpoint.nodeName), nativeName: optionalTrimmedString(endpoint.nativeName), nativeAliases: arrayOrUndefined(endpoint.nativeAliases) ?.map((alias) => trimmedString(alias)) .filter(Boolean), displayName: optionalTrimmedString(endpoint.displayName), host: trimmedString(endpoint.host), guestURL: optionalTrimmedString(endpoint.guestURL), ip: trimmedString(endpoint.ip), ipOverride: optionalTrimmedString(endpoint.ipOverride), fingerprint: optionalTrimmedString(endpoint.fingerprint), online: strictBoolean(endpoint.online), lastSeen: trimmedString(endpoint.lastSeen), pulseReachable: endpoint.pulseReachable ?? undefined, lastPulseCheck: optionalTrimmedString(endpoint.lastPulseCheck), pulseError: optionalTrimmedString(endpoint.pulseError), }); const nodeHasClusterEndpoints = ( node: NodeConfig, ): node is NodeConfig & { type: 'pve'; clusterEndpoints?: RawClusterEndpoint[] } => node.type === 'pve'; const normalizeNodeConfig = (node: NodeConfig): NodeConfig => { if (!nodeHasClusterEndpoints(node)) return node; const clusterEndpoints = arrayOrUndefined(node.clusterEndpoints); if (!clusterEndpoints) return node; return { ...node, clusterEndpoints: clusterEndpoints.map((endpoint) => normalizeClusterEndpoint(endpoint as RawClusterEndpoint), ), }; }; type ProxmoxSetupType = 'pve' | 'pbs'; type RawAgentInstallCommandResponse = { command?: unknown; token?: unknown; }; export type AgentInstallCommandResponse = { command: string; token?: string; }; type RawHostAgentInstallTokenResponse = { token?: unknown; record?: APITokenRecord | null; }; export type HostAgentInstallTokenResponse = { token: string; record: APITokenRecord; }; type RawProxmoxSetupCommandResponse = { type?: unknown; host?: unknown; url?: unknown; downloadURL?: unknown; scriptFileName?: unknown; command?: unknown; commandWithEnv?: unknown; commandWithoutEnv?: unknown; setupToken?: unknown; expires?: unknown; tokenHint?: unknown; }; export type ProxmoxSetupCommandResponse = { type: ProxmoxSetupType; host: string; url: string; downloadURL: string; scriptFileName: string; command: string; commandWithEnv: string; commandWithoutEnv?: string; expires: number; tokenHint: string; setupToken: string; }; export type DownloadedProxmoxSetupScript = { content: string; contentType: string; fileName: string; }; const normalizeAgentInstallCommandResponse = ( response: RawAgentInstallCommandResponse, ): AgentInstallCommandResponse => { const command = trimmedString(response.command); if (!command) { throw new Error('Invalid agent install command response'); } const token = optionalTrimmedString(response.token); if (token && command.includes(token)) { throw new Error('Install credentials must be entered separately from the command'); } return { command, ...(token ? { token } : {}) }; }; const normalizeProxmoxSetupCommandResponse = ( response: RawProxmoxSetupCommandResponse, expectedType: ProxmoxSetupType, ): ProxmoxSetupCommandResponse => { const type = trimmedString(response.type) as ProxmoxSetupType; const host = trimmedString(response.host); const url = trimmedString(response.url); const downloadURL = trimmedString(response.downloadURL); const scriptFileName = trimmedString(response.scriptFileName); const command = trimmedString(response.command); const commandWithEnv = trimmedString(response.commandWithEnv) || command; const commandWithoutEnv = optionalTrimmedString(response.commandWithoutEnv); const setupToken = trimmedString(response.setupToken); const expires = finiteNumberOrUndefined(response.expires); const tokenHint = trimmedString(response.tokenHint); const nowUnix = Date.now() / 1000; if (type !== expectedType) { throw new Error('Invalid Proxmox setup response type'); } if (!host) { throw new Error('Invalid Proxmox setup response host'); } if (!url) { throw new Error('Invalid Proxmox setup response URL'); } if (!downloadURL) { throw new Error('Invalid Proxmox setup response downloadURL'); } if (!scriptFileName) { throw new Error('Invalid Proxmox setup response scriptFileName'); } if (!command) { throw new Error('Invalid Proxmox setup response command'); } if (!commandWithEnv) { throw new Error('Invalid Proxmox setup response commandWithEnv'); } if (!setupToken) { throw new Error('Invalid Proxmox setup response setup token'); } if ( [url, downloadURL, command, commandWithEnv, commandWithoutEnv ?? ''].some((value) => value.includes(setupToken), ) || /[?&]setup_token=/.test(downloadURL) ) { throw new Error('Setup credentials must be entered separately from commands and URLs'); } if (!tokenHint) { throw new Error('Invalid Proxmox setup response token hint'); } if (expires === undefined || expires <= nowUnix) { throw new Error('Invalid Proxmox setup response expiry'); } return { type, host, url, downloadURL, scriptFileName, command, commandWithEnv, commandWithoutEnv, expires, tokenHint, setupToken, }; }; export class NodesAPI { private static readonly baseUrl = '/api/config/nodes'; static async getNodes(): Promise { // The API returns an array of nodes directly const nodes: NodeConfig[] = await apiFetchJSON(this.baseUrl); return nodes.map(normalizeNodeConfig); } static async addNode(node: NodeConfig): Promise<{ success: boolean; message?: string }> { return apiFetchJSON(this.baseUrl, { method: 'POST', body: JSON.stringify(node), }); } static async updateNode( nodeId: string, node: NodeConfig, ): Promise<{ success: boolean; message?: string }> { return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(nodeId)}`, { method: 'PUT', body: JSON.stringify(node), }); } static async deleteNode(nodeId: string): Promise<{ success: boolean; message?: string }> { return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(nodeId)}`, { method: 'DELETE', }); } static async testConnection(node: NodeConfig): Promise<{ status: string; message?: string; isCluster?: boolean; nodeCount?: number; clusterNodeCount?: number; datastoreCount?: number; warnings?: string[]; }> { return apiFetchJSON(`${this.baseUrl}/test-connection`, { method: 'POST', body: JSON.stringify(node), }); } static async testExistingNode(nodeId: string): Promise<{ status: string; message?: string; latency?: number; warnings?: string[]; }> { return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(nodeId)}/test`, { method: 'POST', }); } static async refreshClusterNodes(nodeId: string): Promise<{ status: string; clusterName?: string; oldNodeCount?: number; newNodeCount?: number; nodesAdded?: number; clusterNodes?: Array<{ nodeId: string; nodeName: string; host: string; online: boolean; }>; }> { return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(nodeId)}/refresh-cluster`, { method: 'POST', }); } static async getAgentInstallCommand(params: { type: 'pve' | 'pbs'; enableProxmox: boolean; enableCommands?: boolean; insecure?: boolean; }): Promise { const response = await apiFetchJSON( '/api/agent-install-command', { method: 'POST', body: JSON.stringify(params), }, ); return normalizeAgentInstallCommandResponse(response); } /** * Mint an install token for the generic unified host agent flow. The server * decides the scopes from enableCommands at mint time (scopes cannot be * upgraded on an existing token) and stamps the metadata that makes the * token eligible for first-use command-channel binding. */ static async createHostAgentInstallToken(params: { enableCommands: boolean; name?: string; }): Promise { const response = await apiFetchJSON( '/api/agent-install-command', { method: 'POST', body: JSON.stringify({ type: 'host', enableCommands: params.enableCommands, ...(params.name ? { name: params.name } : {}), }), }, ); const token = trimmedString(response.token); if (!token || !response.record) { throw new Error('Invalid host agent install token response'); } return { token, record: response.record }; } static async getProxmoxSetupCommand(params: { type: 'pve' | 'pbs'; host: string; backupPerms: boolean; // Optional connection name typed in the add dialog; rides the setup token // so the auto-registration triggered by the script keeps this name. name?: string; }): Promise { const response = await apiFetchJSON('/api/setup-script-url', { method: 'POST', body: JSON.stringify(params), }); return normalizeProxmoxSetupCommandResponse(response, params.type); } static async downloadProxmoxSetupScript( bootstrap: ProxmoxSetupCommandResponse, ): Promise { const response = await apiFetch(bootstrap.downloadURL); if (!response.ok) { throw new Error('Failed to fetch setup script'); } const contentType = trimmedString(response.headers.get('Content-Type')); if (!contentType.startsWith('text/x-shellscript')) { throw new Error('Invalid Proxmox setup script content type'); } const contentDisposition = trimmedString(response.headers.get('Content-Disposition')); const fileNameMatch = contentDisposition.match(/filename="([^"]+)"/i) ?? contentDisposition.match(/filename=([^;]+)/i); const fileName = trimmedString(fileNameMatch?.[1] ?? ''); if (!fileName || fileName !== bootstrap.scriptFileName) { throw new Error('Invalid Proxmox setup script filename'); } const content = await response.text(); if (!content.trim()) { throw new Error('Empty Proxmox setup script response'); } return { content, contentType, fileName, }; } }