Skip to content

Commit

Permalink
Add an helper for proxied HTTP/2
Browse files Browse the repository at this point in the history
The standard library's http.Server supports HTTP/2, but only for
tls.Conn. This doesn't work when serving connections behind a
reverse proxy which terminates TLS and uses the PROXY protocol.

Supporting this requires some glue code, which the new helper
provides.

The example was tested with tlstunnel.

Closes: #90
  • Loading branch information
emersion committed Jul 26, 2023
1 parent 864358a commit e3a3503
Show file tree
Hide file tree
Showing 6 changed files with 368 additions and 1 deletion.
3 changes: 2 additions & 1 deletion examples/httpserver/httpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/pires/go-proxyproto"
h2proxy "github.com/pires/go-proxyproto/helper/http2"
)

// TODO: add httpclient example
Expand Down Expand Up @@ -35,5 +36,5 @@ func main() {
}
defer proxyListener.Close()

server.Serve(proxyListener)
h2proxy.NewServer(&server, nil).Serve(proxyListener)
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
module github.com/pires/go-proxyproto

go 1.18

require (
golang.org/x/net v0.12.0 // indirect
golang.org/x/text v0.11.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
176 changes: 176 additions & 0 deletions helper/http2/http2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Package http2 provides helpers for HTTP/2.
package http2

import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"sync"
"time"

"github.com/pires/go-proxyproto"
"golang.org/x/net/http2"
)

// Server is an HTTP server accepting both regular and proxied, both HTTP/1 and
// HTTP/2 connections.
//
// HTTP/2 is negotiated using TLS ALPN, either directly via a tls.Conn, either
// indirectly via the PROXY protocol.
//
// The server is closed when the http.Server is.
type Server struct {
h1 *http.Server
h2 *http2.Server
h2Err error
h1Listener h1Listener

mu sync.Mutex
closed bool
listeners map[net.Listener]struct{}
}

// NewServer creates a new HTTP server.
//
// A nil h2 is equivalent to a zero http2.Server.
func NewServer(h1 *http.Server, h2 *http2.Server) *Server {
if h2 == nil {
h2 = new(http2.Server)
}
srv := &Server{
h1: h1,
h2: h2,
h2Err: http2.ConfigureServer(h1, h2),
listeners: make(map[net.Listener]struct{}),
}
srv.h1Listener = h1Listener{newPipeListener(), srv}
go func() {
// proxyListener.Accept never fails
_ = h1.Serve(srv.h1Listener)
}()
return srv
}

func (srv *Server) errorLog() *log.Logger {
if srv.h1.ErrorLog != nil {
return srv.h1.ErrorLog
}
return log.Default()
}

// Serve accepts incoming connections on the listener l.
func (srv *Server) Serve(ln net.Listener) error {
if srv.h2Err != nil {
return srv.h2Err
}

srv.mu.Lock()
ok := !srv.closed
if ok {
srv.listeners[ln] = struct{}{}
}
srv.mu.Unlock()
if !ok {
return http.ErrServerClosed
}

defer func() {
srv.mu.Lock()
delete(srv.listeners, ln)
srv.mu.Unlock()
}()

var delay time.Duration
for {
conn, err := ln.Accept()
if ne, ok := err.(net.Error); ok && ne.Timeout() {
if delay == 0 {
delay = 5 * time.Millisecond
} else {
delay *= 2
}
if max := 1 * time.Second; delay > max {
delay = max
}
srv.errorLog().Printf("listener %q: accept error (retrying in %v): %v", ln.Addr(), delay, err)
time.Sleep(delay)
} else if err != nil {
return fmt.Errorf("failed to accept connection: %w", err)
}

delay = 0

go func() {
if err := srv.serveConn(conn); err != nil {
srv.errorLog().Printf("listener %q: %v", ln.Addr(), err)
}
}()
}
}

func (srv *Server) serveConn(conn net.Conn) error {
var proto string
switch conn := conn.(type) {
case *tls.Conn:
proto = conn.ConnectionState().NegotiatedProtocol
case *proxyproto.Conn:
if proxyHeader := conn.ProxyHeader(); proxyHeader != nil {
tlvs, err := proxyHeader.TLVs()
if err != nil {
conn.Close()
return err
}
for _, tlv := range tlvs {
if tlv.Type == proxyproto.PP2_TYPE_ALPN {
proto = string(tlv.Value)
break
}
}
}
}

switch proto {
case "h2", "h2c":
defer conn.Close()
opts := http2.ServeConnOpts{
Handler: srv.h1.Handler,
}
srv.h2.ServeConn(conn, &opts)
return nil
case "", "http/1.0", "http/1.1":
return srv.h1Listener.ServeConn(conn)
default:
conn.Close()
return fmt.Errorf("unsupported protocol %q", proto)
}
}

func (srv *Server) closeListeners() error {
srv.mu.Lock()
defer srv.mu.Unlock()

srv.closed = true

var err error
for ln := range srv.listeners {
if cerr := ln.Close(); cerr != nil {
err = cerr
}
}
return err
}

// h1Listener is used to signal back http.Server's Close and Shutdown to the
// HTTP/2 server.
type h1Listener struct {
*pipeListener
srv *Server
}

func (ln h1Listener) Close() error {
// pipeListener.Close never fails
_ = ln.pipeListener.Close()
return ln.srv.closeListeners()
}
114 changes: 114 additions & 0 deletions helper/http2/http2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package http2_test

import (
"errors"
"log"
"net"
"net/http"
"testing"

"github.com/pires/go-proxyproto"
h2proxy "github.com/pires/go-proxyproto/helper/http2"
"golang.org/x/net/http2"
)

func ExampleServer() {
ln, err := net.Listen("tcp", "localhost:80")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}

proxyLn := &proxyproto.Listener{
Listener: ln,
}

server := h2proxy.NewServer(&http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Hello world!\n"))
}),
}, nil)
if err := server.Serve(proxyLn); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

func TestServer_h1(t *testing.T) {
addr, server := newTestServer(t)
defer server.Close()

resp, err := http.Get("http://" + addr)
if err != nil {
t.Fatalf("failed to perform HTTP request: %v", err)
}
resp.Body.Close()
}

func TestServer_h2(t *testing.T) {
addr, server := newTestServer(t)
defer server.Close()

conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
defer conn.Close()

proxyHeader := proxyproto.Header{
Version: 2,
Command: proxyproto.LOCAL,
TransportProtocol: proxyproto.UNSPEC,
}
tlvs := []proxyproto.TLV{{
Type: proxyproto.PP2_TYPE_ALPN,
Value: []byte("h2"),
}}
if err := proxyHeader.SetTLVs(tlvs); err != nil {
t.Fatalf("failed to set TLVs: %v", err)
}
if _, err := proxyHeader.WriteTo(conn); err != nil {
t.Fatalf("failed to write PROXY header: %v", err)
}

h2Conn, err := new(http2.Transport).NewClientConn(conn)
if err != nil {
t.Fatalf("failed to create HTTP connection: %v", err)
}

req, err := http.NewRequest(http.MethodGet, "http://"+addr, nil)
if err != nil {
t.Fatalf("failed to create HTTP request: %v", err)
}

resp, err := h2Conn.RoundTrip(req)
if err != nil {
t.Fatalf("failed to perform HTTP request: %v", err)
}
resp.Body.Close()
}

func newTestServer(t *testing.T) (addr string, server *http.Server) {
ln, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}

server = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
}),
}

h2Server := h2proxy.NewServer(server, nil)
done := make(chan error, 1)
go func() {
done <- h2Server.Serve(&proxyproto.Listener{Listener: ln})
}()

t.Cleanup(func() {
err := <-done
if err != nil && !errors.Is(err, net.ErrClosed) {
t.Fatalf("failed to serve: %v", err)
}
})

return ln.Addr().String(), server
}
67 changes: 67 additions & 0 deletions helper/http2/listener.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package http2

import (
"net"
"sync"
)

// pipeListener is a hack to workaround the lack of http.Server.ServeConn.
// See: https://github.com/golang/go/issues/36673
type pipeListener struct {
ch chan net.Conn
closed bool
mu sync.Mutex
}

func newPipeListener() *pipeListener {
return &pipeListener{
ch: make(chan net.Conn, 64),
}
}

func (ln *pipeListener) Accept() (net.Conn, error) {
conn, ok := <-ln.ch
if !ok {
return nil, net.ErrClosed
}
return conn, nil
}

func (ln *pipeListener) Close() error {
ln.mu.Lock()
defer ln.mu.Unlock()

if ln.closed {
return net.ErrClosed
}
ln.closed = true
close(ln.ch)
return nil
}

// ServeConn enqueues a new connection. The connection will be returned in the
// next Accept call.
func (ln *pipeListener) ServeConn(conn net.Conn) error {
ln.mu.Lock()
defer ln.mu.Unlock()

if ln.closed {
return net.ErrClosed
}
ln.ch <- conn
return nil
}

func (ln *pipeListener) Addr() net.Addr {
return pipeAddr{}
}

type pipeAddr struct{}

func (pipeAddr) Network() string {
return "pipe"
}

func (pipeAddr) String() string {
return "pipe"
}

0 comments on commit e3a3503

Please sign in to comment.