diff --git a/.gitignore b/.gitignore
index 9cc1f76..828cd22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ tools/*.exe
PLAN*.MD
PLAN-INTUNE.md
project.txt
+*-lock.json
\ No newline at end of file
diff --git a/README.md b/README.md
index cbad9c4..028f485 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/
- PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata.
- Intune Publishing Wizard for PSADT Win32 deployment plans, including package source, `.intunewin` tracking, command style, UI compatibility, requirements, detection rules, return-code policy, assignment rings, status, Graph app ID, and visibility.
- Microsoft Graph Intune tracking for PSADT deployments, with Graph environment configuration managed under Config / Intune plus mobile app linking, status sync, failure review, device/user status rows, and sync run history.
-- Host Library with searchable/sortable/paginated table and modal add/edit flow.
+- Host Library with searchable/sortable/paginated table, modal add/edit flow, and VMware vCenter VM import wizard with preview filters and credential attachment.
- Credential Vault as its own menu item with encrypted-at-rest secrets and modal add/edit flow.
- RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action.
- Aggregate job log viewer with search/filter/pagination.
@@ -158,6 +158,11 @@ Important environment variables:
| `DEFAULT_ADMIN_NAME` | First-run admin display name. |
| `POWERSHELL_BIN` | PowerShell executable. Defaults to `pwsh`. |
| `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. |
+| `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. |
+| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. |
+| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
+| `VCENTER_PASSWORD` | vCenter password. Prefer environment pinning for production deployments. |
+| `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. |
| `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. |
| `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. |
| `TOOLS_DIR` | Directory for bundled tools. Defaults to `./tools`. |
@@ -498,6 +503,9 @@ Payload:
| --- | --- | --- | --- |
| `GET` | `/api/hosts` | User | List host library. |
| `POST` | `/api/hosts` | User | Create host. |
+| `GET` | `/api/hosts/import/vcenter/status` | User | Return effective vCenter import configuration status without the password. |
+| `POST` | `/api/hosts/import/vcenter/preview` | User | Authenticate to vCenter, search VM inventory, and return import candidates. |
+| `POST` | `/api/hosts/import/vcenter` | User | Import selected vCenter VM candidates as hosts, attaching the chosen credential/defaults. |
| `PUT` | `/api/hosts/:id` | User | Update host. |
| `DELETE` | `/api/hosts/:id` | User | Delete host. |
@@ -523,6 +531,47 @@ Payload:
`personal`, `shared`, or `group`; hosts default to `shared`. RunPlans can only
target hosts visible to the operator who creates or edits them.
+VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). The backend creates a vCenter REST session with `POST /api/session`, lists VMs from `/api/vcenter/vm`, and then best-effort enriches each candidate from guest identity/networking endpoints. 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.
+
+Preview payload:
+
+```json
+{
+ "filter": {
+ "field": "hostname",
+ "operator": "contains",
+ "value": "ENG-ENT"
+ },
+ "limit": 100
+}
+```
+
+Import payload:
+
+```json
+{
+ "filter": {
+ "field": "hostname",
+ "operator": "contains",
+ "value": "ENG-ENT"
+ },
+ "limit": 100,
+ "vmIds": ["vm-101", "vm-205"],
+ "credentialId": "cred_...",
+ "transport": "winrm",
+ "port": null,
+ "tags": ["engineering", "vcenter-import"],
+ "visibility": "group",
+ "groupId": "grp_..."
+}
+```
+
+`field` can be `hostname`, `name`, `fqdn`, or `ip`. `operator` can be
+`contains`, `equals`, `startsWith`, or `endsWith`. If `vmIds` is empty, all
+previewed candidates that match the filter are considered. Existing visible
+hosts with the same name, address, or FQDN are skipped and returned in the
+`skipped` array so imports can be rerun safely.
+
### Script Library
| Method | Route | Auth | Description |
diff --git a/client/src/App.vue b/client/src/App.vue
index d400db4..3c7a24b 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -304,6 +304,9 @@
Search, sort, and attach credentials to the hosts used by RunPlans.
+
+ Import from vCenter
+
Add host
@@ -414,6 +417,19 @@
+
+
['server_fqdn', 'trusted_origins
.filter((key) => settings.value[key])
.map((key) => ({ key, ...settings.value[key] })));
const configSections = computed(() => {
- const sections = ['Deployment', 'Authentication', 'Execution', 'Runtime', 'Other'];
+ const sections = ['Deployment', 'Authentication', 'Integrations', 'Execution', 'Runtime', 'Other'];
return sections
.map((section) => ({
section,
@@ -2294,6 +2346,50 @@ async function saveHost() {
notify('Host saved');
}
+async function openVCenterImport() {
+ vcenterImportOpen.value = true;
+ vcenterImportError.value = '';
+ try {
+ vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
+ } catch (err) {
+ vcenterImportError.value = err.message;
+ }
+}
+
+async function previewVCenterImport(payload) {
+ vcenterImportLoading.value = true;
+ vcenterImportError.value = '';
+ try {
+ const result = await api.post('/api/hosts/import/vcenter/preview', payload);
+ vcenterImportStatus.value = result.status;
+ vcenterPreviewRows.value = result.candidates || [];
+ notify(`Found ${result.count || 0} vCenter host candidate(s)`);
+ } catch (err) {
+ vcenterImportError.value = err.message;
+ notify(err.message, 'error');
+ } finally {
+ vcenterImportLoading.value = false;
+ }
+}
+
+async function importVCenterHosts(payload) {
+ vcenterImportLoading.value = true;
+ vcenterImportError.value = '';
+ try {
+ const result = await api.post('/api/hosts/import/vcenter', payload);
+ await refreshAll();
+ vcenterPreviewRows.value = [];
+ vcenterImportOpen.value = false;
+ const skipped = result.skipped?.length || 0;
+ notify(`Imported ${result.imported?.length || 0} host(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}`);
+ } catch (err) {
+ vcenterImportError.value = err.message;
+ notify(err.message, 'error');
+ } finally {
+ vcenterImportLoading.value = false;
+ }
+}
+
function resetHostForm() {
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null });
}
@@ -2709,6 +2805,10 @@ function settingPlaceholder(key) {
return settingMetadata[key]?.placeholder || '';
}
+function isSecretSetting(key) {
+ return Boolean(settingMetadata[key]?.secret || /(?:password|secret|token|key)$/i.test(key));
+}
+
function settingSourceLabel(source) {
return source === 'env' ? 'ENV' : 'DB';
}
@@ -2717,6 +2817,7 @@ function configSectionMeta(section) {
const meta = {
Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' },
Authentication: { icon: ShieldCheck, description: 'Identity provider and password-flow configuration.' },
+ Integrations: { icon: Download, description: 'External systems used for inventory, publishing, and governance workflows.' },
Execution: { icon: PlayCircle, description: 'PowerShell execution guardrails.' },
Runtime: { icon: Database, description: 'Container runtime paths and API process settings.' },
Other: { icon: Settings, description: 'Additional app settings exposed by the API.' }
diff --git a/client/src/components/hosts/VCenterImportModal.vue b/client/src/components/hosts/VCenterImportModal.vue
new file mode 100644
index 0000000..7b02118
--- /dev/null
+++ b/client/src/components/hosts/VCenterImportModal.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
diff --git a/client/src/components/settings/ConfigSection.vue b/client/src/components/settings/ConfigSection.vue
index 5d471d7..6cbda17 100644
--- a/client/src/components/settings/ConfigSection.vue
+++ b/client/src/components/settings/ConfigSection.vue
@@ -29,7 +29,7 @@
{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}
-
+
{{ sourceLabel(row.source) }}
@@ -48,6 +48,7 @@ defineProps({
labelFor: { type: Function, required: true },
descriptionFor: { type: Function, required: true },
placeholderFor: { type: Function, required: true },
+ isSecretSetting: { type: Function, default: () => false },
sourceLabel: { type: Function, required: true },
isBooleanSetting: { type: Function, required: true },
normalizedBooleanSetting: { type: Function, required: true }
diff --git a/client/src/style.css b/client/src/style.css
index 927cc10..53e600d 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -6867,13 +6867,188 @@ input:read-only {
box-shadow: 0 -18px 34px rgba(0, 0, 0, .24);
}
+.vcenter-import-form {
+ display: grid;
+ gap: 16px;
+ max-height: min(74vh, 820px);
+ overflow: auto;
+ padding: 2px 2px 0;
+}
+
+.wizard-section {
+ border: 1px solid rgba(139, 224, 255, .14);
+ border-radius: 18px;
+ background:
+ linear-gradient(135deg, rgba(88, 221, 255, .075), rgba(255, 255, 255, .025)),
+ rgba(7, 13, 28, .52);
+ padding: 16px;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .045);
+}
+
+.section-heading {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+ margin-bottom: 14px;
+}
+
+.section-heading > span {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 12px;
+ color: #06101d;
+ font-weight: 900;
+ background: linear-gradient(135deg, #75d8ff, #a579ff 55%, #ffe4f1);
+ box-shadow: 0 12px 30px rgba(108, 122, 255, .28);
+}
+
+.section-heading h4 {
+ margin: 0;
+ color: rgba(255, 255, 255, .9);
+ font-size: .98rem;
+}
+
+.section-heading p {
+ margin: 4px 0 0;
+ color: rgba(219, 227, 255, .62);
+ font-size: .82rem;
+ line-height: 1.45;
+}
+
+.vcenter-filter-grid,
+.vcenter-options-grid {
+ display: grid;
+ grid-template-columns: minmax(150px, .85fr) minmax(150px, .85fr) minmax(220px, 1.35fr) minmax(120px, .55fr);
+ gap: 12px;
+ align-items: end;
+}
+
+.vcenter-options-grid {
+ grid-template-columns: repeat(3, minmax(170px, 1fr));
+}
+
+.vcenter-filter-grid label,
+.vcenter-options-grid label {
+ min-width: 0;
+}
+
+.vcenter-filter-grid .filter-value,
+.vcenter-options-grid .option-tags {
+ grid-column: span 1;
+}
+
+.wizard-actions,
+.preview-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 14px;
+}
+
+.vcenter-status,
+.preview-toolbar span {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: rgba(219, 227, 255, .68);
+ font-size: .82rem;
+}
+
+.status-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 999px;
+ background: #f8c66a;
+ box-shadow: 0 0 14px rgba(248, 198, 106, .62);
+}
+
+.status-dot.success {
+ background: #66f2b5;
+ box-shadow: 0 0 14px rgba(102, 242, 181, .62);
+}
+
+.inline-error {
+ border: 1px solid rgba(255, 124, 152, .28);
+ border-radius: 14px;
+ padding: 10px 12px;
+ color: #ffb7c7;
+ background: rgba(255, 65, 112, .08);
+ margin-bottom: 12px;
+}
+
+.vcenter-preview-table {
+ position: relative;
+ overflow: auto;
+ border: 1px solid rgba(139, 224, 255, .12);
+ border-radius: 16px;
+ background: rgba(3, 8, 18, .36);
+}
+
+.vcenter-preview-table table {
+ width: 100%;
+ min-width: 820px;
+ border-collapse: collapse;
+}
+
+.vcenter-preview-table th,
+.vcenter-preview-table td {
+ padding: 12px 14px;
+ border-bottom: 1px solid rgba(139, 224, 255, .08);
+ text-align: left;
+ vertical-align: top;
+}
+
+.vcenter-preview-table th {
+ color: rgba(151, 221, 255, .78);
+ font-size: .68rem;
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ background: rgba(7, 13, 28, .72);
+}
+
+.vcenter-preview-table td {
+ color: rgba(238, 242, 255, .84);
+ font-size: .84rem;
+}
+
+.vcenter-preview-table strong,
+.vcenter-preview-table small {
+ display: block;
+ min-width: 0;
+}
+
+.vcenter-preview-table small {
+ margin-top: 4px;
+ color: rgba(219, 227, 255, .42);
+ overflow-wrap: anywhere;
+}
+
+.vcenter-preview-table input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: #75d8ff;
+}
+
+.wizard-empty {
+ padding: 28px 18px;
+ color: rgba(219, 227, 255, .56);
+ text-align: center;
+}
+
@media (max-width: 1180px) {
.deployment-section-body.three,
.deployment-section-body.two,
.return-code-row,
.assignment-row,
.relationship-row,
- .intune-deployment-form .installer-analyze-row {
+ .intune-deployment-form .installer-analyze-row,
+ .vcenter-filter-grid,
+ .vcenter-options-grid {
grid-template-columns: 1fr;
}
diff --git a/package-lock.json b/package-lock.json
index e5443ce..56ff982 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"nanoid": "^5.1.6",
"path": "^0.12.7",
"tailwindcss": "^4.3.1",
+ "undici": "^8.5.0",
"vite": "^8.1.0",
"vue": "^3.5.26",
"vuetify": "^4.1.2",
@@ -3330,6 +3331,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/undici": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
+ "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/package.json b/package.json
index 98a9561..f27cfdc 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"nanoid": "^5.1.6",
"path": "^0.12.7",
"tailwindcss": "^4.3.1",
+ "undici": "^8.5.0",
"vite": "^8.1.0",
"vue": "^3.5.26",
"vuetify": "^4.1.2",
diff --git a/server/config.js b/server/config.js
index 4fc157d..af87fae 100644
--- a/server/config.js
+++ b/server/config.js
@@ -40,6 +40,13 @@ export const config = {
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
+ vcenter: {
+ enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
+ baseUrl: process.env.VCENTER_BASE_URL || '',
+ username: process.env.VCENTER_USERNAME || '',
+ password: process.env.VCENTER_PASSWORD || '',
+ allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true'
+ },
entra: {
enabled: (process.env.ENTRA_ENABLED || 'false').toLowerCase() === 'true',
tenantId: process.env.ENTRA_TENANT_ID || '',
diff --git a/server/controllers/hostController.js b/server/controllers/hostController.js
index 2cea664..c6a1db2 100644
--- a/server/controllers/hostController.js
+++ b/server/controllers/hostController.js
@@ -1,5 +1,6 @@
-import { hostSchema } from '../forms/schemas.js';
-import { createHost, deleteHost, listHosts, updateHost } from '../models/hostModel.js';
+import { hostSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
+import { createHost, deleteHost, findVisibleHostByIdentity, listHosts, updateHost } from '../models/hostModel.js';
+import { buildHostPayloadFromVm, previewVCenterHosts, vcenterStatus } from '../services/vcenterService.js';
export function index(req, res) {
res.json(listHosts(req.user.id));
@@ -21,3 +22,45 @@ export function destroy(req, res) {
deleteHost(req.params.id, req.user.id);
res.status(204).end();
}
+
+export function vcenterImportStatus(req, res) {
+ res.json(vcenterStatus());
+}
+
+export async function previewVCenterImport(req, res) {
+ const parsed = vcenterPreviewSchema.safeParse(req.body);
+ if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
+ try {
+ res.json(await previewVCenterHosts(parsed.data));
+ } catch (err) {
+ res.status(502).json({ error: err.message });
+ }
+}
+
+export async function importVCenterHosts(req, res) {
+ const parsed = vcenterImportSchema.safeParse(req.body);
+ if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
+ try {
+ const preview = await previewVCenterHosts(parsed.data);
+ const selected = new Set(parsed.data.vmIds || []);
+ const candidates = selected.size
+ ? preview.candidates.filter((candidate) => selected.has(candidate.id))
+ : preview.candidates;
+ const imported = [];
+ const skipped = [];
+
+ for (const candidate of candidates) {
+ const hostPayload = buildHostPayloadFromVm(candidate, parsed.data);
+ const existing = findVisibleHostByIdentity(hostPayload, req.user.id);
+ if (existing) {
+ skipped.push({ vm: candidate, reason: `Existing host matched ${existing.name}` });
+ continue;
+ }
+ imported.push(createHost(hostPayload, req.user.id));
+ }
+
+ res.status(201).json({ imported, skipped, scanned: preview.count });
+ } catch (err) {
+ res.status(502).json({ error: err.message });
+ }
+}
diff --git a/server/forms/schemas.js b/server/forms/schemas.js
index c4cebaa..b3a2acb 100644
--- a/server/forms/schemas.js
+++ b/server/forms/schemas.js
@@ -63,6 +63,27 @@ export const hostSchema = z.object({
groupId: z.string().nullable().optional()
});
+const vcenterFilterSchema = z.object({
+ field: z.enum(['name', 'hostname', 'fqdn', 'ip']).optional().default('hostname'),
+ operator: z.enum(['contains', 'equals', 'startsWith', 'endsWith']).optional().default('contains'),
+ value: z.string().max(200).optional().default('')
+});
+
+export const vcenterPreviewSchema = z.object({
+ filter: vcenterFilterSchema.optional().default({}),
+ limit: z.coerce.number().int().min(1).max(500).optional().default(100)
+});
+
+export const vcenterImportSchema = vcenterPreviewSchema.extend({
+ vmIds: z.array(z.string().min(1)).optional().default([]),
+ credentialId: z.string().nullable().optional(),
+ visibility: visibilitySchema.default('shared'),
+ groupId: z.string().nullable().optional(),
+ transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
+ port: z.coerce.number().int().min(1).max(65535).nullable().optional(),
+ tags: z.array(z.string().max(80)).optional().default([])
+});
+
export const folderSchema = z.object({
parentId: z.string().nullable().optional(),
name: z.string().min(1),
diff --git a/server/models/hostModel.js b/server/models/hostModel.js
index e983675..4a520d5 100644
--- a/server/models/hostModel.js
+++ b/server/models/hostModel.js
@@ -42,6 +42,26 @@ export function createHost(payload, userId) {
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
}
+export function findVisibleHostByIdentity(payload, userId) {
+ const values = [payload.name, payload.address, payload.fqdn]
+ .map((value) => String(value || '').trim().toLowerCase())
+ .filter(Boolean);
+ if (!values.length) return null;
+ const placeholders = values.map(() => '?').join(', ');
+ const row = db.prepare(`
+ SELECT *
+ FROM hosts
+ WHERE ${visibleClause()}
+ AND (
+ lower(name) IN (${placeholders})
+ OR lower(address) IN (${placeholders})
+ OR lower(coalesce(fqdn, '')) IN (${placeholders})
+ )
+ LIMIT 1
+ `).get(userId, userId, ...values, ...values, ...values);
+ return row ? camelHost(row) : null;
+}
+
export function updateHost(hostId, payload, userId) {
const existing = db.prepare(`SELECT * FROM hosts WHERE id = ? AND ${visibleClause()}`).get(hostId, userId, userId);
if (!existing) return null;
diff --git a/server/routes/hostRoutes.js b/server/routes/hostRoutes.js
index 67f2380..2927c5d 100644
--- a/server/routes/hostRoutes.js
+++ b/server/routes/hostRoutes.js
@@ -1,9 +1,12 @@
import { Router } from 'express';
-import { create, destroy, index, update } from '../controllers/hostController.js';
+import { create, destroy, importVCenterHosts, index, previewVCenterImport, update, vcenterImportStatus } from '../controllers/hostController.js';
import { requireAuth } from '../middleware/auth.js';
export const hostRoutes = Router();
+hostRoutes.get('/import/vcenter/status', requireAuth, vcenterImportStatus);
+hostRoutes.post('/import/vcenter/preview', requireAuth, previewVCenterImport);
+hostRoutes.post('/import/vcenter', requireAuth, importVCenterHosts);
hostRoutes.get('/', requireAuth, index);
hostRoutes.post('/', requireAuth, create);
hostRoutes.put('/:id', requireAuth, update);
diff --git a/server/services/vcenterService.js b/server/services/vcenterService.js
new file mode 100644
index 0000000..964aa1f
--- /dev/null
+++ b/server/services/vcenterService.js
@@ -0,0 +1,221 @@
+import { Buffer } from 'node:buffer';
+import { Agent } from 'undici';
+import { config } from '../config.js';
+import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
+
+const WINDOWS_HINTS = ['windows', 'microsoft'];
+const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
+
+export function getVCenterSettings() {
+ return {
+ enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
+ baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
+ username: getStringSetting('vcenter_username', config.vcenter.username),
+ password: getStringSetting('vcenter_password', config.vcenter.password),
+ allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
+ };
+}
+
+export function normalizeBaseUrl(value) {
+ return String(value || '').trim().replace(/\/+$/, '');
+}
+
+export function vcenterStatus() {
+ const settings = getVCenterSettings();
+ return {
+ enabled: settings.enabled,
+ configured: Boolean(settings.baseUrl && settings.username && settings.password),
+ baseUrl: settings.baseUrl,
+ username: settings.username,
+ allowUntrustedTls: settings.allowUntrustedTls
+ };
+}
+
+function assertConfigured(settings) {
+ if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
+ if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
+ if (!settings.username || !settings.password) throw new Error('VMware vCenter credentials are missing. Set vcenter_username and vcenter_password in Configuration.');
+}
+
+function dispatcherFor(settings) {
+ return settings.allowUntrustedTls
+ ? new Agent({ connect: { rejectUnauthorized: false } })
+ : undefined;
+}
+
+async function parseVCenterResponse(response) {
+ const text = await response.text();
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text.replace(/^"|"$/g, '');
+ }
+}
+
+async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false } = {}) {
+ const headers = { Accept: 'application/json' };
+ if (sessionId) headers['vmware-api-session-id'] = sessionId;
+ else headers.Authorization = `Basic ${Buffer.from(`${settings.username}:${settings.password}`).toString('base64')}`;
+
+ const response = await fetch(`${settings.baseUrl}${path}`, {
+ method,
+ headers,
+ dispatcher: dispatcherFor(settings)
+ });
+
+ if (allow404 && response.status === 404) return null;
+ const payload = await parseVCenterResponse(response);
+ if (!response.ok) {
+ const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText;
+ throw new Error(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`);
+ }
+ return payload;
+}
+
+function unwrapList(payload) {
+ if (Array.isArray(payload)) return payload;
+ if (Array.isArray(payload?.value)) return payload.value;
+ return [];
+}
+
+async function createSession(settings) {
+ const payload = await vcenterFetch(settings, '/api/session', { method: 'POST' });
+ const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
+ if (!sessionId) throw new Error('vCenter session authentication succeeded but no session id was returned.');
+ return sessionId;
+}
+
+async function optionalVCenterFetch(settings, path, sessionId) {
+ try {
+ return await vcenterFetch(settings, path, { sessionId, allow404: true });
+ } catch (err) {
+ return { unavailable: true, reason: err.message };
+ }
+}
+
+function firstIpFromInterfaces(interfaces = []) {
+ for (const nic of interfaces) {
+ const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
+ const firstUsable = addresses.find((entry) => {
+ const address = entry?.ip_address || entry?.address || entry;
+ return address && !String(address).startsWith('fe80:');
+ });
+ if (firstUsable) return firstUsable.ip_address || firstUsable.address || firstUsable;
+ }
+ return '';
+}
+
+function allIpsFromInterfaces(interfaces = []) {
+ return interfaces.flatMap((nic) => {
+ const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
+ return addresses
+ .map((entry) => entry?.ip_address || entry?.address || entry)
+ .filter((address) => address && !String(address).startsWith('fe80:'));
+ });
+}
+
+function inferOsFamily(identity = {}) {
+ const haystack = [identity.family, identity.name, identity.full_name, identity.guest_OS].filter(Boolean).join(' ').toLowerCase();
+ if (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
+ if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
+ return 'windows';
+}
+
+export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
+ const interfaces = unwrapList(interfacesPayload);
+ const interfaceIp = firstIpFromInterfaces(interfaces);
+ const identityIp = identity?.ip_address || identity?.ipAddress || '';
+ const fqdn = identity?.host_name || identity?.hostName || '';
+ const name = vm.name || fqdn || vm.vm || vm.id;
+ const address = fqdn || identityIp || interfaceIp || name;
+ const ipAddresses = [...new Set([identityIp, interfaceIp, ...allIpsFromInterfaces(interfaces)].filter(Boolean))];
+
+ return {
+ id: vm.vm || vm.id,
+ name,
+ fqdn,
+ address,
+ ipAddress: ipAddresses[0] || '',
+ ipAddresses,
+ powerState: vm.power_state || vm.powerState || '',
+ osFamily: inferOsFamily(identity || {}),
+ guestFullName: identity?.full_name || identity?.name || '',
+ toolsAvailable: Boolean(identity || interfaces.length),
+ source: 'vcenter'
+ };
+}
+
+export function vmMatchesFilter(candidate, filter = {}) {
+ const operator = filter.operator || 'contains';
+ const needle = String(filter.value || '').trim().toLowerCase();
+ if (!needle) return true;
+ const field = filter.field || 'hostname';
+ if (field === 'ip') {
+ const values = [candidate.ipAddress, ...(candidate.ipAddresses || [])]
+ .filter(Boolean)
+ .map((value) => String(value).toLowerCase());
+ if (operator === 'equals') return values.some((value) => value === needle);
+ if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
+ if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
+ return values.some((value) => value.includes(needle));
+ }
+ const haystack = {
+ name: candidate.name,
+ hostname: candidate.name || candidate.fqdn,
+ fqdn: candidate.fqdn || candidate.address,
+ }[field] || candidate.name;
+ const value = String(haystack || '').toLowerCase();
+ if (operator === 'equals') return value === needle;
+ if (operator === 'startsWith') return value.startsWith(needle);
+ if (operator === 'endsWith') return value.endsWith(needle);
+ return value.includes(needle);
+}
+
+export function buildHostPayloadFromVm(candidate, options = {}) {
+ const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
+ return {
+ name: candidate.name,
+ fqdn: candidate.fqdn || '',
+ address: candidate.address || candidate.ipAddress || candidate.name,
+ osFamily: candidate.osFamily || 'windows',
+ transport: options.transport || 'winrm',
+ port: options.port || null,
+ credentialId: options.credentialId || null,
+ tags,
+ notes: [
+ `Imported from VMware vCenter VM ${candidate.id}.`,
+ candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
+ candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
+ candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
+ ].filter(Boolean).join(' '),
+ visibility: options.visibility || 'shared',
+ groupId: options.visibility === 'group' ? options.groupId || null : null
+ };
+}
+
+export async function previewVCenterHosts(options = {}) {
+ const settings = getVCenterSettings();
+ assertConfigured(settings);
+ const sessionId = await createSession(settings);
+ const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId });
+ const vms = unwrapList(vmPayload).slice(0, Math.max(options.limit || 100, 1) * 4);
+ const candidates = [];
+
+ for (const vm of vms) {
+ const vmId = vm.vm || vm.id;
+ if (!vmId) continue;
+ const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId);
+ const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId);
+ const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
+ if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
+ if (candidates.length >= (options.limit || 100)) break;
+ }
+
+ return {
+ status: vcenterStatus(),
+ filter: options.filter || {},
+ count: candidates.length,
+ candidates
+ };
+}
diff --git a/server/settingsRegistry.js b/server/settingsRegistry.js
index 36940e7..a7c71ca 100644
--- a/server/settingsRegistry.js
+++ b/server/settingsRegistry.js
@@ -34,6 +34,11 @@ export function settingDefinitions() {
{ key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') },
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
- { key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') }
+ { key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
+ { key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
+ { key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
+ { key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
+ { key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') },
+ { key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') }
];
}
diff --git a/server/test/vcenter.test.mjs b/server/test/vcenter.test.mjs
new file mode 100644
index 0000000..d5b4777
--- /dev/null
+++ b/server/test/vcenter.test.mjs
@@ -0,0 +1,62 @@
+import './setup.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { load } from './setup.mjs';
+
+const {
+ buildHostPayloadFromVm,
+ normalizeBaseUrl,
+ normalizeVCenterVm,
+ vmMatchesFilter
+} = await load('../services/vcenterService.js');
+
+test('normalizeBaseUrl trims trailing slashes', () => {
+ assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
+});
+
+test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {
+ const candidate = normalizeVCenterVm(
+ { vm: 'vm-42', name: 'ENG-ENT-APP01', power_state: 'POWERED_ON' },
+ { host_name: 'eng-ent-app01.contoso.local', ip_address: '10.42.1.20', full_name: 'Microsoft Windows Server 2022' },
+ [{ ip: { ip_addresses: [{ ip_address: 'fe80::1' }, { ip_address: '10.42.1.21' }] } }]
+ );
+
+ assert.equal(candidate.id, 'vm-42');
+ assert.equal(candidate.name, 'ENG-ENT-APP01');
+ assert.equal(candidate.fqdn, 'eng-ent-app01.contoso.local');
+ assert.equal(candidate.address, 'eng-ent-app01.contoso.local');
+ assert.deepEqual(candidate.ipAddresses, ['10.42.1.20', '10.42.1.21']);
+ assert.equal(candidate.osFamily, 'windows');
+});
+
+test('vmMatchesFilter supports hostname contains and IP equals matching', () => {
+ const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] };
+
+ assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'eng-ent' }), true);
+ assert.equal(vmMatchesFilter(candidate, { field: 'ip', operator: 'equals', value: '10.42.1.20' }), true);
+ assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false);
+});
+
+test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => {
+ const payload = buildHostPayloadFromVm(
+ {
+ id: 'vm-42',
+ name: 'ENG-ENT-APP01',
+ fqdn: 'eng-ent-app01.contoso.local',
+ address: 'eng-ent-app01.contoso.local',
+ osFamily: 'windows',
+ powerState: 'POWERED_ON',
+ guestFullName: 'Microsoft Windows Server 2022',
+ ipAddresses: ['10.42.1.20']
+ },
+ { credentialId: 'cred_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
+ );
+
+ assert.equal(payload.credentialId, 'cred_1');
+ assert.equal(payload.transport, 'winrm');
+ assert.equal(payload.visibility, 'group');
+ assert.equal(payload.groupId, 'grp_1');
+ assert.ok(payload.tags.includes('vcenter:vm-42'));
+ assert.ok(payload.tags.includes('engineering'));
+ assert.match(payload.notes, /Imported from VMware vCenter VM vm-42/);
+});