// 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) }