From 5d416014861b2fee061318ba31f74e783c7d66b7 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Tue, 24 Sep 2024 14:26:16 -0400 Subject: [PATCH 1/4] [docs] Added FUNDING.yml [skip ci] --- .github/FUNDING.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..978d8488a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: ["https://openwisp.org/sponsorship/"] From 1ddddcfffdf5b936bc8e609eb953aaca4fae88b9 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 14 Oct 2024 20:58:43 +0530 Subject: [PATCH 2/4] [fix] Removed device configuration block for modemmanager interface #316 Fixes #316 --- docs/source/backends/openwrt.rst | 4 +++ .../backends/openwrt/converters/interfaces.py | 17 ++--------- netjsonconfig/backends/openwrt/schema.py | 28 ++++++++++++++++++- tests/openwrt/test_modemmanager_dsa.py | 10 ++++--- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/docs/source/backends/openwrt.rst b/docs/source/backends/openwrt.rst index 0b616d155..26aa9a146 100644 --- a/docs/source/backends/openwrt.rst +++ b/docs/source/backends/openwrt.rst @@ -1683,6 +1683,8 @@ The following *configuration dictionary*: "name": "modem0", "mtu": 1500, "signalrate": 5, + "loglevel": "ERR", + "force_link": True, } ] } @@ -1700,6 +1702,8 @@ Will be rendered as follows: config interface 'modem0' option apn 'apn.operator.com' option device '/sys/devices/platform/ahb/1b000000.usb/usb1/1-1' + option force_link '1' + option loglevel 'ERR' option lowpower '0' option metric '50' option password 'pwd123456' diff --git a/netjsonconfig/backends/openwrt/converters/interfaces.py b/netjsonconfig/backends/openwrt/converters/interfaces.py index f3f6ebd02..a4eef1eef 100644 --- a/netjsonconfig/backends/openwrt/converters/interfaces.py +++ b/netjsonconfig/backends/openwrt/converters/interfaces.py @@ -32,13 +32,12 @@ class Interfaces(OpenWrtConverter): } _device_config = {} _custom_protocols = ['ppp'] - _proto_dsa_conflict = ['modemmanager', 'modem-manager'] _interface_dsa_types = [ 'loopback', 'ethernet', 'bridge', 'wireless', - ] + _proto_dsa_conflict + ] def __set_dsa_interface(self, interface): """ @@ -452,18 +451,11 @@ def __netjson_interface(self, interface): def __get_device_config_for_interface(self, interface): device = interface.get('device') name = interface.get('name') - if not name and interface.get('proto') in self._proto_dsa_conflict: - name = interface.get('.name') device_config = self._device_config.get(device, self._device_config.get(name)) if not device_config: return device_config - if interface.get('proto') in self._proto_dsa_conflict: - del device_config['type'] # ifname has been renamed to device in OpenWrt 21.02 - if device_config.get('type') == 'bridge': - interface['ifname'] = interface.pop('device') - elif interface.get('proto') not in self._proto_dsa_conflict: - interface['ifname'] = interface.pop('device') + interface['ifname'] = interface.pop('device') return device_config def __netjson_dsa_interface(self, interface): @@ -472,10 +464,7 @@ def __netjson_dsa_interface(self, interface): else: device_config = self.__get_device_config_for_interface(interface) if device_config: - if ( - device_config.pop('bridge_21', None) - or interface.get('proto') in self._proto_dsa_conflict - ): + if device_config.pop('bridge_21', None): for option in device_config: if 'name' in option: continue diff --git a/netjsonconfig/backends/openwrt/schema.py b/netjsonconfig/backends/openwrt/schema.py index b7efb0c76..c8d1d4865 100644 --- a/netjsonconfig/backends/openwrt/schema.py +++ b/netjsonconfig/backends/openwrt/schema.py @@ -373,7 +373,33 @@ "propertyOrder": 1.9, "description": "singal refresh rate in seconds", }, - } + "force_link": { + "type": "boolean", + "title": "Force link", + "format": "checkbox", + "default": True, + "description": ( + "Set interface properties regardless of the link carrier" + " (If set, carrier sense events do not invoke hotplug handlers)." + ), + "propertyOrder": 1.11, + }, + "loglevel": { + "type": "string", + "title": "Log output level", + "default": "ERR", + "enum": ["ERR", "WARN", "INFO", "DEBUG"], + "options": { + "enum_titles": [ + "Error", + "Warning", + "Info", + "Debug", + ] + }, + "propertyOrder": 1.12, + }, + }, }, {"$ref": "#/definitions/base_interface_settings"}, ], diff --git a/tests/openwrt/test_modemmanager_dsa.py b/tests/openwrt/test_modemmanager_dsa.py index 34a47882e..e933f08c5 100644 --- a/tests/openwrt/test_modemmanager_dsa.py +++ b/tests/openwrt/test_modemmanager_dsa.py @@ -22,22 +22,24 @@ class TestModemManager(unittest.TestCase, _TabsMixin): "lowpower": False, "mtu": 1500, "signalrate": 5, + "loglevel": "ERR", + "force_link": True, } ] } _modemmanager_interface_uci = """package network -config device 'device_wwan0' - option mtu '1500' - option name 'wwan0' - config interface 'wwan0' option apn 'apn.vodafone.com' option device '/sys/devices/platform/ahb/1b000000.usb/usb1/1-1' + option force_link '1' + option ifname 'wwan0' option iptype 'ipv4v6' + option loglevel 'ERR' option lowpower '0' option metric '50' + option mtu '1500' option password 'pwd123456' option pincode '1234' option proto 'modemmanager' From f5115c69c6cdd4790490d1acafe5db50e74a031a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 5 Nov 2024 03:36:41 +0530 Subject: [PATCH 3/4] [feature] Added support for DSA #195 - Added VLAN 802.1q and VLAN 802.1ad interfaces Closes #195 --- docs/source/backends/openwrt.rst | 223 +++++++++++++++++ .../backends/openwrt/converters/interfaces.py | 226 ++++++++++++++--- netjsonconfig/backends/openwrt/openwrt.py | 30 ++- netjsonconfig/backends/openwrt/parser.py | 25 +- netjsonconfig/backends/openwrt/schema.py | 130 +++++++++- tests/openwrt/test_interfaces_dsa.py | 236 ++++++++++++++++++ 6 files changed, 816 insertions(+), 54 deletions(-) diff --git a/docs/source/backends/openwrt.rst b/docs/source/backends/openwrt.rst index 26aa9a146..f774f24b2 100644 --- a/docs/source/backends/openwrt.rst +++ b/docs/source/backends/openwrt.rst @@ -641,8 +641,42 @@ key name type default allowed values ``max_age`` integer ``20`` timeout in seconds until topology updates on link loss +``vlan_filtering`` list ``[]``` a list of ``dict ({})`` + defining VLANs for the + bridge + + Refer to the :ref:`VLAN + options table + ` below + for a list of available + options. =========================== ======= ========= ============================ +.. _bridge_vlan_options: + +VLAN options: + +========= ======= ======================================================= +key name type allowed values +========= ======= ======================================================= +``vlan`` integer VLAN ID +``ports`` list A list of ``dict`` defining interfaces participating in + the VLAN + + =============== ======= =============================== + key name type allowed values + =============== ======= =============================== + ``ifname`` string interface name (this interface + should be a bridge member) + ``tagging`` string whether the port is tagged + (``t``) or untagged (``u``) + ``primary_vid`` boolean whether the current VLAN should + be used for all untagged + incoming traffic on this + interface + =============== ======= =============================== +========= ======= ======================================================= + Bridge interface example ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -700,6 +734,89 @@ Will be rendered as follows: option netmask '255.255.255.0' option proto 'static' +Using VLAN Filtering on a Bridge +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following *configuration dictionary*: + +.. code-block:: python + + { + "interfaces": [ + { + "type": "bridge", + "bridge_members": ["lan1", "lan2", "lan3"], + "name": "br-lan", + "vlan_filtering": [ + { + "vlan": 1, + "ports": [ + { + "ifname": "lan1", + "tagging": "t", + "primary_vid": True, + }, + {"ifname": "lan2", "tagging": "t"}, + ], + }, + { + "vlan": 2, + "ports": [ + { + "ifname": "lan1", + "tagging": "t", + "primary_vid": False, + }, + { + "ifname": "lan3", + "tagging": "u", + "primary_vid": True, + }, + ], + }, + ], + } + ] + } + +Will be rendered as follows: + +.. code-block:: + + package network + + config device 'device_br_lan' + option name 'br-lan' + list ports 'lan1' + list ports 'lan2' + list ports 'lan3' + option type 'bridge' + option vlan_filtering '1' + + config bridge-vlan 'vlan_br_lan_1' + option device 'br-lan' + list ports 'lan1:t*' + list ports 'lan2:t' + option vlan '1' + + config bridge-vlan 'vlan_br_lan_2' + option device 'br-lan' + list ports 'lan1:t' + list ports 'lan3:u*' + option vlan '2' + + config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' + + config interface 'vlan_br_lan_2' + option device 'br-lan.2' + option proto 'none' + + config interface 'br_lan' + option device 'br-lan' + option proto 'none' + Wireless settings ----------------- @@ -1712,6 +1829,112 @@ Will be rendered as follows: option signalrate '5' option username 'user123' +VLAN 802.1q / VLAN 802.1ad settings +----------------------------------- + +.. note:: + + The configuration setting for **VLAN 802.1q** and **VLAN 802.1ad** are + exactly same, except the ``type`` setting. Hence, the documentation + only explains **VLAN 802.1q**. + +Interfaces of type ``vlan_8021q`` contain a few options that are specific +to VLAN 802.1q interfaces. + +These are the ``OpenWrt`` backend NetJSON extensions for VLAN 802.1q +interfaces: + +======================= ======= ============== =========================== +key name type default allowed values +======================= ======= ============== =========================== +``type`` string ``vlan_8021q`` type of interface + (``vlan_8021ad`` for VLAN + 802.1ad) +``vid`` integer empty VLAN ID +``ingress_qos_mapping`` string empty Defines a mapping of VLAN + header priority to the + Linux internal packet + priority on incoming frames +``egress_qos_mapping`` string empty Defines a mapping of Linux + internal packet priority to + VLAN header priority but + for outgoing frames +======================= ======= ============== =========================== + +VLAN 802.1q example +~~~~~~~~~~~~~~~~~~~ + +The following *configuration dictionary*: + +.. code-block:: python + + { + "interfaces": [ + { + "type": "8021q", + "vid": 1, + "name": "br-lan", + "mac": "E8:6A:64:3E:4A:3A", + "mtu": 1500, + "ingress_qos_mapping": ["1:1"], + "egress_qos_mapping": ["2:2"], + } + ] + } + +Will be rendered as follows: + +.. code-block:: text + + package network + + config device 'device_br_lan_1' + list egress_qos_mapping '2:2' + option ifname 'br-lan' + list ingress_qos_mapping '1:1' + option macaddr 'E8:6A:64:3E:4A:3A' + option mtu '1500' + option name 'br-lan.1' + option type '8021q' + option vid '1' + + config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' + +VLAN 802.1ad example +~~~~~~~~~~~~~~~~~~~~ + +The following *configuration dictionary*: + +.. code-block:: python + + { + "interfaces": [ + { + "type": "8021ad", + "vid": 6, + "name": "eth0", + } + ] + } + +Will be rendered as follows: + +.. code-block:: text + + package network + + config device 'device_eth0_6' + option ifname 'eth0' + option name 'eth0.6' + option type '8021ad' + option vid '6' + + config interface 'vlan_eth0_6' + option device 'eth0.6' + option proto 'none' + Radio settings -------------- diff --git a/netjsonconfig/backends/openwrt/converters/interfaces.py b/netjsonconfig/backends/openwrt/converters/interfaces.py index a4eef1eef..c07681400 100644 --- a/netjsonconfig/backends/openwrt/converters/interfaces.py +++ b/netjsonconfig/backends/openwrt/converters/interfaces.py @@ -30,15 +30,21 @@ class Interfaces(OpenWrtConverter): ], 'all': ['vlan_filtering', 'macaddr', 'mtu'], } - _device_config = {} _custom_protocols = ['ppp'] _interface_dsa_types = [ 'loopback', 'ethernet', 'bridge', 'wireless', + '8021q', + '8021ad', ] + def __init__(self, backend): + super().__init__(backend) + self._device_config = {} + self._bridge_vlan_config_uci = [] + def __set_dsa_interface(self, interface): """ sets dsa interface property to manage new syntax introduced @@ -48,6 +54,8 @@ def __set_dsa_interface(self, interface): self.dsa and interface.get('proto', None) not in self._custom_protocols and interface.get('type', None) in self._interface_dsa_types + and interface.get('ifname', interface.get('device')) + not in self._bridge_vlan_config_uci ) def to_intermediate_loop(self, block, result, index=None): @@ -56,10 +64,22 @@ def to_intermediate_loop(self, block, result, index=None): interface = self.__intermediate_interface(block, uci_name) self.__set_dsa_interface(interface) if self.dsa_interface: + vlan_list = interface.pop('vlan_filtering', []) + if vlan_list: + interface['vlan_filtering'] = True uci_device = self.__intermediate_device(interface, address_list) if uci_device: result.setdefault('network', []) result['network'].append(self.sorted_dict(uci_device)) + uci_vlan_interfaces = [] + for vlan in vlan_list: + uci_vlan, uci_vlan_interface = self.__intermediate_vlan( + uci_name, interface, vlan + ) + result['network'].append(self.sorted_dict(uci_vlan)) + uci_vlan_interfaces.append(uci_vlan_interface) + for uci_interface in uci_vlan_interfaces: + result['network'].append(self.sorted_dict(uci_interface)) # create one or more "config interface" UCI blocks i = 1 for address in address_list: @@ -155,8 +175,6 @@ def __intermediate_interface(self, interface, uci_name): """ interface.update({'.type': 'interface', '.name': uci_name}) interface['ifname'] = interface.pop('name') - if 'network' in interface: - del interface['network'] if 'mac' in interface: # mac address of wireless interface must # be set in /etc/config/wireless, therfore @@ -179,6 +197,20 @@ def __intermediate_interface(self, interface, uci_name): method = getattr(self, f'_intermediate_{type_}', None) if method: interface = method(interface) + self._check_bridge_vlan(interface) + if 'network' in interface: + del interface['network'] + return interface + + def _check_bridge_vlan(self, interface): + if self.dsa: + if ( + '.' in interface.get('ifname', '') + and interface['ifname'] in self._bridge_vlan_config_uci + ): + # Cleans L2 options from the interface + self._add_l2_options({}, interface) + interface['device'] = interface.pop('ifname') return interface def _intermediate_modem_manager(self, interface): @@ -198,6 +230,19 @@ def _intermediate_vxlan(self, interface): interface['vid'] = interface.pop('vni') return interface + def _intermediate_8021_vlan(self, interface): + interface['name'] = '{}.{}'.format(interface['ifname'], interface['vid']) + interface['.name'] = interface.get( + 'network', 'vlan_{}_{}'.format(interface['.name'], interface['vid']) + ) + return interface + + def _intermediate_8021q(self, interface): + return self._intermediate_8021_vlan(interface) + + def _intermediate_8021ad(self, interface): + return self._intermediate_8021_vlan(interface) + _address_keys = ['address', 'mask', 'family', 'gateway'] def __intermediate_address(self, address): @@ -209,6 +254,39 @@ def __intermediate_address(self, address): del address[key] return address + def __intermediate_vlan(self, uci_name, interface, vlan): + vid = vlan['vlan'] + uci_vlan = { + '.type': 'bridge-vlan', + '.name': f'{uci_name}_{vid}', + 'vlan': vid, + 'device': interface['ifname'], + } + if uci_name == self._get_uci_name(interface['ifname']): + uci_vlan['.name'] = 'vlan_{}'.format(uci_vlan['.name']) + uci_vlan_interface = { + '.type': 'interface', + '.name': uci_vlan['.name'], + 'device': '{ifname}.{vid}'.format(ifname=interface['ifname'], vid=vid), + 'proto': 'none', + } + if 'ports' in vlan: + uci_vlan['ports'] = [] + for port in vlan.get('ports'): + tagging = '' + pvid = '' + if port.get('tagging'): + tagging = ':{tagging}'.format(tagging=port['tagging']) + if port.get('primary_vid'): + pvid = '*' + uci_vlan['ports'].append( + '{ifname}{tagging}{pvid}'.format( + ifname=port['ifname'], tagging=tagging, pvid=pvid + ) + ) + self._bridge_vlan_config_uci.append(uci_vlan_interface['device']) + return uci_vlan, uci_vlan_interface + def __intermediate_device(self, interface, address_list): """ Converts NetJSON bridge to intermediate @@ -228,8 +306,21 @@ def __intermediate_device(self, interface, address_list): # Add 'device' option in related interface configuration if not interface.get('device', None): interface['device'] = device['name'] - - if interface['type'] != 'bridge': + interface_type = interface['type'] + if interface_type.startswith('8021'): + device.update( + { + 'type': interface['type'], + 'vid': interface.pop('vid'), + 'name': interface.pop('name'), + '.name': 'device_{}'.format(interface['.name'].lstrip('vlan_')), + 'ifname': interface.pop('ifname'), + 'ingress_qos_mapping': interface.pop('ingress_qos_mapping', []), + 'egress_qos_mapping': interface.pop('egress_qos_mapping', []), + } + ) + interface['device'] = device['name'] + if interface_type != 'bridge': # A non-bridge interface that contains L2 options. if device == base: return {} @@ -419,16 +510,22 @@ def to_netjson_loop(self, block, result, index): elif _type == 'interface': if self.dsa: block = self.__netjson_dsa_interface(block) - if not self.__is_device_config(block) and not block.get('bridge_21', None): + if ( + block + and not self.__is_device_config(block) + and not block.get('bridge_21', None) + ): interface = self.__netjson_interface(block) - self.__netjson_dns(interface, result) - result.setdefault('interfaces', []) - result['interfaces'].append(interface) + if interface: + self.__netjson_dns(interface, result) + result.setdefault('interfaces', []) + result['interfaces'].append(interface) return result def __netjson_interface(self, interface): del interface['.type'] interface['network'] = interface.pop('.name') + interface['device_name'] = interface.get('name') interface['name'] = interface.pop('ifname', interface['network']) interface['type'] = self.__netjson_type(interface) interface = self.__netjson_addresses(interface) @@ -438,6 +535,8 @@ def __netjson_interface(self, interface): interface['disabled'] = interface.pop('enabled') == '0' if 'mtu' in interface: interface['mtu'] = int(interface['mtu']) + if 'vid' in interface: + interface['vid'] = int(interface['vid']) if 'macaddr' in interface: interface['mac'] = interface.pop('macaddr') if interface['network'] == self._get_uci_name(interface['name']): @@ -449,38 +548,64 @@ def __netjson_interface(self, interface): return interface def __get_device_config_for_interface(self, interface): - device = interface.get('device') + device = interface.get('device', '') name = interface.get('name') device_config = self._device_config.get(device, self._device_config.get(name)) if not device_config: + if '.' in device: + cleaned_device, _, _ = device.rpartition('.') + device_config = self._device_config.get(cleaned_device) + if not device_config: + return device_config + if interface.get('type') == 'bridge-vlan': return device_config # ifname has been renamed to device in OpenWrt 21.02 interface['ifname'] = interface.pop('device') return device_config + def __update_interface_device_config(self, interface, device_config): + if interface.get('type') == 'bridge-vlan': + return self.__netjson_vlan(interface, device_config) + interface = self._handle_bridge_vlan(interface, device_config) + if not interface: + return + if device_config.pop('bridge_21', None): + for option in device_config: + # ifname has been renamed to ports in OpenWrt 21.02 bridge + if option == 'ports': + interface['ifname'] = ' '.join(device_config[option]) + else: + interface[option] = device_config[option] + # Merging L2 options to interface + for options in ( + self._bridge_interface_options['all'] + + self._bridge_interface_options['stp'] + + self._bridge_interface_options['igmp_snooping'] + ): + if options in device_config: + interface[options] = device_config.pop(options) + if device_config.get('type', '').startswith('8021'): + interface['ifname'] = ''.join(device_config['name'].split('.')[:-1]) + return interface + + def _handle_bridge_vlan(self, interface, device_config): + if '.' in interface.get('ifname', ''): + _, _, vlan_id = interface['ifname'].rpartition('.') + if device_config.get('vlan_filtering', []): + for vlan in device_config['vlan_filtering']: + if vlan['vlan'] == int(vlan_id): + return + return interface + def __netjson_dsa_interface(self, interface): if self.__is_device_config(interface) or interface.get('bridge_21', None): self.__netjson_device(interface) else: device_config = self.__get_device_config_for_interface(interface) if device_config: - if device_config.pop('bridge_21', None): - for option in device_config: - if 'name' in option: - continue - # ifname has been renamed to ports in OpenWrt 21.02 bridge - if option == 'ports': - interface['ifname'] = ' '.join(device_config[option]) - else: - interface[option] = device_config[option] - # Merging L2 options to interface - for option in ( - self._bridge_interface_options['all'] - + self._bridge_interface_options['stp'] - + self._bridge_interface_options['igmp_snooping'] - ): - if option in device_config: - interface[option] = device_config.pop(option) + interface = self.__update_interface_device_config( + interface, device_config + ) # if device_config is empty but the interface references it elif 'device' in interface and 'ifname' not in interface: # .name may have '.' substituted with _, @@ -524,16 +649,44 @@ def __netjson_device(self, interface): name = interface.get('name') self._device_config[name] = interface + def __netjson_vlan(self, vlan, device_config): + netjson_vlan = {'vlan': int(vlan['vlan']), 'ports': []} + for port in vlan.get('ports', []): + port_config = port.split(':') + port = {'ifname': port_config[0]} + tagging = port_config[1][0] + pvid = False + if len(port_config[1]) > 1: + pvid = True + port.update( + { + 'tagging': tagging, + 'primary_vid': pvid, + } + ) + netjson_vlan['ports'].append(port) + if isinstance(device_config['vlan_filtering'], list): + device_config['vlan_filtering'].append(netjson_vlan) + else: + device_config['vlan_filtering'] = [netjson_vlan] + return + def __netjson_type(self, interface): - if 'type' in interface and interface['type'] == 'bridge': - interface['bridge_members'] = interface['name'].split() - interface['name'] = 'br-{0}'.format(interface['network']) - # cleanup automatically generated "br_" network prefix - interface['name'] = interface['name'].replace('br_', '') - self.__netjson_bridge_typecast(interface) - if interface.pop('bridge_empty', None) == '1': - interface['bridge_members'] = [] - return 'bridge' + device_name = interface.pop('device_name', None) + if 'type' in interface: + if interface['type'] == 'bridge': + interface['bridge_members'] = interface['name'].split() + interface['name'] = device_name or interface['network'] + if not interface['name'].startswith('br-'): + interface['name'] = 'br-{0}'.format(interface['name']) + # cleanup automatically generated "br_" network prefix + interface['name'] = interface['name'].replace('br_', '') + self.__netjson_bridge_typecast(interface) + if interface.pop('bridge_empty', None) == '1': + interface['bridge_members'] = [] + return 'bridge' + if interface['type'].startswith('802'): + return interface['type'] if interface['name'] in ['lo', 'lo0', 'loopback']: return 'loopback' return 'ethernet' @@ -543,7 +696,6 @@ def __netjson_bridge_typecast(self, interface): 'stp', 'igmp_snooping', 'multicast_querier', - 'vlan_filtering', ]: if option in interface: interface[option] = interface[option] == '1' diff --git a/netjsonconfig/backends/openwrt/openwrt.py b/netjsonconfig/backends/openwrt/openwrt.py index 36a788ba9..956d7f1ae 100644 --- a/netjsonconfig/backends/openwrt/openwrt.py +++ b/netjsonconfig/backends/openwrt/openwrt.py @@ -1,5 +1,6 @@ -from jsonschema.exceptions import ValidationError +from jsonschema import ValidationError as JsonSchemaError +from ...exceptions import ValidationError from ..base.backend import BaseBackend from ..vxlan.vxlan_wireguard import VxlanWireguard from ..wireguard.wireguard import Wireguard @@ -54,6 +55,27 @@ def __init__( self.dsa = dsa super().__init__(config, native, templates, context) + def validate(self): + self._validate_radios() + super().validate() + # When VLAN filtering is enabled on a "bridge" interfaces, + # primary VLAN ID can be set for only one VLAN. + for index, interface in enumerate(self.config.get('interfaces', [])): + pvid_mapping = [] + if interface.get('type') != 'bridge': + continue + for vlan in interface.get('vlan_filtering', []): + for port in vlan.get('ports', []): + if port.get('primary_vid', False): + if port['ifname'] in pvid_mapping: + raise ValidationError( + JsonSchemaError( + f'Invalid configuration triggered by "#/interfaces/{index}"' + ' says: Primary VID can be set only one VLAN for a port.' + ) + ) + pvid_mapping.append(port['ifname']) + def _generate_contents(self, tar): """ Adds configuration files to tarfile instance. @@ -149,10 +171,6 @@ def zerotier_auto_client(cls, **kwargs): data = ZeroTier.auto_client(**kwargs) return {'zerotier': [data]} - def validate(self): - self._validate_radios() - super().validate() - def _validate_radios(self): # We use "hwmode" or "band" property of "radio" configuration # to predict the radio frequency. If both of these @@ -168,7 +186,7 @@ def _validate_radios(self): and radio.get('hwmode') is None and radio.get('channel') == 0 ): - raise ValidationError( + raise JsonSchemaError( '"channel" cannot be set to "auto" when' ' "hwmode" or "band" property is not configured.' ) diff --git a/netjsonconfig/backends/openwrt/parser.py b/netjsonconfig/backends/openwrt/parser.py index 248411948..a26d64c16 100644 --- a/netjsonconfig/backends/openwrt/parser.py +++ b/netjsonconfig/backends/openwrt/parser.py @@ -81,15 +81,20 @@ def _get_uci_blocks(self, text): # list options else: block[key] = block.get(key, []) + [value] - # The new bridge syntax of OpenWrt moved "bridges" - # under "device" config_type. netjsonconfig - # process bridges using the interface converter, - # therefore we need to update block type here. - if block['.type'] == 'device': - block['.type'] = 'interface' - if block.get('type') == 'bridge': - block['bridge_21'] = True - else: - block['type'] = 'device' + self._set_uci_block_type(block) blocks.append(sorted_dict(block)) return blocks + + def _set_uci_block_type(self, block): + # The new bridge syntax of OpenWrt moved "bridges" + # under "device" config_type. netjsonconfig + # process bridges using the interface converter, + # therefore we need to update block type here. + if block['.type'] in ['device', 'bridge-vlan']: + if block.get('type') in ['bridge', '8021q', '8021ad']: + block['bridge_21'] = True + elif block['.type'] == 'bridge-vlan': + block['type'] = 'bridge-vlan' + elif not block.get('type', None): + block['type'] = 'device' + block['.type'] = 'interface' diff --git a/netjsonconfig/backends/openwrt/schema.py b/netjsonconfig/backends/openwrt/schema.py index c8d1d4865..6c275fd18 100644 --- a/netjsonconfig/backends/openwrt/schema.py +++ b/netjsonconfig/backends/openwrt/schema.py @@ -7,13 +7,14 @@ from ..wireguard.schema import base_wireguard_schema from .timezones import timezones +QOS_MAPPING_PATTERN = "^[0-9]\d*:[0-9]\d*$" + default_radio_driver = "mac80211" wireguard = base_wireguard_schema["properties"]["wireguard"]["items"]["properties"] wireguard_peers = wireguard["peers"]["items"]["properties"] interface_settings = default_schema["definitions"]["interface_settings"]["properties"] - schema = merge_config( default_schema, { @@ -30,6 +31,49 @@ } } }, + "vlan_interface_settings": { + "properties": { + "name": {"title": "Base device"}, + "vid": { + "type": "integer", + "title": "VLAN ID", + "propertyOrder": 2, + "minimum": 0, + }, + "ingress_qos_mapping": { + "type": "array", + "title": "Ingress QoS mapping", + "description": ( + "Defines a mapping of VLAN header priority to the Linux" + " internal packet priority on incoming frames" + ), + "uniqueItems": True, + "additionalItems": False, + "items": { + "title": "Mapping", + "type": "string", + "pattern": QOS_MAPPING_PATTERN, + }, + "propertyOrder": 18, + }, + "egress_qos_mapping": { + "type": "array", + "title": "Egress QoS mapping", + "description": ( + "Defines a mapping of Linux internal packet priority to VLAN header" + " priority but for outgoing frames" + ), + "uniqueItems": True, + "additionalItems": False, + "items": { + "title": "Mapping", + "type": "string", + "pattern": QOS_MAPPING_PATTERN, + }, + "propertyOrder": 19, + }, + } + }, "wireless_interface": { "properties": { "wireless": { @@ -264,10 +308,92 @@ "maximum": 40, "propertyOrder": 4, }, + "vlan_filtering": { + "type": "array", + "title": "VLAN Filtering", + "items": { + "type": "object", + "properties": { + "vlan": { + "title": "VLAN", + "type": "integer", + "minimum": 0, + }, + "ports": { + "title": "Ports", + "type": "array", + "items": { + "type": "object", + "required": ["ifname", "tagging"], + "properties": { + "ifname": { + "type": "string", + }, + "tagging": { + "type": "string", + "enum": ["t", "u"], + "options": { + "enum_titles": [ + "Egress tagged", + "Egress untagged", + ] + }, + }, + "primary_vid": { + "type": "boolean", + "title": "Primary VID", + "format": "checkbox", + }, + }, + }, + }, + }, + }, + }, } } ] }, + "vlan_8021q": { + "title": "VLAN (802.1q)", + "type": "object", + "required": ["type", "vid"], + "allOf": [ + { + "properties": { + "type": { + "type": "string", + "enum": ["8021q"], + "default": "8021q", + "propertyOrder": 1, + }, + } + }, + {"$ref": "#/definitions/base_interface_settings"}, + {"$ref": "#/definitions/interface_settings"}, + {"$ref": "#/definitions/vlan_interface_settings"}, + ], + }, + "vlan_8021ad": { + "title": "VLAN (802.1ad)", + "type": "object", + "required": ["type", "vid"], + "allOf": [ + { + "properties": { + "type": { + "type": "string", + "enum": ["8021ad"], + "default": "8021ad", + "propertyOrder": 1, + }, + } + }, + {"$ref": "#/definitions/base_interface_settings"}, + {"$ref": "#/definitions/interface_settings"}, + {"$ref": "#/definitions/vlan_interface_settings"}, + ], + }, "dialup_interface": { "title": "Dialup interface", "required": ["proto", "username", "password"], @@ -788,6 +914,8 @@ {"$ref": "#/definitions/modemmanager_interface"}, {"$ref": "#/definitions/vxlan_interface"}, {"$ref": "#/definitions/wireguard_interface"}, + {"$ref": "#/definitions/vlan_8021q"}, + {"$ref": "#/definitions/vlan_8021ad"}, ] } }, diff --git a/tests/openwrt/test_interfaces_dsa.py b/tests/openwrt/test_interfaces_dsa.py index 427167267..5e3d52443 100644 --- a/tests/openwrt/test_interfaces_dsa.py +++ b/tests/openwrt/test_interfaces_dsa.py @@ -1110,6 +1110,171 @@ def test_parse_l2_options_interface(self): o = OpenWrt(native=self._l2_options_interface_uci) self.assertEqual(o.config, self._l2_options_interface_netjson) + _vlan_filtering_bridge_netjson = { + "interfaces": [ + { + "type": "bridge", + "bridge_members": ["lan1", "lan2", "lan3"], + "name": "br-lan", + "network": "home_vlan", + "vlan_filtering": [ + { + "vlan": 1, + "ports": [ + {"ifname": "lan1", "tagging": "t", "primary_vid": True}, + {"ifname": "lan2", "tagging": "t"}, + ], + }, + { + "vlan": 2, + "ports": [ + {"ifname": "lan1", "tagging": "t", "primary_vid": False}, + {"ifname": "lan3", "tagging": "u", "primary_vid": True}, + ], + }, + ], + } + ] + } + + _vlan_filtering_bridge_uci = """package network + +config device 'device_home_vlan' + option name 'br-lan' + list ports 'lan1' + list ports 'lan2' + list ports 'lan3' + option type 'bridge' + option vlan_filtering '1' + +config bridge-vlan 'home_vlan_1' + option device 'br-lan' + list ports 'lan1:t*' + list ports 'lan2:t' + option vlan '1' + +config bridge-vlan 'home_vlan_2' + option device 'br-lan' + list ports 'lan1:t' + list ports 'lan3:u*' + option vlan '2' + +config interface 'home_vlan_1' + option device 'br-lan.1' + option proto 'none' + +config interface 'home_vlan_2' + option device 'br-lan.2' + option proto 'none' + +config interface 'home_vlan' + option device 'br-lan' + option proto 'none' +""" + + def test_render_bridge_vlan_filtering(self): + o = OpenWrt(self._vlan_filtering_bridge_netjson) + self.assertEqual(self._tabs(self._vlan_filtering_bridge_uci), o.render()) + + with self.subTest('Test setting PVID on same port on different VLANS'): + netjson = deepcopy(self._vlan_filtering_bridge_netjson) + netjson['interfaces'][0]['vlan_filtering'][1]['ports'][0][ + 'primary_vid' + ] = True + with self.assertRaises(ValidationError) as error: + OpenWrt(netjson).validate() + self.assertEqual( + error.exception.message, + ( + 'Invalid configuration triggered by "#/interfaces/0"' + ' says: Primary VID can be set only one VLAN for a port.' + ), + ) + + def test_parse_bridge_vlan_filtering(self): + o = OpenWrt(native=self._vlan_filtering_bridge_uci) + expected = deepcopy(self._vlan_filtering_bridge_netjson) + expected['interfaces'][0]['vlan_filtering'][0]['ports'][1][ + 'primary_vid' + ] = False + self.assertEqual(o.config, expected) + + _vlan_filtering_bridge_override_netjson = { + "interfaces": [ + { + "type": "bridge", + "bridge_members": ["lan1", "lan2", "lan3"], + "name": "br-lan", + "vlan_filtering": [ + { + "vlan": 1, + "ports": [ + {"ifname": "lan1", "tagging": "t", "primary_vid": False}, + {"ifname": "lan2", "tagging": "u", "primary_vid": False}, + ], + } + ], + }, + { + "type": "ethernet", + "name": "br-lan.1", + "mtu": 1500, + "mac": "61:4A:A0:D7:3F:0E", + "addresses": [ + { + "proto": "static", + "family": "ipv4", + "address": "192.168.2.1", + "mask": 24, + } + ], + }, + ] + } + _vlan_filtering_bridge_override_uci = """package network + +config device 'device_br_lan' + option name 'br-lan' + list ports 'lan1' + list ports 'lan2' + list ports 'lan3' + option type 'bridge' + option vlan_filtering '1' + +config bridge-vlan 'vlan_br_lan_1' + option device 'br-lan' + list ports 'lan1:t' + list ports 'lan2:u' + option vlan '1' + +config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' + +config interface 'br_lan' + option device 'br-lan' + option proto 'none' + +config interface 'br_lan_1' + option device 'br-lan.1' + option ipaddr '192.168.2.1' + option netmask '255.255.255.0' + option proto 'static' +""" + + def test_render_bridge_vlan_filtering_override_interface(self): + o = OpenWrt(self._vlan_filtering_bridge_override_netjson) + self.assertEqual( + self._tabs(self._vlan_filtering_bridge_override_uci), o.render() + ) + + def test_parse_bridge_vlan_filtering_override_interface(self): + o = OpenWrt(native=self._vlan_filtering_bridge_override_uci) + expected = deepcopy(self._vlan_filtering_bridge_override_netjson) + del expected['interfaces'][1]['mtu'] + del expected['interfaces'][1]['mac'] + self.assertEqual(o.config, expected) + def test_render_dns(self): o = OpenWrt( { @@ -1821,3 +1986,74 @@ def test_empty_dns(self): """ ) self.assertEqual(o.render(), expected) + + _vlan8021q_netjson = { + "interfaces": [ + { + "type": "8021q", + "vid": 1, + "name": "br-lan", + "mac": "E8:6A:64:3E:4A:3A", + "mtu": 1500, + "ingress_qos_mapping": ["1:1"], + "egress_qos_mapping": ["2:2"], + } + ] + } + + _vlan8021q_uci = """package network + +config device 'device_br_lan_1' + list egress_qos_mapping '2:2' + option ifname 'br-lan' + list ingress_qos_mapping '1:1' + option macaddr 'E8:6A:64:3E:4A:3A' + option mtu '1500' + option name 'br-lan.1' + option type '8021q' + option vid '1' + +config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' +""" + + def test_render_vlan8021q(self): + o = OpenWrt(self._vlan8021q_netjson) + expected = self._tabs(self._vlan8021q_uci) + self.assertEqual(o.render(), expected) + + def test_parse_vlan8021q(self): + o = OpenWrt(native=self._tabs(self._vlan8021q_uci)) + expected = deepcopy(self._vlan8021q_netjson) + expected['interfaces'][0]['network'] = 'vlan_br_lan_1' + self.assertEqual(expected, o.config) + + _vlan8021ad_netjson = { + "interfaces": [ + {"type": "8021ad", "vid": 6, "name": "eth0", "network": "iot_vlan"} + ] + } + + _vlan8021ad_uci = """package network + +config device 'device_iot_vlan' + option ifname 'eth0' + option name 'eth0.6' + option type '8021ad' + option vid '6' + +config interface 'iot_vlan' + option device 'eth0.6' + option proto 'none' +""" + + def test_render_vlan8021ad(self): + o = OpenWrt(self._vlan8021ad_netjson) + expected = self._tabs(self._vlan8021ad_uci) + self.assertEqual(o.render(), expected) + + def test_parse_vlan8021ad(self): + o = OpenWrt(native=self._tabs(self._vlan8021ad_uci)) + expected = deepcopy(self._vlan8021ad_netjson) + self.assertEqual(expected, o.config) From 8afbb38212f6d5dd4563193f77fc1fdd2945ae02 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 5 Nov 2024 03:52:23 +0530 Subject: [PATCH 4/4] [feature] Added support for ZeroTier > 1.14 #312 Closes #312 --- docs/source/backends/zerotier.rst | 30 +++- .../backends/openwrt/converters/zerotier.py | 67 ++++++- netjsonconfig/backends/openwrt/schema.py | 40 ++++- netjsonconfig/backends/zerotier/converters.py | 1 + netjsonconfig/backends/zerotier/schema.py | 39 +++++ netjsonconfig/backends/zerotier/zerotier.py | 4 + tests/openwrt/test_zerotier.py | 165 ++++++++++-------- tests/zerotier/test_backend.py | 6 + 8 files changed, 264 insertions(+), 88 deletions(-) diff --git a/docs/source/backends/zerotier.rst b/docs/source/backends/zerotier.rst index 7b8e90ae0..cb7afdda8 100644 --- a/docs/source/backends/zerotier.rst +++ b/docs/source/backends/zerotier.rst @@ -83,6 +83,12 @@ See an example of initialization and rendering below: "tags": [{"default": 1, "id": 1}], "remoteTraceTarget": "7f5d90eb87", "remoteTraceLevel": 1, + "client_options": { + "allow_managed": True, + "allowed_global": False, + "allowed_default": False, + "allowed_dns": False, + }, } ] } @@ -268,6 +274,20 @@ key name type default description ``tags`` list ``[{}]`` list of network tags dictionaries ``remoteTraceTarget`` string remote target ID for network tracing ``remoteTraceLevel`` integer level of network tracing +``client_options`` dict ``{}`` These options are only used for client configurations + + =================== ======= ========================================== + key name type description + =================== ======= ========================================== + ``allow_managed`` boolean allow ZeroTier to set IP addresses and + routes + ``allowed_global`` boolean allow ZeroTier to set + global/public/not-private range IPs and + routes + ``allowed_default`` boolean allow ZeroTier to set the default route on + the system + ``allowed_dns`` boolean allow ZeroTier to set DNS servers + =================== ======= ========================================== ===================== ======= =========== ======================================================================= Client specific settings @@ -314,7 +334,7 @@ key name type default description determined ``port`` integer ``9993`` port number of the zerotier service -``local_conf`` string path of the local +``local_conf_path`` string path of the local zerotier configuration (only used for advanced configuration) @@ -404,8 +424,8 @@ OpenWrt device, such as setting up trusted paths, blacklisting physical paths, setting up physical path hints for certain nodes, and defining trusted upstream devices, this can be achieved by creating a file named ``local.conf`` in a persistent filesystem location, such as -``/etc/openwisp/zerotier/local.conf`` and then adding the ``local_conf`` -option to the ZeroTier UCI configuration. +``/etc/openwisp/zerotier/local.conf`` and then adding the +``local_conf_path`` option to the ZeroTier UCI configuration. For example, let's create a local configuration file at ``/etc/openwisp/zerotier/local.conf`` (JSON) to blacklist a specific @@ -421,7 +441,7 @@ physical network path **(10.0.0.0/24)** from all ZeroTier traffic. } } -Now add ``local_conf`` option to ``/etc/config/zerotier``: +Now add ``local_conf_path`` option to ``/etc/config/zerotier``: .. code-block:: text @@ -431,7 +451,7 @@ Now add ``local_conf`` option to ``/etc/config/zerotier``: option enabled '1' list join '9536600adf654322' option secret '{{secret}}' - option local_conf '/etc/openwisp/zerotier/local.conf' + option local_conf_path '/etc/openwisp/zerotier/local.conf' **More information** diff --git a/netjsonconfig/backends/openwrt/converters/zerotier.py b/netjsonconfig/backends/openwrt/converters/zerotier.py index 5f5620c08..78e1b5de0 100644 --- a/netjsonconfig/backends/openwrt/converters/zerotier.py +++ b/netjsonconfig/backends/openwrt/converters/zerotier.py @@ -4,9 +4,18 @@ class ZeroTier(OpenWrtConverter, BaseZeroTier): - _uci_types = ['zerotier'] + _uci_types = ['zerotier', 'network'] _schema = schema['properties']['zerotier']['items'] + def to_intermediate_loop(self, block, result, index=None): + vpn = self.__intermediate_vpn(block) + networks = vpn.pop('networks') + result.setdefault('zerotier', []) + result['zerotier'].append(vpn) + for network in networks: + result['zerotier'].append(self.__intermediate_network(network)) + return result + def __intermediate_vpn(self, vpn): nwid_ifnames = vpn.get('networks', []) files = self.netjson.get('files', []) @@ -21,18 +30,68 @@ def __intermediate_vpn(self, vpn): 'enabled': not vpn.pop('disabled', False), } ) - del vpn['networks'] + if vpn.get('local_conf'): + vpn['local_conf_path'] = vpn.get('local_conf') + elif vpn.get('local_conf_path'): + vpn['local_conf'] = vpn.get('local_conf_path') return super().__intermediate_vpn(vpn, remove=['']) + def __intermediate_network(self, network): + # Generates configuration for ZeroTier > 1.14 + # where networks are defined in individual blocks. + network.update( + { + '.name': self._get_uci_name(network.pop('ifname')), + '.type': 'network', + } + ) + return self.sorted_dict(network) + + def to_netjson_loop(self, block, result, index=None): + if block.get('.type') == 'zerotier': + vpn = self.__netjson_vpn(block) + result.setdefault('zerotier', []) + result['zerotier'].append(vpn) + else: + # Handles ZeroTier > 1.14 configuration where + # networks are defined in individual blocks. + network = self.__netjson_network(block) + result['zerotier'][0]['networks'].append(network) + return result + def __netjson_vpn(self, vpn): - nwids = vpn.pop('join') vpn['name'] = vpn.pop('.name') - vpn['networks'] = [{"id": nwid, "ifname": f"owzt{nwid[-6:]}"} for nwid in nwids] # 'disabled' defaults to False in OpenWRT vpn['disabled'] = vpn.pop('enabled', '0') == '0' del vpn['.type'] + # Handles ZeroTier < 1.14 configuration where networks were present + # in the zerotier block. + nwids = vpn.pop('join', []) + vpn['networks'] = [ + {"id": nwid, "ifname": self._get_ifname_from_id(nwid)} for nwid in nwids + ] + if 'local_conf' in vpn: + vpn['local_conf_path'] = vpn.pop('local_conf') return super().__netjson_vpn(vpn) + def __netjson_network(self, network): + for key in ['.name', '.type']: + network.pop(key) + network['ifname'] = self._get_ifname_from_id(network['id']) + # Handle boolean fields + if 'allowed_global' in network: + network['allowed_global'] = network['allowed_global'] == '1' + if 'allowed_default' in network: + network['allowed_default'] = network['allowed_default'] == '1' + if 'allowed_dns' in network: + network['allowed_dns'] = network['allowed_dns'] == '1' + if 'allow_managed' in network: + network['allow_managed'] = network['allow_managed'] == '1' + return network + + def _get_ifname_from_id(self, network_id): + return f"owzt{network_id[-6:]}" + def __get_zt_ifname_files(self, vpn, files): config_path = vpn.get('config_path', '/etc/openwisp/zerotier') nwid_ifnames = vpn.get('networks', []) diff --git a/netjsonconfig/backends/openwrt/schema.py b/netjsonconfig/backends/openwrt/schema.py index 6c275fd18..aee349388 100644 --- a/netjsonconfig/backends/openwrt/schema.py +++ b/netjsonconfig/backends/openwrt/schema.py @@ -1222,14 +1222,13 @@ "name": { "type": "string", "propertyOrder": 2, - "default": "ow_zt", + "default": "global", "minLength": 1, "description": "Name of the zerotier network member configuration", }, "networks": { "type": "array", "title": "Networks", - "minItems": 1, "propertyOrder": 3, "uniqueItems": True, "additionalProperties": True, @@ -1252,6 +1251,41 @@ "maxLength": 10, "description": "Name of zerotier interface", }, + "allow_managed": { + "type": "boolean", + "title": "Allow Managed", + "default": True, + "format": "checkbox", + "description": ( + "Allow ZeroTier to set IP Addresses" + " and Routes (local/private ranges only)", + ), + }, + "allow_global": { + "type": "boolean", + "title": "Allow Global", + "default": False, + "format": "checkbox", + "description": ( + "Allow ZeroTier to set Global/Public/Not-Private" + " range IPs and Routes" + ), + }, + "allow_default": { + "type": "boolean", + "title": "Allow Default", + "format": "checkbox", + "description": ( + "Allow ZeroTier to set the Default Route on the" + " system" + ), + }, + "allow_dns": { + "type": "boolean", + "title": "Allow DNS", + "format": "checkbox", + "description": "Allow ZeroTier to set DNS servers", + }, }, }, }, @@ -1295,7 +1329,7 @@ "propertyOrder": 7, "description": "Port number of the zerotier service", }, - "local_conf": { + "local_conf_path": { "type": "string", "propertyOrder": 8, "description": ( diff --git a/netjsonconfig/backends/zerotier/converters.py b/netjsonconfig/backends/zerotier/converters.py index c540fc155..8cd0c904a 100644 --- a/netjsonconfig/backends/zerotier/converters.py +++ b/netjsonconfig/backends/zerotier/converters.py @@ -14,6 +14,7 @@ def to_intermediate_loop(self, block, result, index=None): return result def __intermediate_vpn(self, config, remove=None): + config.pop('client_options', None) return self.sorted_dict(config) def to_netjson_loop(self, block, result, index=None): diff --git a/netjsonconfig/backends/zerotier/schema.py b/netjsonconfig/backends/zerotier/schema.py index 1f53af61a..cc63efdf9 100644 --- a/netjsonconfig/backends/zerotier/schema.py +++ b/netjsonconfig/backends/zerotier/schema.py @@ -67,6 +67,45 @@ "description": "Time when the network was created", }, # Configurable properties + "client_options": { + "type": "object", + "title": "Client Options", + "propertyOrder": 14, + "properties": { + "allow_managed": { + "type": "boolean", + "title": "Allow Managed", + "default": True, + "format": "checkbox", + "description": ( + "Allow ZeroTier to set IP Addresses and Routes (local/private ranges only)", + ), + }, + "allow_global": { + "type": "boolean", + "title": "Allow Global", + "default": False, + "format": "checkbox", + "description": ( + "Allow ZeroTier to set Global/Public/Not-Private range IPs and Routes" + ), + }, + "allow_default": { + "type": "boolean", + "title": "Allow Default", + "format": "checkbox", + "description": ( + "Allow ZeroTier to set the Default Route on the system" + ), + }, + "allow_dns": { + "type": "boolean", + "title": "Allow DNS", + "format": "checkbox", + "description": "Allow ZeroTier to set DNS servers", + }, + }, + }, "capabilities": { "type": "array", "items": {"type": "object"}, diff --git a/netjsonconfig/backends/zerotier/zerotier.py b/netjsonconfig/backends/zerotier/zerotier.py index cd6a3197d..7944df2d3 100644 --- a/netjsonconfig/backends/zerotier/zerotier.py +++ b/netjsonconfig/backends/zerotier/zerotier.py @@ -22,8 +22,12 @@ def auto_client( identity_secret='{{secret}}', config_path='/etc/openwisp/zerotier', disabled=False, + client_options=None, ): networks = networks or [] + client_options = client_options or {} + for network in networks: + network.update(client_options) return { 'name': name, 'networks': networks, diff --git a/tests/openwrt/test_zerotier.py b/tests/openwrt/test_zerotier.py index 0e6407b96..90e1adb09 100644 --- a/tests/openwrt/test_zerotier.py +++ b/tests/openwrt/test_zerotier.py @@ -8,17 +8,68 @@ class TestZeroTier(unittest.TestCase, _TabsMixin): maxDiff = None # This configuration is used when we want to join multiple networks # and the ZT service is running on a single default port 9993 - _TEST_SAME_NAME_MULTIPLE_CONFIG = { + _multiple_networks_netjson = { "zerotier": [ { + "local_conf_path": "/etc/openwisp/zerotier/zerotier.conf", "name": "ow_zt", "networks": [ - {"id": "9536600adf654321", "ifname": "owzt654321"}, - {"id": "9536600adf654322", "ifname": "owzt654322"}, + { + "id": "9536600adf654321", + "ifname": "owzt654321", + "allow_managed": True, + "allowed_global": False, + "allowed_default": False, + "allowed_dns": False, + }, + { + "id": "9536600adf654322", + "ifname": "owzt654322", + "allow_managed": True, + "allowed_global": False, + "allowed_default": False, + "allowed_dns": False, + }, ], }, ] } + _multiple_networks_uci = """package zerotier + +config zerotier 'ow_zt' + option config_path '/etc/openwisp/zerotier' + option copy_config_path '1' + option enabled '1' + list join '9536600adf654321' + list join '9536600adf654322' + option local_conf '/etc/openwisp/zerotier/zerotier.conf' + option local_conf_path '/etc/openwisp/zerotier/zerotier.conf' + +config network 'owzt654321' + option allow_managed '1' + option allowed_default '0' + option allowed_dns '0' + option allowed_global '0' + option id '9536600adf654321' + +config network 'owzt654322' + option allow_managed '1' + option allowed_default '0' + option allowed_dns '0' + option allowed_global '0' + option id '9536600adf654322' + +# ---------- files ---------- # + +# path: /etc/openwisp/zerotier/devicemap +# mode: 0644 + +# network_id=interface_name +9536600adf654321=owzt654321 +9536600adf654322=owzt654322 + +""" + # This ZT configuration is used when ZT services # are configured to run on multiple ports, e.g., 9993 and 9994. # For more information, refer to: @@ -36,109 +87,71 @@ class TestZeroTier(unittest.TestCase, _TabsMixin): ] } - def test_zt_multiple_render_diff_name(self): - o = OpenWrt(self._TEST_DIFF_NAME_MULTIPLE_CONFIG) - expected = self._tabs( - """package zerotier - -config zerotier 'ow_zt1' - option config_path '/etc/openwisp/zerotier' - option copy_config_path '1' - option enabled '1' - list join '9536600adf654321' - -config zerotier 'ow_zt2' - option config_path '/etc/openwisp/zerotier' - option copy_config_path '1' - option enabled '1' - list join '9536600adf654322' - -# ---------- files ---------- # - -# path: /etc/openwisp/zerotier/devicemap -# mode: 0644 - -# network_id=interface_name -9536600adf654322=owzt654322 - -# network_id=interface_name -9536600adf654321=owzt654321 - -""" - ) + def test_zt_render(self): + o = OpenWrt(self._multiple_networks_netjson) + expected = self._tabs(self._multiple_networks_uci) self.assertEqual(o.render(), expected) - def test_zt_mutiple_parse_diff_name(self): + def test_zt_parse_old(self): native = self._tabs( """package zerotier -config zerotier 'ow_zt1' - option enabled '1' +config zerotier 'ow_zt' + option enabled '0' + option local_conf '/etc/openwisp/zerotier/zerotier.conf' list join '9536600adf654321' - -config zerotier 'ow_zt2' - option enabled '1' list join '9536600adf654322' """ ) expected = { "zerotier": [ { - "name": "ow_zt1", - "disabled": False, - "networks": [{"id": "9536600adf654321", "ifname": "owzt654321"}], - }, - { - "name": "ow_zt2", - "disabled": False, - "networks": [{"id": "9536600adf654322", "ifname": "owzt654322"}], + "local_conf_path": "/etc/openwisp/zerotier/zerotier.conf", + "networks": [ + {"id": "9536600adf654321", "ifname": "owzt654321"}, + {"id": "9536600adf654322", "ifname": "owzt654322"}, + ], + "name": "ow_zt", + "disabled": True, }, ] } o = OpenWrt(native=native) self.assertEqual(o.config, expected) - def test_zt_multiple_render_same_name(self): - o = OpenWrt(self._TEST_SAME_NAME_MULTIPLE_CONFIG) - expected = self._tabs( - """package zerotier - -config zerotier 'ow_zt' - option config_path '/etc/openwisp/zerotier' - option copy_config_path '1' - option enabled '1' - list join '9536600adf654321' - list join '9536600adf654322' - -# ---------- files ---------- # - -# path: /etc/openwisp/zerotier/devicemap -# mode: 0644 - -# network_id=interface_name -9536600adf654321=owzt654321 -9536600adf654322=owzt654322 - -""" - ) - self.assertEqual(o.render(), expected) - - def test_zt_mutiple_parse_same_name(self): + def test_zt_parse_new(self): native = self._tabs( """package zerotier config zerotier 'ow_zt' option enabled '0' - list join '9536600adf654321' - list join '9536600adf654322' + option local_conf_path '/etc/openwisp/zerotier/zerotier.conf' + +config network + option id '9536600adf654321' + +config network + option id '9536600adf654322' + option allow_managed '1' + option allowed_default '0' + option allowed_dns '0' + option allowed_global '0' """ ) expected = { "zerotier": [ { + "local_conf_path": "/etc/openwisp/zerotier/zerotier.conf", "networks": [ {"id": "9536600adf654321", "ifname": "owzt654321"}, - {"id": "9536600adf654322", "ifname": "owzt654322"}, + { + "id": "9536600adf654322", + "ifname": "owzt654322", + "allow_managed": True, + "allowed_global": False, + "allowed_default": False, + "allowed_dns": False, + }, ], "name": "ow_zt", "disabled": True, diff --git a/tests/zerotier/test_backend.py b/tests/zerotier/test_backend.py index c72185c1f..a8158dfef 100644 --- a/tests/zerotier/test_backend.py +++ b/tests/zerotier/test_backend.py @@ -60,6 +60,12 @@ class TestBackend(unittest.TestCase): "tags": [{"default": 1, "id": 1}], "remoteTraceTarget": "7f5d90eb87", "remoteTraceLevel": 1, + "client_options": { + "allow_managed": True, + "allowed_global": False, + "allowed_default": False, + "allowed_dns": False, + }, } ] }