600cec6ccc
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>
312 lines
10 KiB
Go
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
|
|
}
|