blob: 3992557230c31f251e576b660071bc15a08bb3c0 [file] [edit]
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
// responseHooks is a list of custom response hooks to call on responses.
responseHooks []ResponseHook
// traceOpts is a list of options to configure the tracing span.
traceOpts []otelhttp.Option
}
// 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
}
}
// WithResponseHook 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 WithResponseHook(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
}
}