Files
moby/client/client_options.go
T
Sebastiaan van Stijn b8a569e7f6 client: add WithHTTPRequestHook option
Add WithHTTPRequestHook to allow callers to inspect outgoing HTTP requests
and modify request metadata before they are sent to the daemon.

Request hooks receive a clone of the original request so that modifications
made by hooks do not mutate the request passed to the client's RoundTripper.
Changes made by hooks are preserved on the request passed to the underlying
transport.

Protect request bodies while hooks are running, preventing hooks from reading
or closing the body while restoring the original body before the request is
sent. Hooks are invoked in registration order and may return an error to abort
the request.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-01 17:31:54 +02:00

460 lines
15 KiB
Go

package client
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/go-connections/sockets"
"github.com/docker/go-connections/tlsconfig"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/trace"
)
type clientConfig struct {
// scheme sets the scheme for the client
scheme string
// host holds the server address to connect to
host string
// proto holds the client protocol i.e. unix.
proto string
// addr holds the client address.
addr string
// basePath holds the path to prepend to the requests.
basePath string
// client used to send and receive http requests.
client *http.Client
// version of the server to talk to.
version string
// userAgent is the User-Agent header to use for HTTP requests. It takes
// precedence over User-Agent headers set in customHTTPHeaders, and other
// header variables. When set to an empty string, the User-Agent header
// is removed, and no header is sent.
userAgent *string
// custom HTTP headers configured by users.
customHTTPHeaders map[string]string
// manualAPIVersion contains the API version set by users. This field
// will only be non-empty if a valid-formed version was set through
// [WithAPIVersion].
//
// If both manualAPIVersion and envAPIVersion are set, manualAPIVersion
// takes precedence. Either field disables API-version negotiation.
manualAPIVersion string
// envAPIVersion contains the API version set by users. This field
// will only be non-empty if a valid-formed version was set through
// [WithAPIVersionFromEnv].
//
// If both manualAPIVersion and envAPIVersion are set, manualAPIVersion
// takes precedence. Either field disables API-version negotiation.
envAPIVersion string
// requestHooks contains hooks to call before requests are made.
requestHooks []RequestHook
// responseHooks contains hooks to call on responses.
responseHooks []ResponseHook
// traceOpts is a list of options to configure the tracing span.
traceOpts []otelhttp.Option
}
// RequestHook is called for each HTTP request made by the client.
// Hooks are invoked in the order they were added.
//
// Hooks may inspect the request and modify request metadata, such as headers,
// before the request is sent. The request body cannot be read or closed by
// the hook.
type RequestHook func(*http.Request) error
// ResponseHook is called for each HTTP response returned by the daemon.
// Hooks are invoked in the order they were added.
//
// Hooks must not read or close resp.Body.
type ResponseHook func(*http.Response)
// Opt is a configuration option to initialize a [Client].
type Opt func(*clientConfig) error
// FromEnv configures the client with values from environment variables. It
// is the equivalent of using the [WithTLSClientConfigFromEnv], [WithHostFromEnv],
// and [WithAPIVersionFromEnv] options.
//
// FromEnv uses the following environment variables:
//
// - DOCKER_HOST ([EnvOverrideHost]) to set the URL to the docker server.
// - DOCKER_API_VERSION ([EnvOverrideAPIVersion]) to set the version of the
// API to use, leave empty for latest.
// - DOCKER_CERT_PATH ([EnvOverrideCertPath]) to specify the directory from
// which to load the TLS certificates ("ca.pem", "cert.pem", "key.pem').
// - DOCKER_TLS_VERIFY ([EnvTLSVerify]) to enable or disable TLS verification
// (off by default).
func FromEnv(c *clientConfig) error {
ops := []Opt{
WithTLSClientConfigFromEnv(),
WithHostFromEnv(),
WithAPIVersionFromEnv(),
}
for _, op := range ops {
if err := op(c); err != nil {
return err
}
}
return nil
}
// WithDialContext applies the dialer to the client transport. This can be
// used to set the Timeout and KeepAlive settings of the client. It returns
// an error if the client does not have a [http.Transport] configured.
func WithDialContext(dialContext func(ctx context.Context, network, addr string) (net.Conn, error)) Opt {
return func(c *clientConfig) error {
if transport, ok := c.client.Transport.(*http.Transport); ok {
transport.DialContext = dialContext
return nil
}
return fmt.Errorf("cannot apply dialer to transport: %T", c.client.Transport)
}
}
// WithHost overrides the client host with the specified one.
func WithHost(host string) Opt {
return func(c *clientConfig) error {
hostURL, err := ParseHostURL(host)
if err != nil {
return err
}
c.host = host
c.proto = hostURL.Scheme
c.addr = hostURL.Host
c.basePath = hostURL.Path
if transport, ok := c.client.Transport.(*http.Transport); ok {
return sockets.ConfigureTransport(transport, c.proto, c.addr)
}
// For test transports, we skip transport configuration but still
// set the host fields so that the client can use them for headers
if _, ok := c.client.Transport.(testRoundTripper); ok {
return nil
}
return fmt.Errorf("cannot apply host to transport: %T", c.client.Transport)
}
}
// testRoundTripper allows us to inject a mock-transport for testing. We define it
// here so we can detect the tlsconfig and return nil for only this type.
type testRoundTripper func(*http.Request) (*http.Response, error)
func (tf testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return tf(req)
}
// WithHostFromEnv overrides the client host with the host specified in the
// DOCKER_HOST ([EnvOverrideHost]) environment variable. If DOCKER_HOST is not set,
// or set to an empty value, the host is not modified.
func WithHostFromEnv() Opt {
return func(c *clientConfig) error {
if host := os.Getenv(EnvOverrideHost); host != "" {
return WithHost(host)(c)
}
return nil
}
}
// WithHTTPClient overrides the client's HTTP client with the specified one.
func WithHTTPClient(client *http.Client) Opt {
return func(c *clientConfig) error {
if client != nil {
// Make a clone of client so modifications do not affect
// the caller's client. Clone here instead of in New()
// as other options (WithHost) also mutate c.client.
// Cloned clients share the same CookieJar as the
// original.
hc := *client
if ht, ok := hc.Transport.(*http.Transport); ok {
hc.Transport = ht.Clone()
}
c.client = &hc
}
return nil
}
}
// WithTimeout configures the time limit for requests made by the HTTP client.
func WithTimeout(timeout time.Duration) Opt {
return func(c *clientConfig) error {
c.client.Timeout = timeout
return nil
}
}
// WithUserAgent configures the User-Agent header to use for HTTP requests.
// It overrides any User-Agent set in headers. When set to an empty string,
// the User-Agent header is removed, and no header is sent.
func WithUserAgent(ua string) Opt {
return func(c *clientConfig) error {
c.userAgent = &ua
return nil
}
}
// WithHTTPHeaders appends custom HTTP headers to the client's default headers.
// It does not allow overriding built-in headers (such as "User-Agent").
// Also see [WithUserAgent].
//
// It replaces any existing custom headers. Keys are case-insensitive and
// canonicalized using [http.CanonicalHeaderKey]. If multiple entries map
// to the same canonical key, a [cerrdefs.ErrInvalidArgument] is returned.
func WithHTTPHeaders(headers map[string]string) Opt {
return func(c *clientConfig) error {
c.customHTTPHeaders = make(map[string]string)
for k, v := range headers {
k = http.CanonicalHeaderKey(k)
_, ok := c.customHTTPHeaders[k]
if ok {
return cerrdefs.ErrInvalidArgument.WithMessage(fmt.Sprintf("duplicate custom HTTP header (%s)", k))
}
c.customHTTPHeaders[k] = v
}
return nil
}
}
// WithScheme overrides the client scheme with the specified one.
func WithScheme(scheme string) Opt {
return func(c *clientConfig) error {
c.scheme = scheme
return nil
}
}
// WithTLSClientConfig configures the client's existing HTTP transport to use TLS.
// The minimum TLS version is TLS 1.2.
//
// If caFile is non-empty, it specifies the CA certificate file to use for
// server verification, and replaces the system root pool for that verification.
// If certFile is empty, the system root pool is used.
//
// If either certFile or keyFile is set, both must point to readable files
// containing a valid client certificate and unencrypted private key, or this
// option returns an error.
//
// If both certPath and keyPath are empty, no client certificate is configured.
// The connection will use TLS without client authentication (i.e., not mTLS).
func WithTLSClientConfig(caFile, certFile, keyFile string) Opt {
return func(c *clientConfig) error {
transport, ok := c.client.Transport.(*http.Transport)
if !ok {
return fmt.Errorf("cannot configure TLS: unsupported HTTP transport %T", c.client.Transport)
}
config, err := tlsconfig.Client(tlsconfig.Options{
CAFile: caFile,
CertFile: certFile,
KeyFile: keyFile,
ExclusiveRootPools: true,
MinVersion: tls.VersionTLS12,
})
if err != nil {
return fmt.Errorf("configure TLS: %w", err)
}
transport.TLSClientConfig = config
return nil
}
}
// WithTLSClientConfigFromEnv configures the client for TLS using the
// DOCKER_CERT_PATH ([EnvOverrideCertPath]) and DOCKER_TLS_VERIFY
// ([EnvTLSVerify]) environment variables. The minimum TLS version is TLS 1.2.
//
// If DOCKER_CERT_PATH is unset or empty, this option leaves the client
// unchanged.
//
// When DOCKER_CERT_PATH is set, the following files are loaded from that
// directory:
//
// - "ca.pem" as the CA certificate
// - "cert.pem" as the client certificate
// - "key.pem" as the client private key
//
// These files must exist, be readable, and contain valid TLS material, or this
// option returns an error. A client certificate is always loaded from "cert.pem"
// and "key.pem" (mTLS is expected).
//
// If DOCKER_TLS_VERIFY is set to a non-empty value, server certificate
// verification is enabled. In that case, "ca.pem" is added to the system root
// pool used for verification.
//
// If DOCKER_TLS_VERIFY is unset or empty, server certificate verification is
// disabled.
func WithTLSClientConfigFromEnv() Opt {
return func(c *clientConfig) error {
dockerCertPath := os.Getenv(EnvOverrideCertPath)
if dockerCertPath == "" {
return nil
}
tlsConfig, err := tlsconfig.Client(tlsconfig.Options{
CAFile: filepath.Join(dockerCertPath, "ca.pem"),
CertFile: filepath.Join(dockerCertPath, "cert.pem"),
KeyFile: filepath.Join(dockerCertPath, "key.pem"),
InsecureSkipVerify: os.Getenv(EnvTLSVerify) == "",
MinVersion: tls.VersionTLS12,
})
if err != nil {
return fmt.Errorf("configure TLS from %q: %w", EnvOverrideCertPath+"="+dockerCertPath, err)
}
// FIXME(thaJeztah): unlike WithTLSClientConfig, this option replaces the client's http.Client and transport; consider updating just the transport.
c.client = &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsConfig},
CheckRedirect: CheckRedirect,
}
return nil
}
}
// WithAPIVersion overrides the client's API version with the specified one,
// and disables API version negotiation. If an empty version is provided,
// this option is ignored to allow version negotiation. The given version
// should be formatted "<major>.<minor>" (for example, "1.52"). It returns
// an error if the given value not in the correct format.
//
// WithAPIVersion does not validate if the client supports the given version,
// and callers should verify if the version lower than the maximum supported
// version as defined by [MaxAPIVersion].
//
// [WithAPIVersionFromEnv] takes precedence if [WithAPIVersion] and
// [WithAPIVersionFromEnv] are both set.
func WithAPIVersion(version string) Opt {
return func(c *clientConfig) error {
version = strings.TrimSpace(version)
if val := strings.TrimPrefix(version, "v"); val != "" {
ver, err := parseAPIVersion(val)
if err != nil {
return fmt.Errorf("invalid API version (%s): %w", version, err)
}
c.manualAPIVersion = ver
}
return nil
}
}
// WithVersion overrides the client version with the specified one.
//
// Deprecated: use [WithAPIVersion] instead.
//
//go:fix inline
func WithVersion(version string) Opt {
return WithAPIVersion(version)
}
// WithAPIVersionFromEnv overrides the client version with the version specified in
// the DOCKER_API_VERSION ([EnvOverrideAPIVersion]) environment variable.
// If DOCKER_API_VERSION is not set, or set to an empty value, the version
// is not modified.
//
// WithAPIVersion does not validate if the client supports the given version,
// and callers should verify if the version lower than the maximum supported
// version as defined by [MaxAPIVersion].
//
// [WithAPIVersionFromEnv] takes precedence if [WithAPIVersion] and
// [WithAPIVersionFromEnv] are both set.
func WithAPIVersionFromEnv() Opt {
return func(c *clientConfig) error {
version := strings.TrimSpace(os.Getenv(EnvOverrideAPIVersion))
if val := strings.TrimPrefix(version, "v"); val != "" {
ver, err := parseAPIVersion(val)
if err != nil {
return fmt.Errorf("invalid API version (%s): %w", version, err)
}
c.envAPIVersion = ver
}
return nil
}
}
// WithVersionFromEnv overrides the client version with the version specified in
// the DOCKER_API_VERSION ([EnvOverrideAPIVersion]) environment variable.
//
// Deprecated: use [WithAPIVersionFromEnv] instead.
//
//go:fix inline
func WithVersionFromEnv() Opt {
return WithAPIVersionFromEnv()
}
// WithAPIVersionNegotiation enables automatic API version negotiation for the client.
// With this option enabled, the client automatically negotiates the API version
// to use when making requests. API version negotiation is performed on the first
// request; subsequent requests do not re-negotiate.
//
// Deprecated: API-version negotiation is now enabled by default and this options
// is now a no-op.
//
// Use [WithAPIVersion] or [WithAPIVersionFromEnv] to set a fixed API version
// instead of using automatic negotiation.
func WithAPIVersionNegotiation() Opt {
return func(c *clientConfig) error {
return nil
}
}
// WithTraceProvider sets the trace provider for the client.
// If this is not set then the global trace provider is used.
func WithTraceProvider(provider trace.TracerProvider) Opt {
return func(c *clientConfig) error {
c.traceOpts = append(c.traceOpts, otelhttp.WithTracerProvider(provider))
return nil
}
}
// WithTraceOptions sets tracing span options for the client.
func WithTraceOptions(opts ...otelhttp.Option) Opt {
return func(c *clientConfig) error {
c.traceOpts = append(c.traceOpts, opts...)
return nil
}
}
// WithHTTPRequestHook adds a [RequestHook] to the client. RequestHooks are called
// before an HTTP request is made to the daemon. Hooks are invoked in the order
// they were added.
func WithHTTPRequestHook(h RequestHook) Opt {
return func(c *clientConfig) error {
if h == nil {
return errors.New("invalid request hook: hook is nil")
}
c.requestHooks = append(c.requestHooks, h)
return nil
}
}
// WithHTTPResponseHook adds a [ResponseHook] to the client. ResponseHooks are called
// for each HTTP response returned by the daemon. Hooks are invoked in the order
// they were added.
//
// Hooks must not read or close resp.Body.
func WithHTTPResponseHook(h ResponseHook) Opt {
return func(c *clientConfig) error {
if h == nil {
return errors.New("invalid response hook: hook is nil")
}
c.responseHooks = append(c.responseHooks, h)
return nil
}
}
// WithResponseHook is equivalent to [WithHTTPResponseHook].
//
// Deprecated: use [WithHTTPResponseHook] instead.
//
//go:fix inline
func WithResponseHook(h ResponseHook) Opt {
return WithHTTPResponseHook(h)
}