Ansible: managing an nftables firewall with the firewall role
Greetings!

I’ll show how I use my Ansible firewall role to manage nftables: the common policy lives in group_vars, and the differences of individual servers live in host_vars. Along the way we’ll look at what the role does so that applying rules doesn’t cut off your SSH 🔥.

Introduction

While you have one or two servers, the firewall is configured by hand or with homegrown scripts: a few rules in /etc/nftables.conf, systemctl enable nftables, and you’re done. But when there are dozens or even hundreds of hosts, the routine has to be automated. Otherwise you can’t avoid the following problems:

The solution is to describe the firewall declaratively and roll it out with Ansible. That’s exactly why I prepared the firewall role.

From nftables commands to role variables

First, let’s compare the same task in two formats: allow HTTP and HTTPS over TCP, plus QUIC over UDP/443, from the external network.

By hand, you can do it with nft commands:

BASH
sudo nft 'add rule inet filter input_wan tcp dport { 80, 443 } accept comment "Allow TCP ports from WAN"'
sudo nft 'add rule inet filter input_wan udp dport 443 accept comment "Allow UDP ports from WAN"'
Click to expand and view more

And in a configuration file the same rules look like a block:

NFT
table inet filter {
    chain input_wan {
        tcp dport { 80, 443 } accept comment "Allow TCP ports from WAN"
        udp dport 443 accept comment "Allow UDP ports from WAN"
    }
}
Click to expand and view more

These are fragments, not a ready-made firewall configuration: for the commands, the inet filter table and the input_wan chain must already exist, and incoming WAN traffic must reach this chain from the base input chain. Allow rules must come before the final deny - add rule appends them to the end of the chain. Also, the commands only change the rules currently in the kernel: to keep them after a reboot you need a configuration loaded by the nftables service.

With the role, for the same task it’s enough to add the following to inventory/host_vars/crow.r4ven.me.yml:

YAML
firewall_enabled: true
nft_allowed_ports:
  - "80/tcp"
  - "443/tcp"
  - "443/udp"
Click to expand and view more

Given the base policy, which we’ll go through below, the role will generate the rules in input_wan itself, put them before the deny, and save the result to /etc/nftables.conf.

If you need an explicit description of rules rather than a short list of ports, you can use the role’s YAML format instead of nft_allowed_ports:

YAML
firewall_enabled: true
nft_rules_extra:
  allow_web_tcp:
    order: 111
    family: inet
    table: filter
    chain: input_wan
    match:
      tcp:
        dport:
          - 80
          - 443
    action: accept
    comment: "Allow TCP ports from WAN"

  allow_quic_udp:
    order: 112
    family: inet
    table: filter
    chain: input_wan
    match:
      udp:
        dport: 443
    action: accept
    comment: "Allow UDP ports from WAN"
Click to expand and view more

Here family, table, and chain define where the rule goes, match is the condition, action is the action, and order is the position relative to the other rules. It’s the same nftables syntax, but the source settings are stored in the inventory and applied identically on all the hosts that need them.

Software used in this article:

SoftwareVersion
Debian (control host)13
Debian / Ubuntu (managed hosts)13 / 26
nftables1.1.x
AnsibleCore 2.17+

Preparation

Ansible environment (optional)

If you don’t have Ansible set up yet, the easiest option is to take a ready-made working directory - r4ven-me/ansible-workspace. It already contains ansible.cfg, a sample inventory, a playbook, and a requirements.yml for installing the role.

BASH
git clone --branch raven https://github.com/r4ven-me/ansible-workspace.git

cd ./ansible-workspace/
Click to expand and view more

Directory structure:

Output
ansible-workspace/
├── ansible.cfg                          # settings (incl. hash_behaviour and SSH keepalive)
├── requirements.txt                     # ansible-core
├── requirements.yml                     # roles for ansible-galaxy
├── inventory/
│   ├── hosts.yml                        # hosts
│   ├── group_vars/all/firewall.yml      # base policy
│   └── host_vars/crow.r4ven.me.yml      # specifics of a particular host
├── playbooks/firewall.yml
└── roles/                               # roles are installed here
Click to expand and view more

If Ansible isn’t installed on your control machine yet, let’s install it with pipx:

BASH
sudo apt update && sudo apt install pipx ssh-askpass

pipx install ansible-core

pipx ensurepath
Click to expand and view more

Before running the role, replace the address 10.10.10.10 and the user in inventory/hosts.yml with your own, for example using vim or neovim.

Check availability:

BASH
ansible --become --module-name ping crow.r4ven.me

ansible --become --module-name ping birds
Click to expand and view more

On the managed server you must install the python3-apt package:

BASH
ansible --become --module-name shell --args 'apt install --yes python3-apt' crow.r4ven.me
Click to expand and view more

Installing the role

The role is installed from a Git repository via ansible-galaxy. The most illustrative way is to specify the repository link and tag right in the command:

BASH
ansible-galaxy role install git+https://github.com/r4ven-ansible/firewall.git,v1.0.0
Click to expand and view more

Format: git+<url>,<tag or branch>. It’s better to pin the version with a tag so that the role doesn’t update unexpectedly.

For reproducibility, role references are pinned in requirements.yml (the workspace already has one):

YAML
roles:
  - name: firewall
    src: https://github.com/r4ven-ansible/firewall.git
    scm: git
    version: v1.0.0
collections: []
Click to expand and view more

and installed with the command:

BASH
ansible-galaxy role install -r ./requirements.yml
Click to expand and view more

Let’s check that the role is in place:

BASH
ansible-galaxy role list
Click to expand and view more

To move to another version, change version and add --force to the install command.

How the firewall role is structured

In this role, tables, sets, chains, and rules are described by Ansible variables. The role collects them, renders a single /etc/nftables.conf via Jinja2, validates the config, and applies it.

Output
roles/firewall/
├── defaults/main.yml          # default values
├── tasks/
│   ├── main.yml               # supported OS check
│   ├── firewall.yml           # packages, rule assembly, includes apply.yml
│   ├── preflight.yml          # management access protection and pre-apply checks
│   ├── reachability.yml       # connectivity check via Ansible's own path
│   └── apply.yml              # staging, snapshot, transaction, and rollback
├── templates/
│   ├── nftables.conf.j2       # template turning the YAML DSL into nft syntax
│   └── fingerprint.py.j2      # fingerprint of managed tables for drift detection
├── meta/main.yml
├── examples/                  # sample ansible.cfg, inventory, and playbook
├── tests/                     # VM tests, results, and limitations (README.md)
└── README.md
Click to expand and view more

Variables are split into two levels:

WhereWhat it describes
./group_vars/all/firewall.ymlBase policy for all hosts: tables, chains, sets, common rules
./host_vars/<host>.ymlEnabling the firewall and the differences of a particular server

The role is disabled by default (firewall_enabled: false). Until you explicitly enable it for a host, it touches nothing there. This is convenient for gradual rollout: you onboard hosts one at a time.

Full example of a base nftables configuration

If you work in the ansible-workspace repository environment, the rule set is already there: ./inventory/group_vars/all/firewall.yml.

Otherwise, just create/update the file (./inventory/group_vars/all/firewall.yml) with variables for all hosts, adding the contents of the yaml block below.

The file is large, and you don’t need to read it closely right now: below we’ll first apply it as is, and then go through it piece by piece. To make clear what you’re copying, here is its map:

BlockWhat it definesWhere we cover it
firewall_enabled, nft_* switchesglobal flags: enabling the role, removing other firewalls, full flush, restarting DockerApplying
nft_tables, nft_sets + # SET CONTENTtables and named address lists: LAN networks, trusted IPs, spoofing-protection exceptions, dynamic sets for the SSH limitTables and sets
nft_chainschains with hooks and policies plus custom input_wan, input_lan, log_dropChains
nft_rulesthe rules themselves, sorted by order: mandatory passes (1-7), basic checks (10-21), LAN/WAN split (80-90), LAN allow and WAN permissions (100-198), forward (200-206), logging (300+)Rules
commented-out nat_* at the end (removed since the file is big enough)NAT and redirect templatesFlexibility

The order numbering consists of “slots” with headroom: there’s plenty of room between SSH (110) and the default deny (198) for your own rules, so you can add them without rewriting existing ones.

YAML
---

firewall_enabled: false
nft_remove_other_firewall: false
nft_flush_ruleset: false
nft_restart_docker: false

nft_bin: "/usr/sbin/nft"
nft_config_path: "/etc/nftables.conf"
nft_backup_path: "/root/nft_backup"
nft_validate_cmd: "{{ nft_bin }} -c -f %s"
nft_wan_interface: "{{ ansible_facts.get('default_ipv4', {}).get('interface') | default('unknown0', true) }}"

# TABLES
nft_tables:
  - family: inet
    name: filter
  - family: inet
    name: nat

# SETS
nft_sets:
  - family: inet
    table: filter
    name: lan4
    type: ipv4_addr
    flags: interval
    elements: "{{ nft_lan4_networks }}"

  - family: inet
    table: filter
    name: lan6
    type: ipv6_addr
    flags: interval
    elements: "{{ nft_lan6_networks }}"

  - family: inet
    table: filter
    name: trusted
    type: ipv4_addr
    flags: interval
    elements: "{{ nft_trusted_ips }}"

  - family: inet
    table: filter
    name: trusted6
    type: ipv6_addr
    flags: interval
    elements: "{{ nft_trusted_ips6 }}"

  # исключения из spoof guard, по умолчанию пустые
  - family: inet
    table: filter
    name: wan_local4
    type: ipv4_addr
    flags: interval
    auto_merge: true
    elements: "{{ nft_wan_local4_networks }}"

  - family: inet
    table: filter
    name: wan_local6
    type: ipv6_addr
    flags: interval
    auto_merge: true
    elements: "{{ nft_wan_local6_networks }}"

  # per-source SSH rate limiting
  - family: inet
    table: filter
    name: ssh_flood4
    type: ipv4_addr
    flags: dynamic
    size: 65536
    timeout: 10m

  - family: inet
    table: filter
    name: ssh_flood6
    type: ipv6_addr
    flags: dynamic
    size: 65536
    timeout: 10m

# SET CONTENT
nft_lan4_networks:
  - 10.0.0.0/8
  - 172.16.0.0/12
  - 192.168.0.0/16
  - 169.254.0.0/16

nft_lan6_networks:
  - fd00::/8
  - fe80::/10

nft_trusted_ips:
  - 203.0.113.10

# No IPv6 source is trusted until explicitly added.
nft_trusted_ips6: []

# Opt-in: заполняйте только для сегмента, которому доверяете (см. раздел про
# спуфинг). Готовые значения роль выводит из фактов в nft_wan_local4/6.
nft_wan_local4_networks: []
nft_wan_local6_networks: []

# CHAINS
# https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks
nft_chains:
  - family: inet
    table: filter
    name: input
    type: filter
    hook: input
    priority: -1
    policy: drop

  # Intentionally permissive: new routed traffic is accepted unless explicitly
  # dropped here. Input rules do not protect forwarded/container traffic.
  - family: inet
    table: filter
    name: forward
    type: filter
    hook: forward
    priority: -51
    policy: accept

  # priority must stay above -200 (conntrack), otherwise ct matches
  # in this chain will never work
  - family: inet
    table: filter
    name: output
    type: filter
    hook: output
    priority: -1
    policy: accept

  - family: inet
    table: nat
    name: prerouting
    type: nat
    hook: prerouting
    priority: -101
    policy: accept

  - family: inet
    table: nat
    name: output
    type: nat
    hook: output
    priority: -101
    policy: accept

  - family: inet
    table: nat
    name: postrouting
    type: nat
    hook: postrouting
    priority: 99
    policy: accept

  # custom chains
  - family: inet
    table: filter
    name: input_wan

  - family: inet
    table: filter
    name: input_lan

  - family: inet
    table: filter
    name: log_drop

# RULES
nft_rules:
  # ---- BASE ----
  allow_dhcp_client6:
    order: 7
    family: inet
    table: filter
    chain: input
    match:
      iifname: "{{ nft_wan_interface }}"
      meta:
        l4proto: udp
      ip6:
        saddr: "fe80::/10"
      udp:
        sport: 547
        dport: 546
    action: accept
    comment: "Allow DHCPv6 client replies"

  # The guards run after allow_established, so a forged ICMP error carrying a
  # live flow's tuple would be accepted as 'related'. Redirects are the one such
  # message with real consequences (route injection, MITM), and no server needs
  # to honour them; pair this with accept_redirects=0 in sysctl.
  allow_dhcp_client4:
    order: 6
    family: inet
    table: filter
    chain: input
    match:
      iifname: "{{ nft_wan_interface }}"
      meta:
        l4proto: udp
      udp:
        sport: 67
        dport: 68
    action: accept
    comment: "Allow DHCPv4 client replies"

  allow_icmp6_nd:
    order: 5
    family: inet
    table: filter
    chain: input
    match:
      meta:
        l4proto: ipv6-icmp
      icmpv6:
        type:
          - nd-router-solicit
          - nd-router-advert
          - nd-neighbor-solicit
          - nd-neighbor-advert
    action: accept
    comment: "Allow IPv6 neighbour discovery"

  # A DHCP-addressed host loses its lease without these: a unicast renew reply
  # from a private server is dropped by the guard before allow_established, and
  # broadcast rebind replies match no conntrack entry at all. Bound to the WAN
  # interface; a host that leases on another interface needs its own rule.
  drop_invalid:
    order: 10
    family: inet
    table: filter
    chain: input
    match:
      ct:
        state: invalid
    action: drop
    comment: "Drop invalid connections"

  drop_icmp_redirect4:
    order: 11
    family: inet
    table: filter
    chain: input
    match:
      meta:
        l4proto: icmp
      icmp:
        type: redirect
    action: drop
    comment: "Drop ICMP redirects"

  drop_icmp_redirect6:
    order: 12
    family: inet
    table: filter
    chain: input
    match:
      meta:
        l4proto: ipv6-icmp
      icmpv6:
        type: nd-redirect
    action: drop
    comment: "Drop ICMPv6 redirects"

  allow_established:
    order: 15
    family: inet
    table: filter
    chain: input
    match:
      ct:
        state:
          - established
          - related
    action: accept
    comment: "Allow established connections"

  # ---- WAN SPOOFING GUARD ----
  # Run after established/related so replies from private uplink peers survive.
  # New/untracked private-source traffic on WAN still reaches these guards.
  drop_wan_spoofed_lan_ipv4:
    order: 20
    family: inet
    table: filter
    chain: input
    expr: 'iifname "{{ nft_wan_interface }}" ip saddr @lan4 ip saddr != @wan_local4'
    action:
      jump: log_drop
    comment: "Drop spoofed private IPv4 from WAN"

  drop_wan_spoofed_lan_ipv6:
    order: 21
    family: inet
    table: filter
    chain: input
    expr: 'iifname "{{ nft_wan_interface }}" ip6 saddr @lan6 ip6 saddr != @wan_local6'
    action:
      jump: log_drop
    comment: "Drop spoofed private IPv6 from WAN"

  allow_loopback:
    order: 30
    family: inet
    table: filter
    chain: input
    match:
      iif: lo
    action: accept
    comment: "Allow loopback"

  allow_trusted:
    order: 40
    family: inet
    table: filter
    chain: input
    match:
      ip:
        saddr:
          value: "@trusted"
    action: accept
    comment: "Allow trusted IPs"

  allow_trusted6:
    order: 41
    family: inet
    table: filter
    chain: input
    match:
      ip6:
        saddr:
          value: "@trusted6"
    action: accept
    comment: "Allow trusted IPv6 addresses"

  icmp4:
    order: 50
    family: inet
    table: filter
    chain: input
    match:
      meta:
        l4proto: icmp
      icmp:
        type:
          - echo-request
          - destination-unreachable
          - time-exceeded
      limit:
        rate: "10/second"
    action: accept
    comment: "ICMP rate limited"

  icmp6:
    order: 60
    family: inet
    table: filter
    chain: input
    match:
      meta:
        l4proto: ipv6-icmp
      icmpv6:
        type:
          - destination-unreachable
          - packet-too-big
          - time-exceeded
          - parameter-problem
          - nd-router-solicit
          - nd-router-advert
          - nd-neighbor-solicit
          - nd-neighbor-advert
    action: accept
    comment: "Necessary IPv6 ICMP"

  icmp6_ping:
    order: 61
    family: inet
    table: filter
    chain: input
    match:
      meta:
        l4proto: ipv6-icmp
      icmpv6:
        type: echo-request
      limit:
        rate: "10/second"
    action: accept
    comment: "ICMPv6 ping rate limited"

  # ---- LAN/WAN SPLIT ----
  jump_to_lan_ipv4:
    order: 80
    family: inet
    table: filter
    chain: input
    match:
      ip:
        saddr:
          value: "@lan4"
    action:
      jump: input_lan

  jump_to_lan_ipv6:
    order: 81
    family: inet
    table: filter
    chain: input
    match:
      ip6:
        saddr:
          value: "@lan6"
    action:
      jump: input_lan

  jump_to_wan_ipv4:
    order: 82
    family: inet
    table: filter
    chain: input
    match:
      ip:
        saddr:
          op: "!="
          value: "@lan4"
    action:
      jump: input_wan

  jump_to_wan_ipv6:
    order: 83
    family: inet
    table: filter
    chain: input
    match:
      ip6:
        saddr:
          op: "!="
          value: "@lan6"
    action:
      jump: input_wan

  default_drop:
    order: 90
    family: inet
    table: filter
    chain: input
    action:
      jump: log_drop

  # ---- LAN ----
  input_lan_allow_all:
    order: 100
    family: inet
    table: filter
    chain: input_lan
    action: accept
    comment: "Allow all from LAN"

  # ---- WAN ----
  # per-source limit: a single flooding source cannot exhaust
  # the SSH budget for everyone else
  wan_ssh_flood_ipv4:
    order: 108
    family: inet
    table: filter
    chain: input_wan
    match:
      meta:
        l4proto: tcp
      tcp:
        dport: "{{ ssh_port | default('22') }}"
      ct:
        state: new
      raw: "add @ssh_flood4 { ip saddr limit rate over 20/minute }"
    action:
      jump: log_drop
    comment: "Drop SSH flood per IPv4 source"

  wan_ssh_flood_ipv6:
    order: 109
    family: inet
    table: filter
    chain: input_wan
    match:
      meta:
        l4proto: tcp
      tcp:
        dport: "{{ ssh_port | default('22') }}"
      ct:
        state: new
      raw: "add @ssh_flood6 { ip6 saddr limit rate over 20/minute }"
    action:
      jump: log_drop
    comment: "Drop SSH flood per IPv6 source"

  wan_allow_ssh:
    order: 110
    family: inet
    table: filter
    chain: input_wan
    match:
      meta:
        l4proto: tcp
      tcp:
        dport: "{{ ssh_port | default('22') }}"
      ct:
        state: new
    action: accept
    comment: "SSH rate limited per source"

  wan_default_drop:
    order: 198
    family: inet
    table: filter
    chain: input_wan
    action:
      jump: log_drop
    comment: "Default WAN drop"

  # ---- FORWARD ----
  fwd_drop_invalid:
    order: 200
    family: inet
    table: filter
    chain: forward
    match:
      ct:
        state: invalid
    action: drop
    comment: "Drop invalid forwarded connections"

  fwd_allow_established:
    order: 203
    family: inet
    table: filter
    chain: forward
    match:
      ct:
        state:
          - established
          - related
    action: accept
    comment: "Allow established forwarded connections"

  # As in input, accept established/related before checking private WAN sources.
  fwd_drop_wan_spoofed_lan_ipv4:
    order: 205
    family: inet
    table: filter
    chain: forward
    expr: 'iifname "{{ nft_wan_interface }}" ip saddr @lan4 ip saddr != @wan_local4'
    action:
      jump: log_drop
    comment: "Drop spoofed private IPv4 forwarded from WAN"

  fwd_drop_wan_spoofed_lan_ipv6:
    order: 206
    family: inet
    table: filter
    chain: forward
    expr: 'iifname "{{ nft_wan_interface }}" ip6 saddr @lan6 ip6 saddr != @wan_local6'
    action:
      jump: log_drop
    comment: "Drop spoofed private IPv6 forwarded from WAN"

  # ---- LOG/DROP ----
  logdrop_log:
    order: 300
    family: inet
    table: filter
    chain: log_drop
    match:
      limit:
        rate: "5/second"
    action:
      log:
        prefix: "NFT-DROP: "
        flags: all

  logdrop_drop:
    order: 301
    family: inet
    table: filter
    chain: log_drop
    action: drop
Click to expand and view more

We’ll go through the rules in more detail a bit below, but for now…

Applying the base configuration

The playbook for running is minimal. Let’s create it using redirection and here doc:

BASH
cat > ./playbooks/firewall.yml <<EOF
---

- name: Setup firewall
  hosts: all
  gather_facts: true
  become: true
  roles:
    - role: firewall
EOF
Click to expand and view more

Suppose we have a web server crow.r4ven.me in the inventory. Let’s enable the firewall role for it with host variables:

BASH
cat > ./inventory/host_vars/crow.r4ven.me.yml <<EOF
---

nft_remove_other_firewall: true
firewall_enabled: true
EOF
Click to expand and view more

Here:

First, let’s see what would change on it without applying anything (diff + check mode):

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff --check
Click to expand and view more

The role shows the config difference (diff: true), so you see exactly the lines that will appear in /etc/nftables.conf. If nft isn’t installed yet, this is only a comparison of the rendered config: you can’t verify nft syntax, package installation, runtime rules, or service behavior. In check mode packages aren’t installed, and neither the file nor the runtime changes; the service is skipped if nft is absent. Even with nft installed, the diff and syntax check don’t prove the server will be reachable after applying.

If you’re still here and everything looks fine, run it without --check:

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff
Click to expand and view more

Or straight on a group of hosts:

BASH
ansible-playbook ./playbooks/firewall.yml --limit birds --diff
Click to expand and view more

When it finishes, check the host’s availability right away:

BASH
ansible --become --module-name ping crow.r4ven.me
Click to expand and view more

How to quickly open new ports

Now all you need is to add the special variable nft_allowed_ports to inventory/host_vars/crow.r4ven.me.yml:

BASH
cat >> ./inventory/host_vars/crow.r4ven.me.yml <<EOF
nft_allowed_ports:
  - "80/tcp"
  - "443/tcp"
  - "443/udp"
EOF
Click to expand and view more

Look at the changes:

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff --check
Click to expand and view more

If everything is fine, apply:

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff
Click to expand and view more

Breaking down the base policy in group_vars

As mentioned earlier, we define the common policy once for a group of hosts. Here it is, piece by piece.

Tables and sets

YAML
nft_tables:
  - family: inet
    name: filter
  - family: inet
    name: nat

nft_lan4_networks:
  - 10.0.0.0/8
  - 172.16.0.0/12
  - 192.168.0.0/16
  - 169.254.0.0/16

nft_trusted_ips:
  - 203.0.113.10

nft_sets:
  - family: inet
    table: filter
    name: lan4
    type: ipv4_addr
    flags: interval
    elements: "{{ nft_lan4_networks }}"

  - family: inet
    table: filter
    name: trusted
    type: ipv4_addr
    flags: interval
    elements: "{{ nft_trusted_ips }}"
Click to expand and view more

We use the inet family - the same rules work for both IPv4 and IPv6. Sets are named lists of addresses: add an IP to nft_trusted_ips and it gets access to all ports on all servers, unless an earlier rule drops the packet (for example, WAN address-spoofing protection). No rules need to be edited.

Chains

YAML
nft_chains:
  - family: inet
    table: filter
    name: input
    type: filter
    hook: input
    priority: -1
    policy: drop # deny everything by default

  - family: inet
    table: filter
    name: forward
    type: filter
    hook: forward
    priority: -51
    policy: accept # allow everything by default

  # custom chains (no hook)
  - family: inet
    table: filter
    name: input_wan
  - family: inet
    table: filter
    name: input_lan
  - family: inet
    table: filter
    name: log_drop
Click to expand and view more

The policy for incoming traffic to the host itself (input) is drop: whatever isn’t explicitly allowed is denied. Besides the base chains with hooks, custom input_lan, input_wan, and log_drop chains are created - through them we split traffic into “ours” and “foreign”.

Rules

Each rule is a key in the nft_rules dictionary with the following fields:

FieldPurpose
orderOrder of application (lower - earlier)
family, table, chainWhere to add the rule
matchConditions: what kind of packet
actionWhat to do with it: accept, drop, jump, dnat, etc.
commentComment (ends up in the config and in nft list ruleset)

A fragment of the base policy:

YAML
nft_rules:
  drop_invalid:
    order: 10
    family: inet
    table: filter
    chain: input
    match:
      ct:
        state: invalid
    action: drop
    comment: "Drop invalid connections"

  allow_established:
    order: 15
    family: inet
    table: filter
    chain: input
    match:
      ct:
        state:
          - established
          - related
    action: accept
    comment: "Allow established connections"

  allow_trusted:
    order: 40
    family: inet
    table: filter
    chain: input
    match:
      ip:
        saddr:
          value: "@trusted"
    action: accept
    comment: "Allow trusted IPs"

  jump_to_lan_ipv4:
    order: 80
    family: inet
    table: filter
    chain: input
    match:
      ip:
        saddr:
          value: "@lan4"
    action:
      jump: input_lan

  jump_to_wan_ipv4:
    order: 82
    family: inet
    table: filter
    chain: input
    match:
      ip:
        saddr:
          op: "!="
          value: "@lan4"
    action:
      jump: input_wan

  input_lan_allow_all:
    order: 100
    family: inet
    table: filter
    chain: input_lan
    action: accept
    comment: "Allow all from LAN"

  wan_default_drop:
    order: 198
    family: inet
    table: filter
    chain: input_wan
    action:
      jump: log_drop
    comment: "Default WAN drop"
Click to expand and view more

The logic works out like this:

  1. With the very first rule (order: 1) the role itself allows the current management connection - there’s a separate section about it below;
  2. We pass what must work outside conntrack: IPv6 neighbour discovery (order: 5) and DHCP client replies (6, 7);
  3. We drop “broken” packets (invalid, order: 10) and ICMP redirects (11, 12);
  4. We pass replies to already established connections (established, order: 15);
  5. We drop private sources on the WAN: address-spoofing protection rules with order: 20/21 (omitted from the short fragment above);
  6. We pass loopback and trusted addresses from the trusted/trusted6 sets;
  7. We split traffic by source: from private networks to input_lan, from everything else to input_wan;
  8. Everything is allowed from the LAN; from the WAN, only what is explicitly opened by rules with order from 100 to 197;
  9. Everything else at the end of input_wan goes to log_drop - it’s logged with rate limiting and dropped.

Note the ordering: the order numbers are “slots”. Between 110 (SSH) and 198 (default deny) there’s a lot of room where you can add your own rules without rewriting existing ones. That’s exactly why ports from nft_allowed_ports land in slots 111 (TCP) and 112 (UDP).

Spoofing and SSH brute-force protection

The policy already includes two useful things that people usually forget to do by hand.

Address spoofing protection. New packets from the external interface that supposedly came from a private address are dropped:

YAML
drop_wan_spoofed_lan_ipv4:
  order: 20
  family: inet
  table: filter
  chain: input
  expr: 'iifname "{{ nft_wan_interface }}" ip saddr @lan4 ip saddr != @wan_local4'
  action:
    jump: log_drop
  comment: "Drop spoofed private IPv4 from WAN"
Click to expand and view more

The external interface name is determined automatically from Ansible facts (ansible_facts.get('default_ipv4', {}).get('interface'), with an IPv6 fallback); if the specified interface isn’t in the facts, the role warns about it - a rule bound to a nonexistent interface matches nothing and only looks like protection.

If you’re now thinking “won’t this cut me off?” - that’s exactly what the Protecting management access section below is about: the role allows the current connection with the very first rule and refuses to apply the config if it can’t guarantee that.

The @wan_local4/@wan_local6 sets are exceptions to the protection mechanism, and by default they are empty. Fill them only for a segment you truly trust: a private source exempted from this protection ends up in input_lan, where input_lan_allow_all allows everything, meaning you trust the entire on-link subnet, including anyone able to spoof an address in it. The role derives ready-made values from facts - nft_wan_local4 (the interface’s networks) and nft_wan_local6 (its individual addresses, not subnets):

YAML
nft_wan_local4_networks: "{{ nft_wan_local4 }}"
nft_wan_local6_networks: []
Click to expand and view more

Per-IP rate limiting of SSH connections. This uses a dynamic set with a timeout:

YAML
nft_sets:
  - family: inet
    table: filter
    name: ssh_flood4
    type: ipv4_addr
    flags: dynamic
    size: 65536
    timeout: 10m

nft_rules:
  wan_ssh_flood_ipv4:
    order: 108
    family: inet
    table: filter
    chain: input_wan
    match:
      meta:
        l4proto: tcp
      tcp:
        dport: "{{ ssh_port | default('22') }}"
      ct:
        state: new
      raw: "add @ssh_flood4 { ip saddr limit rate over 20/minute }"
    action:
      jump: log_drop
Click to expand and view more

The dynamic set stores the limit state for each source: new SSH connections above 20/minute are dropped. This is not a fixed 10-minute ban - timeout applies to the set entry. A real IPv4 burst was tested on a test virtual machine, but the exact limit refill, fairness between sources, and IPv6 flood were not tested separately.

What comes out

The role turns all this YAML into a plain, readable nftables config. Here is a fragment of what ends up in /etc/nftables.conf on the server from the example above:

NFT
#!/usr/sbin/nft -f

add table inet filter
delete table inet filter
add table inet nat
delete table inet nat
add table inet filter
add table inet nat
add set inet filter lan4 {
  type ipv4_addr;    flags interval;    elements = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 };
}
add chain inet filter input {
  type filter hook input priority -1;
  policy drop;
}
add chain inet filter input_wan
add rule inet filter input iifname "eth0" ip saddr 203.0.113.77 tcp dport 22 ct state { new, established } accept comment "Keep the active management path reachable"
add rule inet filter input meta l4proto ipv6-icmp icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept comment "Allow IPv6 neighbour discovery"
add rule inet filter input meta l4proto udp udp sport 67 udp dport 68 iifname eth0 accept comment "Allow DHCPv4 client replies"
add rule inet filter input meta l4proto udp ip6 saddr fe80::/10 udp sport 547 udp dport 546 iifname eth0 accept comment "Allow DHCPv6 client replies"
add rule inet filter input ct state invalid drop comment "Drop invalid connections"
add rule inet filter input meta l4proto icmp icmp type redirect drop comment "Drop ICMP redirects"
add rule inet filter input meta l4proto ipv6-icmp icmpv6 type nd-redirect drop comment "Drop ICMPv6 redirects"
add rule inet filter input ct state { established, related } accept comment "Allow established connections"
add rule inet filter input iifname "eth0" ip saddr @lan4 ip saddr != @wan_local4 jump log_drop comment "Drop spoofed private IPv4 from WAN"
add rule inet filter input iif lo accept comment "Allow loopback"
add rule inet filter input ip saddr @trusted accept comment "Allow trusted IPs"
add rule inet filter input ip6 saddr @trusted6 accept comment "Allow trusted IPv6 addresses"
add rule inet filter input ip saddr @lan4 jump input_lan
add rule inet filter input ip saddr != @lan4 jump input_wan
add rule inet filter input_lan accept comment "Allow all from LAN"
add rule inet filter input_wan ct state new meta l4proto tcp tcp dport 22 add @ssh_flood4 { ip saddr limit rate over 20/minute } jump log_drop comment "Drop SSH flood per IPv4 source"
add rule inet filter input_wan ct state new meta l4proto tcp tcp dport 22 accept comment "SSH rate limited per source"
add rule inet filter input_wan tcp dport { 80, 443 } accept comment "Allow TCP ports from WAN"
add rule inet filter input_wan jump log_drop comment "Default WAN drop"
add rule inet filter log_drop limit rate 5/second log prefix "NFT-DROP: " flags all
add rule inet filter log_drop drop
Click to expand and view more

A few remarks:

Per-host settings

The base policy covers the typical case. For exceptions there’s the nft_rules_extra variable, which is layered on top of nft_rules with a recursive dictionary merge. And the nft_rules_host variable goes on top of nft_rules_extra. This lets you avoid copying the whole policy into host_vars and describe only the differences. Three main scenarios:

YAML
nft_rules_extra:
  # 1. Add a new rule
  allow_prometheus:
    order: 115
    family: inet
    table: filter
    chain: input_wan
    match:
      ip:
        saddr: 10.20.20.5
      tcp:
        dport: 9100
    action: accept
    comment: "Prometheus scrape"

  # 2. Remove a rule from the base policy
  wan_allow_ssh:

  # 3. Change a single field of an existing rule
  wan_ssh_flood_ipv4:
    match:
      raw: "add @ssh_flood4 { ip saddr limit rate over 5/minute }"
Click to expand and view more

The final merge order is:

Output
nft_rules  →  mgmt rule  →  nft_allowed_ports  →  nft_rules_extra  →  nft_rules_host
(base)        (role, order 1)   (quick ports)       (group/host)        (host level)
Click to expand and view more

The role mixes in the management access rule right after the base, so if necessary you can override or “null out” it via nft_rules_extra/nft_rules_host.

Also keep in mind that inside a single rule lists are replaced, not appended to. For example, tcp.dport: [80] in nft_rules_extra for wan_allow_tcp_ports will replace the list [80, 443] entirely, not add 80 to it.

Protecting management access

Before rendering the rules, the role reads the live management connection from $SSH_CONNECTION on the target host (not from the inventory: with ProxyJump, NAT, or a bastion, the address the server sees is not equal to ansible_host) and adds the following as the first rule of the chain:

Output
iifname "<interface>" ip saddr <admin address> tcp dport <session port> ct state { new, established } accept
Click to expand and view more

It also refuses to apply the configuration if it can’t guarantee protection:

CheckWhy
the management source interface is resolved via ip route geta rule bound to the wrong interface won’t fire
the nft_mgmt_chain chain exists in nft_chainsotherwise there’s nowhere to insert the rule
the rule has the lowest order in the chainotherwise an earlier drop would get there first
sources are valid addresses/CIDRs of one family, not 0.0.0.0/0a wildcard in a rule that accepts first is unacceptable
ssh_port matches the live session’s portotherwise the SSH rules protect a port nobody connects to
the sum of check timeouts is less than the rollback timeoutotherwise the timer would roll back a healthy host in the middle of a check

Parameters:

YAML
nft_mgmt_allow_enabled: true # disables all of the protection at once
nft_mgmt_sources: [] # empty - detect the address automatically
nft_mgmt_interface: "" # empty - detect via "ip route get"
nft_mgmt_match_interface: true # false with asymmetric routing
nft_mgmt_order: 1
nft_mgmt_assert_ssh_port: true
Click to expand and view more

Flexibility: NAT, redirect, and raw nftables

If the constructs described in the DSL aren’t enough, the role doesn’t box you in.

DNAT: forwarding a port to an internal service

YAML
nft_rules_extra:
  nat_dnat_crow:
    order: 400
    family: inet
    table: nat
    chain: prerouting
    match:
      ip:
        daddr: 198.51.100.5
      tcp:
        dport: 8443
    counter: true
    action:
      dnat:
        ip: "192.168.10.20:443"
    comment: "DNAT 8443 to internal crow.r4ven.me"
Click to expand and view more

For the inet family, dnat requires an explicit protocol version (ip or ip6), and then the role will generate the correct dnat ip to ....

Redirect to a local port

YAML
nft_rules_extra:
  nat_redirect_dns:
    order: 404
    family: inet
    table: nat
    chain: prerouting
    match:
      ip:
        daddr: 198.51.100.5
      meta:
        l4proto:
          - tcp
          - udp
      th:
        dport: 53
    action:
      redirect: ":553"
    comment: "Redirect DNS to local service on 553"
Click to expand and view more

Arbitrary expressions

For non-trivial cases there are three “emergency exits”: match.raw, rule-level expr, and action.raw. For example, spoofing protection via FIB:

YAML
nft_rules_extra:
  anti_spoof:
    order: 70
    family: inet
    table: filter
    chain: input
    expr: "fib saddr . iif oif missing"
    action: drop
Click to expand and view more

Flowtables for routers

If the host acts as a gateway, you can enable software acceleration of traffic forwarding (flowtable offload):

YAML
nft_flowtables:
  - family: inet
    table: filter
    name: ft
    devices:
      - eth0
      - eth1

nft_rules_extra:
  offload_established:
    order: 204
    family: inet
    table: filter
    chain: forward
    match:
      ct:
        state: established
      meta:
        l4proto:
          - tcp
          - udp
    action:
      flow: ft
Click to expand and view more

In effect, any nftables syntax can be expressed either through the DSL or through raw - and the other conveniences (ordering, merging, validation, rollback) keep working.

Safe application

The most important thing in remote firewall management is not to cut yourself off from the server. That’s why the role applies changes following a strict scenario:

  1. Preflight. The role identifies the live management connection, builds an allow rule for it, and refuses to continue if that path can’t be protected (see the section above). It also checks here that ssh_port matches the real session’s port and that the check timeouts fit within the rollback timeout.
  2. Comparison and staging. The role compares the rendered config without writing to /etc/nftables.conf. If there’s a change, it creates a private transaction directory in nft_state_dir and a staging file candidate, validated by nft -c -f before the working config is replaced and the rules are applied. This is not “before it hits the disk”: the candidate is already in a temporary file.
  3. Snapshot. The current runtime ruleset is saved, not just the possibly stale config. Separately saved are the original file (including a symlink) or the fact of its absence, the service’s enabled/runtime-enabled and masked/runtime-masked states, and whether it was active.
  4. Independent timer. Before applying, a transient systemd timer with a unique transaction name is created - independently of connectivity checks (nft_rollback_enabled). It restores the runtime, the config (or its absence), and the service state without Ansible’s involvement. The timer’s accuracy is pinned (AccuracySec), otherwise systemd coalesces firings and the timeout turns into a suggestion. In addition, a persistent OnBootSec unit is installed for the duration of the transaction: a reboot between apply and commit would otherwise leave the host without rules and with a masked service, and transient timers don’t survive it.
  5. History and apply. Only if the content changed is the previous config saved to /root/nft_backup/ as nftables.conf_<sha256> with 0600 permissions. An already existing hash isn’t copied again and its mtime isn’t updated. The candidate replaces the working file, the service is temporarily masked, and the rules are loaded via nft -f.
  6. Connectivity check and commit. By default, with nft_ssh_check_delegate, the TCP reachability of the SSH port is checked; optionally (nft_ssh_check_connection) the role also reconnects via Ansible’s own path. apply, rollback, arm, disarm, and commit use a shared flock and a state marker: commit is allowed only from pending. If the timer has already started a rollback, even a late successful connection won’t produce a false success - instead of an empty rc=1 you get a message that the management connection was lost and the state was restored by the timer.
  7. Completion. A successful commit unmasks and enables nftables, disarms the timer and the OnBootSec unit, deletes the transaction directory, and records the fingerprint of the managed tables. After a successful change, the last nft_backup_keep unique previous configs by mtime are kept (10 by default). An identical rerun gives changed=0.

On error, rescue tries to roll back the transaction immediately if the host is still reachable. If SSH is completely lost, that path is unavailable too - then what remains is the armed independent timer. A failed transaction returns an error with the result of the restore attempt and leaves the snapshots and timer for investigation, rather than declaring the rollback successful in words; you can remove the leftover safety net manually with <transaction directory>/transaction disarm.

Two more things the role does so as not to harm itself:

Separately - drift control. The role looks not only at the configuration file: after the commit it saves a fingerprint of the managed tables, and if the live ruleset no longer matches it (someone ran nft flush ruleset, loaded rules by hand, or the service failed to bring them up), the transaction runs again even when the rendered config hasn’t changed. Counters, dynamic set contents, and foreign tables (for example, Docker) are excluded from the comparison.

This reduces risk but gives no absolute guarantee 😉. A TCP port check doesn’t verify SSH authentication or all management routes. The timer depends on a working systemd, nft, the machine itself, and the snapshots. After a reboot inside the transaction window, the host stays without a firewall until the OnBootSec unit fires. Keep a console or a VM snapshot at hand.

You can tune the parameters to your needs:

YAML
nft_rollback_enabled: true # independent safety net (doesn't depend on checks)
nft_rollback_timeout: 120 # after how many seconds to roll back
nft_rollback_on_boot: true # recovery after a reboot within the window
nft_state_dir: /var/lib/nftables-firewall
nft_ssh_check_enabled: true # check availability after applying
nft_ssh_check_tcp: true # TCP probe with delegate (default)
nft_ssh_check_connection: false # reconnect via Ansible's own path
nft_ssh_check_timeout: 30 # how long to wait for a response
nft_drift_check_enabled: true # react to live ruleset drift
nft_backup_keep: 10 # how many unique previous configs to keep
Click to expand and view more

If a rollback did happen

It looks like this (output from my test run, where a rule deliberately cut off SSH):

Output
TASK [firewall : Commit only if rollback has not started] ***
fatal: [crow.r4ven.me]: FAILED! => {"cmd": [".../transaction", "commit"], "rc": 1,
  "stderr": "Transaction state is 'rolled-back', not 'pending': the rollback timer
  already fired or the transaction was rolled back. The control connection was most
  likely lost after the new rules were applied, so the independent timer restored
  the previous state."}

TASK [firewall : Report failed transaction without claiming successful restoration] ***
fatal: [crow.r4ven.me]: FAILED! => {"msg": "nftables transaction failed: ...
  Rollback rc=0. Transaction snapshots remain in
  /var/lib/nftables-firewall/nft-transaction-ir9gi5kj; any armed timer is left
  intact; failed restoration requires investigation. Clean up with
  \"/var/lib/nftables-firewall/nft-transaction-ir9gi5kj/transaction disarm\"
  once the state is understood."}
Click to expand and view more

What this means and what to do, step by step:

  1. The host is already restored - the rules, config, and service state are back to what they were before the run. Verify for yourself: nft list ruleset and sha256sum /etc/nftables.conf.
  2. The cause is in the stderr of the first error. rolled-back means the independent timer fired, i.e. connectivity was lost after the rules were applied. If instead the task failed at the availability check, the message will contain exactly the check timeout - meaning the rules dropped new connections while the established session survived.
  3. Remove the leftover safety net. The role deliberately doesn’t delete the snapshot or disarm the timer on failure - otherwise you’d lose both the evidence and the protection. The command is printed in the error: <transaction directory>/transaction disarm.
  4. Until the safety net is removed, the next run will refuse to start - a new transaction isn’t created next to an unfinished one, because its timer would cancel your new commit. This is not a bug but protection against two overlapping applies.
  5. Fix the cause, not the protection. Previous configs are in /root/nft_backup/ as nftables.conf_<sha256>, so you can compare “what was and what became” without guessing.

Hosts behind a bastion

If you reach the server via ProxyJump, the control machine has no direct route to the target’s SSH port, and the default TCP probe will fail (which means a rollback will happen). There are two solutions.

Delegate the check to a host that sees the target directly (it must be in the inventory):

YAML
nft_ssh_check_delegate: bastion
Click to expand and view more

Or check connectivity via Ansible’s own path - it goes through the same ProxyJump:

YAML
nft_ssh_check_connection: true
nft_ssh_check_tcp: false
Click to expand and view more

Tags and partial runs

All stages of the role are tagged, so you can run only the part you need:

TagWhat it does
firewallThe entire role
nft_packagesOnly installing the nftables package
nft_rulesOnly assembling the rules dictionary
nft_configRule assembly and the same transactional apply (applies changes without --check)
nft_applyRule assembly and apply with rollback; without package installation

For example, to quickly roll out rule changes:

BASH
ansible-playbook playbooks/firewall.yml -l crow.r4ven.me -t nft_apply
Click to expand and view more

-t nft_apply requires nftables to be already installed: the tag skips package installation. For the first run, use the whole role.

Common tasks in a couple of lines

A quick summary of which variables to change in typical cases:

TaskWhat to change
Open a port on one serverAdd "8080/tcp" to the host’s nft_allowed_ports
Give an office IP access to all serversAdd the IP to nft_trusted_ips in group_vars
Close SSH from the internet on one hostwan_allow_ssh: null in nft_rules_extra
Forward a port to an internal serviceA rule with dnat in the nat table
Move from ufw to nftablesnft_remove_other_firewall: true
Onboard a new serverfirewall_enabled: true and a port list in host_vars
Roll back a failed changerescue tries to restore the snapshot immediately, the independent timer is a safety net if connectivity is lost. Config history is in /root/nft_backup/

And all of this is stored in git: you get a history of firewall changes, review via merge request, and reproducibility 👍.

Conclusion

In the end, the policy is stored in YAML, and what remains on the server is a plain /etc/nftables.conf. If something goes wrong, you can look at the diff, inspect the generated rules, and roll back the application. For me that’s more convenient than maintaining a separate config by hand on every server.

Thanks for reading. Good luck automating your routine 🙂.

Copyright Notice

Author: Ivan Cherniy

Link: https://r4ven.me/en/automation/ansible-upravlenie-firewall-nftables-rol-firewall/

License: CC BY-NC-SA 4.0

Blog materials may be used with attribution to the author and source, for non-commercial purposes, and under the same license.

Start searching

Enter keywords to search articles

↑↓
↵
ESC
⌘K Shortcut