Using HAProxy to allow ingress traffic from internet to intranet resources

As mentioned in a previous post, I recently heard about a case where someone wanted to allow an AWS EC2 instance on a public IP address to access a corporate intranet resource. The proposed solution was to run an HAProxy server on the corporate network that could proxy traffic from the AWS compute instance to the intranet server. This post will give a demonstration of that.

Please note: Don’t bypass your organization’s security controls! Implementing a solution like this without prior authorization or approval would likely constitute a violation of an organization’s acceptable use policy or information security policies.

The 3 servers for this example:

  1. The corporate intranet resource, intranet.osric.net or 192.0.2.43
  2. The HAProxy host, haproxy.osric.net or 198.51.100.200
  3. The AWS EC2 instance, ec2-203-0-113-155.compute-1.amazonaws.com or 203.0.113.155

Although I’ve primarily used HAProxy for load balancing in the past, it can easily be used as a basic reverse proxy server. A load-balancer, after all, is basically a reverse proxy with multiple backend servers. I set up a new RedHat Enterprise Linux (RHEL) 10 host, haproxy.osric.net and installed and enabled HAProxy:

$ sudo dnf install haproxy
$ sudo systemctl enable haproxy

I then added an HAProxy config at /etc/haproxy/conf.d/proxy.cfg HAProxy should automatically find a config file ending with .cfg at that location. The default config included at /etc/haproxy/haproxy.cfg will also be loaded. It includes some config options you won’t need!

Here is the content I added to proxy.cfg:

listen http-proxy
    bind :80
    server intranet 192.0.2.43:80

I restarted the haproxy service:

$ sudo systemctl restart haproxy

After that I was able to use curl to access the intranet resource, both from the haproxy host and from the EC2 instance:

From the haproxy host:

$ curl localhost
<!DOCTYPE html>
<html>
<head>
<title>Intranet - osric.net</title>
</head>
<body>
<h1>Intranet - osric.net</h1>
<p>There is nothing to see here.</p>
</body>
</html>

From 203.0.113.155 (the AWS EC2 instance):

$ curl 198.51.100.200
<!DOCTYPE html>
<html>
<head>
<title>Intranet - osric.net</title>
</head>
<body>
<h1>Intranet - osric.net</h1>
<p>There is nothing to see here.</p>
</body>
</html>

Restricting proxy access

As it is, the proxy is open to the world. Anyone can now access resources from intranet.osric.net via the proxy. How can we lock it down so that only the intended IP address, 203.0.113.155, can access it?

I found the article Allow and Block IP Addresses in HAProxy (2026) with some tips. I modified the proxy.cfg file:

$ cat /etc/haproxy/conf.d/proxy.cfg
listen http-proxy
    bind :80
    acl authorized_ip src 203.0.113.155
    http-request deny if !authorized_ip
    server intranet 192.0.2.43:80

From the HAProxy server the HTTP request is denied by HAProxy with an HTTP 403 error:

$ curl localhost
<html><body><h1>403 Forbidden</h1>
Request forbidden by administrative rules.
</body></html>

However, the request still works from the IP address of the authorized AWS EC2 instance:

$ curl 198.51.100.200
<!DOCTYPE html>
<html>
<head>
<title>Intranet - osric.net</title>
</head>
<body>
<h1>Intranet - osric.net</h1>
<p>There is nothing to see here.</p>
</body>
</html>

Host-based firewall restrictions

It’s best if HAProxy never even sees the request from unauthorized sources. We can achieve this by allowing HTTP requests from only the authorized IP address via the host-based firewall.

In this case, I’m using nftables to manage the host-based firewall. There’s a sample configuration for nftables on RHEL 10 at /etc/nftables/main.nft. I added the following line to the allow chain:

ip saddr 203.0.113.155 tcp dport 443 accept

The full allow chain now looks like this:

chain allow {
    ct state established,related accept
    meta l4proto @allowed_protocols accept
    iifname @allowed_interfaces accept
    tcp dport @allowed_tcp_dports accept
    ip saddr 203.0.113.155 tcp dport 443 accept
}

To load the ruleset into nftables, I used the following:

$ sudo nft -f /etc/nftables/main.nft

It’s important when working with nftables that you don’t cut off your own access! The sample file at /etc/nftables/main.nft includes SSH as an allowed port in @allowed_tcp_dports, but the configuration is set to reject any connection that is not explicitly allowed. It is also important to note that the sample file also contains other rules you likely won’t need (it allows port 9090/tcp connections from all sources, for example).

Also important to note: changes to nftables are not persistent by default. For example, if you reboot the system and check for existing nftables rules, nothing is returned:

$ sudo nft list ruleset

To persist the rules on RHEL 10, take the following steps:

Uncomment the following line in /etc/sysconfig/nftables.conf:

#include "/etc/nftables/main.nft"

Then, enable and start the nftables service:

$ sudo systemctl enable nftables
$ sudo systemctl start nftables

The nftables service is not a daemon, it just runs at startup (or when the service is started). It is normal for the service to report that it is exited, as in the excerpt below:

$ systemctl status nftables
● nftables.service - Netfilter Tables
     Loaded: loaded (/usr/lib/systemd/system/nftables.service; enabled; preset: disabled)
     Active: active (exited) since Thu 2026-10-08 22:03:58 UTC; 3h 52min ago

You can check and confirm that the rules are present (and that they persist after a reboot):

$ sudo nft list ruleset

What about TLS connections (HTTPS)?

That’s a great question. I did find a site that has what looks to be helpful info, but I have not tested it yet:

How to Configure HAProxy SSL Passthrough for IPv4 Backend Servers

That will be a project for another day.

Using nftables to allow ingress traffic from internet to intranet resources

I recently heard about a case where someone wanted to allow an AWS EC2 instance on a public IP address to access a corporate intranet resource. Their solution was to run an HAProxy server on the corporate network that could proxy traffic from the AWS compute instance to the intranet server.

Whether or not accessing intranet resources from an external host is good idea or violates corporate policy I will leave aside for now, but I thought of 6 additional ways to accomplish the same objective.

What follow is one of those 6 ideas: using Network Address Translation (NAT) on the intermediary host via nftables. I have spent far more time with iptables than I have with nftables. But this seemed like a good opportunity to experiment.

The 3 servers for this example:

  1. The corporate intranet resource, intranet.osric.net or 192.0.2.43
  2. The NAT host, nat.osric.net or 198.51.100.200
  3. The AWS EC2 instance, ec2-203-0-113-155.compute-1.amazonaws.com or 203.0.113.155

The nat.osric.net server is running RHEL 10, although the commands should work on other systems using nftables.

I followed the steps provided by Configuring destination NAT using nftables on nat.osric.net:

sudo nft add table nat
sudo nft -- add chain nat prerouting { type nat hook prerouting priority -100 \; }
sudo nft add chain nat postrouting { type nat hook postrouting priority 100 \; }

The next commands needed the interface name, which I found using nmcli device status (ens5 in this case).

sudo nft add rule nat prerouting iifname ens5 tcp dport { 80, 443 } dnat to 192.0.2.43
sudo nft add rule nat postrouting oifname "ens5" snat to 198.51.100.200
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/95-IPv4-forwarding.conf
sudo sysctl -p /etc/sysctl.d/95-IPv4-forwarding.conf

It worked! Well, with a couple caveats: an issue with the TLS certificate, and a wide-open NAT configuration.

The TLS certificate

From the EC2 instance:

$ curl https://198.51.100.200
curl: (60) SSL: no alternative certificate subject name matches target host name '198.51.100.200'
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

I could think of a few ways around this:

  • Add an entry to /etc/hosts on the client, i.e. on ec2-203-0-113-155.compute-1.amazonaws.com
  • Ignore the warning, e.g. curl --insecure https://198.51.100.200/
  • Add a subject alternative name (e.g. nat.osric.net) to the TLS cert for intranet.osric.net

The last method, adding a Subject Alternative Name (SAN) sounded like a pain, and also may not be feasible if you don’t manage the TLS certificate for that host. And I never like ignoring security warnings. But editing the /etc/hosts file was a simple solution that worked. Here’s the line I added to the EC2 instance (ec2-203-0-113-155.compute-1.amazonaws.com):

198.51.100.200   intranet.osric.net

After that, I was able to use curl https://intranet.osric.net/ to return content without errors or warnings.

The wide-open NAT

The configuration, as implemented, could allow anyone on the Internet to bypass the network security controls around intranet.osric.net. All they have to do is visit nat.osric.net (and maybe ignore some TLS warnings). This is terrible! Fortunately, we can use nftables to limit NAT to only specific inbound traffic.

How can I review what has already been added to nftables? Fortunately, Quick reference-nftables in 10 minutes was helpful here.

$ sudo nft list tables
table ip nat
$ sudo nft list chains
table ip nat {
        chain prerouting {
                type nat hook prerouting priority dstnat; policy accept;
        }
        chain postrouting {
                type nat hook postrouting priority srcnat; policy accept;
        }
}
$ sudo nft list ruleset
table ip nat {
        chain prerouting {
                type nat hook prerouting priority dstnat; policy accept;
                iifname "ens5" tcp dport { 80, 443 } dnat to 192.0.2.43
        }

        chain postrouting {
                type nat hook postrouting priority srcnat; policy accept;
                oifname "ens5" snat to 198.51.100.200
        }
}

In order to delete the dnat rule, I needed its handle. To get this, I used the -a flag:

$ sudo nft -a list ruleset
table ip nat { # handle 1
        chain prerouting { # handle 1
                type nat hook prerouting priority dstnat; policy accept;
                iifname "ens5" tcp dport { 80, 443 } dnat to 192.0.2.43 # handle 8
        }

        chain postrouting { # handle 2
                type nat hook postrouting priority srcnat; policy accept;
                oifname "ens5" snat to 198.51.100.200 # handle 9
        }
}

I deleted handle 8:

sudo nft delete rule nat prerouting "handle 8"

And added a new prerouting rule:

sudo nft add rule nat prerouting iifname ens5 ip saddr 203.0.113.155 tcp dport { 80, 443 } dnat to 192.0.2.43

That provides NAT for only the EC2 instance at 203.0.113.155.

The thing that I like about this solution is that it requires no new software. It uses the networking functionality that is already present on the host. However, unless you take additional steps, there is virtually no logging. nftables can log messages to syslog for packets that match the DNAT rule, but even that is very different than the level of logging that a proxy server would provide.

References:

During some initial testing, I was using another EC2 instance as the NAT server and it did not work immediately. That’s because the EC2 instance was not aware of its own public IPv4 address, it was only aware of its private IPv4 address: 172.31.15.224. Removing the SNAT rule for the public IP address and adding a new SNAT rule for 172.31.15.224 solved that.