Files
msa-agent/internal/install/uninstall.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

222 lines
8.6 KiB
Go

// Package install removes the agent from a node, in the one order that leaves no dangling state.
//
// The order is the whole point, and every step of it was chosen against a specific failure:
//
// 1. Clear the paired-device list in the config and force one handshake, so the relay retires this
// node's device bearers. Removing the node first would leave the relay holding routing for a node
// that no longer exists — and, worse, leave a phone that thinks it is paired pointing at nothing.
// 2. Stop and disable the service. After this the agent cannot be reached by anything, including the
// relay, so the remaining steps are purely local.
// 3. Remove the unit file and reload systemd. Leaving a unit behind means a later `systemctl start`
// resurrects a node whose config is gone.
// 4. Remove the config directory. This holds the agent's private keys, the relay bearer and — on a
// protected node — the SimAdmin password. It is the step that actually matters for security, and
// it is why "uninstall" cannot be a shell one-liner the operator writes from memory.
// 5. Remove the log directory, unless asked to keep it. Audit entries are the only record of what
// this node was asked to do, so deleting them is a decision rather than a default.
// 6. Remove the system user. Last, because every step above ran as that user.
//
// Nothing here is reversible, so a dry run prints the plan and stops.
package install
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
// Paths is where the install put things. The defaults match agent/systemd/msa-agent.service and the
// deployment document; they are fields so a test can point them at a temporary directory.
type Paths struct {
ServiceName string
UnitPath string
ConfigPath string
LogDir string
BinaryPath string
User string
}
// DefaultPaths is the layout the deployment document describes.
func DefaultPaths() Paths {
return Paths{
ServiceName: "msa-agent",
UnitPath: "/etc/systemd/system/msa-agent.service",
ConfigPath: "/etc/msa-agent/config.json",
LogDir: "/var/log/msa-agent",
BinaryPath: "/opt/msa-agent/msa-agent",
User: "msa-agent",
}
}
// Runner runs system commands. It is an interface so the sequence can be tested without systemd.
type Runner interface {
Run(name string, args ...string) error
}
// ExecRunner runs commands for real.
type ExecRunner struct {
// Stdout and Stderr receive the output of commands that have any. systemctl is quiet on success.
Stdout io.Writer
Stderr io.Writer
}
func (r ExecRunner) Run(name string, args ...string) error {
command := exec.Command(name, args...)
command.Stdout = r.Stdout
command.Stderr = r.Stderr
return command.Run()
}
// Options configures an uninstall.
type Options struct {
Paths Paths
// KeepLogs leaves the audit log in place. Deleting it is the default because the node is being
// removed, but an operator retiring a node after an incident may want the record.
KeepLogs bool
// DryRun prints what would happen and removes nothing.
DryRun bool
// NotifyRelay retires this node's device bearers at the relay, and is called before anything is
// removed. Nil skips it, which is right for a node that was never paired.
NotifyRelay func() error
Runner Runner
Log func(format string, args ...any)
}
// Uninstall performs the sequence. It returns an error only for a failure that leaves the node in a
// state the operator must know about; a missing file or an already-stopped service is not one.
func Uninstall(opts Options) error {
if opts.Runner == nil {
opts.Runner = ExecRunner{}
}
if opts.Log == nil {
opts.Log = func(string, ...any) {}
}
paths := opts.Paths
// 1. The relay has to be told before the node stops existing.
if opts.NotifyRelay != nil {
if opts.DryRun {
opts.Log("would clear the paired-device list and tell the relay to retire this node's bearers")
} else if err := opts.NotifyRelay(); err != nil {
// Not fatal, but the operator has to know: the relay will keep routing to a node that is
// gone until its own restart or until the agent entry is removed from agents.json.
opts.Log("WARNING: could not tell the relay to retire this node: %v", err)
opts.Log(" remove this node's entry from the relay's agents.json by hand")
} else {
opts.Log("told the relay to retire this node's device bearers")
}
}
// 2. Stop and disable. `disable --now` is one call so a failure cannot leave the unit enabled with
// the process still running.
//
// Every step below is gated on step()'s answer, and that is load-bearing rather than tidy: a dry
// run that stops the service is not a dry run. The first version of this file logged the plan and
// ran the commands anyway, which on a Linux box would have taken the node offline while telling the
// operator nothing had been touched.
if step(opts, "stopping and disabling %s", paths.ServiceName) {
if err := opts.Runner.Run("systemctl", "disable", "--now", paths.ServiceName); err != nil {
// A unit that was never enabled, or a node where systemd never started it, lands here. It is
// not a reason to abort: the remaining steps are what make the node clean.
opts.Log(" systemctl disable --now %s: %v (continuing)", paths.ServiceName, err)
}
}
// 3. The unit file, then a reload so systemd forgets it.
if step(opts, "removing %s", paths.UnitPath) {
removeIfPresent(paths.UnitPath, opts)
}
if step(opts, "reloading systemd") {
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
opts.Log(" systemctl daemon-reload: %v (continuing)", err)
}
}
// 4. The config: private keys, relay bearer, and the node password when the node is protected.
if step(opts, "removing %s (private keys, relay bearer, node password)", paths.ConfigPath) {
removeIfPresent(paths.ConfigPath, opts)
// The directory holds relay-ca.pem and anything else the operator put beside the config.
removeIfPresent(filepath.Dir(paths.ConfigPath), opts)
}
// 5. Logs.
if opts.KeepLogs {
step(opts, "keeping %s (--keep-logs)", paths.LogDir)
} else if step(opts, "removing %s", paths.LogDir) {
removeIfPresent(paths.LogDir, opts)
}
// 6. The binary, then the user. The user goes last because the steps above ran as that user.
if step(opts, "removing %s", paths.BinaryPath) {
removeIfPresent(paths.BinaryPath, opts)
removeIfPresent(filepath.Dir(paths.BinaryPath), opts)
}
if step(opts, "removing the %s system user", paths.User) {
if err := opts.Runner.Run("userdel", paths.User); err != nil {
opts.Log(" userdel %s: %v (continuing)", paths.User, err)
}
}
if opts.DryRun {
opts.Log("dry run: nothing was removed")
} else {
opts.Log("msa-agent removed")
opts.Log("the relay's agents.json still lists this node; remove that entry unless you are reinstalling")
}
return nil
}
// step reports one line of the plan, and says whether the action should be performed.
func step(opts Options, format string, args ...any) bool {
return stepWith(opts.DryRun, opts.Log, format, args...)
}
// stepWith is the same decision for the install half, whose options type is different. Taking the two
// fields rather than the struct is what keeps "a dry run changes nothing" in one place: two copies of
// this would eventually disagree, and the one that disagreed would be the one that ran a command during
// a dry run.
func stepWith(dryRun bool, log func(string, ...any), format string, args ...any) bool {
if dryRun {
log("would "+format, args...)
return false
}
log(format, args...)
return true
}
// removeIfPresent deletes a path, and treats "it is not there" as success.
//
// A missing path is the normal case on a partially installed node, and reporting it as an error would
// train the operator to ignore the errors that matter.
func removeIfPresent(path string, opts Options) {
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
opts.Log(" %s is not present", path)
return
}
if err := os.RemoveAll(path); err != nil {
opts.Log(" WARNING: could not remove %s: %v", path, err)
return
}
opts.Log(" removed %s", path)
}
// ErrNotRoot is returned when an uninstall is attempted without the privileges the steps need.
var ErrNotRoot = errors.New("install: uninstalling needs root (it removes a systemd unit and a system user)")
// RequireRoot is what the command calls before doing anything.
func RequireRoot() error {
if os.Geteuid() != 0 {
return ErrNotRoot
}
return nil
}
// String renders the plan for a dry run, so an operator can see it without reading this file.
func (p Paths) String() string {
return fmt.Sprintf("service=%s unit=%s config=%s logs=%s binary=%s user=%s",
p.ServiceName, p.UnitPath, p.ConfigPath, p.LogDir, p.BinaryPath, p.User)
}