package srv import ( "fmt" "html" "html/template" "net/http" "path" "path/filepath" "regexp" "time" "github.com/julienschmidt/httprouter" "github.com/linkerd/linkerd2/pkg/filesonly" "github.com/linkerd/linkerd2/pkg/healthcheck" "github.com/linkerd/linkerd2/pkg/k8s" "github.com/linkerd/linkerd2/pkg/prometheus" vizPb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz" "github.com/patrickmn/go-cache" log "github.com/sirupsen/logrus" ) const ( timeout = 15 * time.Second // statExpiration indicates when items in the stat cache expire. statExpiration = 1500 * time.Millisecond // statCleanupInterval indicates how often expired items in the stat cache // are cleaned up. statCleanupInterval = 5 * time.Minute ) type ( // Server encapsulates the Linkerd control plane's web dashboard server. Server struct { templateDir string reload bool templates map[string]*template.Template router *httprouter.Router reHost *regexp.Regexp } templatePayload struct { Contents interface{} } appParams struct { UUID string ReleaseVersion string ControllerNamespace string Error bool ErrorMessage string PathPrefix string Jaeger string Grafana string GrafanaExternalURL string GrafanaPrefix string } healthChecker interface { RunChecks(observer healthcheck.CheckObserver) (bool, bool) } ) // this is called by the HTTP server to actually respond to a request func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { if !s.reHost.MatchString(req.Host) { err := fmt.Sprintf(`It appears that you are trying to reach this service with a host of '%s'. This does not match /%s/ and has been denied for security reasons. Please see https://linkerd.io/dns-rebinding for an explanation of what is happening and how to fix it.`, html.EscapeString(req.Host), html.EscapeString(s.reHost.String())) http.Error(w, err, http.StatusBadRequest) return } w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set("X-XSS-Protection", "1; mode=block") s.router.ServeHTTP(w, req) } // NewServer returns an initialized `http.Server`, configured to listen on an // address, render templates, and serve static assets, for a given Linkerd // control plane. func NewServer( addr string, grafanaAddr string, grafanaExternalAddr string, grafanaPrefix string, jaegerAddr string, templateDir string, staticDir string, uuid string, version string, controllerNamespace string, clusterDomain string, reload bool, reHost *regexp.Regexp, apiClient vizPb.ApiClient, k8sAPI *k8s.KubernetesAPI, hc healthChecker, ) *http.Server { server := &Server{ templateDir: templateDir, reload: reload, reHost: reHost, } server.router = &httprouter.Router{ RedirectTrailingSlash: true, RedirectFixedPath: true, HandleMethodNotAllowed: false, // disable 405s } wrappedServer := prometheus.WithTelemetry(server) handler := &handler{ apiClient: apiClient, k8sAPI: k8sAPI, render: server.RenderTemplate, uuid: uuid, version: version, controllerNamespace: controllerNamespace, clusterDomain: clusterDomain, jaegerProxy: newReverseProxy(jaegerAddr, ""), grafana: grafanaAddr, grafanaExternalURL: grafanaExternalAddr, grafanaPrefix: grafanaPrefix, jaeger: jaegerAddr, hc: hc, statCache: cache.New(statExpiration, statCleanupInterval), } // Only create the grafana reverse proxy if we aren't using external grafana if grafanaExternalAddr == "" { handler.grafanaProxy = newReverseProxy(grafanaAddr, "/grafana") } httpServer := &http.Server{ Addr: addr, ReadTimeout: timeout, ReadHeaderTimeout: timeout, WriteTimeout: timeout, Handler: wrappedServer, } // webapp routes server.router.GET("/", handler.handleIndex) server.router.GET("/controlplane", handler.handleIndex) server.router.GET("/namespaces", handler.handleIndex) server.router.GET("/gateways", handler.handleIndex) // paths for a list of resources by namespace server.router.GET("/namespaces/:namespace/daemonsets", handler.handleIndex) server.router.GET("/namespaces/:namespace/statefulsets", handler.handleIndex) server.router.GET("/namespaces/:namespace/jobs", handler.handleIndex) server.router.GET("/namespaces/:namespace/deployments", handler.handleIndex) server.router.GET("/namespaces/:namespace/services", handler.handleIndex) server.router.GET("/namespaces/:namespace/replicationcontrollers", handler.handleIndex) server.router.GET("/namespaces/:namespace/pods", handler.handleIndex) server.router.GET("/namespaces/:namespace/cronjobs", handler.handleIndex) server.router.GET("/namespaces/:namespace/replicasets", handler.handleIndex) // legacy paths that are deprecated but should not 404 server.router.GET("/overview", handler.handleIndex) server.router.GET("/daemonsets", handler.handleIndex) server.router.GET("/statefulsets", handler.handleIndex) server.router.GET("/jobs", handler.handleIndex) server.router.GET("/deployments", handler.handleIndex) server.router.GET("/services", handler.handleIndex) server.router.GET("/replicationcontrollers", handler.handleIndex) server.router.GET("/pods", handler.handleIndex) // paths for individual resource view server.router.GET("/namespaces/:namespace", handler.handleIndex) server.router.GET("/namespaces/:namespace/pods/:pod", handler.handleIndex) server.router.GET("/namespaces/:namespace/daemonsets/:daemonset", handler.handleIndex) server.router.GET("/namespaces/:namespace/statefulsets/:statefulset", handler.handleIndex) server.router.GET("/namespaces/:namespace/deployments/:deployment", handler.handleIndex) server.router.GET("/namespaces/:namespace/services/:deployment", handler.handleIndex) server.router.GET("/namespaces/:namespace/jobs/:job", handler.handleIndex) server.router.GET("/namespaces/:namespace/replicationcontrollers/:replicationcontroller", handler.handleIndex) server.router.GET("/namespaces/:namespace/cronjobs/:cronjob", handler.handleIndex) server.router.GET("/namespaces/:namespace/replicasets/:replicaset", handler.handleIndex) // tools and community paths server.router.GET("/tap", handler.handleIndex) server.router.GET("/top", handler.handleIndex) server.router.GET("/community", handler.handleIndex) server.router.GET("/routes", handler.handleIndex) server.router.GET("/extensions", handler.handleIndex) server.router.GET("/profiles/new", handler.handleProfileDownload) // add catch-all parameter to match all files in dir server.router.GET("/dist/*filepath", mkStaticHandler(staticDir)) // webapp api routes server.router.GET("/api/version", handler.handleAPIVersion) // Traffic Performance Summary. This route used to be called /api/stat // but was renamed to avoid triggering ad blockers. // See: https://github.com/linkerd/linkerd2/issues/970 server.router.GET("/api/tps-reports", handler.handleAPIStat) server.router.GET("/api/pods", handler.handleAPIPods) server.router.GET("/api/services", handler.handleAPIServices) server.router.GET("/api/tap", handler.handleAPITap) server.router.GET("/api/routes", handler.handleAPITopRoutes) server.router.GET("/api/edges", handler.handleAPIEdges) server.router.GET("/api/check", handler.handleAPICheck) server.router.GET("/api/resource-definition", handler.handleAPIResourceDefinition) server.router.GET("/api/gateways", handler.handleAPIGateways) server.router.GET("/api/extensions", handler.handleGetExtensions) // grafana proxy, only used if external grafana is not in use if grafanaExternalAddr == "" { server.handleAllOperationsForPath("/grafana/*grafanapath", handler.handleGrafana) } // jaeger proxy server.handleAllOperationsForPath("/jaeger/*jaegerpath", handler.handleJaeger) return httpServer } // RenderTemplate writes a rendered template into a buffer, given an HTTP // request and template information. func (s *Server) RenderTemplate(w http.ResponseWriter, templateFile, templateName string, args interface{}) error { log.Debugf("emitting template %s", templateFile) template, err := s.loadTemplate(templateFile) if err != nil { log.Error(err.Error()) http.Error(w, "internal server error", http.StatusInternalServerError) return nil } w.Header().Set("Content-Type", "text/html") if templateName == "" { return template.Execute(w, args) } return template.ExecuteTemplate(w, templateName, templatePayload{Contents: args}) } func (s *Server) loadTemplate(templateFile string) (template *template.Template, err error) { // load template from disk if necessary template = s.templates[templateFile] if template == nil || s.reload { templatePath := safelyJoinPath(s.templateDir, templateFile) includes, err := filepath.Glob(filepath.Join(s.templateDir, "includes", "*.tmpl.html")) if err != nil { return nil, err } // for cases where you're not calling a named template, the passed-in path needs to be first templateFiles := append([]string{templatePath}, includes...) log.Debugf("loading templates from %v", templateFiles) template, err = template.ParseFiles(templateFiles...) if err == nil && !s.reload { s.templates[templateFile] = template } } return template, err } func (s *Server) handleAllOperationsForPath(path string, handle httprouter.Handle) { s.router.DELETE(path, handle) s.router.GET(path, handle) s.router.HEAD(path, handle) s.router.OPTIONS(path, handle) s.router.PATCH(path, handle) s.router.POST(path, handle) s.router.PUT(path, handle) } func safelyJoinPath(rootPath, userPath string) string { return filepath.Join(rootPath, path.Clean("/"+userPath)) } func mkStaticHandler(staticDir string) httprouter.Handle { fileServer := http.FileServer(filesonly.FileSystem(staticDir)) return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) { filepath := p.ByName("filepath") if filepath == "/index_bundle.js" { // don't cache the bundle because it references a hashed js file w.Header().Set("Cache-Control", "no-store, must-revalidate") } req.URL.Path = filepath fileServer.ServeHTTP(w, req) } }