Files
portainer/api/docker/client/client.go
T
agent_coder dbd2d06659 fix(docker): harden client API-version resolution — eager negotiate + >=1.24 floor (part of #34)
Issue #34 reports container Recreate / native auto-update (#28, #10) failing on modern
Docker Engines via the agent with "starting container with non-empty request body ...
removed in v1.24". The proposed fix: make API-version resolution robust in all three
docker client constructors.

- api/docker/client/client.go: add a shared negotiateWithFloor() used by createLocalClient,
  createTCPClient, createAgentClient. It eagerly NegotiateAPIVersion (10s bound) instead of
  relying on lazy first-call negotiation, and enforces a modern floor: if the negotiated
  version is empty or < 1.24 (versions.LessThan), rebuild pinned at 1.44 (WithVersion, which
  the SDK requires INSTEAD of negotiation — WithVersion sets manualOverride, disabling
  NegotiateAPIVersion). Negotiation stays primary; the floor is only a safety net. Effective
  API version is now guaranteed >= 1.24. Custom transport / TLS / agent headers untouched.
- Tests (api/docker/client/client_test.go): modern daemon -> empty start body; below-floor
  daemon -> pinned to floor; unreachable daemon -> stays >= 1.24.

IMPORTANT FINDING (needs maintainer confirmation before this is called a complete fix for
#34): on the pinned SDK (github.com/docker/docker v28.5.2), ContainerStart posts a nil body
UNCONDITIONALLY (client/container_start.go: cli.post(ctx, ".../start", query, nil, nil) — no
version branch), and NewClientWithOpts defaults to version 1.51 (negotiation only downgrades).
So on develop, the server-side path the issue names (autoupdate.go -> Recreate ->
cli.ContainerStart) cannot emit a request body regardless of API version — this change hardens
the client path but is unlikely to be the actual source of the reported symptom. The
"non-empty body" more likely originates from a DIFFERENT layer (an OLDER docker SDK in the
failing deployment, where ContainerStart did attach HostConfig at API < 1.24; or the docker
PROXY forwarding a raw browser request). Recommend confirming the failing environment's build
before merging as the #34 fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:05:26 +03:00

283 lines
9.0 KiB
Go

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
}