Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/runbits/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
27 changes: 25 additions & 2 deletions pkg/platform/api/buildlogstream/streamer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<jwt> 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.<jwt>` (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")
}
Expand Down
114 changes: 114 additions & 0 deletions pkg/platform/api/buildlogstream/streamer_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Comment thread
antoine-activestate marked this conversation as resolved.

// 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.<jwt> 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
}
Comment thread
antoine-activestate marked this conversation as resolved.
_ = conn.Close()
}))
Comment thread
antoine-activestate marked this conversation as resolved.
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()

Comment thread
antoine-activestate marked this conversation as resolved.
// The server must negotiate the real subprotocol, never the bearer.<jwt>
// 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.<jwt>")

got.await(t)
joined := strings.Join(got.protocols, ",")
assert.Contains(t, joined, "bearer.header.payload.signature",
"client must offer the JWT as a bearer.<jwt> 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()
Comment thread
antoine-activestate marked this conversation as resolved.

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")
}
14 changes: 10 additions & 4 deletions pkg/runtime/internal/buildlog/buildlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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")
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/runtime/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
6 changes: 5 additions & 1 deletion pkg/runtime/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
Loading