diff --git a/config/configuration.go b/config/configuration.go index 34a35612..aa40759b 100644 --- a/config/configuration.go +++ b/config/configuration.go @@ -177,10 +177,11 @@ func ValidateUrl(c *cli.Context, allowURLFromArgs bool) (*url.URL, error) { } type UnvalidatedIngressRule struct { - Hostname string `json:"hostname,omitempty"` - Path string `json:"path,omitempty"` - Service string `json:"service,omitempty"` - OriginRequest OriginRequestConfig `yaml:"originRequest" json:"originRequest"` + Hostname string `json:"hostname,omitempty"` + Path string `json:"path,omitempty"` + PathReplacement string `yaml:"pathReplacement" json:"pathReplacement,omitempty"` + Service string `json:"service,omitempty"` + OriginRequest OriginRequestConfig `yaml:"originRequest" json:"originRequest"` } // OriginRequestConfig is a set of optional fields that users may set to diff --git a/ingress/ingress.go b/ingress/ingress.go index b4600453..a55bd226 100644 --- a/ingress/ingress.go +++ b/ingress/ingress.go @@ -294,6 +294,8 @@ func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginReq return Ingress{}, errors.Wrapf(err, "Rule #%d has an invalid regex", i+1) } pathRegexp = &Regexp{Regexp: regex} + } else if r.PathReplacement != "" { + return Ingress{}, fmt.Errorf("rule #%d has a path replacement without a path specified", i+1) } rules[i] = Rule{ @@ -301,6 +303,7 @@ func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginReq punycodeHostname: punycodeHostname, Service: service, Path: pathRegexp, + PathReplacement: r.PathReplacement, Handlers: handlers, Config: cfg, } diff --git a/ingress/ingress_test.go b/ingress/ingress_test.go index 1e09fac7..977c6e60 100644 --- a/ingress/ingress_test.go +++ b/ingress/ingress_test.go @@ -442,6 +442,41 @@ ingress: service: https://localhost:8000 - hostname: "*" service: https://localhost:8001 +`}, + wantErr: true, + }, + { + name: "Path replacement", + args: args{rawYAML: ` +ingress: +- hostname: test.example.com + service: https://localhost:8000 + path: ^/api/(.*)$ + pathReplacement: /$1 +- service: http_status:404 +`}, + want: []Rule{ + { + Hostname: "test.example.com", + Service: &httpService{url: localhost8000}, + Path: &Regexp{Regexp: regexp.MustCompile("^/api/(.*)$")}, + PathReplacement: "/$1", + Config: defaultConfig, + }, + { + Service: &fourOhFour, + Config: defaultConfig, + }, + }, + }, + { + name: "Path replacement without a path specified", + args: args{rawYAML: ` +ingress: +- hostname: test.example.com + service: https://localhost:8000 + pathReplacement: /$1 +- service: http_status:404 `}, wantErr: true, }, diff --git a/ingress/rule.go b/ingress/rule.go index 43c7ad5e..2ac92ef5 100644 --- a/ingress/rule.go +++ b/ingress/rule.go @@ -20,6 +20,10 @@ type Rule struct { // Path is an optional regex that can specify path-driven ingress rules. Path *Regexp `json:"path"` + // PathReplacement is an optional regexp replacement string for Path, + // that gets sent to the Service + PathReplacement string `json:"pathReplacement"` + // A (probably local) address. Requests for a hostname which matches this // rule's hostname pattern will be proxied to the service running on this // address. diff --git a/ingress/rule_test.go b/ingress/rule_test.go index 1c051137..cf42d578 100644 --- a/ingress/rule_test.go +++ b/ingress/rule_test.go @@ -194,25 +194,25 @@ func TestMarshalJSON(t *testing.T) { { name: "Nil", path: nil, - expected: `{"hostname":"example.com","path":null,"service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, + expected: `{"hostname":"example.com","path":null,"pathReplacement":"","service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, want: true, }, { name: "Nil regex", path: &Regexp{Regexp: nil}, - expected: `{"hostname":"example.com","path":null,"service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, + expected: `{"hostname":"example.com","path":null,"pathReplacement":"","service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, want: true, }, { name: "Empty", path: &Regexp{Regexp: regexp.MustCompile("")}, - expected: `{"hostname":"example.com","path":"","service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, + expected: `{"hostname":"example.com","path":"","pathReplacement":"","service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, want: true, }, { name: "Basic", path: &Regexp{Regexp: regexp.MustCompile("/echo")}, - expected: `{"hostname":"example.com","path":"/echo","service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, + expected: `{"hostname":"example.com","path":"/echo","pathReplacement":"","service":"https://localhost:8000","Handlers":null,"originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null,"http2Origin":false,"access":{"teamName":"","audTag":null}}}`, want: true, }, } diff --git a/proxy/proxy.go b/proxy/proxy.go index c769e19e..fb6a54a5 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -117,6 +117,8 @@ func (p *Proxy) ProxyHTTP( originProxy, isWebsocket, rule.Config.DisableChunkedEncoding, + rule.Path, + rule.PathReplacement, logFields, ); err != nil { rule, srv := ruleField(p.ingressRules, ruleNum) @@ -189,11 +191,18 @@ func (p *Proxy) proxyHTTPRequest( httpService ingress.HTTPOriginProxy, isWebsocket bool, disableChunkedEncoding bool, + pathRegexp *ingress.Regexp, + pathReplacement string, fields logFields, ) error { roundTripReq := tr.Request - if isWebsocket { + if isWebsocket || pathReplacement != "" { roundTripReq = tr.Clone(tr.Request.Context()) + } + if pathReplacement != "" { + roundTripReq.URL.Path = pathRegexp.ReplaceAllString(roundTripReq.URL.Path, pathReplacement) + } + if isWebsocket { roundTripReq.Header.Set("Connection", "Upgrade") roundTripReq.Header.Set("Upgrade", "websocket") roundTripReq.Header.Set("Sec-Websocket-Version", "13") diff --git a/proxy/proxy_posix_test.go b/proxy/proxy_posix_test.go index 40d070c7..b16e4992 100644 --- a/proxy/proxy_posix_test.go +++ b/proxy/proxy_posix_test.go @@ -46,9 +46,9 @@ func TestUnixSocketOrigin(t *testing.T) { tests := []MultipleIngressTest{ { - url: "http://unix.example.com", + url: "http://unix.example.com/created", expectedStatus: http.StatusCreated, - expectedBody: []byte("Created"), + expectedBody: []byte("/created"), }, } diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 384298c3..acee55ad 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -295,6 +295,18 @@ func TestProxyMultipleOrigins(t *testing.T) { defer api.Close() unvalidatedIngress := []config.UnvalidatedIngressRule{ + { + Hostname: "api.example.com", + Service: api.URL, + Path: "^/service1/(.*)$", + PathReplacement: "/$1", + }, + { + Hostname: "api.example.com", + Service: api.URL, + Path: "^/service2/(.*)$", + PathReplacement: "/route2/$1", + }, { Hostname: "api.example.com", Service: api.URL, @@ -305,7 +317,7 @@ func TestProxyMultipleOrigins(t *testing.T) { }, { Hostname: "health.example.com", - Path: "/health", + Path: "^/health$", Service: "http_status:200", }, { @@ -316,9 +328,19 @@ func TestProxyMultipleOrigins(t *testing.T) { tests := []MultipleIngressTest{ { - url: "http://api.example.com", + url: fmt.Sprintf("http://api.example.com/service1%s", hello.HealthRoute), expectedStatus: http.StatusCreated, - expectedBody: []byte("Created"), + expectedBody: []byte(hello.HealthRoute), + }, + { + url: fmt.Sprintf("http://api.example.com/service2%s", hello.HealthRoute), + expectedStatus: http.StatusCreated, + expectedBody: []byte(fmt.Sprintf("/route2%s", hello.HealthRoute)), + }, + { + url: "http://api.example.com/created", + expectedStatus: http.StatusCreated, + expectedBody: []byte("/created"), }, { url: fmt.Sprintf("http://hello.example.com%s", hello.HealthRoute), @@ -384,7 +406,7 @@ type mockAPI struct{} func (ma mockAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte("Created")) + _, _ = w.Write([]byte(r.URL.Path)) } type errorOriginTransport struct{}