diff --git a/netjsonconfig/backends/openwrt/converters/__init__.py b/netjsonconfig/backends/openwrt/converters/__init__.py index 2eb379a80..2864d12c9 100644 --- a/netjsonconfig/backends/openwrt/converters/__init__.py +++ b/netjsonconfig/backends/openwrt/converters/__init__.py @@ -1,4 +1,5 @@ from .default import Default +from .firewall import Firewall from .general import General from .interfaces import Interfaces from .led import Led @@ -11,15 +12,16 @@ from .wireless import Wireless __all__ = [ - 'Default', - 'Interfaces', - 'General', - 'Led', - 'Ntp', - 'OpenVpn', - 'Radios', - 'Routes', - 'Rules', - 'Switch', - 'Wireless', + "Default", + "Interfaces", + "General", + "Led", + "Ntp", + "OpenVpn", + "Radios", + "Routes", + "Rules", + "Switch", + "Wireless", + "Firewall", ] diff --git a/netjsonconfig/backends/openwrt/converters/firewall.py b/netjsonconfig/backends/openwrt/converters/firewall.py new file mode 100644 index 000000000..c4567784d --- /dev/null +++ b/netjsonconfig/backends/openwrt/converters/firewall.py @@ -0,0 +1,207 @@ +"""Firewall configuration management for OpenWRT. + +See the following resource for a detailed description of the sections and parameters of +the UCI configuration for the OpenWRT firewall. + + https://openwrt.org/docs/guide-user/firewall/firewall_configuration +""" +from collections import OrderedDict + +from ..schema import schema +from .base import OpenWrtConverter + + +class Firewall(OpenWrtConverter): + netjson_key = "firewall" + intermediate_key = "firewall" + _uci_types = ["defaults", "forwarding", "zone", "rule", "redirect"] + _schema = schema["properties"]["firewall"] + + def to_intermediate_loop(self, block, result, index=None): + forwardings = self.__intermediate_forwardings(block.pop("forwardings", {})) + zones = self.__intermediate_zones(block.pop("zones", {})) + rules = self.__intermediate_rules(block.pop("rules", {})) + redirects = self.__intermediate_redirects(block.pop("redirects", {})) + block.update({".type": "defaults", ".name": block.pop("id", "defaults")}) + result.setdefault("firewall", []) + result["firewall"] = ( + [self.sorted_dict(block)] + forwardings + zones + rules + redirects + ) + return result + + def __intermediate_forwardings(self, forwardings): + """ + converts NetJSON forwarding to + UCI intermediate data structure + """ + result = [] + for forwarding in forwardings: + resultdict = OrderedDict( + ( + (".name", self.__get_auto_name_forwarding(forwarding)), + (".type", "forwarding"), + ) + ) + resultdict.update(forwarding) + result.append(resultdict) + return result + + def __get_auto_name_forwarding(self, forwarding): + if "family" in forwarding.keys(): + uci_name = self._get_uci_name( + "_".join([forwarding["src"], forwarding["dest"], forwarding["family"]]) + ) + else: + uci_name = self._get_uci_name( + "_".join([forwarding["src"], forwarding["dest"]]) + ) + return "forwarding_{0}".format(uci_name) + + def __intermediate_zones(self, zones): + """ + converts NetJSON zone to + UCI intermediate data structure + """ + result = [] + for zone in zones: + resultdict = OrderedDict( + ((".name", self.__get_auto_name_zone(zone)), (".type", "zone")) + ) + # If network contains only a single value, force the use of a UCI "option" + # rather than "list"". + network = zone["network"] + if len(network) == 1: + zone["network"] = network[0] + resultdict.update(zone) + result.append(resultdict) + return result + + def __get_auto_name_zone(self, zone): + return "zone_{0}".format(self._get_uci_name(zone["name"])) + + def __intermediate_rules(self, rules): + """ + converts NetJSON rule to + UCI intermediate data structure + """ + result = [] + for rule in rules: + if "config_name" in rule: + del rule["config_name"] + resultdict = OrderedDict( + ((".name", self.__get_auto_name_rule(rule)), (".type", "rule")) + ) + if "proto" in rule: + # If proto is a single value, then force it not to be in a list so that + # the UCI uses "option" rather than "list". If proto is only "tcp" + # and"udp", we can force it to the single special value of "tcpudp". + proto = rule["proto"] + if len(proto) == 1: + rule["proto"] = proto[0] + elif set(proto) == {"tcp", "udp"}: + rule["proto"] = "tcpudp" + resultdict.update(rule) + result.append(resultdict) + return result + + def __get_auto_name_rule(self, rule): + return "rule_{0}".format(self._get_uci_name(rule["name"])) + + def __intermediate_redirects(self, redirects): + """ + converts NetJSON redirect to + UCI intermediate data structure + """ + result = [] + for redirect in redirects: + if "config_name" in redirect: + del redirect["config_name"] + resultdict = OrderedDict( + ( + (".name", self.__get_auto_name_redirect(redirect)), + (".type", "redirect"), + ) + ) + if "proto" in redirect: + # If proto is a single value, then force it not to be in a list so that + # the UCI uses "option" rather than "list". If proto is only "tcp" + # and"udp", we can force it to the single special value of "tcpudp". + proto = redirect["proto"] + if len(proto) == 1: + redirect["proto"] = proto[0] + elif set(proto) == {"tcp", "udp"}: + redirect["proto"] = "tcpudp" + resultdict.update(redirect) + result.append(resultdict) + return result + + def __get_auto_name_redirect(self, redirect): + return "redirect_{0}".format(self._get_uci_name(redirect["name"])) + + def to_netjson_loop(self, block, result, index): + result.setdefault("firewall", {}) + + block.pop(".name") + _type = block.pop(".type") + + if _type == "rule": + rule = self.__netjson_rule(block) + result["firewall"].setdefault("rules", []) + result["firewall"]["rules"].append(rule) + if _type == "zone": + zone = self.__netjson_zone(block) + result["firewall"].setdefault("zones", []) + result["firewall"]["zones"].append(zone) + if _type == "forwarding": + forwarding = self.__netjson_forwarding(block) + result["firewall"].setdefault("forwardings", []) + result["firewall"]["forwardings"].append(forwarding) + if _type == "redirect": + redirect = self.__netjson_redirect(block) + result["firewall"].setdefault("redirects", []) + result["firewall"]["redirects"].append(redirect) + + return self.type_cast(result) + + def __netjson_rule(self, rule): + if "enabled" in rule: + rule["enabled"] = rule.pop("enabled") == "1" + if "proto" in rule: + proto = rule.pop("proto") + if not isinstance(proto, list): + if proto == "tcpudp": + rule["proto"] = ["tcp", "udp"] + else: + rule["proto"] = [proto] + + return self.type_cast(rule) + + def __netjson_zone(self, zone): + network = zone["network"] + # network may be specified as a list in a single string e.g. + # option network 'wan wan6' + # Here we ensure that network is always a list. + if not isinstance(network, list): + zone["network"] = network.split() + + if "mtu_fix" in zone: + zone["mtu_fix"] = zone.pop("mtu_fix") == "1" + + if "masq" in zone: + zone["masq"] = zone.pop("masq") == "1" + + return self.type_cast(zone) + + def __netjson_forwarding(self, forwarding): + return self.type_cast(forwarding) + + def __netjson_redirect(self, redirect): + if "proto" in redirect: + proto = redirect.pop("proto") + if not isinstance(proto, list): + if proto == "tcpudp": + redirect["proto"] = ["tcp", "udp"] + else: + redirect["proto"] = [proto] + + return self.type_cast(redirect) diff --git a/netjsonconfig/backends/openwrt/openwrt.py b/netjsonconfig/backends/openwrt/openwrt.py index b6dc64934..54aa28a0b 100644 --- a/netjsonconfig/backends/openwrt/openwrt.py +++ b/netjsonconfig/backends/openwrt/openwrt.py @@ -22,6 +22,7 @@ class OpenWrt(BaseBackend): converters.Radios, converters.Wireless, converters.OpenVpn, + converters.Firewall, converters.Default, ] parser = OpenWrtParser diff --git a/netjsonconfig/backends/openwrt/schema.py b/netjsonconfig/backends/openwrt/schema.py index 9d09b8ea6..8b18a5b6b 100644 --- a/netjsonconfig/backends/openwrt/schema.py +++ b/netjsonconfig/backends/openwrt/schema.py @@ -8,6 +8,19 @@ default_radio_driver = "mac80211" +# The following regex will match against a single valid port, or a port range e.g. 1234-5000 +port_range_regex = "^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])(-([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$" # noqa + +# Match against a MAC address +mac_address_regex = "^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$" + +# Match against a yyyy-mm-dd format date. Note that draft07 of the JSON schema standard +# include a "date" pattern which can replace this. +# https://json-schema.org/understanding-json-schema/reference/string.html +date_regex = "^([0-9]{4})-(0[1-9]|[12][0-9]|3[01])-([012][0-9]|[3][01])$" + +# Match against a time in the format hh:mm:ss +time_regex = "^([01][0-9]|2[0123])(:([012345][0-9])){2}$" schema = merge_config( default_schema, @@ -32,7 +45,7 @@ "network": { "type": "array", "title": "Attached Networks", - "description": "override OpenWRT \"network\" config option of of wifi-iface " + "description": 'override OpenWRT "network" config option of of wifi-iface ' "directive; will be automatically determined if left blank", "uniqueItems": True, "additionalItems": True, @@ -71,9 +84,9 @@ "macfilter": { "type": "string", "title": "MAC Filter", - "description": "specifies the mac filter policy, \"disable\" to disable " - "the filter, \"allow\" to treat it as whitelist or " - "\"deny\" to treat it as blacklist", + "description": 'specifies the mac filter policy, "disable" to disable ' + 'the filter, "allow" to treat it as whitelist or ' + '"deny" to treat it as blacklist', "enum": ["disable", "allow", "deny"], "default": "disable", "propertyOrder": 15, @@ -82,7 +95,7 @@ "type": "array", "title": "MAC List", "description": "mac addresses that will be filtered according to the policy " - "specified in the \"macfilter\" option", + 'specified in the "macfilter" option', "propertyOrder": 16, "items": { "type": "string", @@ -103,7 +116,7 @@ "igmp_snooping": { "type": "boolean", "title": "IGMP snooping", - "description": "sets the \"multicast_snooping\" kernel setting for a bridge", + "description": 'sets the "multicast_snooping" kernel setting for a bridge', "default": True, "format": "checkbox", "propertyOrder": 4, @@ -112,6 +125,26 @@ } ] }, + "firewall_policy": { + "type": "string", + "enum": ["ACCEPT", "REJECT", "DROP"], + "options": {"enum_titles": ["Accept", "Reject", "Drop"]}, + "default": "REJECT", + }, + "zone_policy": { + "type": "string", + "enum": ["ACCEPT", "REJECT", "DROP"], + "options": {"enum_titles": ["Accept", "Reject", "Drop"]}, + "default": "DROP", + }, + "rule_policy": { + "type": "string", + "enum": ["ACCEPT", "REJECT", "DROP", "MARK", "NOTRACK"], + "options": { + "enum_titles": ["Accept", "Reject", "Drop", "Mark", "Notrack"] + }, + "default": "DROP", + }, "base_radio_settings": { "properties": { "driver": { @@ -386,10 +419,562 @@ }, }, }, + "firewall": { + "type": "object", + "title": "Firewall", + "additionalProperties": True, + "propertyOrder": 11, + "properties": { + "syn_flood": { + "type": "boolean", + "title": "enable SYN flood protection", + "default": False, + "format": "checkbox", + "propertyOrder": 1, + }, + "input": { + "allOf": [ + {"$ref": "#/definitions/firewall_policy"}, + { + "title": "input", + "description": "policy for the INPUT chain of the filter table", + "propertyOrder": 2, + }, + ] + }, + "output": { + "allOf": [ + {"$ref": "#/definitions/firewall_policy"}, + { + "title": "output", + "description": "policy for the OUTPUT chain of the filter table", + "propertyOrder": 3, + }, + ] + }, + "forward": { + "allOf": [ + {"$ref": "#/definitions/firewall_policy"}, + { + "title": "forward", + "description": "policy for the FORWARD chain of the filter table", + "propertyOrder": 4, + }, + ] + }, + "forwardings": { + "type": "array", + "title": "Forwardings", + "propertyOrder": 5, + "items": { + "type": "object", + "title": "Forwarding", + "additionalProperties": False, + "required": ["src", "dest"], + "properties": { + "src": { + "type": "string", + "title": "src", + "description": "specifies the traffic source zone and must " + "refer to one of the defined zone names", + "propertyOrder": 1, + }, + "dest": { + "type": "string", + "title": "dest", + "description": "specifies the traffic destination zone and must " + "refer to one of the defined zone names", + "propertyOrder": 2, + }, + "family": { + "type": "string", + "title": "family", + "description": "protocol family (ipv4, ipv6 or any) to generate " + "iptables rules for", + "enum": ["ipv4", "ipv6", "any"], + "default": "any", + "propertyOrder": 3, + }, + }, + }, + }, + "zones": { + "type": "array", + "title": "Zones", + "propertyOrder": 6, + "items": { + "type": "object", + "title": "Zones", + "additionalProperties": True, + "required": ["name"], + "properties": { + "name": { + "type": "string", + "title": "name", + "description": "unique zone name", + "maxLength": 11, + "propertyOrder": 1, + }, + "network": { + "type": "array", + "title": "Network", + "description": "list of interfaces attached to this zone", + "uniqueItems": True, + "propertyOrder": 2, + "items": { + "title": "Network", + "type": "string", + "maxLength": 15, + "pattern": "^[a-zA-z0-9_\\.\\-]*$", + }, + }, + "masq": { + "type": "boolean", + "title": "masq", + "description": "specifies wether outgoing zone traffic should be " + "masqueraded", + "default": False, + "format": "checkbox", + "propertyOrder": 3, + }, + "mtu_fix": { + "type": "boolean", + "title": "mtu_fix", + "description": "enable MSS clamping for outgoing zone traffic", + "default": False, + "format": "checkbox", + "propertyOrder": 4, + }, + "input": { + "allOf": [ + {"$ref": "#/definitions/zone_policy"}, + { + "title": "input", + "description": "default policy for incoming zone traffic", + "propertyOrder": 5, + }, + ] + }, + "output": { + "allOf": [ + {"$ref": "#/definitions/zone_policy"}, + { + "title": "output", + "description": "default policy for outgoing zone traffic", + "propertyOrder": 6, + }, + ] + }, + "forward": { + "allOf": [ + {"$ref": "#/definitions/zone_policy"}, + { + "title": "forward", + "description": "default policy for forwarded zone traffic", + "propertyOrder": 7, + }, + ] + }, + }, + }, + }, + "rules": { + "type": "array", + "title": "Rules", + "propertyOrder": 7, + "items": { + "type": "object", + "title": "Rules", + "additionalProperties": True, + "required": ["src", "target"], + "properties": { + "name": { + "type": "string", + "title": "name", + "description": "name of the rule", + "propertyOrder": 1, + }, + "src": { + "type": "string", + "title": "src", + "description": "specifies the traffic source zone and must " + "refer to one of the defined zone names", + "propertyOrder": 2, + }, + "src_ip": { + "type": "string", + "title": "src_ip", + "description": "match incoming traffic from the specified " + "source ip address", + "propertyOrder": 3, + }, + "src_mac": { + "type": "string", + "title": "src_mac", + "description": "match incoming traffic from the specified " + "mac address", + "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", + "minLength": 17, + "maxLength": 17, + "propertyOrder": 4, + }, + "src_port": { + "type": "string", + "title": "src_port", + "description": "match incoming traffic from the specified " + "source port or port range, if relevant proto " + "is specified. Multiple ports can be specified " + "separated by blanks", + "propertyOrder": 5, + }, + "proto": { + "type": "array", + "title": "proto", + "description": "match incoming traffic using the given protocol. " + "Can be one of tcp, udp, tcpudp, udplite, icmp, esp, " + "ah, sctp, or all or it can be a numeric value, " + "representing one of these protocols or a different one. " + "A protocol name from /etc/protocols is also allowed. " + "The number 0 is equivalent to all", + "default": ["tcp", "udp"], + "propertyOrder": 6, + "items": { + "title": "Protocol type", + "type": "string", + }, + }, + "icmp_type": { + "title": "icmp_type", + "description": "for protocol icmp select specific icmp types to match. " + "Values can be either exact icmp type numbers or type names", + "type": "array", + "uniqueItems": True, + "additionalItems": True, + "propertyOrder": 7, + "items": {"title": "ICMP type", "type": "string"}, + }, + "dest": { + "type": "string", + "title": "dest", + "description": "specifies the traffic destination zone and must " + "refer to one of the defined zone names, or * for " + "any zone. If specified, the rule applies to forwarded " + "traffic; otherwise, it is treated as input rule", + "propertyOrder": 8, + }, + "dest_ip": { + "type": "string", + "title": "dest_ip", + "description": "match incoming traffic directed to the specified " + "destination ip address. With no dest zone, this " + "is treated as an input rule", + "propertyOrder": 9, + }, + "dest_port": { + "type": "string", + "title": "dest_port", + "description": "match incoming traffic directed at the given " + "destination port or port range, if relevant " + "proto is specified. Multiple ports can be specified " + "separated by blanks", + "propertyOrder": 10, + }, + "target": { + "allOf": [ + {"$ref": "#/definitions/rule_policy"}, + { + "title": "target", + "description": "firewall action for matched traffic", + "propertyOrder": 11, + }, + ] + }, + "family": { + "type": "string", + "title": "family", + "description": "protocol family to generate iptables rules for", + "enum": ["ipv4", "ipv6", "any"], + "default": "any", + "propertyOrder": 12, + }, + "limit": { + "type": "string", + "title": "limit", + "description": "maximum average matching rate; specified as a number, " + "with an optional /second, /minute, /hour or /day suffix", + "propertyOrder": 13, + }, + "enabled": { + "type": "boolean", + "title": "enable", + "description": "Enable this rule.", + "default": True, + "format": "checkbox", + "propertyOrder": 14, + }, + }, + }, + }, + "redirects": { + "type": "array", + "title": "Redirects", + "propertyOrder": 8, + "items": { + "type": "object", + "title": "Redirect", + "additionalProperties": False, + "properties": { + "name": { + "type": "string", + "title": "name", + "description": "Name of redirect", + "propertyOrder": 1, + }, + "src": { + "type": "string", + "title": "src", + "description": "Specifies the traffic source zone. " + "Must refer to one of the defined zone names. " + "For typical port forwards this usually is wan.", + "propertyOrder": 2, + }, + "src_ip": { + "type": "string", + "title": "src_ip", + "description": "Match incoming traffic from the specified source ip " + "address.", + "format": "ipv4", + "propertyOrder": 3, + }, + "src_dip": { + "type": "string", + "title": "src_dip", + "description": "For DNAT, match incoming traffic directed at the " + "given destination ip address. For SNAT rewrite the source address " + "to the given address.", + "format": "ipv4", + "propertyOrder": 4, + }, + "src_mac": { + "type": "string", + "title": "src_mac", + "description": "Match incoming traffic from the specified MAC address.", + "pattern": mac_address_regex, + "propertyOrder": 5, + }, + "src_port": { + "type": "string", + "title": "src_port", + "description": "Match incoming traffic originating from the given source " + "port or port range on the client host.", + "pattern": port_range_regex, + "propertyOrder": 6, + }, + "src_dport": { + "type": "string", + "title": "src_dport", + "description": "For DNAT, match incoming traffic directed at the given " + "destination port or port range on this host. For SNAT rewrite the " + "source ports to the given value.", + "pattern": port_range_regex, + "propertyOrder": 7, + }, + "proto": { + "type": "array", + "title": "proto", + "description": "Match incoming traffic using the given protocol. " + "Can be one of tcp, udp, tcpudp, udplite, icmp, esp, " + "ah, sctp, or all or it can be a numeric value, " + "representing one of these protocols or a different one. " + "A protocol name from /etc/protocols is also allowed. " + "The number 0 is equivalent to all", + "default": ["tcp", "udp"], + "propertyOrder": 8, + "items": { + "title": "Protocol type", + "type": "string", + }, + }, + "dest": { + "type": "string", + "title": "dest", + "description": "Specifies the traffic destination zone. Must refer to " + "on of the defined zone names. For DNAT target on Attitude Adjustment, " + 'NAT reflection works only if this is equal to "lan".', + "propertyOrder": 9, + }, + "dest_ip": { + "type": "string", + "title": "dest_ip", + "description": "For DNAT, redirect matches incoming traffic to the " + "specified internal host. For SNAT, it matches traffic directed at " + "the given address. For DNAT, if the dest_ip is not specified, the rule " + "is translated in a iptables/REDIRECT rule, otherwise it is a " + "iptables/DNAT rule.", + "format": "ipv4", + "propertyOrder": 10, + }, + "dest_port": { + "type": "string", + "title": "dest_port", + "description": "For DNAT, redirect matched incoming traffic to the given " + "port on the internal host. For SNAT, match traffic directed at the " + "given ports. Only a single port or range can be specified.", + "pattern": port_range_regex, + "propertyOrder": 11, + }, + "ipset": { + "type": "string", + "title": "ipset", + "description": "Match traffic against the given ipset. The match can be " + "inverted by prefixing the value with an exclamation mark.", + "propertyOrder": 12, + }, + "mark": { + "type": "string", + "title": "mark", + "description": 'Match traffic against the given firewall mark, e.g. ' + '"0xFF" to match mark 255 or "0x0/0x1" to match any even mark value. ' + 'The match can be inverted by prefixing the value with an exclamation ' + 'mark, e.g. "!0x10" to match all but mark #16.', + "propertyOrder": 13, + }, + "start_date": { + "type": "string", + "title": "start_date", + "description": "Only match traffic after the given date (inclusive).", + "pattern": date_regex, + # "format": "date", TODO: replace pattern with this + # when adopt draft07 + "propertyOrder": 14, + }, + "stop_date": { + "type": "string", + "title": "stop_date", + "description": "Only match traffic before the given date (inclusive).", + "pattern": date_regex, + # "format": "date", TODO: replace pattern with this + # when adopt draft07 + "propertyOrder": 15, + }, + "start_time": { + "type": "string", + "title": "start_time", + "description": "Only match traffic after the given time of day " + "(inclusive).", + "pattern": time_regex, + "propertyOrder": 16, + }, + "stop_time": { + "type": "string", + "title": "stop_time", + "description": "Only match traffic before the given time of day " + "(inclusive).", + "pattern": time_regex, + # "format": "time", TODO: replace pattern with this + # when adopt draft07 + "propertyOrder": 17, + }, + # FIXME: regex needed. Also, should this be an array? + "weekdays": { + "type": "string", + "title": "weekdays", + "description": "Only match traffic during the given week days, " + 'e.g. "sun mon thu fri" to only match on Sundays, Mondays, Thursdays and ' + "Fridays. The list can be inverted by prefixing it with an exclamation " + 'mark, e.g. "! sat sun" to always match but not on Saturdays and ' + "Sundays.", + "propertyOrder": 18, + }, + # FIXME: regex needed. Also, should this be an array? + "monthdays": { + "type": "string", + "title": "monthdays", + "description": "Only match traffic during the given days of the " + 'month, e.g. "2 5 30" to only match on every 2nd, 5th and 30th ' + "day of the month. The list can be inverted by prefixing it with " + 'an exclamation mark, e.g. "! 31" to always match but on the ' + "31st of the month.", + "propertyOrder": 19, + }, + "utc_time": { + "type": "boolean", + "title": "utc_time", + "description": "Treat all given time values as UTC time instead of local " + "time.", + "default": False, + "propertyOrder": 20, + }, + "target": { + "type": "string", + "title": "target", + "description": "NAT target (DNAT or SNAT) to use when generating the " + "rule.", + "enum": ["DNAT", "SNAT"], + "default": "DNAT", + "propertyOrder": 21, + }, + "family": { + "type": "string", + "title": "family", + "description": "Protocol family (ipv4, ipv6 or any) to generate iptables " + "rules for", + "enum": ["ipv4", "ipv6", "any"], + "default": "any", + "propertyOrder": 22, + }, + "reflection": { + "type": "boolean", + "title": "reflection", + "description": "Activate NAT reflection for this redirect. Applicable to " + "DNAT targets.", + "default": True, + "propertyOrder": 23, + }, + "reflection_src": { + "type": "string", + "title": "reflection_src", + "description": "The source address to use for NAT-reflected packets if " + "reflection is True. This can be internal or external, specifying which " + "interface’s address to use. Applicable to DNAT targets.", + "enum": ["internal", "external"], + "default": "internal", + "propertyOrder": 24, + }, + "limit": { + "type": "string", + "title": "limit", + "description": "Maximum average matching rate; specified as a number, " + "with an optional /second, /minute, /hour or /day suffix. " + "Examples: 3/second, 3/sec or 3/s.", + "propertyOrder": 25, + }, + "limit_burst": { + "type": "integer", + "title": "limit_burst", + "description": "Maximum initial number of packets to match, allowing a " + "short-term average above limit.", + "default": 5, + "propertyOrder": 26, + }, + "enabled": { + "type": "boolean", + "title": "enable", + "description": "Enable this redirect.", + "default": True, + "format": "checkbox", + "propertyOrder": 27, + }, + }, + }, + }, + }, + }, }, }, ) + # add OpenVPN schema schema = merge_config(schema, base_openvpn_schema) # OpenVPN customizations for OpenWRT diff --git a/tests/openwrt/test_default.py b/tests/openwrt/test_default.py index 91a4f443c..f21f04b8d 100644 --- a/tests/openwrt/test_default.py +++ b/tests/openwrt/test_default.py @@ -21,56 +21,59 @@ def test_render_default(self): "boolean": True, } ], - "firewall": [ - { - "config_name": "rule", - "name": "Allow-MLD", - "src": "wan", - "proto": "icmp", - "src_ip": "fe80::/10", - "family": "ipv6", - "target": "ACCEPT", - "icmp_type": ["130/0", "131/0", "132/0", "143/0"], - }, - { - "config_name": "rule", - "name": "Rule2", - "src": "wan", - "proto": "icmp", - "src_ip": "192.168.1.1/24", - "family": "ipv4", - "target": "ACCEPT", - "icmp_type": ["130/0", "131/0", "132/0", "143/0"], - }, - ], + "firewall": { + "rules": [ + { + "name": "Allow-MLD", + "src": "wan", + "proto": ["icmp"], + "src_ip": "fe80::/10", + "family": "ipv6", + "target": "ACCEPT", + "icmp_type": ["130/0", "131/0", "132/0", "143/0"], + }, + { + "name": "Rule2", + "src": "wan", + "proto": ["icmp"], + "src_ip": "192.168.1.1/24", + "family": "ipv4", + "target": "ACCEPT", + "icmp_type": ["130/0", "131/0", "132/0", "143/0"], + }, + ] + }, } ) expected = self._tabs( - """package firewall + """\ +package firewall -config rule 'rule_1' - option family 'ipv6' - list icmp_type '130/0' - list icmp_type '131/0' - list icmp_type '132/0' - list icmp_type '143/0' +config defaults 'defaults' + +config rule 'rule_Allow_MLD' option name 'Allow-MLD' - option proto 'icmp' option src 'wan' + option proto 'icmp' option src_ip 'fe80::/10' + option family 'ipv6' option target 'ACCEPT' - -config rule 'rule_2' - option family 'ipv4' list icmp_type '130/0' list icmp_type '131/0' list icmp_type '132/0' list icmp_type '143/0' + +config rule 'rule_Rule2' option name 'Rule2' - option proto 'icmp' option src 'wan' + option proto 'icmp' option src_ip '192.168.1.1/24' + option family 'ipv4' option target 'ACCEPT' + list icmp_type '130/0' + list icmp_type '131/0' + list icmp_type '132/0' + list icmp_type '143/0' package luci @@ -133,52 +136,59 @@ def test_parse_default(self): ) o = OpenWrt(native=native) expected = { - "luci": [ - { - "config_name": "core", - "config_value": "main", - "lang": "auto", - "resourcebase": "/luci-static/resources", - "mediaurlbase": "/luci-static/bootstrap", - "number": "4", - "boolean": "1", - } - ], - "firewall": [ - { - "config_name": "rule", - "name": "Allow-MLD", - "src": "wan", - "proto": "icmp", - "src_ip": "fe80::/10", - "family": "ipv6", - "target": "ACCEPT", - "icmp_type": ["130/0", "131/0", "132/0", "143/0"], - } - ], "led": [ { + "dev": "1-1.1", + "interval": 50, "name": "USB1", "sysfs": "tp-link:green:usb1", "trigger": "usbdev", - "dev": "1-1.1", - "interval": 50, } ], "interfaces": [{"name": "eth0", "type": "ethernet"}], + "firewall": { + "rules": [ + { + "family": "ipv6", + "icmp_type": ["130/0", "131/0", "132/0", "143/0"], + "name": "Allow-MLD", + "proto": ["icmp"], + "src": "wan", + "src_ip": "fe80::/10", + "target": "ACCEPT", + } + ] + }, + "luci": [ + { + "boolean": "1", + "lang": "auto", + "mediaurlbase": "/luci-static/bootstrap", + "number": "4", + "resourcebase": "/luci-static/resources", + "config_value": "main", + "config_name": "core", + } + ], "system": [ - {"test": "1", "config_name": "custom", "config_value": "custom"} + {"test": "1", "config_value": "custom", "config_name": "custom"} ], } + + print("*" * 80) + import json + + print(json.dumps(o.config, indent=4)) + print("*" * 80) self.assertDictEqual(o.config, expected) def test_skip(self): o = OpenWrt({"skipme": {"enabled": True}}) - self.assertEqual(o.render(), '') + self.assertEqual(o.render(), "") def test_warning(self): o = OpenWrt({"luci": [{"unrecognized": True}]}) - self.assertEqual(o.render(), '') + self.assertEqual(o.render(), "") def test_merge(self): template = { @@ -217,8 +227,8 @@ def test_merge(self): self.assertEqual(o.config, expected) def test_skip_nonlists(self): - o = OpenWrt({"custom_package": {'unknown': True}}) - self.assertEqual(o.render(), '') + o = OpenWrt({"custom_package": {"unknown": True}}) + self.assertEqual(o.render(), "") def test_render_invalid_uci_name(self): o = OpenWrt( diff --git a/tests/openwrt/test_firewall.py b/tests/openwrt/test_firewall.py new file mode 100644 index 000000000..478336d0b --- /dev/null +++ b/tests/openwrt/test_firewall.py @@ -0,0 +1,415 @@ +import textwrap +import unittest + +from netjsonconfig import OpenWrt +from netjsonconfig.exceptions import ValidationError +from netjsonconfig.utils import _TabsMixin + + +class TestFirewall(unittest.TestCase, _TabsMixin): + maxDiff = None + + _rule_1_netjson = { + "firewall": { + "rules": [ + { + "name": "Allow-MLD", + "src": "wan", + "src_ip": "fe80::/10", + "proto": ["icmp"], + "icmp_type": ["130/0", "131/0", "132/0", "143/0"], + "target": "ACCEPT", + "family": "ipv6", + } + ] + } + } + + _rule_1_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config rule 'rule_Allow_MLD' + option name 'Allow-MLD' + option src 'wan' + option src_ip 'fe80::/10' + option proto 'icmp' + list icmp_type '130/0' + list icmp_type '131/0' + list icmp_type '132/0' + list icmp_type '143/0' + option target 'ACCEPT' + option family 'ipv6' + """ + ) + + def test_render_rule_1(self): + o = OpenWrt(self._rule_1_netjson) + expected = self._tabs(self._rule_1_uci) + self.assertEqual(o.render(), expected) + + def test_parse_rule_1(self): + o = OpenWrt(native=self._rule_1_uci) + self.assertEqual(o.config, self._rule_1_netjson) + + _rule_2_netjson = { + "firewall": { + "rules": [ + { + "name": "Allow-DHCPv6", + "src": "wan", + "src_ip": "fc00::/6", + "dest_ip": "fc00::/6", + "dest_port": "546", + "proto": ["udp"], + "target": "ACCEPT", + "family": "ipv6", + } + ] + } + } + + _rule_2_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config rule 'rule_Allow_DHCPv6' + option name 'Allow-DHCPv6' + option src 'wan' + option src_ip 'fc00::/6' + option dest_ip 'fc00::/6' + option dest_port '546' + option proto 'udp' + option target 'ACCEPT' + option family 'ipv6' + """ + ) + + def test_render_rule_2(self): + o = OpenWrt(self._rule_2_netjson) + expected = self._tabs(self._rule_2_uci) + self.assertEqual(o.render(), expected) + + def test_parse_rule_2(self): + o = OpenWrt(native=self._rule_2_uci) + self.assertEqual(o.config, self._rule_2_netjson) + + _rule_3_netjson = { + "firewall": { + "rules": [ + { + "name": "Allow-Ping", + "src": "wan", + "proto": ["icmp"], + "family": "ipv4", + "icmp_type": ["echo-request"], + "target": "ACCEPT", + "enabled": False, + } + ] + } + } + + _rule_3_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config rule 'rule_Allow_Ping' + option name 'Allow-Ping' + option src 'wan' + option proto 'icmp' + option family 'ipv4' + list icmp_type 'echo-request' + option target 'ACCEPT' + option enabled '0' + """ + ) + + def test_render_rule_3(self): + o = OpenWrt(self._rule_3_netjson) + expected = self._tabs(self._rule_3_uci) + self.assertEqual(o.render(), expected) + + def test_parse_rule_3(self): + o = OpenWrt(native=self._rule_3_uci) + self.assertEqual(o.config, self._rule_3_netjson) + + _rule_4_netjson = { + "firewall": { + "rules": [ + { + "name": "Allow-Isolated-DHCP", + "src": "isolated", + "proto": ["udp"], + "dest_port": "67-68", + "target": "ACCEPT", + } + ] + } + } + + _rule_4_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config rule 'rule_Allow_Isolated_DHCP' + option name 'Allow-Isolated-DHCP' + option src 'isolated' + option proto 'udp' + option dest_port '67-68' + option target 'ACCEPT' + """ + ) + + def test_render_rule_4(self): + o = OpenWrt(self._rule_4_netjson) + expected = self._tabs(self._rule_4_uci) + self.assertEqual(o.render(), expected) + + def test_parse_rule_4(self): + o = OpenWrt(native=self._rule_4_uci) + self.assertEqual(o.config, self._rule_4_netjson) + + _zone_1_netjson = { + "firewall": { + "zones": [ + { + "name": "lan", + "input": "ACCEPT", + "output": "ACCEPT", + "forward": "ACCEPT", + "network": ["lan"], + "mtu_fix": True, + } + ] + } + } + + _zone_1_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config zone 'zone_lan' + option name 'lan' + option input 'ACCEPT' + option output 'ACCEPT' + option forward 'ACCEPT' + option network 'lan' + option mtu_fix '1' + """ + ) + + def test_render_zone_1(self): + o = OpenWrt(self._zone_1_netjson) + expected = self._tabs(self._zone_1_uci) + self.assertEqual(o.render(), expected) + + def test_parse_zone_1(self): + o = OpenWrt(native=self._zone_1_uci) + self.assertEqual(o.config, self._zone_1_netjson) + + _zone_2_netjson = { + "firewall": { + "zones": [ + { + "name": "wan", + "input": "DROP", + "output": "ACCEPT", + "forward": "DROP", + "network": ["wan", "wan6"], + "mtu_fix": True, + "masq": True, + } + ] + } + } + + _zone_2_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config zone 'zone_wan' + option name 'wan' + option input 'DROP' + option output 'ACCEPT' + option forward 'DROP' + list network 'wan' + list network 'wan6' + option mtu_fix '1' + option masq '1' + """ + ) + + # This one is the same as _zone_2_uci with the exception that the "network" + # parameter is specified as a single string. + _zone_3_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config zone 'zone_wan' + option name 'wan' + option input 'DROP' + option output 'ACCEPT' + option forward 'DROP' + option network 'wan wan6' + option mtu_fix '1' + option masq '1' + """ + ) + + def test_render_zone_2(self): + o = OpenWrt(self._zone_2_netjson) + expected = self._tabs(self._zone_2_uci) + self.assertEqual(o.render(), expected) + + def test_parse_zone_2(self): + o = OpenWrt(native=self._zone_2_uci) + self.assertEqual(o.config, self._zone_2_netjson) + + def test_parse_zone_3(self): + o = OpenWrt(native=self._zone_3_uci) + self.assertEqual(o.config, self._zone_2_netjson) + + _forwarding_1_netjson = { + "firewall": {"forwardings": [{"src": "isolated", "dest": "wan"}]} + } + + _forwarding_1_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config forwarding 'forwarding_isolated_wan' + option src 'isolated' + option dest 'wan' + """ + ) + + def test_render_forwarding_1(self): + o = OpenWrt(self._forwarding_1_netjson) + expected = self._tabs(self._forwarding_1_uci) + self.assertEqual(o.render(), expected) + + def test_parse_forwarding_1(self): + o = OpenWrt(native=self._forwarding_1_uci) + self.assertEqual(o.config, self._forwarding_1_netjson) + + _forwarding_2_netjson = { + "firewall": { + "forwardings": [{"src": "isolated", "dest": "wan", "family": "ipv4"}] + } + } + + _forwarding_2_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config forwarding 'forwarding_isolated_wan_ipv4' + option src 'isolated' + option dest 'wan' + option family 'ipv4' + """ + ) + + def test_render_forwarding_2(self): + o = OpenWrt(self._forwarding_2_netjson) + expected = self._tabs(self._forwarding_2_uci) + self.assertEqual(o.render(), expected) + + def test_parse_forwarding_2(self): + o = OpenWrt(native=self._forwarding_2_uci) + self.assertEqual(o.config, self._forwarding_2_netjson) + + _forwarding_3_netjson = { + "firewall": {"forwardings": [{"src": "lan", "dest": "wan", "family": "any"}]} + } + + _forwarding_3_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config forwarding 'forwarding_lan_wan_any' + option src 'lan' + option dest 'wan' + option family 'any' + """ + ) + + def test_render_forwarding_3(self): + o = OpenWrt(self._forwarding_3_netjson) + expected = self._tabs(self._forwarding_3_uci) + self.assertEqual(o.render(), expected) + + def test_parse_forwarding_3(self): + o = OpenWrt(native=self._forwarding_3_uci) + self.assertEqual(o.config, self._forwarding_3_netjson) + + def test_forwarding_validation_error(self): + o = OpenWrt( + { + "firewall": { + "forwardings": [{"src": "lan", "dest": "wan", "family": "XXXXXX"}] + } + } + ) + with self.assertRaises(ValidationError): + o.validate() + + _redirect_1_netjson = { + "firewall": { + "redirects": [ + { + "name": "Adblock DNS, port 53", + "src": "lan", + "proto": ["tcp", "udp"], + "src_dport": "53", + "dest_port": "53", + "target": "DNAT", + } + ] + } + } + + _redirect_1_uci = textwrap.dedent( + """\ + package firewall + + config defaults 'defaults' + + config redirect 'redirect_Adblock DNS, port 53' + option name 'Adblock DNS, port 53' + option src 'lan' + option proto 'tcpudp' + option src_dport '53' + option dest_port '53' + option target 'DNAT' + """ + ) + + def test_render_redirect_1(self): + o = OpenWrt(self._redirect_1_netjson) + expected = self._tabs(self._redirect_1_uci) + self.assertEqual(o.render(), expected) + + def test_parse_redirect_1(self): + o = OpenWrt(native=self._redirect_1_uci) + self.assertEqual(o.config, self._redirect_1_netjson)