diff options
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | internal/serving/serverless/certs.go | 26 | ||||
-rw-r--r-- | internal/serving/serverless/cluster.go | 28 | ||||
-rw-r--r-- | internal/serving/serverless/director.go | 22 | ||||
-rw-r--r-- | internal/serving/serverless/errors.go | 26 | ||||
-rw-r--r-- | internal/serving/serverless/function.go | 18 | ||||
-rw-r--r-- | internal/serving/serverless/function_test.go | 17 | ||||
-rw-r--r-- | internal/serving/serverless/serverless.go | 37 | ||||
-rw-r--r-- | internal/serving/serverless/serverless_test.go | 181 | ||||
-rw-r--r-- | internal/serving/serverless/transport.go | 46 |
11 files changed, 404 insertions, 0 deletions
@@ -20,6 +20,7 @@ require ( github.com/rs/cors v1.7.0 github.com/sirupsen/logrus v1.4.2 github.com/stretchr/testify v1.4.0 + github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad gitlab.com/gitlab-org/labkit v0.0.0-20190902063225-3253d7975ca7 gitlab.com/lupine/go-mimedb v0.0.0-20180307000149-e8af1d659877 @@ -127,6 +127,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-lib v1.5.0/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= diff --git a/internal/serving/serverless/certs.go b/internal/serving/serverless/certs.go new file mode 100644 index 00000000..674e8b25 --- /dev/null +++ b/internal/serving/serverless/certs.go @@ -0,0 +1,26 @@ +package serverless + +import ( + "crypto/tls" + "crypto/x509" +) + +// Certs holds definition of certificates we use to perform mTLS +// handshake with a cluster +type Certs struct { + RootCerts *x509.CertPool + Certificate tls.Certificate +} + +// NewClusterCerts creates a new cluster configuration from cert / key pair +func NewClusterCerts(clientCert, clientKey string) (*Certs, error) { + cert, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey)) + if err != nil { + return nil, err + } + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(clientCert)) + + return &Certs{RootCerts: caCertPool, Certificate: cert}, nil +} diff --git a/internal/serving/serverless/cluster.go b/internal/serving/serverless/cluster.go new file mode 100644 index 00000000..6bdd51da --- /dev/null +++ b/internal/serving/serverless/cluster.go @@ -0,0 +1,28 @@ +package serverless + +import ( + "crypto/tls" + "strings" +) + +// Cluster represent a Knative cluster that we want to proxy requests to +type Cluster struct { + Address string // Address is a real IP address of a cluster ingress + Port string // Port is a real port of HTTP TLS service + Name string // Name is a cluster name, used in cluster certificates + Certs *Certs +} + +// Host returns a real cluster location based on IP address and port +func (c Cluster) Host() string { + return strings.Join([]string{c.Address, c.Port}, ":") +} + +// TLSConfig builds a new tls.Config and returns a pointer to it +func (c Cluster) TLSConfig() *tls.Config { + return &tls.Config{ + Certificates: []tls.Certificate{c.Certs.Certificate}, + RootCAs: c.Certs.RootCerts, + ServerName: c.Name, + } +} diff --git a/internal/serving/serverless/director.go b/internal/serving/serverless/director.go new file mode 100644 index 00000000..4478b104 --- /dev/null +++ b/internal/serving/serverless/director.go @@ -0,0 +1,22 @@ +package serverless + +import ( + "net/http" + + "github.com/tomasen/realip" +) + +// NewDirectorFunc returns a director function capable of configuring a proxy +// request +func NewDirectorFunc(function Function) func(*http.Request) { + return func(request *http.Request) { + host := function.Host() + + request.Host = host + request.URL.Host = host + request.URL.Scheme = "https" + request.Header.Set("User-Agent", "GitLab Pages Daemon") + request.Header.Set("X-Forwarded-For", realip.FromRequest(request)) + request.Header.Set("X-Forwarded-Proto", "https") + } +} diff --git a/internal/serving/serverless/errors.go b/internal/serving/serverless/errors.go new file mode 100644 index 00000000..d208a11d --- /dev/null +++ b/internal/serving/serverless/errors.go @@ -0,0 +1,26 @@ +package serverless + +import ( + "encoding/json" + "net/http" +) + +// NewErrorHandler returns a func(http.ResponseWriter, *http.Request, error) +// responsible for handling proxy errors +func NewErrorHandler() func(http.ResponseWriter, *http.Request, error) { + return func(w http.ResponseWriter, r *http.Request, err error) { + w.WriteHeader(http.StatusInternalServerError) + + message := "cluster error: " + err.Error() + msgmap := map[string]string{"error": message} + + json, err := json.Marshal(msgmap) + if err != nil { + w.Write([]byte(message)) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(json) + } +} diff --git a/internal/serving/serverless/function.go b/internal/serving/serverless/function.go new file mode 100644 index 00000000..20d4ec2c --- /dev/null +++ b/internal/serving/serverless/function.go @@ -0,0 +1,18 @@ +package serverless + +import "strings" + +// Function represents a Knative service that is going to be invoked by the +// proxied request +type Function struct { + Name string // Name is a function name, it includes a "service name" component too + Namespace string // Namespace is a kubernetes namespace this function has been deployed to + BaseDomain string // BaseDomain is a cluster base domain, used to route requests to apropriate service +} + +// Host returns a function address that we are going to expose in the `Host:` +// header to make it possible to route a proxied request to appropriate service +// in a Knative cluster +func (f Function) Host() string { + return strings.Join([]string{f.Name, f.Namespace, f.BaseDomain}, ".") +} diff --git a/internal/serving/serverless/function_test.go b/internal/serving/serverless/function_test.go new file mode 100644 index 00000000..65d84eb7 --- /dev/null +++ b/internal/serving/serverless/function_test.go @@ -0,0 +1,17 @@ +package serverless + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFunctionHost(t *testing.T) { + function := Function{ + Name: "my-func", + Namespace: "my-namespace-123", + BaseDomain: "knative.example.com", + } + + require.Equal(t, "my-func.my-namespace-123.knative.example.com", function.Host()) +} diff --git a/internal/serving/serverless/serverless.go b/internal/serving/serverless/serverless.go new file mode 100644 index 00000000..a8d090da --- /dev/null +++ b/internal/serving/serverless/serverless.go @@ -0,0 +1,37 @@ +package serverless + +import ( + "net/http/httputil" + + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" +) + +// Serverless is a servering used to proxy requests between a client and +// Knative cluster. +type Serverless struct { + proxy *httputil.ReverseProxy +} + +// New returns a new serving instance +func New(function Function, cluster Cluster) serving.Serving { + proxy := httputil.ReverseProxy{ + Director: NewDirectorFunc(function), + Transport: NewTransport(cluster), + ErrorHandler: NewErrorHandler(), + } + + return &Serverless{proxy: &proxy} +} + +// ServeFileHTTP handle an incoming request and proxies it to Knative cluster +func (s *Serverless) ServeFileHTTP(h serving.Handler) bool { + s.proxy.ServeHTTP(h.Writer, h.Request) + + return true +} + +// ServeNotFoundHTTP responds with 404 +func (s *Serverless) ServeNotFoundHTTP(h serving.Handler) { + httperrors.Serve404(h.Writer) +} diff --git a/internal/serving/serverless/serverless_test.go b/internal/serving/serverless/serverless_test.go new file mode 100644 index 00000000..c330cbda --- /dev/null +++ b/internal/serving/serverless/serverless_test.go @@ -0,0 +1,181 @@ +package serverless + +import ( + "crypto/tls" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-pages/internal/fixture" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" +) + +func withTestCluster(t *testing.T, cert, key string, block func(*http.ServeMux, *url.URL, *Certs)) { + mux := http.NewServeMux() + cluster := httptest.NewUnstartedServer(mux) + + certs, err := NewClusterCerts(fixture.Certificate, fixture.Key) + require.NoError(t, err) + + cluster.TLS = &tls.Config{ + Certificates: []tls.Certificate{certs.Certificate}, + RootCAs: certs.RootCerts, + } + + cluster.StartTLS() + defer cluster.Close() + + address, err := url.Parse(cluster.URL) + require.NoError(t, err) + + block(mux, address, certs) +} + +func TestServeFileHTTP(t *testing.T) { + t.Run("when proxying simple request to a cluster", func(t *testing.T) { + withTestCluster(t, fixture.Certificate, fixture.Key, func(mux *http.ServeMux, server *url.URL, certs *Certs) { + serverless := New( + Function{ + Name: "my-func", + Namespace: "my-namespace-123", + BaseDomain: "knative.example.com", + }, + Cluster{ + Name: "knative.gitlab-example.com", + Address: server.Hostname(), + Port: server.Port(), + Certs: certs, + }, + ) + + writer := httptest.NewRecorder() + request := httptest.NewRequest("GET", "http://example.gitlab.com/", nil) + handler := serving.Handler{Writer: writer, Request: request} + request.Header.Set("X-Real-IP", "127.0.0.105") + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "my-func.my-namespace-123.knative.example.com", r.Host) + require.Equal(t, "GitLab Pages Daemon", r.Header.Get("User-Agent")) + require.Equal(t, "https", r.Header.Get("X-Forwarded-Proto")) + require.Contains(t, r.Header.Get("X-Forwarded-For"), "127.0.0.105") + }) + + served := serverless.ServeFileHTTP(handler) + result := writer.Result() + + require.True(t, served) + require.Equal(t, http.StatusOK, result.StatusCode) + }) + }) + + t.Run("when proxying request with invalid hostname", func(t *testing.T) { + withTestCluster(t, fixture.Certificate, fixture.Key, func(mux *http.ServeMux, server *url.URL, certs *Certs) { + serverless := New( + Function{ + Name: "my-func", + Namespace: "my-namespace-123", + BaseDomain: "knative.example.com", + }, + Cluster{ + Name: "knative.invalid-gitlab-example.com", + Address: server.Hostname(), + Port: server.Port(), + Certs: certs, + }, + ) + + writer := httptest.NewRecorder() + request := httptest.NewRequest("GET", "http://example.gitlab.com/", nil) + handler := serving.Handler{Writer: writer, Request: request} + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + + served := serverless.ServeFileHTTP(handler) + result := writer.Result() + body, err := ioutil.ReadAll(writer.Body) + require.NoError(t, err) + + require.True(t, served) + require.Equal(t, http.StatusInternalServerError, result.StatusCode) + require.Contains(t, string(body), "cluster error: x509: certificate") + }) + }) + + t.Run("when a cluster responds with an error", func(t *testing.T) { + withTestCluster(t, fixture.Certificate, fixture.Key, func(mux *http.ServeMux, server *url.URL, certs *Certs) { + serverless := New( + Function{ + Name: "my-func", + Namespace: "my-namespace-123", + BaseDomain: "knative.example.com", + }, + Cluster{ + Name: "knative.gitlab-example.com", + Address: server.Hostname(), + Port: server.Port(), + Certs: certs, + }, + ) + + writer := httptest.NewRecorder() + request := httptest.NewRequest("GET", "http://example.gitlab.com/", nil) + handler := serving.Handler{Writer: writer, Request: request} + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("sorry, service unavailable")) + }) + + served := serverless.ServeFileHTTP(handler) + result := writer.Result() + body, err := ioutil.ReadAll(writer.Body) + require.NoError(t, err) + + require.True(t, served) + require.Equal(t, http.StatusServiceUnavailable, result.StatusCode) + require.Contains(t, string(body), "sorry, service unavailable") + }) + }) + + t.Run("when a cluster responds correctly", func(t *testing.T) { + withTestCluster(t, fixture.Certificate, fixture.Key, func(mux *http.ServeMux, server *url.URL, certs *Certs) { + serverless := New( + Function{ + Name: "my-func", + Namespace: "my-namespace-123", + BaseDomain: "knative.example.com", + }, + Cluster{ + Name: "knative.gitlab-example.com", + Address: server.Hostname(), + Port: server.Port(), + Certs: certs, + }, + ) + + writer := httptest.NewRecorder() + request := httptest.NewRequest("GET", "http://example.gitlab.com/", nil) + handler := serving.Handler{Writer: writer, Request: request} + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + served := serverless.ServeFileHTTP(handler) + result := writer.Result() + body, err := ioutil.ReadAll(writer.Body) + require.NoError(t, err) + + require.True(t, served) + require.Equal(t, http.StatusOK, result.StatusCode) + require.Contains(t, string(body), "OK") + }) + }) +} diff --git a/internal/serving/serverless/transport.go b/internal/serving/serverless/transport.go new file mode 100644 index 00000000..5a0f5165 --- /dev/null +++ b/internal/serving/serverless/transport.go @@ -0,0 +1,46 @@ +package serverless + +import ( + "context" + "net" + "net/http" + "time" +) + +// Transport is a struct that handle the proxy connection round trip to Knative +// cluster +type Transport struct { + cluster Cluster + transport *http.Transport +} + +// NewTransport fabricates as new transport type +func NewTransport(cluster Cluster) *Transport { + dialer := net.Dialer{ + Timeout: 4 * time.Minute, + KeepAlive: 6 * time.Minute, + } + + dialContext := func(ctx context.Context, network, address string) (net.Conn, error) { + address = cluster.Host() + + return dialer.DialContext(ctx, network, address) + } + + return &Transport{ + cluster: cluster, + transport: &http.Transport{ + DialContext: dialContext, + TLSHandshakeTimeout: 5 * time.Second, + TLSClientConfig: cluster.TLSConfig(), + }, + } +} + +// RoundTrip performs a connection to a Knative cluster and returns a response +func (t *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + response, err := t.transport.RoundTrip(request) + + // TODO add prometheus metrics for round trip timing + return response, err +} |