fix: enroll 写出守护进程会拒绝的配置,install 写出与自身路径不符的单元

四处"校验在错误的层"的缺陷,都属于同一类:每个组件单独看都对,接缝错了。

1. `msa-agent enroll --relay http://relay.example` / `relay.example` /
   `https://relay.example/v1` 都会写出配置文件,install 报成功,守护进程在**下一次启动**才
   拒绝,日志里只有一行 "msa-agent exiting"。规则原本散在三处(config 只查非空、transport
   查真实规则、enroll 的注册客户端另有一份),现在收进 `internal/relayurl` 一份,三处都调它。
   `config.File.Validate` 因此也补上了 https/host/无路径/无 query 的检查。

2. `enroll` 不再自己手写字段检查,改为构造真正的 `config.File` 并调用真正的 `Validate`——
   一份"什么是可用配置"的定义,结构上无法再漂移。`config.Marshal` 让 enroll 与 Save 用同一个
   编码器。

3. `install --config /srv/msa/config.json` 会给该文件改属主、交给服务用户,然后写一个
   ExecStart 指向 `/etc/msa-agent/config.json` 的单元——服务起来找不到配置就退出,而它需要的
   文件就在别处、属主正确、从未被读。`enroll --audit /srv/audit/audit.log` 同理:单元的
   `ReadWritePaths` 只覆盖 `/var/log/msa-agent`,在 `ProtectSystem=strict` 下 agent 的
   `MkdirAll` 报 EROFS,进程启动即退出,报错既不提那个 flag 也不提那条指令。现在单元是模板,
   `RenderedUnit` 按实际布局渲染,`--print-unit` 打印的就是会被写入的那一份;默认布局渲染结果
   与仓库里被 review 过的文件逐字节相同,所以 vendored 副本的漂移检查仍然有意义。

测试:新增 `internal/relayurl`,`config`/`enroll`/`install` 各补回归测试。每条都做了变异验证
(把修复回退,对应测试必须失败)。
This commit is contained in:
chick
2026-09-18 12:38:56 +08:00
parent f499d02ca0
commit 20070b90f7
11 changed files with 463 additions and 67 deletions
+17 -1
View File
@@ -15,6 +15,8 @@ import (
"os"
"regexp"
"syscall"
"fun.chick/msa-agent/internal/relayurl"
)
var nodePattern = regexp.MustCompile(`^nd_[A-Za-z0-9_-]{8,64}$`)
@@ -84,9 +86,16 @@ func (c File) Validate(pairMode bool) error {
if !nodePattern.MatchString(c.NodeID) {
return errors.New("nodeId must look like nd_xxxxxxxx")
}
// The URL is checked for shape here, not merely for being present. It used to be checked for
// presence alone, and the transport refused the real rule at dial time — so `enroll` would write a
// config, the daemon would refuse it at the next startup, and the operator would find out after
// the install had already reported success.
if c.RelayURL == "" {
return errors.New("relayUrl is required")
}
if err := relayurl.Validate(c.RelayURL); err != nil {
return err
}
if c.AgentToken == "" {
return errors.New("agentToken is required")
}
@@ -159,6 +168,13 @@ func loadFile(path string) (File, error) {
return cfg, nil
}
// Marshal renders a File the way Save writes it, so a caller that needs the bytes without writing them
// — `enroll`, which validates before it writes — gets the same encoding rather than its own
// json.MarshalIndent that could drift from this one.
func Marshal(cfg File) ([]byte, error) {
return json.MarshalIndent(cfg, "", " ")
}
// Save writes the config back with 0600, atomically, preserving the file's owner.
//
// Atomic (temp file + rename) because the pairing flow writes the device list at moments when the
@@ -176,7 +192,7 @@ func Save(path string, cfg File) error {
if err := cfg.Validate(true); err != nil {
return fmt.Errorf("config: refusing to save an invalid file: %w", err)
}
encoded, err := json.MarshalIndent(cfg, "", " ")
encoded, err := Marshal(cfg)
if err != nil {
return err
}
+27
View File
@@ -123,6 +123,33 @@ func TestSaveRefusesAnInvalidFile(t *testing.T) {
}
}
// The relay URL used to be checked only for being non-empty, so a shape the transport refuses was
// accepted here and refused later — at dial time in the daemon, or at the next startup for a config
// `enroll` had already written and reported as success. These are the values that reached real nodes.
func TestValidateRefusesARelayURLTheDaemonCannotDial(t *testing.T) {
for _, raw := range []string{
"relay.example",
"http://relay.example",
"https://relay.example/v1",
"https://relay.example?x=1",
"https://",
} {
cfg := valid()
cfg.RelayURL = raw
if err := cfg.Validate(false); err == nil {
t.Errorf("accepted relayUrl %q", raw)
}
if err := cfg.Validate(true); err == nil {
t.Errorf("pair mode accepted relayUrl %q", raw)
}
}
cfg := valid()
cfg.RelayURL = "https://relay.example/"
if err := cfg.Validate(false); err != nil {
t.Fatalf("refused a trailing slash: %v", err)
}
}
func TestValidateNamesEveryMissingField(t *testing.T) {
cases := map[string]func(*File){
"nodeId": func(c *File) { c.NodeID = "" },