diff --git a/README.md b/README.md index 8318c1e..3f7a292 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, modal add/edit flow, VMware vCenter VM import wizard, and manual/dynamic Host Groups for RunPlan and script-test targeting. +- Host Library with searchable/sortable/paginated table, modal add/edit flow, VMware vCenter VM import wizard, standalone ESXi connection records, and manual/dynamic Host Groups for RunPlan and script-test targeting. - 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. @@ -506,12 +506,12 @@ Payload: | --- | --- | --- | --- | | `GET` | `/api/hosts` | User | List host library. | | `POST` | `/api/hosts` | User | Create host. | -| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved vCenter systems. | -| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved vCenter systems. | -| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted vCenter system record. | -| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible vCenter system; omit password to keep the existing secret. | -| `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible vCenter system. | -| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Authenticate and list VM summaries to verify a vCenter system. | +| `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. | +| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted VMware connection record. Use `targetType: "vcenter"` for inventory or `targetType: "host"` for standalone ESXi Web Services testing. | +| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible VMware connection; omit password to keep the existing secret. | +| `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 host records call the vSphere Web Services SOAP endpoint. | | `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. | | `GET` | `/api/hosts/groups` | User | List visible manual and dynamic Host Groups with member counts. | @@ -550,7 +550,11 @@ target hosts visible to the operator who creates or edits them. `sourceType`, `sourceConnectionId`, and `sourceRef` are hidden provenance fields. Manual hosts default to `sourceType: "manual"`. vCenter imports set `sourceType: "vmware"`, `sourceConnectionId` to the selected vCenter system, -and `sourceRef` to the vCenter VM id. +and `sourceRef` to the vCenter VM id. `osFamily` is normalized to `windows`, +`linux`, or `other`; older values such as `network` or `api` are treated as +`other`. Manual host entry should set this explicitly when POSHManager cannot +infer it from inventory. vCenter imports infer Windows/Linux from VMware Tools +guest identity when available; unknown guests are imported as `other`. Host Groups let operators aggregate manual, imported, and VMware-sourced hosts without duplicating RunPlans. Manual groups store an explicit list of visible @@ -595,12 +599,17 @@ Dynamic group rule fields are `name`, `fqdn`, `address`, `tags`, `transport`, "all"` or `matchMode: "any"`. Operators can use `contains`, `equals`, `startsWith`, and `endsWith` operators. Rule matching is evaluated only against hosts the group owner/creator can see through personal/shared/group visibility. +The Host Groups Target Collections table includes search, sort, pagination, and +a read-only summary action. The summary view displays the rule/mode and assigned +machines, then exports CSV or Excel-readable `.xls` files with hostname, +IP address, and vCenter/VMware source system columns. -Create/update a saved vCenter system: +Create/update a saved vCenter inventory system: ```json { "name": "Production vCenter", + "targetType": "vcenter", "baseUrl": "https://vcenter.contoso.local", "username": "administrator@vsphere.local", "password": "stored-encrypted-on-save", @@ -613,10 +622,33 @@ Create/update a saved vCenter system: } ``` -Saved vCenter passwords are encrypted with the same credential-store key used -by the Credential Vault. Existing `vcenter_*` settings and `VCENTER_*` -environment variables remain supported as the `Configured default` import -connection for single-vCenter deployments or Docker-provided configuration. +Create/update a saved standalone ESXi host connection: + +```json +{ + "name": "Standalone ESXi 01", + "targetType": "host", + "baseUrl": "https://esxi01.contoso.local", + "username": "root", + "password": "stored-encrypted-on-save", + "apiMode": "web-services", + "requestTimeoutMs": 20000, + "allowUntrustedTls": true, + "enabled": true, + "visibility": "shared", + "groupId": null +} +``` + +Saved VMware passwords are encrypted with the same credential-store key used +by the Credential Vault. `targetType: "vcenter"` records use the vSphere +Automation API or legacy REST API for inventory and VM power-state checks. +`targetType: "host"` records are standalone host connections that use the +vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi +connectivity but cannot perform vCenter VM inventory imports. Existing +`vcenter_*` settings and `VCENTER_*` environment variables remain supported as +the `Configured default` import connection for single-vCenter deployments or +Docker-provided configuration. VMware vCenter import can use a saved vCenter system selected by `connectionId` or the legacy configured default from Config / Integrations (`vcenter_*` settings or `VCENTER_*` environment variables). `apiMode` / `vcenter_api_mode` can be `auto`, `api`, or `rest`. `api` uses the current vSphere Automation API flow (`POST /api/session`, `GET /api/vcenter/vm`). `rest` uses the legacy/community vCenter REST flow (`POST /rest/com/vmware/cis/session`, `GET /rest/vcenter/vm`). `auto` tries the current `/api` profile first and falls back to `/rest` when the endpoint is not supported. @@ -676,6 +708,9 @@ are logged and marked `skipped` for that host, so powered-off machines are not treated like script failures. If the vCenter connection is missing or the management API is temporarily unavailable, the runner logs the reason and continues execution rather than blocking the whole job on a control-plane issue. +Standalone ESXi host connection records do not provide vCenter VM inventory +state, so VMware-sourced imported guests continue to use their saved vCenter +source connection for pre-run power checks. ### Script Library @@ -688,6 +723,7 @@ continues execution rather than blocking the whole job on a control-plane issue. | `GET` | `/api/scripts` | User | List visible scripts. | | `POST` | `/api/scripts` | User | Create script and initial version. | | `GET` | `/api/scripts/:id` | User | Read script. | +| `POST` | `/api/scripts/:id/compatibility` | User | Analyze a script against selected host or Host Group targets before execution. | | `PUT` | `/api/scripts/:id` | User | Update script and create a version record. | | `DELETE` | `/api/scripts/:id` | User | Delete script. | | `GET` | `/api/scripts/:id/versions` | User | List script versions. | @@ -1112,6 +1148,7 @@ with per-host log rows. | `GET` | `/api/runplans` | User | List visible RunPlans. | | `POST` | `/api/runplans` | User | Create RunPlan. | | `GET` | `/api/runplans/:id` | User | Read RunPlan. | +| `GET` | `/api/runplans/:id/compatibility` | User | Analyze the RunPlan script against its current direct and Host Group targets. | | `PUT` | `/api/runplans/:id` | User | Update RunPlan. | | `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. | | `POST` | `/api/runplans/:id/execute` | User | Queue/execute a RunPlan. | @@ -1148,6 +1185,26 @@ curl -X POST http://localhost:3000/api/runplans/rp_123/execute \ -d '{}' ``` +Linux compatibility preflight: + +```bash +curl -X POST http://localhost:3000/api/scripts/scr_123/compatibility \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"hostIds":["hst_linux_01"],"hostGroupIds":["hg_mixed_targets"]}' + +curl http://localhost:3000/api/runplans/rp_123/compatibility \ + -H "Authorization: Bearer $TOKEN" +``` + +The compatibility response includes the selected target OS mix and findings +when Linux hosts are involved. The analyzer flags common cross-platform issues +such as Windows registry providers, `C:\` or UNC paths, Windows-only +executables such as `msiexec.exe`/`reg.exe`, WMI/`Win32_*` patterns, +Windows-specific environment variables, and alias-heavy script style. RunPlan +execution and Script Test / Run also write the same warning summary into +per-host job logs for Linux targets. + ### Settings And Logs | Method | Route | Auth | Description | @@ -1176,6 +1233,7 @@ The runner writes a temporary PowerShell wrapper and launches `pwsh` with enviro - `ssh` hosts use `Invoke-Command -HostName`. - Host credential resolution happens inside backend execution flows. - POSHManager runtime variables are injected as PowerShell variables and `POSHM_*` environment variables. +- `POSHM_HOST_OS_FAMILY` is injected with `windows`, `linux`, or `other` so scripts can branch on the target OS type. - Custom variables are decrypted server-side, token-rendered for `{{VariableName}}`, and injected into the script block as PowerShell variables before execution. - Asset references are token-rendered for `{{asset:}}` and exposed as `POSHM_ASSET_MANIFEST` JSON. Local paths point to the API container asset store; remote scripts must copy or otherwise stage those files before using them on target systems. - PSADT variables are not emulated by POSHManager; they are available when the script runs inside a valid PSAppDeployToolkit session. @@ -1183,6 +1241,11 @@ The runner writes a temporary PowerShell wrapper and launches `pwsh` with enviro Remote PowerShell prerequisites, firewall rules, remoting policy, SSH remoting, and target credentials must be configured outside POSHManager. +PowerShell on Linux requires a supported distro package/runtime and scripts must +avoid assuming Windows-only subsystems. Prefer full cmdlet names over aliases, +branch OS-specific logic with `$IsWindows`/`$IsLinux`, and test modules/cmdlets +on the target platform before broad execution. + ## Security Notes - SQLite access uses prepared statements through `node:sqlite`. diff --git a/client/src/App.vue b/client/src/App.vue index 0656811..498a5bf 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -331,6 +331,7 @@ > + @@ -462,7 +463,7 @@
- + @@ -1326,6 +1327,7 @@ const availableThemes = [ const hostColumns = [ { key: 'name', label: 'Name' }, { key: 'address', label: 'Address' }, + { key: 'osFamily', label: 'OS' }, { key: 'transport', label: 'Transport' }, { key: 'sourceType', label: 'Source' }, { key: 'credentialName', label: 'Credential' }, @@ -1469,6 +1471,7 @@ const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host) host.name, host.address, host.fqdn, + host.osFamily, host.transport, host.sourceType, host.sourceConnectionName, @@ -1945,6 +1948,10 @@ async function executeScriptTestRun(payload) { scriptTestPollFailures = 0; stopScriptTestPolling(); try { + if (!(await confirmLinuxCompatibility(`/api/scripts/${scriptTestTarget.value.id}/compatibility`, payload))) { + scriptTestRunning.value = false; + return; + } const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload); scriptTestJob.value = await api.get(`/api/jobs/${job.id}`).catch(() => job); startScriptTestPolling(job.id); @@ -2471,6 +2478,12 @@ async function saveHost() { notify('Host saved'); } +function osFamilyLabel(value) { + if (value === 'windows') return 'Windows'; + if (value === 'linux') return 'Linux'; + return 'Other'; +} + async function saveHostGroup(payload) { const body = { ...payload }; const groupId = body.id; @@ -2669,6 +2682,7 @@ function openRunPlanModal(runplan = null) { } async function executeRunPlan(id) { + if (!(await confirmLinuxCompatibility(`/api/runplans/${id}/compatibility`))) return; const job = await api.post(`/api/runplans/${id}/execute`, {}); await refreshAll(); await openJob(job.id); @@ -2676,6 +2690,20 @@ async function executeRunPlan(id) { notify('RunPlan queued'); } +async function confirmLinuxCompatibility(path, payload = null) { + const result = payload ? await api.post(path, payload) : await api.get(path); + const warnings = (result.findings || []).filter((finding) => finding.level === 'warning'); + if (!result.hasLinuxTargets || !warnings.length) return true; + const lines = warnings.slice(0, 5).map((finding) => `- ${finding.title}${finding.line ? ` line ${finding.line}` : ''}: ${finding.message}`); + return window.confirm([ + 'This script is targeting one or more Linux hosts and POSHManager found possible compatibility issues.', + '', + ...lines, + '', + 'Continue anyway? The same warnings will be written to the job logs.' + ].join('\n')); +} + async function openJob(id) { selectedJob.value = await api.get(`/api/jobs/${id}`); view.value = 'logs'; diff --git a/client/src/components/hosts/HostGroupsPanel.vue b/client/src/components/hosts/HostGroupsPanel.vue index b146f92..e4e0bee 100644 --- a/client/src/components/hosts/HostGroupsPanel.vue +++ b/client/src/components/hosts/HostGroupsPanel.vue @@ -8,23 +8,31 @@
@@ -33,13 +41,93 @@ + +
+
+ {{ summaryHosts.length }}assigned machines + {{ osBreakdown.windows }}windows + {{ osBreakdown.linux }}linux + {{ osBreakdown.other }}other +
+ +
+
+ RULE SUMMARY +

{{ summaryGroup.mode === 'dynamic' ? 'Dynamic rule set' : 'Manual membership' }}

+

{{ ruleSummary(summaryGroup) }}

+
+ {{ summaryGroup.mode }} +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
HostnameIP addressOSTransportvCenter System
+ {{ host.fqdn || host.name }} + {{ host.name }} + {{ host.address || '-' }}{{ osFamilyLabel(host.osFamily) }}{{ host.transport || '-' }}{{ host.sourceConnectionName || (host.sourceType === 'vmware' ? 'Unknown VMware source' : '-') }}
No machines are currently assigned to this collection.
+
+ + +
+
+ + @@ -105,7 +202,7 @@ Clear page @@ -123,10 +220,11 @@ @change="$event.target.checked ? selectPage() : clearPage()" /> - - - - + + + + + Tags @@ -140,12 +238,13 @@ {{ host.fqdn || 'No FQDN recorded' }} {{ host.address || '-' }} + {{ osFamilyLabel(host.osFamily) }} {{ host.transport || '-' }} {{ sourceLabel(host.sourceType) }} {{ tagSummary(host) }} - No hosts match the current filters. + No hosts match the current filters. @@ -163,10 +262,10 @@ Page {{ Math.min(memberPage, memberPageCount) }} of {{ memberPageCount }}
@@ -193,15 +292,15 @@ - +
@@ -210,7 +309,6 @@ diff --git a/client/src/components/settings/VCenterConnectionsConfig.vue b/client/src/components/settings/VCenterConnectionsConfig.vue index c8bd9e9..128ab22 100644 --- a/client/src/components/settings/VCenterConnectionsConfig.vue +++ b/client/src/components/settings/VCenterConnectionsConfig.vue @@ -3,62 +3,89 @@
CONFIG / VMWARE -

VMware vCenter

-

Register one or more vCenter systems used for host imports and execution-time power checks.

+

VMware connections

+

Register vCenter inventory systems or standalone ESXi hosts for VMware-aware host management.

Inventory sources -

Imported VMware hosts keep their source connection and VM id so runs can skip guests that are powered off.

+

vCenter entries import VM inventory and power-state checks. Standalone host entries validate direct ESXi connections through the vSphere Web Services API.

{{ connections.length }}systems {{ enabledCount }}enabled + {{ vcenterCount }}vCenter + {{ standaloneCount }}hosts {{ testedCount }}tested
- vCenter systems + VMware systems
{{ connection.name }} - {{ connection.baseUrl }} - {{ connection.apiMode }} - {{ connection.lastTestStatus || 'untested' }} + {{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.apiMode }} - {{ connection.lastTestStatus || 'untested' }} - - - + + +
-

No saved vCenter systems configured. The legacy env/settings default can still be used by import.

+

No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.

- - - + + + + - + - - + +
@@ -66,8 +93,7 @@ diff --git a/client/src/components/ui/TargetBrowser.vue b/client/src/components/ui/TargetBrowser.vue index 11b8025..3b84f79 100644 --- a/client/src/components/ui/TargetBrowser.vue +++ b/client/src/components/ui/TargetBrowser.vue @@ -21,6 +21,15 @@ + @@ -141,6 +151,7 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue']); const search = ref(''); +const osFamily = ref(''); const source = ref(''); const transport = ref(''); const selectedOnly = ref(false); @@ -161,6 +172,7 @@ const columns = computed(() => props.kind === 'group' : [ { key: 'name', label: 'Host' }, { key: 'address', label: 'Address' }, + { key: 'osFamily', label: 'OS' }, { key: 'transport', label: 'Transport' }, { key: 'sourceType', label: 'Source' }, { key: 'tags', label: 'Tags', sortable: false } @@ -172,12 +184,13 @@ const filteredItems = computed(() => { const selected = new Set(selectedIds.value); return props.items.filter((item) => { if (selectedOnly.value && !selected.has(item.id)) return false; + if (props.kind === 'host' && osFamily.value && (item.osFamily || 'other') !== osFamily.value) return false; if (props.kind === 'host' && source.value && (item.sourceType || 'manual') !== source.value) return false; if (props.kind === 'host' && transport.value && item.transport !== transport.value) return false; if (!term) return true; const values = props.kind === 'group' ? [item.name, item.mode, item.description, groupRuleSummary(item)] - : [item.name, item.fqdn, item.address, item.transport, item.sourceType, ...(item.tags || [])]; + : [item.name, item.fqdn, item.address, item.osFamily, item.transport, item.sourceType, ...(item.tags || [])]; return values.filter(Boolean).some((value) => String(value).toLowerCase().includes(term)); }).sort((a, b) => { const result = sortableValue(a, sort.value).localeCompare(sortableValue(b, sort.value), undefined, { numeric: true, sensitivity: 'base' }); @@ -188,7 +201,7 @@ const pageCount = computed(() => Math.max(1, Math.ceil(filteredItems.value.lengt const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, (page.value - 1) * pageSize.value + pageSize.value)); const pageAllSelected = computed(() => Boolean(pagedItems.value.length) && pagedItems.value.every((item) => selectedIds.value.includes(item.id))); -watch([search, source, transport, selectedOnly, pageSize], () => { +watch([search, osFamily, source, transport, selectedOnly, pageSize], () => { page.value = 1; }); @@ -235,6 +248,7 @@ function uniqueOptions(values) { } function sortableValue(item, key) { + if (key === 'osFamily') return item.osFamily || 'other'; if (key === 'sourceType') return item.sourceType || 'manual'; return String(item[key] || ''); } @@ -249,6 +263,12 @@ function sourceLabel(value = 'manual') { return value === 'vmware' ? 'VMware' : 'Manual'; } +function osFamilyLabel(value = 'other') { + if (value === 'windows') return 'Windows'; + if (value === 'linux') return 'Linux'; + return 'Other'; +} + function groupRuleSummary(item) { if (item.mode === 'manual') return 'Manual membership'; return (item.rules || []).map((rule) => `${rule.field} ${rule.operator} ${rule.value}`).join(` ${item.matchMode || 'all'} `) || 'No rules'; diff --git a/client/src/style.css b/client/src/style.css index 0b5ecb1..849daee 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -4024,6 +4024,28 @@ textarea:focus { color: var(--cyan); } +.compatibility-callout { + padding: 12px 14px; + border: 1px solid rgba(255, 196, 87, .24); + border-radius: 15px; + background: + linear-gradient(135deg, rgba(255, 196, 87, .13), rgba(88, 221, 255, .045)), + rgba(8, 13, 30, .72); +} + +.compatibility-callout strong { + display: block; + color: #ffe7a6; + font: 800 12px var(--display-font); +} + +.compatibility-callout p { + margin: 5px 0 0; + color: rgba(226, 237, 250, .78); + font-size: 12px; + line-height: 1.45; +} + .script-test-output { min-height: 360px; display: grid; @@ -4678,6 +4700,111 @@ body { flex-wrap: wrap; } +.material-icon-button { + width: 34px; + height: 34px; + min-width: 34px; + padding: 0; + border: 1px solid rgba(156, 199, 232, .16); + border-radius: 11px; + display: inline-grid; + place-items: center; + color: rgba(220, 237, 255, .84); + background: + linear-gradient(180deg, rgba(255, 255, 255, .09), rgba(255, 255, 255, .035)), + rgba(6, 15, 30, .62); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055); +} + +.material-icon-button:hover { + color: #fff; + border-color: rgba(88, 221, 255, .42); + background: + radial-gradient(circle at 24% 0, rgba(88, 221, 255, .18), transparent 42%), + rgba(8, 18, 38, .82); +} + +.primary-action i, +.ghost-button i, +.material-icon-button i { + flex: 0 0 auto; + font-size: 16px; + line-height: 1; +} + +.host-group-summary-modal { + display: grid; + gap: 14px; +} + +.summary-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.summary-metrics span { + min-height: 74px; + padding: 13px; + border: 1px solid rgba(156, 199, 232, .13); + border-radius: 16px; + display: grid; + align-content: center; + gap: 5px; + background: + radial-gradient(circle at 12% 0, rgba(88, 221, 255, .12), transparent 40%), + linear-gradient(145deg, rgba(15, 32, 58, .74), rgba(5, 12, 28, .7)); +} + +.summary-metrics strong { + color: #fff; + font-size: 22px; + line-height: 1; +} + +.summary-metrics small { + color: rgba(190, 215, 238, .72); + font: 800 9px/1.2 var(--display-font); + letter-spacing: .08em; + text-transform: uppercase; +} + +.summary-rule-panel { + padding: 14px; + border: 1px solid rgba(156, 199, 232, .13); + border-radius: 16px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: + linear-gradient(135deg, rgba(88, 221, 255, .07), rgba(166, 122, 255, .055)), + rgba(4, 12, 28, .58); +} + +.summary-rule-panel h4 { + margin: 3px 0 5px; + color: rgba(248, 251, 255, .96); + font-size: 15px; +} + +.summary-rule-panel p { + margin: 0; + color: rgba(206, 222, 240, .74); + font-size: 12px; +} + +.summary-actions { + display: flex; + justify-content: flex-end; + gap: 9px; +} + +.readonly-members { + max-height: 420px; + overflow: auto; +} + .vault-state { display: inline-flex; align-items: center; @@ -4775,7 +4902,7 @@ body { .member-toolbar { display: grid; - grid-template-columns: minmax(280px, 1fr) minmax(150px, 190px) minmax(150px, 190px) auto; + grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(120px, 160px)) auto; gap: 10px; align-items: end; } @@ -4803,7 +4930,8 @@ body { position: relative; } -.member-search svg { +.member-search svg, +.member-search > i { position: absolute; left: 13px; bottom: 13px; @@ -5000,6 +5128,16 @@ body { } @media (max-width: 720px) { + .summary-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .summary-rule-panel, + .summary-actions { + align-items: stretch; + flex-direction: column; + } + .member-browser-header, .member-results-bar, .member-pagination { @@ -5116,6 +5254,10 @@ body { .status-pill.warn, .status-pill.running { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); } +.status-pill.windows { color: #8be0ff; background: rgba(88, 221, 255, .1); border-color: rgba(88, 221, 255, .24); } +.status-pill.linux { color: #64e7bd; background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .24); } +.status-pill.other { color: rgba(226, 237, 250, .72); background: rgba(156, 199, 232, .08); border-color: rgba(156, 199, 232, .18); } + .pagination { display: flex; align-items: center; diff --git a/server/controllers/hostController.js b/server/controllers/hostController.js index c91c830..5893efc 100644 --- a/server/controllers/hostController.js +++ b/server/controllers/hostController.js @@ -82,7 +82,7 @@ export async function previewVCenterImport(req, res) { if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' }); try { const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null; - if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' }); + if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'VMware connection not found' }); res.json(await previewVCenterHosts({ ...parsed.data, connection })); } catch (err) { res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] }); @@ -94,7 +94,7 @@ export async function importVCenterHosts(req, res) { if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' }); try { const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null; - if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' }); + if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'VMware connection not found' }); const preview = await previewVCenterHosts({ ...parsed.data, connection }); const selected = new Set(parsed.data.vmIds || []); const candidates = selected.size @@ -125,16 +125,16 @@ export function listVCenterConnectionRecords(req, res) { export function createVCenterConnectionRecord(req, res) { const parsed = vcenterConnectionSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' }); - if (!parsed.data.password) return res.status(400).json({ error: 'A vCenter password is required when creating a connection' }); + if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' }); + if (!parsed.data.password) return res.status(400).json({ error: 'A VMware password is required when creating a connection' }); res.status(201).json(createVCenterConnection(parsed.data, req.user.id)); } export function updateVCenterConnectionRecord(req, res) { const parsed = vcenterConnectionSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' }); + if (!parsed.success) return res.status(400).json({ error: 'Invalid VMware connection payload' }); const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id); - if (!connection) return res.status(404).json({ error: 'vCenter connection not found' }); + if (!connection) return res.status(404).json({ error: 'VMware connection not found' }); res.json(connection); } @@ -145,7 +145,7 @@ export function deleteVCenterConnectionRecord(req, res) { export async function testVCenterConnectionRecord(req, res) { const connection = getVCenterConnection(req.params.connectionId, req.user.id, true); - if (!connection) return res.status(404).json({ error: 'vCenter connection not found' }); + if (!connection) return res.status(404).json({ error: 'VMware connection not found' }); try { const result = await testVCenterConnection(connection); recordVCenterConnectionTest(req.params.connectionId, 'success', result.message); diff --git a/server/controllers/libraryController.js b/server/controllers/libraryController.js index 826f02f..711a317 100644 --- a/server/controllers/libraryController.js +++ b/server/controllers/libraryController.js @@ -1,4 +1,4 @@ -import { folderSchema, scriptTestRunSchema } from '../forms/schemas.js'; +import { folderSchema, scriptCompatibilitySchema, scriptTestRunSchema } from '../forms/schemas.js'; import { cloneScript, createFolder, @@ -15,6 +15,7 @@ import { } from '../models/libraryModel.js'; import { camelJob } from '../models/mappers.js'; import { executeScriptTest } from '../services/powershellRunner.js'; +import { analyzeScriptTargetCompatibility } from '../services/powershellCompatibilityService.js'; export function folders(req, res) { res.json(listFolders(req.user.id)); @@ -84,6 +85,14 @@ export function scriptUsageAction(req, res) { res.json(usage); } +export function scriptCompatibilityAction(req, res) { + const parsed = scriptCompatibilitySchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: 'Invalid compatibility payload' }); + const result = analyzeScriptTargetCompatibility(req.params.id, parsed.data, req.user.id); + if (!result) return res.status(404).json({ error: 'Script not found' }); + res.json(result); +} + export function testRunScriptAction(req, res) { const parsed = scriptTestRunSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' }); diff --git a/server/controllers/runPlanController.js b/server/controllers/runPlanController.js index 776fd6b..540a8e3 100644 --- a/server/controllers/runPlanController.js +++ b/server/controllers/runPlanController.js @@ -1,6 +1,7 @@ import { camelJob } from '../models/mappers.js'; import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js'; import { executeRunPlan } from '../services/powershellRunner.js'; +import { analyzeRunPlanCompatibility } from '../services/powershellCompatibilityService.js'; export function index(req, res) { res.json(listRunPlans(req.user.id)); @@ -37,3 +38,9 @@ export function execute(req, res) { res.status(400).json({ error: error.message }); } } + +export function compatibility(req, res) { + const result = analyzeRunPlanCompatibility(req.params.id, req.user.id); + if (!result) return res.status(404).json({ error: 'RunPlan not found' }); + res.json(result); +} diff --git a/server/data/helpCatalog.js b/server/data/helpCatalog.js index 7e5633f..165b6c3 100644 --- a/server/data/helpCatalog.js +++ b/server/data/helpCatalog.js @@ -62,7 +62,14 @@ const operationsArticles = [ article('ops-hosts-runplans', 'Operations', 'Hosts, Credential Vault, RunPlans, And Jobs', 'Manage target systems and execute scripts safely.', [ section('Hosts and credentials', [ 'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.', - 'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.' + 'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.', + 'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.', + 'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.' + ]), + section('Host Groups and VMware sources', [ + 'Host Groups are target collections. The table supports search, sort, pagination, a read-only summary view, and CSV/XLS exports of assigned hostnames, IP addresses, and VMware source system.', + 'Config / VMware supports two connection types: vCenter inventory systems and standalone ESXi hosts. vCenter systems can import VM inventory and run pre-execution power-state checks.', + 'Standalone ESXi host connections use the vSphere Web Services API for direct host validation. They are not vCenter inventory sources, so they cannot be selected in the VM import wizard.' ]), section('RunPlans', [ 'A RunPlan joins one script with one or more hosts. The backend creates a job and host-level log rows during execution.', @@ -116,11 +123,18 @@ const apiArticles = [ article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [ apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'), apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'), + apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'), + apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'), apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'), apiExample('Post', '/api/runplans/rp_123/execute', 'Execute a RunPlan and create a job. Replace rp_123 with the RunPlan id.'), apiExample('Get', '/api/jobs', 'List job history.'), apiExample('Get', '/api/jobs/job_123', 'Read one job with host rows and logs.'), apiExample('Get', '/api/logs/system', 'Read system/request logs from the Winston log stream.') + , + section('VMware connection payloads', [ + 'Saved VMware connection records use targetType = vcenter for inventory import/power-state checks or targetType = host for a standalone ESXi host connection.', + 'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Inventory preview/import requires a vCenter connection.' + ], '@{\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}') ], ['api', 'credentials', 'hosts', 'runplans', 'jobs']), article('api-psadt', 'API', 'PSADT And Intune API', 'Use the PSADT catalog, verifier, migration wizard, profiles, and Intune publishing plans through API calls.', [ apiExample('Get', '/api/psadt/catalog', 'Return PSADT source links, module/function/ADMX catalogs, snippets, and Intune publishing guidance.'), @@ -229,6 +243,24 @@ const cmdletArticles = [ ]), section('Discovery examples', [], 'Get-Help Invoke-Command -Detailed\nGet-Command -Module Microsoft.PowerShell.Core\nGet-Command *PSSession*\nUpdate-Help -Force') ], ['powershell', 'cmdlets', 'reference']), + article('powershell-linux-compatibility', 'PowerShell', 'Linux Compatibility Checks', 'Understand how POSHManager warns about scripts that may not work on Linux targets.', [ + section('Host OS type', [ + 'Hosts carry osFamily metadata: windows, linux, or other. The UI shows this in Hosts, Host Groups, RunPlan target pickers, and script test target summaries.', + 'Use Other when the OS is unknown. POSHManager only runs Linux compatibility warnings when at least one selected target is marked Linux.' + ]), + section('Common findings', [ + 'Registry providers such as HKLM: and HKCU: are Windows-specific.', + 'Drive paths such as C:\\Temp and UNC paths assume Windows filesystem semantics.', + 'Windows executables such as msiexec.exe, reg.exe, cmd.exe, and wmic.exe are not expected on Linux.', + 'WMI and Win32_* classes are Windows-specific inventory patterns.', + 'Aliases should be avoided in reusable scripts; use full command names such as Get-ChildItem, Where-Object, and ForEach-Object.' + ]), + section('Runtime behavior', [ + 'Script Test / Run and RunPlan execution call the compatibility preflight before queueing. If warnings are found, the UI asks for confirmation.', + 'The runner also writes compatibility findings into Linux target job logs so API-driven execution receives the same operational evidence.', + 'POSHM_HOST_OS_FAMILY is injected during execution so scripts can branch with the stored host OS type.' + ]) + ], ['linux', 'compatibility', 'powershell', 'hosts']), article('cmdlets-remoting', 'PowerShell', 'PowerShell Remoting Cmdlets', 'Useful cmdlets for WinRM/PSSession workflows used around POSHManager.', [ section('Session workflow', [ 'Enable-PSRemoting configures a computer to receive remote commands. This must be done on targets outside POSHManager.', diff --git a/server/db.js b/server/db.js index 167096e..4a57c2b 100644 --- a/server/db.js +++ b/server/db.js @@ -108,6 +108,7 @@ export function migrate() { secret_cipher TEXT NOT NULL, secret_iv TEXT NOT NULL, secret_tag TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT 'vcenter', api_mode TEXT NOT NULL DEFAULT 'auto', request_timeout_ms INTEGER NOT NULL DEFAULT 20000, allow_untrusted_tls INTEGER NOT NULL DEFAULT 0, @@ -511,6 +512,9 @@ export function migrate() { ensureColumn('hosts', 'source_type', "TEXT NOT NULL DEFAULT 'manual'"); ensureColumn('hosts', 'source_connection_id', 'TEXT'); ensureColumn('hosts', 'source_ref', 'TEXT'); + // VMware connection type separates vCenter inventory/power-state APIs from + // standalone ESXi host checks that use the vSphere Web Services SOAP API. + ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'"); // 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. ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0'); diff --git a/server/forms/schemas.js b/server/forms/schemas.js index b5b6509..8c8e9d1 100644 --- a/server/forms/schemas.js +++ b/server/forms/schemas.js @@ -1,5 +1,6 @@ import { z } from 'zod'; import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js'; +import { normalizeOsFamily } from '../utils/osFamily.js'; export const loginSchema = z.object({ email: z.string().email(), @@ -53,7 +54,7 @@ export const hostSchema = z.object({ name: z.string().min(1), fqdn: z.string().optional(), address: z.string().min(1), - osFamily: z.string().default('windows'), + osFamily: z.preprocess((value) => normalizeOsFamily(value, 'windows'), z.enum(['windows', 'linux', 'other'])).default('windows'), transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'), port: z.number().int().nullable().optional(), credentialId: z.string().nullable().optional(), @@ -68,10 +69,11 @@ export const hostSchema = z.object({ export const vcenterConnectionSchema = z.object({ name: z.string().min(1).max(120), + targetType: z.enum(['vcenter', 'host']).optional().default('vcenter'), baseUrl: z.string().url(), username: z.string().min(1).max(200), password: z.string().optional().default(''), - apiMode: z.enum(['auto', 'api', 'rest']).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), allowUntrustedTls: z.boolean().optional().default(false), enabled: z.boolean().optional().default(true), @@ -364,6 +366,13 @@ const runPlanPayloadSchema = z.object({ hostGroupIds: z.array(z.string()).optional() }); +export const scriptCompatibilitySchema = z.object({ + hostId: z.string().optional().default(''), + hostGroupId: z.string().optional().default(''), + hostIds: z.array(z.string()).optional().default([]), + hostGroupIds: z.array(z.string()).optional().default([]) +}); + export function normalizeScriptPayload(body) { const parsed = scriptPayloadSchema.parse(body); return { diff --git a/server/models/hostModel.js b/server/models/hostModel.js index 7ce2252..0114ffa 100644 --- a/server/models/hostModel.js +++ b/server/models/hostModel.js @@ -1,5 +1,6 @@ import { db, id, json, now, parseJson, visibleClause } from '../db.js'; import { camelHost } from './mappers.js'; +import { normalizeOsFamily } from '../utils/osFamily.js'; export function listHosts(userId) { return db.prepare(` @@ -29,7 +30,7 @@ export function createHost(payload, userId) { payload.name, payload.fqdn || '', payload.address, - payload.osFamily, + normalizeOsFamily(payload.osFamily, 'windows'), payload.transport, payload.port || null, payload.credentialId || null, @@ -80,7 +81,7 @@ export function updateHost(hostId, payload, userId) { payload.name || existing.name, payload.fqdn ?? existing.fqdn, payload.address || existing.address, - payload.osFamily || existing.os_family, + normalizeOsFamily(payload.osFamily ?? existing.os_family, 'windows'), payload.transport || existing.transport, payload.port ?? existing.port, payload.credentialId ?? existing.credential_id, diff --git a/server/models/mappers.js b/server/models/mappers.js index 67431b2..fb0d74f 100644 --- a/server/models/mappers.js +++ b/server/models/mappers.js @@ -1,4 +1,5 @@ import { parseJson } from '../db.js'; +import { normalizeOsFamily } from '../utils/osFamily.js'; export function camelGroup(row) { return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at }; @@ -26,7 +27,7 @@ export function camelHost(row) { name: row.name, fqdn: row.fqdn, address: row.address, - osFamily: row.os_family, + osFamily: normalizeOsFamily(row.os_family, 'other'), transport: row.transport, port: row.port, credentialId: row.credential_id, @@ -53,6 +54,7 @@ export function camelVCenterConnection(row) { baseUrl: row.base_url, username: row.username, hasPassword: Boolean(row.secret_cipher), + targetType: row.target_type || 'vcenter', apiMode: row.api_mode || 'auto', requestTimeoutMs: row.request_timeout_ms || 20000, allowUntrustedTls: Boolean(row.allow_untrusted_tls), diff --git a/server/models/vcenterConnectionModel.js b/server/models/vcenterConnectionModel.js index 45d1d23..b5b0b68 100644 --- a/server/models/vcenterConnectionModel.js +++ b/server/models/vcenterConnectionModel.js @@ -9,8 +9,14 @@ function normalizeConnection(body, existing = {}) { ...body, password: body.password || '' }); + const targetType = parsed.targetType || 'vcenter'; + const apiMode = targetType === 'host' + ? 'web-services' + : (parsed.apiMode === 'web-services' ? 'auto' : parsed.apiMode); return { ...parsed, + targetType, + apiMode, baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''), groupId: parsed.visibility === 'group' ? parsed.groupId || null : null }; @@ -52,10 +58,10 @@ export function createVCenterConnection(body, userId) { db.prepare(` INSERT INTO vcenter_connections ( id, name, base_url, username, secret_cipher, secret_iv, secret_tag, - 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 ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( connectionId, payload.name, @@ -64,6 +70,7 @@ export function createVCenterConnection(body, userId) { encrypted.cipher, encrypted.iv, encrypted.tag, + payload.targetType, payload.apiMode, payload.requestTimeoutMs, payload.allowUntrustedTls ? 1 : 0, @@ -85,6 +92,7 @@ export function updateVCenterConnection(connectionId, body, userId) { name: existing.name, baseUrl: existing.base_url, username: existing.username, + targetType: existing.target_type || 'vcenter', apiMode: existing.api_mode, requestTimeoutMs: existing.request_timeout_ms, allowUntrustedTls: Boolean(existing.allow_untrusted_tls), @@ -100,7 +108,7 @@ export function updateVCenterConnection(connectionId, body, userId) { db.prepare(` UPDATE vcenter_connections SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?, - 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 = ? WHERE id = ? `).run( @@ -110,6 +118,7 @@ export function updateVCenterConnection(connectionId, body, userId) { encrypted.cipher, encrypted.iv, encrypted.tag, + payload.targetType, payload.apiMode, payload.requestTimeoutMs, payload.allowUntrustedTls ? 1 : 0, diff --git a/server/routes/libraryRoutes.js b/server/routes/libraryRoutes.js index f7768be..c49e210 100644 --- a/server/routes/libraryRoutes.js +++ b/server/routes/libraryRoutes.js @@ -7,6 +7,7 @@ import { deleteScriptAction, folders, getScriptAction, + scriptCompatibilityAction, scriptUsageAction, scripts, scriptVersions, @@ -28,6 +29,7 @@ folderRoutes.delete('/:id', requireAuth, deleteFolderAction); scriptRoutes.get('/', requireAuth, scripts); scriptRoutes.post('/', requireAuth, createScriptAction); scriptRoutes.get('/:id/usage', requireAuth, scriptUsageAction); +scriptRoutes.post('/:id/compatibility', requireAuth, scriptCompatibilityAction); scriptRoutes.post('/:id/clone', requireAuth, cloneScriptAction); scriptRoutes.post('/:id/test-run', requireAuth, testRunScriptAction); scriptRoutes.get('/:id', requireAuth, getScriptAction); diff --git a/server/routes/runPlanRoutes.js b/server/routes/runPlanRoutes.js index aabc2e3..6dfe046 100644 --- a/server/routes/runPlanRoutes.js +++ b/server/routes/runPlanRoutes.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { create, destroy, execute, index, show, update } from '../controllers/runPlanController.js'; +import { compatibility, create, destroy, execute, index, show, update } from '../controllers/runPlanController.js'; import { requireAuth } from '../middleware/auth.js'; export const runPlanRoutes = Router(); @@ -8,6 +8,7 @@ export const runPlanRoutes = Router(); runPlanRoutes.get('/', requireAuth, index); runPlanRoutes.post('/', requireAuth, create); runPlanRoutes.get('/:id', requireAuth, show); +runPlanRoutes.get('/:id/compatibility', requireAuth, compatibility); runPlanRoutes.put('/:id', requireAuth, update); runPlanRoutes.delete('/:id', requireAuth, destroy); runPlanRoutes.post('/:id/execute', requireAuth, execute); diff --git a/server/services/powershellCompatibilityService.js b/server/services/powershellCompatibilityService.js new file mode 100644 index 0000000..caad61a --- /dev/null +++ b/server/services/powershellCompatibilityService.js @@ -0,0 +1,334 @@ +import { normalizeOsFamily } from '../utils/osFamily.js'; +import { db } from '../db.js'; + +const WINDOWS_EXECUTABLES = [ + 'cmd.exe', + 'powershell.exe', + 'wmic.exe', + 'msiexec.exe', + 'reg.exe', + 'sc.exe', + 'net.exe', + 'netsh.exe', + 'schtasks.exe', + 'whoami.exe', + 'gpupdate.exe' +]; + +const ALIASES_TO_AVOID = { + '%': 'ForEach-Object', + '?': 'Where-Object', + cat: 'Get-Content', + cd: 'Set-Location', + cls: 'Clear-Host', + copy: 'Copy-Item', + cp: 'Copy-Item', + del: 'Remove-Item', + dir: 'Get-ChildItem', + echo: 'Write-Output', + erase: 'Remove-Item', + h: 'Get-History', + history: 'Get-History', + kill: 'Stop-Process', + ls: 'Get-ChildItem', + md: 'mkdir', + move: 'Move-Item', + mv: 'Move-Item', + popd: 'Pop-Location', + ps: 'Get-Process', + pushd: 'Push-Location', + pwd: 'Get-Location', + r: 'Invoke-History', + rd: 'Remove-Item', + ren: 'Rename-Item', + rm: 'Remove-Item', + rmdir: 'Remove-Item', + sl: 'Set-Location', + sort: 'Sort-Object', + type: 'Get-Content', + where: 'Where-Object' +}; + +const RULES = [ + { + id: 'windows-registry-provider', + level: 'warning', + title: 'Windows registry provider usage', + pattern: /\b(?:HKLM:|HKCU:|HKCR:|HKU:|Registry::|SOFTWARE\\|SYSTEM\\CurrentControlSet\\)/i, + message: 'The script references Windows registry providers or registry paths, which are not available on Linux targets.', + remediation: 'Guard registry logic with $IsWindows or split the script by OS target.' + }, + { + id: 'windows-drive-or-unc-path', + level: 'warning', + title: 'Windows filesystem path usage', + pattern: /(?:\b[A-Za-z]:\\|\\\\[A-Za-z0-9_.-]+\\)/, + message: 'The script contains Windows drive or UNC paths. Linux PowerShell uses Linux paths and case-sensitive filesystems.', + remediation: 'Use Join-Path and OS-aware path roots, or branch path logic with $IsWindows/$IsLinux.' + }, + { + id: 'windows-environment-variable', + level: 'warning', + title: 'Windows environment variable usage', + pattern: /\$env:(?:ProgramFiles|ProgramFiles\(x86\)|SystemRoot|WINDIR|COMPUTERNAME|USERPROFILE|APPDATA|LOCALAPPDATA)\b/i, + message: 'The script references Windows-specific environment variables that are usually absent on Linux.', + remediation: 'Use cross-platform variables such as $HOME where possible, or provide Linux-specific fallbacks.' + }, + { + id: 'wmi-or-win32-class', + level: 'warning', + title: 'WMI or Win32 class usage', + pattern: /\b(?:Get-WmiObject|gwmi|Win32_[A-Za-z0-9_]+|root\\cimv2)\b/i, + message: 'The script uses WMI/Win32 inventory patterns that are Windows-specific and commonly fail on Linux.', + remediation: 'Use cross-platform PowerShell/.NET APIs, SSH-native commands, or OS-specific branches.' + }, + { + id: 'windows-service-or-eventlog-cmdlet', + level: 'warning', + title: 'Windows service or event log cmdlet usage', + pattern: /\b(?:Get-EventLog|New-EventLog|Write-EventLog|Get-WinEvent|Get-Service|Set-Service|Restart-Service)\b/i, + message: 'This cmdlet often targets Windows service or event-log subsystems and may not behave the same on Linux hosts.', + remediation: 'Validate Linux behavior explicitly or wrap the command in an OS-specific branch.' + }, + { + id: 'windows-executable', + level: 'warning', + title: 'Windows executable usage', + pattern: new RegExp(`\\b(?:${WINDOWS_EXECUTABLES.map(escapeRegExp).join('|')})\\b`, 'i'), + message: 'The script launches Windows executables that are not expected to exist on Linux targets.', + remediation: 'Replace with PowerShell-native commands, Linux equivalents, or target Windows hosts only.' + } +]; + +export function analyzePowerShellCompatibility(content, targets = []) { + const targetOsFamilies = [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family)).filter(Boolean))]; + const hasLinuxTargets = targetOsFamilies.includes('linux'); + const findings = []; + + if (!hasLinuxTargets) { + return { + targetOsFamilies, + hasLinuxTargets, + findings, + summary: 'No Linux targets selected.' + }; + } + + const text = String(content || ''); + const lines = text.split(/\r?\n/); + for (const rule of RULES) { + const match = findRuleMatch(lines, rule.pattern); + if (match) findings.push(toFinding(rule, match)); + } + + for (const match of findAliasMatches(lines).slice(0, 6)) { + findings.push({ + id: `alias-${match.alias}`, + level: 'info', + title: `Alias "${match.alias}" used`, + message: `Alias "${match.alias}" maps to ${match.command}. Aliases can be profile or host dependent, so they are risky in scripts that run across Windows and Linux.`, + remediation: `Use the full command name ${match.command}.`, + line: match.line, + excerpt: match.excerpt, + source: 'Microsoft PowerShell alias guidance' + }); + } + + if (!findings.length) { + findings.push({ + id: 'linux-ready', + level: 'ok', + title: 'No obvious Linux blockers detected', + message: 'Static analysis did not find common Windows-only patterns. Runtime behavior still depends on installed modules and host configuration.', + remediation: 'Run a controlled test against a Linux host before broad execution.', + line: null, + excerpt: '', + source: 'POSHManager compatibility analyzer' + }); + } + + return { + targetOsFamilies, + hasLinuxTargets, + findings, + summary: `${findings.filter((finding) => finding.level === 'warning').length} warning(s), ${findings.filter((finding) => finding.level === 'info').length} note(s) for Linux targets.` + }; +} + +export function analyzeScriptTargetCompatibility(scriptId, payload, userId) { + const script = db.prepare(` + SELECT * + FROM scripts + WHERE id = ? AND ( + visibility = 'shared' + OR owner_user_id = ? + OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) + ) + `).get(scriptId, userId, userId); + if (!script) return null; + const targets = resolveVisibleCompatibilityTargets(payload, userId); + return { + scriptId: script.id, + scriptName: script.name, + targets: summarizeTargets(targets), + ...analyzePowerShellCompatibility(script.content, targets) + }; +} + +export function analyzeRunPlanCompatibility(runplanId, userId) { + const runplan = db.prepare(` + SELECT r.*, s.content AS script_content, s.name AS script_name + FROM runplans r + JOIN scripts s ON s.id = r.script_id + WHERE r.id = ? AND ( + r.visibility = 'shared' + OR r.owner_user_id = ? + OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) + ) + `).get(runplanId, userId, userId); + if (!runplan) return null; + const targets = resolveRunPlanTargets(runplanId); + return { + runplanId, + scriptId: runplan.script_id, + scriptName: runplan.script_name, + targets: summarizeTargets(targets), + ...analyzePowerShellCompatibility(runplan.script_content, targets) + }; +} + +export function compatibilityLogLines(analysis) { + if (!analysis?.hasLinuxTargets) return []; + return [ + `Linux target compatibility preflight: ${analysis.summary}`, + ...analysis.findings + .filter((finding) => finding.level !== 'ok') + .slice(0, 10) + .map((finding) => `${finding.level.toUpperCase()}: ${finding.title}${finding.line ? ` on line ${finding.line}` : ''}. ${finding.message} ${finding.remediation}`) + ]; +} + +function findRuleMatch(lines, pattern) { + for (const [index, line] of lines.entries()) { + if (pattern.test(line)) return { line: index + 1, excerpt: line.trim().slice(0, 260) }; + } + return null; +} + +function toFinding(rule, match) { + return { + id: rule.id, + level: rule.level, + title: rule.title, + message: rule.message, + remediation: rule.remediation, + line: match.line, + excerpt: match.excerpt, + source: 'PowerShell cross-platform compatibility' + }; +} + +function findAliasMatches(lines) { + const matches = []; + const aliases = Object.keys(ALIASES_TO_AVOID).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'); + const commandPattern = new RegExp(`^\\s*(?:&\\s*)?(${aliases})(?:\\s|$|\\|)`, 'i'); + const pipelinePattern = new RegExp(`\\|\\s*(${aliases})(?:\\s|$|\\|)`, 'i'); + for (const [index, line] of lines.entries()) { + const stripped = line.replace(/#.*/, ''); + const match = stripped.match(commandPattern) || stripped.match(pipelinePattern); + if (!match) continue; + const alias = match[1].toLowerCase(); + matches.push({ + alias, + command: ALIASES_TO_AVOID[alias], + line: index + 1, + excerpt: line.trim().slice(0, 260) + }); + } + return matches; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function resolveVisibleCompatibilityTargets(payload = {}, userId) { + const hostIds = [...new Set([payload.hostId, ...(payload.hostIds || [])].filter(Boolean))]; + const groupIds = [...new Set([payload.hostGroupId, ...(payload.hostGroupIds || [])].filter(Boolean))]; + const targets = []; + if (hostIds.length) { + targets.push(...db.prepare(` + SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport + FROM hosts h + WHERE h.id IN (${hostIds.map(() => '?').join(',')}) + AND ( + h.visibility = 'shared' + OR h.owner_user_id = ? + OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) + ) + `).all(...hostIds, userId, userId)); + } + if (groupIds.length) { + targets.push(...db.prepare(` + SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport + FROM host_group_members hgm + JOIN host_groups hg ON hg.id = hgm.host_group_id + AND ( + hg.visibility = 'shared' + OR hg.owner_user_id = ? + OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) + ) + JOIN hosts h ON h.id = hgm.host_id + AND ( + h.visibility = 'shared' + OR h.owner_user_id = ? + OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) + ) + WHERE hgm.host_group_id IN (${groupIds.map(() => '?').join(',')}) + `).all(userId, userId, userId, userId, ...groupIds)); + } + return dedupeTargets(targets); +} + +function resolveRunPlanTargets(runplanId) { + return dedupeTargets(db.prepare(` + SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport + FROM hosts h + WHERE h.id IN ( + SELECT host_id FROM runplan_hosts WHERE runplan_id = ? + UNION + SELECT hgm.host_id + FROM runplan_host_groups rhg + JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id + WHERE rhg.runplan_id = ? + ) + ORDER BY h.name + `).all(runplanId, runplanId)); +} + +function dedupeTargets(targets = []) { + const seen = new Set(); + return targets.filter((target) => { + if (!target?.id || seen.has(target.id)) return false; + seen.add(target.id); + target.osFamily = normalizeOsFamily(target.osFamily || target.os_family, 'other'); + return true; + }); +} + +function summarizeTargets(targets = []) { + return { + count: targets.length, + osFamilies: [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other')))], + linuxCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'linux').length, + windowsCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'windows').length, + otherCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'other').length, + sample: targets.slice(0, 8).map((target) => ({ + id: target.id, + name: target.name, + address: target.address, + osFamily: normalizeOsFamily(target.osFamily || target.os_family, 'other'), + transport: target.transport + })) + }; +} diff --git a/server/services/powershellRunner.js b/server/services/powershellRunner.js index 35c2393..68d0f8f 100644 --- a/server/services/powershellRunner.js +++ b/server/services/powershellRunner.js @@ -11,6 +11,8 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js' import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js'; import { getVCenterVmPowerState } from './vcenterService.js'; import { path } from '../utils/pathUtils.js'; +import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js'; +import { normalizeOsFamily } from '../utils/osFamily.js'; // The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and // flipped at runtime via the Config screen (allow_script_execution). The DB @@ -179,6 +181,7 @@ export function executeScriptTest(scriptId, payload, userId) { async function runJob(jobId, script, hosts, parallel, executionContext) { // RunPlans can fan out in parallel or execute serially while preserving identical log semantics. + logCompatibilityPreflight(jobId, script, hosts); const runner = async (host) => runHost(jobId, script, host, executionContext); if (parallel) { await Promise.all(hosts.map(runner)); @@ -190,10 +193,20 @@ async function runJob(jobId, script, hosts, parallel, executionContext) { db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId); } +function logCompatibilityPreflight(jobId, script, hosts) { + const analysis = analyzePowerShellCompatibility(script.content, hosts); + const lines = compatibilityLogLines(analysis); + if (!lines.length) return; + for (const host of hosts.filter((target) => normalizeOsFamily(target.os_family || target.osFamily, 'other') === 'linux')) { + for (const line of lines) appendLog(jobId, host.id, 'system', line); + } +} + async function runHost(jobId, script, host, executionContext) { // Each host owns its own temp wrapper and job_host lifecycle for per-target evidence. db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id); appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`); + appendLog(jobId, host.id, 'system', `Target OS type: ${normalizeOsFamily(host.os_family || host.osFamily, 'other')}`); if (!scriptExecutionAllowed()) { appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.'); @@ -372,6 +385,7 @@ function runtimeVariableValues(host, executionContext) { { name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' }, { name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' }, { name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' }, + { name: 'POSHM_HOST_OS_FAMILY', value: normalizeOsFamily(host.os_family || host.osFamily, 'other'), valueType: 'string' }, { name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' }, { name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' } ]; diff --git a/server/services/vcenterService.js b/server/services/vcenterService.js index 7b3bf95..28578b4 100644 --- a/server/services/vcenterService.js +++ b/server/services/vcenterService.js @@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer'; import { Agent } from 'undici'; import { config } from '../config.js'; import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'; +import { normalizeOsFamily } from '../utils/osFamily.js'; const WINDOWS_HINTS = ['windows', 'microsoft']; const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux']; const MAX_TRACE_ENTRIES = 700; const MAX_TRACE_BODY_CHARS = 6000; const VM_ENRICHMENT_CONCURRENCY = 8; +const WEB_SERVICES_PROFILE_LABEL = 'vSphere Web Services API'; const API_PROFILES = { api: { mode: 'api', @@ -45,6 +47,7 @@ export function getVCenterSettings() { const requestTimeoutMs = Number(getStringSetting('vcenter_request_timeout_ms', String(config.vcenter.requestTimeoutMs || 20000))); return { enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled), + targetType: 'vcenter', baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)), username: getStringSetting('vcenter_username', config.vcenter.username), password: getStringSetting('vcenter_password', config.vcenter.password), @@ -57,12 +60,16 @@ export function getVCenterSettings() { export function settingsFromVCenterConnection(connection) { if (!connection) return getVCenterSettings(); const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase(); + const targetType = String(connection.target_type || connection.targetType || 'vcenter').toLowerCase() === 'host' ? 'host' : 'vcenter'; return { enabled: Boolean(connection.enabled ?? true), + targetType, baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl), username: connection.username || '', password: connection.password || '', - apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto', + apiMode: targetType === 'host' + ? 'web-services' + : (['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto'), requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000), allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls) }; @@ -77,6 +84,7 @@ export function vcenterStatus() { return { enabled: settings.enabled, configured: Boolean(settings.baseUrl && settings.username && settings.password), + targetType: settings.targetType, baseUrl: settings.baseUrl, username: settings.username, apiMode: settings.apiMode, @@ -92,6 +100,7 @@ export function vcenterConnectionStatus(connection) { name: connection?.name || 'Configured default', enabled: settings.enabled, configured: Boolean(settings.baseUrl && settings.username && settings.password), + targetType: settings.targetType, baseUrl: settings.baseUrl, username: settings.username, apiMode: settings.apiMode, @@ -101,9 +110,10 @@ export function vcenterConnectionStatus(connection) { } 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.'); + const label = settings.targetType === 'host' ? 'VMware standalone host' : 'VMware vCenter'; + if (!settings.enabled) throw new Error(`${label} integration is disabled in Configuration.`); + if (!settings.baseUrl) throw new Error(`${label} base URL is missing.`); + if (!settings.username || !settings.password) throw new Error(`${label} credentials are missing.`); } function dispatcherFor(settings) { @@ -224,6 +234,115 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40 return payload; } +function soapEndpoint(settings) { + return `${settings.baseUrl.replace(/\/sdk$/i, '')}/sdk`; +} + +function soapEnvelope(body) { + return `` + + `` + + `${body}`; +} + +function xmlEscape(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function redactSoapBody(value) { + return String(value || '') + .replace(/()([\s\S]*?)(<\/password>)/gi, '$1[redacted]$3') + .replace(/()([\s\S]*?)(<\/userName>)/gi, '$1[redacted username]$3'); +} + +function soapBodyPreview(value) { + const text = redactSoapBody(value); + return text.length > MAX_TRACE_BODY_CHARS + ? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)` + : text; +} + +function extractSoapTag(text, tagName) { + const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i'); + const match = String(text || '').match(pattern); + return match ? match[1].trim() : ''; +} + +function extractSoapFault(text) { + return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || ''; +} + +async function vSphereSoapFetch(settings, action, body, trace = null) { + const url = soapEndpoint(settings); + const requestBody = soapEnvelope(body); + const headers = { + Accept: 'text/xml', + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: `"urn:vim25/${action}"` + }; + const startedAt = Date.now(); + let response; + let text = ''; + try { + response = await fetch(url, { + method: 'POST', + headers, + body: requestBody, + dispatcher: dispatcherFor(settings), + signal: AbortSignal.timeout(settings.requestTimeoutMs) + }); + text = await response.text(); + } catch (err) { + pushTrace(trace, { + method: 'POST', + url, + soapAction: action, + requestHeaders: { ...headers, SOAPAction: headers.SOAPAction }, + requestBody: soapBodyPreview(requestBody), + durationMs: Date.now() - startedAt, + timeoutMs: settings.requestTimeoutMs, + networkError: err.name === 'TimeoutError' + ? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP endpoint.` + : err.message + }); + const message = err.name === 'TimeoutError' + ? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP action ${action}.` + : `Unable to reach VMware host SOAP action ${action}: ${err.message}`; + throw new VCenterTraceError(message, trace); + } + + pushTrace(trace, { + method: 'POST', + url, + soapAction: action, + requestHeaders: { ...headers, SOAPAction: headers.SOAPAction }, + 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') || '' + }, + responseSummary: { + type: 'soap', + bytes: text.length, + fault: Boolean(extractSoapFault(text)) + }, + responseBody: soapBodyPreview(text) + }); + + const fault = extractSoapFault(text); + if (!response.ok || fault) { + throw new VCenterTraceError(`VMware host SOAP action ${action} failed${response.ok ? '' : ` with HTTP ${response.status}`}: ${fault || response.statusText}`, trace, response.status || 502); + } + return text; +} + function unwrapList(payload) { if (Array.isArray(payload)) return payload; if (Array.isArray(payload?.value)) return payload.value; @@ -270,7 +389,7 @@ function inferOsFamily(identity = {}) { const haystack = [identity.family, identity.name, localizedText(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'; + return 'other'; } function localizedText(value) { @@ -313,7 +432,7 @@ function normalizeVCenterSummaryVm(vm) { ipAddress: '', ipAddresses: [], powerState: vm.power_state || vm.powerState || '', - osFamily: 'windows', + osFamily: 'other', guestFullName: '', toolsAvailable: false, source: 'vcenter' @@ -380,7 +499,7 @@ export function buildHostPayloadFromVm(candidate, options = {}) { name: candidate.name, fqdn: candidate.fqdn || '', address: candidate.address || candidate.ipAddress || candidate.name, - osFamily: candidate.osFamily || 'windows', + osFamily: normalizeOsFamily(candidate.osFamily, 'other'), transport: options.transport || 'winrm', port: options.port || null, credentialId: options.credentialId || null, @@ -407,6 +526,9 @@ export async function previewVCenterHosts(options = {}) { } catch (err) { throw new VCenterTraceError(err.message, trace, 400); } + if (settings.targetType === 'host') { + throw new VCenterTraceError('Standalone VMware host connections use the vSphere Web Services API and cannot perform vCenter VM inventory imports. Choose a vCenter connection for VM discovery.', trace, 400); + } const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace); const allVms = unwrapList(vmPayload); const summaryFilter = filterSummaryVms(allVms, options.filter); @@ -452,6 +574,7 @@ export async function testVCenterConnection(connection) { const trace = []; const settings = settingsFromVCenterConnection(connection); assertConfigured(settings); + if (settings.targetType === 'host') return testStandaloneHostConnection(connection, settings, trace); const { profile, vmPayload } = await connectAndListVms(settings, {}, trace); return { status: vcenterConnectionStatus(connection), @@ -462,9 +585,39 @@ export async function testVCenterConnection(connection) { }; } +export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) { + pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, { + mode: 'web-services', + endpoint: soapEndpoint(settings) + }); + const serviceContent = await vSphereSoapFetch( + settings, + 'RetrieveServiceContent', + `<_this type="ServiceInstance">ServiceInstance`, + trace + ); + const sessionManager = extractSoapTag(serviceContent, 'sessionManager') || 'SessionManager'; + await vSphereSoapFetch( + settings, + 'Login', + `<_this type="SessionManager">${xmlEscape(sessionManager)}${xmlEscape(settings.username)}${xmlEscape(settings.password)}`, + trace + ); + return { + status: vcenterConnectionStatus(connection), + apiProfile: WEB_SERVICES_PROFILE_LABEL, + count: 0, + trace, + message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}.` + }; +} + export async function getVCenterVmPowerState(connection, vmId, trace = null) { if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' }; const settings = settingsFromVCenterConnection(connection); + if (settings.targetType === 'host') { + return { checked: false, reason: 'Standalone VMware host connections do not provide vCenter VM power-state inventory lookups.' }; + } assertConfigured(settings); const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode]; let lastError = null; diff --git a/server/test/compatibility.test.mjs b/server/test/compatibility.test.mjs new file mode 100644 index 0000000..312238b --- /dev/null +++ b/server/test/compatibility.test.mjs @@ -0,0 +1,35 @@ +import './setup.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { load } from './setup.mjs'; + +const { analyzePowerShellCompatibility } = await load('../services/powershellCompatibilityService.js'); +const { normalizeOsFamily } = await load('../utils/osFamily.js'); + +test('normalizeOsFamily maps legacy and unknown host values into supported OS types', () => { + assert.equal(normalizeOsFamily('Windows Server 2022'), 'windows'); + assert.equal(normalizeOsFamily('Ubuntu Linux'), 'linux'); + assert.equal(normalizeOsFamily('network'), 'other'); + assert.equal(normalizeOsFamily('api'), 'other'); +}); + +test('PowerShell compatibility analyzer is quiet when no Linux targets are selected', () => { + const result = analyzePowerShellCompatibility('Get-WmiObject Win32_OperatingSystem', [{ osFamily: 'windows' }]); + assert.equal(result.hasLinuxTargets, false); + assert.deepEqual(result.findings, []); +}); + +test('PowerShell compatibility analyzer flags Windows-only patterns for Linux targets', () => { + const result = analyzePowerShellCompatibility([ + 'Get-WmiObject Win32_OperatingSystem', + 'Get-Item HKLM:\\Software\\Contoso', + 'msiexec.exe /i C:\\Temp\\setup.msi', + 'dir C:\\Temp' + ].join('\n'), [{ osFamily: 'linux' }]); + + assert.equal(result.hasLinuxTargets, true); + assert.ok(result.findings.some((finding) => finding.id === 'wmi-or-win32-class')); + assert.ok(result.findings.some((finding) => finding.id === 'windows-registry-provider')); + assert.ok(result.findings.some((finding) => finding.id === 'windows-executable')); + assert.ok(result.findings.some((finding) => finding.id === 'alias-dir')); +}); diff --git a/server/test/vcenter.test.mjs b/server/test/vcenter.test.mjs index 373af77..ff58ac8 100644 --- a/server/test/vcenter.test.mjs +++ b/server/test/vcenter.test.mjs @@ -9,6 +9,8 @@ const { filterSummaryVms, normalizeBaseUrl, normalizeVCenterVm, + previewVCenterHosts, + settingsFromVCenterConnection, vmMatchesFilter } = await load('../services/vcenterService.js'); @@ -16,6 +18,27 @@ test('normalizeBaseUrl trims trailing slashes', () => { assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local'); }); +test('standalone VMware host connections use the Web Services profile', async () => { + const settings = settingsFromVCenterConnection({ + id: 'vc_host', + target_type: 'host', + base_url: 'https://esxi01.lab.local/sdk', + username: 'root', + password: 'secret', + enabled: 1, + api_mode: 'api' + }); + + assert.equal(settings.targetType, 'host'); + assert.equal(settings.apiMode, 'web-services'); + assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk'); + + await assert.rejects( + () => previewVCenterHosts({ connection: { ...settings, name: 'ESXi host' } }), + /Standalone VMware host connections/ + ); +}); + 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' }, @@ -32,6 +55,16 @@ test('normalizeVCenterVm maps guest identity and networking into a host candidat assert.equal(candidate.guestFullName, 'Microsoft Windows Server 2022'); }); +test('normalizeVCenterVm marks unknown guest operating systems as other', () => { + const candidate = normalizeVCenterVm( + { vm: 'vm-unknown', name: 'MYSTERY-01', power_state: 'POWERED_ON' }, + null, + [] + ); + + assert.equal(candidate.osFamily, 'other'); +}); + test('vmMatchesFilter supports hostname contains and IP equals matching', () => { const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', address: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] }; diff --git a/server/utils/osFamily.js b/server/utils/osFamily.js new file mode 100644 index 0000000..e8c2a62 --- /dev/null +++ b/server/utils/osFamily.js @@ -0,0 +1,16 @@ +export const OS_FAMILIES = ['windows', 'linux', 'other']; + +export function normalizeOsFamily(value, fallback = 'other') { + const text = String(value || '').trim().toLowerCase(); + if (!text) return fallback; + if (['win', 'windows', 'windows server', 'microsoft windows'].includes(text) || text.includes('windows')) return 'windows'; + if (['linux', 'unix'].includes(text) || /ubuntu|debian|rhel|red hat|centos|fedora|suse|oracle linux|rocky|alma|amazon linux|arch|kali/.test(text)) return 'linux'; + return 'other'; +} + +export function osFamilyLabel(value) { + const normalized = normalizeOsFamily(value); + if (normalized === 'windows') return 'Windows'; + if (normalized === 'linux') return 'Linux'; + return 'Other'; +}