HA Proxy forwards based on URL

Hi,

So I’ve only just started out with HA proxy and my requirements aren’t anything too heavy but I’m still running into a few issues.

Goal
I have a single IP that I use port forwarding on to allow me to host many web servers behind an NGINX reverse proxy. I now have to get an RDS Gateway functioning behind that proxy. A week of tinkering has been only partially successful until I realised that this solution won’t work. I’ll not go into the details here.

Solution
I came accross this: https://www.haproxy.com/documentation/haproxy/deployment-guides/remote-desktop/rdp-gateway and thought this would be a viable (though a little messy), solution. The thought being that HA Proxy will be the first hop, listening on 80 and 443, examining the headers of inbound connections. If it sees rds.mydomain.com, it forwards the connection to the RDS Gateway. If it doesn’t match, it forwards to my NGINX reverse proxy. Having HA Proxy terminate the SSL connection is also attractive to me, especially when you consider things like this: https://betanews.com/2020/01/27/windows-remote-desktop-gateway-rce-exploit. So, thats the basics of what I’m aiming for.
For my initial testing, I have used most of the config found in the link above with some of my own additions. HA Proxy is listening on 81 and 443 (although only 81 is currently accessible while I nail this part of my problem down), it should check the headers, see that they are bound for webserver.mydomain.com, skip the config for rds.mydomain.com and forward to my test webserver.

Problem
Punching in http://webserver.mydomain.com:81 hits the HA Proxy server and forwards me to http://webserver.mydomain.com:81/RDWeb. Obviously the acl checking is not firing as I expected although I’m not totally clear why.

Code
You’ll have to excuse the heavily commented code. It’s for my benefit as I learn all the syntax (and possibly will help others point out where I’m getting confused).

global
        log /dev/log    local0
        log /dev/log    local1 notice
        chroot /var/lib/haproxy
        stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
        stats timeout 30s
        user haproxy
        group haproxy
        daemon

        # Default SSL material locations
        ca-base /etc/ssl/certs
        crt-base /etc/ssl/private

        # See: https://ssl-config.mozilla.org/#server=haproxy&server-version=2.0.3&config=intermediate
        ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
        ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
        ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
        log             global
        # Define this connection type as HTTP. Defining this parameter allows for further parameters to be defined.
        mode            http
        # Define additional options. Descriptions to follow.
        option          httplog
        option          dontlognull
        option          http-keep-alive
        timeout         http-request 10s
        timeout         queue 1m
        timeout         connect 10s
        timeout         client 10s
        timeout         server 1m
        timeout         http-keep-alive 10s
        timeout         check 10s
        maxconn         1000
        errorfile 400   /etc/haproxy/errors/400.http
        errorfile 403   /etc/haproxy/errors/403.http
        errorfile 408   /etc/haproxy/errors/408.http
        errorfile 500   /etc/haproxy/errors/500.http
        errorfile 502   /etc/haproxy/errors/502.http
        errorfile 503   /etc/haproxy/errors/503.http
        errorfile 504   /etc/haproxy/errors/504.http

    # Define one universal frontend to capture both HTTP & HTTPS traffic.
    frontend mydomain.com
            bind *:81
            bind *:443

            # Define an acl called "acl_rds" that looks for mydomain.com in the request header of the inbound connection.
            acl acl_rds hdr(host) -i rds.mydomain.com

            # Capture the host name from the request header for later use. Max length of header can be no more than 32
            # characters in length. We can only capure request and response headers in seperate streams. Request captures
            # on inbound traffic and responses on outbound traffic.
            capture request header Host len 32

            # Redirect request to /RDWeb if user tries to access the root (/).
            http-request redirect location /RDWeb/ if { path -i / /RDWeb }

            # Define an acl called "path_rdweb" that checks the path of inbound requests for "/RDWeb/".
            acl path_rdweb path_beg -i /RDWeb/

            # Deny the request unless the requested URL matches the acl defined above (path_rdweb). This prevents users
            # trying any sub-folders at rds.mydomain.com other than /RDWeb.
            http-request deny unless path_rdweb

            # Use the backend named "backend_rds" if you find the acl defined as "acl_rds".
            use_backend backend_rds if acl_rds

            # If the incoming connection does not match rds.mydomain.com, send the connection to the nginx proxy server.
            default_backend backend_nginx

    backend backend_rds
            # Use the balacing algorithm "leastconn". This algorithm is best suited for long connections rather than
            # website connections.
            balance leastconn

            # This is how ha-proxy will perform a health-check on the RDS server. It will check to see if the /RDWeb
            # path is accessible. As soon as it becomes inaccessible, the server in the pool (doesn't apply in this
            # specific config), is seen as being down. ha-proxy will continue checking this URL and the moment it
            # comes back up again, it the backend server will be added back into the pool.
            option httpchk GET /RDWeb

            # Insert a cookie called RDWEB. Append a “Cache-Control: nocache” to the cookie since this type of
            # traffic is supposed to be personnal and we don’t want any shared cache on the internet to cache it.
            cookie RDPWEB insert nocache

            # Override the default options for the default server - THIS MIGHT NOT BE NEEDED - POSSIBLE REMOVAL
            default-server inter 3s rise 2 fall 3

            # Define the first (and only in this specific config), backend server.
            server rds_gw 192.168.1.30:443 maxconn 1000 weight 10 ssl verify none check cookie rds_gw

    backend backend_nginx
            mode http
            balance roundrobin
            server srv1 192.168.1.29:80

Hopefully, someone can point out my errors. Once I get the redirect working, I can move onto testing the RDS portion.

Thanks for any suggestions you have.

So I’ve scaled this down to start simple. I think I was diving feet first without learning the basics.

So can anyone explain why the below config ignores requests made for rds.mydomain.com and ends up sending all requests to default backend server?

global
    maxconn 1000
    log /dev/log local0
    user haproxy
    group haproxy
    stats socket /run/haproxy/admin.sock user haproxy group haproxy mode 660 level admin
    ssl-default-bind-ciphers ECDHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
    timeout connect 10s
    timeout client 30s
    timeout server 30s
    log global
    mode http
    option httplog
    maxconn 500

frontend rds.mydomain.com
    bind 192.168.50.3:81
    acl acl_rds hdr(Host) -i rds.mydomain.com
    use_backend be_rds if acl_rds
    default_backend be_nginx

backend be_rds
    mode http
    server be_rds 192.168.100.30:80

backend be_nginx
    mode http
    server be_rds 192.168.100.29:80

Log files generated:

When external user visits http://rds.mydomain.com:

Jun  2 00:51:13 ha-proxy haproxy[12135]: 148.252.133.21:38949 [02/Jun/2020:00:51:13.860] rds.mydomain.com be_nginx/be_rds 0/0/1/3/4 200 468 - - ---- 1/1/0/0/0 0/0 "GET / HTTP/1.1"
Jun  2 00:51:14 ha-proxy haproxy[12135]: 148.252.133.21:32399 [02/Jun/2020:00:51:14.277] rds.mydomain.com be_nginx/be_rds 0/0/1/1/2 404 313 - - ---- 2/2/0/0/0 0/0 "GET /favicon.ico HTTP/1.1"

When a user visits web-server.mydomain.com

Jun  2 00:51:49 ha-proxy haproxy[12135]: 148.252.133.21:37586 [02/Jun/2020:00:51:49.734] rds.mydomain.com be_nginx/be_rds 1/0/0/2/3 200 468 - - ---- 1/1/0/0/0 0/0 "GET / HTTP/1.1"
Jun  2 00:51:50 ha-proxy haproxy[12135]: 148.252.133.21:61183 [02/Jun/2020:00:51:50.082] rds.mydomain.com be_nginx/be_rds 0/0/0/1/1 404 313 - - ---- 2/2/0/0/0 0/0 "GET /favicon.ico HTTP/1.1"

So for those of you that come across this and have similar issues, the fix was painfull simple (always the way).
Here are the steps I took to diagnose the issue as well as the fix so anyone in the same boat has the full picture:

  1. Add this line to catch the header requests in the frontend section with issues. In my case, I only had one frontend as I’m using HA Proxy to intercept all requests and look for one specific URL in the header then send it to a specific server. All non matches to another server. This string below will tell HA Proxy to capture the header requests. This will be used for logging.
    http-request capture req.hdr(Host) len 30

  2. Change your log-format line in the defaults to:
    log-format "%[capture.req.hdr(0)] %{+Q}[capture.req.hdr(1)]"

  3. Restart the ha-proxy server (sudo service haproxy restart) and tail the logs. For me, I saw the following:
    Jun 2 15:16:28 ha-proxy haproxy[13885]: 148.252.133.21:40846 [02/Jun/2020:15:16:28.915] default_frontend be_nginx/be_nginx 0/0/1/1/2 304 169 - - ---- 1/1/0/0/0 0/0 {rds.mydomain.com:81} "GET / HTTP/1.1"

So from that, the error was clear. As I was using:
use_backend be_rds if { hdr(Host) -i rds.mydomain.com } in the frontend, the entire hostname was attempting to be matched rather than if I had used: hdr_beg(host) to only match the host name (everything before the .)

Probably seems simple to those of you that have been using HA Proxy for sometime for for those of us new to it, I don’t think so. All guides I’ve found do not do a good job of explaining why, more a “copy and paste this”.

So a simple change to the use_backend be_rds if { hdr(Host) -i rds.mydomain.com } line, to instead read use_backend be_rds if { hdr(Host) -i rds.mydomain.com:81 } fixed the issue.

Hope it helps others stuck in a similar situation.

Hi I am working on the (allmost) same issue here. I tried opening your link to https://www.haproxy.com/documentation/haproxy/deployment-guides/remote-desktop/rdp-gateway as a starting point (as you did). But the link doesn’t work. Do you (or anyone) by any chance have this saved as a pdf you could post here or send me?

The link to this document just redirects to the latest HAproxy enterprise manual. I then went https://www.haproxy.com/knowledge-base/ and searched for “rdp” and the article you cite comes up in the first page of results, but when I click the link, it is the same redirect to their most recent haproxy manual. It seems they’ve even broken their own internal links.

It looks like may be using your config and solution as my guide because that’s all I can find at this point.