上一轮那段"一键安装"是坏的,而且只在节点上坏。实测三段里漏了三件: 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 项通过。
175 lines
7.4 KiB
Go
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
|
|
}
|