Skip to content
9 changes: 9 additions & 0 deletions AUTH_PROXY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Authenticating requests behind a proxy

If your instance is behind an authenticating proxy that requires additional headers, they can be supplied via environment variables as follows:

```sh
SRC_HEADER_AUTHORIZATION="Bearer $(curl http://service.internal.corp)" SRC_HEADER_EXTRA=metadata src search 'foobar'
```

In this example, the headers `authorization: Bearer my-generated-token` and `extra: metadata` will be threaded to all HTTP requests to your instance. Multiple such headers can be supplied.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to `src-cli` are documented in this file.

### Added

- `SRC_HEADER_AUTHORIZATION="Bearer $(...)"` is now supported for authenticating `src` with custom auth proxies. See [auth proxy configuration docs](AUTH_PROXY.md) for more information.
- Pull missing docker images automatically. [#191](https://github.com/sourcegraph/src-cli/pull/191)
- Searches that result in errors will now display any alerts returned by Sourcegraph, including suggestions for how the search could be corrected. [#221](https://github.com/sourcegraph/src-cli/pull/221)

Expand All @@ -24,4 +25,3 @@ All notable changes to `src-cli` are documented in this file.
### Fixed

### Removed

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Point `src` to your instance and access token using environment variables:
SRC_ENDPOINT=https://sourcegraph.example.com SRC_ACCESS_TOKEN="secret" src search 'foobar'
```

Sourcegraph behind a custom auth proxy? See [auth proxy configuration](./AUTH_PROXY.md) docs.

### Where to get an access token

Visit your Sourcegraph instance (or https://sourcegraph.com), click your username in the top right to open the user menu, select **Settings**, and then select **Access tokens** in the left hand menu.
Expand Down
13 changes: 7 additions & 6 deletions cmd/src/actions_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,13 @@ Format of the action JSON files:
}

opts := campaigns.ExecutorOpts{
Endpoint: cfg.Endpoint,
AccessToken: cfg.AccessToken,
Timeout: *timeoutFlag,
KeepLogs: *keepLogsFlag,
ClearCache: *clearCacheFlag,
Cache: campaigns.ExecutionDiskCache{Dir: *cacheDirFlag},
Endpoint: cfg.Endpoint,
AccessToken: cfg.AccessToken,
AdditionalHeaders: cfg.AdditionalHeaders,
Timeout: *timeoutFlag,
KeepLogs: *keepLogsFlag,
ClearCache: *clearCacheFlag,
Cache: campaigns.ExecutionDiskCache{Dir: *cacheDirFlag},
}

// Query repos over which to run action
Expand Down
10 changes: 8 additions & 2 deletions cmd/src/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func gqlURL(endpoint string) string {
}

// curlCmd returns the curl command to perform the given GraphQL query. Bash-only.
func curlCmd(endpoint, accessToken, query string, vars map[string]interface{}) (string, error) {
func curlCmd(endpoint, accessToken string, additionalHeaders map[string]string, query string, vars map[string]interface{}) (string, error) {
data, err := json.Marshal(map[string]interface{}{
"query": query,
"variables": vars,
Expand All @@ -140,6 +140,9 @@ func curlCmd(endpoint, accessToken, query string, vars map[string]interface{}) (
if accessToken != "" {
s += fmt.Sprintf(" %s \\\n", shellquote.Join("-H", "Authorization: token "+accessToken))
}
for k, v := range additionalHeaders {
s += fmt.Sprintf(" %s \\\n", shellquote.Join("-H", k+": "+v))
}
s += fmt.Sprintf(" %s \\\n", shellquote.Join("-d", string(data)))
s += fmt.Sprintf(" %s", shellquote.Join(gqlURL(endpoint)))
return s, nil
Expand Down Expand Up @@ -177,7 +180,7 @@ func (a *apiRequest) do() error {
if a.done != nil {
// Handle the get-curl flag now.
if *a.flags.getCurl {
curl, err := curlCmd(cfg.Endpoint, cfg.AccessToken, a.query, a.vars)
curl, err := curlCmd(cfg.Endpoint, cfg.AccessToken, cfg.AdditionalHeaders, a.query, a.vars)
if err != nil {
return err
}
Expand Down Expand Up @@ -205,6 +208,9 @@ func (a *apiRequest) do() error {
if cfg.AccessToken != "" {
req.Header.Set("Authorization", "token "+cfg.AccessToken)
}
for k, v := range cfg.AdditionalHeaders {
req.Header.Set(k, v)
}
req.Body = ioutil.NopCloser(&buf)

// Perform the request.
Expand Down
40 changes: 40 additions & 0 deletions cmd/src/headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"os"
"strings"
)

// parseAdditionalHeaders reads the environment for values like SRC_HEADER_NAME=VALUE
// and creates a `{NAME: VALUE}` map. These headers should be applied to each request
// to the Sourcegraph instance, as some private instances require special auth or
// additional proxy values to be passed along with each request.
func parseAdditionalHeaders() map[string]string {
return parseAdditionalHeadersFromMap(os.Environ())
}

const additionalHeaderPrefix = "SRC_HEADER_"

func parseAdditionalHeadersFromMap(environ []string) map[string]string {
additionalHeaders := map[string]string{}
for _, value := range environ {
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
continue
}

// Ensure we'll have a non-empty key after trimming
if !strings.HasPrefix(parts[0], additionalHeaderPrefix) || len(parts[0]) <= len(additionalHeaderPrefix) {
continue
}

// Ensure we have a non-empty value
if len(parts[1]) == 0 {
continue
}

additionalHeaders[strings.ToLower(strings.TrimPrefix(parts[0], additionalHeaderPrefix))] = parts[1]
}

return additionalHeaders
}
29 changes: 29 additions & 0 deletions cmd/src/headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"strings"
"testing"

"github.com/google/go-cmp/cmp"
)

func TestParseAdditionalHeaders(t *testing.T) {
testCases := []struct {
environ []string
headers map[string]string
}{
{environ: []string{}, headers: map[string]string{}},
{environ: []string{"AUTHORIZATION=foo,bar,baz"}, headers: map[string]string{}},
{environ: []string{"SRC_HEADER_AUTHORIZATION=foo,bar,baz"}, headers: map[string]string{"authorization": "foo,bar,baz"}},
{environ: []string{"SRC_HEADER_A=foo", "SRC_HEADER_B=bar", "SRC_HEADER_C=baz"}, headers: map[string]string{"a": "foo", "b": "bar", "c": "baz"}},
{environ: []string{"SRC_HEADER_A", "SRC_HEADER_B=", "SRC_HEADER_=baz"}, headers: map[string]string{}},
}

for _, testCase := range testCases {
t.Run(strings.Join(testCase.environ, " "), func(t *testing.T) {
if diff := cmp.Diff(testCase.headers, parseAdditionalHeadersFromMap(testCase.environ)); diff != "" {
t.Errorf("unexpected headers: %s", diff)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/src/lsif_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ Examples:
opts := codeintel.UploadIndexOpts{
Endpoint: cfg.Endpoint,
AccessToken: cfg.AccessToken,
AdditionalHeaders: cfg.AdditionalHeaders,
Repo: *flags.repo,
Commit: *flags.commit,
Root: *flags.root,
Expand Down
7 changes: 5 additions & 2 deletions cmd/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ var cfg *config

// config represents the config format.
type config struct {
Endpoint string `json:"endpoint"`
AccessToken string `json:"accessToken"`
Endpoint string `json:"endpoint"`
AccessToken string `json:"accessToken"`
AdditionalHeaders map[string]string `json:"additionalHeaders"`
}

// readConfig reads the config file from the given path.
Expand Down Expand Up @@ -124,6 +125,8 @@ func readConfig() (*config, error) {
cfg.Endpoint = "https://sourcegraph.com"
}

cfg.AdditionalHeaders = parseAdditionalHeaders()

// Lastly, apply endpoint flag if set
if endpoint != nil && *endpoint != "" {
cfg.Endpoint = *endpoint
Expand Down
63 changes: 45 additions & 18 deletions cmd/src/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestReadConfig(t *testing.T) {
name string
fileContents *config
envToken string
envFooHeader string
envEndpoint string
flagEndpoint string
want *config
Expand All @@ -23,7 +24,8 @@ func TestReadConfig(t *testing.T) {
{
name: "defaults",
want: &config{
Endpoint: "https://sourcegraph.com",
Endpoint: "https://sourcegraph.com",
AdditionalHeaders: map[string]string{},
},
},
{
Expand All @@ -33,8 +35,9 @@ func TestReadConfig(t *testing.T) {
AccessToken: "deadbeef",
},
want: &config{
Endpoint: "https://example.com",
AccessToken: "deadbeef",
Endpoint: "https://example.com",
AccessToken: "deadbeef",
AdditionalHeaders: map[string]string{},
},
},
{
Expand Down Expand Up @@ -66,45 +69,51 @@ func TestReadConfig(t *testing.T) {
envToken: "abc",
envEndpoint: "https://override.com",
want: &config{
Endpoint: "https://override.com",
AccessToken: "abc",
Endpoint: "https://override.com",
AccessToken: "abc",
AdditionalHeaders: map[string]string{},
},
},
{
name: "no config file, token from environment",
envToken: "abc",
want: &config{
Endpoint: "https://sourcegraph.com",
AccessToken: "abc",
Endpoint: "https://sourcegraph.com",
AccessToken: "abc",
AdditionalHeaders: map[string]string{},
},
},
{
name: "no config file, endpoint from environment",
envEndpoint: "https://example.com",
want: &config{
Endpoint: "https://example.com",
AccessToken: "",
Endpoint: "https://example.com",
AccessToken: "",
AdditionalHeaders: map[string]string{},
},
},
{
name: "no config file, both variables",
envEndpoint: "https://example.com",
envToken: "abc",
want: &config{
Endpoint: "https://example.com",
AccessToken: "abc",
Endpoint: "https://example.com",
AccessToken: "abc",
AdditionalHeaders: map[string]string{},
},
},
{
name: "endpoint flag should override config",
flagEndpoint: "https://override.com/",
fileContents: &config{
Endpoint: "https://example.com/",
AccessToken: "deadbeef",
Endpoint: "https://example.com/",
AccessToken: "deadbeef",
AdditionalHeaders: map[string]string{},
},
want: &config{
Endpoint: "https://override.com",
AccessToken: "deadbeef",
Endpoint: "https://override.com",
AccessToken: "deadbeef",
AdditionalHeaders: map[string]string{},
},
},
{
Expand All @@ -113,8 +122,22 @@ func TestReadConfig(t *testing.T) {
envEndpoint: "https://example.com",
envToken: "abc",
want: &config{
Endpoint: "https://override.com",
AccessToken: "abc",
Endpoint: "https://override.com",
AccessToken: "abc",
AdditionalHeaders: map[string]string{},
},
},

{
name: "additional header",
flagEndpoint: "https://override.com/",
envEndpoint: "https://example.com",
envToken: "abc",
envFooHeader: "bar",
want: &config{
Endpoint: "https://override.com",
AccessToken: "abc",
AdditionalHeaders: map[string]string{"foo": "bar"},
},
},
}
Expand Down Expand Up @@ -157,7 +180,11 @@ func TestReadConfig(t *testing.T) {
}
*configPath = filePath
}


if err := os.Setenv("SRC_HEADER_FOO", test.envFooHeader); err != nil {
t.Fatal(err)
}

config, err := readConfig()
if diff := cmp.Diff(test.want, config); diff != "" {
t.Errorf("config: %v", diff)
Expand Down
3 changes: 3 additions & 0 deletions cmd/src/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ func getRecommendedVersion() (string, error) {
if err != nil {
return "", err
}
for k, v := range cfg.AdditionalHeaders {
req.Header.Set(k, v)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
Expand Down
7 changes: 4 additions & 3 deletions internal/campaigns/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ type ActionRepoStatus struct {
}

type ExecutorOpts struct {
Endpoint string
AccessToken string
Endpoint string
AccessToken string
AdditionalHeaders map[string]string

KeepLogs bool
Timeout time.Duration
Expand Down Expand Up @@ -168,7 +169,7 @@ func (x *Executor) do(ctx context.Context, repo ActionRepo) (err error) {
runCtx, cancel := context.WithTimeout(ctx, x.opt.Timeout)
defer cancel()

patch, err := runAction(runCtx, x.opt.Endpoint, x.opt.AccessToken, prefix, repo.Name, repo.Rev, x.action.Steps, x.logger)
patch, err := runAction(runCtx, x.opt.Endpoint, x.opt.AccessToken, x.opt.AdditionalHeaders, prefix, repo.Name, repo.Rev, x.action.Steps, x.logger)
status := ActionRepoStatus{
FinishedAt: time.Now(),
}
Expand Down
9 changes: 6 additions & 3 deletions internal/campaigns/run_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import (
"golang.org/x/net/context/ctxhttp"
)

func runAction(ctx context.Context, endpoint, accessToken, prefix, repoName, rev string, steps []*ActionStep, logger *ActionLogger) ([]byte, error) {
func runAction(ctx context.Context, endpoint, accessToken string, additionalHeaders map[string]string, prefix, repoName, rev string, steps []*ActionStep, logger *ActionLogger) ([]byte, error) {
logger.RepoStarted(repoName, rev, steps)

zipFile, err := fetchRepositoryArchive(ctx, endpoint, accessToken, repoName, rev)
zipFile, err := fetchRepositoryArchive(ctx, endpoint, accessToken, additionalHeaders, repoName, rev)
if err != nil {
return nil, errors.Wrap(err, "Fetching ZIP archive failed")
}
Expand Down Expand Up @@ -179,7 +179,7 @@ func unzipToTempDir(ctx context.Context, zipFile, prefix string) (string, error)
return volumeDir, unzip(zipFile, volumeDir)
}

func fetchRepositoryArchive(ctx context.Context, endpoint, accessToken, repoName, rev string) (*os.File, error) {
func fetchRepositoryArchive(ctx context.Context, endpoint, accessToken string, additionalHeaders map[string]string, repoName, rev string) (*os.File, error) {
zipURL, err := repositoryZipArchiveURL(endpoint, repoName, rev, "")
if err != nil {
return nil, err
Expand All @@ -193,6 +193,9 @@ func fetchRepositoryArchive(ctx context.Context, endpoint, accessToken, repoName
if accessToken != "" {
req.Header.Set("Authorization", "token "+accessToken)
}
for k, v := range additionalHeaders {
req.Header.Set(k, v)
}
resp, err := ctxhttp.Do(ctx, nil, req)
if err != nil {
return nil, err
Expand Down