文档写的吊销路径是:在节点上跑一次 `--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 项全部通过。
141 lines
4.7 KiB
Go
141 lines
4.7 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LiveDevices is the node's paired-device set, kept in step with the config file.
|
|
//
|
|
// The daemon used to load its device list once at startup and hold it in memory forever. That made
|
|
// the documented revocation path not work: the operator runs `--pair`, POSTs /destroy, the file on
|
|
// disk is emptied — and the running daemon keeps answering with the list it read at boot, then
|
|
// re-pushes that list to the relay on its next handshake. So a revoked phone stayed authorised at
|
|
// the node and stayed routable at the relay, and the only way to make the revocation real was to
|
|
// restart the service, which the deployment document does not say.
|
|
//
|
|
// The design anticipated this: the Devices interface in internal/agent notes that the first
|
|
// implementation is a static list and the intended one is a snapshot pulled from the relay. This is
|
|
// the smaller step that makes revocation work without a new wire protocol — the file is already the
|
|
// authority, it just was not being re-read.
|
|
//
|
|
// Reloading is driven by the file's modification time and size rather than a timer, so a poll cycle
|
|
// pays one stat and only re-reads when something actually changed. A read that fails leaves the
|
|
// previous set in place: a transient filesystem error must not turn into "nobody is paired", which
|
|
// would lock the user out of a node whose configuration is fine.
|
|
type LiveDevices struct {
|
|
path string
|
|
|
|
mu sync.RWMutex
|
|
keys map[string]ed25519.PublicKey
|
|
devices []Device
|
|
stamp fileStamp
|
|
}
|
|
|
|
// fileStamp is what a change is detected by. Modification time alone is not enough: two writes
|
|
// inside the same clock tick are possible, and a pairing followed immediately by a destroy is
|
|
// exactly that. The size catches those, and the inode catches a rename over the file — which is how
|
|
// Save writes, so every legitimate change is a rename.
|
|
type fileStamp struct {
|
|
modTime time.Time
|
|
size int64
|
|
inode uint64
|
|
}
|
|
|
|
// NewLiveDevices reads the device list and remembers how to tell when it changes.
|
|
func NewLiveDevices(path string) (*LiveDevices, error) {
|
|
d := &LiveDevices{path: path}
|
|
if err := d.reload(); err != nil {
|
|
return nil, err
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// SigningKey returns the phone's public key, or nil when it is not paired.
|
|
//
|
|
// The read is on the hot path — every envelope is verified through it — so it takes the read lock
|
|
// and does no I/O. Reloading happens in Refresh, which the loop calls once per cycle.
|
|
func (d *LiveDevices) SigningKey(app string) ed25519.PublicKey {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.keys[app]
|
|
}
|
|
|
|
// Len reports how many phones are trusted.
|
|
func (d *LiveDevices) Len() int {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return len(d.keys)
|
|
}
|
|
|
|
// Devices returns a copy of the current paired-device list, for the handshake's registration push.
|
|
func (d *LiveDevices) Devices() []Device {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return append([]Device(nil), d.devices...)
|
|
}
|
|
|
|
// Refresh re-reads the config if it changed on disk, and reports whether it did.
|
|
//
|
|
// The caller uses the report to decide whether to re-assert the set at the relay: a destroy 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.
|
|
func (d *LiveDevices) Refresh() (bool, error) {
|
|
stamp, err := stampOf(d.path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
d.mu.RLock()
|
|
unchanged := stamp == d.stamp
|
|
d.mu.RUnlock()
|
|
if unchanged {
|
|
return false, nil
|
|
}
|
|
if err := d.reload(); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (d *LiveDevices) reload() error {
|
|
cfg, err := loadFile(d.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Pair mode is the right validation here: a node whose list was just destroyed has zero devices
|
|
// and must still be able to load, because that state is the whole point of a revocation.
|
|
if err := cfg.Validate(true); err != nil {
|
|
return fmt.Errorf("config: %s: %w", d.path, err)
|
|
}
|
|
keys := make(map[string]ed25519.PublicKey, len(cfg.Devices))
|
|
for _, device := range cfg.Devices {
|
|
raw, err := base64.StdEncoding.Strict().DecodeString(device.SignKey)
|
|
if err != nil || len(raw) != ed25519.PublicKeySize {
|
|
return fmt.Errorf("config: device %s has an unusable signKey", device.ID)
|
|
}
|
|
keys[device.ID] = ed25519.PublicKey(raw)
|
|
}
|
|
stamp, err := stampOf(d.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.mu.Lock()
|
|
d.keys = keys
|
|
d.devices = append([]Device(nil), cfg.Devices...)
|
|
d.stamp = stamp
|
|
d.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func stampOf(path string) (fileStamp, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return fileStamp{}, fmt.Errorf("config: %w", err)
|
|
}
|
|
return fileStamp{modTime: info.ModTime(), size: info.Size(), inode: inodeOf(info)}, nil
|
|
}
|