mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 13:05:46 +00:00
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 <irbe@tailscale.com>
This commit is contained in:
parent
891d964bd4
commit
09b5bb3e55
@ -25,6 +25,7 @@
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/logtail/backoff"
|
"tailscale.com/logtail/backoff"
|
||||||
"tailscale.com/net/netutil"
|
"tailscale.com/net/netutil"
|
||||||
@ -35,6 +36,11 @@
|
|||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
contentTypeHeader = "Content-Type"
|
||||||
|
grpcBaseContentType = "application/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
// ErrETagMismatch signals that the given
|
// ErrETagMismatch signals that the given
|
||||||
// If-Match header does not match with the
|
// If-Match header does not match with the
|
||||||
// current etag of a resource.
|
// 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
|
// 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).
|
// 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)
|
targetURL, insecure := expandProxyArg(backend)
|
||||||
u, err := url.Parse(targetURL)
|
u, err := url.Parse(targetURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
|
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
|
||||||
}
|
}
|
||||||
rp := &httputil.ReverseProxy{
|
p := &reverseProxy{
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
logf: b.logf,
|
||||||
r.SetURL(u)
|
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
|
r.Out.Host = r.In.Host
|
||||||
addProxyForwardedHeaders(r)
|
addProxyForwardedHeaders(r)
|
||||||
b.addTailscaleIdentityHeaders(r)
|
rp.lb.addTailscaleIdentityHeaders(r)
|
||||||
},
|
},
|
||||||
Transport: &http.Transport{
|
}
|
||||||
DialContext: b.dialer.SystemDial,
|
|
||||||
|
// 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{
|
TLSClientConfig: &tls.Config{
|
||||||
InsecureSkipVerify: insecure,
|
InsecureSkipVerify: rp.insecure,
|
||||||
},
|
},
|
||||||
// Values for the following parameters have been copied from http.DefaultTransport.
|
// Values for the following parameters have been copied from http.DefaultTransport.
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
@ -558,9 +605,27 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.Reverse
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
ExpectContinueTimeout: 1 * 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) {
|
func addProxyForwardedHeaders(r *httputil.ProxyRequest) {
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user