From 596bd28df82510b1e840c6ba74b1abdc5868c0c4 Mon Sep 17 00:00:00 2001 From: Antoine Date: Mon, 1 Jun 2026 14:37:52 -0400 Subject: [PATCH 1/2] ENG-1372: forward platform JWT to build-log-streamer via Sec-WebSocket-Protocol The build-log-streamer WS opened with only an Origin header, so the server could not authorize the stream and had to admit anonymous Upgrades. Offer the platform JWT as a 'bearer.' subprotocol alongside the real 'build-log-streamer.activestate.com.v1' subprotocol the server echoes back. This mirrors the dashboard (ENG-1374), which can't set custom WS request headers, so both clients stay symmetric. The token is threaded as a plain string (prime.Auth().BearerToken() -> runtime.WithAuthToken -> Opts.AuthToken -> buildlog -> Connect), keeping pkg/runtime free of an authentication dependency. Empty token = anonymous, unchanged behavior. Adds a real-handshake test proving the offered Sec-WebSocket-Protocol carries bearer. when authenticated and omits it when anonymous. --- internal/runbits/runtime/runtime.go | 1 + pkg/platform/api/buildlogstream/streamer.go | 22 +++++- .../api/buildlogstream/streamer_test.go | 74 +++++++++++++++++++ pkg/runtime/internal/buildlog/buildlog.go | 9 ++- pkg/runtime/options.go | 6 ++ pkg/runtime/setup.go | 6 +- 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 pkg/platform/api/buildlogstream/streamer_test.go diff --git a/internal/runbits/runtime/runtime.go b/internal/runbits/runtime/runtime.go index 55d4b8ea50..1b4aa69754 100644 --- a/internal/runbits/runtime/runtime.go +++ b/internal/runbits/runtime/runtime.go @@ -257,6 +257,7 @@ func Update( runtime.WithAnnotations(proj.Owner(), proj.Name(), commitID), runtime.WithEventHandlers(pg.Handle, ah.handle), runtime.WithPreferredLibcVersion(prime.Config().GetString(constants.PreferredGlibcVersionConfig)), + runtime.WithAuthToken(prime.Auth().BearerToken()), } if opts.Archive != nil { rtOpts = append(rtOpts, runtime.WithArchive(opts.Archive.Dir, opts.Archive.PlatformID, checkout.ArtifactExt)) diff --git a/pkg/platform/api/buildlogstream/streamer.go b/pkg/platform/api/buildlogstream/streamer.go index e3794b7506..99b530f475 100644 --- a/pkg/platform/api/buildlogstream/streamer.go +++ b/pkg/platform/api/buildlogstream/streamer.go @@ -11,13 +11,31 @@ import ( "github.com/ActiveState/cli/pkg/platform/api" ) -func Connect(ctx context.Context) (*websocket.Conn, error) { +// wsSubprotocol is the "real" subprotocol the build-log-streamer echoes back. +// The server's upgrader allow-list contains only this value, so the +// bearer. entry we also offer never appears in the upgrade response, +// keeping the token out of proxy/browser response logs. +const wsSubprotocol = "build-log-streamer.activestate.com.v1" + +// Connect opens the build-log-streamer WebSocket. When jwt is non-empty it is +// offered via Sec-WebSocket-Protocol as `bearer.` (alongside +// wsSubprotocol, which the server echoes back) so the server can authorize the +// stream. The browser WebSocket API can't set custom request headers, so the +// dashboard carries the JWT the same way; using the subprotocol here keeps +// both clients symmetric. See ENG-1372 / ENG-1374. +func Connect(ctx context.Context, jwt string) (*websocket.Conn, error) { url := api.GetServiceURL(api.BuildLogStreamer) header := make(http.Header) header.Add("Origin", "https://"+url.Host) + dialer := *websocket.DefaultDialer // copy so we don't mutate the package global + dialer.Subprotocols = []string{wsSubprotocol} + if jwt != "" { + dialer.Subprotocols = []string{"bearer." + jwt, wsSubprotocol} + } + logging.Debug("Creating websocket for %s (origin: %s)", url.String(), header.Get("Origin")) - conn, _, err := websocket.DefaultDialer.DialContext(ctx, url.String(), header) + conn, _, err := dialer.DialContext(ctx, url.String(), header) if err != nil { return nil, errs.Wrap(err, "Could not create websocket dialer") } diff --git a/pkg/platform/api/buildlogstream/streamer_test.go b/pkg/platform/api/buildlogstream/streamer_test.go new file mode 100644 index 0000000000..3b461f198f --- /dev/null +++ b/pkg/platform/api/buildlogstream/streamer_test.go @@ -0,0 +1,74 @@ +package buildlogstream + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ActiveState/cli/internal/constants" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// startMockBLS stands up a real WebSocket server that records the +// Sec-WebSocket-Protocol values offered on the Upgrade, and redirects +// Connect's resolved service URL at it via the per-service override env var +// honored by api.GetServiceURL. Returns a pointer that holds the offered +// subprotocols after Connect runs. +func startMockBLS(t *testing.T) *[]string { + t.Helper() + offered := &[]string{} + + upgrader := websocket.Upgrader{ + CheckOrigin: func(*http.Request) bool { return true }, + // Echo back only the "real" subprotocol; the bearer. entry must + // not be selected (mirrors the build-log-streamer's allow-list). + Subprotocols: []string{wsSubprotocol}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *offered = r.Header.Values("Sec-WebSocket-Protocol") + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + _ = conn.Close() + })) + t.Cleanup(srv.Close) + + // http://127.0.0.1:port -> ws://127.0.0.1:port + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) + + return offered +} + +func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) { + offered := startMockBLS(t) + + conn, err := Connect(context.Background(), "header.payload.signature") + require.NoError(t, err) + _ = conn.Close() + + joined := strings.Join(*offered, ",") + assert.Contains(t, joined, "bearer.header.payload.signature", + "client must offer the JWT as a bearer. subprotocol") + assert.Contains(t, joined, wsSubprotocol, + "client must still offer the real subprotocol the server echoes back") +} + +func TestConnect_AnonymousOffersNoBearer(t *testing.T) { + offered := startMockBLS(t) + + conn, err := Connect(context.Background(), "") + require.NoError(t, err) + _ = conn.Close() + + joined := strings.Join(*offered, ",") + assert.NotContains(t, joined, "bearer.", + "anonymous Connect must not offer a bearer subprotocol") + assert.Contains(t, joined, wsSubprotocol, + "anonymous Connect must still offer the real subprotocol") +} diff --git a/pkg/runtime/internal/buildlog/buildlog.go b/pkg/runtime/internal/buildlog/buildlog.go index 2a4cb9490b..8e15b5b50d 100644 --- a/pkg/runtime/internal/buildlog/buildlog.go +++ b/pkg/runtime/internal/buildlog/buildlog.go @@ -59,16 +59,21 @@ type BuildLog struct { eventHandlers []events.HandlerFunc logFilePath string onArtifactReadyFuncs map[strfmt.UUID][]func() + // authToken is the platform JWT forwarded to the build-log-streamer WS so + // the server can authorize the stream. Empty for unauthenticated callers. + authToken string } // New creates a new BuildLog instance that allows us to wait for incoming build log information // artifactMap comprises all artifacts (from the runtime closure) that are in the recipe, alreadyBuilt is set of artifact IDs that have already been built in the past -func New(recipeID strfmt.UUID, artifactMap buildplan.ArtifactIDMap) *BuildLog { +// authToken is the platform JWT forwarded to the build-log-streamer WS (empty if unauthenticated). +func New(recipeID strfmt.UUID, artifactMap buildplan.ArtifactIDMap, authToken string) *BuildLog { return &BuildLog{ recipeID: recipeID, artifactMap: artifactMap, eventHandlers: []events.HandlerFunc{}, onArtifactReadyFuncs: map[strfmt.UUID][]func(){}, + authToken: authToken, } } @@ -94,7 +99,7 @@ func (b *BuildLog) OnArtifactReady(id strfmt.UUID, cb func()) { // NewWithCustomConnections creates a new BuildLog instance with all physical connections managed by the caller func (b *BuildLog) Wait(ctx context.Context) error { - conn, err := buildlogstream.Connect(ctx) + conn, err := buildlogstream.Connect(ctx, b.authToken) if err != nil { return errs.Wrap(err, "Could not connect to build-log streamer build updates") } diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 7c012fb491..63cf23bde8 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -9,6 +9,12 @@ func WithEventHandlers(handlers ...events.HandlerFunc) SetOpt { return func(opts *Opts) { opts.EventHandlers = handlers } } +// WithAuthToken forwards the platform JWT to the build-log-streamer WebSocket +// so the server can authorize the stream (ENG-1372). Empty token = anonymous. +func WithAuthToken(token string) SetOpt { + return func(opts *Opts) { opts.AuthToken = token } +} + func WithBuildlogFilePath(path string) SetOpt { return func(opts *Opts) { opts.BuildlogFilePath = path } } diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index f789991acc..6c1532a7d0 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -50,6 +50,10 @@ type Opts struct { Portable bool CacheSize int + // AuthToken is the platform JWT forwarded to the build-log-streamer WS so + // the server can authorize the stream. Empty for unauthenticated callers. + AuthToken string + FromArchive *fromArchive // Annotations are used strictly to pass information for the purposes of analytics @@ -238,7 +242,7 @@ func (s *setup) update() error { return errs.Wrap(err, "Could not create runtime config dir") } - blog := buildlog.New(s.buildplan.LegacyRecipeID(), s.toBuild). + blog := buildlog.New(s.buildplan.LegacyRecipeID(), s.toBuild, s.opts.AuthToken). WithEventHandler(s.opts.EventHandlers...). WithLogFile(filepath.Join(s.path, configDir, buildLogFile)) From 53bdee2b89b8911e433da138ed2465b0e7a09744 Mon Sep 17 00:00:00 2001 From: Antoine Date: Mon, 1 Jun 2026 14:41:39 -0400 Subject: [PATCH 2/2] ENG-1372: send versioned State Tool User-Agent on build-log WS Upgrade Set the standard versioned User-Agent (state/...; (os; ver; arch)) on the build-log-streamer WS Upgrade so the server can monitor which State Tool versions are connecting. Extract api.UserAgent() as a package-level function (the *RoundTripper method now delegates) so the WS dialer reuses the exact same agent string as the HTTP API clients. Test asserts the server sees the state/ User-Agent on the Upgrade. --- pkg/platform/api/api.go | 6 +++ pkg/platform/api/buildlogstream/streamer.go | 4 ++ .../api/buildlogstream/streamer_test.go | 37 ++++++++++++------- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/pkg/platform/api/api.go b/pkg/platform/api/api.go index 1ef8fca422..724cceb4cb 100644 --- a/pkg/platform/api/api.go +++ b/pkg/platform/api/api.go @@ -62,6 +62,12 @@ func (r *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { // UserAgent returns the user agent used by the State Tool func (r *RoundTripper) UserAgent() string { + return UserAgent() +} + +// UserAgent returns the user agent used by the State Tool, including the +// version (so the server can monitor State Tool versions) plus OS/arch. +func UserAgent() string { var osVersionStr string osVersion, err := sysinfo.OSVersion() if err != nil { diff --git a/pkg/platform/api/buildlogstream/streamer.go b/pkg/platform/api/buildlogstream/streamer.go index 99b530f475..0c6c17fed0 100644 --- a/pkg/platform/api/buildlogstream/streamer.go +++ b/pkg/platform/api/buildlogstream/streamer.go @@ -27,6 +27,10 @@ func Connect(ctx context.Context, jwt string) (*websocket.Conn, error) { url := api.GetServiceURL(api.BuildLogStreamer) header := make(http.Header) header.Add("Origin", "https://"+url.Host) + // Send the versioned State Tool User-Agent so the server can monitor which + // State Tool versions are connecting (e.g. to size the unauthenticated tail + // before tightening the gate). See ENG-1372. + header.Set("User-Agent", api.UserAgent()) dialer := *websocket.DefaultDialer // copy so we don't mutate the package global dialer.Subprotocols = []string{wsSubprotocol} diff --git a/pkg/platform/api/buildlogstream/streamer_test.go b/pkg/platform/api/buildlogstream/streamer_test.go index 3b461f198f..5fff77bef4 100644 --- a/pkg/platform/api/buildlogstream/streamer_test.go +++ b/pkg/platform/api/buildlogstream/streamer_test.go @@ -13,14 +13,20 @@ import ( "github.com/stretchr/testify/require" ) -// startMockBLS stands up a real WebSocket server that records the -// Sec-WebSocket-Protocol values offered on the Upgrade, and redirects -// Connect's resolved service URL at it via the per-service override env var -// honored by api.GetServiceURL. Returns a pointer that holds the offered -// subprotocols after Connect runs. -func startMockBLS(t *testing.T) *[]string { +// upgradeRequest captures the headers the build-log-streamer server saw on the +// WS Upgrade. +type upgradeRequest struct { + protocols []string + userAgent string +} + +// startMockBLS stands up a real WebSocket server that records the Upgrade +// request headers, and redirects Connect's resolved service URL at it via the +// per-service override env var honored by api.GetServiceURL. Returns a pointer +// populated after Connect runs. +func startMockBLS(t *testing.T) *upgradeRequest { t.Helper() - offered := &[]string{} + got := &upgradeRequest{} upgrader := websocket.Upgrader{ CheckOrigin: func(*http.Request) bool { return true }, @@ -29,7 +35,8 @@ func startMockBLS(t *testing.T) *[]string { Subprotocols: []string{wsSubprotocol}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - *offered = r.Header.Values("Sec-WebSocket-Protocol") + got.protocols = r.Header.Values("Sec-WebSocket-Protocol") + got.userAgent = r.Header.Get("User-Agent") conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return @@ -42,33 +49,37 @@ func startMockBLS(t *testing.T) *[]string { wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) - return offered + return got } func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) { - offered := startMockBLS(t) + got := startMockBLS(t) conn, err := Connect(context.Background(), "header.payload.signature") require.NoError(t, err) _ = conn.Close() - joined := strings.Join(*offered, ",") + joined := strings.Join(got.protocols, ",") assert.Contains(t, joined, "bearer.header.payload.signature", "client must offer the JWT as a bearer. subprotocol") assert.Contains(t, joined, wsSubprotocol, "client must still offer the real subprotocol the server echoes back") + assert.Contains(t, got.userAgent, "state/", + "client must send the versioned State Tool User-Agent so the server can monitor versions") } func TestConnect_AnonymousOffersNoBearer(t *testing.T) { - offered := startMockBLS(t) + got := startMockBLS(t) conn, err := Connect(context.Background(), "") require.NoError(t, err) _ = conn.Close() - joined := strings.Join(*offered, ",") + joined := strings.Join(got.protocols, ",") assert.NotContains(t, joined, "bearer.", "anonymous Connect must not offer a bearer subprotocol") assert.Contains(t, joined, wsSubprotocol, "anonymous Connect must still offer the real subprotocol") + assert.Contains(t, got.userAgent, "state/", + "client must send the versioned State Tool User-Agent even when anonymous") }