Files
nexusterm/README.zh-CN.md
T
ylhan 36d60bab1e Add a Chinese README, and drop two references readers cannot follow
README.zh-CN.md is a full translation rather than a summary: the same 26
sections, the same code blocks. Both files link to each other.

The English README pointed readers at "the implementation plan" twice, for
a file that lives outside the repository. The README has since grown to
cover that design material itself, so the pointers went rather than the
content.
2026-09-17 03:18:16 +08:00

19 KiB
Raw Blame History

NexusTerm

Linux 桌面上的 SSH 会话管理器 —— 连接树、按主机分组的标签页、端口转发、本地终端与串口。

可以理解为:Xshell 的会话管理 + tmux 的多路复用,且本地优先 —— 你的连接资产不出本机,凭据由操作系统钥匙串保管,而不是托管在厂商的云上。

English · 中文

状态

早期阶段。里程碑见下表。

里程碑 内容 状态
M0 脚手架 —— Electron + React + TS 三进程链路 完成
M1 持久化 + 密钥库(SQLite、safeStorage、降级处理) 完成
M2 验证原生模块链路(better-sqlite3,后续 node-pty / serialport) 并入 M1 / M8
M3 连接树界面 完成
M4 SSH 连接 + 终端 完成
M5 标签页与同主机分组 完成
M6 流控与断线重连 完成
M7 端口转发(-L / -R / -D) 未开始
M8 本地终端 + 串口 本地终端完成,串口未做
M9 打包 完成

前置条件

构建工具链(原生模块需要)

sudo apt install build-essential python3 pkg-config libudev-dev

libudev-dev 是 @serialport/bindings-cpp 需要的。M8 之前用不到,但提前装上可以避免日后一个令人费解的构建失败。

运行时钥匙串

凭据存储依赖系统钥匙串守护进程。先确认它在运行:

ps -e -o comm= | grep -E 'gnome-keyring|kwalletd'
echo "$DBUS_SESSION_BUS_ADDRESS"

如果两者都没有,Electron 会静默退回 basic_text 后端——用硬编码密钥"加密",等同于明文,但数据库看起来仍然是密文。本应用会检测这种情况并告警,而不是假装自己是安全的。

串口访问

需要加入 dialout 组:

sudo usermod -aG dialout "$USER"

必须完全注销并重新登录才生效,新开一个 shell 不够。Arch 上组名是 uucp。

有两个守护进程会在应用打开 USB 串口设备之前抢占它们:

  • ModemManager 会向新接入的设备发送 AT 指令探测,需要加 udev 规则设 ID_MM_DEVICE_IGNORE=1
  • brltty(盲文显示守护进程)在 Ubuntu 上会抢占 CH340/CP210x 芯片,表现为设备莫名其妙地消失

开发

npm install
npm run dev

其他脚本:

npm test                 # vitest,见下文「测试」
npm run typecheck        # 主进程/preload 与渲染进程分开检查
npm run build            # 产物打包到 out/
npm run dist             # AppImage + deb 到 release/
npm run rebuild:native   # 按 Electron 的 ABI 重建原生模块

测试

npm test

整套测试跑在普通 Node 下,不需要 Electron 进程。这能做到,是因为 Vault 接收一个 Keyring 接口而非直接 import safeStorage——于是主密钥生命周期、拒绝在不可信钥匙串上运行、加锁这些安全关键路径是被真实执行的,而不是对着一个松到什么也证明不了的 mock 跑。

测试用的是临时目录里真实的文件型 SQLite 数据库,不是 :memory:。两个原因:断言"密钥绝不落盘"需要一个真实文件来检查;以及 WAL 模式(密钥可能落入的地方)对内存数据库不适用。

最值得知道的那条断言在 tests/credentials.test.ts:存入密码后它会 checkpoint WAL 并读取原始数据库文件,断言明文在文件中任何位置都不出现。改为只检查那一列,证明的仅仅是"这一列里是字节"。

集成冒烟测试

单元测试覆盖了仓储层和纯树逻辑,但没有任何一条走过渲染进程与主进程之间的那根线。为此:

npm run build
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
npm run smoke

scripts/ipc-smoke.mjs 通过 Chrome DevTools 协议接入运行中的应用,调用 window.nexusterm——和界面调用的是同一个对象。于是它成了对 preload 桥 → ipcRenderer.invoke → 处理器 → zod 校验 → 仓储 → SQLite → 回包的完整端到端检查,且不需要用手去点界面。

它目前验证:创建文件夹与主机、嵌套、重命名、移回根层、拒绝把文件夹移入自己的子孙、拒绝非法载荷,以及跑完自己清理干净。

SSH:对真实服务端

npm run build
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
npm run e2e:ssh

scripts/ssh-e2e.mjs 更进一步:在树里双击一台主机、应答主机密钥提示、并用合成按键向终端里键入命令。需要 tests/dial.test.ts 里描述的那个测试 sshd。

由于 WebGL 渲染器不在 DOM 里留下任何文本,它不断言终端的输出内容。取而代之:键入的命令让远端 shell 创建一个文件,然后从浏览器外部检查它是否存在:

按键 → xterm → MessagePort → 主进程 → SSH 通道 → 远端 shell → /tmp/…

这覆盖了完整的去程,而且结果无法被渲染层的 bug 伪造。最终报告里带 remoteUser,那是从远端写出的文件里读回来的——回程因此被单独证明了。

打包

npm run dist              # AppImage + deb 到 release/
npm run verify:package    # 检查产物是否真能运行

scripts/verify-package.mjs 的存在理由:一个在关键之处坏掉的包,照样能构建、能安装、能启动。原生模块无法从 asar 归档内部加载,而当它们被留在里面时,故障出现在用户机器上、第一次打开终端的那一刻——不在 CI 里,也不在 npm test 里。所以这个脚本会解开 AppImage,逐一断言每个原生二进制确实在 app.asar.unpacked 里。

它检查的两件事都属于容易搞错的那类:

  • ssh2 带原生模块。 sshcrypto.node 容易被忽略,因为这个库读起来像纯 JavaScript——而且加载失败时它会静默退回 JS 实现,所以没有任何东西会报告这件事。
  • node-pty 必须整包解出,而不只是它的 .node 文件。 在 macOS 和 Windows 上它带一个 spawn-helper——一个从磁盘加载的真实可执行文件,不是 dlopen 进来的;只解包 *.node 会让它困在归档里。

npmRebuild 是关闭的:这里每个原生模块都基于 node-addon-api,所以 npm install 时编译出的二进制在 Electron 的 Node 下可原样加载。重建只会花时间、并冒着用不同方式编译的产物替换掉可用产物的风险,没有任何收益。

.deb 元数据与 desktopName 都已设置,AppImage 的 .desktop 条目由 linux.desktop.entry 生成。

给接手的人

  • author、homepage、maintainer 三个字段目前是占位值(ylhan@example.com、example.com)。deb 格式要求这些字段存在,正式分发前必须替换。
  • asarUnpack 里列了 node_modules/@serialport/**,而那个后端尚不存在。现在无害;串口传输落地后它就是承重的了。

工具链约束

为什么 Electron 锁精确版本

原生模块是针对特定 Electron ABI 编译的。范围版本(^)会让一次静默升级使重建产物失效,而由此产生的故障出现在运行时而非安装时。保持精确版本。

Electron 44.4.1 内置 Node 24.21.0 与 Chrome 152——这是在运行时确认的,不是从依赖图推断的。

三条承重的版本约束

均为实测确立。每一条的失败方式都不指向它自己的成因。

Vite 锁在 7.x,@vitejs/plugin-react 锁在 5.x。

electron-vite@5 声明 peer vite@"^5 || ^6 || ^7",而 @vitejs/plugin-react@6 要求 vite@"^8"。两者无法共存。React 插件的 5.x 线是接受 Vite 7 的最新版本。要升级 Vite,得等 electron-vite 放宽它的 peer 范围。

TypeScript 7 移除了 baseUrl。

paths 映射现在必须是相对路径,带前导 ./。报错是 TS5102: Option 'baseUrl' has been removed。它还启用了 noUncheckedSideEffectImports,所以 import './styles.css' 这类裸导入需要模块声明——"types": ["vite/client"] 提供了它。

better-sqlite3 自带 N-API 预编译产物。

node_modules/better-sqlite3/prebuilds/ 里有 linux-x64.node 等文件,基于 node-addon-api 构建。由于 N-API 的 ABI 在 Node 与 Electron 版本间稳定,这通常免掉了 electron-rebuild 这一步。若某次运行时原生模块加载失败,先查这里,再去考虑重建。

Electron 二进制可能需要手动下载。

如果 npm install 完成但 node_modules/electron/dist/electron 不存在,说明该包的 postinstall 被跳过了。执行:

node node_modules/electron/install.js

架构

主进程               只做 Node 原生的工作:ssh2 客户端、PTY、串口、
                    SQLite(唯一写入者)、safeStorage(唯一解密者)
      │
      │  控制面  ipcMain.handle / invoke —— 请求-响应,低频
      │  数据面  每会话一个 MessagePort —— 终端字节流,高频
      ▼
preload             contextBridge,窄白名单 —— 这个文件**就是**安全边界
      ▼
渲染进程             React + xterm.js。sandbox: true、contextIsolation: true、
                    nodeIntegration: false。无 Node 访问权限。

设计依赖两条规则:

  1. 终端字节以原始 Uint8Array 跨界,绝不用字符串。 UTF-8 多字节字符会被 TCP 分段切断,在主进程里做字符串转换会在 chunk 边界损坏文本。这个 bug 只在中文或 emoji 输出时显现,因此极易漏掉。

  2. 没有任何 IPC 调用会返回已存储的密钥。 渲染进程请求的是 connect(hostId),不是密码。一个 getSecret() 式的接口会让渲染进程侧的任何一处失陷一次性拖走全部已保存凭据。

SSH 会话

主机密钥校验分两阶段,且第一阶段永远拒绝。 密钥不在 known_hosts 里的主机会返回 unknown-host-key 并附上指纹,而不是直接连上;界面展示指纹,只有携带那个确切指纹的重试才能通过并被记录。代价是首次连接要拨号两次。替代方案——让套接字处于半开状态、把提示往返渲染进程——除了延迟什么也换不来,而先连接后询问的客户端,是一个已经输了的客户端。

密钥发生变化时永远不是提示。host-key-mismatch 被当作错误上报,并给出新旧两个指纹——因为一个带"继续"按钮的对话框,恰恰是攻击者最希望用户看到的东西。

指纹以 OpenSSH 的 SHA256:<base64> 格式呈现,这样可以拿去和 ssh-keygen -lf 比对。一个用户无法与任何东西比对的指纹,不值得显示。

会话数据通路

终端字节走的是每个会话专属的 MessagePort,不是共享的 IPC 通道。在共享通道上,所有会话的输出会串行通过同一个队列,于是单个 cat 大文件就会拖慢其他每一个会话的输入输出。

这个端口无法经 contextBridge 传递——穿过它的 MessagePort 到手即不可用(electron/electron#27024)。preload 接收它,再用 window.postMessage(..., ports) 转发,后者能正确转移。

流控是信用制的:未确认字节达到 1MB 时主进程停止从 SSH 通道读取,待渲染进程画完只剩 256KB 时恢复。没有它,cat 一个大文件会把全部输出堆在渲染进程里、界面直接死掉——这是必然,不是风险。

字节以 Uint8Array 跨界,绝不用字符串。TCP 经常把 UTF-8 字符切断在 chunk 之间,单独解码任一半都会损坏它;这个 bug 只在中文或 emoji 输出时出现,因此极易漏掉。

重连

传输断开时,Session 存活,只替换它底下的 backend——这正是那两者被拆成独立对象的理由。渲染进程保住了滚动历史和标签页,因为终端从未被拆掉。

不存活的是 shell 本身。重连得到的是远端一个全新进程、全新的工作目录,终端会把这一点明说,而不是让用户以为还在断线前的目录里。这条提示写进滚动缓冲而不是弹横幅——日后往上翻时,得能看出旧 shell 在哪结束、新 shell 从哪开始。

重试循环遵循两条规则:

  • 只有传输故障才重试。 主机密钥变更、或服务端拒绝了凭据,每次都会以同样方式失败;重试只会白白捶服务端,还拖久了才告诉用户真出了问题。
  • 重试带抖动。 这恰恰是多个会话同时掉线的场景(网络断了),固定延迟会让它们每一轮都在同一刻撞向服务器。

退避从 1 秒起、上限 30 秒,十次后放弃:长到足以扛过一次重启或一条不稳的链路,短到指向一台已下线主机的会话会停下来,而不是永远活在重试循环里。

密码在会话存续期间留在内存里,以便重连时直接拨号。改从密钥库重读,对"用户选择不保存"的密码无效;而用认证弹窗打断一次抖动,等于把两秒的中断变成一场审讯。

一个接口,两种传输

从终端的视角看,一条 SSH 通道和一个本地 PTY 是同一个东西,所以两者都在 SessionBackend 之后,而它之上的一切——流控、端口、标签页、WebGL 生命周期——只写一遍。串口后端落地时,是同一接口的第三个实现。

两者只在一个地方值得注意地不同:ssh2 交出 Buffer,node-pty 交出 string——因为它自己解码 PTY 流,能在跨读取时接住被切断的多字节序列而不是损坏它。因此 Buffer.from(chunk, 'utf8') 是无损的,也就让边界保持一致:上层一律说字节。

node-pty 不需要针对 Electron 重建。 它基于 node-addon-api,所以 npm install 时编译的二进制在 Electron 的 Node 下原样加载——这一点已在 Electron 44 内部实际拉起一个 PTY 验证过。这退休了计划中原先排名第一的风险。

Linux 上也没有 spawn-helper:那只存在于 macOS 和 Windows。asarUnpack 规则无论如何覆盖整个包,所以"可执行文件能否穿过 asar 边界"无需特例。

侧边栏,以及会话住在哪里

侧边栏合并了两个彼此无关的来源:持久化的连接树,和只存在于渲染进程、应用关闭即消失的打开会话。

于是一台主机在它下面有会话时是中间节点,会话全部关掉后又变回叶子。它的可展开性是从会话列表推导的,不是存储的,所以不存在一个会和现实脱节的标志位。

固定的本地终端条目是一个虚拟行——不是数据库行。它不能拖动、改名或删除,这正是要点:它是那个不依赖网络可用的终端,不应该有丢失的可能。双击会在本机再开一个 shell;每次双击都开一个新的。

会话排序用 seq——从打开时刻起单调递增的计数器——而不是数组下标,于是置顶可以重排显示而不用重写列表,关掉一个也不会扰动其他会话的位置。

会话编号

一台主机的第二个及之后的会话带编号:n5095、n5095 2、n5095 3。第一个保持裸名,让常见情况不被搅扰。本地终端自成一組单独计数。

编号是在会话打开时确定并存储的,不是从"当前开着几个"推导的。推导式编号会给幸存者改名:开三个、关掉中间那个,第三个会悄悄变成第二个。它还会撞号——"数量 + 1"会发出一个仍在使用中的号。改为追踪"当前最大号 + 1"则意味着全部关掉后该组从 1 重新开始,而这正是全新开始该有的样子。

基名与编号分开存储,因为本地终端的基名是翻译字符串,而负责拼装标签的模块不该知道语言这回事。

置顶的范围限定在该主机自己的分组内。一个跳到整个列表顶部的会话已经离开了它的分组,而"把一台机器的会话聚在一起"正是分组存在的理由。

标签栏

与侧边栏显示同样的顺序,所以一个会话在两个视图里位置一致。标签可以拖动重排,但只能在自己主机的分组内——一个离开了所属区块的标签,在按主机分组的侧边栏里没有位置可放,两个视图也会失去对应关系。

标签与会话带有相同的五个动作,所以没有哪个视图是另一个的残缺词表:

重命名 只命名这个会话。主机不受影响,且名字在会话关闭后消失——这不是给连接改名。
置顶 移到它所属主机分组的最前。
复制 向同一主机再开一个会话,放在原会话旁边。
打开新标签 向同一主机再开一个会话,追加到分组末尾。
关闭 结束该会话。

复制会连同原会话的置顶状态一并继承,而不只是位置。否则副本会落进另一个排序桶、跑到分组末尾,"放在原会话旁边"就根本无从表达。

位置用分数 seq 而不是数组下标,于是拖动或复制写入的是两个邻居的中点,而不是重排整组。和连接树的 sort_order 同一个取舍、同一个理由:重排的代价不随打开数量增长。

已知缺口:终端对屏幕阅读器不可见

WebGL 渲染器画进 canvas,所以 DOM 里没有可供辅助技术读取的文本。xterm 的 screenReaderMode 能解决这个问题,但有吞吐代价——这使它成为一个需要权衡的决定而非默认值,而目前尚未做出。

这也意味着端到端测试无法断言终端的文本;断言了什么见上文。

连接树

文件夹和主机是同一张 nodes 表里带 kind 判别字段的行,共享同一个 id 空间和每层同一个排序序列。这正是它们能交错的原因:主机可以拖到两个文件夹之间,文件夹也可以拖到两台主机之间。

这并非最初的设计。文件夹和主机起初在两张表里、各自独立的 sort_order 序列,意味着它们的位置不可比较——每个文件夹必然渲染在每台主机之前,而"主机在两个文件夹之间"根本无法表达。迁移 002_interleave.sql 重建了树并把既有行搬运过来。

它们的属性放在 folders 和 hosts 表里、以节点 id 为键,因为仅有 kind 判别字段无法阻止一个外键指向错误的种类。credentials 和 port_forwards 引用 hosts(id),而那个引用是文件夹真正无法满足的。

重建表的迁移

重建是 SQLite 改变表形状的唯一途径,而它需要两个在事务之外设置的 pragma——SQLite 在事务内会忽略这两者:

  • foreign_keys = OFF,因为重建的中间状态合法地违反外键
  • legacy_alter_table = ON,这样 ALTER TABLE ... RENAME 不会去重写其他表里的引用——否则会静默地把 credentials 指向一张即将被删除的表

把这类迁移标记为 rebuildsTables: true,运行器会处理这两者,并在事务内执行 PRAGMA foreign_key_check。留下悬空引用的重建会抛错并回滚,于是一次糟糕的迁移让上一个 schema 保持完整,而不是留下一个转换了一半的数据库。

tests/migration002.test.ts 拿一个按旧结构建起来、装着旧格式行的数据库来跑这件事。拿全新数据库测迁移,等于它一条代码路径都没走到。