eleven / ocho Public

Supply-chain bake window for beta/stable auto-updates (#815)

* server: supply-chain bake window for beta/stable auto-updates

beta and stable channel heads are held 24h/72h (+ per-box jitter) after the
box FIRST SEES them at the channel head before installing — a compromised
release caught and pulled back inside the window never reaches subscribers.
The clock is the box's own observation (/opt/lchat/channel-first-seen.json),
never a feed timestamp, so a stolen signing key can't backdate past it; a
pull-back to a previously-seen version applies immediately. No publisher-side
bypass by design — the override is local root (update run --ignore-bake or a
manual deploy). Held heads surface in update status and the admin consoles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* deploy: ship treats bake-held rows like pinned ones — reported, not waited on

A held beta/stable head is measured in days; ship's deadline is 10 minutes.
Without this, promote-then-ship on a stable-riding fleet would spin to a
false failure.

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a23dbe2941a791e962904b416de437eedf029216 parent 3dcb512
11 files changed, +629 −17
  • bake.go +151 −0
  • bake_test.go +327 −0
  • changelog/2026-07-13-update-safety-hold.md +7 −0
  • deploy/release-server.sh +13 −2
  • docs/instance-router.md +7 −0
  • docs/server-releases.md +44 −0
  • updatecheck.go +7 −0
  • updater.go +36 −3
  • updater_test.go +29 −12
  • web/static/admin.js +4 −0
  • web/static/apps/admin.js +4 −0
diff --git a/bake.go b/bake.go
new file mode 100644
index 0000000..890d4bf
--- /dev/null
+++ b/bake.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// The supply-chain bake window: beta and stable subscribers don't install a
+// release the moment it appears — the box waits until the release has sat at
+// the channel head for a while first, so a compromised release that gets
+// caught and pulled back (docs/server-releases.md) never reaches them. The
+// clock is THIS BOX's own observation ("when did I first see this version at
+// this channel head"), never a manifest timestamp: an attacker holding the
+// signing key controls every timestamp in the feed and would simply backdate
+// one, but cannot backdate what the box already saw.
+//
+// edge has no window — it IS the operator's own deploy path (`publish &&
+// ship`). beta's 24h absorbs the caught-within-a-day mass of real
+// supply-chain incidents; stable's 72h additionally spans a weekend, so a
+// Friday-night malicious publish is seen by a human on Monday with margin to
+// pull it back.
+//
+// There is deliberately NO publisher-side bypass ("critical fix, skip the
+// hold") — that flag is the first thing an attacker sets. The bypass is
+// local and explicit: `lchat update run --ignore-bake` or a plain
+// `lchat deploy <host> --version <ver>`, both of which need root on the box.
+var bakeDelays = map[string]time.Duration{
+ "edge": 0,
+ "beta": 24 * time.Hour,
+ "stable": 72 * time.Hour,
+}
+
+// bakeHours is the display form consoles show ("held up to N hours").
+func bakeHours(channel string) int { return int(bakeDelays[channel].Hours()) }
+
+// firstSeenPath persists the bake clock, next to the anti-replay stamp.
+func firstSeenPath() string { return filepath.Join(optDir, "channel-first-seen.json") }
+
+// firstSeenKeep bounds each channel's history. Past heads are kept (not just
+// the current one) so a pull-back to a previously-seen version is immediately
+// eligible — evacuating a bad release must never itself sit in a bake window.
+const firstSeenKeep = 12
+
+// firstSeenState is the bake clock: for each channel, when this box first
+// observed each version at its head.
+type firstSeenState struct {
+ // Jitter desynchronises a fleet of boxes: each waits delay*(1+Jitter), so
+ // self-hosted boxes don't all install in the same hour and the earliest
+ // become canaries for the rest. Minted once per box, in [0, 0.25).
+ Jitter float64 `json:"jitter"`
+ Channels map[string]map[string]time.Time `json:"channels"`
+}
+
+// loadFirstSeen reads the bake clock. A missing or corrupt file is an empty
+// clock — every head then bakes from "now", which fails closed (a wiped clock
+// delays updates; it never lets one through early).
+func loadFirstSeen() *firstSeenState {
+ st := &firstSeenState{}
+ if raw, err := os.ReadFile(firstSeenPath()); err == nil {
+ _ = json.Unmarshal(raw, st)
+ }
+ if st.Channels == nil {
+ st.Channels = map[string]map[string]time.Time{}
+ }
+ if st.Jitter < 0 || st.Jitter >= 0.25 {
+ st.Jitter = 0
+ }
+ return st
+}
+
+// observe records every channel head the manifest shows that this box hasn't
+// seen at that head before. Returns whether anything changed (the caller
+// persists). Never called on a manifest that failed verification — the clock
+// only ever ticks for signed feeds.
+func (st *firstSeenState) observe(m *releaseManifest, now time.Time) bool {
+ changed := false
+ if st.Jitter == 0 && len(st.Channels) == 0 {
+ // A fresh box mints its jitter once, alongside its first observation.
+ st.Jitter = rand.Float64() * 0.25
+ changed = true
+ }
+ for ch, e := range m.Channels {
+ seen := st.Channels[ch]
+ if seen == nil {
+ seen = map[string]time.Time{}
+ st.Channels[ch] = seen
+ }
+ if _, ok := seen[e.Version]; !ok {
+ seen[e.Version] = now.UTC().Truncate(time.Second)
+ changed = true
+ }
+ // Prune oldest entries beyond the cap — the just-recorded head is the
+ // newest, so it always survives.
+ for len(seen) > firstSeenKeep {
+ oldest, at := "", time.Time{}
+ for v, t := range seen {
+ if oldest == "" || t.Before(at) {
+ oldest, at = v, t
+ }
+ }
+ delete(seen, oldest)
+ }
+ }
+ return changed
+}
+
+func (st *firstSeenState) save() error {
+ raw, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(firstSeenPath(), append(raw, '\n'), 0o644)
+}
+
+// bakeEligibleAt is the moment a version first seen at a channel head may be
+// installed: firstSeen + delay*(1+jitter). ok=false when the version has no
+// record at this channel's head — brand new, so not eligible (updatePass
+// calls observe() first, which makes that case unreachable there; status
+// reads the clock without ticking it and can legitimately see it).
+func (st *firstSeenState) bakeEligibleAt(channel, version string) (time.Time, bool) {
+ seen, ok := st.Channels[channel][version]
+ if !ok {
+ return time.Time{}, false
+ }
+ d := bakeDelays[channel]
+ if d == 0 {
+ return seen, true
+ }
+ return seen.Add(time.Duration(float64(d) * (1 + st.Jitter))), true
+}
+
+// bakeHold returns a non-empty human reason while a channel head must not yet
+// be installed on this box, "" once it may be.
+func bakeHold(st *firstSeenState, channel, version string, ignore bool, now time.Time) string {
+ if ignore {
+ return ""
+ }
+ at, ok := st.bakeEligibleAt(channel, version)
+ if !ok {
+ // Fail closed: no record means no clock has run, so nothing installs.
+ return "in its supply-chain bake window (clock starts this pass)"
+ }
+ if now.Before(at) {
+ return fmt.Sprintf("in its supply-chain bake window until %s", at.UTC().Format("2006-01-02 15:04 MST"))
+ }
+ return ""
+}
diff --git a/bake_test.go b/bake_test.go
new file mode 100644
index 0000000..7d828d8
--- /dev/null
+++ b/bake_test.go
@@ -0,0 +1,327 @@
+package main
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// The supply-chain bake window (bake.go). These tests run with the real
+// delays (stable 72h / beta 24h) — everything else in updater_test.go runs
+// zeroBake()d so the two concerns stay separate.
+
+func TestUpdatePassBakeHoldsNewStableHead(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": "held-binary"})
+ 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")
+
+ // First sighting: the clock starts and NOTHING moves — no download, no
+ // restart. The window is measured from this box's own observation.
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatalf("updatePass: %v", err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "old1111" {
+ t.Fatalf("brand-new stable head installed immediately: live = %q", v)
+ }
+ if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
+ t.Fatal("binary downloaded during the bake window")
+ }
+ if len(*calls) != 0 {
+ t.Fatalf("restarts during the bake window: %v", *calls)
+ }
+ st := loadFirstSeen()
+ seen, ok := st.Channels["stable"]["new1234"]
+ if !ok {
+ t.Fatal("first sighting not recorded")
+ }
+ if st.Jitter < 0 || st.Jitter >= 0.25 {
+ t.Fatalf("minted jitter %v outside [0, 0.25)", st.Jitter)
+ }
+
+ // A second pass inside the window holds too, and doesn't move the clock.
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "old1111" {
+ t.Fatalf("head installed inside the window: live = %q", v)
+ }
+ if again := loadFirstSeen().Channels["stable"]["new1234"]; !again.Equal(seen) {
+ t.Fatalf("re-observation moved the clock: %v -> %v", seen, again)
+ }
+
+ // Once the window (72h plus any jitter, ≤ 90h) has passed, it installs.
+ st.Channels["stable"]["new1234"] = time.Now().Add(-91 * time.Hour)
+ if err := st.save(); err != nil {
+ t.Fatal(err)
+ }
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "new1234" {
+ t.Fatalf("head not installed after the window: live = %q", v)
+ }
+}
+
+// Pulling a channel back to a previously-seen version applies immediately —
+// evacuating a bad release must never itself sit in a bake window.
+func TestUpdatePassBakePullBackIsImmediate(t *testing.T) {
+ withTempOpt(t)
+ stubSystemctl(t)
+ shrink := deployHealthTimeout
+ deployHealthTimeout = 2 * time.Second
+ t.Cleanup(func() { deployHealthTimeout = shrink })
+
+ _, setChannels := startFeed(t, map[string]string{"goodaaa": "good-binary", "badbbbb": "bad-binary"})
+ setChannels(map[string]string{"stable": "badbbbb"})
+
+ // goodaaa sat at the stable head long ago (this box saw it); badbbbb is
+ // the fresh, about-to-be-pulled release.
+ st := loadFirstSeen()
+ st.Jitter = 0.1
+ st.Channels = map[string]map[string]time.Time{
+ "stable": {"goodaaa": time.Now().Add(-200 * time.Hour)},
+ }
+ if err := st.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ 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")
+
+ // The fresh head is held...
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "old1111" {
+ t.Fatalf("fresh head installed: live = %q", v)
+ }
+
+ // ...the operator pulls stable back to the known-good version, and the
+ // next pass applies it with no further wait.
+ setChannels(map[string]string{"stable": "goodaaa"})
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "goodaaa" {
+ t.Fatalf("pull-back target was held: live = %q", v)
+ }
+}
+
+// --ignore-bake is the deliberate local override: root on the box applying a
+// held head NOW (there is intentionally no publisher-side equivalent).
+func TestUpdatePassIgnoreBakeOverrides(t *testing.T) {
+ withTempOpt(t)
+ stubSystemctl(t)
+ shrink := deployHealthTimeout
+ deployHealthTimeout = 2 * time.Second
+ t.Cleanup(func() { deployHealthTimeout = shrink })
+
+ _, 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@", false, true); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "new1234" {
+ t.Fatalf("--ignore-bake didn't install: live = %q", v)
+ }
+}
+
+// edge has no window: it's the operator's own deploy path (`publish && ship`
+// must keep converging the fleet in one pass).
+func TestUpdatePassEdgeHasNoBake(t *testing.T) {
+ withTempOpt(t)
+ stubSystemctl(t)
+ shrink := deployHealthTimeout
+ deployHealthTimeout = 2 * time.Second
+ t.Cleanup(func() { deployHealthTimeout = shrink })
+
+ _, setChannels := startFeed(t, map[string]string{"edge777": "edge-binary"})
+ setChannels(map[string]string{"edge": "edge777"})
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s1 := startFakeInstance(t, "h1.example.com", http.StatusOK,
+ updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "edge"}, "")
+ seedInstance(t, reg, "h1.example.com", s1, "old1111")
+
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "edge777" {
+ t.Fatalf("edge head was held: live = %q", v)
+ }
+}
+
+// Hibernating hosts honour the window too — their repoints have no health
+// check, so they must never be the first to run a fresh head anyway.
+func TestUpdatePassBakeHoldsHibernating(t *testing.T) {
+ withTempOpt(t)
+ calls := stubSystemctl(t)
+
+ oldData := instanceDataDir
+ instanceDataDir = t.TempDir()
+ t.Cleanup(func() { instanceDataDir = oldData })
+
+ _, 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)
+ }
+ src := filepath.Join(t.TempDir(), "old")
+ writeFakeBinary(t, src, "old-binary")
+ if err := stageVersion(src, "old1111"); err != nil {
+ t.Fatal(err)
+ }
+ if err := pointLive("h1.hib", "old1111"); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.upsert(Route{Host: "h1.hib", Kind: "instance", Status: "ready",
+ Socket: filepath.Join(t.TempDir(), "no.sock"), Hibernate: true, Version: "old1111"}); err != nil {
+ t.Fatal(err)
+ }
+ db, err := openDB(filepath.Join(instanceDataDir, "h1.hib.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ db.sql.Close()
+
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.hib"); v != "old1111" {
+ t.Fatalf("hibernating host repointed during the bake window: live = %q", v)
+ }
+ if len(*calls) != 0 {
+ t.Fatalf("systemctl touched: %v", *calls)
+ }
+
+ // After the window it repoints as usual.
+ st := loadFirstSeen()
+ st.Channels["stable"]["new1234"] = time.Now().Add(-91 * time.Hour)
+ if err := st.save(); err != nil {
+ t.Fatal(err)
+ }
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.hib"); v != "new1234" {
+ t.Fatalf("hibernating host not repointed after the window: live = %q", v)
+ }
+}
+
+// Dry runs compute bake eligibility but never persist the clock — a dry run
+// must not start windows ticking.
+func TestUpdatePassBakeDryRunPersistsNothing(t *testing.T) {
+ withTempOpt(t)
+ 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, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(firstSeenPath()); !os.IsNotExist(err) {
+ t.Fatal("dry run persisted the bake clock")
+ }
+}
+
+func TestFirstSeenObservePruneAndEligibility(t *testing.T) {
+ withTempOpt(t)
+
+ // A fresh box mints its jitter alongside the first observation.
+ st := loadFirstSeen()
+ now := time.Now()
+ if !st.observe(&releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "v0"}}}, now) {
+ t.Fatal("first observation reported no change")
+ }
+ if st.Jitter < 0 || st.Jitter >= 0.25 {
+ t.Fatalf("jitter %v outside [0, 0.25)", st.Jitter)
+ }
+ // Re-observing the same head is a no-op.
+ if st.observe(&releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "v0"}}}, now.Add(time.Hour)) {
+ t.Fatal("re-observation reported a change")
+ }
+
+ // Eligibility: firstSeen + delay*(1+jitter), exactly.
+ st.Jitter = 0.2 // 0.25 is exclusive — loadFirstSeen clamps it as corrupt
+ seen := st.Channels["stable"]["v0"]
+ at, ok := st.bakeEligibleAt("stable", "v0")
+ if want := seen.Add(time.Duration(float64(72*time.Hour) * 1.2)); !ok || !at.Equal(want) {
+ t.Fatalf("eligibleAt = %v ok=%v, want %v", at, ok, want)
+ }
+ if _, ok := st.bakeEligibleAt("stable", "never-seen"); ok {
+ t.Fatal("unseen version reported eligible time")
+ }
+ // An unseen version is held, an old one isn't, ignore always passes.
+ if bakeHold(st, "stable", "never-seen", false, now) == "" {
+ t.Fatal("unseen version not held")
+ }
+ if bakeHold(st, "stable", "never-seen", true, now) != "" {
+ t.Fatal("ignore-bake didn't override")
+ }
+ st.Channels["stable"]["ancient"] = now.Add(-100 * time.Hour)
+ if hold := bakeHold(st, "stable", "ancient", false, now); hold != "" {
+ t.Fatalf("aged-out version still held: %s", hold)
+ }
+
+ // History is pruned to firstSeenKeep entries, oldest first — the current
+ // head always survives.
+ for i := 0; i < firstSeenKeep+5; i++ {
+ st.observe(&releaseManifest{Channels: map[string]releaseEntry{
+ "stable": {Version: string(rune('a' + i))},
+ }}, now.Add(time.Duration(i)*time.Minute))
+ }
+ if n := len(st.Channels["stable"]); n != firstSeenKeep {
+ t.Fatalf("history size = %d, want %d", n, firstSeenKeep)
+ }
+ if _, ok := st.Channels["stable"][string(rune('a'+firstSeenKeep+4))]; !ok {
+ t.Fatal("newest head was pruned")
+ }
+
+ // The clock round-trips through disk.
+ if err := st.save(); err != nil {
+ t.Fatal(err)
+ }
+ got := loadFirstSeen()
+ if got.Jitter != st.Jitter || len(got.Channels["stable"]) != firstSeenKeep {
+ t.Fatalf("reload = jitter %v, %d entries", got.Jitter, len(got.Channels["stable"]))
+ }
+}
diff --git a/changelog/2026-07-13-update-safety-hold.md b/changelog/2026-07-13-update-safety-hold.md
new file mode 100644
index 0000000..afb865f
--- /dev/null
+++ b/changelog/2026-07-13-update-safety-hold.md
@@ -0,0 +1,7 @@
+🤖 Server updates now pass through a safety hold. Spaces on the beta and
+stable update channels wait 24 and 72 hours after a release appears before
+installing it automatically — so if a bad or tampered-with release is caught
+and withdrawn in that window, your space never runs it. Withdrawing a release
+takes effect immediately, the hold only ever delays installs. Admins can see
+the hold in the admin console's update section and can still apply an update
+straight away by hand; the edge channel is unchanged.
diff --git a/deploy/release-server.sh b/deploy/release-server.sh
index 8a86eab..33831cf 100755
--- a/deploy/release-server.sh
+++ b/deploy/release-server.sh
@@ -216,11 +216,14 @@ ship)
STATUS="$(ssh "$BOX" /opt/lchat/lchat update status || true)"
# Done when no un-pinned instance with a known channel head is behind, and
# at least one row proves a feed landed at all. Pinned rows are someone's
- # canary: reported, never waited on.
- BEHIND="$(printf '%s\n' "$STATUS" | awk 'NR>1 && $3!="pinned" && $5!="-" && $0 !~ /\(current\)/ {print $1}')"
+ # canary: reported, never waited on. Held rows (a beta/stable head still
+ # inside its supply-chain bake window) are likewise reported, not waited
+ # on — the hold is measured in days and ship's deadline in minutes.
+ BEHIND="$(printf '%s\n' "$STATUS" | awk 'NR>1 && $3!="pinned" && $5!="-" && $0 !~ /\(current\)/ && $0 !~ /\(held/ {print $1}')"
if [ -z "$BEHIND" ] && printf '%s\n' "$STATUS" | grep -q "(current)"; then
printf '%s\n' "$STATUS"
printf '%s\n' "$STATUS" | awk 'NR>1 && $3=="pinned" {print "note: " $1 " is pinned (a canary) - left alone"}'
+ printf '%s\n' "$STATUS" | awk 'NR>1 && /\(held/ {print "note: " $1 " is holding its channel head (safety hold) - not waited on"}'
if printf '%s\n' "$STATUS" | grep -q "$VER (current)"; then
echo "shipped $VER - every un-pinned instance is current"
else
@@ -228,6 +231,14 @@ ship)
fi
exit 0
fi
+ # Nothing behind and nothing current can still be a clean stop: every
+ # remaining row is inside its safety hold (e.g. the whole fleet rides
+ # stable right after a promote). They install when the window elapses.
+ if [ -z "$BEHIND" ] && printf '%s\n' "$STATUS" | grep -q "(held"; then
+ printf '%s\n' "$STATUS"
+ echo "shipped the feed - every remaining instance is holding its channel head inside the safety window (installs when it elapses; per-host override: lchat deploy <host> --version <ver>)"
+ exit 0
+ fi
if [ "$(date +%s)" -ge "$DEADLINE" ]; then
printf '%s\n' "$STATUS"
echo "ship: gave up after 10m - still behind: $BEHIND (GitHub CDN can serve a fresh manifest stale; retry in a few minutes)" >&2
diff --git a/docs/instance-router.md b/docs/instance-router.md
index fc86ddd..1627b4b 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -512,6 +512,13 @@ described in `docs/server-releases.md`:
`/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.
+- **The bake window (supply-chain hold)**: beta/stable heads aren't installed
+ until they've sat at the channel head for 24 h/72 h (+ per-box jitter) by
+ the box's *own* clock (`/opt/lchat/channel-first-seen.json`) — a compromised
+ release caught and pulled back inside the window never reaches subscribers,
+ and a pull-back to a previously-seen version applies immediately. Local
+ override: `lchat update run --ignore-bake` (root); design + numbers in
+ `docs/server-releases.md § The bake window`.
- **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
diff --git a/docs/server-releases.md b/docs/server-releases.md
index 1f37462..bcb3a08 100644
--- a/docs/server-releases.md
+++ b/docs/server-releases.md
@@ -103,6 +103,50 @@ The updater downloads and *executes* native code, so the chain matters:
validly signed) manifest can't downgrade a fleet. An intentional pull-back is
a *re-signed* manifest with a newer timestamp, which still applies.
+### The bake window (supply-chain hold)
+
+Everything above assumes the signing key is honest. The bake window is the
+defence for the day it isn't — a stolen seed, or a compromised operator
+machine signing a malicious build. beta and stable subscribers don't install
+a release the moment it appears; the box waits until the release has **sat at
+the channel head for a while**:
+
+| channel | hold |
+|---------|------|
+| `edge` | none — it *is* the operator's own deploy path |
+| `beta` | 24 h |
+| `stable` | 72 h |
+
+Real supply-chain incidents are bimodal: noisy ones (malicious npm/PyPI
+releases, stolen-token publishes) are overwhelmingly detected and yanked
+within hours to ~3 days; stealthy targeted ones take weeks and no tolerable
+hold defends against them (that's what key hygiene is for). 24 h absorbs the
+caught-within-a-day mass; 72 h additionally **spans a weekend** — a
+Friday-night malicious publish is seen by a human on Monday with margin to
+pull the channel back, and a pull-back to a previously-seen version applies
+*immediately* (evacuation never bakes). This is also why stable-channel lag
+saved every distro from xz-utils.
+
+The mechanics (`bake.go`):
+
+- **The clock is the box's own first sighting** — `lchat update run` records
+ "first saw `<ver>` at the head of `<chan>`" in
+ `/opt/lchat/channel-first-seen.json` and installs only once that record is
+ old enough. Never a manifest timestamp: an attacker holding the key
+ controls every timestamp in the feed and would simply backdate one, but
+ cannot backdate what the box already saw. A missing/corrupt clock file
+ fails **closed** (everything bakes from now).
+- **Jitter**: each box waits `hold × (1 + j)`, `j ∈ [0, 0.25)` minted once
+ per box — a fleet of self-hosted boxes doesn't install in the same hour,
+ so the earliest are canaries for the rest.
+- **There is deliberately NO publisher-side bypass** ("critical fix, skip the
+ hold") — that flag is the first thing an attacker would set. The bypass is
+ local and explicit, requiring root on the box:
+ `lchat update run --ignore-bake`, or a plain
+ `lchat deploy <host> --version <ver>`. A held release shows in
+ `lchat update status` as `(held until <time>)` and in the admin consoles as
+ "auto-installs after a N-hour safety hold".
+
## The S3 mirror
GitHub's asset CDN is the feed's single point of failure — and it has served
diff --git a/updatecheck.go b/updatecheck.go
index e6a1e6c..06055a9 100644
--- a/updatecheck.go
+++ b/updatecheck.go
@@ -78,6 +78,13 @@ func (s *Server) updateInfo() map[string]any {
if e, ok := m.Channels[s.db.updateChannel()]; ok {
info["available_version"] = e.Version
info["available_notes"] = e.Notes
+ // The channel's supply-chain bake window (bake.go), so the console can
+ // explain why a fresh release hasn't auto-installed yet. The instance
+ // can't see the box's bake clock — only the policy — so this is an
+ // "up to N hours" statement, never a countdown.
+ if h := bakeHours(s.db.updateChannel()); h > 0 {
+ info["bake_hours"] = h
+ }
}
return info
}
diff --git a/updater.go b/updater.go
index 74bb0cc..324a39f 100644
--- a/updater.go
+++ b/updater.go
@@ -340,7 +340,9 @@ func ensureVersionStaged(e releaseEntry) error {
// 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 {
+// ignoreBake is the local, root-only override of the supply-chain bake window
+// (bake.go) — deliberately never a publisher-side flag.
+func updatePass(reg *Registry, unitTmpl string, dryRun, ignoreBake bool) error {
m, err := fetchReleaseManifest(releaseManifestURL)
if err != nil {
// No feed is a normal state (the releases repo may not exist yet, or
@@ -357,6 +359,16 @@ func updatePass(reg *Registry, unitTmpl string, dryRun bool) error {
return fmt.Errorf("record manifest stamp: %v", err)
}
}
+ // Tick the bake clock: record every channel head this box hasn't seen at
+ // that head before, so the supply-chain window is measured from the box's
+ // own first sighting, never a feed timestamp (bake.go). Dry runs compute
+ // eligibility but persist nothing.
+ bake := loadFirstSeen()
+ if bake.observe(m, time.Now()) && !dryRun {
+ if err := bake.save(); err != nil {
+ return fmt.Errorf("record bake clock: %v", err)
+ }
+ }
routes, err := reg.list()
if err != nil {
@@ -407,6 +419,11 @@ func updatePass(reg *Registry, unitTmpl string, dryRun bool) error {
if cur == e.Version {
continue // up to date — stay quiet on the hourly tick
}
+ if hold := bakeHold(bake, prefs.Channel, e.Version, ignoreBake, time.Now()); hold != "" {
+ fmt.Printf(" %s: %s (%s) is %s — apply now: lchat deploy %s --version %s\n",
+ rt.Host, e.Version, prefs.Channel, hold, rt.Host, e.Version)
+ continue
+ }
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
@@ -473,6 +490,11 @@ func updatePass(reg *Registry, unitTmpl string, dryRun bool) error {
if cur == e.Version {
continue // up to date — stay quiet on the hourly tick
}
+ if hold := bakeHold(bake, prefs.Channel, e.Version, ignoreBake, time.Now()); hold != "" {
+ fmt.Printf(" %s: %s (%s) is %s — apply now: lchat deploy %s --version %s\n",
+ rt.Host, e.Version, prefs.Channel, hold, rt.Host, e.Version)
+ continue
+ }
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
@@ -669,6 +691,7 @@ func runUpdateRun(args []string) {
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")
+ ignoreBake := fs.Bool("ignore-bake", false, "install channel heads still inside their supply-chain bake window (the deliberate local override)")
_ = fs.Parse(reorderFlagsFirst(fs, args))
unlock, err := acquireDeployLock()
@@ -681,7 +704,7 @@ func runUpdateRun(args []string) {
if err != nil {
die("update: open registry: %v", err)
}
- if err := updatePass(reg, *unitTmpl, *dryRun); err != nil {
+ if err := updatePass(reg, *unitTmpl, *dryRun, *ignoreBake); err != nil {
die("update: %v", err)
}
}
@@ -714,6 +737,7 @@ func runUpdateStatus(args []string) {
die("update: %v", err)
}
m, merr := fetchReleaseManifest(releaseManifestURL)
+ bake := loadFirstSeen()
fmt.Printf("%-34s %-8s %-8s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "CURRENT", "AVAILABLE")
for _, rt := range routes {
@@ -744,8 +768,17 @@ func runUpdateStatus(args []string) {
if m != nil {
if e, ok := m.Channels[channel]; ok {
avail = e.Version
- if e.Version == cur {
+ switch {
+ case e.Version == cur:
avail += " (current)"
+ case bakeDelays[channel] > 0:
+ // Status reads the bake clock without ticking it — only an
+ // update pass observes. An unticked head shows as held too.
+ if at, ok := bake.bakeEligibleAt(channel, e.Version); !ok {
+ avail += " (held — bake starts on the next update pass)"
+ } else if time.Now().Before(at) {
+ avail += " (held until " + at.UTC().Format("2006-01-02 15:04 MST") + ")"
+ }
}
}
}
diff --git a/updater_test.go b/updater_test.go
index 0b6fde1..584f657 100644
--- a/updater_test.go
+++ b/updater_test.go
@@ -185,8 +185,18 @@ func seedInstance(t *testing.T, reg *Registry, host, socket, ver string) {
}
}
+// zeroBake disables the supply-chain bake window (bake.go) for tests that
+// exercise the rest of the pipeline; bake behaviour has its own tests below.
+func zeroBake(t *testing.T) {
+ t.Helper()
+ prev := bakeDelays
+ bakeDelays = map[string]time.Duration{"edge": 0, "beta": 0, "stable": 0}
+ t.Cleanup(func() { bakeDelays = prev })
+}
+
func TestUpdatePassAppliesPerPrefs(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
calls := stubSystemctl(t)
shrink := deployHealthTimeout
deployHealthTimeout = 2 * time.Second
@@ -216,7 +226,7 @@ func TestUpdatePassAppliesPerPrefs(t *testing.T) {
seedInstance(t, reg, "h3.example.com", s3, "old1111")
seedInstance(t, reg, "h4.example.com", s4, "old1111")
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatalf("updatePass: %v", err)
}
@@ -274,6 +284,7 @@ func TestUpdatePassAppliesPerPrefs(t *testing.T) {
func TestUpdatePassRollsBackAndPoisons(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
calls := stubSystemctl(t)
shrink := deployHealthTimeout
deployHealthTimeout = 500 * time.Millisecond
@@ -292,7 +303,7 @@ func TestUpdatePassRollsBackAndPoisons(t *testing.T) {
seedInstance(t, reg, "h1.example.com", s1, "old1111")
seedInstance(t, reg, "h2.example.com", s2, "old1111")
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatalf("updatePass: %v", err)
}
@@ -327,13 +338,14 @@ func TestUpdatePassRefusesReplayedManifest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := updatePass(reg, "lchat@", false); err == nil || !strings.Contains(err.Error(), "replayed") {
+ if err := updatePass(reg, "lchat@", false, false); err == nil || !strings.Contains(err.Error(), "replayed") {
t.Fatalf("want replay refusal, got %v", err)
}
}
func TestUpdatePassDryRunTouchesNothing(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
calls := stubSystemctl(t)
_, setChannels := startFeed(t, map[string]string{"new1234": "content"})
@@ -346,7 +358,7 @@ func TestUpdatePassDryRunTouchesNothing(t *testing.T) {
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 {
+ if err := updatePass(reg, "lchat@", true, false); err != nil {
t.Fatal(err)
}
if v, _ := liveVersion("h1.example.com"); v != "old1111" {
@@ -365,6 +377,7 @@ func TestUpdatePassDryRunTouchesNothing(t *testing.T) {
func TestUpdatePassStagesTheRightArch(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
stubSystemctl(t)
_, setChannels := startFeed(t, map[string]string{"new1234": "binary-content"})
@@ -379,7 +392,7 @@ func TestUpdatePassStagesTheRightArch(t *testing.T) {
s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
seedInstance(t, reg, "h1.example.com", s1, "old1111")
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatal(err)
}
staged, err := os.ReadFile(versionBinPath("new1234"))
@@ -393,6 +406,7 @@ func TestUpdatePassStagesTheRightArch(t *testing.T) {
func TestUpdatePassSkipsArchlessRelease(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
calls := stubSystemctl(t)
prevArch := updaterArch
updaterArch = "arm64"
@@ -420,7 +434,7 @@ func TestUpdatePassSkipsArchlessRelease(t *testing.T) {
s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
seedInstance(t, reg, "h1.example.com", s1, "old1111")
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatal(err)
}
if v, _ := liveVersion("h1.example.com"); v != "old1111" {
@@ -456,7 +470,7 @@ func TestUpdatePassSkipsPinnedInstance(t *testing.T) {
t.Fatal(err)
}
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatal(err)
}
@@ -712,7 +726,7 @@ func TestFetchManifestKeepsStalePrimaryWhenMirrorNoFresher(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := updatePass(reg, "lchat@", false); err == nil || !strings.Contains(err.Error(), "replayed") {
+ if err := updatePass(reg, "lchat@", false, false); err == nil || !strings.Contains(err.Error(), "replayed") {
t.Fatalf("want replay refusal, got %v", err)
}
}
@@ -759,6 +773,7 @@ func mirrorBinaryFeed(t *testing.T, binaryContent, mirrorContent string) {
func TestUpdatePassFallsBackToMirrorBinary(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
stubSystemctl(t)
shrink := deployHealthTimeout
deployHealthTimeout = 2 * time.Second
@@ -774,7 +789,7 @@ func TestUpdatePassFallsBackToMirrorBinary(t *testing.T) {
s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
seedInstance(t, reg, "h1.example.com", s1, "old1111")
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatal(err)
}
if v, _ := liveVersion("h1.example.com"); v != "new1234" {
@@ -788,6 +803,7 @@ func TestUpdatePassFallsBackToMirrorBinary(t *testing.T) {
func TestUpdatePassRejectsBadMirrorBinary(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
calls := stubSystemctl(t)
// The mirror serves bytes that don't hash to the signed sha256 — a
@@ -801,7 +817,7 @@ func TestUpdatePassRejectsBadMirrorBinary(t *testing.T) {
s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
seedInstance(t, reg, "h1.example.com", s1, "old1111")
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatal(err)
}
if v, _ := liveVersion("h1.example.com"); v != "old1111" {
@@ -837,6 +853,7 @@ func TestAcquireDeployLockExcludes(t *testing.T) {
// Prefs come from the parked DB; no DB reads as the defaults; pins hold.
func TestUpdatePassRepointsHibernating(t *testing.T) {
withTempOpt(t)
+ zeroBake(t)
calls := stubSystemctl(t)
oldData := instanceDataDir
@@ -895,7 +912,7 @@ func TestUpdatePassRepointsHibernating(t *testing.T) {
seedDB("h3.hib", map[string]string{"update_pin": "old1111"})
seedDB("h4.hib", map[string]string{"update_auto": "0", "update_download": "0"})
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatalf("updatePass: %v", err)
}
@@ -923,7 +940,7 @@ func TestUpdatePassRepointsHibernating(t *testing.T) {
}
// A second pass is quiet + idempotent.
- if err := updatePass(reg, "lchat@", false); err != nil {
+ if err := updatePass(reg, "lchat@", false, false); err != nil {
t.Fatalf("second pass: %v", err)
}
assertLive("h1.hib", "new1234")
diff --git a/web/static/admin.js b/web/static/admin.js
index dfcab64..d31b0e7 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -127,6 +127,10 @@ async function wireUpdates() {
line += ` — pinned to ${s.update_pin}; automatic updates are paused (pick a channel to resume)`;
} else if (u.available_version && u.available_version !== u.build) {
line += ` — ${u.available_version} is available on ${s.update_channel}`;
+ if (u.bake_hours) {
+ // The supply-chain safety hold: new releases wait before auto-installing.
+ line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`;
+ }
} else if (u.checked_at) {
line += ` — up to date on ${s.update_channel}`;
} else {
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 5d493ad..c66fc91 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -1213,6 +1213,10 @@ export const app = defineApp({
line += ` — pinned to ${pin}; automatic updates are paused (pick a channel to resume)`;
} else if (u.available_version && u.available_version !== u.build) {
line += ` — ${u.available_version} is available on ${channel}`;
+ if (u.bake_hours) {
+ // The supply-chain safety hold: new releases wait before auto-installing.
+ line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`;
+ }
} else if (u.checked_at) {
line += ` — up to date on ${channel}`;
} else {