mirror of https://github.com/status-im/consul.git
Merge pull request #8694 from hashicorp/ui-config-metrics
Add config changes for UI metrics
This commit is contained in:
commit
d0c160130b
|
@ -626,7 +626,7 @@ jobs:
|
|||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- ./agent/bindata_assetfs.go
|
||||
- ./agent/uiserver/bindata_assetfs.go
|
||||
- run: *notify-slack-failure
|
||||
|
||||
# commits static assets to git
|
||||
|
@ -641,16 +641,16 @@ jobs:
|
|||
- attach_workspace:
|
||||
at: .
|
||||
- run:
|
||||
name: commit agent/bindata_assetfs.go if there are changes
|
||||
name: commit agent/uiserver/bindata_assetfs.go if there are changes
|
||||
command: |
|
||||
exit 0
|
||||
if ! git diff --exit-code agent/bindata_assetfs.go; then
|
||||
if ! git diff --exit-code agent/uiserver/bindata_assetfs.go; then
|
||||
git config --local user.email "hashicorp-ci@users.noreply.github.com"
|
||||
git config --local user.name "hashicorp-ci"
|
||||
|
||||
short_sha=$(git rev-parse --short HEAD)
|
||||
git add agent/bindata_assetfs.go
|
||||
git commit -m "auto-updated agent/bindata_assetfs.go from commit ${short_sha}"
|
||||
git add agent/uiserver/bindata_assetfs.go
|
||||
git commit -m "auto-updated agent/uiserver/bindata_assetfs.go from commit ${short_sha}"
|
||||
git push origin master
|
||||
else
|
||||
echo "no new static assets to publish"
|
||||
|
|
|
@ -16,7 +16,7 @@ GOARCH?=$(shell go env GOARCH)
|
|||
GOPATH=$(shell go env GOPATH)
|
||||
MAIN_GOPATH=$(shell go env GOPATH | cut -d: -f1)
|
||||
|
||||
ASSETFS_PATH?=agent/bindata_assetfs.go
|
||||
ASSETFS_PATH?=agent/uiserver/bindata_assetfs.go
|
||||
# Get the git commit
|
||||
GIT_COMMIT?=$(shell git rev-parse --short HEAD)
|
||||
GIT_COMMIT_YEAR?=$(shell git show -s --format=%cd --date=format:%Y HEAD)
|
||||
|
@ -306,7 +306,7 @@ lint:
|
|||
# also run as part of the release build script when it verifies that there are no
|
||||
# changes to the UI assets that aren't checked in.
|
||||
static-assets:
|
||||
@go-bindata-assetfs -modtime 1 -pkg agent -prefix pkg -o $(ASSETFS_PATH) ./pkg/web_ui/...
|
||||
@go-bindata-assetfs -modtime 1 -pkg uiserver -prefix pkg -o $(ASSETFS_PATH) ./pkg/web_ui/...
|
||||
@go fmt $(ASSETFS_PATH)
|
||||
|
||||
|
||||
|
|
|
@ -255,6 +255,23 @@ type Agent struct {
|
|||
// fail, the agent will be shutdown.
|
||||
apiServers *apiServers
|
||||
|
||||
// httpHandlers provides direct access to (one of) the HTTPHandlers started by
|
||||
// this agent. This is used in tests to test HTTP endpoints without overhead
|
||||
// of TCP connections etc.
|
||||
//
|
||||
// TODO: this is a temporary re-introduction after we removed a list of
|
||||
// HTTPServers in favour of apiServers abstraction. Now that HTTPHandlers is
|
||||
// stateful and has config reloading though it's not OK to just use a
|
||||
// different instance of handlers in tests to the ones that the agent is wired
|
||||
// up to since then config reloads won't actually affect the handlers under
|
||||
// test while plumbing the external handlers in the TestAgent through bypasses
|
||||
// testing that the agent itself is actually reloading the state correctly.
|
||||
// Once we move `apiServers` to be a passed-in dependency for NewAgent, we
|
||||
// should be able to remove this and have the Test Agent create the
|
||||
// HTTPHandlers and pass them in removing the need to pull them back out
|
||||
// again.
|
||||
httpHandlers *HTTPHandlers
|
||||
|
||||
// wgServers is the wait group for all HTTP and DNS servers
|
||||
// TODO: remove once dnsServers are handled by apiServers
|
||||
wgServers sync.WaitGroup
|
||||
|
@ -290,6 +307,10 @@ type Agent struct {
|
|||
// IP.
|
||||
httpConnLimiter connlimit.Limiter
|
||||
|
||||
// configReloaders are subcomponents that need to be notified on a reload so
|
||||
// they can update their internal state.
|
||||
configReloaders []ConfigReloader
|
||||
|
||||
// enterpriseAgent embeds fields that we only access in consul-enterprise builds
|
||||
enterpriseAgent
|
||||
}
|
||||
|
@ -735,6 +756,8 @@ func (a *Agent) listenHTTP() ([]apiServer, error) {
|
|||
agent: a,
|
||||
denylist: NewDenylist(a.config.HTTPBlockEndpoints),
|
||||
}
|
||||
a.configReloaders = append(a.configReloaders, srv.ReloadConfig)
|
||||
a.httpHandlers = srv
|
||||
httpServer := &http.Server{
|
||||
Addr: l.Addr().String(),
|
||||
TLSConfig: tlscfg,
|
||||
|
@ -3571,6 +3594,12 @@ func (a *Agent) reloadConfigInternal(newCfg *config.RuntimeConfig) error {
|
|||
|
||||
a.State.SetDiscardCheckOutput(newCfg.DiscardCheckOutput)
|
||||
|
||||
for _, r := range a.configReloaders {
|
||||
if err := r(newCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
@ -797,6 +798,26 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) {
|
|||
return RuntimeConfig{}, fmt.Errorf("serf_wan_allowed_cidrs: %s", err)
|
||||
}
|
||||
|
||||
// Handle Deprecated UI config fields
|
||||
if c.UI != nil {
|
||||
b.warn("The 'ui' field is deprecated. Use the 'ui_config.enabled' field instead.")
|
||||
if c.UIConfig.Enabled == nil {
|
||||
c.UIConfig.Enabled = c.UI
|
||||
}
|
||||
}
|
||||
if c.UIDir != nil {
|
||||
b.warn("The 'ui_dir' field is deprecated. Use the 'ui_config.dir' field instead.")
|
||||
if c.UIConfig.Dir == nil {
|
||||
c.UIConfig.Dir = c.UIDir
|
||||
}
|
||||
}
|
||||
if c.UIContentPath != nil {
|
||||
b.warn("The 'ui_content_path' field is deprecated. Use the 'ui_config.content_path' field instead.")
|
||||
if c.UIConfig.ContentPath == nil {
|
||||
c.UIConfig.ContentPath = c.UIContentPath
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// build runtime config
|
||||
//
|
||||
|
@ -981,8 +1002,6 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) {
|
|||
EnableDebug: b.boolVal(c.EnableDebug),
|
||||
EnableRemoteScriptChecks: enableRemoteScriptChecks,
|
||||
EnableLocalScriptChecks: enableLocalScriptChecks,
|
||||
|
||||
EnableUI: b.boolVal(c.UI),
|
||||
EncryptKey: b.stringVal(c.EncryptKey),
|
||||
EncryptVerifyIncoming: b.boolVal(c.EncryptVerifyIncoming),
|
||||
EncryptVerifyOutgoing: b.boolVal(c.EncryptVerifyOutgoing),
|
||||
|
@ -1058,8 +1077,7 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) {
|
|||
TaggedAddresses: c.TaggedAddresses,
|
||||
TranslateWANAddrs: b.boolVal(c.TranslateWANAddrs),
|
||||
TxnMaxReqLen: b.uint64Val(c.Limits.TxnMaxReqLen),
|
||||
UIDir: b.stringVal(c.UIDir),
|
||||
UIContentPath: UIPathBuilder(b.stringVal(c.UIContentPath)),
|
||||
UIConfig: b.uiConfigVal(c.UIConfig),
|
||||
UnixSocketGroup: b.stringVal(c.UnixSocket.Group),
|
||||
UnixSocketMode: b.stringVal(c.UnixSocket.Mode),
|
||||
UnixSocketUser: b.stringVal(c.UnixSocket.User),
|
||||
|
@ -1091,10 +1109,26 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) {
|
|||
return rt, nil
|
||||
}
|
||||
|
||||
// reBasicName validates that a field contains only lower case alphanumerics,
|
||||
// underscore and dash and is non-empty.
|
||||
var reBasicName = regexp.MustCompile("^[a-z0-9_-]+$")
|
||||
|
||||
func validateBasicName(field, value string, allowEmpty bool) error {
|
||||
if value == "" {
|
||||
if allowEmpty {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s cannot be empty", field)
|
||||
}
|
||||
if !reBasicName.MatchString(value) {
|
||||
return fmt.Errorf("%s can only contain lowercase alphanumeric, - or _ characters."+
|
||||
" received: %q", field, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate performs semantic validation of the runtime configuration.
|
||||
func (b *Builder) Validate(rt RuntimeConfig) error {
|
||||
// reDatacenter defines a regexp for a valid datacenter name
|
||||
var reDatacenter = regexp.MustCompile("^[a-z0-9_-]+$")
|
||||
|
||||
// validContentPath defines a regexp for a valid content path name.
|
||||
var validContentPath = regexp.MustCompile(`^[A-Za-z0-9/_-]+$`)
|
||||
|
@ -1103,22 +1137,53 @@ func (b *Builder) Validate(rt RuntimeConfig) error {
|
|||
// check required params we cannot recover from first
|
||||
//
|
||||
|
||||
if rt.Datacenter == "" {
|
||||
return fmt.Errorf("datacenter cannot be empty")
|
||||
}
|
||||
if !reDatacenter.MatchString(rt.Datacenter) {
|
||||
return fmt.Errorf("datacenter cannot be %q. Please use only [a-z0-9-_]", rt.Datacenter)
|
||||
if err := validateBasicName("datacenter", rt.Datacenter, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DataDir == "" && !rt.DevMode {
|
||||
return fmt.Errorf("data_dir cannot be empty")
|
||||
}
|
||||
|
||||
if !validContentPath.MatchString(rt.UIContentPath) {
|
||||
return fmt.Errorf("ui-content-path can only contain alphanumeric, -, _, or /. received: %s", rt.UIContentPath)
|
||||
if !validContentPath.MatchString(rt.UIConfig.ContentPath) {
|
||||
return fmt.Errorf("ui-content-path can only contain alphanumeric, -, _, or /. received: %q", rt.UIConfig.ContentPath)
|
||||
}
|
||||
|
||||
if hasVersion.MatchString(rt.UIContentPath) {
|
||||
return fmt.Errorf("ui-content-path cannot have 'v[0-9]'. received: %s", rt.UIContentPath)
|
||||
if hasVersion.MatchString(rt.UIConfig.ContentPath) {
|
||||
return fmt.Errorf("ui-content-path cannot have 'v[0-9]'. received: %q", rt.UIConfig.ContentPath)
|
||||
}
|
||||
|
||||
if err := validateBasicName("ui_config.metrics_provider", rt.UIConfig.MetricsProvider, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.UIConfig.MetricsProviderOptionsJSON != "" {
|
||||
// Attempt to parse the JSON to ensure it's valid, parsing into a map
|
||||
// ensures we get an object.
|
||||
var dummyMap map[string]interface{}
|
||||
err := json.Unmarshal([]byte(rt.UIConfig.MetricsProviderOptionsJSON), &dummyMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ui_config.metrics_provider_options_json must be empty "+
|
||||
"or a string containing a valid JSON object. received: %q",
|
||||
rt.UIConfig.MetricsProviderOptionsJSON)
|
||||
}
|
||||
}
|
||||
if rt.UIConfig.MetricsProxy.BaseURL != "" {
|
||||
u, err := url.Parse(rt.UIConfig.MetricsProxy.BaseURL)
|
||||
if err != nil || !(u.Scheme == "http" || u.Scheme == "https") {
|
||||
return fmt.Errorf("ui_config.metrics_proxy.base_url must be a valid http"+
|
||||
" or https URL. received: %q",
|
||||
rt.UIConfig.MetricsProxy.BaseURL)
|
||||
}
|
||||
}
|
||||
for k, v := range rt.UIConfig.DashboardURLTemplates {
|
||||
if err := validateBasicName("ui_config.dashboard_url_templates key names", k, false); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || !(u.Scheme == "http" || u.Scheme == "https") {
|
||||
return fmt.Errorf("ui_config.dashboard_url_templates values must be a"+
|
||||
" valid http or https URL. received: %q",
|
||||
rt.UIConfig.MetricsProxy.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
if !rt.DevMode {
|
||||
|
@ -1190,15 +1255,15 @@ func (b *Builder) Validate(rt RuntimeConfig) error {
|
|||
if rt.AutopilotMaxTrailingLogs < 0 {
|
||||
return fmt.Errorf("autopilot.max_trailing_logs cannot be %d. Must be greater than or equal to zero", rt.AutopilotMaxTrailingLogs)
|
||||
}
|
||||
if rt.ACLDatacenter != "" && !reDatacenter.MatchString(rt.ACLDatacenter) {
|
||||
return fmt.Errorf("acl_datacenter cannot be %q. Please use only [a-z0-9-_]", rt.ACLDatacenter)
|
||||
if err := validateBasicName("acl_datacenter", rt.ACLDatacenter, true); err != nil {
|
||||
return err
|
||||
}
|
||||
// In DevMode, UI is enabled by default, so to enable rt.UIDir, don't perform this check
|
||||
if !rt.DevMode && rt.EnableUI && rt.UIDir != "" {
|
||||
if !rt.DevMode && rt.UIConfig.Enabled && rt.UIConfig.Dir != "" {
|
||||
return fmt.Errorf(
|
||||
"Both the ui and ui-dir flags were specified, please provide only one.\n" +
|
||||
"If trying to use your own web UI resources, use the ui-dir flag.\n" +
|
||||
"The web UI is included in the binary so use ui to enable it")
|
||||
"Both the ui_config.enabled and ui_config.dir (or -ui and -ui-dir) were specified, please provide only one.\n" +
|
||||
"If trying to use your own web UI resources, use ui_config.dir or the -ui-dir flag.\n" +
|
||||
"The web UI is included in the binary so use ui_config.enabled or the -ui flag to enable it")
|
||||
}
|
||||
if rt.DNSUDPAnswerLimit < 0 {
|
||||
return fmt.Errorf("dns_config.udp_answer_limit cannot be %d. Must be greater than or equal to zero", rt.DNSUDPAnswerLimit)
|
||||
|
@ -1647,6 +1712,35 @@ func (b *Builder) serviceConnectVal(v *ServiceConnect) *structs.ServiceConnect {
|
|||
}
|
||||
}
|
||||
|
||||
func (b *Builder) uiConfigVal(v RawUIConfig) UIConfig {
|
||||
return UIConfig{
|
||||
Enabled: b.boolVal(v.Enabled),
|
||||
Dir: b.stringVal(v.Dir),
|
||||
ContentPath: UIPathBuilder(b.stringVal(v.ContentPath)),
|
||||
MetricsProvider: b.stringVal(v.MetricsProvider),
|
||||
MetricsProviderFiles: v.MetricsProviderFiles,
|
||||
MetricsProviderOptionsJSON: b.stringVal(v.MetricsProviderOptionsJSON),
|
||||
MetricsProxy: b.uiMetricsProxyVal(v.MetricsProxy),
|
||||
DashboardURLTemplates: v.DashboardURLTemplates,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) uiMetricsProxyVal(v RawUIMetricsProxy) UIMetricsProxy {
|
||||
var hdrs []UIMetricsProxyAddHeader
|
||||
|
||||
for _, hdr := range v.AddHeaders {
|
||||
hdrs = append(hdrs, UIMetricsProxyAddHeader{
|
||||
Name: b.stringVal(hdr.Name),
|
||||
Value: b.stringVal(hdr.Value),
|
||||
})
|
||||
}
|
||||
|
||||
return UIMetricsProxy{
|
||||
BaseURL: b.stringVal(v.BaseURL),
|
||||
AddHeaders: hdrs,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) boolValWithDefault(v *bool, defaultVal bool) bool {
|
||||
if v == nil {
|
||||
return defaultVal
|
||||
|
|
|
@ -240,9 +240,15 @@ type Config struct {
|
|||
TaggedAddresses map[string]string `json:"tagged_addresses,omitempty" hcl:"tagged_addresses" mapstructure:"tagged_addresses"`
|
||||
Telemetry Telemetry `json:"telemetry,omitempty" hcl:"telemetry" mapstructure:"telemetry"`
|
||||
TranslateWANAddrs *bool `json:"translate_wan_addrs,omitempty" hcl:"translate_wan_addrs" mapstructure:"translate_wan_addrs"`
|
||||
|
||||
// DEPRECATED (ui-config) - moved to the ui_config stanza
|
||||
UI *bool `json:"ui,omitempty" hcl:"ui" mapstructure:"ui"`
|
||||
// DEPRECATED (ui-config) - moved to the ui_config stanza
|
||||
UIContentPath *string `json:"ui_content_path,omitempty" hcl:"ui_content_path" mapstructure:"ui_content_path"`
|
||||
// DEPRECATED (ui-config) - moved to the ui_config stanza
|
||||
UIDir *string `json:"ui_dir,omitempty" hcl:"ui_dir" mapstructure:"ui_dir"`
|
||||
UIConfig RawUIConfig `json:"ui_config,omitempty" hcl:"ui_config" mapstructure:"ui_config"`
|
||||
|
||||
UnixSocket UnixSocket `json:"unix_sockets,omitempty" hcl:"unix_sockets" mapstructure:"unix_sockets"`
|
||||
VerifyIncoming *bool `json:"verify_incoming,omitempty" hcl:"verify_incoming" mapstructure:"verify_incoming"`
|
||||
VerifyIncomingHTTPS *bool `json:"verify_incoming_https,omitempty" hcl:"verify_incoming_https" mapstructure:"verify_incoming_https"`
|
||||
|
@ -769,3 +775,24 @@ type AutoConfigAuthorizerRaw struct {
|
|||
NotBeforeLeeway *string `json:"not_before_leeway,omitempty" hcl:"not_before_leeway" mapstructure:"not_before_leeway"`
|
||||
ClockSkewLeeway *string `json:"clock_skew_leeway,omitempty" hcl:"clock_skew_leeway" mapstructure:"clock_skew_leeway"`
|
||||
}
|
||||
|
||||
type RawUIConfig struct {
|
||||
Enabled *bool `json:"enabled,omitempty" hcl:"enabled" mapstructure:"enabled"`
|
||||
Dir *string `json:"dir,omitempty" hcl:"dir" mapstructure:"dir"`
|
||||
ContentPath *string `json:"content_path,omitempty" hcl:"content_path" mapstructure:"content_path"`
|
||||
MetricsProvider *string `json:"metrics_provider,omitempty" hcl:"metrics_provider" mapstructure:"metrics_provider"`
|
||||
MetricsProviderFiles []string `json:"metrics_provider_files,omitempty" hcl:"metrics_provider_files" mapstructure:"metrics_provider_files"`
|
||||
MetricsProviderOptionsJSON *string `json:"metrics_provider_options_json,omitempty" hcl:"metrics_provider_options_json" mapstructure:"metrics_provider_options_json"`
|
||||
MetricsProxy RawUIMetricsProxy `json:"metrics_proxy,omitempty" hcl:"metrics_proxy" mapstructure:"metrics_proxy"`
|
||||
DashboardURLTemplates map[string]string `json:"dashboard_url_templates" hcl:"dashboard_url_templates" mapstructure:"dashboard_url_templates"`
|
||||
}
|
||||
|
||||
type RawUIMetricsProxy struct {
|
||||
BaseURL *string `json:"base_url,omitempty" hcl:"base_url" mapstructure:"base_url"`
|
||||
AddHeaders []RawUIMetricsProxyAddHeader `json:"add_headers,omitempty" hcl:"add_headers" mapstructure:"add_headers"`
|
||||
}
|
||||
|
||||
type RawUIMetricsProxyAddHeader struct {
|
||||
Name *string `json:"name,omitempty" hcl:"name" mapstructure:"name"`
|
||||
Value *string `json:"value,omitempty" hcl:"value" mapstructure:"value"`
|
||||
}
|
||||
|
|
|
@ -140,7 +140,9 @@ func DevSource() Source {
|
|||
disable_anonymous_signature = true
|
||||
disable_keyring_file = true
|
||||
enable_debug = true
|
||||
ui = true
|
||||
ui_config {
|
||||
enabled = true
|
||||
}
|
||||
log_level = "DEBUG"
|
||||
server = true
|
||||
|
||||
|
|
|
@ -111,8 +111,8 @@ func AddFlags(fs *flag.FlagSet, f *BuilderOpts) {
|
|||
add(&f.Config.Ports.SerfWAN, "serf-wan-port", "Sets the Serf WAN port to listen on.")
|
||||
add(&f.Config.ServerMode, "server", "Switches agent to server mode.")
|
||||
add(&f.Config.EnableSyslog, "syslog", "Enables logging to syslog.")
|
||||
add(&f.Config.UI, "ui", "Enables the built-in static web UI server.")
|
||||
add(&f.Config.UIContentPath, "ui-content-path", "Sets the external UI path to a string. Defaults to: /ui/ ")
|
||||
add(&f.Config.UIDir, "ui-dir", "Path to directory containing the web UI resources.")
|
||||
add(&f.Config.UIConfig.Enabled, "ui", "Enables the built-in static web UI server.")
|
||||
add(&f.Config.UIConfig.ContentPath, "ui-content-path", "Sets the external UI path to a string. Defaults to: /ui/ ")
|
||||
add(&f.Config.UIConfig.Dir, "ui-dir", "Path to directory containing the web UI resources.")
|
||||
add(&f.HCL, "hcl", "hcl config fragment. Can be specified multiple times.")
|
||||
}
|
||||
|
|
|
@ -694,13 +694,6 @@ type RuntimeConfig struct {
|
|||
// flag: -enable-script-checks
|
||||
EnableRemoteScriptChecks bool
|
||||
|
||||
// EnableUI enables the statically-compiled assets for the Consul web UI and
|
||||
// serves them at the default /ui/ endpoint automatically.
|
||||
//
|
||||
// hcl: enable_ui = (true|false)
|
||||
// flag: -ui
|
||||
EnableUI bool
|
||||
|
||||
// EncryptKey contains the encryption key to use for the Serf communication.
|
||||
//
|
||||
// hcl: encrypt = string
|
||||
|
@ -1411,16 +1404,18 @@ type RuntimeConfig struct {
|
|||
// hcl: limits { txn_max_req_len = uint64 }
|
||||
TxnMaxReqLen uint64
|
||||
|
||||
// UIDir is the directory containing the Web UI resources.
|
||||
// If provided, the UI endpoints will be enabled.
|
||||
// UIConfig holds various runtime options that control both the agent's
|
||||
// behavior while serving the UI (e.g. whether it's enabled, what path it's
|
||||
// mounted on) as well as options that enable or disable features within the
|
||||
// UI.
|
||||
//
|
||||
// hcl: ui_dir = string
|
||||
// flag: -ui-dir string
|
||||
UIDir string
|
||||
|
||||
//UIContentPath is a string that sets the external
|
||||
// path to a string. Default: /ui/
|
||||
UIContentPath string
|
||||
// NOTE: Never read from this field directly once the agent has started up
|
||||
// since the UI config is reloadable. The on in the agent's config field may
|
||||
// be out of date. Use the agent.getUIConfig() method to get the latest config
|
||||
// in a thread-safe way.
|
||||
//
|
||||
// hcl: ui_config { ... }
|
||||
UIConfig UIConfig
|
||||
|
||||
// UnixSocketGroup contains the group of the file permissions when
|
||||
// Consul binds to UNIX sockets.
|
||||
|
@ -1518,6 +1513,27 @@ type AutoConfigAuthorizer struct {
|
|||
AllowReuse bool
|
||||
}
|
||||
|
||||
type UIConfig struct {
|
||||
Enabled bool
|
||||
Dir string
|
||||
ContentPath string
|
||||
MetricsProvider string
|
||||
MetricsProviderFiles []string
|
||||
MetricsProviderOptionsJSON string
|
||||
MetricsProxy UIMetricsProxy
|
||||
DashboardURLTemplates map[string]string
|
||||
}
|
||||
|
||||
type UIMetricsProxy struct {
|
||||
BaseURL string
|
||||
AddHeaders []UIMetricsProxyAddHeader
|
||||
}
|
||||
|
||||
type UIMetricsProxyAddHeader struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (c *RuntimeConfig) apiAddresses(maxPerType int) (unixAddrs, httpAddrs, httpsAddrs []string) {
|
||||
if len(c.HTTPSAddrs) > 0 {
|
||||
for i, addr := range c.HTTPSAddrs {
|
||||
|
|
|
@ -290,7 +290,7 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
rt.DisableAnonymousSignature = true
|
||||
rt.DisableKeyringFile = true
|
||||
rt.EnableDebug = true
|
||||
rt.EnableUI = true
|
||||
rt.UIConfig.Enabled = true
|
||||
rt.LeaveOnTerm = false
|
||||
rt.Logging.LogLevel = "DEBUG"
|
||||
rt.RPCAdvertiseAddr = tcpAddr("127.0.0.1:8300")
|
||||
|
@ -850,7 +850,7 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
`-data-dir=` + dataDir,
|
||||
},
|
||||
patch: func(rt *RuntimeConfig) {
|
||||
rt.EnableUI = true
|
||||
rt.UIConfig.Enabled = true
|
||||
rt.DataDir = dataDir
|
||||
},
|
||||
},
|
||||
|
@ -861,7 +861,7 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
`-data-dir=` + dataDir,
|
||||
},
|
||||
patch: func(rt *RuntimeConfig) {
|
||||
rt.UIDir = "a"
|
||||
rt.UIConfig.Dir = "a"
|
||||
rt.DataDir = dataDir
|
||||
},
|
||||
},
|
||||
|
@ -873,7 +873,7 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
},
|
||||
|
||||
patch: func(rt *RuntimeConfig) {
|
||||
rt.UIContentPath = "/a/b/"
|
||||
rt.UIConfig.ContentPath = "/a/b/"
|
||||
rt.DataDir = dataDir
|
||||
},
|
||||
},
|
||||
|
@ -1712,7 +1712,7 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
},
|
||||
json: []string{`{ "acl_datacenter": "%" }`},
|
||||
hcl: []string{`acl_datacenter = "%"`},
|
||||
err: `acl_datacenter cannot be "%". Please use only [a-z0-9-_]`,
|
||||
err: `acl_datacenter can only contain lowercase alphanumeric, - or _ characters.`,
|
||||
warns: []string{`The 'acl_datacenter' field is deprecated. Use the 'primary_datacenter' field instead.`},
|
||||
},
|
||||
{
|
||||
|
@ -1881,7 +1881,7 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{ "datacenter": "%" }`},
|
||||
hcl: []string{`datacenter = "%"`},
|
||||
err: `datacenter cannot be "%". Please use only [a-z0-9-_]`,
|
||||
err: `datacenter can only contain lowercase alphanumeric, - or _ characters.`,
|
||||
},
|
||||
{
|
||||
desc: "dns does not allow socket",
|
||||
|
@ -1894,16 +1894,16 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
err: "DNS address cannot be a unix socket",
|
||||
},
|
||||
{
|
||||
desc: "ui and ui_dir",
|
||||
desc: "ui enabled and dir specified",
|
||||
args: []string{
|
||||
`-datacenter=a`,
|
||||
`-data-dir=` + dataDir,
|
||||
},
|
||||
json: []string{`{ "ui": true, "ui_dir": "a" }`},
|
||||
hcl: []string{`ui = true ui_dir = "a"`},
|
||||
err: "Both the ui and ui-dir flags were specified, please provide only one.\n" +
|
||||
"If trying to use your own web UI resources, use the ui-dir flag.\n" +
|
||||
"The web UI is included in the binary so use ui to enable it",
|
||||
json: []string{`{ "ui_config": { "enabled": true, "dir": "a" } }`},
|
||||
hcl: []string{`ui_config { enabled = true dir = "a"}`},
|
||||
err: "Both the ui_config.enabled and ui_config.dir (or -ui and -ui-dir) were specified, please provide only one.\n" +
|
||||
"If trying to use your own web UI resources, use ui_config.dir or the -ui-dir flag.\n" +
|
||||
"The web UI is included in the binary so use ui_config.enabled or the -ui flag to enable it",
|
||||
},
|
||||
|
||||
// test ANY address failures
|
||||
|
@ -4251,6 +4251,169 @@ func TestBuilder_BuildAndValide_ConfigFlagsAndEdgecases(t *testing.T) {
|
|||
rt.CertFile = "foo"
|
||||
},
|
||||
},
|
||||
|
||||
// UI Config tests
|
||||
{
|
||||
desc: "ui config deprecated",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui": true,
|
||||
"ui_content_path": "/bar"
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui = true
|
||||
ui_content_path = "/bar"
|
||||
`},
|
||||
warns: []string{
|
||||
`The 'ui' field is deprecated. Use the 'ui_config.enabled' field instead.`,
|
||||
`The 'ui_content_path' field is deprecated. Use the 'ui_config.content_path' field instead.`,
|
||||
},
|
||||
patch: func(rt *RuntimeConfig) {
|
||||
// Should still work!
|
||||
rt.UIConfig.Enabled = true
|
||||
rt.UIConfig.ContentPath = "/bar/"
|
||||
rt.DataDir = dataDir
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "ui-dir config deprecated",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_dir": "/bar"
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_dir = "/bar"
|
||||
`},
|
||||
warns: []string{
|
||||
`The 'ui_dir' field is deprecated. Use the 'ui_config.dir' field instead.`,
|
||||
},
|
||||
patch: func(rt *RuntimeConfig) {
|
||||
// Should still work!
|
||||
rt.UIConfig.Dir = "/bar"
|
||||
rt.DataDir = dataDir
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "metrics_provider constraint",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"metrics_provider": "((((lisp 4 life))))"
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
metrics_provider = "((((lisp 4 life))))"
|
||||
}
|
||||
`},
|
||||
err: `ui_config.metrics_provider can only contain lowercase alphanumeric, - or _ characters.`,
|
||||
},
|
||||
{
|
||||
desc: "metrics_provider_options_json invalid JSON",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"metrics_provider_options_json": "not valid JSON"
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
metrics_provider_options_json = "not valid JSON"
|
||||
}
|
||||
`},
|
||||
err: `ui_config.metrics_provider_options_json must be empty or a string containing a valid JSON object.`,
|
||||
},
|
||||
{
|
||||
desc: "metrics_provider_options_json not an object",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"metrics_provider_options_json": "1.0"
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
metrics_provider_options_json = "1.0"
|
||||
}
|
||||
`},
|
||||
err: `ui_config.metrics_provider_options_json must be empty or a string containing a valid JSON object.`,
|
||||
},
|
||||
{
|
||||
desc: "metrics_proxy.base_url valid",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"metrics_proxy": {
|
||||
"base_url": "___"
|
||||
}
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
metrics_proxy {
|
||||
base_url = "___"
|
||||
}
|
||||
}
|
||||
`},
|
||||
err: `ui_config.metrics_proxy.base_url must be a valid http or https URL.`,
|
||||
},
|
||||
{
|
||||
desc: "metrics_proxy.base_url http(s)",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"metrics_proxy": {
|
||||
"base_url": "localhost:1234"
|
||||
}
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
metrics_proxy {
|
||||
base_url = "localhost:1234"
|
||||
}
|
||||
}
|
||||
`},
|
||||
err: `ui_config.metrics_proxy.base_url must be a valid http or https URL.`,
|
||||
},
|
||||
{
|
||||
desc: "dashboard_url_templates key format",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"dashboard_url_templates": {
|
||||
"(*&ASDOUISD)": "localhost:1234"
|
||||
}
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
dashboard_url_templates {
|
||||
"(*&ASDOUISD)" = "localhost:1234"
|
||||
}
|
||||
}
|
||||
`},
|
||||
err: `ui_config.dashboard_url_templates key names can only contain lowercase alphanumeric, - or _ characters.`,
|
||||
},
|
||||
{
|
||||
desc: "dashboard_url_templates value format",
|
||||
args: []string{`-data-dir=` + dataDir},
|
||||
json: []string{`{
|
||||
"ui_config": {
|
||||
"dashboard_url_templates": {
|
||||
"services": "localhost:1234"
|
||||
}
|
||||
}
|
||||
}`},
|
||||
hcl: []string{`
|
||||
ui_config {
|
||||
dashboard_url_templates {
|
||||
services = "localhost:1234"
|
||||
}
|
||||
}
|
||||
`},
|
||||
err: `ui_config.dashboard_url_templates values must be a valid http or https URL.`,
|
||||
},
|
||||
}
|
||||
|
||||
testConfig(t, tests, dataDir)
|
||||
|
@ -5070,9 +5233,26 @@ func TestFullConfig(t *testing.T) {
|
|||
"tls_min_version": "pAOWafkR",
|
||||
"tls_prefer_server_cipher_suites": true,
|
||||
"translate_wan_addrs": true,
|
||||
"ui": true,
|
||||
"ui_dir": "11IFzAUn",
|
||||
"ui_content_path": "consul",
|
||||
"ui_config": {
|
||||
"enabled": true,
|
||||
"dir": "pVncV4Ey",
|
||||
"content_path": "qp1WRhYH",
|
||||
"metrics_provider": "sgnaoa_lower_case",
|
||||
"metrics_provider_files": ["sgnaMFoa", "dicnwkTH"],
|
||||
"metrics_provider_options_json": "{\"DIbVQadX\": 1}",
|
||||
"metrics_proxy": {
|
||||
"base_url": "http://foo.bar",
|
||||
"add_headers": [
|
||||
{
|
||||
"name": "p3nynwc9",
|
||||
"value": "TYBgnN2F"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dashboard_url_templates": {
|
||||
"u2eziu2n_lower_case": "http://lkjasd.otr"
|
||||
}
|
||||
},
|
||||
"unix_sockets": {
|
||||
"group": "8pFodrV8",
|
||||
"mode": "E8sAwOv4",
|
||||
|
@ -5736,9 +5916,26 @@ func TestFullConfig(t *testing.T) {
|
|||
tls_min_version = "pAOWafkR"
|
||||
tls_prefer_server_cipher_suites = true
|
||||
translate_wan_addrs = true
|
||||
ui = true
|
||||
ui_dir = "11IFzAUn"
|
||||
ui_content_path = "consul"
|
||||
ui_config {
|
||||
enabled = true
|
||||
dir = "pVncV4Ey"
|
||||
content_path = "qp1WRhYH"
|
||||
metrics_provider = "sgnaoa_lower_case"
|
||||
metrics_provider_files = ["sgnaMFoa", "dicnwkTH"]
|
||||
metrics_provider_options_json = "{\"DIbVQadX\": 1}"
|
||||
metrics_proxy {
|
||||
base_url = "http://foo.bar"
|
||||
add_headers = [
|
||||
{
|
||||
name = "p3nynwc9"
|
||||
value = "TYBgnN2F"
|
||||
}
|
||||
]
|
||||
}
|
||||
dashboard_url_templates {
|
||||
u2eziu2n_lower_case = "http://lkjasd.otr"
|
||||
}
|
||||
}
|
||||
unix_sockets = {
|
||||
group = "8pFodrV8"
|
||||
mode = "E8sAwOv4"
|
||||
|
@ -6110,7 +6307,6 @@ func TestFullConfig(t *testing.T) {
|
|||
EnableDebug: true,
|
||||
EnableRemoteScriptChecks: true,
|
||||
EnableLocalScriptChecks: true,
|
||||
EnableUI: true,
|
||||
EncryptKey: "A4wELWqH",
|
||||
EncryptVerifyIncoming: true,
|
||||
EncryptVerifyOutgoing: true,
|
||||
|
@ -6509,8 +6705,24 @@ func TestFullConfig(t *testing.T) {
|
|||
},
|
||||
TranslateWANAddrs: true,
|
||||
TxnMaxReqLen: 5678000000000000,
|
||||
UIContentPath: "/consul/",
|
||||
UIDir: "11IFzAUn",
|
||||
UIConfig: UIConfig{
|
||||
Enabled: true,
|
||||
Dir: "pVncV4Ey",
|
||||
ContentPath: "/qp1WRhYH/", // slashes are added in parsing
|
||||
MetricsProvider: "sgnaoa_lower_case",
|
||||
MetricsProviderFiles: []string{"sgnaMFoa", "dicnwkTH"},
|
||||
MetricsProviderOptionsJSON: "{\"DIbVQadX\": 1}",
|
||||
MetricsProxy: UIMetricsProxy{
|
||||
BaseURL: "http://foo.bar",
|
||||
AddHeaders: []UIMetricsProxyAddHeader{
|
||||
{
|
||||
Name: "p3nynwc9",
|
||||
Value: "TYBgnN2F",
|
||||
},
|
||||
},
|
||||
},
|
||||
DashboardURLTemplates: map[string]string{"u2eziu2n_lower_case": "http://lkjasd.otr"},
|
||||
},
|
||||
UnixSocketUser: "E0nB1DwA",
|
||||
UnixSocketGroup: "8pFodrV8",
|
||||
UnixSocketMode: "E8sAwOv4",
|
||||
|
@ -6589,7 +6801,7 @@ func TestFullConfig(t *testing.T) {
|
|||
// we are patching a handful of safe fields to make validation pass.
|
||||
rt.Bootstrap = false
|
||||
rt.DevMode = false
|
||||
rt.EnableUI = false
|
||||
rt.UIConfig.Enabled = false
|
||||
rt.SegmentName = ""
|
||||
rt.Segments = nil
|
||||
|
||||
|
@ -6599,7 +6811,7 @@ func TestFullConfig(t *testing.T) {
|
|||
}
|
||||
|
||||
// check the warnings
|
||||
require.ElementsMatch(t, warns, b.Warnings, "Warnings: %v", b.Warnings)
|
||||
require.ElementsMatch(t, warns, b.Warnings, "Warnings: %#v", b.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -7005,7 +7217,6 @@ func TestSanitize(t *testing.T) {
|
|||
"EnableCentralServiceConfig": false,
|
||||
"EnableLocalScriptChecks": false,
|
||||
"EnableRemoteScriptChecks": false,
|
||||
"EnableUI": false,
|
||||
"EncryptKey": "hidden",
|
||||
"EncryptVerifyIncoming": false,
|
||||
"EncryptVerifyOutgoing": false,
|
||||
|
@ -7179,8 +7390,19 @@ func TestSanitize(t *testing.T) {
|
|||
},
|
||||
"TranslateWANAddrs": false,
|
||||
"TxnMaxReqLen": 5678000000000000,
|
||||
"UIDir": "",
|
||||
"UIContentPath": "",
|
||||
"UIConfig": {
|
||||
"ContentPath": "",
|
||||
"Dir": "",
|
||||
"Enabled": false,
|
||||
"MetricsProvider": "",
|
||||
"MetricsProviderFiles": [],
|
||||
"MetricsProviderOptionsJSON": "",
|
||||
"MetricsProxy": {
|
||||
"AddHeaders": [],
|
||||
"BaseURL": ""
|
||||
},
|
||||
"DashboardURLTemplates": {}
|
||||
},
|
||||
"UnixSocketGroup": "",
|
||||
"UnixSocketMode": "",
|
||||
"UnixSocketUser": "",
|
||||
|
|
232
agent/http.go
232
agent/http.go
|
@ -1,16 +1,13 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
@ -21,8 +18,10 @@ import (
|
|||
"github.com/armon/go-metrics"
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/agent/cache"
|
||||
"github.com/hashicorp/consul/agent/config"
|
||||
"github.com/hashicorp/consul/agent/consul"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/agent/uiserver"
|
||||
"github.com/hashicorp/consul/api"
|
||||
"github.com/hashicorp/consul/lib"
|
||||
"github.com/hashicorp/consul/logging"
|
||||
|
@ -78,135 +77,12 @@ func (e ForbiddenError) Error() string {
|
|||
return "Access is restricted"
|
||||
}
|
||||
|
||||
// HTTPHandlers provides http.Handler functions for the HTTP APi.
|
||||
// HTTPHandlers provides an HTTP api for an agent.
|
||||
type HTTPHandlers struct {
|
||||
agent *Agent
|
||||
denylist *Denylist
|
||||
}
|
||||
|
||||
// bufferedFile implements os.File and allows us to modify a file from disk by
|
||||
// writing out the new version into a buffer and then serving file reads from
|
||||
// that. It assumes you are modifying a real file and presents the actual file's
|
||||
// info when queried.
|
||||
type bufferedFile struct {
|
||||
templated *bytes.Reader
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func newBufferedFile(buf *bytes.Buffer, raw http.File) *bufferedFile {
|
||||
info, _ := raw.Stat()
|
||||
return &bufferedFile{
|
||||
templated: bytes.NewReader(buf.Bytes()),
|
||||
info: info,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Read(p []byte) (n int, err error) {
|
||||
return t.templated.Read(p)
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Seek(offset int64, whence int) (int64, error) {
|
||||
return t.templated.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
return nil, errors.New("not a directory")
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Stat() (os.FileInfo, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Name() string {
|
||||
return t.info.Name()
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Size() int64 {
|
||||
return int64(t.templated.Len())
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Mode() os.FileMode {
|
||||
return t.info.Mode()
|
||||
}
|
||||
|
||||
func (t *bufferedFile) ModTime() time.Time {
|
||||
return t.info.ModTime()
|
||||
}
|
||||
|
||||
func (t *bufferedFile) IsDir() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Sys() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
type redirectFS struct {
|
||||
fs http.FileSystem
|
||||
}
|
||||
|
||||
func (fs *redirectFS) Open(name string) (http.File, error) {
|
||||
file, err := fs.fs.Open(name)
|
||||
if err != nil {
|
||||
file, err = fs.fs.Open("/index.html")
|
||||
}
|
||||
return file, err
|
||||
}
|
||||
|
||||
type settingsInjectedIndexFS struct {
|
||||
fs http.FileSystem
|
||||
UISettings map[string]interface{}
|
||||
}
|
||||
|
||||
func (fs *settingsInjectedIndexFS) Open(name string) (http.File, error) {
|
||||
file, err := fs.fs.Open(name)
|
||||
if err != nil || name != "/index.html" {
|
||||
return file, err
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed reading index.html: %s", err)
|
||||
}
|
||||
file.Seek(0, 0)
|
||||
|
||||
// Replace the placeholder in the meta ENV with the actual UI config settings.
|
||||
// Ember passes the ENV with URL encoded JSON in a meta tag. We are replacing
|
||||
// a key and value that is the encoded version of
|
||||
// `"CONSUL_UI_SETTINGS_PLACEHOLDER":"__CONSUL_UI_SETTINGS_GO_HERE__"`
|
||||
// with a URL-encoded JSON blob representing the actual config.
|
||||
|
||||
// First built an escaped, JSON blob from the settings passed.
|
||||
bs, err := json.Marshal(fs.UISettings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed marshalling UI settings JSON: %s", err)
|
||||
}
|
||||
// We want to remove the first and last chars which will be the { and } since
|
||||
// we are injecting these variabled into the middle of an existing object.
|
||||
bs = bytes.Trim(bs, "{}")
|
||||
|
||||
// We use PathEscape because we don't want spaces to be turned into "+" like
|
||||
// QueryEscape does.
|
||||
escaped := url.PathEscape(string(bs))
|
||||
|
||||
content = bytes.Replace(content,
|
||||
[]byte("%22CONSUL_UI_SETTINGS_PLACEHOLDER%22%3A%22__CONSUL_UI_SETTINGS_GO_HERE__%22"),
|
||||
[]byte(escaped), 1)
|
||||
|
||||
// We also need to inject the content path. This used to be a go template
|
||||
// hence the syntax but for now simple string replacement is fine esp. since
|
||||
// all the other templated stuff above can't easily be done that was as we are
|
||||
// replacing an entire placeholder element in an encoded JSON blob with
|
||||
// multiple encoded JSON elements.
|
||||
if path, ok := fs.UISettings["CONSUL_CONTENT_PATH"].(string); ok {
|
||||
content = bytes.Replace(content, []byte("{{.ContentPath}}"), []byte(path), -1)
|
||||
}
|
||||
|
||||
return newBufferedFile(bytes.NewBuffer(content), file), nil
|
||||
configReloaders []ConfigReloader
|
||||
h http.Handler
|
||||
}
|
||||
|
||||
// endpoint is a Consul-specific HTTP handler that takes the usual arguments in
|
||||
|
@ -249,8 +125,45 @@ func (w *wrappedMux) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
|||
w.handler.ServeHTTP(resp, req)
|
||||
}
|
||||
|
||||
// handler is used to attach our handlers to the mux
|
||||
// ReloadConfig updates any internal state when the config is changed at
|
||||
// runtime.
|
||||
func (s *HTTPHandlers) ReloadConfig(newCfg *config.RuntimeConfig) error {
|
||||
for _, r := range s.configReloaders {
|
||||
if err := r(newCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handler is used to initialize the Handler. In agent code we only ever call
|
||||
// this once during agent initialization so it was always intended as a single
|
||||
// pass init method. However many test rely on it as a cheaper way to get a
|
||||
// handler to call ServeHTTP against and end up calling it multiple times on a
|
||||
// single agent instance. Until this method had to manage state that might be
|
||||
// affected by a reload or otherwise vary over time that was not problematic
|
||||
// although it was wasteful to redo all this setup work multiple times in one
|
||||
// test.
|
||||
//
|
||||
// Now uiserver and possibly other components need to handle reloadable state
|
||||
// having test randomly clobber the state with the original config again for
|
||||
// each call gets confusing fast. So handler will memoize it's response - it's
|
||||
// allowed to call it multiple times on the same agent, but it will only do the
|
||||
// work the first time and return the same handler on subsequent calls.
|
||||
//
|
||||
// The `enableDebug` argument used in the first call will be effective and a
|
||||
// later change will not do anything. The same goes for the initial config. For
|
||||
// example if config is reloaded with UI enabled but it was not originally, the
|
||||
// http.Handler returned will still have it disabled.
|
||||
//
|
||||
// The first call must not be concurrent with any other call. Subsequent calls
|
||||
// may be concurrent with HTTP requests since no state is modified.
|
||||
func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
|
||||
// Memoize multiple calls.
|
||||
if s.h != nil {
|
||||
return s.h
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// handleFuncMetrics takes the given pattern and handler and wraps to produce
|
||||
|
@ -347,31 +260,27 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
|
|||
handlePProf("/debug/pprof/trace", pprof.Trace)
|
||||
|
||||
if s.IsUIEnabled() {
|
||||
var uifs http.FileSystem
|
||||
// Use the custom UI dir if provided.
|
||||
if s.agent.config.UIDir != "" {
|
||||
uifs = http.Dir(s.agent.config.UIDir)
|
||||
} else {
|
||||
fs := assetFS()
|
||||
uifs = fs
|
||||
}
|
||||
// Note that we _don't_ support reloading ui_config.{enabled, content_dir,
|
||||
// content_path} since this only runs at initial startup.
|
||||
|
||||
uifs = &redirectFS{fs: &settingsInjectedIndexFS{fs: uifs, UISettings: s.GetUIENVFromConfig()}}
|
||||
// create a http handler using the ui file system
|
||||
// and the headers specified by the http_config.response_headers user config
|
||||
uifsWithHeaders := serveHandlerWithHeaders(
|
||||
http.FileServer(uifs),
|
||||
uiHandler := uiserver.NewHandler(s.agent.config, s.agent.logger.Named(logging.HTTP))
|
||||
s.configReloaders = append(s.configReloaders, uiHandler.ReloadConfig)
|
||||
|
||||
// Wrap it to add the headers specified by the http_config.response_headers
|
||||
// user config
|
||||
uiHandlerWithHeaders := serveHandlerWithHeaders(
|
||||
uiHandler,
|
||||
s.agent.config.HTTPResponseHeaders,
|
||||
)
|
||||
mux.Handle(
|
||||
"/robots.txt",
|
||||
uifsWithHeaders,
|
||||
uiHandlerWithHeaders,
|
||||
)
|
||||
mux.Handle(
|
||||
s.agent.config.UIContentPath,
|
||||
s.agent.config.UIConfig.ContentPath,
|
||||
http.StripPrefix(
|
||||
s.agent.config.UIContentPath,
|
||||
uifsWithHeaders,
|
||||
s.agent.config.UIConfig.ContentPath,
|
||||
uiHandlerWithHeaders,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -384,21 +293,11 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
|
|||
h = mux
|
||||
}
|
||||
h = s.enterpriseHandler(h)
|
||||
return &wrappedMux{
|
||||
s.h = &wrappedMux{
|
||||
mux: mux,
|
||||
handler: h,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPHandlers) GetUIENVFromConfig() map[string]interface{} {
|
||||
vars := map[string]interface{}{
|
||||
"CONSUL_CONTENT_PATH": s.agent.config.UIContentPath,
|
||||
"CONSUL_ACLS_ENABLED": s.agent.config.ACLsEnabled,
|
||||
}
|
||||
|
||||
s.addEnterpriseUIENVVars(vars)
|
||||
|
||||
return vars
|
||||
return s.h
|
||||
}
|
||||
|
||||
// nodeName returns the node name of the agent
|
||||
|
@ -636,11 +535,17 @@ func (s *HTTPHandlers) marshalJSON(req *http.Request, obj interface{}) ([]byte,
|
|||
|
||||
// Returns true if the UI is enabled.
|
||||
func (s *HTTPHandlers) IsUIEnabled() bool {
|
||||
return s.agent.config.UIDir != "" || s.agent.config.EnableUI
|
||||
// Note that we _don't_ support reloading ui_config.{enabled,content_dir}
|
||||
// since this only runs at initial startup.
|
||||
return s.agent.config.UIConfig.Dir != "" || s.agent.config.UIConfig.Enabled
|
||||
}
|
||||
|
||||
// Renders a simple index page
|
||||
func (s *HTTPHandlers) Index(resp http.ResponseWriter, req *http.Request) {
|
||||
// Send special headers too since this endpoint isn't wrapped with something
|
||||
// that sends them.
|
||||
setHeaders(resp, s.agent.config.HTTPResponseHeaders)
|
||||
|
||||
// Check if this is a non-index path
|
||||
if req.URL.Path != "/" {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
|
@ -655,7 +560,12 @@ func (s *HTTPHandlers) Index(resp http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
|
||||
// Redirect to the UI endpoint
|
||||
http.Redirect(resp, req, s.agent.config.UIContentPath, http.StatusMovedPermanently) // 301
|
||||
http.Redirect(
|
||||
resp,
|
||||
req,
|
||||
s.agent.config.UIConfig.ContentPath,
|
||||
http.StatusMovedPermanently,
|
||||
) // 301
|
||||
}
|
||||
|
||||
func decodeBody(body io.Reader, out interface{}) error {
|
||||
|
|
|
@ -55,8 +55,6 @@ func (s *HTTPHandlers) rewordUnknownEnterpriseFieldError(err error) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *HTTPHandlers) addEnterpriseUIENVVars(_ map[string]interface{}) {}
|
||||
|
||||
func parseACLAuthMethodEnterpriseMeta(req *http.Request, _ *structs.ACLAuthMethodEnterpriseMeta) error {
|
||||
if methodNS := req.URL.Query().Get("authmethod-ns"); methodNS != "" {
|
||||
return BadRequestError{Reason: "Invalid query parameter: \"authmethod-ns\" - Namespaces are a Consul Enterprise feature"}
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/NYTimes/gziphandler"
|
||||
"github.com/hashicorp/consul/agent/config"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
tokenStore "github.com/hashicorp/consul/agent/token"
|
||||
"github.com/hashicorp/consul/api"
|
||||
|
@ -417,62 +418,56 @@ func TestHTTPAPI_TranslateAddrHeader(t *testing.T) {
|
|||
func TestHTTPAPIResponseHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewTestAgent(t, `
|
||||
ui_config {
|
||||
# Explicitly disable UI so we can ensure the index replacement gets headers too.
|
||||
enabled = false
|
||||
}
|
||||
http_config {
|
||||
response_headers = {
|
||||
"Access-Control-Allow-Origin" = "*"
|
||||
"X-XSS-Protection" = "1; mode=block"
|
||||
}
|
||||
}
|
||||
`)
|
||||
defer a.Shutdown()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
handler := func(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", "/v1/agent/self", nil)
|
||||
a.srv.wrap(handler, []string{"GET"})(resp, req)
|
||||
|
||||
origin := resp.Header().Get("Access-Control-Allow-Origin")
|
||||
if origin != "*" {
|
||||
t.Fatalf("bad Access-Control-Allow-Origin: expected %q, got %q", "*", origin)
|
||||
}
|
||||
|
||||
xss := resp.Header().Get("X-XSS-Protection")
|
||||
if xss != "1; mode=block" {
|
||||
t.Fatalf("bad X-XSS-Protection header: expected %q, got %q", "1; mode=block", xss)
|
||||
}
|
||||
}
|
||||
func TestUIResponseHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewTestAgent(t, `
|
||||
http_config {
|
||||
response_headers = {
|
||||
"Access-Control-Allow-Origin" = "*"
|
||||
"X-Frame-Options" = "SAMEORIGIN"
|
||||
}
|
||||
}
|
||||
`)
|
||||
defer a.Shutdown()
|
||||
|
||||
requireHasHeadersSet(t, a, "/v1/agent/self")
|
||||
|
||||
// Check the Index page that just renders a simple message with UI disabled
|
||||
// also gets the right headers.
|
||||
requireHasHeadersSet(t, a, "/")
|
||||
}
|
||||
|
||||
func requireHasHeadersSet(t *testing.T, a *TestAgent, path string) {
|
||||
t.Helper()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
handler := func(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
|
||||
return nil, nil
|
||||
req, _ := http.NewRequest("GET", path, nil)
|
||||
a.srv.handler(true).ServeHTTP(resp, req)
|
||||
|
||||
hdrs := resp.Header()
|
||||
require.Equal(t, "*", hdrs.Get("Access-Control-Allow-Origin"),
|
||||
"Access-Control-Allow-Origin header value incorrect")
|
||||
|
||||
require.Equal(t, "1; mode=block", hdrs.Get("X-XSS-Protection"),
|
||||
"X-XSS-Protection header value incorrect")
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", "/ui", nil)
|
||||
a.srv.wrap(handler, []string{"GET"})(resp, req)
|
||||
|
||||
origin := resp.Header().Get("Access-Control-Allow-Origin")
|
||||
if origin != "*" {
|
||||
t.Fatalf("bad Access-Control-Allow-Origin: expected %q, got %q", "*", origin)
|
||||
func TestUIResponseHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewTestAgent(t, `
|
||||
http_config {
|
||||
response_headers = {
|
||||
"Access-Control-Allow-Origin" = "*"
|
||||
"X-XSS-Protection" = "1; mode=block"
|
||||
"X-Frame-Options" = "SAMEORIGIN"
|
||||
}
|
||||
|
||||
frameOptions := resp.Header().Get("X-Frame-Options")
|
||||
if frameOptions != "SAMEORIGIN" {
|
||||
t.Fatalf("bad X-XSS-Protection header: expected %q, got %q", "SAMEORIGIN", frameOptions)
|
||||
}
|
||||
`)
|
||||
defer a.Shutdown()
|
||||
|
||||
requireHasHeadersSet(t, a, "/ui")
|
||||
}
|
||||
|
||||
func TestAcceptEncodingGzip(t *testing.T) {
|
||||
|
@ -1198,15 +1193,47 @@ func TestACLResolution(t *testing.T) {
|
|||
func TestEnableWebUI(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewTestAgent(t, `
|
||||
ui = true
|
||||
ui_config {
|
||||
enabled = true
|
||||
}
|
||||
`)
|
||||
defer a.Shutdown()
|
||||
|
||||
req, _ := http.NewRequest("GET", "/ui/", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
a.srv.handler(true).ServeHTTP(resp, req)
|
||||
if resp.Code != 200 {
|
||||
t.Fatalf("should handle ui")
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Validate that it actually sent the index page we expect since an error
|
||||
// during serving the special intercepted index.html can result in an empty
|
||||
// response but a 200 status.
|
||||
require.Contains(t, resp.Body.String(), `<!-- CONSUL_VERSION:`)
|
||||
|
||||
// Verify that we injected the variables we expected. The rest of injection
|
||||
// behavior is tested in the uiserver package, this just ensures it's plumbed
|
||||
// in correctly.
|
||||
require.NotContains(t, resp.Body.String(), `__RUNTIME_BOOL`)
|
||||
|
||||
// Reload the config with changed metrics provider options and verify that
|
||||
// they are present in the output.
|
||||
newHCL := `
|
||||
data_dir = "` + a.DataDir + `"
|
||||
ui_config {
|
||||
enabled = true
|
||||
metrics_provider = "valid-but-unlikely-metrics-provider-name"
|
||||
}
|
||||
`
|
||||
c := TestConfig(testutil.Logger(t), config.FileSource{Name: t.Name(), Format: "hcl", Data: newHCL})
|
||||
require.NoError(t, a.reloadConfigInternal(c))
|
||||
|
||||
// Now index requests should contain that metrics provider name.
|
||||
{
|
||||
req, _ := http.NewRequest("GET", "/ui/", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
a.srv.handler(true).ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
require.Contains(t, resp.Body.String(), `<!-- CONSUL_VERSION:`)
|
||||
require.Contains(t, resp.Body.String(), `valid-but-unlikely-metrics-provider-name`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package agent
|
||||
|
||||
import "github.com/hashicorp/consul/agent/config"
|
||||
|
||||
// ConfigReloader is a function type which may be implemented to support reloading
|
||||
// of configuration.
|
||||
type ConfigReloader func(rtConfig *config.RuntimeConfig) error
|
|
@ -213,7 +213,7 @@ func (a *TestAgent) Start(t *testing.T) (err error) {
|
|||
// Start the anti-entropy syncer
|
||||
a.Agent.StartSync()
|
||||
|
||||
a.srv = &HTTPHandlers{agent: agent, denylist: NewDenylist(a.config.HTTPBlockEndpoints)}
|
||||
a.srv = a.Agent.httpHandlers
|
||||
|
||||
if err := a.waitForUp(); err != nil {
|
||||
a.Shutdown()
|
||||
|
|
|
@ -28,18 +28,20 @@ func TestUiIndex(t *testing.T) {
|
|||
|
||||
// Make the server
|
||||
a := NewTestAgent(t, `
|
||||
ui_dir = "`+uiDir+`"
|
||||
ui_config {
|
||||
dir = "`+uiDir+`"
|
||||
}
|
||||
`)
|
||||
defer a.Shutdown()
|
||||
testrpc.WaitForLeader(t, a.RPC, "dc1")
|
||||
|
||||
// Create file
|
||||
path := filepath.Join(a.Config.UIDir, "my-file")
|
||||
if err := ioutil.WriteFile(path, []byte("test"), 0777); err != nil {
|
||||
path := filepath.Join(a.Config.UIConfig.Dir, "my-file")
|
||||
if err := ioutil.WriteFile(path, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
// Register node
|
||||
// Request the custom file
|
||||
req, _ := http.NewRequest("GET", "/ui/my-file", nil)
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = a.HTTPAddr()
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,21 @@
|
|||
package uiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// bufIndexFS is an implementation of http.FS that intercepts requests for
|
||||
// the index.html file and returns a pre-rendered file from memory.
|
||||
type bufIndexFS struct {
|
||||
fs http.FileSystem
|
||||
indexRendered []byte
|
||||
indexInfo os.FileInfo
|
||||
}
|
||||
|
||||
func (fs *bufIndexFS) Open(name string) (http.File, error) {
|
||||
if name == "/index.html" {
|
||||
return newBufferedFile(fs.indexRendered, fs.indexInfo), nil
|
||||
}
|
||||
return fs.fs.Open(name)
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package uiserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bufferedFile implements http.File and allows us to modify a file from disk by
|
||||
// writing out the new version into a buffer and then serving file reads from
|
||||
// that.
|
||||
type bufferedFile struct {
|
||||
buf *bytes.Reader
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func newBufferedFile(buf []byte, info os.FileInfo) *bufferedFile {
|
||||
return &bufferedFile{
|
||||
buf: bytes.NewReader(buf),
|
||||
info: info,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Read(p []byte) (n int, err error) {
|
||||
return t.buf.Read(p)
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Seek(offset int64, whence int) (int64, error) {
|
||||
return t.buf.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
return nil, errors.New("not a directory")
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Stat() (os.FileInfo, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Name() string {
|
||||
return t.info.Name()
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Size() int64 {
|
||||
return int64(t.buf.Len())
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Mode() os.FileMode {
|
||||
return t.info.Mode()
|
||||
}
|
||||
|
||||
func (t *bufferedFile) ModTime() time.Time {
|
||||
return t.info.ModTime()
|
||||
}
|
||||
|
||||
func (t *bufferedFile) IsDir() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *bufferedFile) Sys() interface{} {
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package uiserver
|
||||
|
||||
import "net/http"
|
||||
|
||||
// redirectFS is an http.FS that serves the index.html file for any path that is
|
||||
// not found on the underlying FS.
|
||||
//
|
||||
// TODO: it seems better to actually 404 bad paths or at least redirect them
|
||||
// rather than pretend index.html is everywhere but this is behavior changing
|
||||
// so I don't want to take it on as part of this refactor.
|
||||
type redirectFS struct {
|
||||
fs http.FileSystem
|
||||
}
|
||||
|
||||
func (fs *redirectFS) Open(name string) (http.File, error) {
|
||||
file, err := fs.fs.Open(name)
|
||||
if err != nil {
|
||||
file, err = fs.fs.Open("/index.html")
|
||||
}
|
||||
return file, err
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package uiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/hashicorp/consul/agent/config"
|
||||
)
|
||||
|
||||
// uiTemplateDataFromConfig returns the set of variables that should be injected
|
||||
// into the UI's Env based on the given runtime UI config.
|
||||
func uiTemplateDataFromConfig(cfg *config.RuntimeConfig) (map[string]interface{}, error) {
|
||||
|
||||
uiCfg := map[string]interface{}{
|
||||
"metrics_provider": cfg.UIConfig.MetricsProvider,
|
||||
// We explicitly MUST NOT pass the metrics_proxy object since it might
|
||||
// contain add_headers with secrets that the UI shouldn't know e.g. API
|
||||
// tokens for the backend. The provider should either require the proxy to
|
||||
// be configured and then use that or hit the backend directly from the
|
||||
// browser.
|
||||
"metrics_proxy_enabled": cfg.UIConfig.MetricsProxy.BaseURL != "",
|
||||
"dashboard_url_templates": cfg.UIConfig.DashboardURLTemplates,
|
||||
}
|
||||
|
||||
// Only set this if there is some actual JSON or we'll cause a JSON
|
||||
// marshalling error later during serving which ends up being silent.
|
||||
if cfg.UIConfig.MetricsProviderOptionsJSON != "" {
|
||||
uiCfg["metrics_provider_options"] = json.RawMessage(cfg.UIConfig.MetricsProviderOptionsJSON)
|
||||
}
|
||||
|
||||
d := map[string]interface{}{
|
||||
"ContentPath": cfg.UIConfig.ContentPath,
|
||||
"ACLsEnabled": cfg.ACLsEnabled,
|
||||
}
|
||||
|
||||
err := uiTemplateDataFromConfigEnterprise(cfg, d, uiCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Render uiCfg down to JSON ready to inject into the template
|
||||
bs, err := json.Marshal(uiCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed marshalling UI Env JSON: %s", err)
|
||||
}
|
||||
// Need to also URLEncode it as it is passed through a META tag value. Path
|
||||
// variant is correct to avoid converting spaces to "+". Note we don't just
|
||||
// use html/template because it strips comments and uses a different encoding
|
||||
// for this param than Ember which is OK but just one more weird thing to
|
||||
// account for in the source...
|
||||
d["UIConfigJSON"] = url.PathEscape(string(bs))
|
||||
|
||||
return d, err
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// +build !consulent
|
||||
|
||||
package uiserver
|
||||
|
||||
import "github.com/hashicorp/consul/agent/config"
|
||||
|
||||
func uiTemplateDataFromConfigEnterprise(_ *config.RuntimeConfig, _ map[string]interface{}, _ map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
package uiserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
|
||||
"github.com/hashicorp/consul/agent/config"
|
||||
"github.com/hashicorp/consul/logging"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
)
|
||||
|
||||
// Handler is the http.Handler that serves the Consul UI. It may serve from the
|
||||
// compiled-in AssetFS or from and external dir. It provides a few important
|
||||
// transformations on the index.html file and includes a proxy for metrics
|
||||
// backends.
|
||||
type Handler struct {
|
||||
// state is a reloadableState 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.
|
||||
state atomic.Value
|
||||
logger hclog.Logger
|
||||
}
|
||||
|
||||
// reloadableState encapsulates all the state that might be modified during
|
||||
// ReloadConfig.
|
||||
type reloadableState struct {
|
||||
cfg *config.UIConfig
|
||||
srv http.Handler
|
||||
err 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(agentCfg *config.RuntimeConfig, logger hclog.Logger) *Handler {
|
||||
h := &Handler{
|
||||
logger: logger.Named(logging.UIServer),
|
||||
}
|
||||
// Don't return the error since this is likely the result of a
|
||||
// misconfiguration and reloading config could fix it. Instead we'll capture
|
||||
// it and return an error for all calls to ServeHTTP so the misconfiguration
|
||||
// is visible. Sadly we can't log effectively
|
||||
if err := h.ReloadConfig(agentCfg); err != nil {
|
||||
h.state.Store(reloadableState{
|
||||
err: err,
|
||||
})
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler and serves UI HTTP requests
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: special case for compiled metrics assets in later PR
|
||||
s := h.getState()
|
||||
if s == nil {
|
||||
panic("nil state")
|
||||
}
|
||||
if s.err != nil {
|
||||
http.Error(w, "UI server is misconfigured.", http.StatusInternalServerError)
|
||||
h.logger.Error("Failed to configure UI server: %s", s.err)
|
||||
return
|
||||
}
|
||||
s.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 {
|
||||
newState := reloadableState{
|
||||
cfg: &newCfg.UIConfig,
|
||||
}
|
||||
|
||||
var fs http.FileSystem
|
||||
|
||||
if newCfg.UIConfig.Dir == "" {
|
||||
// Serve from assetFS
|
||||
fs = assetFS()
|
||||
} else {
|
||||
fs = http.Dir(newCfg.UIConfig.Dir)
|
||||
}
|
||||
|
||||
// Render a new index.html with the new config values ready to serve.
|
||||
buf, info, err := renderIndex(newCfg, fs)
|
||||
if _, ok := err.(*os.PathError); ok && newCfg.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.")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// buf can be nil in the PathError case above. We should skip this part but
|
||||
// still serve the rest of the files in that case.
|
||||
if buf != nil {
|
||||
// Create a new fs that serves the rendered index file or falls back to the
|
||||
// underlying FS.
|
||||
fs = &bufIndexFS{
|
||||
fs: fs,
|
||||
indexRendered: buf,
|
||||
indexInfo: info,
|
||||
}
|
||||
|
||||
// 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.
|
||||
fs = &redirectFS{fs: fs}
|
||||
}
|
||||
|
||||
newState.srv = http.FileServer(fs)
|
||||
|
||||
// Store the new state
|
||||
h.state.Store(newState)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getState is a helper to access the atomic internal state
|
||||
func (h *Handler) getState() *reloadableState {
|
||||
if cfg, ok := h.state.Load().(reloadableState); ok {
|
||||
return &cfg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]byte, os.FileInfo, error) {
|
||||
// Open the original index.html
|
||||
f, err := fs.Open("/index.html")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed reading index.html: %s", err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed reading metadata for index.html: %s", err)
|
||||
}
|
||||
|
||||
// Create template data from the current config.
|
||||
tplData, err := uiTemplateDataFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed loading UI config for template: %s", err)
|
||||
}
|
||||
|
||||
// Sadly we can't perform all the replacements we need with Go template
|
||||
// because some of them end up being rendered into an escaped json encoded
|
||||
// meta tag by Ember build which messes up the Go template tags. After a few
|
||||
// iterations of grossness, this seemed like the least bad for now. note we
|
||||
// have to match the encoded double quotes around the JSON string value that
|
||||
// is there as a placeholder so the end result is an actual JSON bool not a
|
||||
// string containing "false" etc.
|
||||
re := regexp.MustCompile(`%22__RUNTIME_BOOL_[A-Za-z0-9-_]+__%22`)
|
||||
|
||||
content = []byte(re.ReplaceAllStringFunc(string(content), func(str string) string {
|
||||
// Trim the prefix and __ suffix
|
||||
varName := strings.TrimSuffix(strings.TrimPrefix(str, "%22__RUNTIME_BOOL_"), "__%22")
|
||||
if v, ok := tplData[varName].(bool); ok && v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}))
|
||||
|
||||
tpl, err := template.New("index").Parse(string(content))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed parsing index.html template: %s", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = tpl.Execute(&buf, tplData)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to render index.html: %s", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), info, nil
|
||||
}
|
|
@ -0,0 +1,253 @@
|
|||
package uiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/consul/agent/config"
|
||||
"github.com/hashicorp/consul/sdk/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUIServer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *config.RuntimeConfig
|
||||
path string
|
||||
wantStatus int
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
wantEnv map[string]interface{}
|
||||
wantUICfgJSON string
|
||||
}{
|
||||
{
|
||||
name: "basic UI serving",
|
||||
cfg: basicUIEnabledConfig(),
|
||||
path: "/", // Note /index.html redirects to /
|
||||
wantStatus: http.StatusOK,
|
||||
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
||||
wantEnv: map[string]interface{}{
|
||||
"CONSUL_ACLS_ENABLED": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO: is this really what we want? It's what we've always done but
|
||||
// seems a bit odd to not do an actual 301 but instead serve the
|
||||
// index.html from every path... It also breaks the UI probably.
|
||||
name: "unknown paths to serve index",
|
||||
cfg: basicUIEnabledConfig(),
|
||||
path: "/foo-bar-bazz-qux",
|
||||
wantStatus: http.StatusOK,
|
||||
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
||||
},
|
||||
{
|
||||
name: "injecting metrics vars",
|
||||
cfg: basicUIEnabledConfig(
|
||||
withMetricsProvider("foo"),
|
||||
withMetricsProviderOptions(`{"bar":1}`),
|
||||
),
|
||||
path: "/",
|
||||
wantStatus: http.StatusOK,
|
||||
wantContains: []string{
|
||||
"<!-- CONSUL_VERSION:",
|
||||
},
|
||||
wantEnv: map[string]interface{}{
|
||||
"CONSUL_ACLS_ENABLED": false,
|
||||
},
|
||||
wantUICfgJSON: `{
|
||||
"metrics_provider": "foo",
|
||||
"metrics_provider_options": {
|
||||
"bar":1
|
||||
},
|
||||
"metrics_proxy_enabled": false,
|
||||
"dashboard_url_templates": null
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "acls enabled",
|
||||
cfg: basicUIEnabledConfig(withACLs()),
|
||||
path: "/",
|
||||
wantStatus: http.StatusOK,
|
||||
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
||||
wantEnv: map[string]interface{}{
|
||||
"CONSUL_ACLS_ENABLED": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := NewHandler(tc.cfg, testutil.Logger(t))
|
||||
|
||||
req := httptest.NewRequest("GET", tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, tc.wantStatus, rec.Code)
|
||||
for _, want := range tc.wantContains {
|
||||
require.Contains(t, rec.Body.String(), want)
|
||||
}
|
||||
for _, wantNot := range tc.wantNotContains {
|
||||
require.NotContains(t, rec.Body.String(), wantNot)
|
||||
}
|
||||
env := extractEnv(t, rec.Body.String())
|
||||
for k, v := range tc.wantEnv {
|
||||
require.Equal(t, v, env[k])
|
||||
}
|
||||
if tc.wantUICfgJSON != "" {
|
||||
require.JSONEq(t, tc.wantUICfgJSON, extractUIConfig(t, rec.Body.String()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractMetaJSON(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
|
||||
// Find and extract the env meta tag. Why yes I _am_ using regexp to parse
|
||||
// HTML thanks for asking. In this case it's HTML with a very limited format
|
||||
// so I don't feel too bad but maybe I should.
|
||||
// https://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454
|
||||
re := regexp.MustCompile(`<meta name="` + name + `+" content="([^"]*)"`)
|
||||
|
||||
matches := re.FindStringSubmatch(content)
|
||||
require.Len(t, matches, 2, "didn't find the %s meta tag", name)
|
||||
|
||||
// Unescape the JSON
|
||||
jsonStr, err := url.PathUnescape(matches[1])
|
||||
require.NoError(t, err)
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func extractEnv(t *testing.T, content string) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
js := extractMetaJSON(t, "consul-ui/config/environment", content)
|
||||
|
||||
var env map[string]interface{}
|
||||
|
||||
err := json.Unmarshal([]byte(js), &env)
|
||||
require.NoError(t, err)
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func extractUIConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
return extractMetaJSON(t, "consul-ui/ui_config", content)
|
||||
}
|
||||
|
||||
type cfgFunc func(cfg *config.RuntimeConfig)
|
||||
|
||||
func basicUIEnabledConfig(opts ...cfgFunc) *config.RuntimeConfig {
|
||||
cfg := &config.RuntimeConfig{
|
||||
UIConfig: config.UIConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
for _, f := range opts {
|
||||
f(cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func withACLs() cfgFunc {
|
||||
return func(cfg *config.RuntimeConfig) {
|
||||
cfg.ACLDatacenter = "dc1"
|
||||
cfg.ACLDefaultPolicy = "deny"
|
||||
cfg.ACLsEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func withMetricsProvider(name string) cfgFunc {
|
||||
return func(cfg *config.RuntimeConfig) {
|
||||
cfg.UIConfig.MetricsProvider = name
|
||||
}
|
||||
}
|
||||
|
||||
func withMetricsProviderOptions(jsonStr string) cfgFunc {
|
||||
return func(cfg *config.RuntimeConfig) {
|
||||
cfg.UIConfig.MetricsProviderOptionsJSON = jsonStr
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleIndexRequests validates that the buffered file mechanism works
|
||||
// beyond the first request. The initial implementation did not as it shared an
|
||||
// bytes.Reader between callers.
|
||||
func TestMultipleIndexRequests(t *testing.T) {
|
||||
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:",
|
||||
"request %d didn't return expected content", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReload(t *testing.T) {
|
||||
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t))
|
||||
|
||||
{
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:")
|
||||
require.NotContains(t, rec.Body.String(), "exotic-metrics-provider-name")
|
||||
}
|
||||
|
||||
// Reload the config with the changed metrics provider name
|
||||
newCfg := basicUIEnabledConfig(
|
||||
withMetricsProvider("exotic-metrics-provider-name"),
|
||||
)
|
||||
h.ReloadConfig(newCfg)
|
||||
|
||||
// Now we should see the new provider name in the output of index
|
||||
{
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:")
|
||||
require.Contains(t, rec.Body.String(), "exotic-metrics-provider-name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDir(t *testing.T) {
|
||||
uiDir := testutil.TempDir(t, "consul-uiserver")
|
||||
defer os.RemoveAll(uiDir)
|
||||
|
||||
path := filepath.Join(uiDir, "test-file")
|
||||
require.NoError(t, ioutil.WriteFile(path, []byte("test"), 0644))
|
||||
|
||||
cfg := basicUIEnabledConfig()
|
||||
cfg.UIConfig.Dir = uiDir
|
||||
h := NewHandler(cfg, testutil.Logger(t))
|
||||
|
||||
req := httptest.NewRequest("GET", "/test-file", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "test")
|
||||
}
|
|
@ -165,7 +165,7 @@ function build_assetfs {
|
|||
(
|
||||
tar -c pkg/web_ui GNUmakefile | docker cp - ${container_id}:/consul &&
|
||||
status "Running build in container" && docker start -i ${container_id} &&
|
||||
status "Copying back artifacts" && docker cp ${container_id}:/consul/bindata_assetfs.go ${sdir}/agent/bindata_assetfs.go
|
||||
status "Copying back artifacts" && docker cp ${container_id}:/consul/bindata_assetfs.go ${sdir}/agent/uiserver/bindata_assetfs.go
|
||||
)
|
||||
ret=$?
|
||||
docker rm ${container_id} > /dev/null
|
||||
|
|
|
@ -477,7 +477,7 @@ function build_release {
|
|||
|
||||
if is_set "${do_tag}"
|
||||
then
|
||||
git add "${sdir}/agent/bindata_assetfs.go"
|
||||
git add "${sdir}/agent/uiserver/bindata_assetfs.go"
|
||||
if test $? -ne 0
|
||||
then
|
||||
err "ERROR: Failed to git add the assetfs file"
|
||||
|
|
|
@ -99,7 +99,7 @@ function confirm_git_push_changes {
|
|||
;;
|
||||
?)
|
||||
# bindata_assetfs.go will make these meaningless
|
||||
git_diff "$(pwd)" ":!agent/bindata_assetfs.go"|| ret 1
|
||||
git_diff "$(pwd)" ":!agent/uiserver/bindata_assetfs.go"|| ret 1
|
||||
answer=""
|
||||
;;
|
||||
* )
|
||||
|
|
|
@ -39,6 +39,6 @@ github_checks:
|
|||
annotations: false
|
||||
|
||||
ignore:
|
||||
- "agent/bindata_assetfs.go"
|
||||
- "agent/uiserver/bindata_assetfs.go"
|
||||
- "vendor/**/*"
|
||||
- "**/*.pb.go"
|
||||
|
|
|
@ -52,6 +52,7 @@ const (
|
|||
TLSUtil string = "tlsutil"
|
||||
Transaction string = "txn"
|
||||
UsageMetrics string = "usage_metrics"
|
||||
UIServer string = "ui_server"
|
||||
WAN string = "wan"
|
||||
Watch string = "watch"
|
||||
Vault string = "vault"
|
||||
|
|
|
@ -120,16 +120,18 @@ module.exports = function(environment, $ = process.env) {
|
|||
});
|
||||
break;
|
||||
case environment === 'production':
|
||||
// Make sure all templated variables check for existence first
|
||||
// before outputting them, this means they all should be conditionals
|
||||
ENV = Object.assign({}, ENV, {
|
||||
// This ENV var is a special placeholder that Consul will replace
|
||||
// entirely with multiple vars from the runtime config for example
|
||||
// CONSUL_ACLs_ENABLED and CONSUL_NSPACES_ENABLED. The actual key here
|
||||
// won't really exist in the actual ember ENV when it's being served
|
||||
// through Consul. See settingsInjectedIndexFS.Open in Go code for the
|
||||
// details.
|
||||
CONSUL_UI_SETTINGS_PLACEHOLDER: "__CONSUL_UI_SETTINGS_GO_HERE__",
|
||||
// These values are placeholders that are replaced when Consul renders
|
||||
// the index.html based on runtime config. They can't use Go template
|
||||
// syntax since this object ends up JSON and URLencoded in an HTML meta
|
||||
// tag which obscured the Go template tag syntax.
|
||||
//
|
||||
// __RUNTIME_BOOL_Xxxx__ will be replaced with either "true" or "false"
|
||||
// depending on whether the named variable is true or valse in the data
|
||||
// returned from `uiTemplateDataFromConfig`.
|
||||
CONSUL_ACLS_ENABLED: '__RUNTIME_BOOL_ACLsEnabled__',
|
||||
CONSUL_SSO_ENABLED: '__RUNTIME_BOOL_SSOEnabled__',
|
||||
CONSUL_NSPACES_ENABLED: '__RUNTIME_BOOL_NSpacesEnabled__',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
module.exports = ({ appName, environment, rootURL, config }) => `
|
||||
<!-- CONSUL_VERSION: ${config.CONSUL_VERSION} -->
|
||||
<meta name="consul-ui/ui_config" content="{{ .UIConfigJSON }}" />
|
||||
|
||||
<link rel="icon" type="image/png" href="${rootURL}assets/favicon-32x32.png" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="${rootURL}assets/favicon-16x16.png" sizes="16x16">
|
||||
<link integrity="" rel="stylesheet" href="${rootURL}assets/vendor.css">
|
||||
|
|
Loading…
Reference in New Issue