24 Commits
Author SHA1 Message Date
chick f499d02ca0 chore: 1.0.1(安装后的启动上限与下一步提示) v1.0.1 2026-09-18 01:10:58 +08:00
chick 9949cfe5b0 fix(install): 刚装完的服务会失败,单元要说清楚而不是每 5 秒重启一次
我在真内核上把安装序列完整走了一遍(容器里补了 useradd/systemctl 的替身,其余全是真的),
暴露出一个只有真跑才看得见的缺陷:

装完 → `install` 启动服务 → 但那时**还没有配对的手机**,而守护进程**故意**拒绝空的设备列表
(一个什么都不能授权的 agent 不该坐在那里显得健康)。于是 `Restart=always` + `RestartSec=5`
变成每 5 秒重启一次,永远,journal 里刷同一行。实测:exit 1,"devices is empty, so no phone
could ever be authorised"。

配对发生在安装**之后**(运维在节点上跑 `--pair`,然后在手机上配对),所以这不是异常状态,
而是流程的必经一步。修法两条:

- 单元加 `StartLimitIntervalSec=300` / `StartLimitBurst=10`:十次之后 systemd 放弃,单元进入
  `failed`——一个运维看得见、能处理的状态,而不是无限重启。
- `install` 的结束语改成说清这一步:服务会退出并停住,下一步是 `--pair`,用
  `systemctl status` 看状态。此前它说"installed and running",而那是假的。

同一轮演练确认了此前所有修复在真内核上成立:服务用户建了、配置与日志目录交给它了、配置对
该用户可读、单元来自二进制且内容一致、卸载按序拆干净(用户/目录/单元全无残留)。

Go 全包通过(新增一项守着"单元必须有启动上限,且 install 必须说出下一步")。
2026-09-18 01:09:15 +08:00
chick fc7a77c500 feat: 本仓库自己发布二进制
发布逻辑搬到这里,因为二进制从这里构建、这里拥有契约。simadmin-relay 的那个脚本改成
转发(一条命令,不是两份实现——两处知道怎么发同一批产物,就是两处要在注册表 API 变化时
一起改,这个仓库已经犯过一次)。

与本仓库的一条硬约束绑定:**必须公开可读**。安装命令由节点执行、节点没有凭据,而 Gitea
对私有仓库返回 404,所以节点上看到的失败长得像 URL 写错。脚本现在会检查并**拒绝发布**,
而不是发出一批拿不到的东西。

VERSION 提到 1.0.0,release body 里的安装片段更新为 enroll + install 两步。
v1.0.0
2026-09-17 23:51:35 +08:00
chick 3473816167 feat: 独立成仓库,自己拥有契约文件
agent 从 simadmin-relay 拆出来,作为公开仓库:安装命令要由节点执行,而节点没有凭据,所以
二进制必须放在匿名可读的地方,而 relay 源码与 IPA 继续私有。

拆出来之后有一处必须改,不是搬文件:`contracts/agent-policy.json` 原先由 relay 仓库拥有、
agent 侧是 vendored 副本(Makefile 从 `../contracts/` 拷、policy_test.go 断言两份相等)。
现在反过来——**agent 拥有策略**,因为 agent 是执行它的那一方,让它去拷贝一份住在别处的文件
本来就是别扭的。relay 保留 vendored 副本(它的限额从同一组数字推导),而两侧的一致性不再
靠构建期比较(跨仓库的构建期比较谁也做不了),改由运行时保证:agent 在握手时上报策略摘要,
relay 拿自己那份比对,在 `/v1/status` 里逐节点给出 `policyMatches`。这比原来的测试更强,
因为它比的是**实际部署的那两份**,而不是两个 checkout 里的文件。

`contracts/test-vectors.json` 同理:Go 实现生成它(`make vectors`),relay 的 Node 参考实现
与 app 的 Swift 测试从 vendored 副本重算。实测重新生成的结果与 relay 那份逐字节相同。

systemd 单元的 `Documentation=` 指向本仓库。`VERSION` 提到 1.0.0。

20 个提交的历史完整保留(filter-branch 按子目录抽取)。
2026-09-17 23:24:57 +08:00
chick 7d174a0261 fix(agent): 卸载时指出该去哪删节点,而不是一律说 agents.json
agent 不持有运维令牌——节点不是运维,一个能删掉邻居的被控节点比它解决的问题更糟——所以
它不能自己从中继上摘掉自己。此前卸载的结束语对所有节点都写"the relay's agents.json still
lists this node",而自注册的节点根本不在那里:它在中继的状态文件里,要从手机上删。运维照着
那句话去翻一个没有这条记录的文件。

现在节点在登记时把来历写进配置(`selfEnrolled`,带 `--code` 为真),卸载结束语据此给出
正确的那一句:

- 自注册的 → "在中继的登记状态里,去 App 的「中继节点」移除"
- 手工登记的 → "在 agents.json 里,删掉那条并重启中继"
- 配置读不出来 → 两句都提,因为读不到不是让卸载失败的理由

测试两项:卸载打印的是它被告知的那一句(且不再对自注册节点说 agents.json)、登记时正确
记录来历(带码为真、不带码不写这个字段)。

Go 全包、Node 141 项、Swift 121 项通过。
2026-09-17 19:12:32 +08:00
chick 934760f8e2 fix(agent): 补上安装的服务那一半——建用户、改所有权、装单元
上一轮那段"一键安装"是坏的,而且只在节点上坏。实测三段里漏了三件:

1. systemd 单元写着 `User=msa-agent`,而命令从没创建这个用户;
2. `enroll` 是 sudo 跑的,配置是 root 所有 0600,服务以另一个用户运行——读不到自己的私钥;
3. 单元从 `msa-agent.service` 这个仓库里的路径拷,而节点上没有仓库。

三段都"看起来对",因为它们各自是一行合理的 shell;错的是它们与单元之间的接缝。这正
是这个项目里反复出现的那一类缺陷。

现在有 `msa-agent install`:

- 建 `--system --no-create-home --shell /usr/sbin/nologin` 的用户(已存在则跳过);
- 建日志目录,并把配置、配置目录、日志目录 chown 给该用户;
- 把 systemd 单元写进去。单元**嵌在二进制里**(`go:embed`),不是从仓库路径拷的,也不是
  从 URL 下的——一个能发 unit 的服务器就能给节点塞一个后门 unit,那与"中继不可信"是同一
  条理由。`--print-unit` 可以在装之前把它打出来看;
- `daemon-reload` 并 enable/start。

幂等:每步先看已经是什么状态,所以升级或上次失败后重跑都安全。`--dry-run` 打印计划且什么
都不改,并且在缺二进制或配置时**不拒绝**——"还没装"正是会去跑 dry-run 的状态,它只把缺的
东西指出来。chown 走 Runner 而不是 os.Chown,这样整条序列都能在无 root 的情况下测。

install 与 uninstall 共用同一份 `Paths`:装了什么没卸掉会在机器上留一份凭据,卸了什么没装
过会在新节点上失败。单元也一样,`TestVendoredUnitMatchesSystemd` 守着嵌入副本与
`systemd/msa-agent.service` 不漂移。

手机上的安装命令相应简化:`install` 那一步取代了原先手工的 useradd/install/systemctl 三行,
卸载那段也先给 `--dry-run`。

测试新增五项:单元不漂移、install 满足单元的全部要求(用户、所有权、单元来自二进制而非仓库
路径)、install dry run 什么都不改、缺二进制或配置时拒绝且一条命令都不跑、dry run 在全新
节点上不拒绝而是指出缺什么。变异验证:去掉 chown、去掉建用户、让 dry run 执行命令——三处都
让对应测试失败。

Go 全包、Node 134 项、Swift 116 项通过。
2026-09-17 17:55:57 +08:00
chick a4439331e3 feat(release): 把 agent 二进制发到 Gitea Releases
安装命令要成立,节点就得有个地方取二进制——节点上没有 Go 工具链,此前唯一的办法是在
另一台机器上编译再 scp,而这一步是安装命令表达不了的。

scripts/release.sh:编译两个 ARM 目标、生成 SHA256SUMS、建 release、上传三个附件。
令牌从 git 的 credential helper 读,所以进程表和 shell 历史里都没有它;也可以用
MSA_GITEA_TOKEN 覆盖。重跑会复用已存在的 release 并替换同名附件——修完东西重发时,
否则会静默留着上一个二进制。--dry-run 只编译和算校验值。

Makefile 的 VERSION 从 0.1.0 提到 0.3.0,并且它现在是二进制里真实带着的版本
(`msa-agent version` 与启动日志都会报),也是 release 的 tag。

已发布 v0.3.0:下载回来验过,sha256 与 SHA256SUMS 一致,是 ARM aarch64 静态 ELF,
且带着新的 enroll/uninstall 子命令。

Go 全包、Node 134 项、Swift 107 项通过。
2026-09-17 13:37:24 +08:00
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 5feb5bfe70 feat(agent): 配对尝试记录来源 IP
"十次失败"说明窗口关了,"十次来自 10.0.0.7 的失败"说明有人在试——后者才是可行动
的那句话,而它是尝试次数上限无法提供的信息。每次尝试与每次成功现在都带 peer。

只记 IP 不记端口:端口每次都是新的临时号,带上会让同一台机器的两次尝试看起来像
两台,而"有几台机器在试"正是要回答的问题。

测试断言失败尝试带上了 peer,且是去掉端口后的主机。变异验证:去掉 peer,测试失败。

Go 全包、Node 120 项、Swift 107 项通过。
2026-09-17 07:58:34 +08:00
chick b5f548f1ad feat(agent): 配对窗口可显式绑定地址,默认仍是内网可达
配对窗口是 agent 唯一持有的监听端口,而它授予的是设备信任:谁能带节点密码打到
/provision,谁就拿到一把 agent 会听命的密钥。此前它固定绑所有接口,且没有任何办法
收窄——一台本身能从公网路由到的节点(公网 VPS 上的 SimAdmin、放在非可信网络里的
设备)在窗口开启期间就把这个面暴露给了整个互联网。

现在加 --pair-bind,只接受 IP 字面量。不接受主机名是刻意的:绑定时解析名字意味着
监听地址取决于 DNS 当时说什么,那不是运维做过的决定。

默认值仍是绑所有接口,这个我改过一次又改回来了,原因值得记下来:我先把默认设成
127.0.0.1 并写了理由,然后发现配对按设计走内网、手机要直接打到节点,回环监听从
另一台机器根本连不上——那不是一个加固措施,而是一个永远不工作的流程,用户对这个
功能的第一次体验会是"连接被拒"且没有解释。所以默认保持内网可达,把"节点能从公网
路由到时请收窄"写进部署文档,并在 --pair-bind 上给出显式入口。

测试断言默认值同时满足两件事:是 IP 字面量(不是名字),且不是回环(手机要能连)。

Go 全包、Node 120 项、Swift 107 项通过。
2026-09-17 07:47:25 +08:00
chick 0f983f046a fix(agent): 读上限要容得下整批轮询结果
同一类缺陷的第四处,也是最难发现的一处,因为它在最繁忙那条接缝的另一侧。agent
限制自己从中继读多少,而那个上限必须容得下中继**合法能发**的最大东西:一整批
轮询结果,即每个邮箱槽一个信封。原值 1 MiB 是"给宽一点"拍出来的,没有任何推导;
而按策略上限算,满邮箱的一批约 4 MiB。于是 agent 会拒掉一个中继答得好好的轮询,
然后重试进同一个拒绝——表现为持续的连接抖动,日志说"relay response exceeds
1048576 bytes",而中继那边一切正常。

现在取 8 MiB,并把推导写清楚:中继信封上限 × 邮箱深度,留一档余量。两侧的关系由
test/limits.test.js 钉住(常量从 Go 源码读出),因为这是跨语言、跨进程的一对数字,
任何一侧单独都看不到。

变异验证:改回 1 MiB,测试失败。

Node 119 项、Go 全包、Swift 101 项通过。
2026-09-17 07:21:29 +08:00
chick 274e25961a fix(agent): 手机已经放弃的请求不再执行
中继答 504 并说"可能已经执行了,重试前请先确认"——这句话诚实的前提是它保持为真。
批次是串行处理的,所以满邮箱里第八个信封可以在它被签名之后 8 个上游超时才轮到;
那时手机早已被告知失败、用户可能已经重试。再执行它,就把一次诚实的"未知"变成了
一条重复发出的短信。

现在 agent 在动任何东西之前先比对自己的截止时间(策略里的 requestTtlSeconds),
超龄一律以 expired 拒绝并落审计。判定放在新鲜度检查之后、策略判定之前:它比签名
更便宜,而且一个过期请求无论路径是否在白名单里都不该执行。

为了让这条规则**可达**,策略新增一条断言:TTL 不得大于时钟偏移窗口。否则新鲜度
检查会先触发,这条规则永远不会被执行——而"永不触发的规则"是出事时才会被发现坏了
的那种东西。

单个上游调用的超时也移进了策略(agentUpstreamTimeoutSeconds)。"中继期限必须长于
一次上游调用"这个比较,与二进制里的常量是比不了的;现在两侧从同一个文件读,策略
加载时就能拒绝一组自相矛盾的数值。原来那个 20s 常量保留为默认值的来源,不再硬编码。

测试两项:一个请求在期限内被转发、一个超龄请求被拒且**没有被转发**(计数按路径,
否则首次转发前的 /api/auth/status 探测会让断言以错误的理由通过);另一项断言策略
本身满足上述两个关系。变异验证:关掉判定,第一项失败。

Go 全包、Node 119 项、Swift 101 项通过。
2026-09-17 07:12:44 +08:00
chick 9608a7ebc0 fix(agent): 审计写入失败不再静默
审计日志是证据而不是控制手段:写失败不能让请求失败,否则磁盘写满会变成一次远程
拒绝服务——已经执行成功的指令会因为日志追加不上而开始报错,那是坏错了地方。

但它也不能消失。静默丢审计条目是那种"运维以为自己有记录、其实没有"的失败,而这份
日志的全部意义就是事后能回答"谁在什么时候要求了什么"。现在写失败会进 agent 自己的
日志,磁盘写满表现为一行反复出现,而不是一片空白。

顺带把 Close 之后的语义写清楚:Close 会丢掉 sink,此后的写入返回 nil——这是刻意的,
因为一个正在关闭的进程与在途请求赛跑时,为每一个恰好到达的请求报一次失败是噪声
而不是信息。这条差别在 agent 日志里看得见。

测试用真实失败路径(写一个已关闭的文件)而不是填满文件系统,并断言 exec.record 把
它报了出来。

Go 全包、Node 112 项、Swift 101 项通过。
2026-09-17 01:52:59 +08:00
chick 3a9eec5de2 fix(agent): 危险写限流改为滑动窗口,与中继侧同一算法
节点侧的限流器此前也是固定窗口(按自然分钟重置计数器),而注释声称的性质是
"任意 60 秒内至多 N 次危险写"——与中继侧那个缺陷完全相同,只是这一侧还没有
人测过它。注释同样以该性质为由选择了固定窗口,所以代码与自己的理由相反。

现在两端是同一个算法:保留被放行的写时间戳,窗口满时最早那笔离开窗口才放行。
这两个限流器从两侧约束同一个操作(节点自己、以及中继对设备的预算),一侧比
另一侧宽一倍是那种要等到事后复盘才会被发现的不一致,所以保持一致比任何一侧
单独正确更重要。

测试里有一处值得记下来:这个性质不能按"花完预算、过一分钟、再花一次"来测——
那样固定窗口也会通过,因为所有写都过期之后两种实现没有分歧。必须把预算花在
自然分钟边界**之前**,再断言边界之后一笔都放不进去。第一版就是这么写错的,
变异验证(把实现改回按自然分钟重置)没有失败,才发现测试本身没在测它。

内存代价同中继侧:每节点至多 N 个时间戳,而不是一个整数。

Go 全包、Node 112 项、Swift 101 项通过。
2026-09-17 01:46:32 +08:00
chick 72b44398cd fix(relay): 每个节点的会话与挑战数量设上限;agent 侧加 fuzz
设计假定节点是敌对方("被攻陷的节点不该能读到另一个节点的流量"),而中继为它
保存两份状态:会话(TTL 30 分钟)与挑战(TTL 60 秒)。两者此前只在**过期**时被
清理,所以一个循环握手的节点可以让它们无界增长——实测 5000ms 中继时间内 5000 次
握手留下 5000 个存活会话,且没有任何东西阻止接下来的 5000 次。

诚实值是 1:每节点只有一个连接,注册表也只保留一个挂起的轮询。所以上限取
每节点 4 个会话、16 个挑战,超出时淘汰最旧的而非拒绝——"最新一次握手获胜"已经是
注册表对连接本身的规则,拒绝会让一次合法的重连被自己早先的尝试挡住。

写这个上限时踩到并修掉了一个自己造的 bug:第一版保留最旧、删掉刚加入的,于是
循环握手的节点会被自己下一次握手淘汰掉最新会话,每次轮询都 401。只断言"表很小"
的测试抓不到它——能抓住它的是"最新会话仍然可用"这条断言。

agent 侧新增 fuzz(agent/internal/agent/fuzz_test.go):Handle 是唯一一个输入来自
被假定敌对一方的函数,而 agent 是单进程,这里的一次 panic 不是一次坏请求而是节点
下线直到 systemd 拉起。断言故意弱——不 panic,且要么返回错误要么返回合法 JSON——
拒绝逻辑由旁边的表驱动测试覆盖。跑 45 秒、134 万次执行,无失败;测试内跑的是种子
语料(Go 的常规做法,fuzztime 只在显式 -fuzz 时生效)。

Node 112 项、Go 全包、Swift 101 项通过。
2026-09-17 01:29:55 +08:00
chick e2d4ea317c fix(agent): 设备列表在运行中重读,使吊销无需重启即生效
文档写的吊销路径是:在节点上跑一次 `--pair`,POST /destroy,节点的下一次
握手就把该 bearer 从中继路由里摘掉。这条路径此前不工作:daemon 只在启动时
读一次设备列表,于是它既继续接受被吊销手机的签名,又在下次握手时把那个
bearer 重新报给中继——吊销被节点自己撤销。唯一能让它生效的办法是重启
服务,而文档没有这么说。

各组件单独看都是对的:文件确实被写空了,push 确实发出去了,中继确实应用了。
坏的是"运行中的进程再也不看那个文件"这件事,而单元测试看不见它。

现在 internal/config/live.go 的 LiveDevices 每个轮询周期重读一次配置:按
mtime、大小与 inode 判断变化(一次 stat,未变则零 I/O),变了就强制重新
握手,让中继拿到新的集合。inode 是必要的:Save 走临时文件加重命名,而
"配对后立刻 destroy"这种连续写入会落在同一个时钟刻度里,只有 inode 能区分。

读取失败保留当前集合而不是清空。清空是最坏的结果——一次瞬时文件系统错误会
把该节点所有已配对的手机锁在门外,而用户必须跑到节点上才能修。

测试:
- internal/config/live_test.go 六项:destroy 与新增设备都能被察觉、未变化时
  不误报(误报会每个周期强制握手,而每次握手都会顶掉挂起的 poll)、同一秒内
  的变化、读取失败保留集合、不可用的密钥被拒。
- test/revocation.test.js 跑真实二进制:在它脚下改配置文件,断言中继侧的设备
  数归零且该 bearer 不再可路由,节点仍在线。它需要 go 与 openssl,缺任一则
  跳过并说明原因。变异验证:去掉重读,测试失败。

顺带修掉一个只在 sudo 配对时暴露的缺陷:config.Save 用临时文件加重命名,
在 root 下会把配置文件变成 root 所有,而 systemd 单元以 msa-agent 用户运行,
于是配对看起来成功、节点重启后读不到自己的配置。现在重命名前保留原文件的
uid/gid,无法保留时报错而非警告。

文档同步:部署文档说明 destroy 在一个轮询窗口内(默认 24s)生效且无需重启;
架构文档把"仍需内网"改写为准确的边界——本地吊销已修好,不碰节点的远程吊销
(中继下发受信集快照)仍未做,那才是丢手机场景真正的短板。

Node 94 项、Go 全包、Swift 99 项全部通过。
2026-09-16 23:43:45 +08:00
chick af738a5bf9 fix(relay): 限额改为从共享策略推导,修掉信封上限过小导致的合法请求被拒
中继此前自带一份限额常量,并在注释里声称"与共享策略一致",而没有任何东西
校验这句话。其中两条已经漂移成真实缺陷:

1. 信封上限 64 KiB 是**信封**的上限,而信封约为其承载 body 的 1.8 倍(body
   先 base64、再 AEAD、再 base64)。于是 body 仍在 agent 的 256 KiB
   maxRequestBytes 之内的请求,在中继处被拒为 relay_payload_too_large——一个
   在局域网上能成功、走中继就失败的操作。响应方向同理:节点返回合法的大
   body,应答在中继被拒,手机白等到超时。实测 120 KiB 的 body 就被拒。
2. mailboxDepth 只靠注释与策略"相等",改任一侧都不会有人发现,而这个深度
   是 agent 重放缓存容量的依据。

现在 relay/src/limits.js 从 contracts/agent-policy.json 推导全部限额,并在
文件不可读、缺 limits、数值不可用时**拒绝启动**:中继无法区分"诚实的大请求"
与"内存耗尽尝试",上限是它唯一的工具,凭空发明一个等于在强制一个没人选过的
数字。推导结果:信封上限 513037 字节、深度 8、请求期限 30s、轮询窗口 24s。

策略摘要在健康检查里公开并逐节点对比:/healthz 返回中继自己那份的摘要,以及
每个在线节点上报的摘要与 policyMatches。它只上报不强制——中继读不到节点上的
文件,声称不一致就拒服务会把"节点跑着旧二进制"变成一次故障;而策略本来就是
agent 在强制。没有上报的节点显示 null 而不是 false,"没说"与"说了不一样的"
是两个不同的发现。

顺带修掉一个只在 sudo 配对时才暴露的缺陷:config.Save 用临时文件加重命名,
在 root 下会把配置文件变成 root 所有,而 systemd 单元以 msa-agent 用户运行,
于是配对看起来成功、节点重启后读不到自己的配置。现在重命名前保留原文件的
uid/gid,且无法保留时报错而非警告。

测试新增 test/limits.test.js(11 项),分三层:推导与策略文件一致、推导出的
上限确实容得下用真实 wire 代码构造的最坏信封(这是旧上限失败的那次测量)、
以及经真实 HTTP 面把合法大请求送过去。另有健康检查的摘要上报与 null 语义。
四处断言都做了变异验证:把上限改回 64 KiB,四项测试失败。

Node 93 项、Go 全包、Swift 99 项、端到端全部通过。
2026-09-16 23:24:28 +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 f8277791a9 feat: 端到端测试(真 agent + 真中继 + TLS),并修三处只有它能发现的缺陷
新增 test/e2e.test.js:编译并运行 Go agent,经 TLS 接到真中继,由模拟手机发起
签名请求,再断言节点实际收到的内容(含 audit 日志不含 body/密码、危险写 body
逐字节一致)。缺 go/openssl 则跳过并说明原因。

它一次抓出三个单元测试各自都通过、合起来却坏了的问题:

1. transport.Client 把会话 token 写进 c.token,而 c.token 是 bearer——Poll/Result
   因此用会话 token 当 bearer 发出,中继一律 401。现象是 agent 连上后无限重连,
   日志说'凭据被拒'。现在两个凭据分成两个字段,并加测试断言握手带 bearer 不带会话、
   轮询带会话。
2. main.loop 每个周期都重新握手。中继每个节点只保留一个会话,新握手会顶掉旧的并
   丢弃其挂起的轮询,于是两次握手之间到达的请求被投进已不存在的会话、以'agent 离线'
   超时——而 agent 全程在线。改为只在会话失效/传输失败时重新握手。
3. auth.registerDevices 把'已存在'一律跳过,随后又被'本轮未声明'的回收循环删掉,
   所以节点的第二次握手会把自己注册的设备全部注销。改为本节点已持有即确认(幂等)。

另:transport 支持 relayCAFile(PEM 钉 CA,替代系统信任库,不是关闭校验),
测试用自签证书 + 钉 CA 走真实 TLS 校验路径。
2026-09-16 20:17:01 +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
chick 3de2736882 feat: 规范化 JSON 与三语言互验加固;app-routes 生成器
- wire.CanonicalJSON 自己写字符串转义:Go 编码器即使在关闭 HTML 转义时仍会转义
  U+2028/U+2029,而 Node 与 Foundation 输出字面量;两处拼写解码结果相同,
  因此签名不同却看不出原因。孤立代理项改为显式拒绝(Go 会替换成 U+FFFD、
  Node 保留转义文本、Foundation 直接解析失败,三种行为只能取交集为空)
- 对象键排序按 UTF-8 字节序(Go 的 sort.Strings 语义);Node 参考实现改用
  Buffer.compare,因为 Array.sort 的 UTF-16 码元序在星形平面边界上与之相反
- test-vectors.json 新增转义、键序与必须拒绝的输入;Go/Node 双向断言
- 新增 scripts/export-app-routes.swift 生成 contracts/app-routes.json,
  补上此前 vendored 路由表靠手抄的一环
- README 记录 iOS 侧向量校验为何是'互相验证'而非'逐字节相同'(CryptoKit
  的 Ed25519 签名随机化)
2026-09-16 18:57:06 +08:00
chick 78e0d303ab feat: 配对管理端点与中继侧设备注册上报
- agent --pair:临时监听 :3001 仅开放 /provision 与 /destroy,以节点密码为共享秘密;
  窗口由三个独立条件关闭(首次成功配对 / 失败尝试 10 次 / 10 分钟超时),配对成功后
  管理监听即关(设计 §7 第 4 步)
- agent 为每台配对手机生成中继 bearer(config.Save 0600 原子写;启动时为手写条目补发)
- transport.PushDevices:握手成功后全量上报本节点受信设备集;空集是吊销声明而非无操作
- relay /v1/agent/devices:agent 会话后鉴权的设备注册;全量替换本节点条目;
  操作员 MSA_DEVICES 为 static 不可被覆盖;跨节点 id 不可抢占
- 中继设备注册替代了 §11 第 6 项的一部分:节点 destroy + 重握手即可在路由层吊销
- 配置:provisionSecret 字段(无密码保护节点需显式设置才可配对);Validate(pairMode)
- 测试:pair 流程 8 项、设备注册 6 项、transport 1 项;文档 §7 实现修订注记与 README 状态
2026-09-16 11:43:52 +08:00
chick 443af20e0a feat: agent 本机 SimAdmin 登录与会话维持;中继设备侧限流与 agent Retry-After
- runtime.NodeAuth:agent 自查 /api/auth/status,需要时自行 POST /api/auth/login 并持
  simadmin_session Cookie 转发;错误密码进入 60s 冷却(对齐控制台 ensureAuthenticated),
  节点回 401/403 即失效重探,SimAdmin 重启无需重启 agent
- 节点密码进 agent 配置(nodePassword),永不经中继、永不下发到手机;登录路径仍被策略
  挡在电话可达面之外(TestAuthPathsAreUnreachable)
- 新拒绝分类 node_auth_failed:与 upstream_error 区分,手机可提示'该节点需重新配置'
- 中继:/v1/request 每设备固定窗口限流(默认 30/分钟),未鉴权调用不消耗预算
- transport:解析 Retry-After 并入退避;main loop 对 429 直接遵从中继指示
- contracts/envelope.schema.json 补 InnerResponse.outcome 字段
- 设计文档与 README 同步:WSS 改为长轮询的实现修订、响应方向已临时×临时、P2/P3 状态
2026-09-16 09:15:25 +08:00
chick 54d9696a4d baseline: P1 契约、agent 核心与中继投递(既有工作首次入库) 2026-09-16 08:41:13 +08:00