Initial
This commit is contained in:
@@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer';
|
||||
import { Agent } 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',
|
||||
@@ -45,6 +47,7 @@ export function getVCenterSettings() {
|
||||
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),
|
||||
@@ -57,12 +60,16 @@ export function getVCenterSettings() {
|
||||
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: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
|
||||
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)
|
||||
};
|
||||
@@ -77,6 +84,7 @@ export function vcenterStatus() {
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||
targetType: settings.targetType,
|
||||
baseUrl: settings.baseUrl,
|
||||
username: settings.username,
|
||||
apiMode: settings.apiMode,
|
||||
@@ -92,6 +100,7 @@ export function vcenterConnectionStatus(connection) {
|
||||
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,
|
||||
@@ -101,9 +110,10 @@ export function vcenterConnectionStatus(connection) {
|
||||
}
|
||||
|
||||
function assertConfigured(settings) {
|
||||
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
|
||||
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
|
||||
if (!settings.username || !settings.password) throw new Error('VMware vCenter credentials are missing. Set vcenter_username and vcenter_password in Configuration.');
|
||||
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) {
|
||||
@@ -224,6 +234,115 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
|
||||
return payload;
|
||||
}
|
||||
|
||||
function soapEndpoint(settings) {
|
||||
return `${settings.baseUrl.replace(/\/sdk$/i, '')}/sdk`;
|
||||
}
|
||||
|
||||
function soapEnvelope(body) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:vim25="urn:vim25">` +
|
||||
`<soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function redactSoapBody(value) {
|
||||
return String(value || '')
|
||||
.replace(/(<password>)([\s\S]*?)(<\/password>)/gi, '$1[redacted]$3')
|
||||
.replace(/(<userName>)([\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 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 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}"`
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
let response;
|
||||
let text = '';
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: requestBody,
|
||||
dispatcher: dispatcherFor(settings),
|
||||
signal: AbortSignal.timeout(settings.requestTimeoutMs)
|
||||
});
|
||||
text = await response.text();
|
||||
} catch (err) {
|
||||
pushTrace(trace, {
|
||||
method: 'POST',
|
||||
url,
|
||||
soapAction: action,
|
||||
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
|
||||
requestBody: soapBodyPreview(requestBody),
|
||||
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
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
pushTrace(trace, {
|
||||
method: 'POST',
|
||||
url,
|
||||
soapAction: action,
|
||||
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
|
||||
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') || ''
|
||||
},
|
||||
responseSummary: {
|
||||
type: 'soap',
|
||||
bytes: text.length,
|
||||
fault: Boolean(extractSoapFault(text))
|
||||
},
|
||||
responseBody: soapBodyPreview(text)
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function unwrapList(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.value)) return payload.value;
|
||||
@@ -270,7 +389,7 @@ 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 'windows';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function localizedText(value) {
|
||||
@@ -313,7 +432,7 @@ function normalizeVCenterSummaryVm(vm) {
|
||||
ipAddress: '',
|
||||
ipAddresses: [],
|
||||
powerState: vm.power_state || vm.powerState || '',
|
||||
osFamily: 'windows',
|
||||
osFamily: 'other',
|
||||
guestFullName: '',
|
||||
toolsAvailable: false,
|
||||
source: 'vcenter'
|
||||
@@ -380,7 +499,7 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
|
||||
name: candidate.name,
|
||||
fqdn: candidate.fqdn || '',
|
||||
address: candidate.address || candidate.ipAddress || candidate.name,
|
||||
osFamily: candidate.osFamily || 'windows',
|
||||
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
|
||||
transport: options.transport || 'winrm',
|
||||
port: options.port || null,
|
||||
credentialId: options.credentialId || null,
|
||||
@@ -407,6 +526,9 @@ export async function previewVCenterHosts(options = {}) {
|
||||
} catch (err) {
|
||||
throw new VCenterTraceError(err.message, trace, 400);
|
||||
}
|
||||
if (settings.targetType === 'host') {
|
||||
throw new VCenterTraceError('Standalone VMware host connections use the vSphere Web Services API and cannot perform vCenter VM inventory imports. Choose a vCenter connection for VM discovery.', trace, 400);
|
||||
}
|
||||
const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
|
||||
const allVms = unwrapList(vmPayload);
|
||||
const summaryFilter = filterSummaryVms(allVms, options.filter);
|
||||
@@ -452,6 +574,7 @@ 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),
|
||||
@@ -462,9 +585,39 @@ export async function testVCenterConnection(connection) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
|
||||
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
|
||||
mode: 'web-services',
|
||||
endpoint: soapEndpoint(settings)
|
||||
});
|
||||
const serviceContent = await vSphereSoapFetch(
|
||||
settings,
|
||||
'RetrieveServiceContent',
|
||||
`<vim25:RetrieveServiceContent><_this type="ServiceInstance">ServiceInstance</_this></vim25:RetrieveServiceContent>`,
|
||||
trace
|
||||
);
|
||||
const sessionManager = extractSoapTag(serviceContent, 'sessionManager') || 'SessionManager';
|
||||
await vSphereSoapFetch(
|
||||
settings,
|
||||
'Login',
|
||||
`<vim25:Login><_this type="SessionManager">${xmlEscape(sessionManager)}</_this><userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></vim25:Login>`,
|
||||
trace
|
||||
);
|
||||
return {
|
||||
status: vcenterConnectionStatus(connection),
|
||||
apiProfile: WEB_SERVICES_PROFILE_LABEL,
|
||||
count: 0,
|
||||
trace,
|
||||
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}.`
|
||||
};
|
||||
}
|
||||
|
||||
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') {
|
||||
return { checked: false, reason: 'Standalone VMware host connections do not provide vCenter VM power-state inventory lookups.' };
|
||||
}
|
||||
assertConfigured(settings);
|
||||
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
||||
let lastError = null;
|
||||
|
||||
Reference in New Issue
Block a user