Repository: openwisp/openwisp-controller (language: python)

Issue:
[docs] Update screenshots of wireguard and subnet division rule howtos
Update the screenshots of wireguard and subnet division rule howtos to the new theme.


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 0365000bd..e5df9a60a 100755
--- a/README.rst
+++ b/README.rst
@@ -136,7 +136,8 @@ Config App
 * **template tags**: tag templates to automate different types of auto-configurations (eg: mesh, WDS, 4G)
 * **device groups**: add `devices to dedicated groups <#device-groups>`_ for easy management
 * **simple HTTP resources**: allow devices to automatically download configuration updates
-* **VPN management**: automatically provision VPN tunnels with unique x509 certificates
+* **VPN management**: `automatically provision VPN tunnels <#openwisp-controller-default-auto-cert>`_,
+  including cryptographic keys, IP addresses
 
 PKI App
 ~~~~~~~
@@ -170,6 +171,18 @@ The geographic app is based on `django-loci <https://github.com/openwisp/django-
 and allows to define the geographic coordinates of the devices,
 as well as their indoor coordinates on floorplan images.
 
+Subnet Division App
+~~~~~~~~~~~~~~~~~~~
+
+This app allows to automatically provision subnets and IP addresses which will be
+available as `system defined configuration variables <#system-defined-variables>`_
+that can be used in templates. The purpose of this app is to allow users to automatically
+provision and configure specific
+subnets and IP addresses to the devices without the need of manual intervention.
+
+Refer to `"How to configure automatic provisioning of subnets and IPs" section of this documentation <#how-to-configure-automatic-provisioning-of-subnets-and-ips>`_
+to learn about features provided by this app.
+
 Settings
 --------
 
@@ -304,18 +317,20 @@ Available configuration backends. For more information, see `netjsonconfig backe
 ``OPENWISP_CONTROLLER_VPN_BACKENDS``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-+--------------+----------------------------------------------------------------+
-| **type**:    | ``tuple``                                                      |
-+--------------+----------------------------------------------------------------+
-| **default**: | .. code-block:: python                                         |
-|              |                                                                |
-|              |   (                                                            |
-|              |     ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),   |
-|              |   )                                                            |
-+--------------+----------------------------------------------------------------+
-
-Available VPN backends for VPN Server objects. For more information, see `OpenVPN netjsonconfig backend
-<http://netjsonconfig.openwisp.org/en/latest/backends/openvpn.html>`_.
++--------------+----------------------------------------------------------------------------------+
+| **type**:    | ``tuple``                                                                        |
++--------------+----------------------------------------------------------------------------------+
+| **default**: | .. code-block:: python                                                           |
+|              |                                                                                  |
+|              |   (                                                                              |
+|              |     ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),                     |
+|              |     ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'),                 |
+|              |     ('openwisp_controller.vpn_backends.VxlanWireguard', 'VXLAN over WireGuard'), |
+|              |   )                                                                              |
++--------------+----------------------------------------------------------------------------------+
+
+Available VPN backends for VPN Server objects. For more information, see `netjsonconfig VPN backends
+<https://netjsonconfig.openwisp.org/en/latest/backends/vpn-backends.html>`_.
 
 A VPN backend must follow some basic rules in order to be compatible with *openwisp-controller*:
 
@@ -439,10 +454,23 @@ of OpenWISP, see `netjsonconfig context: configuration variables
 The default value of the ``auto_cert`` field for new ``Template`` objects.
 
 The ``auto_cert`` field is valid only for templates which have ``type``
-set to ``VPN`` and indicates whether a new x509 certificate should be created
-automatically for each configuration using that template.
-
-The automatically created certificates will also be removed when they are not
+set to ``VPN`` and indicates whether configuration regarding the VPN tunnel is
+provisioned automatically to each device using the template, eg:
+
+- when using OpenVPN, new `x509 <https://tools.ietf.org/html/rfc5280>`_ certificates
+  will be generated automatically using the same CA assigned to the related VPN object
+- when using WireGuard, new pair of private and public keys
+  (using `Curve25519 <http://cr.yp.to/ecdh.html>`_) will be generated, as well as
+  an IP address of the subnet assigned to the related VPN object
+- when using `VXLAN <https://tools.ietf.org/html/rfc7348>`_ tunnels over Wireguad,
+  in addition to the configuration generated for WireGuard, a new VID will be generated
+  automatically for each device if the configuration option "auto VNI" is turned on in
+  the VPN object
+
+All these auto generated configuration options will be available as
+template variables.
+
+The objects that are automatically created will also be removed when they are not
 needed anymore (eg: when the VPN template is removed from a configuration object).
 
 ``OPENWISP_CONTROLLER_CERT_PATH``
@@ -593,6 +621,37 @@ For example, if we want to change the verbose name to "Hotspot", we could write:
 
     OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME = ('Hotspot', 'Hotspots')
 
+``OPENWISP_CONTROLLER_HIDE_AUTOMATICALLY_GENERATED_SUBNETS_AND_IPS``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
++--------------+-----------+
+| **type**:    | ``bool``  |
++--------------+-----------+
+| **default**: | ``False`` |
++--------------+-----------+
+
+Setting this to ``True`` will hide subnets and IPs generated using `subnet division rules <#subnet-division-app>`_
+from being displayed on the changelist view of Subnet and IP admin.
+
+``OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
++--------------+---------------------------------------------------------------------------------------------------------+
+| **type**:    | ``tuple``                                                                                               |
++--------------+---------------------------------------------------------------------------------------------------------+
+| **default**: | .. code-block:: python                                                                                  |
+|              |                                                                                                         |
+|              |    (                                                                                                    |
+|              |       ('openwisp_controller.subnet_division.rule_types.device.DeviceSubnetDivisionRuleType', 'Device'), |
+|              |       ('openwisp_controller.subnet_division.rule_types.vpn.VpnSubnetDivisionRuleType', 'VPN'),          |
+|              |    )                                                                                                    |
+|              |                                                                                                         |
++--------------+---------------------------------------------------------------------------------------------------------+
+
+`Available types for Subject Division Rule <#device-subnet-division-rule>`_ objects.
+For more information on how to write your own types, read
+`"Custom Subnet Division Rule Types" section of this documentation <#custom-subnet-division-rule-types>`_
+
 ``OPENWISP_CONTROLLER_API``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -1797,6 +1856,353 @@ so they can be overridden if needed.
 In the example above, the "SSID" template is flagged as "(required)"
 and its checkbox is always checked and disabled.
 
+How to configure automatic provisioning of subnets and IPs
+----------------------------------------------------------
+
+The following steps will help you configure automatic provisioning of subnets and IPs
+for devices:
+
+1. Create a Subnet and a Subnet Division Rule
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Create a master subnet under which automatically generated subnets will be provisioned.
+
+**Note**: Choose the size of the subnet appropriately considering your use case.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/subnet.png
+  :alt: Creating a master subnet example
+
+On the same page, add a **subnet division rule** that will be used to provision subnets
+under the master subnet.
+
+The type of subnet division rule controls when subnets and IP addresses will be provisioned
+for a device. The subnet division rule types currently implemented are described below.
+
+Device Subnet Division Rule
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This rule type is triggered whenever a device configuration (``config.Config`` model)
+is created for the organization specified in the rule.
+
+Creating a new rule of "Device" type will also provision subnets and
+IP addresses for existing devices of the organization automatically.
+
+**Note**: a device without a configuration will not trigger this rule.
+
+VPN Subnet Division Rule
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+This rule is triggered when a VPN client template is assigned to a device,
+provided the VPN server to which the VPN client template relates to has
+the same subnet for which the subnet division rule is created.
+
+**Note:** This rule will only work for **WireGuard** and **VXLAN over WireGuard**
+VPN servers.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/subnet-division-rule.png
+  :alt: Creating a subnet division rule example
+
+In this example, **VPN subnet division rule** is used.
+
+2. Create a VPN Server
+~~~~~~~~~~~~~~~~~~~~~~
+
+Now create a VPN Server and choose the previously created **master subnet** as the subnet for
+this VPN Server.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/vpn-server.png
+  :alt: Creating a VPN Server example
+
+3. Create a VPN Client Template
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Create a template, setting the **Type** field to **VPN Client** and **VPN** field to use the
+previously created VPN Server.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/vpn-client.png
+  :alt: Creating a VPN Client template example
+
+**Note**: You can also check the **Enable by default** field if you want to automatically
+apply this template to devices that will register in future.
+
+4. Apply VPN Client Template to Devices
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+With everything in place, you can now apply the VPN Client Template to devices.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/apply-template-to-device.png
+  :alt: Adding template to device example
+
+After saving the device, you should see all provisioned Subnets and IPs for this device
+under the `System Defined Variables <#system-defined-variables>`_.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/system-defined-variables.png
+  :alt: Provisioned Subnets and IPs available as System Defined Variables example
+
+Voila! You can now use these variables in configuration of the device. Refer to `How to use configuration variables <#how-to-use-configuration-variables>`_
+section of this documentation to learn how to use configuration variables.
+
+Important notes for using Subnet Division
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+- In the above example Subnet, VPN Server, and VPN Client Template belonged to the **default** organization.
+  You can use **Systemwide Shared** Subnet, VPN Server, or VPN Client Template too, but
+  Subnet Division Rule will be always related to an organization. The Subnet Division Rule will only be
+  triggered when such VPN Client Template will be applied to a Device having the same organization as Subnet Division Rule.
+
+- You can also use the configuration variables for provisioned subnets and IPs in the Template.
+  Each variable will be resolved differently for different devices. E.g. ``OW_subnet1_ip1`` will resolve to
+  ``10.0.0.1`` for one device and ``10.0.0.55`` for another. Every device gets its own set of subnets and IPs.
+  But don't forget to provide the default fall back values in the "default values" template field
+  (used mainly for validation).
+
+- The Subnet Division Rule will automatically create a reserved subnet, this subnet can be used
+  to provision any IP addresses that have to be created manually. The rest of the master subnet
+  address space **must not** be interfered with or the automation implemented in this module
+  will not work.
+
+- The above example used `VPN subnet division rule <#vpn-subnet-division-rule>`_. Similarly,
+  `device subnet division rule <#device-subnet-division-rule>`_ can be used, which only requires
+  `creating a subnet and a subnet division rule <#1-create-a-subnet-and-a-subnet-division-rule>`_.
+
+Limitations of Subnet Division
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+In the current implementation, it is not possible to change "Size", "Number of Subnets" and
+"Number of IPs" fields of an existing subnet division rule due to following reasons:
+
+Size
+""""
+
+Allowing to change size of provisioned subnets of an existing subnet division rule
+will require rebuilding of Subnets and IP addresses which has possibility of breaking
+existing configurations.
+
+Number of Subnets
+"""""""""""""""""
+
+Allowing to decrease number of subnets of an existing subnet division
+rule can create patches of unused subnets dispersed everywhere in the master subnet.
+Allowing to increase number of subnets will break the continuous allocation of subnets for
+every device. It can also break configuration of devices.
+
+Number of IPs
+"""""""""""""
+
+Allowing to decrease number of IPs of an existing subnet division rule
+will lead to deletion of IP Addresses which can break configuration of devices being used.
+It **is allowed** to increase number of IPs.
+
+If you want to make changes to any of above fields, delete the existing rule and create a
+new one. The automation will provision for all existing devices that meets the criteria
+for provisioning. **WARNING**: It is possible that devices get different subnets and IPs
+from previous provisioning.
+
+How to setup WireGuard tunnels
+------------------------------
+
+Follow the procedure described below to setup WireGuard tunnels on your devices.
+
+**Note:** This example uses **Shared systemwide (no organization)** option as
+the organization for VPN server and VPN client template. You can use any
+organization as long as VPN server, VPN client template and Device has same
+organization.
+
+1. Create VPN server configuration for WireGuard
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Visit ``/admin/config/vpn/add/`` to add a new VPN server.
+2. We will set **Name** of this VPN server ``Wireguard`` and **Host** as
+   ``wireguard-server.mydomain.com`` (update this to point to your
+   WireGuard VPN server).
+3. Select ``WireGuard`` from the dropdown as **VPN Backend**.
+4. When using WireGuard, OpenWISP takes care of managing IP addresses
+   (assigning an IP address to each VPN peer). You can create a new subnet or
+   select an existing one from the dropdown menu. You can also assign an
+   **Internal IP** to the WireGuard Server or leave it empty for OpenWISP to
+   configure. This IP address will be used by the WireGuard interface on
+   server.
+5. We have set the **Webhook Endpoint** as ``https://wireguard-server.mydomain.com:8081/trigger-update``
+   for this example. You will need to update this according to you VPN upgrader
+   endpoint. Set **Webhook AuthToken** to any strong passphrase, this will be
+   used to ensure that configuration upgrades are requested from trusted
+   sources.
+
+   **Note**: If you are following this tutorial for also setting up WireGuard
+   VPN server, just substitute ``wireguard-server.mydomain.com`` with hostname
+   of your VPN server and follow the steps in next section.
+
+6. Under the configuration section, set the name of WireGuard tunnel 1 interface.
+   We have used ``wg0`` in this example.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/vpn-server-1.png
+   :alt: WireGuard VPN server configuration example 1
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/vpn-server-2.png
+   :alt: WireGuard VPN server configuration example 2
+
+7. After clicking on **Save and continue editing**, you will see that OpenWISP
+   has automatically created public and private key for WireGuard server in
+   **System Defined Variables** along with internal IP address information.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/vpn-server-3.png
+   :alt: WireGuard VPN server configuration example 3
+
+2. Deploy Wireguard VPN Server
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you haven't already setup WireGuard on your VPN server, this will be a good
+time do so. We stress on using `ansible-wireguard-openwisp <https://github.com/openwisp/ansible-wireguard-openwisp>`_
+role for installing WireGuard since it also installs scripts that allows
+OpenWISP to manage WireGuard VPN server.
+
+Pay attention to the VPN server attributes used in your playbook. It should be same as
+VPN server configuration in OpenWISP.
+
+3. Create VPN client template for WireGuard VPN Server
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Visit ``/admin/config/template/add/`` to add a new template.
+2. Set ``Wireguard Client`` as **Name** (you can set whatever you want) and
+   select ``VPN-client`` as **type** from the dropdown list.
+3. The **Backend** field refers to the backend of the device this template can
+   be applied to. For this example, we will leave it to ``OpenWRT``.
+4. Select the correct VPN server from the dropdown for the **VPN** field. Here
+   it is ``Wireguard``.
+5. Ensure that **Automatic tunnel provisioning** is checked. This will make
+   OpenWISP to automatically generate public and private keys and provision IP
+   address for each WireGuard VPN client.
+6. After clicking on **Save and continue editing** button, you will see details
+   of *Wireguard* VPN server in **System Defined Variables**. The template
+   configuration will be automatically generated which you can tweak
+   accordingly. We will use the automatically generated VPN client configuration
+   for this example.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/template.png
+    :alt: WireGuard VPN client template example
+
+4. Apply Wireguard VPN template to devices
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**Note**: This step assumes that you already have a device registered on
+OpenWISP. Register or create a device before proceeding.
+
+1. Open the **Configuration** tab of the concerned device.
+2. Select the *WireGuard Client* template.
+3. Upon clicking on **Save and continue editing** button, you will see some
+   entries in **System Defined Variables**. It will contain internal IP address,
+   private and public key for the WireGuard client on the device along with
+   details of WireGuard VPN server.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/device-configuration.png
+   :alt: WireGuard VPN device configuration example
+
+**Voila!** You have successfully configured OpenWISP to manage WireGuard
+tunnels for your devices.
+
+How to setup VXLAN over WireGuard tunnels
+-----------------------------------------
+
+By following these steps, you will be able to setup layer 2 VXLAN tunnels
+encapsulated in WireGuard tunnels which work on layer 3.
+
+**Note:** This example uses **Shared systemwide (no organization)** option as
+the organization for VPN server and VPN client template. You can use any
+organization as long as VPN server, VPN client template and Device has same
+organization.
+
+1. Create VPN server configuration for VXLAN over WireGuard
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Visit ``/admin/config/vpn/add/`` to add a new VPN server.
+2. We will set **Name** of this VPN server ``Wireguard VXLAN`` and **Host** as
+   ``wireguard-vxlan-server.mydomain.com`` (update this to point to your
+   WireGuard VXLAN VPN server).
+3. Select ``VXLAN over WireGuard`` from the dropdown as **VPN Backend**.
+4. When using VXLAN over WireGuard, OpenWISP takes care of managing IP addresses
+   (assigning an IP address to each VPN peer). You can create a new subnet or
+   select an existing one from the dropdown menu. You can also assign an
+   **Internal IP** to the WireGuard Server or leave it empty for OpenWISP to
+   configure. This IP address will be used by the WireGuard interface on
+   server.
+5. We have set the **Webhook Endpoint** as ``https://wireguard-vxlan-server.mydomain.com:8081/trigger-update``
+   for this example. You will need to update this according to you VPN upgrader
+   endpoint. Set **Webhook AuthToken** to any strong passphrase, this will be
+   used to ensure that configuration upgrades are requested from trusted
+   sources.
+
+   **Note**: If you are following this tutorial for also setting up WireGuard
+   VPN server, just substitute ``wireguard-server.mydomain.com`` with hostname
+   of your VPN server and follow the steps in next section.
+
+6. Under the configuration section, set the name of WireGuard tunnel 1 interface.
+   We have used ``wg0`` in this example.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/vpn-server-1.png
+   :alt: WireGuard VPN VXLAN server configuration example 1
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/vpn-server-2.png
+   :alt: WireGuard VPN VXLAN server configuration example 2
+
+7. After clicking on **Save and continue editing**, you will see that OpenWISP
+   has automatically created public and private key for WireGuard server in
+   **System Defined Variables** along with internal IP address information.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/vpn-server-3.png
+   :alt: WireGuard VXLAN VPN server configuration example 3
+
+2. Deploy Wireguard VXLAN VPN Server
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you haven't already setup WireGuard on your VPN server, this will be a good
+time do so. We stress on using `ansible-wireguard-openwisp <https://github.com/openwisp/ansible-wireguard-openwisp>`_
+role for installing WireGuard since it also installs scripts that allows
+OpenWISP to manage WireGuard VPN server along with VXLAN tunnels.
+
+Pay attention to the VPN server attributes used in your playbook. It should be same as
+VPN server configuration in OpenWISP.
+
+3. Create VPN client template for WireGuard VXLAN VPN Server
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Visit ``/admin/config/template/add/`` to add a new template.
+2. Set ``Wireguard VXLAN Client`` as **Name** (you can set whatever you want) and
+   select ``VPN-client`` as **type** from the dropdown list.
+3. The **Backend** field refers to the backend of the device this template can
+   be applied to. For this example, we will leave it to ``OpenWRT``.
+4. Select the correct VPN server from the dropdown for the **VPN** field. Here
+   it is ``Wireguard VXLAN``.
+5. Ensure that **Automatic tunnel provisioning** is checked. This will make
+   OpenWISP to automatically generate public and private keys and provision IP
+   address for each WireGuard VPN client along with VXLAN Network Indentifier(VNI).
+6. After clicking on **Save and continue editing** button, you will see details
+   of *Wireguard VXLAN* VPN server in **System Defined Variables**. The template
+   configuration will be automatically generated which you can tweak
+   accordingly. We will use the automatically generated VPN client configuration
+   for this example.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/template.png
+    :alt: WireGuard VXLAN VPN client template example
+
+4. Apply Wireguard VXLAN VPN template to devices
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**Note**: This step assumes that you already have a device registered on
+OpenWISP. Register or create a device before proceeding.
+
+1. Open the **Configuration** tab of the concerned device.
+2. Select the *WireGuard VXLAN Client* template.
+3. Upon clicking on **Save and continue editing** button, you will see some
+   entries in **System Defined Variables**. It will contain internal IP address,
+   private and public key for the WireGuard client on the device and details of
+   WireGuard VPN server along with VXLAN Network Identifier(VNI) of this device.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/device-configuration.png
+   :alt: WireGuard VXLAN VPN device configuration example
+
+**Voila!** You have successfully configured OpenWISP to manage VXLAN over
+WireGuard tunnels for your devices.
+
 Signals
 -------
 
@@ -1966,6 +2372,35 @@ The signal is emitted when the device group changes.
 
 It is not emitted when the device is created.
 
+``subnet_provisioned``
+~~~~~~~~~~~~~~~~~~~~~~
+
+**Path**: ``openwisp_controller.subnet_division.signals.subnet_provisioned``
+
+**Arguments**:
+
+- ``instance``: instance of ``VpnClient``.
+- ``provisioned``: dictionary of ``Subnet`` and ``IpAddress`` provisioned,
+  ``None`` if nothing is provisioned
+
+The signal is emitted when subnets and IP addresses have been provisioned
+for a ``VpnClient`` for a VPN server with a subnet with
+`subnet division rule <#subnet-division-app>`_.
+
+``vpn_peers_changed``
+~~~~~~~~~~~~~~~~~~~~~
+
+**Path**: ``openwisp_controller.config.signals.vpn_peers_changed``
+
+**Arguments**:
+
+- ``instance``: instance of ``Vpn``.
+
+The signal is emitted when the peers of VPN server gets changed.
+
+It is only emitted for ``Vpn`` object with **WireGuard** or
+**VXLAN over WireGuard** backend.
+
 Setup (integrate in an existing django project)
 -----------------------------------------------
 
@@ -1983,6 +2418,7 @@ Add ``openwisp_controller`` applications to ``INSTALLED_APPS``:
         'openwisp_controller.notifications',
         'openwisp_users',
         'openwisp_notifications',
+        'openwisp_ipam',
         # openwisp2 admin theme
         # (must be loaded here)
         'openwisp_utils.admin_theme',
@@ -2045,6 +2481,7 @@ Add the URLs to your main ``urls.py``:
         url(r'^admin/', admin.site.urls),
         url(r'', include('openwisp_controller.urls')),
         url(r'', include('openwisp_notifications.urls')),
+        url(r'', include('openwisp_ipam.urls')),
     ]
 
 Configure caching (you may use a different cache storage if you want):
@@ -2076,7 +2513,7 @@ Configure celery (you may use a different broker if you want):
     EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend'
 
 If you decide to use redis (as shown in these examples),
-install the requierd python packages::
+install the required python packages::
 
     pip install redis django-redis
 
@@ -2129,13 +2566,14 @@ You'll need to create 4 apps in your project for each app in openwisp_controller
 A django app is nothing more than a
 `python package <https://docs.python.org/3/tutorial/modules.html#packages>`_
 (a directory of python scripts), in the following examples we'll call these django app
-``sample_config``, ``sample_pki``, ``sample_connection`` & ``sample_geo``
-but you can name it how you want::
+``sample_config``, ``sample_pki``, ``sample_connection``, ``sample_geo``
+& ``sample_subnet_division``. but you can name it how you want::
 
     django-admin startapp sample_config
     django-admin startapp sample_pki
     django-admin startapp sample_connection
     django-admin startapp sample_geo
+    django-admin startapp sample_subnet_division
 
 Keep in mind that the command mentioned above must be called from a directory
 which is available in your `PYTHON_PATH <https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH>`_
@@ -2155,10 +2593,12 @@ Install (and add to the requirement of your project) openwisp-controller::
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 Now you need to add ``mycontroller.sample_config``,
-``mycontroller.sample_pki``, ``mycontroller.sample_connection``
-& ``mycontroller.sample_geo`` to ``INSTALLED_APPS`` in your ``settings.py``,
-ensuring also that ``openwisp_controller.config``, ``openwisp_controller.geo``,
-``openwisp_controller.pki``, ``openwisp_controller.connnection`` have been removed:
+``mycontroller.sample_pki``, ``mycontroller.sample_connection``,
+``mycontroller.sample_geo`` & ``mycontroller.sample_subnet_division`` to
+``INSTALLED_APPS`` in your ``settings.py``, ensuring also that
+``openwisp_controller.config``, ``openwisp_controller.geo``,
+``openwisp_controller.pki``, ``openwisp_controller.connnection`` &
+``openwisp_controller.subnet_division`` have been removed:
 
 .. code-block:: python
 
@@ -2176,10 +2616,12 @@ ensuring also that ``openwisp_controller.config``, ``openwisp_controller.geo``,
         # 'openwisp_controller.pki', <-- comment out or delete this line
         # 'openwisp_controller.geo', <-- comment out or delete this line
         # 'openwisp_controller.connection', <-- comment out or delete this line
+        # 'openwisp_controller.subnet_division', <-- comment out or delete this line
         'mycontroller.sample_config',
         'mycontroller.sample_pki',
         'mycontroller.sample_geo',
         'mycontroller.sample_connection',
+        'mycontroller.sample_subnet_division',
         'openwisp_users',
         # admin
         'django.contrib.admin',
@@ -2194,8 +2636,8 @@ ensuring also that ``openwisp_controller.config``, ``openwisp_controller.geo``,
         'channels',
     ]
 
-Substitute ``mycontroller``, ``sample_config``, ``sample_pki``, ``sample_connection`` &
-``sample_geo`` with the name you chose in step 1.
+Substitute ``mycontroller``, ``sample_config``, ``sample_pki``, ``sample_connection``,
+``sample_geo`` & ``sample_subnet_division`` with the name you chose in step 1.
 
 4. Add ``EXTENDED_APPS``
 ~~~~~~~~~~~~~~~~~~~~~~~~
@@ -2211,6 +2653,7 @@ Add the following to your ``settings.py``:
         'openwisp_controller.pki',
         'openwisp_controller.geo',
         'openwisp_controller.connection',
+        'openwisp_controller.subnet_division',
     )
 
 5. Add ``openwisp_utils.staticfiles.DependencyFinder``
@@ -2341,6 +2784,10 @@ Please refer to the following files in the sample app of the test project:
     - `sample_connection/__init__.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/__init__.py>`_.
     - `sample_connection/apps.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/apps.py>`_.
 
+- sample_subnet_division:
+    - `sample_subnet_division/__init__.py <https://github.com/openwisp/openwisp-controller/tree/issues/400-subnet-subdivision-rule/tests/openwisp2/sample_subnet_division/__init__.py>`_.
+    - `sample_subnet_division/apps.py <https://github.com/openwisp/openwisp-controller/tree/issues/400-subnet-subdivision-rule/tests/openwisp2/sample_subnet_division/apps.py>`_.
+
 You have to replicate and adapt that code in your project.
 
 For more information regarding the concept of ``AppConfig`` please refer to
@@ -2356,6 +2803,7 @@ to the models of the sample app in the test project.
 - `sample_geo models <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_geo/models.py>`_
 - `sample_pki models <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_pki/models.py>`_
 - `sample_connection models <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/models.py>`_
+- `sample_subnet_division <https://github.com/openwisp/openwisp-controller/tree/issues/400-subnet-subdivision-rule/tests/openwisp2/sample_subnet_division/models.py>`_
 
 You can add fields in a similar way in your ``models.py`` file.
 
@@ -2387,9 +2835,11 @@ Once you have created the models, add the following to your ``settings.py``:
     CONNECTION_CREDENTIALS_MODEL = 'sample_connection.Credentials'
     CONNECTION_DEVICECONNECTION_MODEL = 'sample_connection.DeviceConnection'
     CONNECTION_COMMAND_MODEL = 'sample_connection.Command'
+    SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = 'sample_subnet_division.SubnetDivisionRule'
+    SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL = 'sample_subnet_division.SubnetDivisionIndex'
 
-Substitute ``sample_config``, ``sample_pki``, ``sample_connection`` &
-``sample_geo`` with the name you chose in step 1.
+Substitute ``sample_config``, ``sample_pki``, ``sample_connection``,
+``sample_geo`` & ``sample_subnet_division`` with the name you chose in step 1.
 
 9. Create database migrations
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -2403,9 +2853,10 @@ like the used in the openwisp_controller module, you'll manually need to make a
 migrations file which would look like:
 
 - `sample_config/migrations/0002_default_groups_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_config/migrations/0002_default_groups_permissions.py>`_
-- `sample_geo/migrations/0002_default_groups_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_geo/migrations/0002_default_groups_permissions.py>`_
-- `sample_pki/migrations/0002_default_groups_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_pki/migrations/0002_default_groups_permissions.py>`_
-- `sample_connection/migrations/0002_default_groups_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/migrations/0002_default_groups_permissions.py>`_
+- `sample_geo/migrations/0002_default_group_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_geo/migrations/0002_default_group_permissions.py>`_
+- `sample_pki/migrations/0002_default_group_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_pki/migrations/0002_default_group_permissions.py>`_
+- `sample_connection/migrations/0002_default_group_permissions.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/migrations/0002_default_group_permissions.py>`_
+- `sample_subnet_division/migrations/0002_default_group_permissions.py <https://github.com/openwisp/openwisp-controller/tree/issues/400-subnet-subdivision-rule/tests/openwisp2/sample_subnet_division/migrations/0002_default_group_permissions.py>`_
 
 Create database migrations::
 
@@ -2423,6 +2874,7 @@ Refer to the ``admin.py`` file of the sample app.
 - `sample_geo admin.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_geo/admin.py>`_.
 - `sample_pki admin.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_pki/admin.py>`_.
 - `sample_connection admin.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/admin.py>`_.
+- `sample_subnet_division admin.py <https://github.com/openwisp/openwisp-controller/tree/issues/400-subnet-subdivision-rule/tests/openwisp2/sample_subnet_division/admin.py>`_.
 
 To introduce changes to the admin, you can do it in two main ways which are described below.
 
@@ -2473,10 +2925,19 @@ sample_pki
 
 .. code-block:: python
 
-    from openwisp_controller.geo.admin import CaAdmin, CertAdmin
+    from openwisp_controller.pki.admin import CaAdmin, CertAdmin
 
     # CaAdmin.fields += ['example'] <-- monkey patching example
 
+sample_subnet_division
+""""""""""""""""""""""
+
+.. code-block:: python
+
+    from openwisp_controller.subnet_division.admin import SubnetDivisionRuleInlineAdmin
+
+    # SubnetDivisionRuleInlineAdmin.fields += ['example'] <-- monkey patching example
+
 2. Inheriting admin classes
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -2592,6 +3053,40 @@ sample_pki
     class CertAdmin(BaseCertAdmin):
         # add your changes here
 
+sample_subnet_division
+""""""""""""""""""""""
+
+.. code-block:: python
+
+    from openwisp_controller.subnet_division.admin import (
+        SubnetAdmin as BaseSubnetAdmin,
+        IpAddressAdmin as BaseIpAddressAdmin,
+        SubnetDivisionRuleInlineAdmin as BaseSubnetDivisionRuleInlineAdmin,
+    )
+    from django.contrib import admin
+    from swapper import load_model
+
+    Subnet = load_model('openwisp_ipam', 'Subnet')
+    IpAddress = load_model('openwisp_ipam', 'IpAddress')
+    SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+
+    admin.site.unregister(Subnet)
+    admin.site.unregister(IpAddress)
+    admin.site.unregister(SubnetDivisionRule)
+
+    @admin.register(Subnet)
+    class SubnetAdmin(BaseSubnetAdmin):
+        # add your changes here
+
+    @admin.register(IpAddress)
+    class IpAddressAdmin(BaseIpAddressAdmin):
+        # add your changes here
+
+    @admin.register(SubnetDivisionRule)
+    class SubnetDivisionRuleInlineAdmin(BaseSubnetDivisionRuleInlineAdmin):
+        # add your changes here
+
+
 11. Create root URL configuration
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -2637,6 +3132,7 @@ See the tests in sample_app to find out how to do this.
 - `sample_geo pytest.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_geo/pytest.py>`_
 - `sample_pki tests.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_pki/tests.py>`_
 - `sample_connection tests.py <https://github.com/openwisp/openwisp-controller/tree/master/tests/openwisp2/sample_connection/tests.py>`_
+- `sample_subnet_division tests.py <https://github.com/openwisp/openwisp-controller/tree/issues/400-subnet-subdivision-rule/tests/openwisp2/sample_subnet_division/tests.py>`_
 
 For running the tests, you need to copy fixtures as well:
 
@@ -2675,6 +3171,90 @@ Remember to change ``geo_views`` location in ``urls.py`` in point 11 for extendi
 
 For more information about django views, please refer to the `views section in the django documentation <https://docs.djangoproject.com/en/dev/topics/http/views/>`_.
 
+Custom Subnet Division Rule Types
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+It is possible to create your own `subnet division rule types <#subnet-division-app>`_.
+The rule type determines when subnets and IPs will be provisioned and when they
+will be destroyed.
+
+You can create your custom rule types by extending
+``openwisp_controller.subnet_division.rule_types.base.BaseSubnetDivisionRuleType``.
+
+Below is an example to create a subnet division rule type that will provision
+subnets and IPs when a new device is created and will delete them upon deletion
+for that device.
+
+.. code-block:: python
+
+    # In mycontroller/sample_subnet_division/rules_types/custom.py
+
+    from django.db.models.signals import post_delete, post_save
+    from swapper import load_model
+
+    from openwisp_controller.subnet_division.rule_types.base import (
+        BaseSubnetDivisionRuleType,
+    )
+
+    Device = load_model('config', 'Device')
+
+    class CustomRuleType(BaseSubnetDivisionRuleType):
+        # The signal on which provisioning should be triggered
+        provision_signal = post_save
+        # The sender of the provision_signal
+        provision_sender = Device
+        # Dispatch UID for connecting provision_signal to provision_receiver
+        provision_dispatch_uid = 'some_unique_identifier_string'
+
+        # The signal on which deletion should be triggered
+        destroyer_signal = post_delete
+        # The sender of the destroyer_signal
+        destroyer_sender = Device
+        # Dispatch UID for connecting destroyer_signal to destroyer_receiver
+        destroyer_dispatch_uid = 'another_unique_identifier_string'
+
+        # Attribute path to organization_id
+        # Example 1: If organization_id is direct attribute of provision_signal
+        #            sender instance, then
+        #   organization_id_path = 'organization_id'
+        # Example 2: If organization_id is indirect attribute of provision signal
+        #            sender instance, then
+        #   organization_id_path = 'some_attribute.another_intermediate.organization_id'
+        organization_id_path = 'organization_id'
+
+        # Similar to organization_id_path but for the required subnet attribute
+        subnet_path = 'subnet'
+
+        # An intermediate method through which you can specify conditions for provisions
+        @classmethod
+        def should_create_subnets_ips(cls, instance, **kwargs):
+            # Using "post_save" provision_signal, the rule should be only
+            # triggered when a new object is created.
+            return kwargs['created']
+
+        # You can define logic to trigger provisioning for existing objects
+        # using following classmethod. By default, BaseSubnetDivisionRuleType
+        # performs no operation for existing objects.
+        @classmethod
+        def provision_for_existing_objects(cls, rule_obj):
+            for device in Device.objects.filter(
+                organization=rule_obj.organization
+            ):
+                cls.provision_receiver(device, created=True)
+
+
+After creating a class for your custom rule type, you will need to set
+`OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES <#openwisp-controller-subnet-division-types>`_
+setting as follows:
+
+.. code-block:: python
+
+    OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES = (                                                                                           |
+       ('openwisp_controller.subnet_division.rule_types.vpn.VpnSubnetDivisionRuleType', 'VPN'),
+       ('openwisp_controller.subnet_division.rule_types.device.DeviceSubnetDivisionRuleType', 'Device'),
+       ('mycontroller.sample_subnet_division.rules_types.custom.CustomRuleType', 'Custom Rule'),
+    )
+
 Registering new notification types
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py
index 7a3ee1b32..66c5625b0 100644
--- a/openwisp_controller/config/admin.py
+++ b/openwisp_controller/config/admin.py
@@ -163,10 +163,11 @@ def _get_config_model(self):
 
     def _get_preview_instance(self, request):
         """
-        returns a temporary preview instance used for preview
+        returns a temporary instance used for preview
         """
         kwargs = {}
         config_model = self._get_config_model()
+        instance = config_model()
         for key, value in request.POST.items():
             # skip keys that are not model fields
             try:
@@ -191,9 +192,14 @@ def _get_preview_instance(self, request):
         # default context to None to avoid exception
         if 'context' in kwargs:
             kwargs['context'] = kwargs['context'] or None
-        # this object is instanciated only to generate the preview
-        # it won't be saved to the database
-        instance = config_model(**kwargs)
+        try:
+            instance = config_model.objects.get(pk=request.POST['id'])
+            for key, value in kwargs.items():
+                setattr(instance, key, value)
+        except (KeyError, ValidationError, config_model.DoesNotExist):
+            # this object is instanciated only to generate the preview
+            # it won't be saved to the database
+            instance = config_model(**kwargs)
         # turn off special name validation
         # (see ``ShareableOrgMixinUniqueName``)
         instance._validate_name = False
@@ -235,7 +241,10 @@ def preview_view(self, request):
         else:
             templates = None
         if not error:
-            backend = instance.get_backend_instance(template_instances=templates)
+            context = instance.get_context()
+            backend = instance.get_backend_instance(
+                template_instances=templates, context=context
+            )
             try:
                 instance.clean_netjsonconfig_backend(backend)
                 output = backend.render()
@@ -473,12 +482,17 @@ def config_status(self, obj):
 
     def _get_preview_instance(self, request):
         c = super()._get_preview_instance(request)
-        c.device = self.model(
-            id=request.POST.get('id'),
-            name=request.POST.get('name'),
-            mac_address=request.POST.get('mac_address'),
-            key=request.POST.get('key'),
-        )
+        id_ = request.POST.get('id')
+        # instantiate new device if it's a new config
+        try:
+            c.device
+        except ObjectDoesNotExist:
+            c.device = self.model()
+        # fill attributes with up to date data
+        c.device.id = id_
+        c.device.name = request.POST.get('name')
+        c.device.mac_address = request.POST.get('mac_address')
+        c.device.key = request.POST.get('key')
         if 'hardware_id' in request.POST:
             c.device.hardware_id = request.POST.get('hardware_id')
         return c
@@ -716,16 +730,21 @@ class VpnAdmin(
     list_filter = [('organization', MultitenantOrgFilter), 'backend', 'created']
     search_fields = ['id', 'name', 'host', 'key']
     readonly_fields = ['id', 'uuid', 'system_context']
-    multitenant_shared_relations = ('ca', 'cert')
+    multitenant_shared_relations = ('ca', 'cert', 'subnet')
+    autocomplete_fields = ['ip', 'subnet']
     fields = [
+        'organization',
         'name',
         'host',
-        'organization',
         'uuid',
         'key',
+        'backend',
         'ca',
         'cert',
-        'backend',
+        'subnet',
+        'ip',
+        'webhook_endpoint',
+        'auth_token',
         'notes',
         'dh',
         'system_context',
diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py
index e0683fb0e..b70a13f0e 100644
--- a/openwisp_controller/config/apps.py
+++ b/openwisp_controller/config/apps.py
@@ -13,7 +13,12 @@
 from openwisp_utils.admin_theme.menu import register_menu_group
 
 from . import settings as app_settings
-from .signals import config_modified, device_group_changed, device_name_changed
+from .signals import (
+    config_modified,
+    device_group_changed,
+    device_name_changed,
+    vpn_peers_changed,
+)
 
 # ensure Device.hardware_id field is not flagged as unique
 # (because it's flagged as unique_together with organization)
@@ -40,7 +45,11 @@ def __setmodels__(self):
         self.device_model = load_model('config', 'Device')
         self.devicegroup_model = load_model('config', 'DeviceGroup')
         self.config_model = load_model('config', 'Config')
+        self.vpn_model = load_model('config', 'Vpn')
         self.vpnclient_model = load_model('config', 'VpnClient')
+        self.subnet_division_rule_model = load_model(
+            'subnet_division', 'SubnetDivisionRule'
+        )
         self.cert_model = load_model('django_x509', 'Cert')
 
     def connect_signals(self):
@@ -75,11 +84,21 @@ def connect_signals(self):
             sender=self.config_model.templates.through,
             dispatch_uid='template.enforce_required_template',
         )
+        post_save.connect(
+            self.vpnclient_model.post_save,
+            sender=self.vpnclient_model,
+            dispatch_uid='vpnclient.post_save',
+        )
         post_delete.connect(
             self.vpnclient_model.post_delete,
             sender=self.vpnclient_model,
             dispatch_uid='vpnclient.post_delete',
         )
+        vpn_peers_changed.connect(
+            self.vpn_model.update_vpn_server_configuration,
+            sender=self.vpn_model,
+            dispatch_uid='vpn.update_vpn_server_configuration',
+        )
         post_save.connect(
             self.config_model.certificate_updated,
             sender=self.cert_model,
diff --git a/openwisp_controller/config/base/base.py b/openwisp_controller/config/base/base.py
index 248cd9028..2a4660867 100644
--- a/openwisp_controller/config/base/base.py
+++ b/openwisp_controller/config/base/base.py
@@ -141,14 +141,14 @@ def backend_instance(self):
         """
         return self.get_backend_instance()
 
-    def get_backend_instance(self, template_instances=None):
+    def get_backend_instance(self, template_instances=None, context=None):
         """
         allows overriding config and templates
         needed for pre validation of m2m
         """
         backend = self.backend_class
         kwargs = {'config': self.get_config()}
-        context = {}
+        context = context or {}
         # determine if we can pass templates
         # expecting a many2many relationship
         if hasattr(self, 'templates'):
diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py
index 5390ed280..d058d6c56 100644
--- a/openwisp_controller/config/base/config.py
+++ b/openwisp_controller/config/base/config.py
@@ -443,25 +443,13 @@ def _has_device(self):
         return hasattr(self, 'device')
 
     def get_vpn_context(self):
-        c = super().get_context()
+        context = super().get_context()
         for vpnclient in self.vpnclient_set.all().select_related('vpn', 'cert'):
             vpn = vpnclient.vpn
             vpn_id = vpn.pk.hex
-            context_keys = vpn._get_auto_context_keys()
-            ca = vpn.ca
+            context.update(vpn.get_vpn_server_context())
+            vpn_context_keys = vpn._get_auto_context_keys()
             cert = vpnclient.cert
-            # CA
-            ca_filename = 'ca-{0}-{1}.pem'.format(
-                ca.pk, ca.common_name.replace(' ', '_')
-            )
-            ca_path = '{0}/{1}'.format(app_settings.CERT_PATH, ca_filename)
-            # update context
-            c.update(
-                {
-                    context_keys['ca_path']: ca_path,
-                    context_keys['ca_contents']: ca.certificate,
-                }
-            )
             # conditional needed for VPN without x509 authentication
             # eg: simple password authentication
             if cert:
@@ -472,15 +460,49 @@ def get_vpn_context(self):
                 key_filename = 'key-{0}.pem'.format(vpn_id)
                 key_path = '{0}/{1}'.format(app_settings.CERT_PATH, key_filename)
                 # update context
-                c.update(
+                context.update(
                     {
-                        context_keys['cert_path']: cert_path,
-                        context_keys['cert_contents']: cert.certificate,
-                        context_keys['key_path']: key_path,
-                        context_keys['key_contents']: cert.private_key,
+                        vpn_context_keys['cert_path']: cert_path,
+                        vpn_context_keys['cert_contents']: cert.certificate,
+                        vpn_context_keys['key_path']: key_path,
+                        vpn_context_keys['key_contents']: cert.private_key,
                     }
                 )
-        return c
+            if vpnclient.public_key:
+                context['public_key'] = vpnclient.public_key
+            if vpnclient.private_key:
+                context['private_key'] = vpnclient.private_key
+            if vpn.subnet:
+                if vpnclient.ip:
+                    context[vpn_context_keys['ip_address']] = vpnclient.ip.ip_address
+            if 'vni' in vpn_context_keys and vpnclient.vni:
+                context[vpn_context_keys['vni']] = f'{vpnclient.vni}'
+        return context
+
+    def get_subnet_division_context(self):
+        # NOTE: Use regex to know which subnet division variables
+        # are used in this config and only provide those contexts
+
+        context = {}
+        qs = self.subnetdivisionindex_set.values(
+            'keyword', 'subnet__subnet', 'ip__ip_address'
+        )
+        for entry in qs:
+            if entry['ip__ip_address'] is None:
+                context[entry['keyword']] = str(entry['subnet__subnet'])
+            else:
+                context[entry['keyword']] = str(entry['ip__ip_address'])
+
+        prefixlen = (
+            self.subnetdivisionindex_set.select_related('rule')
+            .values('rule__label', 'rule__size')
+            .first()
+        )
+        if prefixlen:
+            context[f'{prefixlen["rule__label"]}_prefixlen'] = str(
+                prefixlen['rule__size']
+            )
+        return context
 
     def get_context(self, system=False):
         """
@@ -500,6 +522,7 @@ def get_context(self, system=False):
             if self.context and not system:
                 extra.update(self.context)
         extra.update(self.get_vpn_context())
+        extra.update(self.get_subnet_division_context())
         if app_settings.HARDWARE_ID_ENABLED and self._has_device():
             extra.update({'hardware_id': str(self.device.hardware_id)})
         c.update(sorted(extra.items()))
diff --git a/openwisp_controller/config/base/template.py b/openwisp_controller/config/base/template.py
index 51dfe06e4..f8e2dcd7e 100644
--- a/openwisp_controller/config/base/template.py
+++ b/openwisp_controller/config/base/template.py
@@ -2,7 +2,7 @@
 from collections import OrderedDict
 from copy import copy
 
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import models, transaction
 from django.utils.translation import gettext_lazy as _
 from jsonfield import JSONField
@@ -73,15 +73,15 @@ class AbstractTemplate(ShareableOrgMixinUniqueName, BaseConfig):
             'be required for every device in the system)'
         ),
     )
+    # auto_cert naming kept for backward compatibility
     auto_cert = models.BooleanField(
-        _('auto certificate'),
+        _('automatic tunnel provisioning'),
         default=default_auto_cert,
         db_index=True,
         help_text=_(
-            'whether x509 client certificates should '
-            'be automatically managed behind the scenes '
-            'for each configuration using this template, '
-            'valid only for the VPN type'
+            'whether tunnel specific configuration (cryptographic keys, ip addresses, '
+            'etc) should be automatically generated and managed behind the scenes '
+            'for each configuration using this template, valid only for the VPN type'
         ),
     )
     default_values = JSONField(
@@ -169,7 +169,9 @@ def clean(self, *args, **kwargs):
             self.vpn = None
             self.auto_cert = False
         if self.type == 'vpn' and not self.config:
-            self.config = self.vpn.auto_client(auto_cert=self.auto_cert)
+            self.config = self.vpn.auto_client(
+                auto_cert=self.auto_cert, template_backend_class=self.backend_class
+            )
         if self.required and not self.default:
             self.default = True
         super().clean(*args, **kwargs)
@@ -180,6 +182,7 @@ def get_context(self, system=False):
         context = {}
         if self.default_values and not system:
             context = copy(self.default_values)
+        context.update(self.get_vpn_server_context())
         context.update(super().get_context())
         return context
 
@@ -187,6 +190,12 @@ def get_system_context(self):
         system_context = self.get_context(system=True)
         return OrderedDict(sorted(system_context.items()))
 
+    def get_vpn_server_context(self):
+        try:
+            return self.vpn.get_vpn_server_context()
+        except (ObjectDoesNotExist, AttributeError):
+            return {}
+
     def clone(self, user):
         clone = copy(self)
         clone.name = self.__get_clone_name()
diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py
index d4cd7d4fa..36bd91780 100644
--- a/openwisp_controller/config/base/vpn.py
+++ b/openwisp_controller/config/base/vpn.py
@@ -1,8 +1,13 @@
 import collections
+import ipaddress
+import json
+import logging
 import subprocess
 
 import shortuuid
+from cache_memoize import cache_memoize
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models, transaction
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
@@ -11,10 +16,19 @@
 from openwisp_utils.base import KeyField
 
 from ...base import ShareableOrgMixinUniqueName
+from .. import crypto
 from .. import settings as app_settings
-from ..tasks import create_vpn_dh
+from ..signals import vpn_peers_changed
+from ..tasks import create_vpn_dh, trigger_vpn_server_endpoint
 from .base import BaseConfig
 
+logger = logging.getLogger(__name__)
+
+
+def _peer_cache_key(vpn):
+    """used to generate a unique cache key"""
+    return str(vpn.pk)
+
 
 class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
     """
@@ -28,6 +42,8 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
         get_model_name('django_x509', 'Ca'),
         verbose_name=_('Certification Authority'),
         on_delete=models.CASCADE,
+        blank=True,
+        null=True,
     )
     cert = models.ForeignKey(
         get_model_name('django_x509', 'Cert'),
@@ -45,6 +61,41 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
         help_text=_('Select VPN configuration backend'),
     )
     notes = models.TextField(blank=True)
+    # optional, needed for VPNs which do not support automatic IP allocation
+    subnet = models.ForeignKey(
+        get_model_name('openwisp_ipam', 'Subnet'),
+        verbose_name=_('Subnet'),
+        help_text=_('Subnet IP addresses used by VPN clients, if applicable'),
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    ip = models.ForeignKey(
+        get_model_name('openwisp_ipam', 'IpAddress'),
+        verbose_name=_('Internal IP'),
+        help_text=_('Internal IP address of the VPN server interface, if applicable'),
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    # optional, helpful for updating WireGuard and VXLAN server configuration
+    webhook_endpoint = models.CharField(
+        verbose_name=_('Webhook Endpoint'),
+        help_text=_(
+            'Webhook to trigger for updating server configuration '
+            '(e.g. https://openwisp2.mydomain.com:8081/trigger-update)'
+        ),
+        max_length=128,
+        blank=True,
+        null=True,
+    )
+    auth_token = models.CharField(
+        verbose_name=_('Webhook AuthToken'),
+        help_text=_('Authentication token for triggering "Webhook Endpoint"'),
+        max_length=128,
+        blank=True,
+        null=True,
+    )
     # diffie hellman parameters are required
     # in some VPN solutions (eg: OpenVPN)
     dh = models.TextField(blank=True)
@@ -61,9 +112,15 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
         'BbOcwKkB+eBE/B9jqmbG5YYhDo9fQGmNEwIBAg==\n'
         '-----END DH PARAMETERS-----\n'
     )
+    # needed for wireguard
+    public_key = models.CharField(blank=True, max_length=44)
+    private_key = models.CharField(blank=True, max_length=44)
 
     __vpn__ = True
 
+    # cache wireguard / vxlan peers for 7 days (generation is expensive)
+    _PEER_CACHE_TIMEOUT = 60 * 60 * 24 * 7
+
     class Meta:
         verbose_name = _('VPN server')
         verbose_name_plural = _('VPN servers')
@@ -71,29 +128,77 @@ class Meta:
         abstract = True
 
     def clean(self, *args, **kwargs):
-        """
-        * ensure certificate matches CA
-        """
         super().clean(*args, **kwargs)
+        self._validate_backend()
+        self._validate_certs()
+        self._validate_keys()
+        self._validate_org_relation('ca')
+        self._validate_org_relation('cert')
+        self._validate_org_relation('subnet')
+        self._validate_subnet_ip()
+
+    def _validate_backend(self):
+        if self._state.adding:
+            return
+        if self.vpnclient_set.exists():
+            raise ValidationError(
+                {
+                    'backend': _(
+                        'Backend cannot be changed because the VPN is currently in use.'
+                    )
+                }
+            )
+
+    def _validate_certs(self):
+        if not self._is_backend_type('openvpn'):
+            self.ca = None
+            self.cert = None
+            return
+
+        if not self.ca:
+            raise ValidationError({'ca': _('CA is required with this VPN backend')})
         # certificate must be related to CA
         if self.cert and self.cert.ca.pk != self.ca.pk:
             msg = _('The selected certificate must match the selected CA.')
             raise ValidationError({'cert': msg})
-        self._validate_org_relation('ca')
-        self._validate_org_relation('cert')
+
+    def _validate_keys(self):
+        if not self._is_backend_type('wireguard'):
+            self.public_key = ''
+            self.private_key = ''
+
+    def _validate_subnet_ip(self):
+        if self._is_backend_type('openvpn'):
+            self.subnet = None
+            self.ip = None
+        elif self._is_backend_type('wireguard'):
+            if not self.subnet:
+                raise ValidationError(
+                    {'subnet': _('Subnet is required for this VPN backend.')}
+                )
+            if self.ip and self.ip.subnet != self.subnet:
+                raise ValidationError(
+                    {'ip': _('VPN IP address must be within the VPN subnet')}
+                )
 
     def save(self, *args, **kwargs):
         """
         Calls _auto_create_cert() if cert is not set
         """
-        if not self.cert:
+        create_dh = False
+        if not self.cert and self.ca:
             self.cert = self._auto_create_cert()
-        if not self.dh:
+        if self._is_backend_type('openvpn') and not self.dh:
             self.dh = self._placeholder_dh
-        is_adding = self._state.adding
+            create_dh = True
+        if self._is_backend_type('wireguard'):
+            self._generate_wireguard_keys()
+        if self.subnet and not self.ip:
+            self.ip = self._auto_create_ip()
         super().save(*args, **kwargs)
-        if is_adding and self.dh == self._placeholder_dh:
+        if create_dh:
             transaction.on_commit(lambda: create_vpn_dh.delay(self.id))
+        self.update_vpn_server_configuration()
 
     @classmethod
     def dhparam(cls, length):
@@ -104,6 +209,23 @@ def dhparam(cls, length):
             'openssl dhparam {0} 2> /dev/null'.format(length), shell=True
         ).decode('utf-8')
 
+    def update_vpn_server_configuration(instance, **kwargs):
+        if not instance._is_backend_type('wireguard'):
+            return
+        if instance.webhook_endpoint and instance.auth_token:
+            transaction.on_commit(
+                lambda: trigger_vpn_server_endpoint.delay(
+                    endpoint=instance.webhook_endpoint,
+                    auth_token=instance.auth_token,
+                    vpn_id=instance.pk,
+                )
+            )
+        else:
+            logger.info(
+                f'Cannot update configuration of {instance.name} VPN server, '
+                'webhook endpoint and authentication token are empty.'
+            )
+
     def _auto_create_cert(self):
         """
         Automatically generates server x509 certificate
@@ -130,24 +252,82 @@ def _auto_create_cert(self):
         cert.save()
         return cert
 
+    def _auto_create_ip(self):
+        """
+        Automatically generates host IP address
+        """
+        return self.subnet.request_ip()
+
     def get_context(self):
         """
         prepares context for netjsonconfig VPN backend
         """
-        try:
-            c = collections.OrderedDict([('ca', self.ca.certificate)])
-        except ObjectDoesNotExist:
-            c = collections.OrderedDict()
+        c = collections.OrderedDict()
+        if self.ca:
+            try:
+                c['ca'] = self.ca.certificate
+            except ObjectDoesNotExist:
+                pass
         if self.cert:
-            c.update([('cert', self.cert.certificate), ('key', self.cert.private_key)])
+            c['cert'] = self.cert.certificate
+            c['key'] = self.cert.private_key
         if self.dh:
-            c.update([('dh', self.dh)])
+            c['dh'] = self.dh
+        if self.private_key:
+            c['private_key'] = self.private_key
+        if self.public_key:
+            c['public_key'] = self.public_key
+        if self.subnet:
+            c['subnet'] = str(self.subnet.subnet)
+            c['subnet_prefixlen'] = str(self.subnet.subnet.prefixlen)
+        if self.ip:
+            c['ip_address'] = self.ip.ip_address
         c.update(sorted(super().get_context().items()))
         return c
 
+    def get_vpn_server_context(self):
+        context = {}
+        context_keys = self._get_auto_context_keys()
+        if self.host:
+            context[context_keys['vpn_host']] = self.host
+        if self._is_backend_type('wireguard'):
+            context[context_keys['vpn_port']] = self.config['wireguard'][0]['port']
+        if self.ca:
+            ca = self.ca
+            # CA
+            ca_filename = 'ca-{0}-{1}.pem'.format(
+                ca.pk, ca.common_name.replace(' ', '_')
+            )
+            ca_path = '{0}/{1}'.format(app_settings.CERT_PATH, ca_filename)
+            context.update(
+                {
+                    context_keys['ca_path']: ca_path,
+                    context_keys['ca_contents']: ca.certificate,
+                }
+            )
+        if self.public_key:
+            context[context_keys['public_key']] = self.public_key
+        if self.ip:
+            context[context_keys['server_ip_address']] = self.ip.ip_address
+            context[
+                context_keys['server_ip_network']
+            ] = f'{self.ip.ip_address}/{self.subnet.subnet.max_prefixlen}'
+        return context
+
     def get_system_context(self):
         return self.get_context()
 
+    def _is_backend_type(self, backend_type):
+        """
+        returns true if the backend path used converted to lowercase
+        contains ``backend_type``.
+        Checking for the exact path may not be the best choices
+        given backends can be extended and customized.
+        By using this method, customizations will just have
+        to maintain the naming consistent.
+        """
+        return backend_type.lower() in self.backend.lower()
+
     def _get_auto_context_keys(self):
         """
         returns a dictionary which indicates the names of
@@ -158,18 +338,47 @@ def _get_auto_context_keys(self):
             * cert in PEM format
             * path to key file
             * key in PEM format
+        WireGuard:
+            * public key
+            * ip address
+        VXLAN:
+            * vni (VXLAN Network Identifier)
         """
         pk = self.pk.hex
-        return {
-            'ca_path': 'ca_path_{0}'.format(pk),
-            'ca_contents': 'ca_contents_{0}'.format(pk),
-            'cert_path': 'cert_path_{0}'.format(pk),
-            'cert_contents': 'cert_contents_{0}'.format(pk),
-            'key_path': 'key_path_{0}'.format(pk),
-            'key_contents': 'key_contents_{0}'.format(pk),
+        context_keys = {
+            'vpn_host': 'vpn_host_{}'.format(pk),
+            'vpn_port': 'vpn_port_{}'.format(pk),
         }
+        if self._is_backend_type('openvpn'):
+            context_keys.update(
+                {
+                    'ca_path': 'ca_path_{0}'.format(pk),
+                    'ca_contents': 'ca_contents_{0}'.format(pk),
+                    'cert_path': 'cert_path_{0}'.format(pk),
+                    'cert_contents': 'cert_contents_{0}'.format(pk),
+                    'key_path': 'key_path_{0}'.format(pk),
+                    'key_contents': 'key_contents_{0}'.format(pk),
+                }
+            )
+        if self._is_backend_type('wireguard'):
+            context_keys.update(
+                {
+                    'public_key': 'public_key_{}'.format(pk),
+                    'ip_address': 'ip_address_{}'.format(pk),
+                }
+            )
+        if self._is_backend_type('vxlan'):
+            context_keys.update({'vni': 'vni_{}'.format(pk)})
+        if self.ip:
+            context_keys.update(
+                {
+                    'server_ip_address': 'server_ip_address_{}'.format(pk),
+                    'server_ip_network': 'server_ip_network_{}'.format(pk),
+                }
+            )
+        return context_keys
 
-    def auto_client(self, auto_cert=True):
+    def auto_client(self, auto_cert=True, template_backend_class=None):
         """
         calls backend ``auto_client`` method and returns a configuration
         dictionary that is suitable to be used as a template
@@ -187,10 +396,24 @@ def auto_client(self, auto_cert=True):
             if not auto_cert:
                 for key in ['cert_path', 'cert_contents', 'key_path', 'key_contents']:
                     del context_keys[key]
-            conifg_dict_key = self.backend_class.__name__.lower()
-            auto = backend.auto_client(
-                host=self.host, server=self.config[conifg_dict_key][0], **context_keys
-            )
+            config_dict_key = self.backend_class.__name__.lower()
+            vpn_host = context_keys.pop('vpn_host', self.host)
+            if self._is_backend_type('wireguard') and template_backend_class:
+                vpn_auto_client = '{}wireguard_auto_client'.format(
+                    'vxlan_' if self._is_backend_type('vxlan') else ''
+                )
+                auto = getattr(template_backend_class, vpn_auto_client)(
+                    host=vpn_host,
+                    server=self.config['wireguard'][0],
+                    **context_keys,
+                )
+            else:
+                del context_keys['vpn_port']
+                auto = backend.auto_client(
+                    host=self.host,
+                    server=self.config[config_dict_key][0],
+                    **context_keys,
+                )
             config.update(auto)
         return config
 
@@ -201,6 +424,122 @@ def _auto_create_cert_extra(self, cert):
         cert.organization = self.organization
         return cert
 
+    def _generate_wireguard_keys(self):
+        """
+        generates wireguard private and public keys
+        and set the respctive attributes
+        """
+        if not self.private_key or not self.public_key:
+            self.private_key, self.public_key = crypto.generate_wireguard_keys()
+
+    def get_config(self):
+        config = super().get_config()
+        if self._is_backend_type('wireguard'):
+            self._add_wireguard(config)
+        if self._is_backend_type('vxlan'):
+            self._add_vxlan(config)
+        return config
+
+    def _invalidate_peer_cache(self, update=False):
+        """
+        Invalidates peer cache, if update=True is passed,
+        the peer cache will be regenerated
+        """
+        if self._is_backend_type('wireguard'):
+            self._get_wireguard_peers.invalidate(self)
+            if update:
+                self._get_wireguard_peers()
+        if self._is_backend_type('vxlan'):
+            self._get_vxlan_peers.invalidate(self)
+            if update:
+                self._get_vxlan_peers()
+        # Send signal for peers changed
+        vpn_peers_changed.send(sender=self.__class__, instance=self)
+
+    def _get_peer_queryset(self):
+        """
+        returns an iterator to iterate over tunnel peers
+        used to generate the list of peers of a tunnel (WireGuard/VXLAN)
+        """
+        return (
+            self.vpnclient_set.select_related('config', 'ip')
+            .filter(auto_cert=True)
+            .only(
+                'id',
+                'vpn_id',
+                'vni',
+                'public_key',
+                'config__device_id',
+                'config__status',
+                'ip__ip_address',
+            )
+            .iterator()
+        )
+
+    def _add_wireguard(self, config):
+        """
+        Adds wireguard peers and private key to the generated
+        configuration without the need of manual intervention.
+        Modifies the config data structure as a side effect.
+        """
+        try:
+            config['wireguard'][0].setdefault('peers', [])
+        except (KeyError, IndexError):
+            # this error will be handled by
+            # schema validation in subsequent steps
+            return config
+        # private key is added to the config automatically
+        config['wireguard'][0]['private_key'] = self.private_key
+        # peers are also added automatically (and cached)
+        config['wireguard'][0]['peers'] = self._get_wireguard_peers()
+        # internal IP address of wireguard interface
+        config['wireguard'][0]['address'] = '{{ ip_address }}/{{ subnet_prefixlen }}'
+
+    @cache_memoize(_PEER_CACHE_TIMEOUT, args_rewrite=_peer_cache_key)
+    def _get_wireguard_peers(self):
+        """
+        Returns list of wireguard peers, the result is cached.
+        """
+        peers = []
+        for vpnclient in self._get_peer_queryset():
+            if vpnclient.ip:
+                ip_address = ipaddress.ip_address(vpnclient.ip.ip_address)
+                peers.append(
+                    {
+                        'public_key': vpnclient.public_key,
+                        'allowed_ips': f'{ip_address}/{ip_address.max_prefixlen}',
+                    }
+                )
+        return peers
+
+    def _add_vxlan(self, config):
+        """
+        Adds VXLAN peers to the generated configuration
+        without the need of manual intervention.
+        Modifies the config data structure as a side effect.
+        """
+        peers = self._get_vxlan_peers()
+        # add peer list to conifg as a JSON file
+        config.setdefault('files', [])
+        config['files'].append(
+            {
+                'mode': '0644',
+                'path': 'vxlan.json',
+                'contents': json.dumps(peers, indent=4, sort_keys=True),
+            }
+        )
+
+    @cache_memoize(_PEER_CACHE_TIMEOUT, args_rewrite=_peer_cache_key)
+    def _get_vxlan_peers(self):
+        """
+        Returns list of vxlan peers, the result is cached.
+        """
+        peers = []
+        for vpnclient in self._get_peer_queryset():
+            if vpnclient.ip:
+                peers.append({'vni': vpnclient.vni, 'remote': vpnclient.ip.ip_address})
+        return peers
+
 
 class AbstractVpnClient(models.Model):
     """
@@ -220,22 +559,54 @@ class AbstractVpnClient(models.Model):
     # this flags indicates whether the certificate must be
     # automatically managed, which is going to be almost in all cases
     auto_cert = models.BooleanField(default=False)
+    # optional, needed for VPNs which require setting a specific known IP (wireguard)
+    ip = models.ForeignKey(
+        get_model_name('openwisp_ipam', 'IpAddress'),
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+    )
+    # needed for wireguard
+    public_key = models.CharField(blank=True, max_length=44)
+    private_key = models.CharField(blank=True, max_length=44)
+    # needed for vxlan
+    vni = models.PositiveIntegerField(
+        null=True,
+        blank=True,
+        validators=[MinValueValidator(1), MaxValueValidator(16777216)],
+        db_index=True,
+    )
 
     class Meta:
         abstract = True
-        unique_together = ('config', 'vpn')
+        unique_together = (
+            ('config', 'vpn'),
+            ('vpn', 'vni'),
+        )
         verbose_name = _('VPN client')
         verbose_name_plural = _('VPN clients')
 
     def save(self, *args, **kwargs):
         """
-        automatically creates an x509 certificate when ``auto_cert`` is True
+        automatically provisions tunnel keys
+        and configuration if ``auto_cert`` is True
         """
         if self.auto_cert:
-            cn = self._get_common_name()
-            self._auto_create_cert(name=self.config.device.name, common_name=cn)
+            self._auto_x509()
+            self._auto_ip()
+            self._auto_wireguard()
+            self._auto_vxlan()
         super().save(*args, **kwargs)
 
+    def _auto_x509(self):
+        """
+        automatically creates an x509 certificate
+        """
+        if not self.vpn._is_backend_type('openvpn') or self.cert:
+            return
+        cn = self._get_common_name()
+        self._auto_create_cert(name=self.config.device.name, common_name=cn)
+
     def _get_common_name(self):
         """
         returns the common name for a new certificate
@@ -252,14 +623,30 @@ def _get_common_name(self):
         return common_name
 
     @classmethod
-    def post_delete(cls, **kwargs):
+    def post_save(cls, instance, **kwargs):
+        def _post_save():
+            instance.vpn._invalidate_peer_cache(update=True)
+
+        transaction.on_commit(_post_save)
+
+    @classmethod
+    def post_delete(cls, instance, **kwargs):
         """
         class method for ``post_delete`` signal
-        automatically deletes certificates when ``auto_cert`` is ``True``
+        automatically deletes related certificates
+        and ip addresses if necessary
         """
-        instance = kwargs['instance']
-        if instance.auto_cert:
+        # only invalidates, does not regenerate the cache
+        # to avoid generating high load during bulk deletes
+        instance.vpn._invalidate_peer_cache()
+
+        if instance.cert:
             instance.cert.delete()
+        try:
+            if instance.ip:
+                instance.ip.delete()
+        except ObjectDoesNotExist:
+            pass
 
     def _auto_create_cert_extra(self, cert):
         """
@@ -295,3 +682,42 @@ def _auto_create_cert(self, name, common_name):
         cert.save()
         self.cert = cert
         return cert
+
+    def _auto_wireguard(self):
+        """
+        Automatically generates private and public key for wireguard
+        """
+        if not self.vpn._is_backend_type('wireguard') or (
+            self.private_key and self.public_key
+        ):
+            return
+        self.private_key, self.public_key = crypto.generate_wireguard_keys()
+
+    def _auto_vxlan(self):
+        """
+        Automatically generates VNI for VXLAN
+        """
+        if not self.vpn._is_backend_type('vxlan') or self.vni:
+            return
+        last_tunnel = (
+            self._meta.model.objects.filter(vpn=self.vpn).order_by('vni').last()
+        )
+        if last_tunnel:
+            self.vni = last_tunnel.vni + 1
+        else:
+            self.vni = 1
+
+    def _auto_ip(self):
+        if not self.vpn.subnet:
+            return
+        if self.vpn.subnet.subnetdivisionrule_set.filter(
+            organization_id=self.config.device.organization,
+            type=(
+                'openwisp_controller.subnet_division.rule_types.'
+                'vpn.VpnSubnetDivisionRuleType'
+            ),
+        ).exists():
+            # Do not assign IP here if the VPN has a subnet with a subnet
+            # rule for the organization of the device
+            return
+        self.ip = self.vpn.subnet.request_ip()
diff --git a/openwisp_controller/config/crypto.py b/openwisp_controller/config/crypto.py
new file mode 100644
index 000000000..772d23018
--- /dev/null
+++ b/openwisp_controller/config/crypto.py
@@ -0,0 +1,20 @@
+import codecs
+
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+
+def generate_wireguard_keys():
+    private_key = X25519PrivateKey.generate()
+    bytes_ = private_key.private_bytes(
+        encoding=serialization.Encoding.Raw,
+        format=serialization.PrivateFormat.Raw,
+        encryption_algorithm=serialization.NoEncryption(),
+    )
+    private_key_str = codecs.encode(bytes_, 'base64').decode('utf8').strip()
+    # private key
+    public_key = private_key.public_key().public_bytes(
+        encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
+    )
+    public_key_str = codecs.encode(public_key, 'base64').decode('utf8').strip()
+    return private_key_str, public_key_str
diff --git a/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py b/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py
index e74061941..526d4c003 100644
--- a/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py
+++ b/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py
@@ -17,6 +17,7 @@
 import openwisp_utils.base
 import openwisp_utils.utils
 
+from .. import settings as app_settings
 from ..sortedm2m.fields import SortedManyToManyField
 
 
@@ -388,9 +389,7 @@ class Migration(migrations.Migration):
                 (
                     'backend',
                     models.CharField(
-                        choices=[
-                            ('django_netjsonconfig.vpn_backends.OpenVpn', 'OpenVPN')
-                        ],
+                        choices=app_settings.VPN_BACKENDS,
                         help_text='Select VPN configuration backend',
                         max_length=128,
                         verbose_name='VPN backend',
diff --git a/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py b/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py
index 891eeceb6..40421c4b2 100644
--- a/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py
+++ b/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py
@@ -5,6 +5,8 @@
 import django.core.validators
 from django.db import migrations, models
 
+from .. import settings as app_settings
+
 
 class Migration(migrations.Migration):
 
@@ -35,7 +37,7 @@ class Migration(migrations.Migration):
             model_name='vpn',
             name='backend',
             field=models.CharField(
-                choices=[('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN')],
+                choices=app_settings.VPN_BACKENDS,
                 help_text='Select VPN configuration backend',
                 max_length=128,
                 verbose_name='VPN backend',
diff --git a/openwisp_controller/config/migrations/0038_vpn_subnet.py b/openwisp_controller/config/migrations/0038_vpn_subnet.py
new file mode 100644
index 000000000..171e45c5f
--- /dev/null
+++ b/openwisp_controller/config/migrations/0038_vpn_subnet.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.1.7 on 2021-03-08 12:17
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL),
+        ('config', '0037_alter_taggedtemplate'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vpn',
+            name='subnet',
+            field=models.ForeignKey(
+                blank=True,
+                help_text='Subnet IP addresses used by VPN clients, if applicable',
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                verbose_name='Subnet',
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py b/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py
new file mode 100644
index 000000000..d770cf957
--- /dev/null
+++ b/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py
@@ -0,0 +1,132 @@
+import django.core.validators
+import django.db.models.deletion
+import django.utils.timezone
+import swapper
+from django.db import migrations, models
+
+import openwisp_controller.config.base.template
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        swapper.dependency('pki', 'Ca'),
+        swapper.dependency('openwisp_ipam', 'IpAddress'),
+        swapper.dependency('openwisp_ipam', 'Subnet'),
+        ('config', '0038_vpn_subnet'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vpn',
+            name='ip',
+            field=models.ForeignKey(
+                blank=True,
+                help_text=(
+                    'Internal IP address of the VPN server interface, if applicable'
+                ),
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=swapper.get_model_name('openwisp_ipam', 'IpAddress'),
+                verbose_name='Internal IP',
+            ),
+        ),
+        migrations.AddField(
+            model_name='vpn',
+            name='private_key',
+            field=models.CharField(blank=True, max_length=44),
+        ),
+        migrations.AddField(
+            model_name='vpn',
+            name='public_key',
+            field=models.CharField(blank=True, max_length=44),
+        ),
+        migrations.AddField(
+            model_name='vpn',
+            name='auth_token',
+            field=models.CharField(
+                blank=True,
+                help_text=('Authentication token for triggering "Webhook Endpoint"'),
+                max_length=128,
+                null=True,
+                verbose_name='Webhook AuthToken',
+            ),
+        ),
+        migrations.AddField(
+            model_name='vpn',
+            name='webhook_endpoint',
+            field=models.CharField(
+                blank=True,
+                help_text=(
+                    'Webhook to trigger for updating server configuration '
+                    '(e.g. https://openwisp2.mydomain.com:8081/trigger-update)'
+                ),
+                max_length=128,
+                null=True,
+                verbose_name='Webhook Endpoint',
+            ),
+        ),
+        migrations.AddField(
+            model_name='vpnclient',
+            name='private_key',
+            field=models.CharField(blank=True, max_length=44),
+        ),
+        migrations.AddField(
+            model_name='vpnclient',
+            name='public_key',
+            field=models.CharField(blank=True, max_length=44),
+        ),
+        migrations.AddField(
+            model_name='vpnclient',
+            name='vni',
+            field=models.PositiveIntegerField(
+                blank=True,
+                db_index=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(16777216),
+                ],
+            ),
+        ),
+        migrations.AlterField(
+            model_name='template',
+            name='auto_cert',
+            field=models.BooleanField(
+                db_index=True,
+                default=openwisp_controller.config.base.template.default_auto_cert,
+                help_text=(
+                    'whether tunnel specific configuration (cryptographic keys, '
+                    'ip addresses, etc) should be automatically generated and '
+                    'managed behind the scenes for each configuration using this '
+                    'template, valid only for the VPN type'
+                ),
+                verbose_name='automatic tunnel provisioning',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='vpn',
+            name='ca',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=swapper.get_model_name('pki', 'Ca'),
+                verbose_name='Certification Authority',
+            ),
+        ),
+        migrations.AlterUniqueTogether(
+            name='vpnclient',
+            unique_together={('config', 'vpn'), ('vpn', 'vni')},
+        ),
+        migrations.AddField(
+            model_name='vpnclient',
+            name='ip',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=swapper.get_model_name('openwisp_ipam', 'IpAddress'),
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py b/openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py
new file mode 100644
index 000000000..0f359bd8f
--- /dev/null
+++ b/openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.1.13 on 2021-09-20 13:01
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL),
+        ('config', '0039_wireguard_vxlan_ipam'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vpn',
+            name='ip',
+            field=models.ForeignKey(
+                blank=True,
+                help_text=(
+                    'Internal IP address of the VPN server interface, if applicable'
+                ),
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+                verbose_name='Internal IP',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='vpnclient',
+            name='ip',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py
index f2287c22b..acdf383d9 100644
--- a/openwisp_controller/config/settings.py
+++ b/openwisp_controller/config/settings.py
@@ -34,7 +34,12 @@ def get_settings_value(option, default):
 )
 
 VPN_BACKENDS = get_settings_value(
-    'VPN_BACKENDS', (('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),)
+    'VPN_BACKENDS',
+    (
+        ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),
+        ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'),
+        ('openwisp_controller.vpn_backends.VxlanWireguard', 'VXLAN over WireGuard'),
+    ),
 )
 DEFAULT_BACKEND = get_settings_value('DEFAULT_BACKEND', BACKENDS[0][0])
 DEFAULT_VPN_BACKEND = get_settings_value('DEFAULT_VPN_BACKEND', VPN_BACKENDS[0][0])
diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py
index b8fba9f00..35bb4ead7 100644
--- a/openwisp_controller/config/signals.py
+++ b/openwisp_controller/config/signals.py
@@ -33,3 +33,7 @@
 device_group_changed.__doc__ = """
 Providing arguments: ['instance']
 """
+vpn_peers_changed = Signal()
+vpn_peers_changed.__doc__ = """
+providing arguments: ['instance']
+"""
diff --git a/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css b/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css
index 4de47cb1a..f736e5aa7 100644
--- a/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css
+++ b/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css
@@ -290,3 +290,13 @@ div.jsoneditor > div > h3.controls{
   bottom: -52px;
   z-index: 99;
 }
+.jsoneditor-wrapper div.jsoneditor .grid-row .grid-column > select.switcher{
+  position: static;
+  margin-top: 15px;
+}
+.jsoneditor-wrapper div.jsoneditor .grid-row > .grid-column > label {
+  display: none;
+}
+.jsoneditor-wrapper div.jsoneditor .inline-group > .form-row > label {
+  display: block !important;
+}
diff --git a/openwisp_controller/config/static/config/js/vpn.js b/openwisp_controller/config/static/config/js/vpn.js
index 257487d9c..c5e951528 100644
--- a/openwisp_controller/config/static/config/js/vpn.js
+++ b/openwisp_controller/config/static/config/js/vpn.js
@@ -1,33 +1,66 @@
 'use strict';
 django.jQuery(function ($) {
-    if (!$('.add-form').length) {
-        return;
-    }
-
-    var showOverlay = function () {
-        var loading = $('#loading-overlay');
-        if (!loading.length) {
-            $('body').append(
-                '<div id="loading-overlay" class="djnjc-overlay loading"><div class="spinner"></div></div>'
-            );
-            loading = $('#loading-overlay');
-        }
-        loading.fadeIn(100, function () {
-            loading.css('display', 'flex');
-            var spinner = loading.find('.spinner');
-            spinner.fadeOut(100, function () {
-                var message = gettext(
-                    'Please be patient, we are creating all the necessary ' +
-                        'cyrptographic keys which may take some time'
+    if ($('.add-form').length) {
+        var showOverlay = function () {
+            var loading = $('#loading-overlay');
+            if (!loading.length) {
+                $('body').append(
+                    '<div id="loading-overlay" class="djnjc-overlay loading"><div class="spinner"></div></div>'
                 );
-                spinner.remove();
-                loading.append('<p>');
-                loading.find('p').hide().text(message).fadeIn(250);
+                loading = $('#loading-overlay');
+            }
+            loading.fadeIn(100, function () {
+                loading.css('display', 'flex');
+                var spinner = loading.find('.spinner');
+                spinner.fadeOut(100, function () {
+                    var message = gettext(
+                        'Please be patient, we are creating all the necessary ' +
+                        'cyrptographic keys which may take some time'
+                    );
+                    spinner.remove();
+                    loading.append('<p>');
+                    loading.find('p').hide().text(message).fadeIn(250);
+                });
             });
+        };
+
+        $('#vpn_form').submit(function () {
+            showOverlay();
         });
+    }
+
+    var toggleRelatedFields = function () {
+        // Show IP and Subnet field only for WireGuard backend
+        var backendValue = $('#id_backend').val() === undefined ? '' : $('#id_backend').val().toLocaleLowerCase().toLocaleLowerCase();
+        if (backendValue.includes('wireguard') || backendValue.includes('vxlan')) {
+            $('label[for="id_subnet"]').parent().parent().show();
+            $('label[for="id_ip"]').parent().parent().show();
+            $('label[for="id_webhook_endpoint"]').parent().parent().show();
+            $('label[for="id_auth_token"]').parent().parent().show();
+        } else {
+            $('label[for="id_subnet"]').parent().parent().hide();
+            $('label[for="id_ip"]').parent().parent().hide();
+            $('label[for="id_webhook_endpoint"]').parent().parent().hide();
+            $('label[for="id_auth_token"]').parent().parent().hide();
+            // Reset IP and Subnet fields
+            $('#id_subnet').val(null);
+            $('#id_ip').val(null);
+        }
+
+        if (backendValue.includes('openvpn')) {
+            $('label[for="id_ca"]').parent().parent().show();
+            $('label[for="id_cert"]').parent().parent().show();
+        } else {
+            $('label[for="id_ca"]').parent().parent().hide();
+            $('label[for="id_cert"]').parent().parent().hide();
+        }
     };
 
-    $('#vpn_form').submit(function () {
-        showOverlay();
+    // clean config when VPN backend is changed
+    $('#id_backend').change(function () {
+        $('#id_config').val('{}');
+        toggleRelatedFields();
     });
+
+    toggleRelatedFields();
 });
diff --git a/openwisp_controller/config/tasks.py b/openwisp_controller/config/tasks.py
index 339cabb7b..6be1a1476 100644
--- a/openwisp_controller/config/tasks.py
+++ b/openwisp_controller/config/tasks.py
@@ -1,7 +1,9 @@
 import logging
 
+import requests
 from celery import shared_task
 from celery.exceptions import SoftTimeLimitExceeded
+from django.conf import settings
 from django.core.exceptions import ObjectDoesNotExist
 from swapper import load_model
 
@@ -83,3 +85,20 @@ def invalidate_devicegroup_cache_delete(instance_id, model_name, **kwargs):
         DeviceGroupCommonName.certificate_delete_invalidates_cache(
             kwargs['organization_id'], kwargs['common_name']
         )
+
+
+@shared_task(soft_time_limit=1200)
+def trigger_vpn_server_endpoint(endpoint, auth_token, vpn_id):
+    response = requests.post(
+        endpoint,
+        params={'key': auth_token},
+        verify=False if getattr(settings, 'DEBUG') else True,
+    )
+    if response.status_code == 200:
+        logger.info(f'Triggered update webhook of VPN Server UUID: {vpn_id}')
+    else:
+        logger.error(
+            'Failed to update VPN Server configuration. '
+            f'Response status code: {response.status_code}, '
+            f'VPN Server UUID: {vpn_id}',
+        )
diff --git a/openwisp_controller/connection/migrations/0001_initial.py b/openwisp_controller/connection/migrations/0001_initial.py
index 3beb19e13..fcb4f8eb7 100644
--- a/openwisp_controller/connection/migrations/0001_initial.py
+++ b/openwisp_controller/connection/migrations/0001_initial.py
@@ -159,7 +159,7 @@ class Migration(migrations.Migration):
                     'credentials',
                     models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='connection.Credentials',
+                        to=swapper.get_model_name('connection', 'Credentials'),
                     ),
                 ),
                 (
diff --git a/openwisp_controller/subnet_division/__init__.py b/openwisp_controller/subnet_division/__init__.py
new file mode 100644
index 000000000..dd3a7036d
--- /dev/null
+++ b/openwisp_controller/subnet_division/__init__.py
@@ -0,0 +1,1 @@
+default_app_config = 'openwisp_controller.subnet_division.apps.SubnetDivisionConfig'
diff --git a/openwisp_controller/subnet_division/admin.py b/openwisp_controller/subnet_division/admin.py
new file mode 100644
index 000000000..8de21608d
--- /dev/null
+++ b/openwisp_controller/subnet_division/admin.py
@@ -0,0 +1,138 @@
+from django.contrib import admin
+from django.urls import reverse
+from django.utils.html import mark_safe
+from django.utils.translation import gettext_lazy as _
+from openwisp_ipam.admin import IpAddressAdmin as BaseIpAddressAdmin
+from openwisp_ipam.admin import SubnetAdmin as BaseSubnetAdmin
+from swapper import load_model
+
+from openwisp_controller.config.admin import DeviceAdmin
+from openwisp_users.multitenancy import MultitenantAdminMixin, MultitenantOrgFilter
+from openwisp_utils.admin import HelpTextStackedInline, TimeReadonlyAdminMixin
+
+from . import settings as app_settings
+from .filters import DeviceFilter, SubnetFilter, SubnetListFilter
+
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+Device = load_model('config', 'Device')
+
+
+class SubnetDivisionRuleInlineAdmin(
+    MultitenantAdminMixin, TimeReadonlyAdminMixin, HelpTextStackedInline
+):
+    model = SubnetDivisionRule
+    extra = 0
+    help_text = {
+        'text': _(
+            'Please keep in mind that once the subnet division rule is created '
+            'changing changing "Size", "Number of Subnets" or decreasing '
+            '"Number of IPs" will not be possible.'
+        ),
+        'documentation_url': (
+            'https://github.com/openwisp/openwisp-controller'
+            '#limitations-of-subnet-division'
+        ),
+    }
+
+    class Media:
+        js = ['admin/js/jquery.init.js', 'subnet-division/js/subnet-division.js']
+
+
+# Monkey patching DeviceAdmin to allow filtering using subnet
+DeviceAdmin.list_filter.append(SubnetFilter)
+
+# NOTE: Monkey patching SubnetAdmin didn't work for adding readonly_field
+# to change_view because of TimeReadonlyAdminMixin.
+
+admin.site.unregister(Subnet)
+admin.site.unregister(IpAddress)
+
+
+@admin.register(Subnet)
+class SubnetAdmin(BaseSubnetAdmin):
+    list_filter = BaseSubnetAdmin.list_filter + [DeviceFilter]
+    inlines = [SubnetDivisionRuleInlineAdmin] + BaseSubnetAdmin.inlines
+
+    def get_queryset(self, request):
+        qs = super().get_queryset(request)
+        subnet_division_index_qs = (
+            SubnetDivisionIndex.objects.filter(
+                subnet_id__in=qs.filter(master_subnet__isnull=False).values('id'),
+                ip__isnull=True,
+            )
+            .select_related('config__device')
+            .values_list('subnet_id', 'config__device__name')
+        )
+        self._lookup = {}
+        for subnet_id, device_name in subnet_division_index_qs:
+            self._lookup[subnet_id] = device_name
+
+        if app_settings.HIDE_GENERATED_SUBNETS:
+            qs = qs.exclude(
+                id__in=SubnetDivisionIndex.objects.filter(
+                    ip__isnull=True, subnet__isnull=False
+                ).values_list('subnet_id')
+            )
+
+        return qs
+
+    def get_readonly_fields(self, request, obj=None):
+        fields = super().get_readonly_fields(request, obj)
+        if obj is not None and 'related_device' not in fields:
+            fields = ('related_device',) + fields
+        return fields
+
+    def get_list_display(self, request):
+        fields = super().get_list_display(request)
+        return fields + ['related_device']
+
+    def related_device(self, obj):
+        app_label = Device._meta.app_label
+        url = reverse(f'admin:{app_label}_device_changelist')
+        if obj.master_subnet is None:
+            msg_string = _('See all devices')
+            return mark_safe(
+                f'<a href="{url}?subnet={str(obj.subnet)}">{msg_string}</a>'
+            )
+        else:
+            if obj.id in self._lookup:
+                device = self._lookup[obj.id]
+                return mark_safe(
+                    f'<a href="{url}?subnet={str(obj.subnet)}">{device}</a>'
+                )
+
+    def has_change_permission(self, request, obj=None):
+        permission = super().has_change_permission(request, obj)
+        if not obj:
+            return permission
+        automated = SubnetDivisionIndex.objects.filter(subnet_id=obj.id).exists()
+        return permission and not automated
+
+
+@admin.register(IpAddress)
+class IpAddressAdmin(BaseIpAddressAdmin):
+    list_filter = [
+        ('subnet', SubnetListFilter),
+        ('subnet__organization', MultitenantOrgFilter),
+    ]
+
+    def get_queryset(self, request):
+        qs = super().get_queryset(request)
+
+        if app_settings.HIDE_GENERATED_SUBNETS:
+            qs = qs.exclude(
+                id__in=SubnetDivisionIndex.objects.filter(ip__isnull=False).values_list(
+                    'ip_id'
+                )
+            )
+        return qs
+
+    def has_change_permission(self, request, obj=None):
+        permission = super().has_change_permission(request, obj)
+        if not obj:
+            return permission
+        automated = SubnetDivisionIndex.objects.filter(ip_id=obj.id).exists()
+        return permission and not automated
diff --git a/openwisp_controller/subnet_division/apps.py b/openwisp_controller/subnet_division/apps.py
new file mode 100644
index 000000000..b187efd0f
--- /dev/null
+++ b/openwisp_controller/subnet_division/apps.py
@@ -0,0 +1,52 @@
+from django.apps import AppConfig
+from django.db.models.signals import post_delete, post_save, pre_save
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+from . import settings as app_settings
+
+
+class SubnetDivisionConfig(AppConfig):
+    name = 'openwisp_controller.subnet_division'
+    verbose_name = _('Subnet Division')
+    default_auto_field = 'django.db.models.AutoField'
+
+    def ready(self):
+        super().ready()
+        self._load_models()
+
+        for rule_path, name in app_settings.SUBNET_DIVISION_TYPES:
+            rule_class = import_string(rule_path)
+            rule_class.validate_rule_type()
+            rule_class.provision_signal.connect(
+                receiver=rule_class.provision_receiver,
+                sender=rule_class.provision_sender,
+                dispatch_uid=rule_class.provision_dispatch_uid,
+            )
+            rule_class.destroyer_signal.connect(
+                receiver=rule_class.destroyer_receiver,
+                sender=rule_class.destroyer_sender,
+                dispatch_uid=rule_class.destroyer_dispatch_uid,
+            )
+
+        pre_save.connect(
+            receiver=self.subnetdivisionrule_model_.pre_save,
+            sender=self.subnetdivisionrule_model_,
+            dispatch_uid='subnetdivisionrule_pre_save',
+        )
+        post_save.connect(
+            receiver=self.subnetdivisionrule_model_.post_save,
+            sender=self.subnetdivisionrule_model_,
+            dispatch_uid='subnetdivisionrule_post_save',
+        )
+        post_delete.connect(
+            receiver=self.subnetdivisionrule_model_.post_delete,
+            sender=self.subnetdivisionrule_model_,
+            dispatch_uid='subnetdivisionrule_post_delete',
+        )
+
+    def _load_models(self):
+        self.subnetdivisionrule_model_ = load_model(
+            'subnet_division', 'SubnetDivisionRule'
+        )
diff --git a/openwisp_controller/subnet_division/base/models.py b/openwisp_controller/subnet_division/base/models.py
new file mode 100644
index 000000000..06eb61a2f
--- /dev/null
+++ b/openwisp_controller/subnet_division/base/models.py
@@ -0,0 +1,267 @@
+from ipaddress import ip_network
+
+import swapper
+from django.core.exceptions import ValidationError
+from django.db import models, transaction
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
+
+from openwisp_users.mixins import OrgMixin
+from openwisp_utils.base import TimeStampedEditableModel
+
+from .. import settings as app_settings
+
+
+class AbstractSubnetDivisionRule(TimeStampedEditableModel, OrgMixin):
+    _subnet_division_rule_update_queue = dict()
+    # It is used to monitor changes in fields of a SubnetDivisionRule object
+    # An entry is added to the queue from pre_save signal in the following format
+    #
+    # '<rule-uid>: {
+    #   '<field-name>': '<old-value>',
+    # }
+    #
+    # In post_save signal, it is checked whether entry for SubnetDivisionRule object
+    # exists in this queue. If it exists changes are made to related objects.
+
+    type = models.CharField(max_length=200, choices=app_settings.SUBNET_DIVISION_TYPES)
+    master_subnet = models.ForeignKey(
+        swapper.get_model_name('openwisp_ipam', 'Subnet'), on_delete=models.CASCADE
+    )
+    label = models.CharField(
+        max_length=30,
+        help_text=_('Label used to calculate the configuration variables'),
+    )
+    number_of_subnets = models.PositiveIntegerField(
+        verbose_name=_('Number of Subnets'),
+        help_text=_('Indicates how many subnets will be created'),
+    )
+    size = models.PositiveIntegerField(
+        verbose_name=_('Size of subnets'),
+        help_text=_('Indicates the size of each created subnet'),
+    )
+    number_of_ips = models.PositiveIntegerField(
+        verbose_name=_('Number of IPs'),
+        help_text=_('Indicates how many IP addresses will be created for each subnet'),
+    )
+
+    class Meta:
+        abstract = True
+        constraints = [
+            models.UniqueConstraint(
+                fields=['organization', 'label'],
+                name='unique_subnet_division_rule_label',
+            ),
+            models.UniqueConstraint(
+                fields=['organization', 'label', 'type', 'master_subnet'],
+                name='unique_subnet_division_rule',
+            ),
+        ]
+
+    def __str__(self):
+        return f'{self.label}'
+
+    @property
+    def rule_class(self):
+        return import_string(self.type)
+
+    def clean(self):
+        super().clean()
+        self._validate_label()
+        self._validate_master_subnet_consistency()
+        self._validate_ip_address_consistency()
+        if not self._state.adding:
+            self._validate_existing_fields()
+
+    def _validate_label(self):
+        if not self.label.isidentifier():
+            raise ValidationError(
+                {
+                    'label': _(
+                        'Only alphanumeric characters and underscores are allowed.'
+                    )
+                }
+            )
+
+    def _validate_existing_fields(self):
+        db_instance = self._meta.model.objects.get(id=self.id)
+        # The size field should not be changed
+        if self.size != db_instance.size:
+            raise ValidationError({'size': _('Subnet size cannot be changed')})
+        # Number of IPs should not decreased
+        if self.number_of_ips < db_instance.number_of_ips:
+            raise ValidationError(
+                {'number_of_ips': _('Number of IPs cannot be decreased')}
+            )
+        # Number of subnets should not be changed
+        if self.number_of_subnets != db_instance.number_of_subnets:
+            raise ValidationError(
+                {'number_of_subnets': _('Number of Subnets cannot be changed')}
+            )
+
+    def _validate_master_subnet_consistency(self):
+        master_subnet = self.master_subnet.subnet
+        # Validate size of generated subnet is not greater than size of master subnet
+        try:
+            next(master_subnet.subnets(new_prefix=self.size))
+        except ValueError:
+            raise ValidationError(
+                {
+                    'size': _(
+                        'Master subnet cannot accommodate subnets of size /{0}'.format(
+                            self.size
+                        )
+                    )
+                }
+            )
+
+        # Validate master subnet can accommodate required number of generated subnets
+        if self.number_of_subnets > (2 ** (self.size - master_subnet.prefixlen)):
+            raise ValidationError(
+                {
+                    'number_of_subnets': _(
+                        f'Master subnet cannot accommodate {self.number_of_subnets} '
+                        f'subnets of size /{self.size}'
+                    )
+                }
+            )
+
+        # Validate organization of master subnet
+        if (
+            self.master_subnet.organization is not None
+            and self.master_subnet.organization != self.organization
+        ):
+            raise ValidationError(
+                {'organization': _('Organization should be same as the subnet')}
+            )
+
+    def _validate_ip_address_consistency(self):
+        # Validate individual generated subnet can accommodate required number of IPs
+        try:
+            next(
+                ip_network(str(self.master_subnet.subnet)).subnets(new_prefix=self.size)
+            )[self.number_of_ips]
+        except IndexError:
+            raise ValidationError(
+                {
+                    'number_of_ips': _(
+                        f'Generated subnets of size /{self.size} cannot accommodate '
+                        f'{self.number_of_ips} IP Addresses.'
+                    )
+                }
+            )
+
+    def check_and_queue_modified_fields(self):
+        try:
+            db_instance = self._meta.model.objects.get(id=self.id)
+        except self._meta.model.DoesNotExist:
+            # This rule does not exists in database.
+            # No operation is needed to be performed.
+            return
+        else:
+            # Check which fields of instance is modified
+            # NOTE: Open-ended implementation to allow change in all
+            # fields of SubnetDivisionRule in future.
+            # Currently only changing label and number of IPs is allowed.
+            modified_fields = {}
+            for field in db_instance._meta.fields:
+                instance_value = getattr(self, field.name)
+                db_value = getattr(db_instance, field.name)
+                if instance_value != db_value:
+                    modified_fields[field.name] = db_value
+            if modified_fields:
+                self._subnet_division_rule_update_queue[str(self.id)] = modified_fields
+
+    def update_related_objects(self):
+        from .. import tasks
+
+        # Update related objects appropriately.
+        # NOTE: Currently only changing label and number of IPs is implemented/allowed.
+        try:
+            modified_fields = self._subnet_division_rule_update_queue.pop(str(self.id))
+        except KeyError:
+            return
+        else:
+            if 'label' in modified_fields:
+                tasks.update_subnet_division_index.delay(rule_id=str(self.id))
+                tasks.update_subnet_name_description(rule_id=str(self.id))
+            if 'number_of_ips' in modified_fields:
+                tasks.provision_extra_ips.delay(
+                    rule_id=str(self.id),
+                    old_number_of_ips=modified_fields['number_of_ips'],
+                )
+
+    def delete_provisioned_subnets(self):
+        # Deleting an object of SubnetDivisionRule will set the rule field
+        # of related SubnetDivisionIndex to "None" due to "on_delete=SET_NULL".
+        # These indexes are used delete subnets that were provisioned by the
+        # deleted rule. Deleting a Subnet object will automatically delete
+        # related IpAddress objects.
+        Subnet = swapper.load_model('openwisp_ipam', 'Subnet')
+        SubnetDivisionIndex = swapper.load_model(
+            'subnet_division', 'SubnetDivisionIndex'
+        )
+
+        Subnet.objects.filter(
+            id__in=SubnetDivisionIndex.objects.filter(rule_id=None).values('subnet_id')
+        ).delete()
+
+    @classmethod
+    def pre_save(cls, instance, **kwargs):
+        instance.check_and_queue_modified_fields()
+
+    @classmethod
+    def post_save(cls, instance, created, **kwargs):
+        from ..tasks import provision_subnet_ip_for_existing_devices
+
+        if created:
+            transaction.on_commit(
+                lambda: provision_subnet_ip_for_existing_devices.delay(
+                    rule_id=instance.id
+                )
+            )
+        else:
+            transaction.on_commit(instance.update_related_objects)
+
+    @classmethod
+    def post_delete(cls, instance, **kwargs):
+        transaction.on_commit(instance.delete_provisioned_subnets)
+
+
+class AbstractSubnetDivisionIndex(models.Model):
+    keyword = models.CharField(max_length=30)
+    subnet = models.ForeignKey(
+        swapper.get_model_name('openwisp_ipam', 'Subnet'),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    ip = models.ForeignKey(
+        swapper.get_model_name('openwisp_ipam', 'IpAddress'),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    rule = models.ForeignKey(
+        swapper.get_model_name('subnet_division', 'SubnetDivisionRule'),
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    config = models.ForeignKey(
+        swapper.get_model_name('config', 'Config'),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+
+    class Meta:
+        abstract = True
+        indexes = [
+            models.Index(fields=['keyword']),
+        ]
+        constraints = [
+            models.UniqueConstraint(
+                fields=['keyword', 'subnet', 'ip', 'config'],
+                name='unique_subnet_division_index',
+            ),
+        ]
diff --git a/openwisp_controller/subnet_division/filters.py b/openwisp_controller/subnet_division/filters.py
new file mode 100644
index 000000000..e9c079f87
--- /dev/null
+++ b/openwisp_controller/subnet_division/filters.py
@@ -0,0 +1,61 @@
+from django.contrib import admin
+from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+from openwisp_utils.admin_theme.filters import SimpleInputFilter
+
+from . import settings as app_settings
+
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+
+
+class SubnetFilter(SimpleInputFilter):
+    parameter_name = 'subnet'
+    title = _('subnet')
+
+    def queryset(self, request, queryset):
+        if self.value() is not None:
+            master_subnet_key = (
+                'config__subnetdivisionindex__subnet__master_subnet__subnet'
+            )
+            return queryset.filter(
+                Q(**{master_subnet_key: self.value()})
+                | Q(config__subnetdivisionindex__subnet__subnet=self.value())
+            ).distinct()
+
+
+class DeviceFilter(SimpleInputFilter):
+    """
+    Filters Subnet queryset for input device name
+    using SubnetDivisionIndex
+    """
+
+    parameter_name = 'device'
+    title = _('device name')
+
+    def queryset(self, request, queryset):
+        if self.value() is not None:
+            return queryset.filter(
+                id__in=SubnetDivisionIndex.objects.filter(
+                    config__device__name=self.value()
+                ).values_list('subnet_id')
+            )
+
+
+class SubnetListFilter(admin.RelatedFieldListFilter):
+    def field_choices(self, field, request, model_admin):
+        if app_settings.HIDE_GENERATED_SUBNETS and field.name == 'subnet':
+            return field.get_choices(
+                include_blank=False,
+                limit_choices_to={
+                    'id__in': Subnet.objects.exclude(
+                        id__in=SubnetDivisionIndex.objects.filter(
+                            ip__isnull=True, subnet__isnull=False
+                        ).values_list('subnet_id')
+                    )
+                },
+            )
+        choices = super().field_choices(field, request, model_admin)
+        return choices
diff --git a/openwisp_controller/subnet_division/migrations/0001_initial.py b/openwisp_controller/subnet_division/migrations/0001_initial.py
new file mode 100644
index 000000000..b222f9712
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0001_initial.py
@@ -0,0 +1,189 @@
+# Generated by Django 3.1.7 on 2021-03-08 01:37
+
+import uuid
+
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+from django.conf import settings
+from django.db import migrations, models
+
+from .. import settings as app_settings
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.CONFIG_CONFIG_MODEL),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SubnetDivisionRule',
+            fields=[
+                (
+                    'id',
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                (
+                    'created',
+                    model_utils.fields.AutoCreatedField(
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name='created',
+                    ),
+                ),
+                (
+                    'modified',
+                    model_utils.fields.AutoLastModifiedField(
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name='modified',
+                    ),
+                ),
+                (
+                    'type',
+                    models.CharField(
+                        choices=app_settings.SUBNET_DIVISION_TYPES, max_length=200
+                    ),
+                ),
+                (
+                    'master_subnet',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                    ),
+                ),
+                (
+                    'label',
+                    models.CharField(
+                        help_text='Label used to calculate the configuration variables',
+                        max_length=30,
+                    ),
+                ),
+                (
+                    'number_of_subnets',
+                    models.PositiveIntegerField(
+                        help_text='Indicates how many subnets will be created',
+                        verbose_name='Number of Subnets',
+                    ),
+                ),
+                (
+                    'size',
+                    models.PositiveIntegerField(
+                        help_text='Indicates the size of each created subnet',
+                        verbose_name='Size of subnets',
+                    ),
+                ),
+                (
+                    'number_of_ips',
+                    models.PositiveIntegerField(
+                        help_text=(
+                            'Indicates how many IP addresses will '
+                            'be created for each subnet'
+                        ),
+                        verbose_name='Number of IPs',
+                    ),
+                ),
+                (
+                    'organization',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='openwisp_users.organization',
+                        verbose_name='organization',
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+                'swappable': 'SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL',
+            },
+        ),
+        migrations.CreateModel(
+            name='SubnetDivisionIndex',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                ('keyword', models.CharField(max_length=30)),
+                (
+                    'config',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.CONFIG_CONFIG_MODEL,
+                    ),
+                ),
+                (
+                    'ip',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+                    ),
+                ),
+                (
+                    'rule',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL,
+                    ),
+                ),
+                (
+                    'subnet',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+                'swappable': 'SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL',
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionrule',
+            constraint=models.UniqueConstraint(
+                fields=('organization', 'label'),
+                name='unique_subnet_division_rule_label',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionrule',
+            constraint=models.UniqueConstraint(
+                fields=('organization', 'label', 'type', 'master_subnet'),
+                name='unique_subnet_division_rule',
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='subnetdivisionindex',
+            index=models.Index(
+                fields=['keyword'], name='subnet_divi_keyword_c76db3_idx'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionindex',
+            constraint=models.UniqueConstraint(
+                fields=('keyword', 'subnet', 'ip', 'config'),
+                name='unique_subnet_division_index',
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/0002_default_group_migration.py b/openwisp_controller/subnet_division/migrations/0002_default_group_migration.py
new file mode 100644
index 000000000..e269a4434
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0002_default_group_migration.py
@@ -0,0 +1,13 @@
+from django.db import migrations
+
+from . import assign_permissions_to_groups
+
+
+class Migration(migrations.Migration):
+    dependencies = [('subnet_division', '0001_initial')]
+
+    operations = [
+        migrations.RunPython(
+            assign_permissions_to_groups, reverse_code=migrations.RunPython.noop
+        )
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py b/openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py
new file mode 100644
index 000000000..f8631f1b6
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.1.12 on 2021-06-23 18:01
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.CONFIG_CONFIG_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL),
+        ('subnet_division', '0002_default_group_migration'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='config',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.CONFIG_CONFIG_MODEL,
+            ),
+        ),
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='ip',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+            ),
+        ),
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='subnet',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py b/openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py
new file mode 100644
index 000000000..b9b1911d0
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.13 on 2021-09-27 19:25
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('subnet_division', '0003_related_field_allow_blank'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='rule',
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL,
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/__init__.py b/openwisp_controller/subnet_division/migrations/__init__.py
new file mode 100644
index 000000000..7b127fb5b
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/__init__.py
@@ -0,0 +1,33 @@
+from django.contrib.auth.models import Permission
+
+from ...migrations import create_default_permissions, get_swapped_model
+
+
+def assign_permissions_to_groups(apps, schema_editor):
+    create_default_permissions(apps, schema_editor)
+    operators_and_admins_can_manage = ['subnetdivisionrule']
+    admin_manage_operations = ['add', 'change', 'delete', 'view']
+    operator_manage_operations = ['view']
+    Group = get_swapped_model(apps, 'openwisp_users', 'Group')
+
+    try:
+        admin = Group.objects.get(name='Administrator')
+        operator = Group.objects.get(name='Operator')
+    # consider failures custom cases
+    # that do not have to be dealt with
+    except Group.DoesNotExist:
+        return
+
+    for model_name in operators_and_admins_can_manage:
+        for operation in admin_manage_operations:
+            permission = Permission.objects.get(
+                codename='{}_{}'.format(operation, model_name)
+            )
+            admin.permissions.add(permission.pk)
+
+    for model_name in operators_and_admins_can_manage:
+        for operation in operator_manage_operations:
+            permission = Permission.objects.get(
+                codename='{}_{}'.format(operation, model_name)
+            )
+            operator.permissions.add(permission.pk)
diff --git a/openwisp_controller/subnet_division/models.py b/openwisp_controller/subnet_division/models.py
new file mode 100644
index 000000000..e713ef165
--- /dev/null
+++ b/openwisp_controller/subnet_division/models.py
@@ -0,0 +1,15 @@
+from swapper import swappable_setting
+
+from .base.models import AbstractSubnetDivisionIndex, AbstractSubnetDivisionRule
+
+
+class SubnetDivisionRule(AbstractSubnetDivisionRule):
+    class Meta(AbstractSubnetDivisionRule.Meta):
+        abstract = False
+        swappable = swappable_setting('subnet_division', 'SubnetDivisionRule')
+
+
+class SubnetDivisionIndex(AbstractSubnetDivisionIndex):
+    class Meta(AbstractSubnetDivisionIndex.Meta):
+        abstract = False
+        swappable = swappable_setting('subnet_division', 'SubnetDivisionIndex')
diff --git a/openwisp_controller/subnet_division/rule_types/__init__.py b/openwisp_controller/subnet_division/rule_types/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openwisp_controller/subnet_division/rule_types/base.py b/openwisp_controller/subnet_division/rule_types/base.py
new file mode 100644
index 000000000..2992edd1e
--- /dev/null
+++ b/openwisp_controller/subnet_division/rule_types/base.py
@@ -0,0 +1,255 @@
+import logging
+from ipaddress import ip_network
+from operator import attrgetter
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import transaction
+from django.dispatch import Signal
+from django.utils.translation import gettext_lazy as _
+from netaddr import IPNetwork
+from swapper import load_model
+
+from ..signals import subnet_provisioned
+
+logger = logging.getLogger(__name__)
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+VpnClient = load_model('config', 'VpnClient')
+
+
+class BaseSubnetDivisionRuleType(object):
+    provision_signal = None
+    provision_sender = None
+    provision_dispatch_uid = None
+
+    destroyer_signal = None
+    destroyer_sender = None
+    destroyer_dispatch_uid = None
+
+    organization_id_path = None
+    subnet_path = None
+    config_path = 'config'
+
+    @classmethod
+    def validate_rule_type(cls):
+        assert issubclass(cls, BaseSubnetDivisionRuleType)
+
+        assert isinstance(cls.provision_signal, Signal)
+        assert isinstance(cls.provision_dispatch_uid, str)
+        cls.provision_sender = load_model(*cls.provision_sender)
+
+        assert isinstance(cls.destroyer_signal, Signal)
+        assert isinstance(cls.destroyer_dispatch_uid, str)
+        cls.destroyer_sender = load_model(*cls.destroyer_sender)
+
+        assert isinstance(cls.organization_id_path, str)
+        assert isinstance(cls.subnet_path, str)
+
+    @classmethod
+    def provision_receiver(cls, instance, **kwargs):
+        def _provision_receiver():
+            # If any of following operations fail, the database transaction
+            # should fail/rollback.
+
+            # This method is also called by "provision_for_existing_objects"
+            # which passes the "rule" keyword argument. In such case,
+            # provisioning should be only triggered for received rule.
+            if 'rule' in kwargs:
+                rules = [kwargs['rule']]
+            else:
+                try:
+                    rules = cls.get_subnet_division_rules(instance)
+                except (AttributeError, ObjectDoesNotExist):
+                    return
+            for rule in rules:
+                provisioned = cls.create_subnets_ips(instance, rule, **kwargs)
+                cls.post_provision_handler(instance, provisioned, **kwargs)
+                cls.subnet_provisioned_signal_emitter(instance, provisioned)
+
+        if not cls.should_create_subnets_ips(instance, **kwargs):
+            return
+
+        transaction.on_commit(_provision_receiver)
+
+    @classmethod
+    def destroyer_receiver(cls, instance, **kwargs):
+        cls.destroy_provisioned_subnets_ips(instance, **kwargs)
+
+    @staticmethod
+    def post_provision_handler(instance, provisioned, **kwargs):
+        """
+        This method should be overridden in inherited rule types to
+        perform any operation on provisioned subnets and IP addresses.
+        :param instance: object that triggered provisioning
+        :param provisioned: dictionary containing subnets and IP addresses
+            provisioned, None if nothing is provisioned
+        """
+        pass
+
+    @staticmethod
+    def subnet_provisioned_signal_emitter(instance, provisioned):
+        subnet_provisioned.send(
+            sender=SubnetDivisionRule, instance=instance, provisioned=provisioned
+        )
+
+    @classmethod
+    def should_create_subnets_ips(cls, instance, **kwargs):
+        """
+        return a boolean value whether subnets and IPs should
+        be provisioned for "instance" object
+        """
+        raise NotImplementedError()
+
+    @classmethod
+    def provision_for_existing_objects(cls, rule_obj):
+        """
+        Contains logic to trigger provisioning for existing objects
+        """
+        raise NotImplementedError()
+
+    @classmethod
+    def create_subnets_ips(cls, instance, division_rule, **kwargs):
+        try:
+            config = cls.get_config(instance)
+        except (AttributeError, ObjectDoesNotExist):
+            return
+
+        master_subnet = division_rule.master_subnet
+        max_subnet = cls.get_max_subnet(master_subnet, division_rule)
+        generated_indexes = []
+        generated_subnets = cls.create_subnets(
+            config, division_rule, max_subnet, generated_indexes
+        )
+        generated_ips = cls.create_ips(
+            config, division_rule, generated_subnets, generated_indexes
+        )
+        SubnetDivisionIndex.objects.bulk_create(generated_indexes)
+        return {'subnets': generated_subnets, 'ip_addresses': generated_ips}
+
+    @classmethod
+    def get_organization(cls, instance):
+        return attrgetter(cls.organization_id_path)(instance)
+
+    @classmethod
+    def get_subnet(cls, instance):
+        return attrgetter(cls.subnet_path)(instance)
+
+    @classmethod
+    def get_subnet_division_rules(cls, instance):
+        rule_type = f'{cls.__module__}.{cls.__name__}'
+        organization_id = cls.get_organization(instance)
+        subnet = cls.get_subnet(instance)
+        return subnet.subnetdivisionrule_set.filter(
+            organization_id__in=(organization_id, None),
+            type=rule_type,
+        ).iterator()
+
+    @classmethod
+    def get_config(cls, instance):
+        if cls.config_path == 'self':
+            return instance
+        else:
+            return attrgetter(cls.config_path)(instance)
+
+    @staticmethod
+    def get_max_subnet(master_subnet, division_rule):
+        try:
+            max_subnet = (
+                # Get the highest subnet created for this master_subnet
+                Subnet.objects.filter(master_subnet_id=master_subnet.id)
+                .order_by('-created')
+                .first()
+                .subnet
+            )
+        except AttributeError:
+            # If there is no existing subnet, create a reserved subnet
+            # and use it as starting point
+            required_subnet = next(
+                IPNetwork(str(master_subnet.subnet)).subnet(
+                    prefixlen=division_rule.size
+                )
+            )
+            subnet_obj = Subnet(
+                name=f'Reserved Subnet {required_subnet}',
+                subnet=str(required_subnet),
+                description=_('Automatically generated reserved subnet.'),
+                master_subnet_id=master_subnet.id,
+                organization_id=master_subnet.organization_id,
+            )
+            subnet_obj.full_clean()
+            subnet_obj.save()
+            max_subnet = subnet_obj.subnet
+        finally:
+            return max_subnet
+
+    @staticmethod
+    def create_subnets(config, division_rule, max_subnet, generated_indexes):
+        master_subnet = division_rule.master_subnet
+        required_subnet = IPNetwork(str(max_subnet)).next()
+        generated_subnets = []
+
+        for subnet_id in range(1, division_rule.number_of_subnets + 1):
+            if not ip_network(str(required_subnet)).subnet_of(master_subnet.subnet):
+                logger.error(f'Cannot create more subnets of {master_subnet}')
+                break
+            subnet_obj = Subnet(
+                name=f'{division_rule.label}_subnet{subnet_id}',
+                subnet=str(required_subnet),
+                description=_(
+                    f'Automatically generated using {division_rule.label} rule.'
+                ),
+                master_subnet_id=master_subnet.id,
+                organization_id=division_rule.organization_id,
+            )
+            subnet_obj.full_clean()
+            generated_subnets.append(subnet_obj)
+            generated_indexes.append(
+                SubnetDivisionIndex(
+                    keyword=f'{division_rule.label}_subnet{subnet_id}',
+                    subnet_id=subnet_obj.id,
+                    rule_id=division_rule.id,
+                    config=config,
+                )
+            )
+            required_subnet = required_subnet.next()
+        Subnet.objects.bulk_create(generated_subnets)
+        return generated_subnets
+
+    @staticmethod
+    def create_ips(config, division_rule, generated_subnets, generated_indexes):
+        generated_ips = []
+        for subnet_obj in generated_subnets:
+            for ip_id in range(1, division_rule.number_of_ips + 1):
+                ip_obj = IpAddress(
+                    subnet_id=subnet_obj.id,
+                    ip_address=str(subnet_obj.subnet[ip_id]),
+                )
+                ip_obj.full_clean()
+                generated_ips.append(ip_obj)
+
+                generated_indexes.append(
+                    SubnetDivisionIndex(
+                        keyword=f'{subnet_obj.name}_ip{ip_id}',
+                        subnet_id=subnet_obj.id,
+                        ip_id=ip_obj.id,
+                        rule_id=division_rule.id,
+                        config=config,
+                    )
+                )
+
+        IpAddress.objects.bulk_create(generated_ips)
+        return generated_ips
+
+    @classmethod
+    def destroy_provisioned_subnets_ips(cls, instance, **kwargs):
+        # Deleting related subnets automatically deletes related IpAddress
+        # and SubnetDivisionIndex objects
+        config = cls.get_config(instance)
+        rule_type = f'{cls.__module__}.{cls.__name__}'
+        subnet_ids = config.subnetdivisionindex_set.filter(
+            rule__type=rule_type
+        ).values_list('subnet_id')
+        Subnet.objects.filter(id__in=subnet_ids).delete()
diff --git a/openwisp_controller/subnet_division/rule_types/device.py b/openwisp_controller/subnet_division/rule_types/device.py
new file mode 100644
index 000000000..2fceb3c4d
--- /dev/null
+++ b/openwisp_controller/subnet_division/rule_types/device.py
@@ -0,0 +1,52 @@
+from django.db.models.signals import post_delete, post_save
+from swapper import load_model
+
+from .base import BaseSubnetDivisionRuleType
+
+Config = load_model('config', 'Config')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+
+
+class DeviceSubnetDivisionRuleType(BaseSubnetDivisionRuleType):
+    provision_signal = post_save
+    provision_sender = ('config', 'Config')
+    provision_dispatch_uid = 'device_registered_provision_subnet'
+
+    destroyer_signal = post_delete
+    destroyer_sender = ('config', 'Config')
+    destroyer_dispatch_uid = 'device_registered_destroy_subnet'
+
+    organization_id_path = 'device.organization_id'
+    subnet_path = ''
+    config_path = 'self'
+
+    @classmethod
+    def get_subnet(cls, instance):
+        pass
+
+    @classmethod
+    def get_subnet_division_rules(cls, instance):
+        rule_type = f'{cls.__module__}.{cls.__name__}'
+        return instance.device.organization.subnetdivisionrule_set.filter(
+            type=rule_type
+        ).iterator()
+
+    @classmethod
+    def should_create_subnets_ips(cls, instance, **kwargs):
+        return kwargs.get('created', False)
+
+    @staticmethod
+    def destroy_provisioned_subnets_ips(instance, **kwargs):
+        # Deleting related subnets automatically deletes related IpAddress
+        # and SubnetDivisionIndex objects
+        subnet_ids = instance.subnetdivisionindex_set.values_list('subnet_id')
+        Subnet.objects.filter(id__in=subnet_ids).delete()
+
+    @classmethod
+    def provision_for_existing_objects(cls, rule_obj):
+        for config in (
+            Config.objects.select_related('device', 'device__organization')
+            .filter(device__organization_id=rule_obj.organization_id)
+            .iterator()
+        ):
+            cls.provision_receiver(config, created=True, rule=rule_obj)
diff --git a/openwisp_controller/subnet_division/rule_types/vpn.py b/openwisp_controller/subnet_division/rule_types/vpn.py
new file mode 100644
index 000000000..e4407146e
--- /dev/null
+++ b/openwisp_controller/subnet_division/rule_types/vpn.py
@@ -0,0 +1,53 @@
+from django.db.models import Q
+from django.db.models.signals import post_delete, post_save
+from swapper import load_model
+
+from .base import BaseSubnetDivisionRuleType
+
+Vpn = load_model('config', 'Vpn')
+VpnClient = load_model('config', 'VpnClient')
+
+
+class VpnSubnetDivisionRuleType(BaseSubnetDivisionRuleType):
+    provision_signal = post_save
+    provision_sender = ('config', 'VpnClient')
+    provision_dispatch_uid = 'vpn_client_provision_subnet'
+
+    destroyer_signal = post_delete
+    destroyer_sender = provision_sender
+    destroyer_dispatch_uid = 'vpn_client_destroy_subnet'
+
+    organization_id_path = 'config.device.organization_id'
+    subnet_path = 'vpn.subnet'
+
+    @classmethod
+    def should_create_subnets_ips(cls, instance, **kwargs):
+        return kwargs.get('created', False)
+
+    @classmethod
+    def provision_for_existing_objects(cls, rule_obj):
+        organization_filter = Q(organization_id=rule_obj.organization_id) | Q(
+            organization_id=None
+        )
+        vpn_qs = (
+            Vpn.objects.filter(subnet=rule_obj.master_subnet)
+            .filter(organization_filter)
+            .values_list('id')
+        )
+        qs = VpnClient.objects.filter(
+            vpn__in=vpn_qs, config__device__organization_id=rule_obj.organization_id
+        )
+        for vpn_client in qs:
+            cls.provision_receiver(instance=vpn_client, created=True)
+
+    @staticmethod
+    def post_provision_handler(instance, provisioned, **kwargs):
+        # Assign the first provisioned IP address to the VPNClient
+        # only when subnets and IPs have been provisioned
+        if provisioned and provisioned['ip_addresses']:
+            # Delete any previously assigned IP address
+            if instance.ip:
+                instance.ip.delete()
+            instance.ip = provisioned['ip_addresses'][0]
+            instance.full_clean()
+            instance.save()
diff --git a/openwisp_controller/subnet_division/settings.py b/openwisp_controller/subnet_division/settings.py
new file mode 100644
index 000000000..3de884393
--- /dev/null
+++ b/openwisp_controller/subnet_division/settings.py
@@ -0,0 +1,28 @@
+from django.conf import settings
+
+SUBNET_DIVISION_TYPES = getattr(
+    settings,
+    'OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES',
+    (
+        (
+            (
+                'openwisp_controller.subnet_division.rule_types.'
+                'vpn.VpnSubnetDivisionRuleType'
+            ),
+            'VPN',
+        ),
+        (
+            (
+                'openwisp_controller.subnet_division.rule_types.'
+                'device.DeviceSubnetDivisionRuleType'
+            ),
+            'Device',
+        ),
+    ),
+)
+
+HIDE_GENERATED_SUBNETS = getattr(
+    settings,
+    'OPENWISP_CONTROLLER_HIDE_AUTOMATICALLY_GENERATED_SUBNETS_AND_IPS',
+    False,
+)
diff --git a/openwisp_controller/subnet_division/signals.py b/openwisp_controller/subnet_division/signals.py
new file mode 100644
index 000000000..f0e205b12
--- /dev/null
+++ b/openwisp_controller/subnet_division/signals.py
@@ -0,0 +1,6 @@
+from django.dispatch import Signal
+
+subnet_provisioned = Signal()
+subnet_provisioned.__doc__ = """
+Providing arguments: ['instance', 'provisioned']
+"""
diff --git a/openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css b/openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css
new file mode 100644
index 000000000..d8c56f092
--- /dev/null
+++ b/openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css
@@ -0,0 +1,14 @@
+input.readonly {
+  border: 1px solid rgba(0, 0, 0, 0.05) !important;
+  background-color: rgba(0, 0, 0, 0.07);
+}
+.help-text-warning {
+  background-color: #ffe5e5;
+  padding: 5px 10px;
+  font-size: 15px;
+  font-weight: bolder;
+  display: flex;
+}
+.help-text-warning img {
+  min-width: 30px;
+}
diff --git a/openwisp_controller/subnet_division/static/subnet-division/js/subnet-division.js b/openwisp_controller/subnet_division/static/subnet-division/js/subnet-division.js
new file mode 100644
index 000000000..f2edb9038
--- /dev/null
+++ b/openwisp_controller/subnet_division/static/subnet-division/js/subnet-division.js
@@ -0,0 +1,51 @@
+'use strict';
+
+if (typeof gettext === 'undefined') {
+    var gettext = function (word) {
+        return word;
+    };
+}
+
+django.jQuery(function ($) {
+    if ($('#subnetdivisionrule_set-group').length === 0) {
+        return;
+    }
+    // Do not allow decreasing number_of_ips
+    $('input[name$="-number_of_ips"]:visible').each(function (index, el) {
+        if (($(el).val() !== '') && ($(el).attr('min') === "0")) {
+            $(el).attr('min', $(el).val());
+        }
+    });
+
+    // Disable size and number_of_subnets fields for existing rules
+    $('.inline-related.dynamic-subnetdivisionrule_set:visible').each(function (index, el) {
+        // Delete link appears only on unsaved rules.
+        if ($(el).find('.inline-deletelink').length === 0) {
+            $(el).find('input[name$="-size"]').prop('readonly', true);
+            $(el).find('input[name$="-size"]').addClass('readonly');
+            $(el).find('input[name$="-number_of_subnets"]').prop('readonly', true);
+            $(el).find('input[name$="-number_of_subnets"]').addClass('readonly');
+        }
+    });
+
+    // If subnet is not shared, hide organization field from Subnet Division Rule
+    function hideOrganizationFieldForNonSharedSubnet() {
+        if ($('#id_organization').val() !== '') {
+            $('#subnetdivisionrule_set-group select[name$="-organization"]').each(
+                function (index, element) {
+                    element = $(element);
+                    if ((element.val() === '') || (element.val() === $('#id_organization').val())) {
+                        element.val($('#id_organization').val());
+                        element.parent().parent().parent().hide();
+                    } else {
+                        element.parent().parent().parent().show();
+                    }
+                });
+        } else {
+            $('#subnetdivisionrule_set-group .form-row.field-organization').show();
+        }
+    }
+    hideOrganizationFieldForNonSharedSubnet();
+    $('#subnetdivisionrule_set-group .add-row a').click(hideOrganizationFieldForNonSharedSubnet);
+    $('#id_organization').change(hideOrganizationFieldForNonSharedSubnet);
+});
diff --git a/openwisp_controller/subnet_division/tasks.py b/openwisp_controller/subnet_division/tasks.py
new file mode 100644
index 000000000..3775cb758
--- /dev/null
+++ b/openwisp_controller/subnet_division/tasks.py
@@ -0,0 +1,136 @@
+import logging
+
+from celery import shared_task
+from django.db import transaction
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+logger = logging.getLogger(__name__)
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Config = load_model('config', 'Config')
+Vpn = load_model('config', 'Vpn')
+VpnClient = load_model('config', 'VpnClient')
+
+
+@shared_task
+def update_subnet_division_index(rule_id):
+    try:
+        division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as e:
+        logger.warning(
+            'Failed to update indexes for Subnet Division Rule '
+            f'with id: "{rule_id}", reason: {e}'
+        )
+        return
+
+    for index in division_rule.subnetdivisionindex_set.only('keyword').iterator():
+        identifiers = index.keyword.split('_')
+        if index.ip_id is not None:
+            required_identifiers = 2
+        else:
+            required_identifiers = 1
+        index.keyword = '_'.join(
+            [division_rule.label] + identifiers[-required_identifiers:]
+        )
+        index.save()
+
+
+@shared_task
+def update_subnet_name_description(rule_id):
+    try:
+        division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as e:
+        logger.warning(
+            'Failed to update subnets related to Subnet Division Rule '
+            f'with id: "{rule_id}", reason: {e}'
+        )
+        return
+
+    related_subnet_ids = division_rule.subnetdivisionindex_set.filter(
+        subnet_id__isnull=False,
+        ip_id__isnull=True,
+    ).values_list('subnet_id')
+    subnet_queryset = Subnet.objects.filter(id__in=related_subnet_ids)
+
+    for subnet in subnet_queryset:
+        identifiers = subnet.name.split('_')
+        identifiers[0] = division_rule.label
+        subnet.name = '_'.join(identifiers)
+        subnet.description = _(
+            f'Automatically generated using {division_rule.label} rule.'
+        )
+
+    Subnet.objects.bulk_update(
+        subnet_queryset, fields=['name', 'description'], batch_size=20
+    )
+
+
+@shared_task
+def provision_extra_ips(rule_id, old_number_of_ips):
+    def _create_ipaddress_and_subnetdivision_index_objects(ips, indexes):
+        IpAddress.objects.bulk_create(ips)
+        SubnetDivisionIndex.objects.bulk_create(indexes)
+
+    generated_ips = []
+    generated_indexes = []
+
+    try:
+        division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as e:
+        logger.warning(
+            'Failed to provision extra IPs for Subnet Division Rule '
+            f'with id: "{rule_id}", reason: {e}'
+        )
+        return
+
+    index_queryset = division_rule.subnetdivisionindex_set.filter(
+        subnet_id__isnull=False,
+        config_id__isnull=False,
+        ip_id__isnull=True,
+    ).select_related('subnet')
+
+    starting_ip_id = old_number_of_ips + 1
+    ending_ip_id = division_rule.number_of_ips + 1
+
+    for index in index_queryset:
+        subnet = index.subnet
+        for ip_id in range(starting_ip_id, ending_ip_id):
+            ip = IpAddress(
+                subnet_id=subnet.id,
+                ip_address=str(subnet.subnet[ip_id]),
+            )
+
+            generated_ips.append(ip)
+            generated_indexes.append(
+                SubnetDivisionIndex(
+                    keyword=f'{division_rule.label}_subnet{subnet.id}_ip{ip_id}',
+                    subnet_id=subnet.id,
+                    ip_id=ip.id,
+                    rule_id=division_rule.id,
+                    config_id=index.config_id,
+                )
+            )
+
+    transaction.on_commit(
+        lambda: _create_ipaddress_and_subnetdivision_index_objects(
+            generated_ips, generated_indexes
+        )
+    )
+
+
+@shared_task
+def provision_subnet_ip_for_existing_devices(rule_id):
+    try:
+        rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as error:
+        logger.warning(
+            'Failed to provision IPs on existing devices for Subnet '
+            f'Division Rule with id: "{rule_id}", reason: {error}'
+        )
+        return
+    else:
+        rule.rule_class.provision_for_existing_objects(rule)
diff --git a/openwisp_controller/urls.py b/openwisp_controller/urls.py
index f86b0ba69..bc1e72da9 100644
--- a/openwisp_controller/urls.py
+++ b/openwisp_controller/urls.py
@@ -14,6 +14,12 @@
         'app': 'openwisp_notifications',
         'include': {'module': 'openwisp_notifications.urls'},
     },
+    # openwisp_ipam
+    {
+        'regexp': '',
+        'app': 'openwisp_ipam',
+        'include': {'module': 'openwisp_ipam.urls'},
+    },
     # openwisp_controller.pki (CRL view)
     {
         'regexp': '',
diff --git a/openwisp_controller/vpn_backends.py b/openwisp_controller/vpn_backends.py
index cdfe88f08..3954884bb 100644
--- a/openwisp_controller/vpn_backends.py
+++ b/openwisp_controller/vpn_backends.py
@@ -1,6 +1,8 @@
 from copy import deepcopy
 
 from netjsonconfig import OpenVpn as BaseOpenVpn
+from netjsonconfig import VxlanWireguard as BaseVxlanWireguard
+from netjsonconfig import Wireguard as BaseWireguard
 
 # adapt OpenVPN schema in order to limit it to 1 item only
 limited_schema = deepcopy(BaseOpenVpn.schema)
@@ -43,3 +45,27 @@ class OpenVpn(BaseOpenVpn):
     """
 
     schema = limited_schema
+
+
+limited_wireguard_schema = deepcopy(BaseWireguard.schema)
+wireguard_properties = limited_wireguard_schema['properties']['wireguard']
+wireguard_properties.update({'maxItems': 1, 'minItems': 1})
+# private key is handled automatically without the need of user input
+del wireguard_properties['items']['properties']['private_key']
+wireguard_properties['items']['required'].remove('private_key')
+
+
+class Wireguard(BaseWireguard):
+    """
+    WireGuard
+    """
+
+    schema = limited_wireguard_schema
+
+
+class VxlanWireguard(BaseVxlanWireguard):
+    """
+    VXLAN over WireGuard
+    """
+
+    schema = limited_wireguard_schema
diff --git a/requirements.txt b/requirements.txt
index 33591ff8c..4152867fc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,14 +7,18 @@ django-loci @ https://github.com/openwisp/django-loci/tarball/master
 django-flat-json-widget~=0.1.3
 # TODO: change this when next point version of openwisp-utils is released
 openwisp-utils[rest] @ https://github.com/openwisp/openwisp-utils/tarball/master
+# TODO: change this when next point version of netjsonconfig is released
+netjsonconfig @ https://github.com/openwisp/netjsonconfig/tarball/wireguard-vxlan
 # TODO: change this when next point version of openwisp-users is released
 openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/master
 # TODO: change this when next point version of openwisp-notifications is released
 openwisp-notifications @ https://github.com/openwisp/openwisp-notifications/tarball/master
-djangorestframework-gis @ https://github.com/openwisp/django-rest-framework-gis/tarball/master
-netjsonconfig~=0.9.0
+# TODO: change this when the next point version of openwisp-ipam is released
+openwisp-ipam @ https://github.com/openwisp/openwisp-ipam/tarball/master
+djangorestframework-gis~=0.18.0
 paramiko[ed25519]~=2.7.2
 scp>=0.13.0,<0.14.0
 celery~=5.2.3
 django-cache-memoize~=0.1
 shortuuid~=1.0.1
+netaddr~=0.8.0
diff --git a/run-qa-checks b/run-qa-checks
index eea057eae..da91cb4ae 100755
--- a/run-qa-checks
+++ b/run-qa-checks
@@ -15,8 +15,10 @@ rm -rf ./openwisp_controller/geo/tests/.pytest_cache \
 jshint openwisp_controller/pki/static/admin/pki/js/*.js
 jshint openwisp_controller/config/static/config/js/*.js
 jshint openwisp_controller/connection/static/connection/js/*.js
+jshint openwisp_controller/subnet_division/static/subnet-division/js/*.js
 stylelint "openwisp_controller/config/static/config/css/*.css"
 stylelint "openwisp_controller/connection/static/connection/css/*.css"
+stylelint "openwisp_controller/subnet_division/static/subnet-division/css/*.css"
 
 
 # CSS checks
@@ -27,7 +29,8 @@ openwisp-qa-check \
   --migration-path "./openwisp_controller/config/migrations
                   ./openwisp_controller/connection/migrations
                   ./openwisp_controller/geo/migrations
-                  ./openwisp_controller/pki/migrations"
+                  ./openwisp_controller/pki/migrations
+                  ./openwisp_controller/subnet_division/migrations"
 
 echo ''
 echo 'Running checks for SAMPLE_APP'
@@ -40,4 +43,5 @@ SAMPLE_APP=1 openwisp-qa-check \
   --migration-path "./tests/openwisp2/sample_config/migrations/
                     ./tests/openwisp2/sample_pki/migrations/
                     ./tests/openwisp2/sample_connection/migrations/
-                    ./tests/openwisp2/sample_geo/migrations/"
+                    ./tests/openwisp2/sample_geo/migrations/
+                    ./tests/openwisp2/sample_subnet_division/migrations/"


Test patch:
diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py
index b8979079f..b40edea7c 100644
--- a/openwisp_controller/config/tests/test_api.py
+++ b/openwisp_controller/config/tests/test_api.py
@@ -274,7 +274,7 @@ def test_device_download_api(self):
         d1 = self._create_device()
         self._create_config(device=d1)
         path = reverse('config_api:download_device_config', args=[d1.pk])
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(8):
             r = self.client.get(path)
         self.assertEqual(r.status_code, 200)
 
diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py
index a793d577e..266993eae 100644
--- a/openwisp_controller/config/tests/test_controller.py
+++ b/openwisp_controller/config/tests/test_controller.py
@@ -133,7 +133,7 @@ def test_get_cached_checksum(self):
         url = reverse('controller:device_checksum', args=[d.pk])
 
         with self.subTest('first request does not return value from cache'):
-            with self.assertNumQueries(3):
+            with self.assertNumQueries(5):
                 with patch.object(
                     controller_views_logger, 'debug'
                 ) as mocked_view_debug:
@@ -1107,12 +1107,12 @@ def test_ip_fields_not_duplicated(self):
         c2 = self._create_config(device=d2)
         org2 = self._create_org(name='org2', shared_secret='123456')
         c3 = self._create_config(organization=org2)
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(8):
             self.client.get(
                 reverse('controller:device_checksum', args=[c3.device.pk]),
                 {'key': c3.device.key, 'management_ip': '192.168.1.99'},
             )
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(8):
             self.client.get(
                 reverse('controller:device_checksum', args=[c1.device.pk]),
                 {'key': c1.device.key, 'management_ip': '192.168.1.99'},
@@ -1125,7 +1125,7 @@ def test_ip_fields_not_duplicated(self):
             )
         # triggers more queries because devices with conflicting addresses
         # need to be updated, luckily it does not happen often
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(10):
             self.client.get(
                 reverse('controller:device_checksum', args=[c2.device.pk]),
                 {'key': c2.device.key, 'management_ip': '192.168.1.99'},
@@ -1154,14 +1154,14 @@ def test_organization_shares_management_ip_address_space(self):
         org1_config = self._create_config(organization=org1)
         org2 = self._create_org(name='org2', shared_secret='org2')
         org2_config = self._create_config(organization=org2)
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(8):
             self.client.get(
                 reverse('controller:device_checksum', args=[org1_config.device_id]),
                 {'key': org1_config.device.key, 'management_ip': '192.168.1.99'},
             )
         # Device from another organization sends conflicting management IP
         # Extra queries due to conflict resolution
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(10):
             self.client.get(
                 reverse('controller:device_checksum', args=[org2_config.device_id]),
                 {'key': org2_config.device.key, 'management_ip': '192.168.1.99'},
diff --git a/openwisp_controller/config/tests/test_template.py b/openwisp_controller/config/tests/test_template.py
index 851bf6785..e3dc3b49b 100644
--- a/openwisp_controller/config/tests/test_template.py
+++ b/openwisp_controller/config/tests/test_template.py
@@ -357,7 +357,7 @@ def test_context_regression(self):
             template_qs = Template.objects.filter(type='vpn')
             self.assertEqual(template_qs.count(), 1)
             t = template_qs.first()
-            self.assertEqual(t.get_context(), _original_context)
+            self.assertDictContainsSubset(_original_context, t.get_context())
             self.assertEqual(app_settings.CONTEXT, _original_context)
 
         with self.subTest(
diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py
index c533a2754..f20166dc3 100644
--- a/openwisp_controller/config/tests/test_vpn.py
+++ b/openwisp_controller/config/tests/test_vpn.py
@@ -1,16 +1,26 @@
+import json
 from unittest import mock
 
 from celery.exceptions import SoftTimeLimitExceeded
 from django.core.exceptions import ValidationError
+from django.db.models.signals import post_save
+from django.http.response import HttpResponse, HttpResponseNotFound
 from django.test import TestCase, TransactionTestCase
+from openwisp_ipam.tests import CreateModelsMixin as CreateIpamModelsMixin
 from swapper import load_model
 
-from openwisp_users.tests.utils import TestOrganizationMixin
+from openwisp_utils.tests import catch_signal
 
 from ...vpn_backends import OpenVpn
 from .. import settings as app_settings
+from ..signals import vpn_peers_changed
 from ..tasks import create_vpn_dh
-from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin
+from .utils import (
+    CreateConfigTemplateMixin,
+    TestVpnX509Mixin,
+    TestVxlanWireguardVpnMixin,
+    TestWireguardVpnMixin,
+)
 
 Config = load_model('config', 'Config')
 Device = load_model('config', 'Device')
@@ -19,23 +29,25 @@
 VpnClient = load_model('config', 'VpnClient')
 Ca = load_model('django_x509', 'Ca')
 Cert = load_model('django_x509', 'Cert')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
 
 
-class TestVpn(
-    TestOrganizationMixin, TestVpnX509Mixin, CreateConfigTemplateMixin, TestCase
-):
+class BaseTestVpn(CreateIpamModelsMixin, TestVpnX509Mixin, CreateConfigTemplateMixin):
+    maxDiff = None
+
+
+class TestVpn(BaseTestVpn, TestCase):
     """
     tests for Vpn model
     """
 
-    maxDiff = None
-
     def test_config_not_none(self):
         v = Vpn(
             name='test',
             host='vpn1.test.com',
             ca=self._create_ca(),
-            backend='openwisp_controller.vpn_backends.OpenVpn',
+            backend=self._BACKENDS['openvpn'],
             config=None,
             dh=self._dh,
         )
@@ -50,7 +62,7 @@ def test_backend_class(self):
             name='test',
             host='vpn1.test.com',
             ca=self._create_ca(),
-            backend='openwisp_controller.vpn_backends.OpenVpn',
+            backend=self._BACKENDS['openvpn'],
         )
         self.assertIs(v.backend_class, OpenVpn)
 
@@ -59,7 +71,7 @@ def test_backend_instance(self):
             name='test',
             host='vpn1.test.com',
             ca=self._create_ca(),
-            backend='openwisp_controller.vpn_backends.OpenVpn',
+            backend=self._BACKENDS['openvpn'],
             config={},
         )
         self.assertIsInstance(v.backend_instance, OpenVpn)
@@ -70,7 +82,7 @@ def test_validation(self):
             name='test',
             host='vpn1.test.com',
             ca=self._create_ca(),
-            backend='openwisp_controller.vpn_backends.OpenVpn',
+            backend=self._BACKENDS['openvpn'],
             config=config,
         )
         with self.subTest('test invalid openvpn key'):
@@ -148,7 +160,7 @@ def test_vpn_cert_and_ca_mismatch(self):
             host='vpn1.test.com',
             ca=different_ca,
             cert=cert,
-            backend='openwisp_controller.vpn_backends.OpenVpn',
+            backend=self._BACKENDS['openvpn'],
             config=self._vpn_config,
         )
         try:
@@ -162,6 +174,8 @@ def test_auto_client(self):
         vpn = self._create_vpn()
         auto = vpn.auto_client()
         context_keys = vpn._get_auto_context_keys()
+        del context_keys['vpn_host']
+        del context_keys['vpn_port']
         for key in context_keys.keys():
             context_keys[key] = '{{%s}}' % context_keys[key]
         control = vpn.backend_class.auto_client(
@@ -190,6 +204,8 @@ def test_auto_client_auto_cert_False(self):
         vpn = self._create_vpn()
         auto = vpn.auto_client(auto_cert=False)
         context_keys = vpn._get_auto_context_keys()
+        del context_keys['vpn_host']
+        del context_keys['vpn_port']
         for key in context_keys.keys():
             context_keys[key] = '{{%s}}' % context_keys[key]
         for key in ['cert_path', 'cert_contents', 'key_path', 'key_contents']:
@@ -229,6 +245,8 @@ def test_get_auto_context_keys(self):
             'cert_contents': 'cert_contents_{0}'.format(pk),
             'key_path': 'key_path_{0}'.format(pk),
             'key_contents': 'key_contents_{0}'.format(pk),
+            'vpn_host': 'vpn_host_{0}'.format(pk),
+            'vpn_port': 'vpn_port_{0}'.format(pk),
         }
         self.assertEqual(keys, control)
 
@@ -395,13 +413,23 @@ def test_vpn_name_unique_validation(self):
             message_dict = context_manager.exception.message_dict
             self.assertIn('name', message_dict)
 
+    def test_is_backend_type(self):
+        vpn = Vpn(backend=self._BACKENDS['openvpn'])
+        self.assertTrue(vpn._is_backend_type('openvpn'))
+        vpn.backend = self._BACKENDS['wireguard']
+        self.assertTrue(vpn._is_backend_type('wireguard'))
+        self.assertFalse(vpn._is_backend_type('openvpn'))
 
-class TestVpnTransaction(
-    TestOrganizationMixin,
-    TestVpnX509Mixin,
-    CreateConfigTemplateMixin,
-    TransactionTestCase,
-):
+    def test_cert_validation(self):
+        with self.subTest('test certs required case'):
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_vpn(ca=None)
+            message_dict = context_manager.exception.message_dict
+            self.assertIn('ca', message_dict)
+            self.assertIn('CA is required with this VPN backend', message_dict['ca'])
+
+
+class TestVpnTransaction(BaseTestVpn, TransactionTestCase):
     @mock.patch.object(create_vpn_dh, 'delay')
     def test_create_vpn_dh_with_vpn_create(self, delay):
         vpn = self._create_vpn(dh='')
@@ -421,3 +449,420 @@ def test_update_vpn_dh(self, dhparam):
         vpn.refresh_from_db()
         self.assertNotEqual(vpn.dh, Vpn._placeholder_dh)
         dhparam.assert_called_once()
+
+
+class TestWireguard(BaseTestVpn, TestWireguardVpnMixin, TestCase):
+    def test_wireguard_config_creation(self):
+        vpn = self._create_wireguard_vpn()
+
+        with self.subTest('test key generation'):
+            self.assertIsNotNone(vpn.public_key)
+            self.assertEqual(len(vpn.public_key), 44)
+            self.assertIsNotNone(vpn.private_key)
+            self.assertEqual(len(vpn.private_key), 44)
+
+        with self.subTest('test context'):
+            context = vpn.get_context()
+            self.assertEqual(context['public_key'], vpn.public_key)
+            self.assertEqual(context['private_key'], vpn.private_key)
+            self.assertEqual(context['subnet'], str(vpn.subnet.subnet))
+            self.assertEqual(
+                context['subnet_prefixlen'], str(vpn.subnet.subnet.prefixlen)
+            )
+            self.assertEqual(context['ip_address'], vpn.ip.ip_address)
+
+        with self.subTest('test context keys'):
+            context_keys = vpn._get_auto_context_keys()
+            self.assertIn('public_key', context_keys)
+            self.assertIn('ip_address', context_keys)
+            self.assertIn('server_ip_address', context_keys)
+            self.assertIn('server_ip_network', context_keys)
+
+    def test_auto_cert_false(self):
+        device, vpn, template = self._create_wireguard_vpn_template(auto_cert=False)
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 1)
+        vpnclient = vpnclient_qs.first()
+        self.assertEqual(vpnclient.private_key, '')
+        self.assertEqual(vpnclient.public_key, '')
+
+    def test_ip_deleted_when_vpnclient_deleted(self):
+        device, vpn, template = self._create_wireguard_vpn_template()
+        self.assertEqual(IpAddress.objects.count(), 2)
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        vpnclient_qs.first().delete()
+        self.assertEqual(IpAddress.objects.count(), 1)
+
+    def test_ip_deleted_when_device_deleted(self):
+        device, vpn, template = self._create_wireguard_vpn_template()
+        self.assertEqual(device.config.vpnclient_set.count(), 1)
+        device.delete()
+        self.assertEqual(IpAddress.objects.count(), 1)
+
+    def test_delete_vpnclient_ip(self):
+        device, vpn, template = self._create_wireguard_vpn_template()
+        self.assertEqual(device.config.vpnclient_set.count(), 1)
+        vpnclient = device.config.vpnclient_set.first()
+        vpnclient.ip.delete()
+        self.assertEqual(device.config.vpnclient_set.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 1)
+
+    def test_ip_within_subnet(self):
+        org = self._get_org()
+        subnet1 = self._create_subnet(subnet='10.0.1.0/24', organization=org)
+        subnet2 = self._create_subnet(subnet='10.0.2.0/24', organization=org)
+        ip_subnet2 = subnet2.request_ip()
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_wireguard_vpn(organization=org, subnet=subnet1, ip=ip_subnet2)
+        message_dict = context_manager.exception.message_dict
+        self.assertIn('ip', message_dict)
+        self.assertIn(
+            'VPN IP address must be within the VPN subnet', message_dict['ip']
+        )
+
+    def test_wireguard_schema(self):
+        with self.subTest('wireguard schema shall be valid'):
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_wireguard_vpn(config={'wireguard': []})
+            self.assertIn(
+                'Invalid configuration triggered by "#/wireguard"',
+                str(context_manager.exception),
+            )
+            # delete subnet created for previous assertion
+            Subnet.objects.all().delete()
+
+        with self.subTest('wireguard property shall be present'):
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_wireguard_vpn(config={})
+            self.assertIn('wireguard', str(context_manager.exception))
+            self.assertIn('is a required property', str(context_manager.exception))
+
+    def test_auto_client(self):
+        device, vpn, template = self._create_wireguard_vpn_template()
+        auto = vpn.auto_client(template_backend_class=template.backend_class)
+        context_keys = vpn._get_auto_context_keys()
+        for key in context_keys.keys():
+            context_keys[key] = '{{%s}}' % context_keys[key]
+        expected = template.backend_class.wireguard_auto_client(
+            host=context_keys['vpn_host'],
+            server=self._vpn_config['wireguard'][0],
+            **context_keys,
+        )
+        self.assertEqual(auto, expected)
+
+    def test_change_vpn_backend(self):
+        vpn = self._create_vpn(name='new', backend=self._BACKENDS['openvpn'])
+        subnet = self._create_subnet(
+            name='wireguard', subnet='10.0.0.0/16', organization=vpn.organization
+        )
+        ca = vpn.ca
+
+        vpn.backend = self._BACKENDS['wireguard']
+        vpn.subnet = subnet
+        vpn.full_clean()
+        vpn.save()
+        self.assertEqual(vpn.ca, None)
+        self.assertEqual(vpn.cert, None)
+        self.assertEqual(vpn.subnet, subnet)
+        self.assertNotEqual(vpn.ip, None)
+
+        vpn.backend = self._BACKENDS['openvpn']
+        vpn.ca = ca
+        vpn.full_clean()
+        vpn.save()
+        self.assertEqual(vpn.public_key, '')
+        self.assertEqual(vpn.private_key, '')
+        self.assertEqual(vpn.subnet, None)
+        self.assertEqual(vpn.ip, None)
+
+    def test_wireguard_vpn_without_subnet(self):
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_wireguard_vpn(subnet=None)
+        expected_error_dict = {'subnet': ['Subnet is required for this VPN backend.']}
+        self.assertEqual(expected_error_dict, context_manager.exception.message_dict)
+
+    def test_change_vpn_backend_with_vpnclient(self):
+        vpn = self._create_vpn(name='new', backend=self._BACKENDS['openvpn'])
+        subnet = self._create_subnet(
+            name='wireguard', subnet='10.0.0.0/16', organization=vpn.organization
+        )
+        template = self._create_template(name='VPN', type='vpn', vpn=vpn)
+        config = self._create_config(organization=self._get_org())
+        config.templates.add(template)
+        self.assertEqual(VpnClient.objects.count(), 1)
+
+        with self.assertRaises(ValidationError) as context_manager:
+            vpn.backend = self._BACKENDS['wireguard']
+            vpn.subnet = subnet
+            vpn.full_clean()
+            vpn.save()
+        expected_error_dict = {
+            'backend': [
+                'Backend cannot be changed because the VPN is currently in use.'
+            ]
+        }
+        self.assertDictEqual(
+            context_manager.exception.message_dict, expected_error_dict
+        )
+
+
+class TestWireguardTransaction(BaseTestVpn, TestWireguardVpnMixin, TransactionTestCase):
+    def test_auto_peer_configuration(self):
+        self.assertEqual(IpAddress.objects.count(), 0)
+        device, vpn, template = self._create_wireguard_vpn_template()
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 2)
+
+        with self.subTest('caching'):
+            with self.assertNumQueries(0):
+                vpn_config = vpn.get_config()['wireguard'][0]
+
+        self.assertEqual(len(vpn_config.get('peers', [])), 1)
+        self.assertEqual(
+            vpn_config['peers'][0],
+            {
+                'public_key': vpnclient_qs.first().public_key,
+                'allowed_ips': '10.0.0.2/32',
+            },
+        )
+
+        with self.subTest('test VPN related device context'):
+            context = device.get_context()
+            pk = vpn.pk.hex
+            self.assertEqual(context[f'vpn_host_{pk}'], vpn.host)
+            self.assertEqual(context[f'ip_address_{pk}'], '10.0.0.2')
+            self.assertEqual(context[f'server_ip_address_{pk}'], '10.0.0.1')
+            self.assertEqual(context[f'server_ip_network_{pk}'], '10.0.0.1/32')
+            self.assertEqual(context[f'public_key_{pk}'], vpn.public_key)
+
+        with self.subTest('cache update when a new peer is added'):
+            device2 = self._create_device_config(
+                device_opts={
+                    'name': 'test2',
+                    'mac_address': '11:11:22:33:44:55',
+                    'organization': device.organization,
+                }
+            )
+            device2.config.templates.add(template)
+            # cache is invalidated and updated, hence no queries expected
+            with self.assertNumQueries(0):
+                vpn_config = vpn.get_config()['wireguard'][0]
+            self.assertEqual(len(vpn_config.get('peers', [])), 2)
+
+        with self.subTest('cache updated when a new peer is deleted'):
+            device2.delete()
+            # cache is invalidated but not updated
+            # hence we expect queries to be generated
+            with self.assertNumQueries(1):
+                vpn_config = vpn.get_config()['wireguard'][0]
+            self.assertEqual(len(vpn_config.get('peers', [])), 1)
+
+        with self.subTest('other config options not affected by cache'):
+            vpn.config['wireguard'][0]['name'] = 'wg2'
+            vpn.config['wireguard'][0]['port'] = 51821
+            with self.assertNumQueries(0):
+                config = vpn.get_config()
+            self.assertEqual(config['wireguard'][0]['name'], 'wg2')
+            self.assertEqual(config['wireguard'][0]['port'], 51821)
+
+    def test_update_vpn_server_configuration(self):
+        device, vpn, template = self._create_wireguard_vpn_template()
+        vpn_client = device.config.vpnclient_set.first()
+        vpn.save()
+        with self.subTest('Webhook endpoint and authentication endpoint is absent'):
+            with mock.patch('logging.Logger.info') as mocked_logger:
+                post_save.send(
+                    instance=vpn_client, sender=vpn_client._meta.model, created=False
+                )
+                mocked_logger.assert_called_once_with(
+                    f'Cannot update configuration of {vpn.name} VPN server, '
+                    'webhook endpoint and authentication token are empty.'
+                )
+
+        with self.subTest('Webhook endpoint and authentication endpoint is present'):
+            vpn.webhook_endpoint = 'https://example.com'
+            vpn.auth_token = 'super-secret-token'
+            vpn.save()
+            vpn_client.refresh_from_db()
+
+            with mock.patch('logging.Logger.info') as mocked_logger, mock.patch(
+                'requests.post', return_value=HttpResponse()
+            ):
+                post_save.send(
+                    instance=vpn_client, sender=vpn_client._meta.model, created=False
+                )
+                mocked_logger.assert_called_once_with(
+                    f'Triggered update webhook of VPN Server UUID: {vpn.pk}'
+                )
+
+            with mock.patch('logging.Logger.error') as mocked_logger, mock.patch(
+                'requests.post', return_value=HttpResponseNotFound()
+            ):
+                post_save.send(
+                    instance=vpn_client, sender=vpn_client._meta.model, created=False
+                )
+                mocked_logger.assert_called_once_with(
+                    'Failed to update VPN Server configuration. '
+                    f'Response status code: 404, VPN Server UUID: {vpn.pk}'
+                )
+
+    def test_vpn_peers_changed(self):
+        with self.subTest('VpnClient created'):
+            with catch_signal(vpn_peers_changed) as handler:
+                device, vpn, template = self._create_wireguard_vpn_template()
+                handler.assert_called_once()
+
+        with self.subTest('VpnClient deleted'):
+            with catch_signal(vpn_peers_changed) as handler:
+                device.config.templates.remove(template)
+                handler.assert_called_once()
+
+
+class TestVxlan(BaseTestVpn, TestVxlanWireguardVpnMixin, TestCase):
+    def test_vxlan_config_creation(self):
+        tunnel, subnet = self._create_vxlan_tunnel()
+        with self.subTest('vni 1'):
+            d1 = self._create_device()
+            c1 = self._create_config(device=d1)
+            client = VpnClient(vpn=tunnel, config=c1, auto_cert=True)
+            client.full_clean()
+            client.save()
+            client.refresh_from_db()
+            self.assertEqual(client.vni, 1)
+
+        with self.subTest('vni 2'):
+            d2 = self._create_device(name='d2', mac_address='16:DB:7F:E8:50:01')
+            c2 = self._create_config(device=d2)
+            client = VpnClient(vpn=tunnel, config=c2, auto_cert=True)
+            client.full_clean()
+            client.save()
+            client.refresh_from_db()
+            self.assertEqual(client.vni, 2)
+
+        with self.subTest('test context keys'):
+            context_keys = tunnel._get_auto_context_keys()
+            self.assertIn('vni', context_keys)
+
+        with self.subTest('test VPN related device context'):
+            context = d1.get_context()
+            pk = tunnel.pk.hex
+            self.assertEqual(context[f'vpn_host_{pk}'], tunnel.host)
+            self.assertEqual(context[f'ip_address_{pk}'], '10.0.0.2')
+            self.assertEqual(context[f'server_ip_address_{pk}'], '10.0.0.1')
+            self.assertEqual(context[f'server_ip_network_{pk}'], '10.0.0.1/32')
+            self.assertEqual(context[f'vni_{pk}'], '1')
+
+        with self.subTest('auto_cert=False'):
+            d3 = self._create_device(name='d3', mac_address='16:DB:7F:E8:50:03')
+            c3 = self._create_config(device=d3)
+            client = VpnClient(vpn=tunnel, config=c3, auto_cert=False)
+            client.full_clean()
+            client.save()
+            client.refresh_from_db()
+            self.assertEqual(client.vni, None)
+
+    def test_vxlan_vni_conflict(self):
+        tunnel, subnet = self._create_vxlan_tunnel()
+        d1 = self._create_device()
+        c1 = self._create_config(device=d1)
+        client = VpnClient(vpn=tunnel, config=c1, vni=1)
+        client.full_clean()
+        client.save()
+        with self.subTest('vni with same ID should fail'):
+            d2 = self._create_device(name='d2', mac_address='16:DB:7F:E8:50:01')
+            c2 = self._create_config(device=d2)
+            client = VpnClient(vpn=tunnel, config=c2, vni=1)
+            with self.assertRaises(ValidationError) as context_manager:
+                client.full_clean()
+            message_dict = context_manager.exception.message_dict
+            self.assertIn('__all__', message_dict)
+            self.assertEqual(
+                message_dict['__all__'],
+                ['VPN client with this Vpn and Vni already exists.'],
+            )
+
+    def test_vxlan_schema(self):
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_vxlan_tunnel(config={'wireguard': []})
+            self.assertIn(
+                'Invalid configuration triggered by "#/wireguard"',
+                str(context_manager.exception),
+            )
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_vxlan_tunnel(config={})
+            self.assertIn(
+                'Invalid configuration triggered by "#/wireguard"',
+                str(context_manager.exception),
+            )
+
+    def test_auto_client(self):
+        device, vpn, template = self._create_vxlan_vpn_template()
+        auto = vpn.auto_client(template_backend_class=template.backend_class)
+        context_keys = vpn._get_auto_context_keys()
+        for key in context_keys.keys():
+            context_keys[key] = '{{%s}}' % context_keys[key]
+        expected = template.backend_class.vxlan_wireguard_auto_client(
+            host=context_keys['vpn_host'],
+            server=self._vpn_config['wireguard'][0],
+            **context_keys,
+        )
+        self.assertEqual(auto, expected)
+
+
+class TestVxlanTransaction(
+    BaseTestVpn, TestVxlanWireguardVpnMixin, TransactionTestCase
+):
+    def test_auto_peer_configuration(self):
+        self.assertEqual(IpAddress.objects.count(), 0)
+        device, vpn, template = self._create_vxlan_vpn_template()
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 2)
+
+        with self.subTest('caching'):
+            with self.assertNumQueries(0):
+                config = vpn.get_config()
+
+        self.assertEqual(len(config['files']), 1)
+        peers = json.loads(config['files'][0]['contents'])
+        self.assertIsInstance(peers, list)
+        self.assertEqual(len(peers), 1)
+        self.assertEqual(
+            peers[0],
+            {'vni': vpnclient_qs.first().vni, 'remote': '10.0.0.2'},
+        )
+
+        with self.subTest('cache update when a new peer is added'):
+            device2 = self._create_device_config(
+                device_opts={
+                    'name': 'test2',
+                    'mac_address': '11:11:22:33:44:55',
+                    'organization': device.organization,
+                }
+            )
+            device2.config.templates.add(template)
+            # cache is invalidated and updated, hence no queries expected
+            with self.assertNumQueries(0):
+                config = vpn.get_config()
+            peers = json.loads(config['files'][0]['contents'])
+            self.assertEqual(len(peers), 2)
+
+        with self.subTest('cache updated when a new peer is deleted'):
+            device2.delete()
+            # cache is invalidated but not updated
+            # hence we expect queries to be generated
+            with self.assertNumQueries(2):
+                config = vpn.get_config()
+            peers = json.loads(config['files'][0]['contents'])
+            self.assertEqual(len(peers), 1)
+
+        with self.subTest('other config options not affected by cache'):
+            vpn.config['wireguard'][0]['name'] = 'wg2'
+            vpn.config['wireguard'][0]['port'] = 51821
+            with self.assertNumQueries(0):
+                config = vpn.get_config()
+            self.assertEqual(config['wireguard'][0]['name'], 'wg2')
+            self.assertEqual(config['wireguard'][0]['port'], 51821)
diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py
index 3d62b7628..11de3d24f 100644
--- a/openwisp_controller/config/tests/utils.py
+++ b/openwisp_controller/config/tests/utils.py
@@ -40,7 +40,7 @@ def _create_device(self, **kwargs):
     def _create_device_config(self, device_opts=None, config_opts=None):
         device_opts = device_opts or {}
         config_opts = config_opts or {}
-        device_opts['name'] = 'test'
+        device_opts.setdefault('name', 'test')
         d = self._create_device(**device_opts)
         config_opts['device'] = d
         self._create_config(**config_opts)
@@ -80,6 +80,11 @@ def _create_template(self, **kwargs):
 class CreateVpnMixin(object):
     ca_model = Ca
     cert_model = Cert
+    _BACKENDS = {
+        'openvpn': 'openwisp_controller.vpn_backends.OpenVpn',
+        'wireguard': 'openwisp_controller.vpn_backends.Wireguard',
+        'vxlan': 'openwisp_controller.vpn_backends.VxlanWireguard',
+    }
 
     _dh = """-----BEGIN DH PARAMETERS-----
 MIIBCAKCAQEAqzVRdXJ/R4L/sq0bhgCXnFy9M5lOYkux9SIoe8hvrcqNAvJu/V+g
@@ -103,7 +108,8 @@ class CreateVpnMixin(object):
                 'proto': 'udp',
                 'tls_server': True,
             }
-        ]
+        ],
+        'wireguard': [{'name': 'wg0', 'port': 51820}],
     }
 
     @mock.patch(
@@ -114,13 +120,12 @@ def _create_vpn(self, ca_options={}, **kwargs):
         options = dict(
             name='test',
             host='vpn1.test.com',
-            ca=None,
-            backend='openwisp_controller.vpn_backends.OpenVpn',
+            backend=self._BACKENDS['openvpn'],
             config=self._vpn_config,
             dh=self._dh,
         )
         options.update(**kwargs)
-        if not options['ca']:
+        if 'ca' not in options:
             options['ca'] = self._create_ca(**ca_options)
         vpn = Vpn(**options)
         vpn.full_clean()
@@ -128,6 +133,74 @@ def _create_vpn(self, ca_options={}, **kwargs):
         return vpn
 
 
+class TestWireguardVpnMixin:
+    def _create_wireguard_vpn(self, config=None, **kwargs):
+        if config is None:
+            config = {'wireguard': [{'name': 'wg0', 'port': 51820}]}
+        org = kwargs.get('organization', None)
+        if 'subnet' not in kwargs:
+            kwargs['subnet'] = self._create_subnet(
+                name='wireguard test', subnet='10.0.0.0/16', organization=org
+            )
+        vpn_options = {
+            'backend': 'openwisp_controller.vpn_backends.Wireguard',
+            'config': config,
+            'name': 'test',
+            'host': 'vpn1.test.com',
+        }
+        vpn_options.update(kwargs)
+        vpn = Vpn(**vpn_options)
+        vpn.full_clean()
+        vpn.save()
+        return vpn
+
+    def _create_wireguard_vpn_template(self, auto_cert=True):
+        vpn = self._create_wireguard_vpn()
+        org1 = vpn.organization
+        template = self._create_template(
+            name='wireguard',
+            type='vpn',
+            vpn=vpn,
+            organization=org1,
+            auto_cert=auto_cert,
+        )
+        device = self._create_device_config()
+        device.config.templates.add(template)
+        return device, vpn, template
+
+
+class TestVxlanWireguardVpnMixin:
+    def _create_vxlan_tunnel(self, config=None):
+        if config is None:
+            config = {'wireguard': [{'name': 'wg0', 'port': 51820}]}
+        org = self._get_org()
+        subnet = self._create_subnet(
+            name='wireguard test', subnet='10.0.0.0/16', organization=org
+        )
+        tunnel = self._create_vpn(
+            organization=org,
+            backend=self._BACKENDS['vxlan'],
+            config=config,
+            subnet=subnet,
+            ca=None,
+        )
+        return tunnel, subnet
+
+    def _create_vxlan_vpn_template(self):
+        vpn, subnet = self._create_vxlan_tunnel()
+        org1 = vpn.organization
+        template = self._create_template(
+            name='vxlan-wireguard',
+            type='vpn',
+            vpn=vpn,
+            organization=org1,
+            auto_cert=True,
+        )
+        device = self._create_device_config()
+        device.config.templates.add(template)
+        return device, vpn, template
+
+
 class TestVpnX509Mixin(CreateVpnMixin, TestPkiMixin):
     def _create_vpn(self, ca_options={}, **kwargs):
         if 'ca' not in kwargs:
diff --git a/openwisp_controller/subnet_division/tests/__init__.py b/openwisp_controller/subnet_division/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openwisp_controller/subnet_division/tests/helpers.py b/openwisp_controller/subnet_division/tests/helpers.py
new file mode 100644
index 000000000..d52bd5186
--- /dev/null
+++ b/openwisp_controller/subnet_division/tests/helpers.py
@@ -0,0 +1,63 @@
+from openwisp_ipam.tests import CreateModelsMixin as SubnetIpamMixin
+from swapper import load_model
+
+from openwisp_controller.subnet_division.rule_types.device import (
+    DeviceSubnetDivisionRuleType,
+)
+
+from ...config.tests.utils import CreateConfigTemplateMixin, TestWireguardVpnMixin
+from ..rule_types.vpn import VpnSubnetDivisionRuleType
+
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+
+
+class SubnetDivisionTestMixin(
+    CreateConfigTemplateMixin, TestWireguardVpnMixin, SubnetIpamMixin
+):
+    @property
+    def subnet_query(self):
+        return Subnet.objects.exclude(name__contains='Reserved')
+
+    def _create_subnet_division_rule(self, **kwargs):
+        options = dict()
+        options.update(self._get_extra_fields(**kwargs))
+        options.update(kwargs)
+        instance = SubnetDivisionRule(**options)
+        instance.full_clean()
+        instance.save()
+        return instance
+
+    def _get_subnet_division_rule(self, type, **kwargs):
+        options = {
+            'label': 'OW',
+            'size': 28,
+            'number_of_ips': 2,
+            'number_of_subnets': 2,
+            'type': type,
+        }
+        options.update(**kwargs)
+        if 'master_subnet' not in kwargs:
+            options['master_subnet'] = self._get_master_subnet()
+        return self._create_subnet_division_rule(**options)
+
+    def _get_vpn_subdivision_rule(self, **kwargs):
+        path = (
+            f'{VpnSubnetDivisionRuleType.__module__}.'
+            f'{VpnSubnetDivisionRuleType.__name__}'
+        )
+        return self._get_subnet_division_rule(type=path, **kwargs)
+
+    def _get_device_subdivision_rule(self, **kwargs):
+        path = (
+            f'{DeviceSubnetDivisionRuleType.__module__}.'
+            f'{DeviceSubnetDivisionRuleType.__name__}'
+        )
+        return self._get_subnet_division_rule(type=path, **kwargs)
+
+    def _get_master_subnet(self, subnet='10.0.0.0/16', **kwargs):
+        try:
+            return Subnet.objects.get(subnet=subnet, **kwargs)
+        except Subnet.DoesNotExist:
+            return self._create_subnet(subnet=subnet, **kwargs)
diff --git a/openwisp_controller/subnet_division/tests/test_admin.py b/openwisp_controller/subnet_division/tests/test_admin.py
new file mode 100644
index 000000000..90e88a6aa
--- /dev/null
+++ b/openwisp_controller/subnet_division/tests/test_admin.py
@@ -0,0 +1,249 @@
+from unittest.mock import patch
+
+from django.test import TransactionTestCase
+from django.urls import reverse
+from swapper import load_model
+
+from openwisp_users.tests.utils import TestMultitenantAdminMixin
+
+from .helpers import SubnetDivisionTestMixin
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+Device = load_model('config', 'Device')
+
+
+class TestSubnetAdmin(
+    SubnetDivisionTestMixin, TestMultitenantAdminMixin, TransactionTestCase
+):
+    ipam_label = 'openwisp_ipam'
+    config_label = 'config'
+
+    def setUp(self):
+        org = self._get_org()
+        self.master_subnet = self._get_master_subnet()
+        vpn_server = self._create_wireguard_vpn(
+            subnet=self.master_subnet, organization=org
+        )
+        self.template = self._create_template(
+            name='vpn-test', type='vpn', vpn=vpn_server, organization=org
+        )
+        self.config = self._create_config(organization=org)
+        self.rule = self._get_vpn_subdivision_rule(number_of_ips=1, number_of_subnets=1)
+        self.config.templates.add(self.template)
+        admin = self._get_admin()
+        self.client.force_login(admin)
+
+    def test_related_links(self):
+        device_changelist = reverse(f'admin:{self.config_label}_device_changelist')
+        subnet = self.config.subnetdivisionindex_set.first().subnet
+        url = f'{device_changelist}?subnet={subnet.subnet}'
+        with self.subTest('Test changelist view'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_changelist')
+            )
+            self.assertContains(
+                response,
+                f'<a href="{url}">{self.config.device.name}</a>',
+            )
+
+        with self.subTest('Test change view'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_change', args=[subnet.pk])
+            )
+            self.assertContains(
+                response,
+                f'<a href="{url}">{self.config.device.name}</a>',
+            )
+
+    def test_device_filter(self):
+        subnet_changelist = reverse(f'admin:{self.ipam_label}_subnet_changelist')
+        config2 = self._create_config(
+            device=self._create_device(name='device-2', mac_address='00:11:22:33:44:56')
+        )
+        config2.templates.add(self.template)
+        url = f'{subnet_changelist}?device={self.config.device.name}'
+        response = self.client.get(url)
+        self.assertContains(
+            response,
+            self.config.device.name,
+        )
+        self.assertNotContains(response, config2.device.name)
+
+    def test_device_filter_mutitenancy(self):
+        org2 = self._create_org(name='org2')
+        master_subnet2 = self._get_master_subnet(organization=org2)
+        template2 = self._create_template(
+            name='vpn-test',
+            type='vpn',
+            vpn=self._create_wireguard_vpn(subnet=master_subnet2, organization=org2),
+            organization=org2,
+        )
+        self.rule = self._get_vpn_subdivision_rule(
+            number_of_ips=1,
+            number_of_subnets=1,
+            organization=org2,
+            master_subnet=master_subnet2,
+        )
+        config2 = self._create_config(
+            device=self._create_device(name='org2-device', organization=org2)
+        )
+        config2.templates.add(template2)
+
+        org2_user = self._create_org_user(
+            organization=org2, is_admin=True, user=self._create_operator()
+        )
+        org2_user.user.user_permissions.add(*self.get_operator_permissions())
+        self.client.logout()
+        self.client.force_login(org2_user.user)
+
+        response = self.client.get(
+            reverse(f'admin:{self.ipam_label}_subnet_changelist')
+        )
+        self.assertNotContains(response, self.config.device.name)
+        self.assertContains(response, config2.device.name)
+
+    @patch('openwisp_controller.subnet_division.settings.HIDE_GENERATED_SUBNETS', True)
+    def test_hide_generated_subnets(self):
+        with self.subTest('Test SubnetAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_changelist')
+            )
+            self.assertNotContains(response, f'{self.rule.label}_subnet')
+
+        with self.subTest('Test IpAddressAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_ipaddress_changelist')
+            )
+            self.assertNotContains(response, f'{self.rule.label}_subnet')
+
+    def test_not_hide_generated_subnets(self):
+        with self.subTest('Test SubnetAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_changelist')
+            )
+            self.assertContains(response, f'{self.rule.label}_subnet')
+
+        with self.subTest('Test IpAddressAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_ipaddress_changelist')
+            )
+            self.assertContains(response, f'{self.rule.label}_subnet')
+
+
+class TestIPAdmin(
+    SubnetDivisionTestMixin, TestMultitenantAdminMixin, TransactionTestCase
+):
+    ipam_label = 'openwisp_ipam'
+
+    def setUp(self):
+        org = self._get_org()
+        self.master_subnet = self._get_master_subnet()
+        vpn_server = self._create_wireguard_vpn(
+            subnet=self.master_subnet, organization=org
+        )
+        self.template = self._create_template(
+            name='vpn-test', type='vpn', vpn=vpn_server, organization=org
+        )
+        self.config = self._create_config(organization=org)
+        self.rule = self._get_vpn_subdivision_rule(number_of_ips=1, number_of_subnets=1)
+        self.config.templates.add(self.template)
+        admin = self._get_admin()
+        self.client.force_login(admin)
+
+    def test_provisioned_ip_readonly_change_view(self):
+        ip_id = self.rule.subnetdivisionindex_set.filter(ip__isnull=False).first().ip_id
+        response = self.client.get(
+            reverse(f'admin:{self.ipam_label}_ipaddress_change', args=[ip_id])
+        )
+        self.assertNotContains(
+            response, '<select name="subnet" required="" id="id_subnet"'
+        )
+        self.assertNotContains(response, '<input type="text" name="ip_address"')
+
+
+class TestDeviceAdmin(
+    SubnetDivisionTestMixin, TestMultitenantAdminMixin, TransactionTestCase
+):
+    ipam_label = 'openwisp_ipam'
+    config_label = 'config'
+
+    def setUp(self):
+        org = self._get_org()
+        self.master_subnet = self._get_master_subnet()
+        vpn_server = self._create_wireguard_vpn(
+            subnet=self.master_subnet, organization=org
+        )
+        template = self._create_template(
+            name='vpn-test', type='vpn', vpn=vpn_server, organization=org
+        )
+        self.config = self._create_config(organization=org)
+        self.rule = self._get_vpn_subdivision_rule(number_of_ips=1, number_of_subnets=1)
+        self.config.templates.add(template)
+        admin = self._get_admin()
+        self.client.force_login(admin)
+
+    def test_subnet_filter(self):
+        device_changelist = reverse(f'admin:{self.config_label}_device_changelist')
+        device2 = self._create_device(name='device-2', mac_address='00:11:22:33:44:56')
+        subnet = self.config.subnetdivisionindex_set.first().subnet
+        url = (
+            f'{device_changelist}?subnet={subnet.subnet}'
+            '&config__backend__exact=netjsonconfig.OpenWrt'
+        )
+        response = self.client.get(url)
+        self.assertContains(
+            response,
+            self.config.device.name,
+        )
+        self.assertNotContains(response, device2.name)
+
+    def test_subnet_filter_mutitenancy(self):
+        org2 = self._create_org(name='org2')
+        master_subnet2 = self._get_master_subnet(organization=org2)
+        template2 = self._create_template(
+            name='vpn-test',
+            type='vpn',
+            vpn=self._create_wireguard_vpn(subnet=master_subnet2, organization=org2),
+            organization=org2,
+        )
+        self.rule = self._get_vpn_subdivision_rule(
+            number_of_ips=1,
+            number_of_subnets=1,
+            organization=org2,
+            master_subnet=master_subnet2,
+        )
+        config2 = self._create_config(
+            device=self._create_device(name='org2-device', organization=org2)
+        )
+        config2.templates.add(template2)
+
+        org2_owner = self._create_org_user(
+            organization=org2, is_admin=True, user=self._create_operator()
+        )
+        org2_owner.user.user_permissions.add(*self.get_operator_permissions())
+        self.client.logout()
+        self.client.force_login(org2_owner.user)
+
+        response = self.client.get(
+            reverse(f'admin:{self.config_label}_device_changelist')
+        )
+        self.assertNotContains(response, self.config.device.name)
+        self.assertContains(response, config2.device.name)
+
+    def test_delete_device(self):
+        device_response = self.client.post(
+            reverse(
+                f'admin:{self.config_label}_device_delete',
+                args=[self.config.device_id],
+            ),
+            {'post': 'yes'},
+        )
+        self.assertEqual(device_response.status_code, 302)
+        self.assertEqual(Device.objects.count(), 0)
+        self.assertEqual(self.subnet_query.exclude(id=self.master_subnet.id).count(), 0)
+
+        subnet_response = self.client.get(
+            reverse(f'admin:{self.ipam_label}_subnet_changelist')
+        )
+        self.assertEqual(subnet_response.status_code, 200)
+        self.assertContains(subnet_response, self.config.device.name, 1)
diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py
new file mode 100644
index 000000000..285df9afb
--- /dev/null
+++ b/openwisp_controller/subnet_division/tests/test_models.py
@@ -0,0 +1,699 @@
+import uuid
+from unittest import TestCase
+from unittest.mock import patch
+
+from django.core.exceptions import ValidationError
+from django.test import TransactionTestCase
+from django.urls import reverse
+from swapper import load_model
+
+from openwisp_controller.subnet_division.rule_types.vpn import VpnSubnetDivisionRuleType
+from openwisp_utils.tests import catch_signal
+
+from .. import tasks
+from ..signals import subnet_provisioned
+from .helpers import SubnetDivisionTestMixin
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+VpnClient = load_model('config', 'VpnClient')
+Device = load_model('config', 'Device')
+OrganizationConfigSettings = load_model('config', 'OrganizationConfigSettings')
+
+
+class TestSubnetDivisionRule(
+    SubnetDivisionTestMixin,
+    TransactionTestCase,
+):
+    def setUp(self):
+        self.org = self._get_org()
+        self.master_subnet = self._get_master_subnet()
+        self.vpn_server = self._create_wireguard_vpn(
+            subnet=self.master_subnet,
+            organization=self.org,
+        )
+        self.template = self._create_template(
+            name='vpn-test', type='vpn', vpn=self.vpn_server, organization=self.org
+        )
+        self.config = self._create_config(organization=self.org)
+
+    @property
+    def ip_query(self):
+        return IpAddress.objects.exclude(id=self.vpn_server.ip_id)
+
+    def test_field_validations(self):
+        default_options = {
+            'label': 'OW_1',
+            'size': 28,
+            'master_subnet': self.master_subnet,
+            'number_of_ips': 2,
+            'number_of_subnets': 2,
+            'type': (
+                'openwisp_controller.subnet_division.rule_types.'
+                'vpn.VpnSubnetDivisionRuleType'
+            ),
+            'organization': self.org,
+        }
+
+        with self.subTest('Test valid parameters'):
+            options = default_options.copy()
+            rule = SubnetDivisionRule(**options)
+            rule.full_clean()
+            self.assertEqual(str(rule), options['label'])
+
+        with self.subTest('Test label'):
+            options = default_options.copy()
+            options['label'] = 'OW_10.0.0.0/16'
+            rule = SubnetDivisionRule(**options)
+            with self.assertRaises(ValidationError) as error:
+                rule.full_clean()
+            self.assertDictEqual(
+                error.exception.message_dict,
+                {
+                    'label': [
+                        'Only alphanumeric characters and underscores are allowed.'
+                    ]
+                },
+            )
+
+        with self.subTest('Test rule size exceeds size of master_subnet'):
+            options = default_options.copy()
+            options['size'] = 8
+            rule = SubnetDivisionRule(**options)
+            with self.assertRaises(ValidationError) as context_manager:
+                rule.full_clean()
+            expected_message_dict = {
+                'size': ['Master subnet cannot accommodate subnets of size /8']
+            }
+            self.assertDictEqual(
+                context_manager.exception.message_dict, expected_message_dict
+            )
+
+        with self.subTest('Test rule size is illegal'):
+            options = default_options.copy()
+            options['size'] = 33
+            rule = SubnetDivisionRule(**options)
+            with self.assertRaises(ValidationError) as context_manager:
+                rule.full_clean()
+            expected_message_dict = {
+                'size': ['Master subnet cannot accommodate subnets of size /33']
+            }
+            self.assertDictEqual(
+                context_manager.exception.message_dict, expected_message_dict
+            )
+
+        with self.subTest('Test master_subnet cannot accommodate number_of_subnets'):
+            options = default_options.copy()
+            options['number_of_subnets'] = 99999999
+            rule = SubnetDivisionRule(**options)
+            with self.assertRaises(ValidationError) as context_manager:
+                rule.full_clean()
+            expected_message_dict = {
+                'number_of_subnets': [
+                    'Master subnet cannot accommodate 99999999 subnets of size /28'
+                ]
+            }
+            self.assertDictEqual(
+                context_manager.exception.message_dict, expected_message_dict
+            )
+
+        with self.subTest('Test generated subnets cannot accommodate number_of_ips'):
+            options = default_options.copy()
+            options['number_of_ips'] = 99999999
+            rule = SubnetDivisionRule(**options)
+            with self.assertRaises(ValidationError) as context_manager:
+                rule.full_clean()
+            expected_message_dict = {
+                'number_of_ips': [
+                    'Generated subnets of size /28 cannot accommodate 99999999 '
+                    'IP Addresses.'
+                ]
+            }
+            self.assertDictEqual(
+                context_manager.exception.message_dict, expected_message_dict
+            )
+
+        with self.subTest('Test updating existing subnet division rule'):
+            rule = SubnetDivisionRule(**default_options.copy())
+            rule.full_clean()
+            rule.save()
+
+            # Test changing size of provisioned subnets
+            with self.assertRaises(ValidationError) as error:
+                rule.size = 26
+                rule.full_clean()
+            self.assertDictEqual(
+                error.exception.message_dict,
+                {'size': ['Subnet size cannot be changed']},
+            )
+
+            # Test decreasing number_of_ips
+            with self.assertRaises(ValidationError) as error:
+                rule.size = 28
+                rule.number_of_ips = 0
+                rule.full_clean()
+            self.assertDictEqual(
+                error.exception.message_dict,
+                {'number_of_ips': ['Number of IPs cannot be decreased']},
+            )
+
+            # Test changing number of subnets
+            with self.assertRaises(ValidationError) as error:
+                rule.number_of_ips = 2
+                rule.number_of_subnets = 3
+                rule.full_clean()
+            self.assertDictEqual(
+                error.exception.message_dict,
+                {'number_of_subnets': ['Number of Subnets cannot be changed']},
+            )
+
+        with self.subTest('Test multitenancy'):
+            org1 = self._get_org()
+            org2 = self._create_org(name='org2')
+            master_subnet = self._create_subnet(
+                subnet='192.168.0.0/16', organization=org1
+            )
+            with self.assertRaises(ValidationError) as error:
+                rule = self._get_vpn_subdivision_rule(
+                    organization=org2, master_subnet=master_subnet
+                )
+            self.assertDictEqual(
+                error.exception.message_dict,
+                {'organization': ['Organization should be same as the subnet']},
+            )
+
+    def test_provisioned_subnets(self):
+        rule = self._get_vpn_subdivision_rule()
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.assertEqual(subnet_query.count(), 0)
+
+        self.config.templates.add(self.template)
+
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+        self.assertEqual(
+            self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips)
+        )
+
+        # Verify context of config
+        context = self.config.get_subnet_division_context()
+        self.assertIn(f'{rule.label}_prefixlen', context)
+        for subnet_id in range(1, rule.number_of_subnets + 1):
+            self.assertIn(f'{rule.label}_subnet{subnet_id}', context)
+            for ip_id in range(1, rule.number_of_ips + 1):
+                self.assertIn(f'{rule.label}_subnet{subnet_id}_ip{ip_id}', context)
+
+    def test_rule_label_updated(self):
+        new_rule_label = 'TSDR'
+        rule = self._get_vpn_subdivision_rule(label='VPN_OW')
+        self.config.templates.add(self.template)
+        index_queryset = rule.subnetdivisionindex_set.filter(config_id=self.config.id)
+        subnet_queryset = self.subnet_query.filter(
+            id__in=index_queryset.filter(
+                ip__isnull=True, subnet__isnull=False
+            ).values_list('subnet_id', flat=True)
+        )
+        index_count = index_queryset.count()
+        subnet_count = subnet_queryset.count()
+        rule.label = new_rule_label
+        rule.save()
+        rule.refresh_from_db()
+
+        self.assertEqual(rule.label, new_rule_label)
+
+        # Assert keywords of SubnetDivisionIndex are updated
+        new_index_count = index_queryset.filter(
+            keyword__startswith=new_rule_label,
+        ).count()
+        self.assertEqual(index_count, new_index_count)
+
+        # Verify context of config
+        context = self.config.get_subnet_division_context()
+        self.assertIn(f'{rule.label}_prefixlen', context)
+        for subnet_id in range(1, rule.number_of_subnets + 1):
+            self.assertIn(f'{rule.label}_subnet{subnet_id}', context)
+            for ip_id in range(1, rule.number_of_ips + 1):
+                self.assertIn(f'{rule.label}_subnet{subnet_id}_ip{ip_id}', context)
+
+        # Assert name and description of Subnet are updated
+        new_subnet_count = subnet_queryset.filter(
+            name__startswith=new_rule_label,
+            description__contains=new_rule_label,
+        ).count()
+        self.assertEqual(subnet_count, new_subnet_count)
+
+    def test_number_of_ips_updated(self):
+        rule = self._get_vpn_subdivision_rule()
+        self.config.templates.add(self.template)
+        index_queryset = rule.subnetdivisionindex_set.filter(
+            config_id=self.config.id, subnet_id__isnull=False, ip_id__isnull=False
+        )
+
+        # Check if nothing changes
+        rule.save()
+        rule.refresh_from_db()
+        self.assertEqual(
+            index_queryset.count(), rule.number_of_ips * rule.number_of_subnets
+        )
+
+        new_number_of_ips = rule.number_of_ips + 2
+        rule.number_of_ips = new_number_of_ips
+        rule.save()
+        rule.refresh_from_db()
+
+        self.assertEqual(rule.number_of_ips, new_number_of_ips)
+        self.assertEqual(
+            index_queryset.count(), new_number_of_ips * rule.number_of_subnets
+        )
+
+    def test_rule_deleted(self):
+        rule = self._get_vpn_subdivision_rule()
+        self.config.templates.add(self.template)
+        subnet_query = self.subnet_query.exclude(id=self.master_subnet.id).filter(
+            organization=rule.organization
+        )
+        ip_query = self.ip_query
+        index_query = rule.subnetdivisionindex_set
+
+        self.assertEqual(subnet_query.count(), rule.number_of_subnets)
+        self.assertEqual(ip_query.count(), rule.number_of_subnets * rule.number_of_ips)
+        self.assertEqual(
+            index_query.count(),
+            # Keywords for subnets + Keywords for IPs
+            rule.number_of_subnets + (rule.number_of_subnets * rule.number_of_ips),
+        )
+
+        rule.delete()
+
+        self.assertEqual(subnet_query.count(), 0)
+        self.assertEqual(ip_query.count(), 0)
+        self.assertEqual(index_query.count(), 0)
+
+    def test_vpnclient_deleted(self):
+        rule = self._get_vpn_subdivision_rule()
+        self.config.templates.add(self.template)
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+
+        self.config.templates.add(self.template)
+
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+        # 1 IP is automatically assigned to VPN server and client each,
+        # hence add two in below assertion
+        self.assertEqual(
+            self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips)
+        )
+
+        self.config.templates.remove(self.template)
+        self.assertEqual(
+            subnet_query.count(),
+            0,
+        )
+        self.assertEqual(self.ip_query.count(), 0)
+
+    @patch('logging.Logger.error')
+    def test_subnets_exhausted(self, mocked_logger):
+        subnet = self._get_master_subnet(
+            '10.0.0.0/28', master_subnet=self.master_subnet
+        )
+        self._get_vpn_subdivision_rule(
+            master_subnet=subnet,
+            size=29,
+        )
+        self.vpn_server.subnet = subnet
+        self.vpn_server.save()
+        self.config.templates.add(self.template)
+
+        config2 = self._create_config(
+            device=self._create_device(mac_address='00:11:22:33:44:66')
+        )
+        config2.templates.add(self.template)
+        mocked_logger.assert_called_with(f'Cannot create more subnets of {subnet}')
+
+    def test_vpn_subnet_none(self):
+        self.vpn_server.subnet = None
+        self.vpn_server.save()
+
+        rule = self._get_vpn_subdivision_rule()
+
+        self.assertEqual(rule.subnetdivisionindex_set.count(), 0)
+        self.assertEqual(self.subnet_query.exclude(id=self.master_subnet.id).count(), 0)
+        self.assertEqual(self.ip_query.count(), 0)
+
+    def test_vpn_subnet_no_rule(self):
+        # Tests the scenario where a SubDivisionRule does not exist
+        # for subnet of the VPN Server.
+        self.config.templates.add(self.template)
+
+    def test_subnet_already_provisioned(self):
+        rule = self._get_vpn_subdivision_rule()
+        index_count = (
+            rule.number_of_subnets + rule.number_of_subnets * rule.number_of_ips
+        )
+        self.config.templates.add(self.template)
+        self.assertEqual(
+            self.config.subnetdivisionindex_set.count(),
+            index_count,
+        )
+        vpn_client = VpnClient.objects.first()
+        vpn_client.save()
+        self.assertEqual(
+            self.config.subnetdivisionindex_set.count(),
+            index_count,
+        )
+
+    def test_shareable_vpn_vpnclient_subnet(self):
+        self.vpn_server.organization = None
+        self.vpn_server.save()
+
+        self.template.organization = None
+        self.template.save()
+
+        self.master_subnet.organization = None
+        self.master_subnet.save()
+
+        rule = self._get_vpn_subdivision_rule()
+        self.config.templates.add(self.template)
+
+        # Following query asserts that subnet created belongs to the
+        # organization of the related config.device
+        subnet_query = self.subnet_query.filter(
+            organization_id=self.org.id, master_subnet_id=self.master_subnet.id
+        ).exclude(id=self.master_subnet.id)
+
+        self.assertEqual(subnet_query.count(), rule.number_of_subnets)
+
+    def test_sharable_vpn_vpnclient_subnet_multiple_rules(self):
+        self.master_subnet.organization = None
+        self.master_subnet.save()
+
+        vpn_server = self._create_wireguard_vpn(
+            name='vpn-server', subnet=self.master_subnet
+        )
+
+        template = self._create_template(name='vpn-client', type='vpn', vpn=vpn_server)
+
+        org1 = self._create_org(name='org1')
+        org2 = self._create_org(name='org2')
+
+        rule1 = self._get_vpn_subdivision_rule(organization=org1)
+        rule2 = self._get_vpn_subdivision_rule(organization=org2)
+
+        config1 = self._create_config(organization=org1)
+        config2 = self._create_config(organization=org2)
+
+        config1.templates.add(template)
+        config2.templates.add(template)
+
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            rule1.number_of_subnets + rule2.number_of_subnets,
+        )
+        config1_subnets = config1.subnetdivisionindex_set.filter(
+            ip__isnull=True, subnet__isnull=False
+        ).values_list('subnet__subnet', flat=True)
+        config2_subnets = config2.subnetdivisionindex_set.filter(
+            ip__isnull=True, subnet__isnull=False
+        ).values_list('subnet__subnet', flat=True)
+        self.assertNotIn(config1_subnets.first(), config2_subnets)
+        self.assertNotIn(config1_subnets.last(), config2_subnets)
+
+    def test_device_deleted(self):
+        rule = self._get_vpn_subdivision_rule()
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.config.templates.add(self.template)
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+
+        self.config.device.delete()
+        self.assertEqual(
+            subnet_query.count(),
+            0,
+        )
+
+    def test_reserved_subnet(self):
+        # An IP is already provisioned
+        ip = self.master_subnet.request_ip()
+
+        rule = self._get_vpn_subdivision_rule()
+        subnet_query = Subnet.objects.exclude(id=self.master_subnet.id)
+        self.config.templates.add(self.template)
+
+        # Check reserved subnet is created
+        self.assertEqual(
+            subnet_query.filter(
+                name__contains='Reserved Subnet', subnet=f'10.0.0.0/{rule.size}'
+            ).count(),
+            1,
+        )
+        # Check 10.0.0.1 is not provisioned again
+        self.assertEqual(SubnetDivisionIndex.objects.filter(ip_id=ip.id).count(), 0)
+
+    def test_device_subnet_division_rule(self):
+        self.config.delete()
+        rule = self._get_device_subdivision_rule()
+        OrganizationConfigSettings.objects.create(
+            organization=self.org, shared_secret='shared_secret'
+        )
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.assertEqual(subnet_query.count(), 0)
+
+        # Register device
+        options = {
+            'hardware_id': '1234',
+            'secret': 'shared_secret',
+            'name': 'FF:FF:FF:FF:FF:FF',
+            'mac_address': 'FF:FF:FF:FF:FF:FF',
+            'backend': 'netjsonconfig.OpenWrt',
+        }
+        response = self.client.post(reverse('controller:device_register'), options)
+        lines = response.content.decode().split('\n')
+        self.assertEqual(lines[0], 'registration-result: success')
+
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+        self.assertEqual(
+            self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips)
+        )
+
+        # Verify context of config
+        device = Device.objects.get(mac_address='FF:FF:FF:FF:FF:FF')
+        context = device.config.get_subnet_division_context()
+        self.assertIn(f'{rule.label}_prefixlen', context)
+        for subnet_id in range(1, rule.number_of_subnets + 1):
+            self.assertIn(f'{rule.label}_subnet{subnet_id}', context)
+            for ip_id in range(1, rule.number_of_ips + 1):
+                self.assertIn(f'{rule.label}_subnet{subnet_id}_ip{ip_id}', context)
+
+        # Verify working of delete handler
+        device.delete()
+        self.assertEqual(
+            subnet_query.count(),
+            0,
+        )
+        self.assertEqual(self.ip_query.count(), 0)
+
+    def test_device_subnet_division_rule_existing_devices(self):
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.assertEqual(subnet_query.count(), 0)
+        rule = self._get_device_subdivision_rule()
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+        self.assertEqual(
+            self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips)
+        )
+
+    def test_vpn_subnet_division_rule_existing_devices(self):
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.config.templates.add(self.template)
+        self.assertEqual(subnet_query.count(), 0)
+        rule = self._get_vpn_subdivision_rule()
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+        self.assertEqual(
+            self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips)
+        )
+
+    def test_vpn_subnet_division_rule_existing_devices_different_orgs(self):
+        self.master_subnet.organization = None
+        self.master_subnet.save()
+        vpn_server = self._create_wireguard_vpn(
+            name='vpn-server', subnet=self.master_subnet
+        )
+        template = self._create_template(name='vpn-client', type='vpn', vpn=vpn_server)
+        org1 = self._create_org(name='org1')
+        org2 = self._create_org(name='org2')
+        config1 = self._create_config(organization=org1)
+        config2 = self._create_config(organization=org2)
+
+        config1.templates.add(template)
+        config2.templates.add(template)
+
+        rule1 = self._get_vpn_subdivision_rule(organization=org1)
+        rule2 = self._get_vpn_subdivision_rule(organization=org2)
+
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            rule1.number_of_subnets + rule2.number_of_subnets,
+        )
+        config1_subnets = config1.subnetdivisionindex_set.filter(
+            ip__isnull=True, subnet__isnull=False
+        ).values_list('subnet__subnet', flat=True)
+        config2_subnets = config2.subnetdivisionindex_set.filter(
+            ip__isnull=True, subnet__isnull=False
+        ).values_list('subnet__subnet', flat=True)
+        self.assertNotIn(config1_subnets.first(), config2_subnets)
+        self.assertNotIn(config1_subnets.last(), config2_subnets)
+
+    def test_backend_class_property(self):
+        rule = self._get_vpn_subdivision_rule()
+        self.assertEqual(rule.rule_class, VpnSubnetDivisionRuleType)
+
+    def test_subnet_ips_provisioned_signal(self):
+        rule = self._get_vpn_subdivision_rule()
+        with catch_signal(subnet_provisioned) as handler:
+            self.config.templates.add(self.template)
+            handler.assert_called_once()
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+
+    def test_vpn_rule_assigns_vpnclient_ip(self):
+        rule = self._get_vpn_subdivision_rule()
+        subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
+            id=self.master_subnet.id
+        )
+        self.config.templates.add(self.template)
+        expected_assigned_ip = self.config.subnetdivisionindex_set.get(
+            keyword=f'{rule.label}_subnet1_ip1'
+        ).ip
+        vpn_client = self.config.vpnclient_set.first()
+        self.assertEqual(vpn_client.ip, expected_assigned_ip)
+        self.assertEqual(
+            subnet_query.count(),
+            rule.number_of_subnets,
+        )
+
+    def test_subnet_division_index_validation(self):
+        rule = self._get_vpn_subdivision_rule()
+        index = SubnetDivisionIndex(rule=rule, keyword='test')
+        subnet = Subnet.objects.create(subnet='10.0.0.0/16')
+        ip = IpAddress.objects.create(subnet=subnet, ip_address='10.0.0.1')
+
+        with self.subTest('ip, subnet and config are missing'):
+            index.full_clean()
+
+        with self.subTest('subnet and config are missing'):
+            index.ip = ip
+            index.full_clean()
+
+        with self.subTest('config is missing'):
+            index.subnet = subnet
+            index.full_clean()
+
+        with self.subTest('All related fields are present'):
+            index.config = self.config
+            index.full_clean()
+
+    def test_single_subnet_multiple_vpn_rule(self):
+        rule1 = self._get_vpn_subdivision_rule()
+        rule2 = self._get_vpn_subdivision_rule(label='VPN')
+        self.config.templates.add(self.template)
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            rule1.number_of_subnets + rule2.number_of_subnets,
+        )
+        rule2.delete()
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            rule1.number_of_subnets,
+        )
+
+    def test_single_subnet_multiple_device_rule(self):
+        rule1 = self._get_device_subdivision_rule(label='LAN1')
+        rule2 = self._get_device_subdivision_rule(label='LAN2')
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            rule1.number_of_subnets + rule2.number_of_subnets,
+        )
+        rule2.delete()
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            rule1.number_of_subnets,
+        )
+
+    def test_single_subnet_vpn_device_rule(self):
+        device_rule = self._get_device_subdivision_rule(label='LAN')
+        vpn_rule = self._get_vpn_subdivision_rule(label='VPN')
+        self.config.templates.add(self.template)
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            device_rule.number_of_subnets + vpn_rule.number_of_subnets,
+        )
+        self.config.templates.remove(self.template)
+        self.assertEqual(
+            self.subnet_query.exclude(id=self.master_subnet.id).count(),
+            device_rule.number_of_subnets,
+        )
+
+
+class TestCeleryTasks(TestCase):
+    def test_subnet_division_rule_does_not_exist(self):
+        id = uuid.uuid4()
+        log_message = (
+            'Failed to {action} Subnet Division Rule with id: '
+            f'"{id}", reason: SubnetDivisionRule matching query does not exist.'
+        )
+        with patch('logging.Logger.warning') as mocked_logger:
+            tasks.update_subnet_division_index.run(id)
+            mocked_logger.assert_called_once_with(
+                log_message.format(action='update indexes for')
+            )
+
+        with patch('logging.Logger.warning') as mocked_logger:
+            tasks.update_subnet_name_description.run(id)
+            mocked_logger.assert_called_once_with(
+                log_message.format(action='update subnets related to')
+            )
+
+        with patch('logging.Logger.warning') as mocked_logger:
+            tasks.provision_extra_ips.run(id, old_number_of_ips=0)
+            mocked_logger.assert_called_once_with(
+                log_message.format(action='provision extra IPs for')
+            )
+
+        with patch('logging.Logger.warning') as mocked_logger:
+            tasks.provision_subnet_ip_for_existing_devices.run(id)
+            mocked_logger.assert_called_once_with(
+                log_message.format(action='provision IPs on existing devices for')
+            )
diff --git a/openwisp_controller/subnet_division/tests/test_rule.py b/openwisp_controller/subnet_division/tests/test_rule.py
new file mode 100644
index 000000000..aab091368
--- /dev/null
+++ b/openwisp_controller/subnet_division/tests/test_rule.py
@@ -0,0 +1,13 @@
+from django.test import TestCase
+
+from ..rule_types.base import BaseSubnetDivisionRuleType
+
+
+class TestBaseSubnetDivisionRuleType(TestCase):
+    def test_should_create_subnets_ips(self):
+        with self.assertRaises(NotImplementedError):
+            BaseSubnetDivisionRuleType.should_create_subnets_ips(instance=None)
+
+    def test_provision_for_existing_objects(self):
+        with self.assertRaises(NotImplementedError):
+            BaseSubnetDivisionRuleType.provision_for_existing_objects(rule_obj=None)
diff --git a/tests/openwisp2/sample_config/migrations/0001_initial.py b/tests/openwisp2/sample_config/migrations/0001_initial.py
index a28e264b7..ae2da58c5 100644
--- a/tests/openwisp2/sample_config/migrations/0001_initial.py
+++ b/tests/openwisp2/sample_config/migrations/0001_initial.py
@@ -18,6 +18,7 @@
 import openwisp_utils.base
 import openwisp_utils.utils
 from openwisp_controller.config import settings as app_settings
+from openwisp_controller.config.base.template import default_auto_cert
 
 
 class Migration(migrations.Migration):
@@ -69,8 +70,8 @@ class Migration(migrations.Migration):
                             ('netjsonconfig.OpenWisp', 'OpenWISP Firmware 1.x'),
                         ],
                         help_text=(
-                            'Select <a href="http://netjsonconfig.openwisp.org'
-                            '/en/stable/" target="_blank">netjsonconfig</a> backend'
+                            'Select <a href="http://netjsonconfig.openwisp.org/en/'
+                            'stable/" target="_blank">netjsonconfig</a> backend'
                         ),
                         max_length=128,
                         verbose_name='backend',
@@ -97,10 +98,11 @@ class Migration(migrations.Migration):
                         ],
                         default='modified',
                         help_text=(
-                            '"modified" means the configuration is not applied yet; '
-                            '\n"applied" means the configuration is applied '
-                            'successfully; \n"error" means the configuration '
-                            'caused issues and it was rolled back;'
+                            '"modified" means the configuration is not applied yet; \n'
+                            '"applied" means the configuration is applied successfully;'
+                            ' \n'
+                            '"error" means the configuration caused issues '
+                            'and it was rolled back;'
                         ),
                         max_length=100,
                         no_check_for_status=True,
@@ -258,7 +260,12 @@ class Migration(migrations.Migration):
                     'backend',
                     models.CharField(
                         choices=[
-                            ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN')
+                            ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),
+                            ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'),
+                            (
+                                'openwisp_controller.vpn_backends.VxlanWireguard',
+                                'VXLAN over WireGuard',
+                            ),
                         ],
                         help_text='Select VPN configuration backend',
                         max_length=128,
@@ -267,12 +274,16 @@ class Migration(migrations.Migration):
                 ),
                 ('notes', models.TextField(blank=True)),
                 ('dh', models.TextField(blank=True)),
+                ('public_key', models.CharField(blank=True, max_length=44)),
+                ('private_key', models.CharField(blank=True, max_length=44)),
                 ('details', models.CharField(blank=True, max_length=64, null=True)),
                 (
                     'ca',
                     models.ForeignKey(
+                        blank=True,
+                        null=True,
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='sample_pki.Ca',
+                        to='sample_pki.ca',
                         verbose_name='Certification Authority',
                     ),
                 ),
@@ -283,10 +294,24 @@ class Migration(migrations.Migration):
                         help_text='leave blank to create automatically',
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='sample_pki.Cert',
+                        to='sample_pki.cert',
                         verbose_name='x509 Certificate',
                     ),
                 ),
+                (
+                    'ip',
+                    models.ForeignKey(
+                        blank=True,
+                        help_text=(
+                            'Internal IP address of the VPN '
+                            'server interface, if applicable'
+                        ),
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+                        verbose_name='Internal IP',
+                    ),
+                ),
                 (
                     'organization',
                     models.ForeignKey(
@@ -297,6 +322,44 @@ class Migration(migrations.Migration):
                         verbose_name='organization',
                     ),
                 ),
+                (
+                    'subnet',
+                    models.ForeignKey(
+                        blank=True,
+                        help_text=(
+                            'Subnet IP addresses used by VPN clients, if applicable'
+                        ),
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                        verbose_name='Subnet',
+                    ),
+                ),
+                (
+                    'auth_token',
+                    models.CharField(
+                        blank=True,
+                        help_text=(
+                            'Authentication token for triggering "Webhook Endpoint"'
+                        ),
+                        max_length=128,
+                        null=True,
+                        verbose_name='Webhook AuthToken',
+                    ),
+                ),
+                (
+                    'webhook_endpoint',
+                    models.CharField(
+                        blank=True,
+                        help_text=(
+                            'Webhook to trigger for updating server configuration '
+                            '(e.g. https://openwisp2.mydomain.com:8081/trigger-update)'
+                        ),
+                        max_length=128,
+                        null=True,
+                        verbose_name='Webhook Endpoint',
+                    ),
+                ),
             ],
             options={
                 'verbose_name': 'VPN server',
@@ -318,6 +381,26 @@ class Migration(migrations.Migration):
                     ),
                 ),
                 ('auto_cert', models.BooleanField(default=False)),
+                (
+                    'public_key',
+                    models.CharField(blank=True, max_length=44),
+                ),
+                (
+                    'private_key',
+                    models.CharField(blank=True, max_length=44),
+                ),
+                (
+                    'vni',
+                    models.PositiveIntegerField(
+                        blank=True,
+                        db_index=True,
+                        null=True,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(16777216),
+                        ],
+                    ),
+                ),
                 ('details', models.CharField(blank=True, max_length=64, null=True)),
                 (
                     'cert',
@@ -325,21 +408,30 @@ class Migration(migrations.Migration):
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='sample_pki.Cert',
+                        to='sample_pki.cert',
                     ),
                 ),
                 (
                     'config',
                     models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='sample_config.Config',
+                        to='sample_config.config',
+                    ),
+                ),
+                (
+                    'ip',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to='openwisp_ipam.ipaddress',
                     ),
                 ),
                 (
                     'vpn',
                     models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='sample_config.Vpn',
+                        to='sample_config.vpn',
                     ),
                 ),
             ],
@@ -347,7 +439,7 @@ class Migration(migrations.Migration):
                 'verbose_name': 'VPN client',
                 'verbose_name_plural': 'VPN clients',
                 'abstract': False,
-                'unique_together': {('config', 'vpn')},
+                'unique_together': {('vpn', 'vni'), ('config', 'vpn')},
             },
         ),
         migrations.CreateModel(
@@ -387,8 +479,8 @@ class Migration(migrations.Migration):
                             ('netjsonconfig.OpenWisp', 'OpenWISP Firmware 1.x'),
                         ],
                         help_text=(
-                            'Select <a href="http://netjsonconfig.openwisp.org'
-                            '/en/stable/" target="_blank">netjsonconfig</a> backend'
+                            'Select <a href="http://netjsonconfig.openwisp.org/en/'
+                            'stable/" target="_blank">netjsonconfig</a> backend'
                         ),
                         max_length=128,
                         verbose_name='backend',
@@ -424,8 +516,8 @@ class Migration(migrations.Migration):
                         db_index=True,
                         default=False,
                         help_text=(
-                            'whether new configurations will have '
-                            'this template enabled by default'
+                            'whether new configurations will have this '
+                            'template enabled by default'
                         ),
                         verbose_name='enabled by default',
                     ),
@@ -448,15 +540,15 @@ class Migration(migrations.Migration):
                     'auto_cert',
                     models.BooleanField(
                         db_index=True,
-                        default=(
-                            openwisp_controller.config.base.template.default_auto_cert
-                        ),
+                        default=default_auto_cert,
                         help_text=(
-                            'whether x509 client certificates should be automatically '
-                            'managed behind the scenes for each configuration using '
-                            'this template, valid only for the VPN type'
+                            'whether tunnel specific configuration (cryptographic '
+                            'keys, ip addresses, etc) should be automatically '
+                            'generated and managed behind the scenes for each '
+                            'configuration using this template, valid only for '
+                            'the VPN type'
                         ),
-                        verbose_name='auto certificate',
+                        verbose_name='automatic tunnel provisioning',
                     ),
                 ),
                 (
@@ -466,9 +558,9 @@ class Migration(migrations.Migration):
                         default=dict,
                         dump_kwargs={'ensure_ascii': False, 'indent': 4},
                         help_text=(
-                            'A dictionary containing the default values for '
-                            'the variables used by this template; these default '
-                            'variables will be used during schema validation.'
+                            'A dictionary containing the default values for the '
+                            'variables used by this template; these default variables '
+                            'will be used during schema validation.'
                         ),
                         load_kwargs={'object_pairs_hook': collections.OrderedDict},
                         verbose_name='Default Values',
@@ -490,9 +582,9 @@ class Migration(migrations.Migration):
                     taggit.managers.TaggableManager(
                         blank=True,
                         help_text=(
-                            'A comma-separated list of template tags, may '
-                            'be used to ease auto configuration with specific '
-                            'settings (eg: 4G, mesh, WDS, VPN, ecc.)'
+                            'A comma-separated list of template tags, may be used '
+                            'to ease auto configuration with specific settings '
+                            '(eg: 4G, mesh, WDS, VPN, ecc.)'
                         ),
                         through='sample_config.TaggedTemplate',
                         to='sample_config.TemplateTag',
@@ -505,7 +597,7 @@ class Migration(migrations.Migration):
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='sample_config.Vpn',
+                        to='sample_config.vpn',
                         verbose_name='VPN',
                     ),
                 ),
@@ -544,8 +636,8 @@ class Migration(migrations.Migration):
                     models.BooleanField(
                         default=True,
                         help_text=(
-                            'Whether automatic registration of devices '
-                            'is enabled or not'
+                            'Whether automatic registration of '
+                            'devices is enabled or not'
                         ),
                         verbose_name='auto-registration enabled',
                     ),
@@ -836,7 +928,7 @@ class Migration(migrations.Migration):
             model_name='config',
             name='device',
             field=models.OneToOneField(
-                on_delete=django.db.models.deletion.CASCADE, to='sample_config.Device'
+                on_delete=django.db.models.deletion.CASCADE, to='sample_config.device'
             ),
         ),
         migrations.AddField(
diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py
index 6a02c3e1b..b37685870 100644
--- a/tests/openwisp2/sample_config/tests.py
+++ b/tests/openwisp2/sample_config/tests.py
@@ -30,6 +30,8 @@
 from openwisp_controller.config.tests.test_vpn import (
     TestVpnTransaction as BaseTestVpnTransaction,
 )
+from openwisp_controller.config.tests.test_vpn import TestVxlan as BaseTestVxlan
+from openwisp_controller.config.tests.test_vpn import TestWireguard as BaseTestWireguard
 
 
 class TestAdmin(BaseTestAdmin):
@@ -96,6 +98,14 @@ class TestConfigApi(BaseTestConfigApi):
     pass
 
 
+class TestWireguard(BaseTestWireguard):
+    pass
+
+
+class TestVxlan(BaseTestVxlan):
+    pass
+
+
 del BaseTestAdmin
 del BaseTestDeviceGroupAdmin
 del BaseTestConfig
@@ -112,3 +122,5 @@ class TestConfigApi(BaseTestConfigApi):
 del BaseTestVpnTransaction
 del BaseTestApps
 del BaseTestConfigApi
+del BaseTestWireguard
+del BaseTestVxlan
diff --git a/tests/openwisp2/sample_subnet_division/__init__.py b/tests/openwisp2/sample_subnet_division/__init__.py
new file mode 100644
index 000000000..8a76ae9cd
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/__init__.py
@@ -0,0 +1,1 @@
+default_app_config = 'openwisp2.sample_subnet_division.apps.SampleSubnetDivisionConfig'
diff --git a/tests/openwisp2/sample_subnet_division/admin.py b/tests/openwisp2/sample_subnet_division/admin.py
new file mode 100644
index 000000000..d5daab1bb
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/admin.py
@@ -0,0 +1,3 @@
+from openwisp_controller.subnet_division.admin import (  # noqa
+    SubnetDivisionRuleInlineAdmin,
+)
diff --git a/tests/openwisp2/sample_subnet_division/apps.py b/tests/openwisp2/sample_subnet_division/apps.py
new file mode 100644
index 000000000..1e25c8c77
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/apps.py
@@ -0,0 +1,6 @@
+from openwisp_controller.subnet_division.apps import SubnetDivisionConfig
+
+
+class SampleSubnetDivisionConfig(SubnetDivisionConfig):
+    name = 'openwisp2.sample_subnet_division'
+    label = 'sample_subnet_division'
diff --git a/tests/openwisp2/sample_subnet_division/migrations/0001_initial.py b/tests/openwisp2/sample_subnet_division/migrations/0001_initial.py
new file mode 100644
index 000000000..196643164
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/migrations/0001_initial.py
@@ -0,0 +1,191 @@
+# Generated by Django 3.1.7 on 2021-03-15 14:55
+
+import uuid
+
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+from django.conf import settings
+from django.db import migrations, models
+
+import openwisp_users.mixins
+from openwisp_controller.subnet_division import settings as app_settings
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL),
+        ('sample_config', '0003_name_unique_per_organization'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SubnetDivisionRule',
+            fields=[
+                ('details', models.CharField(blank=True, max_length=64, null=True)),
+                (
+                    'id',
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                (
+                    'created',
+                    model_utils.fields.AutoCreatedField(
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name='created',
+                    ),
+                ),
+                (
+                    'modified',
+                    model_utils.fields.AutoLastModifiedField(
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name='modified',
+                    ),
+                ),
+                (
+                    'type',
+                    models.CharField(
+                        choices=app_settings.SUBNET_DIVISION_TYPES, max_length=200
+                    ),
+                ),
+                (
+                    'master_subnet',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                    ),
+                ),
+                (
+                    'label',
+                    models.CharField(
+                        help_text='Label used to calculate the configuration variables',
+                        max_length=30,
+                    ),
+                ),
+                (
+                    'number_of_subnets',
+                    models.PositiveIntegerField(
+                        help_text='Indicates how many subnets will be created',
+                        verbose_name='Number of Subnets',
+                    ),
+                ),
+                (
+                    'size',
+                    models.PositiveIntegerField(
+                        help_text='Indicates the size of each created subnet',
+                        verbose_name='Size of subnets',
+                    ),
+                ),
+                (
+                    'number_of_ips',
+                    models.PositiveIntegerField(
+                        help_text=(
+                            'Indicates how many IP addresses will '
+                            'be created for each subnet'
+                        ),
+                        verbose_name='Number of IPs',
+                    ),
+                ),
+                (
+                    'organization',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_USERS_ORGANIZATION_MODEL,
+                        verbose_name='organization',
+                    ),
+                ),
+            ],
+            options={'abstract': False},
+            bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model),
+        ),
+        migrations.CreateModel(
+            name='SubnetDivisionIndex',
+            fields=[
+                ('details', models.CharField(blank=True, max_length=64, null=True)),
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                ('keyword', models.CharField(max_length=30)),
+                (
+                    'config',
+                    models.ForeignKey(
+                        null=True,
+                        blank=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='sample_config.config',
+                    ),
+                ),
+                (
+                    'ip',
+                    models.ForeignKey(
+                        null=True,
+                        blank=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+                    ),
+                ),
+                (
+                    'rule',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to='sample_subnet_division.subnetdivisionrule',
+                    ),
+                ),
+                (
+                    'subnet',
+                    models.ForeignKey(
+                        null=True,
+                        blank=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                    ),
+                ),
+            ],
+            options={'abstract': False},
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionrule',
+            constraint=models.UniqueConstraint(
+                fields=('organization', 'label', 'type', 'master_subnet'),
+                name='unique_subnet_division_rule',
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='subnetdivisionindex',
+            index=models.Index(
+                fields=['keyword'], name='sample_subn_keyword_3e0783_idx'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionrule',
+            constraint=models.UniqueConstraint(
+                fields=('organization', 'label'),
+                name='unique_subnet_division_rule_label',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionindex',
+            constraint=models.UniqueConstraint(
+                fields=('keyword', 'subnet', 'ip', 'config'),
+                name='unique_subnet_division_index',
+            ),
+        ),
+    ]
diff --git a/tests/openwisp2/sample_subnet_division/migrations/0002_default_group_permissions.py b/tests/openwisp2/sample_subnet_division/migrations/0002_default_group_permissions.py
new file mode 100644
index 000000000..680fd1d63
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/migrations/0002_default_group_permissions.py
@@ -0,0 +1,13 @@
+from django.db import migrations
+
+from openwisp_controller.subnet_division.migrations import assign_permissions_to_groups
+
+
+class Migration(migrations.Migration):
+    dependencies = [('sample_subnet_division', '0001_initial')]
+
+    operations = [
+        migrations.RunPython(
+            assign_permissions_to_groups, reverse_code=migrations.RunPython.noop
+        )
+    ]
diff --git a/tests/openwisp2/sample_subnet_division/migrations/__init__.py b/tests/openwisp2/sample_subnet_division/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/openwisp2/sample_subnet_division/models.py b/tests/openwisp2/sample_subnet_division/models.py
new file mode 100644
index 000000000..f88032725
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/models.py
@@ -0,0 +1,23 @@
+from django.db import models
+
+from openwisp_controller.subnet_division.base.models import (
+    AbstractSubnetDivisionIndex,
+    AbstractSubnetDivisionRule,
+)
+
+
+class DetailsModel(models.Model):
+    details = models.CharField(max_length=64, blank=True, null=True)
+
+    class Meta:
+        abstract = True
+
+
+class SubnetDivisionRule(DetailsModel, AbstractSubnetDivisionRule):
+    class Meta(AbstractSubnetDivisionRule.Meta):
+        abstract = False
+
+
+class SubnetDivisionIndex(DetailsModel, AbstractSubnetDivisionIndex):
+    class Meta(AbstractSubnetDivisionIndex.Meta):
+        abstract = False
diff --git a/tests/openwisp2/sample_subnet_division/tests.py b/tests/openwisp2/sample_subnet_division/tests.py
new file mode 100644
index 000000000..604f54406
--- /dev/null
+++ b/tests/openwisp2/sample_subnet_division/tests.py
@@ -0,0 +1,26 @@
+from openwisp_controller.subnet_division.tests.test_admin import (
+    TestDeviceAdmin as BaseTestDeviceAdmin,
+)
+from openwisp_controller.subnet_division.tests.test_admin import (
+    TestSubnetAdmin as BaseTestSubnetAdmin,
+)
+from openwisp_controller.subnet_division.tests.test_models import (
+    TestSubnetDivisionRule as BaseTestSubnetDivisionRule,
+)
+
+
+class TestDeviceAdmin(BaseTestDeviceAdmin):
+    config_label = 'sample_config'
+
+
+class TestSubnetAdmin(BaseTestSubnetAdmin):
+    config_label = 'sample_config'
+
+
+class TestSubnetDivsionRule(BaseTestSubnetDivisionRule):
+    pass
+
+
+del BaseTestDeviceAdmin
+del BaseTestSubnetAdmin
+del BaseTestSubnetDivisionRule
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index de870750d..d1b9eef65 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -38,12 +38,15 @@
     'openwisp_controller.pki',
     'openwisp_controller.geo',
     'openwisp_controller.connection',
+    'openwisp_controller.subnet_division',
     'openwisp_users',
     'openwisp_users.accounts',
     'openwisp_notifications',
+    'openwisp_ipam',
     # openwisp2 admin theme
     # (must be loaded here)
     'openwisp_utils.admin_theme',
+    # admin
     'django.contrib.admin',
     'django.forms',
     # other dependencies
@@ -152,6 +155,17 @@
 # during development only
 EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
 
+if not TESTING:
+    CACHES = {
+        'default': {
+            'BACKEND': 'django_redis.cache.RedisCache',
+            'LOCATION': 'redis://127.0.0.1:6379/6',
+            'OPTIONS': {
+                'CLIENT_CLASS': 'django_redis.client.DefaultClient',
+            },
+        }
+    }
+
 if not TESTING:
     CELERY_BROKER_URL = os.getenv('REDIS_URL', 'redis://localhost/1')
 else:
@@ -211,6 +225,10 @@
     users_index = INSTALLED_APPS.index('openwisp_users')
     INSTALLED_APPS.remove('openwisp_users')
     INSTALLED_APPS.insert(users_index, 'openwisp2.sample_users')
+    # Replace Subnet Division
+    subnet_division_index = INSTALLED_APPS.index('openwisp_controller.subnet_division')
+    INSTALLED_APPS.remove('openwisp_controller.subnet_division')
+    INSTALLED_APPS.insert(subnet_division_index, 'openwisp2.sample_subnet_division')
     # Extended apps
     EXTENDED_APPS = (
         'django_x509',
@@ -219,6 +237,7 @@
         'openwisp_controller.pki',
         'openwisp_controller.geo',
         'openwisp_controller.connection',
+        'openwisp_controller.subnet_division',
         'openwisp_users',
     )
     # Swapper
@@ -245,6 +264,12 @@
     CONNECTION_CREDENTIALS_MODEL = 'sample_connection.Credentials'
     CONNECTION_DEVICECONNECTION_MODEL = 'sample_connection.DeviceConnection'
     CONNECTION_COMMAND_MODEL = 'sample_connection.Command'
+    SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = (
+        'sample_subnet_division.SubnetDivisionRule'
+    )
+    SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL = (
+        'sample_subnet_division.SubnetDivisionIndex'
+    )
 else:
     # not needed, these are the default values, left here only for example purposes
     # DJANGO_X509_CA_MODEL = 'pki.Ca'