/devops
Ansible

nftables
firewall
infra-as-code

Nftables handling in ansible

If you appreciate to manage your firewall rules at low level and are used to set your entire infra with ansible, you might have been faced with the following dilemma: how to accommodate the single responsibility you might and should expect from your roles with the chained nature of nftables rules? Here I present what I consider to be two pitfalls for code reusability and a solution generic enough for most cases where roles are responsible for their nftables rules.

Two pitfalls

Central administration

Most of the time, firewall rules are handled apart in playbooks in a specific role responsible for all machines, no matter of the roles they assume. In other word, one role (says firewall) has a direct impact on the behavior and successful deployment of applications and services from a different role. In pure terms of code smells, this leads to shotgun surgery: say you change the port listened by a service or the CIDRs of your k8s cluster, you will then need to change either:

i. the rules in your inventory,

ii. your firewall role and

most probably, both. Same goes when you have to change the services handled by a specific machine: changing its role won’t be enough, you will also need set new firewall rules and remove the previous ones.

Even in well crafted roles such as ipr-cnrs/nftables, the issue appears right away in the documentation exemplifying the structure expected in inventory for each class of machine

nft_global_default_rules:
  005 state management:
    - ct state established,related accept
    - ct state invalid drop
nft_global_rules: {}
nft_merged_groups: false
nft_merged_groups_dir: vars/
nft_global_group_rules: {}
nft_global_host_rules: {}

nft_input_default_rules:
  000 policy:
    - type filter hook input priority 0; policy drop;
  005 global:
    - jump global
  010 drop unwanted:
    - ip daddr @blackhole counter drop
  015 localhost:
    - iif lo accept
  210 input tcp accepted:
    - tcp dport @in_tcp_accept ct state new accept
nft_input_rules: {}
nft_input_group_rules: {}
nft_input_host_rules: {}

nft_output_default_rules:
  000 policy:
    - type filter hook output priority 0; policy drop;
  005 global:
    - jump global
  015 localhost:
    - oif lo accept
  050 icmp:
    - ip protocol icmp accept
    - ip6 nexthdr icmpv6 counter accept
  200 output udp accepted:
    - udp dport @out_udp_accept ct state new accept
  210 output tcp accepted:
    - tcp dport @out_tcp_accept ct state new accept
nft_output_rules: {}
nft_output_group_rules: {}
nft_output_host_rules: {}
....

Here, non only are rules centrally handled but we have to keep track of the positions where rules are inserted in the final chains (000, 005, 015…) and we are still faced with the complexity of low-level firewall management. Though this implementation is surely more complete than what I am about to present, its complexity is an overkill in most situations.

Un-coordinated parallel editions

Why not editing /etc/nftables.conf from each role with, say, with an ansible.builtin.lineinfile? After all, at the end of the day, rules are just lines in chains in this very configuration file. Apart from the inherent syntaxic difficulty of such an approach, there is one big semantic one: rules meaning depends on the chain they are inserted in and the position they occupy in their chain.

As a trivial instance of this fact, this allows clients to reach a k3s ingress listening on port 8443 and to receive the response

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        tcp dport 8443 accept comment "Accept HTTPS ingress"

        ct state established,related accept
        counter drop
    }
    chain output{
        type filter hook output priority 0; policy drop;

        ct state established,related accept
        counter drop
    }
}

while this does not

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        ct state established,related accept
        tcp dport 8443 accept comment "Accept HTTPS ingress"
        counter drop
    }
    chain output{
        type filter hook output priority 0; policy drop;

        ct state established,related accept
        counter drop
    }
}

but this does

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        ct state established,related accept
        tcp dport 8443 accept comment "Accept HTTPS ingress"
        counter drop
    }
    chain output{
        type filter hook output priority 0; policy drop;

        ct state established,related accept
        tcp sport 8443 accept comment "Accept HTTPS ingress"
        counter drop
    }
}

The ansible.builtin.lineinfile approach would require pretty complex REGEX based insertafter and insertbefore parameters

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        # INPUT BEGIN
        # INPUT END
        ct state established,related accept
        counter drop
    }
    chain output{
        type filter hook output priority 0; policy drop;
        # OUTPUT BEGIN
        # OUTPUT END
        ct state established,related accept
        counter drop
    }
}
- name: Allow IN 8443
  ansible.builtin.lineinfile:
    path: /etc/nftables.conf
    insertafter: '^\t\t# INPUT BEGIN'
    insertbefore: '^\t\t# INPUT END'
    line: tcp dport 8443 accept comment "Accept HTTPS ingress"

OK, this covers Addition but what about Edition and Suppression? To edit tcp dport 8443 accept comment "Accept HTTPS ingress" would require a new regex, hence Allow IN 8443 would not be idempotent anymore.

A modular approach

As a way out of these pitfalls, I propose to split the logic of packet filtering based on the fact that

  • communication protocols (tcp, udp) are application and service specific, thus should be handled by the individual roles and
  • control protocols (dhcp, icmp, arp), as general prerequisits of the formers, depending mainly on the surrounding network infrastructure, should be handled in the main nftables.conf.j2 template.

One template (not to rule them all)

At a service agnostic level, any machine – say from a LAN – would need some ways to:

  • describe other machines from the same LAN (e.g. though some lan_cidr4 defined by all.vars.lan_cidr4 or <group_name>.vars.lan_cidr4 in the inventory) together with the general access policy (allow out to LAN)
  • determine, based on the network topology, what to do with:
    • ICMP packets
    • DHCP packets
  • handle packets from/to lo from/to main NIC (and their IPv6 counter parts from/to fe80::/10,ff02::/16)
  • log dropped packets

nftables.conf.j2

#!/usr/sbin/nft -f

flush ruleset

define LAN_CIDR4 = {{ lan_cidr4 }}
define WAN_IF = "eth0"

table inet filter {
    set local4 {
        type ipv4_addr
        elements = { 127.0.0.1, {{ ansible_facts['default_ipv4']['address'] }} } 
    }

    chain input {
        type filter hook input priority 0; policy drop;
        # Allow loopback traffic.
        iifname lo accept
        icmp type echo-request limit rate 5/second accept  # Allow ping
        icmpv6 type {133,134,135,136,143} ip6 saddr {fe80::/10,ff02::/16} accept comment "ICMPv6 from local link"
        icmpv6 type {133,134,135,136,143} ip6 daddr {fe80::/10,ff02::/16} accept comment "ICMPv6 to local link"
        ip6 daddr fe80::/64 udp dport dhcpv6-client accept comment "DHCPv6 packets received at a link-local"

        include "/etc/nftables.d/input.*.rules"

        ct state established,related accept
        log prefix "NFTABLES-INPUT: " #trace dropped traffic (use journalctl -k --grep="INPUT")
        counter drop #count and drop
    }
    chain forward {
        type filter hook forward priority 0; policy drop;
        ip saddr $LAN_CIDR4 ip daddr $LAN_CIDR4 icmp type { echo-reply, echo-request} accept comment "allow icmp"

        include "/etc/nftables.d/forward.*.rules"

        ct state established,related accept
        log prefix "NFTABLES-FORWARD: "
        counter drop #count and drop
    }
    chain output {
        type filter hook output priority 0; policy drop;
        # Allow loopback traffic.
        oifname lo accept
        ip saddr @local4 ip daddr @local4 accept
        icmp type echo-request counter accept
        icmpv6 type echo-request counter accept
        icmpv6 type {133,134,135,136,143} ip6 saddr {fe80::/10,ff02::/16} accept comment "ICMPv6 from local link"
        icmpv6 type {133,134,135,136,143} ip6 daddr {fe80::/10,ff02::/16} accept comment "ICMPv6 to local link"
        ip daddr {{ lan_cidr4 }} accept comment "LAN OUT"

        include "/etc/nftables.d/output.*.rules"

        ct state established,related accept
        log prefix "NFTABLES-OUTPUT: " #trace dropped traffic (use journalctl -k --grep="OUTPUT")
        counter drop #count and drop
    }
}
table inet nat {
    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        include "/etc/nftables.d/postrouting.*.rules"
    }
}

Those are structural, LAN-wise rules that do not depend on the actual services hosted on the machine. Adapt it to your needs: you might not want to echo-request from any IP in a WAN context.

A custom ansible module

The attentive reader probably noticed the includes in the different chains. Roles only need to write their input.<role_name>.rules, forward.<role_name>.rules, output.<role_name>.rules in /etc/nftables.d for them to be imported after nftables.service restart. Ansible.builtin.copy could work but we would still have to handle rules suppression. As instead, the following ansible module could live in your repo’s local library (i.e. just a library folder next to your roles).

library/
  nftables_rules.py
roles/
  common/

nftables_rules.py

#!/usr/bin/python
from __future__ import absolute_import, division, print_function
__metaclass__ = type

from ansible.module_utils.basic import AnsibleModule
import os

def run_module():
    module_args = dict(
        name=dict(type="str", required=True),
        input=dict(type="list", elements="str", required=False, default=[]),
        forward=dict(type="list", elements="str", required=False, default=[]),
        output=dict(type="list", elements="str", required=False, default=[]),
        postrouting=dict(type="list", elements="str", required=False, default=[]),
    )
    module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
    result = dict(changed=False, message="")
    if module.check_mode:
        module.exit_json(**result)

    for chain in ["input", "forward", "output", "postrouting"]:
        path = f"/etc/nftables.d/{chain}.{module.params['name']}.rules"
        if module.params[chain] != []:
            edit = False
            if os.path.exists(path) == False: # creation
                edit = True
            else:
                with open(path, "r") as file: # update on change
                    lines = [line.strip() for line in file.readlines() if line.strip() and not line.startswith('#')]
                    if module.params[chain] != lines:
                        edit = True
            if edit:
                with open(f"/etc/nftables.d/{chain}.{module.params['name']}.rules", "w") as file:
                    file.write("\n".join(module.params[chain])+"\n")
                    result["message"] += f"{path} updated\n"
                    result["changed"] = True
        else:
            if os.path.exists(path): # suppression
                os.remove(path)
                result["message"] += f"{path} remove\n"
                result["changed"] = True

    module.exit_json(**result)

def main():
    run_module()

if __name__ == "__main__":
    main()

Here, the list provided with input, output, forward and postrouting are compared with the content of input.base.rules, output.base.rules, forward.base.rules and postrouting.base.rules. Change VS creation depends solely on name stability, which is easy to maintain by naming rules after the role they are produced from. If a change is detected, the entire file is edited and changed status is forwarded to Ansible. For instance, this is how I allow DNS, HTTP, HTTPS OUT and allow SSH IN.

- name: Allow DNS, SSH, HTTP, HTTPS
  nftables_rules:
    name: base
    input:
      - tcp dport 22 accept comment "Accept SSH IN"
    output:
      - udp dport 53 accept comment "Accept DNS OUT"
      - tcp dport 53 accept comment "Accept DNS OUT"
      - tcp dport 443 accept comment "Accept HTTPS OUT"
      - udp dport 67 accept comment "Accept DHCP OUT"
      - tcp dport 80 accept comment "Accept HTTP OUT"
      - udp dport 123 accept comment "Accept Network Time Protocol OUT"
      - udp dport 5353 accept comment "Accept Avahi OUT"
  notify:
    - Restart Nftables

In case of change, a notification is send to the Restart Nftables handler (we’ll cover this later). I just installed k3s on a machine using:

- name: Installation
  ansible.builtin.shell:
    executable: /bin/bash
    cmd: >
         INSTALL_K3S_EXEC='server
         --disable=traefik
         --disable servicelb
         --tls-san {{ inventory_hostname }}
         --node-name {{ inventory_hostname }}
         --cluster-cidr=10.42.0.0/16
         --service-cidr=10.43.0.0/16'
         /usr/local/bin/get-k3s         
  changed_when: false

All I need to delegate fine grained aspects of k3s internal communication to k3s own rules without renouncing to any policy drop is to:

- name: K3s firewall tuning
  nftables_rules:
    name: k3s
    input:
      - tcp dport 6443 accept comment "Accept Kubernetes API"
      - tcp dport 8443 accept comment "Accept HTTPS ingress"
      - tcp dport 9100 accept comment "Accept Traefik metrics"
      - iifname "cni0" ip daddr {{ ansible_facts['default_ipv4']['address'] }} accept comment "K3S to host communication"
    forward:
      - iifname "cni0" accept comment "K3s forward from cni0"
      - oifname "cni0" accept comment "K3s forward to cni0"
    output:
      - ip saddr 10.42.0.0/15 ip daddr 10.42.0.0/15 accept comment "K3S internal communication"
  notify:
    - Restart Nftables

Say I want for an input rule to live if and only if a certain boolean variable is provided, I could:

- name: Inbound web traffic
  nftables_rules:
    name: web
    input:
      - {% if is_reverse_proxy %} tcp dport 443 accept comment "Accept Inbound HTTPS"{% endif %}
  notify:
    - Restart Nftables

input.web.rules would be removed from the machine /etc/nftables.d if is_reverse_proxy becomes false in the future and the role remains the controller of the firewalling logic of its perimeter. To sum up, we cover rule creation, edition and suppression though a unique tasks per role.

Handler

You probably noticed

  notify:
    - Restart Nftables

in the previous examples. Since we changed /etc/nftables.conf or one of its includes, nftables.service should be restarted for the changes to apply. An elegant way to do so is through an ansible handler that will be triggered if and only if an associated task emits a changed status. Somewhere in my common live

roles/
  common/
    handlers/
      main.yml
      restart_nftables.yml
      ...

main.yml simply needs to include

- name: Restart Nftables
  ansible.builtin.include_tasks: restart_nftables.yml

and restart_nftables.yml needs to restart other services generating their own tables – in my case, docker on some of my machines and k3s, on others: adapt to your needs – after nftables.service restarts to have all tables.

- name: Restart Nftables
  ansible.builtin.systemd_service:
    state: restarted
    daemon_reload: true
    enabled: true
    name: nftables
- name: List services
  ansible.builtin.service_facts:
- name: Restart docker.service
  ansible.builtin.systemd_service:
    state: restarted
    daemon_reload: true
    enabled: true
    name: docker.service
  when: ansible_facts['services']['docker.service'] is defined
- name: Restart k3s.service
  ansible.builtin.systemd_service:
    state: restarted
    daemon_reload: true
    enabled: true
    name: k3s.service
  when: ansible_facts['services']['k3s.service'] is defined

An alternative to this would be not to flush ruleset in /etc/nftable.conf but only the ones we are managing with our playbook and leave k3s’ docker’s or any others’ alone. I preferred not to for ideological reasons but this is enterely up to you.

zar3bski

DataOps


A modular approach to nftables editing in ansible, allowing code reusability and easy role attribution changes

By David Zarebski , 2026-01-23


On this page: