From 497022e13c372ed68afe49aeafca66285e2f8001 Mon Sep 17 00:00:00 2001 From: omnom62 <75066712+omnom62@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:37:23 +1000 Subject: T8323: vyos_nat module (#451) Add NAT module --- .../network/vyos/argspec/nat/__init__.py | 0 .../module_utils/network/vyos/argspec/nat/nat.py | 652 +++++++++++ .../network/vyos/config/nat/__init__.py | 0 .../module_utils/network/vyos/config/nat/nat.py | 577 ++++++++++ plugins/module_utils/network/vyos/facts/facts.py | 4 + .../network/vyos/facts/nat/__init__.py | 0 plugins/module_utils/network/vyos/facts/nat/nat.py | 174 +++ .../module_utils/network/vyos/rm_templates/nat.py | 1186 ++++++++++++++++++++ plugins/module_utils/network/vyos/utils/utils.py | 1 + 9 files changed, 2594 insertions(+) create mode 100644 plugins/module_utils/network/vyos/argspec/nat/__init__.py create mode 100644 plugins/module_utils/network/vyos/argspec/nat/nat.py create mode 100644 plugins/module_utils/network/vyos/config/nat/__init__.py create mode 100644 plugins/module_utils/network/vyos/config/nat/nat.py create mode 100644 plugins/module_utils/network/vyos/facts/nat/__init__.py create mode 100644 plugins/module_utils/network/vyos/facts/nat/nat.py create mode 100644 plugins/module_utils/network/vyos/rm_templates/nat.py (limited to 'plugins/module_utils/network') diff --git a/plugins/module_utils/network/vyos/argspec/nat/__init__.py b/plugins/module_utils/network/vyos/argspec/nat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/network/vyos/argspec/nat/nat.py b/plugins/module_utils/network/vyos/argspec/nat/nat.py new file mode 100644 index 00000000..6f81c53c --- /dev/null +++ b/plugins/module_utils/network/vyos/argspec/nat/nat.py @@ -0,0 +1,652 @@ +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +""" +The arg spec for the vyos_nat module +""" + + +class NatArgs(object): # pylint: disable=R0903 + """The arg spec for the vyos_nat module""" + + argument_spec = { + "config": { + "type": "dict", + "options": { + "nat": { + "type": "dict", + "options": { + "cgnat": { + "type": "dict", + "options": { + "log_allocation": { + "type": "bool", + }, + "pool": { + "type": "dict", + "options": { + "external": { + "type": "list", + "elements": "dict", + "options": { + "name": { + "type": "str", + "required": True, + }, + "external_port_range": { + "type": "str", + }, + "per_user_limit": { + "type": "dict", + "options": { + "port": { + "type": "str", + }, + }, + }, + "range": { + "type": "list", + "elements": "dict", + "options": { + "value": { + "type": "str", + "required": True, + }, + "seq": { + "type": "str", + }, + }, + }, + }, + }, + "internal": { + "type": "list", + "elements": "dict", + "options": { + "name": { + "type": "str", + "required": True, + }, + "range": { + "type": "list", + "elements": "str", + }, + }, + }, + }, + }, + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "source": { + "type": "dict", + "options": { + "pool": { + "type": "str", + }, + }, + }, + "translation": { + "type": "dict", + "options": { + "pool": { + "type": "str", + }, + }, + }, + }, + }, + }, + }, + "destination": { + "type": "dict", + "options": { + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "description": { + "type": "str", + }, + "protocol": { + "type": "str", + }, + "packet_type": { + "type": "str", + }, + "exclude": { + "type": "bool", + }, + "log": { + "type": "bool", + }, + "disable": { + "type": "bool", + }, + "inbound_interface": { + "type": "dict", + "options": { + "name": { + "type": "str", + }, + "group": { + "type": "str", + }, + }, + }, + "destination": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "fqdn": { + "type": "str", + }, + "port": { + "type": "str", + }, + "address_group": { + "type": "str", + }, + "domain_group": { + "type": "str", + }, + "mac_group": { + "type": "str", + }, + "network_group": { + "type": "str", + }, + "port_group": { + "type": "str", + }, + }, + }, + "translation": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "port": { + "type": "str", + }, + "redirect_port": { + "type": "str", + }, + "address_mapping": { + "type": "str", + "choices": [ + "random", + "persistent", + ], + }, + "port_mapping": { + "type": "str", + "choices": [ + "random", + "none", + ], + }, + }, + }, + "load_balance": { + "type": "dict", + "options": { + "backend": { + "type": "list", + "elements": "dict", + "options": { + "ip": {"type": "str"}, + "weight": {"type": "int"}, + }, + }, + "hash": { + "type": "list", + "elements": "str", + "choices": [ + "source-address", + "destination-address", + "source-port", + "destination-port", + "random", + ], + }, + }, + }, + }, + }, + }, + }, + "source": { + "type": "dict", + "options": { + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "description": { + "type": "str", + }, + "protocol": { + "type": "str", + }, + "packet_type": { + "type": "str", + }, + "exclude": { + "type": "bool", + }, + "log": { + "type": "bool", + }, + "disable": { + "type": "bool", + }, + "outbound_interface": { + "type": "dict", + "options": { + "name": { + "type": "str", + }, + "group": { + "type": "str", + }, + }, + }, + "destination": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "fqdn": { + "type": "str", + }, + "address_group": { + "type": "str", + }, + "domain_group": { + "type": "str", + }, + "mac_group": { + "type": "str", + }, + "network_group": { + "type": "str", + }, + "port_group": { + "type": "str", + }, + "port": { + "type": "str", + }, + }, + }, + "source": { + "type": "dict", + "options": { + "address": {"type": "str"}, + "fqdn": {"type": "str"}, + "port": {"type": "str"}, + "address_group": {"type": "str"}, + "domain_group": {"type": "str"}, + "mac_group": {"type": "str"}, + "network_group": {"type": "str"}, + "port_group": {"type": "str"}, + }, + }, + "translation": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "port": { + "type": "str", + }, + "address_mapping": { + "type": "str", + "choices": [ + "random", + "persistent", + ], + }, + "port_mapping": { + "type": "str", + "choices": [ + "random", + "none", + ], + }, + }, + }, + "load_balance": { + "type": "dict", + "options": { + "backend": { + "type": "list", + "elements": "dict", + "options": { + "ip": {"type": "str"}, + "weight": {"type": "int"}, + }, + }, + "hash": { + "type": "list", + "elements": "str", + "choices": [ + "source-address", + "destination-address", + "source-port", + "destination-port", + "random", + ], + }, + }, + }, + }, + }, + }, + }, + "static": { + "type": "dict", + "options": { + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "description": { + "type": "str", + }, + "destination": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + }, + }, + "inbound_interface": { + "type": "str", + }, + "log": { + "type": "bool", + }, + "translation": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "nat64": { + "type": "dict", + "options": { + "source": { + "type": "dict", + "options": { + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "description": { + "type": "str", + }, + "disable": { + "type": "bool", + }, + "match": { + "type": "dict", + "options": { + "mark": { + "type": "int", + }, + }, + }, + "source": { + "type": "dict", + "options": { + "prefix": { + "type": "str", + }, + }, + }, + "translation": { + "type": "dict", + "options": { + "pool": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "address": { + "type": "str", + }, + "description": { + "type": "str", + }, + "disable": { + "type": "bool", + }, + "port": { + "type": "str", + }, + "protocol": { + "type": "str", + "choices": [ + "icmp", + "tcp", + "udp", + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "nat66": { + "type": "dict", + "options": { + "destination": { + "type": "dict", + "options": { + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "description": { + "type": "str", + }, + "destination": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "port": { + "type": "str", + }, + }, + }, + "disable": { + "type": "bool", + }, + "exclude": { + "type": "bool", + }, + "inbound_interface": { + "type": "dict", + "options": { + "name": { + "type": "str", + }, + }, + }, + "log": { + "type": "bool", + }, + "protocol": { + "type": "str", + }, + "source": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "port": { + "type": "str", + }, + }, + }, + "translation": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "port": { + "type": "str", + }, + }, + }, + }, + }, + }, + }, + "source": { + "type": "dict", + "options": { + "rule": { + "type": "list", + "elements": "dict", + "options": { + "id": { + "type": "int", + "required": True, + }, + "description": { + "type": "str", + }, + "destination": { + "type": "dict", + "options": { + "port": { + "type": "str", + }, + "prefix": { + "type": "str", + }, + }, + }, + "disable": { + "type": "bool", + }, + "exclude": { + "type": "bool", + }, + "log": { + "type": "bool", + }, + "outbound_interface": { + "type": "dict", + "options": { + "name": { + "type": "str", + }, + }, + }, + "protocol": { + "type": "str", + }, + "source": { + "type": "dict", + "options": { + "port": { + "type": "str", + }, + "prefix": { + "type": "str", + }, + }, + }, + "translation": { + "type": "dict", + "options": { + "address": { + "type": "str", + }, + "port": { + "type": "str", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "running_config": {"type": "str"}, + "state": { + "type": "str", + "choices": [ + "deleted", + "merged", + "overridden", + "replaced", + "gathered", + "rendered", + "parsed", + ], + "default": "merged", + }, + } # pylint: disable=C0301 diff --git a/plugins/module_utils/network/vyos/config/nat/__init__.py b/plugins/module_utils/network/vyos/config/nat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/network/vyos/config/nat/nat.py b/plugins/module_utils/network/vyos/config/nat/nat.py new file mode 100644 index 00000000..a2800914 --- /dev/null +++ b/plugins/module_utils/network/vyos/config/nat/nat.py @@ -0,0 +1,577 @@ +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +from copy import deepcopy + +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module import ( + ResourceModule, +) + +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.facts import Facts +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.nat import ( + NatTemplate, +) +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.utils.utils import combine + + +class Nat(ResourceModule): + """The vyos_nat config class""" + + def __init__(self, module): + super(Nat, self).__init__( + empty_fact_val={}, + facts_module=Facts(module), + module=module, + resource="nat", + tmplt=NatTemplate(), + ) + self.parsers = [] + + def execute_module(self): + if self.state not in ["parsed", "gathered"]: + self.generate_commands() + self.run_commands() + return self.result + + def generate_commands(self): + wantd = deepcopy(self.want) + haved = deepcopy(self.have) + + if self.state == "merged": + wantd = combine(haved, wantd, recursive=True) + + if self.state == "deleted": + if not wantd: + for nat_type in haved: + self.commands.append(f"delete {nat_type}") + return + self._list_to_dict(wantd) + self._list_to_dict(haved) + self._delete_nat_objects(wantd, haved, only_missing=False) + return + + self._list_to_dict(wantd) + self._list_to_dict(haved) + + if self.state == "replaced": + self._delete_nat_objects(wantd, haved, only_missing=False) + self._set_commands(wantd, haved) + elif self.state == "overridden": + self._delete_nat_objects(wantd, haved, only_missing=True) + self._delete_nat_objects(wantd, haved, only_missing=False) + self._set_commands(wantd, haved) + else: + self._set_commands(wantd, haved) + + self.commands = list(dict.fromkeys(self.commands)) + + # ------------------------------------------------------------------------- + # List → keyed dict conversion + # ------------------------------------------------------------------------- + + def _list_to_dict(self, config): + nat = config.get("nat", {}) + cgnat = nat.get("cgnat", {}) + + pool = cgnat.get("pool", {}) + for ptype in ("external", "internal"): + entries = pool.get(ptype) + if isinstance(entries, list): + pool[ptype] = {item["name"]: item for item in entries} + + rules = cgnat.get("rule") + if isinstance(rules, list): + cgnat["rule"] = {r["id"]: r for r in rules} + + for section in ("destination", "source", "static"): + rules = nat.get(section, {}).get("rule") + if isinstance(rules, list): + nat[section]["rule"] = {r["id"]: r for r in rules} + + nat64 = config.get("nat64", {}) + rules = nat64.get("source", {}).get("rule") + if isinstance(rules, list): + nat64["source"]["rule"] = {r["id"]: r for r in rules} + for rule in nat64["source"]["rule"].values(): + pools = rule.get("translation", {}).get("pool") + if isinstance(pools, list): + rule["translation"]["pool"] = {p["id"]: p for p in pools} + + nat66 = config.get("nat66", {}) + for section in ("destination", "source"): + rules = nat66.get(section, {}).get("rule") + if isinstance(rules, list): + nat66[section]["rule"] = {r["id"]: r for r in rules} + + # ------------------------------------------------------------------------- + # Top-level dispatch + # ------------------------------------------------------------------------- + + def _set_commands(self, wantd, haved): + self._compare_cgnat_global(wantd, haved) + self._compare_cgnat_pools(wantd, haved) + self._compare_cgnat_rules(wantd, haved) + + for section in ("destination", "source", "static"): + self._compare_nat_rules("nat", section, wantd, haved) + + self._compare_nat_rules("nat64", "source", wantd, haved) + + for section in ("destination", "source"): + self._compare_nat_rules("nat66", section, wantd, haved) + + self.commands = list(dict.fromkeys(self.commands)) + + # ------------------------------------------------------------------------- + # Delete helpers + # ------------------------------------------------------------------------- + + def _delete_nat_objects(self, wantd, haved, only_missing=False): + """ + Generate delete commands for NAT objects. + only_missing=False: delete objects present in both want and have (when different) + only_missing=True: delete objects present in have but absent from want + """ + for nat_type in haved: + want_nat = wantd.get(nat_type, {}) + have_nat = haved[nat_type] + + if only_missing and nat_type not in wantd: + self.commands.append(f"delete {nat_type}") + continue + + for section in have_nat: + want_section = want_nat.get(section, {}) + have_section = have_nat[section] + + if only_missing and section not in want_nat: + self.commands.append( + f"delete {nat_type} {section.replace('_', '-')}", + ) + continue + + if section == "cgnat": + for pool_type in ("external", "internal"): + want_pools = want_section.get("pool", {}).get(pool_type, {}) + have_pools = have_section.get("pool", {}).get(pool_type, {}) + for name in have_pools: + if only_missing and name not in want_pools: + self.commands.append( + f"delete {nat_type} cgnat pool {pool_type} {name}", + ) + + elif not only_missing and name in want_pools: + if self.state == "deleted" or want_pools[name] != have_pools[name]: + self.commands.append( + f"delete {nat_type} cgnat pool {pool_type} {name}", + ) + want_rules = want_section.get("rule", {}) + have_rules = have_section.get("rule", {}) + for rid in have_rules: + if only_missing and rid not in want_rules: + self.commands.append(f"delete {nat_type} cgnat rule {rid}") + + elif not only_missing and rid in want_rules: + if self.state == "deleted" or want_rules[rid] != have_rules[rid]: + self.commands.append(f"delete {nat_type} cgnat rule {rid}") + else: + want_rules = want_section.get("rule", {}) + have_rules = have_section.get("rule", {}) + cli_section = section.replace("_", "-") + for rid in have_rules: + if only_missing and rid not in want_rules: + self.commands.append( + f"delete {nat_type} {cli_section} rule {rid}", + ) + + elif not only_missing and rid in want_rules: + if self.state == "deleted" or want_rules[rid] != have_rules[rid]: + self.commands.append( + f"delete {nat_type} {cli_section} rule {rid}", + ) + + # ------------------------------------------------------------------------- + # CGNAT + # ------------------------------------------------------------------------- + + def _compare_cgnat_global(self, wantd, haved): + if self.state in ("replaced", "overridden") and not wantd.get("nat", {}).get("cgnat"): + return + w = wantd.get("nat", {}).get("cgnat", {}).get("log_allocation") + h = haved.get("nat", {}).get("cgnat", {}).get("log_allocation") + if bool(w) != bool(h): + self.addcmd( + {"nat": {"cgnat": {"log_allocation": True}}}, + "cgnat_log_allocation", + not bool(w), + ) + + def _compare_cgnat_pools(self, wantd, haved): + want_ext = wantd.get("nat", {}).get("cgnat", {}).get("pool", {}).get("external", {}) + have_ext = haved.get("nat", {}).get("cgnat", {}).get("pool", {}).get("external", {}) + want_int = wantd.get("nat", {}).get("cgnat", {}).get("pool", {}).get("internal", {}) + have_int = haved.get("nat", {}).get("cgnat", {}).get("pool", {}).get("internal", {}) + + scope = self.state in ("replaced", "overridden") + ext_names = set(want_ext) if scope else set(want_ext) | set(have_ext) + int_names = set(want_int) if scope else set(want_int) | set(have_int) + + for name in ext_names: + w = want_ext.get(name, {}) + h = have_ext.get(name, {}) + if scope and w != h: + h = {} + self._compare_external_pool(name, w, h) + + for name in int_names: + w = want_int.get(name, {}) + h = have_int.get(name, {}) + if scope and w != h: + h = {} + self._compare_internal_pool(name, w, h) + + def _compare_external_pool(self, name, want, have): + w = want.get("external_port_range") + h = have.get("external_port_range") + if w != h: + if w: + self.addcmd({"name": name, "range": w}, "cgnat_pool_external_port_range", False) + elif self.state in ("replaced", "overridden"): + self.addcmd({"name": name, "range": h}, "cgnat_pool_external_port_range", True) + + w = want.get("per_user_limit", {}).get("port") + h = have.get("per_user_limit", {}).get("port") + if w != h: + if w: + self.addcmd({"name": name, "limit": w}, "cgnat_pool_external_per_user", False) + elif self.state in ("replaced", "overridden"): + self.addcmd({"name": name, "limit": h}, "cgnat_pool_external_per_user", True) + + want_ranges = {(r["value"] if isinstance(r, dict) else r): r for r in want.get("range", [])} + have_ranges = {(r["value"] if isinstance(r, dict) else r): r for r in have.get("range", [])} + for val, rng in want_ranges.items(): + existing = have_ranges.get(val) + if existing is None or existing != rng: + seq = rng.get("seq") if isinstance(rng, dict) else None + self.addcmd( + {"name": name, "range": val, "seq": seq}, + "cgnat_pool_external_range", + False, + ) + + if self.state in ("replaced", "overridden"): + for val in have_ranges: + if val not in want_ranges: + self.addcmd({"name": name, "range": val}, "cgnat_pool_external_range", True) + + def _compare_internal_pool(self, name, want, have): + want_ranges = set(want.get("range", [])) + have_ranges = set(have.get("range", [])) + + for rng in want_ranges - have_ranges: + self.addcmd({"name": name, "range": rng}, "cgnat_pool_internal_range", False) + + if self.state in ("replaced", "overridden"): + for rng in have_ranges - want_ranges: + self.addcmd({"name": name, "range": rng}, "cgnat_pool_internal_range", True) + + def _compare_cgnat_rules(self, wantd, haved): + want_rules = wantd.get("nat", {}).get("cgnat", {}).get("rule", {}) + have_rules = haved.get("nat", {}).get("cgnat", {}).get("rule", {}) + + rids = ( + set(want_rules) + if self.state in ("replaced", "overridden") + else set(want_rules) | set(have_rules) + ) + + for rid in rids: + w = want_rules.get(rid, {}) + h = have_rules.get(rid, {}) + + if self.state in ("replaced", "overridden") and w != h: + h = {} + + w_src = w.get("source", {}).get("pool") + h_src = h.get("source", {}).get("pool") + if w_src != h_src: + if w_src: + self.addcmd({"id": rid, "pool": w_src}, "cgnat_rule_source_pool", False) + elif self.state in ("replaced", "overridden"): + self.addcmd({"id": rid, "pool": h_src}, "cgnat_rule_source_pool", True) + + w_tr = w.get("translation", {}).get("pool") + h_tr = h.get("translation", {}).get("pool") + if w_tr != h_tr: + if w_tr: + self.addcmd({"id": rid, "pool": w_tr}, "cgnat_rule_translation_pool", False) + elif self.state in ("replaced", "overridden"): + self.addcmd({"id": rid, "pool": h_tr}, "cgnat_rule_translation_pool", True) + + # ------------------------------------------------------------------------- + # NAT / NAT64 / NAT66 rules + # ------------------------------------------------------------------------- + + def _compare_nat_rules(self, nat_type, section, wantd, haved): + want_rules = wantd.get(nat_type, {}).get(section, {}).get("rule", {}) + have_rules = haved.get(nat_type, {}).get(section, {}).get("rule", {}) + + rids = ( + set(want_rules) + if self.state in ("replaced", "overridden") + else set(want_rules) | set(have_rules) + ) + + for rid in rids: + w = want_rules.get(rid, {}) + h = have_rules.get(rid, {}) + if self.state in ("replaced", "overridden") and w != h: + h = {} + if w == h and self.state != "rendered": + continue + self._compare_rule(nat_type, section, rid, w, h) + + def _compare_rule(self, nat_type, section, rid, want, have): + ctx = {"nat": nat_type, "type": section, "id": rid} + + want_lb = want.get("load_balance") or {} + have_lb = have.get("load_balance") or {} + want_trans_addr = (want.get("translation") or {}).get("address") + have_trans_addr = (have.get("translation") or {}).get("address") + if want_lb and want_trans_addr is not None: + self._module.fail_json( + msg="translation.address and load_balance are mutually exclusive", + ) + if self.state == "merged": + if want_lb and have_trans_addr is not None: + self._module.fail_json( + msg=( + "Cannot add load_balance to a rule that already has translation.address with " + "state=merged; use state=replaced or state=overridden" + ), + ) + if want_trans_addr is not None and have_lb: + self._module.fail_json( + msg=( + "Cannot add translation.address to a rule that already has load_balance with " + "state=merged; use state=replaced or state=overridden" + ), + ) + + for field in set(want) | set(have): + if field == "inbound_interface": + continue + val = want.get(field) if field in want else have.get(field) + if isinstance(val, bool): + self._cmp_bool(want, have, field, ctx, f"nat_type_{field}") + elif isinstance(val, str): + self._cmp_scalar(want, have, field, ctx, f"nat_type_{field}") + + self._cmp_interface(want, have, ctx, nat_type, section) + self._cmp_outbound_interface(want, have, ctx) + for atype in ("destination", "source"): + self._cmp_addr_sub(want, have, atype, ctx) + self._cmp_translation(want, have, ctx) + self._cmp_match_mark(want, have, ctx) + self._cmp_nat64_pools(want, have, ctx) + self._cmp_load_balance(want, have, ctx) + + # ------------------------------------------------------------------------- + # Field-level helpers + # ------------------------------------------------------------------------- + + def _cmp_scalar(self, want, have, field, ctx, parser): + w = want.get(field) + h = have.get(field) + if w != h: + if w is not None: + self.addcmd(dict(ctx, **{field: w}), parser, False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, **{field: h}), parser, True) + + def _cmp_bool(self, want, have, field, ctx, parser): + w = bool(want.get(field)) + h = bool(have.get(field)) + if w != h: + if w: + self.addcmd(dict(ctx), parser, False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx), parser, True) + + def _cmp_interface(self, want, have, ctx, nat_type, section): + iface_w = want.get("inbound_interface") + iface_h = have.get("inbound_interface") + if iface_w == iface_h: + return + + if nat_type == "nat" and section == "static": + if iface_w: + self.addcmd(dict(ctx, value=iface_w), "nat_static_inbound_interface", False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, value=iface_h), "nat_static_inbound_interface", True) + return + + iface_w = iface_w or {} + iface_h = iface_h or {} + + if nat_type == "nat": + parser_name = "nat_inbound_interface_name" + parser_group = "nat_inbound_interface_group" + else: + parser_name = "nat6x_inbound_interface" + parser_group = "nat6x_inbound_interface" + + if iface_w.get("name") != iface_h.get("name"): + if iface_w.get("name"): + self.addcmd(dict(ctx, value=iface_w["name"]), parser_name, False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, value=iface_h["name"]), parser_name, True) + + if nat_type == "nat" and iface_w.get("group") != iface_h.get("group"): + if iface_w.get("group"): + self.addcmd(dict(ctx, value=iface_w["group"]), parser_group, False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, value=iface_h["group"]), parser_group, True) + + def _cmp_outbound_interface(self, want, have, ctx): + iface_w = want.get("outbound_interface") or {} + iface_h = have.get("outbound_interface") or {} + + if iface_w.get("name") != iface_h.get("name"): + if iface_w.get("name"): + self.addcmd(dict(ctx, value=iface_w["name"]), "nat_type_outbound_interface", False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, value=iface_h["name"]), "nat_type_outbound_interface", True) + + if iface_w.get("group") != iface_h.get("group"): + if iface_w.get("group"): + self.addcmd( + dict(ctx, value=iface_w["group"]), + "nat_type_outbound_interface_group", + False, + ) + elif self.state in ("replaced", "overridden"): + self.addcmd( + dict(ctx, value=iface_h["group"]), + "nat_type_outbound_interface_group", + True, + ) + + def _cmp_addr_sub(self, want, have, atype, ctx): + sub_w = want.get(atype) or {} + sub_h = have.get(atype) or {} + if sub_w == sub_h: + return + + changed = {k: v for k, v in sub_w.items() if sub_h.get(k) != v} + removed = { + k: v + for k, v in sub_h.items() + if k not in sub_w and self.state in ("replaced", "overridden") + } + + if changed: + self.addcmd(dict(ctx, atype=atype, sub=changed), "nat_type_address", False) + if removed: + self.addcmd(dict(ctx, atype=atype, sub=removed), "nat_type_address", True) + + def _cmp_translation(self, want, have, ctx): + trans_w = want.get("translation") or {} + trans_h = have.get("translation") or {} + if trans_w == trans_h: + return + + changed = {k: v for k, v in trans_w.items() if k != "pool" and trans_h.get(k) != v} + removed = { + k: v + for k, v in trans_h.items() + if k != "pool" and k not in trans_w and self.state in ("replaced", "overridden") + } + + if changed: + self.addcmd(dict(ctx, translation=changed), "nat_type_translation_address", False) + if removed: + self.addcmd(dict(ctx, translation=removed), "nat_type_translation_address", True) + + def _cmp_match_mark(self, want, have, ctx): + w = want.get("match", {}).get("mark") + h = have.get("match", {}).get("mark") + if w != h: + if w is not None: + self.addcmd(dict(ctx, mark=w), "nat64_match_mark", False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, mark=h), "nat64_match_mark", True) + + def _cmp_nat64_pools(self, want, have, ctx): + want_pools = want.get("translation", {}).get("pool", {}) + have_pools = have.get("translation", {}).get("pool", {}) + + if isinstance(want_pools, list): + want_pools = {p["id"]: p for p in want_pools} + if isinstance(have_pools, list): + have_pools = {p["id"]: p for p in have_pools} + + for pid in set(want_pools) | set(have_pools): + wp = want_pools.get(pid, {}) + hp = have_pools.get(pid, {}) + + if wp == hp: + continue + + changed = {k: v for k, v in wp.items() if k != "id" and hp.get(k) != v} + removed = { + k: v + for k, v in hp.items() + if k != "id" and k not in wp and self.state in ("replaced", "overridden") + } + + if changed: + self.addcmd( + dict(ctx, pool_id=pid, pool=changed), + "nat64_translation_pool", + False, + ) + if removed: + self.addcmd( + dict(ctx, pool_id=pid, pool=removed), + "nat64_translation_pool", + True, + ) + + def _cmp_load_balance(self, want, have, ctx): + lb_w = want.get("load_balance") or {} + lb_h = have.get("load_balance") or {} + + want_hash = set(lb_w.get("hash") or []) + have_hash = set(lb_h.get("hash") or []) + + for h in want_hash - have_hash: + self.addcmd(dict(ctx, value=h), "nat_type_lb_hash", False) + if self.state in ("replaced", "overridden"): + for h in have_hash - want_hash: + self.addcmd(dict(ctx, value=h), "nat_type_lb_hash", True) + + want_backends = lb_w.get("backend", []) + have_backends = lb_h.get("backend", []) + if isinstance(want_backends, list): + want_backends = {b["ip"]: b for b in want_backends} + if isinstance(have_backends, list): + have_backends = {b["ip"]: b for b in have_backends} + + for ip in set(want_backends) | set(have_backends): + wb = want_backends.get(ip, {}) + hb = have_backends.get(ip, {}) + if wb == hb: + continue + if wb: + weight = wb.get("weight") + if weight is None: + self._module.fail_json(msg="load_balance.backend entries require 'weight'") + self.addcmd(dict(ctx, ip=ip, weight=weight), "nat_type_lb_backend", False) + elif self.state in ("replaced", "overridden"): + self.addcmd(dict(ctx, ip=ip, weight=hb.get("weight")), "nat_type_lb_backend", True) diff --git a/plugins/module_utils/network/vyos/facts/facts.py b/plugins/module_utils/network/vyos/facts/facts.py index 69cb0d0b..92c0f97a 100644 --- a/plugins/module_utils/network/vyos/facts/facts.py +++ b/plugins/module_utils/network/vyos/facts/facts.py @@ -59,6 +59,9 @@ from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.lldp_ from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.logging_global.logging_global import ( Logging_globalFacts, ) +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.nat.nat import ( + NatFacts, +) from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.ntp_global.ntp_global import ( Ntp_globalFacts, ) @@ -112,6 +115,7 @@ FACT_RESOURCE_SUBSETS = dict( prefix_lists=Prefix_listsFacts, logging_global=Logging_globalFacts, ntp_global=Ntp_globalFacts, + nat=NatFacts, snmp_server=Snmp_serverFacts, hostname=HostnameFacts, vrf=VrfFacts, diff --git a/plugins/module_utils/network/vyos/facts/nat/__init__.py b/plugins/module_utils/network/vyos/facts/nat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/network/vyos/facts/nat/nat.py b/plugins/module_utils/network/vyos/facts/nat/nat.py new file mode 100644 index 00000000..73d6238f --- /dev/null +++ b/plugins/module_utils/network/vyos/facts/nat/nat.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import re + +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common import utils + +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.nat.nat import ( + NatArgs, +) +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.nat import ( + NatTemplate, +) + + +class NatFacts(object): + """The vyos nat facts class""" + + def __init__(self, module, subspec="config", options="options"): + self._module = module + self.argument_spec = NatArgs.argument_spec + + def get_config(self, connection): + return connection.get("show configuration commands | match 'set nat'") + + def populate_facts(self, connection, ansible_facts, data=None): + facts = {} + config_lines = [] + + if not data: + data = self.get_config(connection) + + for resource in data.splitlines(): + config_lines.append(re.sub(r"'([^']*)'", r"\1", resource)) + + nat_parser = NatTemplate(lines=config_lines, module=self._module) + objs = nat_parser.parse() + objs = self._normalise(objs) + + ansible_facts["ansible_network_resources"].pop("nat", None) + + params = utils.remove_empties( + nat_parser.validate_config(self.argument_spec, {"config": objs}, redact=True), + ) + + if params.get("config"): + facts["nat"] = params["config"] + ansible_facts["ansible_network_resources"].update(facts) + + return ansible_facts + + def _deep_merge(self, base, override): + for k, v in override.items(): + if k in base and isinstance(base[k], dict) and isinstance(v, dict): + self._deep_merge(base[k], v) + elif k in base and isinstance(base[k], list) and isinstance(v, list): + for entry in v: + if entry not in base[k]: + base[k].append(entry) + else: + base[k] = v + return base + + def _merge_rule_list(self, rules): + merged = {} + for item in rules: + rid = item["id"] + if rid not in merged: + merged[rid] = {"id": rid} + for k, v in item.items(): + if k == "id": + continue + if isinstance(v, list): + existing = merged[rid].setdefault(k, []) + for entry in v: + if entry not in existing: + existing.append(entry) + elif isinstance(v, dict): + merged[rid].setdefault(k, {}) + self._deep_merge(merged[rid][k], v) + else: + merged[rid][k] = v + return list(merged.values()) + + def _merge_pool_list(self, pools): + merged = {} + for item in pools: + name = item["name"] + if name not in merged: + merged[name] = {"name": name} + for k, v in item.items(): + if k == "name": + continue + if k == "range" and isinstance(v, list): + existing = merged[name].setdefault(k, []) + existing.extend(v) + if v and isinstance(v[0], dict): + merged[name][k] = self._merge_range_list(existing) + else: + merged[name][k] = list(dict.fromkeys(existing)) + elif isinstance(v, list): + merged[name].setdefault(k, []) + for val in v: + if val not in merged[name][k]: + merged[name][k].append(val) + elif isinstance(v, dict): + merged[name].setdefault(k, {}) + self._deep_merge(merged[name][k], v) + else: + merged[name][k] = v + return list(merged.values()) + + def _merge_range_list(self, ranges): + """Merge external pool range entries by value, preserving seq.""" + merged = {} + for entry in ranges: + if isinstance(entry, dict): + key = entry.get("value") or entry.get("address", "") + if not key: + continue + if key not in merged: + merged[key] = {"value": key} + if entry.get("seq"): + merged[key]["seq"] = entry["seq"] + else: + if entry not in merged: + merged[entry] = {"value": entry} + return list(merged.values()) + + def _normalise(self, objs): + for nat_type in ["nat", "nat64", "nat66"]: + nat = objs.get(nat_type) + if not nat: + continue + + for section in ["destination", "source", "static", "cgnat"]: + if section not in nat: + continue + rules = nat[section].get("rule") + if isinstance(rules, list): + nat[section]["rule"] = self._merge_rule_list(rules) + nat[section]["rule"].sort(key=lambda x: x.get("id", 0)) + + if "cgnat" in nat and "pool" in nat["cgnat"]: + pool = nat["cgnat"]["pool"] + for ptype in ["external", "internal"]: + if ptype in pool and isinstance(pool[ptype], list): + pool[ptype] = self._merge_pool_list(pool[ptype]) + + if nat_type == "nat64": + for rule in nat.get("source", {}).get("rule", []): + pools = rule.get("translation", {}).get("pool") + if pools and isinstance(pools, list): + rule["translation"]["pool"] = self._merge_rule_list(pools) + rule["translation"]["pool"].sort(key=lambda x: x.get("id", 0)) + + self._cast_ports(objs) + return objs + + def _cast_ports(self, obj): + """Recursively cast known integer port/seq fields to str.""" + if isinstance(obj, dict): + for k, v in obj.items(): + if k in ("port", "seq") and isinstance(v, int): + obj[k] = str(v) + else: + self._cast_ports(v) + elif isinstance(obj, list): + for item in obj: + self._cast_ports(item) diff --git a/plugins/module_utils/network/vyos/rm_templates/nat.py b/plugins/module_utils/network/vyos/rm_templates/nat.py new file mode 100644 index 00000000..94af8696 --- /dev/null +++ b/plugins/module_utils/network/vyos/rm_templates/nat.py @@ -0,0 +1,1186 @@ +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import re + +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.network_template import ( + NetworkTemplate, +) + + +def _tmplt_nat_rule_addr_sub(config_data): + """Generate address/fqdn/prefix/port/group commands for destination or source sub-dict.""" + nat = config_data["nat"] + type_ = config_data["type"] + rid = config_data["id"] + atype = config_data["atype"] + sub = config_data["sub"] + + base = f"{nat} {type_} rule {rid} {atype}" + commands = [] + + for field in ("address", "fqdn", "prefix", "port"): + if sub.get(field) is not None: + commands.append(f"{base} {field} {sub[field]}") + + for gtype in ("address_group", "domain_group", "mac_group", "network_group", "port_group"): + if sub.get(gtype) is not None: + commands.append(f"{base} group {gtype.replace('_', '-')} {sub[gtype]}") + + return commands + + +def _tmplt_nat_rule_translation(config_data): + """Generate translation commands.""" + nat = config_data["nat"] + type_ = config_data["type"] + rid = config_data["id"] + trans = config_data["translation"] + + base = f"{nat} {type_} rule {rid} translation" + commands = [] + + if trans.get("address") is not None: + commands.append(f"{base} address {trans['address']}") + + if trans.get("port") is not None: + commands.append(f"{base} port {trans['port']}") + + if trans.get("redirect_port") is not None: + commands.append(f"{base} redirect port {trans['redirect_port']}") + + if trans.get("address_mapping") is not None: + commands.append(f"{base} options address-mapping {trans['address_mapping']}") + + if trans.get("port_mapping") is not None: + commands.append(f"{base} options port-mapping {trans['port_mapping']}") + + return commands + + +def _tmplt_nat64_translation_pool(config_data): + """Generate all nat64 translation pool commands from a single call.""" + nat = config_data["nat"] + type_ = config_data["type"] + rid = config_data["id"] + pool_id = config_data["pool_id"] + pool = config_data["pool"] + + base = f"{nat} {type_} rule {rid} translation pool {pool_id}" + commands = [] + + if pool.get("address") is not None: + commands.append(f"{base} address {pool['address']}") + if pool.get("description") is not None: + commands.append(f"{base} description '{pool['description']}'") + if pool.get("port") is not None: + commands.append(f"{base} port {pool['port']}") + if pool.get("protocol") is not None: + commands.append(f"{base} protocol {pool['protocol']}") + if pool.get("disable"): + commands.append(f"{base} disable") + + return commands + + +class NatTemplate(NetworkTemplate): + def __init__(self, lines=None, module=None): + prefix = {"set": "set", "remove": "delete"} + super(NatTemplate, self).__init__(lines=lines, tmplt=self, prefix=prefix, module=module) + + # fmt: off + PARSERS = [ + + # ------------------------- + # CGNAT + # ------------------------- + { + "name": "cgnat_log_allocation", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+log-allocation + $""", + re.VERBOSE, + ), + "setval": "nat cgnat log-allocation", + "result": { + "nat": { + "cgnat": { + "log_allocation": True, + }, + }, + }, + }, + { + "name": "cgnat_pool_external_range", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+pool + \s+external + \s+(?P\S+) + \s+range + \s+(?P\S+)(?:\s+seq\s+(?P\d+))? + $""", + re.VERBOSE, + ), + "setval": "nat cgnat pool external {{ name }} range {{ range }}{% if seq is defined and seq %} seq {{ seq }}{% endif %}", + "result": { + "nat": { + "cgnat": { + "pool": { + "external": [ + { + "name": "{{ name }}", + "range": [ + { + "value": "{{ range }}", + "seq": "{{ seq }}", + }, + ], + }, + ], + }, + }, + }, + }, + }, + { + "name": "cgnat_pool_external_port_range", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+pool + \s+external + \s+(?P\S+) + \s+external-port-range + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat cgnat pool external {{ name }} external-port-range {{ range }}", + "result": { + "nat": { + "cgnat": { + "pool": { + "external": [ + { + "name": "{{ name }}", + "external_port_range": "{{ range }}", + }, + ], + }, + }, + }, + }, + }, + { + "name": "cgnat_pool_external_per_user", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+pool + \s+external + \s+(?P\S+) + \s+per-user-limit + \s+port + \s+(?P\d+) + $""", + re.VERBOSE, + ), + "setval": "nat cgnat pool external {{ name }} per-user-limit port {{ limit }}", + "result": { + "nat": { + "cgnat": { + "pool": { + "external": [ + { + "name": "{{ name }}", + "per_user_limit": {"port": "{{ limit }}"}, + }, + ], + }, + }, + }, + }, + }, + { + "name": "cgnat_pool_internal_range", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+pool + \s+internal + \s+(?P\S+) + \s+range + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat cgnat pool internal {{ name }} range {{ range }}", + "result": { + "nat": { + "cgnat": { + "pool": { + "internal": [ + { + "name": "{{ name }}", + "range": ["{{ range }}"], + }, + ], + }, + }, + }, + }, + }, + { + "name": "cgnat_rule_source_pool", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+rule + \s+(?P\d+) + \s+source + \s+pool + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat cgnat rule {{ id }} source pool {{ pool }}", + "result": { + "nat": { + "cgnat": { + "rule": [ + { + "id": "{{ id }}", + "source": {"pool": "{{ pool }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "cgnat_rule_translation_pool", + "getval": re.compile( + r""" + ^set + \s+nat + \s+cgnat + \s+rule + \s+(?P\d+) + \s+translation + \s+pool + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat cgnat rule {{ id }} translation pool {{ pool }}", + "result": { + "nat": { + "cgnat": { + "rule": [ + { + "id": "{{ id }}", + "translation": {"pool": "{{ pool }}"}, + }, + ], + }, + }, + }, + }, + + # ------------------------- + # GENERIC NAT + # ------------------------- + { + "name": "nat_type_description", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+description + \s+(?P.+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} description '{{ description }}'", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "description": "{{ description }}", + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_protocol", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+protocol + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} protocol {{ protocol }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "protocol": "{{ protocol }}", + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_disable", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+disable + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} disable", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "disable": True, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_exclude", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+exclude + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} exclude", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "exclude": True, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_log", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+log + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} log", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "log": True, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_address", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+(?Pdestination|source) + \s+address + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_addr_sub, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "{{ atype }}": {"address": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_prefix", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+(?Pdestination|source) + \s+prefix + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_addr_sub, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "{{ atype }}": {"prefix": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_fqdn", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+(?Pdestination|source) + \s+fqdn + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_addr_sub, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "{{ atype }}": {"fqdn": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_port", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+(?Pdestination|source) + \s+port + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_addr_sub, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "{{ atype }}": {"port": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_address_group", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+(?Pdestination|source) + \s+group + \s+(?Paddress-group|domain-group|mac-group|network-group|port-group) + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_addr_sub, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "{{ atype }}": { + "{{ gtype | replace('-', '_') }}": "{{ value }}", + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_translation_address", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+translation + \s+address + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_translation, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "translation": {"address": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_translation_port", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+translation + \s+port + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_translation, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "translation": {"port": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_translation_options", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+translation + \s+options + \s+(?Paddress-mapping|port-mapping) + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_translation, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "{{ opt | replace('-', '_') }}": "{{ value }}", + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_translation_redirect", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+translation + \s+redirect + \s+port + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": _tmplt_nat_rule_translation, + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "redirect_port": "{{ value }}", + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat_inbound_interface_name", + "getval": re.compile( + r""" + ^set + \s+nat + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+inbound-interface + \s+name + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat {{ type }} rule {{ id }} inbound-interface name {{ value }}", + "result": { + "nat": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "inbound_interface": {"name": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_inbound_interface_group", + "getval": re.compile( + r""" + ^set + \s+nat + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+inbound-interface + \s+group + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat {{ type }} rule {{ id }} inbound-interface group {{ value }}", + "result": { + "nat": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "inbound_interface": {"group": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_static_inbound_interface", + "getval": re.compile( + r""" + ^set + \s+nat + \s+static + \s+rule + \s+(?P\S+) + \s+inbound-interface + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat static rule {{ id }} inbound-interface {{ value }}", + "result": { + "nat": { + "static": { + "rule": [ + { + "id": "{{ id }}", + "inbound_interface": "{{ value }}", + }, + ], + }, + }, + }, + }, + { + "name": "nat6x_inbound_interface", + "getval": re.compile( + r""" + ^set + \s+(?Pnat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+inbound-interface + \s+name + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} inbound-interface name {{ value }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "inbound_interface": {"name": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_outbound_interface", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+outbound-interface + \s+name + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} outbound-interface name {{ value }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "outbound_interface": {"name": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_outbound_interface_group", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source|static) + \s+rule + \s+(?P\S+) + \s+outbound-interface + \s+group + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} outbound-interface group {{ value }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "outbound_interface": {"group": "{{ value }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_packet_type", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+packet-type + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} packet-type {{ value }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "packet_type": "{{ value }}", + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_lb_backend", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+load-balance + \s+backend + \s+(?P\S+) + \s+weight + \s+(?P\d+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} load-balance backend {{ ip }} weight {{ weight }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "load_balance": { + "backend": [ + {"ip": "{{ ip }}", "weight": "{{ weight }}"}, + ], + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat_type_lb_hash", + "getval": re.compile( + r""" + ^set + \s+(?Pnat|nat64|nat66) + \s+(?Pdestination|source) + \s+rule + \s+(?P\S+) + \s+load-balance + \s+hash + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "{{ nat }} {{ type }} rule {{ id }} load-balance hash {{ value }}", + "result": { + "{{ nat }}": { + "{{ type }}": { + "rule": [ + { + "id": "{{ id }}", + "load_balance": {"hash": ["{{ value }}"]}, + }, + ], + }, + }, + }, + }, + { + "name": "nat64_match_mark", + "getval": re.compile( + r""" + ^set + \s+nat64 + \s+source + \s+rule + \s+(?P\S+) + \s+match + \s+mark + \s+(?P\d+) + $""", + re.VERBOSE, + ), + "setval": "nat64 source rule {{ id }} match mark {{ mark }}", + "result": { + "nat64": { + "source": { + "rule": [ + { + "id": "{{ id }}", + "match": {"mark": "{{ mark }}"}, + }, + ], + }, + }, + }, + }, + { + "name": "nat64_translation_pool", + "getval": re.compile(r"^$"), # never matches — setval only + "setval": _tmplt_nat64_translation_pool, + "result": {}, + }, + { + "name": "nat64_translation_pool_address", + "getval": re.compile( + r""" + ^set + \s+nat64 + \s+source + \s+rule + \s+(?P\S+) + \s+translation + \s+pool + \s+(?P\d+) + \s+address + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat64 source rule {{ id }} translation pool {{ pool_id }} address {{ value }}", + "result": { + "nat64": { + "source": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "pool": [{"id": "{{ pool_id }}", "address": "{{ value }}"}], + }, + }, + ], + }, + }, + }, + }, + + { + "name": "nat64_translation_pool_description", + "getval": re.compile( + r""" + ^set + \s+nat64 + \s+source + \s+rule + \s+(?P\S+) + \s+translation + \s+pool + \s+(?P\d+) + \s+description + \s+(?P.+) + $""", + re.VERBOSE, + ), + "setval": "nat64 source rule {{ id }} translation pool {{ pool_id }} description '{{ value }}'", + "result": { + "nat64": { + "source": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "pool": [{"id": "{{ pool_id }}", "description": "{{ value }}"}], + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat64_translation_pool_disable", + "getval": re.compile( + r""" + ^set + \s+nat64 + \s+source + \s+rule + \s+(?P\S+) + \s+translation + \s+pool + \s+(?P\d+) + \s+disable + $""", + re.VERBOSE, + ), + "setval": "nat64 source rule {{ id }} translation pool {{ pool_id }} disable", + "result": { + "nat64": { + "source": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "pool": [{"id": "{{ pool_id }}", "disable": True}], + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat64_translation_pool_port", + "getval": re.compile( + r""" + ^set + \s+nat64 + \s+source + \s+rule + \s+(?P\S+) + \s+translation + \s+pool + \s+(?P\d+) + \s+port + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat64 source rule {{ id }} translation pool {{ pool_id }} port {{ value }}", + "result": { + "nat64": { + "source": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "pool": [{"id": "{{ pool_id }}", "port": "{{ value }}"}], + }, + }, + ], + }, + }, + }, + }, + { + "name": "nat64_translation_pool_protocol", + "getval": re.compile( + r""" + ^set + \s+nat64 + \s+source + \s+rule + \s+(?P\S+) + \s+translation + \s+pool + \s+(?P\d+) + \s+protocol + \s+(?P\S+) + $""", + re.VERBOSE, + ), + "setval": "nat64 source rule {{ id }} translation pool {{ pool_id }} protocol {{ value }}", + "result": { + "nat64": { + "source": { + "rule": [ + { + "id": "{{ id }}", + "translation": { + "pool": [{"id": "{{ pool_id }}", "protocol": "{{ value }}"}], + }, + }, + ], + }, + }, + }, + }, + ] + # fmt: on diff --git a/plugins/module_utils/network/vyos/utils/utils.py b/plugins/module_utils/network/vyos/utils/utils.py index e95e8dea..89f12773 100644 --- a/plugins/module_utils/network/vyos/utils/utils.py +++ b/plugins/module_utils/network/vyos/utils/utils.py @@ -310,6 +310,7 @@ def combine(a, b, recursive=False, list_merge="replace"): :raises ValueError: If either argument is not a dict, or if an unsupported ``list_merge`` mode is given. """ + if not isinstance(a, dict) or not isinstance(b, dict): raise ValueError("combine expects two dictionaries") -- cgit v1.2.3