Files
poshmanager/server/services/rdpGatewayService.js
2026-08-09 17:33:11 -05:00

225 lines
8.0 KiB
JavaScript

import { db, visibleClause } from '../db.js';
import { config } from '../config.js';
import { decryptSecret } from './cryptoStore.js';
import { logger } from './logger.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
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 numberSetting(key, fallback, min, max) {
const value = Number(getStringSetting(key, String(fallback)));
if (!Number.isFinite(value)) return fallback;
return Math.max(min, Math.min(value, max));
}
function makeHttpError(message, statusCode = 400, details = undefined) {
const error = new Error(message);
error.statusCode = statusCode;
if (details) error.details = details;
return error;
}
export function normalizeMyrtilleGatewayUrl(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return '';
const normalized = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
const url = new URL(normalized);
if (!['http:', 'https:'].includes(url.protocol)) {
throw makeHttpError('Myrtille gateway URL must use http or https.', 400);
}
return url.toString();
}
function normalizeHashEndpoint(value) {
const trimmed = String(value || '').trim();
return trimmed || 'GetHash.aspx';
}
function myrtilleSettings() {
const gatewayUrl = normalizeMyrtilleGatewayUrl(getStringSetting('myrtille_gateway_url', config.myrtille.gatewayUrl));
return {
enabled: getBooleanSetting('myrtille_enabled', config.myrtille.enabled),
gatewayUrl,
usePasswordHash: getBooleanSetting('myrtille_use_password_hash', config.myrtille.usePasswordHash),
allowPlainPassword: getBooleanSetting('myrtille_allow_plain_password', config.myrtille.allowPlainPassword),
hashEndpoint: normalizeHashEndpoint(getStringSetting('myrtille_hash_endpoint', config.myrtille.hashEndpoint)),
width: numberSetting('myrtille_default_width', config.myrtille.defaultWidth, 640, 7680),
height: numberSetting('myrtille_default_height', config.myrtille.defaultHeight, 480, 4320),
requestTimeoutMs: numberSetting('myrtille_request_timeout_ms', config.myrtille.requestTimeoutMs, 1000, 120000)
};
}
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);
}
async function fetchMyrtillePasswordHash(settings, password) {
const url = new URL(settings.hashEndpoint, settings.gatewayUrl);
url.searchParams.set('password', password);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), settings.requestTimeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'text/plain, text/html, */*' },
signal: controller.signal
});
const body = (await response.text()).trim();
if (!response.ok) {
throw makeHttpError(
`Myrtille password hash endpoint returned HTTP ${response.status}.`,
502,
{ status: response.status, body: body.slice(0, 500) }
);
}
const hash = extractHashFromMyrtilleResponse(body);
if (!hash) {
throw makeHttpError('Myrtille password hash endpoint did not return a usable hash.', 502, { body: body.slice(0, 500) });
}
return hash;
} catch (error) {
if (error?.name === 'AbortError') {
throw makeHttpError(`Myrtille password hash request timed out after ${settings.requestTimeoutMs}ms.`, 502);
}
if (error?.statusCode) throw error;
throw makeHttpError(`Unable to request a Myrtille password hash: ${error.message}`, 502);
} finally {
clearTimeout(timeout);
}
}
export function extractHashFromMyrtilleResponse(body = '') {
const text = String(body || '').trim();
if (!text) return '';
const inputMatch = text.match(/value=["']([^"']+)["']/i);
if (inputMatch?.[1]) return inputMatch[1].trim();
const markerMatch = text.match(/passwordHash["'\s:=>-]+([A-Za-z0-9+/=_:-]+)/i);
if (markerMatch?.[1]) return markerMatch[1].trim();
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).find((part) => part.length >= 8) || '';
}
export function buildMyrtilleLaunchUrl({
gatewayUrl,
target,
domain = '',
username,
passwordHash = '',
password = '',
width = 1280,
height = 800
}) {
const url = new URL(gatewayUrl);
url.searchParams.set('__EVENTTARGET', '');
url.searchParams.set('__EVENTARGUMENT', '');
url.searchParams.set('server', target);
if (domain) url.searchParams.set('domain', domain);
url.searchParams.set('user', username);
if (passwordHash) url.searchParams.set('passwordHash', passwordHash);
else if (password) url.searchParams.set('password', password);
url.searchParams.set('width', String(width));
url.searchParams.set('height', String(height));
url.searchParams.set('connect', 'Connect!');
return url.toString();
}
export async function createRdpLaunchSession(hostId, userId) {
const target = loadRdpTarget(hostId, userId);
if (!target) {
throw makeHttpError('Windows host with an assigned visible credential is required for RDP launch.', 404);
}
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
throw makeHttpError('RDP launch is only available for Windows hosts.', 400);
}
if (target.credential_kind !== 'username_password') {
throw makeHttpError('RDP launch requires a username/password credential assigned to the host.', 400);
}
const settings = myrtilleSettings();
if (!settings.enabled || !settings.gatewayUrl) {
throw makeHttpError('Myrtille RDP gateway is not configured. Enable myrtille_enabled and set myrtille_gateway_url in Configuration or environment variables.', 400);
}
const password = decryptSecret(target);
if (!password) {
throw makeHttpError('Assigned host credential does not have a decryptable password.', 400);
}
const address = target.fqdn || target.address;
const parsedUsername = splitDomainUsername(target.credential_username);
let passwordHash = '';
let plainPassword = '';
if (settings.usePasswordHash) {
try {
passwordHash = await fetchMyrtillePasswordHash(settings, password);
} catch (error) {
if (!settings.allowPlainPassword) {
throw makeHttpError(
`${error.message} Myrtille password-hash mode is enabled and plaintext URL fallback is disabled.`,
error.statusCode || 502,
error.details
);
}
logger.warn('falling back to plaintext Myrtille password launch because hash generation failed and fallback is enabled', {
hostId: target.id,
hostName: target.name,
userId,
error: error.message
});
plainPassword = password;
}
} else {
if (!settings.allowPlainPassword) {
throw makeHttpError('Myrtille plaintext password launch is disabled. Enable password-hash mode or explicitly allow plaintext fallback.', 400);
}
plainPassword = password;
}
const url = buildMyrtilleLaunchUrl({
gatewayUrl: settings.gatewayUrl,
target: address,
domain: parsedUsername.domain,
username: parsedUsername.username,
passwordHash,
password: plainPassword,
width: settings.width,
height: settings.height
});
logger.info('myrtille rdp launch created', {
hostId: target.id,
hostName: target.name,
userId,
credentialId: target.credential_id,
gatewayUrl: settings.gatewayUrl,
credentialMode: passwordHash ? 'passwordHash' : 'password'
});
return {
provider: 'myrtille',
url,
gatewayUrl: settings.gatewayUrl,
expiresInSeconds: 0,
credentialMode: passwordHash ? 'passwordHash' : 'password',
host: {
id: target.id,
name: target.name,
address
}
};
}