Skip to content

Commit 2437e41

Browse files
authored
Automatically attempt HTTP/2 prior knowledge without flag when required (#4680)
1 parent 634533a commit 2437e41

4 files changed

Lines changed: 387 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## [Unreleased]
44

5+
- Update `buf curl` to automatically use HTTP/2 prior knowledge for `http` URLs when server
6+
reflection, the gRPC protocol, or a bidirectional streaming method is used, since all of these
7+
require HTTP/2. The `--http2-prior-knowledge` flag is no longer required in these cases.
58
- Add `--stdin-filepath` flag to `buf format`, which reads a single `.proto` file from
69
stdin and writes the formatted result to stdout. The path is not read from disk, and is
710
only used to report parse errors and diffs.

cmd/buf/internal/command/curl/curl.go

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ The URL can use either http or https as the scheme. If http is used then HTTP 1.
127127
unless the --http2-prior-knowledge flag is set. If https is used then HTTP/2 will be preferred
128128
during protocol negotiation and HTTP 1.1 used only if the server does not support HTTP/2.
129129
130+
Server reflection, the gRPC protocol, and bidirectional streaming methods all require HTTP/2. So
131+
if any of them is used with an http URL, HTTP/2 is used as if the --http2-prior-knowledge flag
132+
were set.
133+
130134
The default RPC protocol used will be Connect. To use a different protocol (gRPC or gRPC-Web),
131135
use the --protocol flag. Note that the gRPC protocol cannot be used with HTTP 1.1.
132136
@@ -152,7 +156,7 @@ Examples:
152156
Issue a unary RPC to a plain-text (i.e. "h2c") gRPC server, where the schema for the service is
153157
in a Buf module in the current directory, using an empty request message:
154158
155-
$ buf curl --schema . --protocol grpc --http2-prior-knowledge \
159+
$ buf curl --schema . --protocol grpc \
156160
http://localhost:20202/foo.bar.v1.FooService/DoSomething
157161
158162
Issue an RPC to a Connect server, where the schema comes from the Buf Schema Registry, using
@@ -328,10 +332,13 @@ and port indicated in the URL`,
328332
&f.HTTP2PriorKnowledge,
329333
http2PriorKnowledgeFlagName,
330334
false,
331-
`This flag can be used to indicate that HTTP/2 should be used. Without this, HTTP 1.1
335+
fmt.Sprintf(`This flag can be used to indicate that HTTP/2 should be used. Without this, HTTP 1.1
332336
will be used with URLs with an http scheme, and protocol negotiation will be used to
333337
choose either HTTP 1.1 or HTTP/2 for URLs with an https scheme. With this flag set,
334-
HTTP/2 is always used, even over plain-text.`,
338+
HTTP/2 is always used, even over plain-text. This flag is implied for URLs with an http
339+
scheme when server reflection is used, when --%s is "grpc", or when the method uses
340+
bidirectional streaming, since all of these require HTTP/2.`,
341+
protocolFlagName),
335342
)
336343

337344
flagSet.BoolVar(
@@ -568,8 +575,12 @@ func (f *flags) validate(hasURL, isSecure bool) error {
568575
return fmt.Errorf("if --%s is set, --%s should not be set as it is unused", insecureFlagName, caCertFlagName)
569576
}
570577

571-
if !isSecure && !f.HTTP2PriorKnowledge && f.Protocol == connect.ProtocolGRPC {
572-
return fmt.Errorf("grpc protocol cannot be used with plain-text URLs (http) unless --%s flag is set", http2PriorKnowledgeFlagName)
578+
if !isSecure && !f.HTTP2PriorKnowledge && (f.Reflect || f.Protocol == connect.ProtocolGRPC) {
579+
// Server reflection uses a bidirectional stream and the gRPC protocol
580+
// requires HTTP/2, neither of which works over HTTP 1.1. Since a
581+
// plain-text URL can only use HTTP/2 via prior knowledge, enable it
582+
// automatically rather than requiring the flag.
583+
f.HTTP2PriorKnowledge = true
573584
}
574585

575586
if !isSecure && f.HTTP3 {
@@ -601,9 +612,6 @@ func (f *flags) validate(hasURL, isSecure bool) error {
601612
reflectHeaderFlagName, reflectProtocolFlagName, reflectFlagName)
602613
}
603614
if f.Reflect {
604-
if !isSecure && !f.HTTP2PriorKnowledge {
605-
return fmt.Errorf("--%s cannot be used with plain-text URLs (http) unless --%s flag is set", reflectFlagName, http2PriorKnowledgeFlagName)
606-
}
607615
if _, err := bufcurl.ParseReflectProtocol(f.ReflectProtocol); err != nil {
608616
return fmt.Errorf(
609617
"--%s value must be one of %s",
@@ -878,6 +886,31 @@ func parseEndpointURL(urlArg string) (service, method, baseURL string, err error
878886
return service, method, baseURL, nil
879887
}
880888

889+
// wrapPlainTextHTTP2Error is a best effort to return a more helpful error if err indicates
890+
// that the server answered an HTTP/2 prior knowledge (h2c) connection with an
891+
// HTTP 1.1 response, which means it does not support HTTP/2 over plain-text.
892+
// Otherwise, err is returned unchanged.
893+
//
894+
// Without this, the CLI's error interceptor would render this without details as:
895+
//
896+
// Failure: the server hosted at that remote is unavailable.
897+
func wrapPlainTextHTTP2Error(err error, http2PriorKnowledge bool, host string, isSecure bool) error {
898+
if err == nil || isSecure || !http2PriorKnowledge ||
899+
// The stdlib does not expose a structured way of knowing the error is from a
900+
// HTTP/1.1-like response so do a string match, meaning this function is
901+
// best-effort across Go versions.
902+
!strings.Contains(err.Error(), "frame header looked like an HTTP/1.1 header") {
903+
return err
904+
}
905+
// Format with %v rather than %w on purpose: the CLI's error interceptor
906+
// rewrites any error that wraps a connect.CodeUnavailable error into a
907+
// generic message, which would hide this explanation.
908+
return fmt.Errorf(
909+
"the RPC protocol or method requires HTTP/2, but the server at %s responded with HTTP/1.1 and does not appear to support HTTP/2 over plain-text (h2c): %v",
910+
host, err,
911+
)
912+
}
913+
881914
func run(ctx context.Context, container appext.Container, f *flags) (err error) {
882915
var urlArg, host string
883916
var isSecure bool
@@ -889,6 +922,9 @@ func run(ctx context.Context, container appext.Container, f *flags) (err error)
889922
return err
890923
}
891924
}
925+
defer func() {
926+
err = wrapPlainTextHTTP2Error(err, f.HTTP2PriorKnowledge, host, isSecure)
927+
}()
892928
if err := f.validate(urlArg != "", isSecure); err != nil {
893929
return err
894930
}
@@ -1096,6 +1132,11 @@ func run(ctx context.Context, container appext.Container, f *flags) (err error)
10961132
if err != nil {
10971133
return err
10981134
}
1135+
if !isSecure && methodDescriptor.IsStreamingClient() && methodDescriptor.IsStreamingServer() {
1136+
// Bidirectional streaming requires HTTP/2 regardless of the RPC
1137+
// protocol, so automatically attempt prior knowledge over plain-text.
1138+
f.HTTP2PriorKnowledge = true
1139+
}
10991140
transport, err := makeTransportOnce()
11001141
if err != nil {
11011142
return err
@@ -1160,6 +1201,9 @@ func makeHTTPRoundTripper(f *flags, isSecure bool, authority string, printer ver
11601201
protocols.SetHTTP1(!f.HTTP2PriorKnowledge)
11611202
protocols.SetHTTP2(true)
11621203
protocols.SetUnencryptedHTTP2(f.HTTP2PriorKnowledge && !isSecure)
1204+
if protocols.UnencryptedHTTP2() {
1205+
printer.Printf("* Using HTTP/2 prior knowledge over plain-text (h2c)")
1206+
}
11631207
return &http.Transport{
11641208
Proxy: http.ProxyFromEnvironment,
11651209
DialContext: dialFunc,
@@ -1475,15 +1519,16 @@ func completePathFromServices(
14751519
}
14761520

14771521
// makeCompletionHTTPClient builds an HTTP client for use during shell
1478-
// completion. Returns (client, true) on success, or (nil, false) when
1479-
// reflection is not possible (e.g. plain HTTP without HTTP/2 prior knowledge).
1522+
// completion. Returns (client, true) on success, or (nil, false) when the
1523+
// client could not be built.
14801524
func makeCompletionHTTPClient(cmd *cobra.Command, isSecure bool, authority string) (connect.HTTPClient, bool) {
14811525
insecure, _ := cmd.Flags().GetBool(insecureFlagName)
14821526
http2PriorKnowledge, _ := cmd.Flags().GetBool(http2PriorKnowledgeFlagName)
1483-
if !isSecure && !http2PriorKnowledge {
1484-
// Plain HTTP: server reflection requires HTTP/2, which needs prior knowledge
1485-
// over a cleartext connection. Skip completion if the flag is not set.
1486-
return nil, false
1527+
if !isSecure {
1528+
// Completion uses server reflection, which requires HTTP/2. Over
1529+
// plain-text, HTTP/2 needs prior knowledge, so use it just like the
1530+
// command itself does when reflection is used with an http URL.
1531+
http2PriorKnowledge = true
14871532
}
14881533
key, _ := cmd.Flags().GetString(keyFlagName)
14891534
cert, _ := cmd.Flags().GetString(certFlagName)

cmd/buf/internal/command/curl/curl_completion_test.go

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,32 @@ func newTestReflectionServer(t *testing.T, resolver protodesc.Resolver, serviceN
113113
return server
114114
}
115115

116+
// newTestPlainTextReflectionServer starts an in-process plain-text Connect
117+
// server that serves gRPC reflection (v1 and v1alpha) for the given service
118+
// names, using resolver to look up their descriptors. If unencryptedHTTP2 is
119+
// true, the server also accepts HTTP/2 connections via prior knowledge (h2c);
120+
// otherwise it only speaks HTTP 1.1. The server is automatically closed when
121+
// t completes.
122+
func newTestPlainTextReflectionServer(t *testing.T, resolver protodesc.Resolver, unencryptedHTTP2 bool, serviceNames ...string) *httptest.Server {
123+
t.Helper()
124+
reflector := grpcreflect.NewReflector(
125+
grpcreflect.NamerFunc(func() []string { return serviceNames }),
126+
grpcreflect.WithDescriptorResolver(resolver),
127+
)
128+
mux := http.NewServeMux()
129+
mux.Handle(grpcreflect.NewHandlerV1(reflector))
130+
mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))
131+
132+
server := httptest.NewUnstartedServer(mux)
133+
protocols := new(http.Protocols)
134+
protocols.SetHTTP1(true)
135+
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
136+
server.Config.Protocols = protocols
137+
server.Start()
138+
t.Cleanup(server.Close)
139+
return server
140+
}
141+
116142
// newCompletionCmd returns a minimal cobra.Command that has the flags accessed
117143
// by completeURL (schema, insecure, http2-prior-knowledge).
118144
func newCompletionCmd() *cobra.Command {
@@ -334,6 +360,34 @@ func TestCompleteURL_ReflectionServer(t *testing.T) {
334360
})
335361
}
336362

363+
// TestCompleteURL_PlainTextReflectionServer verifies end-to-end completion via a
364+
// plain-text h2c server with gRPC reflection enabled, without the
365+
// --http2-prior-knowledge flag, which is implied for http URLs.
366+
func TestCompleteURL_PlainTextReflectionServer(t *testing.T) {
367+
t.Parallel()
368+
resolver := newTestDescriptorResolver(t)
369+
server := newTestPlainTextReflectionServer(t, resolver, true, "acme.foo.v1.FooService", "acme.bar.v1.BarService")
370+
371+
cmd := newCompletionCmd()
372+
373+
t.Run("unambiguous branch jumps to service name", func(t *testing.T) {
374+
t.Parallel()
375+
completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.")
376+
assert.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive)
377+
assert.Equal(t, []string{server.URL + "/acme.foo.v1.FooService/\treflection"}, completions)
378+
})
379+
380+
t.Run("lists methods for a service", func(t *testing.T) {
381+
t.Parallel()
382+
completions, directive := completeURL(cmd, nil, server.URL+"/acme.foo.v1.FooService/")
383+
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
384+
assert.Equal(t, []string{
385+
server.URL + "/acme.foo.v1.FooService/GetFoo\treflection",
386+
server.URL + "/acme.foo.v1.FooService/ListFoos\treflection",
387+
}, completions)
388+
})
389+
}
390+
337391
// TestCompleteURLFromReflection_Unavailable verifies that when a server does not
338392
// support reflection, completeURLFromReflection returns ok=false so the caller
339393
// can try an alternative source.
@@ -365,7 +419,8 @@ func TestCompleteURLFromReflection_Unavailable(t *testing.T) {
365419
assert.Nil(t, completions)
366420
}
367421

368-
// TestMakeCompletionHTTPClient verifies the two code paths in makeCompletionHTTPClient.
422+
// TestMakeCompletionHTTPClient verifies that makeCompletionHTTPClient returns a
423+
// client for both secure and plain-text URLs.
369424
func TestMakeCompletionHTTPClient(t *testing.T) {
370425
t.Parallel()
371426

@@ -377,12 +432,12 @@ func TestMakeCompletionHTTPClient(t *testing.T) {
377432
assert.NotNil(t, client)
378433
})
379434

380-
t.Run("http without prior knowledge returns nothing", func(t *testing.T) {
435+
t.Run("http without prior knowledge returns client", func(t *testing.T) {
381436
t.Parallel()
382437
cmd := newCompletionCmd()
383438
client, ok := makeCompletionHTTPClient(cmd, false, "localhost:80")
384-
assert.False(t, ok)
385-
assert.Nil(t, client)
439+
assert.True(t, ok)
440+
assert.NotNil(t, client)
386441
})
387442

388443
t.Run("http with prior knowledge returns client", func(t *testing.T) {

0 commit comments

Comments
 (0)