Add UI metrics proxy (#8744)
* Fix merge conflicts * Add /v1/internal/ui/metrics-proxy API endpoint that proxies to a configured metrics provider backend.
This commit is contained in:
commit
8dd0fb836c
|
@ -12,6 +12,7 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/NYTimes/gziphandler"
|
"github.com/NYTimes/gziphandler"
|
||||||
|
@ -83,6 +84,7 @@ type HTTPHandlers struct {
|
||||||
denylist *Denylist
|
denylist *Denylist
|
||||||
configReloaders []ConfigReloader
|
configReloaders []ConfigReloader
|
||||||
h http.Handler
|
h http.Handler
|
||||||
|
metricsProxyCfg atomic.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// endpoint is a Consul-specific HTTP handler that takes the usual arguments in
|
// endpoint is a Consul-specific HTTP handler that takes the usual arguments in
|
||||||
|
@ -273,7 +275,6 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
|
||||||
if s.IsUIEnabled() {
|
if s.IsUIEnabled() {
|
||||||
// Note that we _don't_ support reloading ui_config.{enabled, content_dir,
|
// Note that we _don't_ support reloading ui_config.{enabled, content_dir,
|
||||||
// content_path} since this only runs at initial startup.
|
// content_path} since this only runs at initial startup.
|
||||||
|
|
||||||
uiHandler := uiserver.NewHandler(s.agent.config, s.agent.logger.Named(logging.HTTP))
|
uiHandler := uiserver.NewHandler(s.agent.config, s.agent.logger.Named(logging.HTTP))
|
||||||
s.configReloaders = append(s.configReloaders, uiHandler.ReloadConfig)
|
s.configReloaders = append(s.configReloaders, uiHandler.ReloadConfig)
|
||||||
|
|
||||||
|
@ -295,6 +296,12 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Initialize (reloadable) metrics proxy config
|
||||||
|
s.metricsProxyCfg.Store(s.agent.config.UIConfig.MetricsProxy)
|
||||||
|
s.configReloaders = append(s.configReloaders, func(cfg *config.RuntimeConfig) error {
|
||||||
|
s.metricsProxyCfg.Store(cfg.UIConfig.MetricsProxy)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
// Wrap the whole mux with a handler that bans URLs with non-printable
|
// Wrap the whole mux with a handler that bans URLs with non-printable
|
||||||
// characters, unless disabled explicitly to deal with old keys that fail this
|
// characters, unless disabled explicitly to deal with old keys that fail this
|
||||||
|
|
|
@ -94,6 +94,7 @@ func init() {
|
||||||
registerEndpoint("/v1/health/service/", []string{"GET"}, (*HTTPHandlers).HealthServiceNodes)
|
registerEndpoint("/v1/health/service/", []string{"GET"}, (*HTTPHandlers).HealthServiceNodes)
|
||||||
registerEndpoint("/v1/health/connect/", []string{"GET"}, (*HTTPHandlers).HealthConnectServiceNodes)
|
registerEndpoint("/v1/health/connect/", []string{"GET"}, (*HTTPHandlers).HealthConnectServiceNodes)
|
||||||
registerEndpoint("/v1/health/ingress/", []string{"GET"}, (*HTTPHandlers).HealthIngressServiceNodes)
|
registerEndpoint("/v1/health/ingress/", []string{"GET"}, (*HTTPHandlers).HealthIngressServiceNodes)
|
||||||
|
registerEndpoint("/v1/internal/ui/metrics-proxy/", []string{"GET"}, (*HTTPHandlers).UIMetricsProxy)
|
||||||
registerEndpoint("/v1/internal/ui/nodes", []string{"GET"}, (*HTTPHandlers).UINodes)
|
registerEndpoint("/v1/internal/ui/nodes", []string{"GET"}, (*HTTPHandlers).UINodes)
|
||||||
registerEndpoint("/v1/internal/ui/node/", []string{"GET"}, (*HTTPHandlers).UINodeInfo)
|
registerEndpoint("/v1/internal/ui/node/", []string{"GET"}, (*HTTPHandlers).UINodeInfo)
|
||||||
registerEndpoint("/v1/internal/ui/services", []string{"GET"}, (*HTTPHandlers).UIServices)
|
registerEndpoint("/v1/internal/ui/services", []string{"GET"}, (*HTTPHandlers).UIServices)
|
||||||
|
|
|
@ -3,12 +3,17 @@ package agent
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/consul/agent/config"
|
"github.com/hashicorp/consul/agent/config"
|
||||||
"github.com/hashicorp/consul/agent/structs"
|
"github.com/hashicorp/consul/agent/structs"
|
||||||
"github.com/hashicorp/consul/api"
|
"github.com/hashicorp/consul/api"
|
||||||
|
"github.com/hashicorp/consul/logging"
|
||||||
|
"github.com/hashicorp/go-hclog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceSummary is used to summarize a service
|
// ServiceSummary is used to summarize a service
|
||||||
|
@ -524,3 +529,80 @@ func (s *HTTPHandlers) UIGatewayIntentions(resp http.ResponseWriter, req *http.R
|
||||||
|
|
||||||
return reply.Intentions, nil
|
return reply.Intentions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UIMetricsProxy handles the /v1/internal/ui/metrics-proxy/ endpoint which, if
|
||||||
|
// configured, provides a simple read-only HTTP proxy to a single metrics
|
||||||
|
// backend to expose it to the UI.
|
||||||
|
func (s *HTTPHandlers) UIMetricsProxy(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
|
||||||
|
// Check the UI was enabled at agent startup (note this is not reloadable
|
||||||
|
// currently).
|
||||||
|
if !s.IsUIEnabled() {
|
||||||
|
return nil, NotFoundError{Reason: "UI is not enabled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reloadable proxy config
|
||||||
|
cfg, ok := s.metricsProxyCfg.Load().(config.UIMetricsProxy)
|
||||||
|
if !ok || cfg.BaseURL == "" {
|
||||||
|
// Proxy not configured
|
||||||
|
return nil, NotFoundError{Reason: "Metrics proxy is not enabled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
log := s.agent.logger.Named(logging.UIMetricsProxy)
|
||||||
|
|
||||||
|
// Construct the new URL from the path and the base path. Note we do this here
|
||||||
|
// not in the Director function below because we can handle any errors cleanly
|
||||||
|
// here.
|
||||||
|
|
||||||
|
// Replace prefix in the path
|
||||||
|
subPath := strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/metrics-proxy")
|
||||||
|
|
||||||
|
// Append that to the BaseURL (which might contain a path prefix component)
|
||||||
|
newURL := cfg.BaseURL + subPath
|
||||||
|
|
||||||
|
// Parse it into a new URL
|
||||||
|
u, err := url.Parse(newURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("couldn't parse target URL", "base_url", cfg.BaseURL, "path", subPath)
|
||||||
|
return nil, BadRequestError{Reason: "Invalid path."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the new URL path to prevent path traversal attacks and remove any
|
||||||
|
// double slashes etc.
|
||||||
|
u.Path = path.Clean(u.Path)
|
||||||
|
|
||||||
|
// Validate that the full BaseURL is still a prefix - if there was a path
|
||||||
|
// prefix on the BaseURL but an attacker tried to circumvent it with path
|
||||||
|
// traversal then the Clean above would have resolve the /../ components back
|
||||||
|
// to the actual path which means part of the prefix will now be missing.
|
||||||
|
//
|
||||||
|
// Note that in practice this is not currently possible since any /../ in the
|
||||||
|
// path would have already been resolved by the API server mux and so not even
|
||||||
|
// hit this handler. Any /../ that are far enough into the path to hit this
|
||||||
|
// handler, can't backtrack far enough to eat into the BaseURL either. But we
|
||||||
|
// leave this in anyway in case something changes in the future.
|
||||||
|
if !strings.HasPrefix(u.String(), cfg.BaseURL) {
|
||||||
|
log.Error("target URL escaped from base path",
|
||||||
|
"base_url", cfg.BaseURL,
|
||||||
|
"path", subPath,
|
||||||
|
"target_url", u.String(),
|
||||||
|
)
|
||||||
|
return nil, BadRequestError{Reason: "Invalid path."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any configured headers
|
||||||
|
for _, h := range cfg.AddHeaders {
|
||||||
|
req.Header.Set(h.Name, h.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := httputil.ReverseProxy{
|
||||||
|
Director: func(r *http.Request) {
|
||||||
|
r.URL = u
|
||||||
|
},
|
||||||
|
ErrorLog: log.StandardLogger(&hclog.StandardLoggerOptions{
|
||||||
|
InferLevels: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(resp, req)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
|
@ -3,15 +3,17 @@ package agent
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/hashicorp/consul/sdk/testutil/retry"
|
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/consul/sdk/testutil/retry"
|
||||||
|
|
||||||
"github.com/hashicorp/consul/agent/config"
|
"github.com/hashicorp/consul/agent/config"
|
||||||
"github.com/hashicorp/consul/agent/structs"
|
"github.com/hashicorp/consul/agent/structs"
|
||||||
"github.com/hashicorp/consul/api"
|
"github.com/hashicorp/consul/api"
|
||||||
|
@ -1522,3 +1524,172 @@ func TestUIServiceTopology(t *testing.T) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIEndpoint_MetricsProxy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var lastHeadersSent atomic.Value
|
||||||
|
|
||||||
|
backendH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastHeadersSent.Store(r.Header)
|
||||||
|
if r.URL.Path == "/some/prefix/ok" {
|
||||||
|
w.Header().Set("X-Random-Header", "Foo")
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/.passwd" {
|
||||||
|
w.Write([]byte("SECRETS!"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "not found on backend", http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
backend := httptest.NewServer(backendH)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
backendURL := backend.URL + "/some/prefix"
|
||||||
|
|
||||||
|
// Share one agent for all these test cases. This has a few nice side-effects:
|
||||||
|
// 1. it's cheaper
|
||||||
|
// 2. it implicitly tests that config reloading works between cases
|
||||||
|
//
|
||||||
|
// Note we can't test the case where UI is disabled though as that's not
|
||||||
|
// reloadable so we'll do that in a separate test below rather than have many
|
||||||
|
// new tests all with a new agent. response headers also aren't reloadable
|
||||||
|
// currently due to the way we wrap API endpoints at startup.
|
||||||
|
a := NewTestAgent(t, `
|
||||||
|
ui_config {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
http_config {
|
||||||
|
response_headers {
|
||||||
|
"Access-Control-Allow-Origin" = "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
defer a.Shutdown()
|
||||||
|
|
||||||
|
endpointPath := "/v1/internal/ui/metrics-proxy"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
config config.UIMetricsProxy
|
||||||
|
path string
|
||||||
|
wantCode int
|
||||||
|
wantContains string
|
||||||
|
wantHeaders map[string]string
|
||||||
|
wantHeadersSent map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled",
|
||||||
|
config: config.UIMetricsProxy{},
|
||||||
|
path: endpointPath + "/ok",
|
||||||
|
wantCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "basic proxying",
|
||||||
|
config: config.UIMetricsProxy{
|
||||||
|
BaseURL: backendURL,
|
||||||
|
},
|
||||||
|
path: endpointPath + "/ok",
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantContains: "OK",
|
||||||
|
wantHeaders: map[string]string{
|
||||||
|
"X-Random-Header": "Foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "404 on backend",
|
||||||
|
config: config.UIMetricsProxy{
|
||||||
|
BaseURL: backendURL,
|
||||||
|
},
|
||||||
|
path: endpointPath + "/random-path",
|
||||||
|
wantCode: http.StatusNotFound,
|
||||||
|
wantContains: "not found on backend",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Note that this case actually doesn't exercise our validation logic at
|
||||||
|
// all since the top level API mux resolves this to /v1/internal/.passwd
|
||||||
|
// and it never hits our handler at all. I left it in though as this
|
||||||
|
// wasn't obvious and it's worth knowing if we change something in our mux
|
||||||
|
// that might affect path traversal opportunity here. In fact this makes
|
||||||
|
// our path traversal handling somewhat redundant because any traversal
|
||||||
|
// that goes "back" far enough to traverse up from the BaseURL of the
|
||||||
|
// proxy target will in fact miss our handler entirely. It's still better
|
||||||
|
// to be safe than sorry though.
|
||||||
|
name: "path traversal should fail - api mux",
|
||||||
|
config: config.UIMetricsProxy{
|
||||||
|
BaseURL: backendURL,
|
||||||
|
},
|
||||||
|
path: endpointPath + "/../../.passwd",
|
||||||
|
wantCode: http.StatusMovedPermanently,
|
||||||
|
wantContains: "Moved Permanently",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adding auth header",
|
||||||
|
config: config.UIMetricsProxy{
|
||||||
|
BaseURL: backendURL,
|
||||||
|
AddHeaders: []config.UIMetricsProxyAddHeader{
|
||||||
|
{
|
||||||
|
Name: "Authorization",
|
||||||
|
Value: "SECRET_KEY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "X-Some-Other-Header",
|
||||||
|
Value: "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path: endpointPath + "/ok",
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantContains: "OK",
|
||||||
|
wantHeaders: map[string]string{
|
||||||
|
"X-Random-Header": "Foo",
|
||||||
|
},
|
||||||
|
wantHeadersSent: map[string]string{
|
||||||
|
"X-Some-Other-Header": "foo",
|
||||||
|
"Authorization": "SECRET_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Reload the agent config with the desired UI config by making a copy and
|
||||||
|
// using internal reload.
|
||||||
|
cfg := *a.Agent.config
|
||||||
|
|
||||||
|
// Modify the UIConfig part (this is a copy remember and that struct is
|
||||||
|
// not a pointer)
|
||||||
|
cfg.UIConfig.MetricsProxy = tc.config
|
||||||
|
|
||||||
|
require.NoError(t, a.Agent.reloadConfigInternal(&cfg))
|
||||||
|
|
||||||
|
// Now fetch the API handler to run requests against
|
||||||
|
h := a.srv.handler(true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", tc.path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, tc.wantCode, rec.Code,
|
||||||
|
"Wrong status code. Body = %s", rec.Body.String())
|
||||||
|
require.Contains(t, rec.Body.String(), tc.wantContains)
|
||||||
|
for k, v := range tc.wantHeaders {
|
||||||
|
// Headers are a slice of values, just assert that one of the values is
|
||||||
|
// the one we want.
|
||||||
|
require.Contains(t, rec.Result().Header[k], v)
|
||||||
|
}
|
||||||
|
if len(tc.wantHeadersSent) > 0 {
|
||||||
|
headersSent, ok := lastHeadersSent.Load().(http.Header)
|
||||||
|
require.True(t, ok, "backend not called")
|
||||||
|
for k, v := range tc.wantHeadersSent {
|
||||||
|
require.Contains(t, headersSent[k], v,
|
||||||
|
"header %s doesn't have the right value set", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -53,6 +53,7 @@ const (
|
||||||
Transaction string = "txn"
|
Transaction string = "txn"
|
||||||
UsageMetrics string = "usage_metrics"
|
UsageMetrics string = "usage_metrics"
|
||||||
UIServer string = "ui_server"
|
UIServer string = "ui_server"
|
||||||
|
UIMetricsProxy string = "ui_metrics_proxy"
|
||||||
WAN string = "wan"
|
WAN string = "wan"
|
||||||
Watch string = "watch"
|
Watch string = "watch"
|
||||||
Vault string = "vault"
|
Vault string = "vault"
|
||||||
|
|
Loading…
Reference in New Issue