edge: fleet tunnel channels authenticate against registry token hashes
TunnelHub gains a second credential source (dynamicAuth: channel "<fleet>/<label>" -> sha256 token hash) alongside the existing static env clients. ServeHTTP tries the static (plaintext) path first, falls through to hashing the presented token and constant-time-comparing it against the dynamic set, and funnels both into one 401 so a caller cannot distinguish which source (or neither) missed. SetDynamicAuth replaces the whole dynamic set and force-closes (CloseChannel, mirrors pingLoop's delete-if-still-live guard) any live conn whose channel just vanished, so a rotated/detached box's old token is refused on its very next dial rather than surviving until its next failed ping. Edge.Reload wires this from Registry.FleetChannelAuth (guarded on e.tunnels != nil) — no new polling, the existing 2s Watch->Reload loop is the whole rotate/detach convergence bound, since Fingerprint already moves on every fleet mutation. enableTunnelsFromEnv no longer requires CARLOS_TUNNEL_CLIENTS to be non-empty: a box that only serves fleet channels legitimately has no static clients configured, but still needs a hub for the dynamic set to land on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+354
−19
internal/edge/edge.go+39 −14internal/edge/tunnel.go+83 −2internal/edge/tunnel_test.go+232 −3
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 88308d2d9..8f30a9973 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -315,6 +315,23 @@ func (e *Edge) Reload() error { |
| } |
| m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib} |
| } |
| + // Fleet channel auth: registry-backed, so this refreshes on the SAME poll |
| + // that rebuilds routes — Task 1's Fingerprint moves on every fleet |
| + // mutation (including token rotation), and the edge's existing 2s |
| + // Watch->Reload loop (below, Watch) is the whole convergence bound for a |
| + // rotated or detached box. No hub configured (e.tunnels == nil, the vast |
| + // majority of boxes today) means no fleet channels either — same |
| + // nil-means-disabled convention as act/cache/staticStore. Errors here |
| + // abort the WHOLE reload (routes included), matching e.reg.List() above: |
| + // a transient registry read failure must leave both tables exactly as |
| + // they were, not blank out fleet auth while routes move on. |
| + if e.tunnels != nil { |
| + auth, err := e.reg.FleetChannelAuth() |
| + if err != nil { |
| + return err |
| + } |
| + e.tunnels.SetDynamicAuth(auth) |
| + } |
| e.upstreams.Store(&m) |
| // Reconcile the activator with the new route set: a host un-flagged (or |
| // removed) since the last reload may still have a live child holding its |
| @@ -1176,19 +1193,29 @@ func enableStaticFromEnv(e *Edge, dataDir string) error { |
| } |
| // enableTunnelsFromEnv arms the reverse-channel hub when the environment |
| -// names a reserved hostname (CARLOS_TUNNEL_HOST) and its authorized clients |
| -// (CARLOS_TUNNEL_CLIENTS, "name=token[,name=token]"). No CARLOS_TUNNEL_HOST is |
| -// not an error: the box simply serves no tunnels, and routes with Tunnel set |
| -// keep whatever local backend they have (Reload's tunnel case is guarded on |
| -// the hub). A host WITH no usable clients IS an error — it would open a |
| -// registration endpoint no one can ever authenticate against, which reads |
| -// like a working tunnel edge right up until a host tries to dial it. |
| +// names a reserved hostname (CARLOS_TUNNEL_HOST); CARLOS_TUNNEL_CLIENTS |
| +// ("name=token[,name=token]") names its STATIC authorized clients, which may |
| +// legitimately be empty. No CARLOS_TUNNEL_HOST is not an error: the box |
| +// simply serves no tunnels at all, and routes with Tunnel set keep whatever |
| +// local backend they have (Reload's tunnel case is guarded on the hub) — no |
| +// fleet channels either, since those need a hub to land on just as much as a |
| +// static client does. |
| +// |
| +// A host with no static clients configured IS still valid, unlike before |
| +// fleet channels existed: the hub also serves customer-fleet boxes, whose |
| +// credentials come from the registry (Reload -> SetDynamicAuth), not this |
| +// env var — a box that only ever hosts fleet channels legitimately runs with |
| +// CARLOS_TUNNEL_CLIENTS unset. The registration endpoint this arms is not |
| +// "no one can ever authenticate against" in that case; it is "no STATIC |
| +// client can," which the operator chose on purpose. |
| // |
| -// Channel names are validated here, at the only place a name enters the |
| +// Static channel names are validated here, at the only place one enters the |
| // process from config: a name the CLI would refuse (registry.ValidTunnelName, |
| // what `carlos route -tunnel` enforces) can never match a route's Tunnel |
| // column, so such a client would authenticate, connect, and silently carry no |
| -// traffic at all. Fail the boot instead, naming the entry. |
| +// traffic at all. Fail the boot instead, naming the entry. Dynamic (fleet) |
| +// channel names are validated separately, at the registry writer |
| +// (AddFleetBox) — they are never parsed from this env var. |
| func enableTunnelsFromEnv(e *Edge) error { |
| host := os.Getenv("CARLOS_TUNNEL_HOST") |
| if host == "" { |
| @@ -1198,9 +1225,6 @@ func enableTunnelsFromEnv(e *Edge) error { |
| if err != nil { |
| return err |
| } |
| - if len(clients) == 0 { |
| - return fmt.Errorf("CARLOS_TUNNEL_HOST=%s set but CARLOS_TUNNEL_CLIENTS is empty", host) |
| - } |
| for _, name := range slices.Sorted(maps.Keys(clients)) { |
| if !registry.ValidTunnelName(name) { |
| return fmt.Errorf("CARLOS_TUNNEL_CLIENTS: %q is not a valid tunnel name — want [a-z0-9-]{1,32}, and it must match a route's -tunnel to carry anything", name) |
| @@ -1208,11 +1232,12 @@ func enableTunnelsFromEnv(e *Edge) error { |
| } |
| e.EnableTunnels(NewTunnelHub(host, clients)) |
| // Rebuild the route table so tunnel-backed routes that were skipped (or |
| - // left on their old socket/addr) by the startup reload pick up the hub. |
| + // left on their old socket/addr) by the startup reload pick up the hub, |
| + // and so fleet channel auth (SetDynamicAuth) gets its first load. |
| if err := e.Reload(); err != nil { |
| return fmt.Errorf("reload after EnableTunnels: %w", err) |
| } |
| - log.Printf("edge: tunnel hub armed on %s (%d client(s))", host, len(clients)) |
| + log.Printf("edge: tunnel hub armed on %s (%d static client(s))", host, len(clients)) |
| return nil |
| } |
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go| index afd6459d0..8730105fd 100644 |
| --- a/internal/edge/tunnel.go |
| +++ b/internal/edge/tunnel.go |
| @@ -10,7 +10,9 @@ package edge |
| import ( |
| "context" |
| + "crypto/sha256" |
| "crypto/subtle" |
| + "encoding/hex" |
| "fmt" |
| "log" |
| "net" |
| @@ -54,9 +56,17 @@ type tunnelConn struct { |
| type TunnelHub struct { |
| host string // reserved hostname this hub answers on |
| - clients map[string]string // channel name -> bearer token |
| + clients map[string]string // channel name -> bearer token (static env config, plaintext) |
| mu sync.Mutex |
| conns map[string]*tunnelConn |
| + // dynamicAuth is the second, registry-backed credential source: channel |
| + // "<fleet>/<label>" -> sha256 hex of its bearer token. Guarded by mu |
| + // alongside conns since SetDynamicAuth's rotation path (below) touches |
| + // both. Static clients keep authenticating by plaintext compare, |
| + // unchanged; this is consulted only when a name misses h.clients, which |
| + // is always true for a dynamic name — ValidTunnelName-checked static |
| + // names never contain "/". |
| + dynamicAuth map[string]string |
| } |
| func NewTunnelHub(host string, clients map[string]string) *TunnelHub { |
| @@ -103,7 +113,29 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // An empty configured token never authenticates anything: parseTunnelClients |
| // rejects one, and a hub built some other way must not turn a blank config |
| // value into a channel that answers to "Authorization: Bearer ". |
| - if name == "" || !ok || want == "" || !hasBearer || subtle.ConstantTimeCompare([]byte(token), []byte(want)) != 1 { |
| + authOK := name != "" && ok && want != "" && hasBearer && |
| + subtle.ConstantTimeCompare([]byte(token), []byte(want)) == 1 |
| + // Second credential source: a name that misses the static map (every |
| + // "<fleet>/<label>" dynamic channel does, since ValidTunnelName-checked |
| + // static names never contain "/") falls through to the registry-backed |
| + // set SetDynamicAuth maintains. The presented token is never compared in |
| + // the clear here — only its sha256 hex is, against the stored hash — so |
| + // a leaked auth log or memory dump of this table never yields a usable |
| + // bearer token. Folded into the SAME authOK the static path feeds, so |
| + // there is exactly one 401 below regardless of which source (or neither) |
| + // missed; a caller cannot distinguish "wrong static token" from "unknown |
| + // dynamic channel" from "wrong dynamic token" by response shape. |
| + if !authOK && name != "" && hasBearer { |
| + h.mu.Lock() |
| + wantHash, dynOK := h.dynamicAuth[name] |
| + h.mu.Unlock() |
| + if dynOK && wantHash != "" { |
| + sum := sha256.Sum256([]byte(token)) |
| + gotHash := hex.EncodeToString(sum[:]) |
| + authOK = subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) == 1 |
| + } |
| + } |
| + if !authOK { |
| http.Error(w, "unauthorized", http.StatusUnauthorized) |
| return |
| } |
| @@ -188,6 +220,55 @@ 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. |
| +// |
| +// 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. |
| +func (h *TunnelHub) SetDynamicAuth(auth map[string]string) { |
| + h.mu.Lock() |
| + old := h.dynamicAuth |
| + h.dynamicAuth = auth |
| + h.mu.Unlock() |
| + for name := range old { |
| + if _, stillAuthorized := auth[name]; !stillAuthorized { |
| + h.CloseChannel(name) |
| + } |
| + } |
| +} |
| + |
| +// CloseChannel force-closes and unregisters name's live conn, if any — the |
| +// detach/rotate path (SetDynamicAuth above), also usable directly. Mirrors |
| +// pingLoop's delete-if-still-live guard: the raw conn is closed OUTSIDE the |
| +// lock (Close can block briefly on the wire), then a second lock re-checks |
| +// that this exact conn is STILL the one registered before deleting it — a |
| +// re-register that has already replaced the entry in between must not have |
| +// ITS fresh conn deleted out from under it. |
| +func (h *TunnelHub) CloseChannel(name string) { |
| + h.mu.Lock() |
| + cur := h.conns[name] |
| + h.mu.Unlock() |
| + if cur == nil { |
| + return |
| + } |
| + cur.raw.Close() |
| + h.mu.Lock() |
| + if c := h.conns[name]; c != nil && c.raw == cur.raw { |
| + delete(h.conns, name) |
| + } |
| + h.mu.Unlock() |
| +} |
| + |
| func (h *TunnelHub) conn(name string) *http2.ClientConn { |
| h.mu.Lock() |
| defer h.mu.Unlock() |
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go| index cc02d65c0..b2d90a116 100644 |
| --- a/internal/edge/tunnel_test.go |
| +++ b/internal/edge/tunnel_test.go |
| @@ -1,6 +1,9 @@ |
| package edge |
| import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net" |
| "net/http" |
| "net/http/httptest" |
| "strings" |
| @@ -9,6 +12,14 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| +// sha256Hex is the test-side mirror of what FleetChannelAuth stores and |
| +// SetDynamicAuth consumes: the sha256 hex of a bearer token, never the |
| +// plaintext. |
| +func sha256Hex(s string) string { |
| + sum := sha256.Sum256([]byte(s)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| // tunnelReq builds a request that is well-formed EXCEPT for the credentials |
| // under test: valid upgrade headers, so the auth cases below can only fail on |
| // auth. Pass an empty token to send no Authorization header at all. |
| @@ -131,6 +142,133 @@ func TestTunnelRegisterRejectsUnknownPath(t *testing.T) { |
| } |
| } |
| +// TestTunnelRegisterAcceptsDynamicToken proves the second credential source: |
| +// a channel name containing "/" (the "<fleet>/<label>" shape ValidTunnelName |
| +// would refuse) authenticates against a token hash set via SetDynamicAuth, |
| +// hashing the presented token and comparing against the stored hash rather |
| +// than the plaintext. |
| +func TestTunnelRegisterAcceptsDynamicToken(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")}) |
| + rec := httptest.NewRecorder() |
| + hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "tok")) |
| + // Same tell as TestTunnelRegisterAcceptsTokenWiseConnectionHeader: past |
| + // auth and the upgrade gate, stopped at the hijack httptest can't do. |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("got %d, want 500 (past auth, stopped at the hijack)", rec.Code) |
| + } |
| +} |
| + |
| +func TestTunnelRegisterRejectsWrongDynamicToken(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")}) |
| + rec := httptest.NewRecorder() |
| + hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "wrong")) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("got %d, want 401", rec.Code) |
| + } |
| +} |
| + |
| +func TestTunnelRegisterRejectsUnknownDynamicChannel(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")}) |
| + rec := httptest.NewRecorder() |
| + hub.ServeHTTP(rec, tunnelReq("acme/pi-2", "tok")) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("got %d, want 401", rec.Code) |
| + } |
| +} |
| + |
| +// TestStaticClientStillAuthenticatesAlongsideDynamicSet is the regression: a |
| +// static env client must keep authenticating unchanged even once a dynamic |
| +// set is also configured on the same hub (tito hq's plaintext-compare path |
| +// is untouched by fleet channels landing). |
| +func TestStaticClientStillAuthenticatesAlongsideDynamicSet(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"}) |
| + hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")}) |
| + rec := httptest.NewRecorder() |
| + hub.ServeHTTP(rec, tunnelReq("hq", "sekrit")) |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("static client with a dynamic set also configured: got %d, want 500", rec.Code) |
| + } |
| +} |
| + |
| +// TestCloseChannelClosesAndRemovesLiveConn: CloseChannel is the detach/rotate |
| +// primitive — force-close a live conn and drop it from the map. No real h2 |
| +// handshake needed: CloseChannel only ever touches .raw. |
| +func TestCloseChannelClosesAndRemovesLiveConn(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + c1, c2 := net.Pipe() |
| + defer c2.Close() |
| + hub.mu.Lock() |
| + hub.conns["acme/pi-1"] = &tunnelConn{raw: c1} |
| + hub.mu.Unlock() |
| + |
| + hub.CloseChannel("acme/pi-1") |
| + |
| + hub.mu.Lock() |
| + _, stillThere := hub.conns["acme/pi-1"] |
| + hub.mu.Unlock() |
| + if stillThere { |
| + t.Fatal("CloseChannel left the channel registered") |
| + } |
| + if _, err := c1.Write([]byte("x")); err == nil { |
| + t.Fatal("CloseChannel did not close the raw conn") |
| + } |
| +} |
| + |
| +// TestCloseChannelOnUnknownNameIsNoop: closing a name with no live conn must |
| +// not panic or otherwise misbehave (Reload calls this for every vanished |
| +// registry entry, whether or not a channel was ever actually connected). |
| +func TestCloseChannelOnUnknownNameIsNoop(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + hub.CloseChannel("nosuch/label") |
| +} |
| + |
| +// TestSetDynamicAuthRotationClosesVanishedChannelAndRefusesOldToken pins the |
| +// brief's exact rotation contract: a token authenticates while its channel is |
| +// in the dynamic set; once SetDynamicAuth is called WITHOUT that entry (the |
| +// shape a rotate/detach registry poll produces), any live conn for it is |
| +// force-closed and the old token is refused on the very next dial — not left |
| +// to die on its own on the next failed ping. |
| +func TestSetDynamicAuthRotationClosesVanishedChannelAndRefusesOldToken(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")}) |
| + |
| + // Valid while present. |
| + rec := httptest.NewRecorder() |
| + hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "tok")) |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("initial dynamic dial: got %d, want 500", rec.Code) |
| + } |
| + |
| + // A live established channel for that name. |
| + c1, c2 := net.Pipe() |
| + defer c2.Close() |
| + hub.mu.Lock() |
| + hub.conns["acme/pi-1"] = &tunnelConn{raw: c1} |
| + hub.mu.Unlock() |
| + |
| + // Rotate: the entry vanishes from the dynamic set entirely. |
| + hub.SetDynamicAuth(map[string]string{}) |
| + |
| + hub.mu.Lock() |
| + _, stillThere := hub.conns["acme/pi-1"] |
| + hub.mu.Unlock() |
| + if stillThere { |
| + t.Fatal("SetDynamicAuth left the vanished channel's live conn registered") |
| + } |
| + if _, err := c1.Write([]byte("x")); err == nil { |
| + t.Fatal("SetDynamicAuth did not close the vanished channel's raw conn") |
| + } |
| + |
| + rec2 := httptest.NewRecorder() |
| + hub.ServeHTTP(rec2, tunnelReq("acme/pi-1", "tok")) |
| + if rec2.Code != http.StatusUnauthorized { |
| + t.Fatalf("old token after rotation: got %d, want 401", rec2.Code) |
| + } |
| +} |
| + |
| func TestParseTunnelClients(t *testing.T) { |
| got, err := parseTunnelClients("pi-box=sekrit,hq=other") |
| if err != nil || got["pi-box"] != "sekrit" || got["hq"] != "other" { |
| @@ -258,11 +396,18 @@ func TestEnableTunnelsFromEnv(t *testing.T) { |
| t.Fatal("hub armed with no CARLOS_TUNNEL_HOST") |
| } |
| }) |
| - t.Run("host with no clients", func(t *testing.T) { |
| + // A host with no STATIC clients is valid: fleet channels (SetDynamicAuth, |
| + // wired by Reload from the registry) can populate the hub with no static |
| + // env config at all — the hub still has to exist for those to land on. |
| + t.Run("host with no static clients is valid", func(t *testing.T) { |
| t.Setenv("CARLOS_TUNNEL_HOST", "tunnel.test") |
| t.Setenv("CARLOS_TUNNEL_CLIENTS", "") |
| - if err := enableTunnelsFromEnv(newEdge()); err == nil { |
| - t.Fatal("want an error: a registration endpoint nobody can authenticate against") |
| + e := newEdge() |
| + if err := enableTunnelsFromEnv(e); err != nil { |
| + t.Fatalf("CARLOS_TUNNEL_HOST with no static clients: %v", err) |
| + } |
| + if e.tunnels == nil { |
| + t.Fatal("hub not armed with no static clients configured") |
| } |
| }) |
| t.Run("invalid channel name", func(t *testing.T) { |
| @@ -286,6 +431,90 @@ func TestEnableTunnelsFromEnv(t *testing.T) { |
| }) |
| } |
| +// TestReloadWiresFleetChannelAuthIntoTunnelHub is Task 3's core integration |
| +// point: Edge.Reload reads Registry.FleetChannelAuth (Task 1) and pushes it |
| +// into the hub, with no new polling — the existing 2s Watch->Reload loop is |
| +// the whole convergence path. |
| +func TestReloadWiresFleetChannelAuthIntoTunnelHub(t *testing.T) { |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: sha256Hex("tok"), Status: "active"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.EnableTunnels(NewTunnelHub("tunnel.test", nil)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := httptest.NewRecorder() |
| + e.tunnels.ServeHTTP(rec, tunnelReq("acme/pi-1", "tok")) |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("fleet channel auth not wired from the registry: got %d, want 500", rec.Code) |
| + } |
| +} |
| + |
| +// TestReloadWithNilTunnelHubIsSafe: most boxes run with no CARLOS_TUNNEL_HOST |
| +// at all, so e.tunnels is nil on every Reload — the fleet-auth wiring must be |
| +// guarded, not assume a hub exists. |
| +func TestReloadWithNilTunnelHubIsSafe(t *testing.T) { |
| + e, err := New(openReg(t), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload with no tunnel hub configured: %v", err) |
| + } |
| +} |
| + |
| +// TestReloadDetachedFleetBoxClosesLiveChannel proves the rotate/detach |
| +// convergence bound end to end through Reload: a box detached in the |
| +// registry has its live conn force-closed on the very next reload, the same |
| +// poll Watch drives every ~2s. |
| +func TestReloadDetachedFleetBoxClosesLiveChannel(t *testing.T) { |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: sha256Hex("tok"), 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 the box had already dialed in successfully. |
| + c1, c2 := net.Pipe() |
| + defer c2.Close() |
| + hub.mu.Lock() |
| + hub.conns["acme/pi-1"] = &tunnelConn{raw: c1} |
| + hub.mu.Unlock() |
| + |
| + 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("Reload did not close the detached fleet box's live channel") |
| + } |
| +} |
| + |
| // 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 |