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
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
username: string
|
username: string
|
||||||
password: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guacamole connect query — credentials stay server-side (rdp_hosts). */
|
||||||
function buildConnectString(info: RdpConnectInfo): string {
|
function buildConnectString(info: RdpConnectInfo): string {
|
||||||
const pairs: [string, string][] = [
|
const pairs: [string, string][] = [
|
||||||
['hostname', info.hostname],
|
['hostname', info.hostname],
|
||||||
['port', String(info.port)],
|
['port', String(info.port)],
|
||||||
['username', info.username],
|
['username', info.username],
|
||||||
['password', info.password],
|
|
||||||
['security', 'any'],
|
['security', 'any'],
|
||||||
['ignore-cert', 'true'],
|
['ignore-cert', 'true'],
|
||||||
['disable-wallpaper', 'true'],
|
['disable-wallpaper', 'true'],
|
||||||
|
|||||||
@@ -238,5 +238,4 @@ async def get_rdp_host_connect_params(
|
|||||||
"hostname": host,
|
"hostname": host,
|
||||||
"port": row.port,
|
"port": row.port,
|
||||||
"username": row.username,
|
"username": row.username,
|
||||||
"password": password,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,36 @@ class GuacdStream:
|
|||||||
raise ConnectionError("guacd closed during read")
|
raise ConnectionError("guacd closed during read")
|
||||||
self._buf += chunk
|
self._buf += chunk
|
||||||
|
|
||||||
async def read_some(self) -> bytes:
|
async def read_raw_instruction(self) -> bytes:
|
||||||
"""Read bytes for relay after handshake (buffered remainder first)."""
|
"""Read one complete guacd instruction (including trailing ``;``)."""
|
||||||
if self._buf:
|
while True:
|
||||||
data = self._buf
|
semi = self._buf.find(b";")
|
||||||
self._buf = b""
|
if semi >= 0:
|
||||||
return data
|
raw = self._buf[: semi + 1]
|
||||||
return await self._reader.read(8192)
|
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:
|
def _param_for_arg(arg_name: str, params: dict[str, str]) -> str:
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ async def run_guacamole_rdp_tunnel(
|
|||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
if is_internal_instruction(data):
|
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)
|
await websocket.send_text(data)
|
||||||
continue
|
continue
|
||||||
writer.write(data.encode("utf-8"))
|
writer.write(data.encode("utf-8"))
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
@@ -63,10 +63,13 @@ async def run_guacamole_rdp_tunnel(
|
|||||||
async def guacd_to_ws() -> None:
|
async def guacd_to_ws() -> None:
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await stream.read_some()
|
# Must not split mid-instruction — guacamole-common-js parses per WS message.
|
||||||
if not chunk:
|
batch = await stream.read_raw_batch()
|
||||||
|
if not batch:
|
||||||
break
|
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:
|
except Exception as exc:
|
||||||
logger.debug("RDP guacd→WS ended: %s", exc)
|
logger.debug("RDP guacd→WS ended: %s", exc)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""Guacamole protocol encode/decode and internal opcode detection."""
|
"""Guacamole protocol encode/decode and internal opcode detection."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from server.infrastructure.guacamole.protocol import (
|
from server.infrastructure.guacamole.protocol import (
|
||||||
|
GuacdStream,
|
||||||
encode_instruction,
|
encode_instruction,
|
||||||
is_internal_instruction,
|
is_internal_instruction,
|
||||||
parse_instruction,
|
parse_instruction,
|
||||||
@@ -30,3 +35,22 @@ def test_tunnel_open_instruction():
|
|||||||
def test_is_internal_ping():
|
def test_is_internal_ping():
|
||||||
ping = encode_instruction("", "ping", "12345")
|
ping = encode_instruction("", "ping", "12345")
|
||||||
assert is_internal_instruction(ping)
|
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