浏览器里开 RDP:三层远程桌面网关的 TCP 隧道把戏

在浏览器里连上一台远程服务器的桌面——这听起来像是个浏览器 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 要做三件事:

  1. 验证用户有远程桌面权限
  2. 创建一个会话记录,包含目标节点 UUID、协议类型(RDP/VNC)、目标端口等
  3. 把会话信息存进 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);

// 告诉 Node:新连接来了
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, // Node 的 WebSocket 连接
tcpServer: null, // 为 guacd 开的本地 TCP 服务器
tcpPort: null, // TCP 端口号
sockets: new Map(), // connectionId → TCP socket 的映射
};
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); // 端口 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, // 30 秒过期
};
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); // Node 已就绪
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('节点远程桌面转发未就绪'));
}, NODE_WAIT_MS); // 默认 15 秒超时

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 管协议翻译。哪一层挂了都可以独立重启,不影响其他层。

欢迎关注我的其它发布渠道