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..619a13508c 100644 --- a/pkg/platform/api/buildlogstream/streamer.go +++ b/pkg/platform/api/buildlogstream/streamer.go @@ -6,18 +6,41 @@ import ( "github.com/gorilla/websocket" "golang.org/x/net/context" + "github.com/ActiveState/cli/internal/constants" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/logging" "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 the +// State Tool and dashboard clients symmetric. +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 see which + // State Tool versions are connecting (e.g. to size the unauthenticated + // tail before tightening the gate). + header.Set("User-Agent", constants.UserAgent) + + 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..66e72545fa --- /dev/null +++ b/pkg/platform/api/buildlogstream/streamer_test.go @@ -0,0 +1,114 @@ +package buildlogstream + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ActiveState/cli/internal/constants" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// upgradeRequest captures the headers the build-log-streamer server saw on the +// WS Upgrade. The mock handler writes the fields from the server goroutine and +// closes recorded; callers must await() before reading the fields so there's a +// happens-before edge (the read would otherwise race the handler's write). +type upgradeRequest struct { + protocols []string + userAgent string + recorded chan struct{} +} + +// await blocks until the mock handler has recorded the Upgrade headers (or +// fails the test if that never happens). Establishes the happens-before edge +// for safely reading protocols/userAgent. +func (u *upgradeRequest) await(t *testing.T) { + t.Helper() + select { + case <-u.recorded: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for mock build-log-streamer to record the Upgrade headers") + } +} + +// 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() + got := &upgradeRequest{recorded: make(chan struct{})} + + 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) { + got.protocols = r.Header.Values("Sec-WebSocket-Protocol") + got.userAgent = r.Header.Get("User-Agent") + close(got.recorded) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("mock build-log-streamer failed to upgrade the WS connection: %v", err) + 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 got +} + +func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) { + got := startMockBLS(t) + + conn, err := Connect(context.Background(), "header.payload.signature") + require.NoError(t, err) + defer conn.Close() + + // The server must negotiate the real subprotocol, never the bearer. + // entry (it's not in the server's allow-list) — so the token can't leak + // into the upgrade response. + assert.Equal(t, wsSubprotocol, conn.Subprotocol(), + "server must negotiate the real subprotocol, not bearer.") + + got.await(t) + 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) { + got := startMockBLS(t) + + conn, err := Connect(context.Background(), "") + require.NoError(t, err) + defer conn.Close() + + assert.Equal(t, wsSubprotocol, conn.Subprotocol(), + "server must negotiate the real subprotocol") + + got.await(t) + 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") +} diff --git a/pkg/runtime/internal/buildlog/buildlog.go b/pkg/runtime/internal/buildlog/buildlog.go index 2a4cb9490b..94a7c29501 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 { +// artifactMap comprises all artifacts (from the runtime closure) that are in the recipe. +// 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, } } @@ -92,9 +97,10 @@ func (b *BuildLog) OnArtifactReady(id strfmt.UUID, cb func()) { b.onArtifactReadyFuncs[id] = append(b.onArtifactReadyFuncs[id], cb) } -// NewWithCustomConnections creates a new BuildLog instance with all physical connections managed by the caller +// Wait connects to the build-log streamer and blocks until the build completes, +// dispatching build events to the registered handlers as they arrive. 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..95c2e0bf53 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. 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))