carlos / platform Public

edge: rotate/detach contract pinned — retry failed reloads, doc the session asymmetry

Two Important review fixes:

- SetDynamicAuth's doc comment now states the rotate/detach asymmetry
  explicitly: rotation refuses NEW dials with the old token but leaves
  an already-live session running (closing it would take the box down
  for the registry-write-to-box-learns-new-token gap); detach is the
  hard revocation that force-closes a live channel immediately. The
  code was already correct (CloseChannel only fires for names missing
  from the new set, not names present under a changed hash) — this
  fixes the comment to say so.

- Watch no longer baselines `last = fp` before Reload succeeds: a
  detach is a revocation, and discarding a failed reload's fingerprint
  used to make the change invisible to every later tick until some
  unrelated write moved the fingerprint again. `last` now only advances
  after a successful Reload, so a failing tick retries on the next one;
  reload failures are throttled the same errs%300 way poll failures
  already were.

Plus the pinning test the review asked for: TestRotateVsDetachAsymmetry
rotates a token and asserts a new dial with the old token is refused
while the pre-existing live channel keeps running, then detaches the
same box and asserts that live channel IS closed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2ca9f442d175efbdfa671f329a3ee9438f71462b parent 3b4a4a2
3 files changed, +121 −14
  • internal/edge/edge.go +15 −2
  • internal/edge/tunnel.go +26 −12
  • internal/edge/tunnel_test.go +80 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 8f30a9973..53f2f00dd 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -852,6 +852,7 @@ func (e *Edge) Watch(stop <-chan struct{}) {
// that gap; one redundant reload at startup is the cost of closing it.)
var last string
errs := 0
+ reloadErrs := 0
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
@@ -872,11 +873,23 @@ func (e *Edge) Watch(stop <-chan struct{}) {
if fp == last {
continue
}
- last = fp
+ // last is baselined only on a SUCCESSFUL reload: a fleet detach is a
+ // revocation, and if Reload fails on this tick, the fingerprint it
+ // was meant to apply must not be discarded — baselining it here
+ // (before Reload runs) would make the change invisible to every
+ // later tick until some UNRELATED write moves the fingerprint again,
+ // silently sitting on a revocation that never took effect. Leaving
+ // last unchanged means the very next tick sees the same fp != last
+ // and retries.
if err := e.Reload(); err != nil {
- log.Printf("edge: registry-watch reload failed: %v", err)
+ reloadErrs++
+ if reloadErrs == 1 || reloadErrs%300 == 0 {
+ log.Printf("edge: registry-watch reload failed (#%d): %v", reloadErrs, err)
+ }
continue
}
+ reloadErrs = 0
+ last = fp
log.Printf("edge: registry changed — reloaded (%d route(s))", len(e.current()))
}
}
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go
index 8730105fd..d73f4eb50 100644
--- a/internal/edge/tunnel.go
+++ b/internal/edge/tunnel.go
@@ -222,19 +222,33 @@ func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn) {
// SetDynamicAuth replaces the entire registry-backed credential set —
// channel "<fleet>/<label>" -> sha256 hex of its bearer token — with auth.
-// The whole-map REPLACE (not an incremental merge) is what makes a rotated
-// or detached box stop authenticating the instant this returns: the next
-// ServeHTTP call for a vanished name simply misses the map. Called by
-// Edge.Reload on every registry poll (edge.go), so this is the whole
-// rotate/detach convergence path — no separate polling of its own.
+// The whole-map REPLACE (not an incremental merge) is what makes a NEW dial
+// with a stale token get refused the instant this returns, for both
+// rotation and detach: the next ServeHTTP call either hashes the presented
+// token against a DIFFERENT stored hash (rotation: the name is still
+// present, just under a new hash) or misses the map entirely (detach: the
+// name is gone). Either way, a new dial with the old token fails now.
//
-// A name present in the OLD set but missing from auth has just been
-// revoked; any conn already established under its (now-invalid) token must
-// be torn down too, or an already-authenticated h2 session would keep
-// carrying traffic past the revocation until its next failed ping
-// (tunnelPingEvery, up to 15s) instead of closing on this call. CloseChannel
-// is called for each such name AFTER the map swap, so the swap (which
-// blocks any NEW dial with the old token) always lands first.
+// ROTATE and DETACH are deliberately asymmetric on an ALREADY-LIVE session,
+// and that asymmetry is load-bearing, not an oversight:
+//
+// - Detach: the name vanishes from auth entirely, so it is in the OLD set
+// but missing from the new one — the loop below calls CloseChannel on
+// it, force-closing any live conn right now. Detach is the hard
+// revocation path; a box taken out of the fleet must not keep a
+// channel open on its old credential for even one more request.
+// - Rotate: the name STAYS present in auth, just under a new hash — the
+// loop below sees it as still-authorized (present in both old and new)
+// and does NOT close it. An already-established h2 session therefore
+// keeps carrying traffic on its OLD token's connection until the
+// legitimate box notices (its own ping/reconnect cycle) and re-dials
+// with the NEW token — not until this call returns. This is
+// deliberate: closing on every rotation would take the box's channel
+// down for the gap between the registry write and the box learning its
+// new token, and rotation (unlike detach) is not "this box must be cut
+// off now" — it is "this box's credential changed." A caller that wants
+// rotation to also force an immediate reconnect must close explicitly
+// (CloseChannel) rather than relying on SetDynamicAuth to do it.
func (h *TunnelHub) SetDynamicAuth(auth map[string]string) {
h.mu.Lock()
old := h.dynamicAuth
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go
index b2d90a116..fdc017273 100644
--- a/internal/edge/tunnel_test.go
+++ b/internal/edge/tunnel_test.go
@@ -515,6 +515,86 @@ func TestReloadDetachedFleetBoxClosesLiveChannel(t *testing.T) {
}
}
+// TestRotateVsDetachAsymmetry pins the deliberate difference between the two
+// registry-driven revocation paths, through the real Reload wiring:
+//
+// - Rotate (SetFleetBoxToken, same box still active): a NEW dial with the
+// OLD token is refused immediately, but an ALREADY-LIVE channel is left
+// alone — closing it would take the box's connection down for the gap
+// between the registry write and the box itself learning its new token.
+// - Detach (SetFleetBoxStatus "detached"): the hard revocation — an
+// ALREADY-LIVE channel is force-closed on the very next Reload.
+func TestRotateVsDetachAsymmetry(t *testing.T) {
+ reg := openReg(t)
+ if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ oldHash := sha256Hex("old-tok")
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: oldHash, Status: "active"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hub := NewTunnelHub("tunnel.test", nil)
+ e.EnableTunnels(hub)
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+
+ // A live channel, as if pi-1 had already dialed in on the old token.
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ hub.mu.Lock()
+ hub.conns["acme/pi-1"] = &tunnelConn{raw: c1}
+ hub.mu.Unlock()
+
+ // --- Rotate ---
+ newHash := sha256Hex("new-tok")
+ if err := reg.SetFleetBoxToken("acme", "pi-1", newHash); err != nil {
+ t.Fatal(err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+
+ // (a) A NEW dial with the OLD token is refused now.
+ rec := httptest.NewRecorder()
+ hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "old-tok"))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("rotate: new dial with old token: got %d, want 401", rec.Code)
+ }
+ // A NEW dial with the NEW token is accepted.
+ rec2 := httptest.NewRecorder()
+ hub.ServeHTTP(rec2, tunnelReq("acme/pi-1", "new-tok"))
+ if rec2.Code != http.StatusInternalServerError {
+ t.Fatalf("rotate: new dial with new token: got %d, want 500 (past auth)", rec2.Code)
+ }
+ // (b) The EXISTING live channel from before the rotation is untouched —
+ // that's the designed asymmetry, not a bug.
+ hub.mu.Lock()
+ c, stillLive := hub.conns["acme/pi-1"]
+ hub.mu.Unlock()
+ if !stillLive || c.raw != c1 {
+ t.Fatal("rotate: the pre-existing live channel was closed — rotation must not evict an established session")
+ }
+
+ // --- Detach ---
+ if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil {
+ t.Fatal(err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ hub.mu.Lock()
+ _, stillThere := hub.conns["acme/pi-1"]
+ hub.mu.Unlock()
+ if stillThere {
+ t.Fatal("detach: the live channel from before detach was not closed — detach must be a hard revocation")
+ }
+}
+
// A tunnel-served host is never activator-managed: the process lives on the
// far side of the channel, so a wake here would start a second, local copy.
// The retire pass must also drop it, killing the child a repointed route left