From 571af14e861945aec3a62f01d0bf95fe4b0db14a Mon Sep 17 00:00:00 2001 From: Aryaman Date: Wed, 11 Oct 2023 13:46:43 +0200 Subject: [PATCH] [feature:gsoc23] Added ZeroTier VPN backend & OpenWrt support #208 #284 Closes #208 Closes #284 Co-authored-by: Federico Capoano --- README.rst | 1 + docs/source/backends/vpn.rst | 1 + docs/source/backends/zerotier.rst | 415 ++++++++++++++ docs/source/general/basics.rst | 1 + netjsonconfig/__init__.py | 2 + netjsonconfig/backends/base/backend.py | 4 +- .../backends/openwrt/converters/__init__.py | 2 + .../backends/openwrt/converters/openvpn.py | 2 +- .../backends/openwrt/converters/zerotier.py | 62 ++ netjsonconfig/backends/openwrt/openwrt.py | 7 + netjsonconfig/backends/openwrt/schema.py | 107 ++++ netjsonconfig/backends/zerotier/__init__.py | 0 netjsonconfig/backends/zerotier/converters.py | 27 + netjsonconfig/backends/zerotier/parser.py | 45 ++ netjsonconfig/backends/zerotier/renderer.py | 13 + netjsonconfig/backends/zerotier/schema.py | 310 ++++++++++ .../zerotier/templates/zerotier.jinja2 | 6 + netjsonconfig/backends/zerotier/zerotier.py | 33 ++ tests/openwrt/test_backend.py | 38 ++ tests/openwrt/test_zerotier.py | 149 +++++ tests/zerotier/__init__.py | 0 tests/zerotier/test_backend.py | 408 ++++++++++++++ tests/zerotier/test_parser.py | 529 ++++++++++++++++++ 23 files changed, 2160 insertions(+), 2 deletions(-) create mode 100644 docs/source/backends/zerotier.rst create mode 100644 netjsonconfig/backends/openwrt/converters/zerotier.py create mode 100644 netjsonconfig/backends/zerotier/__init__.py create mode 100644 netjsonconfig/backends/zerotier/converters.py create mode 100644 netjsonconfig/backends/zerotier/parser.py create mode 100644 netjsonconfig/backends/zerotier/renderer.py create mode 100644 netjsonconfig/backends/zerotier/schema.py create mode 100644 netjsonconfig/backends/zerotier/templates/zerotier.jinja2 create mode 100644 netjsonconfig/backends/zerotier/zerotier.py create mode 100644 tests/openwrt/test_zerotier.py create mode 100644 tests/zerotier/__init__.py create mode 100644 tests/zerotier/test_backend.py create mode 100644 tests/zerotier/test_parser.py diff --git a/README.rst b/README.rst index 8a3c09dc3..5bef45198 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,7 @@ Its main features are listed below for your reference: * `OpenWisp Firmware `_ support * `OpenVPN `_ support * `WireGuard `_ support +* `ZeroTier `_ support * Possibility to support more firmwares via custom backends * Based on the `NetJSON RFC `_ * **Validation** based on `JSON-Schema `_ diff --git a/docs/source/backends/vpn.rst b/docs/source/backends/vpn.rst index 19ddccd6f..edc21b7a2 100644 --- a/docs/source/backends/vpn.rst +++ b/docs/source/backends/vpn.rst @@ -10,3 +10,4 @@ VPN Backends /backends/openvpn /backends/wireguard /backends/vxlan_over_wireguard + /backends/zerotier diff --git a/docs/source/backends/zerotier.rst b/docs/source/backends/zerotier.rst new file mode 100644 index 000000000..fbe1b1f25 --- /dev/null +++ b/docs/source/backends/zerotier.rst @@ -0,0 +1,415 @@ +================ +ZeroTier Backend +================ + +The ``ZeroTier`` backend generates JSON configurations that can be +used with the `ZeroTier Service API `_ +to manage ZeroTier networks on `Self-hosted ZeroTier controllers +`_. + +Its schema is limited to a subset of the features available in ZeroTier +and it doesn't recognize interfaces, radios, wireless settings and so on. + +The main methods work just like the :doc:`OpenWRT backend `: + + * ``__init__`` + * ``render`` + * ``generate`` + * ``write`` + * ``json`` + +The main differences are in the resulting configuration and in its schema. + +See an example of initialization and rendering below: + +.. code-block:: python + + from netjsonconfig import ZeroTier + + config = ZeroTier({ + "zerotier": [ + { + "id": "9536600adf654321", + "nwid": "9536600adf654321", + "objtype": "network", + "revision": 1, + "creationTime": 1632012345, + "name": "zerotier-openwisp-network", + "private": True, + "enableBroadcast": True, + "v4AssignMode": {"zt": True}, + "v6AssignMode": {"6plane": False, "rfc4193": True, "zt": True}, + "mtu": 2700, + "multicastLimit": 16, + "routes": [{"target": "10.0.0.0/24", "via": "10.0.0.1"}], + "ipAssignmentPools": [ + {"ipRangeStart": "10.0.0.10", "ipRangeEnd": "10.0.0.100"} + ], + "dns": {"domain": "zerotier.openwisp.io", "servers": ["10.147.20.3"]}, + "rules": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + }, + {"type": "ACTION_DROP"}, + ], + "capabilities": [ + { + "default": True, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + } + ], + } + ], + "tags": [{"default": 1, "id": 1}], + "remoteTraceTarget": "7f5d90eb87", + "remoteTraceLevel": 1, + } + ] + }) + print(config.render()) + +Will return the following output:: + + // zerotier controller config: 9536600adf654321.json + + { + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } + } + +.. _zerotier_backend_schema: + +ZeroTier backend schema +----------------------- + +The ``ZeroTier`` backend schema is limited, it only recognizes +an ``zerotier`` key with a list of dictionaries representing vpn instances. +The structure of these dictionaries is described below. + +Alternatively you may also want to take a look at the `ZeroTier JSON-Schema source code +`_. + +According to the `NetJSON `_ spec, any unrecognized property will be ignored. + +Server specific settings +~~~~~~~~~~~~~~~~~~~~~~~~ + +Required properties: + +* name + ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| key name | type | default | description | ++========================+=========+==============+====================================================================================================+ +| ``name`` | string | | name of the network | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``id`` | string | | **16-digit** hexadecimal Network ID | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``nwid`` | string | | **16-digit** hexadecimal Network ID (legacy field) | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``objtype`` | string | ``network`` | specifies the type of object | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``revision`` | integer | | revision number of the network configuration | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``creationTime`` | integer | | time when the network was created | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``private`` | boolean | | whether or not the network is private if ``False`` | +| | | | | +| | | | members will NOT need to be authorized to join | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``enableBroadcast`` | boolean | | enable broadcast packets on the network | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``v4AssignMode`` | object | ``{}`` | | +| | | | +----------------------+---------+----------------------------------------------------------+ | +| | | | | key name | type | description | | +| | | | +======================+=========+==========================================================+ | +| | | | | ``zt`` | boolean | whether ZeroTier should assign IPv4 addresses to members | | +| | | | +----------------------+---------+----------------------------------------------------------+ | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``v6AssignMode`` | dict | ``{}`` | | +| | | | +----------------------+---------+---------------------------------------------------------------+ | +| | | | | key name | type | description | | +| | | | +======================+=========+===============================================================+ | +| | | | | ``6plane`` | boolean | 6PLANE assigns each device a single IPv6 address from a | | +| | | | | | | | | +| | | | | | | fully routable /80 block. It utilizes NDP emulation to route | | +| | | | | | | | | +| | | | | | | the entire /80 to the device owner, enabling up to 2^48 IPs | | +| | | | | | | | | +| | | | | | | without additional configuration. Ideal for Docker or VM hosts| | +| | | | +----------------------+---------+---------------------------------------------------------------+ | +| | | | | ``rfc4193`` | boolean | RFC4193 assigns each device a single IPv6 /128 address | | +| | | | | | | | | +| | | | | | | computed from the network ID and device address and uses NDP | | +| | | | | | | | | +| | | | | | | emulation to make these addresses instantly resolvable without| | +| | | | | | | | | +| | | | | | | multicast | | +| | | | +----------------------+---------+---------------------------------------------------------------+ | +| | | | | ``zt`` | boolean | whether ZeroTier should assign IPv6 addresses to members | | +| | | | +----------------------+---------+---------------------------------------------------------------+ | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``mtu`` | integer | | MTU to set on the client virtual network adapter | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``multicastLimit`` | integer | | maximum number of recipients per multicast or broadcast, | +| | | | | +| | | | warning - Setting this to ``0`` will disable IPv4 communication on your network | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``routes`` | list | ``[{}]`` | | list of route dictionaries | +| | | | | +| | | | +----------------------+---------+------------------------------------------+ | +| | | | | key name | type | description | | +| | | | +======================+=========+==========================================+ | +| | | | | ``target`` | string | target IP address range for the route | | +| | | | +----------------------+---------+------------------------------------------+ | +| | | | | ``via`` | string | IP address of the next hop for the route | | +| | | | +----------------------+---------+------------------------------------------+ | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``ipAssignmentPools`` | list | ``[{}]`` | | list that contains dictionaries specifying | +| | | | | a range of IP addresses for the auto assign pool | +| | | | | +| | | | +----------------------+---------+---------------------------------------+ | +| | | | | key name | type | description | | +| | | | +======================+=========+=======================================+ | +| | | | | ``ipRangeStart`` | string | starting IP address of the pool range | | +| | | | +----------------------+---------+---------------------------------------+ | +| | | | | ``ipRangeEnd`` | string | ending IP address of the pool range | | +| | | | +----------------------+---------+---------------------------------------+ | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``dns`` | dict | ``{}`` | +----------------------+---------+---------------------------+ | +| | | | | key name | type | description | | +| | | | +======================+=========+===========================+ | +| | | | | ``domain`` | string | domain for DNS resolution | | +| | | | +----------------------+---------+---------------------------+ | +| | | | | ``server`` | list | DNS server IP addresses | | +| | | | +----------------------+---------+---------------------------+ | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``rules`` | list | ``[{}]`` | list of network rules dictionaries | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``tags`` | list | ``[{}]`` | list of network tags dictionaries | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``remoteTraceTarget`` | string | | remote target ID for network tracing | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ +| ``remoteTraceLevel`` | integer | | level of network tracing | ++------------------------+---------+--------------+----------------------------------------------------------------------------------------------------+ + +Client specific settings +~~~~~~~~~~~~~~~~~~~~~~~~ + +Required properties: + +* name +* networks + ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| key name | type | default | description | ++========================+=========+============================+===========================================================================================================+ +| ``name`` | string | ``ow_zt`` | name of the zerotier network | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| ``networks`` | list | ``[{}]`` | list of dictionaries containing strings with **16-digit** hexadecimal network IDs for joining, | +| | | | | +| | | | along with a corresponding custom **10-digit** ZeroTier interface name for each network | +| | | | | +| | | | **note:** ensure that the list includes at least one such dictionary | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| ``config_path`` | string | ``/etc/openwisp/zerotier`` | path to the persistent configuration directory | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| ``copy_config_path`` | string | ``'1'`` | specifies whether to copy the configuration file to RAM | +| | | | | +| | | | ``'0'`` - No, ``'1'`` - Yes, this prevents writing to flash in zerotier controller mode | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| ``secret`` | string | ``''`` | identity secret of the zerotier client (network member), leave it blank to be automatically determined | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| ``port`` | integer | ``9993`` | port number of the zerotier service | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ +| ``local_conf`` | string | | path of the local zerotier configuration (only used for advanced configuration) | ++------------------------+---------+----------------------------+-----------------------------------------------------------------------------------------------------------+ + +Working around schema limitations +--------------------------------- + +The schema does not include all the possible ZeroTier settings, but it can render appropiately +any property not included in the schema as long as its type is one the following: + +* boolean +* integer +* strings +* lists + +Automatic generation of clients +------------------------------- + +.. automethod:: netjsonconfig.OpenWrt.zerotier_auto_client + +Example (with custom zerotier interface name): + +.. code-block:: python + + from netjsonconfig import OpenWrt + + client_config = OpenWrt.zerotier_auto_client( + name='ow_zt', + networks=[{"id": "9536600adf654321", "ifname": "owzt654321"}], + ) + print(OpenWrt(client_config).render()) + +Will be rendered as: + +.. code-block:: text + + package zerotier + + config zerotier 'ow_zt' + option config_path '/etc/openwisp/zerotier' + option copy_config_path '1' + option enabled '1' + list join '9536600adf654321' + option secret '{{secret}}' + + # ---------- files ---------- # + + # path: /etc/openwisp/zerotier/devicemap + # mode: 0644 + + # network_id=interface_name + 9536600adf654321=owzt654321 + +.. note:: + + The current implementation of **ZeroTier VPN** backend is implemented with + **OpenWrt** backend. Hence, the example above shows configuration generated for + OpenWrt. + + +Useful resources +---------------- + +The default flow rules used in `zerotier/schema.py +`_ +for the ZeroTier self-hosted controller are taken from the flow rules mentioned in the documentation below. + +- `ZeroTier Controller Network Flow Rules `_ + +To explore a comprehensive list of all available ZeroTier network +configuration settings, please refer to the following OpenAPI API specifications. + +- `ZeroTier Service (schema: ControllerNetwork) `_ + +- `ZeroTier Central (schema: NetworkConfig) `_ + +Advanced configuration +~~~~~~~~~~~~~~~~~~~~~~ + +If you want to use advanced configuration options that +apply to your 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. + +For example, let's create a local configuration file at ``/etc/openwisp/zerotier/local.conf`` (JSON) +to blacklist a specific physical network path **(10.0.0.0/24)** from all ZeroTier traffic. + +.. code-block:: json + + { + "physical": { + "10.0.0.0/24": { + "blacklist": true + } + } + } + +Now add ``local_conf`` option to ``/etc/config/zerotier``: + +.. code-block:: text + + package zerotier + + config zerotier 'ow_zt' + option enabled '1' + list join '9536600adf654322' + option secret '{{secret}}' + option local_conf '/etc/openwisp/zerotier/local.conf' + + +**More information** + +- `ZeroTier Controller Local Configuration `_ +- `OpenWRT ZeroTier Advance Configuration `_ diff --git a/docs/source/general/basics.rst b/docs/source/general/basics.rst index b28d11f45..6a374bc31 100644 --- a/docs/source/general/basics.rst +++ b/docs/source/general/basics.rst @@ -103,6 +103,7 @@ The current implemented backends are: * :doc:`OpenVpn ` (custom backend implementing only OpenVPN configuration) * :doc:`WireGuard ` (custom backend implementing only WireGuard configuration) * :doc:`VXLAN over WireGuard ` (custom backend implementing only VXLAN over WireGuard configuration) + * :doc:`ZeroTier ` (custom backend implementing only ZeroTier configuration) Example initialization of ``OpenWrt`` backend: diff --git a/netjsonconfig/__init__.py b/netjsonconfig/__init__.py index 1ba605d3a..84295410a 100644 --- a/netjsonconfig/__init__.py +++ b/netjsonconfig/__init__.py @@ -7,6 +7,7 @@ from .backends.openwrt.openwrt import OpenWrt # noqa from .backends.vxlan.vxlan_wireguard import VxlanWireguard # noqa from .backends.wireguard.wireguard import Wireguard # noqa +from .backends.zerotier.zerotier import ZeroTier # noqa from .version import VERSION, __version__, get_version # noqa @@ -16,6 +17,7 @@ def get_backends(): 'openwisp': OpenWisp, 'openvpn': OpenVpn, 'wireguard': Wireguard, + 'zerotier': ZeroTier, } logger = logging.getLogger(__name__) diff --git a/netjsonconfig/backends/base/backend.py b/netjsonconfig/backends/base/backend.py index e7705cf53..c6da39279 100644 --- a/netjsonconfig/backends/base/backend.py +++ b/netjsonconfig/backends/base/backend.py @@ -380,7 +380,9 @@ def _generate_contents(self, tar): # create a file for each VPN for vpn in vpn_instances: lines = vpn.split('\n') - vpn_name = lines[0] + # It's better to split lines[0] using + # `config_suffix` to extract the correct vpn_name + vpn_name = lines[0].split(self.config_suffix)[0] text_contents = '\n'.join(lines[2:]) # do not end with double new line if text_contents.endswith('\n\n'): diff --git a/netjsonconfig/backends/openwrt/converters/__init__.py b/netjsonconfig/backends/openwrt/converters/__init__.py index 5993c2b83..542a87507 100644 --- a/netjsonconfig/backends/openwrt/converters/__init__.py +++ b/netjsonconfig/backends/openwrt/converters/__init__.py @@ -10,6 +10,7 @@ from .switch import Switch from .wireguard_peers import WireguardPeers from .wireless import Wireless +from .zerotier import ZeroTier __all__ = [ 'Default', @@ -24,4 +25,5 @@ 'Switch', 'WireguardPeers', 'Wireless', + 'ZeroTier', ] diff --git a/netjsonconfig/backends/openwrt/converters/openvpn.py b/netjsonconfig/backends/openwrt/converters/openvpn.py index 0e7e2b93b..3f3902918 100644 --- a/netjsonconfig/backends/openwrt/converters/openvpn.py +++ b/netjsonconfig/backends/openwrt/converters/openvpn.py @@ -18,7 +18,7 @@ def __intermediate_vpn(self, vpn): def __netjson_vpn(self, vpn): if vpn.get('server_bridge') == '1': vpn['server_bridge'] = '' - # 'enabled' defaults to False in OpenWRT + # 'disabled' defaults to False in OpenWRT vpn['disabled'] = vpn.pop('enabled', '0') == '0' vpn['name'] = vpn.pop('.name') del vpn['.type'] diff --git a/netjsonconfig/backends/openwrt/converters/zerotier.py b/netjsonconfig/backends/openwrt/converters/zerotier.py new file mode 100644 index 000000000..5f5620c08 --- /dev/null +++ b/netjsonconfig/backends/openwrt/converters/zerotier.py @@ -0,0 +1,62 @@ +from ...zerotier.converters import ZeroTier as BaseZeroTier +from ..schema import schema +from .base import OpenWrtConverter + + +class ZeroTier(OpenWrtConverter, BaseZeroTier): + _uci_types = ['zerotier'] + _schema = schema['properties']['zerotier']['items'] + + def __intermediate_vpn(self, vpn): + nwid_ifnames = vpn.get('networks', []) + files = self.netjson.get('files', []) + self.netjson['files'] = self.__get_zt_ifname_files(vpn, files) + vpn.update( + { + '.name': self._get_uci_name(vpn.pop('name')), + '.type': 'zerotier', + 'config_path': vpn.get('config_path', '/etc/openwisp/zerotier'), + 'copy_config_path': vpn.get('copy_config_path', '1'), + 'join': [networks.get('id', '') for networks in nwid_ifnames], + 'enabled': not vpn.pop('disabled', False), + } + ) + del vpn['networks'] + return super().__intermediate_vpn(vpn, remove=['']) + + 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'] + return super().__netjson_vpn(vpn) + + def __get_zt_ifname_files(self, vpn, files): + config_path = vpn.get('config_path', '/etc/openwisp/zerotier') + nwid_ifnames = vpn.get('networks', []) + zt_file_contents = '# network_id=interface_name\n' + + for networks in nwid_ifnames: + nwid = networks.get('id', '') + ifname = networks.get('ifname') + zt_file_contents += f"{nwid}={ifname}\n" + + zt_interface_map = { + 'path': f"{config_path}/devicemap", + 'mode': '0644', + 'contents': zt_file_contents, + } + + if not files: + return [zt_interface_map] + updated_files = [] + for file in files: + if file.get('path') == zt_interface_map.get('path'): + zt_interface_map['contents'] += '\n' + file['contents'] + else: + updated_files.append(file) + if zt_interface_map.get('contents'): + updated_files.append(zt_interface_map) + return updated_files diff --git a/netjsonconfig/backends/openwrt/openwrt.py b/netjsonconfig/backends/openwrt/openwrt.py index b6ae8c1ec..36a788ba9 100644 --- a/netjsonconfig/backends/openwrt/openwrt.py +++ b/netjsonconfig/backends/openwrt/openwrt.py @@ -3,6 +3,7 @@ from ..base.backend import BaseBackend from ..vxlan.vxlan_wireguard import VxlanWireguard from ..wireguard.wireguard import Wireguard +from ..zerotier.zerotier import ZeroTier from . import converters from .parser import OpenWrtParser, config_path, packages_pattern from .renderer import OpenWrtRenderer @@ -27,6 +28,7 @@ class OpenWrt(BaseBackend): converters.Wireless, converters.OpenVpn, converters.WireguardPeers, + converters.ZeroTier, converters.Default, ] parser = OpenWrtParser @@ -142,6 +144,11 @@ def vxlan_wireguard_auto_client(cls, **kwargs): config['interfaces'].append(vxlan_interface) return config + @classmethod + def zerotier_auto_client(cls, **kwargs): + data = ZeroTier.auto_client(**kwargs) + return {'zerotier': [data]} + def validate(self): self._validate_radios() super().validate() diff --git a/netjsonconfig/backends/openwrt/schema.py b/netjsonconfig/backends/openwrt/schema.py index a02b9920e..32111cee7 100644 --- a/netjsonconfig/backends/openwrt/schema.py +++ b/netjsonconfig/backends/openwrt/schema.py @@ -1001,6 +1001,113 @@ }, }, }, + "zerotier": { + "type": "array", + "title": "ZeroTier Networks", + "uniqueItems": True, + "propertyOrder": 14, + "items": { + "type": "object", + "title": "Network Member Configuration", + "additionalProperties": True, + "required": ["name", "networks"], + "properties": { + # ZeroTier customization (disabled) for OpenWRT + "disabled": { + "title": "disabled", + "description": "Disable this VPN without deleting its configuration", + "type": "boolean", + "default": False, + "format": "checkbox", + "propertyOrder": 1, + }, + "name": { + "type": "string", + "propertyOrder": 2, + "default": "ow_zt", + "minLength": 1, + "description": "Name of the zerotier network member configuration", + }, + "networks": { + "type": "array", + "title": "Networks", + "minItems": 1, + "propertyOrder": 3, + "uniqueItems": True, + "additionalProperties": True, + "items": { + "type": "object", + "title": "Network Member", + "allOf": [{"required": ["id", "ifname"]}], + "properties": { + "id": { + "type": "string", + "title": "Network ID", + "maxLength": 16, + "minLength": 16, + "description": "Network ID to join", + }, + "ifname": { + "type": "string", + "title": "Interface name", + "minLength": 1, + "maxLength": 10, + "description": "Name of zerotier interface", + }, + }, + }, + }, + "secret": { + "type": "string", + "propertyOrder": 4, + "default": "{{secret}}", + "description": ( + "Identity secret of the zerotier client (network member), " + "You can leave it as the default and OpenWISP will automatically determine it" + ), + }, + # Hidden properties + "config_path": { + "type": "string", + "propertyOrder": 5, + "options": {"hidden": True}, + "default": "/etc/openwisp/zerotier", + "description": ( + "Path to the persistent configuration " + "directory (for zerotier controller mode)" + ), + }, + "copy_config_path": { + "type": "string", + "propertyOrder": 6, + "options": {"hidden": True}, + "enum": ["0", "1"], + "default": "1", + "description": ( + "Specifies whether to copy the configuration " + "file to RAM ('0' - No, '1' - Yes), this prevents " + "writing to flash in zerotier controller mode" + ), + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "default": 9993, + "propertyOrder": 7, + "description": "Port number of the zerotier service", + }, + "local_conf": { + "type": "string", + "propertyOrder": 8, + "description": ( + "Path of the local zerotier configuration " + "(only used for advanced configuration)" + ), + }, + }, + }, + }, }, }, ) diff --git a/netjsonconfig/backends/zerotier/__init__.py b/netjsonconfig/backends/zerotier/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netjsonconfig/backends/zerotier/converters.py b/netjsonconfig/backends/zerotier/converters.py new file mode 100644 index 000000000..c540fc155 --- /dev/null +++ b/netjsonconfig/backends/zerotier/converters.py @@ -0,0 +1,27 @@ +from ..base.converter import BaseConverter +from .schema import schema + + +class ZeroTier(BaseConverter): + netjson_key = 'zerotier' + intermediate_key = 'zerotier' + _schema = schema['definitions']['zerotier_server'] + + def to_intermediate_loop(self, block, result, index=None): + vpn = self.__intermediate_vpn(block) + result.setdefault('zerotier', []) + result['zerotier'].append(vpn) + return result + + def __intermediate_vpn(self, config, remove=None): + return self.sorted_dict(config) + + def to_netjson_loop(self, block, result, index=None): + vpn = self.__netjson_vpn(block) + result.setdefault('zerotier', []) + result['zerotier'].append(vpn) + return result + + def __netjson_vpn(self, vpn): + vpn = self.type_cast(vpn, self._schema) + return vpn diff --git a/netjsonconfig/backends/zerotier/parser.py b/netjsonconfig/backends/zerotier/parser.py new file mode 100644 index 000000000..501fd1287 --- /dev/null +++ b/netjsonconfig/backends/zerotier/parser.py @@ -0,0 +1,45 @@ +import re +import tarfile +from json import loads + +from ..base.parser import BaseParser + +vpn_pattern = re.compile('^// zerotier controller config:\s', flags=re.MULTILINE) +config_pattern = re.compile('^([^\s]*) ?(.*)$') +config_suffix = '.json' + + +class ZeroTierParser(BaseParser): + def parse_text(self, config): + return {'zerotier': self._get_vpn_config(config)} + + def parse_tar(self, tar): + fileobj = tar.buffer if hasattr(tar, 'buffer') else tar + tar = tarfile.open(fileobj=fileobj) + text = '' + for member in tar.getmembers(): + if not member.name.endswith(config_suffix): + continue + text += '// zerotier controller config: {name}\n\n{contents}\n'.format( + **{ + 'name': member.name, + 'contents': tar.extractfile(member).read().decode(), + } + ) + return self.parse_text(text) + + def _get_vpn_config(self, text): + # Remove comments from the vpn text + text = re.sub(r'\/\*(\*(?!\/)|[^*])*\*\/|\/\/.*', '', text) + # Strip leading and trailing whitespace from the text + text = text.strip() + # Split the text into separate VPN instances + # using two or more newline characters as the delimiter + vpn_instances = re.split(r"\n{2,}", text) + # Parse each JSON object separately + vpn_configs = [ + loads(vpn_instance) + for vpn_instance in vpn_instances + if vpn_instance.strip() + ] + return vpn_configs diff --git a/netjsonconfig/backends/zerotier/renderer.py b/netjsonconfig/backends/zerotier/renderer.py new file mode 100644 index 000000000..f56d59d63 --- /dev/null +++ b/netjsonconfig/backends/zerotier/renderer.py @@ -0,0 +1,13 @@ +from ..base.renderer import BaseRenderer + + +class ZeroTierRenderer(BaseRenderer): + """ + ZeroTier Renderer + """ + + def cleanup(self, output): + # remove last newline + if output.endswith('\n\n'): + output = output[0:-1] + return output diff --git a/netjsonconfig/backends/zerotier/schema.py b/netjsonconfig/backends/zerotier/schema.py new file mode 100644 index 000000000..1f53af61a --- /dev/null +++ b/netjsonconfig/backends/zerotier/schema.py @@ -0,0 +1,310 @@ +""" +ZeroTier specific JSON-Schema definition +""" + +from copy import deepcopy + +from ...schema import schema as default_schema + +# The schema is taken from OpenAPI specification: +# https://docs.zerotier.com/service/v1/ (self-hosted controllers) +# https://docs.zerotier.com/openapi/centralv1.json (central controllers) +base_zerotier_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "additionalProperties": True, + "definitions": { + "zerotier_server": { + "type": "object", + "title": "ZeroTier Network", + "required": ["name"], + "properties": { + # Read-only properties + "name": { + "type": "string", + # Since it is intended to be set by + # the VPN backend's name field, it is read-only + "readOnly": True, + "propertyOrder": 1, + "title": "Name", + "description": "Name of the network", + }, + "id": { + "type": "string", + "maxLength": 16, + "readOnly": True, + "propertyOrder": 2, + "title": "Network ID", + "description": "Network ID", + }, + "nwid": { + "type": "string", + "maxLength": 16, + "readOnly": True, + "propertyOrder": 3, + "title": "Network ID", + "description": "Network ID legacy field (same as 'id')", + }, + "objtype": { + "type": "string", + "readOnly": True, + "propertyOrder": 4, + "title": "Object Type", + "default": "network", + }, + "revision": { + "type": "integer", + "readOnly": True, + "propertyOrder": 5, + "title": "Revision Number", + "description": "The revision number of the network configuration", + }, + "creationTime": { + "type": "number", + "readOnly": True, + "propertyOrder": 6, + "title": "Creation Time", + "description": "Time when the network was created", + }, + # Configurable properties + "capabilities": { + "type": "array", + "items": {"type": "object"}, + "propertyOrder": 17, + "title": "Capabilities", + "description": "Array of network capabilities", + }, + "dns": { + "type": "object", + "propertyOrder": 15, + "title": "DNS", + "properties": { + "domain": { + "type": "string", + "propertyOrder": 1, + "title": "Search Domain", + "description": "The domain for DNS resolution", + }, + "servers": { + "type": "array", + "propertyOrder": 2, + "title": "Server Address", + "items": { + "type": "string", + "title": "IP Address", + "description": "The DNS server IP addresses", + }, + }, + }, + }, + "enableBroadcast": { + "type": "boolean", + "default": True, + "format": "checkbox", + "propertyOrder": 8, + "title": "Enable Broadcast", + "description": "Enable broadcast packets on the network", + }, + "ipAssignmentPools": { + "type": "array", + "propertyOrder": 14, + "title": "IPv4 Address Pools", + "items": { + "type": "object", + "properties": { + "ipRangeStart": { + "type": "string", + "propertyOrder": 1, + "title": "IP Range Start", + "description": "The starting IP address of the pool range", + }, + "ipRangeEnd": { + "type": "string", + "propertyOrder": 2, + "title": "IP Range End", + "description": "The ending IP address of the pool range", + }, + }, + }, + "description": "Range of IP addresses for the auto assign pool", + }, + "mtu": { + "type": "integer", + "default": 2800, + "propertyOrder": 10, + "title": "Maximum Transmission Unit", + "description": "MTU to set on the client virtual network adapter", + }, + "multicastLimit": { + "type": "integer", + "default": 32, + "title": "Multicast Recipient Limit", + "propertyOrder": 9, + "description": ( + "Maximum number of recipients per multicast or broadcast. " + "Warning - Setting this to 0 will disable IPv4 communication on your network!" + ), + }, + "private": { + "type": "boolean", + "default": True, + "format": "checkbox", + "title": "Private", + "propertyOrder": 7, + "description": ( + "Whether or not the network is private " + "If false, members will NOT need to be authorized to join" + ), + }, + "remoteTraceLevel": { + "type": "integer", + "propertyOrder": 19, + "title": "Remote Trace Level", + "description": "The level of network tracing", + }, + "remoteTraceTarget": { + "type": ["string", "null"], + "propertyOrder": 20, + "default": "", + "title": "Remote Trace Target", + "description": "The remote target ID for network tracing", + }, + "routes": { + "type": "array", + "propertyOrder": 13, + "title": "Managed Routes", + "items": { + "type": "object", + "properties": { + "target": { + "type": "string", + "propertyOrder": 1, + "title": "Destination", + "description": "The target IP address range for the route", + }, + "via": { + "type": "string", + "propertyOrder": 2, + "title": "Via", + "description": "The IP address of the next hop for the route", + }, + }, + }, + "description": "Array of route objects", + }, + "rules": { + "type": "array", + "items": {"type": "object"}, + "propertyOrder": 16, + "title": "Flow Rules", + "description": "Array of network rule objects", + # This is the default rule set + # that allows IPv4 and IPv6 traffic + # It is based on the default + # network configuration from ZeroTier Central + # https://docs.zerotier.com/zerotier/rules + "default": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + }, + { + "etherType": 2054, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + }, + { + "etherType": 34525, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + }, + {"type": "ACTION_DROP"}, + {"type": "ACTION_ACCEPT"}, + ], + }, + "tags": { + "type": "array", + "items": {"type": "object"}, + "propertyOrder": 18, + "title": "Tags", + "description": "Array of network tag objects", + }, + "v4AssignMode": { + "type": "object", + "propertyOrder": 11, + "title": "IPv4 Auto-Assign", + "properties": { + "zt": { + "type": "boolean", + "format": "checkbox", + "title": "Auto-Assign from Range", + "description": "Whether ZeroTier should assign IPv4 addresses to members", + }, + }, + }, + "v6AssignMode": { + "type": "object", + "propertyOrder": 12, + "title": "IPv6 Auto-Assign", + "properties": { + "6plane": { + "type": "boolean", + "format": "checkbox", + "title": "ZeroTier 6PLANE (/80 routable for each device)", + "description": ( + "6PLANE assigns each device a single " + "IPv6 address from a fully routable /80 block. " + "It utilizes NDP emulation to route the entire /80 " + "to the device owner, enabling up to 2^48 IPs without " + "additional configuration. Ideal for Docker or VM hosts" + ), + }, + "rfc4193": { + "type": "boolean", + "format": "checkbox", + "title": "ZeroTier RFC4193 (/128 for each device)", + "description": ( + "RFC4193 assigns each device a " + "single IPv6 /128 address computed " + "from the network ID and device address, " + "and uses NDP emulation to make these addresses " + "instantly resolvable without multicast" + ), + }, + "zt": { + "type": "boolean", + "format": "checkbox", + "title": "Auto-Assign from Range", + "description": "Whether ZeroTier should assign IPv6 addresses to members", + }, + }, + }, + }, + }, + }, + "properties": { + "zerotier": { + "type": "array", + "title": "ZeroTier", + "uniqueItems": True, + "additionalItems": True, + "propertyOrder": 12, + "items": { + "type": "object", + "title": "VPN", + "additionalProperties": True, + "allOf": [ + {"$ref": "#/definitions/zerotier_server"}, + ], + }, + } + }, +} + +schema = deepcopy(base_zerotier_schema) +schema['required'] = ['zerotier'] +schema['properties']['files'] = default_schema['properties']['files'] diff --git a/netjsonconfig/backends/zerotier/templates/zerotier.jinja2 b/netjsonconfig/backends/zerotier/templates/zerotier.jinja2 new file mode 100644 index 000000000..6e7f6a84f --- /dev/null +++ b/netjsonconfig/backends/zerotier/templates/zerotier.jinja2 @@ -0,0 +1,6 @@ +{% for vpn in data.zerotier %} +// zerotier controller config: {{ vpn.nwid }}.json + +{{ vpn | tojson(indent=4) }} + +{% endfor %} diff --git a/netjsonconfig/backends/zerotier/zerotier.py b/netjsonconfig/backends/zerotier/zerotier.py new file mode 100644 index 000000000..cd6a3197d --- /dev/null +++ b/netjsonconfig/backends/zerotier/zerotier.py @@ -0,0 +1,33 @@ +from ..base.backend import BaseVpnBackend +from . import converters +from .parser import ZeroTierParser, config_suffix, vpn_pattern +from .renderer import ZeroTierRenderer +from .schema import schema + + +class ZeroTier(BaseVpnBackend): + schema = schema + converters = [converters.ZeroTier] + renderer = ZeroTierRenderer + parser = ZeroTierParser + # BaseVpnBackend attributes + vpn_pattern = vpn_pattern + config_suffix = config_suffix + + @classmethod + def auto_client( + cls, + name='ow_zt', + networks=None, + identity_secret='{{secret}}', + config_path='/etc/openwisp/zerotier', + disabled=False, + ): + networks = networks or [] + return { + 'name': name, + 'networks': networks, + 'secret': identity_secret, + 'config_path': config_path, + 'disabled': disabled, + } diff --git a/tests/openwrt/test_backend.py b/tests/openwrt/test_backend.py index 5251aa35c..8d0f3094d 100644 --- a/tests/openwrt/test_backend.py +++ b/tests/openwrt/test_backend.py @@ -545,3 +545,41 @@ def test_vxlan_wireguard_auto_client(self): ), expected, ) + + def test_zerotier_auto_client(self): + with self.subTest('No arguments provided'): + expected = { + 'zerotier': [ + { + 'name': 'ow_zt', + 'networks': [], + 'secret': '{{secret}}', + 'config_path': '/etc/openwisp/zerotier', + 'disabled': False, + } + ] + } + self.assertDictEqual(OpenWrt.zerotier_auto_client(), expected) + with self.subTest('Required arguments provided'): + expected = { + 'zerotier': [ + { + 'name': 'ow_zt', + 'networks': [ + {'id': '9536600adf654321', 'ifname': 'owzt654321'} + ], + 'secret': '{{secret}}', + 'config_path': '/etc/ow_zerotier_test', + 'disabled': False, + } + ] + } + nw_id = '9536600adf654321' + self.assertDictEqual( + OpenWrt.zerotier_auto_client( + # Test it is possible to change default `config_path` + config_path='/etc/ow_zerotier_test', + networks=[{'id': nw_id, 'ifname': f'owzt{nw_id[-6:]}'}], + ), + expected, + ) diff --git a/tests/openwrt/test_zerotier.py b/tests/openwrt/test_zerotier.py new file mode 100644 index 000000000..0e6407b96 --- /dev/null +++ b/tests/openwrt/test_zerotier.py @@ -0,0 +1,149 @@ +import unittest + +from netjsonconfig import OpenWrt +from netjsonconfig.utils import _TabsMixin + + +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 = { + "zerotier": [ + { + "name": "ow_zt", + "networks": [ + {"id": "9536600adf654321", "ifname": "owzt654321"}, + {"id": "9536600adf654322", "ifname": "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: + # https://docs.zerotier.com/zerotier/zerotier.conf/#local-configuration-options + _TEST_DIFF_NAME_MULTIPLE_CONFIG = { + "zerotier": [ + { + "name": "ow_zt1", + "networks": [{"id": "9536600adf654321", "ifname": "owzt654321"}], + }, + { + "name": "ow_zt2", + "networks": [{"id": "9536600adf654322", "ifname": "owzt654322"}], + }, + ] + } + + 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 + +""" + ) + self.assertEqual(o.render(), expected) + + def test_zt_mutiple_parse_diff_name(self): + native = self._tabs( + """package zerotier + +config zerotier 'ow_zt1' + option enabled '1' + 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"}], + }, + ] + } + 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): + native = self._tabs( + """package zerotier + +config zerotier 'ow_zt' + option enabled '0' + list join '9536600adf654321' + list join '9536600adf654322' +""" + ) + expected = { + "zerotier": [ + { + "networks": [ + {"id": "9536600adf654321", "ifname": "owzt654321"}, + {"id": "9536600adf654322", "ifname": "owzt654322"}, + ], + "name": "ow_zt", + "disabled": True, + }, + ] + } + o = OpenWrt(native=native) + self.assertEqual(o.config, expected) diff --git a/tests/zerotier/__init__.py b/tests/zerotier/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/zerotier/test_backend.py b/tests/zerotier/test_backend.py new file mode 100644 index 000000000..c72185c1f --- /dev/null +++ b/tests/zerotier/test_backend.py @@ -0,0 +1,408 @@ +import tarfile +import unittest +from copy import deepcopy + +from netjsonconfig import ZeroTier +from netjsonconfig.exceptions import ValidationError + + +class TestBackend(unittest.TestCase): + """ + Tests for ZeroTier backend + """ + + maxDiff = None + + # Single test config + _TEST_CONFIG = { + "zerotier": [ + { + "id": "9536600adf654321", + "nwid": "9536600adf654321", + "objtype": "network", + "revision": 1, + "creationTime": 1632012345, + "name": "zerotier-openwisp-network", + "private": True, + "enableBroadcast": True, + "v4AssignMode": {"zt": True}, + "v6AssignMode": {"6plane": False, "rfc4193": True, "zt": True}, + "mtu": 2700, + "multicastLimit": 16, + "routes": [{"target": "10.0.0.0/24", "via": "10.0.0.1"}], + "ipAssignmentPools": [ + {"ipRangeStart": "10.0.0.10", "ipRangeEnd": "10.0.0.100"} + ], + "dns": {"domain": "zerotier.openwisp.io", "servers": ["10.147.20.3"]}, + "rules": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + }, + {"type": "ACTION_DROP"}, + ], + "capabilities": [ + { + "default": True, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + } + ], + } + ], + "tags": [{"default": 1, "id": 1}], + "remoteTraceTarget": "7f5d90eb87", + "remoteTraceLevel": 1, + } + ] + } + + # Multiple test config + _TEST_MULTIPLE_CONFIG = deepcopy(_TEST_CONFIG) + _TEST_MULTIPLE_CONFIG['zerotier'].append( + { + "id": "9536600adf654322", + "nwid": "9536600adf654322", + "objtype": "network", + "revision": 1, + "creationTime": 1632012345, + "name": "zerotier-openwisp-network-2", + "private": True, + "enableBroadcast": True, + "v4AssignMode": {"zt": True}, + "v6AssignMode": {"6plane": False, "rfc4193": True, "zt": True}, + "mtu": 2700, + "multicastLimit": 16, + "routes": [{"target": "10.0.0.0/24", "via": "10.0.0.1"}], + "ipAssignmentPools": [ + {"ipRangeStart": "10.0.0.10", "ipRangeEnd": "10.0.0.100"} + ], + "dns": {"domain": "zerotier.openwisp.io", "servers": ["10.147.20.3"]}, + "tags": [{"default": 1, "id": 1}], + "remoteTraceTarget": "7f5d90eb87", + "remoteTraceLevel": 1, + } + ) + + def test_test_schema(self): + with self.assertRaises(ValidationError) as context_manager: + ZeroTier({}).validate() + self.assertIn( + "'zerotier' is a required property", str(context_manager.exception) + ) + + def test_confs(self): + c = ZeroTier(self._TEST_CONFIG) + expected = """// zerotier controller config: 9536600adf654321.json + +{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + self.assertEqual(c.render(), expected) + + def test_mutiple_confs(self): + c = ZeroTier(self._TEST_MULTIPLE_CONFIG) + expected = """// zerotier controller config: 9536600adf654321.json + +{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} + +// zerotier controller config: 9536600adf654322.json + +{ + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654322", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network-2", + "nwid": "9536600adf654322", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + self.assertEqual(c.render(), expected) + + def test_generate(self): + c = ZeroTier(self._TEST_MULTIPLE_CONFIG) + expected = """{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + tar = tarfile.open(fileobj=c.generate(), mode="r") + self.assertEqual(len(tar.getmembers()), 2) + vpn = tar.getmember("9536600adf654321.json") + contents = tar.extractfile(vpn).read().decode() + self.assertEqual(contents, expected) + + def test_auto_client(self): + test_config = self._TEST_CONFIG["zerotier"][0] + nw_id = test_config['id'] + expected = { + 'name': 'ow_zt', + 'networks': [{'id': '9536600adf654321', 'ifname': 'owzt654321'}], + 'secret': 'test_secret', + 'config_path': '/etc/openwisp/zerotier', + 'disabled': False, + } + self.assertEqual( + ZeroTier.auto_client( + name="ow_zt", + networks=[ + { + 'id': nw_id, + 'ifname': f'owzt{nw_id[-6:]}', + } + ], + identity_secret="test_secret", + ), + expected, + ) diff --git a/tests/zerotier/test_parser.py b/tests/zerotier/test_parser.py new file mode 100644 index 000000000..acc3a6cc8 --- /dev/null +++ b/tests/zerotier/test_parser.py @@ -0,0 +1,529 @@ +import os +import unittest +from copy import deepcopy + +from netjsonconfig import ZeroTier +from netjsonconfig.exceptions import ParseError, ValidationError + + +class TestParser(unittest.TestCase): + """ + Tests for netjsonconfig.backends.zerotier.parser.BaseParser + """ + + # Single test config + _TEST_CONFIG = { + "zerotier": [ + { + "id": "9536600adf654321", + "nwid": "9536600adf654321", + "objtype": "network", + "revision": 1, + "creationTime": 1632012345, + "name": "zerotier-openwisp-network", + "private": True, + "enableBroadcast": True, + "v4AssignMode": {"zt": True}, + "v6AssignMode": {"6plane": False, "rfc4193": True, "zt": True}, + "mtu": 2700, + "multicastLimit": 16, + "routes": [{"target": "10.0.0.0/24", "via": "10.0.0.1"}], + "ipAssignmentPools": [ + {"ipRangeStart": "10.0.0.10", "ipRangeEnd": "10.0.0.100"} + ], + "dns": {"domain": "zerotier.openwisp.io", "servers": ["10.147.20.3"]}, + "rules": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + }, + {"type": "ACTION_DROP"}, + ], + "capabilities": [ + { + "default": True, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": True, + "or": False, + "type": "MATCH_ETHERTYPE", + } + ], + } + ], + "tags": [{"default": 1, "id": 1}], + "remoteTraceTarget": "7f5d90eb87", + "remoteTraceLevel": 1, + } + ] + } + + # Multiple test config + _TEST_MULTIPLE_CONFIG = deepcopy(_TEST_CONFIG) + _TEST_MULTIPLE_CONFIG['zerotier'].append( + { + "id": "9536600adf654322", + "nwid": "9536600adf654322", + "objtype": "network", + "revision": 1, + "creationTime": 1632012345, + "name": "zerotier-openwisp-network-2", + "private": True, + "enableBroadcast": True, + "v4AssignMode": {"zt": True}, + "v6AssignMode": {"6plane": False, "rfc4193": True, "zt": True}, + "mtu": 2700, + "multicastLimit": 16, + "routes": [{"target": "10.0.0.0/24", "via": "10.0.0.1"}], + "ipAssignmentPools": [ + {"ipRangeStart": "10.0.0.10", "ipRangeEnd": "10.0.0.100"} + ], + "dns": {"domain": "zerotier.openwisp.io", "servers": ["10.147.20.3"]}, + "tags": [{"default": 1, "id": 1}], + "remoteTraceTarget": "7f5d90eb87", + "remoteTraceLevel": 1, + } + ) + + def test_parse_exception(self): + try: + ZeroTier(native=10) + except Exception as e: + self.assertIsInstance(e, ParseError) + else: + self.fail('Exception not raised') + + def test_parse_text(self): + native = """// zerotier controller config: 9536600adf654321.json + +{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", // test + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, // test + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true // test + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + o = ZeroTier(native=native) + self.assertDictEqual(o.config, self._TEST_CONFIG) + + def test_parse_text_without_comment(self): + native = """ +{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + o = ZeroTier(native=native) + self.assertDictEqual(o.config, self._TEST_CONFIG) + + _MULTIPLE_VPN_TEXT = """// zerotier controller config: 9536600adf654321.json + +{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} + +// zerotier controller config: 9536600adf654322.json + +{ + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654322", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network-2", + "nwid": "9536600adf654322", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + + def test_multiple_vpn(self): + o = ZeroTier(native=self._MULTIPLE_VPN_TEXT) + self.assertEqual(o.config, self._TEST_MULTIPLE_CONFIG) + + _MULTIPLE_VPN_TEXT_WITHOUT_COMMENT = """ +{ + "capabilities": [ + { + "default": true, + "id": 1, + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + } + ] + } + ], + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654321", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network", + "nwid": "9536600adf654321", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "rules": [ + { + "etherType": 2048, + "not": true, + "or": false, + "type": "MATCH_ETHERTYPE" + }, + { + "type": "ACTION_DROP" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} + +{ + "creationTime": 1632012345, + "dns": { + "domain": "zerotier.openwisp.io", + "servers": [ + "10.147.20.3" + ] + }, + "enableBroadcast": true, + "id": "9536600adf654322", + "ipAssignmentPools": [ + { + "ipRangeEnd": "10.0.0.100", + "ipRangeStart": "10.0.0.10" + } + ], + "mtu": 2700, + "multicastLimit": 16, + "name": "zerotier-openwisp-network-2", + "nwid": "9536600adf654322", + "objtype": "network", + "private": true, + "remoteTraceLevel": 1, + "remoteTraceTarget": "7f5d90eb87", + "revision": 1, + "routes": [ + { + "target": "10.0.0.0/24", + "via": "10.0.0.1" + } + ], + "tags": [ + { + "default": 1, + "id": 1 + } + ], + "v4AssignMode": { + "zt": true + }, + "v6AssignMode": { + "6plane": false, + "rfc4193": true, + "zt": true + } +} +""" + + def test_multiple_vpn_without_comment(self): + o = ZeroTier(native=self._MULTIPLE_VPN_TEXT_WITHOUT_COMMENT) + self.assertEqual(o.config, self._TEST_MULTIPLE_CONFIG) + + def test_parse_tar_bytesio(self): + conf = deepcopy(self._TEST_MULTIPLE_CONFIG) + conf.update( + {"files": [{"path": "/etc/dummy", "mode": "0644", "contents": "testing!"}]} + ) + tar = ZeroTier(conf).generate() + o = ZeroTier(native=tar) + self.assertDictEqual(o.config, self._TEST_MULTIPLE_CONFIG) + + def test_parse_tar_file(self): + o = ZeroTier(self._TEST_MULTIPLE_CONFIG) + o.write(name='test', path='/tmp') + ZeroTier(native=open('/tmp/test.tar.gz')) + os.remove('/tmp/test.tar.gz') + self.assertDictEqual(o.config, self._TEST_MULTIPLE_CONFIG) + + def test_file_path_min_length(self): + conf = deepcopy(self._TEST_MULTIPLE_CONFIG) + conf.update({"files": [{"path": ".", "mode": "0644", "contents": "testing!"}]}) + with self.assertRaises(ValidationError) as err: + ZeroTier(conf).generate() + self.assertEqual("'.' is too short", err.exception.message)