Commit Graph
3 Commits
Author SHA1 Message Date
chick 62b322af39 feat(agent,relay): 节点自注册与一键卸载
节点侧现在有两条命令覆盖整个生命周期,中继侧有了一条让节点不必被人工登记就能加入
的通道。

agent 新增两个子命令:

- `msa-agent enroll --relay <url> [--code <code>]` 铸造密钥与标识并写配置。它和
  `msa-keygen` 共用 internal/enroll,所以两者不会对"配置长什么样"产生分歧——区别只在
  运行位置:keygen 需要 Go 工具链,而节点上只有交叉编译好的二进制。带 --code 时它自己
  向中继登记,顺序是先登记再落盘:为"中继还不认识的节点"写下的配置会永远重试 401,而
  运维无法把这种情况和网络故障区分开。
- `msa-agent uninstall [--dry-run] [--keep-logs]` 按唯一一个不会留下悬挂状态的顺序拆除:
  先清设备并告诉中继,再停服务、删单元、删配置目录、删日志、删用户。顺序是重点——先删
  节点会让中继继续向一台已经不存在的节点投递,而"一键卸载"此前根本不存在,尽管安装会
  往宿主机放六样东西,其中配置里还有节点密码。

  写这个时踩到一个值得记的坑:第一版把每个动作都写成"先 log 再执行",于是 --dry-run
  会真的停掉服务——一个不是 dry run 的 dry run。现在每个副作用都 gate 在 step() 的
  返回值上,并有测试断言 dry run 一条命令都不跑。

中继新增自注册,默认关闭:

- `POST /v1/enroll/code`(运维令牌)铸造一次性码,15 分钟、最多 10 个未使用。
- `POST /v1/enroll` 节点用码登记自己。码先兑换再校验登记体,所以用垃圾请求探测码是否
  有效的尝试会烧掉它。
- 登记只授权"加入"这一件事。中继仍然读不到内容、伪造不了应答、下发不了命令——最坏情况
  是让一个不该出现的节点出现,也就是拒绝服务,正是设计已经接受的边界。
- agents.json 的条目优先于自注册状态,所以运维永远能用一个 diff 覆盖或吊销自注册的节点;
  反过来,节点不能覆盖运维条目(被拒绝,400)。
- 自注册状态要落盘,这是中继第一次有它必须记住的东西。中继此前无状态是它的一条性质,
  这里明确让掉了:忘记已登记的节点会让它们全部 401,而症状看起来像 token 坏了。状态文件
  原子写、损坏时拒绝启动(而不是静默变成空机群),systemd 单元用 StateDirectory 给它一块
  可写目录,其余文件系统仍是只读。

测试:internal/enroll 五项(两份产物必须描述同一个节点、密码只在给出时写入、给定标识
不被重新生成、两次铸造不同、缺输入被拒),internal/install 七项(删干净、顺序、中继不可
达仍完成、dry run 不动任何东西、保留日志、缺件不中止、计划列出每条路径),
test/enrollment.test.js 十四项(流程、码单次、垃圾请求烧码、默认关闭、只有运维能铸造、
不能覆盖运维条目、可重复登记、重启后仍在、运维优先、损坏状态拒绝启动、码过期与清扫、
铸造循环不涨内存、登记体校验、状态文件不含私钥)。变异验证:去掉码的单次性、去掉运维条目
保护、让 dry run 执行命令、先删配置再通知中继——四处都让对应测试失败。

Go 全包、Node 134 项、Swift 107 项通过。
2026-09-17 13:28:32 +08:00
chick 09ccc67799 fix(relay): 校验 agent 握手签名、判定 agent 断线、保住跨重握手的邮箱
三处都是"每个组件各自正确、合起来坏了"的接缝缺陷,其中第一处按部署文档
配置时必然命中。

1. 中继没有校验握手签名的能力。校验器被设计成 agent 条目上的 verify 回调,
   而所有测试都注入了桩,于是唯一真实存在的校验器就是那些桩:按
   docs/deployment.md 写的配置里没有它,第二次 hello 抛
   "agent.verify is not a function",对每台节点回 500。现在配置只接受
   signKey(base64 Ed25519 公钥),中继自己验签,能验不能签;密钥缺失、
   长度不对或非规范 base64 一律拒绝启动——一个启动后拒绝全机群的"健康"
   中继比启动失败更难查。msa-keygen 的输出与部署文档同步补上该字段。

2. agent 断线后中继永远认为它在线。此前只有进程关闭才 detach,而节点被杀、
   断电、拔网线留下的只是一个关闭的 socket:节点会永远在线,手机的每条请求
   白等 30 秒并收到"节点可能已执行"——实际没有任何东西在监听。现在长轮询的
   socket 断开即判定离线(比对 session token,避免陈旧连接顶掉新会话),
   实测从 504/30009ms 变为 503/5ms。

3. 重新握手丢弃邮箱。attach() 整体替换节点记录,连带丢掉已排队的信封,
   于是重连窗口内到达的请求被静默丢弃、手机 5 秒后收到"节点离线",而节点
   其实刚连上。重握手在每次会话过期与每次重连都会发生,是常规路径而非边角。
   现在邮箱与每个信封的 handover 期限跨重握手保留。

同时修掉同一节点第二个长轮询孤儿化第一个的问题:两个 poll 共用槽位,后到的
覆盖先到的 resolve,前一个 promise 永不 settle、HTTP 请求一直挂着。现役
agent 串行轮询碰不到,--once 与守护进程同跑就会命中。现在每个 poll 有自己的
槽位,被顶掉的那个立刻以空结果返回。

测试新增三个文件,并全部改用真实密钥(verify 桩已消失):

- test/config.test.js 只经 start() + 环境变量启动中继并完成握手。第一个缺陷
  能存在,正是因为没有任何测试走这条装载路径——e2e.test.js 自己构造
  createRelay 参数,把配置装载整个绕过了。
- test/liveness.test.js 七项节点生死与重连性质,两个方向的错误都断言:
  漏判(死节点永远在线)与误判(陈旧 socket 顶掉新会话)。
- test/hello.test.js 把握手签名的字节直接与 Go 源码里的字面量比对,并断言
  NUL 分隔符两侧一致——这是 relay 与 agent 之间唯一没有共享代码可依赖的地方。

四处修复都做了变异验证:改回旧行为后对应测试必失败。

Node 82 项、Go 全包、Swift 99 项、端到端(真编译运行 Go agent 经 TLS 接真中继)
全部通过。
2026-09-16 22:55:49 +08:00
chick 1f46b7000d feat: 部署文档与单元文件;P6 渗透自查并修复一处真实缺陷
- docs/deployment.md:中继(反代要点含长轮询超时)、agent(msa-keygen 生成配置)、
  配对窗口、按拒绝原因分流的排障表
- agent/cmd/msa-keygen:生成节点密钥、nodeID、agent token 与配对秘密,打印中继注册条目;
  不生成设备密钥(那是手机的事,能生成就能冒充)
- relay/systemd/simadmin-relay.service:保护指令与 agent 单元的差异逐条写明
  (无 MemoryDenyWriteExecute,因为 V8 JIT 需要 W+X 页)
- test/adversarial.test.js(12 项):回环硬校验(结构性断言中继无出站路径)、
  重放与新鲜度的责任划分、邮箱深度与 handover、设备注册越权、信封校验边界
- 修 registry.resolve 未校验 nodeID:pending 表只按 reqID 索引,被控节点知道
  另一节点的在途 reqID 即可抢先应答使其变 orphan,合法应答被丢弃
- .gitignore 排除交叉编译产物
2026-09-16 19:20:59 +08:00