Repository navigation
0.8.38 — 坏配置不再整机崩溃 + Agent 可复用已存机器(issue #48)+ 工作区压缩备份
工作区压缩备份 / 恢复(rw_backup / rw_backup_list / rw_restore)
新增
rw_backup—— 把远程工作区(或其子目录)打成.tar.gz,可选拉到本机或两边都留。
归档默认落在远端$HOME/.dsh-remote/backups/<工作区><哈希>/,每个归档配一个
.meta.jsonsidecar(工作区路径、条目数、sha256、排除项、时间)。rw_backup_list—— 列出远端与本机两侧的归档(大小/条目数/时间/校验值)。rw_restore—— 从某个归档恢复工作区;mode=replace(默认)整树替换、
mode=merge只覆盖不删除;where=local可从本机副本恢复(先自动传回远端)。- 设置页新增「工作区备份」卡片 —— 新建 / 校验 / 下载 / 传回远端 / 恢复 / 删除,
含排除项文本框与「整树替换 / 合并」模式选择。 - 三个新配置项:
backupDir、backupExcludes(默认空)、maxBackupTransferBytes。
设计取舍(都写在代码注释里,这里给结论)
- 默认全量收录。 排除项默认为空:备份静默少打包目录正是「恢复时静默丢数据」的
成因,所以宁可归档大一点,也要让「我备份过」这句话成立。要小归档时显式传excludes
(或配backupExcludes)。 - 归档不可信 ⇒ 恢复前先自证。 恢复脚本先
tar -tzf校验归档完整性,归档损坏或
被截断就直接拒绝、目标目录一个字节都不动。这条有专门的实测用例(往好归档后面追加
垃圾字节 ⇒ARCH_VERIFY=bad⇒ 目标里的precious.txt仍在)。 - 替换是「分阶段 → 换入 → 再删旧」而非原地覆盖。 先解到兄弟暂存目录(同一文件系统,
所以mv是原子的),再把旧目录挪到一边、把暂存目录换进目标位,最后才删旧目录;
换入失败会把原目录移回去。⇒ 一次失败的恢复不会把工作区变成两不像。 - 备份目录绝不会被自己打进归档。 若备份目录恰好在工作区内,该子树会被自动排除,
否则每次备份都会把上一次的归档再打进去、越滚越大。 - 文件名安全是一等的。 所有接受
file参数的动作(列出/校验/恢复/删除/下载)都先过
isSafeArchiveName:只接受纯.tar.gz基名,../、绝对路径、a/b、; rm -rf /一律拒绝。
excludes里的..与绝对路径同样拒绝(改写会排除得比用户要的更多,所以是拒绝而非纠正)。 --exclude的语义坑(实测):GNU tar 把模式与归档成员名匹配,而我们的成员是
./x/y。实测--exclude=src/dist能命中任意深度,但--exclude=/src/dist一条都不排
(静默失效)、--exclude=./src/dist只锚定根。所以用户写的/build/、./node_modules
都先被规范化成裸模式,且构建命令前会断言不变量(传进去就抛错)。-P/--absolute-names永不出现。 没有它,tar 默认会拒绝../成员、剥掉绝对路径前缀,
归档里的越界路径写不到目标之外。代码里有断言 + 用例。
验证
- 新增 4 个测试文件 / 72 个用例(
archive17、backup31、backup-routes20、
backup-shell-syntax4),全绿;npm test与check.mjs通过。 - 生成的 shell 过真实
sh -n:4 类构造器 × 路径/引号/$(...)/反引号/空格的对抗性输入矩阵,
并带负对照(do;、未闭合if、未闭合引号必须被拒),以及「通配/命令替换必须原样作为
单个参数传递」的实测断言。 - 在真实 Linux 主机上跑通全链路(不是 mock):
create → list → verify → restore(replace) → restore(merge) → delete → pull → push;含字节级往返一致(递归diff -r干净、二进制
NUL 字节保留)、损坏归档不破坏目标、同名归档不互相覆盖、路径含空格、
文件名以-开头、以及跨机往返后 sha256 不变。
坏配置让整机崩溃 + Agent 连不上已配好的机器(issue #48)
症状(issue #48 三连):①机器用加密私钥但没填 passphrase 时,整个 DSH 进程直接
fatal load failure: Cannot parse privateKey: Encrypted private OpenSSH key detected, but no passphrase given,且每次重启都再崩一次(启动恢复会重拨保存的当前机器);②Agent 侧
rw_connect 只能填 host/username/port/password/privateKeyPath——passphrase / useAgent /
keyboardInteractive / hostKeyMode 全都给不了,需要这些字段的机器对 Agent 来说根本连不上;
③也没法让 Agent 复用界面上已经配好的机器,只能把整张配置重新敲一遍。
崩溃根因:ssh2 在 Client.connect() 里同步解析私钥,解析失败同步 throw。这个
throw 落在 buildOpts().then(onFulfilled) 的回调里,而那条 promise 链没人接管 ⇒ 变成
unhandled rejection(Node 默认行为 = 杀进程),同时外层连接 promise 永远挂起。
启动恢复那段 try/catch 本来写明了"失败要吞掉",但它 await 的是挂起的外层 promise,
对孤儿链上的崩溃完全无效——所以坏配置才会每次都把整机带走。
修法:
- pool.js:
client.connect(opts)包 try/catch,同步 throw 转成正常的 promise 拒绝。
一处改动同时治好三件事:不再崩溃、不再挂起、所有调用点(启动恢复 / test-connect /
rw_connect)的 try/catch 恢复生效——坏配置现在就是一条普通错误提示; - rw_connect 补全字段:新增
passphrase/useAgent/keyboardInteractive/
hostKeyMode/name;upsert 时未提供的 secret 保留原值(以前只保 password,
passphrase 会被默认空串抹掉); - 复用已存机器:
rw_connect(machineId=...)直接用注册表里的整条记录(含钥匙串密码、
已存 passphrase、跳板机、ssh-config 别名)并设为当前机器;新增rw_machines工具
列出已存机器(id / 地址 / 认证方式旗标 / 别名解析 / 当前标记,只给旗标不给秘密值); - errors.js:
Cannot parse privateKey / no passphrase given归类为 credentials,提示
「私钥已加密但未提供 passphrase」。
测试 +13(test/issue48.test.js):崩溃组带负对照——回退 pool 修复后 A1/A4/B3 准时变红
(连接 promise 挂起 + 捕获到 unhandledRejection),恢复修复后全绿;其余覆盖字段透传、
passphrase 保留、machineId 连接、rw_machines 脱敏。
顺带修一处测试隔离缺陷(本机全量套件因此永挂):search-cancel.test.js 的预算用例
用 schema 默认值挂载真实插件,而默认 updateMode=auto(发起真实 registry 检查)、真实
DSH_HOME 注册表里又存着「当前机器」⇒ 启动恢复真的 SSH 拨号(keepalive 长连接),
用例全绿后进程仍存活 10 分钟以上。现钉 updateMode:'off' + 隔离 HOME/DSH_HOME;
update-default.test.js 的挂载门禁同步补上动态 import 形态(它原先只认静态
from '../lib/index.js',这个文件正是从缺口溜出去的)。