summaryrefslogtreecommitdiff
path: root/plugins/modules/vyos_firewall_rules.py
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/modules/vyos_firewall_rules.py')
-rw-r--r--plugins/modules/vyos_firewall_rules.py367
1 files changed, 160 insertions, 207 deletions
diff --git a/plugins/modules/vyos_firewall_rules.py b/plugins/modules/vyos_firewall_rules.py
index a1c7c01..daf3915 100644
--- a/plugins/modules/vyos_firewall_rules.py
+++ b/plugins/modules/vyos_firewall_rules.py
@@ -127,6 +127,10 @@ notes:
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
- Rule sets are identified by AFI and name. Deleting a rule set removes
all its rules.
+ - The C(group) suboption can only reference an address-group. VyOS also
+ supports network-group/port-group/domain-group references, which this
+ module can read back (via C(gathered)) if already configured by other
+ means, but cannot create -- the argspec has no group-type discriminator.
"""
EXAMPLES = r"""
@@ -185,248 +189,196 @@ gathered:
type: list
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ dict_op,
+ from_device,
+ normalize_have,
+)
_BASE = ["firewall"]
-_AFIS = ["ipv4", "ipv6"]
-
-
-def _parse_rule(rule_num, data):
- rule = {"number": int(rule_num)}
- data = data or {}
- if "action" in data:
- rule["action"] = data["action"]
- if "description" in data:
- rule["description"] = data["description"]
- if "disable" in data:
- rule["disable"] = True
- if "protocol" in data:
- rule["protocol"] = data["protocol"]
- if "state" in data:
- rule["state"] = data["state"]
- if "log" in data:
- rule["log"] = True
-
- src = data.get("source", {}) or {}
- if src:
- rule["source"] = {}
- if "address" in src:
- rule["source"]["address"] = src["address"]
- if "group" in src:
- grp = src["group"]
- if isinstance(grp, dict):
- rule["source"]["group"] = list(grp.values())[0] if grp else None
- else:
- rule["source"]["group"] = grp
- if "port" in src:
- rule["source"]["port"] = src["port"]
-
- dst = data.get("destination", {}) or {}
- if dst:
- rule["destination"] = {}
- if "address" in dst:
- rule["destination"]["address"] = dst["address"]
- if "group" in dst:
- grp = dst["group"]
- if isinstance(grp, dict):
- rule["destination"]["group"] = list(grp.values())[0] if grp else None
- else:
- rule["destination"]["group"] = grp
- if "port" in dst:
- rule["destination"]["port"] = dst["port"]
-
- icmp = data.get("icmp", {}) or {}
- if icmp:
- rule["icmp"] = {}
- if "type" in icmp:
- rule["icmp"]["type"] = int(icmp["type"])
- if "code" in icmp:
- rule["icmp"]["code"] = int(icmp["code"])
-
- return rule
-
-
-def _parse_rule_set(rs_name, data):
- rs = {"name": rs_name}
- data = data or {}
- if "default-action" in data:
- rs["default_action"] = data["default-action"]
- if "description" in data:
- rs["description"] = data["description"]
- rules_raw = data.get("rule", {}) or {}
- if rules_raw and isinstance(rules_raw, dict):
- rules = [
- _parse_rule(num, rdata)
- for num, rdata in sorted(
- rules_raw.items(),
- key=lambda x: int(x[0]),
- )
- ]
- if rules:
- rs["rules"] = rules
- return rs
+_AFIS = ("ipv4", "ipv6")
+
+# Tag nodes VyOS's REST API can collapse to a bare string/list for a
+# single entry with no other config -- "name" (rule sets, keyed by name)
+# and "rule" (rules, keyed by number).
+_TAG_KEYS = {"name", "rule"}
+
+
+# ---------------------------------------------------------------------------
+# want -> device / device -> argspec
+#
+# Every leaf here matches the device shape directly (action, description,
+# disable, protocol, state, log, icmp.type/code) except one: "group".
+# VyOS wraps a group reference under a literal group-kind key
+# (address-group/network-group/...), not a flat value -- see the module
+# note above on why this module can only ever *write* address-group.
+# Rule-set/rule tag-node reshaping (keyed by name/number) is the other
+# unavoidable structural work.
+# ---------------------------------------------------------------------------
+
+
+def _endpoint_to_device(ep):
+ entry = autoclean({k: v for k, v in ep.items() if k != "group"})
+ if ep.get("group"):
+ entry["group"] = {"address-group": ep["group"]}
+ return entry
+
+
+def _endpoint_from_device(data):
+ data = dict(data or {})
+ group = data.pop("group", None)
+ entry = from_device(data)
+ if isinstance(group, dict) and group:
+ entry["group"] = list(group.values())[0]
+ elif isinstance(group, str):
+ entry["group"] = group
+ return entry
+
+
+def _rules_to_device(rules):
+ result = {}
+ for r in rules or []:
+ entry = autoclean(
+ {k: v for k, v in r.items() if k not in ("number", "source", "destination")},
+ )
+ for endpoint in ("source", "destination"):
+ if r.get(endpoint):
+ entry[endpoint] = _endpoint_to_device(r[endpoint])
+ result[str(r["number"])] = entry
+ return result
-def get_running_config(vyos):
+def _rules_from_device(raw):
result = []
- for afi in _AFIS:
- raw = vyos.get_config(_BASE + [afi, "name"])
- if not raw or not isinstance(raw, dict):
- continue
- # unwrap "name" key if present
- raw = raw.get("name", raw)
- if not raw or not isinstance(raw, dict):
- continue
- rule_sets = [_parse_rule_set(name, data) for name, data in sorted(raw.items())]
- if rule_sets:
- result.append({"afi": afi, "rule_sets": rule_sets})
+ for num, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0])):
+ data = dict(data or {})
+ src = data.pop("source", None)
+ dst = data.pop("destination", None)
+ entry = {"number": int(num), **from_device(data)}
+ if src:
+ entry["source"] = _endpoint_from_device(src)
+ if dst:
+ entry["destination"] = _endpoint_from_device(dst)
+ result.append(entry)
return result
-def _rule_cmds(rs_name, afi, rule, have_rule):
- cmds = []
- rbase = _BASE + [afi, "name", rs_name, "rule", str(rule["number"])]
- have_rule = have_rule or {}
-
- if rule.get("action") and rule["action"] != have_rule.get("action"):
- cmds.append(("set", rbase + ["action", rule["action"]]))
- if rule.get("description") and rule["description"] != have_rule.get("description"):
- cmds.append(("set", rbase + ["description", rule["description"]]))
- if rule.get("disable") and not have_rule.get("disable"):
- cmds.append(("set", rbase + ["disable"]))
- if rule.get("protocol") and rule["protocol"] != have_rule.get("protocol"):
- cmds.append(("set", rbase + ["protocol", rule["protocol"]]))
- if rule.get("state") and rule["state"] != have_rule.get("state"):
- cmds.append(("set", rbase + ["state", rule["state"]]))
- if rule.get("log") and not have_rule.get("log"):
- cmds.append(("set", rbase + ["log"]))
-
- for endpoint in ["source", "destination"]:
- want_ep = rule.get(endpoint) or {}
- have_ep = have_rule.get(endpoint) or {}
- if want_ep.get("address") and want_ep["address"] != have_ep.get("address"):
- cmds.append(("set", rbase + [endpoint, "address", want_ep["address"]]))
- if want_ep.get("port") and want_ep["port"] != have_ep.get("port"):
- cmds.append(("set", rbase + [endpoint, "port", str(want_ep["port"])]))
- if want_ep.get("group") and want_ep["group"] != have_ep.get("group"):
- cmds.append(
- (
- "set",
- rbase
- + [
- endpoint,
- "group",
- "address-group",
- want_ep["group"],
- ],
- ),
- )
+def _rule_set_to_device(rs):
+ entry = autoclean({k: v for k, v in rs.items() if k not in ("name", "rules")})
+ if rs.get("rules"):
+ entry["rule"] = _rules_to_device(rs["rules"])
+ return entry
- icmp = rule.get("icmp") or {}
- have_icmp = have_rule.get("icmp") or {}
- if icmp.get("type") and icmp["type"] != have_icmp.get("type"):
- cmds.append(("set", rbase + ["icmp", "type", str(icmp["type"])]))
- if icmp.get("code") and icmp["code"] != have_icmp.get("code"):
- cmds.append(("set", rbase + ["icmp", "code", str(icmp["code"])]))
- return cmds
+def _rule_set_from_device(name, data):
+ data = dict(data or {})
+ rules_raw = data.pop("rule", None) or {}
+ entry = {"name": name, **from_device(data)}
+ if rules_raw:
+ entry["rules"] = _rules_from_device(rules_raw)
+ return entry
-def _rule_set_cmds(afi, rs, have_rs, state):
- cmds = []
- rs_name = rs["name"]
- rsbase = _BASE + [afi, "name", rs_name]
- have_rs = have_rs or {}
+def _want_to_device(config):
+ result = {}
+ for entry in config or []:
+ afi = entry["afi"]
+ rule_sets = entry.get("rule_sets") or []
+ if not rule_sets:
+ continue
+ result[afi] = {rs["name"]: _rule_set_to_device(rs) for rs in rule_sets}
+ return result
- if rs.get("default_action") and rs["default_action"] != have_rs.get("default_action"):
- cmds.append(("set", rsbase + ["default-action", rs["default_action"]]))
- if rs.get("description") and rs["description"] != have_rs.get("description"):
- cmds.append(("set", rsbase + ["description", rs["description"]]))
- have_rules = {r["number"]: r for r in (have_rs.get("rules") or [])}
- want_rules = {r["number"]: r for r in (rs.get("rules") or [])}
+def get_running_config(vyos):
+ """Fetch each AFI's rule-set subtree directly at firewall.<afi>.name --
+ the most targeted path available, deliberately not a broader fetch at
+ firewall.<afi> or firewall itself (which would pull in the hook-filter
+ and group subtrees owned by sibling modules for no benefit here).
+ """
+ result = {}
+ for afi in _AFIS:
+ raw = vyos.get_config(_BASE + [afi, "name"])
+ if raw and isinstance(raw, dict):
+ # Some VyOS REST responses wrap the result in an extra "name"
+ # key even when fetched at a path already ending in "name";
+ # unwrap defensively either way.
+ raw = raw.get("name", raw)
+ if raw and isinstance(raw, dict):
+ result[afi] = raw
+ return result
- if state == "replaced":
- for num in set(have_rules) - set(want_rules):
- cmds.append(("delete", rsbase + ["rule", str(num)]))
- for num, rule in want_rules.items():
- cmds += _rule_cmds(rs_name, afi, rule, have_rules.get(num))
+def _device_to_argspec(raw):
+ raw = raw or {}
+ result = []
+ for afi in _AFIS:
+ afi_raw = raw.get(afi) or {}
+ rule_sets = [_rule_set_from_device(name, data) for name, data in sorted(afi_raw.items())]
+ if rule_sets:
+ result.append({"afi": afi, "rule_sets": rule_sets})
+ return result
- return cmds
+# ---------------------------------------------------------------------------
+# Command building — dict_op scoped to _BASE + [afi, "name", rs_name] per
+# rule set, never a blanket op at _BASE + [afi] or _BASE itself (which
+# would risk vyos_firewall_interfaces's hook-filter subtree and
+# vyos_firewall_global's group subtree under the same "firewall" root).
+# ---------------------------------------------------------------------------
-def build_commands(config, have_list, state):
- cmds = []
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or []
+ norm_have = {afi: normalize_have(data, _TAG_KEYS) for afi, data in raw_have.items()}
if state == "deleted":
+ commands = []
if not config:
- if have_list:
- cmds.append(("delete", _BASE))
+ for afi, rule_sets in raw_have.items():
+ for name in rule_sets:
+ commands.append(("delete", _BASE + [afi, "name", name]))
else:
- have_map = {
- (e["afi"], rs["name"]): rs for e in have_list for rs in e.get("rule_sets", [])
- }
for entry in config:
afi = entry["afi"]
for rs in entry.get("rule_sets") or []:
- if (afi, rs["name"]) in have_map:
- cmds.append(("delete", _BASE + [afi, "name", rs["name"]]))
- return cmds
+ if rs["name"] in (raw_have.get(afi) or {}):
+ commands.append(("delete", _BASE + [afi, "name", rs["name"]]))
+ return commands
- have_map = {e["afi"]: {rs["name"]: rs for rs in e.get("rule_sets", [])} for e in have_list}
+ want = _want_to_device(config)
+ commands = []
if state == "overridden":
- want_keys = {
- (e["afi"], rs["name"]) for e in (config or []) for rs in e.get("rule_sets", [])
- }
- for e in have_list:
- for rs in e.get("rule_sets", []):
- if (e["afi"], rs["name"]) not in want_keys:
- cmds.append(("delete", _BASE + [e["afi"], "name", rs["name"]]))
+ want_keys = {(afi, name) for afi, rule_sets in want.items() for name in rule_sets}
+ for afi, rule_sets in raw_have.items():
+ for name in rule_sets:
+ if (afi, name) not in want_keys:
+ commands.append(("delete", _BASE + [afi, "name", name]))
- for entry in config or []:
- afi = entry["afi"]
- have_afi = have_map.get(afi, {})
-
- for rs in entry.get("rule_sets") or []:
- have_rs = have_afi.get(rs["name"])
-
- if state == "replaced" and have_rs:
- # delete and rebuild if different
- want_cmds = _rule_set_cmds(afi, rs, {}, "merged")
- have_cmds = _rule_set_cmds(
- afi,
- {
- "name": rs["name"],
- "default_action": have_rs.get("default_action"),
- "rules": have_rs.get("rules", []),
- },
- {},
- "merged",
- )
- if want_cmds != have_cmds:
- cmds.append(("delete", _BASE + [afi, "name", rs["name"]]))
- have_rs = None
-
- cmds += _rule_set_cmds(
- afi,
- rs,
- have_rs,
- state if state not in ("replaced", "overridden") else "merged",
- )
-
- return cmds
+ for afi, rule_sets in want.items():
+ for name, want_rs in rule_sets.items():
+ rsbase = _BASE + [afi, "name", name]
+ have_rs = (norm_have.get(afi) or {}).get(name) or {}
+
+ if state in ("replaced", "overridden"):
+ commands += dict_op(want_rs, have_rs, rsbase, op="purge")
+ commands += dict_op(want_rs, have_rs, rsbase, op="set")
+
+ return commands
ARGUMENT_SPEC = dict(
@@ -517,12 +469,13 @@ def main():
state = module.params["state"]
config = module.params.get("config") or []
- have = get_running_config(vyos)
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- commands = build_commands(config, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
module.exit_json(changed=bool(commands), commands=commands, before=have)
@@ -533,7 +486,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,