diff --git a/pkg/server/cli/clicontext.go b/pkg/server/cli/clicontext.go index 2419901..08fee60 100644 --- a/pkg/server/cli/clicontext.go +++ b/pkg/server/cli/clicontext.go @@ -6,6 +6,7 @@ import ( steveauth "github.com/rancher/steve/pkg/auth" authcli "github.com/rancher/steve/pkg/auth/cli" "github.com/rancher/steve/pkg/server" + "github.com/rancher/steve/pkg/ui" "github.com/rancher/wrangler/pkg/kubeconfig" "github.com/rancher/wrangler/pkg/ratelimit" "github.com/urfave/cli" @@ -13,9 +14,10 @@ import ( type Config struct { KubeConfig string + Context string HTTPSListenPort int HTTPListenPort int - Authentication bool + UIPath string WebhookConfig authcli.WebhookConfig } @@ -33,13 +35,13 @@ func (c *Config) ToServer(ctx context.Context) (*server.Server, error) { auth steveauth.Middleware ) - restConfig, err := kubeconfig.GetNonInteractiveClientConfig(c.KubeConfig).ClientConfig() + restConfig, err := kubeconfig.GetNonInteractiveClientConfigWithContext(c.KubeConfig, c.Context).ClientConfig() if err != nil { return nil, err } restConfig.RateLimiter = ratelimit.None - if c.Authentication { + if c.WebhookConfig.WebhookAuthentication { auth, err = c.WebhookConfig.WebhookMiddleware() if err != nil { return nil, err @@ -48,6 +50,7 @@ func (c *Config) ToServer(ctx context.Context) (*server.Server, error) { return server.New(ctx, restConfig, &server.Options{ AuthMiddleware: auth, + Next: ui.New(c.UIPath), }) } @@ -58,6 +61,15 @@ func Flags(config *Config) []cli.Flag { EnvVar: "KUBECONFIG", Destination: &config.KubeConfig, }, + cli.StringFlag{ + Name: "context", + EnvVar: "CONTEXT", + Destination: &config.Context, + }, + cli.StringFlag{ + Name: "ui-path", + Destination: &config.UIPath, + }, cli.IntFlag{ Name: "https-listen-port", Value: 9443, @@ -68,10 +80,6 @@ func Flags(config *Config) []cli.Flag { Value: 9080, Destination: &config.HTTPListenPort, }, - cli.BoolTFlag{ - Name: "authentication", - Destination: &config.Authentication, - }, } return append(flags, authcli.Flags(&config.WebhookConfig)...) diff --git a/pkg/ui/handler.go b/pkg/ui/handler.go new file mode 100644 index 0000000..f630487 --- /dev/null +++ b/pkg/ui/handler.go @@ -0,0 +1,177 @@ +package ui + +import ( + "crypto/tls" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/rancher/apiserver/pkg/middleware" + "github.com/sirupsen/logrus" +) + +var ( + insecureClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } +) + +const ( + defaultPath = "./ui" +) + +type StringSetting func() string +type BoolSetting func() bool + +type Handler struct { + pathSetting func() string + indexSetting func() string + releaseSetting func() bool + offlineSetting func() string + middleware func(http.Handler) http.Handler + indexMiddleware func(http.Handler) http.Handler + + downloadOnce sync.Once + downloadSuccess bool +} + +type Options struct { + // The location on disk of the UI files + Path StringSetting + // The HTTP URL of the index file to download + Index StringSetting + // Whether or not to run the UI offline, should return true/false/dynamic + Offline StringSetting + // Whether or not is it release, if true UI will run offline if set to dynamic + ReleaseSetting BoolSetting +} + +func NewUIHandler(opts *Options) *Handler { + if opts == nil { + opts = &Options{} + } + + h := &Handler{ + indexSetting: opts.Index, + offlineSetting: opts.Offline, + pathSetting: opts.Path, + releaseSetting: opts.ReleaseSetting, + middleware: middleware.Chain{ + middleware.Gzip, + middleware.DenyFrameOptions, + middleware.CacheMiddleware("json", "js", "css"), + }.Handler, + indexMiddleware: middleware.Chain{ + middleware.Gzip, + middleware.NoCache, + middleware.DenyFrameOptions, + middleware.ContentType, + }.Handler, + } + + if h.indexSetting == nil { + h.indexSetting = func() string { + return "https://releases.rancher.com/dashboard/latest/index.html" + } + } + + if h.offlineSetting == nil { + h.offlineSetting = func() string { + return "dynamic" + } + } + + if h.pathSetting == nil { + h.pathSetting = func() string { + return defaultPath + } + } + + if h.releaseSetting == nil { + h.releaseSetting = func() bool { + return false + } + } + + return h +} + +func (u *Handler) canDownload(url string) bool { + u.downloadOnce.Do(func() { + if err := serveIndex(ioutil.Discard, url); err == nil { + u.downloadSuccess = true + } else { + logrus.Errorf("Failed to download %s, falling back to packaged UI", url) + } + }) + return u.downloadSuccess +} + +func (u *Handler) path() (path string, isURL bool) { + switch u.offlineSetting() { + case "dynamic": + if u.releaseSetting() { + return u.pathSetting(), false + } + if u.canDownload(u.indexSetting()) { + return u.indexSetting(), true + } + return u.pathSetting(), false + case "true": + return u.pathSetting(), false + default: + return u.indexSetting(), true + } +} + +func (u *Handler) ServeAsset() http.Handler { + return u.middleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + http.FileServer(http.Dir(u.pathSetting())).ServeHTTP(rw, req) + })) +} + +func (u *Handler) ServeFaviconDashboard() http.Handler { + return u.middleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + http.FileServer(http.Dir(filepath.Join(u.pathSetting(), "dashboard"))).ServeHTTP(rw, req) + })) +} + +func (u *Handler) IndexFileOnNotFound() http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + // we ignore directories here because we want those to come from the CDN when running in that mode + if stat, err := os.Stat(filepath.Join(u.pathSetting(), req.URL.Path)); err == nil && !stat.IsDir() { + u.ServeAsset().ServeHTTP(rw, req) + } else { + u.IndexFile().ServeHTTP(rw, req) + } + }) +} + +func (u *Handler) IndexFile() http.Handler { + return u.indexMiddleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if path, isURL := u.path(); isURL { + _ = serveIndex(rw, path) + } else { + http.ServeFile(rw, req, filepath.Join(path, "index.html")) + } + })) +} + +func serveIndex(resp io.Writer, 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/ui/routes.go b/pkg/ui/routes.go new file mode 100644 index 0000000..5fc0cd1 --- /dev/null +++ b/pkg/ui/routes.go @@ -0,0 +1,38 @@ +package ui + +import ( + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +func New(path string) http.Handler { + vue := NewUIHandler(&Options{ + Path: func() string { + if path == "" { + return defaultPath + } + return path + }, + }) + + router := mux.NewRouter() + router.UseEncodedPath() + + router.Handle("/", http.RedirectHandler("/dashboard/", http.StatusFound)) + router.Handle("/dashboard", http.RedirectHandler("/dashboard/", http.StatusFound)) + router.Handle("/dashboard/", vue.IndexFile()) + router.Handle("/favicon.png", vue.ServeFaviconDashboard()) + router.Handle("/favicon.ico", vue.ServeFaviconDashboard()) + router.PathPrefix("/dashboard/").Handler(vue.IndexFileOnNotFound()) + router.PathPrefix("/k8s/clusters/local").HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + url := strings.TrimPrefix(req.URL.Path, "/k8s/clusters/local") + if url == "" { + url = "/" + } + http.Redirect(rw, req, url, http.StatusFound) + }) + + return router +}