Initial
This commit is contained in:
@@ -40,6 +40,13 @@ export const config = {
|
||||
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
|
||||
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
|
||||
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
|
||||
vcenter: {
|
||||
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
|
||||
baseUrl: process.env.VCENTER_BASE_URL || '',
|
||||
username: process.env.VCENTER_USERNAME || '',
|
||||
password: process.env.VCENTER_PASSWORD || '',
|
||||
allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true'
|
||||
},
|
||||
entra: {
|
||||
enabled: (process.env.ENTRA_ENABLED || 'false').toLowerCase() === 'true',
|
||||
tenantId: process.env.ENTRA_TENANT_ID || '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hostSchema } from '../forms/schemas.js';
|
||||
import { createHost, deleteHost, listHosts, updateHost } from '../models/hostModel.js';
|
||||
import { hostSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
|
||||
import { createHost, deleteHost, findVisibleHostByIdentity, listHosts, updateHost } from '../models/hostModel.js';
|
||||
import { buildHostPayloadFromVm, previewVCenterHosts, vcenterStatus } from '../services/vcenterService.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listHosts(req.user.id));
|
||||
@@ -21,3 +22,45 @@ export function destroy(req, res) {
|
||||
deleteHost(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function vcenterImportStatus(req, res) {
|
||||
res.json(vcenterStatus());
|
||||
}
|
||||
|
||||
export async function previewVCenterImport(req, res) {
|
||||
const parsed = vcenterPreviewSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
|
||||
try {
|
||||
res.json(await previewVCenterHosts(parsed.data));
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function importVCenterHosts(req, res) {
|
||||
const parsed = vcenterImportSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
|
||||
try {
|
||||
const preview = await previewVCenterHosts(parsed.data);
|
||||
const selected = new Set(parsed.data.vmIds || []);
|
||||
const candidates = selected.size
|
||||
? preview.candidates.filter((candidate) => selected.has(candidate.id))
|
||||
: preview.candidates;
|
||||
const imported = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const hostPayload = buildHostPayloadFromVm(candidate, parsed.data);
|
||||
const existing = findVisibleHostByIdentity(hostPayload, req.user.id);
|
||||
if (existing) {
|
||||
skipped.push({ vm: candidate, reason: `Existing host matched ${existing.name}` });
|
||||
continue;
|
||||
}
|
||||
imported.push(createHost(hostPayload, req.user.id));
|
||||
}
|
||||
|
||||
res.status(201).json({ imported, skipped, scanned: preview.count });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,27 @@ export const hostSchema = z.object({
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
const vcenterFilterSchema = z.object({
|
||||
field: z.enum(['name', 'hostname', 'fqdn', 'ip']).optional().default('hostname'),
|
||||
operator: z.enum(['contains', 'equals', 'startsWith', 'endsWith']).optional().default('contains'),
|
||||
value: z.string().max(200).optional().default('')
|
||||
});
|
||||
|
||||
export const vcenterPreviewSchema = z.object({
|
||||
filter: vcenterFilterSchema.optional().default({}),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional().default(100)
|
||||
});
|
||||
|
||||
export const vcenterImportSchema = vcenterPreviewSchema.extend({
|
||||
vmIds: z.array(z.string().min(1)).optional().default([]),
|
||||
credentialId: z.string().nullable().optional(),
|
||||
visibility: visibilitySchema.default('shared'),
|
||||
groupId: z.string().nullable().optional(),
|
||||
transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
|
||||
port: z.coerce.number().int().min(1).max(65535).nullable().optional(),
|
||||
tags: z.array(z.string().max(80)).optional().default([])
|
||||
});
|
||||
|
||||
export const folderSchema = z.object({
|
||||
parentId: z.string().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -42,6 +42,26 @@ export function createHost(payload, userId) {
|
||||
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
|
||||
}
|
||||
|
||||
export function findVisibleHostByIdentity(payload, userId) {
|
||||
const values = [payload.name, payload.address, payload.fqdn]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (!values.length) return null;
|
||||
const placeholders = values.map(() => '?').join(', ');
|
||||
const row = db.prepare(`
|
||||
SELECT *
|
||||
FROM hosts
|
||||
WHERE ${visibleClause()}
|
||||
AND (
|
||||
lower(name) IN (${placeholders})
|
||||
OR lower(address) IN (${placeholders})
|
||||
OR lower(coalesce(fqdn, '')) IN (${placeholders})
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(userId, userId, ...values, ...values, ...values);
|
||||
return row ? camelHost(row) : null;
|
||||
}
|
||||
|
||||
export function updateHost(hostId, payload, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM hosts WHERE id = ? AND ${visibleClause()}`).get(hostId, userId, userId);
|
||||
if (!existing) return null;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { create, destroy, index, update } from '../controllers/hostController.js';
|
||||
import { create, destroy, importVCenterHosts, index, previewVCenterImport, update, vcenterImportStatus } from '../controllers/hostController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const hostRoutes = Router();
|
||||
|
||||
hostRoutes.get('/import/vcenter/status', requireAuth, vcenterImportStatus);
|
||||
hostRoutes.post('/import/vcenter/preview', requireAuth, previewVCenterImport);
|
||||
hostRoutes.post('/import/vcenter', requireAuth, importVCenterHosts);
|
||||
hostRoutes.get('/', requireAuth, index);
|
||||
hostRoutes.post('/', requireAuth, create);
|
||||
hostRoutes.put('/:id', requireAuth, update);
|
||||
|
||||
221
server/services/vcenterService.js
Normal file
221
server/services/vcenterService.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { Agent } from 'undici';
|
||||
import { config } from '../config.js';
|
||||
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||
|
||||
const WINDOWS_HINTS = ['windows', 'microsoft'];
|
||||
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
|
||||
|
||||
export function getVCenterSettings() {
|
||||
return {
|
||||
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
|
||||
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
|
||||
username: getStringSetting('vcenter_username', config.vcenter.username),
|
||||
password: getStringSetting('vcenter_password', config.vcenter.password),
|
||||
allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function vcenterStatus() {
|
||||
const settings = getVCenterSettings();
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||
baseUrl: settings.baseUrl,
|
||||
username: settings.username,
|
||||
allowUntrustedTls: settings.allowUntrustedTls
|
||||
};
|
||||
}
|
||||
|
||||
function assertConfigured(settings) {
|
||||
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
|
||||
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
|
||||
if (!settings.username || !settings.password) throw new Error('VMware vCenter credentials are missing. Set vcenter_username and vcenter_password in Configuration.');
|
||||
}
|
||||
|
||||
function dispatcherFor(settings) {
|
||||
return settings.allowUntrustedTls
|
||||
? new Agent({ connect: { rejectUnauthorized: false } })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function parseVCenterResponse(response) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text.replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false } = {}) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (sessionId) headers['vmware-api-session-id'] = sessionId;
|
||||
else headers.Authorization = `Basic ${Buffer.from(`${settings.username}:${settings.password}`).toString('base64')}`;
|
||||
|
||||
const response = await fetch(`${settings.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
dispatcher: dispatcherFor(settings)
|
||||
});
|
||||
|
||||
if (allow404 && response.status === 404) return null;
|
||||
const payload = await parseVCenterResponse(response);
|
||||
if (!response.ok) {
|
||||
const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText;
|
||||
throw new Error(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function unwrapList(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.value)) return payload.value;
|
||||
return [];
|
||||
}
|
||||
|
||||
async function createSession(settings) {
|
||||
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST' });
|
||||
const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
|
||||
if (!sessionId) throw new Error('vCenter session authentication succeeded but no session id was returned.');
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function optionalVCenterFetch(settings, path, sessionId) {
|
||||
try {
|
||||
return await vcenterFetch(settings, path, { sessionId, allow404: true });
|
||||
} catch (err) {
|
||||
return { unavailable: true, reason: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function firstIpFromInterfaces(interfaces = []) {
|
||||
for (const nic of interfaces) {
|
||||
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
|
||||
const firstUsable = addresses.find((entry) => {
|
||||
const address = entry?.ip_address || entry?.address || entry;
|
||||
return address && !String(address).startsWith('fe80:');
|
||||
});
|
||||
if (firstUsable) return firstUsable.ip_address || firstUsable.address || firstUsable;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function allIpsFromInterfaces(interfaces = []) {
|
||||
return interfaces.flatMap((nic) => {
|
||||
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
|
||||
return addresses
|
||||
.map((entry) => entry?.ip_address || entry?.address || entry)
|
||||
.filter((address) => address && !String(address).startsWith('fe80:'));
|
||||
});
|
||||
}
|
||||
|
||||
function inferOsFamily(identity = {}) {
|
||||
const haystack = [identity.family, identity.name, identity.full_name, identity.guest_OS].filter(Boolean).join(' ').toLowerCase();
|
||||
if (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
|
||||
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
|
||||
return 'windows';
|
||||
}
|
||||
|
||||
export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
|
||||
const interfaces = unwrapList(interfacesPayload);
|
||||
const interfaceIp = firstIpFromInterfaces(interfaces);
|
||||
const identityIp = identity?.ip_address || identity?.ipAddress || '';
|
||||
const fqdn = identity?.host_name || identity?.hostName || '';
|
||||
const name = vm.name || fqdn || vm.vm || vm.id;
|
||||
const address = fqdn || identityIp || interfaceIp || name;
|
||||
const ipAddresses = [...new Set([identityIp, interfaceIp, ...allIpsFromInterfaces(interfaces)].filter(Boolean))];
|
||||
|
||||
return {
|
||||
id: vm.vm || vm.id,
|
||||
name,
|
||||
fqdn,
|
||||
address,
|
||||
ipAddress: ipAddresses[0] || '',
|
||||
ipAddresses,
|
||||
powerState: vm.power_state || vm.powerState || '',
|
||||
osFamily: inferOsFamily(identity || {}),
|
||||
guestFullName: identity?.full_name || identity?.name || '',
|
||||
toolsAvailable: Boolean(identity || interfaces.length),
|
||||
source: 'vcenter'
|
||||
};
|
||||
}
|
||||
|
||||
export function vmMatchesFilter(candidate, filter = {}) {
|
||||
const operator = filter.operator || 'contains';
|
||||
const needle = String(filter.value || '').trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
const field = filter.field || 'hostname';
|
||||
if (field === 'ip') {
|
||||
const values = [candidate.ipAddress, ...(candidate.ipAddresses || [])]
|
||||
.filter(Boolean)
|
||||
.map((value) => String(value).toLowerCase());
|
||||
if (operator === 'equals') return values.some((value) => value === needle);
|
||||
if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
|
||||
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
|
||||
return values.some((value) => value.includes(needle));
|
||||
}
|
||||
const haystack = {
|
||||
name: candidate.name,
|
||||
hostname: candidate.name || candidate.fqdn,
|
||||
fqdn: candidate.fqdn || candidate.address,
|
||||
}[field] || candidate.name;
|
||||
const value = String(haystack || '').toLowerCase();
|
||||
if (operator === 'equals') return value === needle;
|
||||
if (operator === 'startsWith') return value.startsWith(needle);
|
||||
if (operator === 'endsWith') return value.endsWith(needle);
|
||||
return value.includes(needle);
|
||||
}
|
||||
|
||||
export function buildHostPayloadFromVm(candidate, options = {}) {
|
||||
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
|
||||
return {
|
||||
name: candidate.name,
|
||||
fqdn: candidate.fqdn || '',
|
||||
address: candidate.address || candidate.ipAddress || candidate.name,
|
||||
osFamily: candidate.osFamily || 'windows',
|
||||
transport: options.transport || 'winrm',
|
||||
port: options.port || null,
|
||||
credentialId: options.credentialId || null,
|
||||
tags,
|
||||
notes: [
|
||||
`Imported from VMware vCenter VM ${candidate.id}.`,
|
||||
candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
|
||||
candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
|
||||
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
|
||||
].filter(Boolean).join(' '),
|
||||
visibility: options.visibility || 'shared',
|
||||
groupId: options.visibility === 'group' ? options.groupId || null : null
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewVCenterHosts(options = {}) {
|
||||
const settings = getVCenterSettings();
|
||||
assertConfigured(settings);
|
||||
const sessionId = await createSession(settings);
|
||||
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId });
|
||||
const vms = unwrapList(vmPayload).slice(0, Math.max(options.limit || 100, 1) * 4);
|
||||
const candidates = [];
|
||||
|
||||
for (const vm of vms) {
|
||||
const vmId = vm.vm || vm.id;
|
||||
if (!vmId) continue;
|
||||
const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId);
|
||||
const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId);
|
||||
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
|
||||
if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
|
||||
if (candidates.length >= (options.limit || 100)) break;
|
||||
}
|
||||
|
||||
return {
|
||||
status: vcenterStatus(),
|
||||
filter: options.filter || {},
|
||||
count: candidates.length,
|
||||
candidates
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,11 @@ export function settingDefinitions() {
|
||||
{ key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') },
|
||||
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
|
||||
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
|
||||
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') }
|
||||
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
|
||||
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
|
||||
{ key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
|
||||
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
|
||||
{ key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') },
|
||||
{ key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') }
|
||||
];
|
||||
}
|
||||
|
||||
62
server/test/vcenter.test.mjs
Normal file
62
server/test/vcenter.test.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const {
|
||||
buildHostPayloadFromVm,
|
||||
normalizeBaseUrl,
|
||||
normalizeVCenterVm,
|
||||
vmMatchesFilter
|
||||
} = await load('../services/vcenterService.js');
|
||||
|
||||
test('normalizeBaseUrl trims trailing slashes', () => {
|
||||
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
|
||||
});
|
||||
|
||||
test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {
|
||||
const candidate = normalizeVCenterVm(
|
||||
{ vm: 'vm-42', name: 'ENG-ENT-APP01', power_state: 'POWERED_ON' },
|
||||
{ host_name: 'eng-ent-app01.contoso.local', ip_address: '10.42.1.20', full_name: 'Microsoft Windows Server 2022' },
|
||||
[{ ip: { ip_addresses: [{ ip_address: 'fe80::1' }, { ip_address: '10.42.1.21' }] } }]
|
||||
);
|
||||
|
||||
assert.equal(candidate.id, 'vm-42');
|
||||
assert.equal(candidate.name, 'ENG-ENT-APP01');
|
||||
assert.equal(candidate.fqdn, 'eng-ent-app01.contoso.local');
|
||||
assert.equal(candidate.address, 'eng-ent-app01.contoso.local');
|
||||
assert.deepEqual(candidate.ipAddresses, ['10.42.1.20', '10.42.1.21']);
|
||||
assert.equal(candidate.osFamily, 'windows');
|
||||
});
|
||||
|
||||
test('vmMatchesFilter supports hostname contains and IP equals matching', () => {
|
||||
const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] };
|
||||
|
||||
assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'eng-ent' }), true);
|
||||
assert.equal(vmMatchesFilter(candidate, { field: 'ip', operator: 'equals', value: '10.42.1.20' }), true);
|
||||
assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false);
|
||||
});
|
||||
|
||||
test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => {
|
||||
const payload = buildHostPayloadFromVm(
|
||||
{
|
||||
id: 'vm-42',
|
||||
name: 'ENG-ENT-APP01',
|
||||
fqdn: 'eng-ent-app01.contoso.local',
|
||||
address: 'eng-ent-app01.contoso.local',
|
||||
osFamily: 'windows',
|
||||
powerState: 'POWERED_ON',
|
||||
guestFullName: 'Microsoft Windows Server 2022',
|
||||
ipAddresses: ['10.42.1.20']
|
||||
},
|
||||
{ credentialId: 'cred_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
|
||||
);
|
||||
|
||||
assert.equal(payload.credentialId, 'cred_1');
|
||||
assert.equal(payload.transport, 'winrm');
|
||||
assert.equal(payload.visibility, 'group');
|
||||
assert.equal(payload.groupId, 'grp_1');
|
||||
assert.ok(payload.tags.includes('vcenter:vm-42'));
|
||||
assert.ok(payload.tags.includes('engineering'));
|
||||
assert.match(payload.notes, /Imported from VMware vCenter VM vm-42/);
|
||||
});
|
||||
Reference in New Issue
Block a user