From a23b87596a4875bbe2837a464eb7550fe4abc30b Mon Sep 17 00:00:00 2001 From: Aryaman Date: Fri, 22 Sep 2023 19:25:55 +0530 Subject: [PATCH] [feature] Added ZeroTier Parser #106 Closes #106 --- README.rst | 2 + netdiff/__init__.py | 1 + netdiff/parsers/zerotier.py | 44 +++++++ tests/static/zt-peers-updated.json | 180 +++++++++++++++++++++++++ tests/static/zt-peers.json | 202 +++++++++++++++++++++++++++++ tests/test_zerotier.py | 137 +++++++++++++++++++ 6 files changed, 566 insertions(+) create mode 100644 netdiff/parsers/zerotier.py create mode 100644 tests/static/zt-peers-updated.json create mode 100644 tests/static/zt-peers.json create mode 100644 tests/test_zerotier.py diff --git a/README.rst b/README.rst index 6ea7b1b..6d5d4ab 100644 --- a/README.rst +++ b/README.rst @@ -243,6 +243,8 @@ The available parsers are: * ``netdiff.NetJsonParser``: parser for the `NetJSON NetworkGraph`_ format * ``netdiff.OpenvpnParser``: parser for the `OpenVPN status file `_ * ``netdiff.WireguardParser``: parser for the Wireguard VPN (the command to use is ``wg show all dump``) +* ``netdiff.ZeroTierParser``: parser for ZeroTier VPN (the command to use is ``zerotier-cli peers -j`` or + access the peers information through the `ZeroTier Service API `_) Initialization arguments ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/netdiff/__init__.py b/netdiff/__init__.py index 99b7287..48c9c02 100644 --- a/netdiff/__init__.py +++ b/netdiff/__init__.py @@ -6,4 +6,5 @@ from .parsers.olsr import OlsrParser # noqa from .parsers.openvpn import OpenvpnParser # noqa from .parsers.wireguard import WireguardParser # noqa +from .parsers.zerotier import ZeroTierParser # noqa from .utils import diff # noqa diff --git a/netdiff/parsers/zerotier.py b/netdiff/parsers/zerotier.py new file mode 100644 index 0000000..64b41c8 --- /dev/null +++ b/netdiff/parsers/zerotier.py @@ -0,0 +1,44 @@ +from netdiff.parsers.base import BaseParser + + +class ZeroTierParser(BaseParser): + version = '1' + metric = 'static' + protocol = 'ZeroTier Controller Peers' + + def to_python(self, data): + return super().to_python(data) + + def parse(self, data): + graph = self._init_graph() + for peer in data: + # In the ZeroTier architecture, a 'LEAF' refers to a device + # that is a member of a ZeroTier virtual network. + # Therefore, we can skip peers with roles other than 'LEAF' + # or with latency -1 (indicating not reachable) + if peer.get('role') != 'LEAF' or peer.get('latency') == -1: + continue + # Similar to zerotier-cli peers command (PATH) + # We only select path that are active, preferred, and not expired + for path in peer.get('paths'): + if ( + not path.get('expired') + and path.get('active') + and path.get('preferred') + ): + peer_address = peer.get('address') + peer_properties = dict( + label=peer_address, + address=peer_address, + ip_address=path.pop('address'), + role=peer.get('role'), + version=peer.get('version'), + tunneled=peer.get('tunneled'), + isBonded=peer.get('isBonded'), + ) + graph.add_node('controller', label='controller') + graph.add_node(peer_address, **peer_properties) + graph.add_edge( + 'controller', peer_address, weight=peer.get('latency'), **path + ) + return graph diff --git a/tests/static/zt-peers-updated.json b/tests/static/zt-peers-updated.json new file mode 100644 index 0000000..fcb4e10 --- /dev/null +++ b/tests/static/zt-peers-updated.json @@ -0,0 +1,180 @@ +[ + { + "address": "3504e2b2e2", + "isBonded": false, + "latency": 9, + "paths": [ + { + "active": true, + "address": "192.168.56.1/44221", + "expired": false, + "lastReceive": 1691493323678, + "lastSend": 1691493323677, + "localSocket": 94434869108304, + "preferred": true, + "trustedPathId": 0 + }, + { + "active": true, + "address": "fd60:8d57:65e9::1/9993", + "expired": false, + "lastReceive": 1691493323678, + "lastSend": 1691493323677, + "localSocket": 94434869123504, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "fd60:8d57:65e9::1/9993", + "expired": false, + "lastReceive": 1691493323678, + "lastSend": 1691493323677, + "localSocket": 94434869095936, + "preferred": false, + "trustedPathId": 0 + } + ], + "role": "LEAF", + "tunneled": false, + "version": "1.10.3", + "versionMajor": 1, + "versionMinor": 10, + "versionRev": 3 + }, + { + "address": "9a9e1c9f19", + "isBonded": false, + "latency": 0, + "paths": [ + { + "active": true, + "address": "192.168.56.6/9993", + "expired": false, + "lastReceive": 1691493318673, + "lastSend": 1691493318672, + "localSocket": 94434869092208, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "192.168.56.1/9993", + "expired": false, + "lastReceive": 1691493318673, + "lastSend": 1691493318672, + "localSocket": 94434869109504, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "172.168.56.9/9993", + "expired": false, + "lastReceive": 1691493323678, + "lastSend": 1691493323677, + "localSocket": 94434869108304, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "LEAF", + "tunneled": false, + "version": "1.10.3", + "versionMajor": 1, + "versionMinor": 10, + "versionRev": 3 + }, + { + "address": "62f865ae71", + "isBonded": false, + "latency": 297, + "paths": [ + { + "active": true, + "address": "50.7.252.138/9993", + "expired": false, + "lastReceive": 1691493263920, + "lastSend": 1691493323677, + "localSocket": 94434869079296, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + }, + { + "address": "778cde7190", + "isBonded": false, + "latency": 276, + "paths": [ + { + "active": true, + "address": "103.195.103.66/9993", + "expired": false, + "lastReceive": 1691493263899, + "lastSend": 1691493323677, + "localSocket": 94434869085344, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + }, + { + "address": "cafe04eba9", + "isBonded": false, + "latency": 163, + "paths": [ + { + "active": true, + "address": "84.17.53.155/9993", + "expired": false, + "lastReceive": 1691493263786, + "lastSend": 1691493327124, + "localSocket": 94434869086304, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + }, + { + "address": "cafe9efeb9", + "isBonded": false, + "latency": 241, + "paths": [ + { + "active": true, + "address": "104.194.8.134/9993", + "expired": false, + "lastReceive": 1691493263864, + "lastSend": 1691493323677, + "localSocket": 94434869086304, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + } + ] diff --git a/tests/static/zt-peers.json b/tests/static/zt-peers.json new file mode 100644 index 0000000..e052719 --- /dev/null +++ b/tests/static/zt-peers.json @@ -0,0 +1,202 @@ +[ + { + "address": "3504e2b2e2", + "isBonded": false, + "latency": 1, + "paths": [ + { + "active": true, + "address": "fd60:8d57:65e9::1/9993", + "expired": false, + "lastReceive": 1691413701307, + "lastSend": 1691413701307, + "localSocket": 94898166342576, + "preferred": true, + "trustedPathId": 0 + }, + { + "active": true, + "address": "192.168.56.1/46076", + "expired": false, + "lastReceive": 1691413695846, + "lastSend": 1691413695844, + "localSocket": 94898166354352, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "192.168.56.1/46076", + "expired": false, + "lastReceive": 1691413695846, + "lastSend": 1691413695844, + "localSocket": 94898166357312, + "preferred": false, + "trustedPathId": 0 + } + ], + "role": "LEAF", + "tunneled": false, + "version": "1.10.3", + "versionMajor": 1, + "versionMinor": 10, + "versionRev": 3 + }, + { + "address": "4a9e1c6f14", + "isBonded": false, + "latency": 1, + "paths": [ + { + "active": true, + "address": "192.168.56.2/9993", + "expired": false, + "lastReceive": 1691413695845, + "lastSend": 1691413695844, + "localSocket": 94898166237728, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "192.168.9.109/5216", + "expired": false, + "lastReceive": 1691413695845, + "lastSend": 1691413700597, + "localSocket": 94898166193600, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "LEAF", + "tunneled": false, + "version": "1.10.3", + "versionMajor": 1, + "versionMinor": 10, + "versionRev": 3 + }, + { + "address": "62f865ae71", + "isBonded": false, + "latency": 299, + "paths": [ + { + "active": true, + "address": "50.7.252.138/9993", + "expired": false, + "lastReceive": 1691413480922, + "lastSend": 1691413675822, + "localSocket": 94898166306960, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + }, + { + "address": "778cde7190", + "isBonded": false, + "latency": 239, + "paths": [ + { + "active": true, + "address": "103.195.103.66/9993", + "expired": false, + "lastReceive": 1691413255618, + "lastSend": 1691413435572, + "localSocket": 94898166308768, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "103.195.103.66/9993", + "expired": false, + "lastReceive": 1691413480862, + "lastSend": 1691413675822, + "localSocket": 94898166309120, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + }, + { + "address": "a7f77c3e11", + "isBonded": false, + "latency": -1, + "paths": [], + "role": "LEAF", + "tunneled": false, + "version": "1.10.6", + "versionMajor": 1, + "versionMinor": 10, + "versionRev": 6 + }, + { + "address": "cafe04eba9", + "isBonded": false, + "latency": 166, + "paths": [ + { + "active": true, + "address": "84.17.53.155/9993", + "expired": false, + "lastReceive": 1691413455702, + "lastSend": 1691413475617, + "localSocket": 94898166309120, + "preferred": false, + "trustedPathId": 0 + }, + { + "active": true, + "address": "84.17.53.155/9993", + "expired": false, + "lastReceive": 1691413695952, + "lastSend": 1691413700848, + "localSocket": 94898166306960, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + }, + { + "address": "cafe9efeb9", + "isBonded": false, + "latency": 239, + "paths": [ + { + "active": true, + "address": "104.194.8.134/9993", + "expired": false, + "lastReceive": 1691413480862, + "lastSend": 1691413675822, + "localSocket": 94898166308768, + "preferred": true, + "trustedPathId": 0 + } + ], + "role": "PLANET", + "tunneled": false, + "version": "-1.-1.-1", + "versionMajor": -1, + "versionMinor": -1, + "versionRev": -1 + } +] diff --git a/tests/test_zerotier.py b/tests/test_zerotier.py new file mode 100644 index 0000000..ca01f48 --- /dev/null +++ b/tests/test_zerotier.py @@ -0,0 +1,137 @@ +import os + +import networkx + +from netdiff import ZeroTierParser, diff +from netdiff.exceptions import ConversionException +from netdiff.tests import TestCase + +CURRENT_DIR = os.path.dirname(os.path.realpath(__file__)) +zt_peers = open(f'{CURRENT_DIR}/static/zt-peers.json').read() +zt_peers_updated = open(f'{CURRENT_DIR}/static/zt-peers-updated.json').read() + + +class TestZeroTierParser(TestCase): + _TEST_PATH_KEYS = [ + 'weight', + 'active', + 'expired', + 'lastReceive', + 'lastSend', + 'localSocket', + 'preferred', + 'trustedPathId', + ] + _TEST_PEER_KEYS = [ + 'label', + 'address', + 'ip_address', + 'role', + 'version', + 'tunneled', + 'isBonded', + ] + + def test_parse(self): + p = ZeroTierParser(zt_peers) + graph = p.graph + self.assertEqual(len(graph.nodes), 3) + self.assertEqual(len(graph.edges), 2) + self.assertIsInstance(graph, networkx.Graph) + edges = list(graph.edges(data=True)) + nodes = list(graph.nodes(data=True)) + edge1 = edges[0] + edge2 = edges[1] + edge1_properties = edge1[2] + edge2_properties = edge2[2] + controller = nodes[0] + node1 = nodes[1] + node2 = nodes[2] + controller_properties = controller[1] + node1_properties = node1[1] + node2_properties = node2[1] + self.assertEqual(edge1[0], 'controller') + self.assertEqual(edge1[1], '3504e2b2e2') + self.assertEqual(list(edge1_properties.keys()), self._TEST_PATH_KEYS) + self.assertEqual(edge2[0], 'controller') + self.assertEqual(edge2[1], '4a9e1c6f14') + self.assertEqual(list(edge2_properties.keys()), self._TEST_PATH_KEYS) + self.assertEqual(controller_properties, {'label': 'controller'}) + self.assertEqual(list(node1_properties.keys()), self._TEST_PEER_KEYS) + self.assertEqual(list(node2_properties.keys()), self._TEST_PEER_KEYS) + + def test_json_dict(self): + p = ZeroTierParser(zt_peers) + data = p.json(dict=True) + self.assertIsInstance(data, dict) + self.assertEqual(data['type'], 'NetworkGraph') + self.assertEqual(data['protocol'], 'ZeroTier Controller Peers') + self.assertEqual(data['version'], '1') + self.assertEqual(data['revision'], None) + self.assertEqual(data['metric'], 'static') + self.assertIsInstance(data['nodes'], list) + self.assertIsInstance(data['links'], list) + self.assertEqual(len(data['nodes']), 3) + self.assertEqual(len(data['links']), 2) + # check presence of labels + labels = [] + for node in data['nodes']: + if 'label' in node: + labels.append(node['label']) + self.assertEqual(len(labels), 3) + self.assertIn('controller', labels) + self.assertIn('3504e2b2e2', labels) + self.assertIn('4a9e1c6f14', labels) + + def test_bogus_data(self): + try: + ZeroTierParser(data='{%$^*([[zsde4323@#}') + except ConversionException: + pass + else: + self.fail('ConversionException not raised') + + def test_empty_dict(self): + ZeroTierParser(data={}) + + def test_no_changes(self): + old = ZeroTierParser(zt_peers) + new = ZeroTierParser(zt_peers) + result = diff(old, new) + self.assertIsInstance(result, dict) + self.assertIsNone(result['added']) + self.assertIsNone(result['removed']) + self.assertIsNone(result['changed']) + + def test_zerotier_link_update(self): + old = ZeroTierParser(zt_peers) + new = ZeroTierParser(zt_peers_updated) + result = diff(old, new) + + with self.subTest('test links addition'): + added = result.get('added') + links = added.get('links') + self.assertEqual(len(links), 1) + self.assertEqual(links[0].get('source'), 'controller') + self.assertEqual(links[0].get('target'), '9a9e1c9f19') + + with self.subTest('test links deletion'): + removed = result.get('removed') + links = removed.get('links') + self.assertEqual(len(links), 1) + self.assertEqual(links[0].get('source'), 'controller') + self.assertEqual(links[0].get('target'), '4a9e1c6f14') + + with self.subTest('test links modification'): + changed = result.get('changed') + nodes = changed.get('nodes') + links = changed.get('links') + # If the IP address of the active link changes, + # then the ip_address property of the + # node will also be modified accordingly + self.assertEqual(len(nodes), 1) + self.assertEqual(len(links), 1) + self.assertEqual(links[0].get('cost'), 9) + self.assertEqual( + nodes[0].get('properties').get('ip_address'), '192.168.56.1/44221' + )