From ffec71ee20299755fdfef9437b2dc48920792fcb Mon Sep 17 00:00:00 2001 From: Nathan Johnson Date: Tue, 11 Aug 2020 21:10:57 -0500 Subject: [PATCH] https+tcp+sni listener support Adds support for the https+tcp+sni listener. Any routes that are marked with proto=tcp or have a scheme tcp:// will be matched for TCP steering. Failing any matches there, fallthrough will be to https matching. This resolves: #783. clean up error handling on 'use of closed network connection' case fix makefile tag finding stuff fix logic for https+tcp+sni matching so that only explicit proto=tcp matches Adding an integration test make sure proxy is listening before starting test add buffered err chan to Close() per dcarbone add 1.14 and 1.15 to travis tests --- .travis.yml | 7 + Dockerfile | 8 +- Makefile | 6 +- config/load.go | 6 +- config/load_test.go | 4 +- go.mod | 1 + go.sum | 2 + main.go | 39 +- proxy/http_integration_test.go | 11 + proxy/inetaf_tcpproxy.go | 104 ++++ proxy/inetaf_tcpproxy_integration_test.go | 177 +++++++ proxy/internal/testcert.go | 37 ++ proxy/serve.go | 74 ++- vendor/github.com/inetaf/tcpproxy/.gitignore | 2 + vendor/github.com/inetaf/tcpproxy/.travis.yml | 50 ++ .../inetaf/tcpproxy/CONTRIBUTING.md | 8 + vendor/github.com/inetaf/tcpproxy/LICENSE | 202 ++++++++ vendor/github.com/inetaf/tcpproxy/README.md | 5 + vendor/github.com/inetaf/tcpproxy/http.go | 125 +++++ vendor/github.com/inetaf/tcpproxy/listener.go | 108 ++++ vendor/github.com/inetaf/tcpproxy/sni.go | 192 +++++++ vendor/github.com/inetaf/tcpproxy/tcpproxy.go | 474 ++++++++++++++++++ vendor/modules.txt | 2 + 23 files changed, 1628 insertions(+), 16 deletions(-) create mode 100644 proxy/inetaf_tcpproxy.go create mode 100644 proxy/inetaf_tcpproxy_integration_test.go create mode 100644 vendor/github.com/inetaf/tcpproxy/.gitignore create mode 100644 vendor/github.com/inetaf/tcpproxy/.travis.yml create mode 100644 vendor/github.com/inetaf/tcpproxy/CONTRIBUTING.md create mode 100644 vendor/github.com/inetaf/tcpproxy/LICENSE create mode 100644 vendor/github.com/inetaf/tcpproxy/README.md create mode 100644 vendor/github.com/inetaf/tcpproxy/http.go create mode 100644 vendor/github.com/inetaf/tcpproxy/listener.go create mode 100644 vendor/github.com/inetaf/tcpproxy/sni.go create mode 100644 vendor/github.com/inetaf/tcpproxy/tcpproxy.go diff --git a/.travis.yml b/.travis.yml index 5985e6af7..b92289e82 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,15 @@ language: go go: - 1.13 + - 1.14 + - 1.15 - master +addons: + hosts: + - example.com + - example2.com + script: make travis jobs: diff --git a/Dockerfile b/Dockerfile index f79cc89e3..01f7086f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM golang:1.13.4-alpine AS build +FROM golang:1.14.7-alpine AS build -ARG consul_version=1.6.1 +ARG consul_version=1.8.2 ADD https://releases.hashicorp.com/consul/${consul_version}/consul_${consul_version}_linux_amd64.zip /usr/local/bin RUN cd /usr/local/bin && unzip consul_${consul_version}_linux_amd64.zip -ARG vault_version=1.2.3 +ARG vault_version=1.5.0 ADD https://releases.hashicorp.com/vault/${vault_version}/vault_${vault_version}_linux_amd64.zip /usr/local/bin RUN cd /usr/local/bin && unzip vault_${vault_version}_linux_amd64.zip @@ -13,7 +13,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test -mod=vendor -trimpath -ldflags "-s -w" ./... RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -trimpath -ldflags "-s -w" -FROM alpine:3.10 +FROM alpine:3.12 RUN apk update && apk add --no-cache ca-certificates COPY --from=build /src/fabio /usr/bin ADD fabio.properties /etc/fabio/fabio.properties diff --git a/Makefile b/Makefile index bd20dd96a..a5be1506b 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ # CUR_TAG is the last git tag plus the delta from the current commit to the tag # e.g. v1.5.5--g -CUR_TAG ?= $(shell git describe) +CUR_TAG ?= $(shell git describe --tags --first-parent) # LAST_TAG is the last git tag # e.g. v1.5.5 -LAST_TAG ?= $(shell git describe --abbrev=0) +LAST_TAG ?= $(shell git describe --tags --first-parent --abbrev=0) # VERSION is the last git tag without the 'v' # e.g. 1.5.5 -VERSION ?= $(shell git describe --abbrev=0 | cut -c 2-) +VERSION ?= $(shell git describe --tags --first-parent --abbrev=0 | cut -c 2-) # GOFLAGS is the flags for the go compiler. GOFLAGS ?= -mod=vendor -ldflags "-X main.version=$(CUR_TAG)" diff --git a/config/load.go b/config/load.go index d816cf715..60ce836b1 100644 --- a/config/load.go +++ b/config/load.go @@ -385,7 +385,7 @@ func parseListen(cfg map[string]string, cs map[string]CertSource, readTimeout, w case "proto": l.Proto = v switch l.Proto { - case "tcp", "tcp+sni", "tcp-dynamic", "http", "https", "grpc", "grpcs": + case "tcp", "tcp+sni", "tcp-dynamic", "http", "https", "grpc", "grpcs", "https+tcp+sni": // ok default: return Listen{}, fmt.Errorf("unknown protocol %q", v) @@ -461,8 +461,8 @@ func parseListen(cfg map[string]string, cs map[string]CertSource, readTimeout, w if l.Addr == "" { return Listen{}, fmt.Errorf("need listening host:port") } - if csName != "" && l.Proto != "https" && l.Proto != "tcp" && l.Proto != "tcp-dynamic" && l.Proto != "grpcs" { - return Listen{}, fmt.Errorf("cert source requires proto 'https', 'tcp', 'tcp-dynamic' or 'grpcs'") + if csName != "" && l.Proto != "https" && l.Proto != "tcp" && l.Proto != "tcp-dynamic" && l.Proto != "grpcs" && l.Proto != "https+tcp+sni" { + return Listen{}, fmt.Errorf("cert source requires proto 'https', 'tcp', 'tcp-dynamic', 'https+tcp+sni', or 'grpcs'") } if csName == "" && l.Proto == "https" { return Listen{}, fmt.Errorf("proto 'https' requires cert source") diff --git a/config/load_test.go b/config/load_test.go index 3fab9c9a3..371b73f85 100644 --- a/config/load_test.go +++ b/config/load_test.go @@ -1058,13 +1058,13 @@ func TestLoad(t *testing.T) { desc: "-proxy.addr with cert source and proto 'http' requires proto 'https', 'tcp', or 'grpcs'", args: []string{"-proxy.addr", ":5555;cs=name;proto=http", "-proxy.cs", "cs=name;type=path;cert=value"}, cfg: func(cfg *Config) *Config { return nil }, - err: errors.New("cert source requires proto 'https', 'tcp', 'tcp-dynamic' or 'grpcs'"), + err: errors.New("cert source requires proto 'https', 'tcp', 'tcp-dynamic', 'https+tcp+sni', or 'grpcs'"), }, { desc: "-proxy.addr with cert source and proto 'tcp+sni' requires proto 'https', 'tcp' or 'grpcs'", args: []string{"-proxy.addr", ":5555;cs=name;proto=tcp+sni", "-proxy.cs", "cs=name;type=path;cert=value"}, cfg: func(cfg *Config) *Config { return nil }, - err: errors.New("cert source requires proto 'https', 'tcp', 'tcp-dynamic' or 'grpcs'"), + err: errors.New("cert source requires proto 'https', 'tcp', 'tcp-dynamic', 'https+tcp+sni', or 'grpcs'"), }, { desc: "-proxy.noroutestatus too small", diff --git a/go.mod b/go.mod index 8d4689001..73e873b12 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/hashicorp/serf v0.7.0 // indirect github.com/hashicorp/vault v0.6.0 github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect + github.com/inetaf/tcpproxy v0.0.0-20200125044825-b6bb9b5b8252 github.com/jonboulle/clockwork v0.1.0 // indirect github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect github.com/kr/pretty v0.1.0 // indirect diff --git a/go.sum b/go.sum index 6274b29cd..58bcf22b7 100644 --- a/go.sum +++ b/go.sum @@ -125,6 +125,8 @@ github.com/hashicorp/vault v0.6.0 h1:wc/KN2SZ76imak5yOF4Utm6p0e4BNtSKLhWlYrzX+vQ github.com/hashicorp/vault v0.6.0/go.mod h1:KfSyffbKxoVyspOdlaGVjIuwLobi07qD1bAbosPMpP0= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d h1:kJCB4vdITiW1eC1vq2e6IsrXKrZit1bv/TDYFGMp4BQ= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/inetaf/tcpproxy v0.0.0-20200125044825-b6bb9b5b8252 h1:jeqlfkFa5h+Ak/I33QpU4p01nFhw0G5IFm/Rsenne2Y= +github.com/inetaf/tcpproxy v0.0.0-20200125044825-b6bb9b5b8252/go.mod h1:R6mExYS3O0XXjOZye3GtXfbuGF4hWQnF45CFWoj7O6g= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= diff --git a/main.go b/main.go index ae005dbf1..e37fb9b30 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "crypto/tls" "encoding/json" "fmt" @@ -49,7 +50,7 @@ import ( // It is also set by the linker when fabio // is built via the Makefile or the build/docker.sh // script to ensure the correct version number -var version = "1.5.12" +var version = "1.5.14" var shuttingDown int32 @@ -260,6 +261,28 @@ func lookupHostFn(cfg *config.Config) func(string) *route.Target { } } +// Returns a matcher function compatible with tcpproxy Matcher from github.com/inetaf/tcpproxy +func lookupHostMatcher(cfg *config.Config) func(context.Context, string) bool { + pick := route.Picker[cfg.Proxy.Strategy] + return func(ctx context.Context, host string) bool { + t := route.GetTable().LookupHost(host, pick) + if t == nil { + return false + } + + // Make sure this is supposed to be a tcp proxy. + // opts proto= overrides scheme if present. + var ( + ok bool + proto string + ) + if proto, ok = t.Opts["proto"]; !ok && t.URL != nil { + proto = t.URL.Scheme + } + return "tcp" == proto + } +} + func makeTLSConfig(l config.Listen) (*tls.Config, error) { if l.CertSource.Name == "" { return nil, nil @@ -398,6 +421,20 @@ func startServers(cfg *config.Config) { } } }() + case "https+tcp+sni": + go func() { + hp := newHTTPProxy(cfg) + tp := &tcp.SNIProxy{ + DialTimeout: cfg.Proxy.DialTimeout, + Lookup: lookupHostFn(cfg), + Conn: metrics.DefaultRegistry.GetCounter("tcp_sni.conn"), + ConnFail: metrics.DefaultRegistry.GetCounter("tcp_sni.connfail"), + Noroute: metrics.DefaultRegistry.GetCounter("tcp_sni.noroute"), + } + if err := proxy.ListenAndServeHTTPSTCPSNI(l, hp, tp, tlscfg, lookupHostMatcher(cfg)); err != nil { + exit.Fatal("[FATAL] ", err) + } + }() default: exit.Fatal("[FATAL] Invalid protocol ", l.Proto) } diff --git a/proxy/http_integration_test.go b/proxy/http_integration_test.go index 9489cf6b7..ecf7266a6 100644 --- a/proxy/http_integration_test.go +++ b/proxy/http_integration_test.go @@ -644,6 +644,9 @@ func tlsClientConfig() *tls.Config { if ok := rootCAs.AppendCertsFromPEM(internal.LocalhostCert); !ok { panic("could not parse cert") } + if ok := rootCAs.AppendCertsFromPEM(internal.LocalhostCert2); !ok { + panic("could not parse cert") + } return &tls.Config{RootCAs: rootCAs} } @@ -655,6 +658,14 @@ func tlsServerConfig() *tls.Config { return &tls.Config{Certificates: []tls.Certificate{cert}} } +func tlsServerConfig2() *tls.Config { + cert, err := tls.X509KeyPair(internal.LocalhostCert2, internal.LocalhostKey2) + if err != nil { + panic("failed to set cert") + } + return &tls.Config{Certificates: []tls.Certificate{cert}} +} + func mustParse(rawurl string) *url.URL { u, err := url.Parse(rawurl) if err != nil { diff --git a/proxy/inetaf_tcpproxy.go b/proxy/inetaf_tcpproxy.go new file mode 100644 index 000000000..fecbe6e4f --- /dev/null +++ b/proxy/inetaf_tcpproxy.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + + "github.com/inetaf/tcpproxy" +) + +type childProxy struct { + l net.Listener + s Server +} + +type InetAfTCPProxyServer struct { + Proxy *tcpproxy.Proxy + children []*childProxy +} + +// Close - implements Server - is this even called? +func (tps *InetAfTCPProxyServer) Close() error { + _ = tps.Proxy.Close() + firstErr := tps.Proxy.Wait() + errChan := make(chan error, len(tps.children)) + for _, sl := range tps.children { + go func(sl *childProxy) { + errChan <- sl.s.Close() + }(sl) + } + for range tps.children { + err := <-errChan + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + if firstErr == nil { + firstErr = err + } + if err != nil { + log.Printf("[ERROR] %s", err) + } + } + return firstErr + +} + +// Serve - implements server. The listener is ignored, but it +// calls serve on the children +func (tps *InetAfTCPProxyServer) Serve(_ net.Listener) error { + if len(tps.children) == 0 { + return fmt.Errorf("no children defined for listener") + } + errChan := make(chan error, len(tps.children)) + for _, sl := range tps.children { + go func(sl *childProxy) { + errChan <- sl.s.Serve(sl.l) + }(sl) + } + firstErr := tps.Proxy.Wait() + for range tps.children { + err := <-errChan + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + if firstErr == nil { + firstErr = err + } + if err != nil { + log.Print("[FATAL] ", err) + } + } + return firstErr +} + +// ServeLater - l is really only for listeners that are +// tcpproxy.TargetListener or a derivative. Don't call after +// Serve() is called. +func (tps *InetAfTCPProxyServer) ServeLater(l net.Listener, s Server) { + tps.children = append(tps.children, &childProxy{l, s}) +} + +func (tps *InetAfTCPProxyServer) Shutdown(ctx context.Context) error { + _ = tps.Proxy.Close() // always returns nil error anyway + firstErr := tps.Proxy.Wait() // wait for outer listener to close before telling the childProxy + errChan := make(chan error, len(tps.children)) + for _, sl := range tps.children { + go func(sl *childProxy) { + errChan <- sl.s.Shutdown(ctx) + }(sl) + } + for range tps.children { + err := <-errChan + if firstErr == nil { + firstErr = err + } + if err != nil { + log.Print("[ERROR] ", err) + } + } + return firstErr +} diff --git a/proxy/inetaf_tcpproxy_integration_test.go b/proxy/inetaf_tcpproxy_integration_test.go new file mode 100644 index 000000000..8b3e1a721 --- /dev/null +++ b/proxy/inetaf_tcpproxy_integration_test.go @@ -0,0 +1,177 @@ +package proxy + +import ( + "bytes" + "context" + "crypto/x509" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/fabiolb/fabio/config" + "github.com/fabiolb/fabio/proxy/tcp" + "github.com/fabiolb/fabio/proxy/tcp/tcptest" + "github.com/fabiolb/fabio/route" +) + +// to run this test, add the following to /etc/hosts: +// 127.0.0.1 example.com +// 127.0.0.1 example2.com +// and then set the environment FABIO_IHAVEHOSTENTRIES=true +// This also runs in TRAVIS by default, since .travis.yml adds these aliases. +func TestProxyTCPAndHTTPS(t *testing.T) { + if os.Getenv("TRAVIS") != "true" && os.Getenv("FABIO_IHAVEHOSTENTRIES") != "true" { + t.Skip("skipping because env FABIO_IHAVEHOSTENTRIES is not set to true") + } + + tlsCfg1 := tlsServerConfig() + tlsCfg2 := tlsServerConfig2() + tcpServer := httptest.NewUnstartedServer(okHandler) + tcpServer.TLS = tlsCfg2 + tcpServer.StartTLS() + defer tcpServer.Close() + + httpPayload := []byte(`OK HTTP`) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(httpPayload) + })) + defer httpServer.Close() + + tpl := `route add srv / %s opts "proto=https" +route add tcproute example2.com/ tcp://%s opts "proto=tcp"` + + table, _ := route.NewTable(bytes.NewBufferString(fmt.Sprintf(tpl, httpServer.URL, tcpServer.Listener.Addr()))) + hp := &HTTPProxy{ + Lookup: func(r *http.Request) *route.Target { + return table.Lookup(r, "", route.Picker["rr"], route.Matcher["prefix"], globCache, globEnabled) + }, + } + + tp := &tcp.SNIProxy{ + Lookup: func(h string) *route.Target { + return table.LookupHost(h, route.Picker["rr"]) + }, + } + m := func(_ context.Context, h string) bool { + // TODO - matcher needs to move out of main + // so we can test it more easily. Probably + // the other functions too. + t := table.LookupHost(h, route.Picker["rr"]) + if t == nil { + return false + } + // Make sure this is supposed to be a tcp proxy. + // opts proto= overrides scheme if present. + var ( + ok bool + proto string + ) + if proto, ok = t.Opts["proto"]; !ok && t.URL != nil { + proto = t.URL.Scheme + } + return "tcp" == proto + } + + // get an unused port for use for the proxy. the rest of the tests just + // pick a high-numbered port, but this should be safer, if ugly. could + // also just fire up a listener with 0 as the port and let the stack + // pick one - which is what httptest does - but this is less lines and + // I'm lazy. --NJ + tmp := httptest.NewServer(okHandler) + proxyAddr := tmp.Listener.Addr().String() + tmp.Close() + _, port, err := net.SplitHostPort(proxyAddr) + if err != nil { + t.Fatalf("error determining port from addr: %s", err) + } + + l := config.Listen{Addr: proxyAddr} + go func() { + err := ListenAndServeHTTPSTCPSNI(l, hp, tp, tlsCfg1, m) + if err != nil { + t.Logf("error shutting down: %s", err) + } + }() + defer Close() + // retry until listener is responding. + d, err := tcptest.NewRetryDialer().Dial("tcp", proxyAddr) + if err != nil { + t.Fatalf("error connecting to proxy: %s", err) + } + d.Close() + // At this point, the proxy should up and listening and will do + // tcp proxy to https://example2.com, and terminate TLS for + // https://example.com + + c := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsClientConfig(), + DisableKeepAlives: true, + MaxConnsPerHost: -1, + }, + } + + // make sure tcp steering happens for https://example2.com/ + // and https proxying happens for https://example.com/ + for _, data := range []struct { + name string + u string + h string + body []byte + }{ { + name: "https proxy for example.com", + u: "https://example.com:" + port, + h: "example.com", + body: httpPayload, + }, { + name: "tcp proxy for example2.com serving https", + u: "https://example2.com:" + port, + h: "example2.com", + body: []byte(`OK`), + }} { + t.Run(data.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, data.u, nil) + if err != nil { + t.Fatalf("unexpected error creating req: %s", err) + } + resp, err := c.Do(req) + if err != nil { + t.Errorf("error on request %s", err) + return + } + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Errorf("error reading body: %s", err) + return + } + if !bytes.Equal(body, data.body) { + t.Error("http body not equal") + } + if len(resp.TLS.PeerCertificates) != 1 { + t.Errorf("unexpected peer certs") + return + } + if !foundDNSName(resp.TLS.PeerCertificates[0], data.h) { + t.Error("wrong certificate returned") + } + }) + } + +} + +func foundDNSName(crt *x509.Certificate, dnsName string) bool { + found := false + for _, dname := range crt.DNSNames { + if dname == dnsName { + found = true + break + } + } + return found +} diff --git a/proxy/internal/testcert.go b/proxy/internal/testcert.go index bb6a9ca73..9344f053d 100644 --- a/proxy/internal/testcert.go +++ b/proxy/internal/testcert.go @@ -35,3 +35,40 @@ ZYz4pNoKEVxbAgKdy1XzsGTNN/gN+GO1+JJYKK23RRidNkDrNe3RIAhH3inBKRUf 4/AnjFkqwDkDRTh0htkCQQDfrRZr+gazwzDTSp23+l6MEbqBbc+TTC3c40zpNj4a egxjd5+SkMj6zXEJxAOgo+LmQDGWsu1YQ+XXL87VPwIP -----END RSA PRIVATE KEY-----`) + +// LocalhostCert2 is a PEM-encoded TLS cert with SAN IPs +// "127.0.0.1" and "[::1]", expiring at Jan 29 16:00:00 2084 GMT. +// generated from src/crypto/tls: +// go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example2.com --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h +var LocalhostCert2 = []byte(`-----BEGIN CERTIFICATE----- +MIICNTCCAZ6gAwIBAgIQeD/ltjjdLHO9L2c5eMG5rDANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDzwrR53c4RyXpitbeRD9CY6PRhqYgnrCOBy0GUuGs5hJgMqSMXuIH4Vs4h +lOH19hb9o733O+qJM6s4D8GNfz2LC/SC/DOHqXv0DeB6lGJ47I2Wv8569uFjNh3K +pi5yYlAqNdjQ1TYjUZmDytiQxp8eCLCKGpbWvjWzop50GTefqwIDAQABo4GJMIGG +MA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBSLtvT+Rtv9N+Tw1ZbXU+jSxYqYxTAvBgNVHREEKDAm +ggxleGFtcGxlMi5jb22HBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcN +AQELBQADgYEAkO5LWq8SHx8dUgMDryCyrJamsFT3Z/Lt1zMfJfNdTRSvsg7Fy3XR +IOtxPqh2gT7OsSeU6fjjbDUTuGmH/BckwZTFMkRho/WaEgbP6XWWjkl+6euJBvtG +lBFElB/HVPa5puggihR9H1pE3s+SdtslwfOf8XsUA4xlcrhpU5kuMa4= +-----END CERTIFICATE-----`) + +// LocalhostKey2 is the private key for LocalhostCert2. +var LocalhostKey2 = []byte(`-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAPPCtHndzhHJemK1 +t5EP0Jjo9GGpiCesI4HLQZS4azmEmAypIxe4gfhWziGU4fX2Fv2jvfc76okzqzgP +wY1/PYsL9IL8M4epe/QN4HqUYnjsjZa/znr24WM2HcqmLnJiUCo12NDVNiNRmYPK +2JDGnx4IsIoalta+NbOinnQZN5+rAgMBAAECgYEAlRnYuN5SiRC7WpuacBHDX3TG +3uILFXE2utKwB58Sfzk6pCvk+kJyxYubRHFEEeX4RCcfMJYmrMu9BGqm0r0sz6nb +CSZk2Crn7eKgLK01+t2K+2s1R2oNB/fxkmxVUTbxiZ+Bt7xsAvFnnQRl06r9NNYo +XqAadfRFGlmMkSEAbbkCQQD9feneVqIdPPGQOadUq8VYY/M8onMSG72O0qr6Dx8X +j3hXf5D91pvXos+h3TwSICQ354BcakI4VK/EXxfmNUSXAkEA9iwkdXYQXkjgqpwH +3jMxG3DLAl9aHnykFSm8G2vQj2427ePnLppHclXKTPfu3E0qUNT2WgOK64N3qC1F +NkZ8DQJBAPSOlKFfnVlt4XOOW8QRT/wduZ4G79NJlhCDaFaFXi7ByI1J0h1C/ekE +9yInKXwnLCoPG0SNc0ObWFOwloMPYxMCQEIH5yOmro9Lxw+cWLPuUU7F+35Aa2Dg +F/chQbatPb0rWAqJZhpnAaEWh/QLUQPAowgZh5bvelTf57mxou4DDAUCQHynSBDh +GPDiBeTKX+VF50yHR8P6YrbeLIwasw19HA2BVhKKP05ZbQnWtN/ekhhBbI5fTwn0 +njeVQ3HbrfnY1Ik= +-----END PRIVATE KEY-----`) diff --git a/proxy/serve.go b/proxy/serve.go index 0929ba94a..54a98a73b 100644 --- a/proxy/serve.go +++ b/proxy/serve.go @@ -3,6 +3,7 @@ package proxy import ( "context" "crypto/tls" + "errors" "net" "net/http" "sync" @@ -12,6 +13,9 @@ import ( "github.com/fabiolb/fabio/config" "github.com/fabiolb/fabio/proxy/tcp" + + "github.com/armon/go-proxyproto" + "github.com/inetaf/tcpproxy" ) type Server interface { @@ -73,6 +77,62 @@ func ListenAndServeHTTP(l config.Listen, h http.Handler, cfg *tls.Config) error return serve(ln, srv) } +func ListenAndServeHTTPSTCPSNI(l config.Listen, h http.Handler, p tcp.Handler, cfg *tls.Config, m tcpproxy.Matcher) error { + // we only want proxy proto enabled on tcp proxies + pxyProto := l.ProxyProto + l.ProxyProto = false + tp := &tcpproxy.Proxy{ + ListenFunc: func(net, laddr string) (net.Listener, error) { + // cfg is nil here so it's not terminating TLS (yet) + return ListenTCP(l, nil) + }, + } + + // This inspects SNI for matches. If this succeeds then we Proxy tcp. + tcpSNIListener := &tcpproxy.TargetListener{Address: l.Addr} + tp.AddSNIMatchRoute(l.Addr, m, tcpSNIListener) + + // Fallthrough to https + httpsListener := &tcpproxy.TargetListener{Address: l.Addr} + tp.AddRoute(l.Addr, httpsListener) + + // Start the listener + err := tp.Start() + if err != nil { + return err + } + + tps := &InetAfTCPProxyServer{Proxy: tp} + var tln net.Listener = tcpSNIListener + // enable proxy protocol on the tcp side if configured to do so + if pxyProto { + tln = &proxyproto.Listener{ + Listener: tln, + ProxyHeaderTimeout: l.ProxyHeaderTimeout, + } + } + tps.ServeLater(tln, &tcp.Server{ + Addr: l.Addr, + Handler: p, + ReadTimeout: l.ReadTimeout, + WriteTimeout: l.WriteTimeout, + }) + + // wrap TargetListener in a tls terminating version for HTTPS + tps.ServeLater(tls.NewListener(httpsListener, cfg), &http.Server{ + Addr: l.Addr, + Handler: h, + ReadTimeout: l.ReadTimeout, + WriteTimeout: l.WriteTimeout, + IdleTimeout: l.IdleTimeout, + TLSConfig: cfg, + }) + + // tcpproxy creates its own listener from the configuration above so we can + // safely pass nil here. + return serve(nil, tps) +} + func ListenAndServeGRPC(l config.Listen, opts []grpc.ServerOption, cfg *tls.Config) error { ln, err := ListenTCP(l, cfg) if err != nil { @@ -104,8 +164,16 @@ func serve(ln net.Listener, srv Server) error { mu.Lock() servers = append(servers, srv) mu.Unlock() - if err := srv.Serve(ln); err != http.ErrServerClosed { - return err + err := srv.Serve(ln) + if err != nil { + var opErr *net.OpError + if errors.Is(err, http.ErrServerClosed) { + err = nil + } else if errors.As(err, &opErr) { + if opErr.Err != nil && opErr.Err.Error() == "use of closed network connection" { + err = nil + } + } } - return nil + return err } diff --git a/vendor/github.com/inetaf/tcpproxy/.gitignore b/vendor/github.com/inetaf/tcpproxy/.gitignore new file mode 100644 index 000000000..ab78466b9 --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/.gitignore @@ -0,0 +1,2 @@ +tlsrouter +tlsrouter.test diff --git a/vendor/github.com/inetaf/tcpproxy/.travis.yml b/vendor/github.com/inetaf/tcpproxy/.travis.yml new file mode 100644 index 000000000..56aafcbe7 --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/.travis.yml @@ -0,0 +1,50 @@ +language: go +go: +- "1.12" +- "1.13" +- tip +os: +- linux +install: +- go get github.com/golang/lint/golint +before_script: +script: +- go get -t ./... +- go build ./... +- go test ./... +- go vet ./... +- golint -set_exit_status . + +jobs: + include: + - stage: deploy + go: "1.13" + install: + - gem install fpm + script: + - go build ./cmd/tlsrouter + - fpm -s dir -t deb -n tlsrouter -v $(date '+%Y%m%d%H%M%S') + --license Apache2 + --vendor "David Anderson " + --maintainer "David Anderson " + --description "TLS SNI router" + --url "https://github.com/inetaf/tcpproxy/tree/master/cmd/tlsrouter" + ./tlsrouter=/usr/bin/tlsrouter + ./systemd/tlsrouter.service=/lib/systemd/system/tlsrouter.service + deploy: + - provider: packagecloud + repository: tlsrouter + username: danderson + dist: debian/stretch + skip_cleanup: true + on: + branch: master + token: + secure: gNU3o70EU4oYeIS6pr0K5oLMGqqxrcf41EOv6c/YoHPVdV6Cx4j9NW0/ISgu6a1/Xf2NgWKT5BWwLpAuhmGdALuOz1Ah//YBWd9N8mGHGaC6RpOPDU8/9NkQdBEmjEH9sgX4PNOh1KQ7d7O0OH0g8RqJlJa0MkUYbTtN6KJ29oiUXxKmZM4D/iWB8VonKOnrtx1NwQL8jL8imZyEV/1fknhDwumz2iKeU1le4Neq9zkxwICMLUonmgphlrp+SDb1EOoHxT6cn51bqBQtQUplfC4dN4OQU/CPqE9E1N1noibvN29YA93qfcrjD3I95KT9wzq+3B6he33+kb0Gz+Cj5ypGy4P85l7TuX4CtQg0U3NAlJCk32IfsdjK+o47pdmADij9IIb9yKt+g99FMERkJJY5EInqEsxHlW/vNF5OqQCmpiHstZL4R2XaHEsWh6j77npnjjC1Aea8xZTWr8PTsbSzVkbG7bTmFpZoPH8eEmr4GNuw5gnbi6D1AJDjcA+UdY9s5qZNpzuWOqfhOFxL+zUW+8sHBvcoFw3R+pwHECs2LCL1c0xAC1LtNUnmW/gnwHavtvKkzErjR1P8Xl7obCbeChJjp+b/BcFYlNACldZcuzBAPyPwIdlWVyUonL4bm63upfMEEShiAIDDJ21y7fjsQK7CfPA7g25bpyo+hV8= + - provider: script + on: + branch: master + script: go run scripts/prune_old_versions.go -user=danderson -repo=tlsrouter -distro=debian -version=stretch -package=tlsrouter -arch=amd64 -limit=2 + env: + # Packagecloud API key, for prune_old_versions.go + - secure: "SRcNwt+45QyPS1w9aGxMg9905Y6d9w4mBM29G6iTTnUB5nD7cAk4m+tf834knGSobVXlWcRnTDW8zrHdQ9yX22dPqCpH5qE+qzTmIvxRHrVJRMmPeYvligJ/9jYfHgQbvuRT8cUpIcpCQAla6rw8nXfKTOE3h8XqMP2hdc3DTVOu2HCfKCNco1tJ7is+AIAnFV2Wpsbb3ZsdKFvHvi2RKUfFaX61J1GNt2/XJIlZs8jC6Y1IAC+ftjql9UsAE/WjZ9fL0Ww1b9/LBIIGHXWI3HpVv9WvlhhIxIlJgOVjmU2lbSuj2w/EBDJ9cd1Qe+wJkT3yKzE1NRsNScVjGg+Ku5igJu/XXuaHkIX01+15BqgPduBYRL0atiNQDhqgBiSyVhXZBX9vsgsp0bgpKaBSF++CV18Q9dara8aljqqS33M3imO3I8JmXU10944QA9Wvu7pCYuIzXxhINcDXRvqxBqz5LnFJGwnGqngTrOCSVS2xn7Y+sjmhe1n5cPCEISlozfa9mPYPvMPp8zg3TbATOOM8CVfcpaNscLqa/+SExN3zMwSanjNKrBgoaQcBzGW5mIgSPxhXkWikBgapiEN7+2Y032Lhqdb9dYjH+EuwcnofspDjjMabWxnuJaln+E3/9vZi2ooQrBEtvymUTy4VMSnqwIX5bU7nPdIuQycdWhk=" diff --git a/vendor/github.com/inetaf/tcpproxy/CONTRIBUTING.md b/vendor/github.com/inetaf/tcpproxy/CONTRIBUTING.md new file mode 100644 index 000000000..188ad870f --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/CONTRIBUTING.md @@ -0,0 +1,8 @@ +Contributions are welcome by pull request. + +You need to sign the Google Contributor License Agreement before your +contributions can be accepted. You can find the individual and organization +level CLAs here: + +Individual: https://cla.developers.google.com/about/google-individual +Organization: https://cla.developers.google.com/about/google-corporate diff --git a/vendor/github.com/inetaf/tcpproxy/LICENSE b/vendor/github.com/inetaf/tcpproxy/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/inetaf/tcpproxy/README.md b/vendor/github.com/inetaf/tcpproxy/README.md new file mode 100644 index 000000000..f526c213a --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/README.md @@ -0,0 +1,5 @@ +# tcpproxy + +For library usage, see https://godoc.org/inet.af/tcpproxy/ + +For CLI usage, see https://github.com/inetaf/tcpproxy/blob/master/cmd/tlsrouter/README.md diff --git a/vendor/github.com/inetaf/tcpproxy/http.go b/vendor/github.com/inetaf/tcpproxy/http.go new file mode 100644 index 000000000..d28c66fa8 --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/http.go @@ -0,0 +1,125 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tcpproxy + +import ( + "bufio" + "bytes" + "context" + "net/http" +) + +// AddHTTPHostRoute appends a route to the ipPort listener that +// routes to dest if the incoming HTTP/1.x Host header name is +// httpHost. If it doesn't match, rule processing continues for any +// additional routes on ipPort. +// +// The ipPort is any valid net.Listen TCP address. +func (p *Proxy) AddHTTPHostRoute(ipPort, httpHost string, dest Target) { + p.AddHTTPHostMatchRoute(ipPort, equals(httpHost), dest) +} + +// AddHTTPHostMatchRoute appends a route to the ipPort listener that +// routes to dest if the incoming HTTP/1.x Host header name is +// accepted by matcher. If it doesn't match, rule processing continues +// for any additional routes on ipPort. +// +// The ipPort is any valid net.Listen TCP address. +func (p *Proxy) AddHTTPHostMatchRoute(ipPort string, match Matcher, dest Target) { + p.addRoute(ipPort, httpHostMatch{match, dest}) +} + +type httpHostMatch struct { + matcher Matcher + target Target +} + +func (m httpHostMatch) match(br *bufio.Reader) (Target, string) { + hh := httpHostHeader(br) + if m.matcher(context.TODO(), hh) { + return m.target, hh + } + return nil, "" +} + +// httpHostHeader returns the HTTP Host header from br without +// consuming any of its bytes. It returns "" if it can't find one. +func httpHostHeader(br *bufio.Reader) string { + const maxPeek = 4 << 10 + peekSize := 0 + for { + peekSize++ + if peekSize > maxPeek { + b, _ := br.Peek(br.Buffered()) + return httpHostHeaderFromBytes(b) + } + b, err := br.Peek(peekSize) + if n := br.Buffered(); n > peekSize { + b, _ = br.Peek(n) + peekSize = n + } + if len(b) > 0 { + if b[0] < 'A' || b[0] > 'Z' { + // Doesn't look like an HTTP verb + // (GET, POST, etc). + return "" + } + if bytes.Index(b, crlfcrlf) != -1 || bytes.Index(b, lflf) != -1 { + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(b))) + if err != nil { + return "" + } + if len(req.Header["Host"]) > 1 { + // TODO(bradfitz): what does + // ReadRequest do if there are + // multiple Host headers? + return "" + } + return req.Host + } + } + if err != nil { + return httpHostHeaderFromBytes(b) + } + } +} + +var ( + lfHostColon = []byte("\nHost:") + lfhostColon = []byte("\nhost:") + crlf = []byte("\r\n") + lf = []byte("\n") + crlfcrlf = []byte("\r\n\r\n") + lflf = []byte("\n\n") +) + +func httpHostHeaderFromBytes(b []byte) string { + if i := bytes.Index(b, lfHostColon); i != -1 { + return string(bytes.TrimSpace(untilEOL(b[i+len(lfHostColon):]))) + } + if i := bytes.Index(b, lfhostColon); i != -1 { + return string(bytes.TrimSpace(untilEOL(b[i+len(lfhostColon):]))) + } + return "" +} + +// untilEOL returns v, truncated before the first '\n' byte, if any. +// The returned slice may include a '\r' at the end. +func untilEOL(v []byte) []byte { + if i := bytes.IndexByte(v, '\n'); i != -1 { + return v[:i] + } + return v +} diff --git a/vendor/github.com/inetaf/tcpproxy/listener.go b/vendor/github.com/inetaf/tcpproxy/listener.go new file mode 100644 index 000000000..1ddc48ee2 --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/listener.go @@ -0,0 +1,108 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tcpproxy + +import ( + "io" + "net" + "sync" +) + +// TargetListener implements both net.Listener and Target. +// Matched Targets become accepted connections. +type TargetListener struct { + Address string // Address is the string reported by TargetListener.Addr().String(). + + mu sync.Mutex + cond *sync.Cond + closed bool + nextConn net.Conn +} + +var ( + _ net.Listener = (*TargetListener)(nil) + _ Target = (*TargetListener)(nil) +) + +func (tl *TargetListener) lock() { + tl.mu.Lock() + if tl.cond == nil { + tl.cond = sync.NewCond(&tl.mu) + } +} + +type tcpAddr string + +func (a tcpAddr) Network() string { return "tcp" } +func (a tcpAddr) String() string { return string(a) } + +// Addr returns the listener's Address field as a net.Addr. +func (tl *TargetListener) Addr() net.Addr { return tcpAddr(tl.Address) } + +// Close stops listening for new connections. All new connections +// routed to this listener will be closed. Already accepted +// connections are not closed. +func (tl *TargetListener) Close() error { + tl.lock() + if tl.closed { + tl.mu.Unlock() + return nil + } + tl.closed = true + tl.mu.Unlock() + tl.cond.Broadcast() + return nil +} + +// HandleConn implements the Target interface. It blocks until tl is +// closed or another goroutine has called Accept and received c. +func (tl *TargetListener) HandleConn(c net.Conn) { + tl.lock() + defer tl.mu.Unlock() + for tl.nextConn != nil && !tl.closed { + tl.cond.Wait() + } + if tl.closed { + c.Close() + return + } + tl.nextConn = c + tl.cond.Broadcast() // Signal might be sufficient; verify. + for tl.nextConn == c && !tl.closed { + tl.cond.Wait() + } + if tl.closed { + c.Close() + return + } +} + +// Accept implements the Accept method in the net.Listener interface. +func (tl *TargetListener) Accept() (net.Conn, error) { + tl.lock() + for tl.nextConn == nil && !tl.closed { + tl.cond.Wait() + } + if tl.closed { + tl.mu.Unlock() + return nil, io.EOF + } + c := tl.nextConn + tl.nextConn = nil + tl.mu.Unlock() + tl.cond.Broadcast() // Signal might be sufficient; verify. + + return c, nil +} diff --git a/vendor/github.com/inetaf/tcpproxy/sni.go b/vendor/github.com/inetaf/tcpproxy/sni.go new file mode 100644 index 000000000..53b53c24d --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/sni.go @@ -0,0 +1,192 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tcpproxy + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "io" + "net" + "strings" +) + +// AddSNIRoute appends a route to the ipPort listener that routes to +// dest if the incoming TLS SNI server name is sni. If it doesn't +// match, rule processing continues for any additional routes on +// ipPort. +// +// By default, the proxy will route all ACME tls-sni-01 challenges +// received on ipPort to all SNI dests. You can disable ACME routing +// with AddStopACMESearch. +// +// The ipPort is any valid net.Listen TCP address. +func (p *Proxy) AddSNIRoute(ipPort, sni string, dest Target) { + p.AddSNIMatchRoute(ipPort, equals(sni), dest) +} + +// AddSNIMatchRoute appends a route to the ipPort listener that routes +// to dest if the incoming TLS SNI server name is accepted by +// matcher. If it doesn't match, rule processing continues for any +// additional routes on ipPort. +// +// By default, the proxy will route all ACME tls-sni-01 challenges +// received on ipPort to all SNI dests. You can disable ACME routing +// with AddStopACMESearch. +// +// The ipPort is any valid net.Listen TCP address. +func (p *Proxy) AddSNIMatchRoute(ipPort string, matcher Matcher, dest Target) { + cfg := p.configFor(ipPort) + if !cfg.stopACME { + if len(cfg.acmeTargets) == 0 { + p.addRoute(ipPort, &acmeMatch{cfg}) + } + cfg.acmeTargets = append(cfg.acmeTargets, dest) + } + + p.addRoute(ipPort, sniMatch{matcher, dest}) +} + +// AddStopACMESearch prevents ACME probing of subsequent SNI routes. +// Any ACME challenges on ipPort for SNI routes previously added +// before this call will still be proxied to all possible SNI +// backends. +func (p *Proxy) AddStopACMESearch(ipPort string) { + p.configFor(ipPort).stopACME = true +} + +type sniMatch struct { + matcher Matcher + target Target +} + +func (m sniMatch) match(br *bufio.Reader) (Target, string) { + sni := clientHelloServerName(br) + if m.matcher(context.TODO(), sni) { + return m.target, sni + } + return nil, "" +} + +// acmeMatch matches "*.acme.invalid" ACME tls-sni-01 challenges and +// searches for a Target in cfg.acmeTargets that has the challenge +// response. +type acmeMatch struct { + cfg *config +} + +func (m *acmeMatch) match(br *bufio.Reader) (Target, string) { + sni := clientHelloServerName(br) + if !strings.HasSuffix(sni, ".acme.invalid") { + return nil, "" + } + + // TODO: cache. ACME issuers will hit multiple times in a short + // burst for each issuance event. A short TTL cache + singleflight + // should have an excellent hit rate. + // TODO: maybe an acme-specific timeout as well? + // TODO: plumb context upwards? + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ch := make(chan Target, len(m.cfg.acmeTargets)) + for _, target := range m.cfg.acmeTargets { + go tryACME(ctx, ch, target, sni) + } + for range m.cfg.acmeTargets { + if target := <-ch; target != nil { + return target, sni + } + } + + // No target was happy with the provided challenge. + return nil, "" +} + +func tryACME(ctx context.Context, ch chan<- Target, dest Target, sni string) { + var ret Target + defer func() { ch <- ret }() + + conn, targetConn := net.Pipe() + defer conn.Close() + go dest.HandleConn(targetConn) + + deadline, ok := ctx.Deadline() + if ok { + conn.SetDeadline(deadline) + } + + client := tls.Client(conn, &tls.Config{ + ServerName: sni, + InsecureSkipVerify: true, + }) + if err := client.Handshake(); err != nil { + // TODO: log? + return + } + certs := client.ConnectionState().PeerCertificates + if len(certs) == 0 { + // TODO: log? + return + } + // acme says the first cert offered by the server must match the + // challenge hostname. + if err := certs[0].VerifyHostname(sni); err != nil { + // TODO: log? + return + } + + // Target presented what looks like a valid challenge + // response, send it back to the matcher. + ret = dest +} + +// clientHelloServerName returns the SNI server name inside the TLS ClientHello, +// without consuming any bytes from br. +// On any error, the empty string is returned. +func clientHelloServerName(br *bufio.Reader) (sni string) { + const recordHeaderLen = 5 + hdr, err := br.Peek(recordHeaderLen) + if err != nil { + return "" + } + const recordTypeHandshake = 0x16 + if hdr[0] != recordTypeHandshake { + return "" // Not TLS. + } + recLen := int(hdr[3])<<8 | int(hdr[4]) // ignoring version in hdr[1:3] + helloBytes, err := br.Peek(recordHeaderLen + recLen) + if err != nil { + return "" + } + tls.Server(sniSniffConn{r: bytes.NewReader(helloBytes)}, &tls.Config{ + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + sni = hello.ServerName + return nil, nil + }, + }).Handshake() + return +} + +// sniSniffConn is a net.Conn that reads from r, fails on Writes, +// and crashes otherwise. +type sniSniffConn struct { + r io.Reader + net.Conn // nil; crash on any unexpected use +} + +func (c sniSniffConn) Read(p []byte) (int, error) { return c.r.Read(p) } +func (sniSniffConn) Write(p []byte) (int, error) { return 0, io.EOF } diff --git a/vendor/github.com/inetaf/tcpproxy/tcpproxy.go b/vendor/github.com/inetaf/tcpproxy/tcpproxy.go new file mode 100644 index 000000000..9826d9422 --- /dev/null +++ b/vendor/github.com/inetaf/tcpproxy/tcpproxy.go @@ -0,0 +1,474 @@ +// Copyright 2017 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tcpproxy lets users build TCP proxies, optionally making +// routing decisions based on HTTP/1 Host headers and the SNI hostname +// in TLS connections. +// +// Typical usage: +// +// var p tcpproxy.Proxy +// p.AddHTTPHostRoute(":80", "foo.com", tcpproxy.To("10.0.0.1:8081")) +// p.AddHTTPHostRoute(":80", "bar.com", tcpproxy.To("10.0.0.2:8082")) +// p.AddRoute(":80", tcpproxy.To("10.0.0.1:8081")) // fallback +// p.AddSNIRoute(":443", "foo.com", tcpproxy.To("10.0.0.1:4431")) +// p.AddSNIRoute(":443", "bar.com", tcpproxy.To("10.0.0.2:4432")) +// p.AddRoute(":443", tcpproxy.To("10.0.0.1:4431")) // fallback +// log.Fatal(p.Run()) +// +// Calling Run (or Start) on a proxy also starts all the necessary +// listeners. +// +// For each accepted connection, the rules for that ipPort are +// matched, in order. If one matches (currently HTTP Host, SNI, or +// always), then the connection is handed to the target. +// +// The two predefined Target implementations are: +// +// 1) DialProxy, proxying to another address (use the To func to return a +// DialProxy value), +// +// 2) TargetListener, making the matched connection available via a +// net.Listener.Accept call. +// +// But Target is an interface, so you can also write your own. +// +// Note that tcpproxy does not do any TLS encryption or decryption. It +// only (via DialProxy) copies bytes around. The SNI hostname in the TLS +// header is unencrypted, for better or worse. +// +// This package makes no API stability promises. If you depend on it, +// vendor it. +package tcpproxy + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log" + "net" + "time" +) + +// Proxy is a proxy. Its zero value is a valid proxy that does +// nothing. Call methods to add routes before calling Start or Run. +// +// The order that routes are added in matters; each is matched in the order +// registered. +type Proxy struct { + configs map[string]*config // ip:port => config + + lns []net.Listener + donec chan struct{} // closed before err + err error // any error from listening + + // ListenFunc optionally specifies an alternate listen + // function. If nil, net.Dial is used. + // The provided net is always "tcp". + ListenFunc func(net, laddr string) (net.Listener, error) +} + +// Matcher reports whether hostname matches the Matcher's criteria. +type Matcher func(ctx context.Context, hostname string) bool + +// equals is a trivial Matcher that implements string equality. +func equals(want string) Matcher { + return func(_ context.Context, got string) bool { + return want == got + } +} + +// config contains the proxying state for one listener. +type config struct { + routes []route + acmeTargets []Target // accumulates targets that should be probed for acme. + stopACME bool // if true, AddSNIRoute doesn't add targets to acmeTargets. +} + +// A route matches a connection to a target. +type route interface { + // match examines the initial bytes of a connection, looking for a + // match. If a match is found, match returns a non-nil Target to + // which the stream should be proxied. match returns nil if the + // connection doesn't match. + // + // match must not consume bytes from the given bufio.Reader, it + // can only Peek. + // + // If an sni or host header was parsed successfully, that will be + // returned as the second parameter. + match(*bufio.Reader) (Target, string) +} + +func (p *Proxy) netListen() func(net, laddr string) (net.Listener, error) { + if p.ListenFunc != nil { + return p.ListenFunc + } + return net.Listen +} + +func (p *Proxy) configFor(ipPort string) *config { + if p.configs == nil { + p.configs = make(map[string]*config) + } + if p.configs[ipPort] == nil { + p.configs[ipPort] = &config{} + } + return p.configs[ipPort] +} + +func (p *Proxy) addRoute(ipPort string, r route) { + cfg := p.configFor(ipPort) + cfg.routes = append(cfg.routes, r) +} + +// AddRoute appends an always-matching route to the ipPort listener, +// directing any connection to dest. +// +// This is generally used as either the only rule (for simple TCP +// proxies), or as the final fallback rule for an ipPort. +// +// The ipPort is any valid net.Listen TCP address. +func (p *Proxy) AddRoute(ipPort string, dest Target) { + p.addRoute(ipPort, fixedTarget{dest}) +} + +type fixedTarget struct { + t Target +} + +func (m fixedTarget) match(*bufio.Reader) (Target, string) { return m.t, "" } + +// Run is calls Start, and then Wait. +// +// It blocks until there's an error. The return value is always +// non-nil. +func (p *Proxy) Run() error { + if err := p.Start(); err != nil { + return err + } + return p.Wait() +} + +// Wait waits for the Proxy to finish running. Currently this can only +// happen if a Listener is closed, or Close is called on the proxy. +// +// It is only valid to call Wait after a successful call to Start. +func (p *Proxy) Wait() error { + <-p.donec + return p.err +} + +// Close closes all the proxy's self-opened listeners. +func (p *Proxy) Close() error { + for _, c := range p.lns { + c.Close() + } + return nil +} + +// Start creates a TCP listener for each unique ipPort from the +// previously created routes and starts the proxy. It returns any +// error from starting listeners. +// +// If it returns a non-nil error, any successfully opened listeners +// are closed. +func (p *Proxy) Start() error { + if p.donec != nil { + return errors.New("already started") + } + p.donec = make(chan struct{}) + errc := make(chan error, len(p.configs)) + p.lns = make([]net.Listener, 0, len(p.configs)) + for ipPort, config := range p.configs { + ln, err := p.netListen()("tcp", ipPort) + if err != nil { + p.Close() + return err + } + p.lns = append(p.lns, ln) + go p.serveListener(errc, ln, config.routes) + } + go p.awaitFirstError(errc) + return nil +} + +func (p *Proxy) awaitFirstError(errc <-chan error) { + p.err = <-errc + close(p.donec) +} + +func (p *Proxy) serveListener(ret chan<- error, ln net.Listener, routes []route) { + for { + c, err := ln.Accept() + if err != nil { + ret <- err + return + } + go p.serveConn(c, routes) + } +} + +// serveConn runs in its own goroutine and matches c against routes. +// It returns whether it matched purely for testing. +func (p *Proxy) serveConn(c net.Conn, routes []route) bool { + br := bufio.NewReader(c) + for _, route := range routes { + if target, hostName := route.match(br); target != nil { + if n := br.Buffered(); n > 0 { + peeked, _ := br.Peek(br.Buffered()) + c = &Conn{ + HostName: hostName, + Peeked: peeked, + Conn: c, + } + } + target.HandleConn(c) + return true + } + } + // TODO: hook for this? + log.Printf("tcpproxy: no routes matched conn %v/%v; closing", c.RemoteAddr().String(), c.LocalAddr().String()) + c.Close() + return false +} + +// Conn is an incoming connection that has had some bytes read from it +// to determine how to route the connection. The Read method stitches +// the peeked bytes and unread bytes back together. +type Conn struct { + // HostName is the hostname field that was sent to the request router. + // In the case of TLS, this is the SNI header, in the case of HTTPHost + // route, it will be the host header. In the case of a fixed + // route, i.e. those created with AddRoute(), this will always be + // empty. This can be useful in the case where further routing decisions + // need to be made in the Target impementation. + HostName string + + // Peeked are the bytes that have been read from Conn for the + // purposes of route matching, but have not yet been consumed + // by Read calls. It set to nil by Read when fully consumed. + Peeked []byte + + // Conn is the underlying connection. + // It can be type asserted against *net.TCPConn or other types + // as needed. It should not be read from directly unless + // Peeked is nil. + net.Conn +} + +func (c *Conn) Read(p []byte) (n int, err error) { + if len(c.Peeked) > 0 { + n = copy(p, c.Peeked) + c.Peeked = c.Peeked[n:] + if len(c.Peeked) == 0 { + c.Peeked = nil + } + return n, nil + } + return c.Conn.Read(p) +} + +// Target is what an incoming matched connection is sent to. +type Target interface { + // HandleConn is called when an incoming connection is + // matched. After the call to HandleConn, the tcpproxy + // package never touches the conn again. Implementations are + // responsible for closing the connection when needed. + // + // The concrete type of conn will be of type *Conn if any + // bytes have been consumed for the purposes of route + // matching. + HandleConn(net.Conn) +} + +// To is shorthand way of writing &tlsproxy.DialProxy{Addr: addr}. +func To(addr string) *DialProxy { + return &DialProxy{Addr: addr} +} + +// DialProxy implements Target by dialing a new connection to Addr +// and then proxying data back and forth. +// +// The To func is a shorthand way of creating a DialProxy. +type DialProxy struct { + // Addr is the TCP address to proxy to. + Addr string + + // KeepAlivePeriod sets the period between TCP keep alives. + // If zero, a default is used. To disable, use a negative number. + // The keep-alive is used for both the client connection and + KeepAlivePeriod time.Duration + + // DialTimeout optionally specifies a dial timeout. + // If zero, a default is used. + // If negative, the timeout is disabled. + DialTimeout time.Duration + + // DialContext optionally specifies an alternate dial function + // for TCP targets. If nil, the standard + // net.Dialer.DialContext method is used. + DialContext func(ctx context.Context, network, address string) (net.Conn, error) + + // OnDialError optionally specifies an alternate way to handle errors dialing Addr. + // If nil, the error is logged and src is closed. + // If non-nil, src is not closed automatically. + OnDialError func(src net.Conn, dstDialErr error) + + // ProxyProtocolVersion optionally specifies the version of + // HAProxy's PROXY protocol to use. The PROXY protocol provides + // connection metadata to the DialProxy target, via a header + // inserted ahead of the client's traffic. The DialProxy target + // must explicitly support and expect the PROXY header; there is + // no graceful downgrade. + // If zero, no PROXY header is sent. Currently, version 1 is supported. + ProxyProtocolVersion int +} + +// UnderlyingConn returns c.Conn if c of type *Conn, +// otherwise it returns c. +func UnderlyingConn(c net.Conn) net.Conn { + if wrap, ok := c.(*Conn); ok { + return wrap.Conn + } + return c +} + +func goCloseConn(c net.Conn) { go c.Close() } + +// HandleConn implements the Target interface. +func (dp *DialProxy) HandleConn(src net.Conn) { + ctx := context.Background() + var cancel context.CancelFunc + if dp.DialTimeout >= 0 { + ctx, cancel = context.WithTimeout(ctx, dp.dialTimeout()) + } + dst, err := dp.dialContext()(ctx, "tcp", dp.Addr) + if cancel != nil { + cancel() + } + if err != nil { + dp.onDialError()(src, err) + return + } + defer goCloseConn(dst) + + if err = dp.sendProxyHeader(dst, src); err != nil { + dp.onDialError()(src, err) + return + } + defer goCloseConn(src) + + if ka := dp.keepAlivePeriod(); ka > 0 { + if c, ok := UnderlyingConn(src).(*net.TCPConn); ok { + c.SetKeepAlive(true) + c.SetKeepAlivePeriod(ka) + } + if c, ok := dst.(*net.TCPConn); ok { + c.SetKeepAlive(true) + c.SetKeepAlivePeriod(ka) + } + } + + errc := make(chan error, 1) + go proxyCopy(errc, src, dst) + go proxyCopy(errc, dst, src) + <-errc +} + +func (dp *DialProxy) sendProxyHeader(w io.Writer, src net.Conn) error { + switch dp.ProxyProtocolVersion { + case 0: + return nil + case 1: + var srcAddr, dstAddr *net.TCPAddr + if a, ok := src.RemoteAddr().(*net.TCPAddr); ok { + srcAddr = a + } + if a, ok := src.LocalAddr().(*net.TCPAddr); ok { + dstAddr = a + } + + if srcAddr == nil || dstAddr == nil { + _, err := io.WriteString(w, "PROXY UNKNOWN\r\n") + return err + } + + family := "TCP4" + if srcAddr.IP.To4() == nil { + family = "TCP6" + } + _, err := fmt.Fprintf(w, "PROXY %s %s %d %s %d\r\n", family, srcAddr.IP, srcAddr.Port, dstAddr.IP, dstAddr.Port) + return err + default: + return fmt.Errorf("PROXY protocol version %d not supported", dp.ProxyProtocolVersion) + } +} + +// proxyCopy is the function that copies bytes around. +// It's a named function instead of a func literal so users get +// named goroutines in debug goroutine stack dumps. +func proxyCopy(errc chan<- error, dst, src net.Conn) { + // Before we unwrap src and/or dst, copy any buffered data. + if wc, ok := src.(*Conn); ok && len(wc.Peeked) > 0 { + if _, err := dst.Write(wc.Peeked); err != nil { + errc <- err + return + } + wc.Peeked = nil + } + + // Unwrap the src and dst from *Conn to *net.TCPConn so Go + // 1.11's splice optimization kicks in. + src = UnderlyingConn(src) + dst = UnderlyingConn(dst) + + _, err := io.Copy(dst, src) + errc <- err +} + +func (dp *DialProxy) keepAlivePeriod() time.Duration { + if dp.KeepAlivePeriod != 0 { + return dp.KeepAlivePeriod + } + return time.Minute +} + +func (dp *DialProxy) dialTimeout() time.Duration { + if dp.DialTimeout > 0 { + return dp.DialTimeout + } + return 10 * time.Second +} + +var defaultDialer = new(net.Dialer) + +func (dp *DialProxy) dialContext() func(ctx context.Context, network, address string) (net.Conn, error) { + if dp.DialContext != nil { + return dp.DialContext + } + return defaultDialer.DialContext +} + +func (dp *DialProxy) onDialError() func(src net.Conn, dstDialErr error) { + if dp.OnDialError != nil { + return dp.OnDialError + } + return func(src net.Conn, dstDialErr error) { + log.Printf("tcpproxy: for incoming conn %v, error dialing %q: %v", src.RemoteAddr().String(), dp.Addr, dstDialErr) + src.Close() + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 1b4bfe0ac..31a3f15a3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -73,6 +73,8 @@ github.com/hashicorp/hcl/json/token github.com/hashicorp/serf/coordinate # github.com/hashicorp/vault v0.6.0 github.com/hashicorp/vault/api +# github.com/inetaf/tcpproxy v0.0.0-20200125044825-b6bb9b5b8252 +github.com/inetaf/tcpproxy # github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 github.com/kr/logfmt # github.com/magiconair/properties v0.0.0-20171031211101-49d762b9817b