内网穿透+WebSocket:Node 零依赖远程桌面实战

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 原生 httpupgrade 事件接管连接:

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.dllmouse_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 个真把人卡住的坑

  1. PowerShell -Command 传多行脚本在管道下会卡死。 一开始把输入注入的整段脚本用 -Command 传,管道输入场景直接解析失败、进程挂住不返回。解法是先把脚本写成临时 .ps1,再用 -File 启动(lib/input.js 里的 writeScript() + spawn('powershell', ['-NoProfile','-File', ps1Path]))。

  2. 帧掩码方向写反,浏览器秒断且报错很隐晦。 客户端→服务端必须带掩码(服务端要异或还原),服务端→客户端绝不能 掩码。encodeFrame 里服务端全程不写 mask 位就是这个原因;反过来做你会收到一个没有明确原因的 1002 关闭码。

  3. 一次 data 事件 ≠ 一个完整帧。 局域网下小 JSON 帧容易让人误以为"一收一帧",上公网经过代理后立刻出现半包/粘包。必须用累积 buffer + while 循环解析,长度不够就 return 等下一次。

  4. 二进制帧和文本帧要在客户端分流。 onMessage 里先判断 typeof ev.data === 'string':字符串走 JSON.parse,否则当 JPEG 处理。如果无脑 JSON.parse(ev.data),控制台会刷满 Unexpected token 且画面永远是黑的。

  5. 静态文件目录必须防穿越。 serveStaticpath.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 === 0streaming 置 false 并终止递归,笔记本不会一直空转截屏耗电。
  • 截屏有超时兜底execFile 设了 timeout: 8000,失败则返回一张预生成的占位图(makePlaceholder() 里画的 "Waiting for screen signal ..."),界面不会卡在黑屏。
  • 终端输出有上限maxBuffer: 20 * 1024 * 1024timeout: 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 · 远程桌面 · 内网穿透 · 架构设计

相关推荐
Jeremy_WW1 小时前
802.3协议解读 05:116章节 200 Gb/s 和 400 Gb/s 网络介绍 V
网络·网络协议·信息与通信·光模块·802.3协议
河北之花1 小时前
计算机三级:BGQ
网络·tcp/ip·计算机网络·智能路由器
en.en..1 小时前
TCP/IP四层模型「数据包封装与拆包」| 网络传输
服务器·网络·php
梦想平凡2 小时前
百游棋牌源代码开发搭建教程(七):WebSocket房间同步与断线恢复
网络·websocket·网络协议
binqian2 小时前
Docker Desktop(WSL2 后端)三层网络互通技术文档
网络·docker·容器
Lhappy嘻嘻2 小时前
网络(六)|应用层 HTTP&HTTPS:URL、请求响应、Cookie 与证书
网络·http·https
星恒讯工业路由器3 小时前
四张网协同演进:新一代通信网对工业通信设备意味着什么?
网络·物联网·智能路由器·工业路由器·工业物联网·5g-a·新一代通信网
小程序设计3 小时前
校园网络资产自动探测与安全状态评估系统设计与实现
网络·安全
新时代牛马3 小时前
PCI与PCIe 完整篇:硬件拓扑→ 报文协议→配置/BAR → Linux 驱动(一条主线讲透)
linux·运维·网络