节点侧现在有两条命令覆盖整个生命周期,中继侧有了一条让节点不必被人工登记就能加入 的通道。 agent 新增两个子命令: - `msa-agent enroll --relay <url> [--code <code>]` 铸造密钥与标识并写配置。它和 `msa-keygen` 共用 internal/enroll,所以两者不会对"配置长什么样"产生分歧——区别只在 运行位置:keygen 需要 Go 工具链,而节点上只有交叉编译好的二进制。带 --code 时它自己 向中继登记,顺序是先登记再落盘:为"中继还不认识的节点"写下的配置会永远重试 401,而 运维无法把这种情况和网络故障区分开。 - `msa-agent uninstall [--dry-run] [--keep-logs]` 按唯一一个不会留下悬挂状态的顺序拆除: 先清设备并告诉中继,再停服务、删单元、删配置目录、删日志、删用户。顺序是重点——先删 节点会让中继继续向一台已经不存在的节点投递,而"一键卸载"此前根本不存在,尽管安装会 往宿主机放六样东西,其中配置里还有节点密码。 写这个时踩到一个值得记的坑:第一版把每个动作都写成"先 log 再执行",于是 --dry-run 会真的停掉服务——一个不是 dry run 的 dry run。现在每个副作用都 gate 在 step() 的 返回值上,并有测试断言 dry run 一条命令都不跑。 中继新增自注册,默认关闭: - `POST /v1/enroll/code`(运维令牌)铸造一次性码,15 分钟、最多 10 个未使用。 - `POST /v1/enroll` 节点用码登记自己。码先兑换再校验登记体,所以用垃圾请求探测码是否 有效的尝试会烧掉它。 - 登记只授权"加入"这一件事。中继仍然读不到内容、伪造不了应答、下发不了命令——最坏情况 是让一个不该出现的节点出现,也就是拒绝服务,正是设计已经接受的边界。 - agents.json 的条目优先于自注册状态,所以运维永远能用一个 diff 覆盖或吊销自注册的节点; 反过来,节点不能覆盖运维条目(被拒绝,400)。 - 自注册状态要落盘,这是中继第一次有它必须记住的东西。中继此前无状态是它的一条性质, 这里明确让掉了:忘记已登记的节点会让它们全部 401,而症状看起来像 token 坏了。状态文件 原子写、损坏时拒绝启动(而不是静默变成空机群),systemd 单元用 StateDirectory 给它一块 可写目录,其余文件系统仍是只读。 测试:internal/enroll 五项(两份产物必须描述同一个节点、密码只在给出时写入、给定标识 不被重新生成、两次铸造不同、缺输入被拒),internal/install 七项(删干净、顺序、中继不可 达仍完成、dry run 不动任何东西、保留日志、缺件不中止、计划列出每条路径), test/enrollment.test.js 十四项(流程、码单次、垃圾请求烧码、默认关闭、只有运维能铸造、 不能覆盖运维条目、可重复登记、重启后仍在、运维优先、损坏状态拒绝启动、码过期与清扫、 铸造循环不涨内存、登记体校验、状态文件不含私钥)。变异验证:去掉码的单次性、去掉运维条目 保护、让 dry run 执行命令、先删配置再通知中继——四处都让对应测试失败。 Go 全包、Node 134 项、Swift 107 项通过。
214 lines
8.2 KiB
Go
214 lines
8.2 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 {
|
|
if opts.DryRun {
|
|
opts.Log("would "+format, args...)
|
|
return false
|
|
}
|
|
opts.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)
|
|
}
|