Repository: openwisp/openwisp-network-topology (language: python)

Issue:
[feature] Add controller integration for Wireguard
This module as an optional subapp which depends on openwisp-controller and is able to connect the topology information to the Device in OpenWISP Controller, that allows to show meaningful names in the topology data.

The code is here: https://github.com/openwisp/openwisp-network-topology/tree/master/openwisp_network_topology/integrations/device.

The differences for Wireguard should be contained in the methods mentioned here:
https://github.com/openwisp/openwisp-network-topology/blob/master/openwisp_network_topology/integrations/device/base/models.py#L25-L31

I think the up and down actions for wireguard can be empty. The `auto_create_wireguard` shall look into the DB for devices of the same organization (or any organization if the topology is shared) for devices which have a related vpnclient object with the same ip, eg: 

```
Device.objects.filter(config__vpnclient__ip__ip_address='10.254.0.5').first()
```

We shall update [the docs regarding this integration](https://github.com/openwisp/openwisp-network-topology#integration-with-openwisp-controller-and-openwisp-monitoring) to mention Wireguard.
[feature] Add controller integration for Wireguard
This module as an optional subapp which depends on openwisp-controller and is able to connect the topology information to the Device in OpenWISP Controller, that allows to show meaningful names in the topology data.

The code is here: https://github.com/openwisp/openwisp-network-topology/tree/master/openwisp_network_topology/integrations/device.

The differences for Wireguard should be contained in the methods mentioned here:
https://github.com/openwisp/openwisp-network-topology/blob/master/openwisp_network_topology/integrations/device/base/models.py#L25-L31

I think the up and down actions for wireguard can be empty. The `auto_create_wireguard` shall look into the DB for devices of the same organization (or any organization if the topology is shared) for devices which have a related vpnclient object with the same ip, eg: 

```
Device.objects.filter(config__vpnclient__ip__ip_address='10.254.0.5').first()
```

We shall update [the docs regarding this integration](https://github.com/openwisp/openwisp-network-topology#integration-with-openwisp-controller-and-openwisp-monitoring) to mention Wireguard.


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index ec0f10ef..92f4c5de 100644
--- a/README.rst
+++ b/README.rst
@@ -434,12 +434,12 @@ Integration with OpenWISP Controller and OpenWISP Monitoring
 
 If you use `OpenWISP Controller <https://github.com/openwisp/openwisp-controller>`_
 or `OpenWISP Monitoring <https://github.com/openwisp/openwisp-monitoring>`_
-and you use OpenVPN for the management VPN, you can use the integration available in
-``openwisp_network_topology.integrations.device``.
+and you use OpenVPN or Wireguard for the management VPN, you can use the integration
+available in ``openwisp_network_topology.integrations.device``.
 
 This additional and optional module provides the following features:
 
-- whenever the stauts of an OpenVPN link changes:
+- whenever the status of a link changes:
 
   - the management IP address of the related device is updated straightaway
   - if OpenWISP Monitoring is enabled, the device checks are triggered (e.g.: ping)
diff --git a/openwisp_network_topology/integrations/device/base/models.py b/openwisp_network_topology/integrations/device/base/models.py
index c7319264..e60562cb 100644
--- a/openwisp_network_topology/integrations/device/base/models.py
+++ b/openwisp_network_topology/integrations/device/base/models.py
@@ -1,5 +1,5 @@
 import logging
-from ipaddress import ip_address
+from ipaddress import ip_address, ip_network
 
 from django.conf import settings
 from django.db import models
@@ -27,13 +27,34 @@ class AbstractDeviceNode(UUIDModel):
             'auto_create': 'auto_create_openvpn',
             'link_down': 'link_down_openvpn',
             'link_up': 'link_up_openvpn',
-        }
+        },
+        'netdiff.WireguardParser': {
+            'auto_create': 'auto_create_wireguard',
+        },
     }
 
     class Meta:
         abstract = True
         unique_together = ('node', 'device')
 
+    @classmethod
+    def save_device_node(cls, device, node):
+        device_node = cls(device=device, node=node)
+        try:
+            device_node.full_clean()
+            device_node.save()
+            # Update organization of the node. This is required
+            # when topology is shared.
+            if node.organization_id is None:
+                node.organization_id = device.organization_id
+                node.save(update_fields=['organization_id'])
+        except Exception:
+            logger.exception('Exception raised during auto_create_openvpn')
+            return
+        else:
+            logger.info(f'DeviceNode relation created for {node.label} - {device.name}')
+            return device_node
+
     @classmethod
     def auto_create(cls, node):
         """
@@ -69,21 +90,41 @@ def auto_create_openvpn(cls, node):
         if not device:
             return
 
-        device_node = cls(device=device, node=node)
-        try:
-            device_node.full_clean()
-            device_node.save()
-            # Update organization of the node. This is required
-            # when topology is shared.
-            if node.organization_id is None:
-                node.organization_id = device.organization_id
-                node.save(update_fields=['organization_id'])
-        except Exception:
-            logger.exception('Exception raised during auto_create_openvpn')
+        return cls.save_device_node(device, node)
+
+    @classmethod
+    def auto_create_wireguard(cls, node):
+        allowed_ips = node.properties.get('allowed_ips')
+        if not allowed_ips:
             return
-        else:
-            logger.info(f'DeviceNode relation created for {node.label} - {device.name}')
-            return device_node
+        Device = load_model('config', 'Device')
+        ip_addresses = []
+        for ip in allowed_ips:
+            try:
+                network = ip_network(ip)
+                if network.prefixlen == network._max_prefixlen:
+                    # In python 3.7, hosts method is not returning any ip
+                    # if subnet mask is 32, resolved in future python releases
+                    # https://bugs.python.org/issue28577
+                    ip_addresses.append(str(network.network_address))
+                else:
+                    ip_addresses.extend([str(host) for host in ip_network(ip).hosts()])
+            except ValueError:
+                # invalid IP address
+                continue
+        device_filter = models.Q(config__vpnclient__ip__ip_address__in=ip_addresses)
+        if node.organization_id:
+            device_filter &= models.Q(organization_id=node.organization_id)
+        device = (
+            Device.objects.only(
+                'id', 'name', 'last_ip', 'management_ip', 'organization_id'
+            )
+            .filter(device_filter)
+            .first()
+        )
+        if not device:
+            return
+        return cls.save_device_node(device, node)
 
     def link_action(self, link, status):
         """
@@ -93,7 +134,8 @@ def link_action(self, link, status):
         opts = self.ENABLED_PARSERS.get(link.topology.parser)
         if opts:
             key = f'link_{status}'
-            return getattr(self, opts[key])()
+            if key in opts and hasattr(self, opts[key]):
+                return getattr(self, opts[key])()
 
     def link_down_openvpn(self):
         """
diff --git a/openwisp_network_topology/integrations/device/management/commands/__init__.py b/openwisp_network_topology/integrations/device/management/commands/__init__.py
index f2081c3c..ed55210b 100644
--- a/openwisp_network_topology/integrations/device/management/commands/__init__.py
+++ b/openwisp_network_topology/integrations/device/management/commands/__init__.py
@@ -1,5 +1,6 @@
 import swapper
 from django.core.management.base import BaseCommand
+from django.db.models import Q
 
 
 class BaseCreateDeviceNodeCommand(BaseCommand):
@@ -9,7 +10,8 @@ def handle(self, *args, **kwargs):
         Node = swapper.load_model('topology', 'Node')
         DeviceNode = swapper.load_model('topology_device', 'DeviceNode')
         queryset = Node.objects.select_related('topology').filter(
-            topology__parser='netdiff.OpenvpnParser'
+            Q(topology__parser='netdiff.OpenvpnParser')
+            | Q(topology__parser='netdiff.WireguardParser')
         )
         for node in queryset.iterator():
             DeviceNode.auto_create(node)


Test patch:
diff --git a/openwisp_network_topology/integrations/device/tests.py b/openwisp_network_topology/integrations/device/tests.py
index 364a793f..8e42055f 100644
--- a/openwisp_network_topology/integrations/device/tests.py
+++ b/openwisp_network_topology/integrations/device/tests.py
@@ -11,7 +11,9 @@
 from openwisp_controller.config.tests.utils import (
     CreateConfigTemplateMixin,
     TestVpnX509Mixin,
+    TestWireguardVpnMixin,
 )
+from openwisp_ipam.tests import CreateModelsMixin as SubnetIpamMixin
 
 from openwisp_network_topology.tests.utils import CreateGraphObjectsMixin
 from openwisp_users.tests.utils import TestOrganizationMixin
@@ -34,6 +36,8 @@
 
 class Base(
     TestVpnX509Mixin,
+    TestWireguardVpnMixin,
+    SubnetIpamMixin,
     CreateConfigTemplateMixin,
     CreateGraphObjectsMixin,
     TestOrganizationMixin,
@@ -63,6 +67,39 @@ def _init_test_node(
             node.save()
         return node
 
+    def _init_wireguard_test_node(self, topology, addresses=[], create=True, **kwargs):
+        if not addresses:
+            addresses = ['public_key']
+        properties = {
+            'preshared_key': None,
+            'endpoint': None,
+            'latest_handsake': '0',
+            'transfer_rx': '0',
+            'transfer_tx': '0',
+            'persistent_keepalive': 'off',
+            'allowed_ips': ['10.0.0.2/32'],
+        }
+        properties.update(kwargs)
+        allowed_ips = properties.get('allowed_ips')
+        node = Node(
+            organization=topology.organization,
+            topology=topology,
+            label=','.join(allowed_ips),
+            addresses=addresses,
+            properties=properties,
+        )
+        if create:
+            node.full_clean()
+            node.save()
+        return node
+
+    def _create_wireguard_test_env(self, parser):
+        org = self._get_org()
+        device, _, _ = self._create_wireguard_vpn_template()
+        device.organization = org
+        topology = self._create_topology(organization=org, parser=parser)
+        return topology, device
+
     def _create_test_env(self, parser):
         organization = self._get_org()
         vpn = self._create_vpn(name='test VPN', organization=organization)
@@ -128,6 +165,38 @@ def test_auto_create_openvpn_failures(self):
                     logger_exception.assert_called_once()
                     self.assertEqual(DeviceNode.objects.count(), 0)
 
+    def test_auto_create_wireguard(self):
+        topology, device = self._create_wireguard_test_env(
+            parser='netdiff.WireguardParser'
+        )
+        self.assertEqual(DeviceNode.objects.count(), 0)
+        with self.subTest('return if node has no allowed ips'):
+            node = self._init_wireguard_test_node(topology, allowed_ips=[])
+            self.assertEqual(DeviceNode.objects.count(), 0)
+        with self.subTest('handle error if node has bogus allowed ips'):
+            try:
+                node = self._init_wireguard_test_node(topology, allowed_ips=['invalid'])
+            except ValueError:
+                self.fail('ValueError raised')
+        with self.subTest('assert number of queries'):
+            with self.assertNumQueries(13):
+                node = self._init_wireguard_test_node(topology)
+        self.assertEqual(DeviceNode.objects.count(), 1)
+        device_node = DeviceNode.objects.first()
+        self.assertEqual(device_node.device, device)
+        self.assertEqual(device_node.node, node)
+        with self.subTest('do not raise Exception on link status changed'):
+            try:
+                target_node = self._init_wireguard_test_node(topology)
+                link = Link(
+                    topology=node.topology, source=node, target=target_node, cost=0
+                )
+                link.save()
+                link.status = 'down'
+                link.save(update_fields=['status'])
+            except KeyError:
+                self.fail('KeyError raised')
+
     def test_filter_by_link(self):
         topology, device, cert = self._create_test_env(parser='netdiff.OpenvpnParser')