diff --git a/pkg/auth/filter.go b/pkg/auth/filter.go index 5ee30ec..67da214 100644 --- a/pkg/auth/filter.go +++ b/pkg/auth/filter.go @@ -146,6 +146,17 @@ func ToMiddleware(auth Authenticator) Middleware { } } +func AlwaysAdmin(req *http.Request) (user.Info, bool, error) { + return &user.DefaultInfo{ + Name: "admin", + UID: "admin", + Groups: []string{ + "system:masters", + "system:authenticated", + }, + }, true, nil +} + func Impersonation(req *http.Request) (user.Info, bool, error) { userName := req.Header.Get(transport.ImpersonateUserHeader) if userName == "" { diff --git a/pkg/dashboard/ui.go b/pkg/dashboard/ui.go new file mode 100644 index 0000000..807b97c --- /dev/null +++ b/pkg/dashboard/ui.go @@ -0,0 +1,90 @@ +package dashboard + +import ( + "crypto/tls" + "io" + "net/http" + "strings" + + "github.com/gorilla/mux" + "github.com/rancher/steve/pkg/responsewriter" + "github.com/rancher/steve/pkg/schemaserver/parse" + "github.com/sirupsen/logrus" +) + +var ( + insecureClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } +) + +func content(uiSetting func() string) http.Handler { + return http.FileServer(http.Dir(uiSetting())) +} + +func Route(next http.Handler, uiSetting func() string) http.Handler { + uiContent := responsewriter.NewMiddlewareChain(responsewriter.Gzip, + responsewriter.DenyFrameOptions, + responsewriter.CacheMiddleware("json", "js", "css")).Handler(content(uiSetting)) + + root := mux.NewRouter() + root.Path("/dashboard").HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Add("Location", "/dashboard/") + rw.WriteHeader(http.StatusFound) + }) + root.PathPrefix("/dashboard/assets").Handler(uiContent) + root.PathPrefix("/dashboard/translations").Handler(uiContent) + root.PathPrefix("/dashboard/engines-dist").Handler(uiContent) + root.Handle("/dashboard/asset-manifest.json", uiContent) + root.Handle("/dashboard/index.html", uiContent) + root.PathPrefix("/dashboard/").Handler(wrapUI(next, uiSetting)) + root.NotFoundHandler = next + + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if strings.HasPrefix(req.URL.Path, "/k8s/clusters/local") { + req.URL.Path = strings.TrimPrefix(req.URL.Path, "/k8s/clusters/local") + if req.URL.Path == "" { + req.URL.Path = "/" + } + } + root.ServeHTTP(rw, req) + }) +} + +func wrapUI(next http.Handler, uiGetter func() string) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + if parse.IsBrowser(req, true) { + path := uiGetter() + if strings.HasPrefix(path, "http") { + ui(resp, req, path) + } else { + http.ServeFile(resp, req, path) + } + } else { + next.ServeHTTP(resp, req) + } + }) +} + +func ui(resp http.ResponseWriter, req *http.Request, url string) { + if err := serveIndex(resp, req, url); err != nil { + logrus.Errorf("failed to serve UI: %v", err) + resp.WriteHeader(500) + } +} + +func serveIndex(resp http.ResponseWriter, req *http.Request, url string) error { + r, err := insecureClient.Get(url) + if err != nil { + return err + } + defer r.Body.Close() + + _, err = io.Copy(resp, r.Body) + return err +} diff --git a/pkg/responsewriter/cache.go b/pkg/responsewriter/cache.go new file mode 100644 index 0000000..e5ece3c --- /dev/null +++ b/pkg/responsewriter/cache.go @@ -0,0 +1,49 @@ +package responsewriter + +import ( + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +func CacheMiddleware(suffixes ...string) mux.MiddlewareFunc { + return mux.MiddlewareFunc(func(handler http.Handler) http.Handler { + return Cache(handler, suffixes...) + }) +} + +func Cache(handler http.Handler, suffixes ...string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + i := strings.LastIndex(r.URL.Path, ".") + if i >= 0 { + for _, suffix := range suffixes { + if suffix == r.URL.Path[i+1:] { + w.Header().Set("Cache-Control", "max-age=31536000, public") + } + } + } + handler.ServeHTTP(w, r) + }) +} + +func NoCache(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + handler.ServeHTTP(w, r) + }) +} + +func DenyFrameOptions(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "deny") + handler.ServeHTTP(w, r) + }) +} + +func ContentTypeOptions(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + handler.ServeHTTP(w, r) + }) +} diff --git a/pkg/responsewriter/content.go b/pkg/responsewriter/content.go new file mode 100644 index 0000000..101d4f8 --- /dev/null +++ b/pkg/responsewriter/content.go @@ -0,0 +1,42 @@ +package responsewriter + +import ( + "bufio" + "fmt" + "net" + "net/http" + "reflect" + "strings" +) + +type ContentTypeWriter struct { + http.ResponseWriter +} + +func (c ContentTypeWriter) Write(b []byte) (int, error) { + found := false + for k := range c.Header() { + if strings.EqualFold(k, "Content-Type") { + found = true + break + } + } + if !found { + c.Header().Set("Content-Type", http.DetectContentType(b)) + } + return c.ResponseWriter.Write(b) +} + +func ContentType(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writer := ContentTypeWriter{ResponseWriter: w} + handler.ServeHTTP(writer, r) + }) +} + +func (c ContentTypeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := c.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, fmt.Errorf("Upstream ResponseWriter of type %v does not implement http.Hijacker", reflect.TypeOf(c.ResponseWriter)) +} diff --git a/pkg/responsewriter/gzip.go b/pkg/responsewriter/gzip.go new file mode 100644 index 0000000..31ca8c1 --- /dev/null +++ b/pkg/responsewriter/gzip.go @@ -0,0 +1,70 @@ +package responsewriter + +import ( + "bufio" + "compress/gzip" + "fmt" + "io" + "net" + "net/http" + "reflect" + "strings" +) + +type wrapWriter struct { + gzipResponseWriter + + code int +} + +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter +} + +func (g gzipResponseWriter) Write(b []byte) (int, error) { + // Header logic is kept here in case the user does not use WriteHeader + g.Header().Set("Content-Encoding", "gzip") + g.Header().Del("Content-Length") + + return g.Writer.Write(b) +} + +// Close uses gzip to write gzip footer if message is gzip encoded +func (g gzipResponseWriter) Close(writer *gzip.Writer) { + if g.Header().Get("Content-Encoding") == "gzip" { + writer.Close() + } +} + +// WriteHeader sets gzip encoding and removes length. Should always be used when using gzip writer. +func (g gzipResponseWriter) WriteHeader(statusCode int) { + g.Header().Set("Content-Encoding", "gzip") + g.Header().Del("Content-Length") + g.ResponseWriter.WriteHeader(statusCode) +} + +// Gzip creates a gzip writer if gzip encoding is accepted. +func Gzip(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + handler.ServeHTTP(w, r) + return + } + gz := gzip.NewWriter(w) + + gzw := &wrapWriter{gzipResponseWriter{Writer: gz, ResponseWriter: w}, http.StatusOK} + defer gzw.Close(gz) + + // Content encoding will be set once Write or WriteHeader is called, to avoid gzipping empty messages + handler.ServeHTTP(gzw, r) + }) +} + +// Hijack must be implemented to properly chain with handlers expecting a hijacker handler to be passed +func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := g.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, fmt.Errorf("Upstream ResponseWriter of type %v does not implement http.Hijacker", reflect.TypeOf(g.ResponseWriter)) +} diff --git a/pkg/responsewriter/gzip_test.go b/pkg/responsewriter/gzip_test.go new file mode 100644 index 0000000..3e13f7c --- /dev/null +++ b/pkg/responsewriter/gzip_test.go @@ -0,0 +1,174 @@ +package responsewriter + +import ( + "compress/gzip" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +// All other writers will attempt additional unnecessary logic +// Implements http.responseWriter and io.Writer +type DummyWriter struct { + header map[string][]string + buffer []byte +} + +type DummyHandler struct { +} + +type DummyHandlerWithWrite struct { + DummyHandler + next http.Handler +} + +func NewDummyWriter() *DummyWriter { + return &DummyWriter{map[string][]string{}, []byte{}} +} + +func NewRequest(accept string) *http.Request { + return &http.Request{ + Header: map[string][]string{"Accept-Encoding": {accept}}, + } +} + +func (d *DummyWriter) Header() http.Header { + return d.header +} + +func (d *DummyWriter) Write(p []byte) (n int, err error) { + d.buffer = append(d.buffer, p...) + return 0, nil +} + +func (d *DummyWriter) WriteHeader(int) { +} + +func (d *DummyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +} + +func (d *DummyHandlerWithWrite) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Write([]byte{0, 0}) + if d.next != nil { + d.next.ServeHTTP(w, r) + } +} + +// TestWriteHeader asserts content-length header is deleted and content-encoding header is set to gzip +func TestWriteHeader(t *testing.T) { + assert := assert.New(t) + + w := NewDummyWriter() + gz := &gzipResponseWriter{gzip.NewWriter(w), w} + + gz.Header().Set("Content-Length", "80") + gz.WriteHeader(400) + // Content-Length should have been deleted in WriterHeader, resulting in empty string + assert.Equal("", gz.Header().Get("Content-Length")) + assert.Equal(1, len(w.header["Content-Encoding"])) + assert.Equal("gzip", gz.Header().Get("Content-Encoding")) +} + +// TestSetContentWithoutWrite asserts content-encoding is NOT "gzip" if accept-encoding header does not contain gzip +func TestSetContentWithoutWrite(t *testing.T) { + assert := assert.New(t) + + // Test content encoding header when write is not used + handlerFunc := Gzip(&DummyHandler{}) + + // Test when accept-encoding only contains gzip + rw := NewDummyWriter() + req := NewRequest("gzip") + handlerFunc.ServeHTTP(rw, req) + // Content encoding should be empty since write has not been used + assert.Equal(0, len(rw.header["Content-Encoding"])) + assert.Equal("", rw.Header().Get("Content-Encoding")) + + // Test when accept-encoding contains multiple options, including gzip + rw = NewDummyWriter() + req = NewRequest("json, xml, gzip") + handlerFunc.ServeHTTP(rw, req) + assert.Equal(0, len(rw.header["Content-Encoding"])) + assert.Equal("", rw.Header().Get("Content-Encoding")) + + // Test when accept-encoding is empty + req = NewRequest("") + rw = NewDummyWriter() + handlerFunc.ServeHTTP(rw, req) + assert.Equal(0, len(rw.header["Content-Encoding"])) + assert.Equal("", rw.Header().Get("Content-Encoding")) + + // Test when accept-encoding is is not empty but does not include gzip + req = NewRequest("json, xml") + rw = NewDummyWriter() + handlerFunc.ServeHTTP(rw, req) + assert.Equal(0, len(rw.header["Content-Encoding"])) + assert.Equal("", rw.Header().Get("Content-Encoding")) +} + +// TestSetContentWithWrite asserts content-encoding is "gzip" if accept-encoding header contains gzip +func TestSetContentWithWrite(t *testing.T) { + assert := assert.New(t) + + // Test content encoding header when write is used + handlerFunc := Gzip(&DummyHandlerWithWrite{}) + + // Test when accept-encoding only contains gzip + req := NewRequest("gzip") + rw := NewDummyWriter() + handlerFunc.ServeHTTP(rw, req) + // Content encoding should be gzip since write has been used + assert.Equal(1, len(rw.header["Content-Encoding"])) + assert.Equal("gzip", rw.Header().Get("Content-Encoding")) + + // Test when accept-encoding contains multiple options, including gzip + req = NewRequest("json, xml, gzip") + rw = NewDummyWriter() + handlerFunc.ServeHTTP(rw, req) + // Content encoding should be gzip since write has been used + assert.Equal(1, len(rw.header["Content-Encoding"])) + assert.Equal("gzip", rw.Header().Get("Content-Encoding")) + + // Test when accept-encoding is empty + req = NewRequest("") + rw = NewDummyWriter() + handlerFunc.ServeHTTP(rw, req) + // Content encoding should be empty since gzip is not an accepted encoding + assert.Equal(0, len(rw.header["Content-Encoding"])) + assert.Equal("", rw.Header().Get("Content-Encoding")) + + // Test when accept-encoding is is not empty but does not include gzip + req = NewRequest("json, xml") + rw = NewDummyWriter() + handlerFunc.ServeHTTP(rw, req) + // Content encoding should be empty since gzip is not an accepted encoding + assert.Equal(0, len(rw.header["Content-Encoding"])) + assert.Equal("", rw.Header().Get("Content-Encoding")) +} + +// TestMultipleWrites ensures that Write can be used multiple times +func TestMultipleWrites(t *testing.T) { + assert := assert.New(t) + + // Handler function that contains one writing handler + handlerFuncOneWrite := Gzip(&DummyHandlerWithWrite{}) + + // Handler function that contains a chain of two writing handlers + handlerFuncTwoWrites := Gzip(&DummyHandlerWithWrite{next: &DummyHandlerWithWrite{}}) + + req := NewRequest("gzip") + rw := NewDummyWriter() + handlerFuncOneWrite.ServeHTTP(rw, req) + oneWriteResult := rw.buffer + + req = NewRequest("gzip") + rw = NewDummyWriter() + handlerFuncTwoWrites.ServeHTTP(rw, req) + multiWriteResult := rw.buffer + + // Content encoding should be gzip since write has been used (twice) + assert.Equal(1, len(rw.header["Content-Encoding"])) + assert.Equal("gzip", rw.Header().Get("Content-Encoding")) + assert.NotEqual(multiWriteResult, oneWriteResult) +} diff --git a/pkg/responsewriter/middleware.go b/pkg/responsewriter/middleware.go new file mode 100644 index 0000000..f8f2b7e --- /dev/null +++ b/pkg/responsewriter/middleware.go @@ -0,0 +1,24 @@ +package responsewriter + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type MiddlewareChain struct { + middleWares []mux.MiddlewareFunc +} + +func NewMiddlewareChain(middleWares ...mux.MiddlewareFunc) *MiddlewareChain { + return &MiddlewareChain{middleWares: middleWares} +} + +func (m *MiddlewareChain) Handler(handler http.Handler) http.Handler { + rtn := handler + for i := len(m.middleWares) - 1; i >= 0; i-- { + w := m.middleWares[i] + rtn = w.Middleware(rtn) + } + return rtn +} diff --git a/pkg/server/cli/clicontext.go b/pkg/server/cli/clicontext.go index ac0f31b..67dbbba 100644 --- a/pkg/server/cli/clicontext.go +++ b/pkg/server/cli/clicontext.go @@ -4,6 +4,7 @@ import ( authcli "github.com/rancher/steve/pkg/auth/cli" "github.com/rancher/steve/pkg/server" "github.com/rancher/wrangler/pkg/kubeconfig" + "github.com/rancher/wrangler/pkg/ratelimit" "github.com/urfave/cli" ) @@ -11,6 +12,7 @@ type Config struct { KubeConfig string HTTPSListenPort int HTTPListenPort int + DashboardURL string WebhookConfig authcli.WebhookConfig } @@ -28,6 +30,7 @@ func (c *Config) ToServer() (*server.Server, error) { if err != nil { return nil, err } + restConfig.RateLimiter = ratelimit.None auth, err := c.WebhookConfig.WebhookMiddleware() if err != nil { @@ -37,6 +40,9 @@ func (c *Config) ToServer() (*server.Server, error) { return &server.Server{ RestConfig: restConfig, AuthMiddleware: auth, + DashboardURL: func() string { + return c.DashboardURL + }, }, nil } @@ -57,6 +63,11 @@ func Flags(config *Config) []cli.Flag { Value: 8080, Destination: &config.HTTPListenPort, }, + cli.StringFlag{ + Name: "dashboard-url", + Value: "https://releases.rancher.com/dashboard/latest/index.html", + Destination: &config.DashboardURL, + }, } return append(flags, authcli.Flags(&config.WebhookConfig)...) diff --git a/pkg/server/config.go b/pkg/server/config.go index 601e08a..498cafd 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -37,6 +37,7 @@ type Server struct { Router router.RouterFunc PostStartHooks []func() error StartHooks []StartHook + DashboardURL func() string } type Controllers struct { diff --git a/pkg/server/handler/apiserver.go b/pkg/server/handler/apiserver.go index a6258b8..5bb5f1c 100644 --- a/pkg/server/handler/apiserver.go +++ b/pkg/server/handler/apiserver.go @@ -33,6 +33,7 @@ func New(cfg *rest.Config, sf schema.Factory, authMiddleware auth.Middleware, ne if err != nil { return nil, err } + authMiddleware = auth.ToMiddleware(auth.AuthenticatorFunc(auth.AlwaysAdmin)) } else { proxy = k8sproxy.ImpersonatingHandler("/", cfg) } diff --git a/pkg/server/server.go b/pkg/server/server.go index 071b59d..e179938 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/rancher/steve/pkg/client" "github.com/rancher/steve/pkg/clustercache" schemacontroller "github.com/rancher/steve/pkg/controllers/schema" + "github.com/rancher/steve/pkg/dashboard" "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/schemaserver/types" "github.com/rancher/steve/pkg/server/handler" @@ -87,6 +88,10 @@ func setup(ctx context.Context, server *Server) (http.Handler, *schema.Collectio return sync() }) + if server.DashboardURL != nil && server.DashboardURL() != "" { + handler = dashboard.Route(handler, server.DashboardURL) + } + return handler, sf, nil }