Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accept label value as regexp #171

Merged
merged 1 commit into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 may expose sensitive metrics if you use a too permissive expression.

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
60 changes: 51 additions & 9 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 @@ -305,18 +315,21 @@ func NewRoutes(upstream *url.URL, label string, extractLabeler ExtractLabeler, o
// 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",
r.errorIfRegexpMatch(
enforceMethods(
assertSingleLabelValue(r.silences),
"GET", "POST",
),
),
)),
mux.Handle("/api/v2/silence/", r.el.ExtractLabel(
enforceMethods(
assertSingleLabelValue(r.deleteSilence),
"DELETE",
r.errorIfRegexpMatch(
enforceMethods(
assertSingleLabelValue(r.deleteSilence),
"DELETE",
),
),
)),

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 @@ -386,6 +399,17 @@ func enforceMethods(h http.HandlerFunc, methods ...string) http.HandlerFunc {
}
}

func (r *routes) errorIfRegexpMatch(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if r.regexMatch {
prometheusAPIError(w, "support for regex match not implemented", http.StatusNotImplemented)
return
}

next(w, req)
}
}

type ctxKey int

const keyLabel ctxKey = iota
Expand Down Expand Up @@ -438,16 +462,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)
sathieu marked this conversation as resolved.
Show resolved Hide resolved
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
simonpasquier marked this conversation as resolved.
Show resolved Hide resolved
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
21 changes: 21 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"net/http"
"net/url"
"os"
"regexp"
"strings"
"syscall"

Expand Down Expand Up @@ -64,6 +65,7 @@ func main() {
enableLabelAPIs bool
unsafePassthroughPaths string // Comma-delimited string.
errorOnReplace bool
regexMatch bool
)

flagset := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
Expand All @@ -81,6 +83,7 @@ func main() {
"This option is checked after Prometheus APIs, you cannot override enforced API endpoints to be not enforced with this option. Use carefully as it can easily cause a data leak if the provided path is an important "+
"API (like /api/v1/configuration) which isn't enforced by prom-label-proxy. NOTE: \"all\" matching paths like \"/\" or \"\" and regex are not allowed.")
flagset.BoolVar(&errorOnReplace, "error-on-replace", false, "When specified, the proxy will return HTTP status code 400 if the query already contains a label matcher that differs from the one the proxy would inject.")
flagset.BoolVar(&regexMatch, "regex-match", false, "When specified, the tenant name is treated as a regular expression. In this case, only one tenant name should be provided.")

//nolint: errcheck // Parse() will exit on error.
flagset.Parse(os.Args[1:])
Expand Down Expand Up @@ -126,6 +129,24 @@ func main() {
opts = append(opts, injectproxy.WithErrorOnReplace())
}

if regexMatch {
sathieu marked this conversation as resolved.
Show resolved Hide resolved
if len(labelValues) > 0 {
if len(labelValues) > 1 {
log.Fatalf("Regex match is limited to one label value")
}
compiledRegex, err := regexp.Compile(labelValues[0])
if err != nil {
log.Fatalf("Invalid regexp: %v", err.Error())
return
}
if compiledRegex.MatchString("") {
log.Fatalf("Regex should not match empty string")
return
}
}
opts = append(opts, injectproxy.WithRegexMatch())
simonpasquier marked this conversation as resolved.
Show resolved Hide resolved
}

var extractLabeler injectproxy.ExtractLabeler
switch {
case len(labelValues) > 0:
Expand Down