Files
msa-agent/internal/relayurl/relayurl.go
T
chick 20070b90f7 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` 各补回归测试。每条都做了变异验证
(把修复回退,对应测试必须失败)。
2026-09-18 12:38:56 +08:00

57 lines
2.5 KiB
Go

// Package relayurl holds the one definition of an acceptable relay URL.
//
// Three components need to agree on it — the config validator that decides whether the daemon starts
// at all, the transport that dials the relay, and the enrollment client that registers a node before
// either of them runs — and they did not. `msa-agent enroll --relay relay.example` (no scheme) and
// `--relay https://relay.example/v1` (a path) both wrote a config happily; the daemon then refused it
// at startup, on a node the operator had just finished installing, minutes later and in a different
// process. The error arrived as "msa-agent exiting" in the journal.
//
// The rule is short enough to state once and nowhere else: https, a host, and nothing after it.
package relayurl
import (
"errors"
"fmt"
"net/url"
)
// Validate reports whether raw can be used as a relay base URL.
//
// The path rule is not cosmetic. The transport builds every request URL by concatenation
// (baseURL + "/v1/hello"), so a base URL carrying a query would produce
// `https://relay.example?x=1/v1/hello` — an empty path and a query of "/v1/hello". A base URL with a
// path would silently address endpoints under it. Both fail somewhere far from the typo.
func Validate(raw string) error {
if raw == "" {
// Named separately because the empty string is what a flag that was never given a value looks
// like, and "must be https, got """ is a worse thing to read at 2am than "is required".
return errors.New("relay url is required")
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("relay url: %w", err)
}
if parsed.Scheme != "https" {
// Not negotiable. The relay is untrusted by design, so the channel's job is to stop a third
// party from substituting a different ciphertext; over an unauthenticated channel the
// end-to-end encryption above it is decorative.
return fmt.Errorf("relay url must be https, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return errors.New("relay url has no host")
}
if parsed.Path != "" && parsed.Path != "/" {
return fmt.Errorf("relay url must not carry a path, got %q", parsed.Path)
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("relay url must not carry a query or a fragment")
}
if parsed.User != nil {
// Credentials in the URL would be sent on every request and are not how this authenticates:
// the agent signs a challenge with its own key, and a bearer in the URL would end up in logs.
return errors.New("relay url must not carry credentials")
}
return nil
}