1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
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()
|