Configure URLs with prefix in Mojolicious behind Reverse Proxy (ProxyPass)
Asked Answered
C

3

6

I'm looking for a reliable way to configure Mojolicious running behind an Apache reverse proxy under /app, so that url_for('/foo') actually returns /app/foo instead of just /foo (otherwise all the links would be broken).

The documentation shows a reverse proxy example for everything under /. But that's not what I need, since the application should be under /app.
Turning ProxyPass / http://localhost:8080/ into ProxyPass /app http://localhost:8080/ will cause the issue as the /app prefix would be missing from all urls generated by the application.

The documentation also has a section on rewriting, which has an example of a before_dispatch hook that will take the first part of the request url and use it as base. This requires the prefix to be appended to the ProxyPass url (ProxyPass /app http://localhost:8080/app/ with trailing slash) in the Apache config, which does not seem to be mentioned on that page but maybe it does not need to be ("Move first part and slash from path to base path") because it's obvious. That makes it possible to call http://localhost/app/page, which turns into http://localhost:8080/app/page ('app' removed by the hook), where url_for('/foo') will return '/app/foo' (http://localhost/app/foo), so the links will be correct (without trailing slash in the ProxyPass rule, that would make /apppage/foo).

However, in this example, the url modification is always made in production mode (if app->mode eq 'production'). So calling the backend server directly (http://localhost:8080/testpage) won't work anymore, since all the urls would be broken.

So I thought, I'd check if the X-Forwarded-For header is set (by mod_proxy_http) instead, which would always be set for reverse proxy requests. And since the Apache mod_proxy documentation mentions that this header might already be set in the client request (and end up containing more than one value), I'd remove it from the request first - because a client sending this header should not cause the url base modification.

Apache VirtualHost configuration:

# ProxyPreserveHost: Mojo::URL::to_abs() not 127.0.0.1
ProxyPreserveHost On
<Location "/app/">
    # ProxyPass: prefix pass-thru
    ProxyPass http://localhost:3000/app/
    # RequestHeader: must not be set externally
    RequestHeader unset X-Forwarded-For
</Location>

Hook in Mojolicious startup():

$self->hook('before_dispatch' => sub {
    my $c = shift;
    my $behind_proxy = !!$c->req->headers->header('X-Forwarded-Host');
    if ($behind_proxy) {
        push @{$c->req->url->base->path->trailing_slash(1)},
            shift @{$c->req->url->path->leading_slash(0)};
        $c->req->url->path->trailing_slash(0) # root 404
            unless @{$c->req->url->path->parts};
    }
});

This seems to work...

Question: Will my approach work reliably in the "real world" or is it flawed?

Edit:
Requesting the root address (http://localhost:3000/app/) through the reverse proxy always resulted in an error 404. So I've added two lines to turn the trailing slash off in that case. Since I can't find that in the docs, there may be a better way.

Cassicassia answered 9/5, 2014 at 18:27 Comment(1)
You might be interested in using Mojolicious Route Bridge. This way you can easily add /app to all your urls: mojolicio.us/perldoc/Mojolicious/Guides/Routing#BridgesErrolerroll
C
4

I'm now answering my own question as I'm getting more suggestions (not just here) from people who would put a hard-coded prefix in their application code. It should be obvious that prefixing all generated urls manually isn't a solution. Just imagine two instances of the same application deployed on the same server, one under /app1 and the other under /app2. The whole point of the suggested code in my question is that the application works and produces correct urls if accessed through a reverse proxy without breaking requests going directly to the application server. A developer would run Morbo, but a hard-coded prefix would break that. Also, I made at least one mistake in my question, but nobody seems to have noticed.

Flaws in code

I had too many slashes in my example in the question. The way the Location block is defined, requests to /app without trailing slash would fail. It might be better to write it like that:

<Location "/app">
...

Next, I wrote that I check for the X-Forwarded-For header but I actually checked for X-Forwarded-Host. That wouldn't be a problem if I were also clearing that header but I cleared X-Forwarded-For instead. With that awkward mistake, the safety mechanism wouldn't work, so if you'd set this header while working with your application server at localhost:3000, the app would try to repair the manipulated url even though it's not supposed to do that.

It should've been:

RequestHeader unset X-Forwarded-Host

Example:

ProxyPreserveHost On
<Location /app>
    ProxyPass http://localhost:3000/app
    RequestHeader unset X-Forwarded-Host
</Location>

The ProxyPreserveHost directive isn't required as long as the application uses relative urls everywhere. If the application wants to generate an absolute url, for example url_for('/page')->to_abs, ProxyPreserveHost should be enabled, otherwise external clients would get http://localhost:3000/app/page.

Reverse proxy detection

When I wrote that question, I saw the before_dispatch hook in the Mojolicious documentation and, as pointed out in the question, I wanted to use it for an application running under /app. However, I didn't want to break Morbo. The example assumes that the application is in production mode ($app->mode) while running behind a reverse proxy but not when access directly through Morbo, but I didn't want to change the mode for every other request.

That's why I added a condition to check if the request came through a reverse proxy. As this header is only set by Apache (by the mod_proxy_http module) and not by Mojo::Server::Morbo, it can be used as reverse proxy detection. Together with the right directive to clear the X-Forwarded-Host, I believe the answer to my question would be that yes, that should work reliably.

(Although that last part isn't strictly necessary as long as direct access to the app server is limited to the developer.)

Manipulated url

To show why I've added the /app prefix to the ProxyPass line in the Apache configuration, I'd like to point out that this approach manipulates the url to allow the application to work under the given prefix. There's another question of someone who forgot to add the prefix in the Apache configuration and I wrote an answer explaining what the hook does.

Morbo: localhost:3000
Apache reverse proxy: host.com/app or localhost/app

# Browser > Apache:
http://host.com/app
# Apache (ProxyPass http://localhost:3000/) > Mojolicious sees:
GET /
url_for '/test' = /test 
(or even //test if the hook pushes undef, see the other answer linked above)
# Apache (configured as described above) > Mojolicious sees:
GET /app
# Hook:
base = /app
request = /
url_for '/test' = /app/test 

Normally, the local target argument in the ProxyPass directive would not have the prefix, it would just be something like ProxyPass http://...:3000/. In that case, the application doesn't know about the prefix, which is why all generated urls and links are incomplete.

This approach requires you to let Apache pass the prefix through to the application server. The application doesn't know about the prefix, so it wouldn't know what to do with a request to /app/page. This is where the hook comes in. It assumes that the first level of the path is always the prefix, so it turns /app/page into /page and it conveniently appends the /app prefix to the url base, which is used when generating urls, making sure that a link to /test actually points to /app/test.

Obviously, this modification should not be done for any request sent directly to Morbo.

Alternative

Alternatively, a custom request header could be set by the reverse proxy and then used by the hook to produce working urls. The Mojolicious::Plugin::RequestBase module works that way. It expects you to define the prefix in the X-Request-Base header, not in the url:

RequestHeader set X-Request-Base "/app"

In that case, the application should only receive requests for urls relative to that prefix:

ProxyPass http://localhost:3000/

All that module really does is pick up the header and use it as url base:

$c->req->url->base($url); # url = X-Request-Base = /app

Example:

<Location /app>
    ProxyPass http://localhost:3000
    RequestHeader set X-Request-Base "/app"
</Location>

This is a nice and simply solution. Note that the /app prefix appears twice in that case. And of course, the hook implemented by that module only does its work if the X-Request-Base header is set, just like the hook shown above does nothing if the X-Forwarded-Host header is not set.

Cassicassia answered 6/9, 2018 at 20:5 Comment(0)
P
3

You need to set base path for each request url in before_dispatch hook

$app->hook(before_dispatch => sub {
  my $c = shift;
  $c->req->url->base->path('/app/');
});

Example:

use Mojolicious::Lite;

app->hook(before_dispatch => sub {
  shift->req->url->base->path('/app/');
});


get '/' => sub {
  my $c = shift;
  $c->render(text => $c->url_for('test'));
};

get '/test/url' => sub { ... } => 'test';

app->start;

And result:

$ curl 127.0.0.1:3000
/app/test/url
Petro answered 8/1, 2015 at 7:15 Comment(3)
This would always prepend the "app" prefix, even for direct requests (curl 127.0.0.1:3000) that don't go through the Apache proxy. But this is not the question. Quoting: So calling the backend server directly (http://localhost:8080/testpage) won't work anymore, since all the urls would be broken.Cassicassia
You can add the prefix 'app' only if request was make via proxy server. <pre><code> app->hook(before_dispatch => sub { my $self = shift; $self->req->url->base->path('/app/') if $self->req->headers->header('Some_header_from_proxy_server'); }); </pre></code>Petro
Andrey, thanks for trying to help, I really appreciate it - but please take another look at my question. You're suggesting that I check for "some" header. However I thought of this too before I asked the question, I found a specific header ("X-Forwarded-Host") which does not need to be configured because it's set automatically. My question was: Will my approach work reliably in the "real world" or is it flawed?Cassicassia
C
0

You should mount your application under required path.

In your startup you should do:

$r =  $app->routes;
$r =  $r->any( '/api' )->partial( 1 );

$r->get( '/test' );

You should not configure specially your apache. When GET /api/test will come your app will get /api/test route. This route partially matched to /api and the rest route /test will be assigned into ->stash->{ path }.

So rest routes will be checked against /test (source)

Cumulate answered 31/10, 2017 at 7:13 Comment(1)
This doesn't fix urls generated by the application. In addition to the hardcoded prefix in the any route, all url_for calls would have to have the same prefix added. Also, this has absolutely nothing to do with REST.Cassicassia

© 2022 - 2024 — McMap. All rights reserved.