Files
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

228 lines
7.5 KiB
Go

package config
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// liveConfig is a config whose device keys are real Ed25519 public keys.
//
// The shared `valid()` fixture uses "AAAA", which is fine for the loader's shape checks — it does not
// verify key sizes — but LiveDevices parses the keys, because a device it cannot build a public key
// for is a device it must not silently trust. So these tests need a usable key, and the generator
// keeps them from depending on a literal that would rot.
func liveConfig(t *testing.T) File {
t.Helper()
cfg := valid()
public, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
cfg.Devices = []Device{{
ID: "ap_5b1d0000",
SignKey: base64.StdEncoding.EncodeToString(public),
RelayToken: strings.Repeat("d", 48),
}}
return cfg
}
// The property these tests exist for: a device destroyed on the node stops being trusted by the
// running daemon, without a restart. Before this, the daemon held the list it read at startup and
// re-pushed it to the relay on every handshake, so a revoked phone stayed authorised at the node and
// stayed routable at the relay — the documented revocation path did not work.
func TestLiveDevicesSeesAReloadedList(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := Save(path, liveConfig(t)); err != nil {
t.Fatalf("Save: %v", err)
}
live, err := NewLiveDevices(path)
if err != nil {
t.Fatalf("NewLiveDevices: %v", err)
}
if live.Len() != 1 {
t.Fatalf("expected one paired device, got %d", live.Len())
}
if live.SigningKey("ap_5b1d0000") == nil {
t.Fatal("the paired device is not trusted")
}
// The operator destroys the list. Save writes through a rename, which is how the real path
// changes the file, so this exercises the inode branch of the change detection.
destroyed := liveConfig(t)
destroyed.Devices = nil
if err := Save(path, destroyed); err != nil {
t.Fatalf("Save after destroy: %v", err)
}
changed, err := live.Refresh()
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if !changed {
t.Fatal("a destroyed device list was not noticed")
}
if live.Len() != 0 {
t.Fatalf("the destroyed device is still trusted: %d", live.Len())
}
if live.SigningKey("ap_5b1d0000") != nil {
t.Fatal("the destroyed device still has a signing key")
}
// The registration push must now carry an empty set: that is what retires the bearer at the
// relay, and without it the revocation is only half done.
if got := live.Devices(); len(got) != 0 {
t.Fatalf("the relay would be told about %d devices after a destroy", len(got))
}
}
// A pairing performed while the daemon is running must be picked up too, and it is the same
// mechanism: the file changed, so the set is re-read and re-asserted.
func TestLiveDevicesSeesAnAddedDevice(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg := liveConfig(t)
cfg.Devices = nil
if err := Save(path, cfg); err != nil {
t.Fatalf("Save: %v", err)
}
live, err := NewLiveDevices(path)
if err != nil {
t.Fatalf("NewLiveDevices: %v", err)
}
if live.Len() != 0 {
t.Fatalf("expected no devices, got %d", live.Len())
}
paired := liveConfig(t)
if err := Save(path, paired); err != nil {
t.Fatalf("Save after pairing: %v", err)
}
changed, err := live.Refresh()
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if !changed {
t.Fatal("a new pairing was not noticed")
}
if live.Len() != 1 || live.SigningKey("ap_5b1d0000") == nil {
t.Fatal("the newly paired device is not trusted")
}
if got := live.Devices(); len(got) != 1 || got[0].ID != "ap_5b1d0000" {
t.Fatalf("the relay would be told the wrong set: %+v", got)
}
}
// Refresh is called once per poll cycle, so the common case — nothing changed — must be cheap and
// must not report a change. A false positive would force a handshake every cycle, which is the
// defect the agent's loop comment warns about: each handshake replaces the session and drops the
// parked poll.
func TestLiveDevicesReportsNoChangeWhenNothingChanged(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := Save(path, liveConfig(t)); err != nil {
t.Fatalf("Save: %v", err)
}
live, err := NewLiveDevices(path)
if err != nil {
t.Fatalf("NewLiveDevices: %v", err)
}
for i := 0; i < 3; i++ {
changed, err := live.Refresh()
if err != nil {
t.Fatalf("Refresh %d: %v", i, err)
}
if changed {
t.Fatalf("Refresh %d reported a change with no write", i)
}
}
}
// A rewrite that keeps the same content but changes the file is still a change worth noticing: the
// operator may have re-saved the file, and re-asserting the set at the relay costs one handshake.
// What must not happen is the opposite — a change that is missed.
func TestLiveDevicesNoticesAChangeWithinTheSameSecond(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := Save(path, liveConfig(t)); err != nil {
t.Fatalf("Save: %v", err)
}
live, err := NewLiveDevices(path)
if err != nil {
t.Fatalf("NewLiveDevices: %v", err)
}
// A destroy immediately after a pairing, which is exactly the sequence an operator performs when
// they revoke the wrong phone and re-pair. The two writes land inside one clock tick on a
// filesystem with coarse timestamps, so the size and the inode are what has to catch it.
cfg := liveConfig(t)
cfg.Devices = nil
if err := Save(path, cfg); err != nil {
t.Fatalf("Save: %v", err)
}
_ = time.Now()
changed, err := live.Refresh()
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if !changed {
t.Fatal("a destroy inside the same timestamp tick was missed")
}
if live.Len() != 0 {
t.Fatal("the destroyed device is still trusted")
}
}
// An unreadable or invalid file leaves the previous set in place. Replacing it with an empty set
// would be the worst outcome: the node would lock out every paired phone because of a transient
// filesystem error, and the user would have to reach the node to fix it.
func TestLiveDevicesKeepsTheCurrentSetWhenTheFileCannotBeRead(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := Save(path, liveConfig(t)); err != nil {
t.Fatalf("Save: %v", err)
}
live, err := NewLiveDevices(path)
if err != nil {
t.Fatalf("NewLiveDevices: %v", err)
}
if err := os.WriteFile(path, []byte("{ this is not json"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := live.Refresh(); err == nil {
t.Fatal("a corrupt config was accepted")
}
if live.Len() != 1 || live.SigningKey("ap_5b1d0000") == nil {
t.Fatal("a failed reload dropped the trusted device set")
}
// And a missing file is the same story: the set stays, because the node is still the node.
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if _, err := live.Refresh(); err == nil {
t.Fatal("a missing config was accepted")
}
if live.Len() != 1 {
t.Fatal("a missing config dropped the trusted device set")
}
}
// A device whose key cannot be used is a config error rather than a device to skip: skipping it
// would leave a phone paired in the UI and unable to command anything, with nothing to point at.
func TestLiveDevicesRejectsAnUnusableKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg := liveConfig(t)
cfg.Devices[0].SignKey = base64.StdEncoding.EncodeToString([]byte("too short"))
encoded, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
if _, err := NewLiveDevices(path); err == nil {
t.Fatal("a device with an unusable key was accepted")
}
}