This commit is contained in:
2026-06-25 18:34:45 -05:00
parent da16e5ff56
commit 552b86c323
12 changed files with 439 additions and 125 deletions

View File

@@ -1,5 +1,5 @@
import { Buffer } from 'node:buffer';
import { Agent } from 'undici';
import { Agent, fetch as undiciFetch } from 'undici';
import { config } from '../config.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
@@ -122,6 +122,12 @@ function dispatcherFor(settings) {
: undefined;
}
function fetchForDispatcher(dispatcher) {
// Node's built-in fetch and the npm undici Agent can be different major
// versions. Use package fetch whenever we pass a package dispatcher.
return dispatcher ? undiciFetch : fetch;
}
async function parseVCenterResponse(response) {
const text = await response.text();
if (!text) return null;
@@ -179,10 +185,11 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
let response;
let payload = null;
try {
response = await fetch(`${settings.baseUrl}${path}`, {
const dispatcher = dispatcherFor(settings);
response = await fetchForDispatcher(dispatcher)(`${settings.baseUrl}${path}`, {
method,
headers,
dispatcher: dispatcherFor(settings),
dispatcher,
signal: AbortSignal.timeout(settings.requestTimeoutMs)
});
payload = await parseVCenterResponse(response);
@@ -234,8 +241,27 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
return payload;
}
function soapBaseUrl(settings) {
return normalizeBaseUrl(settings.baseUrl)
.replace(/\/sdk\/vimService\.wsdl$/i, '')
.replace(/\/sdk\/vimService$/i, '')
.replace(/\/sdk$/i, '');
}
function soapEndpoint(settings) {
return `${settings.baseUrl.replace(/\/sdk$/i, '')}/sdk`;
return `${soapBaseUrl(settings)}/sdk`;
}
function soapWsdlEndpoint(settings) {
return `${soapBaseUrl(settings)}/sdk/vimService.wsdl`;
}
function soapEndpointCandidates(settings) {
return [...new Set([
`${soapBaseUrl(settings)}/sdk`,
`${soapBaseUrl(settings)}/sdk/`,
`${soapBaseUrl(settings)}/sdk/vimService`
])];
}
function soapEnvelope(body) {
@@ -266,6 +292,18 @@ function soapBodyPreview(value) {
: text;
}
function networkErrorMessage(err) {
if (err.name === 'TimeoutError') return `Timed out after the configured request timeout.`;
const cause = err.cause || {};
const details = [
err.message,
cause.code ? `code=${cause.code}` : '',
cause.reason ? `reason=${cause.reason}` : '',
cause.message && cause.message !== err.message ? `cause=${cause.message}` : ''
].filter(Boolean);
return details.join('; ');
}
function extractSoapTag(text, tagName) {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
const match = String(text || '').match(pattern);
@@ -301,51 +339,36 @@ function extractSoapFault(text) {
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
}
async function vSphereSoapFetch(settings, action, body, trace = null) {
const url = soapEndpoint(settings);
const requestBody = soapEnvelope(body);
const headers = {
Accept: 'text/xml',
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"urn:vim25/${action}"`
};
async function probeSoapWsdl(settings, trace = null) {
const url = soapWsdlEndpoint(settings);
const startedAt = Date.now();
let response;
let text = '';
try {
response = await fetch(url, {
method: 'POST',
headers,
body: requestBody,
dispatcher: dispatcherFor(settings),
const dispatcher = dispatcherFor(settings);
response = await fetchForDispatcher(dispatcher)(url, {
method: 'GET',
headers: { Accept: 'text/xml, application/xml, */*' },
dispatcher,
signal: AbortSignal.timeout(settings.requestTimeoutMs)
});
text = await response.text();
} catch (err) {
pushTrace(trace, {
method: 'POST',
method: 'GET',
url,
soapAction: action,
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
requestBody: soapBodyPreview(requestBody),
requestHeaders: { Accept: 'text/xml, application/xml, */*' },
durationMs: Date.now() - startedAt,
timeoutMs: settings.requestTimeoutMs,
networkError: err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP endpoint.`
: err.message
networkError: networkErrorMessage(err),
diagnosticOnly: true
});
const message = err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP action ${action}.`
: `Unable to reach VMware host SOAP action ${action}: ${err.message}`;
throw new VCenterTraceError(message, trace);
return;
}
pushTrace(trace, {
method: 'POST',
method: 'GET',
url,
soapAction: action,
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
requestBody: soapBodyPreview(requestBody),
requestHeaders: { Accept: 'text/xml, application/xml, */*' },
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
@@ -353,19 +376,104 @@ async function vSphereSoapFetch(settings, action, body, trace = null) {
'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || ''
},
responseSummary: {
type: 'soap',
bytes: text.length,
fault: Boolean(extractSoapFault(text))
},
responseBody: soapBodyPreview(text)
responseSummary: { type: 'wsdl', bytes: text.length, ok: response.ok },
responseBody: soapBodyPreview(text),
diagnosticOnly: true
});
}
const fault = extractSoapFault(text);
if (!response.ok || fault) {
throw new VCenterTraceError(`VMware host SOAP action ${action} failed${response.ok ? '' : ` with HTTP ${response.status}`}: ${fault || response.statusText}`, trace, response.status || 502);
async function vSphereSoapFetch(settings, action, body, trace = null, options = {}) {
const requestBody = soapEnvelope(body);
const baseHeaders = {
Accept: 'text/xml',
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"urn:vim25/${action}"`
};
const endpoints = options.endpoint ? [options.endpoint] : soapEndpointCandidates(settings);
let lastError = null;
for (const [index, url] of endpoints.entries()) {
const headers = options.cookie ? { ...baseHeaders, Cookie: options.cookie } : baseHeaders;
const startedAt = Date.now();
let response;
let text = '';
try {
const dispatcher = dispatcherFor(settings);
response = await fetchForDispatcher(dispatcher)(url, {
method: 'POST',
headers,
body: requestBody,
dispatcher,
signal: AbortSignal.timeout(settings.requestTimeoutMs)
});
text = await response.text();
} catch (err) {
const message = err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP action ${action}.`
: `Unable to reach VMware host SOAP action ${action}: ${networkErrorMessage(err)}`;
pushTrace(trace, {
method: 'POST',
url,
soapAction: action,
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction, Cookie: headers.Cookie ? '[redacted session cookie]' : undefined },
requestBody: soapBodyPreview(requestBody),
durationMs: Date.now() - startedAt,
timeoutMs: settings.requestTimeoutMs,
networkError: networkErrorMessage(err),
endpointAttempt: `${index + 1} of ${endpoints.length}`
});
lastError = new VCenterTraceError(message, trace);
if (index < endpoints.length - 1) {
pushTraceInfo(trace, `Retrying VMware SOAP action ${action} with alternate ESXi endpoint path.`, {
failedEndpoint: url,
nextEndpoint: endpoints[index + 1]
});
continue;
}
throw lastError;
}
pushTrace(trace, {
method: 'POST',
url,
soapAction: action,
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction, Cookie: headers.Cookie ? '[redacted session cookie]' : undefined },
requestBody: soapBodyPreview(requestBody),
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
responseHeaders: {
'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || '',
'set-cookie': response.headers.get('set-cookie') ? '[redacted session cookie]' : ''
},
responseSummary: {
type: 'soap',
bytes: text.length,
fault: Boolean(extractSoapFault(text))
},
responseBody: soapBodyPreview(text),
endpointAttempt: `${index + 1} of ${endpoints.length}`
});
const fault = extractSoapFault(text);
if ((!response.ok || fault) && [404, 405].includes(response.status) && index < endpoints.length - 1) {
pushTraceInfo(trace, `Retrying VMware SOAP action ${action} after HTTP ${response.status} from ESXi endpoint path.`, {
failedEndpoint: url,
nextEndpoint: endpoints[index + 1],
status: response.status
});
continue;
}
if (!response.ok || fault) {
throw new VCenterTraceError(`VMware host SOAP action ${action} failed${response.ok ? '' : ` with HTTP ${response.status}`}: ${fault || response.statusText}`, trace, response.status || 502);
}
if (options.capture) {
options.capture.cookie = response.headers.get('set-cookie') || options.capture.cookie || '';
options.capture.endpoint = url;
}
return text;
}
return text;
throw lastError || new VCenterTraceError(`VMware host SOAP action ${action} could not be completed.`, trace);
}
function unwrapList(payload) {
@@ -679,8 +787,11 @@ function soapRefElement(name, ref, fallbackType) {
async function connectStandaloneSoap(settings, trace = null) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services',
endpoint: soapEndpoint(settings)
endpoint: soapEndpoint(settings),
endpointCandidates: soapEndpointCandidates(settings),
wsdl: soapWsdlEndpoint(settings)
});
await probeSoapWsdl(settings, trace);
const serviceContent = await vSphereSoapFetch(
settings,
'RetrieveServiceContent',
@@ -693,13 +804,15 @@ async function connectStandaloneSoap(settings, trace = null) {
viewManager: extractManagedObject(serviceContent, 'viewManager', 'ViewManager'),
sessionManager: extractManagedObject(serviceContent, 'sessionManager', 'SessionManager')
};
const loginMeta = {};
await vSphereSoapFetch(
settings,
'Login',
`<Login xmlns="urn:vim25">${soapRefElement('_this', refs.sessionManager, 'SessionManager')}<userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></Login>`,
trace
trace,
{ capture: loginMeta }
);
return refs;
return { ...refs, sessionCookie: loginMeta.cookie || '', endpoint: loginMeta.endpoint || '' };
}
async function createVmContainerView(settings, refs, trace = null) {
@@ -707,7 +820,8 @@ async function createVmContainerView(settings, refs, trace = null) {
settings,
'CreateContainerView',
`<CreateContainerView xmlns="urn:vim25">${soapRefElement('_this', refs.viewManager, 'ViewManager')}${soapRefElement('container', refs.rootFolder, 'Folder')}<type>VirtualMachine</type><recursive>true</recursive></CreateContainerView>`,
trace
trace,
{ cookie: refs.sessionCookie, endpoint: refs.endpoint }
);
return extractManagedObject(response, 'returnval', 'ContainerView');
}
@@ -759,11 +873,11 @@ async function retrieveStandaloneVmInventory(settings, { connection = null, limi
const refs = await connectStandaloneSoap(settings, trace);
const viewRef = await createVmContainerView(settings, refs, trace);
const objects = [];
let response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', retrievePropertiesBody(refs.propertyCollector, viewRef, limit), trace);
let response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', retrievePropertiesBody(refs.propertyCollector, viewRef, limit), trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
objects.push(...parseVmPropertyObjects(response));
let token = decodeXml(extractSoapTag(response, 'token'));
while (token && objects.length < limit) {
response = await vSphereSoapFetch(settings, 'ContinueRetrievePropertiesEx', continuePropertiesBody(refs.propertyCollector, token), trace);
response = await vSphereSoapFetch(settings, 'ContinueRetrievePropertiesEx', continuePropertiesBody(refs.propertyCollector, token), trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
objects.push(...parseVmPropertyObjects(response));
token = decodeXml(extractSoapTag(response, 'token'));
}
@@ -778,7 +892,7 @@ async function retrieveStandaloneVmPowerState(settings, vmId, trace = null) {
`<specSet><propSet><type>VirtualMachine</type><all>false><pathSet>runtime.powerState</pathSet></propSet>` +
`<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` +
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`;
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace);
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
const object = parseVmPropertyObjects(response)[0];
return normalizePowerState(object?.props?.['runtime.powerState'] || '');
}