├── Makefile ├── upstreams └── upstreams.go ├── main.go ├── load-test └── targets.txt ├── LICENSE ├── routing ├── routing.go └── configuration.go ├── proxy ├── proxy_test.go └── proxy.go └── README.md /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test -v -failfast ./... 3 | 4 | run: 5 | go run main.go 6 | 7 | stress: 8 | @go run main.go & 9 | @echo "waiting a few seconds to allow app to start up" && sleep 5 10 | @vegeta attack -rate=50/1s -duration=30s -targets=./load-test/targets.txt | vegeta report 11 | @pkill go main # kill two separate processes: 'go' (go run main.go) and 'main' (/var/folders/.../go-build.../.../exe/main) 12 | -------------------------------------------------------------------------------- /upstreams/upstreams.go: -------------------------------------------------------------------------------- 1 | package upstreams 2 | 3 | // Upstream defines the backend origin to be proxied 4 | type Upstream struct { 5 | Name string 6 | Host string 7 | } 8 | 9 | // HTTPBin is used for testing 10 | var HTTPBin = &Upstream{ 11 | Name: "httpbin", 12 | Host: "httpbin.org", 13 | } 14 | 15 | // Google is used for more testing 16 | var Google = &Upstream{ 17 | Name: "google", 18 | Host: "google.com", 19 | } 20 | 21 | // Integralist is used for even more testing 22 | var Integralist = &Upstream{ 23 | Name: "integralist", 24 | Host: "integralist.co.uk", 25 | } 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/integralist/go-reverse-proxy/proxy" 8 | "github.com/integralist/go-reverse-proxy/routing" 9 | ) 10 | 11 | func main() { 12 | log.Print("Application Starting up on port 9001") 13 | 14 | router := &routing.Handler{} 15 | 16 | for _, conf := range routing.Configuration { 17 | proxy := proxy.GenerateProxy(conf) 18 | 19 | router.HandleFunc(conf, func(w http.ResponseWriter, r *http.Request) { 20 | proxy.ServeHTTP(w, r) 21 | }) 22 | } 23 | 24 | log.Fatal(http.ListenAndServe(":9001", router)) 25 | } 26 | -------------------------------------------------------------------------------- /load-test/targets.txt: -------------------------------------------------------------------------------- 1 | GET http://localhost:9001/anything/standard 2 | GET http://localhost:9001/anything/foo 3 | GET http://localhost:9001/anything/bar 4 | GET http://localhost:9001/anything/foobar 5 | 6 | GET http://localhost:9001/anything/foobar 7 | X-BF-Testing: integralist 8 | 9 | GET http://localhost:9001/double-checks 10 | 11 | GET http://localhost:9001/double-checks 12 | X-BF-Testing: integralist 13 | 14 | GET http://localhost:9001/anything/integralist 15 | 16 | GET http://localhost:9001/anything/integralist 17 | X-BF-Testing: integralist 18 | 19 | GET http://localhost:9001/about?s=integralist 20 | 21 | GET http://localhost:9001/anything/querytest 22 | GET http://localhost:9001/anything/querytest?s=integralist123 23 | GET http://localhost:9001/anything/querytest?s=integralist666 24 | GET http://localhost:9001/foo123 25 | GET http://localhost:9001/foo666 26 | GET http://localhost:9001/beepboop 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mark McDonnell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /routing/routing.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "regexp" 8 | ) 9 | 10 | var captureGroupsPattern = regexp.MustCompile("P<([^>]+)>") 11 | 12 | type route struct { 13 | pattern *regexp.Regexp 14 | handler http.Handler 15 | config Config 16 | } 17 | 18 | // Handler defines our own custom HTTP handler that supports pattern matching a 19 | // path using regular expressions 20 | type Handler struct { 21 | routes []*route 22 | } 23 | 24 | // HandleFunc appends a new route and coerces the given function into a HandlerFunc 25 | // MustCompile allows this service to fail fast when provided invalid regex config 26 | func (h *Handler) HandleFunc(config Config, handler func(http.ResponseWriter, *http.Request)) { 27 | h.routes = append(h.routes, &route{ 28 | pattern: regexp.MustCompile(config.Path), 29 | handler: http.HandlerFunc(handler), 30 | config: config, 31 | }) 32 | } 33 | 34 | // ServeHTTP attempts to match the incoming request path against the configured 35 | // route patterns we've defined, and if a match is found we take any named 36 | // capture groups and add them onto the request's query so we can later 37 | // interpolate the captured values back into the request path (if configured to 38 | // be used there -- see README for examples). Before the response is sent back 39 | // to the client we clean-up the added query parameters. 40 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 41 | for _, route := range h.routes { 42 | pathMatchAndCaptureGroup := route.pattern.FindStringSubmatch(r.URL.Path) 43 | 44 | if len(pathMatchAndCaptureGroup) > 0 { 45 | captureGroups := captureGroupsPattern.FindAllStringSubmatch(route.config.Path, -1) 46 | 47 | if len(captureGroups) > 0 { 48 | // captureGroups = [[P foo] [P bar]] 49 | newQuery := r.URL.Query() 50 | originalQuery, _ := url.ParseQuery(r.URL.RawQuery) 51 | 52 | for k, v := range originalQuery { 53 | newQuery.Set(k, v[0]) 54 | } 55 | 56 | for i, v := range pathMatchAndCaptureGroup[1:] { 57 | // prefix named capture groups when adding them as quer params 58 | // because this helps us identify them when cleaning query string 59 | // before making the actual proxy request 60 | queryKey := fmt.Sprintf("ncg_%s", captureGroups[i][1]) 61 | newQuery.Set(queryKey, v) 62 | } 63 | 64 | r.URL.RawQuery = newQuery.Encode() 65 | } 66 | 67 | route.handler.ServeHTTP(w, r) 68 | return 69 | } 70 | } 71 | 72 | http.NotFound(w, r) 73 | } 74 | -------------------------------------------------------------------------------- /routing/configuration.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import "github.com/integralist/go-reverse-proxy/upstreams" 4 | 5 | // Override defines the expected sub level routing override configuration 6 | type Override struct { 7 | Header string 8 | Query string 9 | Match string 10 | MatchType string 11 | Upstream *upstreams.Upstream 12 | ModifyPath string 13 | } 14 | 15 | // Config defines the expected top level routing configuration 16 | type Config struct { 17 | Path string 18 | Upstream *upstreams.Upstream 19 | ModifyPath string 20 | Override Override 21 | } 22 | 23 | // Configuration is the main routing logic that dictates how routing behaviours 24 | // should be controlled and overriding behaviours determined. 25 | var Configuration = []Config{ 26 | Config{ 27 | Path: "/anything/standard", 28 | Upstream: upstreams.HTTPBin, 29 | }, 30 | Config{ 31 | Path: "/anything/(?:foo|bar)$", 32 | Upstream: upstreams.HTTPBin, 33 | }, 34 | Config{ 35 | Path: "/(?Panything)/(?Pfoobar)$", 36 | Upstream: upstreams.HTTPBin, 37 | Override: Override{ 38 | Header: "X-BF-Testing", 39 | Match: "integralist", 40 | ModifyPath: "/anything/newthing${cap}", 41 | }, 42 | }, 43 | Config{ 44 | Path: "/(?Pdouble-checks)$", 45 | Upstream: upstreams.HTTPBin, 46 | ModifyPath: "/anything/toplevel-modified-${cap}", 47 | Override: Override{ 48 | Header: "X-BF-Testing", 49 | Match: "integralist", 50 | ModifyPath: "/anything/override-modified-${cap}", 51 | }, 52 | }, 53 | Config{ 54 | Path: "/anything/(?Pintegralist)", 55 | Upstream: upstreams.HTTPBin, 56 | Override: Override{ 57 | Header: "X-BF-Testing", 58 | Match: "integralist", 59 | ModifyPath: "/about", 60 | Upstream: upstreams.Integralist, 61 | }, 62 | }, 63 | Config{ 64 | Path: "/about", 65 | Upstream: upstreams.HTTPBin, 66 | Override: Override{ 67 | Query: "s", 68 | Match: "integralist", 69 | Upstream: upstreams.Integralist, 70 | }, 71 | }, 72 | Config{ 73 | Path: "/anything/querytest", 74 | Upstream: upstreams.HTTPBin, 75 | Override: Override{ 76 | Query: "s", 77 | Match: `integralist(?P\d{1,3})$`, 78 | MatchType: "regex", 79 | ModifyPath: "/anything/newthing${cap}", 80 | }, 81 | }, 82 | Config{ 83 | Path: `/(?Pfoo\w{3})`, 84 | Upstream: upstreams.HTTPBin, 85 | ModifyPath: "/anything/${cap}", 86 | }, 87 | Config{ 88 | Path: "/beep(?Pboop)", 89 | Upstream: upstreams.HTTPBin, 90 | ModifyPath: "/anything/beepboop", 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /proxy/proxy_test.go: -------------------------------------------------------------------------------- 1 | // The tests in this file are verifying the expected behaviour from the 2 | // integration perspective. We stub the upstream responses so they return the 3 | // specific upstream and path that was finally requested. This way we can be 4 | // sure the routing configuration we have is mutating the request as expected. 5 | 6 | package proxy 7 | 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | "io/ioutil" 12 | "log" 13 | "net/http" 14 | "net/http/httptest" 15 | "net/url" 16 | "testing" 17 | 18 | "github.com/integralist/go-reverse-proxy/routing" 19 | "github.com/integralist/go-reverse-proxy/upstreams" 20 | ) 21 | 22 | type Response struct { 23 | Upstream string `json:"upstream"` 24 | URL string `json:"url"` 25 | } 26 | 27 | var handler = &routing.Handler{} 28 | var server = httptest.NewServer(handler) 29 | 30 | var mockHTTPBin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | w.Header().Set("Content-Type", "application/json") 32 | fmt.Fprintln(w, fmt.Sprintf(`{"upstream": "httpbin", "url": "%s"}`, r.URL)) 33 | })) 34 | 35 | var mockIntegralist = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | w.Header().Set("Content-Type", "application/json") 37 | fmt.Fprintln(w, fmt.Sprintf(`{"upstream": "integralist", "url": "%s"}`, r.URL)) 38 | })) 39 | 40 | func configureRouting() { 41 | for _, conf := range routing.Configuration { 42 | if conf.Upstream.Name == "httpbin" { 43 | u, err := url.Parse(mockHTTPBin.URL) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | 48 | host := fmt.Sprintf("%s:%s", u.Hostname(), u.Port()) 49 | 50 | conf.Upstream = &upstreams.Upstream{ 51 | Name: "httpbin", 52 | Host: host, 53 | } 54 | } 55 | 56 | if conf.Override.Upstream != nil && conf.Override.Upstream.Name == "integralist" { 57 | u, err := url.Parse(mockIntegralist.URL) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | 62 | host := fmt.Sprintf("%s:%s", u.Hostname(), u.Port()) 63 | 64 | conf.Override.Upstream = &upstreams.Upstream{ 65 | Name: "integralist", 66 | Host: host, 67 | } 68 | } 69 | 70 | proxy := GenerateProxy(conf) 71 | 72 | handler.HandleFunc(conf, func(w http.ResponseWriter, r *http.Request) { 73 | r.Header.Add("X-Testing", "true") 74 | r.URL.Scheme = "http" 75 | proxy.ServeHTTP(w, r) 76 | }) 77 | } 78 | } 79 | 80 | func verifyResponse(res *Response, upstream string, path string, t *testing.T) { 81 | if res.Upstream != upstream { 82 | t.Errorf("The response:\n '%s'\ndidn't match the expectation:\n '%s'", res.Upstream, upstream) 83 | } 84 | 85 | if res.URL != path { 86 | t.Errorf("The response:\n '%s'\ndidn't match the expectation:\n '%s'", res.URL, path) 87 | } 88 | } 89 | 90 | type testHeaders struct { 91 | Key string 92 | Value string 93 | } 94 | 95 | var testMatrix = []struct { 96 | input string 97 | output string 98 | outputPath string 99 | headers testHeaders 100 | }{ 101 | { 102 | input: "/anything/standard", 103 | output: "httpbin", 104 | outputPath: "/anything/standard", 105 | headers: testHeaders{}, 106 | }, 107 | { 108 | input: "/anything/foo", 109 | output: "httpbin", 110 | outputPath: "/anything/foo", 111 | headers: testHeaders{}, 112 | }, 113 | { 114 | input: "/anything/bar", 115 | output: "httpbin", 116 | outputPath: "/anything/bar", 117 | headers: testHeaders{}, 118 | }, 119 | { 120 | input: "/anything/foobar", 121 | output: "httpbin", 122 | outputPath: "/anything/foobar", 123 | headers: testHeaders{}, 124 | }, 125 | { 126 | input: "/anything/foobar", 127 | output: "httpbin", 128 | outputPath: "/anything/newthingfoobar", 129 | headers: testHeaders{"X-BF-Testing", "integralist"}, 130 | }, 131 | { 132 | input: "/double-checks", 133 | output: "httpbin", 134 | outputPath: "/anything/toplevel-modified-double-checks", 135 | headers: testHeaders{}, 136 | }, 137 | { 138 | input: "/double-checks", 139 | output: "httpbin", 140 | outputPath: "/anything/override-modified-double-checks", 141 | headers: testHeaders{"X-BF-Testing", "integralist"}, 142 | }, 143 | { 144 | input: "/anything/integralist", 145 | output: "httpbin", 146 | outputPath: "/anything/integralist", 147 | headers: testHeaders{}, 148 | }, 149 | { 150 | input: "/anything/integralist", 151 | output: "integralist", 152 | outputPath: "/about", 153 | headers: testHeaders{"X-BF-Testing", "integralist"}, 154 | }, 155 | { 156 | input: "/about", 157 | output: "httpbin", 158 | outputPath: "/about", 159 | headers: testHeaders{}, 160 | }, 161 | { 162 | input: "/about?s=integralist", 163 | output: "integralist", 164 | outputPath: "/about?s=integralist", 165 | headers: testHeaders{}, 166 | }, 167 | { 168 | input: "/anything/querytest", 169 | output: "httpbin", 170 | outputPath: "/anything/querytest", 171 | headers: testHeaders{}, 172 | }, 173 | { 174 | input: "/anything/querytest?s=integralistabc", 175 | output: "httpbin", 176 | outputPath: "/anything/querytest?s=integralistabc", 177 | headers: testHeaders{}, 178 | }, 179 | { 180 | input: "/anything/querytest?s=integralist123", 181 | output: "httpbin", 182 | outputPath: "/anything/newthing123?s=integralist123", 183 | headers: testHeaders{}, 184 | }, 185 | { 186 | input: "/fooabc", 187 | output: "httpbin", 188 | outputPath: "/anything/fooabc", 189 | headers: testHeaders{}, 190 | }, 191 | { 192 | input: "/beepboop", 193 | output: "httpbin", 194 | outputPath: "/anything/beepboop", 195 | headers: testHeaders{}, 196 | }, 197 | } 198 | 199 | func TestProxy(t *testing.T) { 200 | defer mockHTTPBin.Close() 201 | defer mockIntegralist.Close() 202 | defer server.Close() 203 | 204 | configureRouting() 205 | 206 | for _, tt := range testMatrix { 207 | t.Run(tt.input, func(t *testing.T) { 208 | endpoint := fmt.Sprintf("%s%s", server.URL, tt.input) 209 | 210 | client := &http.Client{} 211 | req, _ := http.NewRequest("GET", endpoint, nil) 212 | 213 | if tt.headers.Key != "" && tt.headers.Value != "" { 214 | req.Header.Add(tt.headers.Key, tt.headers.Value) 215 | } 216 | 217 | res, err := client.Do(req) 218 | if err != nil { 219 | log.Fatal(err) 220 | } 221 | 222 | body, err := ioutil.ReadAll(res.Body) 223 | res.Body.Close() 224 | if err != nil { 225 | log.Fatal(err) 226 | } 227 | 228 | jsonResponse := &Response{} 229 | 230 | err = json.Unmarshal(body, jsonResponse) 231 | if err != nil { 232 | t.Errorf("Couldn't unmarshall the json response: %s", err) 233 | } 234 | 235 | verifyResponse(jsonResponse, tt.output, tt.outputPath, t) 236 | }) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "net/http" 7 | "net/http/httputil" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | "github.com/integralist/go-reverse-proxy/routing" 13 | ) 14 | 15 | var responseHeaders = []string{ 16 | "X-Forwarded-Host", 17 | "X-Origin-Host", 18 | "X-Router-Upstream", 19 | "X-Router-Upstream-OriginalHost", 20 | "X-Router-Upstream-OriginalPath", 21 | "X-Router-Upstream-OriginalPathModified", 22 | "X-Router-Upstream-Override", 23 | "X-Router-Upstream-OverrideHost", 24 | "X-Router-Upstream-OverridePath", 25 | } 26 | 27 | // GenerateProxy returns a unique reverse proxy instance which includes logic 28 | // for handling override behaviour for configuration routes. 29 | func GenerateProxy(conf routing.Config) http.Handler { 30 | proxy := &httputil.ReverseProxy{ 31 | Director: func(req *http.Request) { 32 | req.Header.Add("X-Router-Upstream", conf.Upstream.Name) 33 | req.Header.Add("X-Router-Upstream-OriginalHost", conf.Upstream.Host) 34 | req.Header.Add("X-Router-Upstream-OriginalPath", req.URL.Path) 35 | req.Header.Add("X-Forwarded-Host", req.Host) 36 | req.Header.Add("X-Origin-Host", conf.Upstream.Host) 37 | 38 | // This was done to unblock the ability to test the code, but it would be 39 | // better if there were no references to testing within the code itself, 40 | // so I think the only way to solve this would be to inject the 41 | // modifications as a func dependency so we could modify the request 42 | // object differently depending on the runtime environment context. 43 | if req.Header.Get("X-Testing") != "true" { 44 | req.URL.Scheme = "https" 45 | } 46 | 47 | // some upstreams will reject a request if the given Host HTTP header 48 | // isn't recognised, and so we always make sure to tweak that header 49 | // so it isn't set to the proxy's host value, but set to the upsteam's. 50 | req.Host = conf.Upstream.Host 51 | req.URL.Host = conf.Upstream.Host 52 | 53 | if conf.ModifyPath != "" { 54 | modifyPath(req, conf.ModifyPath) 55 | } 56 | 57 | if conf.Override.Header != "" && conf.Override.Match != "" { 58 | overrideHeader(req, conf.Override) 59 | } 60 | 61 | if conf.Override.Query != "" && conf.Override.Match != "" { 62 | overrideQuery(req, conf.Override) 63 | } 64 | 65 | cleanUpQueryString(req) 66 | 67 | log.Printf("request: %s\n", req.URL) 68 | }, 69 | Transport: &http.Transport{ 70 | Dial: (&net.Dialer{ 71 | Timeout: 5 * time.Second, 72 | }).Dial, 73 | }, 74 | ModifyResponse: func(r *http.Response) error { 75 | for _, header := range responseHeaders { 76 | value := r.Request.Header.Get(header) 77 | 78 | if value != "" { 79 | r.Header.Set(header, value) 80 | } 81 | } 82 | return nil 83 | }, 84 | } 85 | 86 | return proxy 87 | } 88 | 89 | func modifyPath(req *http.Request, modifyPath string) { 90 | if strings.Contains(modifyPath, "$") { 91 | req.URL.Path = modifyPath 92 | 93 | for k, v := range req.URL.Query() { 94 | // req.URL.Query() = map[foo:[ncg_foovalue] bar:[ncg_barvalue]] 95 | isCaptureGroup := strings.HasPrefix(k, "ncg_") 96 | 97 | if isCaptureGroup { 98 | // interpolate query param value into modified request path 99 | cleanKeyPrefix := strings.Replace(k, "ncg_", "", 1) 100 | // replace ${foo} with foo 101 | r := strings.NewReplacer("$", "", "{", "", "}", "", cleanKeyPrefix, v[0]) 102 | req.URL.Path = r.Replace(req.URL.Path) 103 | } 104 | } 105 | } else { 106 | req.URL.Path = modifyPath 107 | } 108 | 109 | req.Header.Add("X-Router-Upstream-OriginalPathModified", req.URL.Path) 110 | } 111 | 112 | func overrideHeader(req *http.Request, override routing.Override) { 113 | if req.Header.Get(override.Header) == override.Match { 114 | if override.ModifyPath != "" { 115 | // TODO: duplicated logic with modifyPath function 116 | if strings.Contains(override.ModifyPath, "$") { 117 | req.URL.Path = override.ModifyPath 118 | 119 | for k, v := range req.URL.Query() { 120 | // req.URL.Query() = map[foo:[ncg_foovalue] bar:[ncg_barvalue]] 121 | isCaptureGroup := strings.HasPrefix(k, "ncg_") 122 | 123 | if isCaptureGroup { 124 | // interpolate query param value into modified request path 125 | cleanKeyPrefix := strings.Replace(k, "ncg_", "", 1) 126 | // replace ${foo} with foo 127 | r := strings.NewReplacer("$", "", "{", "", "}", "", cleanKeyPrefix, v[0]) 128 | req.URL.Path = r.Replace(req.URL.Path) 129 | } 130 | } 131 | } else { 132 | req.URL.Path = override.ModifyPath 133 | } 134 | 135 | req.Header.Add("X-Router-Upstream-OverridePath", req.URL.Path) 136 | } 137 | 138 | if override.Upstream != nil && override.Upstream.Host != "" && override.Upstream.Name != "" { 139 | req.Host = override.Upstream.Host 140 | req.URL.Host = override.Upstream.Host 141 | req.Header.Add("X-Router-Upstream-Override", override.Upstream.Name) 142 | req.Header.Add("X-Router-Upstream-OverrideHost", override.Upstream.Host) 143 | } 144 | } 145 | } 146 | 147 | func overrideQuery(req *http.Request, override routing.Override) { 148 | param := req.URL.Query().Get(override.Query) 149 | 150 | if override.MatchType == "regex" { 151 | // TODO: figure out how to precompile this rather than at runtime 152 | pattern, err := regexp.Compile(override.Match) 153 | if err != nil { 154 | return 155 | } 156 | 157 | match := pattern.MatchString(param) 158 | 159 | if match { 160 | if override.Upstream != nil { 161 | req.Host = override.Upstream.Host 162 | req.URL.Host = override.Upstream.Host 163 | req.Header.Add("X-Router-Upstream-OverrideHost", req.URL.Host) 164 | } 165 | if override.ModifyPath != "" { 166 | newpath := []byte{} 167 | queryparam := []byte(param) 168 | template := []byte(override.ModifyPath) 169 | 170 | for _, submatches := range pattern.FindAllSubmatchIndex(queryparam, -1) { 171 | newpath = pattern.Expand(newpath, template, queryparam, submatches) 172 | } 173 | 174 | req.URL.Path = string(newpath) 175 | req.Header.Add("X-Router-Upstream-OverridePath", req.URL.Path) 176 | } 177 | } 178 | } else { 179 | if param == override.Match { 180 | if override.Upstream != nil { 181 | req.Host = override.Upstream.Host 182 | req.URL.Host = override.Upstream.Host 183 | req.Header.Add("X-Router-Upstream-OverrideHost", req.URL.Host) 184 | } 185 | if override.ModifyPath != "" { 186 | req.URL.Path = override.ModifyPath 187 | req.Header.Add("X-Router-Upstream-OverridePath", req.URL.Path) 188 | } 189 | } 190 | } 191 | } 192 | 193 | // cleanUpQueryString removes any named capture groups from the query string 194 | // that were added by our routing logic. The capture groups were added to the 195 | // query string so that the final request handler could easily parse the values 196 | // and interpolate them into a modified request path, but after that they are 197 | // not useful to either the client nor the proxied upstream. 198 | func cleanUpQueryString(req *http.Request) { 199 | for k := range req.URL.Query() { 200 | isCaptureGroup := strings.HasPrefix(k, "ncg_") 201 | 202 | if isCaptureGroup { 203 | originalQuery := req.URL.Query() 204 | originalQuery.Del(k) 205 | req.URL.RawQuery = originalQuery.Encode() 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Reverse Proxy 2 | 3 | A simple configuration-driven reverse proxy written in Go. 4 | 5 | It has zero dependencies outside of the Go standard library. 6 | 7 | ## Configuration 8 | 9 | Define a slice of type `Config`, with the minimum set of fields being: `Path` and `Upstream`. 10 | 11 | Configuration is defined in the [routing/configuration](./routing/configuration.go) file. 12 | 13 | Upstreams are defined in the [upstreams](./upstreams/upstreams.go) file. 14 | 15 | ## Example Config 16 | 17 | Below we explain the actual [routing configuration](./routing/configuration.go) committed into this repo... 18 | 19 | - [Proxy Request](#proxy-request) 20 | - [Proxy Request using Regular Expression](#proxy-request-using-regular-expression) 21 | - [Proxy Request with Modified Path](#proxy-request-with-modified-path) 22 | - [Override with Modified Path](#override-with-modified-path) 23 | - [Modified Path + Override with Modified Path](#modified-path--override-with-modified-path) 24 | - [Override to Different Upstream](#override-to-different-upstream) 25 | - [Query String Override](#query-string-override) 26 | - [Query String Override with Regular Expression](#query-string-override-with-regular-expression) 27 | 28 | ### Proxy Request 29 | 30 | ```go 31 | Config{ 32 | Path: "/anything/standard", 33 | Upstream: upstreams.HTTPBin, 34 | } 35 | ``` 36 | 37 | ### Requests 38 | 39 | - `/anything/standard` 40 | 41 | ### Result 42 | 43 | The request will be proxied straight through to the specified upstream without any modifications. 44 | 45 | --- 46 | 47 | ### Proxy Request using Regular Expression 48 | 49 | ```go 50 | Config{ 51 | Path: "/anything/(?:foo|bar)$", 52 | Upstream: upstreams.HTTPBin, 53 | } 54 | ``` 55 | 56 | ### Requests 57 | 58 | - `/anything/foo` 59 | - `/anything/bar` 60 | 61 | ### Result 62 | 63 | Both requests will be proxied straight through to the specified upstream without any modifications. 64 | 65 | --- 66 | 67 | ### Proxy Request with Modified Path 68 | 69 | ```go 70 | Config{ 71 | Path: `/(?Pfoo\w{3})`, 72 | Upstream: upstreams.HTTPBin, 73 | ModifyPath: "/anything/${cap}", 74 | } 75 | ``` 76 | 77 | ### Requests 78 | 79 | - `/fooabc` 80 | - `/fooxyz` 81 | 82 | ### Result 83 | 84 | Both requests will be proxied through to the specified upstream but the path will be modified to include the captured information: `/anything/abc` and `/anything/xyz`. 85 | 86 | --- 87 | 88 | ### Override with Modified Path 89 | 90 | ```go 91 | Config{ 92 | Path: "/(?Panything)/(?Pfoobar)$", 93 | Upstream: upstreams.HTTPBin, 94 | Override: Override{ 95 | Header: "X-BF-Testing", 96 | Match: "integralist", 97 | ModifyPath: "/anything/newthing${cap}", 98 | }, 99 | } 100 | ``` 101 | 102 | ### Requests 103 | 104 | - `/anything/foobar` 105 | - `/anything/foobar` (+ HTTP Request Header `X-BF-Testing: integralist`) 106 | 107 | ### Result 108 | 109 | The request will be proxied straight through to the specified upstream without any modifications. 110 | 111 | If the relevant request header is specified, then the request will be proxied through to the specified upstream but the path will be modified to include the captured information: `/anything/newthingfoobar`. 112 | 113 | --- 114 | 115 | ### Modified Path + Override with Modified Path 116 | 117 | ```go 118 | Config{ 119 | Path: "/(?Pdouble-checks)$", 120 | Upstream: upstreams.HTTPBin, 121 | ModifyPath: "/anything/toplevel-modified-${cap}", 122 | Override: Override{ 123 | Header: "X-BF-Testing", 124 | Match: "integralist", 125 | ModifyPath: "/anything/override-modified-${cap}", 126 | }, 127 | } 128 | ``` 129 | 130 | ### Requests 131 | 132 | - `/double-checks` 133 | - `/double-checks` (+ HTTP Request Header `X-BF-Testing: integralist`) 134 | 135 | ### Result 136 | 137 | The request will be proxied through to the specified upstream but the path will be modified to include the captured information: `/anything/toplevel-modified-double-checks`. 138 | 139 | If the relevant request header is specified, then the request will be proxied through to the specified upstream but the path will be modified to include the captured information: `/anything/override-modified-double-checks`. 140 | 141 | --- 142 | 143 | ### Override to Different Upstream 144 | 145 | ```go 146 | Config{ 147 | Path: "/anything/(?Pintegralist)", 148 | Upstream: upstreams.HTTPBin, 149 | Override: Override{ 150 | Header: "X-BF-Testing", 151 | Match: "integralist", 152 | ModifyPath: "/about", 153 | Upstream: upstreams.Integralist, 154 | }, 155 | } 156 | ``` 157 | 158 | ### Requests 159 | 160 | - `/anything/integralist` 161 | - `/anything/integralist` (+ HTTP Request Header `X-BF-Testing: integralist`) 162 | 163 | ### Result 164 | 165 | The request will be proxied straight through to the specified upstream without any modifications. 166 | 167 | If the relevant request header is specified, then the request will be proxied through to a _different_ specified upstream and the path will also be modified. 168 | 169 | > Note: although we use a named capture group, we don't actually utilise it anywhere in the rest of the configuration, so it's effectively a no-op. 170 | 171 | --- 172 | 173 | ### Query String Override 174 | 175 | ```go 176 | Config{ 177 | Path: "/about", 178 | Upstream: upstreams.HTTPBin, 179 | Override: Override{ 180 | Query: "s", 181 | Match: "integralist", 182 | Upstream: upstreams.Integralist, 183 | }, 184 | } 185 | ``` 186 | 187 | ### Requests 188 | 189 | - `/about` 190 | - `/about?s=integralist` 191 | 192 | ### Result 193 | 194 | The request will be proxied straight through to the specified upstream without any modifications. 195 | 196 | If the relevant query parameter is specified, then the request will be proxied through to a _different_ specified upstream. 197 | 198 | --- 199 | 200 | ### Query String Override with Regular Expression 201 | 202 | ```go 203 | Config{ 204 | Path: "/anything/querytest", 205 | Upstream: upstreams.HTTPBin, 206 | Override: Override{ 207 | Query: "s", 208 | Match: `integralist(?P\d{1,3})$`, 209 | MatchType: "regex", 210 | ModifyPath: "/anything/newthing${cap}", 211 | }, 212 | } 213 | ``` 214 | 215 | ### Requests 216 | 217 | - `/anything/querytest` 218 | - `/anything/querytest?s=integralist123` 219 | - `/anything/querytest?s=integralist456` 220 | 221 | ### Result 222 | 223 | The first request will be proxied straight through to the specified upstream without any modifications. 224 | 225 | If the relevant query parameter is specified, then the second and third requests will have their path modified to include the captured information: `/anything/newthing123` and `/anything/newthing456`. 226 | 227 | ## Response Headers 228 | 229 | We set the following response headers (not all will be set depending on the configuration): 230 | 231 | ``` 232 | X-Forwarded-Host 233 | X-Origin-Host 234 | X-Router-Upstream 235 | X-Router-Upstream-OriginalHost 236 | X-Router-Upstream-OriginalPath 237 | X-Router-Upstream-OriginalPathModified 238 | X-Router-Upstream-Override 239 | X-Router-Upstream-OverrideHost 240 | X-Router-Upstream-OverridePath 241 | ``` 242 | 243 | ## Usage 244 | 245 | ``` 246 | make run 247 | ``` 248 | 249 | > Note: the application listens on port `9001`. 250 | 251 | ``` 252 | curl -v http://localhost:9001/some/path/you/configured 253 | ``` 254 | 255 | ## Tests 256 | 257 | ``` 258 | make test 259 | ``` 260 | 261 | ## Load Test 262 | 263 | We use [`vegeta`](https://github.com/tsenart/vegeta) for load testing, so make sure you have that installed. 264 | 265 | ``` 266 | make stress 267 | ``` 268 | 269 | Example output: 270 | 271 | ``` 272 | Requests [total, rate] 1500, 50.03 273 | Duration [total, attack, wait] 30.11237994s, 29.982166788s, 130.213152ms 274 | Latencies [mean, 50, 95, 99, max] 154.522948ms, 96.76258ms, 358.770472ms, 1.076826656s, 2.954136535s 275 | Bytes In [total, mean] 2039772, 1359.85 276 | Bytes Out [total, mean] 0, 0.00 277 | Success [ratio] 100.00% 278 | Status Codes [code:count] 200:1500 279 | Error Set: 280 | ``` 281 | 282 | ## TODO 283 | 284 | - Look at implementing thread pool processing on a host or server basis. 285 | - Verify if DNS caching (or request memoization) would affect latency results? 286 | - Review 301 redirect behaviour to be sure we don't need to handle that differently. 287 | - Flesh out some unit tests (not just integration testing) 288 | --------------------------------------------------------------------------------