Managing Firewalld with Ansible - Part 2



In the first part of our series, we got the basics out of the way. Now we will look at handling inbound firewalld rules with Ansible. Read on below, for more ...

If you have not already read Part 1, I would recommend going back and checking through that post to get up to speed with the preparatory work we have put in place.

For this post, we are going to look at building out support for configuring inbound rules controlling traffic passing into the system.

firewalld_rules

To handle configuring both our inbound and outbound rules, we are going to move responsibility for that out to a role separate from our firewalld_common role. This new firewalld_rules role will, potentially, be called multiple times, to configure each set of rules that are required by a given application or workload on a host. It is intended that that each role will include a dependency on this role, passing a dictionary of rules that should be configured. Like before, create a directory within your roles directory called firewalld_rules, and then within there, create defaults, meta, tasks & templates directories. Here is the code:

firewalld_rules/defaults/main.yml

---

firewalld_service_path: /etc/firewalld/services
firewalld_default_zone: local

firewalld_rules/meta/main.yml

---

dependencies:
  - { role: firewalld_common }

firewalld_rules/tasks/main.yml

---

- block:

    - name: Manage inbound rules
      include: manage_inbound_rule.yml
      loop: "{{ firewalld_rules.inbound | default([]) }}"
      loop_control:
        loop_var: rule

 when: firewall_enabled

firewalld_rules/tasks/manage_inbound_rule.yml

---

- name: Manage {{ rule.name }} firewalld service definition
  template:
    src: service.xml.j2
    dest: "{{ firewalld_service_path }}/{{ rule.name }}.xml"
    owner: root
    group: root
    mode: "0644"
  notify: reload_firewalld

- meta: flush_handlers

- name: Manage {{ rule.name }} firewalld service
  firewalld:
    service: "{{ rule.name }}"
    permanent: yes
    immediate: yes
    zone: "{{ rule.zone | default( firewalld_default_zone ) }}"
    state: enabled

Again, as with Part 1, hopefully this is all pretty straightforward stuff. We once again in the defaults/main.yml set a couple of convenience variables. The first determines where we are going to store our service files, more of which in a moment. We then defined firewalld_default_zone to contain the name of the zone that we will add rules to, in the cases where an explicit zone has not be defined in the config. For simpler setups, this removes the need to be constantly referring to the same zone in a collection of rules.

In meta/main.yml we configure a dependency on firewalld_common. This serves two purposes:

  • It means that we can be sure that firewalld_common has been called before we attempt to configure any rules
  • It gives us access to the variables and handlers that are defined within firewalld_common

This approach can be a nice simplification, by reducing the need to duplicate handlers, but it is not without its problems. Every task run in Ansible takes time, even if does not trigger a change. Within a play, a dependency will only be called once if it is called in the same way by all its dependents. However, if a role in a different play is dependent on the same role, it will be called again. Now given it has already been called once, it should make no changes, but it will consume some time to run. Therefore if you have a lot of plays all with dependencies on this role, a lot of needless time can be taken just running it over and over. In these cases, it is likely better to look at breaking the dependency between firewalld_rules and firewalld_common and taking the hit on the duplication. You will also need to be certain that firewalld_common has been included before the first call to firewalld_rules.

Next up we have tasks/main.yml as the main entry point to the role. The whole contents of this task file are contained within a block with a conditional attached to it. This just ensures that we only run this code if firewall_enabled is true. The task contained in the block loops over an inbound key in a dictionary called firewalld_rules and if the key does not exist, defaults to an empty list, thus meaning this step will be skipped. For each item in this list, it then calls manage_inbound_rule.yml. But where does this firewalld_rules dictionary come from? We didn't specify it in our defaults file or anywhere else within our firewalld_common and firewalld_rules roles. The answer is that this variable will be passed to this role, via a dependency from another role that requires firewall rules configuring. We will have a look at an example towards the end of this post, but for now, just trust me when I say this variable contains a specification of the rules we want adding to the firewall. The inbound key contains a list, with each item ultimately representing rules that need to be added.

In tasks/manage_inbound_rule.yml we perform the tasks to configure the necessary rules. As mentioned in Part 1, Firewalld has a concept of a 'Service', which represents a collection of ports and protocols, that a given service uses. Take for example a plain web server. It may well support HTTP traffic on port 80 and HTTPS traffic on port 443. In both cases the protocol is TCP. These two sockets could be grouped within a Firewalld service, giving us a single object to manage in the firewall, whilst any number of port/protocol combinations could be managed by it. We take advantage of this by creating a service for each item passed in the firewalld_rules.inbound list. Each service can then be published to one or more Zones, and given that each of those zones has a series of source addresses associated with them, we can then choose which services are accessible to which clients.

To create a service, our first task is to create an XML file containing the necessary configuration. We use a template for this purpose:

firewalld_rules/templates/service.xml.j2

<?xml version="1.0" encoding="utf-8"?>
<service>
  <short>{{ rule.name }}</short>
  <description>{{ rule.description | default( rule.name + ' firewall service' ) }}</description>
{% if rule.ports is defined %}{% for port in rule.ports %}
  <port port="{{ port.port }}" protocol="{{ port.protocol }}"/>
{% endfor %}{% endif %}
{% if rule.source_ports is defined %}{% for port in rule.source_ports %}
  <source-port port="{{ port.port }}" protocol="{{ port.protocol }}"/>
{% endfor %}{% endif %}
{% if rule.protocols is defined %}{% for protocol in rule.protocols %}
  <protocol value="{{ protocol }}"/>
{% endfor %}{% endif %}
{% if rule.modules is defined %}{% for module in rule.modules %}
  <module name="{{ module }}"/>
{% endfor %}{% endif %}
{% if rule.destination is defined %}
  <destination {% if rule.destination.ipv4 is defined %}ipv4="{{ rule.destination.ipv4 }}"{% endif %} {% if rule.destination.ipv6 is defined %}ipv6="{{ rule.destination.ipv6 }}"{% endif %}/>
{% endif %}
</service>

At first glance it may look complicated, but in reality it is not so bad. The specification for a service can allow a lot of optional items, and we cater for this, by wrapping most of them in conditional blocks, so the final generated file only contains what is necessary for the given service. Often this just comprises a 'short' name, 'description' and a few 'port' elements. If you are not familiar with the available options that can go in this file, check out the Firewalld Service documentation

The template is written out to the location that we specified in firewalld_service_path and then we add a notify directive to reload the firewall config, once the new service is added. Firewalld actually automatically polls for new service files, but it can be some time before it spots them. Given we need this service to be available in the next step, we need to force the issue, hence the notify to trigger a reload. This alone is not enough, because by default, Ansible only runs handlers after it has completed all other tasks. Given we are reloading the firewall to ensure that the service is available for the next task, this is not sufficient. For these cases, Ansible gives us the special task meta: flush_handlers. This causes Ansible to flush all queued handlers immediately, rather than waiting till the end of the play.

Finally, we then use the Ansible firewalld module, to ensure that the service is then enabled in the relevant zone. Once more we use permanent: yes and immediate: yes as we did when creating the zones in Part 1, to ensure that the service is enabled both immediately and after a subsequent restart of the firewall or host.

Putting it to use

So we are now at the stage where we can use this role to configure some rules for us. As previously explained, it is not intended that you will apply this role directly, but instead will call it from other roles, via a dependency. So as an example, create a testrole directory in your roles directory, and then within that directory create two directories, meta and defaults. Now, create the following files:

testrole/defaults/main.yml

---

testrole_firewalld_rules:
  inbound:
    - name: test_service
      zone:
        - testing
      ports:
        - port: 8080
          protocol: tcp
    - name: another_test_service
      zone:
        - development
        - testing
      ports:
        - port: 8090
          protocol: tcp

testrole/meta/main.yml

---

dependencies:
  - { role: firewalld_rules, firewalld_rules: "{{ testrole_firewalld_rules }}" }

Finally, create a play to apply our new test role:

test.yml

---

- hosts: all
  become: yes
  roles:
    - firewalld_common
    - testrole

Here we are combining the step from Part 1 to apply the common role and initialise the firewall on the host, followed by applying testrole, which will add the rules we specified in the defaults section of testrole. Use ansible-playbook to apply test.yml. With a bit of luck that will run without error. If so, jump on a host that you have applied this playbook against, become root and then run:

[root@mgmt-1 ~]# firewall-cmd --info-zone=development
development (active)
  target: default
  icmp-block-inversion: no
  interfaces: 
  sources: 192.168.10.0/24
  services: another_test_service
  ports: 
  protocols: 
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

[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: another_test_service test_service
  ports: 
  protocols: 
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules:

Here we can see that the services: field contains the names of our services, showing they have been enabled in the relevant zones.

Conclusion

You are now able to use the techniques described to allow your workload roles to take responsibility for requesting the appropriate firewall rules to be configured for their operation. At this point, all outbound traffic is allowed, and in a lot of cases this is sufficient to meet the firewalling requirements for each host. If however, full control of outbound rules is also required in your configurations, read Part 3, the final part in this short series, which will cover configuration of outbound rules.