Managing Iptables with Ansible the Easy Way
When we started working with Ansible, we struggled to find a simple and easy solution to manage iptables. Ansible doesn’t have a built-in way of configuring iptables, so usually a recommended way is to use a single template with all the rules defined in it, which is then configured using different variables. There are also some solutions where you generate parts of the configuration with Ansible and assemble them with, for example, ferm in the end.
However, we didn’t like any of these solutions because they always turned out to be complicated to use and maintain. The other reason why we didn’t like them is because they didn’t feel like a natural way of configuring iptables. We felt that configuring iptables should be an inseparable part of deploying a service.
Ansible 2.0 introduced an iptables module, which is interesting. However, the problem with the module is that it doesn’t keep state, so it’s not that much different from executing iptables commands with Ansible. Also, the module introduced custom parameters to define rules, so we would need to convert our current rules to be able to use it.
iptables_raw Module
That’s why we decided to write our own module to manage iptables, which would allow us to:
- Use iptables syntax to define rules – we didn’t want to learn a new syntax and convert our current rules.
- Roles must define their own rules – for example, the
nginx
role would open ports 80 and 443. - Easily order rules by weight – since the order of the rules is important, it would need to be easy to order them.
- Manage rules not defined with Ansible – we would need to be able to distinguish rules that were created outside Ansible so that we could remove them if needed.
- Keep state – Ansible should be able to save the state of iptables.
We named the module iptables_raw
and you can download it from github(documentation). It should work with Ansible 1.9+, but we recommend that you use it with 2.1+ since it supports diffs. This will show exactly what changes to iptables were made.
To use theiptables_raw
module, just copy the file into./library
, alongside your top level playbooks. Alternatively, copy it into the path specified byANSIBLE_LIBRARY
or the--module-path
command line option.
We’ve tested the module on CentOS 5, 6 and 7, but any distribution that loads iptables rules on boot from /etc/sysconfig/iptables
should work. Also, note that this module saves state in the /etc/ansible-iptables
directory, so don’t modify this directory.
Here are a few examples of how to use the module:
# Open TCP port 80
- iptables_raw:
name: allow_tcp_80
rules: '-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT'
# Open TCP port 22, but insert it before port 80 (default weight is 40)
- iptables_raw:
name: allow_tcp_22
rules: '-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT'
weight: 35
As you can see, the module is very easy to use if you know iptables. After running these two tasks, and if you don’t have any prior rules, iptables will look like this and they will be loaded on boot:
iptables -nL
Chain INPUT (policy ACCEPT)
target prot opt source destination
ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:22 /* ansible[allow_tcp_22] */
ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:80 /* ansible[allow_tcp_80] */
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
The module adds a comment ansible[name]
to every rule so it’s easy to distinguish which task created each rule. Also, since it has a smaller weight, the port 22 rule is before 80.
To delete a rule is also easy; all you need is the name of the rule:
# Delete allow_tcp_80
- iptables_raw:
name: allow_tcp_80
state: absent
Of course, these are just the most simple examples. The module also supports:
- adding rules in different tables (
raw
,nat
,mangle
,…) - ip6tables
- management of unmanaged rules
- safe flushing of rules.
Using iptables_raw in Roles
Now that we have a module that keeps the state of iptables, we can easily configure iptables using Ansible from each role separately. This means that a role should add all iptables rules the service it deploys needs to function properly.
For example, if we have an nginx
role, that role will take care of opening all the needed ports for Nginx
to work. Likewise, if we set up a Postgres
replication cluster, that role will open all the needed ports for the replication to work.
Let’s see an example of how to use this module with an nginx
role. We would basically add the following variable in defaults:
# roles/nginx/defaults/main.yml
---
nginx_open_ports:
- 80
- 443
and have this task to open those ports:
# roles/nginx/tasks/main.yml
# other tasks
- name: Open ports in iptables
iptables_raw:
name: nginx_open_ports
state: '{{ nginx_open_ports | ternary("present", "absent") }}'
rules: '-A INPUT -p tcp -m multiport --dports {{ nginx_open_ports|join(",") }} -j ACCEPT'
tags: iptables
# other tasks
We use a ternary
filter here that will remove this rule (or won’t even create it) if there are no ports specified. What is also important is to tag all tasks that modify iptables with an iptables
tag. That way, if we want to quickly configure iptables on some host, we could just run that tag on a host, for example:
ansible-playbook web_servers.yml -l web-001 -t iptables
Managing Default/Custom Rules
Since roles define their own rules, this is pretty much all you need to use the iptables_raw
module. One thing that isn’t covered is where to put default rules (e.g. open port 22, allow all traffic on loopback, allow RELATED
and ESTABLISHED
, etc) and custom rules that are not configured by roles.
You can put these rules in some common
role (if you have one), but we decided to create a separate iptables
role for this.
Our iptables
role looks like this:
# roles/iptables/defaults/main.yml
---
# Default head (allow) rules
iptables_default_head: |
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp --icmp-type echo-request -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
# Default tail (deny) rules
iptables_default_tail: |
-A INPUT -j REJECT
-A FORWARD -j REJECT
iptables_custom_rules: []
# Example:
# iptables_custom_rules:
# - name: open_port_12345 # 'iptables_custom_rules_' will be prepended to this
# rules: '-A INPUT -p tcp --dport 12345 -j ACCEPT'
# state: present
# weight: 40
# ipversion: 4
# table: filter
#
# NOTE: 'name', 'rules' and 'state' are required, others are optional.
# By default this role deletes all iptables rules which are not managed by Ansible.
# Set this to 'yes', if you want the role to keep unmanaged rules.
iptables_keep_unmanaged: no
with the following tasks:
# roles/iptables/tasks/main.yml
- name: Set custom iptables rules
iptables_raw:
name: 'iptables_custom_rules_{{ item.name }}'
rules: '{{ item.rules }}'
state: '{{ item.state }}'
weight: '{{ item.weight|default(omit) }}'
table: '{{ item.table|default(omit) }}'
with_items: '{{ iptables_custom_rules }}'
tags: iptables
- name: Set default iptables head rules
iptables_raw:
name: iptables_default_head
weight: 10
keep_unmanaged: '{{ iptables_keep_unmanaged }}'
state: present
rules: '{{ iptables_default_head }}'
tags: iptables
- name: Set default iptables tail rules
iptables_raw:
name: iptables_default_tail
weight: 99
keep_unmanaged: '{{ iptables_keep_unmanaged }}'
state: '{{ (iptables_default_tail != "" ) | ternary("present", "absent") }}'
rules: '{{ iptables_default_tail }}'
tags: iptables
Custom rules are added first. Our aim is always to have the least amount of custom rules because we want the roles to set up iptables.
After custom rules, the role adds the iptables_default_head
rules, which have a weight of 10. This weight should put them before all the rules that are added by default (with weight 40). And finally, the role adds iptables_default_tail
rules — rules that reject all traffic and have the greatest weight (99), and are therefore, put at the bottom.
If the iptables default rules were configured with the defaultINPUT
andFORWARD
chain policies set toDROP
, then iptables_default_tail rules wouldn’t be needed. The problem with this is that if you flush all rules by mistake, you get locked out of the host. With default policies set toACCEPT
, and withREJECT
rules as the last rules, flushing doesn’t lock you out. (Note that flushing all rules with theiptables_raw
module doesn’t have this drawback since the module will first set all policies toACCEPT
and then flush them).
One more important thing to note is that the iptables_default_head
rules task also has a keep_unmanaged
parameter, which is set to the value of the iptables_keep_unmanaged
variable. This parameter is by default set to yes
in the iptables_raw
module, which means that the module won’t remove rules that were not created by Ansible. It will leave them there but will set them to have a weight of 90.
Since we wanted Ansible to manage all the iptables rules on our production servers, we set keep_unmanaged=no
in the iptables
role so that Ansible deletes all rules that are not created by the module. We made this variable configurable in the role because we allow custom rules on our test servers — our developers add/remove a lot of different rules while they are developing and testing, and need this functionality.
So, all together, a playbook for our web servers could look like this:
---
- hosts: web_servers
roles:
- { role: 'common', tags: 'common' }
- { role: 'postgres', tags: 'postgres' }
- { role: 'nginx', tags: 'nginx' }
- { role: 'php-fpm', tags: 'php-fpm' }
- { role: 'iptables', tags: 'iptables' }
The nginx
and postgres
roles open all the needed ports for those services to work, while the iptables
role opens default and custom rules. We added the iptables
role last because that role deletes all unmanaged rules when keep_unmanaged=no
. If we first ran the iptables
role on a host, which already had iptables rules that were not defined with the iptables_raw
module, it would delete all other rules. This would then cut connectivity to all your services (except SSH, since port 22 is in default rules). Of course, if you provision a host from scratch, the order of the roles doesn’t matter because there aren’t any services running — there is nothing to cut off.
Conclusion
The iptables_raw
module allows us to manage iptables rules like we did before we started using Ansible. We define new rules when they are really needed (when a service is configured), we use the same iptables syntax we are familiar with, and on top of that, the module saves state automatically.
What's more, if we need to deploy some roles on a host, we don’t have to worry if those roles needs to open any ports or add any iptables rules because our roles take care of that as well. Combining all of this together makes managing iptables a breeze and saves us a lot of time. And, in the end, what’s more important than that?
We’ve been using this module in production for a long time now and we plan to maintain and continue improving it. We also hope that the Ansible core developers like the module enough to make it part of Ansible (like our pull request). Try it out and tell us what you think. Your feedback will be much appreciated!