Files
msa-agent/internal/install/service.go
T
chick 934760f8e2 fix(agent): 补上安装的服务那一半——建用户、改所有权、装单元
上一轮那段"一键安装"是坏的,而且只在节点上坏。实测三段里漏了三件:

1. systemd 单元写着 `User=msa-agent`,而命令从没创建这个用户;
2. `enroll` 是 sudo 跑的,配置是 root 所有 0600,服务以另一个用户运行——读不到自己的私钥;
3. 单元从 `msa-agent.service` 这个仓库里的路径拷,而节点上没有仓库。

三段都"看起来对",因为它们各自是一行合理的 shell;错的是它们与单元之间的接缝。这正
是这个项目里反复出现的那一类缺陷。

现在有 `msa-agent install`:

- 建 `--system --no-create-home --shell /usr/sbin/nologin` 的用户(已存在则跳过);
- 建日志目录,并把配置、配置目录、日志目录 chown 给该用户;
- 把 systemd 单元写进去。单元**嵌在二进制里**(`go:embed`),不是从仓库路径拷的,也不是
  从 URL 下的——一个能发 unit 的服务器就能给节点塞一个后门 unit,那与"中继不可信"是同一
  条理由。`--print-unit` 可以在装之前把它打出来看;
- `daemon-reload` 并 enable/start。

幂等:每步先看已经是什么状态,所以升级或上次失败后重跑都安全。`--dry-run` 打印计划且什么
都不改,并且在缺二进制或配置时**不拒绝**——"还没装"正是会去跑 dry-run 的状态,它只把缺的
东西指出来。chown 走 Runner 而不是 os.Chown,这样整条序列都能在无 root 的情况下测。

install 与 uninstall 共用同一份 `Paths`:装了什么没卸掉会在机器上留一份凭据,卸了什么没装
过会在新节点上失败。单元也一样,`TestVendoredUnitMatchesSystemd` 守着嵌入副本与
`systemd/msa-agent.service` 不漂移。

手机上的安装命令相应简化:`install` 那一步取代了原先手工的 useradd/install/systemctl 三行,
卸载那段也先给 `--dry-run`。

测试新增五项:单元不漂移、install 满足单元的全部要求(用户、所有权、单元来自二进制而非仓库
路径)、install dry run 什么都不改、缺二进制或配置时拒绝且一条命令都不跑、dry run 在全新
节点上不拒绝而是指出缺什么。变异验证:去掉 chown、去掉建用户、让 dry run 执行命令——三处都
让对应测试失败。

Go 全包、Node 134 项、Swift 116 项通过。
2026-09-17 17:55:57 +08:00

175 lines
7.4 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"
"fmt"
"io"
"os"
"path/filepath"
)
// 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 }
// 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.
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)
}
if err := os.WriteFile(paths.UnitPath, embeddedUnit, 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
}
opts.Log("msa-agent installed and running")
opts.Log("check it with: journalctl -u %s -n 20", paths.ServiceName)
opts.Log("next: run `msa-agent --pair` once, then pair the phone from the app")
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 embedded unit to w, so an operator can read what will be installed before it is.
func PrintUnit(w io.Writer) error {
_, err := w.Write(embeddedUnit)
return err
}