31 KiB
远程终端、端口转发、USB 透传 规划文档
目标:在 LocalNetMsg 中实现三类“远程使用对方设备”的能力——对方机器的本地终端(shell)、TCP 端口转发、USB 设备透传。本文先定整体架构、协议、库选型,再分阶段落地。
适配范围:Windows / macOS / Linux 三端。被控端的 shell 按平台决定:Windows → PowerShell(默认
pwsh.exe,回退powershell.exe),macOS → zsh(回退 bash),Linux → bash(按SHELL环境变量)。
1. 设计目标与边界
| 项目 | MVP(v0.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.12(beta=1.2.0-beta.14,可选升级) |
自带六平台预编译:win32-x64/arm64、darwin-x64/arm64、linux-x64/arm64;latest 是 1.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 模块 |
— | 不引入额外依赖;与现有 ws、file-server 同风格 |
| USB 后端(被控端) | usb(node-usb v3.0.1,Rust N-API 重写版) |
3.0.1 |
同时提供 usb(自由访问)和 webusb(需授权)两种 API |
| HID 快速通道 | node-hid(v3.3.0) |
3.3.0 |
只针对 HID 类(键盘/鼠标/游戏手柄/自定义 HID)速度更快、API 更友好;非 HID 设备走 usb |
| 串口 / USB-Serial | serialport(v13.0.0) |
13.0.0 |
需要 Node ≥20 |
| 公网隧道(可选 v0.3+) | localtunnel |
2.0.2 |
公共中继;不推荐默认开,作为开发期选项 |
镜像访问:
https://registry.npmmirror.com/<pkg>/latest可直接读 JSON,元数据字段含dist-tags.latest、engines、os、cpu、napi.targets,比 GitHub 页面稳定。GitHub raw/README 多数代理 403/Cloudflare 拦截,npm 元数据是兜底。
2.2 不选 / 避坑
node-pty上游microsoft/node-ptylatest=1.1.0,仅beta=1.2.0-beta.14。@lydell/node-pty是社区 fork,包更小、预编译更全。直接用社区版,等microsoft/node-pty进入 stable 再切。robotjs:维护停滞,对 Electron 32 ABI 兼容性差。usbipd-win:Windows 官方 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 |
扩展三条新控制帧:terminal、forward、usb |
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/* |
新增 useTerminalStore、useForwardStore、useUsbStore |
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(自动从握手记录填)appVersion、nonce(每次open重新生成)
被控端收到 open 后:
- 校验对端是否在
usb/terminal/forward.allowPeers中(设置项)。 - 弹窗确认(被控端的 window),10 秒倒计时。
- 通过/拒绝,发送 ack。
5. 模块设计
5.1 Remote Terminal(src/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 Forward(src/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 server(net.createServer,host: '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.0;v0.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.open,client 端发 hex 字符串 |
全平台 | ✓ 完整(OS 看来就是普通串口) |
usb |
libusb 字节桥:server 端 node-usb open + claim interface,client 端发 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,剩余 TTL,in/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)+ type(standard/class/vendor)+ recipient(device/interface/endpoint/other)+ request + wValue + wIndex + data(hex) / length
- 批量/中断传输表单:方向(IN/OUT)+ EP 地址 + length + timeout + data(hex)
- 调用日志:每次 ctrl/bulk 调用的时间、tag、status、hex dump
- Endpoints 列表(从 attach 帧的
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.ts 内 initAudit()):
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-pty、usb、node-hid、serialport 都是 native,必须走现有 postinstall 的 electron-builder install-app-deps;CI 加 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、只读终端 + 本机转发
- 加
@lydell/node-pty、@xterm/xterm、@xterm/addon-fit、@xterm/addon-web-links、usb、node-hid、serialport。 src/main/protocol.ts加WsFrame三类控制帧。src/main/remote/{terminal,forward,usb}.ts三个 manager。src/main/db.ts加audit_log表。src/main/ipc.ts增terminal:*、forward:*、usb:*共约 10 个 handler。src/renderer/src/components/remote/{TerminalPanel,ForwardPanel,UsbPanel}.vue。src/renderer/src/stores/{terminal,forward,usb}.ts。SettingsView.vue增加“远程”开关与 allowPeers 列表。npm run typecheck通过;手动两机对测。
v0.3.0 — 多 session、带宽/TTL、串口透传
forward.maxBandwidth、forward.defaultTtl设置项。serialport路径完善(Linux udev 提示)。- xterm.js 同步主题、深色模式。
v0.4.0 — 公网可选
localtunnel接入(仅转发;终端和 USB 不走公网)。- 文案与免责声明。
不做
- 屏幕/桌面、鼠标键盘输入、剪贴板双向同步、音频、文件双向同步——都留给 RustDesk / AnyDesk。
- 公网中继自建(成本过高,Y 不如直接用现成 SaaS)。
- 远程重启、远程安装驱动、远程执行任意脚本。
11. 验证清单(手动两机)
| # | 场景 | 期望 |
|---|---|---|
| T1 | 控制端请求被控端终端 → 被控端允许 | xterm 渲染 PowerShell/zsh/bash 提示符 |
| T2 | 控制端输入 ls -la |
双端能正确显示(中文文件名 OK) |
| T3 | 控制端把窗口拉大 | 终端按比例刷新(fit + resize 帧) |
| T4 | 控制端断开 | 被控端 PTY 收到 SIGHUP(win 下 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-ptyREADME & 平台包列表:https://github.com/lydell/node-pty@xterm/xtermv6.0.0:https://xtermjs.org/usb@3.0.1(Rust 重写版):https://github.com/node-usb/node-usb-rsnode-hid@3.3.0:https://github.com/node-hid/node-hidserialport@13.0.0:https://serialport.io/localtunnel@2.0.2:https://github.com/localtunnel/localtunnel- 现有项目入口:
AGENTS.md、src/main/protocol.ts、src/main/chat-client.ts:91、src/main/ipc.ts:59