fix(rdp): guacd→WebSocket 按完整指令转发,修复黑屏
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

TCP 块转发会截断 img/blob 指令导致 guacamole-common-js 无法渲染;connect 不再经 URL 传密码。
This commit is contained in:
Nexus Agent
2026-06-10 21:37:10 +08:00
parent 960598f504
commit aeca2fd7cb
6 changed files with 92 additions and 15 deletions
@@ -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。
@@ -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'],
-1
View File
@@ -238,5 +238,4 @@ async def get_rdp_host_connect_params(
"hostname": host,
"port": row.port,
"username": row.username,
"password": password,
}
+30 -7
View File
@@ -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:
+7 -4
View File
@@ -50,7 +50,7 @@ async def run_guacamole_rdp_tunnel(
while True:
data = await websocket.receive_text()
if is_internal_instruction(data):
if "ping" in 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"))
@@ -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)
+24
View File
@@ -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";")