Files
LocalNetMsg/docs/remote-terminal-forward-usb.md
T

31 KiB
Raw Blame History

远程终端、端口转发、USB 透传 规划文档

目标:在 LocalNetMsg 中实现三类“远程使用对方设备”的能力——对方机器的本地终端(shellTCP 端口转发USB 设备透传。本文先定整体架构、协议、库选型,再分阶段落地。

适配范围:Windows / macOS / Linux 三端。被控端的 shell 按平台决定:Windows → PowerShell(默认 pwsh.exe,回退 powershell.exe),macOS → zsh(回退 bash),Linux → bash(按 SHELL 环境变量)。


1. 设计目标与边界

项目 MVPv0.2.0 v0.3.0 不做(明确放弃)
远程终端 单 PTY、单 session、只读/可写 多 session、窗口大小同步、UTF-8 CJK、ANSI 颜色、剪贴板同步 远程录制/回放(交给 asciinema)
端口转发 TCP 127.0.0.1 监听、字节计数、TTL 多并发、IPv6、UDP SOCKS / HTTP CONNECT / 公网映射
USB 透传 HID 设备名单 + “绑定本机、远程附加” 串口、复合设备过滤 Isochronous、复杂描述符自定义

不做 = 让用户走更合适的工具(AnyDesk/RustDesk 远控、Serveo/Cloudflared Tunnel、VirtualHere)。本地调试范围以单局域网、单设备对、单设备为限。


2. 选型与依赖

2.1 库与版本(2026-07 通过 registry.npmmirror.com 核实)

用途 版本 备注
PTY 后端(被控端) @lydell/node-pty 1.2.0-beta.12beta=1.2.0-beta.14,可选升级) 自带六平台预编译:win32-x64/arm64、darwin-x64/arm64、linux-x64/arm64latest1.2.0-beta.12优先 latest,只在 win11 ARM 用户出问题时再切 beta
终端前端(控制端) @xterm/xterm 6.0.0 xterm.js
终端自适应 @xterm/addon-fit 0.11.0 让终端随容器尺寸变化
终端链接 @xterm/addon-web-links 0.12.0 终端内 URL 自动可点击
端口转发 / TCP 隧道 Node 内置 net 模块 不引入额外依赖;与现有 wsfile-server 同风格
USB 后端(被控端) usbnode-usb v3.0.1Rust N-API 重写版) 3.0.1 同时提供 usb(自由访问)和 webusb(需授权)两种 API
HID 快速通道 node-hidv3.3.0 3.3.0 只针对 HID 类(键盘/鼠标/游戏手柄/自定义 HID)速度更快、API 更友好;非 HID 设备走 usb
串口 / USB-Serial serialportv13.0.0 13.0.0 需要 Node ≥20
公网隧道(可选 v0.3+ localtunnel 2.0.2 公共中继;不推荐默认开,作为开发期选项

镜像访问:https://registry.npmmirror.com/<pkg>/latest 可直接读 JSON,元数据字段含 dist-tags.latestenginesoscpunapi.targets,比 GitHub 页面稳定。GitHub raw/README 多数代理 403/Cloudflare 拦截,npm 元数据是兜底。

2.2 不选 / 避坑

  • node-pty 上游 microsoft/node-pty latest=1.1.0,仅 beta=1.2.0-beta.14@lydell/node-pty 是社区 fork,包更小、预编译更全。直接用社区版,等 microsoft/node-pty 进入 stable 再切。
  • robotjs:维护停滞,对 Electron 32 ABI 兼容性差。
  • usbipd-winWindows 官方 USB/IP,原理正确,但与我们 Node 进程隔离;MVP 不集成,后续可作为被控端可选依赖以获得 Isochronous 能力。
  • FreeRDP / libvncserver / RustDesk:都是完整远控项目,体积与许可(AGPL)不适合直接嵌入。
  • @lydell/node-pty-*-* 这些平台包随主包自动拉,无需单独声明。

3. 整体架构

3.1 分层

┌──────────────────────────────────────────┐
│ Renderer (Vue 3)                         │  UI: TerminalPanel / PortForwardPanel / UsbPanel
│   xterm.js + addon-fit + addon-web-links │  Pinia stores: terminal / forward / usb
└───────────────▲──────────────────────────┘
                │ IPC (contextBridge)
┌───────────────┴──────────────────────────┐
│ Preload (typed window.api)               │
└───────────────▲──────────────────────────┘
                │ ipcMain.handle / broadcastToRenderer
┌───────────────┴────────────────────────────────────────────────────────┐
│ Main (Node)                                                              │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐         │
│  │ RemoteTerminal  │  │ PortForward     │  │ UsbForward      │         │
│  │  (manager.ts)   │  │  (manager.ts)   │  │  (manager.ts)   │         │
│  └────────┬────────┘  └────────┬────────┘  └────────┬────────┘         │
│           │                    │                    │                   │
│  ┌────────▼────────┐  ┌────────▼────────┐  ┌────────▼────────┐         │
│  │ @lydell/node-pty│  │ net (TCP)       │  │ usb / node-hid  │         │
│  └─────────────────┘  └─────────────────┘  └─────────────────┘         │
│           │                    │                    │                   │
│           └──────── WS 信令(复用 chat-client.ts:91)──┘                   │
│  src/main/protocol.ts WsFrame 新增 terminal / forward / usb 控制帧        │
└───────────────────────────────────────────────────────────────────────────┘

3.2 与现有代码的衔接

现有 复用方式
src/main/protocol.ts:30 WsFrame union 扩展三条新控制帧:terminalforwardusb
src/main/chat-client.ts:91 ws://${peer.address}:${peer.chatPort} 复用同一 outgoing WS;多路复用消息类型
src/main/ipc.ts:59 bindNetworkContext 在末尾给三个新 manager 各建一组 c.chatServer.on('xxx', ...) 与 IPC handler
src/main/discovery.ts UDP 广播 不动;新功能依然走 LAN 局域网
src/main/db.ts 新增 audit_log 表 + 写入 helper,记录 terminal/forward/usb 事件
src/renderer/src/stores/* 新增 useTerminalStoreuseForwardStoreuseUsbStore
src/renderer/src/App.vue 在 sidebar 增加“工具”下拉,包含三个子入口
src/renderer/src/components/SettingsView.vue 增加“远程”开关 + 默认 TTL/最大带宽/允许的设备名单

4. 协议扩展(WsFrame 新增)

// src/main/protocol.ts(追加,不改 PROTOCOL_VERSION
export type TerminalFrame =
  | { type: 'terminal.open';    sessionId: string; rows: number; cols: number; shell?: string }
  | { type: 'terminal.input';   sessionId: string; data: string /* base64 of bytes */ }
  | { type: 'terminal.output';  sessionId: string; data: string /* base64 of bytes */ }
  | { type: 'terminal.resize';  sessionId: string; rows: number; cols: number }
  | { type: 'terminal.close';   sessionId: string; reason?: string }
  | { type: 'terminal.ack';     sessionId: string; ok: boolean; reason?: string }

export type ForwardFrame =
  | { type: 'forward.open';   sessionId: string; listenPort: number; targetHost: string; targetPort: number; ttlSec: number }
  | { type: 'forward.data';   sessionId: string; dir: 'c2s' | 's2c'; data: string /* base64 */; fin?: boolean }
  | { type: 'forward.close';  sessionId: string; reason?: string; bytesIn: number; bytesOut: number }
  | { type: 'forward.ack';    sessionId: string; ok: boolean; reason?: string }

export type UsbFrame =
  | { type: 'usb.list';       reqId: string }
  | { type: 'usb.devices';    reqId: string; devices: Array<{ busId: string; vid: number; pid: number; class: number; subclass: number; product?: string; manufacturer?: string; serial?: string }> }
  | { type: 'usb.attach';     sessionId: string; busId: string }
  | { type: 'usb.detach';     sessionId: string }
  | { type: 'usb.transfer';   sessionId: string; dir: 'host->dev' | 'dev->host'; data?: string; fin?: boolean; status?: string }
  | { type: 'usb.ack';        reqId?: string; sessionId?: string; ok: boolean; reason?: string }

WsFrame 联合类型追加上面三个分支。信令用 JSON数据帧也用 JSON 但 payload 用 base64——避免把 WS 当二进制流用,简化现有 codec。

4.1 控制权与角色

每条 *.open 帧必须包含以下 meta,由发送方填:

  • fromDeviceId(自动从握手记录填)
  • appVersionnonce(每次 open 重新生成)

被控端收到 open 后:

  1. 校验对端是否在 usb/terminal/forward.allowPeers 中(设置项)。
  2. 弹窗确认(被控端的 window),10 秒倒计时。
  3. 通过/拒绝,发送 ack。

5. 模块设计

5.1 Remote Terminalsrc/main/remote/terminal.ts

class TerminalManager {
  // 被控端: 接受 open 时新建 pty
  handleOpen(env: MessageEnvelope): void
  // 控制端: 写 stdin / 收 stdout
  sendInput(sessionId: string, bytes: Buffer): void
  onOutput(cb: (sessionId: string, bytes: Buffer) => void): void
  resize(sessionId: number, rows: number, cols: number): void
  close(sessionId: string, reason?: string): void
}

平台默认 shell

// src/main/remote/shell.ts
import { platform } from 'node:process'
import { execSync } from 'node:child_process'

export function defaultShell(): { file: string; args: string[] } {
  switch (platform) {
    case 'win32': {
      const file = execSync('where pwsh.exe', { stdio: ['ignore', 'pipe', 'ignore'] })
        .toString().trim() ? 'pwsh.exe' : 'powershell.exe'
      return { file, args: [] }
    }
    case 'darwin':
    case 'linux': {
      return { file: process.env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash'), args: [] }
    }
  }
}

被控端启动逻辑:

import * as pty from '@lydell/node-pty'

const env = { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8' }
const proc = pty.spawn(shell.file, shell.args, {
  name: 'xterm-256color',
  cols: frame.cols, rows: frame.rows,
  cwd: os.homedir(), env, encoding: null /* bytes */,
})
proc.onData((data: string) => send('terminal.output', { sessionId, data: Buffer.from(data, 'utf8').toString('base64') }))
proc.onExit(({ exitCode }) => send('terminal.close', { sessionId, reason: `exit ${exitCode}` }))

控制端写入:

proc.write(Buffer.from(frame.data, 'base64').toString('utf8'))

ConPTY 是 Windows 10+ 默认;xp/7 用 winpty@lydell/node-pty 自动选)。encoding: null 让数据以 string 但保留字节流语义;解码统一在两端做。

5.2 Port Forwardsrc/main/remote/forward.ts

class ForwardManager {
  // 申请方(控制端)
  async request(peerDeviceId, { listenPort, targetHost, targetPort, ttlSec }): Promise<SessionId>
  // 数据流
  onData(sessionId, dir, bytes)
  sendData(sessionId, dir, bytes, fin?)
  close(sessionId, reason?)
}

申请方作为 TCP servernet.createServerhost: '127.0.0.1'),被控端作为 TCP client

控制端浏览器/应用 -> 127.0.0.1:listenPort
                              ↓
              ForwardManager (net.Server)
                              ↓ (base64 frame)
                 chat-client.ts:91 (现有 WS)
                              ↓
              对方 ForwardManager (net.Socket)
                              ↓
              对方 127.0.0.1:targetPort

要点:

  • 单 forward session 一个 TCP 连接,复用现有 WS 子协议;帧不超过 16 KB/帧,超出分片。
  • 字节计数:双向分别累计;TTL 到期(默认 1 小时)自动关闭。
  • 端口黑名单:禁止 22, 23, 53, 80, 135, 139, 443, 445, 3389, 5900, 5985/5986,被控端在 forward.ack 里拒。
  • 不开 0.0.0.0v0.3+ 再加 LAN 提示。
  • 关闭时若仍有未消费字节,发送 fin:true 让对端 socket 半关闭,不丢数据。

5.3 USB / 串口 共享(src/main/remote/usb.ts

老实说在前:纯 Node.js 在 Windows 上做不到"USB 设备完全透明地变成本机 USB"——那是商业软件 VirtualHere / USB Network Gate / FlexiHub)或 Linux usbip + WSL 才有的能力。我们提供三种模式,按场景取用:

模式 能力 平台 透明度
serial 真正的双向字节流转发:server 端 SerialPort.openclient 端发 hex 字符串 全平台 ✓ 完整(OS 看来就是普通串口)
usb libusb 字节桥:server 端 node-usb open + claim interfaceclient 端发 controlTransfer/bulkTransfer 请求 全平台 ✗ 不是透明 USB——是远程调设备
usbip 调系统 usbip CLI,让 Linux 内核接管(vhci_hcd 模块) Linux only ✓ 真透明(设备出现在本机 lsusb)

5.3.1 serial 模式(最实用)

覆盖所有 USB-串口适配器(CH340 / CP210x / FTDI / PL2303)和原生 COM/tty。对端不用装任何驱动——serialport 走 OS 标准串口 API,自动枚举。 对硬件开发者来说这就是"远程调试 Arduino / STM32 / ESP32 / 串口打印机 / 串口屏"。

控制端                                被控端
usb.attach(busId, { kind: 'serial', baudRate: 115200 })
  └→ chatClient.send(usb.attach)
                                        └→ 弹窗确认
                                        └→ new SerialPort({ path, baudRate: ... }).open()
                                        └→ sp.on('data', buf) → usb.data { dir: 'dev->host' }
usb.serialSend(sid, bytes)
  └→ chatClient.send(usb.data { dir: 'host->dev' })
                                        └→ sp.write(buf)

5.3.2 usb 模式(libusb 字节桥)

适合访问自定义 USB 设备(单片机编程器、调试器、JTAG、自定义硬件)。 不是透明 USB——是 node-usb 的 controlTransfer / bulkTransfer 调用搬到对端机器上。 要"像本机 USB 一样"必须走 usbip 或商业软件。

控制端                                被控端
usb.attach(busId, { kind: 'usb', interfaceNumber: 0 })
  └→ chatClient.send(usb.attach)
                                        └→ node-usb.open(vid,pid)
                                        └→ claimInterface(0)
                                        └→ 返回 endpoints 列表
usb.ctrlIn(sid, { requestType, request, value, index }, length)
  └→ chatClient.send(usb.ctrlIn { setup, length })
                                        └→ dev.nativeControlTransferIn(setup, 5000, length)
                                        └→ usb.ctrlResult { ok, data, status }
usb.bulkIn(sid, endpoint, length)         类似
usb.bulkOut(sid, endpoint, bytes)         类似

5.3.3 usbip 模式(Linux only

真透明 USB 透传。usbip attach -r <peer> -b <busid> 让本机内核接管,设备出现在 lsusb前置条件:用户机器装了 usbip 包 + 加载 vhci_hcd 内核模块。我们的应用首次用时自动检测 + 提示安装(permissions.ts 里的 diagnoseAndFixUsbAttach)。 macOS/Windows 默认不支持——UI 会显示提示但禁用该选项。

5.3.4 协议帧

type UsbFrame =
  | { type: 'list'; reqId: string }
  | { type: 'devices'; reqId: string; devices: UsbDeviceInfo[] }
  | { type: 'attach'; sessionId: string; direction: UsbDirection; busId: string; config: UsbAttachConfig }
  | { type: 'attached'; sessionId: string; ok: boolean; reason?: string; info?: UsbAttachedInfo }
  | { type: 'detach'; sessionId: string; reason?: string }
  | { type: 'detached'; sessionId: string; ok: boolean; reason?: string }
  // 字节流 (serial 模式)
  | { type: 'data'; sessionId: string; dir: 'host->dev' | 'dev->host'; data: string; fin?: boolean }
  // USB 控制传输 (request-response, 用 reqId 配对)
  | { type: 'ctrlOut'; sessionId: string; reqId: string; setup: UsbControlSetup; data?: string }
  | { type: 'ctrlIn'; sessionId: string; reqId: string; setup: UsbControlSetup; length: number }
  | { type: 'ctrlResult'; sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
  // USB 批量/中断传输 (request-response, 用 reqId 配对)
  | { type: 'bulkOut'; sessionId: string; reqId: string; endpoint: number; data: string }
  | { type: 'bulkIn'; sessionId: string; reqId: string; endpoint: number; length: number; timeoutMs?: number }
  | { type: 'bulkResult'; sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
  | { type: 'ack'; ok: boolean; reason?: string }
  | { type: 'error'; sessionId: string; reason: string }

6. 数据流与生命周期

6.1 远程终端

控制端                              被控端
ui-click "打开终端" 
  └→ TerminalPanel.onOpen()
      └→ window.api.terminal.open(peerId, { rows, cols })
          └→ ipcMain: terminal.open
              └→ chatClient.send(terminal.open frame)
                                      └→ ChatServer → TerminalManager.handleOpen
                                          ├→ 检查 peer 在 allowPeers
                                          ├→ 弹窗 (被控 UI)
                                          ├→ pty.spawn(defaultShell())
                                          └→ send(terminal.ack { ok: true })
ui 显示 ack + xterm 开始渲染
ui-keypress
  └→ window.api.terminal.sendInput(sid, bytes)
      └→ chatClient.send(terminal.input frame)
                                      └→ TerminalManager → pty.write(bytes)
pty.onData(bytes)
  └→ chatClient.send(terminal.output frame)
ui: xterm.write(bytes)

6.2 端口转发

控制端                              被控端
window.api.forward.open(peerId, { listenPort, targetHost, targetPort, ttlSec })
  └→ chatClient.send(forward.open frame)
                                      └→ ack { ok }(先校验端口黑名单、TTL

ui → curl 127.0.0.1:listenPort
  └→ net.Server connection
      └→ ForwardManager 维护 sessionId, socket map
      └→ chatClient.send(forward.data { dir: 'c2s', data: base64, fin })
                                      └→ net.Socket.write(data)
                                          └→ target process
                                          response bytes → chatClient.send(forward.data { dir: 's2c', data })
ui ← net.Server socket.write(data)

6.3 USB / 串口 共享

6.3.1 serial 模式 (字节流)

控制端                                  被控端
window.api.usb.list(peerId)
  └→ chatClient.send(usb.list)
                                        └→ SerialPort.list() + (libusb if any) + (usbip if linux)
                                        └→ usb.devices { kind: 'serial' | 'usb' | 'usbip', ... }

ui 选 serial 设备 + 波特率
  └→ window.api.usb.attach(peerId, busId, { config: { kind: 'serial', baudRate: 115200 } })
      └→ chatClient.send(usb.attach { config })
                                        └→ 弹窗确认
                                        └→ new SerialPort(...).open()
                                        └→ sp.on('data', buf) → usb.data { dir: 'dev->host', data: base64 }
                                        └→ usb.attached { ok, info: { kind: 'serial', serialPath } }

ui 写字节
  └→ window.api.usbSerialSend(sid, bytes)
      └→ chatClient.send(usb.data { dir: 'host->dev', data })
                                        └→ sp.write(buf)

ui 关闭
  └→ window.api.usb.detach(sid) → chatClient.send(usb.detach)
                                        └→ sp.close() → cleanupServerSession

6.3.2 usb 模式 (libusb 字节桥)

控制端                                  被控端
ui 选 usb 设备
  └→ window.api.usb.attach(peerId, busId, { config: { kind: 'usb', interfaceNumber: 0 } })
      └→ chatClient.send(usb.attach { config })
                                        └→ 弹窗确认
                                        └→ node-usb.findDeviceByIds(vid, pid)
                                        └→ dev.open() → selectConfiguration(1) → claimInterface(0)
                                        └→ 枚举 endpoints
                                        └→ usb.attached { ok, info: { kind: 'usb', endpoints: [...] } }

ui 发 control IN (例: GET_DESCRIPTOR)
  └→ window.api.usbCtrlIn(sid, { requestType: 0x80, request: 0x06, value: 0x0100, index: 0 }, 18)
      └→ chatClient.send(usb.ctrlIn { setup, length: 18 })
                                        └→ dev.nativeControlTransferIn(setup, 5000, 18)
                                        └→ usb.ctrlResult { ok, data, status: 18 }

ui 发 bulk IN
  └→ window.api.usbBulkIn(sid, 0x81, 64)  → 类似 ctrlIn

ui 发 bulk OUT
  └→ window.api.usbBulkOut(sid, 0x01, hexData)  → 走 ctrlOut-style request-response

6.3.3 usbip 模式 (Linux only)

控制端 (Linux 客户端)                    被控端 (Linux 设备持有方)
ui 选 usbip 设备
  └→ window.api.usb.attach(peerId, busId, { config: { kind: 'usbip' } })
      └→ chatClient.send(usb.attach { config: { kind: 'usbip' } })
                                        └→ 弹窗确认 → 仅记录 (不直接 open)
                                        └→ usb.attached { ok }
      └→ permissions.diagnoseAndFixUsbAttach() — 检查 usbip + vhci_hcd
      └→ `usbip attach -r <peerLanIp> -b <busId>` (spawn sudo-prompt)
                                        └─ → 内核 vhci_hcd 接收 → 设备出现在 `lsusb`

7. UI 设计

7.1 Sidebar 增加“工具”tab

设备  文件  工具  设置
       └─ 终端
       └─ 端口转发
       └─ USB 设备

7.2 TerminalPanel.vue

<template>
  <div class="term-panel">
    <div class="term-status">
      <span :class="state">{{ stateLabel }}</span>
      <span>{{ peer.name }} · {{ shellLabel }}</span>
      <el-button size="small" @click="disconnect">断开</el-button>
    </div>
    <div ref="hostEl" class="term-host" />
  </div>
</template>

<script setup lang="ts">
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
// ... 见 src/renderer/src/components/remote/TerminalPanel.vue
</script>

7.3 ForwardPanel.vue

  • “+ 新建转发”按钮 → 弹表单(peer / 监听端口 / 目标主机 / 目标端口 / TTL)
  • 列表展示:peerName, listen:127.0.0.1:PORT0 → peer:HOST:PORT1,剩余 TTLin/out 字节数
  • 行内操作:停用、复制链接

7.4 UsbPanel.vue

顶部方向切换 (我用对方的 / 对方用我的) + 模式切换 (串口 / USB / USB/IP) + 设备扫描按钮。

串口模式(最实用):

  • 设备表 + 波特率下拉 + Attach 按钮
  • 附加后显示 hex 流(serial 字节流可视化)
  • "发送文本"按钮快速发 ASCII/UTF-8 字节

USB 模式libusb 字节桥):

  • 设备表 + Interface # 输入 + "Linux detach 内核驱动" 复选
  • 附加后显示:
    • Endpoints 列表(从 attach 帧的 info.endpoints 拿到,提示用户 EP 地址和方向)
    • 控制传输表单:方向(IN/OUT+ typestandard/class/vendor+ recipientdevice/interface/endpoint/other+ request + wValue + wIndex + data(hex) / length
    • 批量/中断传输表单:方向(IN/OUT+ EP 地址 + length + timeout + data(hex)
    • 调用日志:每次 ctrl/bulk 调用的时间、tag、status、hex dump

USB/IP 模式Linux):

  • 顶部显示提示:"USB/IP 是 Linux 内核自带的真透明 USB 透传...需要装 usbip + 加载 vhci_hcd 模块"
  • 设备表 + Attach 按钮
  • 附加后由内核接管,本应用不再做数据转发

底部始终有"对方正在使用我的设备"横条(当 server-side session 存在时),可一键 stop。


8. 安全模型

本项目原本 LAN 信任域 = 同一子网任意主机;启用终端/转发/USB 后需要更明确的边界。

8.1 信任与确认

  • 每条 *.open 必须在被控端弹窗(默认聚焦窗口、10 秒倒计时、不可超时自动通过)。
  • 设置项 remote.allowPeers: Record<DeviceId, { terminal: bool; forward: bool; usb: bool }>,默认全 false。
  • 同一对端 24 小时内只能弹一次允许(除非重新启动应用)。
  • “仅只读终端”开关:禁用 terminal.input(被控端拒绝写入帧)。
  • forward.open 必须显式选择目标端口范围 + TTL;被控端按白名单校验 host。

8.2 端口安全

  • 监听 host 永远 127.0.0.1,不开 0.0.0.0
  • 只挡自家端口 SELF_PORTS = {47800, 47900, 47901}(防把消息/文件端口转发造成死循环/桥接冲突),其他端口一律不挡(用户的 SSH/RDP/MySQL 等服务我们不去判断)。
  • 默认 TTL 1 小时,最大 24 小时。
  • 默认带宽 10 MB/s,最大 100 MB/s,按 session 计数。

8.3 USB / 串口 安全

  • 每条 usb.list / usb.attach 必须被控端显式授权(弹窗)。
  • serial 模式:被控端打开串口前弹窗告知"对方将通过此串口发送/接收字节",用户勾"记住"后 24h 内不再弹。
  • usb 模式:被控端 claim interface 前弹窗,告知"对方将通过本机的 USB 设备 X 发送控制/批量传输"。
  • usbip 模式:弹窗告知"对方将 attach 你的 USB 设备 X 到他自己的机器上"(设备会从你机器上消失)。
  • 设备列表本身(vendor/product/serial)属于"信息泄露"敏感字段,要求接收端在 usb.list 时显式授权一次。
  • 被控端顶部横幅"USB/串口 设备已被 X 远程使用中"(chat header 同时显示)。
  • session 30 秒无活动自动 detach。

8.4 审计

新增表(db.tsinitAudit()):

CREATE TABLE audit_log (
  id INTEGER PRIMARY KEY,
  ts INTEGER NOT NULL,
  action TEXT NOT NULL,        -- terminal.open / forward.open / usb.attach ...
  source_device_id TEXT,
  target_device_id TEXT,
  session_id TEXT,
  payload_json TEXT,
  result TEXT,                -- ok / denied / error / closed
  bytes_in INTEGER DEFAULT 0,
  bytes_out INTEGER DEFAULT 0
);
CREATE INDEX idx_audit_ts ON audit_log(ts DESC);

保留 30 天(设置项可改),手动“导出 audit.zip”。


9. 与现有 pitfalls 的对齐

Pitfall 对策
#1 chat-client 必带 'error' 监听 新增帧引入不影响;已有 on('error') 已稳
#2 drag-drop file path 不涉及
#3 native rebuild @lydell/node-ptyusbnode-hidserialport 都是 native,必须走现有 postinstallelectron-builder install-app-depsCI 加 npm rebuild --runtime=electron 兜底
#4 dist-Electron 路径 不动
#5 nsis oneClick false 不动
#6 dragenter/dragleave 深度计数 不涉及
#7 渲染层只暴露 在线/离线 两种状态 新增的三个 UI 独立成 tab,不污染 ChatView/Sidebar 的现有状态机
#8 文件冲突命名 新增 downloadDir 复用 file-server.ts 既有目录,不改

10. 落地节奏

v0.2.0 — 单平台、单 session、只读终端 + 本机转发

  1. @lydell/node-pty@xterm/xterm@xterm/addon-fit@xterm/addon-web-linksusbnode-hidserialport
  2. src/main/protocol.tsWsFrame 三类控制帧。
  3. src/main/remote/{terminal,forward,usb}.ts 三个 manager。
  4. src/main/db.tsaudit_log 表。
  5. src/main/ipc.tsterminal:*forward:*usb:* 共约 10 个 handler。
  6. src/renderer/src/components/remote/{TerminalPanel,ForwardPanel,UsbPanel}.vue
  7. src/renderer/src/stores/{terminal,forward,usb}.ts
  8. SettingsView.vue 增加“远程”开关与 allowPeers 列表。
  9. npm run typecheck 通过;手动两机对测。

v0.3.0 — 多 session、带宽/TTL、串口透传

  1. forward.maxBandwidthforward.defaultTtl 设置项。
  2. serialport 路径完善(Linux udev 提示)。
  3. xterm.js 同步主题、深色模式。

v0.4.0 — 公网可选

  1. localtunnel 接入(仅转发;终端和 USB 不走公网)。
  2. 文案与免责声明。

不做

  • 屏幕/桌面、鼠标键盘输入、剪贴板双向同步、音频、文件双向同步——都留给 RustDesk / AnyDesk。
  • 公网中继自建(成本过高,Y 不如直接用现成 SaaS)。
  • 远程重启、远程安装驱动、远程执行任意脚本。

11. 验证清单(手动两机)

# 场景 期望
T1 控制端请求被控端终端 → 被控端允许 xterm 渲染 PowerShell/zsh/bash 提示符
T2 控制端输入 ls -la 双端能正确显示(中文文件名 OK
T3 控制端把窗口拉大 终端按比例刷新(fit + resize 帧)
T4 控制端断开 被控端 PTY 收到 SIGHUPwin 下 ConPTY 退出)
F1 控制端 127.0.0.1:5180 → 被控 127.0.0.1:22 ack 被控拒(黑名单)
F2 控制端 127.0.0.1:5180 → 被控 127.0.0.1:3000 浏览器开 5180 看到 3000 内容
F3 TTL 到期 listener 自动释放;netstat 看不到端口
U1 列出被控端 USB 设备 控制端看到 product/serial
U2 attach 串口 /dev/ttyUSB0 被控端 z串口被占;控制端能 write
U3 被控端关闭应用 控制端收到 usb.close { reason: 'peer-down' }
S1 控制端不在 allowPeers 被控端收到 open 直接 ack false,写 audit
S2 控制端伪造 fromDeviceId 被控端按 WS 握手记录覆盖,伪造失败
S3 同时 5 个并发 forward 全部独立,UI 列表正确

12. 参考

  • @lydell/node-pty README & 平台包列表:https://github.com/lydell/node-pty
  • @xterm/xterm v6.0.0https://xtermjs.org/
  • usb@3.0.1Rust 重写版):https://github.com/node-usb/node-usb-rs
  • node-hid@3.3.0https://github.com/node-hid/node-hid
  • serialport@13.0.0https://serialport.io/
  • localtunnel@2.0.2https://github.com/localtunnel/localtunnel
  • 现有项目入口:AGENTS.mdsrc/main/protocol.tssrc/main/chat-client.ts:91src/main/ipc.ts:59