diff options
Diffstat (limited to 'plugins/modules/vyos_nat.py')
| -rw-r--r-- | plugins/modules/vyos_nat.py | 492 |
1 files changed, 492 insertions, 0 deletions
diff --git a/plugins/modules/vyos_nat.py b/plugins/modules/vyos_nat.py new file mode 100644 index 0000000..ae8fea0 --- /dev/null +++ b/plugins/modules/vyos_nat.py @@ -0,0 +1,492 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_nat +short_description: Manage NAT configuration on VyOS devices using REST API +description: + - Manages NAT configuration on VyOS devices via the REST API. + - Supports source, destination, static, CGNAT, NAT64, and NAT66. + - Uses REST API (C(connection=httpapi)) instead of CLI. + - Targets VyOS 1.5+. +version_added: "1.0.0" +author: + - Evgeny Molotkov (@omnom62) +options: + config: + description: NAT configuration. + type: dict + state: + description: + - The desired state of the NAT configuration. + type: str + default: merged + choices: [merged, replaced, overridden, deleted, gathered] +""" + +EXAMPLES = r""" +- name: Merge source NAT rule + vyos.rest.vyos_nat: + config: + nat: + source: + rule: + - id: 100 + outbound_interface: + name: eth0 + translation: + address: masquerade + state: merged + +- name: Delete all NAT + vyos.rest.vyos_nat: + state: deleted + +- name: Gather NAT configuration + vyos.rest.vyos_nat: + state: gathered +""" + +RETURN = r""" +before: + description: NAT configuration before this module ran. + returned: always + type: dict +after: + description: NAT configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API commands sent to the device. + returned: always + type: list +gathered: + description: Current NAT configuration as structured data. + returned: when state is gathered + type: dict +saved: + description: Whether the config was saved after changes. + returned: when changed + type: bool +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( + VyOSModule, + autoclean, + dict_op, + from_device, + normalize_have, + to_tag_dict, +) + + +_NAT_TYPES = ("nat", "nat64", "nat66") + +# "rule" (any NAT rule set) and "backend" (load-balance) are genuine tag +# nodes everywhere they appear -- unambiguous. "range" is NOT included +# here: confirmed against vyos-1x that it means two different things +# depending on parent -- cgnat.pool.external.<name>.range is a tagNode +# (has a nested "seq" leaf), but cgnat.pool.internal.<name>.range is a +# plain multi-value leafNode (<multi/>). Handling that context-sensitive +# case generically by key name alone would silently corrupt one or the +# other, so it's handled explicitly in _normalize_cgnat_have() instead. +_TAG_KEYS = {"rule", "backend"} + + +# --------------------------------------------------------------------------- +# load_balance.backend / nat64 translation.pool — the only two genuine +# structural exceptions in this module (confirmed tagNodes with nested +# substructure). Every other field (destination/source/translation/ +# match, inbound/outbound-interface, exclude, disable, description, +# protocol, packet_type, load_balance.hash) is a direct structural match +# and flows through autoclean/from_device untouched. hash in particular +# stays a plain list -- it's a multi-value leafNode, not a tag node, so +# dict_op's own native list handling applies to it directly. +# --------------------------------------------------------------------------- + + +def _backend_to_device(backends): + return {b["ip"]: autoclean({k: v for k, v in b.items() if k != "ip"}) for b in backends or []} + + +def _backend_from_device(raw): + result = [] + for ip, data in sorted((raw or {}).items()): + entry = {"ip": ip, **from_device(data or {})} + if "weight" in entry: + entry["weight"] = int(entry["weight"]) + result.append(entry) + return result + + +def _pool_to_device(pools): + return {str(p["id"]): autoclean({k: v for k, v in p.items() if k != "id"}) for p in pools or []} + + +def _pool_from_device(raw): + return [ + {"id": int(pid), **from_device(data or {})} + for pid, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0])) + ] + + +def _rule_to_device(rule): + entry = autoclean( + {k: v for k, v in rule.items() if k not in ("id", "load_balance", "translation")}, + ) + + lb = rule.get("load_balance") + if lb: + lb_entry = autoclean({k: v for k, v in lb.items() if k != "backend"}) + if lb.get("backend"): + lb_entry["backend"] = _backend_to_device(lb["backend"]) + entry["load_balance"] = lb_entry + + translation = rule.get("translation") + if translation: + t_entry = autoclean({k: v for k, v in translation.items() if k != "pool"}) + if translation.get("pool"): + t_entry["pool"] = _pool_to_device(translation["pool"]) + entry["translation"] = t_entry + + return entry + + +def _rule_from_device(raw): + raw = raw or {} + entry = from_device({k: v for k, v in raw.items() if k not in ("load-balance", "translation")}) + + lb_raw = raw.get("load-balance") + if lb_raw: + lb_entry = from_device({k: v for k, v in lb_raw.items() if k != "backend"}) + # "hash" is a multi-value leafNode; the device can collapse a + # single value to a bare string. from_device() only does + # kebab->snake translation, not type coercion, so fix that up + # explicitly here (there's no ARGUMENT_SPEC for cast_by_spec to + # derive this from -- config is a bare type=dict in this module). + if isinstance(lb_entry.get("hash"), str): + lb_entry["hash"] = [lb_entry["hash"]] + if lb_raw.get("backend"): + lb_entry["backend"] = _backend_from_device(lb_raw["backend"]) + entry["load_balance"] = lb_entry + + t_raw = raw.get("translation") + if t_raw: + t_entry = from_device({k: v for k, v in t_raw.items() if k != "pool"}) + if t_raw.get("pool"): + t_entry["pool"] = _pool_from_device(t_raw["pool"]) + entry["translation"] = t_entry + + return entry + + +def _rules_to_device(rules): + return {str(r["id"]): _rule_to_device(r) for r in rules or []} + + +def _rules_from_device(raw): + return [ + {"id": int(rid), **_rule_from_device(data or {})} + for rid, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0])) + ] + + +# --------------------------------------------------------------------------- +# CGNAT — cgnat.pool.external.<name>.range is a genuine tag node +# (confirmed: nested "seq" leaf); cgnat.pool.internal.<name>.range is a +# plain multi-value leafNode (confirmed <multi/>). Fixing the real bug +# here: the previous implementation only checked isinstance(str)/ +# isinstance(dict) for internal range and silently dropped it whenever +# the device returned the actual real shape -- a plain list. +# --------------------------------------------------------------------------- + + +def _cgnat_pool_external_to_device(pools): + result = {} + for p in pools or []: + entry = autoclean({k: v for k, v in p.items() if k not in ("name", "range")}) + if p.get("range"): + entry["range"] = { + r["value"]: ({"seq": r["seq"]} if r.get("seq") is not None else {}) + for r in p["range"] + } + result[p["name"]] = entry + return result + + +def _cgnat_pool_external_from_device(raw): + result = [] + for name, data in sorted((raw or {}).items()): + data = data or {} + p = {"name": name, **from_device({k: v for k, v in data.items() if k != "range"})} + rng = data.get("range") + if rng: + rng_dict = to_tag_dict(rng) + p["range"] = [ + ( + {"value": v, "seq": int(d["seq"])} + if isinstance(d, dict) and d.get("seq") + else {"value": v} + ) + for v, d in sorted(rng_dict.items()) + ] + result.append(p) + return result + + +def _cgnat_pool_internal_to_device(pools): + return {p["name"]: {"range": list(p["range"])} for p in pools or [] if p.get("range")} + + +def _cgnat_pool_internal_from_device(raw): + result = [] + for name, data in sorted((raw or {}).items()): + rng = (data or {}).get("range") + p = {"name": name} + if rng: + # Confirmed real bug in the previous implementation: it only + # checked isinstance(str)/isinstance(dict) here, silently + # dropping "range" entirely whenever the device returned the + # actual real shape for >1 value -- a plain list. + p["range"] = [rng] if isinstance(rng, str) else list(rng) + result.append(p) + return result + + +# The two known CGNAT pool kinds and their handlers, declared once. Both +# are unavoidable exceptions -- "external" pool range is a tag node +# (confirmed: nested "seq" leaf), "internal" pool range is a plain +# multi-value leaf (confirmed <multi/>), same key name, genuinely +# different device shape, not discoverable by walking the JSON alone. +# What's NOT necessary is repeating "if pool.get(kind)" per kind inline +# -- one table declares the exception, both directions read it. +_CGNAT_POOL_KINDS = { + "external": (_cgnat_pool_external_to_device, _cgnat_pool_external_from_device), + "internal": (_cgnat_pool_internal_to_device, _cgnat_pool_internal_from_device), +} + + +def _cgnat_pool_to_device(pool): + return { + kind: to_fn(pool[kind]) + for kind, (to_fn, _from_fn) in _CGNAT_POOL_KINDS.items() + if pool.get(kind) + } + + +def _cgnat_pool_from_device(pool_raw): + return { + kind: from_fn(pool_raw[kind]) + for kind, (_to_fn, from_fn) in _CGNAT_POOL_KINDS.items() + if pool_raw.get(kind) + } + + +def _normalize_cgnat_have(cgnat_raw): + """Like normalize_have(), but external/internal pool "range" needs + different treatment despite sharing a key name -- see _TAG_KEYS. + """ + if not cgnat_raw or not isinstance(cgnat_raw, dict): + return {} + result = normalize_have(cgnat_raw, _TAG_KEYS) + ext_raw = (cgnat_raw.get("pool") or {}).get("external") + if ext_raw: + ext_norm = {} + for name, data in ext_raw.items(): + data = dict(data or {}) + if "range" in data: + data["range"] = to_tag_dict(data["range"]) + ext_norm[name] = data + result.setdefault("pool", {})["external"] = ext_norm + return result + + +def _cgnat_to_device(cgnat): + if not cgnat: + return {} + entry = autoclean({k: v for k, v in cgnat.items() if k not in ("pool", "rule")}) + pool = cgnat.get("pool") or {} + pool_entry = _cgnat_pool_to_device(pool) + if pool_entry: + entry["pool"] = pool_entry + if cgnat.get("rule"): + entry["rule"] = _rules_to_device(cgnat["rule"]) + return entry + + +def _cgnat_from_device(raw): + raw = raw or {} + entry = from_device({k: v for k, v in raw.items() if k not in ("pool", "rule")}) + pool_entry = _cgnat_pool_from_device(raw.get("pool") or {}) + if pool_entry: + entry["pool"] = pool_entry + if raw.get("rule"): + entry["rule"] = _rules_from_device(raw["rule"]) + return entry + + +# --------------------------------------------------------------------------- +# want -> device / device -> argspec (top level) +# --------------------------------------------------------------------------- + + +# Which sections are valid under each NAT type, and whether it has a +# cgnat subtree (only "nat" does) -- declared once so _want_to_device and +# _device_to_argspec each need a single loop instead of three near- +# identical hand-written blocks per NAT type. +_NAT_TYPE_SECTIONS = { + "nat": ("destination", "source", "static"), + "nat64": ("source",), + "nat66": ("destination", "source"), +} + + +def _want_to_device(config): + if not config: + return {} + result = {} + for nat_type, sections in _NAT_TYPE_SECTIONS.items(): + nat = config.get(nat_type) or {} + if not nat: + continue + nat_dev = {} + if nat_type == "nat" and nat.get("cgnat"): + nat_dev["cgnat"] = _cgnat_to_device(nat["cgnat"]) + for section in sections: + rules = (nat.get(section) or {}).get("rule") + if rules: + nat_dev[section] = {"rule": _rules_to_device(rules)} + if nat_dev: + result[nat_type] = nat_dev + return result + + +def _device_to_argspec(raw_all): + if not raw_all: + return {} + result = {} + for nat_type, sections in _NAT_TYPE_SECTIONS.items(): + nat = raw_all.get(nat_type) or {} + if not nat: + continue + nat_arg = {} + if nat_type == "nat" and nat.get("cgnat"): + nat_arg["cgnat"] = _cgnat_from_device(nat["cgnat"]) + for section in sections: + rules = (nat.get(section) or {}).get("rule") + if rules: + nat_arg[section] = {"rule": _rules_from_device(rules)} + if nat_arg: + result[nat_type] = nat_arg + return result + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def _get_raw(vyos): + """Retrieve all NAT config from device.""" + result = {} + for nat_type in _NAT_TYPES: + raw = vyos.get_config([nat_type]) + if raw: + result[nat_type] = raw + return result + + +def _normalize_nat_have(raw_have, nat_type): + """normalize_have() for a given NAT type's have data, with "nat"'s + cgnat section handled by the range-context-aware + _normalize_cgnat_have() instead of the generic pass (which would + mishandle internal-pool range -- see _TAG_KEYS). + """ + nat_raw = raw_have.get(nat_type, {}) + result = normalize_have(nat_raw, _TAG_KEYS) + if nat_type == "nat" and nat_raw.get("cgnat"): + result["cgnat"] = _normalize_cgnat_have(nat_raw["cgnat"]) + return result + + +def main(): + argument_spec = dict( + config=dict(type="dict"), + state=dict( + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or {} + + raw_have = _get_raw(vyos) + have = _device_to_argspec(raw_have) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + want_device = _want_to_device(config) + + if state == "deleted": + commands = [] + if not config: + for nat_type in _NAT_TYPES: + if raw_have.get(nat_type): + commands.append(("delete", [nat_type])) + else: + for nat_type in _NAT_TYPES: + if config.get(nat_type) and raw_have.get(nat_type): + commands.append(("delete", [nat_type])) + elif state == "overridden": + commands = [] + for nat_type in _NAT_TYPES: + nat_want = want_device.get(nat_type, {}) + nat_have_norm = _normalize_nat_have(raw_have, nat_type) + base = [nat_type] + commands += dict_op(nat_want, nat_have_norm, base, op="purge") + commands += dict_op(nat_want, nat_have_norm, base, op="set") + else: + commands = [] + for nat_type in _NAT_TYPES: + nat_want = want_device.get(nat_type, {}) + nat_have_norm = _normalize_nat_have(raw_have, nat_type) + base = [nat_type] + if state == "replaced": + for section, section_want in nat_want.items(): + section_have = nat_have_norm.get(section, {}) + commands += dict_op(section_want, section_have, base + [section], op="purge") + commands += dict_op(nat_want, nat_have_norm, base, op="set") + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + after = _device_to_argspec(_get_raw(vyos)) + module.exit_json( + changed=True, + before=have, + after=after, + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() |
