diff --git a/README.md b/README.md index f15c082..e534f59 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Reproxy is a simple edge HTTP(s) server / reverse proxy supporting various provi -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` diff --git a/app/discovery/discovery.go b/app/discovery/discovery.go index 4e64a57..955a056 100644 --- a/app/discovery/discovery.go +++ b/app/discovery/discovery.go @@ -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) diff --git a/app/discovery/discovery_test.go b/app/discovery/discovery_test.go index 1e13dda..e05ecca 100644 --- a/app/discovery/discovery_test.go +++ b/app/discovery/discovery_test.go @@ -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 {