diff --git a/cli/login.go b/cli/login.go index e16118dfec0d6..2fa7eb231ce9c 100644 --- a/cli/login.go +++ b/cli/login.go @@ -76,7 +76,7 @@ func (r *RootCmd) login() *clibase.Cmd { serverURL.Scheme = "https" } - client, err := r.createUnauthenticatedClient(serverURL) + client, err := r.createUnauthenticatedClient(ctx, serverURL) if err != nil { return err } diff --git a/cli/root.go b/cli/root.go index 036be18a01300..3197a3e3fce21 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,6 +1,8 @@ package cli import ( + "bufio" + "bytes" "context" "encoding/base64" "encoding/json" @@ -13,6 +15,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/signal" "path/filepath" "runtime" @@ -55,6 +58,7 @@ const ( varAgentToken = "agent-token" varAgentURL = "agent-url" varHeader = "header" + varHeaderCommand = "header-command" varNoOpen = "no-open" varNoVersionCheck = "no-version-warning" varNoFeatureWarning = "no-feature-warning" @@ -356,6 +360,13 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { Value: clibase.StringArrayOf(&r.header), Group: globalGroup, }, + { + Flag: varHeaderCommand, + Env: "CODER_HEADER_COMMAND", + Description: "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line.", + Value: clibase.StringOf(&r.headerCommand), + Group: globalGroup, + }, { Flag: varNoOpen, Env: "CODER_NO_OPEN", @@ -437,6 +448,7 @@ type RootCmd struct { token string globalConfig string header []string + headerCommand string agentToken string agentURL *url.URL forceTTY bool @@ -540,9 +552,7 @@ func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing return err } } - err = r.setClient( - client, r.clientURL, - ) + err = r.setClient(inv.Context(), client, r.clientURL) if err != nil { return err } @@ -592,12 +602,38 @@ func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing } } -func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error { +func (r *RootCmd) setClient(ctx context.Context, client *codersdk.Client, serverURL *url.URL) error { transport := &headerTransport{ transport: http.DefaultTransport, header: http.Header{}, } - for _, header := range r.header { + headers := r.header + if r.headerCommand != "" { + shell := "sh" + caller := "-c" + if runtime.GOOS == "windows" { + shell = "cmd.exe" + caller = "/c" + } + var outBuf bytes.Buffer + // #nosec + cmd := exec.CommandContext(ctx, shell, caller, r.headerCommand) + cmd.Env = append(os.Environ(), "CODER_URL="+serverURL.String()) + cmd.Stdout = &outBuf + cmd.Stderr = io.Discard + err := cmd.Run() + if err != nil { + return xerrors.Errorf("failed to run %v: %w", cmd.Args, err) + } + scanner := bufio.NewScanner(&outBuf) + for scanner.Scan() { + headers = append(headers, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return xerrors.Errorf("scan %v: %w", cmd.Args, err) + } + } + for _, header := range headers { parts := strings.SplitN(header, "=", 2) if len(parts) < 2 { return xerrors.Errorf("split header %q had less than two parts", header) @@ -611,9 +647,9 @@ func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error { return nil } -func (r *RootCmd) createUnauthenticatedClient(serverURL *url.URL) (*codersdk.Client, error) { +func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL) (*codersdk.Client, error) { var client codersdk.Client - err := r.setClient(&client, serverURL) + err := r.setClient(ctx, &client, serverURL) return &client, err } diff --git a/cli/root_test.go b/cli/root_test.go index c892701a3acbc..fb19aae6884a8 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "runtime" "strings" "sync/atomic" "testing" @@ -72,20 +73,29 @@ func TestRoot(t *testing.T) { t.Run("Header", func(t *testing.T) { t.Parallel() + var url string var called int64 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&called, 1) assert.Equal(t, "wow", r.Header.Get("X-Testing")) assert.Equal(t, "Dean was Here!", r.Header.Get("Cool-Header")) + assert.Equal(t, "very-wow-"+url, r.Header.Get("X-Process-Testing")) + assert.Equal(t, "more-wow", r.Header.Get("X-Process-Testing2")) w.WriteHeader(http.StatusGone) })) defer srv.Close() + url = srv.URL buf := new(bytes.Buffer) + coderURLEnv := "$CODER_URL" + if runtime.GOOS == "windows" { + coderURLEnv = "%CODER_URL%" + } inv, _ := clitest.New(t, "--no-feature-warning", "--no-version-warning", "--header", "X-Testing=wow", "--header", "Cool-Header=Dean was Here!", + "--header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow", "login", srv.URL, ) inv.Stdout = buf @@ -97,8 +107,8 @@ func TestRoot(t *testing.T) { }) } -// TestDERPHeaders ensures that the client sends the global `--header`s to the -// DERP server when connecting. +// TestDERPHeaders ensures that the client sends the global `--header`s and +// `--header-command` to the DERP server when connecting. func TestDERPHeaders(t *testing.T) { t.Parallel() @@ -129,8 +139,9 @@ func TestDERPHeaders(t *testing.T) { // Inject custom /derp handler so we can inspect the headers. var ( expectedHeaders = map[string]string{ - "X-Test-Header": "test-value", - "Cool-Header": "Dean was Here!", + "X-Test-Header": "test-value", + "Cool-Header": "Dean was Here!", + "X-Process-Testing": "very-wow", } derpCalled int64 ) @@ -159,9 +170,12 @@ func TestDERPHeaders(t *testing.T) { "--no-version-warning", "ping", workspace.Name, "-n", "1", + "--header-command", "printf X-Process-Testing=very-wow", } for k, v := range expectedHeaders { - args = append(args, "--header", fmt.Sprintf("%s=%s", k, v)) + if k != "X-Process-Testing" { + args = append(args, "--header", fmt.Sprintf("%s=%s", k, v)) + } } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index e074756680e84..6e988a9f568fd 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -62,6 +62,11 @@ variables or flags. Additional HTTP headers added to all requests. Provide as key=value. Can be specified multiple times. + --header-command string, $CODER_HEADER_COMMAND + An external command that outputs additional HTTP headers added to all + requests. The command must output each header as `key=value` on its + own line. + --no-feature-warning bool, $CODER_NO_FEATURE_WARNING Suppress warnings about unlicensed features. diff --git a/cli/vscodessh.go b/cli/vscodessh.go index 136a0d727c17a..7a576581fa05c 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -86,7 +86,7 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd { client.SetSessionToken(string(sessionToken)) // This adds custom headers to the request! - err = r.setClient(client, serverURL) + err = r.setClient(ctx, client, serverURL) if err != nil { return xerrors.Errorf("set client: %w", err) } diff --git a/docs/cli.md b/docs/cli.md index 1a0c40a22fca6..96acc1cf940db 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -96,6 +96,15 @@ Path to the global `coder` config directory. Additional HTTP headers added to all requests. Provide as key=value. Can be specified multiple times. +### --header-command + +| | | +| ----------- | ---------------------------------- | +| Type | string | +| Environment | $CODER_HEADER_COMMAND | + +An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line. + ### --no-feature-warning | | | diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index 7fc6962ded847..ae24592079a69 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -33,6 +33,11 @@ variables or flags. Additional HTTP headers added to all requests. Provide as key=value. Can be specified multiple times. + --header-command string, $CODER_HEADER_COMMAND + An external command that outputs additional HTTP headers added to all + requests. The command must output each header as `key=value` on its + own line. + --no-feature-warning bool, $CODER_NO_FEATURE_WARNING Suppress warnings about unlicensed features.