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 |
|