fix(rdp): 服务端 guacd 握手替代透明 TCP 桥接
guacamole-common-js 不会向 guacd 发送 select/connect;改为 Nexus 用 rdp_hosts 凭据完成握手后再桥接 WebSocket,消除 protocol violation。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# 2026-06-10 — RDP guacd 握手修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复浏览器 RDP 连接时 guacd 报 `Guacamole protocol violation`:由透明 TCP 桥接改为服务端完成 guacd 握手后再转发 WebSocket。
|
||||
|
||||
## 动机
|
||||
|
||||
`guacamole-common-js` 的 `WebSocketTunnel` 假定服务端(官方 Java servlet)向 guacd 发送 `select`/`connect` 等指令;Nexus 原先将浏览器 WS 字节原样转发到 guacd,导致协议错误。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/infrastructure/guacamole/protocol.py` — 指令编解码、guacd RDP 握手
|
||||
- `server/infrastructure/guacamole/ws_tunnel.py` — 握手后 WS↔guacd 桥接,过滤内部 ping
|
||||
- `server/api/rdp.py` — 使用 DB 中 `rdp_hosts` 凭据握手,JWT 仍走路径
|
||||
- `tests/test_guacamole_protocol.py` — 协议单测
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移
|
||||
- 需重启 API 容器(`deploy-production.sh`)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash scripts/local_verify.sh
|
||||
python -m pytest tests/test_guacamole_protocol.py -q
|
||||
```
|
||||
|
||||
生产:3389 页连接 → guacd 日志无 protocol violation → 画布显示桌面。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 2026-06-10 RDP guacd 握手修复 — 生产验证
|
||||
|
||||
## 变更
|
||||
|
||||
- 服务端完成 guacd `select` → `connect` → `ready` 握手,再桥接 WebSocket
|
||||
- 凭据来自 `rdp_hosts` 表,不依赖 URL 中的 password
|
||||
|
||||
## 部署
|
||||
|
||||
- 主机 `/opt/nexus` 已同步新文件
|
||||
- `nexus-1panel.sh upgrade --no-backup` 重建镜像 `nexus-prod-nexus`
|
||||
- `/health` → ok
|
||||
|
||||
## 验证(Agent 执行)
|
||||
|
||||
### 1. guacd 直连握手(容器内 Python)
|
||||
|
||||
```
|
||||
handshake 20.189.75.225 3389 azureuserwindwos guacd guacd 4822
|
||||
READY $da3288e0-2943-4186-b542-b00156251633
|
||||
```
|
||||
|
||||
guacd 日志:
|
||||
|
||||
```
|
||||
Creating new client for protocol "rdp"
|
||||
Connected to RDPDR 1.13 as client 0x0003
|
||||
```
|
||||
|
||||
### 2. WebSocket 隧道
|
||||
|
||||
```
|
||||
FIRST_MSG 0.,37.$f5b63cbd-2af9-4ee4-a714-37a7fbf1df9c;
|
||||
```
|
||||
|
||||
首包为 Guacamole tunnel OPEN(空 opcode + connection id),符合 `guacamole-common-js` 预期。
|
||||
|
||||
### 3. 结论
|
||||
|
||||
- 原 `Guacamole protocol violation`(透明 TCP 桥接)已消除
|
||||
- RDP 目标 20.189.75.225:3389 可从 guacd 建立会话
|
||||
|
||||
## 待用户终验
|
||||
|
||||
浏览器:`https://api.synaglobal.vip/app/#/rdp` → 选择 `azurewin11` → 连接,应出现远程桌面画布。
|
||||
|
||||
## 备注
|
||||
|
||||
- 代码尚未 `git push`(仅主机热更新 + 镜像重建);建议后续提交 `protocol.py` / `ws_tunnel.py` / `rdp.py`
|
||||
- RDP 用户名库中为 `azureuserwindwos`(若 Windows 实际用户不同需在前端编辑)
|
||||
+57
-10
@@ -4,15 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from fastapi import APIRouter, Path, WebSocket, WebSocketDisconnect
|
||||
|
||||
from server.config import settings
|
||||
from server.domain.models import Admin
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from server.infrastructure.database.rdp_host_repo import RdpHostRepositoryImpl
|
||||
from server.infrastructure.guacamole.tunnel import bridge_websocket_to_guacd
|
||||
from server.infrastructure.guacamole.ws_tunnel import run_guacamole_rdp_tunnel
|
||||
|
||||
logger = logging.getLogger("nexus.rdp")
|
||||
|
||||
@@ -55,19 +57,35 @@ async def _verify_rdp_access_token(token: str) -> Optional[Admin]:
|
||||
return admin
|
||||
|
||||
|
||||
def _rdp_password_plain(enc: str) -> str:
|
||||
plain = str(enc or "").strip()
|
||||
if not plain:
|
||||
return ""
|
||||
try:
|
||||
return decrypt_value(plain)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_display_size(query_string: bytes) -> tuple[int, int]:
|
||||
qs = parse_qs(query_string.decode("utf-8", errors="replace"))
|
||||
try:
|
||||
w = int(qs.get("width", ["1024"])[0])
|
||||
h = int(qs.get("height", ["768"])[0])
|
||||
if w > 0 and h > 0:
|
||||
return w, h
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 1024, 768
|
||||
|
||||
|
||||
@router.websocket("/ws/rdp/hosts/{host_id}/tunnel/{access_token}")
|
||||
async def rdp_guacamole_ws(
|
||||
websocket: WebSocket,
|
||||
host_id: int = Path(...),
|
||||
access_token: str = Path(..., description="URL-encoded JWT (not query — Guacamole appends ?connect)"),
|
||||
):
|
||||
"""Transparent Guacamole tunnel to guacd after JWT + RDP host lookup.
|
||||
|
||||
Token is in the path because guacamole-common-js ``Client.connect()`` always
|
||||
appends ``?hostname=...`` to the tunnel URL; a ``?token=`` query would break JWT parsing.
|
||||
"""
|
||||
from urllib.parse import unquote
|
||||
|
||||
"""Guacamole WS tunnel: JWT auth, server-side guacd handshake, then relay."""
|
||||
token = unquote(access_token).strip()
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Missing JWT token")
|
||||
@@ -89,17 +107,46 @@ async def rdp_guacamole_ws(
|
||||
await websocket.close(code=4404, reason="RDP host not found")
|
||||
return
|
||||
|
||||
password = _rdp_password_plain(row.password)
|
||||
if not password:
|
||||
await websocket.close(code=4400, reason="RDP password not configured")
|
||||
return
|
||||
|
||||
connect_params = {
|
||||
k: v
|
||||
for k, v in (
|
||||
("hostname", row.host.strip()),
|
||||
("port", str(row.port)),
|
||||
("username", row.username.strip()),
|
||||
("password", password),
|
||||
("security", "any"),
|
||||
("ignore-cert", "true"),
|
||||
("disable-wallpaper", "true"),
|
||||
("disable-theming", "true"),
|
||||
("enable-font-smoothing", "true"),
|
||||
("enable-full-window-drag", "false"),
|
||||
("enable-desktop-composition", "false"),
|
||||
("enable-menu-animations", "false"),
|
||||
)
|
||||
if v
|
||||
}
|
||||
|
||||
width, height = _parse_display_size(websocket.scope.get("query_string", b""))
|
||||
|
||||
await websocket.accept(subprotocol="guacamole")
|
||||
try:
|
||||
await bridge_websocket_to_guacd(
|
||||
await run_guacamole_rdp_tunnel(
|
||||
websocket,
|
||||
settings.GUACD_HOST,
|
||||
settings.GUACD_PORT,
|
||||
connect_params,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.warning("RDP guacd bridge failed host_id=%s: %s", host_id, exc)
|
||||
logger.warning("RDP guacd tunnel failed host_id=%s: %s", host_id, exc)
|
||||
try:
|
||||
await websocket.close(code=1011, reason="guacd unreachable")
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Guacamole protocol helpers (instruction encode/decode + guacd handshake)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("nexus.guacamole.protocol")
|
||||
|
||||
|
||||
def encode_element(value: str) -> str:
|
||||
s = value if value is not None else ""
|
||||
return f"{len(s)}.{s}"
|
||||
|
||||
|
||||
def encode_instruction(opcode: str, *args: str) -> str:
|
||||
parts = [encode_element(opcode)] + [encode_element(a) for a in args]
|
||||
return ",".join(parts) + ";"
|
||||
|
||||
|
||||
def parse_instruction(message: str) -> tuple[str, list[str]]:
|
||||
"""Parse one complete instruction (without trailing semicolon)."""
|
||||
elements: list[str] = []
|
||||
i = 0
|
||||
length = len(message)
|
||||
while i < length:
|
||||
dot = message.find(".", i)
|
||||
if dot < 0:
|
||||
raise ValueError(f"invalid guacamole instruction at {i}")
|
||||
el_len = int(message[i:dot])
|
||||
start = dot + 1
|
||||
end = start + el_len
|
||||
elements.append(message[start:end])
|
||||
i = end
|
||||
if i < length and message[i] == ",":
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
if not elements:
|
||||
raise ValueError("empty guacamole instruction")
|
||||
return elements[0], elements[1:]
|
||||
|
||||
|
||||
class GuacdStream:
|
||||
"""Buffered guacd TCP reader — one instruction per read()."""
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader):
|
||||
self._reader = reader
|
||||
self._buf = b""
|
||||
|
||||
async def read_instruction(self) -> tuple[str, list[str]]:
|
||||
while True:
|
||||
semi = self._buf.find(b";")
|
||||
if semi >= 0:
|
||||
raw = self._buf[:semi]
|
||||
self._buf = self._buf[semi + 1 :]
|
||||
msg = raw.decode("utf-8", errors="replace")
|
||||
return parse_instruction(msg)
|
||||
chunk = await self._reader.read(8192)
|
||||
if not chunk:
|
||||
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)
|
||||
|
||||
|
||||
def _param_for_arg(arg_name: str, params: dict[str, str]) -> str:
|
||||
"""Map guacd arg name to connection parameter value."""
|
||||
normalized = arg_name.replace("_", "-").lower()
|
||||
for key, value in params.items():
|
||||
if key.replace("_", "-").lower() == normalized:
|
||||
return value or ""
|
||||
return ""
|
||||
|
||||
|
||||
async def guacd_rdp_handshake(
|
||||
stream: GuacdStream,
|
||||
writer: asyncio.StreamWriter,
|
||||
params: dict[str, str],
|
||||
*,
|
||||
width: int = 1024,
|
||||
height: int = 768,
|
||||
dpi: int = 96,
|
||||
) -> str:
|
||||
"""Run guacd handshake for RDP; return connection id from ``ready``."""
|
||||
writer.write(encode_instruction("select", "rdp").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
arg_names: list[str] = []
|
||||
while True:
|
||||
opcode, args = await stream.read_instruction()
|
||||
if opcode == "args":
|
||||
arg_names = list(args)
|
||||
break
|
||||
if opcode == "error":
|
||||
raise RuntimeError(args[0] if args else "guacd error during args")
|
||||
logger.warning("guacd unexpected during select: %s %s", opcode, args)
|
||||
|
||||
writer.write(encode_instruction("size", str(width), str(height), str(dpi)).encode("utf-8"))
|
||||
await writer.drain()
|
||||
writer.write(encode_instruction("audio").encode("utf-8"))
|
||||
await writer.drain()
|
||||
writer.write(encode_instruction("video").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
connect_values = [_param_for_arg(name, params) for name in arg_names]
|
||||
writer.write(encode_instruction("connect", *connect_values).encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
while True:
|
||||
opcode, args = await stream.read_instruction()
|
||||
if opcode == "ready":
|
||||
if not args:
|
||||
raise RuntimeError("guacd ready without connection id")
|
||||
return args[0]
|
||||
if opcode == "error":
|
||||
raise RuntimeError(args[0] if args else "guacd connection failed")
|
||||
logger.debug("guacd during handshake: %s %s", opcode, args)
|
||||
|
||||
|
||||
def tunnel_open_instruction(connection_id: Optional[str] = None) -> str:
|
||||
"""Empty opcode + UUID — tells guacamole-common-js the tunnel is OPEN."""
|
||||
cid = connection_id or str(uuid.uuid4())
|
||||
return encode_instruction("", cid)
|
||||
|
||||
|
||||
def is_internal_instruction(message: str) -> bool:
|
||||
"""True if message uses internal (empty) opcode — not for guacd."""
|
||||
try:
|
||||
opcode, _ = parse_instruction(message.rstrip(";"))
|
||||
return opcode == ""
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Guacamole WebSocket tunnel: handshake with guacd then relay (not raw TCP bridge)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from server.infrastructure.guacamole.protocol import (
|
||||
GuacdStream,
|
||||
guacd_rdp_handshake,
|
||||
is_internal_instruction,
|
||||
tunnel_open_instruction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.guacamole.ws_tunnel")
|
||||
|
||||
|
||||
async def run_guacamole_rdp_tunnel(
|
||||
websocket: WebSocket,
|
||||
guacd_host: str,
|
||||
guacd_port: int,
|
||||
connect_params: dict[str, str],
|
||||
*,
|
||||
width: int = 1024,
|
||||
height: int = 768,
|
||||
) -> None:
|
||||
"""Handshake with guacd using server-side credentials, then bridge WS ↔ guacd."""
|
||||
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
|
||||
stream = GuacdStream(reader)
|
||||
|
||||
try:
|
||||
conn_id = await guacd_rdp_handshake(
|
||||
stream, writer, connect_params, width=width, height=height,
|
||||
)
|
||||
await websocket.send_text(tunnel_open_instruction(conn_id))
|
||||
except Exception as exc:
|
||||
logger.warning("guacd handshake failed: %s", exc)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.close(code=1011, reason=str(exc)[:120])
|
||||
return
|
||||
|
||||
async def ws_to_guacd() -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
if is_internal_instruction(data):
|
||||
if "ping" in data:
|
||||
await websocket.send_text(data)
|
||||
continue
|
||||
writer.write(data.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("RDP WS→guacd ended: %s", exc)
|
||||
|
||||
async def guacd_to_ws() -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = await stream.read_some()
|
||||
if not chunk:
|
||||
break
|
||||
await websocket.send_text(chunk.decode("utf-8", errors="replace"))
|
||||
except Exception as exc:
|
||||
logger.debug("RDP guacd→WS ended: %s", exc)
|
||||
|
||||
ws_task = asyncio.create_task(ws_to_guacd())
|
||||
guacd_task = asyncio.create_task(guacd_to_ws())
|
||||
try:
|
||||
await asyncio.wait([ws_task, guacd_task], return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in (ws_task, guacd_task):
|
||||
task.cancel()
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Guacamole protocol encode/decode and internal opcode detection."""
|
||||
|
||||
from server.infrastructure.guacamole.protocol import (
|
||||
encode_instruction,
|
||||
is_internal_instruction,
|
||||
parse_instruction,
|
||||
tunnel_open_instruction,
|
||||
)
|
||||
|
||||
|
||||
def test_encode_instruction():
|
||||
assert encode_instruction("select", "rdp") == "6.select,3.rdp;"
|
||||
|
||||
|
||||
def test_parse_instruction_roundtrip():
|
||||
raw = encode_instruction("connect", "host", "3389").rstrip(";")
|
||||
opcode, args = parse_instruction(raw)
|
||||
assert opcode == "connect"
|
||||
assert args == ["host", "3389"]
|
||||
|
||||
|
||||
def test_tunnel_open_instruction():
|
||||
msg = tunnel_open_instruction("abc-uuid")
|
||||
assert is_internal_instruction(msg)
|
||||
opcode, args = parse_instruction(msg.rstrip(";"))
|
||||
assert opcode == ""
|
||||
assert args == ["abc-uuid"]
|
||||
|
||||
|
||||
def test_is_internal_ping():
|
||||
ping = encode_instruction("", "ping", "12345")
|
||||
assert is_internal_instruction(ping)
|
||||
Reference in New Issue
Block a user