new version full rebuild with claude opus 4.6 and own rdp daemon
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}\""
|
||||
|
||||
240
docker/patch-display-flush.py
Normal file
240
docker/patch-display-flush.py
Normal 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)")
|
||||
@@ -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}
|
||||
|
||||
188
frontend/src/components/Sidebar/ConnectionProperties.tsx
Normal file
188
frontend/src/components/Sidebar/ConnectionProperties.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
22
rdpd/Cargo.toml
Normal 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
57
rdpd/Dockerfile
Normal 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
134
rdpd/src/capture.rs
Normal 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
231
rdpd/src/input.rs
Normal 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
151
rdpd/src/main.rs
Normal 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
77
rdpd/src/protocol.rs
Normal 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
307
rdpd/src/session.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
57
rdpd/src/session_manager.rs
Normal file
57
rdpd/src/session_manager.rs
Normal 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
97
rdpd/src/xfreerdp.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user