From 52e54ecc8fe282c7e9229f3e66be0f9ac84a86f9 Mon Sep 17 00:00:00 2001 From: jo Date: Thu, 30 Oct 2025 18:14:09 +0100 Subject: [PATCH] fix: firewall idempotency with ipv6 addresse Always use the canonical address representation when checking if rules changed. --- .../firewall-rules-ipv6-idempotency.yml | 2 + plugins/module_utils/ipaddress.py | 7 +++ plugins/modules/firewall.py | 43 ++++++++++++------- .../targets/firewall/tasks/test.yml | 38 +++++++++++++++- 4 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 changelogs/fragments/firewall-rules-ipv6-idempotency.yml create mode 100644 plugins/module_utils/ipaddress.py diff --git a/changelogs/fragments/firewall-rules-ipv6-idempotency.yml b/changelogs/fragments/firewall-rules-ipv6-idempotency.yml new file mode 100644 index 0000000..7176c4d --- /dev/null +++ b/changelogs/fragments/firewall-rules-ipv6-idempotency.yml @@ -0,0 +1,2 @@ +bugfixes: + - firewall - Ensure idempotency when using non canonical ipv6 representation in Firewall rules. diff --git a/plugins/module_utils/ipaddress.py b/plugins/module_utils/ipaddress.py new file mode 100644 index 0000000..30c3bd6 --- /dev/null +++ b/plugins/module_utils/ipaddress.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from ipaddress import ip_interface + + +def normalize_ip(value: str) -> str: + return str(ip_interface(value)) diff --git a/plugins/modules/firewall.py b/plugins/modules/firewall.py index 4407bed..bf11c28 100644 --- a/plugins/modules/firewall.py +++ b/plugins/modules/firewall.py @@ -221,6 +221,7 @@ import time from ansible.module_utils.basic import AnsibleModule from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.ipaddress import normalize_ip from ..module_utils.vendor.hcloud import APIException, HCloudException from ..module_utils.vendor.hcloud.firewalls import ( BoundFirewall, @@ -229,6 +230,14 @@ from ..module_utils.vendor.hcloud.firewalls import ( ) +def normalize_rules(rules: list[dict]) -> list[dict]: + for rule in rules: + # Ensure ip addresses canonical representation + rule["source_ips"] = [normalize_ip(o) for o in rule["source_ips"]] + rule["destination_ips"] = [normalize_ip(o) for o in rule["destination_ips"]] + return rules + + class AnsibleHCloudFirewall(AnsibleHCloud): represent = "hcloud_firewall" @@ -287,6 +296,7 @@ class AnsibleHCloudFirewall(AnsibleHCloud): } rules = self.module.params.get("rules") if rules is not None: + rules = normalize_rules(rules) params["rules"] = [ FirewallRule( direction=rule["direction"], @@ -323,21 +333,24 @@ class AnsibleHCloudFirewall(AnsibleHCloud): self._mark_as_changed() rules = self.module.params.get("rules") - if rules is not None and rules != [self._prepare_result_rule(rule) for rule in self.hcloud_firewall.rules]: - if not self.module.check_mode: - new_rules = [ - FirewallRule( - direction=rule["direction"], - protocol=rule["protocol"], - source_ips=rule["source_ips"] if rule["source_ips"] is not None else [], - destination_ips=rule["destination_ips"] if rule["destination_ips"] is not None else [], - port=rule["port"], - description=rule["description"], - ) - for rule in rules - ] - self.hcloud_firewall.set_rules(new_rules) - self._mark_as_changed() + if rules is not None: + rules = normalize_rules(rules) + + if rules != [self._prepare_result_rule(rule) for rule in self.hcloud_firewall.rules]: + if not self.module.check_mode: + new_rules = [ + FirewallRule( + direction=rule["direction"], + protocol=rule["protocol"], + source_ips=rule["source_ips"] if rule["source_ips"] is not None else [], + destination_ips=rule["destination_ips"] if rule["destination_ips"] is not None else [], + port=rule["port"], + description=rule["description"], + ) + for rule in rules + ] + self.hcloud_firewall.set_rules(new_rules) + self._mark_as_changed() self._get_firewall() diff --git a/tests/integration/targets/firewall/tasks/test.yml b/tests/integration/targets/firewall/tasks/test.yml index 079efe9..194f99f 100644 --- a/tests/integration/targets/firewall/tasks/test.yml +++ b/tests/integration/targets/firewall/tasks/test.yml @@ -12,6 +12,14 @@ - result is failed - 'result.msg == "one of the following is required: id, name"' +- name: Save allowed ssh sources + ansible.builtin.set_fact: + allowed_ssh_source_ips: + - "201.10.10.0/24" # ipv4 network + - "201.11.11.2/32" # ipv4 host + - "2a02:1234:10:0100::/64" # ipv6 network (non canonical representation) + - "2a02:1234:11:10::1/128" # ipv6 host + - name: Test create with check mode hetzner.hcloud.firewall: name: "{{ hcloud_firewall_name }}" @@ -20,6 +28,11 @@ direction: in protocol: icmp source_ips: ["0.0.0.0/0", "::/0"] + - description: allow ssh in + direction: in + protocol: tcp + port: 22 + source_ips: "{{ allowed_ssh_source_ips }}" labels: key: value check_mode: true @@ -37,6 +50,11 @@ direction: in protocol: icmp source_ips: ["0.0.0.0/0", "::/0"] + - description: allow ssh in + direction: in + protocol: tcp + port: 22 + source_ips: "{{ allowed_ssh_source_ips }}" labels: key: value register: result @@ -45,11 +63,22 @@ that: - result is changed - result.hcloud_firewall.name == hcloud_firewall_name - - result.hcloud_firewall.rules | list | count == 1 + - result.hcloud_firewall.rules | list | count == 2 - result.hcloud_firewall.rules[0].description == "allow icmp in" - result.hcloud_firewall.rules[0].direction == "in" - result.hcloud_firewall.rules[0].protocol == "icmp" - - result.hcloud_firewall.rules[0].source_ips == ["0.0.0.0/0", "::/0"] + - result.hcloud_firewall.rules[0].source_ips | count == 2 + - result.hcloud_firewall.rules[0].source_ips[0] == "0.0.0.0/0" + - result.hcloud_firewall.rules[0].source_ips[1] == "::/0" + - result.hcloud_firewall.rules[1].description == "allow ssh in" + - result.hcloud_firewall.rules[1].direction == "in" + - result.hcloud_firewall.rules[1].protocol == "tcp" + - result.hcloud_firewall.rules[1].port == "22" + - result.hcloud_firewall.rules[1].source_ips | count == 4 + - result.hcloud_firewall.rules[1].source_ips[0] == "201.10.10.0/24" + - result.hcloud_firewall.rules[1].source_ips[1] == "201.11.11.2/32" + - result.hcloud_firewall.rules[1].source_ips[2] == "2a02:1234:10:100::/64" # canonical representation + - result.hcloud_firewall.rules[1].source_ips[3] == "2a02:1234:11:10::1/128" - result.hcloud_firewall.labels.key == "value" - result.hcloud_firewall.applied_to | list | count == 0 @@ -61,6 +90,11 @@ direction: in protocol: icmp source_ips: ["0.0.0.0/0", "::/0"] + - description: allow ssh in + direction: in + protocol: tcp + port: 22 + source_ips: "{{ allowed_ssh_source_ips }}" labels: key: value register: result