Support regex in host / server

Main consideration is backward compatibility. example.com should be treated as an exact
match, where possible. So current order is: exact host, regex host, * or "".

Regex matches are cached for better performance, cache is invalidated once mappings are refreshed.
This commit is contained in:
Alexey Nesterov 2021-11-03 20:55:19 +00:00 committed by Umputun
parent 1783f540f8
commit 184d5ba87c
3 changed files with 142 additions and 7 deletions

View File

@ -30,7 +30,7 @@ Reproxy is a simple edge HTTP(s) server / reverse proxy supporting various provi
</div>
Server (host) can be set as FQDN, i.e. `s.example.com` or `*` (catch all). Requested url can be regex, for example `^/api/(.*)` and destination url may have regex matched groups in, i.e. `http://d.example.com:8080/$1`. For the example above `http://s.example.com/api/something?foo=bar` will be proxied to `http://d.example.com:8080/something?foo=bar`.
Server (host) can be set as FQDN, i.e. `s.example.com`, `*` (catch all) or a regex. Exact match takes priority, so if there are two rules with servers `example.com` and `example\.(com|org)`, request to `example.com/some/url` will match the former. Requested url can be regex, for example `^/api/(.*)` and destination url may have regex matched groups in, i.e. `http://d.example.com:8080/$1`. For the example above `http://s.example.com/api/something?foo=bar` will be proxied to `http://d.example.com:8080/something?foo=bar`.
For convenience, requests with the trailing `/` and without regex groups expanded to `/(.*)`, and destinations in those cases expanded to `/$1`. I.e. `/api/` -> `http://127.0.0.1/service` will be translated to `^/api/(.*)` -> `http://127.0.0.1/service/$1`

View File

@ -21,10 +21,11 @@ import (
// Service implements discovery with multiple providers and url matcher
type Service struct {
providers []Provider
mappers map[string][]URLMapper
lock sync.RWMutex
interval time.Duration
providers []Provider
mappers map[string][]URLMapper
mappersCache map[string][]URLMapper
lock sync.RWMutex
interval time.Duration
}
// URLMapper contains all info about source and destination routes
@ -144,6 +145,7 @@ func (s *Service) Run(ctx context.Context) error {
}
s.lock.Lock()
s.mappers = make(map[string][]URLMapper)
s.mappersCache = make(map[string][]URLMapper)
for _, m := range lst {
s.mappers[m.Server] = append(s.mappers[m.Server], m)
}
@ -161,7 +163,7 @@ func (s *Service) Match(srv, src string) (res Matches) {
lastSrcMatch := ""
for _, srvName := range []string{srv, "*", ""} {
for _, m := range s.mappers[srvName] {
for _, m := range findMatchingMappers(s, srvName) {
// if the first match found and the next src match is not identical we can stop as src match regexes presorted
if len(res.Routes) > 0 && m.SrcMatch.String() != lastSrcMatch {
@ -198,6 +200,37 @@ func (s *Service) Match(srv, src string) (res Matches) {
return res
}
func findMatchingMappers(s *Service, srvName string) []URLMapper {
// strict match - for backward compatibility
if mappers, isStrictMatch := s.mappers[srvName]; isStrictMatch {
return mappers
}
if cachedMapper, isCached := s.mappersCache[srvName]; isCached {
return cachedMapper
}
for mapperServer, mapper := range s.mappers {
// * and "" should not be treated as regex and require exact match (above)
if mapperServer == "*" || mapperServer == "" {
continue
}
re, err := regexp.Compile(mapperServer)
if err != nil {
log.Printf("[WARN] invalid regexp %s: %s", mapperServer, err)
continue
}
if re.MatchString(srvName) {
s.mappersCache[srvName] = mapper
return mapper
}
}
return nil
}
// ScheduleHealthCheck starts background loop with health-check
func (s *Service) ScheduleHealthCheck(ctx context.Context, interval time.Duration) {
log.Printf("health-check scheduled every %s", interval)

View File

@ -161,7 +161,7 @@ func TestService_Match(t *testing.T) {
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
t.Run(strconv.Itoa(i)+"-"+tt.server, func(t *testing.T) {
res := svc.Match(tt.server, tt.src)
require.Equal(t, len(tt.res.Routes), len(res.Routes), res.Routes)
for i := 0; i < len(res.Routes); i++ {
@ -173,6 +173,108 @@ func TestService_Match(t *testing.T) {
}
}
func TestService_MatchServerRegex(t *testing.T) {
mockProvider := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan ProviderID {
res := make(chan ProviderID, 1)
res <- PIFile
return res
},
ListFunc: func() ([]URLMapper, error) {
return []URLMapper{
// invalid regex
{Server: "[", SrcMatch: *regexp.MustCompile("^/"),
Dst: "http://127.0.0.10:8080/", MatchType: MTProxy, dead: false},
// regex servers
{Server: "test-prefix\\.(.*)", SrcMatch: *regexp.MustCompile("^/"),
Dst: "http://127.0.0.1:8080/", MatchType: MTProxy, dead: false},
{Server: "(.*)\\.test-domain\\.(com|org)", SrcMatch: *regexp.MustCompile("^/"),
Dst: "http://127.0.0.2:8080/", MatchType: MTProxy, dead: false},
// strict match
{Server: "test-prefix.exact.com", SrcMatch: *regexp.MustCompile("/"),
Dst: "http://127.0.0.4:8080", MatchType: MTProxy, dead: false},
}, nil
},
}
svc := NewService([]Provider{mockProvider}, time.Millisecond*100)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := svc.Run(ctx)
require.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
tbl := []struct {
server, src string
res Matches
}{
// strict match should take priority
{"test-prefix.exact.com", "/", Matches{MTProxy, []MatchedRoute{{Destination: "http://127.0.0.4:8080/", Alive: true}}}},
// regex servers
{"test-prefix.example.com", "/", Matches{MTProxy, []MatchedRoute{{Destination: "http://127.0.0.1:8080/", Alive: true}}}},
{"another-prefix.example.com", "/", Matches{MTProxy, nil}},
{"another-prefix.test-domain.org", "/", Matches{MTProxy, []MatchedRoute{{Destination: "http://127.0.0.2:8080/", Alive: true}}}},
{"another-prefix.test-domain.net", "/", Matches{MTProxy, nil}},
}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i)+"-"+tt.server, func(t *testing.T) {
res := svc.Match(tt.server, tt.src)
require.Equal(t, len(tt.res.Routes), len(res.Routes), res.Routes)
for i := 0; i < len(res.Routes); i++ {
assert.Equal(t, tt.res.Routes[i].Alive, res.Routes[i].Alive)
assert.Equal(t, tt.res.Routes[i].Destination, res.Routes[i].Destination)
}
assert.Equal(t, tt.res.MatchType, res.MatchType)
})
}
}
func TestService_MatchServerRegexInvalidateCache(t *testing.T) {
res := make(chan ProviderID)
serverRegex := "test-(.*)"
p1 := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan ProviderID {
return res
},
ListFunc: func() ([]URLMapper, error) {
return []URLMapper{
{Server: serverRegex, SrcMatch: *regexp.MustCompile("^/"), Dst: "http://127.0.0.1/foo"},
}, nil
},
}
svc := NewService([]Provider{p1}, time.Millisecond*10)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
go func() {
err := svc.Run(ctx)
require.Error(t, err)
}()
res <- PIFile
// wait for update
time.Sleep(50 * time.Millisecond)
match := svc.Match("test-server", "/")
assert.Len(t, match.Routes, 1)
serverRegex = "another-(.*)"
res <- PIFile
// wait for cache invalidation
time.Sleep(50 * time.Millisecond)
match = svc.Match("test-server", "/")
assert.Len(t, match.Routes, 0)
}
func TestService_MatchConflictRegex(t *testing.T) {
p1 := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan ProviderID {