summaryrefslogtreecommitdiff
path: root/plugins/module_utils
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/module_utils')
-rw-r--r--plugins/module_utils/network/vyos/argspec/nat/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/argspec/nat/nat.py652
-rw-r--r--plugins/module_utils/network/vyos/config/nat/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/config/nat/nat.py577
-rw-r--r--plugins/module_utils/network/vyos/facts/facts.py4
-rw-r--r--plugins/module_utils/network/vyos/facts/nat/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/facts/nat/nat.py174
-rw-r--r--plugins/module_utils/network/vyos/rm_templates/nat.py1186
-rw-r--r--plugins/module_utils/network/vyos/utils/utils.py1
9 files changed, 2594 insertions, 0 deletions
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
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/nat/__init__.py
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
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/nat/__init__.py
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
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/nat/__init__.py
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<name>\S+)
+ \s+range
+ \s+(?P<range>\S+)(?:\s+seq\s+(?P<seq>\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<name>\S+)
+ \s+external-port-range
+ \s+(?P<range>\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<name>\S+)
+ \s+per-user-limit
+ \s+port
+ \s+(?P<limit>\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<name>\S+)
+ \s+range
+ \s+(?P<range>\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<id>\d+)
+ \s+source
+ \s+pool
+ \s+(?P<pool>\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<id>\d+)
+ \s+translation
+ \s+pool
+ \s+(?P<pool>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+description
+ \s+(?P<description>.+)
+ $""",
+ 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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+protocol
+ \s+(?P<protocol>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+(?P<atype>destination|source)
+ \s+address
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+(?P<atype>destination|source)
+ \s+prefix
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+(?P<atype>destination|source)
+ \s+fqdn
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+(?P<atype>destination|source)
+ \s+port
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+(?P<atype>destination|source)
+ \s+group
+ \s+(?P<gtype>address-group|domain-group|mac-group|network-group|port-group)
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+translation
+ \s+address
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+translation
+ \s+port
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+translation
+ \s+options
+ \s+(?P<opt>address-mapping|port-mapping)
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+translation
+ \s+redirect
+ \s+port
+ \s+(?P<value>\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+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+inbound-interface
+ \s+name
+ \s+(?P<value>\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+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+inbound-interface
+ \s+group
+ \s+(?P<value>\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<id>\S+)
+ \s+inbound-interface
+ \s+(?P<value>\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+(?P<nat>nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+inbound-interface
+ \s+name
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+outbound-interface
+ \s+name
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source|static)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+outbound-interface
+ \s+group
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+packet-type
+ \s+(?P<value>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+load-balance
+ \s+backend
+ \s+(?P<ip>\S+)
+ \s+weight
+ \s+(?P<weight>\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+(?P<nat>nat|nat64|nat66)
+ \s+(?P<type>destination|source)
+ \s+rule
+ \s+(?P<id>\S+)
+ \s+load-balance
+ \s+hash
+ \s+(?P<value>\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<id>\S+)
+ \s+match
+ \s+mark
+ \s+(?P<mark>\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<id>\S+)
+ \s+translation
+ \s+pool
+ \s+(?P<pool_id>\d+)
+ \s+address
+ \s+(?P<value>\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<id>\S+)
+ \s+translation
+ \s+pool
+ \s+(?P<pool_id>\d+)
+ \s+description
+ \s+(?P<value>.+)
+ $""",
+ 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<id>\S+)
+ \s+translation
+ \s+pool
+ \s+(?P<pool_id>\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<id>\S+)
+ \s+translation
+ \s+pool
+ \s+(?P<pool_id>\d+)
+ \s+port
+ \s+(?P<value>\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<id>\S+)
+ \s+translation
+ \s+pool
+ \s+(?P<pool_id>\d+)
+ \s+protocol
+ \s+(?P<value>\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")