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

@@ -512,6 +512,7 @@ Payload:
| --- | --- | --- | --- |
| `GET` | `/api/hosts` | User | List host library. |
| `POST` | `/api/hosts` | User | Create host. |
| `POST` | `/api/hosts/:id/rdp-session` | User | Create a short-lived browser RDP launch session for a visible Windows host with an assigned visible username/password credential. Returns a same-origin `/rdp/:token` URL. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
@@ -562,6 +563,15 @@ and `sourceRef` to the vCenter VM id. `osFamily` is normalized to `windows`,
infer it from inventory. vCenter imports infer Windows/Linux from VMware Tools
guest identity when available; unknown guests are imported as `other`.
Windows hosts with an assigned visible username/password Credential Vault entry
show an RDP icon in the Hosts table. Clicking it calls
`POST /api/hosts/:id/rdp-session`, opens a same-origin `/rdp/:token`
tab, and renders a browser RDP canvas through `mstsc.js`. The browser receives
only a short-lived opaque launch token; POSHManager resolves the host address
and decrypts the assigned credential server-side. The RDP gateway uses the
default RDP port `3389`; PowerShell remoting `port` metadata remains separate
from RDP launch behavior.
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
without duplicating RunPlans. Manual groups store an explicit list of visible
host IDs that an operator can add/remove in the Hosts screen. Dynamic groups
@@ -1379,6 +1389,7 @@ on the target platform before broad execution.
- SQLite access uses prepared statements through `node:sqlite`.
- Credential secrets are encrypted with AES-256-GCM and are not returned by API reads.
- Browser RDP sessions use `mstsc.js` with short-lived POSHManager launch tokens; host passwords stay in the API process and are never embedded in the Vue app or launch URL. The upstream `mstsc.js` dependency is legacy and currently brings transitive npm audit findings, so expose `/rdp` only through authenticated POSHManager/reverse-proxy paths and restrict network access to trusted operators.
- Request logs redact authorization and cookie headers.
- Protected routes use JWT auth.
- Admin-only APIs are guarded by `requireAdmin`.

View File

@@ -336,7 +336,30 @@
<template #cell-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
<template #cell-tags="{ row }"><span class="tag-row">{{ row.tags?.join(', ') || '-' }}</span></template>
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openHostModal(row)">Edit</button></template>
<template #cell-actions="{ row }">
<div class="host-row-actions">
<button
v-if="isWindowsHost(row)"
class="ghost-button compact icon-button"
type="button"
:disabled="!canLaunchRdp(row)"
:aria-label="rdpActionTitle(row)"
:title="rdpActionTitle(row)"
@click="launchRdpHost(row)"
>
<i class="mdi mdi-remote-desktop" aria-hidden="true"></i>
</button>
<button
class="ghost-button compact icon-button"
type="button"
aria-label="Edit host"
title="Edit host"
@click="openHostModal(row)"
>
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
<HostGroupsPanel
@@ -2592,6 +2615,91 @@ function osFamilyLabel(value) {
return 'Other';
}
function isWindowsHost(host) {
return (host?.osFamily || '').toLowerCase() === 'windows';
}
function canLaunchRdp(host) {
return isWindowsHost(host) && Boolean(host?.credentialId);
}
function rdpActionTitle(host) {
if (!isWindowsHost(host)) return 'RDP is available for Windows hosts only';
if (!host?.credentialId) return 'Assign a host credential before launching RDP';
return `Open RDP session to ${host.name}`;
}
function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
if (!popup?.document) return;
const isError = state === 'error';
popup.document.open();
popup.document.write(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapePreviewHtml(isError ? 'RDP launch failed' : `Opening RDP - ${hostName}`)}</title>
<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;
display: grid;
place-items: center;
padding: 24px;
color: #eff7ff;
background:
radial-gradient(circle at 20% 14%, rgba(109, 102, 255, .28), transparent 32%),
radial-gradient(circle at 78% 16%, rgba(88, 221, 255, .22), transparent 32%),
linear-gradient(135deg, #070b18, #101a32 48%, #050814);
}
section {
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;
}
.eyebrow { color: ${isError ? '#ff9aa8' : '#58ddff'}; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 8px 0; font-size: clamp(28px, 4vw, 42px); 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: ${isError ? '#ff7d91' : '#64e7bd'}; box-shadow: 0 0 22px currentColor; color: ${isError ? '#ff7d91' : '#64e7bd'}; }
</style>
</head>
<body>
<section>
<span class="eyebrow">${isError ? 'POSHManager RDP error' : 'POSHManager RDP'}</span>
<h1>${escapePreviewHtml(hostName)}</h1>
<p>${escapePreviewHtml(message || (isError ? 'The RDP launch failed.' : 'Preparing the browser RDP session...'))}</p>
<div class="status"><span class="pulse"></span><span>${isError ? 'Launch failed' : 'Creating secure launch token...'}</span></div>
</section>
</body>
</html>`);
popup.document.close();
}
async function launchRdpHost(host) {
if (!canLaunchRdp(host)) return;
const popup = window.open('about:blank', '_blank');
writeRdpLaunchPopup(popup, host.name, 'loading');
try {
const result = await api.post(`/api/hosts/${host.id}/rdp-session`, {});
const launchUrl = new URL(result.url, window.location.origin).toString();
if (popup) {
popup.location.replace(launchUrl);
} else {
window.open(launchUrl, '_blank', 'noopener,noreferrer');
}
notify(`Opening RDP session to ${host.name}`);
} catch (err) {
writeRdpLaunchPopup(popup, host.name, 'error', err.message);
notify(err.message, 'error');
}
}
async function saveHostGroup(payload) {
const body = { ...payload };
const groupId = body.id;

View File

@@ -506,6 +506,29 @@ textarea:focus {
padding: 0 12px;
}
.icon-button {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
gap: 0;
}
.icon-button i {
margin: 0;
font-size: 17px;
line-height: 1;
}
.host-row-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
min-width: 0;
}
.sidebar {
position: fixed;
top: 12px;

View File

@@ -94,11 +94,23 @@
<template #cell-latestAt="{ row }">{{ shortDate(row.latestAt) }}</template>
<template #cell-actions="{ row }">
<div class="job-output-row-actions">
<button class="ghost-button compact" type="button" @click="viewGroup(row)">
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>View
<button
class="ghost-button compact icon-only"
type="button"
aria-label="View selected output artifact"
title="View selected output artifact"
@click="viewGroup(row)"
>
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact" type="button" @click="downloadGroup(row)">
<i class="mdi mdi-download-outline" aria-hidden="true"></i>Download
<button
class="ghost-button compact icon-only"
type="button"
aria-label="Download selected output artifact"
title="Download selected output artifact"
@click="downloadGroup(row)"
>
<i class="mdi mdi-download-outline" aria-hidden="true"></i>
</button>
</div>
</template>
@@ -405,12 +417,12 @@ function formatBytes(bytes = 0) {
.job-output-list :deep(th:nth-child(1)),
.job-output-list :deep(td:nth-child(1)) {
width: 30%;
width: 32%;
}
.job-output-list :deep(th:nth-child(2)),
.job-output-list :deep(td:nth-child(2)) {
width: 22%;
width: 23%;
}
.job-output-list :deep(th:nth-child(3)),
@@ -425,12 +437,12 @@ function formatBytes(bytes = 0) {
.job-output-list :deep(th:nth-child(5)),
.job-output-list :deep(td:nth-child(5)) {
width: 14%;
width: 15%;
}
.job-output-list :deep(th:last-child),
.job-output-list :deep(td:last-child) {
width: 13%;
width: 9%;
}
.job-output-title {
@@ -533,16 +545,24 @@ function formatBytes(bytes = 0) {
}
.job-output-row-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
display: flex;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.job-output-row-actions .ghost-button {
.job-output-row-actions .icon-only {
width: 38px;
height: 38px;
justify-content: center;
min-width: 0;
padding-inline: 10px;
min-width: 38px;
padding: 0;
}
.job-output-row-actions .icon-only i {
margin: 0;
font-size: 17px;
line-height: 1;
}
@media (max-width: 760px) {

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"monaco-editor": "^0.53.0",
"mstsc.js": "^0.2.4",
"multer": "^2.2.0",
"nanoid": "^5.1.6",
"path": "^0.12.7",

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, '&amp;')
.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);

View File

@@ -20,6 +20,12 @@ export default defineConfig({
target: apiTarget,
changeOrigin: true,
secure: false
},
'/rdp': {
target: apiTarget,
changeOrigin: true,
secure: false,
ws: true
}
}
},
@@ -31,6 +37,12 @@ export default defineConfig({
target: apiTarget,
changeOrigin: true,
secure: false
},
'/rdp': {
target: apiTarget,
changeOrigin: true,
secure: false,
ws: true
}
}
},