How to manage IPTables rules with UFW and Docker
When using Docker, it has added a whole bunch of firewall rules by default. These rules allow you to intelligently route the host machine's ports to the right containers, but also to allow exchanges between several networks (in a Swarm, for example). It is, however, complicated to set up our own rules when Docker issues its own.
Let's use UFW
UFW is a very simple application to avoid putting your fingers in the complex world of firewalls. With a few commands you can allow or block a port from one IP to a new one.
Now, how's it going? Any rules you put in place will pass after the rules put in place by Docker. So if you block port 80 using UFW, for example, the containers will remain accessible. By default, the policy I like to use is the following:
ufw allow ssh
ufw default deny incoming
ufw default allow outgoing
We block all incoming connections and allow all outgoing ones. I want to be in control of everything that goes through the server.
Execute UFW rules before those of Docker
There's a trick to it. Indeed, our objective here is to execute UFW rules before
Docker's. There is a chain in IPTables called DOCKER-USER
, which allows
rules to be executed before generic container rules. However, UFW cannot
communicate with this chain, but only with ufw-user-input
(in our case). So
let's start by resetting these rules each time UFW is restarted: modify the
/etc/ufw/before.init
file to include the lines about the firewall :
set -e
box "$1" in
start)
# typically required
;;
stop)
iptables -F DOCKER-USER || true
iptables -A DOCKER-USER -j RETURN || true
iptables -X ufw-user-input || true
# typically required
;;
status)
# optional
;;
flush-all)
# optional
;;
*)
echo "'$1' not supported"
echo "Usage: before.init {start|stop|flush-all|status}"
;;
Now you have to tell the firewall that the rules defined by UFW must be executed
before those of Docker. Let's add these lines to the /etc/ufw/after.rules
file.
Note that the $INTERFACE
variable must be replaced with the name of the
primary host interface used by Docker (such as eth0
or eno1
).
*filter
:DOCKER-USER - [0:0]
:ufw-user-input - [0:0]
:ufw-after-logging-forward - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i $INTERFACE -j ufw-user-input
-A DOCKER-USER -i $INTERFACE -j ufw-after-logging-forward
-A DOCKER-USER -i $INTERFACE -j DROP
COMMIT
Before restarting UFW, you must also allow primary connections to the server, namely :
# Web
ufw allow web
# Docker Swarm cluster
# The ports used by Docker Swarm to communicate between the different nodes
ufw allow proto tcp from $SERVER1_IP to any port 2377,7946
ufw allow proto udp from $SERVER1_IP to any port 4789,7946
ufw allow proto tcp from $SERVER2_IP to any port 2377,7946
ufw allow proto udp from $SERVER2_IP to any port 4789,7946
# ...
We can restart ufw
to take into account all the changes we have made. Be
careful not to restart ufw
too soon, otherwise you won't have remote access to
the server (all ports will be closed if you didn't allow SSH).
ufw reload
You can easily do a test run, start a listening container on the host (port 8000, for example) and you should not have access to the service until you allow the port using UFW. We now have complete control over our server.
Bonus: Ansible
Here are some useful rules used to implement these rules automatically using Ansible.
---
- host: <redacted>
become: true
tasks:
- name: install ufw
apt: name={{ item }} state=present update_cache=yes
with_items:
- ufw
- name: configure ufw defaults
ufw: direction={{ item.direction }} policy={{ item.policy }}
with_items:
- { direction: 'incoming', policy: 'deny' }
- { direction: 'outgoing', policy: 'allow' }
notify:
- restart ufw
- name: configure ufw ports
ufw: rule={{ item.rule }} port={{ item.port }} proto={{ item.proto }}
with_items:
- { rule: 'allow', port: '22', proto: 'tcp' }
- { rule: 'allow', port: '80', proto: 'tcp' }
- { rule: 'allow', port: '443', proto: 'tcp' }
notify:
- restart ufw
# You should install Docker before this rule.
- name: configure ufw before.init to remove existing rules
blockinfile:
path: /etc/ufw/before.init
marker: "# {mark} ANSIBLE MANAGED BLOCK"
insertafter: stop\)
block: |
iptables -F DOCKER-USER || true
iptables -A DOCKER-USER -j RETURN || true
iptables -X ufw-user-input || true
- name: chmod /etc/ufw/before.init
file:
path: /etc/ufw/before.init
state: touch
mode: "a+x"
- name: configure ufw to work with DOCKER-USER chain name
blockinfile:
path: /etc/ufw/after.rules
marker: "# {mark} ANSIBLE MANAGED BLOCK (docker-user)"
block: |
*filter
:DOCKER-USER - [0:0]
:ufw-user-input - [0:0]
:ufw-after-logging-forward - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i {{ ansible_default_ipv4.interface }} -j ufw-user-input
-A DOCKER-USER -i {{ ansible_default_ipv4.interface }} -j ufw-after-logging-forward
-A DOCKER-USER -i {{ ansible_default_ipv4.interface }} -j DROP
COMMIT
# Optional
- name: configure ufw ports for docker swarm (TCP)
ufw: rule=allow src={{ hostvars[item]['ansible_default_ipv4']['address'] }} port=2377,7946 proto=tcp
with_items: "{{ ansible_play_hosts | default(play_hosts) }}"
notify: restart ufw
# Optional
- name: configure ufw ports for docker swarm (UDP)
ufw: rule=allow src={{ hostvars[item]['ansible_default_ipv4']['address'] }} port=4789,7946 proto=udp
with_items: "{{ ansible_play_hosts | default(play_hosts) }}"
notify: restart ufw
handlers:
- name: restart ufw
service: name=ufw state=restarted enabled=yes