A Simple HAProxy Setup

2023-03-08

I'm fortunate enough to have pretty fast Internet at home, but it comes with a downside: DS-Lite. This means I don't have my own public IPv4 address, only IPv6. It's a non-issue for outgoing connections (at least in my case, I have heard of reliability issues), but incoming connections are required to use IPv6.

In theory, this shouldn't be a problem either. Practically, IPv6 is still broken in many places, often making it impossible to access the things I'm hosting at home. The solution: Setting up a proxy.

Some Considerations

First of all, the proxy should run in TCP, not HTTP mode. It will simply forward all traffic without terminating TLS or changing HTTP headers. Certificate management will stay on the target server in my home network. I want to keep that server directly accessible over IPv6, only IPv4 should be proxied.

Furthermore, this setup allows for easily adding additional services like SSH to be accessible via the proxy.

HAProxy

HAProxy is popular, powerful and relatively easy to set up. It seems like a good fit. Based on an example from the manual, the simplest working haproxy.conf to forward ports 80 and 443 looks like this:

global
    daemon
    user haproxy
    group haproxy
    maxconn 10

defaults
    mode tcp
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

listen http-in
    bind *:80
    bind *:443
    server myserver example.com maxconn 10

This will listen on all addresses and forward all traffic to ports 80 and 443 to example.com.

However, in this setup, those ports are blocked entirely for the machine hosting the proxy. We need a dedicated IPv4 address specifically for the proxy to use:

listen http-in
    bind ipv4@1.2.3.4:80
    bind ipv4@1.2.3.4:443
    server ipv6@myserver example.com maxconn 10

HAProxy also allows for hostnames in bind-directives:

listen http-in
    bind ipv4@example.com:80
    bind ipv4@example.com:443
    server myserver ipv6@example.com maxconn 10

Keep in mind that in TCP mode, HAProxy does not really understand hostnames like it would in HTTP mode. They are simply resolved to IP addresses, it will not look at HTTP headers. Therefore, in TCP mode, it is not possible to sort traffic going to the same IP merely based on hostnames (which would be found in the "Host" Header).

However, this is still useful for an easier configuration and in case the IP behind the hostname changes, which happens regularly in home networks.

Proxy Protocol

Anyway, the setup is working well, but one problem remains: The target server thinks all the requests are coming from the proxy, it does not see the real client's real IP! Ideally, this would be solved with http headers like X-Forwarded-For. However, as mentioned above, HAProxy is running in TCP mode, rendering it unable to set http headers.

But there is a solution – HAProxy's own Proxy Protocol:

listen http-in
    bind ipv4@example.com:80
    bind ipv4@example.com:443
    server ipv6@myserver example.com maxconn 10 send-proxy-v2 check

This will also need some reconfiguring on the backend, which in my case is nginx. It already supports the proxy protocol, so only some adjustment of the listen-directive in nginx.conf is necessary:

http {
    server {
        ...
        listen [::]:80 http2 proxy_protocol
        listen [::]:443 ssl http2 proxy_protocol

        set_real_ip_from my_proxy; # Replace my_proxy with the proxy's IP or hostname
        real_ip_header proxy_protocol;
        real_ip_recursive on;
        ...

The backend server can now see the client's real IP, but we've created a new issue: It cannot listen for connections using both the Proxy Protocol and regular http(s) on the same ports, meaning now it can only be reached via the proxy. Since only IPv4 traffic is supposed to be proxied, this is not acceptable. Fortunately, the solution is simple:

http {
    server {
        ...
        listen [::]:80 http2
        listen [::]:8080 http2 proxy_protocol
        listen [::]:443 ssl http2
        listen [::]:8443 ssl http2 proxy_protocol
        ...

Nginx will now listen for regular http requests on ports 80 and 443 and for requests using the Proxy Protocol on ports 8080 and 8443.

haproxy.conf has to be adjusted accordingly. Separate listen-Blocks are required to properly remap each port:

listen http-in
        timeout queue 10s
        bind ipv4@example.com:80
        server myserver ipv6@example.com:8080 maxconn 10 send-proxy-v2

listen https-in
        timeout queue 10s
        bind ipv4@example.com:443
        server myserver ipv6@example.com:8443 maxconn 10 send-proxy-v2

The result of this setup: With the correct DNS records set, IPv6 requests to example.com ports 80 and 443 will go directly to the server, whereas IPv4 requests will go the proxy. It will forward them to the target server over IPv6, using ports 8080 and 8443.

A diagram showing the setup described in this post

Health checks

As mentioned above, using hostnames instead of IP addresses in haproxy.conf is useful if the IP changes. However, to make HAProxy automatically re-resolve the hostname, some more configuration is needed:

listen http-in
        timeout queue 10s
        bind ipv4@example.com:80
        server myserver ipv6@example.com:8080 maxconn 10 send-proxy-v2 check resolvers mydns

listen https-in
        timeout queue 10s
        bind ipv4@example.com:443
        server myserver ipv6@example.com:8443 maxconn 10 send-proxy-v2 check resolvers mydns
 
resolvers mydns
    parse-resolv-conf
 

check resolvers mydns tells HAProxy to check if it can still reach the target server at example.com. If it can't, it will use the resolver "mydns" to resolve the hostname again. parse-resolv-conf tells it to use the nameservers defined in /etc/resolv.conf, i.e. the system default nameservers.