Skip to content

0.8.38 — 坏配置不再整机崩溃 + Agent 可复用已存机器(issue #48)+ 工作区压缩备份

Choose a tag to compare

@flymysql flymysql released this 08 Oct 05:34
· 182 commits to main since this release

工作区压缩备份 / 恢复(rw_backup / rw_backup_list / rw_restore)

新增

  • rw_backup —— 把远程工作区(或其子目录)打成 .tar.gz,可选拉到本机或两边都留。
    归档默认落在远端 $HOME/.dsh-remote/backups/<工作区><哈希>/,每个归档配一个
    .meta.json sidecar(工作区路径、条目数、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 个用例(archive 17、backup 31、backup-routes 20、
    backup-shell-syntax 4),全绿;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,
对孤儿链上的崩溃完全无效——所以坏配置才会每次都把整机带走。

修法:

  1. pool.js:client.connect(opts) 包 try/catch,同步 throw 转成正常的 promise 拒绝。
    一处改动同时治好三件事:不再崩溃、不再挂起、所有调用点(启动恢复 / test-connect /
    rw_connect)的 try/catch 恢复生效——坏配置现在就是一条普通错误提示;
  2. rw_connect 补全字段:新增 passphrase / useAgent / keyboardInteractive /
    hostKeyMode / name;upsert 时未提供的 secret 保留原值(以前只保 password,
    passphrase 会被默认空串抹掉);
  3. 复用已存机器:rw_connect(machineId=...) 直接用注册表里的整条记录(含钥匙串密码、
    已存 passphrase、跳板机、ssh-config 别名)并设为当前机器;新增 rw_machines 工具
    列出已存机器(id / 地址 / 认证方式旗标 / 别名解析 / 当前标记,只给旗标不给秘密值);
  4. 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',这个文件正是从缺口溜出去的)。