add support for DOCKER_CUSTOM_HEADERS env-var (experimental)

This environment variable allows for setting additional headers
to be sent by the client. Headers set through this environment
variable are added to headers set through the config-file (through
the HttpHeaders field).

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.

WARNING: If both config and environment-variable are set, the environment
variable currently overrides all headers set in the configuration file.
This behavior may change in a future update, as we are considering the
environment variable to be appending to existing headers (and to only
override headers with the same name).

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn 2024-05-31 13:04:52 +02:00
parent cd92610bca
commit 6638deb9d6
No known key found for this signature in database
GPG Key ID: 76698F39D527CE8C
5 changed files with 153 additions and 5 deletions

View File

@ -324,7 +324,7 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF
if len(configFile.HTTPHeaders) > 0 { if len(configFile.HTTPHeaders) > 0 {
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders)) opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
} }
opts = append(opts, client.WithUserAgent(UserAgent())) opts = append(opts, withCustomHeadersFromEnv(), client.WithUserAgent(UserAgent()))
return client.NewClientWithOpts(opts...) return client.NewClientWithOpts(opts...)
} }

View File

@ -2,13 +2,18 @@ package command
import ( import (
"context" "context"
"encoding/csv"
"io" "io"
"net/http"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"github.com/moby/term" "github.com/moby/term"
"github.com/pkg/errors"
) )
// CLIOption is a functional argument to apply options to a [DockerCli]. These // CLIOption is a functional argument to apply options to a [DockerCli]. These
@ -108,3 +113,107 @@ func WithAPIClient(c client.APIClient) CLIOption {
return nil 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.
//
// WARNING: If both config and environment-variable are set, the environment
// variable currently overrides all headers set in the configuration file.
// This behavior may change in a future update, as we are considering the
// environment variable to be appending to existing headers (and to only
// override headers with the same name).
//
// 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.
//
// WARNING: If both config and environment-variable are set, the environment-
// variable currently overrides all headers set in the configuration file.
// This behavior may change in a future update, as we are considering the
// environment-variable to be appending to existing headers (and to only
// override headers with the same name).
//
// 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.Errorf("failed to parse custom headers from %s environment variable: value must be formatted as comma-separated key=value pairs", 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: '%s'`, envOverrideHTTPHeaders, kv))
}
// We don't currently allow empty 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: '%s'`, envOverrideHTTPHeaders, kv))
}
env[http.CanonicalHeaderKey(k)] = v
}
if len(env) == 0 {
// We should probably not hit this case, as we don't skip values
// (only return errors), but we don't want to discard existing
// headers with an empty set.
return nil
}
// TODO(thaJeztah): add a client.WithExtraHTTPHeaders() function to allow these headers to be _added_ to existing ones, instead of _replacing_
// see https://github.com/docker/cli/pull/5098#issuecomment-2147403871 (when updating, also update the WARNING in the function and env-var GoDoc)
return client.WithHTTPHeaders(env)(apiClient)
}
}

View File

@ -87,6 +87,41 @@ func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
assert.DeepEqual(t, received, expectedHeaders) 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"},
"Three": []string{""},
"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) { func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) {
customVersion := "v3.3.3" customVersion := "v3.3.3"
t.Setenv("DOCKER_API_VERSION", customVersion) t.Setenv("DOCKER_API_VERSION", customVersion)

View File

@ -118,13 +118,14 @@ The following list of environment variables are supported by the `docker` comman
line: line:
| Variable | Description | | Variable | Description |
| :---------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | :---------------------------- |:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DOCKER_API_VERSION` | Override the negotiated API version to use for debugging (e.g. `1.19`) | | `DOCKER_API_VERSION` | Override the negotiated API version to use for debugging (e.g. `1.19`) |
| `DOCKER_CERT_PATH` | Location of your authentication keys. This variable is used both by the `docker` CLI and the [`dockerd` daemon](https://docs.docker.com/reference/cli/dockerd/) | | `DOCKER_CERT_PATH` | Location of your authentication keys. This variable is used both by the `docker` CLI and the [`dockerd` daemon](https://docs.docker.com/reference/cli/dockerd/) |
| `DOCKER_CONFIG` | The location of your client configuration files. | | `DOCKER_CONFIG` | The location of your client configuration files. |
| `DOCKER_CONTENT_TRUST_SERVER` | The URL of the Notary server to use. Defaults to the same URL as the registry. | | `DOCKER_CONTENT_TRUST_SERVER` | The URL of the Notary server to use. Defaults to the same URL as the registry. |
| `DOCKER_CONTENT_TRUST` | When set Docker uses notary to sign and verify images. Equates to `--disable-content-trust=false` for build, create, pull, push, run. | | `DOCKER_CONTENT_TRUST` | When set Docker uses notary to sign and verify images. Equates to `--disable-content-trust=false` for build, create, pull, push, run. |
| `DOCKER_CONTEXT` | Name of the `docker context` to use (overrides `DOCKER_HOST` env var and default context set with `docker context use`) | | `DOCKER_CONTEXT` | Name of the `docker context` to use (overrides `DOCKER_HOST` env var and default context set with `docker context use`) |
| `DOCKER_CUSTOM_HEADERS` | (Experimental) Configure [custom HTTP headers](#custom-http-headers) to be sent by the client. Headers must be provided as a comma-separated list of `name=value` pairs. This is the equivalent to the `HttpHeaders` field in the configuration file. |
| `DOCKER_DEFAULT_PLATFORM` | Default platform for commands that take the `--platform` flag. | | `DOCKER_DEFAULT_PLATFORM` | Default platform for commands that take the `--platform` flag. |
| `DOCKER_HIDE_LEGACY_COMMANDS` | When set, Docker hides "legacy" top-level commands (such as `docker rm`, and `docker pull`) in `docker help` output, and only `Management commands` per object-type (e.g., `docker container`) are printed. This may become the default in a future release. | | `DOCKER_HIDE_LEGACY_COMMANDS` | When set, Docker hides "legacy" top-level commands (such as `docker rm`, and `docker pull`) in `docker help` output, and only `Management commands` per object-type (e.g., `docker container`) are printed. This may become the default in a future release. |
| `DOCKER_HOST` | Daemon socket to connect to. | | `DOCKER_HOST` | Daemon socket to connect to. |
@ -281,6 +282,10 @@ sent from the Docker client to the daemon. Docker doesn't try to interpret or
understand these headers; it simply puts them into the messages. Docker does understand these headers; it simply puts them into the messages. Docker does
not allow these headers to change any headers it sets for itself. not allow these headers to change any headers it sets for itself.
Alternatively, use the `DOCKER_CUSTOM_HEADERS` [environment variable](#environment-variables),
which is available in v27.1 and higher. This environment-variable is experimental,
and its exact behavior may change.
#### Credential store options #### Credential store options
The property `credsStore` specifies an external binary to serve as the default The property `credsStore` specifies an external binary to serve as the default

View File

@ -133,9 +133,8 @@ to [the `daemon.json` file](#daemon-configuration-file).
The following list of environment variables are supported by the `dockerd` daemon. The following list of environment variables are supported by the `dockerd` daemon.
Some of these environment variables are supported both by the Docker Daemon and Some of these environment variables are supported both by the Docker Daemon and
the `docker` CLI. Refer to [Environment variables](https://docs.docker.com/reference/cli/docker/#environment-variables) the `docker` CLI. Refer to [Environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables)
in the CLI section to learn about environment variables supported by the to learn about environment variables supported by the `docker` CLI.
`docker` CLI.
| Variable | Description | | Variable | Description |
| :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |