diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index c3c6a7eeb..e36df42ba 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -25,6 +25,7 @@ "sync" "time" + "golang.org/x/net/http2" "tailscale.com/ipn" "tailscale.com/logtail/backoff" "tailscale.com/net/netutil" @@ -35,6 +36,11 @@ "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) { diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go index 242dd9ca2..61137c166 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/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) + } + }) + } +}