Files
portainer/api/docker/client/client.go
T
agent_coder 600cec6ccc fix(docker): make the API-version floor undefeatable (Ping-based) + pin 1.24 + honor caller timeout (#35 review round 1)
Round-1 review found the floor was DEFEATED for the main #34 target (daemon unreachable
at construction): the SDK keeps ClientVersion() == "1.51" on a ping failure (not empty),
so the old empty-string guard passed, the floor branch was skipped, lazy negotiation
stayed armed, and the client could downgrade below 1.24 on the first real request.

- Detect unreachable EXPLICITLY via cli.Ping(ctx) instead of inspecting ClientVersion():
  ping error -> rebuildAtFloor; otherwise NegotiateAPIVersionPing(ping) applies the
  advertised version AND marks the client negotiated (disarms the lazy re-ping); if the
  result is empty or < 1.24 -> rebuildAtFloor. rebuildAtFloor closes and rebuilds with
  WithVersion(minSupportedAPIVersion) — which sets manualOverride, making both the lazy
  checkVersion and NegotiateAPIVersion no-ops, so the pinned client can NEVER re-negotiate
  below the floor. The rebuild reuses the same opts, so agent-signature headers / TLS /
  custom transport are preserved.
- Pin the floor at minSupportedAPIVersion "1.24", not "1.44": 1.44 is "too new" for a
  genuinely old daemon (400) and would make container.go's `< 1.44` MAC-cleanup wrongly
  skip. 1.24 satisfies the invariant and every supported daemon accepts it. Dropped the
  1.44 const.
- resolveNegotiateTimeout(timeout): use the caller's timeout when set (deployer's 3600s,
  autoupdate/autoheal), else a modest 10s default. The proxy hotpath and snapshot loop
  pass nil -> bounded 10s, so a daemon that accepts TCP but hangs on /_ping can't stall
  the whole snapshot loop for a huge fixed time.
- Test TestNegotiateWithFloor_UnreachableThenBelowFloor: unreachable at construction, then
  the daemon advertises 1.20; asserts the client pins to 1.24, ContainerStart goes /v1.24/
  (not /v1.20/), and /_ping isn't hit again after construction (no lazy re-negotiation).
  Mutation-verified: reverting to the old logic reds it.

go build / vet / gofmt clean; go test ./docker/client/ -race 4/4 pass. Still framed as a
hardening (part of #34) — the vendored SDK's ContainerStart sends no body regardless, so
confirm the failing deployment's build before treating this as the complete #34 fix.

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

312 lines
10 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"
// defaultNegotiateTimeout bounds eager API-version negotiation at client
// construction when the caller does not supply an explicit timeout, so an
// unreachable or hung daemon cannot block construction for long. Callers
// that pass a timeout (e.g. remote stack deploys, container automation)
// override this with their own deliberate value.
defaultNegotiateTimeout = 10 * time.Second
// minSupportedAPIVersion is both the hard invariant every Docker client
// must satisfy and the version the client is pinned to when negotiation
// cannot determine a usable daemon version. Below API v1.24 the daemon
// rejects requests the SDK still shapes with legacy semantics (e.g. a
// non-empty body on "POST /containers/{id}/start"), which breaks container
// recreate (issue #34). 1.24 is accepted by every supported daemon, so it
// is safe to pin even against a genuinely old engine (a newer floor such as
// 1.44 would be rejected as "too new" and would also skip the
// pre-1.44 MAC-address cleanup in container.go).
minSupportedAPIVersion = "1.24"
)
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)
}
// resolveNegotiateTimeout derives the eager-negotiation timeout from the
// caller's requested client timeout, respecting deliberate per-endpoint
// timeouts (e.g. remote stack deploys, container automation) and falling back
// to a modest default when none is provided.
func resolveNegotiateTimeout(timeout *time.Duration) time.Duration {
if timeout != nil {
return *timeout
}
return defaultNegotiateTimeout
}
// negotiateWithFloor builds a Docker client from opts (which must include
// client.WithAPIVersionNegotiation()) and resolves its effective API version at
// construction time, guaranteeing it never drops below minSupportedAPIVersion —
// not even lazily on a later request. 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.
//
// We ping the daemon explicitly (bounded by timeout) rather than relying on
// cli.NegotiateAPIVersion + inspecting ClientVersion(), because that cannot
// tell "reached the daemon and negotiated" apart from "never reached it": the
// SDK seeds every client with api.DefaultVersion and NegotiateAPIVersion
// swallows a ping error without clearing the version or marking the client as
// negotiated (docker@v28.5.2 client.go NegotiateAPIVersion). A non-empty
// ClientVersion() would therefore let an unreachable daemon pass, leaving lazy
// negotiation armed to downgrade below the floor on the first real request.
//
// - Ping error (unreachable at construction): pin at the floor.
// - Ping ok but advertised version < floor (or empty): pin at the floor.
// - Ping ok and version >= floor: apply it via NegotiateAPIVersionPing, which
// also marks the client negotiated so it will not re-ping lazily.
//
// The floor is applied by rebuilding with client.WithVersion, which sets
// manualOverride=true and thereby disables all further (lazy) negotiation, so
// the effective version is fixed and can never re-negotiate below the floor.
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()
ping, err := cli.Ping(ctx)
if err != nil {
// Daemon unreachable at construction: pin at the floor so the client
// cannot lazily negotiate below it on the first real request.
return rebuildAtFloor(cli, opts)
}
// Apply the version advertised by the daemon; this also disarms lazy
// re-negotiation on subsequent requests.
cli.NegotiateAPIVersionPing(ping)
if v := cli.ClientVersion(); v == "" || versions.LessThan(v, minSupportedAPIVersion) {
return rebuildAtFloor(cli, opts)
}
return cli, nil
}
// rebuildAtFloor closes cli and returns a new client from the same opts pinned
// at minSupportedAPIVersion. client.WithVersion sets manualOverride=true, which
// makes both the lazy checkVersion and NegotiateAPIVersion no-ops, so the
// effective API version stays fixed at the floor.
func rebuildAtFloor(cli *client.Client, opts []client.Opt) (*client.Client, error) {
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(minSupportedAPIVersion))...)
}
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
opts := []client.Opt{
client.WithHost(endpoint.URL),
client.WithAPIVersionNegotiation(),
}
return negotiateWithFloor(opts, defaultNegotiateTimeout)
}
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, resolveNegotiateTimeout(timeout))
}
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, resolveNegotiateTimeout(timeout))
}
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
}