Skip to content
Closed
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
6 changes: 6 additions & 0 deletions pkg/platform/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 24 additions & 2 deletions pkg/platform/api/buildlogstream/streamer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,35 @@ 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.<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
// 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)
// 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}
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
85 changes: 85 additions & 0 deletions pkg/platform/api/buildlogstream/streamer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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"
)

// 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()
got := &upgradeRequest{}

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")
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 got
}

func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) {
got := startMockBLS(t)

conn, err := Connect(context.Background(), "header.payload.signature")
require.NoError(t, err)
_ = conn.Close()

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)
_ = conn.Close()

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")
}
9 changes: 7 additions & 2 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 {
// authToken is the platform JWT forwarded to the build-log-streamer WS (empty if unauthenticated).
Comment on lines 67 to +69
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 @@ -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")
}
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 (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 }
}
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