From ff94c0cce5542c53cfa0895d9e83a6d7308c0592 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Thu, 25 Jun 2026 13:20:58 -0500 Subject: [PATCH] Initial --- .gitignore | 1 + README.md | 51 +++- client/src/App.vue | 103 +++++++- .../components/hosts/VCenterImportModal.vue | 240 ++++++++++++++++++ .../src/components/settings/ConfigSection.vue | 3 +- client/src/style.css | 177 ++++++++++++- package-lock.json | 10 + package.json | 1 + server/config.js | 7 + server/controllers/hostController.js | 47 +++- server/forms/schemas.js | 21 ++ server/models/hostModel.js | 20 ++ server/routes/hostRoutes.js | 5 +- server/services/vcenterService.js | 221 ++++++++++++++++ server/settingsRegistry.js | 7 +- server/test/vcenter.test.mjs | 62 +++++ 16 files changed, 968 insertions(+), 8 deletions(-) create mode 100644 client/src/components/hosts/VCenterImportModal.vue create mode 100644 server/services/vcenterService.js create mode 100644 server/test/vcenter.test.mjs 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.

+
@@ -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/); +});