diff --git a/backend/src/websocket/rdp.ts b/backend/src/websocket/rdp.ts index d174231..b44fc01 100644 --- a/backend/src/websocket/rdp.ts +++ b/backend/src/websocket/rdp.ts @@ -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 { - 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((resolve, reject) => { - guacd.once('connect', resolve); - guacd.once('error', reject); + rdpd = await new Promise((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((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 = { - 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.;" - 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(); + } + }); } ); } diff --git a/docker-compose.yml b/docker-compose.yml index f2b87d1..2fc58c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/guacd.Dockerfile b/docker/guacd.Dockerfile index acb325c..5b20b8b 100644 --- a/docker/guacd.Dockerfile +++ b/docker/guacd.Dockerfile @@ -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}\"" diff --git a/docker/patch-display-flush.py b/docker/patch-display-flush.py new file mode 100644 index 0000000..0ce4fe5 --- /dev/null +++ b/docker/patch-display-flush.py @@ -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)") diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx index 3ee8460..87e6eb9 100644 --- a/frontend/src/components/Layout/MainLayout.tsx +++ b/frontend/src/components/Layout/MainLayout.tsx @@ -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(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 ( @@ -45,6 +83,7 @@ export const MainLayout: React.FC = () => {
{/* Resizable sidebar */}
{ background: '#fff', }} > - + {/* Connection tree (top) */} +
+ +
+ + {/* Vertical splitter */} +
{ + (e.currentTarget as HTMLDivElement).style.background = '#1677ff40'; + }} + onMouseLeave={(e) => { + if (!isVResizing.current) + (e.currentTarget as HTMLDivElement).style.background = 'transparent'; + }} + /> + + {/* Connection properties (bottom) */} +
+ +
{/* Resize handle */} diff --git a/frontend/src/components/Sidebar/ConnectionProperties.tsx b/frontend/src/components/Sidebar/ConnectionProperties.tsx new file mode 100644 index 0000000..a15e2af --- /dev/null +++ b/frontend/src/components/Sidebar/ConnectionProperties.tsx @@ -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) => [ + , + ...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(); + 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 ( +
+ + Select a connection to view its properties + +
+ ); + } + + return ( +
+
+ + {connection.name} + + + {connection.protocol.toUpperCase()} + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {protocol === 'ssh' && ( + +