Skip to content

Commit b90d88e

Browse files
committed
cli/grpc: fall back to the legacy endpoint on native transport failure
An intermediary between the client and the daemon may not relay HTTP/2 even though the daemon's API version advertises native gRPC support — Docker Desktop's API proxy does not, at the time of writing. Probe native connections with a health-check RPC (any RPC-level outcome, including Unimplemented, proves the transport) and fall back to the legacy upgrade endpoint on transport-level failure. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent ed1a1a1 commit b90d88e

2 files changed

Lines changed: 89 additions & 8 deletions

File tree

cli/grpc/grpc.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
// [Connect] hides those transport details: it returns a [grpc.ClientConn]
1212
// reaching the daemon, whatever the transport of the current endpoint (unix
1313
// or npipe socket, tcp with or without TLS, or a connection helper such as
14-
// ssh://), falling back to the legacy upgrade endpoint for older daemons.
15-
// Note that the legacy endpoint only exposes the daemon's built-in gRPC
16-
// services: services published by daemon extensions require a daemon serving
17-
// gRPC natively.
14+
// ssh://), falling back to the legacy upgrade endpoint for older daemons —
15+
// or when an intermediary between the client and the daemon does not relay
16+
// HTTP/2 even though the daemon's API version advertises native support
17+
// (Docker Desktop's API proxy, at the time of writing). Note that the legacy
18+
// endpoint only exposes the daemon's built-in gRPC services: services
19+
// published by daemon extensions require a daemon serving gRPC natively.
1820
package grpc
1921

2022
import (
@@ -29,8 +31,11 @@ import (
2931
"github.com/moby/moby/client"
3032
"github.com/moby/moby/client/pkg/versions"
3133
"google.golang.org/grpc"
34+
"google.golang.org/grpc/codes"
3235
"google.golang.org/grpc/credentials"
3336
"google.golang.org/grpc/credentials/insecure"
37+
healthpb "google.golang.org/grpc/health/grpc_health_v1"
38+
"google.golang.org/grpc/status"
3439
)
3540

3641
// nativeAPIVersion is the minimum API version where the daemon serves gRPC
@@ -87,9 +92,9 @@ func WithDialOptions(opts ...grpc.DialOption) Opt {
8792
}
8893

8994
// Connect returns a gRPC client connection to the daemon of dockerCLI's
90-
// current endpoint. ctx is only used to query the daemon's API version; the
91-
// returned connection is lazy: it is not bound to ctx, and is only
92-
// established when the first RPC is made.
95+
// current endpoint. ctx bounds the daemon API version query and the probing
96+
// of native connections; the returned connection is not bound to it, and
97+
// behaves lazily afterwards.
9398
func Connect(ctx context.Context, dockerCLI DockerCLI, opts ...Opt) (*grpc.ClientConn, error) {
9499
return Dial(ctx, dockerCLI.Client(), dockerCLI.DockerEndpoint(), opts...)
95100
}
@@ -105,11 +110,34 @@ func Dial(ctx context.Context, apiClient APIClient, ep docker.Endpoint, opts ...
105110
return nil, fmt.Errorf("establishing gRPC connection to the daemon: %w", err)
106111
}
107112
if ping.APIVersion != "" && !versions.LessThan(ping.APIVersion, nativeAPIVersion) {
108-
return dialNative(apiClient, ep, &cfg)
113+
conn, err := dialNative(apiClient, ep, &cfg)
114+
if err != nil {
115+
return nil, err
116+
}
117+
if probeTransport(ctx, conn) == nil {
118+
return conn, nil
119+
}
120+
// The daemon's API version advertises native gRPC support but the
121+
// transport doesn't get through: an intermediary between the client
122+
// and the daemon (Docker Desktop's API proxy, at the time of writing)
123+
// may not relay HTTP/2. Fall back to the legacy upgrade endpoint.
124+
_ = conn.Close()
109125
}
110126
return dialLegacy(apiClient, &cfg)
111127
}
112128

129+
// probeTransport verifies conn actually reaches an HTTP/2 server. Any
130+
// RPC-level outcome — including Unimplemented from a daemon not serving the
131+
// health service — proves the transport; only transport-level failures
132+
// (codes.Unavailable) are reported.
133+
func probeTransport(ctx context.Context, conn *grpc.ClientConn) error {
134+
_, err := healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
135+
if status.Code(err) == codes.Unavailable {
136+
return err
137+
}
138+
return nil
139+
}
140+
113141
// dialNative connects to a daemon serving gRPC natively on its API endpoint:
114142
// ALPN-negotiated HTTP/2 for TLS endpoints, cleartext HTTP/2 (h2c) over the
115143
// API client's own dialer otherwise. The client dialer covers unix and npipe

cli/grpc/grpc_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,59 @@ func TestDialNative(t *testing.T) {
102102
assert.Check(t, !apiClient.hijackCalled.Load())
103103
}
104104

105+
// startHTTP1Server answers any connection with a plain HTTP/1.1 response,
106+
// standing in for an intermediary that does not relay HTTP/2 (e.g. Docker
107+
// Desktop's API proxy).
108+
func startHTTP1Server(t *testing.T) string {
109+
t.Helper()
110+
l, err := net.Listen("tcp", "127.0.0.1:0")
111+
assert.NilError(t, err)
112+
go func() {
113+
for {
114+
conn, err := l.Accept()
115+
if err != nil {
116+
return
117+
}
118+
go func(c net.Conn) {
119+
buf := make([]byte, 1024)
120+
_, _ = c.Read(buf)
121+
_, _ = c.Write([]byte("HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n"))
122+
_ = c.Close()
123+
}(conn)
124+
}
125+
}()
126+
t.Cleanup(func() { _ = l.Close() })
127+
return l.Addr().String()
128+
}
129+
130+
func TestDialNativeFallsBackToLegacy(t *testing.T) {
131+
// The daemon advertises native support but an HTTP/1.1-only intermediary
132+
// sits on the endpoint: the probe fails at transport level and the
133+
// connection falls back to the legacy upgrade endpoint.
134+
http1Addr := startHTTP1Server(t)
135+
grpcAddr := startServer(t)
136+
apiClient := &fakeAPIClient{
137+
apiVersion: "1.53",
138+
dialer: func(context.Context) (net.Conn, error) {
139+
return net.Dial("tcp", http1Addr)
140+
},
141+
hijack: func(context.Context, string, string, map[string][]string) (net.Conn, error) {
142+
return net.Dial("tcp", grpcAddr)
143+
},
144+
}
145+
146+
conn, err := Dial(t.Context(), apiClient, docker.Endpoint{
147+
EndpointMeta: docker.EndpointMeta{Host: "unix:///var/run/docker.sock"},
148+
})
149+
assert.NilError(t, err)
150+
defer conn.Close()
151+
152+
checkHealth(t, conn)
153+
assert.Check(t, apiClient.dialerCalled.Load())
154+
assert.Check(t, apiClient.hijackCalled.Load())
155+
assert.Check(t, is.Equal(apiClient.hijackURL, "/grpc"))
156+
}
157+
105158
func TestDialLegacy(t *testing.T) {
106159
for _, apiVersion := range []string{"", "1.52"} {
107160
t.Run("api-version-"+apiVersion, func(t *testing.T) {

0 commit comments

Comments
 (0)