// 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 } // The service will fail until a phone is paired, and that is expected rather than a fault: the daemon // refuses an empty device list so it cannot sit there looking healthy while authorising nothing. The // unit's start limit turns that into a `failed` state instead of a restart loop, and this line is // what makes the state expected. opts.Log("msa-agent installed and enabled") opts.Log("next: run `msa-agent --pair` once and pair the phone; until then the service exits and stops") opts.Log("check it with: systemctl status %s; journalctl -u %s -n 20", paths.ServiceName, paths.ServiceName) 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 }