301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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;
|
|
}
|