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 项通过。
This commit is contained in:
chick
2026-09-17 17:55:57 +08:00
parent a4439331e3
commit 934760f8e2
6 changed files with 503 additions and 4 deletions
+51
View File
@@ -51,6 +51,8 @@ func main() {
switch os.Args[1] {
case "enroll":
os.Exit(runEnrollCommand(os.Args[2:]))
case "install":
os.Exit(runInstallCommand(os.Args[2:]))
case "uninstall":
os.Exit(runUninstallCommand(os.Args[2:]))
case "version":
@@ -459,3 +461,52 @@ func runUninstallCommand(args []string) int {
}
return 0
}
// runInstallCommand makes a node ready to run the agent as a service.
//
// It is the second half of the install: `enroll` puts the keys and the config on the node, and this
// puts the service around them. Both are needed, and the first version of the install command had only
// the first — it left three things undone, each of which fails on the node: the service user was never
// created, the config stayed root-owned 0600 so the service could not read its own keys, and the unit
// was copied from a path in the repository that a node does not have.
func runInstallCommand(args []string) int {
flags := flag.NewFlagSet("install", flag.ExitOnError)
var opts install.InstallOptions
configPath := flags.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
flags.BoolVar(&opts.DryRun, "dry-run", false, "print what would be done and change nothing")
printUnit := flags.Bool("print-unit", false, "print the systemd unit this binary installs, then exit")
flags.Usage = func() {
fmt.Fprintf(flags.Output(), "usage: msa-agent install [--dry-run] [--print-unit]\n\n")
fmt.Fprintf(flags.Output(), "Creates the service user, gives it the config and the log directory, writes the\n")
fmt.Fprintf(flags.Output(), "systemd unit from this binary, and starts the service. Run `enroll` first: this\n")
fmt.Fprintf(flags.Output(), "needs the config that enroll writes. Needs root.\n\n")
flags.PrintDefaults()
}
if err := flags.Parse(args); err != nil {
return 2
}
if *printUnit {
// The unit is embedded, so this is how an operator reads it before it is installed — which is the
// point of embedding rather than fetching: the file that will be installed is readable first.
if err := install.PrintUnit(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
return 1
}
return 0
}
if !opts.DryRun {
if err := install.RequireRoot(); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
return 1
}
}
paths := install.DefaultPaths()
paths.ConfigPath = *configPath
opts.Paths = paths
opts.Log = func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
if err := install.Install(opts); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
return 1
}
return 0
}