diff options
| author | omnom62 <omnom62@outlook.com> | 2026-07-04 14:39:16 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-07-04 14:39:16 +1000 |
| commit | d95da0c5d3dc8e29c231eb4ac3ed5d66050da427 (patch) | |
| tree | bb06733b2d89a374bb17942de35547d79b1fabce /plugins/modules | |
| parent | d997d0c7a4d5c8973186f2d9ba1581314a93866a (diff) | |
| download | rest.vyos-d95da0c5d3dc8e29c231eb4ac3ed5d66050da427.tar.gz rest.vyos-d95da0c5d3dc8e29c231eb4ac3ed5d66050da427.zip | |
T8989: vyos_firewall_rules module
Diffstat (limited to 'plugins/modules')
| -rw-r--r-- | plugins/modules/vyos_firewall_rules.py | 546 |
1 files changed, 546 insertions, 0 deletions
diff --git a/plugins/modules/vyos_firewall_rules.py b/plugins/modules/vyos_firewall_rules.py new file mode 100644 index 0000000..a1c7c01 --- /dev/null +++ b/plugins/modules/vyos_firewall_rules.py @@ -0,0 +1,546 @@ +#!/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_firewall_rules +short_description: Manage firewall rule sets on VyOS devices using REST API +description: + - Manages named firewall rule sets on VyOS devices via the REST API. + - Supports both IPv4 (C(ipv4)) and IPv6 (C(ipv6)) rule sets. + - Uses REST API (C(connection=httpapi)) instead of CLI. + - In VyOS 1.5+, firewall uses named rule sets under C(firewall.ipv4.name) + and C(firewall.ipv6.name). +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: Firewall rule set configuration. + type: list + elements: dict + suboptions: + afi: + description: Address family. + type: str + choices: [ipv4, ipv6] + required: true + rule_sets: + description: Named rule sets for this address family. + type: list + elements: dict + suboptions: + name: + description: Rule set name. + type: str + required: true + default_action: + description: Default action when no rule matches. + type: str + choices: [accept, drop, reject] + description: + description: Rule set description. + type: str + rules: + description: Firewall rules in this rule set. + type: list + elements: dict + suboptions: + number: + description: Rule number. + type: int + required: true + action: + description: Rule action. + type: str + choices: [accept, drop, reject, return, queue, continue] + description: + description: Rule description. + type: str + disable: + description: Disable this rule. + type: bool + protocol: + description: Protocol to match. + type: str + state: + description: Connection state to match. + type: str + choices: [established, invalid, new, related] + source: + description: Source match criteria. + type: dict + suboptions: + address: + description: Source IP address or prefix. + type: str + group: + description: Source group name. + type: str + port: + description: Source port or range. + type: str + destination: + description: Destination match criteria. + type: dict + suboptions: + address: + description: Destination IP address or prefix. + type: str + group: + description: Destination group name. + type: str + port: + description: Destination port or range. + type: str + log: + description: Enable logging for this rule. + type: bool + icmp: + description: ICMP type/code to match. + type: dict + suboptions: + type: + description: ICMP type. + type: int + code: + description: ICMP code. + type: int + state: + description: + - Desired state of the firewall rules configuration. + - C(merged) adds or updates without removing existing config. + - C(replaced) replaces rule sets for named rule sets in config. + - C(overridden) replaces all firewall rule sets. + - C(deleted) removes firewall rule sets. + - C(gathered) returns current configuration as structured data. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged +notes: + - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. + - 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. +""" + +EXAMPLES = r""" +- name: Merge firewall rules + vyos.rest.vyos_firewall_rules: + config: + - afi: ipv4 + rule_sets: + - name: RULE-SET1 + default_action: drop + rules: + - number: 10 + action: accept + protocol: tcp + source: + address: 192.168.1.0/24 + destination: + port: "80" + - number: 20 + action: drop + state: invalid + - afi: ipv6 + rule_sets: + - name: RULE-SET6 + default_action: accept + rules: + - number: 10 + action: accept + state: merged + +- name: Delete all firewall rules + vyos.rest.vyos_firewall_rules: + state: deleted + +- name: Gather firewall rules + vyos.rest.vyos_firewall_rules: + state: gathered +""" + +RETURN = r""" +before: + description: Firewall rules configuration before this module ran. + returned: always + type: list +after: + description: Firewall rules configuration after this module ran. + returned: when changed + type: list +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current firewall rules configuration as structured data. + returned: when state is gathered + type: list +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_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 + + +def get_running_config(vyos): + 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}) + 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"], + ], + ), + ) + + 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_cmds(afi, rs, have_rs, state): + cmds = [] + rs_name = rs["name"] + rsbase = _BASE + [afi, "name", rs_name] + have_rs = have_rs or {} + + 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 [])} + + 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)) + + return cmds + + +def build_commands(config, have_list, state): + cmds = [] + + if state == "deleted": + if not config: + if have_list: + cmds.append(("delete", _BASE)) + 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 + + have_map = {e["afi"]: {rs["name"]: rs for rs in e.get("rule_sets", [])} for e in have_list} + + 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"]])) + + 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 + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", choices=["ipv4", "ipv6"], required=True), + rule_sets=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + default_action=dict( + type="str", + choices=["accept", "drop", "reject"], + ), + description=dict(type="str"), + rules=dict( + type="list", + elements="dict", + options=dict( + number=dict(type="int", required=True), + action=dict( + type="str", + choices=[ + "accept", + "drop", + "reject", + "return", + "queue", + "continue", + ], + ), + description=dict(type="str"), + disable=dict(type="bool"), + protocol=dict(type="str"), + state=dict( + type="str", + choices=[ + "established", + "invalid", + "new", + "related", + ], + ), + log=dict(type="bool"), + source=dict( + type="dict", + options=dict( + address=dict(type="str"), + group=dict(type="str"), + port=dict(type="str"), + ), + ), + destination=dict( + type="dict", + options=dict( + address=dict(type="str"), + group=dict(type="str"), + port=dict(type="str"), + ), + ), + icmp=dict( + type="dict", + options=dict( + type=dict(type="int"), + code=dict(type="int"), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + + +def main(): + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or [] + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + 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() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() |
