Managing Firewalld with Ansible - Part 1
Ansible already provides modules to handle Firewalld, however it can be tricky to build a comprehensive, flexible ruleset. Here we will combine those modules, with Roles, to achieve a highly flexible approach. Read on below, for more ...

Overview
Whilst it is possible to use the native Ansible Firewalld modules to control the active rule set on your host, it can be hard to combine them into a flexible and modular system. This is a particular pain point, when you have multiple teams contributing to the overall Ansible codebase, and can inhibit delegating control to the teams responsible for a given application. By the end of this short series, we will be in a position to declare a simple dictionary of rules:
app_firewall_rules:
inbound:
- name: app_inbound
zone:
- local
ports:
- port: 80
protocol: tcp
- port: 443
protocol: tcp
outbound:
- name: app_smtp
protocol: tcp
ports:
- 25
hosts:
- 10.10.1.10
- 10.10.1.11
and by passing this variable through a dependency from, for example, an application role to a role we are going to write:
dependencies:
- { role: firewalld_rules, firewalld_rules: "{{ app_firewall_rules }}" }
ensure that this and any number of other applications on a host, can manage their firewall rules.
Disclaimer
This blog series should serve as inspiration for your own solution. Whilst we have done our best to ensure everything here is accurate and as flexible as possible, there is no substitute for fully understanding what is happening under the hood. As with any work involving a firewall, it remains eminently possible to lock yourself out of a host, or otherwise break your services. Proceed at your own risk!
Scope (or what this is not about)
There are two fundamental use cases when it comes to firewalls in modern environments.:
- Grabbing some commodity hardware and a Linux distro, then combining them to build a dedicated firewalling host. This would be used to control the flow of traffic across one or more network boundaries, in place of a purpose built appliance
- Configuring a host based firewall on each host on your network, to add a local layer of firewalling.
For this series of posts, we will be firmly talking about the latter use case. Whilst it is likely possible to adapt what is contained here to use Firewalld to build the former, it is not something I have done or, in honesty, would likely recommend. There are already much better products that fill this space (i..e pfSense, OPNSense, etc)
So how is this going to work?
Before we get into that, it is probably worth a sentence or two around how Firewalld works, as this will inform the approach we take.
In most cases, the primary concern is likely controlling incoming traffic to a host. We might for example, have a web service running on our host on port 443, and we want to ensure that only traffic from a local, internal network can access it. Firewalld splits this into two concepts:
- Zones: Firewalld allows you to define a series of zones. Zones describe a logical network location, for which you can control which services are exposed to it. The location is largely arbitrary, but can be defined in terms of networks from which traffic may originate. or a location to which one of your local network interfaces is connected
- Services: These are typically a series of port/protocol combinations (sockets) that your host may be listening on, which can then be placed in one or more zones
In our simple example, we would create a Zone in our configuration to represent the local network, and a Service comprising of port TCP/443, which would then be added into the Zone.
Given this, our approach when devising a way to manage this in Ansible, will be via the creation of two roles:
firewalld_common
: this role will be used to initialise the basic firewall landscape of the host. In this role we will initialise some basic elements, and also handle the creation of all the zones that will be needed on each host. Given this is a key basis to our approach, this role must be applied before we attempt to configure our rulesfirewalld_rules
: this role will build on the base that we have applied infirewalld_common
to then add each of the rules that the workloads running on our hosts require. This role will be designed to be called multiple times in a modular fashion, such that each application can define a series of rules that it requires and then have them be applied by this role
The rest of this series will require you to be familiar with Ansible roles. You can find the relevant documentation here.
Also, it is assumed that you declare become: yes
in your playbook that calls the roles, as the tasks in these roles will need to be run as root.
firewalld_common role
Lets get started by creating the firewalld_common
role. In your roles
directory create a firewalld_common
directory. Within that directory, create two more directories: defaults
and tasks
. Now create the following files:
roles/firewalld_common/defaults/main.yml
---
firewall_enabled: true
firewalld_zones:
- name: development
sources:
- 192.168.10.0/24
- name: testing
sources:
- 192.168.20.0/24
- 192.168.30.0/24
roles/firewalld_common/tasks/main.yml
---
- name: Manage firewalld service
systemd:
name: firewalld
state: "{{ firewall_enabled | ternary('started','stopped') }}"
enabled: "{{ firewall_enabled }}"
- name: Manage firewalld zone files
include: manage_firewalld_zone.yml
loop: "{{ firewalld_zones | default([]) }}"
loop_control:
loop_var: zone
when: firewall_enabled
roles/firewalld_common/tasks/manage_firewalld_zone.yml
---
- name: Get current zone status
shell: /bin/firewall-cmd --list-all-zones | grep -q "^{{ zone.name }}" && echo configured || echo not-configured
register: zone_status
changed_when: false
- name: Manage {{ zone.name }} zone
shell: /bin/firewall-cmd --permanent --new-zone="{{ zone.name }}" && /bin/firewall-cmd --reload
when: zone_status.stdout == 'not-configured'
- name: Manage {{ zone.name }} firewalld zone sources
firewalld:
zone: "{{ zone.name }}"
source: "{{ source_cidr }}"
state: enabled
permanent: yes
immediate: yes
loop: "{{ zone.sources }}"
loop_control:
loop_var: source_cidr
So hopefully nothing too taxing here. We start out by defining our defaults file. This contains enough basic detail to get a sane config in place, but can easily (and likely will) be overridden elsewhere in the inventory. firewall_enabled
simply gives us a toggle to turn the whole firewall on and off. firewalld_zones
is a place to define the various custom zones we wish to have in place on this host. In this case we are defining two zones. For each zone we specify the CIDR network addresses that inbound traffic from those zones originates. Remember these are the role defaults and will be configured on each host unless overridden. In practice, it is likely that you will want to define different zones, dependent on the location and purpose of each host in your network. This can easily be accommodated by redeclaring the firewalld_zones
variable within a group_var
or host_var
file, on a case by case basis.
tasks\main.yml
is then the entry point to our role and performs two tasks. This first is responsible for ensuring firewalld
is running (or not if firewall_enabled
is set false
). Then, in the case that the firewall is enabled, we first loop over the firewalld_zones
list and pull in manage_firewalld_zone.yml
on each pass, to take responsibility for ensuring each of our zones exist and are configured correctly.
roles/firewalld_common/tasks/manage_firewalld_zone.yml
is responsible for configuring each zone. Ansible does not have a module to deal with ensuring that a zone exists, therefore we have to take on this task manually. First up, we need to check if the zone has been previously configured which we do by running:
/bin/firewall-cmd --list-all-zones | grep -q "^{{ zone.name }}" && echo configured || echo not-configured
Here we call the firewall-cmd
command to output a list of configured zones, use grep
to check if the one we are interested in exists and then use &&
and ||
to either echo 'configured' if it does, or 'not-configured' if it doesn't. This is a trick I often use to clean up the logic within Ansible. I am registering the output of this command into an Ansible variable, which I am going to need to check in the next step. By using this technique, I can know that the stdout
key in the registered variable will hold only one of two strings. This means I can test for an exact match, rather than having to parse the output of the commands in Ansible.
In the next step we can now simply check if zone_status.stdout
is 'not-configured' and if it is, run the necessary firewall-cmd
command to create the zone. We also issue a --reload
to ensure that the new zone is available for the final step.
In the final step, we can now use the Ansible firewalld
module, to ensure that the zone has all the necessary source addresses associated with it. We have designed our rule dictionary to supply the source addresses as a list, and therefore we can loop over the supplied addresses, calling the firewalld
module to ensure they are present. The use of permanent: yes
and immediate: yes
, ensures that the zone is properly configured now, and also after a reboot of the host, or full restart of firewalld.
Throughout these tasks, where we are using loops you may notice that I specify the loop variable name using loop_var
. I do this as a matter of course for two reasons:
- It reads better than the default variable name of
item
. As things become more complicated, it can be easy to loose track of what this variable contains and so by picking a relevant name in each case, makes our task files far easier to follow - We often end up in a situation where we have one loop nested inside another. In this case, without setting the
loop_var
to something unique to the loop, we can get odd behaviour when both loops are attempting use the defaultitem
variable name.
Putting it to use
Note If you are following along, given the config above, following the steps below will enable the firewall. By default, ONLY inbound access to port TCP/22 (SSH) will be allowed. All other traffic will be blocked until you add the necessary rules to allow it, which we deal with later in this series.
Give the nature of this role, it should be an essential building block applied early to all your hosts. In my case, I tend to create a common
role that is comprised largely of dependencies to other building block roles. This common
role is the first thing I apply, and I apply it to all hosts in my estate. Through this, by applying one role I get all the basics of firewalling, logging, time management dealt with easily. Therefore in such a model, firewalld_common
should be added to this common role as a dependency:
common/meta/main.yml
---
dependencies:
- firewalld_common
Alternatively, it can just be called from a playbook. Perhaps you have an overarching playbook that defines how your whole estate should be built, in which case, this should be added early in that process. e.g.
site.yml
---
- hosts: all
become: yes
roles:
- firewalld_common
Whichever way you apply it, once done jump onto one of the systems that you have applied this role against and check that the zones have been created:
[root@mgmt-1 ~]# firewall-cmd --info-zone=testing
testing (active)
target: default
icmp-block-inversion: no
interfaces:
sources: 192.168.20.0/24 192.168.30.0/24
services:
ports:
protocols:
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
[root@mgmt-1 ~]# firewall-cmd --info-zone=development
development (active)
target: default
icmp-block-inversion: no
interfaces:
sources: 192.168.10.0/24
services:
ports:
protocols:
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
Conclusion
That's all for now. Join us in Part 2, where we will start to write the firewalld_rules
role and get the inbound firewall rules configured. In the final part, we will subsequently go on to add outbound rule handling to our role and complete the series.