Skip to content

Commit

Permalink
Accept label value as regexp
Browse files Browse the repository at this point in the history
Fixes: #82
Signed-off-by: Mathieu Parent <[email protected]>
  • Loading branch information
sathieu committed Dec 12, 2023
1 parent 0664aa8 commit 646a9f5
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 25 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ prom-label-proxy \
`prom-label-proxy` will enforce the `tenant=~"prometheus|alertmanager"` label selector in all requests.
You can match the label value using a regular expression with the `-regex-match` option. For example:
```
prom-label-proxy \
-label-value '^foo-.+$' \
-label namespace \
-upstream http://demo.do.prometheus.io:9090 \
-insecure-listen-address 127.0.0.1:8080 \
-regex-match
```
> :warning: The above feature is experimental. Be careful when using this option, it can expose sensitive metrics by accident.
To error out when the query already contains a label matcher that differs from the one the proxy would inject, you can use the `-error-on-replace` option. For example:
```
prom-label-proxy \
-header-name X-Namespace \
-label namespace \
-upstream http://demo.do.prometheus.io:9090 \
-insecure-listen-address 127.0.0.1:8080 \
-error-on-replace
```
Once again for clarity: **this project only enforces a particular label in the respective calls to Prometheus, it in itself does not authenticate or
authorize the requesting entity in any way, this has to be built around this project.**
Expand Down
77 changes: 62 additions & 15 deletions injectproxy/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ type routes struct {
mux http.Handler
modifiers map[string]func(*http.Response) error
errorOnReplace bool
regexMatch bool
}

type options struct {
enableLabelAPIs bool
passthroughPaths []string
errorOnReplace bool
registerer prometheus.Registerer
regexMatch bool
}

type Option interface {
Expand Down Expand Up @@ -96,6 +98,13 @@ func WithErrorOnReplace() Option {
})
}

// WithRegexMatch causes the proxy to handle tenant name as regexp
func WithRegexMatch() Option {
return optionFunc(func(o *options) {
o.regexMatch = true
})
}

// mux abstracts away the behavior we expect from the http.ServeMux type in this package.
type mux interface {
http.Handler
Expand Down Expand Up @@ -279,6 +288,7 @@ func NewRoutes(upstream *url.URL, label string, extractLabeler ExtractLabeler, o
label: label,
el: extractLabeler,
errorOnReplace: opt.errorOnReplace,
regexMatch: opt.regexMatch,
}
mux := newStrictMux(newInstrumentedMux(http.NewServeMux(), opt.registerer))

Expand All @@ -301,22 +311,41 @@ func NewRoutes(upstream *url.URL, label string, extractLabeler ExtractLabeler, o
)
}

errs.Add(
// Reject multi label values with assertSingleLabelValue() because the
// semantics of the Silences API don't support multi-label matchers.
mux.Handle("/api/v2/silences", r.el.ExtractLabel(
enforceMethods(
assertSingleLabelValue(r.silences),
if opt.regexMatch {
errs.Add(
mux.Handle("/api/v2/silences", enforceMethods(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prometheusAPIError(w, "silences with regex match not implemented ", http.StatusNotImplemented)
}),
"GET", "POST",
),
)),
mux.Handle("/api/v2/silence/", r.el.ExtractLabel(
enforceMethods(
assertSingleLabelValue(r.deleteSilence),
)),
mux.Handle("/api/v2/silence/", enforceMethods(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prometheusAPIError(w, "silences with regex match not implemented ", http.StatusNotImplemented)
}),
"DELETE",
),
)),
)),
)
} else {
errs.Add(
// Reject multi label values with assertSingleLabelValue() because the
// semantics of the Silences API don't support multi-label matchers.
mux.Handle("/api/v2/silences", r.el.ExtractLabel(
enforceMethods(
assertSingleLabelValue(r.silences),
"GET", "POST",
),
)),
mux.Handle("/api/v2/silence/", r.el.ExtractLabel(
enforceMethods(
assertSingleLabelValue(r.deleteSilence),
"DELETE",
),
)),
)
}

errs.Add(
mux.Handle("/api/v2/alerts/groups", r.el.ExtractLabel(enforceMethods(r.enforceFilterParameter, "GET"))),
mux.Handle("/api/v2/alerts", r.el.ExtractLabel(enforceMethods(r.alerts, "GET"))),
)
Expand Down Expand Up @@ -438,16 +467,34 @@ func (r *routes) query(w http.ResponseWriter, req *http.Request) {
var matcher *labels.Matcher

if len(MustLabelValues(req.Context())) > 1 {
if r.regexMatch {
prometheusAPIError(w, "Only one label value allowed with regex match", http.StatusBadRequest)
return
}
matcher = &labels.Matcher{
Name: r.label,
Type: labels.MatchRegexp,
Value: labelValuesToRegexpString(MustLabelValues(req.Context())),
}
} else {
matcherType := labels.MatchEqual
matcherValue := MustLabelValue(req.Context())
if r.regexMatch {
compiledRegex, err := regexp.Compile(matcherValue)
if err != nil {
prometheusAPIError(w, err.Error(), http.StatusBadRequest)
return
}
if compiledRegex.MatchString("") {
prometheusAPIError(w, "Regex should not match empty string", http.StatusBadRequest)
return
}
matcherType = labels.MatchRegexp
}
matcher = &labels.Matcher{
Name: r.label,
Type: labels.MatchEqual,
Value: MustLabelValue(req.Context()),
Type: matcherType,
Value: matcherValue,
}
}

Expand Down
58 changes: 58 additions & 0 deletions injectproxy/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ func TestQuery(t *testing.T) {
expPromQueryBody string
expResponse []byte
errorOnReplace bool
regexMatch bool
}{
{
name: `No "namespace" parameter returns an error`,
Expand Down Expand Up @@ -1121,6 +1122,60 @@ func TestQuery(t *testing.T) {
expPromQuery: `up{instance="localhost:9090",namespace="default"} + foo{namespace="default"}`,
expResponse: okResponse,
},
{
name: `HTTP header as regexp`,
headers: http.Header{"namespace": []string{"tenant1-.*"}},
headerName: "namespace",
regexMatch: true,
promQuery: `up{instance="localhost:9090"} + foo{namespace="other"}`,
expCode: http.StatusOK,
expPromQuery: `up{instance="localhost:9090",namespace=~"tenant1-.*"} + foo{namespace="other",namespace=~"tenant1-.*"}`,
expResponse: okResponse,
},
{
name: `query param as regexp`,
queryParam: "namespace",
labelv: []string{"tenant1-.*"},
regexMatch: true,
promQuery: `up{instance="localhost:9090"} + foo{namespace="other"}`,
expCode: http.StatusOK,
expPromQuery: `up{instance="localhost:9090",namespace=~"tenant1-.*"} + foo{namespace="other",namespace=~"tenant1-.*"}`,
expResponse: okResponse,
},
{
name: `HTTP header as regexp with same regexp in query`,
headers: http.Header{"namespace": []string{"tenant1-.*"}},
headerName: "namespace",
regexMatch: true,
promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`,
expCode: http.StatusOK,
expPromQuery: `up{instance="localhost:9090",namespace=~"tenant1-.*"} + foo{namespace="tenant1-.*",namespace=~"tenant1-.*"}`,
expResponse: okResponse,
},
{
name: `HTTP header with invalid regexp with same regexp in query`,
headers: http.Header{"namespace": []string{"tenant1-(.*"}},
headerName: "namespace",
regexMatch: true,
promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`,
expCode: http.StatusBadRequest,
},
{
name: `Multiple regexp HTTP headers is invalid`,
headers: http.Header{"namespace": []string{"tenant1", "tenant2"}},
headerName: "namespace",
regexMatch: true,
promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`,
expCode: http.StatusBadRequest,
},
{
name: `Regex should not match empty string`,
headers: http.Header{"namespace": []string{".*"}},
headerName: "namespace",
regexMatch: true,
promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`,
expCode: http.StatusBadRequest,
},
} {
for _, endpoint := range []string{"query", "query_range", "query_exemplars"} {
t.Run(endpoint+"/"+strings.ReplaceAll(tc.name, " ", "_"), func(t *testing.T) {
Expand All @@ -1140,6 +1195,9 @@ func TestQuery(t *testing.T) {
if tc.errorOnReplace {
opts = append(opts, WithErrorOnReplace())
}
if tc.regexMatch {
opts = append(opts, WithRegexMatch())
}

var labelEnforcer ExtractLabeler
if len(tc.staticLabelVal) > 0 {
Expand Down
2 changes: 1 addition & 1 deletion injectproxy/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ func TestRules(t *testing.T) {
labelv: []string{"not_present_gzip_requested"},
upstream: gzipHandler(validRules()),
reqHeaders: map[string][]string{
"Accept-Encoding": []string{"gzip"},
"Accept-Encoding": {"gzip"},
},

expCode: http.StatusOK,
Expand Down
19 changes: 17 additions & 2 deletions injectproxy/silences.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"io"
"net/http"
"path"
"regexp"
"strconv"
"strings"

Expand Down Expand Up @@ -73,10 +74,24 @@ func (r *routes) enforceFilterParameter(w http.ResponseWriter, req *http.Request
Value: labelValuesToRegexpString(MustLabelValues(req.Context())),
}
} else {
matcherType := labels.MatchEqual
matcherValue := MustLabelValue(req.Context())
if r.regexMatch {
compiledRegex, err := regexp.Compile(matcherValue)
if err != nil {
prometheusAPIError(w, err.Error(), http.StatusBadRequest)
return
}
if compiledRegex.MatchString("") {
prometheusAPIError(w, "Regex should not match empty string", http.StatusBadRequest)
return
}
matcherType = labels.MatchRegexp
}
proxyLabelMatch = labels.Matcher{
Type: labels.MatchEqual,
Type: matcherType,
Name: r.label,
Value: MustLabelValue(req.Context()),
Value: matcherValue,
}
}

Expand Down
37 changes: 30 additions & 7 deletions injectproxy/silences_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import (

func TestListSilences(t *testing.T) {
for _, tc := range []struct {
labelv []string
filters []string
labelv []string
filters []string
regexMatch bool

expCode int
expFilters []string
Expand Down Expand Up @@ -74,11 +75,22 @@ func TestListSilences(t *testing.T) {
labelv: []string{"default", "something"},
expCode: http.StatusUnprocessableEntity,
},
{
// Regex match
labelv: []string{"tenant1-.*"},
regexMatch: true,
filters: []string{`namespace=~"foo|default"`, `job="prometheus"`},
expCode: http.StatusNotImplemented,
},
} {
t.Run(strings.Join(tc.filters, "&"), func(t *testing.T) {
m := newMockUpstream(checkQueryHandler("", "filter", tc.expFilters...))
defer m.Close()
r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel})
var opts []Option
if tc.regexMatch {
opts = append(opts, WithRegexMatch())
}
r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}, opts...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -234,9 +246,10 @@ func (c *chainedHandlers) ServeHTTP(w http.ResponseWriter, req *http.Request) {

func TestDeleteSilence(t *testing.T) {
for _, tc := range []struct {
ID string
labelv []string
upstream http.Handler
ID string
labelv []string
upstream http.Handler
regexMatch bool

expCode int
expBody []byte
Expand Down Expand Up @@ -308,11 +321,21 @@ func TestDeleteSilence(t *testing.T) {
labelv: []string{"default", "something"},
expCode: http.StatusUnprocessableEntity,
},
{
// Regexp is not supported.
labelv: []string{"default"},
regexMatch: true,
expCode: http.StatusNotImplemented,
},
} {
t.Run("", func(t *testing.T) {
m := newMockUpstream(tc.upstream)
defer m.Close()
r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel})
var opts []Option
if tc.regexMatch {
opts = append(opts, WithRegexMatch())
}
r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}, opts...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
Loading

0 comments on commit 646a9f5

Please sign in to comment.