Initial
This commit is contained in:
34
README.md
34
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.'),
|
||||||
|
|||||||
10
server/db.js
10
server/db.js
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,20 +376,105 @@ 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) {
|
||||||
if (Array.isArray(payload)) return payload;
|
if (Array.isArray(payload)) return 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'] || '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user