Files
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

264 lines
12 KiB
Go

// Package install puts the agent on a node and takes it off again, as the two halves of one sequence.
//
// It is one package because the two have to agree about every path. An install that creates something
// the uninstall does not remove leaves a machine with a stray credential on it, and an uninstall that
// removes something the install did not create fails on a fresh node. Keeping the path list in one
// place is what makes that agreement checkable rather than remembered.
//
// The systemd unit is embedded rather than fetched. Two reasons, and the second is the one that
// decided it: a node installing the agent may have no route to anything except the relay, and a unit
// file downloaded from a URL is a second thing that can 404 at the exact moment the operator has the
// least context to debug it. Embedding also means the unit an operator runs is the one the binary was
// built with, so a node cannot end up with a unit from a different release than its agent.
package install
import (
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// The two literals in the embedded unit that have to follow the install's own paths. They are named
// here rather than parsed out of the file so a unit edit that removes one is a visible failure in
// RenderedUnit's tests rather than a silent no-op substitution.
const (
defaultConfigPath = "/etc/msa-agent/config.json"
defaultWritablePaths = "/var/log/msa-agent"
)
// The vendored unit. It is copied from agent/systemd/ by the Makefile, and
// TestVendoredUnitMatchesSystemd fails when the copy drifts — the same arrangement the agent's policy
// uses, and for the same reason: an embedded file that is edited in place is a file the build
// overwrites, and the mistake should be loud rather than silent.
//
//go:embed msa-agent.service
var embeddedUnit []byte
// UnitFile returns the unit this binary installs, for tests and for `install --print-unit`.
func UnitFile() []byte { return embeddedUnit }
// RenderedUnit is the unit as it will actually be written for a given layout.
//
// The embedded file is a template, and two of its literals have to follow the paths the install was
// given. Both were measured drifting:
//
// - `install --config /srv/msa/config.json` chowned that file, gave it to the service user, and then
// wrote a unit whose ExecStart named `/etc/msa-agent/config.json` — a path that on that node holds
// nothing, so the service fails with "no such file or directory" while the config it needs sits
// elsewhere, correctly owned and never read.
// - `enroll --audit /srv/audit/audit.log` puts that path in the config, and the unit's
// `ReadWritePaths=/var/log/msa-agent` does not cover it. Under `ProtectSystem=strict` the agent's
// `os.MkdirAll` for the parent directory fails with EROFS and the daemon exits at startup, with a
// permission error that names neither the flag nor the directive.
//
// The default layout renders byte-for-byte the embedded file, which is what keeps the vendored copy
// and the drift test meaningful: the template is still the thing that was reviewed.
func RenderedUnit(paths Paths, writableDirs []string) []byte {
unit := string(embeddedUnit)
// Only substituted when it differs, so the common install produces exactly the reviewed file and a
// diff of `install --print-unit` against the repository copy stays empty.
if paths.ConfigPath != defaultConfigPath {
unit = strings.Replace(unit, "--config "+defaultConfigPath, "--config "+paths.ConfigPath, 1)
}
dirs := uniqueDirs(append([]string{paths.LogDir}, writableDirs...))
if len(dirs) > 0 && strings.Join(dirs, " ") != defaultWritablePaths {
unit = strings.Replace(unit, "ReadWritePaths="+defaultWritablePaths, "ReadWritePaths="+strings.Join(dirs, " "), 1)
}
return []byte(unit)
}
// uniqueDirs keeps the first occurrence of each path, in order, so a log directory that is also an
// audit directory appears once and the rendering is stable.
func uniqueDirs(dirs []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(dirs))
for _, dir := range dirs {
dir = filepath.Clean(dir)
if dir == "" || dir == "." || dir == "/" || seen[dir] {
continue
}
seen[dir] = true
out = append(out, dir)
}
return out
}
// auditDirOf reports the directory the config's audit log needs to be writable, or "" when the config
// cannot be read.
//
// An unreadable config is not an error here: `install --dry-run` is run on nodes where `enroll` has not
// happened yet, and the real run refuses a missing config a few lines above. What it must not do is
// invent a path — a wrong ReadWritePaths entry is a sandbox hole, and a missing one is the EROFS above.
func auditDirOf(configPath string) string {
raw, err := os.ReadFile(configPath)
if err != nil {
return ""
}
var parsed struct {
AuditPath string `json:"auditPath"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return ""
}
return filepath.Dir(parsed.AuditPath)
}
// InstallOptions configures an install.
type InstallOptions struct {
Paths Paths
// DryRun prints the plan and changes nothing.
DryRun bool
Runner Runner
Log func(format string, args ...any)
}
// Install makes a node ready to run the agent as a service, assuming the binary is already in place
// and the config has already been written by `msa-agent enroll`.
//
// It is idempotent: every step checks what is already true, so re-running it after an upgrade or a
// failed attempt is safe. That matters because the alternative — a command that only works on a clean
// node — makes every partial failure a manual cleanup.
//
// The order is the reverse of the uninstall's, and for the same reason: the user has to exist before
// anything can be owned by it, and the config has to be readable by that user before the service can
// start.
func Install(opts InstallOptions) error {
if opts.Runner == nil {
opts.Runner = ExecRunner{}
}
if opts.Log == nil {
opts.Log = func(string, ...any) {}
}
paths := opts.Paths
// The two preconditions, reported rather than enforced during a dry run.
//
// A dry run is asked before the work is done — that is the whole point of it — so refusing it for
// the very conditions it exists to reveal would make it useless on exactly the machine an operator
// runs it on. The real run refuses, because a service whose ExecStart points at nothing fails with
// "no such file or directory" in the journal, which reads like a broken build.
binaryMissing := !exists(paths.BinaryPath)
configMissing := !exists(paths.ConfigPath)
if !opts.DryRun {
if binaryMissing {
return fmt.Errorf("install: %s is not there; run this from the installed binary, not a copy in /tmp", paths.BinaryPath)
}
if configMissing {
return fmt.Errorf("install: %s is not there; run `msa-agent enroll` first", paths.ConfigPath)
}
} else {
if binaryMissing {
opts.Log("note: %s is not there yet; the real run would refuse", paths.BinaryPath)
}
if configMissing {
opts.Log("note: %s is not there yet; run `msa-agent enroll` first", paths.ConfigPath)
}
}
// 1. The system user. `--system` gives it no login shell and no home, which is what a process that
// only dials out needs; `--no-create-home` keeps the filesystem as it was.
if stepWith(opts.DryRun, opts.Log, "creating the %s system user", paths.User) {
if err := opts.Runner.Run("getent", "passwd", paths.User); err == nil {
opts.Log(" %s already exists", paths.User)
} else if err := opts.Runner.Run("useradd", "--system", "--no-create-home",
"--shell", "/usr/sbin/nologin", paths.User); err != nil {
return fmt.Errorf("install: creating the %s user: %w", paths.User, err)
}
}
// 2. The log directory, owned by that user. The unit's ProtectSystem=strict makes everything else
// read-only, so this is one of the two paths the service can write.
if stepWith(opts.DryRun, opts.Log, "creating %s", paths.LogDir) {
if err := os.MkdirAll(paths.LogDir, 0o750); err != nil {
return fmt.Errorf("install: creating the log directory: %w", err)
}
}
// 3. Ownership of the config, and of the log directory, and that step is load-bearing rather than
// tidy: `enroll` ran under sudo, so the config is root-owned 0600 and the service — which runs as
// the user above — cannot read its own private keys. The failure is a service that starts and
// immediately exits with a permission error.
if stepWith(opts.DryRun, opts.Log, "giving %s ownership of the config and logs", paths.User) {
for _, path := range []string{paths.ConfigPath, filepath.Dir(paths.ConfigPath), paths.LogDir} {
if err := chownTo(path, paths.User, opts); err != nil {
return err
}
}
}
// 4. The unit, from this binary. The audit log's directory comes from the config that `enroll` wrote,
// because the unit's ReadWritePaths is the only thing that makes it writable under
// ProtectSystem=strict — and a config pointing outside it is a daemon that exits at startup with
// EROFS, naming neither the flag nor the directive.
if stepWith(opts.DryRun, opts.Log, "writing %s", paths.UnitPath) {
if err := os.MkdirAll(filepath.Dir(paths.UnitPath), 0o755); err != nil {
return fmt.Errorf("install: creating the unit directory: %w", err)
}
unit := RenderedUnit(paths, []string{auditDirOf(paths.ConfigPath)})
if err := os.WriteFile(paths.UnitPath, unit, 0o644); err != nil {
return fmt.Errorf("install: writing the unit: %w", err)
}
}
// 5. Enable and start. `daemon-reload` first, because systemd caches unit files and would otherwise
// start whatever it read last.
if stepWith(opts.DryRun, opts.Log, "reloading systemd") {
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("install: systemctl daemon-reload: %w", err)
}
}
if stepWith(opts.DryRun, opts.Log, "enabling and starting %s", paths.ServiceName) {
if err := opts.Runner.Run("systemctl", "enable", "--now", paths.ServiceName); err != nil {
return fmt.Errorf("install: systemctl enable --now: %w", err)
}
}
if opts.DryRun {
opts.Log("dry run: nothing was changed")
return nil
}
// The service will fail until a phone is paired, and that is expected rather than a fault: the daemon
// refuses an empty device list so it cannot sit there looking healthy while authorising nothing. The
// unit's start limit turns that into a `failed` state instead of a restart loop, and this line is
// what makes the state expected.
opts.Log("msa-agent installed and enabled")
opts.Log("next: run `msa-agent --pair` once and pair the phone; until then the service exits and stops")
opts.Log("check it with: systemctl status %s; journalctl -u %s -n 20", paths.ServiceName, paths.ServiceName)
return nil
}
// exists reports whether a path is present, without caring why it might not be.
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// chownTo gives a path to the service user.
//
// It shells out to chown rather than calling os.Chown, and that is a deliberate consistency rather than
// a preference: the whole install is a sequence of system commands, so routing all of them through the
// Runner is what makes the sequence testable without root. os.Chown would work and would also be the one
// step a test could not intercept.
func chownTo(path, user string, opts InstallOptions) error {
if err := opts.Runner.Run("chown", user+":"+user, path); err != nil {
return fmt.Errorf("install: giving %s to %s: %w", path, user, err)
}
opts.Log(" %s -> %s", path, user)
return nil
}
// PrintUnit writes the unit as it would be installed for a layout to w, so an operator can read what
// will be installed before it is.
//
// It renders rather than dumping the embedded bytes, because the two are not the same thing once the
// install has been given a non-default config path or an audit log outside the log directory — and the
// command's whole promise is that what it prints is what gets written.
func PrintUnit(w io.Writer, paths Paths) error {
_, err := w.Write(RenderedUnit(paths, []string{auditDirOf(paths.ConfigPath)}))
return err
}