diff --git a/cli/command/cli.go b/cli/command/cli.go index 28253f8c16c1..aeef81fe4078 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -309,7 +309,7 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF if len(configFile.HTTPHeaders) > 0 { opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders)) } - opts = append(opts, client.WithUserAgent(UserAgent())) + opts = append(opts, withCustomHeadersFromEnv(), client.WithUserAgent(UserAgent())) return client.NewClientWithOpts(opts...) } diff --git a/cli/command/cli_options.go b/cli/command/cli_options.go index 8e431bf8652e..a4fb31a94d56 100644 --- a/cli/command/cli_options.go +++ b/cli/command/cli_options.go @@ -2,13 +2,18 @@ package command import ( "context" + "encoding/csv" "io" + "net/http" "os" "strconv" + "strings" "github.com/docker/cli/cli/streams" "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" "github.com/moby/term" + "github.com/pkg/errors" ) // CLIOption is a functional argument to apply options to a [DockerCli]. These @@ -107,3 +112,93 @@ func WithAPIClient(c client.APIClient) CLIOption { return nil } } + +// envOverrideHTTPHeaders is the name of the environment variable that can be +// used to set custom HTTP headers to be sent by the client. This environment +// variable is the equivalent to the HttpHeaders field in the configuration +// file; if both are set, the environment variable overrides the headers +// set in the configuration file. +// +// While this env-var allows for custom headers to be set, it does not allow +// for built-in headers (such as "User-Agent", if set) to be overridden. +// Also see [client.WithHTTPHeaders] and [client.WithUserAgent]. +// +// This environment variable can be used in situations where headers must be +// set for a specific invocation of the CLI, but should not be set by default, +// and therefore cannot be set in the config-file. +// +// envOverrideHTTPHeaders accepts a comma-separated (CSV) list of key=value pairs, +// where key must be a non-empty, valid MIME header format. Whitespaces surrounding +// the key are trimmed, and the key is normalised. Whitespaces in values are +// preserved, but "key=value" pairs with an empty value (e.g. "key=") are ignored. +// Tuples without a "=" produce an error. +// +// It follows CSV rules for escaping, allowing "key=value" pairs to be quoted +// if they must contain commas. which allows for multiple values for a single +// header to be set. If a key is repeated in the list, later values override +// prior values. +// +// For example, the following value: +// +// one=one-value,"two=two,value","three= a value with whitespace ",four=,five=five=one,five=five-two +// +// Produces four headers (four is omitted as it has an empty value set): +// +// - one (value is "one-value") +// - two (value is "two,value") +// - three (value is " a value with whitespace ") +// - five (value is "five-two", the later value has overridden the prior value) +const envOverrideHTTPHeaders = "DOCKER_CUSTOM_HEADERS" + +// withCustomHeadersFromEnv overriding custom HTTP headers to be sent by the +// client through the [envOverrideHTTPHeaders] environment variable. This +// environment variable is the equivalent to the HttpHeaders field in the +// configuration file; if both are set, the environment variable overrides +// the headers set in the configuration file. +// +// TODO(thaJeztah): this is a client Option, and should be moved to the client. It is non-exported for that reason. +func withCustomHeadersFromEnv() client.Opt { + return func(apiClient *client.Client) error { + value := os.Getenv(envOverrideHTTPHeaders) + if value == "" { + return nil + } + csvReader := csv.NewReader(strings.NewReader(value)) + fields, err := csvReader.Read() + if err != nil { + return errdefs.InvalidParameter(errors.Wrapf(err, "failed to set custom headers from %s environment variable", envOverrideHTTPHeaders)) + } + if len(fields) == 0 { + return nil + } + + env := map[string]string{} + for _, kv := range fields { + k, v, hasValue := strings.Cut(kv, "=") + + // Only strip whitespace in keys; preserve whitespace in values. + k = strings.TrimSpace(k) + + if k == "" { + return errdefs.InvalidParameter(errors.Errorf("failed to set custom headers from %s environment variable: value contains a key=value pair with an empty key", envOverrideHTTPHeaders)) + } + + // We don't currently allow key=value pairs, and produce an error. + // This is something we could allow in future (e.g. to read value + // from an environment variable with the same name). In the meantime, + // produce an error to prevent users from depending on this. + if !hasValue { + return errdefs.InvalidParameter(errors.Errorf(`failed to set custom headers from %s environment variable: missing "=" in key=value pair %q`, envOverrideHTTPHeaders, kv)) + } + + if v == "" { + // Ignore empty values, and consider them to not be set + continue + } + env[http.CanonicalHeaderKey(k)] = v + } + + // TODO(thaJeztah): should an empty result be ignored? + return client.WithHTTPHeaders(env)(apiClient) + } +} diff --git a/cli/command/cli_test.go b/cli/command/cli_test.go index d0456cddc575..6cb2e099b0cf 100644 --- a/cli/command/cli_test.go +++ b/cli/command/cli_test.go @@ -86,6 +86,40 @@ func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) { assert.DeepEqual(t, received, expectedHeaders) } +func TestNewAPIClientFromFlagsWithCustomHeadersFromEnv(t *testing.T) { + var received http.Header + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = r.Header.Clone() + _, _ = w.Write([]byte("OK")) + })) + defer ts.Close() + host := strings.Replace(ts.URL, "http://", "tcp://", 1) + opts := &flags.ClientOptions{Hosts: []string{host}} + configFile := &configfile.ConfigFile{ + HTTPHeaders: map[string]string{ + "My-Header": "Custom-Value from config-file", + }, + } + + // envOverrideHTTPHeaders should override the HTTPHeaders from the config-file, + // so "My-Header" should not be present. + t.Setenv(envOverrideHTTPHeaders, `one=one-value,"two=two,value",three=,four=four-value,four=four-value-override`) + apiClient, err := NewAPIClientFromFlags(opts, configFile) + assert.NilError(t, err) + assert.Equal(t, apiClient.DaemonHost(), host) + assert.Equal(t, apiClient.ClientVersion(), api.DefaultVersion) + + expectedHeaders := http.Header{ + "One": []string{"one-value"}, + "Two": []string{"two,value"}, + "Four": []string{"four-value-override"}, + "User-Agent": []string{UserAgent()}, + } + _, err = apiClient.Ping(context.Background()) + assert.NilError(t, err) + assert.DeepEqual(t, received, expectedHeaders) +} + func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) { customVersion := "v3.3.3" t.Setenv("DOCKER_API_VERSION", customVersion)