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 rules
  • firewalld_rules: this role will build on the base that we have applied in firewalld_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 default item 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.