225 lines
8.0 KiB
JavaScript
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
|
|
}
|
|
};
|
|
}
|