Files
LocalNetMsg/docs/remote-terminal-forward-usb.md
T
xiaoshuai d854c95ee6 fix(build): 修好 npm install 挂掉 (postinstall 只重建 better-sqlite3)
`npm install` 在 postinstall 阶段直接失败退出:

    node-hid 编译报错 -> node-addon-api/napi.h:2477 expected unqualified-id

根因不是 node-hid 坏了, 是重建范围错了:

1. postinstall 跑的是 `electron-builder install-app-deps`, 它重建**所有**原生模块;
2. 它给子进程传 `npm_config_build_from_source=false` —— 但这是**字符串** "false",
   npm 11 对未知的 npm_config_* 不做布尔转换;
3. pkg-prebuilds-verify 用 `if (process.env.npm_config_build_from_source)` 判断,
   字符串 "false" 在 JS 里是真值 -> 直接 exit 1 -> 强制源码编译;
4. node-hid 于是去编译它自带的 node-addon-api 3.2.1, 和现代 Node 头文件不兼容 -> 编译失败
   -> 整个 npm install 挂掉。

而 node-hid 根本不需要编译 —— 它自带 N-API 预编译产物, 任何 ABI 都能加载。

真正需要按 Electron ABI 重编的只有 better-sqlite3 (非 N-API, 绑死 V8/ABI,
Electron 32 -> node ABI 128); 其余 (@lydell/node-pty / node-hid /
@serialport/bindings-cpp / usb) 都是 N-API 预编译, 重编既没必要又有害。

改动:

- postinstall 换成 `node scripts/postinstall.js`:
  只重建 better-sqlite3, 装完**在 Electron 下真跑一次读写校验**, 失败就 exit 1
  —— 把"启动即崩"提前到 install 阶段暴露
- package.json 加 `allowScripts` 白名单 (npm 11 新机制, 目前只提醒不拦截,
  但迟早会拦; 顺便让 npm install 输出干净)
- AGENTS.md 坑 #3 改写成新规矩 + 完整根因链
- docs/windows-compat.md / docs/remote-terminal-forward-usb.md 里过时的
  "postinstall 走 install-app-deps" 表述一并纠正

验证:

- npm run build            ✅
- npm run typecheck        ✅ node + web
- npm run selftest         ✅ 64 通过 / 0 失败
- npm run selftest:sync    ✅ 全部通过
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4 (从 app.asar 真实 spawn pty)
2026-10-21 09:32:32 +08:00

663 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 远程终端、端口转发、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-pty` `latest=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 新增)
```ts
// 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` 后:
1. 校验对端是否在 `usb/terminal/forward.allowPeers` 中(设置项)。
2. 弹窗确认(被控端的 window),10 秒倒计时。
3. 通过/拒绝,发送 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:
```ts
// 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: [] }
}
}
}
```
被控端启动逻辑:
```ts
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}` }))
```
控制端写入:
```ts
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 协议帧
```ts
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 远程终端
```text
控制端 被控端
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 端口转发
```text
控制端 被控端
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 模式 (字节流)
```text
控制端 被控端
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 字节桥)
```text
控制端 被控端
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)
```text
控制端 (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
```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
**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()`):
```sql
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` 都是 N-API 预编译,**不需要**按 Electron 重编;只有 `better-sqlite3`(非 N-API)要编,由 `postinstall` 的 `scripts/postinstall.js` 单独重建并在 Electron 下自校验。**别用 `electron-builder install-app-deps`**(会把 N-API 模块也拖进源码编译,node-hid 编译失败,根因见 AGENTS.md 坑 #3)。CI 兜底直接 `npm ci`(会触发 postinstall → `scripts/postinstall.js`),或手动 `node scripts/postinstall.js` |
| #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-links`、`usb`、`node-hid`、`serialport`。
2. `src/main/protocol.ts` 加 `WsFrame` 三类控制帧。
3. `src/main/remote/{terminal,forward,usb}.ts` 三个 manager。
4. `src/main/db.ts` 加 `audit_log` 表。
5. `src/main/ipc.ts` 增 `terminal:*`、`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.maxBandwidth`、`forward.defaultTtl` 设置项。
2. `serialport` 路径完善(Linux udev 提示)。
3. xterm.js 同步主题、深色模式。
### v0.4.0 — 公网可选
1. `localtunnel` 接入(仅转发;终端和 USB 不走公网)。
2. 文案与免责声明。
### 不做
- 屏幕/桌面、鼠标键盘输入、剪贴板双向同步、音频、文件双向同步——都留给 RustDesk / AnyDesk。
- 公网中继自建(成本过高,Y 不如直接用现成 SaaS)。
- 远程重启、远程安装驱动、远程执行任意脚本。
---
## 11. 验证清单(手动两机)
### 11.1 自动化自测(单进程双节点)
`npm run selftest` 会在一个进程里拉起两个虚拟节点(A=控制端、B=被控端),走真实的
`ChatServer` + `ChatClient` + `TerminalManager` / `ForwardManager` / `UsbBridgeManager`,
接线方式与 `src/main/ipc.ts` 完全一致,不需要第二台机器:
| # | 用例 | 覆盖 |
|---|---|---|
| T1–T5 | 终端 open / 输出回显 / 输入 / resize / close | PTY 生命周期与 `terminal.output` 帧 |
| F1–F4 | 转发 self-out / self-in / 端口黑名单 / 字节计数 | 真实 TCP 隧道往返 |
| U1–U2c | 列设备 / libusb 枚举 / 控制传输 setup 翻译 | node-usb v3 API 适配 |
| U3–U5 | 串口 attach + 双向字节流 | 用 socat 造 PTY 对冒充真串口 |
| A1 | 关键操作写进 `audit_log` | 审计链路 |
> 跑测试用 `ELECTRON_RUN_AS_NODE=1` 起 Electron 的 node:`better-sqlite3` 是按 Electron ABI
> 编译的,用系统 node 会报 `NODE_MODULE_VERSION` 不匹配。
### 11.2 实现约定(踩过的坑)
- **sessionId 两端必须一致**:控制端生成 sessionId 并随 `attach` 帧发出,被控端**必须原样沿用**
作为自己 session 的 key。反过来自己 `randomUUID()` 会导致数据帧查表落空、被当成"无此 session"丢掉
(表现为串口/USB 字节流永远不通、detach 报"session 不存在")。
- **终端 output 帧要在控制端落地**:`handleIncoming` 收到 `output` 帧后要 `emit('output', ...)`,
`ipc.ts` 靠这个事件把数据广播给渲染层;只在被控端 emit 的话控制端 xterm 永远是空白。
- **node-usb v3(Rust 重写版)**:
- 枚举用 `webusb.loadDevices()`;`webusb.getDevices()` 是 WebUSB 语义,只返回本进程已授权的设备。
- 控制传输的 `setup` 用字符串枚举(`requestType: 'standard'|'class'|'vendor'`、
`recipient: 'device'|'interface'|'endpoint'|'other'`),协议里传的标准 `bmRequestType`
数值要翻译一次(`toUsbControlSetup`)。
- 设备被内核驱动占着时,必须**先 `detachKernelDriver` 再 `selectConfiguration`**,否则 EBUSY。
- **转发端口的归属**:只有两种拓扑,UI 必须按视角标注,否则用户会连错机器——
- `self-out`:发起方 bind 监听端口,目标在**被控端**机器上
- `self-in`:被控端 bind 监听端口,目标在**发起方**机器上
- 列表里 `side='client'` 表示"我发起",`side='server'` 表示"对方发起";
入口/目标列都要写清"本机"还是"对方"。
- **转发 session 两侧都要能关**:UI 的"关闭"按钮走 `ForwardManager.closeSession()`,
关闭 server 侧时必须给对端发 `close` 帧,否则对端的监听端口不会释放(端口一直被占)。
### 11.3 手动清单
| # | 场景 | 期望 |
|---|---|---|
| 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-pty` README & 平台包列表:`https://github.com/lydell/node-pty`
- `@xterm/xterm` v6.0.0:`https://xtermjs.org/`
- `usb@3.0.1`(Rust 重写版):`https://github.com/node-usb/node-usb-rs`
- `node-hid@3.3.0`:`https://github.com/node-hid/node-hid`
- `serialport@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`