This commit is contained in:
2026-06-25 16:51:56 -05:00
parent 94f4d8959d
commit 402bf6782a
25 changed files with 1323 additions and 103 deletions

View File

@@ -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);

View File

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

View File

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

View File

@@ -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.',

View File

@@ -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');

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

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

View File

@@ -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' }
];

View File

@@ -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 `<?xml version="1.0" encoding="UTF-8"?>` +
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:vim25="urn:vim25">` +
`<soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`;
}
function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function redactSoapBody(value) {
return String(value || '')
.replace(/(<password>)([\s\S]*?)(<\/password>)/gi, '$1[redacted]$3')
.replace(/(<userName>)([\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',
`<vim25:RetrieveServiceContent><_this type="ServiceInstance">ServiceInstance</_this></vim25:RetrieveServiceContent>`,
trace
);
const sessionManager = extractSoapTag(serviceContent, 'sessionManager') || 'SessionManager';
await vSphereSoapFetch(
settings,
'Login',
`<vim25:Login><_this type="SessionManager">${xmlEscape(sessionManager)}</_this><userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></vim25:Login>`,
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;

View File

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

View File

@@ -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'] };

16
server/utils/osFamily.js Normal file
View File

@@ -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';
}