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

View File

@@ -126,16 +126,23 @@ export function listVCenterConnectionRecords(req, res) {
export function createVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' });
if (!parsed.data.password) return res.status(400).json({ error: 'A VMware password is required when creating a connection' });
res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
try {
res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function updateVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' });
const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id);
if (!connection) return res.status(404).json({ error: 'VMware connection not found' });
res.json(connection);
try {
const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id);
if (!connection) return res.status(404).json({ error: 'VMware connection not found' });
res.json(connection);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function deleteVCenterConnectionRecord(req, res) {

View File

@@ -141,8 +141,10 @@ const apiArticles = [
,
section('VMware connection payloads', [
'Saved VMware connection records use targetType = vcenter for inventory import/power-state checks or targetType = host for a standalone ESXi host connection.',
'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Import preview enumerates VM inventory from the selected ESXi host, applies the same filter fields as vCenter imports, and stores the VM managed-object reference for later power-state checks.'
], '@{\n name = "Production vCenter"\n targetType = "vcenter"\n baseUrl = "https://vcenter.contoso.local"\n username = "administrator@vsphere.local"\n password = "secret"\n apiMode = "auto"\n requestTimeoutMs = 20000\n allowUntrustedTls = $false\n enabled = $true\n visibility = "shared"\n}\n\n@{\n name = "Standalone ESXi 01"\n targetType = "host"\n baseUrl = "https://esxi01.contoso.local"\n username = "root"\n password = "secret"\n apiMode = "web-services"\n requestTimeoutMs = 20000\n allowUntrustedTls = $true\n enabled = $true\n visibility = "shared"\n}')
'VMware connections authenticate with a Credential Vault username/password entry referenced by credentialId. Create or rotate secrets in Credential Vault, then select that credential in Config / VMware; legacy embedded VMware passwords only remain for old records.',
'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint. Saved URLs can be host root, /sdk, or /sdk/vimService.wsdl; POSHManager normalizes them, probes /sdk/vimService.wsdl, and retries SOAP POSTs across /sdk, /sdk/, and /sdk/vimService for ESXi/reverse-proxy compatibility.',
'Import preview enumerates VM inventory from the selected ESXi host, applies the same filter fields as vCenter imports, and stores the VM managed-object reference for later power-state checks. The API trace includes endpoint attempts, redacted SOAP cookies, and low-level network/TLS causes.'
], '@{\n name = "Production vCenter"\n targetType = "vcenter"\n baseUrl = "https://vcenter.contoso.local"\n credentialId = "cred_vmware_api"\n apiMode = "auto"\n requestTimeoutMs = 20000\n allowUntrustedTls = $false\n enabled = $true\n visibility = "shared"\n}\n\n@{\n name = "Standalone ESXi 01"\n targetType = "host"\n baseUrl = "https://esxi01.contoso.local"\n credentialId = "cred_esxi_api"\n apiMode = "web-services"\n requestTimeoutMs = 20000\n allowUntrustedTls = $true\n enabled = $true\n visibility = "shared"\n}')
], ['api', 'credentials', 'hosts', 'runplans', 'jobs']),
article('api-psadt', 'API', 'PSADT And Intune API', 'Use the PSADT catalog, verifier, migration wizard, profiles, and Intune publishing plans through API calls.', [
apiExample('Get', '/api/psadt/catalog', 'Return PSADT source links, module/function/ADMX catalogs, snippets, and Intune publishing guidance.'),

View File

@@ -104,10 +104,11 @@ export function migrate() {
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
username TEXT NOT NULL,
secret_cipher TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
username TEXT,
secret_cipher TEXT,
secret_iv TEXT,
secret_tag TEXT,
credential_id TEXT REFERENCES credentials(id) ON DELETE SET NULL,
target_type TEXT NOT NULL DEFAULT 'vcenter',
api_mode TEXT NOT NULL DEFAULT 'auto',
request_timeout_ms INTEGER NOT NULL DEFAULT 20000,
@@ -528,6 +529,7 @@ export function migrate() {
// VMware connection type separates vCenter inventory/power-state APIs from
// standalone ESXi host checks that use the vSphere Web Services SOAP API.
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
ensureColumn('vcenter_connections', 'credential_id', 'TEXT REFERENCES credentials(id) ON DELETE SET NULL');
ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'");
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
// read-only connections can never be used to change a tenant by accident.

View File

@@ -71,7 +71,8 @@ export const vcenterConnectionSchema = z.object({
name: z.string().min(1).max(120),
targetType: z.enum(['vcenter', 'host']).optional().default('vcenter'),
baseUrl: z.string().url(),
username: z.string().min(1).max(200),
credentialId: z.string().nullable().optional(),
username: z.string().max(200).optional().default(''),
password: z.string().optional().default(''),
apiMode: z.enum(['auto', 'api', 'rest', 'web-services']).optional().default('auto'),
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional().default(20000),

View File

@@ -52,8 +52,11 @@ export function camelVCenterConnection(row) {
id: row.id,
name: row.name,
baseUrl: row.base_url,
username: row.username,
hasPassword: Boolean(row.secret_cipher),
username: row.credential_username || row.username || '',
credentialId: row.credential_id || null,
credentialName: row.credential_name || null,
hasPassword: Boolean(row.credential_id || row.secret_cipher),
hasLegacySecret: Boolean(row.secret_cipher && !row.credential_id),
targetType: row.target_type || 'vcenter',
apiMode: row.api_mode || 'auto',
requestTimeoutMs: row.request_timeout_ms || 20000,

View File

@@ -7,7 +7,8 @@ function normalizeConnection(body, existing = {}) {
const parsed = vcenterConnectionSchema.parse({
...existing,
...body,
password: body.password || ''
password: body.password || '',
credentialId: body.credentialId ?? existing.credentialId ?? existing.credential_id ?? null
});
const targetType = parsed.targetType || 'vcenter';
const apiMode = targetType === 'host'
@@ -18,15 +19,55 @@ function normalizeConnection(body, existing = {}) {
targetType,
apiMode,
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
credentialId: parsed.credentialId || null,
username: parsed.username || '',
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
export function listVCenterConnections(userId) {
return db.prepare(`
SELECT c.*, g.name AS group_name
function connectionSelect() {
return `
SELECT c.*, g.name AS group_name, cred.name AS credential_name, cred.username AS credential_username,
cred.kind AS credential_kind, cred.secret_cipher AS credential_secret_cipher,
cred.secret_iv AS credential_secret_iv, cred.secret_tag AS credential_secret_tag
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
LEFT JOIN credentials cred ON cred.id = c.credential_id
`;
}
function getVisibleCredential(credentialId, userId) {
if (!credentialId) return null;
return db.prepare(`SELECT * FROM credentials WHERE id = ? AND ${visibleClause()}`).get(credentialId, userId, userId);
}
function assertUsableCredential(credentialId, userId) {
const credential = getVisibleCredential(credentialId, userId);
if (!credential) throw new Error('Choose a visible Credential Vault entry for this VMware connection.');
if (credential.kind !== 'username_password') throw new Error('VMware connections require a username/password Credential Vault entry.');
if (!credential.username) throw new Error('The selected Credential Vault entry must include a username.');
return credential;
}
function attachSecret(row, includeSecret) {
if (!includeSecret) return camelVCenterConnection(row);
if (row.credential_id) {
return {
...row,
username: row.credential_username || '',
password: decryptSecret({
secret_cipher: row.credential_secret_cipher,
secret_iv: row.credential_secret_iv,
secret_tag: row.credential_secret_tag
})
};
}
return { ...row, password: decryptSecret(row) };
}
export function listVCenterConnections(userId) {
return db.prepare(`
${connectionSelect()}
WHERE ${visibleClause('c')}
ORDER BY c.name
`).all(userId, userId).map(camelVCenterConnection);
@@ -35,41 +76,42 @@ export function listVCenterConnections(userId) {
export function getVCenterConnection(connectionId, userId, includeSecret = false) {
if (!connectionId) return null;
const row = db.prepare(`
SELECT c.*, g.name AS group_name
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
${connectionSelect()}
WHERE c.id = ? AND ${visibleClause('c')}
`).get(connectionId, userId, userId);
if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
return attachSecret(row, includeSecret);
}
export function getVCenterConnectionSystem(connectionId, includeSecret = false) {
if (!connectionId) return null;
const row = db.prepare('SELECT * FROM vcenter_connections WHERE id = ?').get(connectionId);
const row = db.prepare(`${connectionSelect()} WHERE c.id = ?`).get(connectionId);
if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
return attachSecret(row, includeSecret);
}
export function createVCenterConnection(body, userId) {
const payload = normalizeConnection(body);
const encrypted = encryptSecret(payload.password);
const credential = payload.credentialId ? assertUsableCredential(payload.credentialId, userId) : null;
if (!credential && !payload.password) throw new Error('Choose a Credential Vault entry for this VMware connection.');
const encrypted = credential ? encryptSecret('') : encryptSecret(payload.password);
const connectionId = id('vc');
db.prepare(`
INSERT INTO vcenter_connections (
id, name, base_url, username, secret_cipher, secret_iv, secret_tag,
id, name, base_url, username, secret_cipher, secret_iv, secret_tag, credential_id,
target_type, api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
connectionId,
payload.name,
payload.baseUrl,
payload.username,
credential?.username || payload.username || '',
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.credentialId,
payload.targetType,
payload.apiMode,
payload.requestTimeoutMs,
@@ -92,6 +134,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
name: existing.name,
baseUrl: existing.base_url,
username: existing.username,
credentialId: existing.credential_id,
targetType: existing.target_type || 'vcenter',
apiMode: existing.api_mode,
requestTimeoutMs: existing.request_timeout_ms,
@@ -100,24 +143,27 @@ export function updateVCenterConnection(connectionId, body, userId) {
visibility: existing.visibility,
groupId: existing.group_id
});
const encrypted = payload.password ? encryptSecret(payload.password) : {
const credential = payload.credentialId ? assertUsableCredential(payload.credentialId, userId) : null;
if (!credential && !payload.password && !existing.secret_cipher) throw new Error('Choose a Credential Vault entry for this VMware connection.');
const encrypted = credential ? encryptSecret('') : payload.password ? encryptSecret(payload.password) : {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
};
db.prepare(`
UPDATE vcenter_connections
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?, credential_id = ?,
target_type = ?, api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.baseUrl,
payload.username,
credential?.username || payload.username || '',
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.credentialId,
payload.targetType,
payload.apiMode,
payload.requestTimeoutMs,

View File

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

View File

@@ -1,38 +1,49 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
import http from 'node:http';
import { load, makeUser } from './setup.mjs';
const {
buildVmListPath,
buildHostPayloadFromVm,
buildStandaloneHostCandidate,
createCredential,
createVCenterConnection,
filterSummaryVms,
getVCenterConnection,
getVCenterVmPowerState,
normalizeBaseUrl,
normalizeVCenterVm,
previewVCenterHosts,
settingsFromVCenterConnection,
vmMatchesFilter
} = await load('../services/vcenterService.js');
} = {
...(await load('../services/vcenterService.js')),
...(await load('../models/credentialModel.js')),
...(await load('../models/vcenterConnectionModel.js'))
};
const originalFetch = globalThis.fetch;
function soapResponse(body) {
function soapResponse(body, init = {}) {
return new Response(`<?xml version="1.0" encoding="UTF-8"?><soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"><soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`, {
status: 200,
headers: { 'content-type': 'text/xml' }
headers: { 'content-type': 'text/xml', ...(init.headers || {}) }
});
}
function installStandaloneEsxiSoapStub() {
globalThis.fetch = async (_url, options = {}) => {
function installStandaloneEsxiSoapStub({ sdk404 = false } = {}) {
const calls = [];
globalThis.fetch = async (url, options = {}) => {
calls.push({ url: String(url), method: options.method || 'GET', body: String(options.body || ''), headers: options.headers || {} });
const body = String(options.body || '');
if (options.method === 'GET') return new Response('<definitions>vimService</definitions>', { status: 200, headers: { 'content-type': 'text/xml' } });
if (sdk404 && String(url).endsWith('/sdk')) return new Response('not found', { status: 404, statusText: 'Not Found', headers: { 'content-type': 'text/plain' } });
if (body.includes('<RetrieveServiceContent')) {
return soapResponse(`<RetrieveServiceContentResponse xmlns="urn:vim25"><returnval><rootFolder type="Folder">ha-folder-root</rootFolder><propertyCollector type="PropertyCollector">ha-property-collector</propertyCollector><viewManager type="ViewManager">ViewManager</viewManager><sessionManager type="SessionManager">ha-sessionmgr</sessionManager></returnval></RetrieveServiceContentResponse>`);
}
if (body.includes('<Login')) {
return soapResponse(`<LoginResponse xmlns="urn:vim25"><returnval type="UserSession">session-1</returnval></LoginResponse>`);
return soapResponse(`<LoginResponse xmlns="urn:vim25"><returnval type="UserSession">session-1</returnval></LoginResponse>`, { headers: { 'set-cookie': 'vmware_soap_session=session-1; Path=/sdk; Secure' } });
}
if (body.includes('<CreateContainerView')) {
return soapResponse(`<CreateContainerViewResponse xmlns="urn:vim25"><returnval type="ContainerView">session[52b6]-vm-view</returnval></CreateContainerViewResponse>`);
@@ -42,15 +53,79 @@ function installStandaloneEsxiSoapStub() {
}
return soapResponse('');
};
return () => { globalThis.fetch = originalFetch; };
return { calls, restore: () => { globalThis.fetch = originalFetch; } };
}
function startStandaloneEsxiSoapServer() {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
requests.push({ url: req.url, method: req.method, body, cookie: req.headers.cookie || '' });
res.setHeader('content-type', 'text/xml');
if (req.method === 'GET') {
res.end('<definitions>vimService</definitions>');
} else if (body.includes('<Login')) {
res.setHeader('set-cookie', 'vmware_soap_session=session-http; Path=/sdk');
res.end(`<?xml version="1.0"?><Envelope><Body><LoginResponse><returnval type="UserSession">session-http</returnval></LoginResponse></Body></Envelope>`);
} else if (body.includes('<RetrieveServiceContent')) {
res.end(`<?xml version="1.0"?><Envelope><Body><RetrieveServiceContentResponse><returnval><rootFolder type="Folder">ha-folder-root</rootFolder><propertyCollector type="PropertyCollector">ha-property-collector</propertyCollector><viewManager type="ViewManager">ViewManager</viewManager><sessionManager type="SessionManager">ha-sessionmgr</sessionManager></returnval></RetrieveServiceContentResponse></Body></Envelope>`);
} else if (body.includes('<CreateContainerView')) {
res.end(`<?xml version="1.0"?><Envelope><Body><CreateContainerViewResponse><returnval type="ContainerView">session-http-vm-view</returnval></CreateContainerViewResponse></Body></Envelope>`);
} else if (body.includes('<RetrievePropertiesEx')) {
res.end(`<?xml version="1.0"?><Envelope><Body><RetrievePropertiesExResponse><returnval><objects><obj type="VirtualMachine">vim.VirtualMachine:201</obj><propSet><name>name</name><val>HTTP-ESXI-VM01</val></propSet><propSet><name>guest.hostName</name><val>http-esxi-vm01.lab.local</val></propSet><propSet><name>guest.ipAddress</name><val>10.42.9.21</val></propSet><propSet><name>guest.guestFullName</name><val>Microsoft Windows Server 2022</val></propSet><propSet><name>runtime.powerState</name><val>poweredOn</val></propSet></objects></returnval></RetrievePropertiesExResponse></Body></Envelope>`);
} else {
res.end(`<?xml version="1.0"?><Envelope><Body /></Envelope>`);
}
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
baseUrl: `http://127.0.0.1:${server.address().port}`,
requests,
close: () => new Promise((done) => server.close(done))
});
});
});
}
test('normalizeBaseUrl trims trailing slashes', () => {
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
});
test('VMware connections resolve API credentials from Credential Vault', () => {
const owner = makeUser({ email: 'vmware-vault@test.local' });
const credential = createCredential({
name: 'vCenter API',
kind: 'username_password',
username: 'administrator@vsphere.local',
secret: 'vault-secret',
visibility: 'personal'
}, owner);
const connection = createVCenterConnection({
name: 'Vault Backed vCenter',
targetType: 'vcenter',
baseUrl: 'https://vcenter.lab.local',
credentialId: credential.id,
apiMode: 'auto',
requestTimeoutMs: 20000,
allowUntrustedTls: true,
enabled: true,
visibility: 'personal'
}, owner);
const secretConnection = getVCenterConnection(connection.id, owner, true);
assert.equal(connection.credentialId, credential.id);
assert.equal(connection.credentialName, 'vCenter API');
assert.equal(connection.username, 'administrator@vsphere.local');
assert.equal(secretConnection.username, 'administrator@vsphere.local');
assert.equal(secretConnection.password, 'vault-secret');
});
test('standalone VMware host connections use the Web Services profile', async () => {
const restoreFetch = installStandaloneEsxiSoapStub();
const { calls, restore } = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
@@ -68,9 +143,11 @@ test('standalone VMware host connections use the Web Services profile', async ()
assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } });
restoreFetch();
restore();
assert.equal(preview.count, 1);
assert.ok(calls.some((call) => call.url.endsWith('/sdk/vimService.wsdl')));
assert.ok(calls.some((call) => call.headers.Cookie?.includes('vmware_soap_session')));
assert.equal(preview.diagnostics.standaloneHost, true);
assert.equal(preview.candidates[0].source, 'standalone-vm');
assert.equal(preview.candidates[0].fqdn, 'eng-ent-app01.contoso.local');
@@ -79,7 +156,7 @@ test('standalone VMware host connections use the Web Services profile', async ()
});
test('standalone VMware VM candidates build managed host payloads', async () => {
const restoreFetch = installStandaloneEsxiSoapStub();
const { restore } = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
@@ -98,7 +175,7 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
visibility: 'shared'
});
const power = await getVCenterVmPowerState(connection, preview.candidates[0].id);
restoreFetch();
restore();
assert.equal(payload.name, 'ENG-ENT-APP01');
assert.equal(payload.address, 'eng-ent-app01.contoso.local');
@@ -112,6 +189,50 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
assert.equal(power.state, 'POWERED_ON');
});
test('standalone VMware SOAP retries alternate endpoint paths after /sdk 404', async () => {
const { calls, restore } = installStandaloneEsxiSoapStub({ sdk404: true });
const connection = {
id: 'vc_host_retry',
name: 'ESXi Retry',
target_type: 'host',
base_url: 'https://esxi01.lab.local/sdk/vimService.wsdl',
username: 'root',
password: 'secret',
enabled: 1
};
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'eng-ent' } });
restore();
assert.equal(preview.count, 1);
assert.ok(calls.some((call) => call.url === 'https://esxi01.lab.local/sdk'));
assert.ok(calls.some((call) => call.url === 'https://esxi01.lab.local/sdk/'));
assert.ok(preview.trace.some((entry) => String(entry.message || '').includes('Retrying VMware SOAP action RetrieveServiceContent')));
});
test('standalone VMware custom dispatcher path works when untrusted TLS is enabled', async () => {
const server = await startStandaloneEsxiSoapServer();
try {
const connection = {
id: 'vc_host_dispatcher',
name: 'ESXi Dispatcher',
target_type: 'host',
base_url: server.baseUrl,
username: 'root',
password: 'secret',
enabled: 1,
allow_untrusted_tls: 1
};
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'HTTP-ESXI' } });
assert.equal(preview.count, 1);
assert.equal(preview.candidates[0].name, 'HTTP-ESXI-VM01');
assert.ok(server.requests.some((request) => request.url === '/sdk/vimService.wsdl'));
assert.ok(server.requests.some((request) => request.cookie.includes('vmware_soap_session')));
} finally {
await server.close();
}
});
test('standalone VMware host candidates build managed host payloads', () => {
const candidate = buildStandaloneHostCandidate(
{ id: 'vc_host', name: 'ESXi 01' },