Managing Firewalld with Ansible - Part 3



In the final part of our short series, we will cover handling outbound rules. Read on below, for more ...

In Part 1 we covered initialising our firewall, and in Part 2 we got to grips with adding rules to handle inbound traffic to our hosts. In this final part, we will address adding rules to control outbound traffic.

Before we get stuck in, I would caution that this part needs to be approached with most care. Firewalld does not really provide a strongly considered option for adding rules controlling outbound access. The only option is via the so called 'Direct' system. This largely boils down to a single XML file into which you add raw iptables rules, and is the mechanism through which anything that is not directly supported by Firewalld, is indirectly supported. Our method of adding support for controlling outbound access, will therefore involve writing functionality in Ansible, to maintain this file. To make this manageable we are only going to allow a limited amount of options for outbound rules. If we don't include the options you require, you will need to modify this approach to match your needs, but hopefully we will have got you started with a direction of travel.

Implementing outbound rules

Lets have a look at some code:

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

    - name: Manage outbound rules
      template:
        src: direct.xml.j2
        dest: /etc/firewalld/direct.xml
      notify: reload_firewalld
      when: firewalld_rules.outbound is defined

  when: firewall_enabled

firewalld_rules/templates/direct.xml.j2

<?xml version="1.0" encoding="utf-8"?>
<direct>
  <rule table="filter" chain="OUTPUT" ipv="ipv4" priority="0">-m state --state ESTABLISHED,RELATED -j ACCEPT</rule>
{# Loop through each element in firewalld_rules.outbound #}
{% for rule in firewalld_rules.outbound %}
{# Loop through each element in rule.hosts. If the key does not exist, set a list with a single '0.0.0.0' element #}
{% for host in ( rule.hosts | default(["0.0.0.0"]) ) %}
{# If rule.ports key exists, handle targeting specific ports on the host #}
{% if rule.ports is defined %}
{# Select how to handle the port[s], based on whether there is 1 or multiple #}
{% if ( rule.ports | length ) == 1 %}
{# If there is only one port, we do not need to use 'multiport' #}
  <rule table="filter" chain="OUTPUT" ipv="ipv4" priority="{{ rule.priority | default('1') }}">-p {{ rule.protocol }} -d {{ host }} -m {{ rule.protocol }} --dport {{ rule.ports | join(',') }} -j {{ (rule.target | default('accept')) | upper }}</rule>
{# If there are multiple ports, we add the 'multiport' option and join the port list into a comma separated string #}
{% else %}
  <rule table="filter" chain="OUTPUT" ipv="ipv4" priority="{{ rule.priority | default('1') }}">-p {{ rule.protocol }} -d {{ host }} -m multiport --dports {{ rule.ports | join(',') }} -j {{ (rule.target | default('accept')) | upper }}</rule>
{% endif %}
{# No ports have been passed, in which case we simply want to allow all traffic to the specified host #}
{% else %}
  <rule table="filter" chain="OUTPUT" ipv="ipv4" priority="{{ rule.priority | default('1') }}">-p {{ rule.protocol }} -d {{ host }} -j {{ (rule.target | default('accept')) | upper }}</rule>
{% endif %}
{# End of the host loop #}
{% endfor %}
{# End of the rule loop #}
{% endfor %}
{# Default reject all rule. With 999 priority it will be the last rule matched if none of the previous ones have #}
  <rule table="filter" chain="OUTPUT" ipv="ipv4" priority="999">-j REJECT</rule>
</direct>

As you can see, the code is pretty straight forward, comprising of the addition of a single template task to generate direct.xml. The bulk of the work is carried out inside the template. It may look a little complex on first glance, but we will break it down into its constituent parts.

There is quite a lot going on in the template, and so I have added some Jinja comments inline by way of explanation (Jinja comments will not appear in the final, generated template). There are a couple of points that maybe warrant a little more detail:

{% for host in ( rule.hosts | default(["0.0.0.0"]) ) %}

Early on in the template we open a loop, iterating over the contents of rule.hosts. This is actually an optional key, because we might want an outbound rule that allows traffic to a given port, without limiting which hosts we can connect to on that port. To handle this, we specify a default value to be used when rule.hosts is not set: ( rule.hosts | default(["0.0.0.0"]). In cases where the hosts key is missing, this will instead pass a list to the loop containing a single element: '0.0.0.0' (i.e. any host).

In the case of each rule we add dynamically, we include:

priority="{{ rule.priority | default('1') }}"

The direct.xml does not need to be populated with rules in the order that they should be processed. Firewalld instead uses the priority parameter to determine the order that the contained rules should be evaluated. This small section of the template allows you to pass a priority key in your rules for cases where you want to explicitly control the order, but will default to '1' in the case that it is not specified. If you want to use this feature, pass priority values between 1 & 998. The final static rule in the file will drop all traffic and has a priority of 999. This means it will always be the last rule to be matched, in the event that none of the others have, meaning the firewall will default to blocking packets that do not have explicit rules targeting them.

Finally, in each dynamically added rule:

-j {{ (rule.target | default('accept')) | upper }}

Given that the default policy is to REJECT outbound traffic, it is likely that most, if not all of the rules added, will allow outbound traffic. To cut down on repetition in the rules config, we default to setting the rule target to ACCEPT but also allow the target to be explicitly set in the configuration using the target key. As it may be ambiguous to the end user whether they need to use uppercase, we use the upper filter to ensure that the iptables convention is maintained.

Putting it to use

This was already covered in the previous article where we addressed adding rules to allow inbound traffic. At this stage, all you should need to do is rerun Ansible to apply the differences.

Conclusion

This wraps up our short series and hopefully provides you with a solid enough starting point to implement your own system for managing Firewalld. If you feel we have missed any major use cases, please feel free to comment below and we may look at updating or extending the series.

Alternatively, if you would like Clockworknet to supply you with a fully developed solution, whether for firewalld or something more general, open a conversation at info@clockworknet.com and we can discuss your requirements.

Thanks for reading!