new version full rebuild with claude opus 4.6 and own rdp daemon

This commit is contained in:
felixg
2026-02-28 21:19:52 +01:00
parent fac33c27b4
commit 6e9956bce9
19 changed files with 2185 additions and 326 deletions

View File

@@ -1,5 +1,3 @@
import { createConnection, Socket } from 'net';
import { randomUUID } from 'crypto';
import { FastifyInstance, FastifyRequest } from 'fastify';
import { SocketStream } from '@fastify/websocket';
import { WebSocket } from 'ws';
@@ -11,71 +9,7 @@ interface JwtPayload {
}
// ---------------------------------------------------------------------------
// Guacamole protocol helpers
// ---------------------------------------------------------------------------
function encodeElement(value: string): string {
return `${value.length}.${value}`;
}
function buildInstruction(opcode: string, ...args: string[]): string {
return [encodeElement(opcode), ...args.map(encodeElement)].join(',') + ';';
}
function parseInstruction(instruction: string): string[] {
const raw = instruction.endsWith(';') ? instruction.slice(0, -1) : instruction;
const elements: string[] = [];
let pos = 0;
while (pos < raw.length) {
const dotPos = raw.indexOf('.', pos);
if (dotPos === -1) break;
const length = parseInt(raw.substring(pos, dotPos), 10);
if (isNaN(length)) break;
const value = raw.substring(dotPos + 1, dotPos + 1 + length);
elements.push(value);
pos = dotPos + 1 + length;
if (raw[pos] === ',') pos++;
}
return elements;
}
function readInstruction(tcpSocket: Socket, buf: { value: string }): Promise<string[]> {
return new Promise((resolve, reject) => {
const check = () => {
const idx = buf.value.indexOf(';');
if (idx !== -1) {
const instruction = buf.value.substring(0, idx + 1);
buf.value = buf.value.substring(idx + 1);
resolve(parseInstruction(instruction));
return true;
}
return false;
};
if (check()) return;
const onData = (data: Buffer) => {
buf.value += data.toString('utf8');
if (check()) {
tcpSocket.removeListener('data', onData);
tcpSocket.removeListener('error', onError);
}
};
const onError = (err: Error) => {
tcpSocket.removeListener('data', onData);
reject(err);
};
tcpSocket.on('data', onData);
tcpSocket.on('error', onError);
});
}
// ---------------------------------------------------------------------------
// RDP WebSocket handler
// RDP WebSocket handler — proxies between browser and rdpd
// ---------------------------------------------------------------------------
export async function rdpWebsocket(fastify: FastifyInstance) {
@@ -98,7 +32,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
return;
}
// --- Fetch connection ---
// --- Fetch connection from DB ---
const { connectionId } = request.params as { connectionId: string };
const conn = await fastify.prisma.connection.findFirst({
where: { id: connectionId, userId },
@@ -109,160 +43,96 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
return;
}
const guacdHost = process.env.GUACD_HOST || 'localhost';
const guacdPort = Number(process.env.GUACD_PORT) || 4822;
const rdpdUrl = process.env.RDPD_URL || 'ws://localhost:7777';
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
// --- Connect to guacd ---
const guacd = createConnection(guacdPort, guacdHost);
const tcpBuf = { value: '' };
fastify.log.info(
{ host: conn.host, port: conn.port, user: conn.username, rdpdUrl },
'RDP: connecting to rdpd'
);
// --- Connect to rdpd ---
let rdpd: WebSocket;
try {
await new Promise<void>((resolve, reject) => {
guacd.once('connect', resolve);
guacd.once('error', reject);
rdpd = await new Promise<WebSocket>((resolve, reject) => {
const ws = new WebSocket(rdpdUrl);
ws.once('open', () => resolve(ws));
ws.once('error', reject);
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'guacd connect failed';
fastify.log.warn({ err }, 'guacd connect failed');
const msg = err instanceof Error ? err.message : 'rdpd connect failed';
fastify.log.warn({ err }, 'rdpd connect failed');
socket.close(1011, msg);
return;
}
// Pre-check: verify the RDP server is TCP-reachable from this container.
// The backend is on the same Docker network as guacd, so same reachability.
try {
await new Promise<void>((resolve, reject) => {
const test = createConnection(conn.port, conn.host);
const timer = setTimeout(() => {
test.destroy();
reject(new Error(`Cannot reach ${conn.host}:${conn.port} — connection timed out`));
}, 5000);
test.once('connect', () => { clearTimeout(timer); test.destroy(); resolve(); });
test.once('error', (err) => {
clearTimeout(timer);
reject(new Error(`Cannot reach ${conn.host}:${conn.port}${err.message}`));
});
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Cannot reach RDP host';
fastify.log.warn({ host: conn.host, port: conn.port }, msg);
socket.close(1011, msg);
return;
}
fastify.log.info({ host: conn.host, port: conn.port, user: conn.username }, 'RDP: TCP reachable, starting guacd handshake');
try {
// 1. Select protocol
guacd.write(buildInstruction('select', conn.protocol));
// 2. Read args list from guacd
const argsInstruction = await readInstruction(guacd, tcpBuf);
const argNames = argsInstruction.slice(1);
fastify.log.info({ argNames }, 'RDP: guacd args list received');
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
const rdpParams: Record<string, string> = {
hostname: conn.host,
port: String(conn.port),
username: conn.username,
password: decryptedPassword,
domain: conn.domain || '',
width: '1280',
height: '720',
dpi: '96',
'color-depth': '32',
'ignore-cert': 'true',
// 'nla' = Network Level Authentication (CredSSP). Required for Windows 11 22H2+ which
// mandates CredSSP v6. FreeRDP 3.x handles CredSSP v6 properly.
security: 'nla',
'disable-auth': 'false',
'enable-drive': 'false',
'create-drive-path': 'false',
'enable-printing': 'false',
'disable-audio': 'true',
'disable-glyph-caching': 'false',
'disable-gfx': 'true',
'cert-tofu': 'true',
'resize-method': 'reconnect',
};
// Acknowledge whichever VERSION_x_y_z guacd advertises.
// Without this echo, guacd runs in legacy compatibility mode which
// can cause FreeRDP to crash on modern Windows targets.
for (const name of argNames) {
if (name.startsWith('VERSION_')) {
rdpParams[name] = name;
}
}
// 3. Send size instruction so guacd populates client->info.optimal_width/height/resolution.
// Without this, guacd logs "0x0 at 0 DPI" and FreeRDP may use a zero-size display.
guacd.write(buildInstruction('size', rdpParams.width, rdpParams.height, rdpParams.dpi));
// 4. Connect with values guacd requested
const argValues = argNames.map((name) => rdpParams[name] ?? '');
const connectInstruction = buildInstruction('connect', ...argValues);
fastify.log.info(
{ argNames, argValues, connectInstruction: connectInstruction.substring(0, 500) },
'RDP: sending connect instruction'
);
guacd.write(connectInstruction);
// 5. Read ready instruction
const readyInstruction = await readInstruction(guacd, tcpBuf);
fastify.log.info({ readyInstruction }, 'RDP: guacd ready instruction received');
if (readyInstruction[0] !== 'ready') {
throw new Error(`guacd handshake failed: expected 'ready', got '${readyInstruction[0]}'`);
}
// 6. Send the tunnel UUID as a Guacamole internal instruction.
// WebSocketTunnel (1.5.0+) expects opcode "" (empty string) with the
// UUID as the single argument: "0.,36.<uuid>;"
const guacdUUID = readyInstruction[1] ?? randomUUID();
socket.send(buildInstruction('', guacdUUID));
// 7. Flush any buffered bytes that arrived after 'ready'
if (tcpBuf.value.length > 0) {
fastify.log.info({ flushed: tcpBuf.value.substring(0, 300) }, 'RDP: flushing buffered data after ready');
if (socket.readyState === WebSocket.OPEN) socket.send(tcpBuf.value);
tcpBuf.value = '';
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Guacamole handshake failed';
fastify.log.warn({ err }, 'Guacamole handshake failed');
guacd.destroy();
socket.close(1011, msg);
return;
}
fastify.log.info('RDP: entering proxy mode');
// --- Proxy mode ---
guacd.on('data', (data: Buffer) => {
const text = data.toString('utf8');
// Log all data during proxy phase at info level so we can see error instructions
fastify.log.info({ guacdData: text.substring(0, 500) }, 'RDP: data from guacd');
if (socket.readyState === WebSocket.OPEN) socket.send(text);
// --- Send connect message with credentials ---
const connectMsg = JSON.stringify({
type: 'connect',
host: conn.host,
port: conn.port,
username: conn.username,
password: decryptedPassword,
domain: conn.domain || '',
width: 1280,
height: 720,
});
guacd.on('end', () => {
fastify.log.info('RDP: guacd TCP connection ended');
socket.close();
});
guacd.on('error', (err) => {
fastify.log.warn({ err }, 'guacd socket error');
socket.close(1011, err.message);
});
rdpd.send(connectMsg);
fastify.log.info('RDP: connect message sent to rdpd');
socket.on('message', (message: Buffer | string) => {
if (guacd.writable) {
guacd.write(typeof message === 'string' ? message : message.toString('utf8'));
// --- Bidirectional proxy ---
// rdpd → browser: forward both binary (JPEG frames) and text (JSON control)
rdpd.on('message', (data: Buffer | string, isBinary: boolean) => {
if (socket.readyState !== WebSocket.OPEN) return;
if (isBinary) {
// JPEG frame — forward as binary
socket.send(data as Buffer, { binary: true });
} else {
// JSON control message — forward as text
const text = typeof data === 'string' ? data : (data as Buffer).toString('utf8');
socket.send(text);
}
});
socket.on('close', () => guacd.destroy());
// browser → rdpd: forward all messages (JSON input events)
socket.on('message', (data: Buffer | string) => {
if (rdpd.readyState !== WebSocket.OPEN) return;
const text = typeof data === 'string' ? data : data.toString('utf8');
rdpd.send(text);
});
// --- Cleanup on either side closing ---
rdpd.on('close', () => {
fastify.log.info('RDP: rdpd connection closed');
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
});
rdpd.on('error', (err) => {
fastify.log.warn({ err }, 'rdpd WebSocket error');
if (socket.readyState === WebSocket.OPEN) {
socket.close(1011, err.message);
}
});
socket.on('close', () => {
fastify.log.info('RDP: browser WebSocket closed');
if (rdpd.readyState === WebSocket.OPEN) {
rdpd.close();
}
});
socket.on('error', (err) => {
fastify.log.warn({ err }, 'browser WebSocket error');
if (rdpd.readyState === WebSocket.OPEN) {
rdpd.close();
}
});
}
);
}