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 项全部通过。
This commit is contained in:
chick
2026-09-16 23:43:45 +08:00
parent af738a5bf9
commit e2d4ea317c
5 changed files with 425 additions and 7 deletions
+28 -7
View File
@@ -140,7 +140,10 @@ func run(configPath string, checkOnly, once bool) error {
if err != nil {
return err
}
devices, err := deviceSet(cfg.Devices)
// The device set is live rather than a startup snapshot, because revocation has to work without
// a restart. See LiveDevices: a daemon holding the list it read at boot re-pushes a destroyed
// device to the relay on its next handshake, which silently undoes the revocation.
devices, err := config.NewLiveDevices(configPath)
if err != nil {
return err
}
@@ -200,12 +203,15 @@ func run(configPath string, checkOnly, once bool) error {
if once {
return cycle(ctx, node, client, pol)
}
return loop(ctx, node, client, pol, registrationsFrom(cfg))
return loop(ctx, node, client, pol, devices)
}
func registrationsFrom(cfg config.File) []transport.DeviceRegistration {
out := make([]transport.DeviceRegistration, 0, len(cfg.Devices))
for _, device := range cfg.Devices {
// registrationsFrom renders the node's paired set for the relay's routing table. It is called on
// every handshake and after every detected config change, so a revocation reaches the relay without
// anyone touching the node again.
func registrationsFrom(devices []config.Device) []transport.DeviceRegistration {
out := make([]transport.DeviceRegistration, 0, len(devices))
for _, device := range devices {
out = append(out, transport.DeviceRegistration{App: device.ID, Token: device.RelayToken})
}
return out
@@ -244,7 +250,7 @@ func relayRoots(path string) (*x509.CertPool, error) {
//
// Errors are classified here rather than handled per call site, because the situations look identical
// from inside a function and need different responses: retry now, re-authenticate, or wait.
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error {
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, live *config.LiveDevices) error {
backoff := minBackoff
authenticated := false
for {
@@ -252,6 +258,19 @@ func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol
return nil
}
var err error
// A config change is noticed here, once per cycle. The common case is one stat call.
changed, refreshErr := live.Refresh()
if refreshErr != nil {
// Logged and survived: a transient read failure must not become "nobody is paired", which
// would lock the user out of a node whose configuration is perfectly fine.
slog.Warn("could not re-read the device list; keeping the current one", "error", refreshErr)
} else if changed {
// Forcing the handshake is what re-asserts the set at the relay: a revocation that only
// took effect locally would leave the revoked bearer routable, and the phone would get
// "unknown device" from a node that had stopped trusting it rather than a clean refusal.
slog.Info("device list changed on disk", "devices", live.Len())
authenticated = false
}
if !authenticated {
if _, err = client.Hello(ctx, version, pol.SHA256()); err == nil {
// The relay holds no device state across restarts, and it must never trust a device
@@ -259,7 +278,7 @@ func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol
// the handshake is what makes that true without a second channel: the node's paired
// set, including any revocation, is re-asserted every time a session is established.
// Empty is pushed too, because "nobody" is exactly the claim revocation needs.
err = client.PushDevices(ctx, devices)
err = client.PushDevices(ctx, registrationsFrom(live.Devices()))
authenticated = err == nil
}
}
@@ -339,6 +358,8 @@ func decodeKey(value string, size int, field string) ([]byte, error) {
return raw, nil
}
// deviceSet is used by --check and by tests, where a fixed snapshot is what is wanted. The daemon
// uses config.LiveDevices instead, which is the same list re-read whenever the file changes.
func deviceSet(devices []config.Device) (agent.StaticDevices, error) {
set := make(agent.StaticDevices, len(devices))
for _, device := range devices {