eleven / messenger Public

server: box-side auto-updater — lchat update run/status + hourly timer (#383)

The piece that acts on the release channels: a root oneshot (fired hourly by
lchat-update.timer, installed + enabled by bootstrap) that fetches the signed
manifest, reads each ready instance's admin prefs over its unix socket
(404 = defaults: on/on/stable), downloads + sha256-verifies + stages the
channel's binary, and applies it via the shared deploy dance.

- control.go: the per-host apply block extracted from runDeploy into
  deployHostVersion (die-free, returned errors) — one implementation of
  repoint/restart/health-check/rollback for deploy and the updater
- a box-wide flock (/opt/lchat/.deploy.lock) serialises deploy, rollback and
  update run, so a timer pass can't interleave an operator's deploy
- anti-replay: newest accepted generated_at persisted to
  /opt/lchat/manifest-stamp; older signed manifests are refused
- a failed health check poisons that version for the rest of the pass;
  per-host errors log and continue (an unreachable instance never strands
  the fleet)
- version-store GC after each pass (keep: route current/prev, channel heads,
  live symlink targets, the control binary's own build) + dl-* sweep
- lchat update status: HOST/CHANNEL/AUTO/CURRENT/AVAILABLE fleet table
- validateManifest now allows plain-http URLs on loopback only (local feeds
  and tests); remote stays https-only
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell aa6709b1d3e9b75114b12c6c18bc36b20491448b parent c405f72
12 files changed, +941 −45
  • bootstrap.go +17 −1
  • control.go +67 −19
  • deploy/systemd/lchat-update.service +31 −0
  • deploy/systemd/lchat-update.timer +12 −0
  • docs/instance-router.md +37 −0
  • docs/server-releases.md +2 −2
  • main.go +3 −0
  • release.go +14 −2
  • updatecheck.go +3 −21
  • updater.go +387 −0
  • updater_test.go +362 −0
  • versions.go +6 −0
diff --git a/bootstrap.go b/bootstrap.go
index 573aa04..3064b5f 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -44,6 +44,17 @@ var unitControl string
//go:embed deploy/systemd/lchat-cloud.service
var unitCloud string
+// The auto-updater (updater.go): a root oneshot fired hourly by its timer.
+// Enabled by bootstrap — a pass with no reachable release feed is a no-op, so
+// it's safe everywhere, and a box provisioned tomorrow auto-updates without a
+// runbook step anyone has to remember.
+//
+//go:embed deploy/systemd/lchat-update.service
+var unitUpdate string
+
+//go:embed deploy/systemd/lchat-update.timer
+var unitUpdateTimer string
+
// serviceUser is the unprivileged account every unit runs as (see the hardened
// units in deploy/systemd). bootstrap creates it and hands it the data + runtime
// dirs; the router keeps CAP_NET_BIND_SERVICE to still bind :80/:443.
@@ -142,6 +153,8 @@ func runBootstrap(args []string) {
write(filepath.Join(*unitDir, "lchat-home.service"), unitHome)
write(filepath.Join(*unitDir, "lchat-control.service"), unitControl)
write(filepath.Join(*unitDir, "lchat-cloud.service"), unitCloud)
+ write(filepath.Join(*unitDir, "lchat-update.service"), unitUpdate)
+ write(filepath.Join(*unitDir, "lchat-update.timer"), unitUpdateTimer)
write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf)
if err := ensureControlToken(); err != nil {
@@ -158,6 +171,9 @@ func runBootstrap(args []string) {
if err := systemctl("enable", "--now", "lchat-router"); err != nil {
die("bootstrap: start router: %v", err)
}
- fmt.Println("bootstrap: router installed and started")
+ if err := systemctl("enable", "--now", "lchat-update.timer"); err != nil {
+ die("bootstrap: enable update timer: %v", err)
+ }
+ fmt.Println("bootstrap: router installed and started (auto-update timer enabled)")
fmt.Println(" next: lchat add-instance <host>")
}
diff --git a/control.go b/control.go
index 180dfb7..a786c5c 100644
--- a/control.go
+++ b/control.go
@@ -14,6 +14,7 @@ import (
"regexp"
"sort"
"strings"
+ "syscall"
"time"
)
@@ -509,6 +510,12 @@ func runDeploy(args []string) {
}
}
+ unlock, err := acquireDeployLock()
+ if err != nil {
+ die("deploy: %v", err)
+ }
+ defer unlock()
+
reg, err := openRegistry(*registryPath)
if err != nil {
die("deploy: open registry: %v", err)
@@ -544,31 +551,72 @@ func runDeploy(args []string) {
}
// --version: repoint, restart, verify the build took, roll back if not.
- prev, _ := liveVersion(host) // "" if this instance isn't versioned yet
- if prev == ver {
- fmt.Printf("deployed %s (already on %s)\n", host, ver)
- continue
- }
- if err := pointLive(host, ver); err != nil {
+ changed, err := deployHostVersion(reg, rt, unit, ver)
+ if err != nil {
die("deploy %s: %v", host, err)
}
- if err := systemctl("restart", unit); err != nil {
- rollbackLive(host, prev, unit, rt.Socket)
- die("deploy %s: restart: %v (rolled back)", host, err)
- }
- if err := waitHealthyVersion(rt.Socket, ver, 20*time.Second); err != nil {
- fmt.Printf("deploy %s: %v — rolling back to %q\n", host, err, prev)
- rollbackLive(host, prev, unit, rt.Socket)
- die("deploy %s: failed health check, rolled back to %q", host, prev)
- }
- rt.PrevVersion, rt.Version = prev, ver
- if err := reg.upsert(*rt); err != nil {
- die("deploy %s: record version: %v", host, err)
+ if changed {
+ fmt.Printf("deployed %s @ %s\n", host, ver)
+ } else {
+ fmt.Printf("deployed %s (already on %s)\n", host, ver)
}
- fmt.Printf("deployed %s @ %s\n", host, ver)
}
}
+// deployHostVersion is the one per-host apply dance, shared by `deploy
+// --version` and the auto-updater (updater.go): repoint the live symlink at a
+// staged version, restart, verify the instance actually reports that build,
+// roll back on failure, and record current+previous in the registry. Returns
+// changed=false when the host was already on ver.
+//
+// deployHealthTimeout is a var so tests can shrink the failure path's wait.
+var deployHealthTimeout = 20 * time.Second
+
+func deployHostVersion(reg *Registry, rt *Route, unit, ver string) (changed bool, err error) {
+ host := rt.Host
+ prev, _ := liveVersion(host) // "" if this instance isn't versioned yet
+ if prev == ver {
+ return false, nil
+ }
+ if err := pointLive(host, ver); err != nil {
+ return false, err
+ }
+ if err := systemctl("restart", unit); err != nil {
+ rollbackLive(host, prev, unit, rt.Socket)
+ return false, fmt.Errorf("restart: %v (rolled back to %q)", err, prev)
+ }
+ if err := waitHealthyVersion(rt.Socket, ver, deployHealthTimeout); err != nil {
+ rollbackLive(host, prev, unit, rt.Socket)
+ return false, fmt.Errorf("failed health check (%v), rolled back to %q", err, prev)
+ }
+ rt.PrevVersion, rt.Version = prev, ver
+ if err := reg.upsert(*rt); err != nil {
+ return true, fmt.Errorf("record version: %v", err)
+ }
+ return true, nil
+}
+
+// acquireDeployLock serialises everything that runs the repoint/restart/
+// health-check dance — deploy, rollback, and the timer-fired `update run` —
+// across processes, so an auto-update can never interleave with an operator's
+// deploy mid-swap. Non-blocking: the second caller gets a clear error rather
+// than silently queueing behind an unknown amount of work.
+func acquireDeployLock() (func(), error) {
+ path := filepath.Join(optDir, ".deploy.lock")
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
+ if err != nil {
+ return nil, err
+ }
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ f.Close()
+ return nil, fmt.Errorf("another deploy/rollback/update is running (%s is held)", path)
+ }
+ return func() {
+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
+ f.Close()
+ }, nil
+}
+
// rollbackLive best-effort restores an instance to prev after a failed
// --version deploy: repoint the symlink and restart. If there is no prev (the
// instance had no version yet) it leaves the failed link in place — the operator
diff --git a/deploy/systemd/lchat-update.service b/deploy/systemd/lchat-update.service
new file mode 100644
index 0000000..3599b1f
--- /dev/null
+++ b/deploy/systemd/lchat-update.service
@@ -0,0 +1,31 @@
+[Unit]
+Description=lchat auto-updater (one release-channel update pass)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+# Optional override: LCHAT_UPDATE_MANIFEST_URL (point a box at another feed).
+EnvironmentFile=-/data/update.env
+ExecStart=/opt/lchat/lchat update run
+
+# --- hardening (see docs/instance-router.md § Hardening) ---
+# Runs as ROOT deliberately, like lchat-control: an update pass writes the
+# version store (/opt/lchat) and restarts instance units via systemctl. It is
+# fired by lchat-update.timer, never routed or listening. NoNewPrivileges and
+# the syscall filter are omitted for the systemctl child; the rest applies.
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+MemoryMax=256M
+TasksMax=64
diff --git a/deploy/systemd/lchat-update.timer b/deploy/systemd/lchat-update.timer
new file mode 100644
index 0000000..81dc1fc
--- /dev/null
+++ b/deploy/systemd/lchat-update.timer
@@ -0,0 +1,12 @@
+[Unit]
+Description=hourly lchat release-channel update check
+
+[Timer]
+# A short boot delay so instances are up before the first pass; the random
+# spread keeps a fleet of boxes from hitting the feed in the same second.
+OnBootSec=5min
+OnUnitActiveSec=1h
+RandomizedDelaySec=10min
+
+[Install]
+WantedBy=timers.target
diff --git a/docs/instance-router.md b/docs/instance-router.md
index 1f2e1d0..2004cc9 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -402,6 +402,43 @@ returned 200.
The router itself changes rarely; when it does, its own front listener gets
zero-downtime via `SO_REUSEPORT` / systemd socket activation.
+## 🤖 Auto-updating (release channels)
+
+Instances can keep themselves current without an operator driving `deploy` —
+built on the versioned-deploy machinery above plus the signed release feed
+described in `docs/server-releases.md`:
+
+- **`lchat update run`** (fired hourly by `lchat-update.timer`, installed +
+ enabled by `bootstrap`; root, like `lchat-control`) fetches and
+ Ed25519-verifies the channel manifest, asks each *ready* instance what its
+ admin chose (`GET /api/update-prefs` over its unix socket — a pre-feature
+ binary 404s, which reads as the defaults: auto-update on, stable channel),
+ then per instance: downloads + sha256-checks + stages the channel's binary
+ and applies it with the exact `deploy --version` dance (repoint → restart →
+ health-check → auto-rollback). A version that fails its health check on one
+ host is not tried on the rest that pass ("poisoned" for the run); other
+ per-host failures log and continue, so one unreachable instance never
+ strands the fleet.
+- **Prefs are per-instance admin settings** (`update_auto`, `update_download`,
+ `update_channel` in the instance's own config table, set from the admin
+ consoles). Download-only means "staged, apply manually with
+ `lchat deploy <host> --version <ver>`". **A pinned canary gets stomped like
+ `deploy --all` would** — flip that instance's auto-update off first.
+- **A deploy flock** (`/opt/lchat/.deploy.lock`) serialises `deploy`,
+ `rollback`, and `update run` across processes; the loser errors immediately
+ ("another deploy/rollback/update is running") instead of interleaving the
+ swap dance. A timer pass that loses simply retries next tick.
+- **Anti-replay**: the newest accepted `generated_at` persists in
+ `/opt/lchat/manifest-stamp`; older (validly signed but stale) manifests are
+ refused, so the feed can be pulled backward only by *re-signing* — a
+ deliberate act with the key.
+- **GC**: after a pass, version-store dirs nothing references (no route's
+ current/previous, no channel head, no live symlink, not the control binary's
+ own build) are pruned, and crashed `dl-*` downloads in `incoming/` are
+ swept — hourly edge updates stay disk-bounded.
+- **`lchat update status`** prints HOST / CHANNEL / AUTO / CURRENT / AVAILABLE
+ across the fleet; `lchat update run --dry-run` shows what a pass would do.
+
## Supervision (systemd)
- `lchat-router.service` — `Restart=always`, socket-activated (its own
diff --git a/docs/server-releases.md b/docs/server-releases.md
index 75c4278..6c5b1c5 100644
--- a/docs/server-releases.md
+++ b/docs/server-releases.md
@@ -4,8 +4,8 @@ How server builds get released, how the three channels work, and the trust
model behind instance auto-updating. The publishing tool is
`deploy/release-server.sh`; the format/signing code is `release.go` (shared by
the publisher and the box updater so they can't drift). The box-side updater
-that consumes this feed is described in `docs/instance-router.md` (§ auto-update,
-once it lands).
+that consumes this feed is described in `docs/instance-router.md`
+(§ Auto-updating).
## The shape
diff --git a/main.go b/main.go
index 57912fb..7a215ee 100644
--- a/main.go
+++ b/main.go
@@ -159,6 +159,9 @@ func main() {
case "release":
runRelease(os.Args[2:])
return
+ case "update":
+ runUpdate(os.Args[2:])
+ return
case "controld":
runControld(os.Args[2:])
return
diff --git a/release.go b/release.go
index 20be70e..208627d 100644
--- a/release.go
+++ b/release.go
@@ -9,6 +9,7 @@ import (
"encoding/json"
"flag"
"fmt"
+ "net/url"
"os"
"strings"
"time"
@@ -52,7 +53,7 @@ func validChannel(c string) bool {
// releasePubKeyB64 is the Ed25519 public key server releases are signed with
// (the private half lives in 1Password, never in the repo). A var so tests can
-// swap in their own trust root via setReleasePubKey.
+// swap in their own trust root, and `release verify --pubkey` can override it.
var releasePubKeyB64 = "6pL2QKDCfXqcYGR6Vr5uUZZKrpV6YVwg9rV0Okb8LY8="
// releaseManifestURL is where the box updater and instances look for the feed.
@@ -107,7 +108,7 @@ func validateManifest(m *releaseManifest) error {
if !validVersion(e.Version) || e.Version == "dev" {
return fmt.Errorf("channel %s: invalid version %q", ch, e.Version)
}
- if !strings.HasPrefix(e.URL, "https://") {
+ if !strings.HasPrefix(e.URL, "https://") && !isLoopbackURL(e.URL) {
return fmt.Errorf("channel %s: binary URL must be https (%q)", ch, e.URL)
}
if raw, err := hex.DecodeString(e.SHA256); err != nil || len(raw) != sha256.Size {
@@ -117,6 +118,17 @@ func validateManifest(m *releaseManifest) error {
return nil
}
+// isLoopbackURL allows plain-http binary URLs only on loopback — local feeds
+// and tests; anything remote must be https.
+func isLoopbackURL(u string) bool {
+ p, err := url.Parse(u)
+ if err != nil || p.Scheme != "http" {
+ return false
+ }
+ h := p.Hostname()
+ return h == "localhost" || h == "127.0.0.1" || h == "::1"
+}
+
// signReleaseManifest stamps generated_at (now — this is what makes replayed
// old feeds detectable) and wraps the payload in the signed wire format.
func signReleaseManifest(priv ed25519.PrivateKey, m *releaseManifest) ([]byte, error) {
diff --git a/updatecheck.go b/updatecheck.go
index 75d88d1..3b61e7b 100644
--- a/updatecheck.go
+++ b/updatecheck.go
@@ -1,8 +1,6 @@
package main
import (
- "fmt"
- "io"
"net/http"
"sync"
"time"
@@ -50,28 +48,12 @@ func (s *Server) runUpdateChecker() {
}
}
-// checkForUpdate fetches and verifies the release manifest, keeping the whole
+// checkForUpdate fetches and verifies the release manifest (the same fetch
+// the box updater runs — fetchReleaseManifest, updater.go), keeping the whole
// channel map — so an admin switching channels sees that channel's version
// immediately, without waiting for the next fetch.
func (s *Server) checkForUpdate() error {
- pub, err := releasePubKey()
- if err != nil {
- return err
- }
- client := &http.Client{Timeout: 30 * time.Second}
- resp, err := client.Get(releaseManifestURL)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("fetch manifest: %s", resp.Status)
- }
- raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
- if err != nil {
- return err
- }
- m, err := verifyReleaseManifest(pub, raw)
+ m, err := fetchReleaseManifest(releaseManifestURL)
if err != nil {
return err
}
diff --git a/updater.go b/updater.go
new file mode 100644
index 0000000..20ce644
--- /dev/null
+++ b/updater.go
@@ -0,0 +1,387 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// The box-side auto-updater: `lchat update run`, fired hourly by
+// lchat-update.timer (root — it stages binaries and restarts units, like
+// lchat-control). One pass reads the signed channel feed (release.go), asks
+// each instance what its admin chose (GET /api/update-prefs over the unix
+// socket — instances are sandboxed and can't update themselves), then
+// downloads/stages/applies per those prefs, reusing the exact deploy dance
+// (deployHostVersion: repoint → restart → health-check → auto-rollback).
+// It shares the deploy flock with `deploy`/`rollback`, so a timer firing
+// mid-operator-deploy waits its turn (fails fast, next tick retries).
+//
+// Trust + anti-replay: the manifest is Ed25519-verified, binaries are hashed
+// against the signed sha256, and the last accepted generated_at is persisted —
+// a replayed stale feed is refused, so nobody can downgrade the fleet by
+// re-serving an old (validly signed) manifest. See docs/server-releases.md.
+
+func incomingDir() string { return filepath.Join(optDir, "incoming") }
+
+// manifestStampPath persists the newest generated_at ever accepted.
+func manifestStampPath() string { return filepath.Join(optDir, "manifest-stamp") }
+
+func readManifestStamp() time.Time {
+ raw, err := os.ReadFile(manifestStampPath())
+ if err != nil {
+ return time.Time{}
+ }
+ t, err := time.Parse(time.RFC3339, strings.TrimSpace(string(raw)))
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
+
+func writeManifestStamp(t time.Time) error {
+ return os.WriteFile(manifestStampPath(), []byte(t.Format(time.RFC3339)+"\n"), 0o644)
+}
+
+// fetchReleaseManifest downloads and signature-verifies the channel feed.
+// Shared with the instance-side availability checker (updatecheck.go).
+func fetchReleaseManifest(url string) (*releaseManifest, error) {
+ pub, err := releasePubKey()
+ if err != nil {
+ return nil, err
+ }
+ client := &http.Client{Timeout: 2 * time.Minute}
+ resp, err := client.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetch manifest: %s", resp.Status)
+ }
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ return verifyReleaseManifest(pub, raw)
+}
+
+// updatePrefs is what an instance's admin chose (GET /api/update-prefs).
+type updatePrefs struct {
+ AutoUpdate bool `json:"auto_update"`
+ AutoDownload bool `json:"auto_download"`
+ Channel string `json:"channel"`
+}
+
+// fetchUpdatePrefs reads an instance's prefs over its unix socket. A binary
+// too old to have the endpoint 404s — that means the defaults (on/on/stable),
+// which is exactly how a pre-feature fleet adopts auto-updating.
+func fetchUpdatePrefs(socket string) (updatePrefs, error) {
+ defaults := updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable"}
+ client := unixHTTPClient(socket)
+ resp, err := client.Get("http://lchat/api/update-prefs")
+ if err != nil {
+ return defaults, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return defaults, nil
+ }
+ if resp.StatusCode != http.StatusOK {
+ return defaults, fmt.Errorf("update-prefs: %s", resp.Status)
+ }
+ var p updatePrefs
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&p); err != nil {
+ return defaults, err
+ }
+ if !validChannel(p.Channel) {
+ p.Channel = "stable"
+ }
+ return p, nil
+}
+
+// downloadReleaseBinary fetches a release into the incoming dir and refuses
+// anything whose sha256 doesn't match the signed manifest entry. Returns the
+// temp path; the caller stages it and removes the temp.
+func downloadReleaseBinary(e releaseEntry) (string, error) {
+ if err := os.MkdirAll(incomingDir(), 0o755); err != nil {
+ return "", err
+ }
+ f, err := os.CreateTemp(incomingDir(), "dl-")
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ fail := func(err error) (string, error) {
+ os.Remove(f.Name())
+ return "", err
+ }
+ client := &http.Client{Timeout: 10 * time.Minute}
+ resp, err := client.Get(e.URL)
+ if err != nil {
+ return fail(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fail(fmt.Errorf("download %s: %s", e.Version, resp.Status))
+ }
+ h := sha256.New()
+ if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
+ return fail(err)
+ }
+ if got := hex.EncodeToString(h.Sum(nil)); got != e.SHA256 {
+ return fail(fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", e.Version))
+ }
+ return f.Name(), nil
+}
+
+// ensureVersionStaged makes a manifest entry's binary available in the version
+// store, downloading it if needed. Idempotent across hosts in one pass.
+func ensureVersionStaged(e releaseEntry) error {
+ if _, err := os.Stat(versionBinPath(e.Version)); err == nil {
+ return nil
+ }
+ tmp, err := downloadReleaseBinary(e)
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp)
+ return stageVersion(tmp, e.Version)
+}
+
+// updatePass is one full check-and-apply sweep, split from runUpdateRun so
+// tests drive it directly (die()-free: per-host problems log and continue —
+// an unreachable instance must not strand the rest of the fleet unchecked).
+func updatePass(reg *Registry, unitTmpl string, dryRun bool) error {
+ m, err := fetchReleaseManifest(releaseManifestURL)
+ if err != nil {
+ // No feed is a normal state (the releases repo may not exist yet, or
+ // the box is offline) — never an error exit the timer would flap on.
+ fmt.Printf("update: no usable manifest (%v) — nothing to do\n", err)
+ return nil
+ }
+ if last := readManifestStamp(); m.GeneratedAt.Before(last) {
+ return fmt.Errorf("manifest generated_at %s is older than the last accepted %s — refusing a replayed feed",
+ m.GeneratedAt.Format(time.RFC3339), last.Format(time.RFC3339))
+ }
+ if !dryRun {
+ if err := writeManifestStamp(m.GeneratedAt); err != nil {
+ return fmt.Errorf("record manifest stamp: %v", err)
+ }
+ }
+
+ routes, err := reg.list()
+ if err != nil {
+ return err
+ }
+ // A version that failed its health check on one host isn't tried on the
+ // next this pass — one bad build shouldn't bounce every instance in turn.
+ poisoned := map[string]bool{}
+ for _, rt := range routes {
+ if rt.Kind != "instance" {
+ continue
+ }
+ if rt.Status != "ready" {
+ fmt.Printf(" %s: skipped (status %s)\n", rt.Host, rt.Status)
+ continue
+ }
+ prefs, err := fetchUpdatePrefs(rt.Socket)
+ if err != nil {
+ fmt.Printf(" %s: unreachable (%v) — skipped\n", rt.Host, err)
+ continue
+ }
+ e, ok := m.Channels[prefs.Channel]
+ if !ok {
+ fmt.Printf(" %s: channel %s has no release yet\n", rt.Host, prefs.Channel)
+ continue
+ }
+ cur, _ := liveVersion(rt.Host)
+ if cur == e.Version {
+ continue // up to date — stay quiet on the hourly tick
+ }
+ if !prefs.AutoDownload && !prefs.AutoUpdate {
+ fmt.Printf(" %s: %s available on %s (downloads off — admin's call)\n", rt.Host, e.Version, prefs.Channel)
+ continue
+ }
+ if dryRun {
+ fmt.Printf(" %s: would update %s -> %s (%s)\n", rt.Host, cur, e.Version, prefs.Channel)
+ continue
+ }
+ if err := ensureVersionStaged(e); err != nil {
+ fmt.Printf(" %s: %v\n", rt.Host, err)
+ continue
+ }
+ if !prefs.AutoUpdate {
+ fmt.Printf(" %s: %s downloaded and staged (auto-update off — apply with `lchat deploy %s --version %s`)\n",
+ rt.Host, e.Version, rt.Host, e.Version)
+ continue
+ }
+ if poisoned[e.Version] {
+ fmt.Printf(" %s: skipping %s (failed health check on another instance this pass)\n", rt.Host, e.Version)
+ continue
+ }
+ rtCopy := rt
+ changed, err := deployHostVersion(reg, &rtCopy, unitTmpl+rt.Host, e.Version)
+ if err != nil {
+ poisoned[e.Version] = true
+ fmt.Printf(" %s: update to %s failed: %v\n", rt.Host, e.Version, err)
+ continue
+ }
+ if changed {
+ fmt.Printf(" %s: updated %s -> %s (%s)\n", rt.Host, cur, e.Version, prefs.Channel)
+ }
+ }
+ if !dryRun {
+ gcVersions(reg, m)
+ }
+ return nil
+}
+
+// gcVersions prunes version-store dirs nothing references anymore: kept are
+// every route's current + previous version, every channel head, the control
+// binary's own build, and whatever any live symlink points at (belt and
+// braces — the symlinks are what units actually exec). Without this, hourly
+// edge updates would grow /opt/lchat/versions without bound on a box that
+// enforces a free-disk floor. Leftover crashed downloads are swept too.
+func gcVersions(reg *Registry, m *releaseManifest) {
+ keep := map[string]bool{buildVersion: true}
+ routes, err := reg.list()
+ if err != nil {
+ return
+ }
+ for _, rt := range routes {
+ keep[rt.Version], keep[rt.PrevVersion] = true, true
+ }
+ if m != nil {
+ for _, e := range m.Channels {
+ keep[e.Version] = true
+ }
+ }
+ if links, err := os.ReadDir(liveDir()); err == nil {
+ for _, l := range links {
+ if v, err := liveVersion(l.Name()); err == nil {
+ keep[v] = true
+ }
+ }
+ }
+ entries, err := os.ReadDir(versionsDir())
+ if err != nil {
+ return
+ }
+ for _, en := range entries {
+ if !en.IsDir() || keep[en.Name()] {
+ continue
+ }
+ if err := os.RemoveAll(filepath.Join(versionsDir(), en.Name())); err == nil {
+ fmt.Printf(" pruned unused version %s\n", en.Name())
+ }
+ }
+ // Our downloads are temp-then-removed; a surviving dl-* file is a crashed
+ // pass. (Operator-scp'd files in incoming/ are left alone.)
+ if files, err := os.ReadDir(incomingDir()); err == nil {
+ for _, f := range files {
+ if strings.HasPrefix(f.Name(), "dl-") {
+ _ = os.Remove(filepath.Join(incomingDir(), f.Name()))
+ }
+ }
+ }
+}
+
+// runUpdate: `lchat update run|status` — the auto-updater CLI.
+func runUpdate(args []string) {
+ if len(args) == 0 {
+ die("update: subcommand required: run | status")
+ }
+ switch args[0] {
+ case "run":
+ runUpdateRun(args[1:])
+ case "status":
+ runUpdateStatus(args[1:])
+ default:
+ die("update: unknown subcommand %q (run | status)", args[0])
+ }
+}
+
+func runUpdateRun(args []string) {
+ fs := flag.NewFlagSet("update run", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
+ dryRun := fs.Bool("dry-run", false, "report what would change without staging or restarting anything")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ unlock, err := acquireDeployLock()
+ if err != nil {
+ die("update: %v", err)
+ }
+ defer unlock()
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("update: open registry: %v", err)
+ }
+ if err := updatePass(reg, *unitTmpl, *dryRun); err != nil {
+ die("update: %v", err)
+ }
+}
+
+// runUpdateStatus prints where every instance stands relative to its channel.
+func runUpdateStatus(args []string) {
+ fs := flag.NewFlagSet("update status", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("update: open registry: %v", err)
+ }
+ routes, err := reg.list()
+ if err != nil {
+ die("update: %v", err)
+ }
+ m, merr := fetchReleaseManifest(releaseManifestURL)
+
+ fmt.Printf("%-34s %-8s %-8s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "CURRENT", "AVAILABLE")
+ for _, rt := range routes {
+ if rt.Kind != "instance" {
+ continue
+ }
+ channel, auto := "?", "?"
+ if prefs, err := fetchUpdatePrefs(rt.Socket); err == nil {
+ channel = prefs.Channel
+ switch {
+ case prefs.AutoDownload && prefs.AutoUpdate:
+ auto = "dl+upd"
+ case prefs.AutoDownload:
+ auto = "dl"
+ case prefs.AutoUpdate:
+ auto = "upd"
+ default:
+ auto = "off"
+ }
+ }
+ cur, _ := liveVersion(rt.Host)
+ if cur == "" {
+ cur = "-"
+ }
+ avail := "-"
+ if m != nil {
+ if e, ok := m.Channels[channel]; ok {
+ avail = e.Version
+ if e.Version == cur {
+ avail += " (current)"
+ }
+ }
+ }
+ fmt.Printf("%-34s %-8s %-8s %-12s %s\n", rt.Host, channel, auto, cur, avail)
+ }
+ if merr != nil {
+ fmt.Printf("(no manifest: %v)\n", merr)
+ }
+}
diff --git a/updater_test.go b/updater_test.go
new file mode 100644
index 0000000..0ec1c16
--- /dev/null
+++ b/updater_test.go
@@ -0,0 +1,362 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// startFakeInstance serves /api/update-prefs + /api/version on a real unix
+// socket. Its reported build follows the host's live symlink — so pointLive +
+// a stubbed systemctl restart looks exactly like a real swap to
+// waitHealthyVersion. prefsCode 404 simulates a pre-feature binary; brokenVer
+// (if set) makes that version report a wrong build, forcing the rollback path.
+func startFakeInstance(t *testing.T, host string, prefsCode int, prefs updatePrefs, brokenVer string) string {
+ t.Helper()
+ // Short base dir: unix socket paths have a ~104-char limit on darwin.
+ dir, err := os.MkdirTemp("", "lch")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ socket := filepath.Join(dir, host+".sock")
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) {
+ if prefsCode != http.StatusOK {
+ http.Error(w, "no such endpoint", prefsCode)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "auto_update": prefs.AutoUpdate, "auto_download": prefs.AutoDownload, "channel": prefs.Channel,
+ })
+ })
+ mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
+ v, _ := liveVersion(host)
+ if brokenVer != "" && v == brokenVer {
+ v = "borked"
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"build": v})
+ })
+
+ 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() })
+ return socket
+}
+
+// startFeed signs entries into a manifest and serves it (plus each version's
+// binary bytes) from a loopback server, swapping in a test trust root.
+func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func(channels map[string]string)) {
+ t.Helper()
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var mu sync.Mutex
+ var wrapper []byte
+ mux := http.NewServeMux()
+ mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ w.Write(wrapper)
+ })
+ mux.HandleFunc("/bin/", func(w http.ResponseWriter, r *http.Request) {
+ ver := strings.TrimPrefix(r.URL.Path, "/bin/")
+ w.Write([]byte(binaries[ver]))
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ oldKey, oldURL := releasePubKeyB64, releaseManifestURL
+ releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub)
+ releaseManifestURL = ts.URL + "/manifest.json"
+ t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL })
+
+ setChannels := func(channels map[string]string) {
+ m := &releaseManifest{Channels: map[string]releaseEntry{}}
+ for ch, ver := range channels {
+ sum := sha256.Sum256([]byte(binaries[ver]))
+ m.Channels[ch] = releaseEntry{
+ Version: ver,
+ URL: ts.URL + "/bin/" + ver,
+ SHA256: hex.EncodeToString(sum[:]),
+ }
+ }
+ w, err := signReleaseManifest(priv, m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mu.Lock()
+ wrapper = w
+ mu.Unlock()
+ }
+ return ts, setChannels
+}
+
+// stubSystemctl records unit restarts and leaves the "restart" to the fake
+// instance's symlink-following /api/version.
+func stubSystemctl(t *testing.T) *[]string {
+ t.Helper()
+ var calls []string
+ prev := systemctl
+ systemctl = func(args ...string) error {
+ calls = append(calls, strings.Join(args, " "))
+ return nil
+ }
+ t.Cleanup(func() { systemctl = prev })
+ return &calls
+}
+
+func seedInstance(t *testing.T, reg *Registry, host, socket, ver string) {
+ t.Helper()
+ src := filepath.Join(t.TempDir(), "old")
+ writeFakeBinary(t, src, "old-binary-"+ver)
+ if err := stageVersion(src, ver); err != nil {
+ t.Fatal(err)
+ }
+ if err := pointLive(host, ver); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.upsert(Route{Host: host, Kind: "instance", Status: "ready", Socket: socket, Version: ver}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestUpdatePassAppliesPerPrefs(t *testing.T) {
+ withTempOpt(t)
+ calls := stubSystemctl(t)
+ shrink := deployHealthTimeout
+ deployHealthTimeout = 2 * time.Second
+ t.Cleanup(func() { deployHealthTimeout = shrink })
+
+ _, setChannels := startFeed(t, map[string]string{
+ "new1234": "new-binary-content",
+ "beta5555": "beta-binary-content",
+ })
+ setChannels(map[string]string{"stable": "new1234", "beta": "beta5555"})
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // h1: defaults-by-404 (old binary) — updates to stable.
+ // h2: auto-update off — downloaded + staged, never restarted.
+ // h3: everything off, beta channel — beta's binary must not even download.
+ // h4: explicit on/on/stable — updates.
+ s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
+ s2 := startFakeInstance(t, "h2.example.com", http.StatusOK, updatePrefs{AutoUpdate: false, AutoDownload: true, Channel: "stable"}, "")
+ s3 := startFakeInstance(t, "h3.example.com", http.StatusOK, updatePrefs{AutoUpdate: false, AutoDownload: false, Channel: "beta"}, "")
+ s4 := startFakeInstance(t, "h4.example.com", http.StatusOK, updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable"}, "")
+ seedInstance(t, reg, "h1.example.com", s1, "old1111")
+ seedInstance(t, reg, "h2.example.com", s2, "old1111")
+ seedInstance(t, reg, "h3.example.com", s3, "old1111")
+ seedInstance(t, reg, "h4.example.com", s4, "old1111")
+
+ if err := updatePass(reg, "lchat@", false); err != nil {
+ t.Fatalf("updatePass: %v", err)
+ }
+
+ assertLive := func(host, want string) {
+ t.Helper()
+ if v, _ := liveVersion(host); v != want {
+ t.Errorf("%s live version = %q, want %q", host, v, want)
+ }
+ }
+ assertLive("h1.example.com", "new1234")
+ assertLive("h2.example.com", "old1111") // staged only
+ assertLive("h3.example.com", "old1111")
+ assertLive("h4.example.com", "new1234")
+
+ // h2's chosen build is staged and byte-exact; h3's channel never downloaded.
+ staged, err := os.ReadFile(versionBinPath("new1234"))
+ if err != nil || string(staged) != "new-binary-content" {
+ t.Fatalf("staged binary = %q, %v", staged, err)
+ }
+ if _, err := os.Stat(versionBinPath("beta5555")); !os.IsNotExist(err) {
+ t.Fatal("beta5555 was downloaded despite downloads off")
+ }
+
+ // Registry recorded the moves (and only the moves).
+ rt, _ := reg.get("h1.example.com")
+ if rt.Version != "new1234" || rt.PrevVersion != "old1111" {
+ t.Fatalf("h1 registry = %s/%s", rt.Version, rt.PrevVersion)
+ }
+ rt, _ = reg.get("h2.example.com")
+ if rt.Version != "old1111" {
+ t.Fatalf("h2 registry = %s", rt.Version)
+ }
+
+ // Exactly two restarts: h1 and h4.
+ got := strings.Join(*calls, "; ")
+ if !strings.Contains(got, "restart lchat@h1.example.com") || !strings.Contains(got, "restart lchat@h4.example.com") || len(*calls) != 2 {
+ t.Fatalf("systemctl calls = %v", *calls)
+ }
+
+ // The pass recorded the manifest stamp for anti-replay.
+ if readManifestStamp().IsZero() {
+ t.Fatal("manifest stamp not written")
+ }
+}
+
+func TestUpdatePassRollsBackAndPoisons(t *testing.T) {
+ withTempOpt(t)
+ calls := stubSystemctl(t)
+ shrink := deployHealthTimeout
+ deployHealthTimeout = 500 * time.Millisecond
+ t.Cleanup(func() { deployHealthTimeout = shrink })
+
+ _, setChannels := startFeed(t, map[string]string{"bad9999": "does-not-boot"})
+ setChannels(map[string]string{"stable": "bad9999"})
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Both instances report "borked" while on bad9999 → health check fails.
+ s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "bad9999")
+ s2 := startFakeInstance(t, "h2.example.com", http.StatusNotFound, updatePrefs{}, "bad9999")
+ seedInstance(t, reg, "h1.example.com", s1, "old1111")
+ seedInstance(t, reg, "h2.example.com", s2, "old1111")
+
+ if err := updatePass(reg, "lchat@", false); err != nil {
+ t.Fatalf("updatePass: %v", err)
+ }
+
+ // h1 tried, failed, rolled back; h2 was spared (the version is poisoned).
+ for _, host := range []string{"h1.example.com", "h2.example.com"} {
+ if v, _ := liveVersion(host); v != "old1111" {
+ t.Errorf("%s live version = %q, want rollback to old1111", host, v)
+ }
+ rt, _ := reg.get(host)
+ if rt.Version != "old1111" {
+ t.Errorf("%s registry version = %q", host, rt.Version)
+ }
+ }
+ // h1: restart onto bad + rollback restart = 2 calls; h2: none.
+ if len(*calls) != 2 || strings.Contains(strings.Join(*calls, ";"), "h2.example.com") {
+ t.Fatalf("systemctl calls = %v", *calls)
+ }
+}
+
+func TestUpdatePassRefusesReplayedManifest(t *testing.T) {
+ withTempOpt(t)
+ stubSystemctl(t)
+
+ _, setChannels := startFeed(t, map[string]string{"new1234": "x"})
+ setChannels(map[string]string{"stable": "new1234"})
+
+ // Pretend we've already accepted a manifest from the future.
+ if err := writeManifestStamp(time.Now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := updatePass(reg, "lchat@", false); err == nil || !strings.Contains(err.Error(), "replayed") {
+ t.Fatalf("want replay refusal, got %v", err)
+ }
+}
+
+func TestUpdatePassDryRunTouchesNothing(t *testing.T) {
+ withTempOpt(t)
+ calls := stubSystemctl(t)
+
+ _, setChannels := startFeed(t, map[string]string{"new1234": "content"})
+ setChannels(map[string]string{"stable": "new1234"})
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
+ seedInstance(t, reg, "h1.example.com", s1, "old1111")
+
+ if err := updatePass(reg, "lchat@", true); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "old1111" {
+ t.Fatalf("dry-run moved the symlink to %q", v)
+ }
+ if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
+ t.Fatal("dry-run downloaded/staged a binary")
+ }
+ if len(*calls) != 0 {
+ t.Fatalf("dry-run ran systemctl: %v", *calls)
+ }
+ if !readManifestStamp().IsZero() {
+ t.Fatal("dry-run wrote the manifest stamp")
+ }
+}
+
+func TestGCVersionsKeepsEverythingReferenced(t *testing.T) {
+ withTempOpt(t)
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ bin := filepath.Join(t.TempDir(), "b")
+ writeFakeBinary(t, bin, "x")
+ for _, v := range []string{"cur1", "prev1", "head1", "orphan1", "orphan2", "linked1"} {
+ if err := stageVersion(bin, v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reg.upsert(Route{Host: "a.example.com", Kind: "instance", Status: "ready", Version: "cur1", PrevVersion: "prev1"})
+ // A live symlink nothing in the registry mentions must still survive.
+ if err := pointLive("b.example.com", "linked1"); err != nil {
+ t.Fatal(err)
+ }
+ m := &releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "head1"}}}
+
+ gcVersions(reg, m)
+
+ for _, keep := range []string{"cur1", "prev1", "head1", "linked1"} {
+ if _, err := os.Stat(versionBinPath(keep)); err != nil {
+ t.Errorf("gc pruned %s (should be kept)", keep)
+ }
+ }
+ for _, gone := range []string{"orphan1", "orphan2"} {
+ if _, err := os.Stat(versionBinPath(gone)); !os.IsNotExist(err) {
+ t.Errorf("gc kept %s (should be pruned)", gone)
+ }
+ }
+ // buildVersion ("dev" under test) is always kept — the control binary's own.
+ // (No dir staged for it here; just assert gc didn't error on its absence.)
+}
+
+func TestAcquireDeployLockExcludes(t *testing.T) {
+ withTempOpt(t)
+ unlock, err := acquireDeployLock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := acquireDeployLock(); err == nil {
+ t.Fatal("second acquire succeeded — deploy and update could interleave")
+ }
+ unlock()
+ unlock2, err := acquireDeployLock()
+ if err != nil {
+ t.Fatalf("re-acquire after release: %v", err)
+ }
+ unlock2()
+}
diff --git a/versions.go b/versions.go
index 4a41fb8..d3981fc 100644
--- a/versions.go
+++ b/versions.go
@@ -287,6 +287,12 @@ func runRollback(args []string) {
all := fs.Bool("all", false, "roll back every instance that has a previous version")
_ = fs.Parse(reorderFlagsFirst(fs, args))
+ unlock, err := acquireDeployLock()
+ if err != nil {
+ die("rollback: %v", err)
+ }
+ defer unlock()
+
reg, err := openRegistry(*registryPath)
if err != nil {
die("rollback: open registry: %v", err)