Node.jsWebSocket远程桌面内网穿透架构设计# 内网穿透+WebSocket:Node 零依赖远程桌面实战
出门只带了手机,家里/公司电脑上的文件拿不到、命令跑不了------这是很多人真实遇到过的场景。我用纯 Node.js 内置模块(不装任何 npm 包,连 WebSocket 都是手写的)做了一个 Web 端远程控制平台:被控机跑 node server.js,你在任何能打开浏览器的设备上就能看屏幕、动鼠标、敲命令、管文件、杀进程。这篇文章拆一遍它的架构和 4 个核心模块的实现,以及我在写帧编解码和 Windows 输入注入时踩的 5 个坑。
一、先看它到底能干什么
一个 node server.js(默认 9000 端口,默认令牌 remote)之后,浏览器里就能拿到下面这些能力:
| 模块 | 具体能力 | 实现位置 |
|---|---|---|
| 实时屏幕 | 远程桌面画面串流,可调 2/5/10/15 fps,Canvas 渲染 | lib/screen.js + public/app.js |
| 触控控制 | 点按=单击、拖动=拖选、长按右键、滚动模式、Win 键 | lib/input.js |
| 虚拟键盘 | 文本输入、Ctrl/Shift/Alt/Win 修饰键、方向键与功能键 | lib/input.js |
| 终端 | 远程执行 cmd / PowerShell,cd 可用,维护工作目录 |
lib/terminal.js |
| 文件管理 | 目录浏览、新建文件夹、删除、下载、上传 | lib/files.js + /api/* |
| 进程管理 | 查看进程 CPU / 内存占用,一键结束 | lib/process.js |
| 访问控制 | 默认令牌 remote,可用 REMOTE_TOKEN 环境变量修改 |
server.js |
只有局域网能用是不够的。要真正做到"人在外面也能连",需要把 9000 端口暴露出去------也就是 2026 年依旧很热的内网穿透话题。README 里给了两条路:路由器端口转发(长期、免费,但要有公网 IP),或者一条 cloudflared tunnel --url http://localhost:9000 / ngrok http 9000 的反向隧道(自带 HTTPS、不用公网 IP)。穿透只是把端口送出去,真正决定体验的是穿透之后那条长连接怎么设计,这也是本文的重点。
二、整体架构:一条 WebSocket 扛下所有
┌─────────────── 被控电脑(Windows) ───────────────┐
│ │
│ node server.js │
│ ├── http.createServer 静态页面 / 下载 / 上传 │
│ └── server.on('upgrade') ──► lib/ws.js 自研 WS │
│ ▲ 文本帧(JSON) : auth/input/cmd/fs/proc │
│ ▼ 二进制帧 : JPEG 屏幕画面 │
│ ├── lib/screen.js PowerShell + GDI 截屏 │
│ ├── lib/input.js 常驻 PowerShell 调 user32 │
│ ├── lib/terminal.js 命令执行 + cwd 维护 │
│ ├── lib/files.js / lib/process.js │
└───────────────────────────────────────────────────┘
▲ 一条 WebSocket 全双工
▼
┌────── 手机 / 平板 / 任意浏览器(public/app.js)────┐
│ Canvas 渲染 JPEG 帧 · 指针事件归一化 · 令牌认证 │
└───────────────────────────────────────────────────┘
为什么不用现成轮子?这里给一张选型对比表,也是我当初的真实权衡:
| 方案 | 需要安装 | 可控性 | 适合场景 |
|---|---|---|---|
ws / socket.io 等成熟库 |
需要 npm install |
高,但依赖体积与版本要维护 | 生产项目,追求稳定 |
| RustDesk / ToDesk 等成品 | 装客户端 | 低,闭源或需自建中继 | 只想"能用",不想写代码 |
| Apache Guacamole | 需要 Java + 数据库 + 反代 | 中,配置复杂 | 企业多用户网关 |
| 本方案(零依赖自研) | 无,node 即可 |
完全可控,代码 2 千行内 | 临时远程协助、内网自用、学习协议 |
零依赖的最大好处不是"炫技",而是可移植 :把目录拷到任何一台装了 Node 的机器上,node server.js 就能跑,不用担心内网机器没网装包。
还有一个设计取舍值得说清楚:为什么屏幕画面不单独开一条连接、不走 HTTP 轮询 。轮询的延迟下限取决于轮询间隔,屏幕上一次鼠标移动要等下一次请求才能看到,天然做不到实时;而单独开连接又要重新过一遍握手和鉴权。所以最终方案是一条 WebSocket 全双工复用 :上行是 JSON 文本帧(控制指令),下行既有 JSON 文本帧(终端输出、文件列表、进程列表),也有二进制帧(JPEG 画面)。server.js 里只用一个 clients 集合管理所有连接,鉴权通过才 clients.add(conn),没通过的连接收不到任何一帧画面------认证和传输在同一条连接上一次做完。
三步跑起来
bash
# 1. 进入目录直接启动(默认 9000 端口,默认令牌 remote)
cd RemoteControl
node server.js
# 2. 改端口 / 改令牌(上公网前务必改令牌)
PORT=8080 node server.js
REMOTE_TOKEN=你的密码 node server.js
# 3. 想在外网访问,任选一条隧道把 9000 暴露出去(自带 HTTPS)
cloudflared tunnel --url http://localhost:9000
# 或
ngrok http 9000
三、核心原理一:WebSocket 握手其实就 4 行响应头
服务端不用任何库,靠 Node 原生 http 的 upgrade 事件接管连接:
js
// lib/ws.js ------ 极简 WebSocket 服务端实现(仅依赖 Node 内置模块)
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function handleUpgrade(req, socket, onConnection) {
const key = req.headers['sec-websocket-key'];
if (!key) { socket.destroy(); return; }
// 1. 客户端随机 Key + 固定 GUID,做 SHA-1 再 base64,就是 Accept
const accept = crypto.createHash('sha1').update(key + GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
);
const conn = new WSConnection(socket); // 2. 握手成功后交给连接对象
onConnection(conn);
}
RFC 6455 规定:服务端把客户端送来的 Sec-WebSocket-Key 拼上那个固定 GUID,做一次 SHA-1 再 base64,回给浏览器。浏览器校验通过才会触发 onopen。注意 socket.write 之后不能 再 socket.end()------这个 TCP 连接从 HTTP 升级成了 WebSocket,后续所有数据都走它。
四、核心原理二:帧编解码,方向不同掩码规则不同
这是整个项目最值得讲的一段。WebSocket 的帧头是变长的,payload 长度有三个档位,而且客户端发给服务端必须掩码,服务端发给客户端绝对不能掩码,写反了浏览器会直接以 1002 错误断开。
js
// lib/ws.js ------ 帧编码:FIN + opcode + 变长长度
function encodeFrame(data, isBinary) {
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
const len = payload.length;
let header;
if (len < 126) { // 短帧:长度直接塞进第 2 个字节低 7 位
header = Buffer.alloc(2);
header[1] = len;
} else if (len < 65536) { // 中帧:长度标记 126,后跟 2 字节
header = Buffer.alloc(4);
header[1] = 126;
header.writeUInt16BE(len, 2);
} else { // 长帧:长度标记 127,后跟 8 字节
header = Buffer.alloc(10);
header[1] = 127;
header.writeUInt32BE(Math.floor(len / 0x100000000), 2);
header.writeUInt32BE(len >>> 0, 6);
}
header[0] = 0x80 | (isBinary ? 0x2 : 0x1); // 0x80=FIN;0x1 文本 / 0x2 二进制
return Buffer.concat([header, payload]);
}
解码侧最容易被忽略的是粘包/半包 :TCP 是字节流,一次 data 事件里可能只有半个帧,也可能有多个帧。所以必须用累积 buffer + while 循环解析,长度不够就 return 等下次数据:
js
// lib/ws.js ------ 帧解码:先攒 buffer,够一帧再解析
_onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
this._parse();
}
_parse() {
while (true) {
const buf = this.buffer;
if (buf.length < 2) return; // 连帧头都不够,等下一批数据
const fin = (buf[0] & 0x80) !== 0;
const opcode = buf[0] & 0x0f;
const masked = (buf[1] & 0x80) !== 0; // 客户端帧必然带掩码
let len = buf[1] & 0x7f;
let offset = 2;
if (len === 126) { len = buf.readUInt16BE(offset); offset += 2; }
else if (len === 127) { /* 读 8 字节 */ }
let maskKey;
if (masked) { maskKey = buf.slice(offset, offset + 4); offset += 4; }
if (buf.length < offset + len) return; // 载荷没收全,继续等
let payload = buf.slice(offset, offset + len);
if (masked) { // 掩码还原:逐字节异或
const out = Buffer.alloc(len);
for (let i = 0; i < len; i++) out[i] = payload[i] ^ maskKey[i & 3];
payload = out;
}
this.buffer = buf.slice(offset + len); // 切除已消费部分,继续下一帧
this._handleFrame(fin, opcode, payload);
}
}
_handleFrame 里几个 opcode 的处理也很关键:0x8 是 close,0x9 是 ping(必须回 pong,否则浏览器会认为连接已死),0x0 是分片续帧,需要攒齐 FIN 再合并。
五、核心原理三:屏幕为什么走二进制帧,而不是 base64
屏幕画面走的是二进制帧 (opcode 0x2),正文控制消息走文本帧 (opcode 0x1)------同一条连接上两种帧混用,靠 isBinary 参数区分。如果图省事把 JPEG 转 base64 塞进 JSON,体积会膨胀约 33%,而远程桌面每秒要发 5~15 帧,这个开销是白给的。
服务端串流循环长这样,注意它会在没有客户端时自动停摆,避免空转截屏:
js
// server.js ------ 屏幕串流:有人看才截屏,没人看立即停止
function streamLoop() {
if (clients.size === 0) { streaming = false; return; }
screen.capture().then((jpeg) => {
if (jpeg) for (const c of clients) c.send(jpeg, true); // true = 二进制帧
}).catch(() => {}).finally(() => {
if (clients.size > 0) setTimeout(streamLoop, Math.max(50, 1000 / fps));
else streaming = false;
});
}
Windows 下的截屏用 PowerShell 调 GDI,先写到临时 JPEG 文件再读回 Buffer:
js
// lib/screen.js ------ 用 System.Drawing 的 CopyFromScreen 抓屏并编码为 JPEG
const ps =
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
'$s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; ' +
'$b=New-Object System.Drawing.Bitmap($s.Width,$s.Height); ' +
'$g=[System.Drawing.Graphics]::FromImage($b); ' +
'$g.CopyFromScreen($s.X,$s.Y,0,0,$b.Size); ' + // 核心:GDI 位块传输
'$ms=New-Object System.IO.MemoryStream; ' +
'$b.Save($ms,[System.Drawing.Imaging.ImageFormat]::Jpeg); ' + // 直接压成 JPEG
'$b.Dispose(); $g.Dispose(); ' +
'[System.IO.File]::WriteAllBytes(\'' + p + '\',$ms.ToArray())';
execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 8000 }, ...);
客户端收到二进制帧后走 Blob + createImageBitmap 直接画到 Canvas,比 new Image() + ObjectURL 少一次内存拷贝:
js
// public/app.js ------ 二进制帧 = JPEG 屏幕画面
var blob = new Blob([ev.data], { type: 'image/jpeg' });
createImageBitmap(blob).then(function (bmp) {
canvas.width = bmp.width;
canvas.height = bmp.height;
ctx.drawImage(bmp, 0, 0);
bmp.close && bmp.close(); // 及时释放位图内存,防长时间串流后 OOM
overlay.classList.add('hide');
}).catch(function () {});
六、核心原理四:输入注入靠一个常驻的 PowerShell 守护进程
鼠标键盘注入不能每次都 spawn 一个 PowerShell------那样一次点击要等几百毫秒的进程启动 + .NET 程序集加载。项目里的做法是:启动一个常驻 PowerShell 进程,用 stdin 逐行喂指令、读 stdout 逐行取回执 ,配合 user32.dll 的 mouse_event / keybd_event / SetCursorPos:
js
// lib/input.js ------ 常驻进程里通过 Add-Type 内联 C# 声明 user32 API
Add-Type @"
using System;using System.Runtime.InteropServices;
public class ISim{
[DllImport("user32.dll")]public static extern void mouse_event(uint f,int x,int y,int d,int e);
[DllImport("user32.dll")]public static extern void keybd_event(byte vk,byte sc,uint f,int e);
[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y);}
"@
function Act($line){
$o=$line|ConvertFrom-Json # 每行一个 JSON 指令
switch($o.a){
'move' {[ISim]::SetCursorPos([int]$o.x,[int]$o.y)|Out-Null}
'down' {[ISim]::mouse_event($(if($o.b-eq'right'){8}else{2}),0,0,0,0)|Out-Null}
'up' {[ISim]::mouse_event($(if($o.b-eq'right'){16}else{4}),0,0,0,0)|Out-Null}
'wheel'{[ISim]::mouse_event(2048,0,0,[int]$o.d,0)|Out-Null}
'key' { if($o.vk -ne $null){[ISim]::keybd_event([byte]$o.vk,0,$(if($o.u){2}else{0}),0)|Out-Null}
else {[System.Windows.Forms.SendKeys]::SendWait($o.t)} }
}
W('DONE') # 回执,Node 侧据此出队下一条
}
Node 侧用了一个串行队列保证严格有序------鼠标"移动→按下→抬起"乱序会直接导致操作错乱:
js
// lib/input.js ------ 串行队列:一次只发一条,收到 DONE 再发下一条
function next() {
if (busy || !ready || queue.length === 0) return;
busy = true;
const job = queue[0];
try { ps.stdin.write(JSON.stringify(job.obj) + '\n'); }
catch (e) { dequeue(false, '写入失败'); }
}
function send(obj) {
return new Promise((resolve, reject) => {
if (!ensure()) { return reject(new Error('输入控制仅支持 Windows 平台')); }
queue.push({ obj, resolve, reject });
next();
});
}
客户端把触摸坐标归一化成屏幕绝对坐标再发送,这样手机屏幕尺寸怎么变都不影响:
js
// public/app.js ------ 画布坐标 → 被控机屏幕绝对坐标
function toAbs(clientX, clientY) {
var r = canvas.getBoundingClientRect();
var nx = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
var ny = Math.max(0, Math.min(1, (clientY - r.top) / r.height));
return { x: Math.round(nx * screenSize.w), y: Math.round(ny * screenSize.h), nx: nx, ny: ny };
}
// 移动做了双重节流:位移超过 6px 才算「拖动」,超过 2px 才发一次 move
if (moved && (Math.abs(a.x - lastSentX) > 2 || Math.abs(a.y - lastSentY) > 2)) {
sendInput({ action: 'move', x: a.x, y: a.y });
}
七、踩坑记录:5 个真把人卡住的坑
-
PowerShell
-Command传多行脚本在管道下会卡死。 一开始把输入注入的整段脚本用-Command传,管道输入场景直接解析失败、进程挂住不返回。解法是先把脚本写成临时.ps1,再用-File启动(lib/input.js里的writeScript()+spawn('powershell', ['-NoProfile','-File', ps1Path]))。 -
帧掩码方向写反,浏览器秒断且报错很隐晦。 客户端→服务端必须带掩码(服务端要异或还原),服务端→客户端绝不能 掩码。
encodeFrame里服务端全程不写 mask 位就是这个原因;反过来做你会收到一个没有明确原因的 1002 关闭码。 -
一次
data事件 ≠ 一个完整帧。 局域网下小 JSON 帧容易让人误以为"一收一帧",上公网经过代理后立刻出现半包/粘包。必须用累积 buffer +while循环解析,长度不够就return等下一次。 -
二进制帧和文本帧要在客户端分流。
onMessage里先判断typeof ev.data === 'string':字符串走JSON.parse,否则当 JPEG 处理。如果无脑JSON.parse(ev.data),控制台会刷满Unexpected token且画面永远是黑的。 -
静态文件目录必须防穿越。
serveStatic里path.normalize之后还要校验fp.startsWith(PUBLIC),否则GET /../../Windows/win.ini之类的路径会把整台机器的文件暴露出去。同理,下载接口/api/download也要配合令牌鉴权一起用。
八、性能与安全的取舍(非实验室压测,是代码里的真实配置)
- 帧率可调 :服务端默认
fps = 5,客户端可下发config调整,代码里钳制在1~30之间(Math.min(30, Math.max(1, +msg.fps || 5))),串流间隔Math.max(50, 1000 / fps),避免帧率设高把 CPU 打满。 - 没人看就停 :
clients.size === 0时streaming置 false 并终止递归,笔记本不会一直空转截屏耗电。 - 截屏有超时兜底 :
execFile设了timeout: 8000,失败则返回一张预生成的占位图(makePlaceholder()里画的 "Waiting for screen signal ..."),界面不会卡在黑屏。 - 终端输出有上限 :
maxBuffer: 20 * 1024 * 1024,timeout: 30000,跑dir /s这类巨量输出命令不会把进程撑爆,超时返回[命令执行超时]。 - 安全 :默认令牌
remote太弱,上公网前务必REMOTE_TOKEN=强密码 node server.js;隧道建议用自带 HTTPS 的方案,长期暴露再叠一层 Nginx + Basic Auth。
九、还能往哪走
目前的短板也很明确:屏幕捕获和输入注入只覆盖 Windows(其它平台会看到占位图并提示"仅支持 Windows"),终端/文件/进程是跨平台的。下一步可以接 macOS 的 screencapture、Linux 的 xdotool 扩展 screen.js / input.js;画面现在是全屏 JPEG,做成脏矩形差分 + 分块传输能显著降低带宽;再远一点可以做多被控机管理面板。
整套代码没有一行来自第三方包,理解完上面这几段,你对 WebSocket 协议本身、Windows 输入注入、浏览器端二进制帧处理的理解会上一个台阶------这比调通一个库值钱得多。
完整工程(含一键启动脚本、内网穿透配置说明和部署注意点)我已经整理好了,需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后面会把自研协议、远程桌面这条线继续往下写。
标签:Node.js · WebSocket · 远程桌面 · 内网穿透 · 架构设计