Preserve channel-derived fleet identity in runtime snapshots
4 files changed,
+62
−31
internal/edge/edge.go+2 −2internal/edge/storedoor.go+3 −3internal/edge/tunnel.go+23 −22internal/edge/tunnel_test.go+34 −4
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.013s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... github.com/benbjohnson/litestream/s3 -count=1 -timeout 30m
ok github.com/carlosframework/platform 160.817s
ok github.com/carlosframework/platform/cmd/console 3.332s
? github.com/carlosframework/platform/cmd/pricebook-export [no test files]
ok github.com/carlosframework/platform/internal/activator 56.123s
ok github.com/carlosframework/platform/internal/agentsock 0.021s
ok github.com/carlosframework/platform/internal/analytics 0.024s
ok github.com/carlosframework/platform/internal/appconfig 0.779s
ok github.com/carlosframework/platform/internal/apperr 0.116s
ok github.com/carlosframework/platform/internal/applog 0.013s
ok github.com/carlosframework/platform/internal/authority 0.006s
ok github.com/carlosframework/platform/internal/awsbilling 0.015s
ok github.com/carlosframework/platform/internal/awsinv 0.009s
ok github.com/carlosframework/platform/internal/billingstripe 0.277s
ok github.com/carlosframework/platform/internal/caps 0.017s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.045s
ok github.com/carlosframework/platform/internal/clamd 0.060s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 226.013s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.029s
ok github.com/carlosframework/platform/internal/domains 1.293s
ok github.com/carlosframework/platform/internal/economics 0.028s
ok github.com/carlosframework/platform/internal/edge 20.923s
ok github.com/carlosframework/platform/internal/edgesfile 0.028s
ok github.com/carlosframework/platform/internal/entitlement 0.028s
ok github.com/carlosframework/platform/internal/fleetapi 0.149s
ok github.com/carlosframework/platform/internal/gate 1.739s
ok github.com/carlosframework/platform/internal/host 7.173s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 0.443s
ok github.com/carlosframework/platform/internal/instanceexec 0.034s
ok github.com/carlosframework/platform/internal/instancefs 0.028s
ok github.com/carlosframework/platform/internal/lease 5.604s
ok github.com/carlosframework/platform/internal/ledger 0.028s
ok github.com/carlosframework/platform/internal/mail 0.082s
ok github.com/carlosframework/platform/internal/maintenance 0.117s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.572s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.106s
ok github.com/carlosframework/platform/internal/metrics 0.026s
ok github.com/carlosframework/platform/internal/objstore 8.767s
ok github.com/carlosframework/platform/internal/ops 15.818s
ok github.com/carlosframework/platform/internal/ops/admissionmodel 0.191s
ok github.com/carlosframework/platform/internal/plans 0.073s
ok github.com/carlosframework/platform/internal/privatefile 0.026s
ok github.com/carlosframework/platform/internal/provision 0.946s
ok github.com/carlosframework/platform/internal/publicrelease 0.025s
ok github.com/carlosframework/platform/internal/registry 1.655s
ok github.com/carlosframework/platform/internal/relay 0.251s
ok github.com/carlosframework/platform/internal/release 12.180s
ok github.com/carlosframework/platform/internal/resourcebudget 0.401s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/schedule 1.142s
ok github.com/carlosframework/platform/internal/sigfence 0.368s
ok github.com/carlosframework/platform/internal/status 0.050s
ok github.com/carlosframework/platform/internal/steering 0.025s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/usage 3.954s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.801s
ok github.com/benbjohnson/litestream/s3 3.936s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
minio contract: PASS
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 5df6e6f4d..dff59be47 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -3032,7 +3032,7 @@ func enableStaticFromEnv(e *Edge, dataDir string) error { |
| // |
| // 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 |
| +// credentials come from the registry (Reload -> SetDynamicRuntime), 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 |
| @@ -3062,7 +3062,7 @@ 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, |
| - // and so fleet channel auth (SetDynamicAuth) gets its first load. |
| + // and so fleet channel auth (SetDynamicRuntime) gets its first load. |
| if err := e.Reload(); err != nil { |
| return fmt.Errorf("reload after EnableTunnels: %w", err) |
| } |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index 332f1685c..d657ac256 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -5,9 +5,9 @@ |
| // |
| // One mechanism, not N endpoints: the five Store operations, authenticated |
| // per request by the credential the box already holds (its fleet tunnel |
| -// token, verified through TunnelHub.VerifyDynamic — the same hash table |
| -// detach/rotate already converge in seconds), and ACL'd server-side from the |
| -// fleet's registry row. The box's own loops — adoption, fast-converge, |
| +// token, verified through TunnelHub.AuthorizeDynamic against the same runtime |
| +// that detach/rotate already converge in seconds), and ACL'd server-side from |
| +// the fleet's registry row. The box's own loops — adoption, fast-converge, |
| // config, status, metrics, bindings — run unchanged against the client half |
| // (Task 3). |
| // |
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go| index 5043326d8..2cfad620f 100644 |
| --- a/internal/edge/tunnel.go |
| +++ b/internal/edge/tunnel.go |
| @@ -142,8 +142,8 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // 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 |
| + // runtime installed by SetDynamicRuntime. 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) |
| @@ -255,15 +255,12 @@ func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dy |
| } |
| } |
| -// VerifyDynamic reports whether (name, token) authenticates as a dynamic |
| -// (registry-backed fleet) channel, and which fleet owns it. It is the single |
| -// answer to that question: ServeHTTP's dynamic branch is one caller, and |
| -// surfaces with no ResponseWriter to 401 with are the others. |
| -// |
| +// verifyDynamic performs the shared dynamic credential lookup and returns the |
| +// exact installed entry plus the hash needed for the post-hijack commit gate. |
| // The "/" requirement is a gate, not a parse convenience: static channel names |
| // are ValidTunnelName-checked and never contain "/", so refusing slash-less |
| // names here means a static client's plaintext token can never be admitted by |
| -// this path, whatever h.clients (or a hand-built dynamicAuth) holds. An empty |
| +// this path, whatever h.clients (or a hand-built dynamic runtime) holds. An empty |
| // fleet segment ("/box-a") is refused for the same reason: it would otherwise |
| // authenticate into fleet "", a name no real fleet has, and every ownership |
| // check downstream compares against the fleet this returns. |
| @@ -282,15 +279,20 @@ func (h *TunnelHub) verifyDynamic(name, token string) (fleet string, entry regis |
| h.mu.Lock() |
| entry, known := h.dynamicRuntime[name] |
| h.mu.Unlock() |
| - if !known || entry.TokenHash == "" { |
| + if !known || entry.Fleet != fleet || entry.TokenHash == "" { |
| return "", registry.FleetRuntimeEntry{}, "", false |
| } |
| if subtle.ConstantTimeCompare([]byte(presentedHash), []byte(entry.TokenHash)) != 1 { |
| return "", registry.FleetRuntimeEntry{}, "", false |
| } |
| - return entry.Fleet, entry, presentedHash, true |
| + return fleet, entry, presentedHash, true |
| } |
| +// VerifyDynamic reports whether (name, token) authenticates as a dynamic |
| +// (registry-backed fleet) channel, and returns the fleet parsed from that |
| +// channel name. It is the single answer to that question: ServeHTTP's dynamic |
| +// branch is one caller, and surfaces with no ResponseWriter to 401 with are |
| +// the others. |
| func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool) { |
| fleet, _, _, ok = h.verifyDynamic(name, token) |
| return fleet, ok |
| @@ -320,8 +322,7 @@ func (h *TunnelHub) SetDynamicAuth(auth map[string]string) { |
| } |
| // SetDynamicRuntime replaces the entire registry-backed credential/account |
| -// set. The whole-map replace makes every later dial use one current entry. |
| -// The whole-map REPLACE (not an incremental merge) is what makes a NEW dial |
| +// set. The whole-map REPLACE (not an incremental merge) makes every later 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 |
| @@ -331,11 +332,11 @@ func (h *TunnelHub) SetDynamicAuth(auth map[string]string) { |
| // 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. |
| +// - Detach: the name vanishes from auth entirely, so while holding h.mu the |
| +// loop below captures and deletes that exact live dynamic conn. After the |
| +// lock is released, only the captured raw conn is closed. CloseChannel is |
| +// not the install path, so a successor registered after the swap cannot |
| +// be closed by stale name lookup. Detach is the hard revocation path. |
| // - 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 |
| @@ -347,7 +348,7 @@ func (h *TunnelHub) SetDynamicAuth(auth map[string]string) { |
| // 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. |
| +// (CloseChannel) rather than relying on SetDynamicRuntime to do it. |
| func (h *TunnelHub) SetDynamicRuntime(entries []registry.FleetRuntimeEntry) { |
| next := make(map[string]registry.FleetRuntimeEntry, len(entries)) |
| for _, entry := range entries { |
| @@ -393,10 +394,10 @@ func (h *TunnelHub) commitDynamic(name, presentedHash string, conn *tunnelConn) |
| return true |
| } |
| -// 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 |
| +// CloseChannel force-closes and unregisters name's live conn, if any. Runtime |
| +// installation does not call it; this is the explicit by-name close primitive. |
| +// It mirrors pingLoop's delete-if-still-live guard: the raw conn closes |
| +// 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. |
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go| index 923e6f435..18c698d23 100644 |
| --- a/internal/edge/tunnel_test.go |
| +++ b/internal/edge/tunnel_test.go |
| @@ -302,7 +302,7 @@ func TestAuthorizeDynamicReturnsOneRuntimeTuple(t *testing.T) { |
| Channel: "pi/box-a", Fleet: "pi", TokenHash: sha256Hex("tok-a"), Account: "acct-a", |
| }} |
| hub.SetDynamicRuntime(entries) |
| - entries[0] = registry.FleetRuntimeEntry{Channel: "pi/box-a", Fleet: "wrong", TokenHash: sha256Hex("wrong"), Account: "wrong"} |
| + entries[0] = registry.FleetRuntimeEntry{Channel: "other/box-a", Fleet: "other", TokenHash: sha256Hex("wrong"), Account: "wrong"} |
| if fleet, account, ok := hub.AuthorizeDynamic("pi/box-a", "tok-a"); !ok || fleet != "pi" || account != "acct-a" { |
| t.Fatalf("AuthorizeDynamic = (%q, %q, %v), want (pi, acct-a, true)", fleet, account, ok) |
| } |
| @@ -315,6 +315,34 @@ func TestAuthorizeDynamicReturnsOneRuntimeTuple(t *testing.T) { |
| } |
| } |
| +func TestDynamicRuntimeRefusesFleetIdentityMismatch(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + fleet string |
| + }{ |
| + {name: "mismatch", fleet: "other"}, |
| + {name: "empty", fleet: ""}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + hub.SetDynamicRuntime([]registry.FleetRuntimeEntry{{ |
| + Channel: "pi/box-a", Fleet: tc.fleet, TokenHash: sha256Hex("tok"), Account: "acct", |
| + }}) |
| + if fleet, ok := hub.VerifyDynamic("pi/box-a", "tok"); ok || fleet != "" { |
| + t.Errorf("VerifyDynamic accepted entry fleet %q for channel pi/box-a: (%q, %v)", tc.fleet, fleet, ok) |
| + } |
| + if fleet, account, ok := hub.AuthorizeDynamic("pi/box-a", "tok"); ok || fleet != "" || account != "" { |
| + t.Errorf("AuthorizeDynamic accepted entry fleet %q for channel pi/box-a: (%q, %q, %v)", tc.fleet, fleet, account, ok) |
| + } |
| + rec := httptest.NewRecorder() |
| + hub.ServeHTTP(rec, tunnelReq("pi/box-a", "tok")) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Errorf("dynamic dial with entry fleet %q: status=%d, want 401", tc.fleet, rec.Code) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestDynamicRuntimeSwapNeverMixesTokenAndAccount(t *testing.T) { |
| hub := NewTunnelHub("tunnel.test", nil) |
| a := []registry.FleetRuntimeEntry{{Channel: "pi/box-a", Fleet: "pi", TokenHash: sha256Hex("tok-a"), Account: "acct-a"}} |
| @@ -545,9 +573,11 @@ func TestEdgeFleetRuntimeWiringUsesOnlyCombinedSnapshot(t *testing.T) { |
| } |
| return true |
| }) |
| - if counts["FleetRuntimeSnapshot"] != 1 || counts["SetDynamicRuntime"] != 1 || counts["SetDynamicAuth"] != 0 { |
| - t.Fatalf("edge.go fleet runtime calls = snapshot:%d install:%d old-install:%d, want 1,1,0", |
| - counts["FleetRuntimeSnapshot"], counts["SetDynamicRuntime"], counts["SetDynamicAuth"]) |
| + if counts["FleetRuntimeSnapshot"] != 1 || counts["SetDynamicRuntime"] != 1 || counts["SetDynamicAuth"] != 0 || |
| + counts["FleetChannelAuth"] != 0 || counts["ListFleets"] != 0 { |
| + t.Fatalf("edge.go fleet runtime selectors = snapshot:%d install:%d old-install:%d split-auth:%d split-fleets:%d, want 1,1,0,0,0", |
| + counts["FleetRuntimeSnapshot"], counts["SetDynamicRuntime"], counts["SetDynamicAuth"], |
| + counts["FleetChannelAuth"], counts["ListFleets"]) |
| } |
| } |