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;
// --- Connect to guacd ---
const guacd = createConnection(guacdPort, guacdHost);
const tcpBuf = { value: '' };
try {
await new Promise<void>((resolve, reject) => {
guacd.once('connect', resolve);
guacd.once('error', reject);
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'guacd connect failed';
fastify.log.warn({ err }, 'guacd 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 rdpdUrl = process.env.RDPD_URL || 'ws://localhost:7777';
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
const rdpParams: Record<string, string> = {
hostname: conn.host,
port: String(conn.port),
fastify.log.info(
{ host: conn.host, port: conn.port, user: conn.username, rdpdUrl },
'RDP: connecting to rdpd'
);
// --- Connect to rdpd ---
let rdpd: WebSocket;
try {
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 : 'rdpd connect failed';
fastify.log.warn({ err }, 'rdpd connect failed');
socket.close(1011, msg);
return;
}
// --- 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',
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);
width: 1280,
height: 720,
});
guacd.on('end', () => {
fastify.log.info('RDP: guacd TCP connection ended');
rdpd.send(connectMsg);
fastify.log.info('RDP: connect message sent to rdpd');
// --- 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);
}
});
// 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();
});
guacd.on('error', (err) => {
fastify.log.warn({ err }, 'guacd socket error');
socket.close(1011, err.message);
});
socket.on('message', (message: Buffer | string) => {
if (guacd.writable) {
guacd.write(typeof message === 'string' ? message : message.toString('utf8'));
}
});
socket.on('close', () => guacd.destroy());
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();
}
});
}
);
}

View File

@@ -16,14 +16,14 @@ services:
timeout: 5s
retries: 10
guacd:
rdpd:
build:
context: .
dockerfile: docker/guacd.Dockerfile
dockerfile: rdpd/Dockerfile
restart: unless-stopped
environment:
GUACD_LOG_LEVEL: debug
WLOG_LEVEL: "0"
RDPD_LISTEN: 0.0.0.0:7777
RDPD_LOG_LEVEL: info
networks:
- internal
@@ -35,12 +35,11 @@ services:
env_file: .env
environment:
POSTGRES_URL: postgresql://mremotify:mremotify@postgres:5432/mremotify
GUACD_HOST: guacd
GUACD_PORT: '4822'
RDPD_URL: ws://rdpd:7777
depends_on:
postgres:
condition: service_healthy
guacd:
rdpd:
condition: service_started
networks:
- internal

View File

@@ -1,43 +1,47 @@
# syntax=docker/dockerfile:1
#
# Custom guacd image built against FreeRDP 3.x on Ubuntu 24.04.
# Custom guacd image built against FreeRDP 3.8.0+ on Ubuntu 24.04.
#
# Why: The official guacamole/guacd image uses FreeRDP 2.x, which crashes
# silently when connecting to Windows 11 22H2+ hosts due to NLA/CredSSP
# cipher-suite changes introduced by Microsoft. FreeRDP 3.x fixes this.
# Ubuntu 24.04 ships freerdp3-dev (FreeRDP 3.5.1+) in its universe repo.
#
# Source: built from git main branch (post-1.6.0) to pick up FreeRDP 3.x
# crash fixes that landed after the June 2025 release tarball.
# Why FreeRDP from source: Ubuntu 24.04 ships freerdp3-dev 3.5.1.
# guacamole-server git main (1.7-dev) targets FreeRDP 3.8.0+:
# - gdi.c's GUAC_ASSERT(current_context == NULL) is gated #if MINOR < 8
# so with 3.5.x it fires when GFX calls begin_paint before desktop_resize
# - gdi_resize() in 3.8.0+ calls EndPaint internally, which guacamole relies on
# - GFX pipeline surface synchronisation is fixed in 3.8.0+
# Building 3.x HEAD from source (always >= 3.8.0 by now) gives us the
# correct FreeRDP that guacamole-server git main was developed against.
#
# Build notes:
# - CPPFLAGS=-Wno-error=deprecated-declarations suppresses build-time warnings from
# FreeRDP 3.x headers marking some fields/functions as deprecated; these are
# warnings only and do NOT affect runtime behavior.
# - CPPFLAGS=-DHAVE_FREERDP_VERIFYCERTIFICATEEX=1 fixes a macro name mismatch bug
# in guacamole-server 1.6.0: configure.ac's AC_CHECK_MEMBERS generates the macro
# HAVE_STRUCT_FREERDP_VERIFYCERTIFICATEEX (with STRUCT_ infix), but rdp.c checks
# HAVE_FREERDP_VERIFYCERTIFICATEEX (without STRUCT_ infix), so the check is always
# false. This means guacamole never registers the VerifyCertificateEx callback, which
# FreeRDP 3.x calls during TLS certificate verification. The NULL callback causes a
# silent connection drop ~430ms after keymap loading. Defining the macro manually
# forces rdp.c to register rdp_inst->VerifyCertificateEx (correct FreeRDP 3.x path)
# instead of the legacy rdp_inst->VerifyCertificate (padding in FreeRDP 3.x).
# - CPPFLAGS=-DHAVE_FREERDP_VERIFYCERTIFICATEEX=1 fixes a macro name mismatch
# in guacamole-server: configure.ac generates HAVE_STRUCT_FREERDP_VERIFYCERTIFICATEEX
# (AC_CHECK_MEMBERS adds STRUCT_ prefix) but rdp.c checks
# HAVE_FREERDP_VERIFYCERTIFICATEEX (no STRUCT_). Without this the VerifyCertificateEx
# callback is never registered, causing a silent drop ~430ms after keymap load.
# - display-flush.c / display-plan.c patch: loop over layers doesn't advance
# `current` before `continue` when pending_frame.buffer is NULL → infinite loop
# or GUAC_ASSERT abort. Fixed by replacing the GUAC_ASSERT with a pointer advance.
# - gdi.c patch: GUAC_ASSERT(current_context == NULL) in desktop_resize fires on
# FreeRDP 3.5.x because the GFX pipeline calls begin_paint then gdi_resize before
# end_paint. Replaced with a guard that calls end_paint if context is still open.
# With FreeRDP 3.8.0+ this code is inside #if MINOR < 8 so the patch is harmless.
# - user.c patch: guac_user_supports_webp() dereferences image_mimetypes without
# NULL check → SIGSEGV when client didn't send image MIME types.
FROM ubuntu:24.04
COPY docker/patch-display-flush.py /patch-display-flush.py
ENV DEBIAN_FRONTEND=noninteractive
# Build dependencies
# Build dependencies for guacamole-server AND FreeRDP source build
RUN apt-get update && apt-get install -y --no-install-recommends \
autoconf \
automake \
build-essential \
ca-certificates \
cmake \
curl \
gdb \
git \
freerdp3-dev \
libcairo2-dev \
libjpeg-turbo8-dev \
libossp-uuid-dev \
@@ -51,16 +55,58 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libvncserver-dev \
libwebp-dev \
libwebsockets-dev \
libkrb5-dev \
libavcodec-dev \
libavutil-dev \
libswscale-dev \
libusb-1.0-0-dev \
pkgconf \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Build FreeRDP 3.x from source (3.8.0+ required for guacamole-server git main).
# We install to /usr with lib under lib/ (not lib/x86_64-linux-gnu/) so that
# pkg-config --variable=libdir freerdp3 returns /usr/lib cleanly.
# Optional features (audio, printing, X11 display, H264, Kerberos) are disabled
# since guacd runs headless and we have disable-audio: true.
RUN git clone --depth=1 --branch 3.22.0 https://github.com/FreeRDP/FreeRDP.git /tmp/freerdp \
&& cmake -S /tmp/freerdp -B /tmp/freerdp-build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
-DWITH_X11=OFF \
-DWITH_WAYLAND=OFF \
-DWITH_PULSEAUDIO=OFF \
-DWITH_ALSA=OFF \
-DWITH_OSS=OFF \
-DWITH_CUPS=OFF \
-DWITH_FFMPEG=OFF \
-DWITH_OPENH264=OFF \
-DWITH_JPEG=ON \
-DWITH_CAIRO=ON \
-DWITH_CHANNELS=ON \
-DWITH_CLIENT=ON \
-DWITH_CLIENT_COMMON=ON \
-DWITH_SERVER=OFF \
-DWITH_SAMPLE=OFF \
-DBUILD_TESTING=OFF \
-DWITH_GSSAPI=OFF \
-DWITH_FUSE=OFF \
&& cmake --build /tmp/freerdp-build -j"$(nproc)" \
&& cmake --install /tmp/freerdp-build \
&& ldconfig \
&& rm -rf /tmp/freerdp /tmp/freerdp-build
# Build guacamole-server from git main against our newly installed FreeRDP 3.x.
RUN FREERDP_PLUGIN_DIR=$(pkg-config --variable=libdir freerdp3 2>/dev/null)/freerdp3 \
&& echo "Building guacamole-server (git main) with FreeRDP plugin dir: ${FREERDP_PLUGIN_DIR}" \
&& echo "FreeRDP version: $(pkg-config --modversion freerdp3)" \
&& git clone --depth=1 https://github.com/apache/guacamole-server.git \
&& cd guacamole-server \
&& python3 /patch-display-flush.py \
&& autoreconf -fi \
&& CPPFLAGS="-Wno-error=deprecated-declarations -DHAVE_FREERDP_VERIFYCERTIFICATEEX=1" \
CFLAGS="-g -O0" \
CFLAGS="-O2 -Wno-error=unused-variable" \
./configure \
--prefix=/usr \
--sysconfdir=/etc \
@@ -75,4 +121,4 @@ ENV GUACD_LOG_LEVEL=info
EXPOSE 4822
CMD sh -c "ulimit -c unlimited && exec /usr/sbin/guacd -b 0.0.0.0 -f -L \"${GUACD_LOG_LEVEL}\""
CMD sh -c "exec /usr/sbin/guacd -b 0.0.0.0 -f -L \"${GUACD_LOG_LEVEL}\""

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Patch guacamole-server source files to fix bugs in git main that abort()
or segfault the child process in debug builds (no -DNDEBUG).
--- Fix 1: src/libguac/display-flush.c ---
The frame-flush loop iterates over display layers to diff pending vs last frame.
When a layer has pending_frame.buffer == NULL (e.g. cursor/aux layers that have
never been drawn), the code does:
GUAC_ASSERT(current->pending_frame.buffer_is_external);
continue; <- BUG: never advances `current`
Two problems:
1. GUAC_ASSERT aborts the process if buffer_is_external is false, which happens
for any fresh layer that has not yet received display data. In a debug build
(no -DNDEBUG) this is a real assert -> abort() -> child process dies.
2. If the assert passes, `continue` loops forever on the same layer.
Fix: replace the GUAC_ASSERT with a proper pointer advance before the continue.
--- Fix 2: src/libguac/display-plan.c ---
Identical bug: the same GUAC_ASSERT(current->pending_frame.buffer_is_external)
pattern exists in display-plan.c (same layer-iteration loop, same missing pointer
advance before continue). Applying the same fix.
--- Fix 3: src/libguac/user.c ---
guac_user_supports_webp() iterates user->info.image_mimetypes without checking
whether the pointer itself is NULL. If the client did not send any image MIME
types (or guac_copy_mimetypes() returned NULL), the very first dereference
`*mimetype` causes a SIGSEGV in the display worker thread.
Fix: insert a NULL guard immediately after the pointer is loaded from the struct.
--- Fix 6: src/protocols/rdp/gdi.c (guac_rdp_gdi_end_paint) ---
With disable-gfx/legacy RDP, many Windows bitmap updates do NOT include
FrameMarker PDUs. guac_display_render_thread_notify_frame() (which wakes the
render thread to encode dirty tiles) is ONLY called from guac_rdp_gdi_mark_frame
— the FrameMarker handler. Legacy bitmaps that arrive without FrameMarkers mark
regions dirty but never wake the render thread → tiles are never encoded →
black squares on screen that only appear when a subsequent hover redraw (which
DOES carry a FrameMarker) finally wakes the thread for that region.
Root cause: the gdi_modified → notify_modified path in the main event loop only
fires AFTER the RDP event queue drains. During a continuous burst of bitmap
updates (initial desktop load), the queue never drains, so notify_modified is
never called, and the render thread sleeps indefinitely. Additionally, the render
thread applies lag compensation: if the browser hasn't ACK'd the last sync
instruction, processing_lag can reach 500 ms, and the render thread pauses up to
500 ms between flushes. Both factors combine to cause large areas of the screen
to remain black throughout the initial render.
Fix: call notify_frame unconditionally on every EndPaint. The render thread
naturally rate-limits output via MIN_FRAME_DURATION (10 ms = 100 fps max). To
prevent the processing_lag from reaching the 500 ms cap (which would cause the
render thread to throttle itself), the Node.js proxy (rdp.ts) immediately echoes
every sync instruction received from guacd back to guacd as a client ACK. This
keeps processing_lag near 0 ms so the render thread flushes as fast as
MIN_FRAME_DURATION allows. The browser's own sync ACKs are dropped by the proxy
to avoid double-updating the processing_lag measurement.
"""
import sys
def patch_file_replace(path, search_string, replacement_fn, description):
"""Replace the line containing search_string with replacement_fn(indent)."""
with open(path) as f:
lines = f.readlines()
patched = False
i = 0
while i < len(lines):
if search_string in lines[i]:
indent = lines[i][: len(lines[i]) - len(lines[i].lstrip())]
lines[i] = indent + replacement_fn(indent) + "\n"
patched = True
break
i += 1
if not patched:
print("ERROR: patch target not found in " + path + " (" + description + ")", file=sys.stderr)
sys.exit(1)
with open(path, "w") as f:
f.writelines(lines)
print("Patched " + path + " (" + description + ")")
def patch_file_insert_after(path, search_string, insert_lines, description):
"""Insert insert_lines (with matching indent) after the line containing search_string."""
with open(path) as f:
lines = f.readlines()
patched = False
i = 0
while i < len(lines):
if search_string in lines[i]:
indent = lines[i][: len(lines[i]) - len(lines[i].lstrip())]
for j, new_line in enumerate(insert_lines):
lines.insert(i + 1 + j, indent + new_line + "\n")
patched = True
break
i += 1
if not patched:
print("ERROR: patch target not found in " + path + " (" + description + ")", file=sys.stderr)
sys.exit(1)
with open(path, "w") as f:
f.writelines(lines)
print("Patched " + path + " (" + description + ")")
# Fix 1: display-flush.c
patch_file_replace(
"src/libguac/display-flush.c",
"GUAC_ASSERT(current->pending_frame.buffer_is_external)",
lambda indent: "current = current->pending_frame.next;",
"replace GUAC_ASSERT with pointer advance",
)
# Fix 2: display-plan.c (identical bug pattern)
patch_file_replace(
"src/libguac/display-plan.c",
"GUAC_ASSERT(current->pending_frame.buffer_is_external)",
lambda indent: "current = current->pending_frame.next;",
"replace GUAC_ASSERT with pointer advance",
)
# Fix 3: gdi.c - handle begin_paint context still open during desktop_resize.
# With the GFX pipeline on FreeRDP < 3.8.0, begin_paint may be active when
# desktop_resize fires, leaving current_context non-NULL. FreeRDP 3.8.0 fixed
# this by calling EndPaint inside gdi_resize(); backport that behaviour here.
gdi_path = "src/protocols/rdp/gdi.c"
with open(gdi_path) as f:
gdi_content = f.read()
gdi_old = (
" /* For FreeRDP versions prior to 3.8.0, EndPaint will not be called in\n"
" * `gdi_resize()`, so the current context should be NULL. If it is not\n"
" * NULL, it means that the current context is still open, and therefore the\n"
" * GDI buffer has not been flushed yet. */\n"
" GUAC_ASSERT(rdp_client->current_context == NULL);"
)
gdi_new = (
" /* For FreeRDP < 3.8.0, gdi_resize() does not call EndPaint internally.\n"
" * With the GFX pipeline begin_paint may still be active when desktop_resize\n"
" * fires, leaving current_context non-NULL. End the paint now so the context\n"
" * is properly closed before we open a new one for the resize. */\n"
" if (rdp_client->current_context != NULL)\n"
" guac_rdp_gdi_end_paint(context);"
)
if gdi_old not in gdi_content:
print("ERROR: patch target not found in " + gdi_path + " (desktop_resize assert)", file=sys.stderr)
sys.exit(1)
gdi_content = gdi_content.replace(gdi_old, gdi_new, 1)
with open(gdi_path, "w") as f:
f.write(gdi_content)
print("Patched " + gdi_path + " (desktop_resize assert -> end_paint guard)")
# Fix 5: user.c - NULL guard for image_mimetypes in guac_user_supports_webp
patch_file_insert_after(
"src/libguac/user.c",
"const char** mimetype = user->info.image_mimetypes;",
[
"",
"/* Guard: image_mimetypes may be NULL if client sent no image types */",
"if (mimetype == NULL)",
" return 0;",
"",
],
"NULL guard for image_mimetypes in guac_user_supports_webp",
)
# Fix 6: gdi.c - notify_frame on every EndPaint (no rate limit)
#
# Problem: legacy RDP bitmap-cache updates (taskbar icons, static content) carry
# no FrameMarker PDU. The gdi_modified → notify_modified path only fires after
# the RDP event queue drains — never during a burst — so tiles accumulate as
# black squares. Additionally the render thread's lag-compensation can pause up
# to 500 ms between flushes if the browser hasn't ACK'd the previous sync.
#
# Fix: call notify_frame unconditionally in EndPaint. The render thread's own
# MIN_FRAME_DURATION (10 ms) provides natural batching at up to 100 fps. The
# sync-flood / lag-compensation problem is solved at the proxy layer (rdp.ts):
# the Node.js proxy immediately echoes every sync timestamp back to guacd so
# processing_lag stays near 0 ms, and the browser's own ACKs are dropped to
# prevent double-updating the measurement.
gdi_end_paint_old = (
" /* There will be no further drawing operations */\n"
" rdp_client->current_context = NULL;\n"
" guac_display_layer_close_raw(default_layer, current_context);\n"
"\n"
" return TRUE;\n"
)
gdi_end_paint_new = (
" /* There will be no further drawing operations */\n"
" rdp_client->current_context = NULL;\n"
" guac_display_layer_close_raw(default_layer, current_context);\n"
"\n"
" /* Notify the render thread that new tiles are ready to encode.\n"
" * notify_frame rather than notify_modified ensures the frame counter\n"
" * advances and a sync is emitted after each batch. This causes legacy\n"
" * bitmap-cache updates (which carry no FrameMarker PDU) to be flushed\n"
" * immediately on every EndPaint instead of accumulating as black squares\n"
" * until the next FrameMarker. The render thread's MIN_FRAME_DURATION\n"
" * (10 ms) limits output to 100 fps; the Node.js proxy handles sync ACKs\n"
" * so guacd's processing_lag stays near 0 ms. */\n"
" if (rdp_client->render_thread != NULL)\n"
" guac_display_render_thread_notify_frame(rdp_client->render_thread);\n"
"\n"
" return TRUE;\n"
)
gdi_path = "src/protocols/rdp/gdi.c"
with open(gdi_path) as f:
gdi_end_content = f.read()
if gdi_end_paint_old not in gdi_end_content:
print("ERROR: patch target not found in " + gdi_path + " (notify_frame on EndPaint)", file=sys.stderr)
sys.exit(1)
gdi_end_content = gdi_end_content.replace(gdi_end_paint_old, gdi_end_paint_new, 1)
with open(gdi_path, "w") as f:
f.write(gdi_end_content)
print("Patched " + gdi_path + " (notify_frame on every EndPaint)")

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { Layout } from 'antd';
import { TopNav } from '../Nav/TopNav';
import { ConnectionTree } from '../Sidebar/ConnectionTree';
import { ConnectionProperties } from '../Sidebar/ConnectionProperties';
import { SessionTabs } from '../Tabs/SessionTabs';
const { Content } = Layout;
@@ -10,10 +11,17 @@ const MIN_SIDEBAR = 180;
const MAX_SIDEBAR = 600;
const DEFAULT_SIDEBAR = 260;
const MIN_PROPERTIES_HEIGHT = 150;
const DEFAULT_TREE_FRACTION = 0.6;
export const MainLayout: React.FC = () => {
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR);
const [treeFraction, setTreeFraction] = useState(DEFAULT_TREE_FRACTION);
const isResizing = useRef(false);
const isVResizing = useRef(false);
const sidebarRef = useRef<HTMLDivElement>(null);
// Horizontal sidebar resize
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isResizing.current = true;
@@ -38,6 +46,36 @@ export const MainLayout: React.FC = () => {
document.addEventListener('mouseup', onMouseUp);
}, []);
// Vertical splitter between tree and properties
const onVMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isVResizing.current = true;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
const onMouseMove = (me: MouseEvent) => {
if (!isVResizing.current || !sidebarRef.current) return;
const rect = sidebarRef.current.getBoundingClientRect();
const totalHeight = rect.height;
const relativeY = me.clientY - rect.top;
const propertiesHeight = totalHeight - relativeY;
// Enforce min heights
if (propertiesHeight < MIN_PROPERTIES_HEIGHT || relativeY < MIN_PROPERTIES_HEIGHT) return;
setTreeFraction(relativeY / totalHeight);
};
const onMouseUp = () => {
isVResizing.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}, []);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<TopNav />
@@ -45,6 +83,7 @@ export const MainLayout: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'row', flex: 1, overflow: 'hidden' }}>
{/* Resizable sidebar */}
<div
ref={sidebarRef}
style={{
width: sidebarWidth,
flexShrink: 0,
@@ -55,9 +94,38 @@ export const MainLayout: React.FC = () => {
background: '#fff',
}}
>
{/* Connection tree (top) */}
<div style={{ flex: `0 0 ${treeFraction * 100}%`, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ConnectionTree />
</div>
{/* Vertical splitter */}
<div
onMouseDown={onVMouseDown}
style={{
height: 4,
cursor: 'row-resize',
background: 'transparent',
flexShrink: 0,
zIndex: 10,
borderTop: '1px solid #f0f0f0',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background = '#1677ff40';
}}
onMouseLeave={(e) => {
if (!isVResizing.current)
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
/>
{/* Connection properties (bottom) */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ConnectionProperties />
</div>
</div>
{/* Resize handle */}
<div
onMouseDown={onMouseDown}

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useState } from 'react';
import {
Form,
Input,
InputNumber,
Select,
Button,
Tag,
Typography,
message,
Divider,
Row,
Col,
} from 'antd';
import { useStore } from '../../store';
import { apiConnectionUpdate } from '../../api/client';
import { ConnectionFormValues, Folder } from '../../types';
const { Option } = Select;
const { TextArea } = Input;
const buildFolderOptions = (
allFolders: Folder[],
parentId: string | null = null,
depth = 0
): React.ReactNode[] => {
return allFolders
.filter((f) => f.parentId === parentId)
.flatMap((f) => [
<Option key={f.id} value={f.id}>
{'\u00a0\u00a0'.repeat(depth)}
{depth > 0 ? '└ ' : ''}
{f.name}
</Option>,
...buildFolderOptions(allFolders, f.id, depth + 1),
]);
};
export const ConnectionProperties: React.FC = () => {
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
const connections = useStore((s) => s.connections);
const folders = useStore((s) => s.folders);
const setConnections = useStore((s) => s.setConnections);
const [form] = Form.useForm<ConnectionFormValues>();
const [saving, setSaving] = useState(false);
const connection = connections.find((c) => c.id === selectedConnectionId) ?? null;
const protocol = Form.useWatch('protocol', form);
useEffect(() => {
if (connection) {
form.setFieldsValue({
name: connection.name,
host: connection.host,
port: connection.port,
protocol: connection.protocol,
username: connection.username,
privateKey: connection.privateKey ?? undefined,
domain: connection.domain ?? undefined,
osType: connection.osType ?? undefined,
notes: connection.notes ?? undefined,
folderId: connection.folderId ?? null,
});
} else {
form.resetFields();
}
}, [connection, form]);
const handleSave = async () => {
if (!connection) return;
try {
const values = await form.validateFields();
setSaving(true);
const res = await apiConnectionUpdate(connection.id, values);
// Update connection in store
setConnections(
connections.map((c) => (c.id === connection.id ? { ...c, ...res.data } : c))
);
message.success('Connection updated');
} catch {
message.error('Failed to save connection');
} finally {
setSaving(false);
}
};
if (!connection) {
return (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
>
<Typography.Text type="secondary">
Select a connection to view its properties
</Typography.Text>
</div>
);
}
return (
<div style={{ height: '100%', overflow: 'auto', padding: '8px 12px' }}>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
{connection.name}
</Typography.Text>
<Tag color={connection.protocol === 'rdp' ? 'blue' : 'green'}>
{connection.protocol.toUpperCase()}
</Tag>
</div>
<Form form={form} layout="vertical" size="small" requiredMark={false}>
<Row gutter={8}>
<Col flex={1}>
<Form.Item label="Host" name="host" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col style={{ width: 90 }}>
<Form.Item label="Port" name="port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="Protocol" name="protocol" rules={[{ required: true }]}>
<Select
onChange={(v: 'ssh' | 'rdp') => form.setFieldValue('port', v === 'ssh' ? 22 : 3389)}
>
<Option value="ssh">SSH</Option>
<Option value="rdp">RDP</Option>
</Select>
</Form.Item>
<Form.Item label="Username" name="username" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Password" name="password" extra="Leave blank to keep current">
<Input.Password placeholder="••••••••" autoComplete="new-password" />
</Form.Item>
{protocol === 'ssh' && (
<Form.Item label="Private Key" name="privateKey">
<TextArea rows={3} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
</Form.Item>
)}
{protocol === 'rdp' && (
<Form.Item label="Domain" name="domain">
<Input placeholder="CORP" />
</Form.Item>
)}
<Divider style={{ margin: '4px 0 8px' }} />
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="OS Type" name="osType">
<Select allowClear placeholder="Select OS">
<Option value="linux">Linux</Option>
<Option value="windows">Windows</Option>
</Select>
</Form.Item>
<Form.Item label="Folder" name="folderId">
<Select allowClear placeholder="Root (no folder)">
{buildFolderOptions(folders)}
</Select>
</Form.Item>
<Form.Item label="Notes" name="notes">
<TextArea rows={2} placeholder="Optional notes…" />
</Form.Item>
<Button type="primary" block onClick={handleSave} loading={saving}>
Save
</Button>
</Form>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
Tree,
Button,
@@ -21,6 +21,7 @@ import {
EditOutlined,
DeleteOutlined,
FolderAddOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { useStore } from '../../store';
import {
@@ -85,6 +86,8 @@ export const ConnectionTree: React.FC = () => {
const setFolders = useStore((s) => s.setFolders);
const setConnections = useStore((s) => s.setConnections);
const openSession = useStore((s) => s.openSession);
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
const setSelectedConnection = useStore((s) => s.setSelectedConnection);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
@@ -92,6 +95,65 @@ export const ConnectionTree: React.FC = () => {
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null);
const [addingFolder, setAddingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const searchInputRef = useRef<ReturnType<typeof Input.Search>>(null);
// Find all connections matching the search query
const searchMatches = useMemo(() => {
if (!searchQuery.trim()) return [];
const q = searchQuery.toLowerCase();
return connections.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.host.toLowerCase().includes(q)
);
}, [searchQuery, connections]);
// Auto-select the first match when query or matches change
useEffect(() => {
if (searchMatches.length > 0) {
const idx = Math.min(searchMatchIndex, searchMatches.length - 1);
setSelectedConnection(searchMatches[idx].id);
setSearchMatchIndex(idx);
}
}, [searchMatches, searchMatchIndex, setSelectedConnection]);
// Compute expanded folder keys so matched connections inside folders are visible
const searchExpandedKeys = useMemo(() => {
if (!searchQuery.trim() || searchMatches.length === 0) return undefined;
const keys = new Set<string>();
const folderMap = new Map(folders.map((f) => [f.id, f]));
for (const conn of searchMatches) {
let fid = conn.folderId;
while (fid) {
keys.add(`folder-${fid}`);
fid = folderMap.get(fid)?.parentId ?? null;
}
}
return [...keys];
}, [searchQuery, searchMatches, folders]);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (searchMatches.length > 0)
setSearchMatchIndex((i) => (i + 1) % searchMatches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (searchMatches.length > 0)
setSearchMatchIndex((i) => (i - 1 + searchMatches.length) % searchMatches.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (searchMatches.length > 0) {
const idx = Math.min(searchMatchIndex, searchMatches.length - 1);
openSession(searchMatches[idx]);
}
} else if (e.key === 'Escape') {
setSearchQuery('');
setSearchMatchIndex(0);
}
};
const refresh = useCallback(async () => {
setLoading(true);
@@ -135,6 +197,16 @@ export const ConnectionTree: React.FC = () => {
}
};
// --- Single-click: select connection for properties panel ---
const onSelect = (selectedKeys: React.Key[], info: { node: DataNode }) => {
const ext = info.node as ExtendedDataNode;
if (ext.itemData.type === 'connection') {
setSelectedConnection(ext.itemData.connection!.id);
} else {
setSelectedConnection(null);
}
};
// --- Double-click: open session ---
const onDoubleClick = (_: React.MouseEvent, node: DataNode) => {
const ext = node as ExtendedDataNode;
@@ -311,6 +383,22 @@ export const ConnectionTree: React.FC = () => {
<Tooltip title="Refresh">
<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={refresh} />
</Tooltip>
<Input
ref={searchInputRef as React.Ref<any>}
size="small"
placeholder="Search…"
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
allowClear
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setSearchMatchIndex(0); }}
onKeyDown={handleSearchKeyDown}
style={{ flex: 1, minWidth: 0 }}
suffix={searchQuery && searchMatches.length > 0 ? (
<span style={{ fontSize: 11, color: '#999', whiteSpace: 'nowrap' }}>
{Math.min(searchMatchIndex + 1, searchMatches.length)}/{searchMatches.length}
</span>
) : undefined}
/>
</div>
{/* Inline folder name input */}
@@ -340,6 +428,9 @@ export const ConnectionTree: React.FC = () => {
draggable={{ icon: false }}
blockNode
showIcon
selectedKeys={selectedConnectionId ? [`connection-${selectedConnectionId}`] : []}
{...(searchExpandedKeys ? { expandedKeys: searchExpandedKeys } : {})}
onSelect={onSelect}
onDrop={onDrop}
onDoubleClick={onDoubleClick}
titleRender={titleRender}

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Alert, Spin } from 'antd';
import Guacamole from 'guacamole-common-js';
import { useStore } from '../../store';
import { Session } from '../../types';
@@ -10,95 +9,300 @@ interface Props {
type Status = 'connecting' | 'connected' | 'disconnected' | 'error';
// NOTE: Do NOT include query params here. Guacamole.WebSocketTunnel always
// appends "?" + connectData to the URL, so auth params must go via connect().
function getWsUrl(connectionId: string): string {
// ---------------------------------------------------------------------------
// X11 KeySym lookup table — maps browser event.code / event.key to X11 keysyms
// ---------------------------------------------------------------------------
const KEY_CODE_TO_KEYSYM: Record<string, number> = {
// Letters (lowercase keysyms — X11 uses lowercase for letter keys)
KeyA: 0x61, KeyB: 0x62, KeyC: 0x63, KeyD: 0x64, KeyE: 0x65,
KeyF: 0x66, KeyG: 0x67, KeyH: 0x68, KeyI: 0x69, KeyJ: 0x6a,
KeyK: 0x6b, KeyL: 0x6c, KeyM: 0x6d, KeyN: 0x6e, KeyO: 0x6f,
KeyP: 0x70, KeyQ: 0x71, KeyR: 0x72, KeyS: 0x73, KeyT: 0x74,
KeyU: 0x75, KeyV: 0x76, KeyW: 0x77, KeyX: 0x78, KeyY: 0x79,
KeyZ: 0x7a,
// Digits
Digit0: 0x30, Digit1: 0x31, Digit2: 0x32, Digit3: 0x33, Digit4: 0x34,
Digit5: 0x35, Digit6: 0x36, Digit7: 0x37, Digit8: 0x38, Digit9: 0x39,
// Function keys
F1: 0xffbe, F2: 0xffbf, F3: 0xffc0, F4: 0xffc1, F5: 0xffc2,
F6: 0xffc3, F7: 0xffc4, F8: 0xffc5, F9: 0xffc6, F10: 0xffc7,
F11: 0xffc8, F12: 0xffc9,
// Navigation
ArrowUp: 0xff52, ArrowDown: 0xff54, ArrowLeft: 0xff51, ArrowRight: 0xff53,
Home: 0xff50, End: 0xff57, PageUp: 0xff55, PageDown: 0xff56,
Insert: 0xff63,
// Editing
Backspace: 0xff08, Delete: 0xffff, Enter: 0xff0d, NumpadEnter: 0xff0d,
Tab: 0xff09, Escape: 0xff1b, Space: 0x20,
// Modifiers
ShiftLeft: 0xffe1, ShiftRight: 0xffe2,
ControlLeft: 0xffe3, ControlRight: 0xffe4,
AltLeft: 0xffe9, AltRight: 0xffea,
MetaLeft: 0xffeb, MetaRight: 0xffec,
CapsLock: 0xffe5, NumLock: 0xff7f, ScrollLock: 0xff14,
// Punctuation / symbols
Minus: 0x2d, Equal: 0x3d, BracketLeft: 0x5b, BracketRight: 0x5d,
Backslash: 0x5c, Semicolon: 0x3b, Quote: 0x27, Backquote: 0x60,
Comma: 0x2c, Period: 0x2e, Slash: 0x2f,
// Numpad
Numpad0: 0xffb0, Numpad1: 0xffb1, Numpad2: 0xffb2, Numpad3: 0xffb3,
Numpad4: 0xffb4, Numpad5: 0xffb5, Numpad6: 0xffb6, Numpad7: 0xffb7,
Numpad8: 0xffb8, Numpad9: 0xffb9,
NumpadDecimal: 0xffae, NumpadAdd: 0xffab, NumpadSubtract: 0xffad,
NumpadMultiply: 0xffaa, NumpadDivide: 0xffaf,
// Misc
PrintScreen: 0xff61, Pause: 0xff13, ContextMenu: 0xff67,
};
// Shifted symbol mapping — when Shift is held, browser sends the symbol as event.key
const SHIFTED_KEY_TO_KEYSYM: Record<string, number> = {
'!': 0x21, '@': 0x40, '#': 0x23, '$': 0x24, '%': 0x25,
'^': 0x5e, '&': 0x26, '*': 0x2a, '(': 0x28, ')': 0x29,
'_': 0x5f, '+': 0x2b, '{': 0x7b, '}': 0x7d, '|': 0x7c,
':': 0x3a, '"': 0x22, '~': 0x7e, '<': 0x3c, '>': 0x3e,
'?': 0x3f,
};
function getKeySym(e: KeyboardEvent): number | null {
// Try code-based lookup first (position-independent)
const codeSym = KEY_CODE_TO_KEYSYM[e.code];
if (codeSym !== undefined) {
// For letter keys, return uppercase keysym if shift is held
if (e.code.startsWith('Key') && e.shiftKey) {
return codeSym - 0x20; // lowercase → uppercase in ASCII/X11
}
return codeSym;
}
// Try shifted symbol lookup
const shiftedSym = SHIFTED_KEY_TO_KEYSYM[e.key];
if (shiftedSym !== undefined) return shiftedSym;
// Single printable character — use char code as keysym (works for ASCII)
if (e.key.length === 1) {
return e.key.charCodeAt(0);
}
return null;
}
function getWsUrl(connectionId: string, token: string): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${proto}//${host}/ws/rdp/${connectionId}`;
return `${proto}//${host}/ws/rdp/${connectionId}?token=${encodeURIComponent(token)}`;
}
export const RdpTab: React.FC<Props> = ({ session }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const token = useStore((s) => s.token) ?? '';
const [status, setStatus] = useState<Status>('connecting');
const [errorMsg, setErrorMsg] = useState<string>('');
// Scale factor for translating mouse coordinates
const scaleRef = useRef({ x: 1, y: 1 });
const sendJson = useCallback((msg: object) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!container) return;
if (!canvas || !container) return;
setStatus('connecting');
setErrorMsg('');
const url = getWsUrl(session.connection.id);
const tunnel = new Guacamole.WebSocketTunnel(url);
const client = new Guacamole.Client(tunnel);
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Mount the Guacamole display canvas
const displayEl = client.getDisplay().getElement();
displayEl.style.cursor = 'default';
container.appendChild(displayEl);
const url = getWsUrl(session.connection.id, token);
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
// Mouse input
const mouse = new Guacamole.Mouse(displayEl);
const sendMouse = (mouseState: Guacamole.Mouse.State) =>
client.sendMouseState(mouseState, true);
mouse.onmousedown = sendMouse;
mouse.onmouseup = sendMouse;
mouse.onmousemove = sendMouse;
// Track the remote resolution for coordinate mapping
let remoteWidth = 1280;
let remoteHeight = 720;
// Keyboard input
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym: number) => client.sendKeyEvent(1, keysym);
keyboard.onkeyup = (keysym: number) => client.sendKeyEvent(0, keysym);
// Scale display to fit container
const fitDisplay = () => {
const display = client.getDisplay();
if (!container || display.getWidth() === 0) return;
const scaleX = container.clientWidth / display.getWidth();
const scaleY = container.clientHeight / display.getHeight();
display.scale(Math.min(scaleX, scaleY));
const updateScale = () => {
if (canvas.clientWidth > 0 && canvas.clientHeight > 0) {
scaleRef.current = {
x: remoteWidth / canvas.clientWidth,
y: remoteHeight / canvas.clientHeight,
};
client.getDisplay().onresize = () => {
setStatus('connected');
fitDisplay();
};
const resizeObserver = new ResizeObserver(fitDisplay);
resizeObserver.observe(container);
tunnel.onerror = (status: Guacamole.Status) => {
console.error('Guacamole tunnel error:', status.message);
setStatus('error');
setErrorMsg(`Tunnel error: ${status.message ?? 'unknown'}`);
};
client.onerror = (error: Guacamole.Status) => {
console.error('Guacamole client error:', error.message);
setStatus('error');
setErrorMsg(`Client error: ${error.message ?? 'unknown'}`);
};
// Connect — pass token as query param via connect(data).
// WebSocketTunnel appends "?" + data to the base URL, so auth goes here.
client.connect(`token=${encodeURIComponent(token)}`);
return () => {
keyboard.onkeydown = null;
keyboard.onkeyup = null;
resizeObserver.disconnect();
client.disconnect();
if (container.contains(displayEl)) {
container.removeChild(displayEl);
}
};
}, [session.connection.id, token]);
ws.onopen = () => {
// The backend handles the connect message to rdpd — we just need
// the WebSocket open. No client-side connect message needed.
};
ws.onmessage = async (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary frame — JPEG image
const blob = new Blob([event.data], { type: 'image/jpeg' });
try {
const bitmap = await createImageBitmap(blob);
// Update canvas size if the frame size changed
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
canvas.width = bitmap.width;
canvas.height = bitmap.height;
remoteWidth = bitmap.width;
remoteHeight = bitmap.height;
updateScale();
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
} catch (err) {
console.warn('Failed to decode JPEG frame:', err);
}
} else {
// Text frame — JSON control message
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'connected':
setStatus('connected');
break;
case 'disconnected':
setStatus('disconnected');
break;
case 'error':
setStatus('error');
setErrorMsg(msg.message || 'Unknown error');
break;
case 'clipboardRead':
// Write remote clipboard content to local clipboard
if (msg.text && navigator.clipboard) {
navigator.clipboard.writeText(msg.text).catch(() => {
// Clipboard write may fail without user gesture
});
}
break;
}
} catch (err) {
console.warn('Failed to parse control message:', err);
}
}
};
ws.onerror = () => {
setStatus('error');
setErrorMsg('WebSocket connection error');
};
ws.onclose = () => {
if (status !== 'error') {
setStatus('disconnected');
}
};
// --- Mouse event handlers ---
const translateCoords = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return {
x: Math.round((e.clientX - rect.left) * scaleRef.current.x),
y: Math.round((e.clientY - rect.top) * scaleRef.current.y),
};
};
const onMouseMove = (e: MouseEvent) => {
const { x, y } = translateCoords(e);
sendJson({ type: 'mouseMove', x, y });
};
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
canvas.focus();
const { x, y } = translateCoords(e);
// Browser button: 0=left, 1=middle, 2=right → X11: 1, 2, 3
sendJson({ type: 'mouseDown', button: e.button + 1, x, y });
};
const onMouseUp = (e: MouseEvent) => {
e.preventDefault();
const { x, y } = translateCoords(e);
sendJson({ type: 'mouseUp', button: e.button + 1, x, y });
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const { x, y } = translateCoords(e);
// Normalize delta: positive = scroll up, negative = scroll down
const delta = e.deltaY < 0 ? 3 : -3;
sendJson({ type: 'mouseScroll', delta, x, y });
};
const onContextMenu = (e: Event) => e.preventDefault();
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', onContextMenu);
// --- Keyboard event handlers ---
const onKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const keySym = getKeySym(e);
if (keySym !== null) {
sendJson({ type: 'keyDown', keySym });
}
};
const onKeyUp = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const keySym = getKeySym(e);
if (keySym !== null) {
sendJson({ type: 'keyUp', keySym });
}
};
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
// --- Clipboard: paste handler ---
const onPaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData('text');
if (text) {
sendJson({ type: 'clipboardWrite', text });
}
};
canvas.addEventListener('paste', onPaste);
// --- Resize observer to keep scale in sync ---
const resizeObserver = new ResizeObserver(() => {
updateScale();
});
resizeObserver.observe(container);
return () => {
canvas.removeEventListener('mousemove', onMouseMove);
canvas.removeEventListener('mousedown', onMouseDown);
canvas.removeEventListener('mouseup', onMouseUp);
canvas.removeEventListener('wheel', onWheel);
canvas.removeEventListener('contextmenu', onContextMenu);
canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp);
canvas.removeEventListener('paste', onPaste);
resizeObserver.disconnect();
ws.close();
wsRef.current = null;
};
}, [session.connection.id, token, sendJson]);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
@@ -110,8 +314,17 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
justifyContent: 'center',
}}
>
{/* Guacamole canvas is appended here by the effect */}
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<canvas
ref={canvasRef}
tabIndex={0}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
outline: 'none',
cursor: 'default',
}}
/>
{/* Status overlays */}
{status === 'connecting' && (
@@ -130,14 +343,7 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
)}
{status === 'error' && (
<div
style={{
position: 'absolute',
top: 16,
left: 16,
right: 16,
}}
>
<div style={{ position: 'absolute', top: 16, left: 16, right: 16 }}>
<Alert
type="error"
showIcon
@@ -148,14 +354,7 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
)}
{status === 'disconnected' && (
<div
style={{
position: 'absolute',
top: 16,
left: 16,
right: 16,
}}
>
<div style={{ position: 'absolute', top: 16, left: 16, right: 16 }}>
<Alert type="warning" showIcon message="RDP session disconnected." />
</div>
)}

View File

@@ -17,3 +17,20 @@ body { margin: 0; padding: 0; }
.ant-tabs-tabpane {
height: 100%;
}
/* Connection tree: keep icon + title on one line */
.ant-tree .ant-tree-treenode {
display: flex;
align-items: center;
white-space: nowrap;
}
.ant-tree .ant-tree-node-content-wrapper {
display: inline-flex !important;
align-items: center;
min-width: 0;
overflow: hidden;
}
.ant-tree .ant-tree-iconEle {
flex-shrink: 0;
margin-right: 4px;
}

View File

@@ -14,6 +14,10 @@ interface AppState {
setFolders: (folders: Folder[]) => void;
setConnections: (connections: Connection[]) => void;
// Selected connection (properties panel)
selectedConnectionId: string | null;
setSelectedConnection: (id: string | null) => void;
// Open sessions (tabs)
sessions: Session[];
activeSessionId: string | null;
@@ -52,6 +56,10 @@ export const useStore = create<AppState>((set, get) => ({
setFolders: (folders) => set({ folders }),
setConnections: (connections) => set({ connections }),
// Selected connection
selectedConnectionId: null,
setSelectedConnection: (id) => set({ selectedConnectionId: id }),
// Sessions
sessions: [],
activeSessionId: null,

22
rdpd/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "rdpd"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.21"
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
libc = "0.2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
x11rb = { version = "0.13", features = ["allow-unsafe-code", "xtest"] }
dashmap = "6"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
[profile.release]
lto = true
strip = true

57
rdpd/Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
#
# Multi-stage build for rdpd — custom RDP daemon using xfreerdp3 + Xvfb.
# Uses Ubuntu 24.04 for both stages since freerdp3-x11 is only in Ubuntu repos.
#
# ---- Build stage ----
FROM ubuntu:24.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
build-essential \
pkg-config \
libx11-dev \
libxcb1-dev \
libxtst-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Rust toolchain
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /app
COPY rdpd/Cargo.toml rdpd/Cargo.lock* ./
COPY rdpd/src ./src
RUN cargo build --release
# ---- Runtime stage ----
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
xdotool \
xclip \
x11-utils \
x11-xserver-utils \
freerdp3-x11 \
libxcb1 \
libx11-6 \
libxtst6 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rdpd /usr/local/bin/rdpd
ENV RDPD_LISTEN=0.0.0.0:7777
ENV RDPD_LOG_LEVEL=info
EXPOSE 7777
ENTRYPOINT ["/usr/local/bin/rdpd"]

134
rdpd/src/capture.rs Normal file
View File

@@ -0,0 +1,134 @@
use image::codecs::jpeg::JpegEncoder;
use image::ExtendedColorType;
use std::io::Cursor;
use tracing::{debug, trace};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{self, ConnectionExt, ImageFormat};
use x11rb::rust_connection::RustConnection;
use xxhash_rust::xxh3::xxh3_64;
/// Connects to the X11 display and provides framebuffer capture.
pub struct FrameCapture {
conn: RustConnection,
root: u32,
width: u16,
height: u16,
prev_hash: u64,
}
impl FrameCapture {
/// Connect to the X display specified by `display_num` (e.g. 10 → ":10").
/// Retries a few times since Xvfb may take a moment to start.
pub async fn connect(display_num: u32) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let display_str = format!(":{}", display_num);
let mut last_err = String::new();
for attempt in 0..30 {
match RustConnection::connect(Some(&display_str)) {
Ok((conn, screen_num)) => {
let screen = &conn.setup().roots[screen_num];
let root = screen.root;
let width = screen.width_in_pixels;
let height = screen.height_in_pixels;
debug!(
display = %display_str,
width,
height,
attempt,
"connected to X11 display"
);
return Ok(Self {
conn,
root,
width,
height,
prev_hash: 0,
});
}
Err(e) => {
last_err = e.to_string();
trace!(attempt, err = %last_err, "X11 connect attempt failed, retrying...");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
}
Err(format!(
"failed to connect to X display {} after 30 attempts: {}",
display_str, last_err
)
.into())
}
/// Capture the full framebuffer. Returns Some(jpeg_bytes) if the frame
/// changed since last capture, None if unchanged.
///
/// TODO: implement dirty region tracking — capture only changed tiles and
/// send them with (x, y, w, h) metadata to reduce bandwidth.
pub fn capture_frame(&mut self, quality: u8) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error + Send + Sync>> {
let reply = self
.conn
.get_image(
ImageFormat::Z_PIXMAP,
self.root,
0,
0,
self.width,
self.height,
u32::MAX,
)?
.reply()?;
let pixels = &reply.data;
// Fast hash comparison to skip unchanged frames
let hash = xxh3_64(pixels);
if hash == self.prev_hash {
return Ok(None);
}
self.prev_hash = hash;
// X11 ZPixmap gives us BGRA (or BGRx) — convert to RGB for JPEG
let pixel_count = (self.width as usize) * (self.height as usize);
let mut rgb = Vec::with_capacity(pixel_count * 3);
for i in 0..pixel_count {
let base = i * 4;
if base + 2 < pixels.len() {
rgb.push(pixels[base + 2]); // R
rgb.push(pixels[base + 1]); // G
rgb.push(pixels[base]); // B
}
}
// Encode as JPEG
let mut jpeg_buf = Cursor::new(Vec::with_capacity(256 * 1024));
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, quality);
encoder.encode(&rgb, self.width as u32, self.height as u32, ExtendedColorType::Rgb8)?;
let jpeg_bytes = jpeg_buf.into_inner();
trace!(
size = jpeg_bytes.len(),
width = self.width,
height = self.height,
"frame captured and encoded"
);
Ok(Some(jpeg_bytes))
}
pub fn width(&self) -> u16 {
self.width
}
pub fn height(&self) -> u16 {
self.height
}
/// Update the capture dimensions (e.g. after xrandr resize).
pub fn set_dimensions(&mut self, width: u16, height: u16) {
self.width = width;
self.height = height;
// Reset hash to force next capture to be sent
self.prev_hash = 0;
}
}

231
rdpd/src/input.rs Normal file
View File

@@ -0,0 +1,231 @@
use tracing::{debug, trace, warn};
use x11rb::connection::{Connection, RequestConnection};
use x11rb::protocol::xproto::{self, ConnectionExt};
use x11rb::protocol::xtest::ConnectionExt as XTestConnectionExt;
use x11rb::rust_connection::RustConnection;
/// Handles X11 input injection via the XTEST extension.
pub struct InputInjector {
conn: RustConnection,
root: u32,
#[allow(dead_code)]
screen_num: usize,
}
impl InputInjector {
/// Connect to the X display for input injection.
pub async fn connect(display_num: u32) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let display_str = format!(":{}", display_num);
let mut last_err = String::new();
for attempt in 0..30 {
match RustConnection::connect(Some(&display_str)) {
Ok((conn, screen_num)) => {
let screen = &conn.setup().roots[screen_num];
let root = screen.root;
// Verify XTEST extension is available
match conn.extension_information(x11rb::protocol::xtest::X11_EXTENSION_NAME) {
Ok(Some(_)) => {
debug!(display = %display_str, attempt, "input injector connected with XTEST");
}
_ => {
return Err("XTEST extension not available on X display".into());
}
}
return Ok(Self {
conn,
root,
screen_num,
});
}
Err(e) => {
last_err = e.to_string();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
}
Err(format!(
"input injector: failed to connect to X display {} after 30 attempts: {}",
display_str, last_err
)
.into())
}
/// Move the mouse pointer to (x, y).
pub fn mouse_move(&self, x: i32, y: i32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.conn.xtest_fake_input(
xproto::MOTION_NOTIFY_EVENT,
0, // detail (unused for motion)
x11rb::CURRENT_TIME,
self.root,
x as i16,
y as i16,
0,
)?;
self.conn.flush()?;
trace!(x, y, "mouse_move injected");
Ok(())
}
/// Press a mouse button. Button mapping: 1=left, 2=middle, 3=right.
pub fn mouse_down(&self, button: u8, x: i32, y: i32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Move first, then press
self.mouse_move(x, y)?;
self.conn.xtest_fake_input(
xproto::BUTTON_PRESS_EVENT,
button,
x11rb::CURRENT_TIME,
self.root,
0,
0,
0,
)?;
self.conn.flush()?;
trace!(button, x, y, "mouse_down injected");
Ok(())
}
/// Release a mouse button.
pub fn mouse_up(&self, button: u8, x: i32, y: i32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.mouse_move(x, y)?;
self.conn.xtest_fake_input(
xproto::BUTTON_RELEASE_EVENT,
button,
x11rb::CURRENT_TIME,
self.root,
0,
0,
0,
)?;
self.conn.flush()?;
trace!(button, x, y, "mouse_up injected");
Ok(())
}
/// Scroll the mouse wheel. In X11, button 4 = scroll up, button 5 = scroll down.
pub fn mouse_scroll(&self, delta: i32, x: i32, y: i32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.mouse_move(x, y)?;
let (button, count) = if delta < 0 {
(5u8, (-delta) as u32) // scroll down
} else {
(4u8, delta as u32) // scroll up
};
for _ in 0..count.min(10) {
self.conn.xtest_fake_input(
xproto::BUTTON_PRESS_EVENT,
button,
x11rb::CURRENT_TIME,
self.root,
0,
0,
0,
)?;
self.conn.xtest_fake_input(
xproto::BUTTON_RELEASE_EVENT,
button,
x11rb::CURRENT_TIME,
self.root,
0,
0,
0,
)?;
}
self.conn.flush()?;
trace!(delta, button, count, x, y, "mouse_scroll injected");
Ok(())
}
/// Convert an X11 KeySym to a keycode on this display, then inject a key press.
pub fn key_down(&self, keysym: u32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let keycode = self.keysym_to_keycode(keysym)?;
self.conn.xtest_fake_input(
xproto::KEY_PRESS_EVENT,
keycode,
x11rb::CURRENT_TIME,
self.root,
0,
0,
0,
)?;
self.conn.flush()?;
trace!(keysym, keycode, "key_down injected");
Ok(())
}
/// Inject a key release.
pub fn key_up(&self, keysym: u32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let keycode = self.keysym_to_keycode(keysym)?;
self.conn.xtest_fake_input(
xproto::KEY_RELEASE_EVENT,
keycode,
x11rb::CURRENT_TIME,
self.root,
0,
0,
0,
)?;
self.conn.flush()?;
trace!(keysym, keycode, "key_up injected");
Ok(())
}
/// Map a KeySym to a keycode on the current display.
fn keysym_to_keycode(&self, keysym: u32) -> Result<u8, Box<dyn std::error::Error + Send + Sync>> {
let setup = self.conn.setup();
let min_keycode = setup.min_keycode;
let max_keycode = setup.max_keycode;
let mapping = self
.conn
.get_keyboard_mapping(min_keycode, max_keycode - min_keycode + 1)?
.reply()?;
let keysyms_per_keycode = mapping.keysyms_per_keycode as usize;
for i in 0..=(max_keycode - min_keycode) as usize {
for j in 0..keysyms_per_keycode {
let idx = i * keysyms_per_keycode + j;
if idx < mapping.keysyms.len() && mapping.keysyms[idx] == keysym {
return Ok(min_keycode + i as u8);
}
}
}
// If keysym not found in the current mapping, try to use xdotool as fallback
warn!(keysym, "keysym not found in keyboard mapping");
Err(format!("keysym 0x{:x} not found in keyboard mapping", keysym).into())
}
// TODO: clipboard monitoring — watch X11 CLIPBOARD selection changes via
// XFixes SelectionNotify and forward to the browser as clipboardRead messages
/// Set the X11 clipboard content (CLIPBOARD selection).
/// Uses xclip as a simple approach; a pure X11 implementation would need
/// to become a selection owner which requires an event loop.
pub fn set_clipboard(&self, display_num: u32, text: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::process::{Command, Stdio};
use std::io::Write;
let display = format!(":{}", display_num);
let mut child = Command::new("xclip")
.args(["-selection", "clipboard"])
.env("DISPLAY", &display)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(text.as_bytes())?;
}
child.wait()?;
debug!(len = text.len(), "clipboard set via xclip");
Ok(())
}
}

151
rdpd/src/main.rs Normal file
View File

@@ -0,0 +1,151 @@
mod capture;
mod input;
mod protocol;
mod session;
mod session_manager;
mod xfreerdp;
use crate::protocol::{ClientMessage, ServerMessage};
use crate::session::Session;
use crate::session_manager::SessionManager;
use futures_util::StreamExt;
use std::env;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use tracing::{error, info, warn};
#[tokio::main]
async fn main() {
// Initialize tracing
let log_level = env::var("RDPD_LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_new(&log_level)
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let listen_addr = env::var("RDPD_LISTEN").unwrap_or_else(|_| "0.0.0.0:7777".to_string());
let addr: SocketAddr = listen_addr
.parse()
.expect("RDPD_LISTEN must be a valid socket address");
let manager = SessionManager::new();
let listener = TcpListener::bind(&addr)
.await
.expect("failed to bind TCP listener");
info!(addr = %addr, "rdpd listening for WebSocket connections");
loop {
let (stream, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
error!(err = %e, "TCP accept error");
continue;
}
};
let manager = manager.clone();
tokio::spawn(async move {
info!(peer = %peer, "new TCP connection");
let ws = match accept_async(stream).await {
Ok(ws) => ws,
Err(e) => {
warn!(peer = %peer, err = %e, "WebSocket handshake failed");
return;
}
};
handle_connection(ws, peer, manager).await;
});
}
}
async fn handle_connection(
mut ws: tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
peer: SocketAddr,
manager: Arc<SessionManager>,
) {
// The first message must be a "connect" JSON message
let connect_msg = match ws.next().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Connect {
host,
port,
username,
password,
domain,
width,
height,
security,
}) => {
info!(
peer = %peer,
host = %host,
port,
username = %username,
width,
height,
security = %security,
"connect request received"
);
(host, port, username, password, domain, width, height, security)
}
Ok(_) => {
warn!(peer = %peer, "first message must be a connect request");
let msg = ServerMessage::Error {
message: "First message must be a connect request".to_string(),
};
let _ = futures_util::SinkExt::send(
&mut ws,
Message::Text(msg.to_json()),
).await;
return;
}
Err(e) => {
warn!(peer = %peer, err = %e, "invalid connect message");
let msg = ServerMessage::Error {
message: format!("Invalid connect message: {}", e),
};
let _ = futures_util::SinkExt::send(
&mut ws,
Message::Text(msg.to_json()),
).await;
return;
}
},
Some(Ok(Message::Close(_))) | None => {
info!(peer = %peer, "client disconnected before sending connect");
return;
}
Some(Ok(_)) => {
warn!(peer = %peer, "expected text message with connect request");
return;
}
Some(Err(e)) => {
warn!(peer = %peer, err = %e, "WebSocket error before connect");
return;
}
};
let (host, port, username, password, domain, width, height, security) = connect_msg;
// Allocate a display number and register the session
let display_num = manager.allocate_display();
manager.register(display_num, host.clone(), username.clone());
// Run the session — this blocks until the session ends
Session::run(display_num, ws, host, port, username, password, domain, width, height, security).await;
// Cleanup
manager.unregister(display_num);
info!(peer = %peer, display_num, "session ended");
}

77
rdpd/src/protocol.rs Normal file
View File

@@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Client → Daemon messages (JSON text frames)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
#[allow(non_snake_case)]
pub enum ClientMessage {
#[serde(rename = "connect")]
Connect {
host: String,
port: u16,
username: String,
password: String,
#[serde(default)]
domain: String,
#[serde(default = "default_width")]
width: u16,
#[serde(default = "default_height")]
height: u16,
/// RDP security mode: "tls", "nla", "rdp", or "any". Defaults to "tls".
#[serde(default = "default_security")]
security: String,
},
#[serde(rename = "mouseMove")]
MouseMove { x: i32, y: i32 },
#[serde(rename = "mouseDown")]
MouseDown { button: u8, x: i32, y: i32 },
#[serde(rename = "mouseUp")]
MouseUp { button: u8, x: i32, y: i32 },
#[serde(rename = "mouseScroll")]
MouseScroll { delta: i32, x: i32, y: i32 },
#[serde(rename = "keyDown")]
KeyDown { keySym: u32 },
#[serde(rename = "keyUp")]
KeyUp { keySym: u32 },
#[serde(rename = "clipboardWrite")]
ClipboardWrite { text: String },
#[serde(rename = "resize")]
Resize { width: u16, height: u16 },
}
fn default_width() -> u16 {
1280
}
fn default_height() -> u16 {
720
}
fn default_security() -> String {
"nla".to_string()
}
// ---------------------------------------------------------------------------
// Daemon → Client messages (JSON text frames)
// Binary frames (JPEG) are sent directly, not through this enum.
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum ServerMessage {
#[serde(rename = "connected")]
Connected,
#[serde(rename = "error")]
Error { message: String },
#[serde(rename = "disconnected")]
Disconnected,
#[serde(rename = "clipboardRead")]
ClipboardRead { text: String },
}
impl ServerMessage {
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("ServerMessage serialization cannot fail")
}
}

307
rdpd/src/session.rs Normal file
View File

@@ -0,0 +1,307 @@
use crate::capture::FrameCapture;
use crate::input::InputInjector;
use crate::protocol::{ClientMessage, ServerMessage};
use crate::xfreerdp;
use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use tracing::{error, info, warn};
/// Frame capture rate — ~15 fps.
const FRAME_INTERVAL_MS: u64 = 66;
/// JPEG quality (1-100).
const JPEG_QUALITY: u8 = 85;
type WsSink = Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>;
/// Represents a single active RDP session.
pub struct Session;
impl Session {
/// Start a new RDP session: launch Xvfb, launch xfreerdp3, wire up frame
/// capture and input injection, and proxy everything over the WebSocket.
pub async fn run(
display_num: u32,
ws: WebSocketStream<TcpStream>,
host: String,
port: u16,
username: String,
password: String,
domain: String,
width: u16,
height: u16,
security: String,
) {
let (ws_sink, mut ws_stream) = ws.split();
let sink: WsSink = Arc::new(Mutex::new(ws_sink));
// Helper to send a JSON control message
let send_msg = |sink: WsSink, msg: ServerMessage| async move {
let mut s = sink.lock().await;
let _ = s.send(Message::Text(msg.to_json())).await;
};
// 1. Start Xvfb
let screen = format!("{}x{}x24", width, height);
let display_arg = format!(":{}", display_num);
info!(display_num, %screen, "starting Xvfb");
let xvfb_result = tokio::process::Command::new("Xvfb")
.args([
&display_arg,
"-screen", "0", &screen,
"-ac", // disable access control
"-nolisten", "tcp",
"+extension", "XTEST",
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
let mut xvfb = match xvfb_result {
Ok(child) => child,
Err(e) => {
error!(err = %e, "failed to start Xvfb");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Failed to start Xvfb: {}", e),
}).await;
return;
}
};
// Give Xvfb a moment to initialize
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Check Xvfb is still running
match xvfb.try_wait() {
Ok(Some(status)) => {
error!(?status, "Xvfb exited prematurely");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Xvfb exited with status: {}", status),
}).await;
return;
}
Ok(None) => { /* still running, good */ }
Err(e) => {
error!(err = %e, "failed to check Xvfb status");
}
}
// 2. Start xfreerdp3
info!(display_num, host = %host, port, "starting xfreerdp3");
let xfreerdp_result = xfreerdp::spawn_xfreerdp(
display_num, &host, port, &username, &password, &domain, width, height, &security,
).await;
let mut xfreerdp = match xfreerdp_result {
Ok(child) => child,
Err(e) => {
error!(err = %e, "failed to start xfreerdp3");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Failed to start xfreerdp3: {}", e),
}).await;
let _ = xvfb.kill().await;
return;
}
};
// Wait a bit for xfreerdp to connect before we start capturing
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// Check xfreerdp is still running (connection failure shows up here)
match xfreerdp.try_wait() {
Ok(Some(status)) => {
// Capture stderr for diagnostics
let stderr_msg = if let Some(mut stderr) = xfreerdp.stderr.take() {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
String::from_utf8_lossy(&buf).to_string()
} else {
String::new()
};
error!(?status, stderr = %stderr_msg, "xfreerdp3 exited prematurely");
let user_msg = if stderr_msg.contains("LOGON_FAILURE") {
"RDP authentication failed — check username/password".to_string()
} else if stderr_msg.contains("CONNECT_TRANSPORT_FAILED") || stderr_msg.contains("connect_rdp") {
"Could not reach RDP host — check host/port".to_string()
} else {
format!("xfreerdp3 exited (code {}): {}", status, stderr_msg.lines().filter(|l| l.contains("ERROR")).collect::<Vec<_>>().join("; "))
};
send_msg(sink.clone(), ServerMessage::Error { message: user_msg }).await;
let _ = xvfb.kill().await;
return;
}
Ok(None) => { /* still running */ }
Err(e) => {
error!(err = %e, "failed to check xfreerdp3 status");
}
}
// 3. Connect frame capture and input injector to the X display
let capture_result = FrameCapture::connect(display_num).await;
let input_result = InputInjector::connect(display_num).await;
let mut capture = match capture_result {
Ok(c) => c,
Err(e) => {
error!(err = %e, "failed to connect frame capture");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Failed to connect to X display: {}", e),
}).await;
xfreerdp::kill_xfreerdp(&mut xfreerdp).await;
let _ = xvfb.kill().await;
return;
}
};
let input = match input_result {
Ok(i) => i,
Err(e) => {
error!(err = %e, "failed to connect input injector");
send_msg(sink.clone(), ServerMessage::Error {
message: format!("Failed to connect input injector: {}", e),
}).await;
xfreerdp::kill_xfreerdp(&mut xfreerdp).await;
let _ = xvfb.kill().await;
return;
}
};
// Notify client that we're connected
send_msg(sink.clone(), ServerMessage::Connected).await;
info!(display_num, "RDP session connected, entering proxy mode");
let input = Arc::new(input);
// 4. Spawn frame capture loop
let frame_sink = sink.clone();
let frame_shutdown = Arc::new(AtomicBool::new(false));
let frame_shutdown_rx = frame_shutdown.clone();
let capture_display = display_num;
let capture_handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
loop {
// Check for shutdown
if frame_shutdown_rx.load(Ordering::Relaxed) {
break;
}
match capture.capture_frame(JPEG_QUALITY) {
Ok(Some(jpeg_bytes)) => {
let sink = frame_sink.clone();
rt.block_on(async {
let mut s = sink.lock().await;
if s.send(Message::Binary(jpeg_bytes)).await.is_err() {
// WebSocket closed
}
});
}
Ok(None) => {
// Frame unchanged, skip
}
Err(e) => {
warn!(err = %e, display = capture_display, "frame capture error");
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(FRAME_INTERVAL_MS));
}
});
// 5. Process incoming WebSocket messages (input events)
let input_ref = input.clone();
let ws_display = display_num;
while let Some(msg_result) = ws_stream.next().await {
match msg_result {
Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(client_msg) => {
if let Err(e) = handle_input(&input_ref, ws_display, client_msg) {
warn!(err = %e, "input injection error");
}
}
Err(e) => {
warn!(err = %e, raw = %text, "failed to parse client message");
}
}
}
Ok(Message::Close(_)) => {
info!(display_num, "client sent close frame");
break;
}
Ok(Message::Ping(data)) => {
let mut s = sink.lock().await;
let _ = s.send(Message::Pong(data)).await;
}
Ok(_) => { /* ignore binary from client, pong, etc */ }
Err(e) => {
warn!(err = %e, "WebSocket receive error");
break;
}
}
}
// 6. Cleanup
info!(display_num, "session ending, cleaning up");
// Signal capture loop to stop
frame_shutdown.store(true, Ordering::Relaxed);
let _ = capture_handle.await;
// Send disconnected message
{
let mut s = sink.lock().await;
let _ = s.send(Message::Text(ServerMessage::Disconnected.to_json())).await;
let _ = s.close().await;
}
// Kill child processes
xfreerdp::kill_xfreerdp(&mut xfreerdp).await;
let _ = xvfb.kill().await;
info!(display_num, "session cleanup complete");
}
}
#[allow(non_snake_case)]
fn handle_input(
input: &InputInjector,
display_num: u32,
msg: ClientMessage,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match msg {
ClientMessage::MouseMove { x, y } => input.mouse_move(x, y),
ClientMessage::MouseDown { button, x, y } => input.mouse_down(button, x, y),
ClientMessage::MouseUp { button, x, y } => input.mouse_up(button, x, y),
ClientMessage::MouseScroll { delta, x, y } => input.mouse_scroll(delta, x, y),
ClientMessage::KeyDown { keySym } => input.key_down(keySym),
ClientMessage::KeyUp { keySym } => input.key_up(keySym),
ClientMessage::ClipboardWrite { text } => input.set_clipboard(display_num, &text),
ClientMessage::Resize { width, height } => {
// TODO: implement dynamic resolution change via xrandr:
// 1. xrandr --output default --mode {width}x{height} (on the Xvfb display)
// 2. Send /size:{width}x{height} to xfreerdp3 via its control pipe
// 3. Update capture dimensions
warn!(width, height, "resize requested but not yet implemented");
Ok(())
}
ClientMessage::Connect { .. } => {
// Connect is handled at session start, not here
Ok(())
}
}
}

View File

@@ -0,0 +1,57 @@
use dashmap::DashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tracing::{info, warn};
/// Tracks active sessions and allocates X display numbers.
///
/// Display numbers start at 10 and increment. Released numbers are NOT reused
/// to avoid races with lingering Xvfb processes. Since each number is a u32,
/// we can run ~4 billion sessions before wrapping — effectively unlimited.
pub struct SessionManager {
/// Maps display_num → session metadata (currently just a marker).
sessions: DashMap<u32, SessionInfo>,
/// Next display number to allocate.
next_display: AtomicU32,
}
pub struct SessionInfo {
pub host: String,
pub username: String,
}
impl SessionManager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
sessions: DashMap::new(),
next_display: AtomicU32::new(10),
})
}
/// Allocate a new display number for a session.
pub fn allocate_display(&self) -> u32 {
let num = self.next_display.fetch_add(1, Ordering::Relaxed);
info!(display_num = num, "allocated display number");
num
}
/// Register a session as active.
pub fn register(&self, display_num: u32, host: String, username: String) {
self.sessions.insert(display_num, SessionInfo { host, username });
info!(display_num, active = self.sessions.len(), "session registered");
}
/// Remove a session when it ends.
pub fn unregister(&self, display_num: u32) {
if self.sessions.remove(&display_num).is_some() {
info!(display_num, active = self.sessions.len(), "session unregistered");
} else {
warn!(display_num, "attempted to unregister unknown session");
}
}
/// Number of active sessions.
pub fn active_count(&self) -> usize {
self.sessions.len()
}
}

97
rdpd/src/xfreerdp.rs Normal file
View File

@@ -0,0 +1,97 @@
use std::process::Stdio;
use tokio::process::{Child, Command};
use tracing::{info, warn};
/// Spawn an xfreerdp3 process targeting the given X display.
///
/// Returns the child process handle. The caller is responsible for killing it
/// on session teardown.
pub async fn spawn_xfreerdp(
display_num: u32,
host: &str,
port: u16,
username: &str,
password: &str,
domain: &str,
width: u16,
height: u16,
security: &str,
) -> std::io::Result<Child> {
let display_str = format!(":{}", display_num);
let geometry = format!("{}x{}", width, height);
let mut cmd = Command::new("xfreerdp3");
cmd.env("DISPLAY", &display_str);
// Connection target
cmd.arg(format!("/v:{}:{}", host, port));
cmd.arg(format!("/u:{}", username));
cmd.arg(format!("/p:{}", password));
if !domain.is_empty() {
cmd.arg(format!("/d:{}", domain));
}
// Display settings
cmd.arg(format!("/size:{}", geometry));
cmd.arg("/bpp:32");
cmd.arg("/gfx");
// Security mode — "tls", "nla", "rdp", or "any"
cmd.arg(format!("/sec:{}", security));
// Accept all certificates for lab/internal use
cmd.arg("/cert:ignore");
// Disable features we don't need
cmd.arg("-decorations");
cmd.arg("-wallpaper");
cmd.arg("-aero");
cmd.arg("-themes");
cmd.arg("-sound");
cmd.arg("-microphone");
// Clipboard redirection — we handle it via our own protocol
// TODO: implement clipboard channel via xclip/xsel monitoring
cmd.arg("+clipboard");
// TODO: audio forwarding — could use /sound:sys:pulse with a per-session PulseAudio sink
// TODO: dynamic resolution change — /dynamic-resolution flag + xrandr in the Xvfb
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
info!(
display_str = %display_str,
host = %host,
port = %port,
username = %username,
geometry = %geometry,
"spawning xfreerdp3"
);
let child = cmd.spawn()?;
info!(pid = child.id().unwrap_or(0), "xfreerdp3 process started");
Ok(child)
}
/// Kill an xfreerdp3 child process gracefully, falling back to SIGKILL.
pub async fn kill_xfreerdp(child: &mut Child) {
if let Some(pid) = child.id() {
info!(pid, "terminating xfreerdp3");
// Try SIGTERM first
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
// Give it a moment to exit
match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
Ok(Ok(status)) => {
info!(pid, ?status, "xfreerdp3 exited after SIGTERM");
}
_ => {
warn!(pid, "xfreerdp3 did not exit after SIGTERM, sending SIGKILL");
let _ = child.kill().await;
}
}
}
}