Files
pulse/frontend-modern/src/api/nodes.ts
T
pulse-triage[bot] 2fd114b3ac Keep Proxmox bootstrap credentials out of copied commands
Separate PVE/PBS setup and telemetry credentials from shell source and download URLs. Use silent root/sudo input, private-file handoff, complete downloads and the agent preflight; preserve scope, TLS defaults, single-line paste and coherent rolling-upgrade metadata. Reveal tokens through the existing dialog and discard late issuance after close. Pin executable shell, history, TLS/registration and browser contracts without using real credentials.

Change-source: pulse-maintainer
2026-10-01 05:23:08 +01:00

363 lines
11 KiB
TypeScript

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<ClusterEndpoint>;
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<string>(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<RawClusterEndpoint>(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<NodeConfig[]> {
// 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<AgentInstallCommandResponse> {
const response = await apiFetchJSON<RawAgentInstallCommandResponse>(
'/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<HostAgentInstallTokenResponse> {
const response = await apiFetchJSON<RawHostAgentInstallTokenResponse>(
'/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<ProxmoxSetupCommandResponse> {
const response = await apiFetchJSON<RawProxmoxSetupCommandResponse>('/api/setup-script-url', {
method: 'POST',
body: JSON.stringify(params),
});
return normalizeProxmoxSetupCommandResponse(response, params.type);
}
static async downloadProxmoxSetupScript(
bootstrap: ProxmoxSetupCommandResponse,
): Promise<DownloadedProxmoxSetupScript> {
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,
};
}
}