How do I ensure firewall rule order

ansible [core 2.19.2]
community.routeros 3.10.0

I want to ensure firewall rules match those in my vars/firewall.yml. I’m not sure how to specify this in the playbook.

What I have done is create a generic firewall.yml playbook task, and then I feed it with a loop.

It mostly works, without any attempt to pin the firewall rules in order. One rule always says it has changed, and results in a duplicate firewall entry added to the end of the firewall rules. The others are recognised as already being there, and are unchanged.

I tried using the following, but they don’t work with an Ansible loop. Only the last rule remains.

    handle_absent_entries: remove
    handle_entries_content: remove_as_much_as_possible
    ensure_order: true

tasks/firewall.yml

---
#/ip firewall filter
#  .id looks to be being ignored.
# Router is injecting a rule 0, so .id is always 1 off
# Rule with hw=yes always returned changed, and a duplicate rule is added.
- name: Firewall settings
  community.routeros.api_modify:
    path: ip firewall filter
    data:
      - action: "{{ item.action }}"
        chain: "{{ item.chain }}"
        comment: "{{ item.comment | default(omit) }}"
        address-list: "{{ item.address_list | default(omit) }}"
        address-list-timeout: "{{ item.address_list_timeout | default(omit) }}"
        connection-bytes: "{{ item.connection_bytes | default(omit) }}"
        connection-limit: "{{ item.connection_limit | default(omit) }}"
        connection-mark: "{{ item.connection_mark | default(omit) }}"
        connection-nat-state: "{{ item.connection_nat_state | default(omit) }}"
        connection-rate: "{{ item.connection_rate | default(omit) }}"
        connection-state: "{{ item.connection_state | default(omit) }}"
        connection-type: "{{ item.connection_type | default(omit) }}"
        content: "{{ item.content | default(omit) }}"
        disabled: "{{ item.disabled | default(omit) }}"
        dscp: "{{ item.dscp | default(omit) }}"
        dst-address: "{{ item.dst_address | default(omit) }}"
        dst-address-list: "{{ item.dst_address_list | default(omit) }}"
        dst-address-type: "{{ item.dst_address_type | default(omit) }}"
        dst-limit: "{{ item.dst_limit | default(omit) }}"
        dst-port: "{{ item.dst_port | default(omit) }}"
        fragment: "{{ item.fragment | default(omit) }}"
        hotspot: "{{ item.hotspot | default(omit) }}"
        hw-offload: "{{ item.hw_offload | default(omit) }}"
        icmp-options: "{{ item.icmp_options | default(omit) }}"
        in-bridge-port: "{{ item.in_bridge_port | default(omit) }}"
        in-bridge-port-list: "{{ item.in_bridge_port_list | default(omit) }}"
        in-interface: "{{ item.in_interface | default(omit) }}"
        in-interface-list: "{{ item.in_interface_list | default(omit) }}"
        ingress-priority: "{{ item.ingress_priority | default(omit) }}"
        ipsec-policy: "{{ item.ipsec_policy | default(omit) }}"
        ipv4-options: "{{ item.ipv4_options | default(omit) }}"
        jump-target: "{{ item.jump_target | default(omit) }}"
        layer7-protocol: "{{ item.layer7_protocol | default(omit) }}"
        limit: "{{ item.limit | default(omit) }}"
        log: "{{ item.log | default(omit) }}"
        log-prefix: "{{ item.log_prefix | default(omit) }}"
        nth: "{{ item.nth | default(omit) }}"
        out-bridge-port: "{{ item.out_bridge_port | default(omit) }}"
        out-bridge-port-list: "{{ item.out_bridge_port_list | default(omit) }}"
        out-interface: "{{ item.out_interface | default(omit) }}"
        out-interface-list: "{{ item.out_interface_list | default(omit) }}"
        p2p: "{{ item.p2p | default(omit) }}"
        packet-mark: "{{ item.packet_mark | default(omit) }}"
        packet-size: "{{ item.packet_size | default(omit) }}"
        per-connection-classifier: "{{ item.per_connection_classifier | default(omit) }}"
        port: "{{ item.port | default(omit) }}"
        priority: "{{ item.priority | default(omit) }}"
        protocol: "{{ item.protocol | default(omit) }}"
        psd: "{{ item.psd | default(omit) }}"
        random: "{{ item.random | default(omit) }}"
        realm: "{{ item.realm | default(omit) }}"
        reject-with: "{{ item.reject_with | default(omit) }}"
        routing-mark: "{{ item.routing_mark | default(omit) }}"
        routing-table: "{{ item.routing_table | default(omit) }}"
        src-address: "{{ item.src_address | default(omit) }}"
        src-address-list: "{{ item.src_address_list | default(omit) }}"
        src-address-type: "{{ item.src_address_type | default(omit) }}"
        src-mac-address: "{{ item.src_mac_address | default(omit) }}"
        src-port: "{{ item.src_port | default(omit) }}"
        tcp-flags: "{{ item.tcp_flags | default(omit) }}"
        tcp-mss: "{{ item.tcp_mss | default(omit) }}"
        time: "{{ item.time | default(omit) }}"
        tls-host: "{{ item.tls_host | default(omit) }}"
        ttl: "{{ item.ttl | default(omit) }}"
         # .id: "*{{ rindex + 1 }}"
  loop: "{{ firewall_rules }}"
  loop_control:
    index_var: rindex

vars/firewall.yml

# Note for variables with '-'s we have substituted '_' or we get an Ansible error with loops
firewall_rules:
  # input chain
  - { action: accept, chain: input, comment: "accept established,related,untracked", connection_state: "established,related,untracked" }
  - { action: drop, chain: input, comment: "drop invalid", connection_state: "invalid" }
  - { action: accept, chain: input, comment: "accept ICMP", protocol: icmp }
  - { action: accept, chain: input, comment: "accept to local loopback (for CAPsMAN)", dst_address: "127.0.0.1" }
  - { action: accept, chain: input, comment: "Allow Management Port", in_interface_list: "MGMT" }
  - { action: drop, chain: input, comment: "drop all not coming from LAN", in_interface_list: "!LAN" }
  # Forward chain
  - { action: accept, chain: forward, comment: "accept in ipsec policy", ipsec_policy: "in,ipsec" }
  - { action: accept, chain: forward, comment: "accept out ipsec policy", ipsec_policy: "out,ipsec" }
  # the following rule get duplicated every run
  - { action: "fasttrack-connection", chain: forward, comment: "fasttrack", connection_state: "established,related", hw_offload: "yes"}
  - { action: accept, chain: forward, comment: "accept established,related,untracked", connection_state: "established,related,untracked" }
  - { action: drop, chain: forward, comment: "drop invalid", connection_state: "invalid" }
  - { action: drop, chain: forward, comment: "drop all from WAN not DSTNATed", connection_nat_state: "!dstnat", connection_state: "new", in_interface_list: "WAN" }
nat_rules:
  - { action: masquerade, chain: srcnat, comment: "masquerade", ipsec_policy: "out,none", out_interface_list: "WAN" }

Gives:

TASK [networking : Firewall settings] *************************************************************************************************************************************************************************
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'input', 'comment': 'accept established,related,untracked', 'connection_state': 'established,related,untracked'})
ok: [fibre-02] => (item={'action': 'drop', 'chain': 'input', 'comment': 'drop invalid', 'connection_state': 'invalid'})
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'input', 'comment': 'accept ICMP', 'protocol': 'icmp'})
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'input', 'comment': 'accept to local loopback (for CAPsMAN)', 'dst_address': '127.0.0.1'})
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'input', 'comment': 'Allow Management Port', 'in_interface_list': 'MGMT'})
ok: [fibre-02] => (item={'action': 'drop', 'chain': 'input', 'comment': 'drop all not coming from LAN', 'in_interface_list': '!LAN'})
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'forward', 'comment': 'accept in ipsec policy', 'ipsec_policy': 'in,ipsec'})
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'forward', 'comment': 'accept out ipsec policy', 'ipsec_policy': 'out,ipsec'})
changed: [fibre-02] => (item={'action': 'fasttrack-connection', 'chain': 'forward', 'comment': 'fasttrack', 'connection_state': 'established,related', 'hw_offload': 'yes'})
ok: [fibre-02] => (item={'action': 'accept', 'chain': 'forward', 'comment': 'accept established,related,untracked', 'connection_state': 'established,related,untracked'})
ok: [fibre-02] => (item={'action': 'drop', 'chain': 'forward', 'comment': 'drop invalid', 'connection_state': 'invalid'})
ok: [fibre-02] => (item={'action': 'drop', 'chain': 'forward', 'comment': 'drop all from WAN not DSTNATed', 'connection_nat_state': '!dstnat', 'connection_state': 'new', 'in_interface_list': 'WAN'})

After 3 runs, the changedrule appears 3x
```
/ip firewall filter
add action=accept chain=input comment=“accept established,related,untracked” connection-state=established,related,untracked
add action=drop chain=input comment=“drop invalid” connection-state=invalid
add action=accept chain=input comment=“accept ICMP” protocol=icmp
add action=accept chain=input comment=“accept to local loopback (for CAPsMAN)” dst-address=127.0.0.1
add action=accept chain=input comment=“Allow Management Port” in-interface-list=MGMT
add action=drop chain=input comment=“drop all not coming from LAN” in-interface-list=!LAN
add action=accept chain=forward comment=“accept in ipsec policy” ipsec-policy=in,ipsec
add action=accept chain=forward comment=“accept out ipsec policy” ipsec-policy=out,ipsec
add action=fasttrack-connection chain=forward comment=fasttrack connection-state=established,related hw-offload=yes
add action=accept chain=forward comment=“accept established,related,untracked” connection-state=established,related,untracked
add action=drop chain=forward comment=“drop invalid” connection-state=invalid
add action=drop chain=forward comment=“drop all from WAN not DSTNATed” connection-nat-state=!dstnat connection-state=new in-interface-list=WAN
add action=fasttrack-connection chain=forward comment=fasttrack connection-state=established,related hw-offload=yes
add action=fasttrack-connection chain=forward comment=fasttrack connection-state=established,related hw-offload=yes
add action=fasttrack-connection chain=forward comment=fasttrack connection-state=established,related hw-offload=yes
```

The complete playbook is at routeros_isp_modem. This works on a newly reset RB960 running routeros 7.19.4, with only the firewall rules needing to be corrected after a second run. A number of other tasks do report changed but these are not altering the configuration.

#firewall #get-help routeros #api_modify

This works if a list is passed to the data: field. It only fails if an Ansible loop is used.

- name: Firewall settings
community.routeros.api_modify:
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
ensure_order: true
path: ip firewall filter
data: “{{ firewall_rules2 }}

The router now only has the entries in firewall_rules2

Same works for the nat rules.

So as long as the firewall rules are listed in one variable, and nothing is dynamically added with other rules, this looks to be a solution to my problem.