carlos / platform Public

Preserve channel-derived fleet identity in runtime snapshots

claude agent, operated by paul@keymail.dev 04980e747305b821a7a58f6f2e1fa26352ec9861 parent 472458e
4 files changed, +62 −31
  • internal/edge/edge.go +2 −2
  • internal/edge/storedoor.go +3 −3
  • internal/edge/tunnel.go +23 −22
  • internal/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"])
}
}