diff --git a/README.md b/README.md index 1bc077b..6f00e47 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,22 @@ SSL mode (by default none) can be set to `auto` (ACME/LE certificates), `static` By default no request log generated. This can be turned on by setting `--logger.enabled`. The log (auto-rotated) has [Apache Combined Log Format](http://httpd.apache.org/docs/2.2/logs.html#combined) +User can also turn stdout log on with `--logger.stdout`. It won't affect the file logging but will output some minimal info about processed requests, something like this: + +``` +2021/04/16 01:17:25.601 [INFO] GET - /echo/image.png - xxx.xxx.xxx.xxx - 200 (155400) - 371.661251ms +2021/04/16 01:18:18.959 [INFO] GET - /api/v1/params - xxx.xxx.xxx.xxx - 200 (74) - 1.217669m +``` + ## Assets Server -User may turn assets server on (off by default) to serve static files. As long as `--assets.location` set it will treat every non-proxied request under `assets.root` as a request for static files. +User may turn assets server on (off by default) to serve static files. As long as `--assets.location` set it will treat every non-proxied request under `assets.root` as a request for static files. Assets server can be used without any proxy providers. In this mode reproxy acts as a simple web server for a static context. -Assets server can be used without any proxy providers. In this mode reproxy acts as a simple web server for a static context. +In addition to the common assets server multiple custom static servers supported. Each provider has a different way to define such static rule and some providers may not support it at all. For example, multiple static server make sense in case of static (command line provide), file provider and can be even useful with docker provider. + +1. static provider - if source element prefixed by `assets:` it will be treated as file-server. For example `*,assets:/web,/var/www,` will serve all `/web/*` request with a file server on top of `/var/www` directory. +2. file provider - setting optional field `assets: true` +3. docker provider - `reproxy.assets=web-root:location`, i.e. `reproxy.assets=/web:/var/www`. ## More options diff --git a/app/discovery/discovery.go b/app/discovery/discovery.go index cc73381..930aef0 100644 --- a/app/discovery/discovery.go +++ b/app/discovery/discovery.go @@ -32,6 +32,10 @@ type URLMapper struct { Dst string ProviderID ProviderID PingURL string + MatchType MatchType + + AssetsLocation string + AssetsWebRoot string } // Provider defines sources of mappers @@ -50,6 +54,26 @@ const ( PIFile ProviderID = "file" ) +// MatchType defines the type of mapper (rule) +type MatchType int + +// enum of all match types +const ( + MTProxy MatchType = iota + MTStatic +) + +func (m MatchType) String() string { + switch m { + case MTProxy: + return "proxy" + case MTStatic: + return "static" + default: + return "unknown" + } +} + // NewService makes service with given providers func NewService(providers []Provider, interval time.Duration) *Service { return &Service{providers: providers, interval: interval} @@ -79,7 +103,7 @@ func (s *Service) Run(ctx context.Context) error { evRecv = false lst := s.mergeLists() for _, m := range lst { - log.Printf("[INFO] match for %s: %s %s -> %s", m.ProviderID, m.Server, m.SrcMatch.String(), m.Dst) + log.Printf("[INFO] match for %s: %s %s -> %s (%s)", m.ProviderID, m.Server, m.SrcMatch.String(), m.Dst, m.MatchType) } s.lock.Lock() s.mappers = make(map[string][]URLMapper) @@ -92,19 +116,39 @@ func (s *Service) Run(ctx context.Context) error { } // Match url to all mappers -func (s *Service) Match(srv, src string) (string, bool) { +func (s *Service) Match(srv, src string) (string, MatchType, bool) { s.lock.RLock() defer s.lock.RUnlock() + + var staticRules []URLMapper for _, srvName := range []string{srv, "*", ""} { for _, m := range s.mappers[srvName] { + if m.MatchType == MTStatic { // collect static for + staticRules = append(staticRules, m) + continue + } dest := m.SrcMatch.ReplaceAllString(src, m.Dst) if src != dest { - return dest, true + return dest, m.MatchType, true } } } - return src, false + + // process static rules after all regular proxy rules as we want to prioritize regular rules + // static rule returns a pair (separated by :) of assets location:assets web root + for _, m := range staticRules { + dest := m.SrcMatch.ReplaceAllString(src, m.Dst) + if src == dest { // try to match with trialing / to match web root requests, i.e. /web (without trailing /) + dest := m.SrcMatch.ReplaceAllString(src+"/", m.Dst) + if src+"/" == dest { + continue + } + } + return m.AssetsWebRoot + ":" + m.AssetsLocation, MTStatic, true + } + + return src, MTProxy, false } // Servers return list of all servers, skips "*" (catch-all/default) @@ -158,19 +202,30 @@ func (s *Service) mergeLists() (res []URLMapper) { func (s *Service) extendMapper(m URLMapper) URLMapper { src := m.SrcMatch.String() + m.Dst = strings.Replace(m.Dst, "@", "$", -1) // allow group defined as @n instead of $n (yaml friendly) - // TODO: Probably should be ok in practice but we better figure a nicer way to do it - if strings.Contains(m.Dst, "$1") || strings.Contains(m.Dst, "@1") || - strings.Contains(src, "(") || !strings.HasSuffix(src, "/") { + if m.MatchType == MTStatic && m.AssetsWebRoot == "" && m.AssetsLocation == "" { + m.AssetsWebRoot = strings.TrimSuffix(src, "/") + m.AssetsLocation = strings.TrimSuffix(m.Dst, "/") + "/" + } - m.Dst = strings.Replace(m.Dst, "@", "$", -1) // allow group defined as @n instead of $n + // don't extend src and dst with dst or src regex groups + if strings.Contains(m.Dst, "$") || strings.Contains(m.Dst, "@") || strings.Contains(src, "(") { return m } + + if !strings.HasSuffix(src, "/") && m.MatchType == MTProxy { + return m + } + res := URLMapper{ - Server: m.Server, - Dst: strings.TrimSuffix(m.Dst, "/") + "/$1", - ProviderID: m.ProviderID, - PingURL: m.PingURL, + Server: m.Server, + Dst: strings.TrimSuffix(m.Dst, "/") + "/$1", + ProviderID: m.ProviderID, + PingURL: m.PingURL, + MatchType: m.MatchType, + AssetsWebRoot: m.AssetsWebRoot, + AssetsLocation: m.AssetsLocation, } rx, err := regexp.Compile("^" + strings.TrimSuffix(src, "/") + "/(.*)") @@ -212,3 +267,13 @@ func (s *Service) mergeEvents(ctx context.Context, chs ...<-chan ProviderID) <-c }() return out } + +// Contains checks if the input string (e) in the given slice +func Contains(e string, s []string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/app/discovery/discovery_test.go b/app/discovery/discovery_test.go index 8bb7032..66b63fe 100644 --- a/app/discovery/discovery_test.go +++ b/app/discovery/discovery_test.go @@ -81,6 +81,8 @@ func TestService_Match(t *testing.T) { ListFunc: func() ([]URLMapper, error) { return []URLMapper{ {SrcMatch: *regexp.MustCompile("/api/svc3/xyz"), Dst: "http://127.0.0.3:8080/blah3/xyz", ProviderID: PIDocker}, + {SrcMatch: *regexp.MustCompile("/web"), Dst: "/var/web", ProviderID: PIDocker, MatchType: MTStatic}, + {SrcMatch: *regexp.MustCompile("/www/"), Dst: "/var/web", ProviderID: PIDocker, MatchType: MTStatic}, }, nil }, } @@ -91,27 +93,35 @@ func TestService_Match(t *testing.T) { err := svc.Run(ctx) require.Error(t, err) assert.Equal(t, context.DeadlineExceeded, err) - assert.Equal(t, 3, len(svc.Mappers())) + assert.Equal(t, 5, len(svc.Mappers())) tbl := []struct { server, src string dest string + mt MatchType ok bool }{ - {"example.com", "/api/svc3/xyz/something", "http://127.0.0.3:8080/blah3/xyz/something", true}, - {"example.com", "/api/svc3/xyz", "http://127.0.0.3:8080/blah3/xyz", true}, - {"abc.example.com", "/api/svc1/1234", "http://127.0.0.1:8080/blah1/1234", true}, - {"zzz.example.com", "/aaa/api/svc1/1234", "/aaa/api/svc1/1234", false}, - {"m.example.com", "/api/svc2/1234", "http://127.0.0.2:8080/blah2/1234/abc", true}, - {"m1.example.com", "/api/svc2/1234", "/api/svc2/1234", false}, + {"example.com", "/api/svc3/xyz/something", "http://127.0.0.3:8080/blah3/xyz/something", MTProxy, true}, + {"example.com", "/api/svc3/xyz", "http://127.0.0.3:8080/blah3/xyz", MTProxy, true}, + {"abc.example.com", "/api/svc1/1234", "http://127.0.0.1:8080/blah1/1234", MTProxy, true}, + {"zzz.example.com", "/aaa/api/svc1/1234", "/aaa/api/svc1/1234", MTProxy, false}, + {"m.example.com", "/api/svc2/1234", "http://127.0.0.2:8080/blah2/1234/abc", MTProxy, true}, + {"m1.example.com", "/api/svc2/1234", "/api/svc2/1234", MTProxy, false}, + {"m1.example.com", "/web/index.html", "/web:/var/web/", MTStatic, true}, + {"m1.example.com", "/web/", "/web:/var/web/", MTStatic, true}, + {"m1.example.com", "/www", "/www:/var/web/", MTStatic, true}, + {"m1.example.com", "/www/something", "/www:/var/web/", MTStatic, true}, } for i, tt := range tbl { tt := tt t.Run(strconv.Itoa(i), func(t *testing.T) { - res, ok := svc.Match(tt.server, tt.src) + res, mt, ok := svc.Match(tt.server, tt.src) assert.Equal(t, tt.ok, ok) assert.Equal(t, tt.dest, res) + if ok { + assert.Equal(t, tt.mt, mt) + } }) } } diff --git a/app/discovery/provider/docker.go b/app/discovery/provider/docker.go index 3e214c2..1b5c623 100644 --- a/app/discovery/provider/docker.go +++ b/app/discovery/provider/docker.go @@ -91,6 +91,7 @@ func (d *Docker) List() ([]discovery.URLMapper, error) { destURL := fmt.Sprintf("http://%s:%d/$1", c.IP, port) pingURL := fmt.Sprintf("http://%s:%d/ping", c.IP, port) server := "*" + assetsWebRoot, assetsLocation := "", "" // we don't care about value because disabled will be filtered before if _, ok := c.Labels["reproxy.enabled"]; ok { @@ -117,6 +118,14 @@ func (d *Docker) List() ([]discovery.URLMapper, error) { pingURL = fmt.Sprintf("http://%s:%d%s", c.IP, port, v) } + if v, ok := c.Labels["reproxy.assets"]; ok { + if ae := strings.Split(v, ":"); len(ae) == 2 { + enabled = true + assetsWebRoot = ae[0] + assetsLocation = ae[1] + } + } + if !enabled { log.Printf("[DEBUG] container %s disabled", c.Name) continue @@ -129,8 +138,16 @@ func (d *Docker) List() ([]discovery.URLMapper, error) { // docker server label may have multiple, comma separated servers for _, srv := range strings.Split(server, ",") { - res = append(res, discovery.URLMapper{Server: strings.TrimSpace(srv), SrcMatch: *srcRegex, Dst: destURL, - PingURL: pingURL, ProviderID: discovery.PIDocker}) + mp := discovery.URLMapper{Server: strings.TrimSpace(srv), SrcMatch: *srcRegex, Dst: destURL, + PingURL: pingURL, ProviderID: discovery.PIDocker, MatchType: discovery.MTProxy} + + if assetsWebRoot != "" { + mp.MatchType = discovery.MTStatic + mp.AssetsWebRoot = assetsWebRoot + mp.AssetsLocation = assetsLocation + } + + res = append(res, mp) } } @@ -184,7 +201,7 @@ func (d *Docker) events(ctx context.Context, client DockerClient, eventsCh chan log.Printf("[DEBUG] api event %+v", ev) containerName := strings.TrimPrefix(ev.Actor.Attributes["name"], "/") - if contains(containerName, d.Excludes) { + if discovery.Contains(containerName, d.Excludes) { log.Printf("[DEBUG] container %s excluded", containerName) continue } @@ -213,12 +230,12 @@ func (d *Docker) listContainers() (res []containerInfo, err error) { log.Printf("[DEBUG] total containers = %d", len(containers)) for _, c := range containers { - if !contains(c.State, []string{"running"}) { + if c.State != "running" { log.Printf("[DEBUG] skip container %s due to state %s", c.Names[0], c.State) continue } containerName := strings.TrimPrefix(c.Names[0], "/") - if contains(containerName, d.Excludes) || strings.EqualFold(containerName, "reproxy") { + if discovery.Contains(containerName, d.Excludes) || strings.EqualFold(containerName, "reproxy") { log.Printf("[DEBUG] container %s excluded", containerName) continue } @@ -263,12 +280,3 @@ func (d *Docker) listContainers() (res []containerInfo, err error) { log.Print("[DEBUG] completed list") return res, nil } - -func contains(e string, s []string) bool { - for _, a := range s { - if a == e { - return true - } - } - return false -} diff --git a/app/discovery/provider/file.go b/app/discovery/provider/file.go index 75559f5..5701b7c 100644 --- a/app/discovery/provider/file.go +++ b/app/discovery/provider/file.go @@ -70,9 +70,10 @@ func (d *File) Events(ctx context.Context) <-chan discovery.ProviderID { 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"` + SourceRoute string `yaml:"route"` + Dest string `yaml:"dest"` + Ping string `yaml:"ping"` + AssetsEnabled bool `yaml:"assets"` } fh, err := os.Open(d.FileName) if err != nil { @@ -94,7 +95,17 @@ func (d *File) List() (res []discovery.URLMapper, err error) { if srv == "default" { srv = "*" } - mapper := discovery.URLMapper{Server: srv, SrcMatch: *rx, Dst: f.Dest, PingURL: f.Ping, ProviderID: discovery.PIFile} + mapper := discovery.URLMapper{ + Server: srv, + SrcMatch: *rx, + Dst: f.Dest, + PingURL: f.Ping, + ProviderID: discovery.PIFile, + MatchType: discovery.MTProxy, + } + if f.AssetsEnabled { + mapper.MatchType = discovery.MTStatic + } res = append(res, mapper) } } diff --git a/app/discovery/provider/file_test.go b/app/discovery/provider/file_test.go index 0d1465d..e9b54fd 100644 --- a/app/discovery/provider/file_test.go +++ b/app/discovery/provider/file_test.go @@ -83,7 +83,7 @@ func TestFile_Events_BusyListener(t *testing.T) { // exhaust creation and one write event for i := 0; i < 2; i++ { t.Log("event") - <- ch + <-ch } // wait until last write definitely has happened @@ -105,20 +105,25 @@ func TestFile_List(t *testing.T) { res, err := f.List() require.NoError(t, err) t.Logf("%+v", res) - assert.Equal(t, 3, len(res)) + assert.Equal(t, 4, len(res)) assert.Equal(t, "/api/svc3/xyz", res[0].SrcMatch.String()) assert.Equal(t, "http://127.0.0.3:8080/blah3/xyz", res[0].Dst) assert.Equal(t, "http://127.0.0.3:8080/ping", res[0].PingURL) assert.Equal(t, "*", res[0].Server) - assert.Equal(t, "^/api/svc1/(.*)", res[1].SrcMatch.String()) - assert.Equal(t, "http://127.0.0.1:8080/blah1/$1", res[1].Dst) + assert.Equal(t, "/web/", res[1].SrcMatch.String()) + assert.Equal(t, "/var/web", res[1].Dst) assert.Equal(t, "", res[1].PingURL) assert.Equal(t, "*", res[1].Server) - assert.Equal(t, "^/api/svc2/(.*)", res[2].SrcMatch.String()) - assert.Equal(t, "http://127.0.0.2:8080/blah2/$1/abc", res[2].Dst) + assert.Equal(t, "^/api/svc1/(.*)", res[2].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.1:8080/blah1/$1", res[2].Dst) assert.Equal(t, "", res[2].PingURL) - assert.Equal(t, "srv.example.com", res[2].Server) + assert.Equal(t, "*", res[2].Server) + + assert.Equal(t, "^/api/svc2/(.*)", res[3].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.2:8080/blah2/$1/abc", res[3].Dst) + assert.Equal(t, "", res[3].PingURL) + assert.Equal(t, "srv.example.com", res[3].Server) } diff --git a/app/discovery/provider/static.go b/app/discovery/provider/static.go index 4456490..12968f5 100644 --- a/app/discovery/provider/static.go +++ b/app/discovery/provider/static.go @@ -34,13 +34,26 @@ func (s *Static) List() (res []discovery.URLMapper, err error) { return discovery.URLMapper{}, fmt.Errorf("can't parse regex %s: %w", elems[1], err) } - return discovery.URLMapper{ + dst := strings.TrimSpace(elems[2]) + assets := false + if strings.HasPrefix(dst, "assets:") { + dst = strings.TrimPrefix(dst, "assets:") + assets = true + } + + res := discovery.URLMapper{ Server: strings.TrimSpace(elems[0]), SrcMatch: *rx, - Dst: strings.TrimSpace(elems[2]), + Dst: dst, PingURL: strings.TrimSpace(elems[3]), ProviderID: discovery.PIStatic, - }, nil + MatchType: discovery.MTProxy, + } + if assets { + res.MatchType = discovery.MTStatic + } + + return res, nil } for _, r := range s.Rules { diff --git a/app/discovery/provider/static_test.go b/app/discovery/provider/static_test.go index e397ee6..2638a20 100644 --- a/app/discovery/provider/static_test.go +++ b/app/discovery/provider/static_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/umputun/reproxy/app/discovery" ) func TestStatic_List(t *testing.T) { @@ -13,13 +15,15 @@ func TestStatic_List(t *testing.T) { tbl := []struct { rule string server, src, dst, ping string + static bool err bool }{ - {"example.com,123,456, ping ", "example.com", "123", "456", "ping", false}, - {"*,123,456,", "*", "123", "456", "", false}, - {"123,456", "", "", "", "", true}, - {"123", "", "", "", "", true}, - {"example.com , 123, 456 ,ping", "example.com", "123", "456", "ping", false}, + {"example.com,123,456, ping ", "example.com", "123", "456", "ping", false, false}, + {"*,123,456,", "*", "123", "456", "", false, false}, + {"123,456", "", "", "", "", false, true}, + {"123", "", "", "", "", false, true}, + {"example.com , 123, 456 ,ping", "example.com", "123", "456", "ping", false, false}, + {"example.com,123, assets:456, ping ", "example.com", "123", "456", "ping", true, false}, } for i, tt := range tbl { @@ -36,6 +40,11 @@ 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) + if tt.static { + assert.Equal(t, discovery.MTStatic, res[0].MatchType) + } else { + assert.Equal(t, discovery.MTProxy, res[0].MatchType) + } }) } diff --git a/app/discovery/provider/testdata/config.yml b/app/discovery/provider/testdata/config.yml index 6d4bb1d..5f3200c 100644 --- a/app/discovery/provider/testdata/config.yml +++ b/app/discovery/provider/testdata/config.yml @@ -1,5 +1,6 @@ default: - {route: "^/api/svc1/(.*)", dest: "http://127.0.0.1:8080/blah1/$1"} - {route: "/api/svc3/xyz", dest: "http://127.0.0.3:8080/blah3/xyz", "ping": "http://127.0.0.3:8080/ping"} + - {route: "/web/", dest: "/var/web", "static": yes} srv.example.com: - {route: "^/api/svc2/(.*)", dest: "http://127.0.0.2:8080/blah2/$1/abc"} diff --git a/app/main.go b/app/main.go index c913a53..39484d0 100644 --- a/app/main.go +++ b/app/main.go @@ -6,9 +6,9 @@ import ( "fmt" "io" "io/ioutil" + "net/http" "os" "os/signal" - "runtime" "strings" "syscall" "time" @@ -103,7 +103,15 @@ func main() { } setupLog(opts.Dbg) - catchSignal() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { // catch signal and invoke graceful termination + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + log.Printf("[WARN] interrupt signal") + cancel() + }() providers, err := makeProviders() if err != nil { @@ -163,7 +171,11 @@ func main() { ResponseHeader: opts.Timeouts.ResponseHeader, }, } - if err := px.Run(context.Background()); err != nil { + if err := px.Run(ctx); err != nil { + if err == http.ErrServerClosed { + log.Printf("[WARN] proxy server closed, %v", err) //nolint gocritic + return + } log.Fatalf("[ERROR] proxy server failed, %v", err) //nolint gocritic } } @@ -258,20 +270,3 @@ func setupLog(dbg bool) { } log.Setup(log.Msec, log.LevelBraces) } - -func catchSignal() { - // catch SIGQUIT and print stack traces - sigChan := make(chan os.Signal) - go func() { - for range sigChan { - log.Print("[INFO] SIGQUIT detected") - stacktrace := make([]byte, 8192) - length := runtime.Stack(stacktrace, true) - if length > 8192 { - length = 8192 - } - fmt.Println(string(stacktrace[:length])) - } - }() - signal.Notify(sigChan, syscall.SIGQUIT) -} diff --git a/app/main_test.go b/app/main_test.go new file mode 100644 index 0000000..0ea87b5 --- /dev/null +++ b/app/main_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "io/ioutil" + "math/rand" + "net" + "net/http" + "os" + "strconv" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Main(t *testing.T) { + + port := chooseRandomUnusedPort() + os.Args = []string{"test", "--static.enabled", + "--static.rule=*,/svc1, https://httpbin.org/get,https://feedmaster.umputun.com/ping", + "--dbg", "--logger.stdout", "--listen=127.0.0.1:" + strconv.Itoa(port), "--signature"} + + done := make(chan struct{}) + go func() { + <-done + e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + require.NoError(t, e) + }() + + finished := make(chan struct{}) + go func() { + main() + close(finished) + }() + + // defer cleanup because require check below can fail + defer func() { + close(done) + <-finished + }() + + waitForHTTPServerStart(port) + time.Sleep(time.Second) + + { + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/ping", port)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, "pong", string(body)) + } + { + client := http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/svc1", port)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Contains(t, string(body), `"Host": "127.0.0.1"`) + } + { + client := http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/bas", port)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + } +} + +func chooseRandomUnusedPort() (port int) { + for i := 0; i < 10; i++ { + port = 40000 + int(rand.Int31n(10000)) + if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil { + _ = ln.Close() + break + } + } + return port +} + +func waitForHTTPServerStart(port int) { + // wait for up to 10 seconds for server to start before returning it + client := http.Client{Timeout: time.Second} + for i := 0; i < 100; i++ { + time.Sleep(time.Millisecond * 100) + if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil { + _ = resp.Body.Close() + return + } + } +} diff --git a/app/proxy/proxy.go b/app/proxy/proxy.go index 860b740..9ff89ff 100644 --- a/app/proxy/proxy.go +++ b/app/proxy/proxy.go @@ -41,7 +41,7 @@ type Http struct { // nolint golint // Matcher source info (server and route) to the destination url // If no match found return ok=false type Matcher interface { - Match(srv, src string) (string, bool) + Match(srv, src string) (string, discovery.MatchType, bool) Servers() (servers []string) Mappers() (mappers []discovery.URLMapper) } @@ -194,20 +194,36 @@ func (h *Http) proxyHandler() http.HandlerFunc { if server == "" { server = strings.Split(r.Host, ":")[0] } - u, ok := h.Match(server, r.URL.Path) + u, mt, ok := h.Match(server, r.URL.Path) if !ok { assetsHandler.ServeHTTP(w, r) return } - uu, err := url.Parse(u) - if err != nil { - http.Error(w, "Server error", http.StatusBadGateway) - return + switch mt { + case discovery.MTProxy: + uu, err := url.Parse(u) + if err != nil { + http.Error(w, "Server error", http.StatusBadGateway) + return + } + log.Printf("[DEBUG] proxy to %s", uu) + ctx := context.WithValue(r.Context(), contextKey("url"), uu) // set destination url in request's context + reverseProxy.ServeHTTP(w, r.WithContext(ctx)) + case discovery.MTStatic: + // static match result has webroot:location, i.e. /www:/var/somedir/ + ae := strings.Split(u, ":") + if len(ae) != 2 { // shouldn't happen + http.Error(w, "Server error", http.StatusInternalServerError) + return + } + fs, err := R.FileServer(ae[0], ae[1]) + if err != nil { + http.Error(w, "Server error", http.StatusBadGateway) + return + } + fs.ServeHTTP(w, r) } - - ctx := context.WithValue(r.Context(), contextKey("url"), uu) // set destination url in request's context - reverseProxy.ServeHTTP(w, r.WithContext(ctx)) } } diff --git a/app/proxy/proxy_test.go b/app/proxy/proxy_test.go index 2a18baa..be622ae 100644 --- a/app/proxy/proxy_test.go +++ b/app/proxy/proxy_test.go @@ -21,7 +21,7 @@ import ( func TestHttp_Do(t *testing.T) { port := rand.Intn(10000) + 40000 h := Http{Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port), - AccessLog: io.Discard, Signature: true, ProxyHeaders: []string{"hh1:vv1", "hh2:vv2"}} + AccessLog: io.Discard, Signature: true, ProxyHeaders: []string{"hh1:vv1", "hh2:vv2"}, StdOutEnabled: true} ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() @@ -154,7 +154,72 @@ func TestHttp_DoWithAssets(t *testing.T) { assert.Equal(t, "test html", string(body)) assert.Equal(t, "", resp.Header.Get("App-Name")) assert.Equal(t, "", resp.Header.Get("h1")) + } +} + +func TestHttp_DoWithAssetRules(t *testing.T) { + port := rand.Intn(10000) + 40000 + h := Http{Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port), + AccessLog: io.Discard} + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + ds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Logf("req: %v", r) + w.Header().Add("h1", "v1") + require.Equal(t, "127.0.0.1", r.Header.Get("X-Real-IP")) + fmt.Fprintf(w, "response %s", r.URL.String()) + })) + + svc := discovery.NewService([]discovery.Provider{ + &provider.Static{Rules: []string{ + "localhost,^/api/(.*)," + ds.URL + "/123/$1,", + "127.0.0.1,^/api/(.*)," + ds.URL + "/567/$1,", + "*,/web,assets:testdata,", + }, + }}, time.Millisecond*10) + + go func() { + _ = svc.Run(context.Background()) + }() + time.Sleep(50 * time.Millisecond) + h.Matcher = svc + go func() { + _ = h.Run(ctx) + }() + time.Sleep(10 * time.Millisecond) + + client := http.Client{} + + { + req, err := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(port)+"/api/something", nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + t.Logf("%+v", resp.Header) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "response /567/something", string(body)) + assert.Equal(t, "", resp.Header.Get("App-Name")) + assert.Equal(t, "v1", resp.Header.Get("h1")) + } + + { + resp, err := client.Get("http://localhost:" + strconv.Itoa(port) + "/web/1.html") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + t.Logf("%+v", resp.Header) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "test html", string(body)) + assert.Equal(t, "", resp.Header.Get("App-Name")) + assert.Equal(t, "", resp.Header.Get("h1")) } } diff --git a/examples/file/Makefile b/examples/file/Makefile index b134bf0..c3362d5 100644 --- a/examples/file/Makefile +++ b/examples/file/Makefile @@ -1,18 +1,17 @@ run: install - whoami -port 8081 -name=svc1 & - whoami -port 8082 -name=svc2 & - whoami -port 8083 -name=svc3 & - ../../dist/reproxy --file.enabled --file.name=reproxy.yml --assets.location=./web --assets.root=/static - pkill -9 whoami + echo-http --listen=0.0.0.0:8081 --message=svc1 & + echo-http --listen=0.0.0.0:8082 --message=svc2 & + echo-http --listen=0.0.0.0:8083 --message=svc3 & + ../../dist/reproxy --file.enabled --file.name=reproxy.yml --assets.location=./web --assets.root=/static --dbg --logger.stdout + pkill -9 echo-http run_assets_only: install ../../dist/reproxy --assets.location=./web --assets.root=/ - - pkill -9 whoami + pkill -9 echo-http kill: - pkill -9 whoami + pkill -9 echo-http install: cd ../../app && CGO_ENABLED=0 go build -o ../dist/reproxy - cd /tmp && go install github.com/traefik/whoami@latest + cd /tmp && go install github.com/umputun/echo-http@latest diff --git a/examples/file/reproxy.yml b/examples/file/reproxy.yml index da167a0..e0eccc7 100644 --- a/examples/file/reproxy.yml +++ b/examples/file/reproxy.yml @@ -3,3 +3,4 @@ default: - {route: "/api/svc2", dest: "http://127.0.0.1:8082/api", "ping": "http://127.0.0.1:8082/health"} localhost: - {route: "^/api/svc3/(.*)", dest: "http://localhost:8083/$1","ping": "http://127.0.0.1:8083/health"} + - {route: "/www", dest: "web2", "static": y} diff --git a/examples/file/web2/1.html b/examples/file/web2/1.html new file mode 100644 index 0000000..dd87c50 --- /dev/null +++ b/examples/file/web2/1.html @@ -0,0 +1 @@ +1.html web2 \ No newline at end of file diff --git a/examples/file/web2/index.html b/examples/file/web2/index.html new file mode 100644 index 0000000..7246079 --- /dev/null +++ b/examples/file/web2/index.html @@ -0,0 +1 @@ +index web2 \ No newline at end of file