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/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/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..3cd85fc3a --- /dev/null +++ b/netjsonconfig/backends/zerotier/converters.py @@ -0,0 +1,17 @@ +from ..base.converter import BaseConverter +from .schema import schema + + +class ZeroTier(BaseConverter): + netjson_key = 'zerotier' + intermediate_key = 'zerotier' + _schema = schema + + 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=[False, 0, '']): + return self.sorted_dict(config) diff --git a/netjsonconfig/backends/zerotier/parser.py b/netjsonconfig/backends/zerotier/parser.py new file mode 100644 index 000000000..9395854a0 --- /dev/null +++ b/netjsonconfig/backends/zerotier/parser.py @@ -0,0 +1,21 @@ +import re + +from ..base.parser import BaseParser + +vpn_pattern = re.compile('^// zerotier config:\s', flags=re.MULTILINE) +config_pattern = re.compile('^([^\s]*) ?(.*)$') +config_suffix = '.json' + + +class ZeroTierParser(BaseParser): + def parse_text(self, config): + raise NotImplementedError() + + def parse_tar(self, tar): + raise NotImplementedError() + + def _get_vpns(self, text): + raise NotImplementedError() + + def _get_config(self, contents): + raise NotImplementedError() diff --git a/netjsonconfig/backends/zerotier/renderer.py b/netjsonconfig/backends/zerotier/renderer.py new file mode 100644 index 000000000..9d88e1daf --- /dev/null +++ b/netjsonconfig/backends/zerotier/renderer.py @@ -0,0 +1,15 @@ +from ..base.renderer import BaseRenderer + + +class ZeroTierRenderer(BaseRenderer): + """ + ZeroTier Renderer + """ + + def cleanup(self, output): + # remove indentations + output = output.replace(' ', '') + # 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..9b28a41f7 --- /dev/null +++ b/netjsonconfig/backends/zerotier/schema.py @@ -0,0 +1,209 @@ +""" +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, + "properties": { + "zerotier": { + "type": "array", + "title": "ZeroTier", + "uniqueItems": True, + "additionalItems": True, + "items": { + "type": "object", + "title": "ZeroTier Network", + "additionalProperties": True, + "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, + "example": "openwisp-wifi-network", + "description": "Name of the network", + }, + "id": { + "type": "string", + "maxLength": 16, + "readOnly": True, + "example": "3e245e31af000001", + "description": "Network ID", + }, + "nwid": { + "type": "string", + "maxLength": 16, + "readOnly": True, + "example": "3e245e31af000001", + "description": "Network ID legacy field (same as 'id')", + }, + "objtype": { + "type": "string", + "readOnly": True, + "default": "network", + "example": "network", + }, + "revision": { + "type": "integer", + "example": 1, + "readOnly": True, + "description": "The revision number of the network configuration", + }, + "creationTime": { + "type": "number", + "readOnly": True, + "example": 1623101592, + "description": "Time when the network was created", + }, + # Configurable properties + "private": { + "type": "boolean", + "default": True, + "description": ( + "Whether or not the network is private " + "If false, members will NOT need to be authorized to join" + ), + }, + "enableBroadcast": { + "type": "boolean", + "description": "Enable broadcast packets on the network", + }, + "v4AssignMode": { + "type": "object", + "properties": { + "zt": { + "type": "boolean", + "description": "Whether ZeroTier should assign IPv4 addresses to members", + }, + }, + }, + "v6AssignMode": { + "type": "object", + "properties": { + "6plane": { + "type": "boolean", + "description": "Whether 6PLANE addressing should be used for IPv6 assignment", + }, + "rfc4193": { + "type": "boolean", + "description": "Whether RFC4193 addressing should be used for IPv6 assignment", # noqa + }, + "zt": { + "type": "boolean", + "description": "Whether ZeroTier should assign IPv6 addresses to members", + }, + }, + }, + "mtu": { + "type": "integer", + "example": 2800, + "description": "MTU to set on the client virtual network adapter", + }, + "multicastLimit": { + "type": "integer", + "example": 32, + "description": ( + "Maximum number of recipients per multicast or broadcast." + "Warning - Setting this to 0 will disable IPv4 communication on your network!" + ), + }, + "routes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target": { + "type": "string", + "example": "192.168.192.0/24", + "description": "The target IP address range for the route", + }, + "via": { + "type": "string", + "example": "192.168.192.1", + "description": "The IP address of the next hop for the route", + }, + }, + }, + "description": "Array of route objects", + }, + "ipAssignmentPools": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ipRangeStart": { + "type": "string", + "example": "192.168.192.1", + "description": "The starting IP address of the pool range", + }, + "ipRangeEnd": { + "type": "string", + "example": "192.168.192.254", + "description": "The ending IP address of the pool range", + }, + }, + }, + "description": "Range of IP addresses for the auto assign pool", + }, + "dns": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "example": "zerotier.openwisp.io", + "description": "The domain for DNS resolution", + }, + "servers": { + "type": "array", + "items": { + "type": "string", + "example": "10.147.20.3", + "description": "The DNS server IP addresses", + }, + }, + }, + }, + "rules": { + "type": "array", + "items": {"type": "object"}, + "description": "Array of network rule objects", + }, + "capabilities": { + "type": "array", + "items": {"type": "object"}, + "description": "Array of network capabilities", + }, + "tags": { + "type": "array", + "items": {"type": "object"}, + "description": "Array of network tag objects", + }, + "remoteTraceTarget": { + "type": "string", + "example": "7f5d90eb87", + "description": "The remote target ID for network tracing", + }, + "remoteTraceLevel": { + "type": "integer", + "description": "The level of network tracing", + }, + }, + }, + }, + }, +} + +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..e8e25594f --- /dev/null +++ b/netjsonconfig/backends/zerotier/templates/zerotier.jinja2 @@ -0,0 +1,6 @@ +{% for vpn in data.zerotier %} +// zerotier config: {{ vpn.nwid }} + +{{ vpn | tojson(indent=2)}} + +{% endfor %} diff --git a/netjsonconfig/backends/zerotier/zerotier.py b/netjsonconfig/backends/zerotier/zerotier.py new file mode 100644 index 000000000..f8e9ff765 --- /dev/null +++ b/netjsonconfig/backends/zerotier/zerotier.py @@ -0,0 +1,26 @@ +from ..base.backend import BaseVpnBackend +from . import converters +from .parser import config_suffix, vpn_pattern +from .renderer import ZeroTierRenderer +from .schema import schema + + +class ZeroTier(BaseVpnBackend): + schema = schema + converters = [converters.ZeroTier] + renderer = ZeroTierRenderer + # BaseVpnBackend attributes + vpn_pattern = vpn_pattern + config_suffix = config_suffix + + @classmethod + def auto_client(cls, server={}, **kwargs): + network_id = server.get('id', server.get('nwid', '')) + return { + 'zerotier': [ + { + 'id': network_id, + 'name': server.get('name', ''), + } + ] + } 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..286f262ac --- /dev/null +++ b/tests/zerotier/test_backend.py @@ -0,0 +1,311 @@ +import tarfile +import unittest + +from netjsonconfig import ZeroTier +from netjsonconfig.exceptions import ValidationError + + +class TestBackend(unittest.TestCase): + """ + Tests for ZeroTier backend + """ + + maxDiff = None + _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, + }, + { + "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 config: 9536600adf654321 + +{ + "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 config: 9536600adf654322 + +{ + "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_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): + expected = { + "zerotier": [ + { + "id": "9536600adf654321", + "name": "zerotier-openwisp-network", + } + ] + } + self.assertEqual( + ZeroTier.auto_client(server=self._TEST_CONFIG["zerotier"][0]), expected + ) diff --git a/tests/zerotier/test_parser.py b/tests/zerotier/test_parser.py new file mode 100644 index 000000000..72d8ee385 --- /dev/null +++ b/tests/zerotier/test_parser.py @@ -0,0 +1,34 @@ +import unittest +from unittest.mock import patch + +from netjsonconfig.backends.zerotier.parser import ZeroTierParser + + +class TestBaseParser(unittest.TestCase): + """ + Tests for netjsonconfig.backends.zerotier.parser.BaseParser + """ + + def test_parse_text(self): + # Creating an instance of ZeroTier Parser will raise + # NotImplementedError since it will requires "parse_text" + with self.assertRaises(NotImplementedError): + ZeroTierParser(config="") + + @patch.object(ZeroTierParser, "parse_text", return_value=None) + def test_parse_tar(self, mocked): + parser = ZeroTierParser(config="") + with self.assertRaises(NotImplementedError): + parser.parse_tar(tar=None) + + @patch.object(ZeroTierParser, "parse_text", return_value=None) + def test_get_vpns(self, mocked): + parser = ZeroTierParser(config="") + with self.assertRaises(NotImplementedError): + parser._get_vpns(text=None) + + @patch.object(ZeroTierParser, "parse_text", return_value=None) + def test_get_config(self, mocked): + parser = ZeroTierParser(config="") + with self.assertRaises(NotImplementedError): + parser._get_config(contents=None)