mirror of https://github.com/hashicorp/consul
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
254 lines
7.7 KiB
254 lines
7.7 KiB
// Copyright (c) HashiCorp, Inc. |
|
// SPDX-License-Identifier: BUSL-1.1 |
|
|
|
package uiserver |
|
|
|
import ( |
|
"bytes" |
|
"embed" |
|
"encoding/json" |
|
"fmt" |
|
"io" |
|
"io/fs" |
|
"net/http" |
|
"os" |
|
"path" |
|
"strings" |
|
"sync/atomic" |
|
"text/template" |
|
|
|
"github.com/hashicorp/go-hclog" |
|
|
|
"github.com/hashicorp/consul/agent/config" |
|
"github.com/hashicorp/consul/logging" |
|
) |
|
|
|
const ( |
|
compiledProviderJSPath = "assets/compiled-metrics-providers.js" |
|
) |
|
|
|
//go:embed dist |
|
var dist embed.FS |
|
|
|
// Handler is the http.Handler that serves the Consul UI. It may serve from the |
|
// embedded fs.FS or from an external directory. It provides a few important |
|
// transformations on the index.html file and includes a proxy for metrics |
|
// backends. |
|
type Handler struct { |
|
// runtimeConfig is a struct accessed through an atomic value to make |
|
// it safe to reload at run time. Each call to ServeHTTP will see the latest |
|
// version of the state without internal locking needed. |
|
runtimeConfig atomic.Value |
|
logger hclog.Logger |
|
transform UIDataTransform |
|
} |
|
|
|
// UIDataTransform is an optional dependency that allows the agent to add |
|
// additional data into the UI index as needed. For example we use this to |
|
// inject enterprise-only feature flags into the template without making this |
|
// package inherently dependent on Enterprise-only code. |
|
// |
|
// It is passed the current RuntimeConfig being applied and a map containing the |
|
// current data that will be passed to the template. It should be modified |
|
// directly to inject additional context. |
|
type UIDataTransform func(data map[string]interface{}) error |
|
|
|
// NewHandler returns a Handler that can be used to serve UI http requests. It |
|
// accepts a full agent config since properties like ACLs being enabled affect |
|
// the UI so we need more than just UIConfig parts. |
|
func NewHandler(runtimeCfg *config.RuntimeConfig, logger hclog.Logger, transform UIDataTransform) *Handler { |
|
h := &Handler{ |
|
logger: logger.Named(logging.UIServer), |
|
transform: transform, |
|
} |
|
h.runtimeConfig.Store(runtimeCfg) |
|
return h |
|
} |
|
|
|
// ServeHTTP implements http.Handler and serves UI HTTP requests |
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
|
// We need to support the path being trimmed by http.StripTags just like the |
|
// file servers do since http.StripPrefix will remove the leading slash in our |
|
// current config. Everything else works fine that way so we should to. |
|
pathTrimmed := strings.TrimLeft(r.URL.Path, "/") |
|
if pathTrimmed == compiledProviderJSPath { |
|
h.serveUIMetricsProviders(w) |
|
return |
|
} |
|
|
|
srv, err := h.handleIndex() |
|
if err != nil { |
|
http.Error(w, "UI server is misconfigured.", http.StatusInternalServerError) |
|
h.logger.Error("Failed to configure UI server: %s", err) |
|
return |
|
} |
|
srv.ServeHTTP(w, r) |
|
} |
|
|
|
// ReloadConfig is called by the agent when the configuration is reloaded and |
|
// updates the UIConfig values the handler uses to serve requests. |
|
func (h *Handler) ReloadConfig(newCfg *config.RuntimeConfig) error { |
|
h.runtimeConfig.Store(newCfg) |
|
return nil |
|
} |
|
|
|
func (h *Handler) handleIndex() (http.Handler, error) { |
|
cfg := h.getRuntimeConfig() |
|
|
|
var fsys fs.FS |
|
if cfg.UIConfig.Dir == "" { |
|
// strip the dist/ prefix |
|
sub, err := fs.Sub(dist, "dist") |
|
if err != nil { |
|
return nil, err |
|
} |
|
fsys = sub |
|
} else { |
|
fsys = os.DirFS(cfg.UIConfig.Dir) |
|
} |
|
|
|
// Render a new index.html with the new config values ready to serve. |
|
buf, err := h.renderIndexFile(cfg, fsys) |
|
if _, ok := err.(*os.PathError); ok && cfg.UIConfig.Dir != "" { |
|
// A Path error indicates that there is no index.html. This could happen if |
|
// the user configured their own UI dir and is serving something that is not |
|
// our usual UI. This won't work perfectly because our uiserver will still |
|
// redirect everything to the UI but we shouldn't fail the entire UI server |
|
// with a 500 in this case. Partly that's just bad UX and partly it's a |
|
// breaking change although quite an edge case. Instead, continue but just |
|
// return a 404 response for the index.html and log a warning. |
|
h.logger.Warn("ui_config.dir does not contain an index.html. Index templating and redirects to index.html are disabled.") |
|
return http.FileServer(http.FS(fsys)), nil |
|
} |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
// Create a new fsys that serves the rendered index file or falls back to the |
|
// underlying FS. |
|
fsys = &bufIndexFS{ |
|
fs: fsys, |
|
bufIndex: buf, |
|
} |
|
|
|
// Wrap the buffering FS our redirect FS. This needs to happen later so that |
|
// redirected requests for /index.html get served the rendered version not the |
|
// original. |
|
return http.FileServer(http.FS(&redirectFS{fs: fsys})), nil |
|
} |
|
|
|
// getRuntimeConfig is a helper to atomically access the runtime config. |
|
func (h *Handler) getRuntimeConfig() *config.RuntimeConfig { |
|
if cfg, ok := h.runtimeConfig.Load().(*config.RuntimeConfig); ok { |
|
return cfg |
|
} |
|
return nil |
|
} |
|
|
|
func (h *Handler) serveUIMetricsProviders(resp http.ResponseWriter) { |
|
// Reload config in case it's changed |
|
cfg := h.getRuntimeConfig() |
|
|
|
if len(cfg.UIConfig.MetricsProviderFiles) < 1 { |
|
http.Error(resp, "No provider JS files configured", http.StatusNotFound) |
|
return |
|
} |
|
|
|
var buf bytes.Buffer |
|
|
|
// Open each one and concatenate them |
|
for _, file := range cfg.UIConfig.MetricsProviderFiles { |
|
if err := concatFile(&buf, file); err != nil { |
|
http.Error(resp, "Internal Server Error", http.StatusInternalServerError) |
|
h.logger.Error("failed serving metrics provider js file", "file", file, "error", err) |
|
return |
|
} |
|
} |
|
// Done! |
|
resp.Header()["Content-Type"] = []string{"application/javascript"} |
|
_, err := buf.WriteTo(resp) |
|
if err != nil { |
|
http.Error(resp, "Internal Server Error", http.StatusInternalServerError) |
|
h.logger.Error("failed writing ui metrics provider files: %s", err) |
|
return |
|
} |
|
} |
|
|
|
func concatFile(buf *bytes.Buffer, file string) error { |
|
base := path.Base(file) |
|
_, err := buf.WriteString("// " + base + "\n\n") |
|
if err != nil { |
|
return fmt.Errorf("failed writing provider JS files: %w", err) |
|
} |
|
|
|
// Attempt to open the file |
|
f, err := os.Open(file) |
|
if err != nil { |
|
return fmt.Errorf("failed opening ui metrics provider JS file: %w", err) |
|
} |
|
defer f.Close() |
|
_, err = buf.ReadFrom(f) |
|
if err != nil { |
|
return fmt.Errorf("failed reading ui metrics provider JS file: %w", err) |
|
} |
|
_, err = buf.WriteString("\n\n") |
|
if err != nil { |
|
return fmt.Errorf("failed writing provider JS files: %w", err) |
|
} |
|
return nil |
|
} |
|
|
|
func (h *Handler) renderIndexFile(cfg *config.RuntimeConfig, fsys fs.FS) (fs.File, error) { |
|
// Open the original index.html |
|
f, err := fsys.Open("index.html") |
|
if err != nil { |
|
return nil, err |
|
} |
|
defer f.Close() |
|
|
|
content, err := io.ReadAll(f) |
|
if err != nil { |
|
return nil, fmt.Errorf("failed reading index.html: %w", err) |
|
} |
|
|
|
info, err := f.Stat() |
|
if err != nil { |
|
return nil, fmt.Errorf("failed reading metadata for index.html: %w", err) |
|
} |
|
|
|
// Create template data from the current config. |
|
tplData, err := uiTemplateDataFromConfig(cfg) |
|
if err != nil { |
|
return nil, fmt.Errorf("failed loading UI config for template: %w", err) |
|
} |
|
|
|
// Allow caller to apply additional data transformations if needed. |
|
if h.transform != nil { |
|
if err := h.transform(tplData); err != nil { |
|
return nil, fmt.Errorf("failed running transform: %w", err) |
|
} |
|
} |
|
|
|
tpl, err := template.New("index").Funcs(template.FuncMap{ |
|
"jsonEncode": func(data map[string]interface{}) (string, error) { |
|
bs, err := json.MarshalIndent(data, "", " ") |
|
if err != nil { |
|
return "", fmt.Errorf("failed jsonEncode: %w", err) |
|
} |
|
return string(bs), nil |
|
}, |
|
}).Parse(string(content)) |
|
if err != nil { |
|
return nil, fmt.Errorf("failed parsing index.html template: %w", err) |
|
} |
|
|
|
var buf bytes.Buffer |
|
|
|
err = tpl.Execute(&buf, tplData) |
|
if err != nil { |
|
return nil, fmt.Errorf("failed to render index.html: %w", err) |
|
} |
|
|
|
file := newBufferedFile(&buf, info) |
|
return file, nil |
|
}
|
|
|