carlos / platform Public

edge+host: mesh key review fixes — standalone mint loop, canonical host, tolerant .next, honest rotation comment, quiet mint

FINDING 1 (Critical): mintMeshKey rode retentionLoop's tick, which returns
early — never arming at all — on a box missing CARLOS_RELEASE_PUBKEY; such a
box still wins the housekeeper election, so the mesh key would silently
never mint fleet-wide. Fixed with mintMeshKeyLoop, its own loop in
housekeeper.go, gated on nothing but deployment-store presence (mirrors
edgesFileLoop's shape exactly), armed beside edgesFileLoop in RunAgent.
retentionLoop is back to prune alone. Plan's Task 2 wiring sentence and Step
3 sample corrected in-place (dated amendments) to name the standalone loop,
matching the File Structure section's original intent.

FINDING 2 (Important): hopMAC now canonicalizes host (lowercase, strip
trailing dot, strip :port via net.SplitHostPort) before it enters the MAC,
so a signer's and a verifier's differing-but-equivalent host forms can never
diverge into spurious refusals. Doc comments state the MAC is over the
concrete request host, never a wildcard map key.

FINDING 3 (Important): loadMeshKeys now tolerates a malformed
control/edge-mesh-key.next per-key — an unparseable .next logs loudly and is
treated as "no rotation in flight" rather than failing the whole call and
taking the live current key down with it.

FINDING 4 (Important): the file header's rotation-safety claim rewritten to
state the truth: dual-accept removes the PROMOTION window, not the
PROPAGATION window (a signer prefers .next instantly; a remote verifier
learns via its own ~30s poll), and names the real cost of that window
(served as an ordinary client, stripped XFF — not an outage).

FINDING 5 (Important): a lost CAS race (key already minted) is now
completely silent, matching housekeeper.go's own silence on election
contention — no log level existed to hedge with, so the honest fix is no
line at all, not "already minted" every tick forever.

Covering tests: TestMintMeshKeyLoopArmsWithoutReleasePubkey (the Finding 1
regression guard), TestMintMeshKeyLoopIsAPlatformBoxJob,
TestHopMACCanonicalizesHost, TestLoadMeshKeysToleratesAnUnreadableNext,
TestMintMeshKeySecondMintIsSilent. Full gate green: build/vet/gofmt/darwin,
all 21 packages, -race on internal/edge and internal/host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0c07490effb97d290d6b92067f5320f03547f359 parent fc2bad7
6 files changed, +350 −46
  • docs/superpowers/plans/2026-08-22-multi-region-backhaul.md +24 −7
  • internal/edge/mesh.go +102 −29
  • internal/edge/mesh_test.go +119 −0
  • internal/host/agent.go +8 −10
  • internal/host/housekeeper.go +48 −0
  • internal/host/housekeeper_test.go +49 −0
diff --git a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
index f99fb432d..34741bc8a 100644
--- a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
+++ b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
@@ -177,8 +177,18 @@ git commit -m "host: per-box edges file — what this box homes, for the fleet's
**Files:**
- Create: `internal/edge/mesh.go`
- Test: `internal/edge/mesh_test.go`
-- Modify: `internal/host/agent.go` (the `runHousekeeperDuties` call at
- ~:1632 gains the mint duty beside `prune`)
+- Modify: `internal/host/housekeeper.go` (a standalone `mintMeshKeyLoop`,
+ armed beside `edgesFileLoop` in `RunAgent` — **Amendment (2026-08-22,
+ review):** the line above originally read "the `runHousekeeperDuties`
+ call at ~:1632 gains the mint duty beside `prune`", i.e. folded into
+ `retentionLoop`'s tick; that is wrong and this File Structure section's
+ own `internal/host/housekeeper.go` entry ("mesh-key mint duty, T2,
+ additive") was the plan's real intent all along. `retentionLoop` returns
+ early — never arming at all — on a box without `CARLOS_RELEASE_PUBKEY`,
+ and such a box still wins the housekeeper election, so a mint duty living
+ only inside it would silently never run fleet-wide. The mint needs its
+ own loop, gated on nothing but deployment-store presence, mirroring
+ `edgesFileLoop`'s shape.)
- Modify: `internal/host/housekeeper_test.go` (duty-count expectations,
if any assert exact duty sets)
@@ -241,15 +251,22 @@ func TestHopSignatureRoundTripsAndBoundsSkew(t *testing.T) {
```
- [ ] **Step 2: verify failure. Step 3: implement** per the interfaces
- above; wire the host-side duty:
+ above; wire the host-side duty as its own loop (**Amendment
+ (2026-08-22, review):** NOT folded into `retentionLoop`'s
+ `runHousekeeperDuties(housekeeperGate, prune, mintMesh)` call as this
+ step originally showed — see the Files section above for why):
```go
-runHousekeeperDuties(housekeeperGate, prune, mintMesh)
+runHousekeeperDuties(housekeeperGate, mint)
```
- where `mintMesh` closes over the deployment store and calls
- `edge.MintMeshKey`; a mint error logs and does not fail the tick
- (idempotent next tick). Single-box inertness: key mints once, nothing
+ called from `mintMeshKeyLoop`'s own `for { sleep; ... }`, armed
+ unconditionally on store presence (mirroring `edgesFileLoop`) rather
+ than riding `retentionLoop`'s `CARLOS_RELEASE_PUBKEY` gate.
+
+ `mint` closes over the deployment store and calls `edge.MintMeshKey`;
+ a mint error logs and does not fail the tick (idempotent next tick).
+ Single-box inertness: key mints once, nothing
reads it until Tasks 3-4.
- [ ] **Step 4:** package gates both packages. **Step 5: Commit**
`edge+host: mesh key — housekeeper mints create-only, HMAC hop vocabulary`.
diff --git a/internal/edge/mesh.go b/internal/edge/mesh.go
index 9f056ea5d..25d14fe24 100644
--- a/internal/edge/mesh.go
+++ b/internal/edge/mesh.go
@@ -11,34 +11,59 @@ package edge
// housekeeper's other duties already use: exactly ONE box mints a shared
// secret, every box mirrors it, and no second election is invented.
//
-// - mintMeshKey is the housekeeper duty (wired beside the releases
-// retention prune, internal/host/agent.go): 32 random bytes,
-// hex-encoded, written to control/edge-mesh-key with an empty ifMatch —
-// release.Store's create-only form. That IS the compare-and-swap: two
-// housekeepers racing the same tick (the transient double-holder window
-// housekeeper.go's poll cadence allows) both attempt the Put, exactly
-// one wins, and the loser's release.ErrCASConflict is not a failure —
-// it is proof a key already exists, which is the only thing this func
-// promises. Every later tick calls it again and finds the same
-// conflict, forever, for the deployment's life: minting is a one-time
-// event, not a duty that ever "finishes".
+// - mintMeshKey is the housekeeper duty (wired as its own loop,
+// internal/host/housekeeper.go's mintMeshKeyLoop — see that func's
+// comment for why it is NOT folded into retentionLoop's tick): 32
+// random bytes, hex-encoded, written to control/edge-mesh-key with an
+// empty ifMatch — release.Store's create-only form. That IS the
+// compare-and-swap: two housekeepers racing the same tick (the
+// transient double-holder window housekeeper.go's poll cadence allows)
+// both attempt the Put, exactly one wins, and the loser's
+// release.ErrCASConflict is not a failure — it is proof a key already
+// exists, which is the only thing this func promises. Every later tick
+// calls it again and finds the same conflict, forever, for the
+// deployment's life: minting is a one-time event, not a duty that ever
+// "finishes" — and it stays SILENT after the first tick (see
+// mintMeshKey's own comment): the steady state of every tick but one,
+// forever, is not something worth a log line, exactly like the
+// housekeeper election's own contention.
//
// - signHop / verifyHop are the hop vocabulary Task 3/4 wire onto the
// request path under the headers X-Carlos-Hop: 1 and X-Carlos-Hop-Auth:
// <unix>:<hex>. The MAC binds the target host into the signature so one
// hop's header cannot be replayed against another, and the timestamp
-// bounds replay in time (±hopSkew) independent of the host check.
+// bounds replay in time (±hopSkew) independent of the host check. The
+// host is canonicalized (canonicalizeHopHost: lowercased, trailing dot
+// stripped, :port stripped) before it ever reaches the MAC, and the MAC
+// is always over the CONCRETE request host a hop is bound for — never a
+// wildcard map key (e.g. "*.example.com") — because Task 3 verifies
+// against the literal net/http request host and Task 4 signs the same;
+// canonicalizing in one place (hopMAC) is what keeps a signer's and a
+// verifier's idea of "host" from ever diverging on case, a trailing
+// root-label dot, or a stray port.
//
// - Rotation's READ side ships in this task, ahead of any code that
// writes .next: control/edge-mesh-key.next mirrors a rotation
// successor while one is in flight, loadMeshKeys reads both,
// verifyHopEitherKey accepts a signature under either, and signingKey
// is what a signer consults to prefer .next the instant it exists —
-// never waiting for a runbook step. That is what makes the promotion
-// itself (copy .next over control/edge-mesh-key, delete .next) safe as
-// a plain runbook action: every verifier already accepts both keys
-// before promotion happens, so there is no window where a box that
-// picked up .next early gets refused by a box that hasn't promoted yet.
+// never waiting for a runbook step.
+//
+// What this removes is the PROMOTION window, not the PROPAGATION one.
+// A signer starts using .next as soon as its OWN box has picked it up;
+// a remote verifier accepts a .next-signed hop only once ITS OWN
+// mirror has too (Task 3/4's poll of the control/ prefix, up to ~30s
+// behind a write). Between those two moments a hop signed under .next
+// CAN be refused by a verifier that hasn't caught up — that is a real,
+// if short, window, and per Task 3's rules the cost of that refusal is
+// the request being served as an ordinary client (its X-Forwarded-For
+// stripped), not dropped: a degraded hop, not an outage. What
+// dual-accept actually buys is that the runbook's promotion step
+// itself (copy .next over control/edge-mesh-key, delete .next) needs
+// no synchronized cutover: because every verifier already accepts
+// EITHER key well before promotion happens, that write can land as an
+// ordinary, un-sequenced action with no box needing to be told "stop
+// signing with the old key" first.
//
// Single-box inertness: on a deployment with one platform box, this box is
// always the housekeeper (housekeeper.go's housekeeperSole), so it mints its
@@ -54,6 +79,7 @@ import (
"errors"
"fmt"
"log"
+ "net"
"os"
"strconv"
"strings"
@@ -88,6 +114,15 @@ const hopSkew = 30 * time.Second
// minted one yet. crypto/rand is required — this key authenticates hops
// between platform boxes, so it gets the same random source every other
// credential in this codebase does, never math/rand.
+//
+// A lost race (release.ErrCASConflict — a key already exists, from this
+// box's own earlier tick or another housekeeper that won the same race) is
+// deliberately SILENT, not logged even at "already minted": that outcome is
+// the steady state of every tick but the very first, forever, for the
+// deployment's whole life — one line every tick would be ~264 lines/day of
+// nothing happening, exactly the noise housekeeper.go's own election
+// contention refuses to log for the same reason. Only an actual, novel mint
+// gets a line.
func mintMeshKey(ctx context.Context, s release.Store) error {
buf := make([]byte, meshKeyBytes)
if _, err := rand.Read(buf); err != nil {
@@ -96,11 +131,6 @@ func mintMeshKey(ctx context.Context, s release.Store) error {
body := []byte(hex.EncodeToString(buf))
if _, err := s.Put(ctx, meshKeyKey, body, ""); err != nil {
if errors.Is(err, release.ErrCASConflict) {
- // Success-by-other: a key already exists (this box's earlier
- // tick, or another housekeeper that won the same race). Debug,
- // not error — this is the steady state for every tick after the
- // first, forever.
- log.Printf("edge: mesh key: already minted")
return nil
}
return fmt.Errorf("mesh key: put: %w", err)
@@ -110,8 +140,8 @@ func mintMeshKey(ctx context.Context, s release.Store) error {
}
// MintMeshKey is mintMeshKey's exported wrapper — the seam
-// internal/host's housekeeper duty calls through, so the duty lives beside
-// the releases-retention prune in internal/host/agent.go while the key's own
+// internal/host's housekeeper duty (mintMeshKeyLoop, housekeeper.go) calls
+// through, so the duty lives beside the housekeeper while the key's own
// shape (its object name, its random-byte discipline) stays edge's to own.
func MintMeshKey(ctx context.Context, s release.Store) error {
return mintMeshKey(ctx, s)
@@ -141,14 +171,25 @@ func getMeshKeyHex(ctx context.Context, s release.Store, key string) ([]byte, er
// only before the housekeeper has minted anything; next is nil the whole
// rest of the time — nearly always, since a rotation is a deliberate,
// short-lived operator action.
+//
+// A decode failure on meshKeyNextKey does NOT propagate: .next is written
+// by hand during a rotation (there is no code in this task that writes it),
+// so one bad upload is a real possibility, and the safe response to "I
+// can't read the rotation successor" is to act as though no rotation is in
+// flight — not to take every hop fleet-wide down by refusing the perfectly
+// good current key alongside it. The failure is loud (an operator mistake
+// worth paging on), just not fatal to this call. A decode failure on
+// meshKeyKey itself DOES propagate: that is the live, day-to-day signing
+// and verifying key, and there is no safe fallback for it being unreadable.
func loadMeshKeys(ctx context.Context, s release.Store) (current, next []byte, err error) {
current, err = getMeshKeyHex(ctx, s, meshKeyKey)
if err != nil {
return nil, nil, err
}
- next, err = getMeshKeyHex(ctx, s, meshKeyNextKey)
- if err != nil {
- return nil, nil, err
+ next, nextErr := getMeshKeyHex(ctx, s, meshKeyNextKey)
+ if nextErr != nil {
+ log.Printf("edge: mesh key: %s is unreadable, treating as no rotation in flight until fixed: %v", meshKeyNextKey, nextErr)
+ next = nil
}
return current, next, nil
}
@@ -169,6 +210,11 @@ func signingKey(current, next []byte) []byte {
// HMAC-SHA256 over "host\nunix-timestamp", hex-encoded and prefixed with the
// timestamp so a verifier does not have to guess it. host is bound into the
// MAC so a signature minted for one hop cannot be replayed against another.
+// host must be the CONCRETE host a hop is bound for (a net/http request
+// host, e.g. from Task 4's forwarding decision) — never a wildcard home-map
+// key like "*.example.com" — because hopMAC canonicalizes it the same way a
+// verifier's literal request host is canonicalized, and a wildcard pattern
+// is not a request host under any canonicalization.
func signHop(key []byte, host string, now time.Time) string {
ts := strconv.FormatInt(now.Unix(), 10)
return ts + ":" + hex.EncodeToString(hopMAC(key, host, ts))
@@ -220,11 +266,38 @@ func verifyHopEitherKey(current, next []byte, host, header string, now time.Time
}
// hopMAC is the MAC signHop and verifyHop both compute — one place so the
-// two can never drift out of sync on what bytes are actually signed.
+// two can never drift out of sync on what bytes are actually signed, and
+// the one choke point that canonicalizes host before it enters the MAC.
func hopMAC(key []byte, host, ts string) []byte {
mac := hmac.New(sha256.New, key)
- mac.Write([]byte(host))
+ mac.Write([]byte(canonicalizeHopHost(host)))
mac.Write([]byte("\n"))
mac.Write([]byte(ts))
return mac.Sum(nil)
}
+
+// canonicalizeHopHost normalizes a request host into the exact form the MAC
+// is computed over, so a signer and a verifier can never disagree on what
+// "host" means for the same request:
+//
+// - a :port is stripped via net.SplitHostPort when present — Task 3
+// verifies against a literal net/http request Host, which carries a
+// port when the client set one (or the request came in over a
+// non-default port), while a signer's own idea of the host (e.g. a
+// home-map lookup key) never does;
+// - lowercased — HTTP host headers are case-insensitive, but bytes
+// entering an HMAC are not, so a client or intermediary that
+// capitalizes a label must not be able to produce a request this MAC
+// treats as a different host;
+// - a single trailing root-label dot is stripped — "example.com." and
+// "example.com" name the same host, and a client is free to send
+// either.
+//
+// host is returned unchanged (net.SplitHostPort's error path) when it
+// carries no port — the common case, and not a failure.
+func canonicalizeHopHost(host string) string {
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ return strings.TrimSuffix(strings.ToLower(host), ".")
+}
diff --git a/internal/edge/mesh_test.go b/internal/edge/mesh_test.go
index ebadd551d..59be378da 100644
--- a/internal/edge/mesh_test.go
+++ b/internal/edge/mesh_test.go
@@ -4,12 +4,26 @@ import (
"bytes"
"context"
"encoding/hex"
+ "log"
+ "strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/release"
)
+// captureLog redirects the standard logger for the duration of a test —
+// synchronous callers only (mintMeshKey, loadMeshKeys direct calls here are
+// never run concurrently with the read), same shape certsync_test.go uses.
+func captureLog(t *testing.T) *bytes.Buffer {
+ t.Helper()
+ var buf bytes.Buffer
+ prev := log.Writer()
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(prev) })
+ return &buf
+}
+
// meshTestStore is a fresh directory-backed store, the same DirStore every
// other edge test in this package uses in place of live S3.
func meshTestStore(t *testing.T) release.Store {
@@ -48,6 +62,28 @@ func TestMintMeshKeyExportedWrapperMintsTheSameKey(t *testing.T) {
}
}
+// A lost race is the steady state of every tick but the first, forever — it
+// must not log anything, matching housekeeper.go's own silence on election
+// contention. Only the first, real mint gets a line.
+func TestMintMeshKeySecondMintIsSilent(t *testing.T) {
+ buf := captureLog(t)
+ s := meshTestStore(t)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatal(err)
+ }
+ first := buf.String()
+ if !strings.Contains(first, "minted") {
+ t.Fatalf("first mint log = %q, want it to mention minting", first)
+ }
+ buf.Reset()
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatalf("second mint must be success-by-other: %v", err)
+ }
+ if got := buf.String(); got != "" {
+ t.Fatalf("second mint (a lost CAS race) logged %q, want silence", got)
+ }
+}
+
func TestHopSignatureRoundTripsAndBoundsSkew(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
now := time.Unix(1_766_000_000, 0)
@@ -99,6 +135,50 @@ func TestVerifyHopRefusesMalformedHeaders(t *testing.T) {
}
}
+// The MAC is over the CANONICAL host, so a signer's and a verifier's
+// differing-but-equivalent forms of the same host must both verify against
+// the same signature: Task 4 signs one form (e.g. a home-map lookup key,
+// unlikely to carry a port), Task 3 verifies a literal net/http request Host
+// (may carry a port, whatever case and trailing dot the client sent) —
+// divergence here would be spurious refusals presenting as regional
+// flakiness, not a security property either side intends.
+func TestHopMACCanonicalizesHost(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ now := time.Unix(1_766_000_000, 0)
+ canonical := signHop(key, "app.example.com", now)
+
+ // A signature made for the canonical host verifies against every
+ // equivalent form a verifier might see the request host in.
+ for _, host := range []string{
+ "app.example.com",
+ "APP.EXAMPLE.COM",
+ "App.Example.Com",
+ "app.example.com.",
+ "app.example.com:443",
+ "app.example.com.:8443",
+ "APP.EXAMPLE.COM.:443",
+ } {
+ if !verifyHop(key, host, canonical, now) {
+ t.Fatalf("verifyHop(%q) against a signature made for the canonical host = false, want true", host)
+ }
+ }
+
+ // And signing under any of those same equivalent forms produces a
+ // signature that verifies against the canonical form too — the
+ // canonicalization is symmetric, not a one-sided allowance.
+ for _, host := range []string{"APP.EXAMPLE.COM", "app.example.com.", "app.example.com:9443"} {
+ h := signHop(key, host, now)
+ if !verifyHop(key, "app.example.com", h, now) {
+ t.Fatalf("a signature made for host %q must verify against the canonical form", host)
+ }
+ }
+
+ // A genuinely different host must still refuse.
+ if verifyHop(key, "other.example.com", canonical, now) {
+ t.Fatal("a different host must still refuse — canonicalization must not collapse distinct hosts")
+ }
+}
+
// ---- rotation dual-accept (plan's global constraint: the READ side ships
// now — verifyHopEitherKey and signingKey are what Task 3/4's request path
// calls once it has loaded both mirrored keys) --------------------------------
@@ -189,3 +269,42 @@ func TestLoadMeshKeysReadsBothMirroredObjects(t *testing.T) {
t.Fatalf("key lengths = (%d, %d), want both %d", len(current), len(next), meshKeyBytes)
}
}
+
+// A malformed .next — hand-written during a rotation, so a bad upload is a
+// real possibility — must not take the live current key down with it: the
+// safe response is to treat it as no rotation in flight (loudly logged),
+// never to refuse every hop fleet-wide over one bad object.
+func TestLoadMeshKeysToleratesAnUnreadableNext(t *testing.T) {
+ ctx := context.Background()
+ s := meshTestStore(t)
+ if err := mintMeshKey(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, meshKeyNextKey, []byte("not-valid-hex-zzzz"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureLog(t)
+ current, next, err := loadMeshKeys(ctx, s)
+ if err != nil {
+ t.Fatalf("loadMeshKeys with a malformed .next must not fail: %v", err)
+ }
+ if len(current) != meshKeyBytes {
+ t.Fatalf("current key length = %d, want %d — a malformed .next took the live key down with it", len(current), meshKeyBytes)
+ }
+ if next != nil {
+ t.Fatal("a malformed .next must be treated as absent (no rotation in flight), not surfaced")
+ }
+ if got := buf.String(); !strings.Contains(got, meshKeyNextKey) {
+ t.Fatalf("log = %q, want a loud mention of %s — this is an operator mistake worth paging on", got, meshKeyNextKey)
+ }
+
+ // And a signature made under the still-good current key verifies fine —
+ // the tolerance is not merely "does not error", it's "hop auth keeps
+ // working".
+ now := time.Unix(1_766_000_000, 0)
+ h := signHop(current, "app.example.com", now)
+ if !verifyHopEitherKey(current, next, "app.example.com", h, now) {
+ t.Fatal("hop auth must keep working off the current key despite the malformed .next")
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 34d64e6a0..4438131c2 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -322,6 +322,13 @@ func RunAgent(args []string) {
go retentionLoop(reg)
go registrySnapshotLoop(reg)
go edgesFileLoop(reg)
+ // The mesh key's own loop (multi-region backhaul spec, Part 3 Task 2) —
+ // deliberately NOT a duty riding retentionLoop's tick: that loop returns
+ // early, never arming at all, on a box missing CARLOS_RELEASE_PUBKEY,
+ // and such a box still WINS the housekeeper election. A mint duty living
+ // only inside retentionLoop would then silently never run fleet-wide.
+ // See mintMeshKeyLoop's own comment (housekeeper.go).
+ go mintMeshKeyLoop()
// The sweep reads the registry only through this closure — the stamped
// bespoke units to sample beside the carlos- ones, re-read each tick.
go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })
@@ -1626,20 +1633,11 @@ func retentionLoop(reg *registry.Registry) {
prune := housekeeperDuty{"retention pass", func(ctx context.Context) error {
return retentionPass(ctx, store, pub, reg)
}}
- // The mesh key mint (multi-region backhaul spec, Part 3 Task 2) rides
- // the same tick and the same store: it is a second deployment-global
- // duty, so it belongs behind the same one election this loop already
- // gates prune on, not a second loop contending for a second lock.
- // mintMeshKey is create-only and idempotent — every tick after the
- // first finds the key already minted and does nothing, quietly.
- mintMesh := housekeeperDuty{"mesh key mint", func(ctx context.Context) error {
- return edge.MintMeshKey(ctx, store)
- }}
delay := time.Minute
for {
time.Sleep(delay)
delay = 5*time.Minute + rand.N(time.Minute)
- runHousekeeperDuties(housekeeperGate, prune, mintMesh)
+ runHousekeeperDuties(housekeeperGate, prune)
}
}
diff --git a/internal/host/housekeeper.go b/internal/host/housekeeper.go
index ab7386fa0..baf4442c4 100644
--- a/internal/host/housekeeper.go
+++ b/internal/host/housekeeper.go
@@ -58,10 +58,12 @@ import (
"context"
"errors"
"log"
+ "math/rand/v2"
"os"
"sync/atomic"
"time"
+ "github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
)
@@ -422,3 +424,49 @@ func runHousekeeperDuties(gate func() (context.Context, bool), duties ...houseke
}
}
}
+
+// mintMeshKeyLoop mints the deployment's mesh key (multi-region backhaul
+// spec, Part 3 Task 2) — a housekeeper duty, but deliberately its OWN loop
+// rather than a duty folded into retentionLoop's runHousekeeperDuties call
+// (agent.go's retentionLoop, gated on prune's cadence).
+//
+// The plan first drafted the mint as riding retentionLoop's tick; review
+// caught the trap: retentionLoop returns early — never arming its loop at
+// all, so runHousekeeperDuties is never even called — on a box missing
+// CARLOS_RELEASE_PUBKEY (release-signature verification, something wholly
+// unrelated to the mesh key). Critically, a box in that state still WINS
+// the housekeeper election (newHousekeeper/hk.run don't consult
+// CARLOS_RELEASE_PUBKEY at all), so the one box holding the lease would
+// simply never mint, and no other box would ever try either — the mesh key
+// would silently never exist, and Tasks 3/4's hop auth would never arm,
+// fleet-wide, with no error anywhere. This loop is gated on nothing but
+// deployment-store presence (shouldSkipPublisherElection, openDeployStore),
+// mirroring edgesfile.go's edgesFileLoop shape exactly, so minting is
+// available on every housekeeper independent of every other loop's
+// preconditions.
+//
+// mintMeshKey itself is create-only and idempotent (mesh.go's own comment):
+// every tick after the deployment's first successful mint finds the key
+// already there and does nothing, silently.
+func mintMeshKeyLoop() {
+ ctx := context.Background()
+ if shouldSkipPublisherElection(os.Getenv) {
+ log.Printf("agent: mesh key mint loop is a platform-box job — skipped on a fleet box")
+ return
+ }
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: mesh key mint loop disabled: %v", err)
+ return
+ }
+ log.Printf("agent: mesh key mint loop armed (5min + jitter) — mints only while this box holds the housekeeper lease")
+ mint := housekeeperDuty{"mesh key mint", func(ctx context.Context) error {
+ return edge.MintMeshKey(ctx, store)
+ }}
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ runHousekeeperDuties(housekeeperGate, mint)
+ }
+}
diff --git a/internal/host/housekeeper_test.go b/internal/host/housekeeper_test.go
index 13ec86c3e..31d19e64f 100644
--- a/internal/host/housekeeper_test.go
+++ b/internal/host/housekeeper_test.go
@@ -511,3 +511,52 @@ func TestHousekeeperHeldIsFalseBeforeAnyElectionIsArmed(t *testing.T) {
t.Fatal("housekeeperGate() reports held with no housekeeper armed")
}
}
+
+// ---- mintMeshKeyLoop (multi-region backhaul spec, Part 3 Task 2's review
+// fix): the mesh key's own arming loop, independent of retentionLoop -------
+
+// Mirrors TestRetentionLoopIsAPlatformBoxJob's shape (agent_test.go): a
+// fleet box must skip before ever opening a store.
+func TestMintMeshKeyLoopIsAPlatformBoxJob(t *testing.T) {
+ const skipLine = "agent: mesh key mint loop is a platform-box job — skipped on a fleet box"
+
+ logs := captureLog(t)
+ door := newDoorFake(t)
+ for k, v := range fleetDoorEnv(door.url) {
+ t.Setenv(k, v)
+ }
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+
+ mintMeshKeyLoop() // must return; a fleet box never reaches the loop
+
+ if got := logs(); !strings.Contains(got, skipLine) {
+ t.Fatalf("log = %q, want the skip line verbatim", got)
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero — the skip must land BEFORE the store opens", hits)
+ }
+}
+
+// The regression guard for the Critical review finding: the mint must NOT be
+// gated on CARLOS_RELEASE_PUBKEY (retentionLoop's own gate, unrelated to the
+// mesh key) — a box missing that var still wins the housekeeper election, so
+// a mint duty tied to retentionLoop's arming would silently never run
+// fleet-wide. Proven by explicitly leaving CARLOS_RELEASE_PUBKEY unset and
+// confirming the loop still arms.
+func TestMintMeshKeyLoopArmsWithoutReleasePubkey(t *testing.T) {
+ t.Setenv("CARLOS_FLEET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
+ t.Setenv("CARLOS_RELEASE_PUBKEY", "") // deliberately unset — the whole point
+
+ buf := captureRacyLog(t)
+ // mintMeshKeyLoop never returns once armed: run it in its own goroutine
+ // (it parks in a time.Sleep for a minute, long past this test's end) and
+ // wait for the arming log line against a synchronized sink.
+ go mintMeshKeyLoop()
+
+ waitFor(t, "the mint loop to arm despite no CARLOS_RELEASE_PUBKEY", func() bool {
+ return strings.Contains(buf.String(), "agent: mesh key mint loop armed")
+ })
+}