Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement redirect capability #395

Closed
wants to merge 6 commits into from
Closed

Conversation

ctlajoie
Copy link
Contributor

@ctlajoie ctlajoie commented Nov 28, 2017

NOTE: The syntax described below was not used in the final implementation.

This is my initial attempt to implement the ability to perform redirections (see #87). I decided to indicate a redirect by prefixing the destination with the HTTP status code to be used for redirection, followed by a pipe, followed by the specific URL you want to redirect to. There is no built-in protection against creating redirect loops currently. Here is how you might use it:

route add svc example.com:80/ 302|https://example.com
route add svc example.com:443/ 1.2.3.4:5678

Note that these include explicit source ports. To use it on consul services, you have to use multiple service tags.

urlprefix-example.com:80/ 302|https://example.com
urlprefix-example.com:443/

You can create redirect routes to anywhere.

route add svc example.com/dothething/ 301|https://github.com/fabiolb/fabio

Looking for feedback.
(Signed CLA)

@magiconair
Copy link
Contributor

Thx a lot. A number of users have been asking for this. I’ll have a look tonight.

@aaronhurt
Copy link
Member

Yes, we were just discussing doing this last week but it looks like you beat us to it. Thank you, this is one of the few things we're still using HAProxy for on the frontend.

@ctlajoie
Copy link
Contributor Author

ctlajoie commented Nov 28, 2017

Happy to help! I hope the syntax for indicating a redirect is acceptable. It's not even close to what was discussed in #87 but it enables you to redirect to any given URL, which I find valuable.

Also what's up with travis? The failure doesn't seem legit.

@aaronhurt
Copy link
Member

Looks like golang tip and the Vault checks aren't getting along.

@magiconair
Copy link
Contributor

lets disable tip for now then. I've got some cycles in the coming weeks to clean out the issue list. So lets get this done and merged.

@magiconair
Copy link
Contributor

This is doing the right thing in the http_proxy.go but the config implementation is more complex than it needs to be IMO. It should be enough if you add a redirect=<code> option since the syntax parsing is mostly there. The notable exception is https://github.com/fabiolb/fabio/blob/master/registry/consul/service.go#L120-L131 which you have covered already and which could use some tests (cough). Then you don't need an addRedirectTarget method and can just add that one option to addTarget method.

I assume you need to make the changes for matching the ports in order to do the http -> https redirect. I'm wondering whether this wouldn't be more expressive

urlprefix-example.com/ https://example.com proto=http redirect=302
urlprefix-example.com/

I'd also like to see an integration test in https://github.com/fabiolb/fabio/blob/master/proxy/http_integration_test.go

@magiconair
Copy link
Contributor

It may also make sense to get #385 fixed first. I have the patch but need to add the test.

@magiconair magiconair added this to the 1.6 milestone Nov 28, 2017
@ctlajoie
Copy link
Contributor Author

Ok I'll see what I can get done on this tonight. Understood regarding tests.. will add those too.

I assume you need to make the changes for matching the ports in order to do the http -> https redirect.

Correct. This makes it possible to define a route explicitly on http but not https (and vice versa), which was required for this feature.

My understanding is that the proto= option tells fabio what protocol to use for comms with upstream. I wouldn't recommend using it to define route-matching behavior.

@magiconair
Copy link
Contributor

The proto= option means whatever we want it to mean. It can mean different things in different contexts if its meaning can be unambiguously derived from the context. Lets get this down to the minimal and tested change first and then we can polish it.

@magiconair
Copy link
Contributor

I've just merged #388 so you want to rebase.

@ctlajoie
Copy link
Contributor Author

ctlajoie commented Nov 29, 2017

I implemented your suggestions, minus the proto=http one.
In writing the integration test I discovered some issues that I'm not comfortable resolving without feedback. The integration test fails... have a look at it and I think you'll see the problem. It's copying the Path part of the request URL over to the target. In many cases this will be desired, but not always. I am considering adding another opt like absolute=true to indicate the target URL shouldn't be tampered with. Thoughts?

@magiconair
Copy link
Contributor

Got the kids today. So this might be tomorrow.

Copy link
Contributor

@magiconair magiconair left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments but mostly realized that the the syntax I suggested earlier is not sufficient since you need to be able to specify a redirect target. I'm suggesting

redirect=302,http://www.bar.com/foo

but I'm open for suggestions.

route/route.go Outdated
@@ -134,7 +136,12 @@ func contains(src, dst []string) bool {
}

func (r *Route) TargetConfig(t *Target, addWeight bool) string {
s := fmt.Sprintf("route add %s %s %s", t.Service, r.Host+r.Path, t.URL)
s := fmt.Sprintf("route add %s %s", t.Service, r.Host+r.Path)
if t.RedirectCode != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still using the old syntax

route/route.go Outdated
@@ -215,7 +222,7 @@ func (r *Route) weighTargets() {
}

// compute the weight for the targets with dynamic weights
dynamic := (1 - sumFixed) / float64(len(r.Targets)-nFixed)
dynamic := float64(1-sumFixed) / float64(len(r.Targets)-nFixed)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gofmt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made this change because I thought it was a bug. VS Code was telling me that dynamic is an int. But then I ran some tests under the debugger and it is definitely not an int. Now I know I can't trust VS Code to determine var types correctly.

screenshot
screenshot2

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you divide something by a float64 then the result can never be an int since Go doesn't auto-convert between numeric types. const values are the only exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, this makes sense. My bad.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries. You live and learn :)

@@ -416,6 +416,9 @@ func TestTableParse(t *testing.T) {
targetURLs := make([]string, len(r.wTargets))
for i, tg := range r.wTargets {
targetURLs[i] = tg.URL.Scheme + "://" + tg.URL.Host + tg.URL.Path
if tg.RedirectCode != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

old syntax here as well

route/table.go Outdated
@@ -273,8 +273,8 @@ func (t Table) route(host, path string) *Route {

// normalizeHost returns the hostname from the request
// and removes the default port if present.
func normalizeHost(req *http.Request) string {
host := strings.ToLower(req.Host)
func normalizeHost(host string, req *http.Request) string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I've passed in *http.Request was that I needed both host and the TLS state. If you pass in the host then you might as well pass in the TLS state, e.g.

func normalizeHost(host string, tls bool) string {
    host = strings.ToLower(host)
    if !tls && strings.HasSuffix(host, ":80") {
        return host[:len(host)-len(":80")]
    }
    if tls && strings.HasSuffix(host, ":443") {
        return host[:len(host)-len(":443")]
    }
    return host
}

...

normalizeHost(req.Host, req.TLS != nil)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, good call.

@@ -33,6 +33,9 @@ type Target struct {
// URL is the endpoint the service instance listens on
URL *url.URL

// RedirectCode is the HTTP status code used for redirects.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// RedirectCode is the HTTP status code used for redirects.
// When set to a value > 0 the client is redirected to the target url.

route/route.go Outdated
@@ -68,6 +69,7 @@ func (r *Route) addTarget(service string, targetURL *url.URL, fixedWeight float6
t.StripPath = opts["strip"]
t.TLSSkipVerify = opts["tlsskipverify"] == "true"
t.Host = opts["host"]
t.RedirectCode, _ = strconv.Atoi(opts["redirect"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we log the error or catch this somewhere sooner and log it there?

{
tag: "p-www.bar.com:80/foo https://www.bar.com/ redirect=302",
route: "www.bar.com:80/foo",
opts: "https://www.bar.com/ redirect=302",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I forgot that we need to be able to specify the redirect target. How about this?

redirect=302,http://www.bar.com/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I can do that syntax for the consul service tags.

@@ -62,6 +62,9 @@ func (h *RoutesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Rate1: tg.Timer.Rate1(),
Pct99: tg.Timer.Percentile(0.99),
}
if tg.RedirectCode != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

old syntax

@@ -119,6 +119,8 @@ func serviceConfig(client *api.Client, name string, passing map[string]bool, tag
dst := "http://" + addr + "/"
for _, o := range strings.Fields(opts) {
switch {
case strings.Contains(o, "://"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would then also become more obvious by looking for redirect=xxx

if opts["redirect"] != "" {
t.RedirectCode, err = strconv.Atoi(opts["redirect"])
if err != nil {
log.Printf("[ERROR] redirect status code should be numeric in 3xx range. Got: %s", opts["redirect"])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally these kind of errors should prevent the route from being added, but that would take some refactoring.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could be added the route parser but then it would need to treat opts not as an opaque k/v string. I can have a look at this if and when I write a better parser.

@magiconair
Copy link
Contributor

You missed one spot:

route/table_test.go:480:48: cannot use tt.req (type *http.Request) as type bool in argument to normalizeHost

@ctlajoie
Copy link
Contributor Author

I made the requested changes but we're not done yet unfortunately. As I mentioned in an earlier comment the integration test fails due to the way the targetURL is constructed in http_proxy.go. With
route add mock /foo http://a.com/abc opts "redirect=301", we get redirected to http://a.com/foo. So I was considering implementing some way to indicate you want the target URL to be absolute, in case this behavior is not desired. Can you comment on that?

@magiconair
Copy link
Contributor

Yeah, I'm not sure that matters since the target URL should always be absolute. There is no path mangling in fabio since the routing table is usually generated and the http://host:port/ is really just host:port. Originally, I wanted to keep this open and used URLs for that but this may be misleading. So in fabio you can't forward /foo to /bar/foo. This isn't supported and so far not many have complained about that.

@magiconair
Copy link
Contributor

I think the other thing that bugs me a bit but for which I don't have a good answer right now is the continued divergence between the syntax of the urlprefix- tags and the route commands.

urlprefix-/ redirect=302,http://www.google.com/

# translates to
route add svc / http://www.google.com/ opts "redirect=302"

I need to think about this a bit more.

@magiconair
Copy link
Contributor

That isn't something that I'd want to address in this PR anyway but I'd like to keep in mind. One approach could be to write a template instead of a special syntax.

fabio <templated route add command>

e.g.

fabio route add $svc / $addr opts "redirect=302"

Then you'd have to learn only one syntax.

@ctlajoie
Copy link
Contributor Author

I'm not so sure we always want it to be absolute. Suppose you have these routes

route add svc example.com:80 / https://example.com redirect=302
route add svc example.com:443 / 1.2.3.4:5678

If you hit http://example.com/some/deep/link you get redirected to https://example.com/some/deep/link. This seems very useful, but I can also see how it might not be desired, depending on the purpose of your redirect route.

@magiconair
Copy link
Contributor

How do other load balancers address that?

@ctlajoie
Copy link
Contributor Author

Don't know.. I'll do a bit of research.

@magiconair
Copy link
Contributor

Thx. That'd be nice. It's getting a bit late here in NL. Going to hit the pillow since the kids get up early. We're getting there.

@aaronhurt
Copy link
Member

aaronhurt commented Nov 29, 2017

I can comment on how it's done with HAProxy where we do our http to https redirects.

    redirect scheme https code 301 if { hdr(Host) -i my.ena.com } !{ ssl_fc }

We have that syntax in our main frontend that binds on both 80 and 443 and redirects any request coming in with a Host header of my.ena.com to the exact same URI but using the https scheme.

When I request http://my.ena.com/foo/bar/thing.html I will get a 301 redirect to https://my.ena.com/foo/bar/thing.html with the above configuration.

@magiconair
Copy link
Contributor

We could add some magic with a terminating slash or not. redirect=301,http://foo.com would append the path from the original request and redirect=301,http://foo.com/bar would redirect to /bar. That wouldn't solve the path mangling if you want to redirect /foo to /foo/bar or /bar/foo. Another approach could be to add a pseudo-variable like $path at the end. This would give you full control over the redirect target.

request uri: http://bar.com/foo

redirect=301,http://foo.com$path -> http://foo.com/foo
redirect=301,http://foo.com/ -> http://foo.com/
redirect=301,http://foo.com/bar$path -> http://foo.com/bar/foo
redirect=301,http://foo.com/bar/$path -> http://foo.com/bar/foo # ignore double slashes

@aaronhurt
Copy link
Member

aaronhurt commented Nov 29, 2017

The pseudo variable would be very flexible and is also less ambiguous than magic based on trailing vs non-trailing slash paths.

@ctlajoie
Copy link
Contributor Author

I really like the pseudo-variable idea

@ctlajoie
Copy link
Contributor Author

Pushed changes. Have a close look at http_proxy because I rearranged some code in there since I am no longer using the targetURL var as the redirect target.

@magiconair
Copy link
Contributor

@ctlajoie I will. This looks decent so far but I'll let it bake for a couple more days since I want to think a bit more about edge cases. If me or you can't think of anything major then I'll merge this early next week (Mon or Tue). Sounds good?

@ctlajoie
Copy link
Contributor Author

Sounds good. I'm actually using it right now in a semi-production environment (accessed by people working in my office) so it's getting some real-world testing.

@magiconair
Copy link
Contributor

Even better.

@ctlajoie
Copy link
Contributor Author

ctlajoie commented Dec 5, 2017

The only thing I can think of that this implementation does not cover would be complex URL rewriting based on regexps (like mod_rewrite). I think it's fair to say that's out of scope.

Do you want me to squash everything down to 1 commit before this gets merged? Are there any other changes you want me to make?

magiconair pushed a commit that referenced this pull request Dec 5, 2017
This patch adds support to redirect a request for a matching route to
another URL. If the `redirect=<code>` option is set on a route fabio will
send a redirect response to the dst address with the given code.

The syntax for the `urlprefix-` tag is slightly different since the
destination address is usually generated from the service registration
stored in Consul.

The `$path` pseudo-variable can be used to include the original request URI
in the destination target.

    # redirect /foo to https://www.foo.com/
    route add svc /foo https://www.foo.com/ opts "redirect=301"

    # redirect /foo to https://www.foo.com/
    urlprefix-/foo redirect=301,https://www.foo.com/

    # redirect /foo to https://www.foo.com/foo
    urlprefix-/foo redirect=301,https://www.foo.com$path

Fixes #87
@magiconair magiconair closed this in 44b8986 Dec 5, 2017
@magiconair
Copy link
Contributor

I've merged this to master. I had to update the demo server to handle the comma in the redirect option but that was a good time to make this change anyway.

Thanks a lot for this. This has been a long requested feature.

@ctlajoie
Copy link
Contributor Author

ctlajoie commented Dec 8, 2017

@magiconair You might want to know that there was/is a problem with current versions of nomad/consul where if you have :// in a service tag, nomad is unable to deregister that service. See hashicorp/nomad#3620

Should be fixed in the next nomad release.

@magiconair
Copy link
Contributor

@ctlajoie Thanks. That's good to know.

@magiconair magiconair modified the milestones: 1.6, 1.5.4 Dec 9, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants