diff --git a/README.md b/README.md index 25dc476b..21c8fa39 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ This default can be changed with labels: - `reproxy.auth` - require basic auth for the route with comma-separated `user:bcrypt_hash` pairs (generated by `htpasswd -nbB`) - `reproxy.assets` - set assets mapping as `web-root:location`, for example `reproxy.assets=/web:/var/www` - `reproxy.keep-host` - keep host header as is (`yes`, `true`, `1`) or replace with destination host (`no`, `false`, `0`) +- `reproxy.forward-health-checks` - forward `/ping` and `/health` requests to the backend instead of reproxy handling them (`yes`, `true`, `1`). Useful when the backend has its own health check endpoints with application-specific responses. - `reproxy.enabled` - enable (`yes`, `true`, `1`) or disable (`no`, `false`, `0`) container from reproxy destinations. Pls note: without `--docker.auto` the destination container has to have at least one of `reproxy.*` labels to be considered as a potential destination. @@ -172,6 +173,7 @@ This default can be changed with tags: - `reproxy.remote` - restrict access to the route with a list of comma-separated subnets or ips - `reproxy.auth` - require basic auth for the route with comma-separated `user:bcrypt_hash` pairs (generated by `htpasswd -nbB`) - `reproxy.ping` - ping path for the destination service. +- `reproxy.forward-health-checks` - forward `/ping` and `/health` requests to the backend (`true`, `yes`, `1`). - `reproxy.enabled` - enable (`yes`, `true`, `1`) or disable (`any different value`) service from reproxy destinations. ### Compose-specific details diff --git a/app/discovery/discovery.go b/app/discovery/discovery.go index 2053e9b2..eb22ebb6 100644 --- a/app/discovery/discovery.go +++ b/app/discovery/discovery.go @@ -37,9 +37,10 @@ type URLMapper struct { PingURL string MatchType MatchType RedirectType RedirectType - KeepHost *bool - OnlyFromIPs []string - AuthUsers []string // basic auth credentials as user:bcrypt_hash pairs + KeepHost *bool + ForwardHealthChecks bool + OnlyFromIPs []string + AuthUsers []string // basic auth credentials as user:bcrypt_hash pairs AssetsLocation string // local FS root location AssetsWebRoot string // web root location @@ -460,18 +461,19 @@ func (s *Service) extendMapper(m URLMapper) URLMapper { } res := URLMapper{ - Server: m.Server, - Dst: strings.TrimSuffix(m.Dst, "/") + "/$1", - ProviderID: m.ProviderID, - PingURL: m.PingURL, - MatchType: m.MatchType, - AssetsWebRoot: m.AssetsWebRoot, - AssetsLocation: m.AssetsLocation, - AssetsSPA: m.AssetsSPA, - RedirectType: m.RedirectType, - KeepHost: m.KeepHost, - OnlyFromIPs: m.OnlyFromIPs, - AuthUsers: m.AuthUsers, + Server: m.Server, + Dst: strings.TrimSuffix(m.Dst, "/") + "/$1", + ProviderID: m.ProviderID, + PingURL: m.PingURL, + MatchType: m.MatchType, + AssetsWebRoot: m.AssetsWebRoot, + AssetsLocation: m.AssetsLocation, + AssetsSPA: m.AssetsSPA, + RedirectType: m.RedirectType, + KeepHost: m.KeepHost, + ForwardHealthChecks: m.ForwardHealthChecks, + OnlyFromIPs: m.OnlyFromIPs, + AuthUsers: m.AuthUsers, } rx, err := regexp.Compile("^" + strings.TrimSuffix(src, "/") + "/(.*)") if err != nil { diff --git a/app/discovery/provider/consulcatalog/consulcatalog.go b/app/discovery/provider/consulcatalog/consulcatalog.go index b8659667..92842c1f 100644 --- a/app/discovery/provider/consulcatalog/consulcatalog.go +++ b/app/discovery/provider/consulcatalog/consulcatalog.go @@ -142,6 +142,7 @@ func (cc *ConsulCatalog) List() ([]discovery.URLMapper, error) { pingURL := fmt.Sprintf("http://%s:%d/ping", c.ServiceAddress, c.ServicePort) server := "*" var keepHost *bool + forwardHealthChecks := false onlyFrom := []string{} if v, ok := c.Labels["reproxy.enabled"]; ok && (v == "true" || v == "yes" || v == "1") { @@ -191,6 +192,10 @@ func (cc *ConsulCatalog) List() ([]discovery.URLMapper, error) { } } + if v, ok := c.Labels["reproxy.forward-health-checks"]; ok && (v == "true" || v == "yes" || v == "1") { + forwardHealthChecks = true + } + if !enabled { log.Printf("[DEBUG] service %s disabled", c.ServiceID) continue @@ -204,7 +209,8 @@ func (cc *ConsulCatalog) List() ([]discovery.URLMapper, error) { // server label may have multiple, comma separated servers for srv := range strings.SplitSeq(server, ",") { res = append(res, discovery.URLMapper{Server: strings.TrimSpace(srv), SrcMatch: *srcRegex, Dst: destURL, - PingURL: pingURL, ProviderID: discovery.PIConsulCatalog, KeepHost: keepHost, OnlyFromIPs: onlyFrom, AuthUsers: authUsers}) + PingURL: pingURL, ProviderID: discovery.PIConsulCatalog, KeepHost: keepHost, + ForwardHealthChecks: forwardHealthChecks, OnlyFromIPs: onlyFrom, AuthUsers: authUsers}) } } diff --git a/app/discovery/provider/consulcatalog/consulcatalog_test.go b/app/discovery/provider/consulcatalog/consulcatalog_test.go index 919d2501..fbf6cfe3 100644 --- a/app/discovery/provider/consulcatalog/consulcatalog_test.go +++ b/app/discovery/provider/consulcatalog/consulcatalog_test.go @@ -173,6 +173,49 @@ func TestConsulCatalog_List(t *testing.T) { assert.Equal(t, "*", res[6].Server) assert.Equal(t, &fa, res[6].KeepHost) assert.Equal(t, []string{}, res[6].AuthUsers) + + for i := range 7 { + assert.False(t, res[i].ForwardHealthChecks, "route %d should not have forward-health-checks", i) + } +} + +func TestConsulCatalog_ListForwardHealthChecks(t *testing.T) { + clientMock := &ConsulClientMock{GetFunc: func() ([]consulService, error) { + return []consulService{ + { + ServiceID: "fhc1", + ServiceName: "fhcService", + ServiceAddress: "addr-fhc", + ServicePort: 9000, + Labels: map[string]string{ + "reproxy.enabled": "true", + "reproxy.server": "fhc.example.com", + "reproxy.forward-health-checks": "true", + }, + }, + { + ServiceID: "nofhc", + ServiceName: "noFhcService", + ServiceAddress: "addr-nofhc", + ServicePort: 9001, + Labels: map[string]string{ + "reproxy.enabled": "true", + }, + }, + }, nil + }} + + cc := &ConsulCatalog{client: clientMock} + res, err := cc.List() + require.NoError(t, err) + require.Len(t, res, 2) + + fhcByServer := map[string]bool{} + for _, r := range res { + fhcByServer[r.Server] = r.ForwardHealthChecks + } + assert.True(t, fhcByServer["fhc.example.com"]) + assert.False(t, fhcByServer["*"]) } func TestConsulCatalog_serviceListWasChanged(t *testing.T) { diff --git a/app/discovery/provider/docker.go b/app/discovery/provider/docker.go index 891b4ab1..46caa0a8 100644 --- a/app/discovery/provider/docker.go +++ b/app/discovery/provider/docker.go @@ -105,6 +105,7 @@ func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper) // defaults destURL, pingURL, server := fmt.Sprintf("http://%s:%d/$1", c.IP, port), fmt.Sprintf("http://%s:%d/ping", c.IP, port), "*" assetsWebRoot, assetsLocation, assetsSPA := "", "", false + forwardHealthChecks := false onlyFrom := []string{} if d.AutoAPI && n == 0 { @@ -172,6 +173,10 @@ func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper) keepHost := d.getKeepHostValue(c.Labels, n) + if _, ok := d.labelN(c.Labels, n, "forward-health-checks"); ok { + forwardHealthChecks = true + } + if !enabled { continue } @@ -186,7 +191,7 @@ func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper) for srv := range strings.SplitSeq(server, ",") { mp := discovery.URLMapper{Server: strings.TrimSpace(srv), SrcMatch: *srcRegex, Dst: destURL, PingURL: pingURL, ProviderID: discovery.PIDocker, MatchType: discovery.MTProxy, - KeepHost: keepHost, OnlyFromIPs: onlyFrom, AuthUsers: authUsers} + KeepHost: keepHost, ForwardHealthChecks: forwardHealthChecks, OnlyFromIPs: onlyFrom, AuthUsers: authUsers} // for assets we add the second proxy mapping only if explicitly requested if assetsWebRoot != "" && explicit { diff --git a/app/discovery/provider/docker_test.go b/app/discovery/provider/docker_test.go index 25b33e02..a2a2dc9b 100644 --- a/app/discovery/provider/docker_test.go +++ b/app/discovery/provider/docker_test.go @@ -205,6 +205,65 @@ func TestDocker_ListMulti(t *testing.T) { assert.Equal(t, "^/kn/", res[7].SrcMatch.String()) assert.False(t, *res[7].KeepHost) + + for i := range 8 { + assert.False(t, res[i].ForwardHealthChecks, "route %d should not have forward-health-checks", i) + } +} + +func TestDocker_ListForwardHealthChecks(t *testing.T) { + dclient := &DockerClientMock{ + ListContainersFunc: func() ([]containerInfo, error) { + return []containerInfo{ + { + Name: "abs", State: "running", IP: "127.0.0.10", Ports: []int{8080}, + Labels: map[string]string{ + "reproxy.server": "abs.example.com", + "reproxy.route": "^/(.*)", + "reproxy.dest": "/$1", + "reproxy.forward-health-checks": "yes", + }, + }, + { + Name: "normal", State: "running", IP: "127.0.0.11", Ports: []int{8080}, + Labels: map[string]string{ + "reproxy.server": "normal.example.com", + "reproxy.route": "^/(.*)", + "reproxy.dest": "/$1", + }, + }, + { + Name: "multi", State: "running", IP: "127.0.0.12", Ports: []int{8080}, + Labels: map[string]string{ + "reproxy.0.route": "^/api/(.*)", + "reproxy.0.dest": "/api/$1", + "reproxy.1.route": "^/web/(.*)", + "reproxy.1.dest": "/web/$1", + "reproxy.1.forward-health-checks": "true", + }, + }, + }, nil + }, + } + + d := Docker{DockerClient: dclient} + res, err := d.List() + require.NoError(t, err) + require.Len(t, res, 4) + + fhcByServer := map[string]bool{} + for _, r := range res { + fhcByServer[r.Server] = r.ForwardHealthChecks + } + assert.True(t, fhcByServer["abs.example.com"], "abs.example.com should have forward-health-checks") + assert.False(t, fhcByServer["normal.example.com"], "normal.example.com should not have forward-health-checks") + + fhcByRoute := map[string]bool{} + for _, r := range res { + fhcByRoute[r.SrcMatch.String()] = r.ForwardHealthChecks + } + assert.False(t, fhcByRoute["^/api/(.*)"], "multi route 0 should not have forward-health-checks") + assert.True(t, fhcByRoute["^/web/(.*)"], "multi route 1 should have forward-health-checks") } func TestDocker_ListMultiFallBack(t *testing.T) { diff --git a/app/discovery/provider/file.go b/app/discovery/provider/file.go index 5ecdf442..385b6973 100644 --- a/app/discovery/provider/file.go +++ b/app/discovery/provider/file.go @@ -81,12 +81,13 @@ func (d *File) List() (res []discovery.URLMapper, err error) { var fileConf map[string][]struct { SourceRoute string `yaml:"route"` Dest string `yaml:"dest"` - Ping string `yaml:"ping"` - AssetsEnabled bool `yaml:"assets"` - AssetsSPA bool `yaml:"spa"` - KeepHost *bool `yaml:"keep-host,omitempty"` - OnlyFrom string `yaml:"remote"` - Auth string `yaml:"auth"` + Ping string `yaml:"ping"` + AssetsEnabled bool `yaml:"assets"` + AssetsSPA bool `yaml:"spa"` + KeepHost *bool `yaml:"keep-host,omitempty"` + ForwardHealthChecks bool `yaml:"forward-health-checks"` + OnlyFrom string `yaml:"remote"` + Auth string `yaml:"auth"` } fh, err := os.Open(d.FileName) if err != nil { @@ -109,15 +110,16 @@ func (d *File) List() (res []discovery.URLMapper, err error) { srv = "*" } mapper := discovery.URLMapper{ - Server: srv, - SrcMatch: *rx, - Dst: f.Dest, - PingURL: f.Ping, - KeepHost: f.KeepHost, - ProviderID: discovery.PIFile, - MatchType: discovery.MTProxy, - OnlyFromIPs: discovery.ParseOnlyFrom(f.OnlyFrom), - AuthUsers: discovery.ParseAuth(f.Auth), + Server: srv, + SrcMatch: *rx, + Dst: f.Dest, + PingURL: f.Ping, + KeepHost: f.KeepHost, + ForwardHealthChecks: f.ForwardHealthChecks, + ProviderID: discovery.PIFile, + MatchType: discovery.MTProxy, + OnlyFromIPs: discovery.ParseOnlyFrom(f.OnlyFrom), + AuthUsers: discovery.ParseAuth(f.Auth), } if f.AssetsEnabled || f.AssetsSPA { mapper.MatchType = discovery.MTStatic diff --git a/app/discovery/provider/file_test.go b/app/discovery/provider/file_test.go index 9ce72a51..b5f67de1 100644 --- a/app/discovery/provider/file_test.go +++ b/app/discovery/provider/file_test.go @@ -103,59 +103,79 @@ func TestFile_List(t *testing.T) { res, err := f.List() require.NoError(t, err) t.Logf("%+v", res) - assert.Len(t, res, 6) - - // sorted by server name length, auth.example.com comes first (same length as srv.example.com but alphabetically first) - assert.Equal(t, "^/api/(.*)", res[0].SrcMatch.String()) - assert.Equal(t, "http://127.0.0.4:8080/$1", res[0].Dst) - assert.Empty(t, res[0].PingURL) - assert.Equal(t, "auth.example.com", res[0].Server) - assert.Equal(t, discovery.MTProxy, res[0].MatchType) - assert.Nil(t, res[0].KeepHost) - assert.Equal(t, []string{}, res[0].OnlyFromIPs) - assert.Equal(t, []string{"user1:$2y$05$hash1", "user2:$2y$05$hash2"}, res[0].AuthUsers) - - assert.Equal(t, "^/api/svc2/(.*)", res[1].SrcMatch.String()) - assert.Equal(t, "http://127.0.0.2:8080/blah2/$1/abc", res[1].Dst) - assert.Empty(t, res[1].PingURL) - assert.Equal(t, "srv.example.com", res[1].Server) - assert.Equal(t, discovery.MTProxy, res[1].MatchType) - assert.Nil(t, res[1].KeepHost) - assert.Equal(t, []string{}, res[1].OnlyFromIPs) - assert.Equal(t, []string{}, res[1].AuthUsers) - - assert.Equal(t, "^/api/svc1/(.*)", res[2].SrcMatch.String()) - assert.Equal(t, "http://127.0.0.1:8080/blah1/$1", res[2].Dst) - assert.Empty(t, res[2].PingURL) - assert.Equal(t, "*", res[2].Server) - assert.Equal(t, discovery.MTProxy, res[2].MatchType) - assert.Nil(t, res[2].KeepHost) - assert.Equal(t, []string{}, res[2].OnlyFromIPs) - assert.Equal(t, []string{}, res[2].AuthUsers) - - assert.Equal(t, "/api/svc3/xyz", res[3].SrcMatch.String()) - assert.Equal(t, "http://127.0.0.3:8080/blah3/xyz", res[3].Dst) - assert.Equal(t, "http://127.0.0.3:8080/ping", res[3].PingURL) - assert.Equal(t, "*", res[3].Server) - assert.Equal(t, discovery.MTProxy, res[3].MatchType) - assert.Nil(t, res[3].KeepHost) - assert.Equal(t, []string{}, res[3].OnlyFromIPs) - - assert.Equal(t, "/web/", res[4].SrcMatch.String()) - assert.Equal(t, "/var/web", res[4].Dst) - assert.Empty(t, res[4].PingURL) - assert.Equal(t, "*", res[4].Server) - assert.Equal(t, discovery.MTStatic, res[4].MatchType) - assert.False(t, res[4].AssetsSPA) - assert.Equal(t, []string{"192.168.1.0/24", "124.0.0.1"}, res[4].OnlyFromIPs) - assert.True(t, *res[4].KeepHost) - - assert.Equal(t, "/web2/", res[5].SrcMatch.String()) - assert.Equal(t, "/var/web2", res[5].Dst) - assert.Empty(t, res[5].PingURL) - assert.Equal(t, "*", res[5].Server) - assert.Equal(t, discovery.MTStatic, res[5].MatchType) - assert.True(t, res[5].AssetsSPA) - assert.Empty(t, res[5].OnlyFromIPs) - assert.False(t, *res[5].KeepHost) + assert.Len(t, res, 7) + + // build a lookup by server name for the first 3 entries (same-length server names, order is non-deterministic) + byServer := map[string]discovery.URLMapper{} + for _, m := range res { + byServer[m.Server] = m + } + + authEntry := byServer["auth.example.com"] + assert.Equal(t, "^/api/(.*)", authEntry.SrcMatch.String()) + assert.Equal(t, "http://127.0.0.4:8080/$1", authEntry.Dst) + assert.Empty(t, authEntry.PingURL) + assert.Equal(t, discovery.MTProxy, authEntry.MatchType) + assert.Nil(t, authEntry.KeepHost) + assert.False(t, authEntry.ForwardHealthChecks) + assert.Equal(t, []string{}, authEntry.OnlyFromIPs) + assert.Equal(t, []string{"user1:$2y$05$hash1", "user2:$2y$05$hash2"}, authEntry.AuthUsers) + + fhcEntry := byServer["fhc.example.com"] + assert.Equal(t, "^/(.*)", fhcEntry.SrcMatch.String()) + assert.Equal(t, "http://127.0.0.5:8080/$1", fhcEntry.Dst) + assert.Empty(t, fhcEntry.PingURL) + assert.Equal(t, discovery.MTProxy, fhcEntry.MatchType) + assert.True(t, fhcEntry.ForwardHealthChecks) + + srvEntry := byServer["srv.example.com"] + assert.Equal(t, "^/api/svc2/(.*)", srvEntry.SrcMatch.String()) + assert.Equal(t, "http://127.0.0.2:8080/blah2/$1/abc", srvEntry.Dst) + assert.Empty(t, srvEntry.PingURL) + assert.Equal(t, discovery.MTProxy, srvEntry.MatchType) + assert.Nil(t, srvEntry.KeepHost) + assert.False(t, srvEntry.ForwardHealthChecks) + assert.Equal(t, []string{}, srvEntry.OnlyFromIPs) + assert.Equal(t, []string{}, srvEntry.AuthUsers) + + // the remaining entries have server "*" and are in deterministic order (sorted by route length) + starEntries := []discovery.URLMapper{} + for _, m := range res { + if m.Server == "*" { + starEntries = append(starEntries, m) + } + } + require.Len(t, starEntries, 4) + + assert.Equal(t, "^/api/svc1/(.*)", starEntries[0].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.1:8080/blah1/$1", starEntries[0].Dst) + assert.Empty(t, starEntries[0].PingURL) + assert.Equal(t, discovery.MTProxy, starEntries[0].MatchType) + assert.Nil(t, starEntries[0].KeepHost) + assert.False(t, starEntries[0].ForwardHealthChecks) + assert.Equal(t, []string{}, starEntries[0].OnlyFromIPs) + assert.Equal(t, []string{}, starEntries[0].AuthUsers) + + assert.Equal(t, "/api/svc3/xyz", starEntries[1].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.3:8080/blah3/xyz", starEntries[1].Dst) + assert.Equal(t, "http://127.0.0.3:8080/ping", starEntries[1].PingURL) + assert.Equal(t, discovery.MTProxy, starEntries[1].MatchType) + assert.Nil(t, starEntries[1].KeepHost) + assert.Equal(t, []string{}, starEntries[1].OnlyFromIPs) + + assert.Equal(t, "/web/", starEntries[2].SrcMatch.String()) + assert.Equal(t, "/var/web", starEntries[2].Dst) + assert.Empty(t, starEntries[2].PingURL) + assert.Equal(t, discovery.MTStatic, starEntries[2].MatchType) + assert.False(t, starEntries[2].AssetsSPA) + assert.Equal(t, []string{"192.168.1.0/24", "124.0.0.1"}, starEntries[2].OnlyFromIPs) + assert.True(t, *starEntries[2].KeepHost) + + assert.Equal(t, "/web2/", starEntries[3].SrcMatch.String()) + assert.Equal(t, "/var/web2", starEntries[3].Dst) + assert.Empty(t, starEntries[3].PingURL) + assert.Equal(t, discovery.MTStatic, starEntries[3].MatchType) + assert.True(t, starEntries[3].AssetsSPA) + assert.Empty(t, starEntries[3].OnlyFromIPs) + assert.False(t, *starEntries[3].KeepHost) } diff --git a/app/discovery/provider/static.go b/app/discovery/provider/static.go index 664f7490..deeef4b6 100644 --- a/app/discovery/provider/static.go +++ b/app/discovery/provider/static.go @@ -9,9 +9,9 @@ import ( "github.com/umputun/reproxy/app/discovery" ) -// Static provider, rules are server,source_url,destination[,ping] +// Static provider, rules are server,source_url,destination[,ping[,forward-health-checks]] type Static struct { - Rules []string // each rule is 4 elements comma separated - server,source_url,destination,ping + Rules []string // each rule is up to 5 elements comma separated - server,source_url,destination,ping,forward-health-checks } // Events returns channel updating once @@ -24,17 +24,21 @@ func (s *Static) Events(_ context.Context) <-chan discovery.ProviderID { // List all src dst pairs func (s *Static) List() (res []discovery.URLMapper, err error) { - // inp is 4 elements string server,source_url,destination,ping - // the last one can be omitted if no ping required + // inp is up to 5 elements string server,source_url,destination[,ping[,forward-health-checks]] + // ping and forward-health-checks can be omitted parse := func(inp string) (discovery.URLMapper, error) { elems := strings.Split(inp, ",") if len(elems) < 3 { return discovery.URLMapper{}, fmt.Errorf("invalid rule %q", inp) } pingURL := "" - if len(elems) == 4 { + if len(elems) >= 4 { pingURL = strings.TrimSpace(elems[3]) } + forwardHealthChecks := false + if len(elems) >= 5 { + forwardHealthChecks = strings.TrimSpace(elems[4]) != "" + } rx, err := regexp.Compile(strings.TrimSpace(elems[1])) if err != nil { return discovery.URLMapper{}, fmt.Errorf("can't parse regex %s: %w", elems[1], err) @@ -53,12 +57,13 @@ func (s *Static) List() (res []discovery.URLMapper, err error) { } res := discovery.URLMapper{ - Server: strings.TrimSpace(elems[0]), - SrcMatch: *rx, - Dst: dst, - PingURL: pingURL, - ProviderID: discovery.PIStatic, - MatchType: discovery.MTProxy, + Server: strings.TrimSpace(elems[0]), + SrcMatch: *rx, + Dst: dst, + PingURL: pingURL, + ForwardHealthChecks: forwardHealthChecks, + ProviderID: discovery.PIStatic, + MatchType: discovery.MTProxy, } if assets { res.MatchType = discovery.MTStatic diff --git a/app/discovery/provider/static_test.go b/app/discovery/provider/static_test.go index 0b0137be..31fa414a 100644 --- a/app/discovery/provider/static_test.go +++ b/app/discovery/provider/static_test.go @@ -16,16 +16,19 @@ func TestStatic_List(t *testing.T) { rule string server, src, dst, ping string static, spa bool + forwardHealthChecks bool err bool }{ - {"example.com,123,456, ping ", "example.com", "123", "456", "ping", false, false, false}, - {"*,123,456,", "*", "123", "456", "", false, false, false}, - {"123,456", "", "", "", "", false, false, true}, - {"123", "", "", "", "", false, false, true}, - {"example.com , 123, 456 ,ping", "example.com", "123", "456", "ping", false, false, false}, - {"example.com,123, assets:456, ping ", "example.com", "123", "456", "ping", true, false, false}, - {"example.com,123, assets:456 ", "example.com", "123", "456", "", true, false, false}, - {"example.com,123, spa:456 ", "example.com", "123", "456", "", true, true, false}, + {"example.com,123,456, ping ", "example.com", "123", "456", "ping", false, false, false, false}, + {"*,123,456,", "*", "123", "456", "", false, false, false, false}, + {"123,456", "", "", "", "", false, false, false, true}, + {"123", "", "", "", "", false, false, false, true}, + {"example.com , 123, 456 ,ping", "example.com", "123", "456", "ping", false, false, false, false}, + {"example.com,123, assets:456, ping ", "example.com", "123", "456", "ping", true, false, false, false}, + {"example.com,123, assets:456 ", "example.com", "123", "456", "", true, false, false, false}, + {"example.com,123, spa:456 ", "example.com", "123", "456", "", true, true, false, false}, + {"example.com,^/(.*),/$1,/ping,true", "example.com", "^/(.*)", "/$1", "/ping", false, false, true, false}, + {"example.com,^/(.*),/$1,,yes", "example.com", "^/(.*)", "/$1", "", false, false, true, false}, } for i, tt := range tbl { @@ -41,6 +44,7 @@ func TestStatic_List(t *testing.T) { assert.Equal(t, tt.src, res[0].SrcMatch.String()) assert.Equal(t, tt.dst, res[0].Dst) assert.Equal(t, tt.ping, res[0].PingURL) + assert.Equal(t, tt.forwardHealthChecks, res[0].ForwardHealthChecks) if tt.static { assert.Equal(t, discovery.MTStatic, res[0].MatchType) assert.Equal(t, tt.spa, res[0].AssetsSPA) diff --git a/app/discovery/provider/testdata/config.yml b/app/discovery/provider/testdata/config.yml index d9528203..8fbc15fd 100644 --- a/app/discovery/provider/testdata/config.yml +++ b/app/discovery/provider/testdata/config.yml @@ -7,3 +7,5 @@ srv.example.com: - {route: "^/api/svc2/(.*)", dest: "http://127.0.0.2:8080/blah2/$1/abc"} auth.example.com: - {route: "^/api/(.*)", dest: "http://127.0.0.4:8080/$1", auth: "user1:$2y$05$hash1, user2:$2y$05$hash2"} +fhc.example.com: + - {route: "^/(.*)", dest: "http://127.0.0.5:8080/$1", forward-health-checks: true} diff --git a/app/proxy/health.go b/app/proxy/health.go index f32e2264..eaec2265 100644 --- a/app/proxy/health.go +++ b/app/proxy/health.go @@ -14,6 +14,10 @@ import ( func (h *Http) healthMiddleware(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" && strings.EqualFold(r.URL.Path, "/health") { + if h.shouldForwardHealthChecks(r) { + next.ServeHTTP(w, r) + return + } h.healthHandler(w, r) return } @@ -66,8 +70,11 @@ func (h *Http) healthHandler(w http.ResponseWriter, _ *http.Request) { // pingHandler middleware response with pong to /ping. Stops chain if ping request detected func (h *Http) pingHandler(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" && strings.EqualFold(r.URL.Path, "/ping") { + if h.shouldForwardHealthChecks(r) { + next.ServeHTTP(w, r) + return + } w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("pong")) @@ -77,3 +84,18 @@ func (h *Http) pingHandler(next http.Handler) http.Handler { } return http.HandlerFunc(fn) } + +// shouldForwardHealthChecks checks if the request matches a route with ForwardHealthChecks enabled +func (h *Http) shouldForwardHealthChecks(r *http.Request) bool { + server := r.URL.Hostname() + if server == "" { + server = strings.Split(r.Host, ":")[0] + } + matches := h.Match(server, r.URL.EscapedPath()) + for _, route := range matches.Routes { + if route.Mapper.ForwardHealthChecks { + return true + } + } + return false +} diff --git a/app/proxy/health_test.go b/app/proxy/health_test.go index c10df6d6..62076251 100644 --- a/app/proxy/health_test.go +++ b/app/proxy/health_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "math/rand" + "net" "net/http" "net/http/httptest" "strconv" @@ -169,3 +170,96 @@ func TestHttp_pingHandler(t *testing.T) { require.NoError(t, err) assert.Equal(t, "pong", string(b)) } + +func TestHttp_pingForwardHealthChecks(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success": true}`)) + })) + defer backend.Close() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + h := Http{Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port)} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + svc := discovery.NewService([]discovery.Provider{ + &provider.Static{Rules: []string{ + fmt.Sprintf("fhc.example.com,^/(.*),%s/$1,,true", backend.URL), + "normal.example.com,^/(.*),http://127.0.0.1:9999/$1,", + }}, + }, time.Millisecond*10) + + go func() { + _ = svc.Run(ctx) + }() + time.Sleep(20 * time.Millisecond) + + h.Matcher = svc + h.Metrics = mgmt.NewMetrics(mgmt.MetricsConfig{}) + + go func() { + _ = h.Run(ctx) + }() + + client := http.Client{} + + t.Run("ping forwarded to backend when forward-health-checks enabled", func(t *testing.T) { + var resp *http.Response + require.Eventually(t, func() bool { + req, e := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d/ping", port), http.NoBody) + if e != nil { + return false + } + req.Host = "fhc.example.com" + resp, e = client.Do(req) + return e == nil + }, time.Second, 10*time.Millisecond, "server failed to start") + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + b, e := io.ReadAll(resp.Body) + require.NoError(t, e) + assert.Equal(t, `{"success": true}`, string(b)) + }) + + t.Run("health forwarded to backend when forward-health-checks enabled", func(t *testing.T) { + req, e := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d/health", port), http.NoBody) + require.NoError(t, e) + req.Host = "fhc.example.com" + resp, e := client.Do(req) + require.NoError(t, e) + defer resp.Body.Close() + b, e := io.ReadAll(resp.Body) + require.NoError(t, e) + assert.Equal(t, `{"success": true}`, string(b)) + }) + + t.Run("ping returns pong for host without forward-health-checks", func(t *testing.T) { + req, e := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d/ping", port), http.NoBody) + require.NoError(t, e) + req.Host = "normal.example.com" + resp, e := client.Do(req) + require.NoError(t, e) + defer resp.Body.Close() + b, e := io.ReadAll(resp.Body) + require.NoError(t, e) + assert.Equal(t, "pong", string(b)) + }) + + t.Run("ping returns pong for unmatched host", func(t *testing.T) { + req, e := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d/ping", port), http.NoBody) + require.NoError(t, e) + req.Host = "unknown.example.com" + resp, e := client.Do(req) + require.NoError(t, e) + defer resp.Body.Close() + b, e := io.ReadAll(resp.Body) + require.NoError(t, e) + assert.Equal(t, "pong", string(b)) + }) +}