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 || '')}`; } function soapBoolElement(name, value) { return `<${name}>${value ? 'true' : 'false'}`; } function propertySpecXml(type, paths = []) { return `` + `${xmlEscape(type)}` + soapBoolElement('all', false) + paths.map((pathSet) => `${xmlEscape(pathSet)}`).join('') + ``; } function objectSpecXml(ref, { skip = false, selectSet = '' } = {}) { return `` + soapRefElement('obj', ref, ref?.type || 'ManagedObjectReference') + soapBoolElement('skip', skip) + selectSet + ``; } function traversalSpecXml() { return `` + `containerViewTraversal` + `ContainerView` + `view` + soapBoolElement('skip', false) + ``; } 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'); } export 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') + `` + propertySpecXml('VirtualMachine', paths) + objectSpecXml(viewRef, { skip: true, selectSet: traversalSpecXml() }) + `` + `${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') + `` + propertySpecXml('VirtualMachine', ['runtime.powerState']) + objectSpecXml(vmRef, { skip: 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); }