diff --git a/cmd/msa-agent/enroll.go b/cmd/msa-agent/enroll.go new file mode 100644 index 0000000..4cf6edd --- /dev/null +++ b/cmd/msa-agent/enroll.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "fun.chick/msa-agent/internal/config" + "fun.chick/msa-agent/internal/enroll" + "fun.chick/msa-agent/internal/install" + "fun.chick/msa-agent/internal/policy" + "fun.chick/msa-agent/internal/transport" +) + +// EnrollOptions is what `msa-agent enroll` was asked to do. +type EnrollOptions struct { + ConfigPath string + RelayURL string + LocalPort int + AuditPath string + NodeID string + AgentToken string + ProvisionSecret string + RelayCAFile string + NodePassword string + // Code is a one-time enrollment code. When set, the node registers itself with the relay and does + // not print a registration entry for anyone to copy. + Code string + // Force overwrites an existing config. Without it, an existing file is a refusal rather than an + // overwrite, because the file holds the node's private keys: replacing it silently would unpair + // every phone and orphan the node at the relay. + Force bool + // PrintOnly mints and prints without writing anything. + PrintOnly bool +} + +// runEnroll mints this node's identity and writes its config, optionally registering with the relay. +// +// It is the on-node counterpart of `msa-keygen`, and both call internal/enroll, so the two cannot +// disagree about what a config looks like. The difference is where they run: msa-keygen needs a Go +// toolchain, which a node with a cross-compiled binary does not have. +func runEnroll(ctx context.Context, opts EnrollOptions) error { + if _, err := os.Stat(opts.ConfigPath); err == nil && !opts.Force && !opts.PrintOnly { + return fmt.Errorf("%s already exists; pass --force to replace it (this discards the node's keys "+ + "and unpairs every phone)", opts.ConfigPath) + } + + result, err := enroll.Generate(enroll.Options{ + RelayURL: opts.RelayURL, + LocalPort: opts.LocalPort, + AuditPath: opts.AuditPath, + NodeID: opts.NodeID, + AgentToken: opts.AgentToken, + ProvisionSecret: opts.ProvisionSecret, + RelayCAFile: opts.RelayCAFile, + NodePassword: opts.NodePassword, + }) + if err != nil { + return err + } + + if opts.PrintOnly { + os.Stdout.Write(result.Config) + fmt.Fprintf(os.Stderr, "\n# relay registration\n%s\n", result.Registration) + fmt.Fprintf(os.Stderr, "# pairing secret: %s\n", result.ProvisionSecret) + return nil + } + + // The registration goes first, and that order is the point of this command: a config written for a + // relay that does not know the node is a node that retries 401 forever, and an operator has no way + // to tell that apart from a network problem. + if opts.Code != "" { + var registration enroll.Registration + if err := json.Unmarshal(result.Registration, ®istration); err != nil { + return fmt.Errorf("enroll: decoding the registration: %w", err) + } + if err := enroll.Register(ctx, enroll.RegisterOptions{ + RelayURL: opts.RelayURL, + Code: opts.Code, + Registration: registration, + RelayCAFile: opts.RelayCAFile, + }); err != nil { + // Nothing has been written, so a retry with a fresh code is safe and the node is untouched. + return err + } + slog.Info("relay accepted the enrollment", "node", result.NodeID, "relay", opts.RelayURL) + } + + if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0o755); err != nil { + return err + } + // 0600: private keys, the relay bearer, and the node password when the node is protected. + if err := os.WriteFile(opts.ConfigPath, result.Config, 0o600); err != nil { + return err + } + + fmt.Printf("node id: %s\n", result.NodeID) + fmt.Printf("wrote config: %s (mode 0600)\n", opts.ConfigPath) + if opts.Code == "" { + // Without a code the relay still has to be told by hand, so the entry is printed for copying. + fmt.Printf("\n# the relay does not know this node yet. Add this to its agents.json:\n%s\n", result.Registration) + } + fmt.Printf("\npairing secret: %s\n", result.ProvisionSecret) + fmt.Printf(" (only needed on an unprotected node; a node with nodePassword uses that instead)\n") + fmt.Printf("\nnext: systemctl enable --now msa-agent, then `msa-agent --pair` once and pair from the app\n") + return nil +} + +// runUninstall removes the agent from this node. +// +// The relay is told first, through the same mechanism a revocation uses: the device list is emptied and +// the relay is told directly, so it retires this node's device bearers before the node stops existing. +// Removing the node first would leave the relay routing to a node that is gone. +func runUninstall(ctx context.Context, opts install.Options, configPath string) error { + // A dry run needs no privileges: it reads nothing and removes nothing, and an operator checking + // what an uninstall would do should not have to be root to ask. + if !opts.DryRun { + if err := install.RequireRoot(); err != nil { + return err + } + } + paths := install.DefaultPaths() + // The config path is a flag, so it can differ from the layout the defaults assume. + paths.ConfigPath = configPath + opts.Paths = paths + + if opts.NotifyRelay == nil && !opts.DryRun { + opts.NotifyRelay = func() error { return retireAtRelay(ctx, configPath) } + } + return install.Uninstall(opts) +} + +// retireAtRelay empties the paired-device list and tells the relay, so it drops this node's bearers. +// +// It talks to the relay itself rather than waiting for the running agent to notice. The agent does +// notice — it re-reads its config every poll cycle — but a poll window is 24 seconds, and making an +// uninstall wait on one, with a timeout, to learn something it can say in one round trip is a worse +// trade than holding a session for two requests. +// +// The order matters: the file is cleared first, so an agent that notices mid-way pushes the same empty +// set this function does. Both converge, and neither can resurrect a device. +func retireAtRelay(ctx context.Context, configPath string) error { + cfg, err := config.LoadForPairing(configPath) + if err != nil { + return fmt.Errorf("reading %s: %w", configPath, err) + } + if len(cfg.Devices) == 0 { + return nil + } + + cleared := cfg + cleared.Devices = nil + if err := config.Save(configPath, cleared); err != nil { + return fmt.Errorf("clearing the device list: %w", err) + } + + sign, err := decodeKey(cfg.SignKey, ed25519.PrivateKeySize, "signKey") + if err != nil { + return err + } + roots, err := relayRoots(cfg.RelayCAFile) + if err != nil { + return err + } + client, err := transport.New(transport.Config{ + BaseURL: cfg.RelayURL, + NodeID: cfg.NodeID, + Token: cfg.AgentToken, + Signer: sign, + UserAgent: "msa-agent/" + version, + RootCAs: roots, + }) + if err != nil { + return err + } + pol, err := policy.Load() + if err != nil { + return err + } + + // A short, bounded attempt: the uninstall must finish even when the relay is unreachable, and the + // caller prints what to do by hand in that case. + attemptCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + if _, err := client.Hello(attemptCtx, version, pol.SHA256()); err != nil { + return fmt.Errorf("handshaking with the relay: %w", err) + } + if err := client.PushDevices(attemptCtx, nil); err != nil { + return fmt.Errorf("reporting an empty device set: %w", err) + } + return nil +} diff --git a/cmd/msa-agent/main.go b/cmd/msa-agent/main.go index fd54c64..7100e72 100644 --- a/cmd/msa-agent/main.go +++ b/cmd/msa-agent/main.go @@ -25,6 +25,7 @@ import ( "fun.chick/msa-agent/internal/agent" "fun.chick/msa-agent/internal/config" + "fun.chick/msa-agent/internal/install" "fun.chick/msa-agent/internal/pair" "fun.chick/msa-agent/internal/policy" "fun.chick/msa-agent/internal/runtime" @@ -42,6 +43,22 @@ const ( ) func main() { + // Subcommands are dispatched before flag.Parse, because the default invocation is a bare + // `msa-agent --config ...` from systemd and a positional argument there would be a parse error. + // The two subcommands are the whole lifecycle around that daemon: put the node on the machine, and + // take it off again. + if len(os.Args) > 1 { + switch os.Args[1] { + case "enroll": + os.Exit(runEnrollCommand(os.Args[2:])) + case "uninstall": + os.Exit(runUninstallCommand(os.Args[2:])) + case "version": + fmt.Println(version) + return + } + } + var ( configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file") check = flag.Bool("check", false, "validate the config and policy, then exit") @@ -379,3 +396,66 @@ func deviceSet(devices []config.Device) (agent.StaticDevices, error) { } return set, nil } + +// enrollFlags are the enrollment command's own flag set. It is separate from the daemon's so that +// `msa-agent enroll --help` describes only what enroll does, and so a flag added to one cannot +// silently change the other. +func runEnrollCommand(args []string) int { + flags := flag.NewFlagSet("enroll", flag.ExitOnError) + var opts EnrollOptions + flags.StringVar(&opts.ConfigPath, "write", "/etc/msa-agent/config.json", "where to write the agent config") + flags.StringVar(&opts.RelayURL, "relay", "", "relay base URL, https (required)") + flags.IntVar(&opts.LocalPort, "port", 3000, "the local SimAdmin port on 127.0.0.1") + flags.StringVar(&opts.AuditPath, "audit", "/var/log/msa-agent/audit.log", "audit log path") + flags.StringVar(&opts.NodeID, "node", "", "node id (nd_...); generated when empty") + flags.StringVar(&opts.AgentToken, "token", "", "relay bearer for this agent; generated when empty") + flags.StringVar(&opts.ProvisionSecret, "provision-secret", "", "pairing secret; generated when empty") + flags.StringVar(&opts.RelayCAFile, "relay-ca", "", "PEM bundle to pin as the relay's CA (optional)") + flags.StringVar(&opts.NodePassword, "node-password", "", "the node's SimAdmin password, if it has one") + flags.StringVar(&opts.Code, "code", "", "one-time enrollment code from the app; registers this node with the relay") + flags.BoolVar(&opts.Force, "force", false, "overwrite an existing config") + flags.BoolVar(&opts.PrintOnly, "print", false, "print the config instead of writing it") + flags.Usage = func() { + fmt.Fprintf(flags.Output(), "usage: msa-agent enroll --relay [--code ] [flags]\n\n") + fmt.Fprintf(flags.Output(), "Mints this node's keys and identifiers and writes its config. With --code it also\n") + fmt.Fprintf(flags.Output(), "registers itself with the relay, which is what makes the one-line install work.\n\n") + flags.PrintDefaults() + } + if err := flags.Parse(args); err != nil { + return 2 + } + if opts.RelayURL == "" { + flags.Usage() + return 2 + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + if err := runEnroll(context.Background(), opts); err != nil { + fmt.Fprintln(os.Stderr, "msa-agent enroll:", err) + return 1 + } + return 0 +} + +func runUninstallCommand(args []string) int { + flags := flag.NewFlagSet("uninstall", flag.ExitOnError) + var opts install.Options + configPath := flags.String("config", "/etc/msa-agent/config.json", "path to the agent config file") + flags.BoolVar(&opts.KeepLogs, "keep-logs", false, "leave the audit log in place") + flags.BoolVar(&opts.DryRun, "dry-run", false, "print what would be removed and remove nothing") + flags.Usage = func() { + fmt.Fprintf(flags.Output(), "usage: msa-agent uninstall [--dry-run] [--keep-logs]\n\n") + fmt.Fprintf(flags.Output(), "Removes the agent: the relay is told first, then the service, unit, config\n") + fmt.Fprintf(flags.Output(), "directory, logs and system user. Needs root.\n\n") + flags.PrintDefaults() + } + if err := flags.Parse(args); err != nil { + return 2 + } + opts.Log = func(format string, args ...any) { fmt.Printf(format+"\n", args...) } + if err := runUninstall(context.Background(), opts, *configPath); err != nil { + fmt.Fprintln(os.Stderr, "msa-agent uninstall:", err) + return 1 + } + return 0 +} diff --git a/cmd/msa-keygen/main.go b/cmd/msa-keygen/main.go index 197f570..752536c 100644 --- a/cmd/msa-keygen/main.go +++ b/cmd/msa-keygen/main.go @@ -1,29 +1,22 @@ -// Command msa-keygen mints the key material and identifiers a node needs, and prints a config file -// skeleton to stdout. +// Command msa-keygen prints the key material, identifiers and secrets a node needs, as a config file +// and a relay registration entry. // -// It exists because the agent's config cannot be written by hand: the two private keys are raw -// 32-byte values that must be base64, and a node id, an agent token and a device id all have to -// match patterns the agent and the relay both enforce. An operator generating those with openssl and -// a text editor will eventually produce a file that fails at startup with a validation error whose -// cause is a character in the wrong place. +// It is a thin wrapper over internal/enroll, which is also what `msa-agent enroll` calls. The two must +// not drift: one writes the config an operator places, the other writes it on the node, and a +// disagreement between them would show up as a node that pairs and then cannot authenticate. // -// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing -// time, and never leave it — a tool that could mint a device key would be a tool that can impersonate -// a phone. +// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing time, +// and never leave it — a tool that could mint a device key would be a tool that can impersonate a +// phone. package main import ( - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "encoding/json" "flag" "fmt" "os" "path/filepath" - "fun.chick/msa-agent/internal/wire" + "fun.chick/msa-agent/internal/enroll" ) func main() { @@ -38,95 +31,36 @@ func main() { ) flag.Parse() - // The signing key is Ed25519 for challenge-response at the relay and for signing responses. - signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader) - check(err) - // The key-agreement key is raw X25519: clamping is the curve library's job, and a stored key - // that had already been clamped would be a second, non-standard encoding to keep in sync across - // three languages. - kxKey := make([]byte, wire.KeyBytes) - _, err = rand.Read(kxKey) - check(err) - - id := *nodeID - if id == "" { - id = "nd_" + randomHex(5) - } - token := *agentToken - if token == "" { - token = randomHex(32) - } - // The pairing secret is generated even though it is only needed for an unprotected node: a - // config without one cannot pair at all, and discovering that at pairing time means another trip - // to the node. When the node *is* protected, `nodePassword` takes precedence over this value, so - // carrying it is harmless. - pairingSecret := *secret - if pairingSecret == "" { - pairingSecret = randomHex(16) - } - - config := map[string]any{ - "nodeId": id, - "relayUrl": *relayURL, - "agentToken": token, - "localPort": *localPort, - "signKey": base64.StdEncoding.EncodeToString(signingKey), - "kxKey": base64.StdEncoding.EncodeToString(kxKey), - // The node password is intentionally absent: it is the operator's, and it is the one secret - // this tool must not invent. An unprotected node needs no value; a protected one needs the - // real password or every command fails with node_auth_failed. - "auditPath": *auditPath, - // Only used to authorise the pairing window on an unprotected node. Delete it after pairing - // if you like; nothing else reads it. - "provisionSecret": pairingSecret, - // Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only - // path that should ever be able to introduce a trusted phone. - "devices": []any{}, - } - - encoded, err := json.MarshalIndent(config, "", " ") - check(err) - encoded = append(encoded, '\n') - - // The relay registration carries the agent's *public* signing key as well as the bearer token. - // Both are required: the token is only an abuse-control handle, and the signature over the relay's - // challenge is what proves the caller holds the node's private key. A registration without the - // key makes the relay refuse to start rather than start and reject every node, which is the - // failure mode that shipped when the key was omitted. - registration, err := json.Marshal(map[string]string{ - "nodeID": id, - "token": token, - "signKey": base64.StdEncoding.EncodeToString(signingPublic), + result, err := enroll.Generate(enroll.Options{ + RelayURL: *relayURL, + LocalPort: *localPort, + AuditPath: *auditPath, + NodeID: *nodeID, + AgentToken: *agentToken, + ProvisionSecret: *secret, }) check(err) if *write == "" { - // The relay's own registration is printed alongside, because the two files have to agree on - // the node id and token and copying them by hand is where that agreement breaks. + // The relay's own registration is printed alongside, because the two files have to agree on the + // node id, the token and the signing key, and copying them by hand is where that agreement breaks. fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json") - os.Stdout.Write(encoded) + os.Stdout.Write(result.Config) fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n") - fmt.Fprintf(os.Stderr, "%s\n", registration) - fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", pairingSecret) + fmt.Fprintf(os.Stderr, "%s\n", result.Registration) + fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", result.ProvisionSecret) fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n") return } if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil { check(err) } - // 0600: this file holds the agent's private keys and the node's password. - if err := os.WriteFile(*write, encoded, 0o600); err != nil { + // 0600: this file holds the agent's private keys and, on a protected node, the node password. + if err := os.WriteFile(*write, result.Config, 0o600); err != nil { check(err) } fmt.Printf("wrote %s (mode 0600)\n", *write) - fmt.Printf("relay registration: %s\n", registration) -} - -func randomHex(bytes int) string { - raw := make([]byte, bytes) - _, err := rand.Read(raw) - check(err) - return hex.EncodeToString(raw) + fmt.Printf("relay registration: %s\n", result.Registration) } func check(err error) { diff --git a/internal/enroll/enroll.go b/internal/enroll/enroll.go new file mode 100644 index 0000000..737774b --- /dev/null +++ b/internal/enroll/enroll.go @@ -0,0 +1,164 @@ +// Package enroll mints the key material, identifiers and secrets a node needs, and renders the two +// artefacts an operator has to place: the agent's config file and the relay's registration entry. +// +// It exists as a package rather than as a command because two callers need the same thing and must +// not drift. `msa-keygen` was the first, and it required a Go toolchain on whichever machine ran it — +// fine when that was the operator's laptop, wrong now that enrollment happens on the node itself, +// where there is a cross-compiled binary and no toolchain. `msa-agent enroll` is the second caller, +// and it is the same code path, so the two can never disagree about what a config looks like. +// +// What it deliberately does NOT do: invent the node's SimAdmin password. That is the operator's, and +// it is the one secret a tool must not make up. An unprotected node needs no value; a protected one +// needs the real one, or every command fails with node_auth_failed and the cause is not obvious. +package enroll + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + + "fun.chick/msa-agent/internal/wire" +) + +// Options are what the caller knows and cannot derive. +type Options struct { + // RelayURL is the relay this node will connect to. https; the agent refuses anything else. + RelayURL string + // LocalPort is the SimAdmin port on 127.0.0.1. There is deliberately no host. + LocalPort int + // AuditPath is where the audit log is appended. + AuditPath string + // NodeID, AgentToken and ProvisionSecret are generated when empty. They are settable so an + // operator re-enrolling a node can keep the identifiers the relay already knows. + NodeID string + AgentToken string + ProvisionSecret string + // RelayCAFile pins the relay's CA instead of the system trust store. Optional. + RelayCAFile string + // NodePassword is written into the config when non-empty. Never generated. + NodePassword string +} + +// Result is everything the caller has to place or show. +type Result struct { + // Config is the agent config file, ready to write 0600. + Config []byte + // Registration is the relay's agents.json entry, as compact JSON. + Registration []byte + // NodeID and ProvisionSecret are echoed because both have to be shown to the operator. + NodeID string + ProvisionSecret string +} + +// Registration is the relay's view of this node: an id, a bearer token, and the public key that +// verifies its handshake signatures. +// +// The signing key is the part that cannot be omitted, and the reason is not tidiness. The relay +// refuses to start when an entry has no usable key — it did ship once with an optional verifier that +// nobody supplied, and the result was a relay that reported healthy and answered every node with 500. +type Registration struct { + NodeID string `json:"nodeID"` + Token string `json:"token"` + SignKey string `json:"signKey"` +} + +// Generate mints the material and renders both artefacts. +func Generate(opts Options) (Result, error) { + if opts.RelayURL == "" { + return Result{}, fmt.Errorf("enroll: relay URL is required") + } + if opts.LocalPort <= 0 || opts.LocalPort > 65535 { + return Result{}, fmt.Errorf("enroll: local port %d is out of range", opts.LocalPort) + } + if opts.AuditPath == "" { + return Result{}, fmt.Errorf("enroll: audit path is required") + } + + // The signing key is Ed25519: it answers the relay's handshake challenge and signs responses. + signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return Result{}, fmt.Errorf("enroll: generating the signing key: %w", err) + } + // The key-agreement key is raw X25519. Clamping is the curve library's job, and a stored key that + // had already been clamped would be a second, non-standard encoding to keep in sync across three + // languages. + kxKey := make([]byte, wire.KeyBytes) + if _, err := rand.Read(kxKey); err != nil { + return Result{}, fmt.Errorf("enroll: generating the key-agreement key: %w", err) + } + + nodeID := opts.NodeID + if nodeID == "" { + nodeID = "nd_" + randomHex(5) + } + token := opts.AgentToken + if token == "" { + token = randomHex(32) + } + // The pairing secret is minted even though an unprotected node is the only thing that needs it: a + // config without one cannot pair at all, and discovering that at pairing time means another trip to + // the node. When the node *is* protected, nodePassword takes precedence and this value is unused. + pairingSecret := opts.ProvisionSecret + if pairingSecret == "" { + pairingSecret = randomHex(16) + } + + config := map[string]any{ + "nodeId": nodeID, + "relayUrl": opts.RelayURL, + "agentToken": token, + "localPort": opts.LocalPort, + "signKey": base64.StdEncoding.EncodeToString(signingKey), + "kxKey": base64.StdEncoding.EncodeToString(kxKey), + "auditPath": opts.AuditPath, + // Only used to authorise the pairing window on an unprotected node. Deleting it after pairing + // is fine; nothing else reads it. + "provisionSecret": pairingSecret, + // Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only path + // that should ever be able to introduce a trusted phone. + "devices": []any{}, + } + // Absent rather than empty when unset, so a config that has not been told about a password does not + // claim the node has none. + if opts.NodePassword != "" { + config["nodePassword"] = opts.NodePassword + } + if opts.RelayCAFile != "" { + config["relayCAFile"] = opts.RelayCAFile + } + + encoded, err := json.MarshalIndent(config, "", " ") + if err != nil { + return Result{}, fmt.Errorf("enroll: encoding the config: %w", err) + } + encoded = append(encoded, '\n') + + registration, err := json.Marshal(Registration{ + NodeID: nodeID, + Token: token, + SignKey: base64.StdEncoding.EncodeToString(signingPublic), + }) + if err != nil { + return Result{}, fmt.Errorf("enroll: encoding the registration: %w", err) + } + + return Result{ + Config: encoded, + Registration: registration, + NodeID: nodeID, + ProvisionSecret: pairingSecret, + }, nil +} + +func randomHex(bytes int) string { + raw := make([]byte, bytes) + if _, err := rand.Read(raw); err != nil { + // rand.Read only fails if the system entropy source is gone, in which case nothing here can run + // safely: a key minted from a broken source is worse than no key. + panic(fmt.Sprintf("enroll: entropy source failed: %v", err)) + } + return hex.EncodeToString(raw) +} diff --git a/internal/enroll/enroll_test.go b/internal/enroll/enroll_test.go new file mode 100644 index 0000000..112e133 --- /dev/null +++ b/internal/enroll/enroll_test.go @@ -0,0 +1,144 @@ +package enroll + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +// The two artefacts have to agree, and that agreement is the whole reason this is a package rather +// than two commands: the config carries the private signing key, the registration carries the public +// one, and a node whose relay entry was minted from a different key pair authenticates nowhere. +func TestTheConfigAndTheRegistrationDescribeTheSameNode(t *testing.T) { + result, err := Generate(Options{ + RelayURL: "https://relay.example", + LocalPort: 3000, + AuditPath: "/var/log/msa-agent/audit.log", + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + var cfg struct { + NodeID string `json:"nodeId"` + RelayURL string `json:"relayUrl"` + AgentToken string `json:"agentToken"` + LocalPort int `json:"localPort"` + SignKey string `json:"signKey"` + KxKey string `json:"kxKey"` + AuditPath string `json:"auditPath"` + Devices []any `json:"devices"` + } + if err := json.Unmarshal(result.Config, &cfg); err != nil { + t.Fatalf("the config is not valid JSON: %v", err) + } + var reg Registration + if err := json.Unmarshal(result.Registration, ®); err != nil { + t.Fatalf("the registration is not valid JSON: %v", err) + } + + if cfg.NodeID != reg.NodeID || cfg.NodeID != result.NodeID { + t.Fatalf("node ids disagree: config=%q registration=%q result=%q", cfg.NodeID, reg.NodeID, result.NodeID) + } + if cfg.AgentToken != reg.Token { + t.Fatal("the config's bearer and the registration's token differ; the agent would 401") + } + if cfg.RelayURL != "https://relay.example" || cfg.LocalPort != 3000 { + t.Fatalf("relay or port lost: %+v", cfg) + } + if cfg.Devices == nil || len(cfg.Devices) != 0 { + t.Fatalf("devices must start empty, got %v", cfg.Devices) + } + + // The private key is 64 bytes; its last 32 are the public key, which is what the relay verifies. + private, err := base64.StdEncoding.DecodeString(cfg.SignKey) + if err != nil || len(private) != ed25519.PrivateKeySize { + t.Fatalf("signKey is not a base64 ed25519 private key: %v (%d bytes)", err, len(private)) + } + public, err := base64.StdEncoding.DecodeString(reg.SignKey) + if err != nil || len(public) != ed25519.PublicKeySize { + t.Fatalf("the registration's signKey is not a base64 ed25519 public key: %v (%d bytes)", err, len(public)) + } + if string(private[ed25519.PrivateKeySize-ed25519.PublicKeySize:]) != string(public) { + t.Fatal("the relay's registration carries a key that does not belong to the config's private key") + } +} + +// The node password is the one secret this tool must not invent. An unprotected node needs no value; +// a protected one needs the real one, or every command fails with node_auth_failed and the cause is +// not obvious from the phone. +func TestTheNodePasswordIsOnlyWrittenWhenGiven(t *testing.T) { + without, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(without.Config), "nodePassword") { + t.Fatal("a config with no password claims one") + } + with, err := Generate(Options{ + RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a", NodePassword: "hunter2", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(with.Config), `"nodePassword": "hunter2"`) { + t.Fatal("the given password was not written") + } +} + +// Re-enrolling a node has to be able to keep the identifiers the relay already knows, or the operator +// has two edits to make instead of one. +func TestGivenIdentifiersAreKept(t *testing.T) { + result, err := Generate(Options{ + RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a", + NodeID: "nd_keepme0001", AgentToken: strings.Repeat("a", 64), ProvisionSecret: "kept-secret", + }) + if err != nil { + t.Fatal(err) + } + var reg Registration + if err := json.Unmarshal(result.Registration, ®); err != nil { + t.Fatal(err) + } + if reg.NodeID != "nd_keepme0001" || reg.Token != strings.Repeat("a", 64) { + t.Fatalf("identifiers were regenerated: %+v", reg) + } + if result.ProvisionSecret != "kept-secret" { + t.Fatal("the pairing secret was regenerated") + } +} + +// Two enrollments must not produce the same keys or ids. A generator that repeated a key would make +// two nodes able to impersonate each other, and it is the kind of bug a seeded RNG introduces once. +func TestTwoEnrollmentsDiffer(t *testing.T) { + first, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"}) + if err != nil { + t.Fatal(err) + } + second, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"}) + if err != nil { + t.Fatal(err) + } + if first.NodeID == second.NodeID { + t.Fatal("two enrollments produced the same node id") + } + if string(first.Config) == string(second.Config) { + t.Fatal("two enrollments produced the same config, so the same keys") + } +} + +func TestMissingInputIsRefused(t *testing.T) { + cases := map[string]Options{ + "no relay": {LocalPort: 3000, AuditPath: "/a"}, + "bad port": {RelayURL: "https://relay.example", LocalPort: 0, AuditPath: "/a"}, + "huge port": {RelayURL: "https://relay.example", LocalPort: 70000, AuditPath: "/a"}, + "no audit": {RelayURL: "https://relay.example", LocalPort: 3000}, + } + for name, opts := range cases { + if _, err := Generate(opts); err == nil { + t.Fatalf("%s was accepted", name) + } + } +} diff --git a/internal/enroll/register.go b/internal/enroll/register.go new file mode 100644 index 0000000..fcf501d --- /dev/null +++ b/internal/enroll/register.go @@ -0,0 +1,140 @@ +package enroll + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Register announces this node to the relay, using a one-time code an operator generated in the app. +// +// This is the step that lets a node join without anyone editing the relay's agents.json, and it is the +// only place in this system where the relay learns about a node it did not already know. What the code +// buys is bounded and worth stating: it authorises *registration*, nothing else. The relay still cannot +// read a request, cannot forge an answer, and cannot command anything — the phone's signature and the +// agent's pinned keys are unchanged. The worst a stolen code can do is make a node appear, which is a +// denial of service, the outcome the design already accepts for a compromised relay. +type RegisterOptions struct { + // RelayURL is the relay's base URL, https. + RelayURL string + // Code is the one-time enrollment code. + Code string + // Registration is the entry to install: node id, bearer token, signing public key. + Registration Registration + // RelayCAFile pins a CA instead of the system trust store. Same meaning as the agent's own + // relayCAFile: it replaces the trust root rather than disabling verification. + RelayCAFile string + // Timeout bounds the whole exchange. A node with no route to the relay should fail in seconds, not + // hang the install. + Timeout time.Duration +} + +// Register posts the registration and returns nil when the relay accepted it. +// +// The failure vocabulary is deliberately small and each case names the next action, because this runs +// during an install and the operator is looking at one line of output. +func Register(ctx context.Context, opts RegisterOptions) error { + parsed, err := url.Parse(opts.RelayURL) + if err != nil { + return fmt.Errorf("enroll: relay url: %w", err) + } + if parsed.Scheme != "https" { + // https is not negotiable here for the same reason the agent refuses it: a registration sent in + // the clear can be replayed by anyone who sees it, and the code is single-use. + return fmt.Errorf("enroll: relay url must be https, got %q", parsed.Scheme) + } + if strings.TrimSpace(opts.Code) == "" { + return fmt.Errorf("enroll: the enrollment code is empty") + } + + body, err := json.Marshal(struct { + Code string `json:"code"` + Registration + }{Code: opts.Code, Registration: opts.Registration}) + if err != nil { + return fmt.Errorf("enroll: encoding the registration: %w", err) + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = 20 * time.Second + } + client := &http.Client{Timeout: timeout} + if opts.RelayCAFile != "" { + pem, err := os.ReadFile(opts.RelayCAFile) + if err != nil { + return fmt.Errorf("enroll: relayCAFile: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return fmt.Errorf("enroll: relayCAFile %s contains no PEM certificates", opts.RelayCAFile) + } + // A fresh transport rather than a mutated DefaultTransport, so this does not change trust for + // anything else in the process. + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + } + } + + endpoint := strings.TrimRight(opts.RelayURL, "/") + "/v1/enroll" + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("enroll: request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("enroll: cannot reach the relay at %s: %w", endpoint, err) + } + defer func() { + io.Copy(io.Discard, response.Body) + response.Body.Close() + }() + + if response.StatusCode == http.StatusOK { + return nil + } + // The relay's refusals are the ones an operator has to act on, so the detail is passed through + // rather than replaced with a status code. + detail := readDetail(response.Body) + switch response.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("enroll: the relay refused the code (%s). Codes are single-use and short-lived; "+ + "generate a new install command in the app", detail) + case http.StatusConflict: + return fmt.Errorf("enroll: the relay already knows this node (%s)", detail) + case http.StatusNotFound: + return fmt.Errorf("enroll: this relay does not accept self-enrollment (%s). Add this node to its "+ + "agents.json by hand, or enable enrollment on the relay", detail) + default: + return fmt.Errorf("enroll: the relay refused the registration: %d %s", response.StatusCode, detail) + } +} + +// readDetail pulls the RFC 9457 `detail` field out of a refusal, falling back to the raw body. +func readDetail(body io.Reader) string { + raw, err := io.ReadAll(io.LimitReader(body, 4<<10)) + if err != nil || len(raw) == 0 { + return "no detail" + } + var problem struct { + Detail string `json:"detail"` + Code string `json:"code"` + } + if err := json.Unmarshal(raw, &problem); err == nil && problem.Detail != "" { + if problem.Code != "" { + return problem.Code + ": " + problem.Detail + } + return problem.Detail + } + return strings.TrimSpace(string(raw)) +} diff --git a/internal/install/uninstall.go b/internal/install/uninstall.go new file mode 100644 index 0000000..3775bcb --- /dev/null +++ b/internal/install/uninstall.go @@ -0,0 +1,213 @@ +// 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) +} diff --git a/internal/install/uninstall_test.go b/internal/install/uninstall_test.go new file mode 100644 index 0000000..0ecf922 --- /dev/null +++ b/internal/install/uninstall_test.go @@ -0,0 +1,210 @@ +package install + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// recorder stands in for systemctl and userdel, so the sequence can be tested without systemd. +type recorder struct { + calls []string + // failOn makes a named command fail, for the cases where a missing unit or an absent user must not + // abort the uninstall. + failOn string +} + +func (r *recorder) Run(name string, args ...string) error { + call := name + " " + strings.Join(args, " ") + r.calls = append(r.calls, call) + if r.failOn != "" && strings.HasPrefix(call, r.failOn) { + return fmt.Errorf("simulated failure") + } + return nil +} + +// installed creates the layout an install leaves behind, so the test removes real files. +func installed(t *testing.T) Paths { + t.Helper() + root := t.TempDir() + paths := Paths{ + ServiceName: "msa-agent", + UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"), + ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"), + LogDir: filepath.Join(root, "var/log/msa-agent"), + BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"), + User: "msa-agent", + } + for _, path := range []string{paths.UnitPath, paths.ConfigPath, filepath.Join(paths.LogDir, "audit.log"), paths.BinaryPath} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + return paths +} + +func TestUninstallRemovesEverythingTheInstallPlaced(t *testing.T) { + paths := installed(t) + runner := &recorder{} + var lines []string + err := Uninstall(Options{ + Paths: paths, + Runner: runner, + Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, + NotifyRelay: func() error { + lines = append(lines, "relay notified") + return nil + }, + }) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.LogDir, paths.BinaryPath} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("%s survived the uninstall", path) + } + } + // The config directory goes with the config: it holds relay-ca.pem and anything else the operator + // put beside it. + if _, err := os.Stat(filepath.Dir(paths.ConfigPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("the config directory survived; it may still hold pinned CA material") + } + joined := strings.Join(runner.calls, "\n") + for _, want := range []string{"systemctl disable --now msa-agent", "systemctl daemon-reload", "userdel msa-agent"} { + if !strings.Contains(joined, want) { + t.Fatalf("expected %q among the commands, got:\n%s", want, joined) + } + } +} + +// The order is the point: the relay has to be told before the node stops existing, or it keeps routing +// to a node that is gone and a phone that believes it is paired points at nothing. +func TestUninstallTellsTheRelayBeforeItRemovesAnything(t *testing.T) { + paths := installed(t) + configStillThere := false + err := Uninstall(Options{ + Paths: paths, + Runner: &recorder{}, + Log: func(string, ...any) {}, + NotifyRelay: func() error { + // At the moment the relay is told, the config must still exist: the notification reads it. + _, statErr := os.Stat(paths.ConfigPath) + configStillThere = statErr == nil + return nil + }, + }) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if !configStillThere { + t.Fatal("the config was removed before the relay was told; the device list could not be read") + } +} + +// A relay that cannot be reached must not stop an uninstall. The operator is removing the node either +// way, and the alternative — aborting half way — leaves the machine in a state nobody described. +func TestUninstallCompletesWhenTheRelayIsUnreachable(t *testing.T) { + paths := installed(t) + var lines []string + err := Uninstall(Options{ + Paths: paths, + Runner: &recorder{}, + Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, + NotifyRelay: func() error { return fmt.Errorf("relay unreachable") }, + }) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("the uninstall did not finish") + } + // And the operator is told what is left to do by hand, because the relay still lists this node. + output := strings.Join(lines, "\n") + if !strings.Contains(output, "WARNING") || !strings.Contains(output, "agents.json") { + t.Fatalf("a failed relay notification was not explained:\n%s", output) + } +} + +// A dry run removes nothing, and that includes not stopping the service: an operator asking what an +// uninstall would do must not lose the node while they read the answer. +func TestDryRunRemovesNothingAndRunsNothing(t *testing.T) { + paths := installed(t) + runner := &recorder{} + var lines []string + err := Uninstall(Options{ + Paths: paths, + DryRun: true, + Runner: runner, + Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, + }) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if len(runner.calls) != 0 { + t.Fatalf("a dry run ran commands: %v", runner.calls) + } + for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.BinaryPath} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("a dry run removed %s", path) + } + } + if !strings.Contains(strings.Join(lines, "\n"), "dry run: nothing was removed") { + t.Fatal("a dry run did not say so") + } +} + +func TestKeepLogsLeavesTheAuditLog(t *testing.T) { + paths := installed(t) + audit := filepath.Join(paths.LogDir, "audit.log") + err := Uninstall(Options{ + Paths: paths, + KeepLogs: true, + Runner: &recorder{}, + Log: func(string, ...any) {}, + }) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if _, err := os.Stat(audit); err != nil { + t.Fatalf("--keep-logs removed the audit log: %v", err) + } +} + +// A missing unit or an absent user is the normal case on a partially installed node, and it must not +// abort: reporting it as an error would train the operator to ignore the errors that matter. +func TestMissingPiecesDoNotAbortTheUninstall(t *testing.T) { + paths := installed(t) + // Remove the unit first, then uninstall. + if err := os.Remove(paths.UnitPath); err != nil { + t.Fatal(err) + } + runner := &recorder{failOn: "systemctl disable"} + err := Uninstall(Options{ + Paths: paths, + Runner: runner, + Log: func(string, ...any) {}, + }) + if err != nil { + t.Fatalf("a missing unit aborted the uninstall: %v", err) + } + if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("the config survived") + } +} + +// The plan names every path, so an operator can check it before running anything. +func TestThePlanNamesEveryPath(t *testing.T) { + plan := DefaultPaths().String() + for _, want := range []string{"/etc/systemd/system/msa-agent.service", "/etc/msa-agent/config.json", + "/var/log/msa-agent", "/opt/msa-agent/msa-agent", "msa-agent"} { + if !strings.Contains(plan, want) { + t.Fatalf("the plan does not mention %s:\n%s", want, plan) + } + } +}