Files
msa-agent/cmd/msa-keygen/main.go
T
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

138 lines
5.4 KiB
Go

// Command msa-keygen mints the key material and identifiers a node needs, and prints a config file
// skeleton to stdout.
//
// It exists because the agent's config cannot be written by hand: the two private keys are raw
// 32-byte values that must be base64, and a node id, an agent token and a device id all have to
// match patterns the agent and the relay both enforce. An operator generating those with openssl and
// a text editor will eventually produce a file that fails at startup with a validation error whose
// cause is a character in the wrong place.
//
// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing
// time, and never leave it — a tool that could mint a device key would be a tool that can impersonate
// a phone.
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"fun.chick/msa-agent/internal/wire"
)
func main() {
var (
nodeID = flag.String("node", "", "node id (nd_...); generated when empty")
relayURL = flag.String("relay", "https://relay.example", "relay base URL, https")
localPort = flag.Int("port", 3000, "the local SimAdmin port on 127.0.0.1")
auditPath = flag.String("audit", "/var/log/msa-agent/audit.log", "audit log path")
agentToken = flag.String("token", "", "relay bearer for this agent; generated when empty")
secret = flag.String("provision-secret", "", "pairing secret for an unprotected node; generated when empty")
write = flag.String("write", "", "write the config here instead of stdout (mode 0600)")
)
flag.Parse()
// The signing key is Ed25519 for challenge-response at the relay and for signing responses.
signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader)
check(err)
// The key-agreement key is raw X25519: clamping is the curve library's job, and a stored key
// that had already been clamped would be a second, non-standard encoding to keep in sync across
// three languages.
kxKey := make([]byte, wire.KeyBytes)
_, err = rand.Read(kxKey)
check(err)
id := *nodeID
if id == "" {
id = "nd_" + randomHex(5)
}
token := *agentToken
if token == "" {
token = randomHex(32)
}
// The pairing secret is generated even though it is only needed for an unprotected node: a
// config without one cannot pair at all, and discovering that at pairing time means another trip
// to the node. When the node *is* protected, `nodePassword` takes precedence over this value, so
// carrying it is harmless.
pairingSecret := *secret
if pairingSecret == "" {
pairingSecret = randomHex(16)
}
config := map[string]any{
"nodeId": id,
"relayUrl": *relayURL,
"agentToken": token,
"localPort": *localPort,
"signKey": base64.StdEncoding.EncodeToString(signingKey),
"kxKey": base64.StdEncoding.EncodeToString(kxKey),
// The node password is intentionally absent: it is the operator's, and it is the one secret
// this tool must not invent. An unprotected node needs no value; a protected one needs the
// real password or every command fails with node_auth_failed.
"auditPath": *auditPath,
// Only used to authorise the pairing window on an unprotected node. Delete it after pairing
// if you like; nothing else reads it.
"provisionSecret": pairingSecret,
// Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only
// path that should ever be able to introduce a trusted phone.
"devices": []any{},
}
encoded, err := json.MarshalIndent(config, "", " ")
check(err)
encoded = append(encoded, '\n')
// The relay registration carries the agent's *public* signing key as well as the bearer token.
// Both are required: the token is only an abuse-control handle, and the signature over the relay's
// challenge is what proves the caller holds the node's private key. A registration without the
// key makes the relay refuse to start rather than start and reject every node, which is the
// failure mode that shipped when the key was omitted.
registration, err := json.Marshal(map[string]string{
"nodeID": id,
"token": token,
"signKey": base64.StdEncoding.EncodeToString(signingPublic),
})
check(err)
if *write == "" {
// The relay's own registration is printed alongside, because the two files have to agree on
// the node id and token and copying them by hand is where that agreement breaks.
fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json")
os.Stdout.Write(encoded)
fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n")
fmt.Fprintf(os.Stderr, "%s\n", registration)
fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", pairingSecret)
fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n")
return
}
if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil {
check(err)
}
// 0600: this file holds the agent's private keys and the node's password.
if err := os.WriteFile(*write, encoded, 0o600); err != nil {
check(err)
}
fmt.Printf("wrote %s (mode 0600)\n", *write)
fmt.Printf("relay registration: %s\n", registration)
}
func randomHex(bytes int) string {
raw := make([]byte, bytes)
_, err := rand.Read(raw)
check(err)
return hex.EncodeToString(raw)
}
func check(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "msa-keygen:", err)
os.Exit(1)
}
}