diff --git a/netjsonconfig/backends/openwrt/converters/firewall.py b/netjsonconfig/backends/openwrt/converters/firewall.py index e6298e2ba..c4567784d 100644 --- a/netjsonconfig/backends/openwrt/converters/firewall.py +++ b/netjsonconfig/backends/openwrt/converters/firewall.py @@ -14,16 +14,19 @@ class Firewall(OpenWrtConverter): netjson_key = "firewall" intermediate_key = "firewall" - _uci_types = ["defaults", "forwarding", "zone", "rule"] + _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 + result["firewall"] = ( + [self.sorted_dict(block)] + forwardings + zones + rules + redirects + ) return result def __intermediate_forwardings(self, forwardings): @@ -104,6 +107,37 @@ def __intermediate_rules(self, rules): 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", {}) @@ -122,6 +156,10 @@ def to_netjson_loop(self, block, result, index): 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) @@ -156,3 +194,14 @@ def __netjson_zone(self, 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/schema.py b/netjsonconfig/backends/openwrt/schema.py index e1b070f02..ec427ea2c 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, @@ -693,7 +706,8 @@ }, "enabled": { "type": "boolean", - "title": "enable rule", + "title": "enable", + "description": "Enable this rule.", "default": True, "format": "checkbox", "propertyOrder": 14, @@ -701,6 +715,257 @@ }, }, }, + "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, + "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, + }, + }, + }, + }, }, }, }, diff --git a/tests/openwrt/test_firewall.py b/tests/openwrt/test_firewall.py index 8190ac807..478336d0b 100644 --- a/tests/openwrt/test_firewall.py +++ b/tests/openwrt/test_firewall.py @@ -373,3 +373,43 @@ def test_forwarding_validation_error(self): ) 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)