mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
2fd114b3ac
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
363 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|