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)
}
}