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:

  1. Use iptables syntax to define rules – we didn’t want to learn a new syntax and convert our current rules.
  2. Roles must define their own rules – for example, the nginx role would open ports 80 and 443.
  3. Easily order rules by weight – since the order of the rules is important, it would need to be easy to order them.
  4. 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.
  5. 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 the iptables_raw module, just copy the file into ./library, alongside your top level playbooks. Alternatively, copy it into the path specified by ANSIBLE_LIBRARYor 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 default INPUT and FORWARD chain policies set to DROP, 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 to ACCEPT, and with REJECT rules as the last rules, flushing doesn’t lock you out. (Note that flushing all rules with the iptables_raw module doesn’t have this drawback since the module will first set all policies to ACCEPT 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!