app/vmauth: support regex matching in src_query_args (#6115)

Support regex matching when routing incoming requests based on HTTP query args
via `src_query_args` option at `url_map`.

https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6070

Signed-off-by: hagen1778 <roman@victoriametrics.com>
This commit is contained in:
Roman Khavronenko 2024-04-17 09:54:43 +02:00 committed by GitHub
parent b4d8837917
commit b155b20de4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 92 additions and 15 deletions

View File

@ -189,7 +189,7 @@ type Regex struct {
// QueryArg represents HTTP query arg
type QueryArg struct {
Name string
Value string
Value *Regex
sOriginal string
}
@ -203,10 +203,17 @@ func (qa *QueryArg) UnmarshalYAML(f func(interface{}) error) error {
qa.sOriginal = s
n := strings.IndexByte(s, '=')
if n >= 0 {
qa.Name = s[:n]
qa.Value = s[n+1:]
if n < 0 {
return nil
}
qa.Name = s[:n]
expr := []byte(s[n+1:])
var re Regex
if err := yaml.Unmarshal(expr, &re); err != nil {
return fmt.Errorf("failed to unmarshal regex %q: %s", expr, err)
}
qa.Value = &re
return nil
}

View File

@ -386,8 +386,11 @@ users:
SrcPaths: getRegexs([]string{"/api/v1/write"}),
SrcQueryArgs: []QueryArg{
{
Name: "foo",
Value: "bar",
Name: "foo",
Value: &Regex{
sOriginal: "bar",
re: regexp.MustCompile("^(?:bar)$"),
},
},
},
SrcHeaders: []Header{

View File

@ -91,8 +91,14 @@ func matchAnyQueryArg(qas []QueryArg, args url.Values) bool {
return true
}
for _, qa := range qas {
if slices.Contains(args[qa.Name], qa.Value) {
return true
vs, ok := args[qa.Name]
if !ok {
continue
}
for _, v := range vs {
if qa.Value.match(v) {
return true
}
}
}
return false

View File

@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
"reflect"
"regexp"
"strings"
"testing"
)
@ -97,8 +98,13 @@ func TestCreateTargetURLSuccess(t *testing.T) {
bu := up.getBackendURL()
target := mergeURLs(bu.url, u, up.dropSrcPathPrefixParts)
bu.put()
if target.String() != expectedTarget {
t.Fatalf("unexpected target; got %q; want %q", target, expectedTarget)
gotTarget, err := url.QueryUnescape(target.String())
if err != nil {
t.Fatalf("failed to unescape query %q: %s", target, err)
}
if gotTarget != expectedTarget {
t.Fatalf("unexpected target; \ngot:\n%q;\nwant:\n%q", gotTarget, expectedTarget)
}
if s := headersToString(hc.RequestHeaders); s != expectedRequestHeaders {
t.Fatalf("unexpected request headers; got %q; want %q", s, expectedRequestHeaders)
@ -154,7 +160,7 @@ func TestCreateTargetURLSuccess(t *testing.T) {
}, "/../../aaa", "https://sss:3894/x/y/aaa", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("https://sss:3894/x/y"),
}, "/./asd/../../aaa?a=d&s=s/../d", "https://sss:3894/x/y/aaa?a=d&s=s%2F..%2Fd", "", "", nil, "least_loaded", 0)
}, "/./asd/../../aaa?a=d&s=s/../d", "https://sss:3894/x/y/aaa?a=d&s=s/../d", "", "", nil, "least_loaded", 0)
// Complex routing with `url_map`
ui := &UserInfo{
@ -164,8 +170,11 @@ func TestCreateTargetURLSuccess(t *testing.T) {
SrcPaths: getRegexs([]string{"/vmsingle/api/v1/query"}),
SrcQueryArgs: []QueryArg{
{
Name: "db",
Value: "foo",
Name: "db",
Value: &Regex{
sOriginal: "foo",
re: regexp.MustCompile("^(?:foo)$"),
},
},
},
URLPrefix: mustParseURL("http://vmselect/0/prometheus"),
@ -249,7 +258,43 @@ func TestCreateTargetURLSuccess(t *testing.T) {
}, "/api/v1/query", "http://foo.bar/api/v1/query?extra_label=team=dev", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar?extra_label=team=mobile"),
}, "/api/v1/query?extra_label=team=dev", "http://foo.bar/api/v1/query?extra_label=team%3Dmobile", "", "", nil, "least_loaded", 0)
}, "/api/v1/query?extra_label=team=dev", "http://foo.bar/api/v1/query?extra_label=team=mobile", "", "", nil, "least_loaded", 0)
// Complex routing regexp query args in `url_map`
ui = &UserInfo{
URLMaps: []URLMap{
{
SrcPaths: getRegexs([]string{"/api/v1/query"}),
SrcQueryArgs: []QueryArg{
{
Name: "query",
Value: &Regex{
sOriginal: "foo",
re: regexp.MustCompile(`^(?:.*env="dev".*)$`),
},
},
},
URLPrefix: mustParseURL("http://vmselect/0/prometheus"),
},
{
SrcPaths: getRegexs([]string{"/api/v1/query"}),
SrcQueryArgs: []QueryArg{
{
Name: "query",
Value: &Regex{
sOriginal: "foo",
re: regexp.MustCompile(`^(?:.*env="prod".*)$`),
},
},
},
URLPrefix: mustParseURL("http://vmselect/1/prometheus"),
},
},
URLPrefix: mustParseURL("http://default-server"),
}
f(ui, `/api/v1/query?query=up{env="prod"}`, `http://vmselect/1/prometheus/api/v1/query?query=up{env="prod"}`, "", "", nil, "least_loaded", 0)
f(ui, `/api/v1/query?query=up{foo="bar", env="dev", pod!=""}`, `http://vmselect/0/prometheus/api/v1/query?query=up{foo="bar", env="dev", pod!=""}`, "", "", nil, "least_loaded", 0)
f(ui, `/api/v1/query?query=up{foo="bar"}`, `http://default-server/api/v1/query?query=up{foo="bar"}`, "", "", nil, "least_loaded", 0)
}
func TestCreateTargetURLFailure(t *testing.T) {

View File

@ -30,6 +30,8 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/).
## tip
* FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth/): support regex matching when routing incoming requests based on HTTP [query args](https://en.wikipedia.org/wiki/Query_string) via `src_query_args` option at `url_map`. See [these docs](https://docs.victoriametrics.com/vmauth/#generic-http-proxy-for-different-backends) and [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6070).
* BUGFIX: [vmalert](https://docs.victoriametrics.com/vmalert.html): supported any status codes from the range 200-299 from alertmanager. Previously, only 200 status code considered a successful action. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6110).
* BUGFIX: [vmauth](https://docs.victoriametrics.com/vmauth/): don't treat concurrency limit hit as an error of the backend. Previously, hitting the concurrency limit would increment both `vmauth_concurrent_requests_limit_reached_total` and `vmauth_user_request_backend_errors_total` counters. Now, only concurrency limit counter is incremented. Updates [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5565).

View File

@ -117,7 +117,7 @@ if the whole request path matches at least one `src_paths` entry. The incoming r
If both `src_paths` and `src_hosts` lists are specified, then the request is routed to the given `url_prefix` when both request path and request host match at least one entry
in the corresponding lists.
An optional `src_query_args` can be used for routing requests based on [HTTP query args](https://en.wikipedia.org/wiki/Query_string) additionaly to hostname and path.
An optional `src_query_args` can be used for routing requests based on [HTTP query args](https://en.wikipedia.org/wiki/Query_string) additionally to hostname and path.
For example, the following config routes requests to `http://app1-backend/` if `db=foo` query arg is present in the request,
while routing requests with `db=bar` query arg to `http://app2-backend`:
@ -135,6 +135,20 @@ If `src_query_args` contains multiple entries, then it is enough to match only a
If `src_query_args` are specified together with `src_hosts`, `src_paths` or `src_headers`, then the request is routed to the given `url_prefix`
if its query args, host, path and headers match the given lists simultaneously.
`src_query_args` supports regex matching:
```yaml
unauthorized_user:
url_map:
- src_query_args: [ "query=.*env=\"prod\".*" ]
url_prefix: "http://prod-backend/"
- src_query_args: [ "query=.*env=\"dev\".*" ]
url_prefix: "http://dev-backend/"
```
The config above will route requests like `/api/v1/query?query=up{env="prod"}` to `http://prod-backend/`.
And queries matching `.*env=\"dev\".*` will be routed to `http://dev-backend/`.
_Please note, by default Grafana sends `query` param in request's body and vmauth won't be able to read it.
You need to manually switch datasource settings in Grafana to use GET method for sending queries._
An optional `src_headers` can be used for routing requests based on HTTP request headers additionally to hostname, path and [HTTP query args](https://en.wikipedia.org/wiki/Query_string).
For example, the following config routes requests to `http://app1-backend` if `TenantID` request header equals to `42`, while routing requests to `http://app2-backend`
if `TenantID` request header equals to `123:456`: