fix(rdp): guacd→WebSocket 按完整指令转发,修复黑屏
TCP 块转发会截断 img/blob 指令导致 guacamole-common-js 无法渲染;connect 不再经 URL 传密码。
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -238,5 +238,4 @@ async def get_rdp_host_connect_params(
|
||||
"hostname": host,
|
||||
"port": row.port,
|
||||
"username": row.username,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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";")
|
||||
|
||||
Reference in New Issue
Block a user