diff --git a/changelogs/fragments/t8323-vyos_nat.yml b/changelogs/fragments/t8323-vyos_nat.yml index 32053ce9..ae71d4a9 100644 --- a/changelogs/fragments/t8323-vyos_nat.yml +++ b/changelogs/fragments/t8323-vyos_nat.yml @@ -1,3 +1,3 @@ --- minor_changes: - - vyos_nat - Add new module to support NAT configuration + - vyos_nat - Add new module to support NAT configuration. diff --git a/docs/vyos.vyos.vyos_nat_module.rst b/docs/vyos.vyos.vyos_nat_module.rst index 47d21e8f..3323a5c9 100644 --- a/docs/vyos.vyos.vyos_nat_module.rst +++ b/docs/vyos.vyos.vyos_nat_module.rst @@ -1,3499 +1,3499 @@ .. _vyos.vyos.vyos_nat_module: ****************** vyos.vyos.vyos_nat ****************** **NAT resource module** Version added: 6.0.0 .. contents:: :local: :depth: 1 Synopsis -------- - This module manages NAT configuration on devices running VyOS. Parameters ---------- .. raw:: html
Parameter Choices/Defaults Comments
config
dictionary
The desired configuration for the NAT resource represented as a dictionary.
nat
dictionary
Configuration for NAT rules.
cgnat
dictionary
Configuration for Carrier Grade NAT (CGNAT).
log_allocation
boolean
    Choices:
  • no
  • yes
Log CGNAT address allocations.
pool
dictionary
Configuration for CGNAT pools.
external
list / elements=dictionary
List of external NAT pools for CGNAT.
external_port_range
string
Port range to use for NAT translations in this external pool.
name
string / required
Name of the external NAT pool.
per_user_limit
dictionary
Per-user limit configuration for the external pool.
port
string
Maximum number of ports allocated per user.
range
list / elements=dictionary
List of external IP address ranges in the pool.
seq
string
Optional sequence number for this range entry.
value
string / required
IP address, prefix, or range (e.g. 203.0.113.0/24 or 203.0.113.1-203.0.113.60).
internal
list / elements=dictionary
List of internal NAT pools for CGNAT.
name
string / required
Name of the internal NAT pool.
range
list / elements=string
List of internal IP addresses or prefixes in the pool.
rule
list / elements=dictionary
List of CGNAT rules.
id
integer / required
Rule number for CGNAT.
source
dictionary
Source pool configuration for CGNAT translation.
pool
string
Source pool name to use for CGNAT translation.
translation
dictionary
Translation pool configuration for CGNAT.
pool
string
Translation pool name to use for CGNAT translation.
destination
dictionary
Configuration for destination NAT rules.
rule
list / elements=dictionary
List of destination NAT rules.
description
string
User-friendly description of the destination NAT rule.
destination
dictionary
Match criteria for destination NAT.
address
string
IP address, subnet, or range to match.
address_group
string
Address group name to match.
domain_group
string
Domain group name to match.
fqdn
string
Fully qualified domain name to match.
mac_group
string
MAC address group name to match.
network_group
string
Network group name to match.
port
string
Port number or range to match.
port_group
string
Port group name to match.
disable
boolean
    Choices:
  • no
  • yes
Disable this destination NAT rule.
exclude
boolean
    Choices:
  • no
  • yes
Exclude packets matching this rule from NAT.
id
integer / required
Rule number for destination NAT.
inbound_interface
dictionary
Match inbound interface.
group
string
Interface group to match.
name
string
Interface name to match.
log
boolean
    Choices:
  • no
  • yes
Log packets hitting this rule.
packet_type
string
Packet type to match.
protocol
string
Protocol to NAT (default all).
translation
dictionary
Translation configuration for destination NAT.
address
string
IP address or prefix to translate destination to.
address_mapping
string
    Choices:
  • random
  • persistent
Address mapping mode for translation.
port
string
Port number or range to translate destination port to.
port_mapping
string
    Choices:
  • random
  • none
Port mapping mode for translation.
redirect_port
string
Redirect to local port number.
source
dictionary
Configuration for source NAT rules.
rule
list / elements=dictionary
List of source NAT rules.
description
string
User-friendly description of the source NAT rule.
destination
dictionary
Destination match criteria for source NAT.
address
string
IP address, subnet, or range to match.
address_group
string
Address group name to match.
domain_group
string
Domain group name to match.
fqdn
string
Fully qualified domain name to match.
mac_group
string
MAC address group name to match.
network_group
string
Network group name to match.
port
string
Port number or range to match.
port_group
string
Port group name to match.
disable
boolean
    Choices:
  • no
  • yes
Disable this source NAT rule.
exclude
boolean
    Choices:
  • no
  • yes
Exclude packets matching this rule from NAT.
id
integer / required
Rule number for source NAT.
log
boolean
    Choices:
  • no
  • yes
Log packets hitting this rule.
outbound_interface
dictionary
Match outbound interface.
group
string
Interface group to match.
name
string
Interface name to match.
packet_type
string
Packet type to match.
protocol
string
Protocol to NAT (default all).
source
dictionary
Source match criteria for source NAT.
address
string
IP address, subnet, or range to match.
address_group
string
Address group name to match.
domain_group
string
Domain group name to match.
fqdn
string
Fully qualified domain name to match.
mac_group
string
MAC address group name to match.
network_group
string
Network group name to match.
port
string
Port number or range to match.
port_group
string
Port group name to match.
translation
dictionary
Translation configuration for source NAT.
address
string
IP address or prefix to translate source to. Use masquerade to masquerade as the outbound interface address.
address_mapping
string
    Choices:
  • random
  • persistent
Address mapping mode for translation.
port
string
Port number or range to translate source port to.
port_mapping
string
    Choices:
  • random
  • none
Port mapping mode for translation.
static
dictionary
Configuration for static one-to-one NAT rules.
rule
list / elements=dictionary
List of static NAT rules.
description
string
User-friendly description of the static NAT rule.
destination
dictionary
Match criteria for static NAT.
address
string
IP address, subnet, or range to match.
id
integer / required
Rule number for static NAT.
inbound_interface
string
Inbound interface that this static NAT rule applies to.
log
boolean
    Choices:
  • no
  • yes
Log packets hitting this static NAT rule.
translation
dictionary
Translation configuration for static NAT.
address
string
IP address or prefix to translate to.
nat64
dictionary
Configuration for NAT64 (IPv6-to-IPv4) rules.
source
dictionary
Configuration for NAT64 source rules.
rule
list / elements=dictionary
List of NAT64 source rules.
description
string
User-friendly description of the NAT64 source rule.
disable
boolean
    Choices:
  • no
  • yes
Disable this NAT64 source rule.
id
integer / required
Rule number for NAT64 source rule (1-999999).
match
dictionary
Match criteria for NAT64 source rule.
mark
integer
Match on firewall mark value (1-2147483647).
source
dictionary
IPv6 source prefix to match for NAT64 translation.
prefix
string
IPv6 source prefix to match (h:h:h:h:h:h:h:h/x).
translation
dictionary
Translation configuration for NAT64 source rule.
pool
list / elements=dictionary
List of translation pools for NAT64.
address
string
IPv4 address or prefix for translation pool.
description
string
User-friendly description of the translation pool.
disable
boolean
    Choices:
  • no
  • yes
Disable this translation pool.
id
integer / required
Pool number (1-999999).
port
string
Port number or range for translation pool.
protocol
string
    Choices:
  • icmp
  • tcp
  • udp
Protocol for this translation pool entry.
nat66
dictionary
Configuration for NAT66 (IPv6-to-IPv6) rules.
destination
dictionary
Configuration for NAT66 destination rules.
rule
list / elements=dictionary
List of NAT66 destination rules.
description
string
User-friendly description of the NAT66 destination rule.
destination
dictionary
Match criteria for NAT66 destination rule.
address
string
IPv6 address or prefix to match.
port
string
Port number or range to match.
disable
boolean
    Choices:
  • no
  • yes
Disable this NAT66 destination rule.
exclude
boolean
    Choices:
  • no
  • yes
Exclude packets matching this rule from NAT66.
id
integer / required
Rule number for NAT66 destination rule.
inbound_interface
dictionary
Inbound interface to match for NAT66 destination rule.
name
string
Interface name to match.
log
boolean
    Choices:
  • no
  • yes
Log packets hitting this NAT66 destination rule.
protocol
string
Protocol to match.
source
dictionary
Source match criteria for NAT66 destination rule.
address
string
IPv6 source address or prefix to match.
port
string
Source port number or range to match.
translation
dictionary
Translation configuration for NAT66 destination rule.
address
string
IPv6 address or prefix to translate destination to.
port
string
Port number or range to translate destination port to.
source
dictionary
Configuration for NAT66 source rules.
rule
list / elements=dictionary
List of NAT66 source rules.
description
string
User-friendly description of the NAT66 source rule.
destination
dictionary
Destination match criteria for NAT66 source rule.
port
string
Destination port number or range to match.
prefix
string
IPv6 destination prefix to match (h:h:h:h:h:h:h:h/x).
disable
boolean
    Choices:
  • no
  • yes
Disable this NAT66 source rule.
exclude
boolean
    Choices:
  • no
  • yes
Exclude packets matching this rule from NAT66.
id
integer / required
Rule number for NAT66 source rule.
log
boolean
    Choices:
  • no
  • yes
Log packets hitting this NAT66 source rule.
outbound_interface
dictionary
Outbound interface to match for NAT66 source rule.
name
string
Interface name to match.
protocol
string
Protocol to match.
source
dictionary
Source match criteria for NAT66 source rule.
port
string
Source port number or range to match.
prefix
string
IPv6 source prefix to match (h:h:h:h:h:h:h:h/x).
translation
dictionary
Translation configuration for NAT66 source rule.
address
string
IPv6 address or prefix to translate source to. Use masquerade to masquerade as the outbound interface address.
port
string
Port number or range to translate source port to.
running_config
string
This option is used only with state parsed.
The value of this option should be the output received from the VyOS device by executing the command show configuration commands | match 'nat'.
The state parsed reads the configuration from show configuration commands | match 'nat' and transforms it into Ansible structured data as per the module argspec. The value is then returned in the parsed key within the result.
-
The states replaced and overridden have identical behaviour for this module.
+
The state replaced replaces only the provided configuration, while overridden removes any existing NAT configuration not specified in config.
state
string
    Choices:
  • deleted
  • merged ←
  • overridden
  • replaced
  • gathered
  • rendered
  • parsed
The state the configuration should be left in.

Notes ----- .. note:: - Tested against VyOS 1.3.8, 1.4.2, the upcoming 1.5, and the rolling release of spring 2025. - This module works with connection ``network_cli``. Examples -------- .. code-block:: yaml # Using merged - configure CGNAT - name: Merge CGNAT configuration vyos.vyos.vyos_nat: config: nat: cgnat: log_allocation: true pool: external: - name: ext-pool-1 external_port_range: "10000-20000" per_user_limit: port: "200" range: - value: 203.0.113.0/24 internal: - name: int-pool-1 range: - 10.0.0.0/24 rule: - id: 1 source: pool: int-pool-1 translation: pool: ext-pool-1 state: merged # Using merged - configure destination NAT - name: Merge destination NAT rule vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Web server NAT" protocol: tcp log: true destination: address: 198.51.100.10 port: "80" translation: address: 192.168.1.10 port: "8080" state: merged # Using merged - configure source NAT - name: Merge source NAT rule vyos.vyos.vyos_nat: config: nat: source: rule: - id: 200 description: "Outbound masquerade" protocol: tcp log: true outbound_interface: name: eth0 translation: address: masquerade state: merged # Using merged - configure static NAT - name: Merge static NAT rule vyos.vyos.vyos_nat: config: nat: static: rule: - id: 300 description: "Static mapping" inbound_interface: eth2 destination: address: 198.51.100.20 translation: address: 192.168.1.20 log: true state: merged # Using merged - configure NAT64 - name: Merge NAT64 source rule vyos.vyos.vyos_nat: config: nat64: source: rule: - id: 10 description: "NAT64 example" source: prefix: 2001:db8::/96 match: mark: 100 translation: pool: - id: 1 address: 192.168.100.10 port: "1-65535" protocol: udp state: merged # Using merged - configure NAT66 - name: Merge NAT66 destination rule vyos.vyos.vyos_nat: config: nat66: destination: rule: - id: 20 description: "NAT66 DNAT" protocol: tcp inbound_interface: name: eth1 destination: address: 2001:db8::1 translation: address: 2001:db8:1::10 port: "8443" state: merged # Using replaced - replace specific NAT rules - name: Replace destination NAT rule vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Replaced web server NAT" protocol: tcp destination: address: 198.51.100.10 port: "443" translation: address: 192.168.1.10 port: "8443" state: replaced # Using overridden - override entire NAT configuration - name: Override entire NAT configuration vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Only rule after override" protocol: tcp destination: address: 198.51.100.10 port: "80" translation: address: 192.168.1.10 port: "8080" state: overridden # Using deleted - delete all NAT configuration - name: Delete all NAT configuration vyos.vyos.vyos_nat: state: deleted # Using deleted - delete specific NAT rules - name: Delete specific NAT rules vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 source: rule: - id: 200 nat64: source: rule: - id: 10 state: deleted # Using gathered - name: Gather NAT configuration from device vyos.vyos.vyos_nat: state: gathered # Using rendered - name: Render NAT configuration offline vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Rendered rule" protocol: tcp destination: address: 198.51.100.10 port: "80" translation: address: 192.168.1.10 port: "8080" state: rendered # Using parsed - name: Parse NAT configuration from file vyos.vyos.vyos_nat: running_config: "{{ lookup('file', './nat_config.cfg') }}" state: parsed Return Values ------------- Common return values are documented `here `_, the following are the fields unique to this module: .. raw:: html
Key Returned Description
after
dictionary
when changed
The resulting configuration after module execution.

Sample:
This output will always be in the same format as the module argspec.
before
dictionary
when state is merged, replaced, overridden or deleted
The configuration prior to the module execution.

Sample:
This output will always be in the same format as the module argspec.
commands
list
when state is merged, replaced, overridden or deleted
The set of commands pushed to the remote device.

Sample:
["set nat destination rule 100 description 'Web server NAT'", 'set nat destination rule 100 protocol tcp', 'set nat destination rule 100 inbound-interface name eth2', 'set nat destination rule 100 destination address 198.51.100.10', 'set nat destination rule 100 translation address 192.168.1.10', 'delete nat source rule 200']
gathered
dictionary
when state is gathered
Facts about the network resource gathered from the remote device as structured data.

Sample:
This output will always be in the same format as the module argspec.
parsed
dictionary
when state is parsed
The device native config provided in running_config option parsed into structured data as per module argspec.

Sample:
This output will always be in the same format as the module argspec.
rendered
list
when state is rendered
The provided configuration in the task rendered in device-native format (offline).

Sample:
["set nat destination rule 100 description 'Web server NAT'", 'set nat destination rule 100 protocol tcp', 'set nat destination rule 100 inbound-interface name eth2', 'set nat destination rule 100 destination address 198.51.100.10', 'set nat destination rule 100 translation address 192.168.1.10']


Status ------ Authors ~~~~~~~ - Evgeny Molotkov (@omnom62) diff --git a/plugins/module_utils/network/vyos/config/nat/nat.py b/plugins/module_utils/network/vyos/config/nat/nat.py index 858ae4ac..ca21b710 100644 --- a/plugins/module_utils/network/vyos/config/nat/nat.py +++ b/plugins/module_utils/network/vyos/config/nat/nat.py @@ -1,505 +1,518 @@ # -*- 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, list_merge="append_rp") 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 want_pools[name] != have_pools[name]: + 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 want_rules[rid] != have_rules[rid]: + 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 want_rules[rid] != have_rules[rid]: + 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: - self._compare_external_pool(name, want_ext.get(name, {}), have_ext.get(name, {})) + 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: - self._compare_internal_pool(name, want_int.get(name, {}), have_int.get(name, {})) + 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(): if val not in have_ranges: 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} 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) # ------------------------------------------------------------------------- # 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, ) diff --git a/plugins/modules/vyos_nat.py b/plugins/modules/vyos_nat.py index 797a343a..137dfb29 100644 --- a/plugins/modules/vyos_nat.py +++ b/plugins/modules/vyos_nat.py @@ -1,882 +1,883 @@ # -*- coding: utf-8 -*- # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) """ The module file for vyos_nat """ from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = """ module: vyos_nat version_added: 6.0.0 short_description: NAT resource module description: - This module manages NAT configuration on devices running VyOS. author: - Evgeny Molotkov (@omnom62) notes: - Tested against VyOS 1.3.8, 1.4.2, the upcoming 1.5, and the rolling release of spring 2025. - This module works with connection C(network_cli). options: config: description: - The desired configuration for the NAT resource represented as a dictionary. type: dict suboptions: nat: type: dict description: Configuration for NAT rules. suboptions: cgnat: type: dict description: Configuration for Carrier Grade NAT (CGNAT). suboptions: log_allocation: type: bool description: Log CGNAT address allocations. pool: type: dict description: Configuration for CGNAT pools. suboptions: external: type: list elements: dict description: List of external NAT pools for CGNAT. suboptions: name: type: str required: true description: Name of the external NAT pool. external_port_range: type: str description: Port range to use for NAT translations in this external pool. per_user_limit: type: dict description: Per-user limit configuration for the external pool. suboptions: port: type: str description: Maximum number of ports allocated per user. range: type: list elements: dict description: List of external IP address ranges in the pool. suboptions: value: type: str required: true description: IP address, prefix, or range (e.g. 203.0.113.0/24 or 203.0.113.1-203.0.113.60). seq: type: str description: Optional sequence number for this range entry. internal: type: list elements: dict description: List of internal NAT pools for CGNAT. suboptions: name: type: str required: true description: Name of the internal NAT pool. range: type: list elements: str description: List of internal IP addresses or prefixes in the pool. rule: type: list elements: dict description: List of CGNAT rules. suboptions: id: type: int required: true description: Rule number for CGNAT. source: type: dict description: Source pool configuration for CGNAT translation. suboptions: pool: type: str description: Source pool name to use for CGNAT translation. translation: type: dict description: Translation pool configuration for CGNAT. suboptions: pool: type: str description: Translation pool name to use for CGNAT translation. destination: type: dict description: Configuration for destination NAT rules. suboptions: rule: type: list elements: dict description: List of destination NAT rules. suboptions: id: type: int required: true description: Rule number for destination NAT. description: type: str description: User-friendly description of the destination NAT rule. protocol: type: str description: Protocol to NAT (default all). packet_type: type: str description: Packet type to match. exclude: type: bool description: Exclude packets matching this rule from NAT. log: type: bool description: Log packets hitting this rule. disable: type: bool description: Disable this destination NAT rule. inbound_interface: type: dict description: Match inbound interface. suboptions: name: type: str description: Interface name to match. group: type: str description: Interface group to match. destination: type: dict description: Match criteria for destination NAT. suboptions: address: type: str description: IP address, subnet, or range to match. fqdn: type: str description: Fully qualified domain name to match. port: type: str description: Port number or range to match. address_group: type: str description: Address group name to match. domain_group: type: str description: Domain group name to match. mac_group: type: str description: MAC address group name to match. network_group: type: str description: Network group name to match. port_group: type: str description: Port group name to match. translation: type: dict description: Translation configuration for destination NAT. suboptions: address: type: str description: IP address or prefix to translate destination to. port: type: str description: Port number or range to translate destination port to. redirect_port: type: str description: Redirect to local port number. address_mapping: type: str choices: - random - persistent description: Address mapping mode for translation. port_mapping: type: str choices: - random - none description: Port mapping mode for translation. source: type: dict description: Configuration for source NAT rules. suboptions: rule: type: list elements: dict description: List of source NAT rules. suboptions: id: type: int required: true description: Rule number for source NAT. description: type: str description: User-friendly description of the source NAT rule. protocol: type: str description: Protocol to NAT (default all). packet_type: type: str description: Packet type to match. exclude: type: bool description: Exclude packets matching this rule from NAT. log: type: bool description: Log packets hitting this rule. disable: type: bool description: Disable this source NAT rule. outbound_interface: type: dict description: Match outbound interface. suboptions: name: type: str description: Interface name to match. group: type: str description: Interface group to match. destination: type: dict description: Destination match criteria for source NAT. suboptions: address: type: str description: IP address, subnet, or range to match. fqdn: type: str description: Fully qualified domain name to match. port: type: str description: Port number or range to match. address_group: type: str description: Address group name to match. domain_group: type: str description: Domain group name to match. mac_group: type: str description: MAC address group name to match. network_group: type: str description: Network group name to match. port_group: type: str description: Port group name to match. source: type: dict description: Source match criteria for source NAT. suboptions: address: type: str description: IP address, subnet, or range to match. fqdn: type: str description: Fully qualified domain name to match. port: type: str description: Port number or range to match. address_group: type: str description: Address group name to match. domain_group: type: str description: Domain group name to match. mac_group: type: str description: MAC address group name to match. network_group: type: str description: Network group name to match. port_group: type: str description: Port group name to match. translation: type: dict description: Translation configuration for source NAT. suboptions: address: type: str description: IP address or prefix to translate source to. Use masquerade to masquerade as the outbound interface address. port: type: str description: Port number or range to translate source port to. address_mapping: type: str choices: - random - persistent description: Address mapping mode for translation. port_mapping: type: str choices: - random - none description: Port mapping mode for translation. static: type: dict description: Configuration for static one-to-one NAT rules. suboptions: rule: type: list elements: dict description: List of static NAT rules. suboptions: id: type: int required: true description: Rule number for static NAT. description: type: str description: User-friendly description of the static NAT rule. destination: type: dict description: Match criteria for static NAT. suboptions: address: type: str description: IP address, subnet, or range to match. inbound_interface: type: str description: Inbound interface that this static NAT rule applies to. log: type: bool description: Log packets hitting this static NAT rule. translation: type: dict description: Translation configuration for static NAT. suboptions: address: type: str description: IP address or prefix to translate to. nat64: type: dict description: Configuration for NAT64 (IPv6-to-IPv4) rules. suboptions: source: type: dict description: Configuration for NAT64 source rules. suboptions: rule: type: list elements: dict description: List of NAT64 source rules. suboptions: id: type: int required: true description: Rule number for NAT64 source rule (1-999999). description: type: str description: User-friendly description of the NAT64 source rule. disable: type: bool description: Disable this NAT64 source rule. match: type: dict description: Match criteria for NAT64 source rule. suboptions: mark: type: int description: Match on firewall mark value (1-2147483647). source: type: dict description: IPv6 source prefix to match for NAT64 translation. suboptions: prefix: type: str description: IPv6 source prefix to match (h:h:h:h:h:h:h:h/x). translation: type: dict description: Translation configuration for NAT64 source rule. suboptions: pool: type: list elements: dict description: List of translation pools for NAT64. suboptions: id: type: int required: true description: Pool number (1-999999). address: type: str description: IPv4 address or prefix for translation pool. description: type: str description: User-friendly description of the translation pool. disable: type: bool description: Disable this translation pool. port: type: str description: Port number or range for translation pool. protocol: type: str choices: - icmp - tcp - udp description: Protocol for this translation pool entry. nat66: type: dict description: Configuration for NAT66 (IPv6-to-IPv6) rules. suboptions: destination: type: dict description: Configuration for NAT66 destination rules. suboptions: rule: type: list elements: dict description: List of NAT66 destination rules. suboptions: id: type: int required: true description: Rule number for NAT66 destination rule. description: type: str description: User-friendly description of the NAT66 destination rule. destination: type: dict description: Match criteria for NAT66 destination rule. suboptions: address: type: str description: IPv6 address or prefix to match. port: type: str description: Port number or range to match. disable: type: bool description: Disable this NAT66 destination rule. exclude: type: bool description: Exclude packets matching this rule from NAT66. inbound_interface: type: dict description: Inbound interface to match for NAT66 destination rule. suboptions: name: type: str description: Interface name to match. log: type: bool description: Log packets hitting this NAT66 destination rule. protocol: type: str description: Protocol to match. source: type: dict description: Source match criteria for NAT66 destination rule. suboptions: address: type: str description: IPv6 source address or prefix to match. port: type: str description: Source port number or range to match. translation: type: dict description: Translation configuration for NAT66 destination rule. suboptions: address: type: str description: IPv6 address or prefix to translate destination to. port: type: str description: Port number or range to translate destination port to. source: type: dict description: Configuration for NAT66 source rules. suboptions: rule: type: list elements: dict description: List of NAT66 source rules. suboptions: id: type: int required: true description: Rule number for NAT66 source rule. description: type: str description: User-friendly description of the NAT66 source rule. destination: type: dict description: Destination match criteria for NAT66 source rule. suboptions: port: type: str description: Destination port number or range to match. prefix: type: str description: IPv6 destination prefix to match (h:h:h:h:h:h:h:h/x). disable: type: bool description: Disable this NAT66 source rule. exclude: type: bool description: Exclude packets matching this rule from NAT66. log: type: bool description: Log packets hitting this NAT66 source rule. outbound_interface: type: dict description: Outbound interface to match for NAT66 source rule. suboptions: name: type: str description: Interface name to match. protocol: type: str description: Protocol to match. source: type: dict description: Source match criteria for NAT66 source rule. suboptions: port: type: str description: Source port number or range to match. prefix: type: str description: IPv6 source prefix to match (h:h:h:h:h:h:h:h/x). translation: type: dict description: Translation configuration for NAT66 source rule. suboptions: address: type: str description: IPv6 address or prefix to translate source to. Use masquerade to masquerade as the outbound interface address. port: type: str description: Port number or range to translate source port to. running_config: description: - This option is used only with state I(parsed). - The value of this option should be the output received from the VyOS device by executing the command B(show configuration commands | match 'nat'). - The state I(parsed) reads the configuration from C(show configuration commands | match 'nat') and transforms it into Ansible structured data as per the module argspec. The value is then returned in the I(parsed) key within the result. - - The states I(replaced) and I(overridden) have identical behaviour for this module. + - The state I(replaced) replaces only the provided configuration, while I(overridden) removes any + existing NAT configuration not specified in I(config). type: str state: description: - The state the configuration should be left in. type: str choices: - deleted - merged - overridden - replaced - gathered - rendered - parsed default: merged """ EXAMPLES = """ # Using merged - configure CGNAT - name: Merge CGNAT configuration vyos.vyos.vyos_nat: config: nat: cgnat: log_allocation: true pool: external: - name: ext-pool-1 external_port_range: "10000-20000" per_user_limit: port: "200" range: - value: 203.0.113.0/24 internal: - name: int-pool-1 range: - 10.0.0.0/24 rule: - id: 1 source: pool: int-pool-1 translation: pool: ext-pool-1 state: merged # Using merged - configure destination NAT - name: Merge destination NAT rule vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Web server NAT" protocol: tcp log: true destination: address: 198.51.100.10 port: "80" translation: address: 192.168.1.10 port: "8080" state: merged # Using merged - configure source NAT - name: Merge source NAT rule vyos.vyos.vyos_nat: config: nat: source: rule: - id: 200 description: "Outbound masquerade" protocol: tcp log: true outbound_interface: name: eth0 translation: address: masquerade state: merged # Using merged - configure static NAT - name: Merge static NAT rule vyos.vyos.vyos_nat: config: nat: static: rule: - id: 300 description: "Static mapping" inbound_interface: eth2 destination: address: 198.51.100.20 translation: address: 192.168.1.20 log: true state: merged # Using merged - configure NAT64 - name: Merge NAT64 source rule vyos.vyos.vyos_nat: config: nat64: source: rule: - id: 10 description: "NAT64 example" source: prefix: 2001:db8::/96 match: mark: 100 translation: pool: - id: 1 address: 192.168.100.10 port: "1-65535" protocol: udp state: merged # Using merged - configure NAT66 - name: Merge NAT66 destination rule vyos.vyos.vyos_nat: config: nat66: destination: rule: - id: 20 description: "NAT66 DNAT" protocol: tcp inbound_interface: name: eth1 destination: address: 2001:db8::1 translation: address: 2001:db8:1::10 port: "8443" state: merged # Using replaced - replace specific NAT rules - name: Replace destination NAT rule vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Replaced web server NAT" protocol: tcp destination: address: 198.51.100.10 port: "443" translation: address: 192.168.1.10 port: "8443" state: replaced # Using overridden - override entire NAT configuration - name: Override entire NAT configuration vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Only rule after override" protocol: tcp destination: address: 198.51.100.10 port: "80" translation: address: 192.168.1.10 port: "8080" state: overridden # Using deleted - delete all NAT configuration - name: Delete all NAT configuration vyos.vyos.vyos_nat: state: deleted # Using deleted - delete specific NAT rules - name: Delete specific NAT rules vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 source: rule: - id: 200 nat64: source: rule: - id: 10 state: deleted # Using gathered - name: Gather NAT configuration from device vyos.vyos.vyos_nat: state: gathered # Using rendered - name: Render NAT configuration offline vyos.vyos.vyos_nat: config: nat: destination: rule: - id: 100 description: "Rendered rule" protocol: tcp destination: address: 198.51.100.10 port: "80" translation: address: 192.168.1.10 port: "8080" state: rendered # Using parsed - name: Parse NAT configuration from file vyos.vyos.vyos_nat: running_config: "{{ lookup('file', './nat_config.cfg') }}" state: parsed """ RETURN = """ before: description: The configuration prior to the module execution. returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) type: dict sample: > This output will always be in the same format as the module argspec. after: description: The resulting configuration after module execution. returned: when changed type: dict sample: > This output will always be in the same format as the module argspec. commands: description: The set of commands pushed to the remote device. returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) type: list sample: - set nat destination rule 100 description 'Web server NAT' - set nat destination rule 100 protocol tcp - set nat destination rule 100 inbound-interface name eth2 - set nat destination rule 100 destination address 198.51.100.10 - set nat destination rule 100 translation address 192.168.1.10 - delete nat source rule 200 rendered: description: The provided configuration in the task rendered in device-native format (offline). returned: when I(state) is C(rendered) type: list sample: - set nat destination rule 100 description 'Web server NAT' - set nat destination rule 100 protocol tcp - set nat destination rule 100 inbound-interface name eth2 - set nat destination rule 100 destination address 198.51.100.10 - set nat destination rule 100 translation address 192.168.1.10 gathered: description: Facts about the network resource gathered from the remote device as structured data. returned: when I(state) is C(gathered) type: dict sample: > This output will always be in the same format as the module argspec. parsed: description: The device native config provided in I(running_config) option parsed into structured data as per module argspec. returned: when I(state) is C(parsed) type: dict sample: > This output will always be in the same format as the module argspec. """ from ansible.module_utils.basic import AnsibleModule 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.config.nat.nat import ( Nat, ) def main(): """ Main entry point for module execution :returns: the result form module invocation """ module = AnsibleModule( argument_spec=NatArgs.argument_spec, mutually_exclusive=[["config", "running_config"]], required_if=[ ["state", "merged", ["config"]], ["state", "replaced", ["config"]], ["state", "overridden", ["config"]], ["state", "rendered", ["config"]], ["state", "parsed", ["running_config"]], ], supports_check_mode=True, ) result = Nat(module).execute_module() module.exit_json(**result) if __name__ == "__main__": main() diff --git a/tests/integration/targets/vyos_nat/tests/cli/deleted_full_match.yaml b/tests/integration/targets/vyos_nat/tests/cli/deleted_full_match.yaml new file mode 100644 index 00000000..bf7e03d6 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/cli/deleted_full_match.yaml @@ -0,0 +1,44 @@ +--- +- debug: + msg: START vyos_nat deleted full-match integration tests on connection={{ ansible_connection }} + +- include_tasks: _populate.yaml + +- block: + - name: Delete destination rule 100 with full matching config + register: result + vyos.vyos.vyos_nat: + config: + nat: + destination: + rule: + - id: 100 + description: Web server NAT + protocol: tcp + log: true + inbound_interface: + name: eth2 + destination: + address: 198.51.100.10 + port: "80" + translation: + address: 192.168.1.10 + port: "8080" + state: deleted + + - assert: + that: + - result.changed == true + - result.commands == ["delete nat destination rule 100"] + + - vyos.vyos.vyos_facts: + gather_network_resources: nat + + - name: Assert rule 100 is actually gone from the device + assert: + that: + - ansible_facts['network_resources']['nat'].destination is not defined or + ansible_facts['network_resources']['nat'].destination.rule | default([]) | selectattr('id', 'equalto', 100) | list | length == 0 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/tests/cli/merged.yaml b/tests/integration/targets/vyos_nat/tests/cli/merged.yaml index 01bcb0c2..b572c6fc 100644 --- a/tests/integration/targets/vyos_nat/tests/cli/merged.yaml +++ b/tests/integration/targets/vyos_nat/tests/cli/merged.yaml @@ -1,50 +1,92 @@ --- - debug: msg: START vyos_nat merged integration tests on connection={{ ansible_connection }} - include_tasks: _populate.yaml - block: - name: Merge new NAT rule with existing configuration register: result vyos.vyos.vyos_nat: &id001 config: nat: destination: rule: - id: 101 description: New DNAT rule protocol: tcp destination: address: 198.51.100.11 port: "443" translation: address: 192.168.1.11 port: "8443" state: merged - vyos.vyos.vyos_facts: gather_network_resources: nat - assert: that: - result.changed == true - result.commands|symmetric_difference(merged.commands) == [] - result.after|symmetric_difference(ansible_facts['network_resources']['nat']) == [] - name: Assert that before dicts were correctly generated assert: that: - - "{{ merged['before'] | symmetric_difference(result['before']) |length == 0 }}" + - merged['before'] | symmetric_difference(result['before']) | length == 0 - name: Merge the provided configuration with the existing running configuration (IDEMPOTENT) register: result vyos.vyos.vyos_nat: *id001 - name: Assert that the previous task was idempotent assert: that: - result['changed'] == false + - name: Add static rule with plain inbound_interface string + register: result + vyos.vyos.vyos_nat: + config: + nat: + static: + rule: + - id: 300 + description: Static mapping + inbound_interface: eth2 + destination: + address: 192.168.100.20 + translation: + address: 192.168.1.20 + state: merged + + - assert: + that: + - result.changed == true + - "'set nat static rule 300 inbound-interface eth2' in result.commands" + + - name: Change static rule inbound_interface + register: result + vyos.vyos.vyos_nat: + config: + nat: + static: + rule: + - id: 300 + description: Static mapping + inbound_interface: eth3 + destination: + address: 192.168.100.20 + translation: + address: 192.168.1.20 + state: merged + + - assert: + that: + - result.changed == true + - result.commands == ["set nat static rule 300 inbound-interface eth3"] + always: - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/tests/cli/replaced_cgnat_pool.yaml b/tests/integration/targets/vyos_nat/tests/cli/replaced_cgnat_pool.yaml new file mode 100644 index 00000000..77fc295b --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/cli/replaced_cgnat_pool.yaml @@ -0,0 +1,77 @@ +--- +- debug: + msg: START vyos_nat replaced cgnat pool integration tests on connection={{ ansible_connection }} + +- include_tasks: _populate.yaml + +- block: + - name: Establish baseline CGNAT external pool + vyos.vyos.vyos_nat: + config: + nat: + cgnat: + log_allocation: true + pool: + external: + - name: ext-pool-1 + external_port_range: "10000-20000" + per_user_limit: + port: "200" + range: + - value: 203.0.113.0/24 + - value: 203.1.113.1-203.1.113.60 + seq: "10" + internal: + - name: int-pool-1 + range: + - 10.0.0.0/24 + rule: + - id: 1 + source: + pool: int-pool-1 + translation: + pool: ext-pool-1 + state: merged + + - name: Replace CGNAT pool changing only external_port_range + register: result + vyos.vyos.vyos_nat: + config: + nat: + cgnat: + log_allocation: true + pool: + external: + - name: ext-pool-1 + external_port_range: "30000-40000" + per_user_limit: + port: "200" + range: + - value: 203.0.113.0/24 + - value: 203.1.113.1-203.1.113.60 + seq: "10" + internal: + - name: int-pool-1 + range: + - 10.0.0.0/24 + rule: + - id: 1 + source: + pool: int-pool-1 + translation: + pool: ext-pool-1 + state: replaced + + - vyos.vyos.vyos_facts: + gather_network_resources: nat + + - name: Assert all pool fields survived the replace, not just the changed one + assert: + that: + - result.changed == true + - ansible_facts['network_resources']['nat'].nat.cgnat.pool.external[0].external_port_range == "30000-40000" + - ansible_facts['network_resources']['nat'].nat.cgnat.pool.external[0].per_user_limit.port == "200" + - ansible_facts['network_resources']['nat'].nat.cgnat.pool.external[0].range | length == 2 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/unit/modules/network/vyos/test_vyos_nat.py b/tests/unit/modules/network/vyos/test_vyos_nat.py index cb98fa2b..62ed3882 100644 --- a/tests/unit/modules/network/vyos/test_vyos_nat.py +++ b/tests/unit/modules/network/vyos/test_vyos_nat.py @@ -1,607 +1,689 @@ # (c) 2024 Red Hat Inc. # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . from __future__ import absolute_import, division, print_function __metaclass__ = type from unittest.mock import patch from ansible_collections.vyos.vyos.plugins.modules import vyos_nat from ansible_collections.vyos.vyos.tests.unit.modules.utils import set_module_args from .vyos_module import TestVyosModule, load_fixture class TestVyosNatModule(TestVyosModule): module = vyos_nat def setUp(self): super(TestVyosNatModule, self).setUp() self.mock_get_resource_connection_config = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module_base.get_resource_connection", ) self.get_resource_connection_config = self.mock_get_resource_connection_config.start() self.mock_get_resource_connection_facts = patch( "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.facts.facts.get_resource_connection", ) self.get_resource_connection_facts = self.mock_get_resource_connection_facts.start() self.mock_execute_show_command = patch( "ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.nat.nat.NatFacts.get_config", ) self.execute_show_command = self.mock_execute_show_command.start() self.maxDiff = None def tearDown(self): super(TestVyosNatModule, self).tearDown() self.mock_get_resource_connection_config.stop() self.mock_get_resource_connection_facts.stop() self.mock_execute_show_command.stop() def load_fixtures(self, commands=None, filename=None): if filename is None: filename = "vyos_nat_config.cfg" def load_from_file(*args, **kwargs): return load_fixture(filename) self.execute_show_command.side_effect = load_from_file # ------------------------------------------------------------------------- # merged # ------------------------------------------------------------------------- def test_vyos_nat_merged_idempotent(self): set_module_args( dict( config=dict( nat=dict( cgnat=dict( log_allocation=True, pool=dict( external=[ dict( name="ext-pool-1", external_port_range="10000-20000", per_user_limit=dict(port="200"), range=[ dict(value="203.0.113.0/24"), dict(value="203.1.113.1-203.1.113.60", seq="10"), ], ), ], internal=[ dict( name="int-pool-1", range=["10.0.0.0/24", "10.1.0.0/24"], ), ], ), rule=[ dict( id=1, source=dict(pool="int-pool-1"), translation=dict(pool="ext-pool-1"), ), ], ), destination=dict( rule=[ dict( id=100, description="Web server NAT", protocol="tcp", log=True, inbound_interface=dict(name="eth2"), destination=dict(address="198.51.100.10", port="80"), translation=dict( address="192.168.1.10", port="8080", address_mapping="persistent", port_mapping="random", ), ), ], ), source=dict( rule=[ dict( id=200, description="Outbound NAT", protocol="tcp", log=True, exclude=True, disable=True, destination=dict(address="192.168.10.100", port="8083"), translation=dict(address="masquerade", port="443"), ), ], ), static=dict( rule=[ dict( id=300, description="Static mapping", inbound_interface="eth2", destination=dict(address="192.168.100.20"), translation=dict(address="192.168.1.20"), log=True, ), ], ), ), nat64=dict( source=dict( rule=[ dict( id=10, description="NAT64 example", disable=True, match=dict(mark=100), source=dict(prefix="2001:db8::/96"), translation=dict( pool=[ dict( id=1, address="192.168.100.10", description="NAT64 translation pool", disable=True, port="1-65535", protocol="udp", ), ], ), ), ], ), ), nat66=dict( destination=dict( rule=[ dict( id=20, description="NAT66 DNAT", protocol="tcp", inbound_interface=dict(name="eth1"), destination=dict(address="2001:db8::1"), translation=dict(address="2001:db8:1::10", port="8443"), ), ], ), source=dict( rule=[ dict( id=30, description="NAT66 SNAT", protocol="tcp", destination=dict(prefix="2001:db8::/96"), outbound_interface=dict(name="eth2"), source=dict(prefix="2001:db8:2::/64"), translation=dict(address="masquerade"), ), ], ), ), ), state="merged", ), ) self.execute_module(changed=False, commands=[]) def test_vyos_nat_merged_new_rule(self): set_module_args( dict( config=dict( nat=dict( destination=dict( rule=[ dict( id=101, description="New DNAT rule", protocol="tcp", destination=dict(address="198.51.100.11", port="443"), translation=dict(address="192.168.1.11", port="8443"), ), ], ), ), ), state="merged", ), ) commands = [ "set nat destination rule 101 description 'New DNAT rule'", "set nat destination rule 101 protocol tcp", "set nat destination rule 101 destination address 198.51.100.11", "set nat destination rule 101 destination port 443", "set nat destination rule 101 translation address 192.168.1.11", "set nat destination rule 101 translation port 8443", ] self.execute_module(changed=True, commands=commands) def test_vyos_nat_merged_update_existing_rule(self): set_module_args( dict( config=dict( nat=dict( destination=dict( rule=[ dict( id=100, description="Updated DNAT rule", protocol="tcp", inbound_interface=dict(name="eth2"), destination=dict(address="198.51.100.10", port="80"), translation=dict( address="192.168.1.10", port="8080", address_mapping="persistent", port_mapping="random", ), ), ], ), ), ), state="merged", ), ) commands = [ "set nat destination rule 100 description 'Updated DNAT rule'", ] self.execute_module(changed=True, commands=commands) def test_vyos_nat_merged_cgnat_new_pool(self): set_module_args( dict( config=dict( nat=dict( cgnat=dict( pool=dict( external=[ dict( name="ext-pool-2", external_port_range="30000-40000", range=[dict(value="203.0.114.0/24")], ), ], ), ), ), ), state="merged", ), ) commands = [ "set nat cgnat pool external ext-pool-2 external-port-range 30000-40000", "set nat cgnat pool external ext-pool-2 range 203.0.114.0/24", ] self.execute_module(changed=True, commands=commands) def test_vyos_nat_merged_nat66_new_rule(self): set_module_args( dict( config=dict( nat66=dict( source=dict( rule=[ dict( id=31, description="New NAT66 SNAT", protocol="udp", outbound_interface=dict(name="eth3"), source=dict(prefix="2001:db8:3::/64"), translation=dict(address="masquerade"), ), ], ), ), ), state="merged", ), ) commands = [ "set nat66 source rule 31 description 'New NAT66 SNAT'", "set nat66 source rule 31 protocol udp", "set nat66 source rule 31 outbound-interface name eth3", "set nat66 source rule 31 source prefix 2001:db8:3::/64", "set nat66 source rule 31 translation address masquerade", ] self.execute_module(changed=True, commands=commands) + def test_vyos_nat_merged_static_inbound_interface_change(self): + set_module_args( + dict( + config=dict( + nat=dict( + static=dict( + rule=[ + dict( + id=300, + description="Static mapping", + inbound_interface="eth3", + destination=dict(address="192.168.100.20"), + translation=dict(address="192.168.1.20"), + log=True, + ), + ], + ), + ), + ), + state="merged", + ), + ) + commands = ["set nat static rule 300 inbound-interface eth3"] + self.execute_module(changed=True, commands=commands) + # ------------------------------------------------------------------------- # deleted # ------------------------------------------------------------------------- def test_vyos_nat_deleted_all(self): set_module_args(dict(state="deleted")) commands = [ "delete nat", "delete nat64", "delete nat66", ] self.execute_module(changed=True, commands=commands) def test_vyos_nat_deleted_specific_rules(self): set_module_args( dict( config=dict( nat=dict( destination=dict(rule=[dict(id=100)]), source=dict(rule=[dict(id=200)]), ), ), state="deleted", ), ) commands = [ "delete nat destination rule 100", "delete nat source rule 200", ] self.execute_module(changed=True, commands=commands) def test_vyos_nat_deleted_cgnat_pool(self): set_module_args( dict( config=dict( nat=dict( cgnat=dict( pool=dict( external=[dict(name="ext-pool-1")], internal=[dict(name="int-pool-1")], ), ), ), ), state="deleted", ), ) commands = [ "delete nat cgnat pool external ext-pool-1", "delete nat cgnat pool internal int-pool-1", ] self.execute_module(changed=True, commands=commands) def test_vyos_nat_deleted_nat64_rule(self): set_module_args( dict( config=dict( nat64=dict( source=dict(rule=[dict(id=10)]), ), ), state="deleted", ), ) commands = ["delete nat64 source rule 10"] self.execute_module(changed=True, commands=commands) def test_vyos_nat_deleted_nonexistent_rule(self): set_module_args( dict( config=dict( nat=dict( destination=dict(rule=[dict(id=999)]), ), ), state="deleted", ), ) self.execute_module(changed=False, commands=[]) + def test_vyos_nat_deleted_cgnat_rule_full_match(self): + set_module_args( + dict( + config=dict( + nat=dict( + cgnat=dict( + rule=[ + dict( + id=1, + source=dict(pool="int-pool-1"), + translation=dict(pool="ext-pool-1"), + ), + ], + ), + ), + ), + state="deleted", + ), + ) + commands = ["delete nat cgnat rule 1"] + self.execute_module(changed=True, commands=commands) + # ------------------------------------------------------------------------- # replaced # ------------------------------------------------------------------------- def test_vyos_nat_replaced_idempotent(self): set_module_args( dict( config=dict( nat=dict( destination=dict( rule=[ dict( id=100, description="Web server NAT", protocol="tcp", log=True, inbound_interface=dict(name="eth2"), destination=dict(address="198.51.100.10", port="80"), translation=dict( address="192.168.1.10", port="8080", address_mapping="persistent", port_mapping="random", ), ), ], ), ), ), state="replaced", ), ) self.execute_module(changed=False, commands=[]) def test_vyos_nat_replaced_rule(self): set_module_args( dict( config=dict( nat=dict( destination=dict( rule=[ dict( id=100, description="Replaced DNAT rule", protocol="udp", destination=dict(address="198.51.100.10", port="53"), translation=dict(address="192.168.1.53", port="53"), ), ], ), ), ), state="replaced", ), ) commands = [ "delete nat destination rule 100", "set nat destination rule 100 description 'Replaced DNAT rule'", "set nat destination rule 100 protocol udp", "set nat destination rule 100 destination address 198.51.100.10", "set nat destination rule 100 destination port 53", "set nat destination rule 100 translation address 192.168.1.53", "set nat destination rule 100 translation port 53", ] self.execute_module(changed=True, commands=commands) + def test_vyos_nat_replaced_cgnat_pool_partial_field_change(self): + set_module_args( + dict( + config=dict( + nat=dict( + cgnat=dict( + pool=dict( + external=[ + dict( + name="ext-pool-1", + external_port_range="30000-40000", + per_user_limit=dict(port="200"), + range=[ + dict(value="203.0.113.0/24"), + dict(value="203.1.113.1-203.1.113.60", seq="10"), + ], + ), + ], + ), + ), + ), + ), + state="replaced", + ), + ) + commands = [ + "delete nat cgnat pool external ext-pool-1", + "delete nat cgnat log-allocation", + "set nat cgnat pool external ext-pool-1 external-port-range 30000-40000", + "set nat cgnat pool external ext-pool-1 per-user-limit port 200", + "set nat cgnat pool external ext-pool-1 range 203.0.113.0/24", + "set nat cgnat pool external ext-pool-1 range 203.1.113.1-203.1.113.60 seq 10", + ] + self.execute_module(changed=True, commands=commands) + # ------------------------------------------------------------------------- # overridden # ------------------------------------------------------------------------- def test_vyos_nat_overridden_remove_sections(self): set_module_args( dict( config=dict( nat=dict( destination=dict( rule=[ dict( id=100, description="Overridden web server NAT", # changed protocol="tcp", inbound_interface=dict(name="eth3"), # changed destination=dict(address="198.51.100.10", port="80"), translation=dict( address="192.168.1.10", port="8080", ), ), ], ), ), ), state="overridden", ), ) commands = [ "delete nat cgnat", "delete nat source", "delete nat static", "delete nat64", "delete nat66", "delete nat destination rule 100", "set nat destination rule 100 description 'Overridden web server NAT'", "set nat destination rule 100 protocol tcp", "set nat destination rule 100 inbound-interface name eth3", "set nat destination rule 100 destination address 198.51.100.10", "set nat destination rule 100 destination port 80", "set nat destination rule 100 translation address 192.168.1.10", "set nat destination rule 100 translation port 8080", ] self.execute_module(changed=True, commands=commands) # ------------------------------------------------------------------------- # rendered # ------------------------------------------------------------------------- def test_vyos_nat_rendered(self): set_module_args( dict( config=dict( nat=dict( destination=dict( rule=[ dict( id=100, description="Rendered rule", protocol="tcp", destination=dict(address="198.51.100.10", port="80"), translation=dict(address="192.168.1.10", port="8080"), ), ], ), ), ), state="rendered", ), ) rendered_cmds = [ "set nat destination rule 100 description 'Rendered rule'", "set nat destination rule 100 protocol tcp", "set nat destination rule 100 destination address 198.51.100.10", "set nat destination rule 100 destination port 80", "set nat destination rule 100 translation address 192.168.1.10", "set nat destination rule 100 translation port 8080", ] result = self.execute_module(changed=False) self.assertEqual(sorted(result["rendered"]), sorted(rendered_cmds), result["rendered"]) # ------------------------------------------------------------------------- # parsed # ------------------------------------------------------------------------- def test_vyos_nat_parsed(self): parsed_str = ( "set nat destination rule 100 description 'Web server NAT'\n" "set nat destination rule 100 destination address '198.51.100.10'\n" "set nat destination rule 100 destination port '80'\n" "set nat destination rule 100 inbound-interface name 'eth2'\n" "set nat destination rule 100 log\n" "set nat destination rule 100 protocol 'tcp'\n" "set nat destination rule 100 translation address '192.168.1.10'\n" "set nat destination rule 100 translation port '8080'" ) set_module_args(dict(running_config=parsed_str, state="parsed")) result = self.execute_module(changed=False) parsed_list = { "nat": { "destination": { "rule": [ { "id": 100, "description": "Web server NAT", "protocol": "tcp", "log": True, "inbound_interface": {"name": "eth2"}, "destination": {"address": "198.51.100.10", "port": "80"}, "translation": {"address": "192.168.1.10", "port": "8080"}, }, ], }, }, } self.assertEqual(parsed_list, result["parsed"]) # ------------------------------------------------------------------------- # gathered # ------------------------------------------------------------------------- def test_vyos_nat_gathered(self): set_module_args(dict(state="gathered")) result = self.execute_module(changed=False) gathered = result["gathered"] self.assertIn("nat", gathered) self.assertIn("nat64", gathered) self.assertIn("nat66", gathered) self.assertEqual(gathered["nat"]["destination"]["rule"][0]["id"], 100) self.assertEqual(gathered["nat64"]["source"]["rule"][0]["id"], 10) self.assertEqual(gathered["nat66"]["destination"]["rule"][0]["id"], 20)