When I purchased my first VPS, I had to learn how setup a firewall to secure my server. Instead of relying on a management tool like ufw, I opted to just write individual iptables rules directly, and thus have complete control over my firewall.

I came up with a base set of rules for all of my servers. To automatically apply the rules at startup, I placed a simple loader script inside of /etc/network/if-up.d/ which would essentially flush all the iptables chains and then restore my custom rules. This was achieved through the iptables-restore command. The loader script would then run systemctl restart fail2ban to restore fail2ban's chains and rules, which had been removed by the iptables-restore command.

This method of managing my firewalls was working fine until I started using Docker. I learned that Docker relies on its own iptables chains and forwarding/NAT rules for container networking to work properly. My firewall loader script would break Docker networking every time iptables-restore was called, because it completely wiped out existing Docker rules. An easy, but bad solution would be to just call systemctl restart docker to restore the rules, but this would also restart any running Docker containers.

So I did some research and came up with a better (IMO) way to load my rules. I created two systemd unit files.


The first is firewall-init.service. Here's what it looks like:

[Unit]
Description=Firewall Init
After=network.target iptables.service firewalld.service fail2ban.service
Before=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/etc/network/firewall/init.sh

[Install]
WantedBy=multi-user.target

At system startup, this service calls /etc/network/firewall/init.sh after fail2ban starts, but before Docker starts. This allows us to place our custom rules right where we want them. Keep in mind that at startup, iptables will be empty.


Here's what init.sh contains:

#!/bin/bash

input="INPUT-CUSTOM"

iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
iptables -N $input
iptables -A INPUT -j $input

ip6tables -P INPUT DROP
ip6tables -P FORWARD DROP
ip6tables -P OUTPUT ACCEPT
ip6tables -N $input
ip6tables -A INPUT -j $input

init.sh does the following:

  • Define the chain that will hold our customized input rules
  • Set the default policies of the INPUT and FORWARD chains to DROP
  • Set the default policy of the OUTPUT chain to ACCEPT
  • Create our custom chain, in this case, INPUT-CUSTOM
  • Append a jump to our custom chain INPUT-CUSTOM to the INPUT chain
  • Do everything again, except with ip6tables

The second unit file is firewall.service. Here's what that one looks like:

[Unit]
Description=Firewall Rules
Requires=firewall-init.service
After=firewall-init.service docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/etc/network/firewall/rules.sh

[Install]
WantedBy=multi-user.target

This service calls /etc/network/firewall/rules.sh after both firewall-init.service and Docker have started.


Here's what rules.sh contains:

#!/bin/bash

input="INPUT-CUSTOM"

iptables -F $input
iptables -F DOCKER-USER
ip6tables -F $input

iptables -A $input -i lo -j ACCEPT -m comment --comment "Accept loopback traffic"

iptables -A $input -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Accept established, related"

iptables -A $input -p icmp -m icmp --icmp-type echo-request -j ACCEPT -m comment --comment "Accept pings"

iptables -A $input -p tcp --dport 22 -j ACCEPT -m comment --comment "Accept SSH"

ip6tables -A $input -i lo -j ACCEPT -m comment --comment "Accept loopback traffic"

ip6tables -A $input -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Accept established, related traffic"

# start custom rules

# end custom rules

iptables -A $input -j RETURN
iptables -A DOCKER-USER -j RETURN
ip6tables -A $input -j RETURN

rules.sh does the following:

  • Define the chain that will hold our customized input rules

  • Flush our custom chain, in this case, INPUT-CUSTOM

  • Flush the DOCKER-USER chain, which Docker creates for us

  • Add a rule to accept loopback traffic

  • Add a rule to accept established/related traffic

  • Add a rule to accept pings

  • Add a rule to accept SSH traffic

  • Add rules to accept IPv6 loopback and established/related traffic

  • Add a RETURN rule to the end of INPUT-CUSTOM to jump back to the INPUT chain if no rules in our custom chain are matched

  • Add a RETURN rule to the end of DOCKER-USER to jump back to the FORWARD chain if no rules in the DOCKER-USER chain are matched


The nice part about this method is that it doesn't touch any fail2ban or Docker rules. It creates a custom chain for our rules and only flushes that chain. Any time you modify rules in rules.sh, all you have to do is run systemctl restart firewall to apply them. No restart of fail2ban or Docker will be necessary.

The unit files are flexible in that they will work just fine installed on a system that will not be used as a Docker host. For non-Docker hosts, you would just remove any references to the DOCKER-USER chain from rules.sh. I currently implement firewalls on all my servers using this method. Not having to wait for fail2ban to restart every time I reload firewall rules is great!


Practical Example

So now that you have an overview of how to load your custom rules, here is an example of how to setup rules for Docker containers.

First I'll give you an example of a docker-compose.yml:

  version: '3'
  services:
     nagios:
       container_name: nagios
       image: jasonrivers/nagios
       restart: always
       networks:
         compose:
           ipv4_address: 192.168.240.2
       ports:
         - "80:80"
     unifi:
       container_name: unifi
       image: jacobalberty/unifi
       restart: always
       networks:
         compose:
           ipv4_address: 192.168.240.3
       ports:
         - "8443:8443"
         - "8080:8080"
         - "3478:3478/udp"

  networks:
    compose:
      ipam:
        driver: default
        config:
          - subnet: 192.168.240.0/24
      driver_opts:
        com.docker.network.bridge.enable_ip_masquerade: "true"

As you see, I like to explicitly define container networks and IP addresses. In this example, all our containers are part of the compose network: 192.168.240.0/24. This makes it easier to create firewall rules.

By default, if we were to run docker-compose up -d and start these containers, anybody would be able to access the exposed ports. Even though our INPUT chain has a default policy of DROP, people will still be able to access our containers. The reason for this is that the containers are on a different network than the host, and are using NAT for port forwarding. So our Docker host is essentially a router at this point.

Any traffic for our Docker containers goes through the FORWARD chain. Even though we've set the default policy on the FORWARD chain to DROP, Docker inserts its own ACCEPT rules after the DOCKER-USER chain. So to firewall our containers, all our rules need to be added to the DOCKER-USER chain.

In the above example, you'll see that we have a container running Nagios (192.168.240.2) on port 80, and a UniFi controller (192.168.240.3) on ports 8443, 8080, and 3478/udp.

Here are the example firewall rules we want to implement:

  • Allow all established/related traffic to reach Docker containers
  • Only allow access to Nagios port 80 from the subnet 10.10.10.0/24.
  • Only allow access to the UniFi web interface on port 8443 from the subnet 10.10.11.0/24.
  • Only allow access to UniFi ports 8080 and 3478/udp from the subnet 10.10.12.0/24
  • Drop all unsolicited traffic for Docker containers

Here's what those rules would look like in rules.sh:

#!/bin/bash

input="INPUT-CUSTOM"

iptables -F $input
iptables -F DOCKER-USER
ip6tables -F $input

iptables -A $input -i lo -j ACCEPT -m comment --comment "Accept loopback traffic"

iptables -A $input -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Accept established, related"

iptables -A $input -p icmp -m icmp --icmp-type echo-request -j ACCEPT -m comment --comment "Accept pings"

iptables -A $input -p tcp --dport 22 -j ACCEPT -m comment --comment "Accept SSH"

ip6tables -A $input -i lo -j ACCEPT -m comment --comment "Accept loopback traffic"

ip6tables -A $input -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Accept established, related traffic"

# start custom rules

iptables -A DOCKER-USER -d 192.168.240.0/24 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Accept established, related"

iptables -A DOCKER-USER -d 192.168.240.2 -s 10.10.10.0/24 -p tcp --dport 80 -j ACCEPT -m comment --comment "Accept Nagios TCP 80 from 10.10.10.0/24"

iptables -A DOCKER-USER -d 192.168.240.3 -s 10.10.11.0/24 -p tcp --dport 8443 -j ACCEPT -m comment --comment "Accept UniFi TCP 8443 from 10.10.11.0/24"

iptables -A DOCKER-USER -d 192.168.240.3 -s 10.10.12.0/24 -p tcp --dport 8080 -j ACCEPT -m comment --comment "Accept UniFi TCP 8080 from 10.10.12.0/24"

iptables -A DOCKER-USER -d 192.168.240.3 -s 10.10.12.0/24 -p udp --dport 3478 -j ACCEPT -m comment --comment "Accept UniFi UDP 3478 from 10.10.12.0/24"

iptables -A DOCKER-USER -d 192.168.240.0/24 -j DROP -m comment --comment "Drop unsolicited"

# end custom rules

iptables -A $input -j RETURN
iptables -A DOCKER-USER -j RETURN
ip6tables -A $input -j RETURN

Install

So now that you have an idea of how this works, feel free to fire up a VM, install Docker, and play around with it. Here's everything you need to get a firewall up and running on your Docker host.

curl -fsSL https://raw.githubusercontent.com/vishalmalli/docker-host-iptables/master/lib/systemd/system/firewall-init.service -o /lib/systemd/system/firewall-init.service
curl -fsSL https://raw.githubusercontent.com/vishalmalli/docker-host-iptables/master/lib/systemd/system/firewall.service -o /lib/systemd/system/firewall.service
mkdir /etc/network/firewall/
curl -fsSL https://raw.githubusercontent.com/vishalmalli/docker-host-iptables/master/etc/network/firewall/init.sh -o /etc/network/firewall/init.sh
curl -fsSL https://raw.githubusercontent.com/vishalmalli/docker-host-iptables/master/etc/network/firewall/rules.sh -o /etc/network/firewall/rules.sh
chmod +x /etc/network/firewall/init.sh
chmod +x /etc/network/firewall/rules.sh
systemctl enable firewall-init
systemctl enable firewall

GitHub Repo: https://github.com/vishalmalli/docker-host-iptables


Conclusion

I'm definitely no expert when it comes to Linux, Docker, or networking. There's so many different ways to handle firewalls on Linux, but this is what works for me.