diff --git a/docs/changelog/2026-06-10-rdp-guacd-handshake-fix.md b/docs/changelog/2026-06-10-rdp-guacd-handshake-fix.md new file mode 100644 index 00000000..6763fb06 --- /dev/null +++ b/docs/changelog/2026-06-10-rdp-guacd-handshake-fix.md @@ -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 → 画布显示桌面。 diff --git a/docs/reports/2026-06-10-rdp-guacd-handshake-deploy-verification.md b/docs/reports/2026-06-10-rdp-guacd-handshake-deploy-verification.md new file mode 100644 index 00000000..3a1887f3 --- /dev/null +++ b/docs/reports/2026-06-10-rdp-guacd-handshake-deploy-verification.md @@ -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 实际用户不同需在前端编辑) diff --git a/server/api/rdp.py b/server/api/rdp.py index cf5ba4fa..b63d6062 100644 --- a/server/api/rdp.py +++ b/server/api/rdp.py @@ -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: diff --git a/server/infrastructure/guacamole/protocol.py b/server/infrastructure/guacamole/protocol.py new file mode 100644 index 00000000..27216a75 --- /dev/null +++ b/server/infrastructure/guacamole/protocol.py @@ -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 diff --git a/server/infrastructure/guacamole/ws_tunnel.py b/server/infrastructure/guacamole/ws_tunnel.py new file mode 100644 index 00000000..39214752 --- /dev/null +++ b/server/infrastructure/guacamole/ws_tunnel.py @@ -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 diff --git a/tests/test_guacamole_protocol.py b/tests/test_guacamole_protocol.py new file mode 100644 index 00000000..50ac8f1b --- /dev/null +++ b/tests/test_guacamole_protocol.py @@ -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)