在浏览器里连上一台远程服务器的桌面——这听起来像是个浏览器 RDP 客户端就能搞定的事,对吧?Apache Guacamole 就是干这个的:浏览器里跑 JavaScript,通过 guacd(Guacamole proxy daemon)把 RDP/VNC 协议翻译成 Guacamole 自己的协议,再通过 WebSocket 送到浏览器渲染。
但 ServerManager 的场景有个特殊需求——控制端(Panel)在 A 机房,被控节点(Node)可能在 B 机房甚至用户的内网里。guacd 放哪?放 Panel 这边,它直接连不上目标机器的 3389 端口;放 Node 那边,浏览器没法直接访问。
答案是一个独立 Node.js 网关进程,它不翻译协议——它做 TCP 隧道。guacd 还是在 Panel 这边跑,但它不连目标机器的 3389,而是连网关动态开的一个本地端口。网关再通过一个到 Node 的 WebSocket 连接,把 TCP 数据透传到 Node 本地,Node 再连 RDP/VNC。整个链路是这样的:
1
| 浏览器 ←WebSocket→ guacd ←TCP→ 网关 ←WebSocket(二进制帧)→ Node ←TCP→ 目标(3389/5900)
|
这套方案一共 490 行 JavaScript,但里面的门道不少。
第一层:Django 签发票据
远程桌面不是想开就开的。用户在浏览器点”远程桌面”按钮后,Django 要做三件事:
- 验证用户有远程桌面权限
- 创建一个会话记录,包含目标节点 UUID、协议类型(RDP/VNC)、目标端口等
- 把会话信息存进 Redis,生成一个一次性票据 token
票据验证是网关做的——网关拿到 token 后,会向 Django 的 /verify 接口发 POST 请求,Django 从 Redis 取出会话数据返回。网关和 Django 之间有一个共享密钥 REMOTE_DESKTOP_GATEWAY_VERIFY_SECRET,放在 HTTP 头 x-remote-desktop-secret 里:
1 2 3 4 5 6 7 8 9 10 11 12
| async function verifyTicket(token, role) { const headers = { 'content-type': 'application/json' }; if (VERIFY_SECRET) { headers['x-remote-desktop-secret'] = VERIFY_SECRET; } const response = await fetch(VERIFY_URL, { method: 'POST', headers, body: JSON.stringify({ token, role }), }); }
|
为什么用 HTTP 验证而不是 WebSocket?因为网关和 Django 在同一内网,HTTP 调用简单可靠,没必要把简单的鉴权搞成 WebSocket 那么复杂。
第二层:Node.js 网关——TCP 中继的艺术
网关是整个系统的核心。它承担两个角色:管理 Node 的 WebSocket 连接,以及为 guacd 提供本地 TCP 端口。
连的是一个 Node,但一个 Node 可能同时开多个桌面——不同的协议、不同的分辨率、甚至同一个用户开了两个 RDP 会话。怎么办?每个 TCP 连接需要一个唯一标识。
答案在二进制帧格式里。当 Node 通过 WebSocket 发来二进制消息时,前 16 字节是 connection ID,后面的是 TCP 原始数据:
1 2 3 4 5 6 7 8 9 10 11 12 13
| ws.on('message', (message, isBinary) => { if (isBinary) { const frame = Buffer.isBuffer(message) ? message : Buffer.from(message); if (frame.length <= 16) return; const connectionId = frame.subarray(0, 16).toString('hex'); const socket = state.sockets.get(connectionId); if (socket && !socket.destroyed) { socket.write(frame.subarray(16)); } return; } });
|
反向也一样,当 guacd 连上本地的 TCP 端口后,网关把 TCP 数据包上 16 字节的 connection ID 头,用 WebSocket 二进制帧发给 Node:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function forwardTcpToNode(state, socket) { const connectionId = crypto.randomBytes(16).toString('hex'); state.sockets.set(connectionId, socket);
sendJson(state.nodeWs, { type: 'tcp_connect', connection_id: connectionId, host: state.session.target_host, port: state.session.target_port, protocol: state.session.protocol, });
socket.on('data', (chunk) => { const frame = Buffer.concat([Buffer.from(connectionId, 'hex'), chunk]); state.nodeWs.send(frame, { binary: true }); }); }
|
一个 WebSocket 连接,多路 TCP 数据。这比给每个桌面会话开一个 WebSocket 连接要高效得多——减少了连接管理的复杂度,Node 端也只需要维护一个 WebSocket。
会话状态管理
每个远程桌面会话用一个 state 对象管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function ensureSession(sessionData) { const sessionId = sessionData.session_id; let state = sessions.get(sessionId); if (!state) { state = { sessionId, session: sessionData, nodeWs: null, tcpServer: null, tcpPort: null, sockets: new Map(), }; sessions.set(sessionId, state); } return state; }
|
Node 连上来之后,网关为这个会话动态开启一个 TCP 服务器,绑定一个随机端口,然后把端口号告诉 Node:
1 2 3 4 5 6 7 8 9 10 11 12
| async function prepareNodeRelay(state, ws) { state.nodeWs = ws; state.tcpServer = net.createServer((socket) => forwardTcpToNode(state, socket)); const address = await listen(state.tcpServer, RELAY_TCP_BIND_HOST, 0); state.tcpPort = address.port; sendJson(ws, { type: 'relay_ready', session_id: state.sessionId, tcp_host: state.tcpHost, tcp_port: state.tcpPort, }); }
|
端口 0 让操作系统分配一个空闲端口,避免了端口冲突。guacd 连上这个端口后,数据就走 guacd → TCP → 网关 → WebSocket → Node → TCP → 目标机器 这条路。
第三层:浏览器的连接票据
浏览器不能直接连 guacd——那样密钥就暴露在前端了。所以有一个 connect 接口,浏览器拿第一步验证过的 token 换取一个短命 connect_token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| async function handleConnectTicket(req, res) { const body = await readJsonBody(req); const sessionData = await verifyTicket(body.token, 'client'); const state = await waitForNode(sessionData.session_id); const connectToken = crypto.randomBytes(24).toString('base64url'); const ticket = { session: sessionData, tcpHost: state.tcpHost, tcpPort: state.tcpPort, width: clampNumber(body.width, 1280, 640, 7680), height: clampNumber(body.height, 720, 480, 4320), dpi: clampNumber(body.dpi, 96, 72, 240), expiresAt: Date.now() + CONNECT_TICKET_TTL * 1000, }; connectTickets.set(connectToken, ticket); setTimeout(() => connectTickets.delete(connectToken), CONNECT_TICKET_TTL * 1000); return jsonResponse(res, 200, { status: 1, data: { connect_token: connectToken, expires_in: CONNECT_TICKET_TTL, }}); }
|
注意 clampNumber——宽高和 DPI 都有边界限制。这不是摆设:如果恶意请求传入 99999 的宽度,Guacamole 会试图创建超大帧缓冲区,直接把 guacd 内存撑爆。
票据 30 秒自动过期,用完就销毁:
1 2 3 4 5 6 7 8 9 10 11 12
| if (url.pathname === '/ws') { const connectToken = url.searchParams.get('connect_token'); const ticket = connectTickets.get(connectToken); if (!ticket || ticket.expiresAt <= Date.now()) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } connectTickets.delete(connectToken); const encryptedToken = encryptGuacToken(buildGuacConnection(ticket)); }
|
Guacamole token 加密
guacamole-lite 库需要 token 来建立到 guacd 的连接。这个 token 包含了目标机器的 host/port/分辨率/RDP 凭据等敏感信息,所以做了 AES-256-CBC 加密:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const CIPHER = 'AES-256-CBC'; const CRYPT_KEY = crypto.createHash('sha256') .update(process.env.GUAC_TOKEN_KEY || VERIFY_SECRET || 'ServerManagerRemoteDesktopGateway') .digest();
function encryptGuacToken(jsonData) { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(CIPHER, CRYPT_KEY, iv); let encrypted = cipher.update(JSON.stringify(jsonData), 'utf8', 'binary'); encrypted += cipher.final('binary'); return Buffer.from(JSON.stringify({ iv: Buffer.from(iv).toString('base64'), value: Buffer.from(encrypted, 'binary').toString('base64'), })).toString('base64'); }
|
密钥从环境变量 GUAC_TOKEN_KEY 取,没有的话回退到 VERIFY_SECRET,再没有就用硬编码默认值(当然生产环境绝不能这样)。每次加密都生成随机 IV,同样的明文加密出来的密文也不一样。
等待 Node 就绪
还有一个不得不说的细节。用户点”远程桌面”后,浏览器先请求 connect 票据。但这时候 Node 可能还没连上网关——毕竟 WebSocket 连接什么时候建起来不确定。waitForNode 函数处理了这个时序问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function waitForNode(sessionId) { const current = sessions.get(sessionId); if (current && current.nodeWs && current.tcpPort) { return Promise.resolve(current); } return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error('节点远程桌面转发未就绪')); }, NODE_WAIT_MS);
const waiters = nodeWaiters.get(sessionId) || []; waiters.push({ resolve, reject, timer }); nodeWaiters.set(sessionId, waiters); }); }
|
Node 连上来之后,wakeNodeWaiters 会唤醒所有等待这个会话的 Promise:
1 2 3 4 5 6 7 8
| function wakeNodeWaiters(sessionId, state) { const waiters = nodeWaiters.get(sessionId) || []; nodeWaiters.delete(sessionId); waiters.forEach(({ resolve, timer }) => { clearTimeout(timer); resolve(state); }); }
|
这样不论浏览器和 Node 谁先到,都不会丢失请求。
为什么要三层?
为什么不直接让 guacd 连 Node?因为 guacd 是一个固定进程,它不知道哪个 Node 在哪、什么时候会连上来。网关扮演的是”动态中介”的角色:Node 一旦连上,网关就为它开一个本地端口、把端口信息告诉 Node,然后让 guacd 连这个端口。
为什么不让浏览器直接连 Node?因为 Node 在内网里,浏览器从公网根本连不到。
为什么用 Node.js 而不是 Python?因为在连接密集的场景下,Node.js 的事件循环比 Python 的 asyncio 更适合做大量 TCP 连接的中继。而且 guacamole-lite 本身就是 Node.js 生态的——没必要在 Python 里再包一层。
三层架构看起来多余,但每一层各司其职:Django 管认证和授权,网关管连接中继,guacd 管协议翻译。哪一层挂了都可以独立重启,不影响其他层。