package client import ( "bytes" "context" "errors" "fmt" "io" "net/http" "strings" "time" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/rs/zerolog/log" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" "github.com/segmentio/encoding/json" ) var errUnsupportedEnvironmentType = errors.New("environment not supported") const ( defaultDockerRequestTimeout = 60 * time.Second dockerClientVersion = "1.37" // negotiateTimeout bounds the eager API-version negotiation performed at // client construction so an unreachable daemon cannot block indefinitely. negotiateTimeout = 10 * time.Second // minSupportedAPIVersion is the hard invariant every Docker client must // satisfy. Below API v1.24 the daemon rejects requests that the SDK still // shapes with legacy semantics (e.g. a non-empty body on // "POST /containers/{id}/start"), which breaks container recreate. minSupportedAPIVersion = "1.24" // modernAPIVersionFloor is the version the client is pinned to when API // negotiation cannot determine a concrete daemon version (daemon // unreachable at construction, tunnel hiccup, ...). It keeps the effective // API version well above minSupportedAPIVersion instead of falling back to // the SDK legacy default. modernAPIVersionFloor = "1.44" ) type NodeNamesCtxKey struct{} // ClientFactory is used to create Docker clients type ClientFactory struct { signatureService portainer.DigitalSignatureService reverseTunnelService portainer.ReverseTunnelService } // NewClientFactory returns a new instance of a ClientFactory func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *ClientFactory { return &ClientFactory{ signatureService: signatureService, reverseTunnelService: reverseTunnelService, } } // CreateClient is a generic function to create a Docker client based on // a specific environment(endpoint) configuration. The nodeName parameter can be used // with an agent enabled environment(endpoint) to target a specific node in an agent cluster. // The underlying http client timeout may be specified, a default value is used otherwise. func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) { switch endpoint.Type { case portainer.AzureEnvironment: return nil, errUnsupportedEnvironmentType case portainer.AgentOnDockerEnvironment: return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout) case portainer.EdgeAgentOnDockerEnvironment: tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) if err != nil { return nil, err } endpointURL := "http://" + tunnelAddr return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout) } if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { return createLocalClient(endpoint) } return createTCPClient(endpoint, timeout) } // negotiateWithFloor builds a Docker client from opts (which must include // client.WithAPIVersionNegotiation()) and eagerly negotiates the API version // against the daemon, bounded by timeout. The custom agent/TCP transports do // not reliably resolve a modern API version through the SDK's lazy first-call // negotiation, so we resolve it up-front here. // // If negotiation cannot determine a concrete daemon version at or above // minSupportedAPIVersion (daemon unreachable at construction, tunnel hiccup, // or a pre-negotiation daemon), the client is rebuilt pinned at // modernAPIVersionFloor so its effective API version never drops below the // supported floor. // // SDK semantics relied upon (github.com/docker/docker/client): // - client.WithVersion(v) sets manualOverride=true, which makes both the // lazy checkVersion and NegotiateAPIVersion no-ops. So a pinned client // stays at the pinned version. This is why the floor is applied by // rebuilding with WithVersion rather than by combining WithVersion with // WithAPIVersionNegotiation (which would silently disable negotiation). // - NegotiateAPIVersion leaves ClientVersion() empty when it cannot reach // the daemon (Ping error), and otherwise sets it to the negotiated // version (capped at the client's maximum supported version). func negotiateWithFloor(opts []client.Opt, timeout time.Duration) (*client.Client, error) { cli, err := client.NewClientWithOpts(opts...) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cli.NegotiateAPIVersion(ctx) // Negotiation succeeded with a version at or above the supported floor: // keep the negotiated version (best match for the actual daemon). if v := cli.ClientVersion(); v != "" && !versions.LessThan(v, minSupportedAPIVersion) { return cli, nil } // Negotiation failed or resolved below the supported floor: rebuild the // client pinned at a modern version. WithVersion disables negotiation, so // the effective API version is fixed at modernAPIVersionFloor. if err := cli.Close(); err != nil { log.Warn().Err(err).Msg("failed to close docker client before applying API version floor") } return client.NewClientWithOpts(append(opts, client.WithVersion(modernAPIVersionFloor))...) } func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) { opts := []client.Opt{ client.WithHost(endpoint.URL), client.WithAPIVersionNegotiation(), } return negotiateWithFloor(opts, negotiateTimeout) } func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) { httpCli, err := httpClient(endpoint, timeout) if err != nil { return nil, err } opts := []client.Opt{ client.WithHost(endpoint.URL), client.WithAPIVersionNegotiation(), client.WithHTTPClient(httpCli), } if endpoint.TLSConfig.TLS { opts = append(opts, client.WithScheme("https")) } return negotiateWithFloor(opts, negotiateTimeout) } func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) { httpCli, err := httpClient(endpoint, timeout) if err != nil { return nil, err } signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err } headers := map[string]string{ portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(), portainer.PortainerAgentSignatureHeader: signature, } if nodeName != "" { headers[portainer.PortainerAgentTargetHeader] = nodeName } opts := []client.Opt{ client.WithHost(endpointURL), client.WithAPIVersionNegotiation(), client.WithHTTPClient(httpCli), client.WithHTTPHeaders(headers), } if endpoint.TLSConfig.TLS { opts = append(opts, client.WithScheme("https")) } return negotiateWithFloor(opts, negotiateTimeout) } type NodeNameTransport struct { *http.Transport } func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := t.Transport.RoundTrip(req) if err != nil || resp.StatusCode != http.StatusOK || resp.ContentLength == 0 || !strings.HasSuffix(req.URL.Path, "/images/json") { return resp, err } body, err := io.ReadAll(resp.Body) if err != nil { if err := resp.Body.Close(); err != nil { log.Warn().Err(err).Msg("failed to close response body") } return resp, err } if err := resp.Body.Close(); err != nil { log.Warn().Err(err).Msg("failed to close response body") } resp.Body = io.NopCloser(bytes.NewReader(body)) var rs []struct { image.Summary Portainer struct { Agent struct { NodeName string } } } if err = json.Unmarshal(body, &rs); err != nil { return resp, nil } nodeNames, ok := req.Context().Value(NodeNamesCtxKey{}).(map[string]string) if ok { for idx, r := range rs { // as there is no way to differentiate the same image available in multiple nodes only by their ID // we append the index of the image in the payload response to match the node name later // from the image.Summary[] list returned by docker's client.ImageList() nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName } } return resp, err } func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) { var transport *NodeNameTransport if endpoint.TLSConfig.TLS { tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) if err != nil { return nil, err } transport = &NodeNameTransport{ Transport: ssrf.NewTransport(tlsConfig), } } else { transport = &NodeNameTransport{ Transport: ssrf.NewTransport(nil), } } clientTimeout := defaultDockerRequestTimeout if timeout != nil { clientTimeout = *timeout } return &http.Client{ Transport: transport, Timeout: clientTimeout, }, nil }