From aeca2fd7cbcc1bb3fcabd901f036816b023399c7 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Wed, 10 Jun 2026 21:37:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(rdp):=20guacd=E2=86=92WebSocket=20=E6=8C=89?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=8C=87=E4=BB=A4=E8=BD=AC=E5=8F=91=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=BB=91=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TCP 块转发会截断 img/blob 指令导致 guacamole-common-js 无法渲染;connect 不再经 URL 传密码。 --- ...2026-06-10-rdp-ws-instruction-relay-fix.md | 29 +++++++++++++++ frontend/src/composables/rdp/useRdpSession.ts | 3 +- server/api/rdp_hosts.py | 1 - server/infrastructure/guacamole/protocol.py | 37 +++++++++++++++---- server/infrastructure/guacamole/ws_tunnel.py | 13 ++++--- tests/test_guacamole_protocol.py | 24 ++++++++++++ 6 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 docs/changelog/2026-06-10-rdp-ws-instruction-relay-fix.md diff --git a/docs/changelog/2026-06-10-rdp-ws-instruction-relay-fix.md b/docs/changelog/2026-06-10-rdp-ws-instruction-relay-fix.md new file mode 100644 index 00000000..96e2dde9 --- /dev/null +++ b/docs/changelog/2026-06-10-rdp-ws-instruction-relay-fix.md @@ -0,0 +1,29 @@ +# 2026-06-10 — RDP WebSocket 按指令边界转发 + +## 摘要 + +修复浏览器 RDP 黑屏/连不上:guacd→WebSocket 原先按 TCP 块转发,会截断 Guacamole 指令;改为按 `;` 完整指令转发。 + +## 动机 + +生产验证可见 WS 消息在 `4.blob,` 等处被截断,guacamole-common-js 无法解析图像流。微软 RDP 客户端直连正常,说明目标机与凭据无问题。 + +## 涉及文件 + +- `server/infrastructure/guacamole/protocol.py` — `read_raw_instruction` / `read_raw_batch` +- `server/infrastructure/guacamole/ws_tunnel.py` — guacd→WS 按指令转发;内部 ping 全部回显 +- `server/api/rdp_hosts.py` — connect 接口不再返回 password +- `frontend/src/composables/rdp/useRdpSession.ts` — connect 查询串不含密码 +- `tests/test_guacamole_protocol.py` — 跨 TCP 块指令重组单测 + +## 迁移 / 重启 + +无 DB 迁移;需重建 API 镜像并同步前端。 + +## 验证 + +```bash +bash scripts/local_verify.sh +``` + +生产:3389 页连接 → 应显示 Windows 桌面画布;guacd 日志无 Instruction parse error。 diff --git a/frontend/src/composables/rdp/useRdpSession.ts b/frontend/src/composables/rdp/useRdpSession.ts index f0d99b04..e9217dce 100644 --- a/frontend/src/composables/rdp/useRdpSession.ts +++ b/frontend/src/composables/rdp/useRdpSession.ts @@ -13,15 +13,14 @@ export interface RdpConnectInfo { hostname: string port: number username: string - password: string } +/** Guacamole connect query — credentials stay server-side (rdp_hosts). */ function buildConnectString(info: RdpConnectInfo): string { const pairs: [string, string][] = [ ['hostname', info.hostname], ['port', String(info.port)], ['username', info.username], - ['password', info.password], ['security', 'any'], ['ignore-cert', 'true'], ['disable-wallpaper', 'true'], diff --git a/server/api/rdp_hosts.py b/server/api/rdp_hosts.py index 12078598..97225c75 100644 --- a/server/api/rdp_hosts.py +++ b/server/api/rdp_hosts.py @@ -238,5 +238,4 @@ async def get_rdp_host_connect_params( "hostname": host, "port": row.port, "username": row.username, - "password": password, } diff --git a/server/infrastructure/guacamole/protocol.py b/server/infrastructure/guacamole/protocol.py index 27216a75..af9111bb 100644 --- a/server/infrastructure/guacamole/protocol.py +++ b/server/infrastructure/guacamole/protocol.py @@ -63,13 +63,36 @@ class GuacdStream: raise ConnectionError("guacd closed during read") self._buf += chunk - async def read_some(self) -> bytes: - """Read bytes for relay after handshake (buffered remainder first).""" - if self._buf: - data = self._buf - self._buf = b"" - return data - return await self._reader.read(8192) + async def read_raw_instruction(self) -> bytes: + """Read one complete guacd instruction (including trailing ``;``).""" + while True: + semi = self._buf.find(b";") + if semi >= 0: + raw = self._buf[: semi + 1] + self._buf = self._buf[semi + 1 :] + return raw + chunk = await self._reader.read(8192) + if not chunk: + raise ConnectionError("guacd closed during read") + self._buf += chunk + + async def read_raw_batch(self) -> bytes: + """Read all currently buffered complete instructions (may be multiple).""" + parts: list[bytes] = [] + while True: + semi = self._buf.find(b";") + if semi < 0: + if not parts: + chunk = await self._reader.read(8192) + if not chunk: + if parts: + break + raise ConnectionError("guacd closed during read") + self._buf += chunk + continue + break + parts.append(await self.read_raw_instruction()) + return b"".join(parts) def _param_for_arg(arg_name: str, params: dict[str, str]) -> str: diff --git a/server/infrastructure/guacamole/ws_tunnel.py b/server/infrastructure/guacamole/ws_tunnel.py index 39214752..058badad 100644 --- a/server/infrastructure/guacamole/ws_tunnel.py +++ b/server/infrastructure/guacamole/ws_tunnel.py @@ -50,8 +50,8 @@ async def run_guacamole_rdp_tunnel( while True: data = await websocket.receive_text() if is_internal_instruction(data): - if "ping" in data: - await websocket.send_text(data) + # Tunnel stability pings use empty opcode — echo, do not forward to guacd. + await websocket.send_text(data) continue writer.write(data.encode("utf-8")) await writer.drain() @@ -63,10 +63,13 @@ async def run_guacamole_rdp_tunnel( async def guacd_to_ws() -> None: try: while True: - chunk = await stream.read_some() - if not chunk: + # Must not split mid-instruction — guacamole-common-js parses per WS message. + batch = await stream.read_raw_batch() + if not batch: break - await websocket.send_text(chunk.decode("utf-8", errors="replace")) + await websocket.send_text(batch.decode("utf-8", errors="replace")) + except ConnectionError: + pass except Exception as exc: logger.debug("RDP guacd→WS ended: %s", exc) diff --git a/tests/test_guacamole_protocol.py b/tests/test_guacamole_protocol.py index 50ac8f1b..625f0a8a 100644 --- a/tests/test_guacamole_protocol.py +++ b/tests/test_guacamole_protocol.py @@ -1,6 +1,11 @@ """Guacamole protocol encode/decode and internal opcode detection.""" +import asyncio + +import pytest + from server.infrastructure.guacamole.protocol import ( + GuacdStream, encode_instruction, is_internal_instruction, parse_instruction, @@ -30,3 +35,22 @@ def test_tunnel_open_instruction(): def test_is_internal_ping(): ping = encode_instruction("", "ping", "12345") assert is_internal_instruction(ping) + + +@pytest.mark.asyncio +async def test_guacd_stream_splits_tcp_chunks_on_semicolon(): + class FakeReader: + def __init__(self, chunks: list[bytes]): + self._chunks = list(chunks) + + async def read(self, n: int) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + ins = encode_instruction("sync", "123").encode("utf-8") + reader = FakeReader([ins[:10], ins[10:]]) + stream = GuacdStream(reader) # type: ignore[arg-type] + raw = await stream.read_raw_instruction() + assert raw == ins + assert raw.endswith(b";")