Просмотр исходного кода

ipn/ipnlocal: proxy gRPC requests over h2c if needed. (#9847)

Updates userspace proxy to detect plaintext grpc requests
using the preconfigured host prefix and request's content
type header and ensure that these will be proxied over h2c.

Updates tailscale/tailscale#9725

Signed-off-by: Irbe Krumina <[email protected]>
Irbe Krumina 2 лет назад
Родитель
Сommit
09b5bb3e55
2 измененных файлов с 114 добавлено и 13 удалено
  1. 78 13
      ipn/ipnlocal/serve.go
  2. 36 0
      ipn/ipnlocal/serve_test.go

+ 78 - 13
ipn/ipnlocal/serve.go

@@ -25,6 +25,7 @@ import (
 	"sync"
 	"time"
 
+	"golang.org/x/net/http2"
 	"tailscale.com/ipn"
 	"tailscale.com/logtail/backoff"
 	"tailscale.com/net/netutil"
@@ -35,6 +36,11 @@ import (
 	"tailscale.com/version"
 )
 
+const (
+	contentTypeHeader   = "Content-Type"
+	grpcBaseContentType = "application/grpc"
+)
+
 // ErrETagMismatch signals that the given
 // If-Match header does not match with the
 // current etag of a resource.
@@ -534,23 +540,64 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
 
 // proxyHandlerForBackend creates a new HTTP reverse proxy for a particular backend that
 // we serve requests for. `backend` is a HTTPHandler.Proxy string (url, hostport or just port).
-func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.ReverseProxy, error) {
+func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, error) {
 	targetURL, insecure := expandProxyArg(backend)
 	u, err := url.Parse(targetURL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
 	}
-	rp := &httputil.ReverseProxy{
-		Rewrite: func(r *httputil.ProxyRequest) {
-			r.SetURL(u)
-			r.Out.Host = r.In.Host
-			addProxyForwardedHeaders(r)
-			b.addTailscaleIdentityHeaders(r)
-		},
-		Transport: &http.Transport{
-			DialContext: b.dialer.SystemDial,
+	p := &reverseProxy{
+		logf:     b.logf,
+		url:      u,
+		insecure: insecure,
+		backend:  backend,
+		lb:       b,
+	}
+	return p, nil
+}
+
+// reverseProxy is a proxy that forwards a request to a backend host
+// (preconfigured via ipn.ServeConfig). If the host is configured with
+// http+insecure prefix, connection between proxy and backend will be over
+// insecure TLS. If the backend host has a http prefix and the incoming request
+// has application/grpc content type header, the connection will be over h2c.
+// Otherwise standard Go http transport will be used.
+type reverseProxy struct {
+	logf     logger.Logf
+	url      *url.URL
+	insecure bool
+	backend  string
+	lb       *LocalBackend
+}
+
+func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	p := &httputil.ReverseProxy{Rewrite: func(r *httputil.ProxyRequest) {
+		r.SetURL(rp.url)
+		r.Out.Host = r.In.Host
+		addProxyForwardedHeaders(r)
+		rp.lb.addTailscaleIdentityHeaders(r)
+	},
+	}
+
+	// There is no way to autodetect h2c as per RFC 9113
+	// https://datatracker.ietf.org/doc/html/rfc9113#name-starting-http-2.
+	// However, we assume that http:// proxy prefix in combination with the
+	// protoccol being HTTP/2 is sufficient to detect h2c for our needs. Only use this for
+	// gRPC to fix a known problem pf plaintext gRPC backends
+	if rp.shouldProxyViaH2C(r) {
+		rp.logf("received a proxy request for plaintext gRPC")
+		p.Transport = &http2.Transport{
+			AllowHTTP: true,
+			DialTLSContext: func(ctx context.Context, network string, addr string, _ *tls.Config) (net.Conn, error) {
+				return rp.lb.dialer.SystemDial(ctx, "tcp", rp.url.Host)
+			},
+		}
+
+	} else {
+		p.Transport = &http.Transport{
+			DialContext: rp.lb.dialer.SystemDial,
 			TLSClientConfig: &tls.Config{
-				InsecureSkipVerify: insecure,
+				InsecureSkipVerify: rp.insecure,
 			},
 			// Values for the following parameters have been copied from http.DefaultTransport.
 			ForceAttemptHTTP2:     true,
@@ -558,9 +605,27 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.Reverse
 			IdleConnTimeout:       90 * time.Second,
 			TLSHandshakeTimeout:   10 * time.Second,
 			ExpectContinueTimeout: 1 * time.Second,
-		},
+		}
 	}
-	return rp, nil
+	p.ServeHTTP(w, r)
+
+}
+
+// This is not a generally reliable way how to determine whether a request is
+// for a h2c server, but sufficient for our particular use case.
+func (rp *reverseProxy) shouldProxyViaH2C(r *http.Request) bool {
+	contentType := r.Header.Get(contentTypeHeader)
+	return r.ProtoMajor == 2 && strings.HasPrefix(rp.backend, "http://") && isGRPCContentType(contentType)
+}
+
+// isGRPC accepts an HTTP request's content type header value and determines
+// whether this is gRPC content. grpc-go considers a value that equals
+// application/grpc or has a prefix of application/grpc+ or application/grpc; a
+// valid grpc content type header.
+// https://github.com/grpc/grpc-go/blob/v1.60.0-dev/internal/grpcutil/method.go#L41-L78
+func isGRPCContentType(contentType string) bool {
+	s, ok := strings.CutPrefix(contentType, grpcBaseContentType)
+	return ok && len(s) == 0 || s[0] == '+' || s[0] == ';'
 }
 
 func addProxyForwardedHeaders(r *httputil.ProxyRequest) {

+ 36 - 0
ipn/ipnlocal/serve_test.go

@@ -587,3 +587,39 @@ func TestServeFileOrDirectory(t *testing.T) {
 		}
 	}
 }
+
+func Test_isGRPCContentType(t *testing.T) {
+	tests := map[string]struct {
+		contentType string
+		want        bool
+	}{
+		"application/grpc": {
+			contentType: "application/grpc",
+			want:        true,
+		},
+		"application/grpc;": {
+			contentType: "application/grpc;",
+			want:        true,
+		},
+		"application/grpc+": {
+			contentType: "application/grpc+",
+			want:        true,
+		},
+		"application/grpcfoobar": {
+			contentType: "application/grpcfoobar",
+		},
+		"application/text": {
+			contentType: "application/text",
+		},
+		"foobar": {
+			contentType: "foobar",
+		},
+	}
+	for name, scenario := range tests {
+		t.Run(name, func(t *testing.T) {
+			if got := isGRPCContentType(scenario.contentType); got != scenario.want {
+				t.Errorf("test case %s failed, isGRPCContentType() = %v, want %v", name, got, scenario.want)
+			}
+		})
+	}
+}