Initial
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
334
server/services/powershellCompatibilityService.js
Normal file
334
server/services/powershellCompatibilityService.js
Normal 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
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
35
server/test/compatibility.test.mjs
Normal file
35
server/test/compatibility.test.mjs
Normal 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'));
|
||||
});
|
||||
@@ -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
16
server/utils/osFamily.js
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user