I'm trying to secure a VPS running Docker containers so that their exposed ports are only accessible through a VPN interface (in my case it's Tailscale). In order to do that, I read about firewalld and came up with the following rules:
sudo firewall-cmd --reload
sudo firewall-cmd --zone=trusted --add-interface=tailscale0
sudo firewall-cmd --zone=trusted --add-port=8080/tcp
Before adding Docker into the mix, I tested my rules with a simple Python server:
python -m http.server 8080
Note: From now on, I'll refer to my Tailscale IP as 100.100.100.100
and my public IP as 1.2.3.4
.
As expected, the connection succeeds from Tailscale:
➜ telnet 100.100.100.100 8080
Trying 100.100.100.100...
Connected to 100.100.100.100.
Escape character is '^]'.
And fails from the public IP:
➜ telnet 1.2.3.4 8080
Trying 1.2.3.4...
telnet: connect to address 1.2.3.4: Connection refused
telnet: Unable to connect to remote host
Then I tried with a Docker container:
docker run -p 8080:80 --name test-http-server --restart always nginx:alpine
At first the server was accessible from anywhere, so I read a lot on the subject (see sources below for a subset of my readings) and managed to build the latest version of firewalld to date to get support for the (relatively new) StrictForwardPorts=yes option, which did indeed prevent the port from being exposed automatically.
But that also made my rules unable to forward the port unless I add a rule:
CONTAINER_IP=$(docker inspect -f '{{.NetworkSettings.Networks.bridge.IPAddress}}' test-http-server)
sudo firewall-cmd --zone=trusted --add-forward-port=port=8080:proto=tcp:toport=80:toaddr=${CONTAINER_IP}
Unfortunately now my server is accessible from all interfaces, even though the rules are in the trusted
zone, where only the Tailscale interface is:
➜ telnet 1.2.3.4 8080
Trying 1.2.3.4...
Connected to 1.2.3.4.
Escape character is '^]'.
➜ telnet 100.100.100.100 8080
Trying 100.100.100.100...
Connected to 100.100.100.100.
Escape character is '^]'.
After much reading, failed attempts, and discussing with ChatGPT, I'm running out of ideas. I seem to have narrowed it down to the port forward rule that somehow seems to get around the firewalld zones, but I couldn't find another way to achieve my goal.
What am I missing/doing wrong ?
Note #1: I know that I could (and should) bind my container to my Tailscale interface, and that it would achieve my goal:
docker run -d -p 100.100.100.100:8080:80 --name test-http-server --restart always nginx:alpine
That's indeed what I intend to do when everything else is ready, but that's not enough for me, as I want my firewall rules to be foolproof against whatever mistake I could make with my containers, and against weak binding values my services (which might spawn containers on their own) might have hard-coded. The firewall should give me the "last word" over my packets.
Note #2: I guess I could achieve my goal using iptables, but I spent so much time already on this and, honestly, iptables is just too complicated for my needs. firewalld, on the other hand, seems to be a standard for easy-to-use firewalls.
Note #3: I tried changing the FirewallBackend
setting from nftables
(default) to iptables
since Docker's firewall limitations mention that they are only compatible with iptables-nft
and iptables-legacy
and firewalld's homepage mentions that too at the bottom:
docker (iptables backend only)
I'm not sure what this means, but since there is iptables
in both of these names and not in nftables
, I figured this was worth a shot. To my greatest surprise, using this backend just brought me back to exactly the same spot I was with StrictForwardPorts=no
: the server is accessible from both interfaces without the need to add the port forward rule.