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

@@ -509,8 +509,8 @@ Payload:
| `POST` | `/api/hosts` | User | Create host. | | `POST` | `/api/hosts` | User | Create host. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. | | `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. | | `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted VMware connection record. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. | | `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible VMware connection; omit password to keep the existing secret. | | `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible VMware connection and its selected vault credential. |
| `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible VMware connection. | | `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible VMware connection. |
| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Verify a VMware connection. vCenter records authenticate and list VM summaries; standalone ESXi records authenticate to `/sdk` and query VM inventory through the vSphere Web Services API. | | `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Verify a VMware connection. vCenter records authenticate and list VM summaries; standalone ESXi records authenticate to `/sdk` and query VM inventory through the vSphere Web Services API. |
| `POST` | `/api/hosts/import/vcenter/preview` | User | Authenticate to vCenter, search VM inventory, and return import candidates. | | `POST` | `/api/hosts/import/vcenter/preview` | User | Authenticate to vCenter, search VM inventory, and return import candidates. |
@@ -612,8 +612,7 @@ Create/update a saved vCenter inventory system:
"name": "Production vCenter", "name": "Production vCenter",
"targetType": "vcenter", "targetType": "vcenter",
"baseUrl": "https://vcenter.contoso.local", "baseUrl": "https://vcenter.contoso.local",
"username": "administrator@vsphere.local", "credentialId": "cred_vmware_api",
"password": "stored-encrypted-on-save",
"apiMode": "auto", "apiMode": "auto",
"requestTimeoutMs": 20000, "requestTimeoutMs": 20000,
"allowUntrustedTls": false, "allowUntrustedTls": false,
@@ -630,8 +629,7 @@ Create/update a saved standalone ESXi host connection:
"name": "Standalone ESXi 01", "name": "Standalone ESXi 01",
"targetType": "host", "targetType": "host",
"baseUrl": "https://esxi01.contoso.local", "baseUrl": "https://esxi01.contoso.local",
"username": "root", "credentialId": "cred_esxi_api",
"password": "stored-encrypted-on-save",
"apiMode": "web-services", "apiMode": "web-services",
"requestTimeoutMs": 20000, "requestTimeoutMs": 20000,
"allowUntrustedTls": true, "allowUntrustedTls": true,
@@ -641,13 +639,21 @@ Create/update a saved standalone ESXi host connection:
} }
``` ```
Saved VMware passwords are encrypted with the same credential-store key used VMware connection records reference a Credential Vault username/password entry
by the Credential Vault. `targetType: "vcenter"` records use the vSphere through `credentialId`; operators create and rotate the actual VMware API
username/password in Credential Vault, then select it in Config / VMware.
Existing legacy connection records with an embedded encrypted VMware password
continue to resolve for compatibility, but new records should use the vault.
`targetType: "vcenter"` records use the vSphere
Automation API or legacy REST API for inventory and VM power-state checks. Automation API or legacy REST API for inventory and VM power-state checks.
`targetType: "host"` records are standalone host connections that use the `targetType: "host"` records are standalone host connections that use the
vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi
connectivity and enumerate VM inventory directly from the selected ESXi host in connectivity and enumerate VM inventory directly from the selected ESXi host in
the VMware import wizard. Existing the VMware import wizard. The saved URL can be the host root
(`https://esxi01.contoso.local`), `/sdk`, or `/sdk/vimService.wsdl`; POSHManager
normalizes those forms, probes `/sdk/vimService.wsdl` for diagnostics, and
retries SOAP POSTs across `/sdk`, `/sdk/`, and `/sdk/vimService` when ESXi or a
reverse proxy maps one path differently. Existing
`vcenter_*` settings and `VCENTER_*` environment variables remain supported as `vcenter_*` settings and `VCENTER_*` environment variables remain supported as
the `Configured default` import connection for single-vCenter deployments or the `Configured default` import connection for single-vCenter deployments or
Docker-provided configuration. Docker-provided configuration.
@@ -658,10 +664,12 @@ When the import wizard selects a standalone ESXi connection (`targetType:
"host"`), preview uses the vSphere Web Services API sequence "host"`), preview uses the vSphere Web Services API sequence
`RetrieveServiceContent`, `Login`, `CreateContainerView`, and `RetrieveServiceContent`, `Login`, `CreateContainerView`, and
`RetrievePropertiesEx` against `/sdk` to enumerate `VirtualMachine` managed `RetrievePropertiesEx` against `/sdk` to enumerate `VirtualMachine` managed
objects. Filter fields work the same as vCenter imports. Imported hosts store objects. The API trace includes the WSDL probe, endpoint attempt count, redacted
the selected ESXi connection plus the VM managed-object reference in SOAP session cookies, and low-level network/TLS causes when Node reports a
`sourceRef`, so script tests and RunPlans can run a direct ESXi power-state generic `fetch failed`. Filter fields work the same as vCenter imports.
check before spawning PowerShell. Imported hosts store the selected ESXi connection plus the VM managed-object
reference in `sourceRef`, so script tests and RunPlans can run a direct ESXi
power-state check before spawning PowerShell.
For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. The combined `hostname` field searches both the VM inventory name and the VMware Tools guest `host_name`/FQDN after enrichment. VM display-name matches are checked first, then the rest of the inventory is still enriched for guest-only hostname matches. Networking/IP enrichment is only requested for candidates that match the filter. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes. For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. The combined `hostname` field searches both the VM inventory name and the VMware Tools guest `host_name`/FQDN after enrichment. VM display-name matches are checked first, then the rest of the inventory is still enriched for guest-only hostname matches. Networking/IP enrichment is only requested for candidates that match the filter. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes.

View File

@@ -621,6 +621,7 @@
<VCenterConnectionsConfig <VCenterConnectionsConfig
:connections="vcenterConnections" :connections="vcenterConnections"
:groups="groups" :groups="groups"
:credentials="credentials"
@save="saveVCenterConnection" @save="saveVCenterConnection"
@delete="deleteVCenterConnection" @delete="deleteVCenterConnection"
@test="testVCenterConnection" @test="testVCenterConnection"
@@ -1164,25 +1165,25 @@ const settingMetadata = {
section: 'Execution' section: 'Execution'
}, },
vcenter_enabled: { vcenter_enabled: {
label: 'VMware vCenter', label: 'Legacy vCenter Default',
description: 'Enable VM discovery and import through the VMware vCenter REST API.', description: 'Enable the legacy single-vCenter fallback. Saved VMware connections should use Config / VMware with Credential Vault authentication.',
section: 'Integrations' section: 'Integrations'
}, },
vcenter_base_url: { vcenter_base_url: {
label: 'vCenter Base URL', label: 'Legacy vCenter Base URL',
description: 'Root URL of the vCenter API endpoint used for VM inventory discovery.', description: 'Fallback vCenter root URL used only when no saved VMware connection is selected.',
placeholder: 'https://vcenter.contoso.local', placeholder: 'https://vcenter.contoso.local',
section: 'Integrations' section: 'Integrations'
}, },
vcenter_username: { vcenter_username: {
label: 'vCenter Username', label: 'Legacy vCenter Username',
description: 'Account used to create vCenter API sessions for host import previews.', description: 'Fallback username for the legacy configured default. Prefer Credential Vault-backed saved VMware connections.',
placeholder: 'administrator@vsphere.local', placeholder: 'administrator@vsphere.local',
section: 'Integrations' section: 'Integrations'
}, },
vcenter_password: { vcenter_password: {
label: 'vCenter Password', label: 'Legacy vCenter Password',
description: 'Password used only by the backend service when authenticating to vCenter.', description: 'Fallback password for the legacy configured default. Prefer Credential Vault-backed saved VMware connections.',
placeholder: 'Stored in app settings or pinned by VCENTER_PASSWORD', placeholder: 'Stored in app settings or pinned by VCENTER_PASSWORD',
section: 'Integrations', section: 'Integrations',
secret: true secret: true

View File

@@ -243,7 +243,7 @@ const activeStatus = computed(() => {
return { return {
name: connection?.name || 'Selected VMware connection', name: connection?.name || 'Selected VMware connection',
typeLabel: connection?.targetType === 'host' ? 'ESXi host' : 'vCenter', typeLabel: connection?.targetType === 'host' ? 'ESXi host' : 'vCenter',
configured: Boolean(connection?.baseUrl && connection?.username), configured: Boolean(connection?.baseUrl && (connection?.credentialId || connection?.username)),
baseUrl: connection?.baseUrl || '' baseUrl: connection?.baseUrl || ''
}; };
} }

View File

@@ -14,7 +14,7 @@
<div class="intune-config-grid"> <div class="intune-config-grid">
<section class="publisher-card"> <section class="publisher-card">
<strong>Inventory sources</strong> <strong>Inventory sources</strong>
<p>vCenter entries import VM inventory and power-state checks. Standalone host entries validate direct ESXi connections through the vSphere Web Services API.</p> <p>vCenter and standalone ESXi entries use Credential Vault entries for VMware API authentication, inventory import, and power-state checks.</p>
<div class="config-stat-row"> <div class="config-stat-row">
<span><b>{{ connections.length }}</b><small>systems</small></span> <span><b>{{ connections.length }}</b><small>systems</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span> <span><b>{{ enabledCount }}</b><small>enabled</small></span>
@@ -28,7 +28,7 @@
<strong>VMware systems</strong> <strong>VMware systems</strong>
<div v-for="connection in connections" :key="connection.id" class="check-row"> <div v-for="connection in connections" :key="connection.id" class="check-row">
<b>{{ connection.name }}</b> <b>{{ connection.name }}</b>
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.apiMode }} - {{ connection.lastTestStatus || 'untested' }}</small> <small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.credentialName || connection.username || 'No vault credential' }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span> <span>
<button class="ghost-button compact" type="button" @click="openModal(connection)"> <button class="ghost-button compact" type="button" @click="openModal(connection)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit <i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
@@ -49,7 +49,7 @@
:open="modalOpen" :open="modalOpen"
:title="form.id ? 'Update VMware connection' : 'New VMware connection'" :title="form.id ? 'Update VMware connection' : 'New VMware connection'"
eyebrow="CONFIG / VMWARE" eyebrow="CONFIG / VMWARE"
description="Store VMware credentials encrypted at rest. vCenter systems can import VM inventory; standalone hosts use the vSphere Web Services API for direct connection validation." description="Select a Credential Vault username/password entry for VMware API authentication. vCenter systems and standalone ESXi hosts both use the selected vault credential."
wide wide
@close="modalOpen = false" @close="modalOpen = false"
> >
@@ -63,8 +63,16 @@
</label> </label>
<label><span>Name</span><input v-model="form.name" required :placeholder="form.targetType === 'host' ? 'Rack ESXi host 01' : 'Production vCenter'" /></label> <label><span>Name</span><input v-model="form.name" required :placeholder="form.targetType === 'host' ? 'Rack ESXi host 01' : 'Production vCenter'" /></label>
<label><span>Base URL</span><input v-model="form.baseUrl" required :placeholder="baseUrlPlaceholder" /></label> <label><span>Base URL</span><input v-model="form.baseUrl" required :placeholder="baseUrlPlaceholder" /></label>
<label><span>Username</span><input v-model="form.username" required :placeholder="form.targetType === 'host' ? 'root or delegated ESXi account' : 'administrator@vsphere.local'" /></label> <label class="full">
<label><span>{{ form.id ? 'Replace password' : 'Password' }}</span><input v-model="form.password" :required="!form.id" type="password" placeholder="Stored encrypted after save" /></label> <span>Credential Vault entry</span>
<select v-model="form.credentialId" required>
<option value="" disabled>Choose VMware API credential</option>
<option v-for="credential in vmwareCredentials" :key="credential.id" :value="credential.id">
{{ credential.name }}{{ credential.username ? ` - ${credential.username}` : '' }}
</option>
</select>
<small>Use a username/password credential. Create or update secrets in Credential Vault.</small>
</label>
<label> <label>
<span>API mode</span> <span>API mode</span>
<select v-model="form.apiMode" :disabled="form.targetType === 'host'"> <select v-model="form.apiMode" :disabled="form.targetType === 'host'">
@@ -98,7 +106,8 @@ import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({ const props = defineProps({
connections: { type: Array, default: () => [] }, connections: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] } groups: { type: Array, default: () => [] },
credentials: { type: Array, default: () => [] }
}); });
const emit = defineEmits(['save', 'delete', 'test']); const emit = defineEmits(['save', 'delete', 'test']);
@@ -109,6 +118,7 @@ const enabledCount = computed(() => props.connections.filter((connection) => con
const vcenterCount = computed(() => props.connections.filter((connection) => connection.targetType !== 'host').length); const vcenterCount = computed(() => props.connections.filter((connection) => connection.targetType !== 'host').length);
const standaloneCount = computed(() => props.connections.filter((connection) => connection.targetType === 'host').length); const standaloneCount = computed(() => props.connections.filter((connection) => connection.targetType === 'host').length);
const testedCount = computed(() => props.connections.filter((connection) => connection.lastTestStatus).length); const testedCount = computed(() => props.connections.filter((connection) => connection.lastTestStatus).length);
const vmwareCredentials = computed(() => props.credentials.filter((credential) => credential.kind === 'username_password'));
const baseUrlPlaceholder = computed(() => form.targetType === 'host' ? 'https://esxi01.contoso.local' : 'https://vcenter.contoso.local'); const baseUrlPlaceholder = computed(() => form.targetType === 'host' ? 'https://esxi01.contoso.local' : 'https://vcenter.contoso.local');
const enabledHelpText = computed(() => form.targetType === 'host' const enabledHelpText = computed(() => form.targetType === 'host'
? 'Allow this standalone host connection to be tested and used for direct VMware host workflows.' ? 'Allow this standalone host connection to be tested and used for direct VMware host workflows.'
@@ -128,8 +138,7 @@ function defaultForm() {
name: '', name: '',
targetType: 'vcenter', targetType: 'vcenter',
baseUrl: '', baseUrl: '',
username: '', credentialId: '',
password: '',
apiMode: 'auto', apiMode: 'auto',
requestTimeoutMs: 20000, requestTimeoutMs: 20000,
allowUntrustedTls: false, allowUntrustedTls: false,
@@ -143,7 +152,7 @@ function openModal(connection = null) {
Object.assign(form, defaultForm(), connection || {}); Object.assign(form, defaultForm(), connection || {});
form.targetType = connection?.targetType || 'vcenter'; form.targetType = connection?.targetType || 'vcenter';
form.apiMode = form.targetType === 'host' ? 'web-services' : (connection?.apiMode || 'auto'); form.apiMode = form.targetType === 'host' ? 'web-services' : (connection?.apiMode || 'auto');
form.password = ''; form.credentialId = connection?.credentialId || '';
form.groupId = connection?.groupId || ''; form.groupId = connection?.groupId || '';
modalOpen.value = true; modalOpen.value = true;
} }

View File

@@ -126,16 +126,23 @@ export function listVCenterConnectionRecords(req, res) {
export function createVCenterConnectionRecord(req, res) { export function createVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body); const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' }); 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' }); try {
res.status(201).json(createVCenterConnection(parsed.data, req.user.id)); res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
} catch (error) {
res.status(400).json({ error: error.message });
}
} }
export function updateVCenterConnectionRecord(req, res) { export function updateVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body); const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' }); if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' });
try {
const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id); const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id);
if (!connection) return res.status(404).json({ error: 'VMware connection not found' }); if (!connection) return res.status(404).json({ error: 'VMware connection not found' });
res.json(connection); res.json(connection);
} catch (error) {
res.status(400).json({ error: error.message });
}
} }
export function deleteVCenterConnectionRecord(req, res) { export function deleteVCenterConnectionRecord(req, res) {

View File

@@ -141,8 +141,10 @@ const apiArticles = [
, ,
section('VMware connection payloads', [ 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.', '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.' '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.',
], '@{\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}') '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']), ], ['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.', [ 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.'), 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, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
base_url TEXT NOT NULL, base_url TEXT NOT NULL,
username TEXT NOT NULL, username TEXT,
secret_cipher TEXT NOT NULL, secret_cipher TEXT,
secret_iv TEXT NOT NULL, secret_iv TEXT,
secret_tag TEXT NOT NULL, secret_tag TEXT,
credential_id TEXT REFERENCES credentials(id) ON DELETE SET NULL,
target_type TEXT NOT NULL DEFAULT 'vcenter', target_type TEXT NOT NULL DEFAULT 'vcenter',
api_mode TEXT NOT NULL DEFAULT 'auto', api_mode TEXT NOT NULL DEFAULT 'auto',
request_timeout_ms INTEGER NOT NULL DEFAULT 20000, 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 // VMware connection type separates vCenter inventory/power-state APIs from
// standalone ESXi host checks that use the vSphere Web Services SOAP API. // 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', '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 '{}'"); ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'");
// Tenant write-back is opt-in per Graph connection. Default 0 so existing // 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. // 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), name: z.string().min(1).max(120),
targetType: z.enum(['vcenter', 'host']).optional().default('vcenter'), targetType: z.enum(['vcenter', 'host']).optional().default('vcenter'),
baseUrl: z.string().url(), 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(''), password: z.string().optional().default(''),
apiMode: z.enum(['auto', 'api', 'rest', 'web-services']).optional().default('auto'), apiMode: z.enum(['auto', 'api', 'rest', 'web-services']).optional().default('auto'),
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional().default(20000), 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, id: row.id,
name: row.name, name: row.name,
baseUrl: row.base_url, baseUrl: row.base_url,
username: row.username, username: row.credential_username || row.username || '',
hasPassword: Boolean(row.secret_cipher), 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', targetType: row.target_type || 'vcenter',
apiMode: row.api_mode || 'auto', apiMode: row.api_mode || 'auto',
requestTimeoutMs: row.request_timeout_ms || 20000, requestTimeoutMs: row.request_timeout_ms || 20000,

View File

@@ -7,7 +7,8 @@ function normalizeConnection(body, existing = {}) {
const parsed = vcenterConnectionSchema.parse({ const parsed = vcenterConnectionSchema.parse({
...existing, ...existing,
...body, ...body,
password: body.password || '' password: body.password || '',
credentialId: body.credentialId ?? existing.credentialId ?? existing.credential_id ?? null
}); });
const targetType = parsed.targetType || 'vcenter'; const targetType = parsed.targetType || 'vcenter';
const apiMode = targetType === 'host' const apiMode = targetType === 'host'
@@ -18,15 +19,55 @@ function normalizeConnection(body, existing = {}) {
targetType, targetType,
apiMode, apiMode,
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''), baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
credentialId: parsed.credentialId || null,
username: parsed.username || '',
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
}; };
} }
export function listVCenterConnections(userId) { function connectionSelect() {
return db.prepare(` return `
SELECT c.*, g.name AS group_name 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 FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id 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')} WHERE ${visibleClause('c')}
ORDER BY c.name ORDER BY c.name
`).all(userId, userId).map(camelVCenterConnection); `).all(userId, userId).map(camelVCenterConnection);
@@ -35,41 +76,42 @@ export function listVCenterConnections(userId) {
export function getVCenterConnection(connectionId, userId, includeSecret = false) { export function getVCenterConnection(connectionId, userId, includeSecret = false) {
if (!connectionId) return null; if (!connectionId) return null;
const row = db.prepare(` const row = db.prepare(`
SELECT c.*, g.name AS group_name ${connectionSelect()}
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE c.id = ? AND ${visibleClause('c')} WHERE c.id = ? AND ${visibleClause('c')}
`).get(connectionId, userId, userId); `).get(connectionId, userId, userId);
if (!row) return null; if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row); return attachSecret(row, includeSecret);
} }
export function getVCenterConnectionSystem(connectionId, includeSecret = false) { export function getVCenterConnectionSystem(connectionId, includeSecret = false) {
if (!connectionId) return null; 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; if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row); return attachSecret(row, includeSecret);
} }
export function createVCenterConnection(body, userId) { export function createVCenterConnection(body, userId) {
const payload = normalizeConnection(body); 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'); const connectionId = id('vc');
db.prepare(` db.prepare(`
INSERT INTO vcenter_connections ( 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, target_type, api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
visibility, owner_user_id, group_id, created_by, created_at, updated_at visibility, owner_user_id, group_id, created_by, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
connectionId, connectionId,
payload.name, payload.name,
payload.baseUrl, payload.baseUrl,
payload.username, credential?.username || payload.username || '',
encrypted.cipher, encrypted.cipher,
encrypted.iv, encrypted.iv,
encrypted.tag, encrypted.tag,
payload.credentialId,
payload.targetType, payload.targetType,
payload.apiMode, payload.apiMode,
payload.requestTimeoutMs, payload.requestTimeoutMs,
@@ -92,6 +134,7 @@ export function updateVCenterConnection(connectionId, body, userId) {
name: existing.name, name: existing.name,
baseUrl: existing.base_url, baseUrl: existing.base_url,
username: existing.username, username: existing.username,
credentialId: existing.credential_id,
targetType: existing.target_type || 'vcenter', targetType: existing.target_type || 'vcenter',
apiMode: existing.api_mode, apiMode: existing.api_mode,
requestTimeoutMs: existing.request_timeout_ms, requestTimeoutMs: existing.request_timeout_ms,
@@ -100,24 +143,27 @@ export function updateVCenterConnection(connectionId, body, userId) {
visibility: existing.visibility, visibility: existing.visibility,
groupId: existing.group_id 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, cipher: existing.secret_cipher,
iv: existing.secret_iv, iv: existing.secret_iv,
tag: existing.secret_tag tag: existing.secret_tag
}; };
db.prepare(` db.prepare(`
UPDATE vcenter_connections 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 = ?, target_type = ?, api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
visibility = ?, group_id = ?, updated_at = ? visibility = ?, group_id = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
payload.name, payload.name,
payload.baseUrl, payload.baseUrl,
payload.username, credential?.username || payload.username || '',
encrypted.cipher, encrypted.cipher,
encrypted.iv, encrypted.iv,
encrypted.tag, encrypted.tag,
payload.credentialId,
payload.targetType, payload.targetType,
payload.apiMode, payload.apiMode,
payload.requestTimeoutMs, payload.requestTimeoutMs,

View File

@@ -1,5 +1,5 @@
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { Agent } from 'undici'; import { Agent, fetch as undiciFetch } from 'undici';
import { config } from '../config.js'; import { config } from '../config.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'; import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { normalizeOsFamily } from '../utils/osFamily.js'; import { normalizeOsFamily } from '../utils/osFamily.js';
@@ -122,6 +122,12 @@ function dispatcherFor(settings) {
: undefined; : 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) { async function parseVCenterResponse(response) {
const text = await response.text(); const text = await response.text();
if (!text) return null; if (!text) return null;
@@ -179,10 +185,11 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
let response; let response;
let payload = null; let payload = null;
try { try {
response = await fetch(`${settings.baseUrl}${path}`, { const dispatcher = dispatcherFor(settings);
response = await fetchForDispatcher(dispatcher)(`${settings.baseUrl}${path}`, {
method, method,
headers, headers,
dispatcher: dispatcherFor(settings), dispatcher,
signal: AbortSignal.timeout(settings.requestTimeoutMs) signal: AbortSignal.timeout(settings.requestTimeoutMs)
}); });
payload = await parseVCenterResponse(response); payload = await parseVCenterResponse(response);
@@ -234,8 +241,27 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
return payload; return payload;
} }
function soapBaseUrl(settings) {
return normalizeBaseUrl(settings.baseUrl)
.replace(/\/sdk\/vimService\.wsdl$/i, '')
.replace(/\/sdk\/vimService$/i, '')
.replace(/\/sdk$/i, '');
}
function soapEndpoint(settings) { 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) { function soapEnvelope(body) {
@@ -266,6 +292,18 @@ function soapBodyPreview(value) {
: text; : 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) { function extractSoapTag(text, tagName) {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i'); const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
const match = String(text || '').match(pattern); const match = String(text || '').match(pattern);
@@ -301,51 +339,36 @@ function extractSoapFault(text) {
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || ''; return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
} }
async function vSphereSoapFetch(settings, action, body, trace = null) { async function probeSoapWsdl(settings, trace = null) {
const url = soapEndpoint(settings); const url = soapWsdlEndpoint(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(); const startedAt = Date.now();
let response; let response;
let text = ''; let text = '';
try { try {
response = await fetch(url, { const dispatcher = dispatcherFor(settings);
method: 'POST', response = await fetchForDispatcher(dispatcher)(url, {
headers, method: 'GET',
body: requestBody, headers: { Accept: 'text/xml, application/xml, */*' },
dispatcher: dispatcherFor(settings), dispatcher,
signal: AbortSignal.timeout(settings.requestTimeoutMs) signal: AbortSignal.timeout(settings.requestTimeoutMs)
}); });
text = await response.text(); text = await response.text();
} catch (err) { } catch (err) {
pushTrace(trace, { pushTrace(trace, {
method: 'POST', method: 'GET',
url, url,
soapAction: action, requestHeaders: { Accept: 'text/xml, application/xml, */*' },
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
requestBody: soapBodyPreview(requestBody),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
timeoutMs: settings.requestTimeoutMs, timeoutMs: settings.requestTimeoutMs,
networkError: err.name === 'TimeoutError' networkError: networkErrorMessage(err),
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP endpoint.` diagnosticOnly: true
: err.message
}); });
const message = err.name === 'TimeoutError' return;
? `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, { pushTrace(trace, {
method: 'POST', method: 'GET',
url, url,
soapAction: action, requestHeaders: { Accept: 'text/xml, application/xml, */*' },
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
requestBody: soapBodyPreview(requestBody),
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
@@ -353,19 +376,104 @@ async function vSphereSoapFetch(settings, action, body, trace = null) {
'content-type': response.headers.get('content-type') || '', 'content-type': response.headers.get('content-type') || '',
date: response.headers.get('date') || '' 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: { responseSummary: {
type: 'soap', type: 'soap',
bytes: text.length, bytes: text.length,
fault: Boolean(extractSoapFault(text)) fault: Boolean(extractSoapFault(text))
}, },
responseBody: soapBodyPreview(text) responseBody: soapBodyPreview(text),
endpointAttempt: `${index + 1} of ${endpoints.length}`
}); });
const fault = extractSoapFault(text); 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) { 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); 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) { function unwrapList(payload) {
@@ -679,8 +787,11 @@ function soapRefElement(name, ref, fallbackType) {
async function connectStandaloneSoap(settings, trace = null) { async function connectStandaloneSoap(settings, trace = null) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, { pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services', mode: 'web-services',
endpoint: soapEndpoint(settings) endpoint: soapEndpoint(settings),
endpointCandidates: soapEndpointCandidates(settings),
wsdl: soapWsdlEndpoint(settings)
}); });
await probeSoapWsdl(settings, trace);
const serviceContent = await vSphereSoapFetch( const serviceContent = await vSphereSoapFetch(
settings, settings,
'RetrieveServiceContent', 'RetrieveServiceContent',
@@ -693,13 +804,15 @@ async function connectStandaloneSoap(settings, trace = null) {
viewManager: extractManagedObject(serviceContent, 'viewManager', 'ViewManager'), viewManager: extractManagedObject(serviceContent, 'viewManager', 'ViewManager'),
sessionManager: extractManagedObject(serviceContent, 'sessionManager', 'SessionManager') sessionManager: extractManagedObject(serviceContent, 'sessionManager', 'SessionManager')
}; };
const loginMeta = {};
await vSphereSoapFetch( await vSphereSoapFetch(
settings, settings,
'Login', 'Login',
`<Login xmlns="urn:vim25">${soapRefElement('_this', refs.sessionManager, 'SessionManager')}<userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></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) { async function createVmContainerView(settings, refs, trace = null) {
@@ -707,7 +820,8 @@ async function createVmContainerView(settings, refs, trace = null) {
settings, settings,
'CreateContainerView', 'CreateContainerView',
`<CreateContainerView xmlns="urn:vim25">${soapRefElement('_this', refs.viewManager, 'ViewManager')}${soapRefElement('container', refs.rootFolder, 'Folder')}<type>VirtualMachine</type><recursive>true</recursive></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'); return extractManagedObject(response, 'returnval', 'ContainerView');
} }
@@ -759,11 +873,11 @@ async function retrieveStandaloneVmInventory(settings, { connection = null, limi
const refs = await connectStandaloneSoap(settings, trace); const refs = await connectStandaloneSoap(settings, trace);
const viewRef = await createVmContainerView(settings, refs, trace); const viewRef = await createVmContainerView(settings, refs, trace);
const objects = []; 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)); objects.push(...parseVmPropertyObjects(response));
let token = decodeXml(extractSoapTag(response, 'token')); let token = decodeXml(extractSoapTag(response, 'token'));
while (token && objects.length < limit) { 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)); objects.push(...parseVmPropertyObjects(response));
token = decodeXml(extractSoapTag(response, 'token')); 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>` + `<specSet><propSet><type>VirtualMachine</type><all>false><pathSet>runtime.powerState</pathSet></propSet>` +
`<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` + `<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` +
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`; `<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]; const object = parseVmPropertyObjects(response)[0];
return normalizePowerState(object?.props?.['runtime.powerState'] || ''); return normalizePowerState(object?.props?.['runtime.powerState'] || '');
} }

View File

@@ -1,38 +1,49 @@
import './setup.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { load } from './setup.mjs'; import http from 'node:http';
import { load, makeUser } from './setup.mjs';
const { const {
buildVmListPath, buildVmListPath,
buildHostPayloadFromVm, buildHostPayloadFromVm,
buildStandaloneHostCandidate, buildStandaloneHostCandidate,
createCredential,
createVCenterConnection,
filterSummaryVms, filterSummaryVms,
getVCenterConnection,
getVCenterVmPowerState, getVCenterVmPowerState,
normalizeBaseUrl, normalizeBaseUrl,
normalizeVCenterVm, normalizeVCenterVm,
previewVCenterHosts, previewVCenterHosts,
settingsFromVCenterConnection, settingsFromVCenterConnection,
vmMatchesFilter 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; 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>`, { 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, status: 200,
headers: { 'content-type': 'text/xml' } headers: { 'content-type': 'text/xml', ...(init.headers || {}) }
}); });
} }
function installStandaloneEsxiSoapStub() { function installStandaloneEsxiSoapStub({ sdk404 = false } = {}) {
globalThis.fetch = async (_url, options = {}) => { 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 || ''); 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')) { 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>`); 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')) { 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')) { if (body.includes('<CreateContainerView')) {
return soapResponse(`<CreateContainerViewResponse xmlns="urn:vim25"><returnval type="ContainerView">session[52b6]-vm-view</returnval></CreateContainerViewResponse>`); return soapResponse(`<CreateContainerViewResponse xmlns="urn:vim25"><returnval type="ContainerView">session[52b6]-vm-view</returnval></CreateContainerViewResponse>`);
@@ -42,15 +53,79 @@ function installStandaloneEsxiSoapStub() {
} }
return soapResponse(''); 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', () => { test('normalizeBaseUrl trims trailing slashes', () => {
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local'); 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 () => { test('standalone VMware host connections use the Web Services profile', async () => {
const restoreFetch = installStandaloneEsxiSoapStub(); const { calls, restore } = installStandaloneEsxiSoapStub();
const connection = { const connection = {
id: 'vc_host', id: 'vc_host',
name: 'ESXi 01', 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'); assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } }); const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } });
restoreFetch(); restore();
assert.equal(preview.count, 1); 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.diagnostics.standaloneHost, true);
assert.equal(preview.candidates[0].source, 'standalone-vm'); assert.equal(preview.candidates[0].source, 'standalone-vm');
assert.equal(preview.candidates[0].fqdn, 'eng-ent-app01.contoso.local'); 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 () => { test('standalone VMware VM candidates build managed host payloads', async () => {
const restoreFetch = installStandaloneEsxiSoapStub(); const { restore } = installStandaloneEsxiSoapStub();
const connection = { const connection = {
id: 'vc_host', id: 'vc_host',
name: 'ESXi 01', name: 'ESXi 01',
@@ -98,7 +175,7 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
visibility: 'shared' visibility: 'shared'
}); });
const power = await getVCenterVmPowerState(connection, preview.candidates[0].id); const power = await getVCenterVmPowerState(connection, preview.candidates[0].id);
restoreFetch(); restore();
assert.equal(payload.name, 'ENG-ENT-APP01'); assert.equal(payload.name, 'ENG-ENT-APP01');
assert.equal(payload.address, 'eng-ent-app01.contoso.local'); 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'); 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', () => { test('standalone VMware host candidates build managed host payloads', () => {
const candidate = buildStandaloneHostCandidate( const candidate = buildStandaloneHostCandidate(
{ id: 'vc_host', name: 'ESXi 01' }, { id: 'vc_host', name: 'ESXi 01' },