diff --git a/README.md b/README.md index 1446c9b..8bdc64b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ relay's `agents.json` by hand. Without it, `enroll` prints the entry to add. Removing it again is `sudo msa-agent uninstall`; `--dry-run` shows the plan first, and `--keep-logs` keeps the audit trail. +### After installing, pair + +The daemon refuses to start with no paired devices, on purpose: an agent that could authorise nothing +must not sit there looking healthy. Pairing happens after install, so a freshly installed node's service +will fail and stop — the unit's start limit makes systemd give up rather than restart every five seconds +forever, and `systemctl status msa-agent` shows it as failed rather than flapping. + +```sh +sudo /opt/msa-agent/msa-agent --pair # opens the LAN window; then pair from the app +``` + +Then `systemctl start msa-agent` (or just wait — it is enabled) and check `journalctl -u msa-agent -n 20` +for the policy digest and the device count. + ## Subcommands | Command | What it does | @@ -49,6 +63,7 @@ keeps the audit trail. | `uninstall` | Tells the relay first, then removes the service, unit, config, logs and user | | `version` | Prints the version stamped into the binary | | `--pair` | Opens the temporary LAN window a phone uses to pair; closes itself | +| `--pair-bind` | The address that window binds to (an IP literal; the default is all interfaces) | | `--check` | Validates the config and policy, then exits | | `--once` | Runs a single poll cycle and exits | diff --git a/internal/install/msa-agent.service b/internal/install/msa-agent.service index f13f226..39c401e 100644 --- a/internal/install/msa-agent.service +++ b/internal/install/msa-agent.service @@ -11,6 +11,17 @@ Restart=always # Backing off on our own is not enough: a relay that is down for an hour should not be hammered by # every node in the fleet the moment it comes back. RestartSec=5 +# A freshly installed node has no paired devices, and the daemon refuses to run in that state on +# purpose: an agent that could authorise nothing must not sit there looking healthy. But pairing happens +# *after* install — the operator runs `--pair` on the node and then pairs the phone — so between those +# two moments this unit would restart every five seconds, forever, filling the journal with the same +# line. Measured on a real kernel: exit 1, "devices is empty, so no phone could ever be authorised". +# +# StartLimitIntervalSec/StartLimitBurst make systemd give up instead: ten attempts inside five minutes +# and the unit enters `failed`, which is a state an operator can see and act on. The install command +# prints the next step precisely so that state is expected rather than surprising. +StartLimitIntervalSec=300 +StartLimitBurst=10 TimeoutStopSec=20 KillSignal=SIGTERM diff --git a/internal/install/service.go b/internal/install/service.go index 304ced2..114c82c 100644 --- a/internal/install/service.go +++ b/internal/install/service.go @@ -141,9 +141,13 @@ func Install(opts InstallOptions) error { opts.Log("dry run: nothing was changed") return nil } - opts.Log("msa-agent installed and running") - opts.Log("check it with: journalctl -u %s -n 20", paths.ServiceName) - opts.Log("next: run `msa-agent --pair` once, then pair the phone from the app") + // 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 } diff --git a/internal/install/uninstall_test.go b/internal/install/uninstall_test.go index a0a68a0..fde2340 100644 --- a/internal/install/uninstall_test.go +++ b/internal/install/uninstall_test.go @@ -426,3 +426,51 @@ func TestTheRemovalHintIsTheOneItWasGiven(t *testing.T) { t.Fatalf("the uninstall still claims every node is in agents.json:\n%s", output) } } + +// The install ends by starting a service that cannot run yet, and the unit has to say what to do about +// that. A freshly installed node has no paired devices — pairing happens after install — and the daemon +// refuses to run in that state on purpose, because an agent that could authorise nothing must not look +// healthy. With `Restart=always` and `RestartSec=5` and no start limit, that is a restart every five +// seconds until a phone pairs. +// +// Found by rehearsing the install on a real kernel: exit 1, "devices is empty, so no phone could ever be +// authorised", and the journal filling with the same line. +func TestTheUnitGivesUpInsteadOfRestartingForever(t *testing.T) { + unit := string(UnitFile()) + if !strings.Contains(unit, "Restart=always") { + t.Skip("the unit no longer restarts, so there is nothing to bound") + } + if !strings.Contains(unit, "StartLimitIntervalSec=") || !strings.Contains(unit, "StartLimitBurst=") { + t.Fatal("the unit restarts without a start limit: an unpaired node would restart every RestartSec forever") + } + // And the install tells the operator what to do next, so the failed state is expected rather than + // surprising. + var lines []string + 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.BinaryPath, paths.ConfigPath} { + 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) + } + } + if err := Install(InstallOptions{ + Paths: paths, + Runner: &recorder{failOn: "getent"}, + Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, + }); err != nil { + t.Fatalf("Install: %v", err) + } + if !strings.Contains(strings.Join(lines, "\n"), "--pair") { + t.Fatalf("the install does not say to pair next:\n%s", strings.Join(lines, "\n")) + } +} diff --git a/systemd/msa-agent.service b/systemd/msa-agent.service index f13f226..39c401e 100644 --- a/systemd/msa-agent.service +++ b/systemd/msa-agent.service @@ -11,6 +11,17 @@ Restart=always # Backing off on our own is not enough: a relay that is down for an hour should not be hammered by # every node in the fleet the moment it comes back. RestartSec=5 +# A freshly installed node has no paired devices, and the daemon refuses to run in that state on +# purpose: an agent that could authorise nothing must not sit there looking healthy. But pairing happens +# *after* install — the operator runs `--pair` on the node and then pairs the phone — so between those +# two moments this unit would restart every five seconds, forever, filling the journal with the same +# line. Measured on a real kernel: exit 1, "devices is empty, so no phone could ever be authorised". +# +# StartLimitIntervalSec/StartLimitBurst make systemd give up instead: ten attempts inside five minutes +# and the unit enters `failed`, which is a state an operator can see and act on. The install command +# prints the next step precisely so that state is expected rather than surprising. +StartLimitIntervalSec=300 +StartLimitBurst=10 TimeoutStopSec=20 KillSignal=SIGTERM