This commit is contained in:
2026-06-25 22:24:10 -05:00
parent 12e97e3020
commit d27811254d
13 changed files with 1368 additions and 15 deletions

View File

@@ -10,6 +10,7 @@ import {
updateVCenterConnection
} from '../models/vcenterConnectionModel.js';
import { buildHostPayloadFromVm, previewVCenterHosts, testVCenterConnection, vcenterStatus } from '../services/vcenterService.js';
import { createRdpLaunchSession } from '../services/rdpGatewayService.js';
export function index(req, res) {
res.json(listHosts(req.user.id));
@@ -32,6 +33,14 @@ export function destroy(req, res) {
res.status(204).end();
}
export function createRdpSession(req, res) {
try {
res.status(201).json(createRdpLaunchSession(req.params.id, req.user.id));
} catch (error) {
res.status(error.statusCode || 400).json({ error: error.message });
}
}
export function listGroups(req, res) {
res.json(listHostGroups(req.user.id));
}

View File

@@ -64,6 +64,7 @@ const operationsArticles = [
'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.',
'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.',
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon opens a short-lived /rdp/:token URL in a new tab and renders a mstsc.js browser RDP canvas while the API keeps the real password server-side.',
'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', [
@@ -134,6 +135,7 @@ 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/hosts/hst_123/rdp-session', 'Create a short-lived browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process "$base$($session.url)"'),
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") }'),

View File

@@ -11,6 +11,7 @@ import { startCatalogWorker } from './services/catalogWorker.js';
import { startPromotionWorker } from './services/promotionWorker.js';
import { startHostGroupWorker } from './services/hostGroupWorker.js';
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
import { mountRdpGateway, startRdpGateway } from './services/rdpGatewayService.js';
// Fail fast in production rather than ship with well-known default secrets.
// These defaults are fine for local development but are publicly known, so a
@@ -55,14 +56,16 @@ app.use(cors({
app.use(express.json({ limit: config.jsonLimit }));
app.use(requestLogger);
app.use('/api', apiRoutes);
mountRdpGateway(app);
app.use((error, req, res, next) => {
logger.error('unhandled error', { error });
res.status(500).json({ error: 'Unexpected server error' });
});
app.listen(config.port, () => {
const httpServer = app.listen(config.port, () => {
logger.info(`POSHManager API listening on ${config.port}`);
startRdpGateway(httpServer);
startCatalogWorker();
startPromotionWorker();
startHostGroupWorker();

View File

@@ -2,6 +2,7 @@ import { Router } from 'express';
import {
create,
createGroup,
createRdpSession,
createVCenterConnectionRecord,
deleteGroup,
deleteVCenterConnectionRecord,
@@ -39,5 +40,6 @@ hostRoutes.delete('/groups/:groupId', requireAuth, deleteGroup);
hostRoutes.post('/groups/:groupId/sync', requireAuth, syncGroup);
hostRoutes.get('/', requireAuth, index);
hostRoutes.post('/', requireAuth, create);
hostRoutes.post('/:id/rdp-session', requireAuth, createRdpSession);
hostRoutes.put('/:id', requireAuth, update);
hostRoutes.delete('/:id', requireAuth, destroy);

View File

@@ -0,0 +1,300 @@
import { createRequire } from 'node:module';
import { randomBytes } from 'node:crypto';
import { path } from '../utils/pathUtils.js';
import express from 'express';
import { db, visibleClause } from '../db.js';
import { decryptSecret } from './cryptoStore.js';
import { logger } from './logger.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
const require = createRequire(import.meta.url);
const rdp = require('node-rdpjs');
const socketIo = require('socket.io');
const mstscPackagePath = require.resolve('mstsc.js/package.json');
const mstscClientDir = path.join(path.dirname(mstscPackagePath), 'client');
const SESSION_TTL_MS = 5 * 60 * 1000;
const sessions = new Map();
let socketServer = null;
function token() {
return randomBytes(24).toString('base64url');
}
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function splitDomainUsername(username = '') {
const value = String(username || '');
const match = value.match(/^([^\\]+)\\(.+)$/);
if (!match) return { domain: '', username: value };
return { domain: match[1], username: match[2] };
}
function cleanupExpiredSessions() {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [sessionToken, session] of sessions.entries()) {
if (session.createdAt < cutoff) sessions.delete(sessionToken);
}
}
function loadRdpTarget(hostId, userId) {
return db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM hosts h
JOIN credentials c ON c.id = h.credential_id
WHERE h.id = ?
AND ${visibleClause('h')}
AND ${visibleClause('c')}
`).get(hostId, userId, userId, userId, userId);
}
export function createRdpLaunchSession(hostId, userId) {
cleanupExpiredSessions();
const target = loadRdpTarget(hostId, userId);
if (!target) {
const error = new Error('Windows host with an assigned visible credential is required for RDP launch.');
error.statusCode = 404;
throw error;
}
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
const error = new Error('RDP launch is only available for Windows hosts.');
error.statusCode = 400;
throw error;
}
if (target.credential_kind !== 'username_password') {
const error = new Error('RDP launch requires a username/password credential assigned to the host.');
error.statusCode = 400;
throw error;
}
const password = decryptSecret(target);
if (!password) {
const error = new Error('Assigned host credential does not have a decryptable password.');
error.statusCode = 400;
throw error;
}
const sessionToken = token();
const parsedUsername = splitDomainUsername(target.credential_username);
const session = {
token: sessionToken,
userId,
hostId: target.id,
hostName: target.name,
address: target.fqdn || target.address,
port: 3389,
domain: parsedUsername.domain,
username: parsedUsername.username,
password,
credentialName: target.credential_name,
createdAt: Date.now()
};
sessions.set(sessionToken, session);
logger.info('rdp session created', {
hostId: target.id,
hostName: target.name,
userId,
credentialId: target.credential_id
});
return {
token: sessionToken,
url: `/rdp/${sessionToken}`,
expiresInSeconds: Math.floor(SESSION_TTL_MS / 1000),
host: {
id: target.id,
name: target.name,
address: session.address
}
};
}
function renderSessionPage(session) {
const safeToken = escapeHtml(session.token);
const safeHost = escapeHtml(session.hostName);
const safeAddress = escapeHtml(session.address);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>RDP - ${safeHost}</title>
<link rel="icon" href="/rdp/assets/img/favicon.ico" />
<script src="/rdp/socket.io/socket.io.js"></script>
<script src="/rdp/assets/js/mstsc.js"></script>
<script src="/rdp/assets/js/keyboard.js"></script>
<script src="/rdp/assets/js/rle.js"></script>
<script src="/rdp/assets/js/client.js"></script>
<script src="/rdp/assets/js/canvas.js"></script>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
color: #eff7ff;
background:
radial-gradient(circle at 18% 12%, rgba(109, 102, 255, .25), transparent 34%),
radial-gradient(circle at 78% 18%, rgba(88, 221, 255, .22), transparent 32%),
linear-gradient(135deg, #090d1e, #101a32 48%, #050814);
}
.launch {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
}
.panel {
width: min(560px, 100%);
border: 1px solid rgba(156, 199, 232, .24);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
padding: 28px;
backdrop-filter: blur(24px) saturate(140%);
}
.eyebrow { color: #58ddff; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 8px 0 8px; font-size: clamp(28px, 4vw, 44px); line-height: 1; }
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
.pulse { width: 10px; height: 10px; border-radius: 50%; background: #64e7bd; box-shadow: 0 0 22px #64e7bd; }
#rdpCanvas { display: none; width: 100vw; height: 100vh; background: #050814; }
</style>
</head>
<body>
<main id="main" class="launch">
<section class="panel">
<span class="eyebrow">POSHManager RDP</span>
<h1>${safeHost}</h1>
<p>Opening a browser RDP session to ${safeAddress} using the assigned host credential. Close this tab to end the session.</p>
<div class="status"><span class="pulse"></span><span id="status">Preparing secure session...</span></div>
</section>
</main>
<canvas id="rdpCanvas"></canvas>
<script>
(function () {
try { window.opener = null; } catch (err) {}
var token = "${safeToken}";
var status = document.getElementById('status');
var canvas = document.getElementById('rdpCanvas');
var main = document.getElementById('main');
var client = Mstsc.client.create(canvas);
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
status.textContent = 'Connecting...';
canvas.style.display = 'block';
main.style.display = 'none';
client.connect('poshmanager-session', '', '', token, function (err) {
canvas.style.display = 'none';
main.style.display = 'grid';
status.textContent = err ? 'RDP session failed or closed.' : 'RDP session closed.';
});
}());
</script>
</body>
</html>`;
}
export function mountRdpGateway(app) {
app.use('/rdp/assets', express.static(mstscClientDir, {
fallthrough: false,
immutable: true,
maxAge: '1h'
}));
app.get('/rdp/sessions/:token', (req, res) => {
res.redirect(302, `/rdp/${encodeURIComponent(req.params.token)}`);
});
app.get('/rdp/:token', (req, res) => {
cleanupExpiredSessions();
const session = sessions.get(req.params.token);
if (!session) return res.status(404).send('RDP session expired or not found.');
res.type('html').send(renderSessionPage(session));
});
}
export function startRdpGateway(httpServer) {
if (socketServer) return socketServer;
socketServer = socketIo(httpServer, { path: '/rdp/socket.io' });
socketServer.on('connection', (client) => {
let rdpClient = null;
client.on('infos', (infos = {}) => {
cleanupExpiredSessions();
const sessionToken = infos.token || infos.sessionToken || infos.password;
const session = sessions.get(sessionToken);
if (!session) {
client.emit('rdp-error', { code: 'POSHM_RDP_SESSION', message: 'RDP session expired or invalid.' });
client.disconnect();
return;
}
if (rdpClient) rdpClient.close();
const screen = {
width: Math.max(640, Math.min(Number(infos.screen?.width || 1280), 3840)),
height: Math.max(480, Math.min(Number(infos.screen?.height || 800), 2160))
};
logger.info('rdp connection starting', {
hostId: session.hostId,
hostName: session.hostName,
address: session.address,
userId: session.userId
});
rdpClient = rdp.createClient({
domain: session.domain,
userName: session.username,
password: session.password,
enablePerf: true,
autoLogin: true,
screen,
locale: infos.locale,
logLevel: 'ERROR'
}).on('connect', () => {
client.emit('rdp-connect');
logger.info('rdp connection established', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
}).on('bitmap', (bitmap) => {
client.emit('rdp-bitmap', bitmap);
}).on('close', () => {
client.emit('rdp-close');
sessions.delete(sessionToken);
logger.info('rdp connection closed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
}).on('error', (err) => {
client.emit('rdp-error', { code: err.code || 'RDP_ERROR', message: err.message || String(err) });
logger.error('rdp connection failed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId, error: err.message || String(err) });
}).connect(session.address, session.port);
});
client.on('mouse', (x, y, button, isPressed) => {
if (rdpClient) rdpClient.sendPointerEvent(x, y, button, isPressed);
});
client.on('wheel', (x, y, step, isNegative, isHorizontal) => {
if (rdpClient) rdpClient.sendWheelEvent(x, y, step, isNegative, isHorizontal);
});
client.on('scancode', (code, isPressed) => {
if (rdpClient) rdpClient.sendKeyEventScancode(code, isPressed);
});
client.on('unicode', (code, isPressed) => {
if (rdpClient) rdpClient.sendKeyEventUnicode(code, isPressed);
});
client.on('disconnect', () => {
if (rdpClient) rdpClient.close();
});
});
logger.info('POSHManager RDP gateway mounted at /rdp');
return socketServer;
}

View File

@@ -3,8 +3,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
const { createCredential } = await load('../models/credentialModel.js');
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
const { db } = await load('../db.js');
const owner = adminUserId();
@@ -52,6 +54,46 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
assert.deepEqual(visibleToOther, [shared.id]);
});
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
const credential = createCredential({
name: 'RDP Admin',
kind: 'username_password',
username: 'CONTOSO\\rdpadmin',
secret: 'rdp-secret',
visibility: 'shared'
}, owner);
const host = createHost({
name: 'RDP-WIN-01',
address: 'rdp-win-01.contoso.local',
fqdn: 'rdp-win-01.contoso.local',
osFamily: 'windows',
transport: 'winrm',
credentialId: credential.id,
tags: [],
visibility: 'shared'
}, owner);
const session = createRdpLaunchSession(host.id, other);
assert.match(session.url, /^\/rdp\/[^/]+$/);
assert.equal(session.host.name, 'RDP-WIN-01');
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
assert.ok(!session.url.includes('rdp-secret'));
});
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
const credential = createCredential({
name: 'RDP Linux Credential',
kind: 'username_password',
username: 'linux-user',
secret: 'linux-secret',
visibility: 'shared'
}, owner);
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
});
test('manual host groups keep explicit members', () => {
const host = createHost({ name: 'ManualMember', address: 'manual-member.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
const group = createHostGroup({ name: 'Manual Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);