import { Buffer } from 'node:buffer';
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';
const WINDOWS_HINTS = ['windows', 'microsoft'];
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
const MAX_TRACE_ENTRIES = 700;
const MAX_TRACE_BODY_CHARS = 6000;
const VM_ENRICHMENT_CONCURRENCY = 8;
const WEB_SERVICES_PROFILE_LABEL = 'vSphere Web Services API';
const API_PROFILES = {
api: {
mode: 'api',
label: 'vSphere Automation API',
sessionPath: '/api/session',
vmListPath: '/api/vcenter/vm',
vmNameParam: 'names',
vmDetailPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}`,
guestIdentityPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
guestNetworkingPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
},
rest: {
mode: 'rest',
label: 'Legacy vCenter REST API',
sessionPath: '/rest/com/vmware/cis/session',
vmListPath: '/rest/vcenter/vm',
vmNameParam: 'filter.names',
vmDetailPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}`,
guestIdentityPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
guestNetworkingPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
}
};
export class VCenterTraceError extends Error {
constructor(message, trace = [], statusCode = 502) {
super(message);
this.name = 'VCenterTraceError';
this.trace = trace;
this.statusCode = statusCode;
}
}
export function getVCenterSettings() {
const apiMode = getStringSetting('vcenter_api_mode', config.vcenter.apiMode || 'auto').toLowerCase();
const requestTimeoutMs = Number(getStringSetting('vcenter_request_timeout_ms', String(config.vcenter.requestTimeoutMs || 20000)));
return {
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
targetType: 'vcenter',
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
username: getStringSetting('vcenter_username', config.vcenter.username),
password: getStringSetting('vcenter_password', config.vcenter.password),
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
requestTimeoutMs: Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 20000,
allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
};
}
export function settingsFromVCenterConnection(connection) {
if (!connection) return getVCenterSettings();
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
const targetType = String(connection.target_type || connection.targetType || 'vcenter').toLowerCase() === 'host' ? 'host' : 'vcenter';
return {
enabled: Boolean(connection.enabled ?? true),
targetType,
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
username: connection.username || '',
password: connection.password || '',
apiMode: targetType === 'host'
? 'web-services'
: (['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto'),
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
};
}
export function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
export function vcenterStatus() {
const settings = getVCenterSettings();
return {
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
targetType: settings.targetType,
baseUrl: settings.baseUrl,
username: settings.username,
apiMode: settings.apiMode,
requestTimeoutMs: settings.requestTimeoutMs,
allowUntrustedTls: settings.allowUntrustedTls
};
}
export function vcenterConnectionStatus(connection) {
const settings = settingsFromVCenterConnection(connection);
return {
id: connection?.id || '',
name: connection?.name || 'Configured default',
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
targetType: settings.targetType,
baseUrl: settings.baseUrl,
username: settings.username,
apiMode: settings.apiMode,
requestTimeoutMs: settings.requestTimeoutMs,
allowUntrustedTls: settings.allowUntrustedTls
};
}
function assertConfigured(settings) {
const label = settings.targetType === 'host' ? 'VMware standalone host' : 'VMware vCenter';
if (!settings.enabled) throw new Error(`${label} integration is disabled in Configuration.`);
if (!settings.baseUrl) throw new Error(`${label} base URL is missing.`);
if (!settings.username || !settings.password) throw new Error(`${label} credentials are missing.`);
}
function dispatcherFor(settings) {
return settings.allowUntrustedTls
? new Agent({ connect: { rejectUnauthorized: false } })
: 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;
try {
return JSON.parse(text);
} catch {
return text.replace(/^"|"$/g, '');
}
}
function summarizePayload(payload) {
if (Array.isArray(payload)) return { type: 'array', count: payload.length };
if (Array.isArray(payload?.value)) return { type: 'value-array', count: payload.value.length };
if (payload && typeof payload === 'object') return { type: 'object', keys: Object.keys(payload).slice(0, 20) };
return { type: typeof payload };
}
function bodyPreview(payload, path) {
if (path === '/api/session' || path === '/rest/com/vmware/cis/session') return '[redacted vCenter session id]';
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
const text = raw || '';
return text.length > MAX_TRACE_BODY_CHARS
? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)`
: text;
}
function pushTrace(trace, entry) {
if (!trace) return;
if (trace.length < MAX_TRACE_ENTRIES) {
trace.push(entry);
return;
}
if (!trace.some((item) => item.truncated)) {
trace.push({
truncated: true,
message: `vCenter trace limited to ${MAX_TRACE_ENTRIES} entries. Narrow the filter to capture fewer guest enrichment calls.`
});
}
}
function pushTraceInfo(trace, message, details = {}) {
pushTrace(trace, {
info: true,
message,
details
});
}
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false, trace = null } = {}) {
const headers = { Accept: 'application/json' };
if (sessionId) headers['vmware-api-session-id'] = sessionId;
else headers.Authorization = `Basic ${Buffer.from(`${settings.username}:${settings.password}`).toString('base64')}`;
const startedAt = Date.now();
let response;
let payload = null;
try {
const dispatcher = dispatcherFor(settings);
response = await fetchForDispatcher(dispatcher)(`${settings.baseUrl}${path}`, {
method,
headers,
dispatcher,
signal: AbortSignal.timeout(settings.requestTimeoutMs)
});
payload = await parseVCenterResponse(response);
} catch (err) {
pushTrace(trace, {
method,
url: `${settings.baseUrl}${path}`,
requestHeaders: {
Accept: headers.Accept,
Authorization: headers.Authorization ? '[redacted basic auth]' : undefined,
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
},
durationMs: Date.now() - startedAt,
timeoutMs: settings.requestTimeoutMs,
networkError: err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for vCenter.`
: err.message
});
const message = err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for vCenter ${method} ${path}.`
: `Unable to reach vCenter ${method} ${path}: ${err.message}`;
throw new VCenterTraceError(message, trace);
}
pushTrace(trace, {
method,
url: `${settings.baseUrl}${path}`,
requestHeaders: {
Accept: headers.Accept,
Authorization: headers.Authorization ? '[redacted basic auth]' : undefined,
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
},
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
responseHeaders: {
'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || ''
},
responseSummary: summarizePayload(payload),
responseBody: bodyPreview(payload, path)
});
if (allow404 && response.status === 404) return null;
if (!response.ok) {
const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText;
throw new VCenterTraceError(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`, trace, response.status);
}
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 `${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) {
return `` +
`` +
`${body}`;
}
function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function redactSoapBody(value) {
return String(value || '')
.replace(/()([\s\S]*?)(<\/password>)/gi, '$1[redacted]$3')
.replace(/()([\s\S]*?)(<\/userName>)/gi, '$1[redacted username]$3');
}
function soapBodyPreview(value) {
const text = redactSoapBody(value);
return text.length > MAX_TRACE_BODY_CHARS
? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)`
: 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);
return match ? match[1].trim() : '';
}
function extractSoapBlocks(text, tagName) {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'gi');
return [...String(text || '').matchAll(pattern)].map((match) => match[1]);
}
function decodeXml(value) {
return String(value ?? '')
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
function extractManagedObject(text, tagName, fallbackType = '') {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}([^>]*)>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
const match = String(text || '').match(pattern);
if (!match) return { type: fallbackType, value: '' };
const typeMatch = String(match[1] || '').match(/\btype=["']([^"']+)["']/i);
return {
type: typeMatch?.[1] || fallbackType,
value: decodeXml(match[2].trim())
};
}
function extractSoapFault(text) {
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
}
async function probeSoapWsdl(settings, trace = null) {
const url = soapWsdlEndpoint(settings);
const startedAt = Date.now();
let response;
let text = '';
try {
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: 'GET',
url,
requestHeaders: { Accept: 'text/xml, application/xml, */*' },
durationMs: Date.now() - startedAt,
timeoutMs: settings.requestTimeoutMs,
networkError: networkErrorMessage(err),
diagnosticOnly: true
});
return;
}
pushTrace(trace, {
method: 'GET',
url,
requestHeaders: { Accept: 'text/xml, application/xml, */*' },
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
responseHeaders: {
'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || ''
},
responseSummary: { type: 'wsdl', bytes: text.length, ok: response.ok },
responseBody: soapBodyPreview(text),
diagnosticOnly: true
});
}
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;
}
throw lastError || new VCenterTraceError(`VMware host SOAP action ${action} could not be completed.`, trace);
}
function unwrapList(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.value)) return payload.value;
return [];
}
async function createSession(settings, profile, trace = null) {
const payload = await vcenterFetch(settings, profile.sessionPath, { method: 'POST', trace });
const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
if (!sessionId) throw new VCenterTraceError('vCenter session authentication succeeded but no session id was returned.', trace);
return sessionId;
}
async function optionalVCenterFetch(settings, path, sessionId, trace = null) {
try {
return await vcenterFetch(settings, path, { sessionId, allow404: true, trace });
} catch (err) {
return { unavailable: true, reason: err.message };
}
}
function firstIpFromInterfaces(interfaces = []) {
for (const nic of interfaces) {
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
const firstUsable = addresses.find((entry) => {
const address = entry?.ip_address || entry?.address || entry;
return address && !String(address).startsWith('fe80:');
});
if (firstUsable) return firstUsable.ip_address || firstUsable.address || firstUsable;
}
return '';
}
function allIpsFromInterfaces(interfaces = []) {
return interfaces.flatMap((nic) => {
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
return addresses
.map((entry) => entry?.ip_address || entry?.address || entry)
.filter((address) => address && !String(address).startsWith('fe80:'));
});
}
function inferOsFamily(identity = {}) {
const haystack = [identity.family, identity.name, localizedText(identity.full_name), identity.guest_OS].filter(Boolean).join(' ').toLowerCase();
if (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
return 'other';
}
function localizedText(value) {
if (!value) return '';
if (typeof value === 'string') return value;
if (typeof value === 'object') return value.default_message || value.id || '';
return String(value);
}
function normalizePowerState(value) {
const text = String(value || '').trim();
const map = {
poweredon: 'POWERED_ON',
poweredoff: 'POWERED_OFF',
suspended: 'SUSPENDED'
};
return map[text.toLowerCase()] || text;
}
export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
const interfaces = unwrapList(interfacesPayload);
const interfaceIp = firstIpFromInterfaces(interfaces);
const identityIp = identity?.ip_address || identity?.ipAddress || '';
const fqdn = identity?.host_name || identity?.hostName || '';
const name = vm.name || fqdn || vm.vm || vm.id;
const address = fqdn || identityIp || interfaceIp || name;
const ipAddresses = [...new Set([identityIp, interfaceIp, ...allIpsFromInterfaces(interfaces)].filter(Boolean))];
return {
id: vm.vm || vm.id,
name,
fqdn,
address,
ipAddress: ipAddresses[0] || '',
ipAddresses,
powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
osFamily: inferOsFamily(identity || {}),
guestFullName: localizedText(identity?.full_name) || identity?.name || '',
toolsAvailable: Boolean(identity || interfaces.length),
source: 'vcenter'
};
}
function normalizeVCenterSummaryVm(vm) {
return {
id: vm.vm || vm.id,
name: vm.name || vm.vm || vm.id || '',
fqdn: '',
address: vm.name || vm.vm || vm.id || '',
ipAddress: '',
ipAddresses: [],
powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
osFamily: 'other',
guestFullName: '',
toolsAvailable: false,
source: 'vcenter'
};
}
function normalizeStandaloneVmObject(object, connection = null) {
const props = object.props || {};
const guestHostName = props['guest.hostName'] || props['summary.guest.hostName'] || '';
const guestIp = props['guest.ipAddress'] || props['summary.guest.ipAddress'] || '';
const guestFullName = props['guest.guestFullName'] || props['summary.config.guestFullName'] || '';
const guestId = props['guest.guestId'] || props['summary.config.guestId'] || '';
const name = props.name || guestHostName || object.id;
return {
id: object.id,
name,
fqdn: guestHostName,
address: guestHostName || guestIp || name,
ipAddress: guestIp,
ipAddresses: guestIp ? [guestIp] : [],
powerState: normalizePowerState(props['runtime.powerState'] || props['summary.runtime.powerState'] || ''),
osFamily: inferOsFamily({ full_name: guestFullName, guest_OS: guestId, host_name: guestHostName }),
guestFullName,
toolsAvailable: Boolean(guestHostName || guestIp || guestFullName || guestId),
source: 'standalone-vm',
sourceConnectionId: connection?.id || null,
sourceConnectionName: connection?.name || '',
targetType: 'host'
};
}
export function vmMatchesFilter(candidate, filter = {}) {
const operator = filter.operator || 'contains';
const needle = String(filter.value || '').trim().toLowerCase();
if (!needle) return true;
const field = filter.field || 'hostname';
if (field === 'ip') {
const values = [candidate.ipAddress, ...(candidate.ipAddresses || [])]
.filter(Boolean)
.map((value) => String(value).toLowerCase());
if (operator === 'equals') return values.some((value) => value === needle);
if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
return values.some((value) => value.includes(needle));
}
const haystacks = {
name: [candidate.name],
hostname: [candidate.name, candidate.fqdn, candidate.address].filter(Boolean),
fqdn: [candidate.fqdn, candidate.address].filter(Boolean),
}[field] || [candidate.name];
const values = haystacks.map((value) => String(value || '').toLowerCase());
if (operator === 'equals') return values.some((value) => value === needle);
if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
return values.some((value) => value.includes(needle));
}
export function filterSummaryVms(vms = [], filter = {}) {
const field = filter?.field || 'hostname';
if (!['name', 'hostname'].includes(field)) return { vms, summaryMatched: vms.length, summaryFilterApplied: false, summaryFilterFallback: false };
const summaryFilter = { ...filter, field: 'name' };
const matched = vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), summaryFilter));
if (field === 'hostname') {
const matchedIds = new Set(matched.map((vm) => vm.vm || vm.id));
return {
vms: [...matched, ...vms.filter((vm) => !matchedIds.has(vm.vm || vm.id))],
summaryMatched: matched.length,
summaryFilterApplied: true,
summaryFilterFallback: matched.length === 0
};
}
return { vms: matched, summaryMatched: matched.length, summaryFilterApplied: true, summaryFilterFallback: false };
}
function canUseServerSideNameFilter(filter = {}) {
return filter.field === 'name' && filter.operator === 'equals' && String(filter.value || '').trim();
}
export function buildVmListPath(profile, filter = {}) {
if (!canUseServerSideNameFilter(filter)) return profile.vmListPath;
const params = new URLSearchParams();
params.append(profile.vmNameParam, String(filter.value).trim());
return `${profile.vmListPath}?${params.toString()}`;
}
export function buildHostPayloadFromVm(candidate, options = {}) {
if (candidate.source === 'standalone-host') return buildHostPayloadFromStandaloneHost(candidate, options);
if (candidate.source === 'standalone-vm') return buildHostPayloadFromStandaloneVm(candidate, options);
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from VMware vCenter VM ${candidate.id}.`,
candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: options.connectionId || null,
sourceRef: candidate.id || ''
};
}
export function buildHostPayloadFromStandaloneVm(candidate, options = {}) {
const tags = [...new Set([
'vmware',
'esxi',
'standalone-esxi',
candidate.id ? `esxi-vm:${candidate.id}` : '',
...(options.tags || [])
].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from standalone VMware ESXi VM ${candidate.id}.`,
candidate.sourceConnectionName || candidate.sourceConnectionId
? `Source ESXi connection: ${candidate.sourceConnectionName || candidate.sourceConnectionId}.`
: '',
candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: candidate.sourceConnectionId || options.connectionId || null,
sourceRef: candidate.id || ''
};
}
export function buildStandaloneHostCandidate(connection, settings = settingsFromVCenterConnection(connection)) {
const hostname = hostnameFromBaseUrl(settings.baseUrl);
const name = connection?.name || hostname || settings.baseUrl;
return {
id: `standalone-host:${connection?.id || hostname || settings.baseUrl}`,
name,
fqdn: hostname,
address: hostname || settings.baseUrl,
ipAddress: '',
ipAddresses: [],
powerState: 'Standalone host',
osFamily: 'other',
guestFullName: 'Standalone VMware ESXi host',
toolsAvailable: false,
source: 'standalone-host',
sourceConnectionId: connection?.id || null,
sourceConnectionName: connection?.name || '',
targetType: 'host'
};
}
export function buildHostPayloadFromStandaloneHost(candidate, options = {}) {
const tags = [...new Set([
'vmware',
'esxi',
'standalone-esxi',
candidate.sourceConnectionId ? `vmware-host:${candidate.sourceConnectionId}` : '',
...(options.tags || [])
].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.fqdn || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'ssh',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from standalone VMware ESXi connection ${candidate.sourceConnectionName || candidate.sourceConnectionId || candidate.name}.`,
'This host was added from a direct VMware host connection, not vCenter VM inventory.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: candidate.sourceConnectionId || options.connectionId || null,
sourceRef: 'standalone-esxi-host'
};
}
function hostnameFromBaseUrl(value) {
try {
return new URL(value).hostname;
} catch {
return String(value || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
}
}
function soapRefElement(name, ref, fallbackType) {
return `<${name} type="${xmlEscape(ref?.type || fallbackType)}">${xmlEscape(ref?.value || '')}${name}>`;
}
async function connectStandaloneSoap(settings, trace = null) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services',
endpoint: soapEndpoint(settings),
endpointCandidates: soapEndpointCandidates(settings),
wsdl: soapWsdlEndpoint(settings)
});
await probeSoapWsdl(settings, trace);
const serviceContent = await vSphereSoapFetch(
settings,
'RetrieveServiceContent',
`<_this type="ServiceInstance">ServiceInstance`,
trace
);
const refs = {
rootFolder: extractManagedObject(serviceContent, 'rootFolder', 'Folder'),
propertyCollector: extractManagedObject(serviceContent, 'propertyCollector', 'PropertyCollector'),
viewManager: extractManagedObject(serviceContent, 'viewManager', 'ViewManager'),
sessionManager: extractManagedObject(serviceContent, 'sessionManager', 'SessionManager')
};
const loginMeta = {};
await vSphereSoapFetch(
settings,
'Login',
`${soapRefElement('_this', refs.sessionManager, 'SessionManager')}${xmlEscape(settings.username)}${xmlEscape(settings.password)}`,
trace,
{ capture: loginMeta }
);
return { ...refs, sessionCookie: loginMeta.cookie || '', endpoint: loginMeta.endpoint || '' };
}
async function createVmContainerView(settings, refs, trace = null) {
const response = await vSphereSoapFetch(
settings,
'CreateContainerView',
`${soapRefElement('_this', refs.viewManager, 'ViewManager')}${soapRefElement('container', refs.rootFolder, 'Folder')}VirtualMachinetrue`,
trace,
{ cookie: refs.sessionCookie, endpoint: refs.endpoint }
);
return extractManagedObject(response, 'returnval', 'ContainerView');
}
function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
const paths = [
'name',
'guest.hostName',
'guest.ipAddress',
'guest.guestFullName',
'guest.guestId',
'runtime.powerState',
'summary.guest.hostName',
'summary.guest.ipAddress',
'summary.config.guestFullName',
'summary.config.guestId',
'summary.runtime.powerState'
];
return `` +
`${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` +
`` +
`VirtualMachinefalse${paths.map((pathSet) => `${pathSet}`).join('')}` +
`${soapRefElement('obj', viewRef, 'ContainerView')}true` +
`containerViewTraversalContainerViewviewfalse` +
`` +
`` +
`${Math.max(1, Math.min(Number(limit) || 100, 500))}` +
``;
}
function continuePropertiesBody(propertyCollector, token) {
return `${soapRefElement('_this', propertyCollector, 'PropertyCollector')}${xmlEscape(token)}`;
}
function parseVmPropertyObjects(xml) {
return extractSoapBlocks(xml, 'objects').map((block) => {
const obj = extractManagedObject(block, 'obj', 'VirtualMachine');
const props = {};
for (const propBlock of extractSoapBlocks(block, 'propSet')) {
const name = decodeXml(extractSoapTag(propBlock, 'name'));
const rawValue = extractSoapTag(propBlock, 'val');
if (name) props[name] = decodeXml(rawValue.replace(/<[^>]+>/g, '').trim());
}
return { id: obj.value, type: obj.type, props };
}).filter((object) => object.id);
}
async function retrieveStandaloneVmInventory(settings, { connection = null, limit = 100, trace = null } = {}) {
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, { 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, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
objects.push(...parseVmPropertyObjects(response));
token = decodeXml(extractSoapTag(response, 'token'));
}
return objects.slice(0, limit).map((object) => normalizeStandaloneVmObject(object, connection));
}
async function retrieveStandaloneVmPowerState(settings, vmId, trace = null) {
const refs = await connectStandaloneSoap(settings, trace);
const vmRef = { type: 'VirtualMachine', value: vmId };
const body = `` +
`${soapRefElement('_this', refs.propertyCollector, 'PropertyCollector')}` +
`VirtualMachinefalse>runtime.powerState` +
`${soapRefElement('obj', vmRef, 'VirtualMachine')}false` +
`1`;
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'] || '');
}
export async function previewVCenterHosts(options = {}) {
const trace = [];
const settings = settingsFromVCenterConnection(options.connection);
try {
assertConfigured(settings);
} catch (err) {
throw new VCenterTraceError(err.message, trace, 400);
}
if (settings.targetType === 'host') {
const allVms = await retrieveStandaloneVmInventory(settings, {
connection: options.connection,
limit: options.limit || 100,
trace
});
const filtered = allVms.filter((candidate) => vmMatchesFilter(candidate, options.filter)).slice(0, options.limit || 100);
pushTraceInfo(trace, 'Enumerated standalone ESXi VM inventory through the vSphere Web Services API.', {
connectionId: options.connection?.id || null,
baseUrl: settings.baseUrl,
totalFromHost: allVms.length,
matched: filtered.length
});
return {
status: vcenterConnectionStatus(options.connection),
filter: options.filter || {},
count: filtered.length,
diagnostics: {
targetType: 'host',
apiMode: 'web-services',
apiProfile: WEB_SERVICES_PROFILE_LABEL,
totalFromVCenter: allVms.length,
summaryMatched: filtered.length,
scanned: allVms.length,
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.id).filter(Boolean),
standaloneHost: true,
filter: options.filter || {}
},
trace,
candidates: filtered
};
}
const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
const allVms = unwrapList(vmPayload);
const summaryFilter = filterSummaryVms(allVms, options.filter);
const vms = summaryFilter.vms;
const candidates = [];
const diagnostics = {
totalFromVCenter: allVms.length,
scanned: 0,
summaryMatched: summaryFilter.summaryMatched,
summaryFilterApplied: summaryFilter.summaryFilterApplied,
summaryFilterFallback: summaryFilter.summaryFilterFallback,
apiMode: profile.mode,
apiProfile: profile.label,
serverSideNameFilter: canUseServerSideNameFilter(options.filter),
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
filter: options.filter || {}
};
await enrichVmsForPreview({
vms,
settings,
profile,
sessionId,
trace,
filter: options.filter,
limit: options.limit || 100,
diagnostics,
candidates
});
return {
status: options.connection ? vcenterConnectionStatus(options.connection) : vcenterStatus(),
filter: options.filter || {},
count: candidates.length,
diagnostics,
trace,
candidates
};
}
export async function testVCenterConnection(connection) {
const trace = [];
const settings = settingsFromVCenterConnection(connection);
assertConfigured(settings);
if (settings.targetType === 'host') return testStandaloneHostConnection(connection, settings, trace);
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
return {
status: vcenterConnectionStatus(connection),
apiProfile: profile.label,
count: unwrapList(vmPayload).length,
trace,
message: `Connected to ${settings.baseUrl} with ${profile.label}; ${unwrapList(vmPayload).length} VM summary row(s) returned.`
};
}
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
const vms = await retrieveStandaloneVmInventory(settings, {
connection,
limit: 1,
trace
});
return {
status: vcenterConnectionStatus(connection),
apiProfile: WEB_SERVICES_PROFILE_LABEL,
count: vms.length,
trace,
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}; VM inventory query returned ${vms.length} row(s).`
};
}
export async function getVCenterVmPowerState(connection, vmId, trace = null) {
if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' };
const settings = settingsFromVCenterConnection(connection);
if (settings.targetType === 'host') {
assertConfigured(settings);
const state = await retrieveStandaloneVmPowerState(settings, vmId, trace);
return {
checked: Boolean(state),
state,
apiProfile: WEB_SERVICES_PROFILE_LABEL,
reason: state ? '' : 'VM power state was not returned by the ESXi Web Services API.'
};
}
assertConfigured(settings);
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
let lastError = null;
for (const mode of modes) {
const profile = API_PROFILES[mode];
try {
const sessionId = await createSession(settings, profile, trace);
const payload = await vcenterFetch(settings, profile.vmDetailPath(vmId), { sessionId, trace });
const value = payload?.value || payload || {};
return {
checked: true,
state: value.power_state || value.powerState || '',
apiProfile: profile.label
};
} catch (err) {
lastError = err;
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
pushTraceInfo(trace, `Falling back after ${profile.label} power-state lookup failed`, {
mode,
error: err.message,
statusCode: err.statusCode
});
}
}
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted for power-state lookup.', trace);
}
async function enrichVmsForPreview({ vms, settings, profile, sessionId, trace, filter, limit, diagnostics, candidates }) {
let index = 0;
async function worker() {
while (index < vms.length && candidates.length < limit) {
const vm = vms[index++];
const vmId = vm.vm || vm.id;
if (!vmId) continue;
diagnostics.scanned += 1;
const identity = await optionalVCenterFetch(settings, profile.guestIdentityPath(vmId), sessionId, trace);
const candidateWithoutNetworking = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, null);
if (!vmMatchesFilter(candidateWithoutNetworking, filter)) continue;
const interfaces = await optionalVCenterFetch(settings, profile.guestNetworkingPath(vmId), sessionId, trace);
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
if (candidates.length < limit) candidates.push(candidate);
}
}
await Promise.all(
Array.from({ length: Math.min(VM_ENRICHMENT_CONCURRENCY, Math.max(vms.length, 1)) }, () => worker())
);
}
function shouldTryFallback(err) {
return err instanceof VCenterTraceError && [404, 405, 501].includes(err.statusCode);
}
async function connectAndListWithProfile(settings, filter, profile, trace) {
pushTraceInfo(trace, `Using ${profile.label}`, {
mode: profile.mode,
sessionPath: profile.sessionPath,
vmListPath: buildVmListPath(profile, filter)
});
const sessionId = await createSession(settings, profile, trace);
const vmPayload = await vcenterFetch(settings, buildVmListPath(profile, filter), { sessionId, trace });
return { profile, sessionId, vmPayload };
}
async function connectAndListVms(settings, filter, trace) {
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
let lastError = null;
for (const mode of modes) {
try {
return await connectAndListWithProfile(settings, filter, API_PROFILES[mode], trace);
} catch (err) {
lastError = err;
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
pushTraceInfo(trace, `Falling back after ${API_PROFILES[mode].label} failed`, {
mode,
error: err.message,
statusCode: err.statusCode
});
}
}
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted.', trace);
}