diff --git a/oauthproxy.go b/oauthproxy.go index e2357c8d..28cf0a90 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -289,6 +289,7 @@ func (p *OAuthProxy) setupServer(opts *options.Options) error { BindAddress: opts.Server.BindAddress, SecureBindAddress: opts.Server.SecureBindAddress, TLS: opts.Server.TLS, + HTTP2: opts.Server.HTTP2, } // Option: AllowQuerySemicolons @@ -306,6 +307,7 @@ func (p *OAuthProxy) setupServer(opts *options.Options) error { BindAddress: opts.MetricsServer.BindAddress, SecureBindAddress: opts.MetricsServer.SecureBindAddress, TLS: opts.MetricsServer.TLS, + HTTP2: opts.MetricsServer.HTTP2, }) if err != nil { return fmt.Errorf("could not build metrics server: %v", err) diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index e53fd480..3d5824c2 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -482,6 +482,7 @@ type LegacyServer struct { TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"` TLSMinVersion string `flag:"tls-min-version" cfg:"tls_min_version"` TLSCipherSuites []string `flag:"tls-cipher-suite" cfg:"tls_cipher_suites"` + ForceHTTP2 bool `flag:"force-http2" cfg:"force_http2"` } func legacyServerFlagset() *pflag.FlagSet { @@ -497,6 +498,7 @@ func legacyServerFlagset() *pflag.FlagSet { flagSet.String("tls-key-file", "", "path to private key file") flagSet.String("tls-min-version", "", "minimal TLS version for HTTPS clients (either \"TLS1.2\" or \"TLS1.3\")") flagSet.StringSlice("tls-cipher-suite", []string{}, "restricts TLS cipher suites to those listed (e.g. TLS_RSA_WITH_RC4_128_SHA) (may be given multiple times)") + flagSet.Bool("force-http2", false, "enable HTTP/2 support (h2c for HTTP, h2 for HTTPS); required for gRPC proxying") return flagSet } @@ -653,6 +655,7 @@ func (l LegacyServer) convert() (Server, Server) { appServer := Server{ BindAddress: l.HTTPAddress, SecureBindAddress: l.HTTPSAddress, + HTTP2: l.ForceHTTP2, } if l.TLSKeyFile != "" || l.TLSCertFile != "" { appServer.TLS = &TLS{ diff --git a/pkg/apis/options/server.go b/pkg/apis/options/server.go index 830ea09a..be061f46 100644 --- a/pkg/apis/options/server.go +++ b/pkg/apis/options/server.go @@ -22,6 +22,12 @@ type Server struct { // TLS contains the information for loading the certificate and key for the // secure traffic and further configuration for the TLS server. TLS *TLS `yaml:"tls,omitempty"` + + // HTTP2 enables HTTP/2 support on the server. + // For the insecure (HTTP) server, this enables h2c (HTTP/2 Cleartext) support, + // which is required for gRPC proxying without TLS. + // For the secure (HTTPS) server, this adds "h2" to the TLS ALPN negotiation. + HTTP2 bool `yaml:"http2,omitempty"` } // TLS contains the information for loading a TLS certificate and key diff --git a/pkg/proxyhttp/server.go b/pkg/proxyhttp/server.go index 2982e1fc..8bcbde2f 100644 --- a/pkg/proxyhttp/server.go +++ b/pkg/proxyhttp/server.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" "golang.org/x/sync/errgroup" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" @@ -39,6 +41,11 @@ type Opts struct { // TLS is the TLS configuration for the server. TLS *options.TLS + // HTTP2 enables HTTP/2 support. + // For insecure (HTTP) servers, this enables h2c (HTTP/2 Cleartext). + // For secure (HTTPS) servers, this adds "h2" to TLS ALPN negotiation. + HTTP2 bool + // Let testing infrastructure circumvent parsing file descriptors fdFiles []*os.File } @@ -47,6 +54,7 @@ type Opts struct { func NewServer(opts Opts) (Server, error) { s := &server{ handler: opts.Handler, + http2: opts.HTTP2, } if len(opts.fdFiles) > 0 { @@ -70,6 +78,9 @@ type server struct { listener net.Listener tlsListener net.Listener + // http2 enables HTTP/2 support + http2 bool + // ensure activation.Files are called once fdFiles []*os.File } @@ -182,10 +193,15 @@ func (s *server) setupTLSListener(opts Opts) error { return nil } + nextProtos := []string{"http/1.1"} + if opts.HTTP2 { + nextProtos = []string{"h2", "http/1.1"} + } + config := &tls.Config{ MinVersion: tls.VersionTLS12, // default, override below MaxVersion: tls.VersionTLS13, - NextProtos: []string{"http/1.1"}, + NextProtos: nextProtos, } if opts.TLS == nil { return errors.New("no TLS config provided") @@ -234,7 +250,7 @@ func (s *server) Start(ctx context.Context) error { if s.listener != nil { g.Go(func() error { - if err := s.startServer(groupCtx, s.listener); err != nil { + if err := s.startServer(groupCtx, s.listener, s.http2, false); err != nil { return fmt.Errorf("error starting insecure server: %v", err) } return nil @@ -243,7 +259,7 @@ func (s *server) Start(ctx context.Context) error { if s.tlsListener != nil { g.Go(func() error { - if err := s.startServer(groupCtx, s.tlsListener); err != nil { + if err := s.startServer(groupCtx, s.tlsListener, s.http2, true); err != nil { return fmt.Errorf("error starting secure server: %v", err) } return nil @@ -256,8 +272,27 @@ func (s *server) Start(ctx context.Context) error { // startServer creates and starts a new server with the given listener. // When the given context is cancelled the server will be shutdown. // If any errors occur, only the first error will be returned. -func (s *server) startServer(ctx context.Context, listener net.Listener) error { - srv := &http.Server{Handler: s.handler, ReadHeaderTimeout: time.Minute} +// If enableHTTP2 is true and isTLS is false, the handler is wrapped +// with h2c to support HTTP/2 Cleartext (required for gRPC without TLS). +// If enableHTTP2 is true and isTLS is true, HTTP/2 is configured on the server. +func (s *server) startServer(ctx context.Context, listener net.Listener, enableHTTP2 bool, isTLS bool) error { + handler := s.handler + + if enableHTTP2 && !isTLS { + // Wrap handler with h2c for HTTP/2 Cleartext support + h2s := &http2.Server{} + handler = h2c.NewHandler(handler, h2s) + } + + srv := &http.Server{Handler: handler, ReadHeaderTimeout: time.Minute} + + if enableHTTP2 && isTLS { + // Configure HTTP/2 for TLS server + if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil { + return fmt.Errorf("error configuring HTTP/2: %v", err) + } + } + g, groupCtx := errgroup.WithContext(ctx) g.Go(func() error { diff --git a/pkg/proxyhttp/server_test.go b/pkg/proxyhttp/server_test.go index f6d12436..7ea553c1 100644 --- a/pkg/proxyhttp/server_test.go +++ b/pkg/proxyhttp/server_test.go @@ -2,6 +2,7 @@ package proxyhttp import ( "context" + "crypto/tls" "errors" "fmt" "io" @@ -9,6 +10,8 @@ import ( "net/http" "os" + "golang.org/x/net/http2" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -1329,6 +1332,136 @@ var _ = Describe("Server", func() { }) }) + Context("HTTP/2 support", func() { + var srv Server + var ctx context.Context + var cancel context.CancelFunc + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + }) + + AfterEach(func() { + cancel() + Eventually(Goroutines).ShouldNot(HaveLeaked()) + }) + + Context("with h2c (HTTP/2 Cleartext) on an insecure server", func() { + var listenAddr string + + BeforeEach(func() { + var err error + srv, err = NewServer(Opts{ + Handler: handler, + BindAddress: "127.0.0.1:0", + HTTP2: true, + }) + Expect(err).ToNot(HaveOccurred()) + + s, ok := srv.(*server) + Expect(ok).To(BeTrue()) + Expect(s.http2).To(BeTrue()) + + listenAddr = s.listener.Addr().String() + }) + + It("Serves HTTP/2 cleartext requests", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + // Use an HTTP/2 cleartext client + h2cTransport := &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return net.Dial(network, addr) + }, + } + h2cClient := &http.Client{ + Transport: h2cTransport, + } + + resp, err := h2cClient.Get(fmt.Sprintf("http://%s/", listenAddr)) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(resp.ProtoMajor).To(Equal(2)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + + // Close idle connections to prevent goroutine leaks + h2cTransport.CloseIdleConnections() + }) + + It("Still serves HTTP/1.1 requests", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + resp, err := httpGet(ctx, fmt.Sprintf("http://%s/", listenAddr)) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + }) + }) + + Context("with HTTP/2 on a TLS server", func() { + var secureListenAddr string + + BeforeEach(func() { + var err error + srv, err = NewServer(Opts{ + Handler: handler, + SecureBindAddress: "127.0.0.1:0", + TLS: &options.TLS{ + Key: &ipv4KeyDataSource, + Cert: &ipv4CertDataSource, + }, + HTTP2: true, + }) + Expect(err).ToNot(HaveOccurred()) + + s, ok := srv.(*server) + Expect(ok).To(BeTrue()) + + secureListenAddr = s.tlsListener.Addr().String() + }) + + It("Negotiates HTTP/2 via ALPN", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + // Use an HTTP/2 TLS client + h2Transport := transport.Clone() + h2Transport.ForceAttemptHTTP2 = true + + h2Client := &http.Client{Transport: h2Transport} + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/", secureListenAddr), nil) + Expect(err).ToNot(HaveOccurred()) + + resp, err := h2Client.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(resp.ProtoMajor).To(Equal(2)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + + // Close idle connections to prevent goroutine leaks + h2Transport.CloseIdleConnections() + }) + }) + }) + Context("getNetworkScheme", func() { DescribeTable("should return the scheme", func(in, expected string) { Expect(getNetworkScheme(in)).To(Equal(expected))