eleven / messenger Public

server: read back update pin/major writes — old binaries silently ignore unknown settings (#827)

An instance binary that predates a setting accepts the admin-settings POST
and ignores the unknown field, so lchat update pin/major (and deploy --pin)
reported success while storing nothing — a pin that didn't stick is how the
next hourly pass stomps a canary. pinHost and update major now re-read
/api/update-prefs and fail loudly, pointing at the fix (update the instance
first). Found live: update major vs a pre-0.11.0 build.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6be4dce8f8e3dcdae19adc3dede9e19e10f78e0c parent 1af9515
2 files changed, +84 −1
  • updater.go +26 −1
  • updater_test.go +58 −0
diff --git a/updater.go b/updater.go
index a074305..ff04903 100644
--- a/updater.go
+++ b/updater.go
@@ -632,8 +632,30 @@ func runUpdate(args []string) {
// over the unix socket — the pin is instance state (the same value the admin
// panel shows and /api/update-prefs reports), not a registry side-channel. The
// admin token comes from the instance's EnvironmentFile, like `admin-link`.
+// The write is read back before reporting success: a pin the operator
+// believes is set but isn't is exactly how the next hourly pass stomps a
+// canary.
func pinHost(rt *Route, pin string) error {
- return writeHostSetting(rt, "update_pin", pin)
+ if err := writeHostSetting(rt, "update_pin", pin); err != nil {
+ return err
+ }
+ return confirmHostSetting(rt, "pin", pin, func(p updatePrefs) string { return p.Pin })
+}
+
+// confirmHostSetting re-reads /api/update-prefs after a settings write and
+// errors when the value didn't stick. An instance binary that predates a
+// setting ACCEPTS the POST and silently ignores the unknown field — found
+// live: `update major` against a pre-0.11.0 build reported success while
+// storing nothing.
+func confirmHostSetting(rt *Route, what, want string, read func(updatePrefs) string) error {
+ p, err := fetchUpdatePrefs(rt.Socket)
+ if err != nil {
+ return fmt.Errorf("wrote the %s but couldn't read it back (%v) — verify with `lchat update status`", what, err)
+ }
+ if got := read(p); got != want {
+ return fmt.Errorf("%s accepted the %s write but reports %q — its binary likely predates the setting; update the instance first, then retry", rt.Host, what, got)
+ }
+ return nil
}
// writeHostSetting posts one settings key into the instance's own admin API —
@@ -744,6 +766,9 @@ func runUpdateMajor(args []string) {
if err := writeHostSetting(rt, "update_major", major); err != nil {
die("update major %s: %v", host, err)
}
+ if err := confirmHostSetting(rt, "major pin", major, func(p updatePrefs) string { return p.Major }); err != nil {
+ die("update major: %v", err)
+ }
if major == "" {
fmt.Printf("cleared %s's major pin (follows its channel fully from the next update pass)\n", host)
} else {
diff --git a/updater_test.go b/updater_test.go
index efc3cad..03b62d6 100644
--- a/updater_test.go
+++ b/updater_test.go
@@ -564,6 +564,13 @@ func TestPinHostWritesViaAdminAPI(t *testing.T) {
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
})
+ // A current binary reports the stored pin back — pinHost verifies against
+ // this before claiming success.
+ mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "auto_update": true, "auto_download": true, "channel": "stable", "pin": gotPin,
+ })
+ })
socket := filepath.Join(dir, "h1.sock")
ln, err := net.Listen("unix", socket)
if err != nil {
@@ -588,6 +595,57 @@ func TestPinHostWritesViaAdminAPI(t *testing.T) {
}
}
+// An instance binary that predates a setting accepts the settings POST and
+// silently ignores the unknown field. The read-back turns that into a loud
+// error instead of a false success (a pin that didn't stick is how the next
+// hourly pass stomps a canary; found live with `update major` vs a pre-0.11
+// build).
+func TestPinHostRefusesSilentlyIgnoredWrite(t *testing.T) {
+ dir, err := os.MkdirTemp("", "lch")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ prevDataDir := instanceDataDir
+ instanceDataDir = dir
+ t.Cleanup(func() { instanceDataDir = prevDataDir })
+ if err := os.WriteFile(filepath.Join(dir, "h1.example.com.env"), []byte("LCHAT_ADMIN_TOKEN=tok123\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ mux := http.NewServeMux()
+ // The old binary: 200 on the write, but the field never appears in prefs.
+ mux.HandleFunc("POST /api/admin/settings", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ })
+ mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "auto_update": true, "auto_download": true, "channel": "stable", "pin": "",
+ })
+ })
+ socket := filepath.Join(dir, "h1.sock")
+ ln, err := net.Listen("unix", socket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: mux}
+ go srv.Serve(ln)
+ t.Cleanup(func() { srv.Close() })
+
+ rt := &Route{Host: "h1.example.com", Kind: "instance", Socket: socket}
+ if err := pinHost(rt, "pin7777"); err == nil || !strings.Contains(err.Error(), "predates") {
+ t.Fatalf("silently ignored pin write reported success (err=%v)", err)
+ }
+ // Same helper, the major-pin shape: a prefs response with no "major" field
+ // decodes as "", so a wanted value must fail and a clear must pass.
+ if err := confirmHostSetting(rt, "major pin", "1", func(p updatePrefs) string { return p.Major }); err == nil {
+ t.Fatal("missing major field confirmed as set")
+ }
+ if err := confirmHostSetting(rt, "major pin", "", func(p updatePrefs) string { return p.Major }); err != nil {
+ t.Fatalf("clear should confirm against an old binary: %v", err)
+ }
+}
+
// entryFor is a syntactically valid manifest entry (validateManifest passes)
// whose binary URL nothing in the manifest tests ever downloads.
func entryFor(ver string) releaseEntry {