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

Issue:
[feature] Add support for custom ZeroTier interface name
We need to update [auto_client method of Vpn model](https://github.com/openwisp/openwisp-controller/blob/master/openwisp_controller/config/base/vpn.py#L421) by adding required keyword arguments for custom zerotier interface name. 

More Info: https://github.com/openwisp/netjsonconfig/issues/284


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a0f996b8..8ad53d02c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,9 +6,11 @@ on:
   push:
     branches:
       - master
+      - gsoc23
   pull_request:
     branches:
       - master
+      - gsoc23
 
 jobs:
 
diff --git a/README.rst b/README.rst
index 11aa23879..155094a6c 100644
--- a/README.rst
+++ b/README.rst
@@ -1280,6 +1280,115 @@ OpenWISP. Register or create a device before proceeding.
 **Voila!** You have successfully configured OpenWISP to manage VXLAN over
 WireGuard tunnels for your devices.
 
+How to setup ZeroTier Tunnels
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Follow the procedure described below to setup ZeroTier 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. Configure Self-Hosted ZeroTier Network Controller
+####################################################
+
+If you haven't already set up a self-hosted Zerotier network controller on your server,
+now is a good time to do so. You can start by simply installing Zerotier on your server
+from the `official website <https://www.zerotier.com/download/>`_.
+
+2. Create VPN server configuration for ZeroTier
+###############################################
+
+1. Visit ``/admin/config/vpn/add/`` to add a new VPN server.
+2. We will set **Name** of this VPN server ``ZeroTier`` and **Host** as
+   ``my-zerotier-server.mydomain.com:9993`` (update this to point to your ZeroTier VPN server).
+3. Select ``ZeroTier`` from the dropdown as **VPN Backend**.
+4. When using ZeroTier, OpenWISP takes care of managing IP addresses
+   (assigning an IP address to each VPN clients (Zerotier network members).
+   You can create a new subnet or select an existing one from the dropdown menu.
+   You can also assign an **Internal IP** to the Zerotier controller or
+   leave it empty for OpenWISP to configure. This IP address will be used
+   to assign it to the Zerotier controller running on the server.
+5. Set the **Webhook AuthToken**, this will be ZeroTier authorization token which you
+   can obtain by running the following command on the ZeroTier controller:
+
+   .. code-block:: shell
+
+        sudo cat /var/lib/zerotier-one/authtoken.secret
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/vpn-server-1.png
+   :alt: ZeroTier VPN server configuration example 1
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/vpn-server-2.png
+   :alt: ZeroTier VPN server configuration example 2
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/vpn-server-3.png
+   :alt: ZeroTier VPN server configuration example 3
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/vpn-server-4.png
+   :alt: ZeroTier VPN server configuration example 4
+
+6. After clicking on **Save and continue editing**, OpenWISP automatically detects
+   the node address of the Zerotier controller and creates a Zerotier network.
+   The **network_id**  of this network can be viewed in the **System Defined Variables**
+   section, where it also provides internal IP address information.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/vpn-server-5.png
+   :alt: ZeroTier VPN server configuration example 5
+
+3. Create VPN client template for ZeroTier VPN Server
+#####################################################
+
+1. Visit ``/admin/config/template/add/`` to add a new template.
+2. Set ``ZeroTier 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 ``ZeroTier``.
+5. Ensure that the **Automatic tunnel provisioning** option is checked.
+   This will enable OpenWISP to automatically provision an IP address and
+   ZeroTier identity secrets (used for assigning member IDs) for each ZeroTier VPN client.
+6. After clicking on **Save and continue editing** button, you will see details
+   of *ZeroTier* 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.
+
+**Note:** OpenWISP uses `zerotier-idtool
+<https://github.com/zerotier/ZeroTierOne/blob/dev/doc/zerotier-idtool.1.md>`_
+to manage **ZeroTier identity secrets**. Please make sure that you have
+`ZeroTier package installed <https://www.zerotier.com/download/>`_ on the server.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/template.png
+    :alt: ZeroTier VPN client template example
+
+4. Apply ZeroTier 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 *ZeroTier Client* template.
+3. Upon clicking the **Save and Continue Editing** button, you will see entries
+   in the **System Defined Variables** section. These entries will include **zerotier_member_id**, **identity_secret**,
+   and the internal **IP address** of the ZeroTier client (network member) on the device, along with details of the VPN server.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/device-configuration-1.png
+   :alt: ZeroTier VPN device configuration example 1
+
+4. Once the configuration is successfully applied to the device, you will notice a new ZeroTier interface
+   that is up and running. This interface will have the name ``owzt89f498`` (where ``owzt`` is followed
+   by the last six hexadecimal characters of the ZeroTier **network ID**).
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/zerotier-tutorial/device-configuration-2.png
+   :alt: ZeroTier VPN device configuration example 2
+
+**Voila!** You have successfully configured OpenWISP
+to manage ZeroTier tunnels for your devices.
+
 How to configure automatic provisioning of subnets and IPs
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -2709,6 +2818,7 @@ Available configuration backends. For more information, see `netjsonconfig backe
 |              |     ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),                     |
 |              |     ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'),                 |
 |              |     ('openwisp_controller.vpn_backends.VxlanWireguard', 'VXLAN over WireGuard'), |
+|              |     ('openwisp_controller.vpn_backends.ZeroTier', 'ZeroTier'),                   |
 |              |   )                                                                              |
 +--------------+----------------------------------------------------------------------------------+
 
@@ -3230,6 +3340,39 @@ Allows to show a pie chart like the one in the screenshot.
 Active groups are groups which have at least one device in them,
 while emtpy groups do not have any device assigned.
 
+``OPENWISP_CONTROLLER_API_TASK_RETRY_OPTIONS``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
++--------------+-----------+
+| **type**:    | ``dict``  |
++--------------+-----------+
+| **default**: | see below |
++--------------+-----------+
+
+.. code-block:: python
+
+    # default value of OPENWISP_CONTROLLER_API_TASK_RETRY_OPTIONS:
+
+    dict(
+        max_retries=5, # total number of retries
+        retry_backoff=True, # exponential backoff
+        retry_backoff_max=600, # 10 minutes
+        retry_jitter=True, # randomness into exponential backoff
+    )
+
+
+This setting is utilized by background API tasks executed
+by `ZeroTier VPN servers and ZeroTier VPN clients <#how-to-setup-zerotier-tunnels>`_ to handle recoverable
+HTTP status codes such as 429, 500, 502, 503, and 504. These tasks are retried with a maximum
+of 5 attempts with an exponential backoff and jitter, with a maximum delay of 10 minutes.
+
+This feature ensures that ZeroTier Service API calls
+are resilient to recoverable failures, improving the reliability of the system.
+
+For more information on these settings, you can refer to the `the celery documentation regarding automatic retries
+for known errors. <https://docs.celeryq.dev/en/stable/userguide/tasks.html#automatic-retry-for-known-exceptions>`_
+
+
 Signals
 -------
 
diff --git a/openwisp_controller/config/api/zerotier_service.py b/openwisp_controller/config/api/zerotier_service.py
new file mode 100644
index 000000000..9fc718567
--- /dev/null
+++ b/openwisp_controller/config/api/zerotier_service.py
@@ -0,0 +1,195 @@
+import requests
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+from requests.exceptions import ConnectionError, RequestException, Timeout
+
+REQUEST_TIMEOUT = 5
+
+
+class ZerotierService:
+    def _get_endpoint(self, property, operation, id):
+        _API_ENDPOINTS = {
+            'network': {
+                'create': f'/controller/network/{id}______',
+                'get': f'/controller/network/{id}',
+                'update': f'/controller/network/{id}',
+                'delete': f'/controller/network/{id}',
+            }
+        }
+        return _API_ENDPOINTS.get(property).get(operation)
+
+    def __init__(self, host, token, subnet=''):
+        self.host = host
+        self.token = token
+        self.subnet = subnet
+        self.url = f'http://{host}'
+        self.headers = {
+            'X-ZT1-Auth': self.token,
+            'Accept': 'application/json',
+            'Content-Type': 'application/json',
+        }
+
+    def _get_repsonse(self, repsonse):
+        # remove redundant fields from the response
+        _redundant_fields = [
+            'authTokens',
+            'authorizationEndpoint',
+            'clientId',
+            'rulesSource',
+            'ssoEnabled',
+            'creationTime',
+            'name',
+            'nwid',
+            'objtype',
+            'revision',
+            'routes',
+            'ipAssignmentPools',
+        ]
+        for field in _redundant_fields:
+            if field in repsonse.keys():
+                del repsonse[field]
+        return repsonse
+
+    def _add_routes_and_ip_assignment(self, config):
+        """
+        Adds ZeroTier network routes
+        and IP assignmentpools through OpenWISP subnet
+
+        Params:
+            config (dict): ZeroTier network config dict
+        """
+        config['routes'] = [{'target': str(self.subnet), 'via': ''}]
+        ip_end = str(self.subnet.broadcast_address)
+        ip_start = str(next(self.subnet.hosts()))
+        config['ipAssignmentPools'] = [{"ipRangeEnd": ip_end, "ipRangeStart": ip_start}]
+        return config
+
+    def get_node_status(self):
+        """
+        Fetches the status of the running ZeroTier controller
+        This method is used for host validation during VPN creation
+        """
+        url = f'{self.url}/status'
+        try:
+            response = requests.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
+            return response
+        except (Timeout, ConnectionError) as e:
+            raise ValidationError(
+                {
+                    'host': _(
+                        'Failed to connect to the ZeroTier controller, Error: {0}'
+                    ).format(e)
+                }
+            )
+
+    def join_network(self, network_id):
+        """
+        Adds ZeroTier Controller to the specified network
+
+        Params:
+            network_id (str): ID of the network to join
+        """
+        url = f'{self.url}/network/{network_id}'
+        response = requests.post(
+            url, json={}, headers=self.headers, timeout=REQUEST_TIMEOUT
+        )
+        return response
+
+    def leave_network(self, network_id):
+        """
+        Removes ZeroTier Controller from the specified network
+
+        Params:
+            network_id (str): ID of the network to leave
+        """
+        url = f'{self.url}/network/{network_id}'
+        response = requests.delete(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
+        return response
+
+    def create_network(self, node_id, config):
+        """
+        Creates a new network in the ZeroTier Controller
+
+        Params:
+            node_id (str): ID of the controller node
+            config (dict): Configuration of the new network
+
+        Returns:
+            network_config(dict): Filtered response from the ZeroTier Controller API
+        """
+        url = f"{self.url}{self._get_endpoint('network', 'create', node_id)}"
+        config = self._add_routes_and_ip_assignment(config)
+        try:
+            response = requests.post(
+                url, json=config, headers=self.headers, timeout=REQUEST_TIMEOUT
+            )
+            response.raise_for_status()
+            network_config = self._get_repsonse(response.json())
+            return network_config
+        except RequestException as e:
+            raise ValidationError(
+                _('Failed to create ZeroTier network, Error: {0}').format(e)
+            )
+
+    def update_network(self, config, network_id):
+        """
+        Update configuration of an existing ZeroTier Controller network
+
+        Params:
+            config (dict): New configuration data for the network
+            network_id (str): ID of the network to update
+        """
+        url = f"{self.url}{self._get_endpoint('network', 'update', network_id)}"
+        config = self._add_routes_and_ip_assignment(config)
+        response = requests.post(
+            url, json=config, headers=self.headers, timeout=REQUEST_TIMEOUT
+        )
+        return response, self._get_repsonse(response.json())
+
+    def delete_network(self, network_id):
+        """
+        Deletes ZeroTier Controller network
+
+        Params:
+            network_id (str): ID of the ZeroTier network to be deleted
+        """
+        url = f"{self.url}{self._get_endpoint('network', 'delete', network_id)}"
+        response = requests.delete(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
+        return response
+
+    def update_network_member(self, node_id, network_id, member_ip):
+        """
+        Update ZeroTier Network Member Configuration
+
+        This method is currently used to authorize, enable the bridge
+        and assign an IP address to a network member
+
+        Params:
+            node_id (str): Node ID of the network member
+            network_id (str): Network ID to which the member belongs
+            member_ip (str): IP address to be assigned to the network member
+        """
+        url = f'{self.url}/controller/network/{network_id}/member/{node_id}'
+        response = requests.post(
+            url,
+            json={
+                'authorized': True,
+                'activeBridge': True,
+                'ipAssignments': [str(member_ip)],
+            },
+            headers=self.headers,
+            timeout=5,
+        )
+        return response
+
+    def remove_network_member(self, node_id, network_id):
+        """
+        Remove a member from ZeroTier network
+
+        Params:
+            node_id (str): ID of the network member
+            network_id (str): ID of the ZeroTier network
+        """
+        url = f'{self.url}/controller/network/{network_id}/member/{node_id}'
+        response = requests.delete(url, headers=self.headers, timeout=5)
+        return response
diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py
index 14d79b7ef..f614fb901 100644
--- a/openwisp_controller/config/apps.py
+++ b/openwisp_controller/config/apps.py
@@ -102,6 +102,11 @@ def connect_signals(self):
             sender=self.vpn_model,
             dispatch_uid='vpn.update_vpn_server_configuration',
         )
+        post_delete.connect(
+            self.vpn_model.post_delete,
+            sender=self.vpn_model,
+            dispatch_uid='vpn.post_delete',
+        )
         post_save.connect(
             self.config_model.certificate_updated,
             sender=self.cert_model,
@@ -209,6 +214,46 @@ def register_notification_types(self):
             models=[self.device_model],
         )
 
+        register_notification_type(
+            'api_task_error',
+            {
+                'verbose_name': _('Background API Task ERROR'),
+                'verb': _('encountered an unrecoverable error'),
+                'level': 'error',
+                'email_subject': _(
+                    '[{site.name}] ERROR: "{notification.target}" - '
+                    'VPN Server {action} API Task {notification.verb}'
+                ),
+                'message': _(
+                    'Unable to perform {action} operation on the '
+                    '[{notification.target}]({notification.target_link}) VPN server '
+                    'due to an unrecoverable error (status code: {status_code})'
+                ),
+                # Disable email notifications by default
+                'email_notification': False,
+            },
+            models=[self.vpn_model],
+        )
+        register_notification_type(
+            'api_task_recovery',
+            {
+                'verbose_name': _('Background API Task RECOVERY'),
+                'verb': _('has been completed successfully'),
+                'level': 'info',
+                'email_subject': _(
+                    '[{site.name}] RECOVERY: "{notification.target}" - '
+                    'VPN Server {action} API Task {notification.verb}'
+                ),
+                'message': _(
+                    'The {action} operation on [{notification.target}]'
+                    '({notification.target_link}) {notification.verb}'
+                ),
+                # Disable email notifications by default
+                'email_notification': False,
+            },
+            models=[self.vpn_model],
+        )
+
         #  Unregister default notification type
         try:
             unregister_notification_type('default')
diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py
index 24fcfb245..2c9fda910 100644
--- a/openwisp_controller/config/base/config.py
+++ b/openwisp_controller/config/base/config.py
@@ -263,7 +263,9 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
                 ).exists():
                     continue
                 client = vpn_client_model(
-                    config=instance, vpn=template.vpn, auto_cert=template.auto_cert
+                    config=instance,
+                    vpn=template.vpn,
+                    auto_cert=template.auto_cert,
                 )
                 client.full_clean()
                 client.save()
@@ -601,6 +603,11 @@ def get_vpn_context(self):
                 context[
                     vpn_context_keys['vni']
                 ] = f'{vpnclient.vni or vpnclient.vpn._vxlan_vni}'
+            if vpnclient.secret:
+                context[
+                    vpn_context_keys['zerotier_member_id']
+                ] = vpnclient.zerotier_member_id
+                context[vpn_context_keys['secret']] = vpnclient.secret
         return context
 
     def get_context(self, system=False):
diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py
index 1de99874d..ddfec7fea 100644
--- a/openwisp_controller/config/base/vpn.py
+++ b/openwisp_controller/config/base/vpn.py
@@ -3,13 +3,17 @@
 import json
 import logging
 import subprocess
+from copy import deepcopy
+from subprocess import CalledProcessError, TimeoutExpired
 
 import django
 import shortuuid
 from cache_memoize import cache_memoize
+from django.core.cache import cache
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models, transaction
+from django.utils.functional import cached_property
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 from swapper import get_model_name
@@ -19,8 +23,17 @@
 from ...base import ShareableOrgMixinUniqueName
 from .. import crypto
 from .. import settings as app_settings
+from ..api.zerotier_service import ZerotierService
+from ..exceptions import ZeroTierIdentityGenerationError
 from ..signals import vpn_peers_changed, vpn_server_modified
 from ..tasks import create_vpn_dh, trigger_vpn_server_endpoint
+from ..tasks_zerotier import (
+    trigger_zerotier_server_delete,
+    trigger_zerotier_server_join,
+    trigger_zerotier_server_remove_member,
+    trigger_zerotier_server_update,
+    trigger_zerotier_server_update_member,
+)
 from .base import BaseConfig
 
 logger = logging.getLogger(__name__)
@@ -91,7 +104,10 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
     )
     auth_token = models.CharField(
         verbose_name=_('Webhook AuthToken'),
-        help_text=_('Authentication token for triggering "Webhook Endpoint"'),
+        help_text=_(
+            'Authentication token used for triggering "Webhook Endpoint" '
+            'or for calling "ZerotierService" API'
+        ),
         max_length=128,
         blank=True,
         null=True,
@@ -115,6 +131,9 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
     # needed for wireguard
     public_key = models.CharField(blank=True, max_length=44)
     private_key = models.CharField(blank=True, max_length=44)
+    # needed for zerotier
+    node_id = models.CharField(blank=True, max_length=10)
+    network_id = models.CharField(blank=True, max_length=16)
 
     __vpn__ = True
 
@@ -141,6 +160,8 @@ def clean(self, *args, **kwargs):
         self._validate_org_relation('cert')
         self._validate_org_relation('subnet')
         self._validate_subnet_ip()
+        self._validate_authtoken()
+        self._validate_host()
 
     def _validate_backend(self):
         if self._state.adding:
@@ -178,7 +199,7 @@ def _validate_keys(self):
             self.private_key = ''
 
     def _validate_subnet_ip(self):
-        if self._is_backend_type('wireguard'):
+        if self._is_backend_type('wireguard') or self._is_backend_type('zerotier'):
             if not self.subnet:
                 raise ValidationError(
                     {'subnet': _('Subnet is required for this VPN backend.')}
@@ -188,10 +209,44 @@ def _validate_subnet_ip(self):
                     {'ip': _('VPN IP address must be within the VPN subnet')}
                 )
 
+    def _validate_authtoken(self):
+        if self._is_backend_type('zerotier') and not self.auth_token:
+            raise ValidationError(
+                {'auth_token': _('Auth token is required for this VPN backend')}
+            )
+
+    def _validate_host(self):
+        if not (self._is_backend_type('zerotier') and self.host):
+            return
+        response = ZerotierService(self.host, self.auth_token).get_node_status()
+        if response.status_code == 401:
+            raise ValidationError(
+                {
+                    'auth_token': _(
+                        'Authorization failed for ZeroTier controller, '
+                        'ensure you are using the correct authorization token'
+                    )
+                }
+            )
+        # For cases other than unsuccessful connection and unauthorized access
+        if response.status_code != 200:
+            raise ValidationError(
+                {
+                    'host': _(
+                        'Failed to connect to the ZeroTier controller, '
+                        'ensure you are using the correct hostname '
+                        '(error: {0}, status code: {1})'
+                    ).format(response.reason, response.status_code)
+                }
+            )
+        else:
+            self.node_id = response.json()['address']
+
     def save(self, *args, **kwargs):
         """
         Calls _auto_create_cert() if cert is not set
         """
+        config = {}
         created = self._state.adding
         if not created:
             self._check_changes()
@@ -205,13 +260,37 @@ def save(self, *args, **kwargs):
             self._generate_wireguard_keys()
         if self.subnet and not self.ip:
             self.ip = self._auto_create_ip()
-        super().save(*args, **kwargs)
+        if self._is_backend_type('zerotier'):
+            config = deepcopy(self.config['zerotier'][0])
+            config['name'] = self.name
+            if created:
+                self._create_zt_server(config)
+        try:
+            super().save(*args, **kwargs)
+        except Exception as e:
+            # If the db transaction for zt vpn server creation fails
+            # for any reason, we should delete the recently
+            # created zt network to prevent duplicate networks
+            if created and self._is_backend_type('zerotier'):
+                trigger_zerotier_server_delete.delay(
+                    host=self.host,
+                    auth_token=self.auth_token,
+                    network_id=self.network_id,
+                    vpn_id=self.pk,
+                )
+            raise e
         if create_dh:
             transaction.on_commit(lambda: create_vpn_dh.delay(self.id))
         if not created and self._send_vpn_modified_after_save:
             self._send_vpn_modified_signal()
             self._send_vpn_modified_after_save = False
-        self.update_vpn_server_configuration()
+        # For ZeroTier VPN server, if the
+        # ZeroTier network is created successfully,
+        # this method triggers a background task to
+        # add the controller node to the ZeroTier network
+        # Otherwise, in the case of an update, this method triggers
+        # a background task to update the ZeroTier network configuration
+        self.update_vpn_server_configuration(created=created, config=config)
 
     def _check_changes(self):
         attrs = [
@@ -226,6 +305,7 @@ def _check_changes(self):
             'dh',
             'public_key',
             'private_key',
+            'network_id',
         ]
         current = self._meta.model.objects.only(*attrs).get(pk=self.pk)
         for attr in attrs:
@@ -246,22 +326,88 @@ 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'):
+    @classmethod
+    def post_delete(cls, instance, **kwargs):
+        """
+        class method for ``post_delete`` signal
+        for managing automatic deletion of vpn servers
+        """
+        if not instance._is_backend_type('zerotier'):
             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,
-                )
+        transaction.on_commit(
+            lambda: trigger_zerotier_server_delete.delay(
+                host=instance.host,
+                auth_token=instance.auth_token,
+                network_id=instance.network_id,
+                vpn_id=instance.pk,
             )
-        else:
-            logger.info(
-                f'Cannot update configuration of {instance.name} VPN server, '
-                'webhook endpoint and authentication token are empty.'
+        )
+        # Delete ZT API tasks notification cache keys
+        cache.delete_many(cache.keys(f'*{instance.pk.hex}_last_operation'))
+
+    def _create_zt_server(self, config):
+        server_config = ZerotierService(
+            self.host, self.auth_token, self.subnet.subnet
+        ).create_network(self.node_id, config)
+        self.network_id = server_config.pop('id', None)
+        self.config = {**self.config, 'zerotier': [server_config]}
+
+    def _update_zt_server(self, config):
+        config = config or {}
+        transaction.on_commit(
+            lambda: trigger_zerotier_server_update.delay(
+                config=config,
+                vpn_id=self.pk,
+            )
+        )
+
+    def _add_controller_to_zt_server(self):
+        transaction.on_commit(
+            lambda: trigger_zerotier_server_join.delay(
+                vpn_id=self.pk,
+            )
+        )
+
+    def _add_zt_network_member(self, zt_member_id, member_ip):
+        transaction.on_commit(
+            lambda: trigger_zerotier_server_update_member.delay(
+                vpn_id=self.pk, ip=str(member_ip), node_id=zt_member_id
+            )
+        )
+
+    def _remove_zt_network_member(self, zt_member_id):
+        vpn_kwargs = dict(
+            id=self.pk,
+            host=self.host,
+            auth_token=self.auth_token,
+            network_id=self.network_id,
+        )
+        transaction.on_commit(
+            lambda: trigger_zerotier_server_remove_member.delay(
+                node_id=zt_member_id, **vpn_kwargs
             )
+        )
+
+    def update_vpn_server_configuration(instance, **kwargs):
+        if instance._is_backend_type('zerotier'):
+            if kwargs.get('created'):
+                instance._add_controller_to_zt_server()
+            else:
+                instance._update_zt_server(kwargs.get('config'))
+        if instance._is_backend_type('wireguard'):
+            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):
         """
@@ -319,6 +465,10 @@ def get_context(self):
             c['subnet_prefixlen'] = str(self.subnet.subnet.prefixlen)
         if self.ip:
             c['ip_address'] = self.ip.ip_address
+        if self.node_id:
+            c['node_id'] = self.node_id
+        if self.network_id:
+            c['network_id'] = self.network_id
         c.update(sorted(super().get_context().items()))
         return c
 
@@ -350,6 +500,10 @@ def get_vpn_server_context(self):
                 context_keys['server_ip_network']
             ] = f'{self.ip.ip_address}/{self.subnet.subnet.max_prefixlen}'
             context[context_keys['vpn_subnet']] = str(self.subnet.subnet)
+        if self._is_backend_type('zerotier') and self.network_id:
+            context[context_keys['network_name']] = self.name
+            context[context_keys['node_id']] = self.node_id
+            context[context_keys['network_id']] = self.network_id
         return context
 
     def get_system_context(self):
@@ -381,6 +535,8 @@ def _get_auto_context_keys(self):
             * ip address
         VXLAN:
             * vni (VXLAN Network Identifier)
+        ZeroTier
+            * network_id (ZeroTier Network Identifier)
         """
         pk = self.pk.hex
         context_keys = {
@@ -416,6 +572,16 @@ def _get_auto_context_keys(self):
                     'server_ip_network': 'server_ip_network_{}'.format(pk),
                 }
             )
+        if self._is_backend_type('zerotier'):
+            context_keys.update(
+                {
+                    'node_id': 'node_id_{}'.format(pk),
+                    'network_id': 'network_id_{}'.format(pk),
+                    'network_name': 'network_name_{}'.format(pk),
+                    'zerotier_member_id': 'zerotier_member_id',
+                    'secret': 'secret',
+                }
+            )
         return context_keys
 
     def auto_client(self, auto_cert=True, template_backend_class=None):
@@ -435,7 +601,12 @@ def auto_client(self, auto_cert=True, template_backend_class=None):
             # do not include cert and key if auto_cert is False
             if not auto_cert:
                 for key in ['cert_path', 'cert_contents', 'key_path', 'key_contents']:
-                    del context_keys[key]
+                    try:
+                        del context_keys[key]
+                    # In case of zerotier backend
+                    # these keys doesn't exist
+                    except KeyError:
+                        pass
             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:
@@ -448,6 +619,16 @@ def auto_client(self, auto_cert=True, template_backend_class=None):
                     vxlan=self.config.get('vxlan', [{}])[0],
                     **context_keys,
                 )
+            # If the backend is 'zerotier' then
+            # call auto_client and update the config
+            elif self._is_backend_type('zerotier') and template_backend_class:
+                auto = getattr(template_backend_class, 'zerotier_auto_client')(
+                    name='ow_zt',
+                    networks=[
+                        {'id': self.network_id, 'ifname': f'owzt{self.network_id[-6:]}'}
+                    ],
+                    identity_secret=context_keys['secret'],
+                )
             else:
                 # The OpenVPN backend does not support these kwargs,
                 # hence, they are removed before creating configuration
@@ -492,16 +673,13 @@ 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)
+        for backend in ['wireguard', 'vxlan']:
+            if self._is_backend_type(backend):
+                getattr(self, f'_get_{backend}_peers').invalidate(self)
+                if update:
+                    getattr(self, f'_get_{backend}_peers')()
+                # Send signal for peers changed
+                vpn_peers_changed.send(sender=self.__class__, instance=self)
 
     def _get_peer_queryset(self):
         """
@@ -627,6 +805,8 @@ class AbstractVpnClient(models.Model):
     # needed for wireguard
     public_key = models.CharField(blank=True, max_length=44)
     private_key = models.CharField(blank=True, max_length=44)
+    # needed for zerotier
+    secret = models.TextField(blank=True)
     # needed for vxlan
     vni = models.PositiveIntegerField(
         null=True,
@@ -642,6 +822,13 @@ class Meta:
         verbose_name = _('VPN client')
         verbose_name_plural = _('VPN clients')
 
+    @cached_property
+    def zerotier_member_id(self):
+        """
+        Needed for ZeroTier VPN Clients
+        """
+        return self.secret[:10]
+
     @classmethod
     def register_auto_ip_stopper(cls, func):
         """
@@ -678,6 +865,7 @@ def save(self, *args, **kwargs):
             self._auto_ip()
             self._auto_wireguard()
             self._auto_vxlan()
+            self._auto_secret()
         super().save(*args, **kwargs)
 
     def _auto_x509(self):
@@ -710,6 +898,13 @@ def _post_save():
             instance.vpn._invalidate_peer_cache(update=True)
 
         transaction.on_commit(_post_save)
+        # ZT network member should be authorized and assigned
+        # an IP after the creation of the VPN client object
+        if instance.vpn._is_backend_type('zerotier'):
+            if instance.zerotier_member_id and instance.ip:
+                instance.vpn._add_zt_network_member(
+                    instance.zerotier_member_id, instance.ip.ip_address
+                )
 
     @classmethod
     def post_delete(cls, instance, **kwargs):
@@ -721,6 +916,10 @@ class method for ``post_delete`` signal
         # only invalidates, does not regenerate the cache
         # to avoid generating high load during bulk deletes
         instance.vpn._invalidate_peer_cache()
+        # Zt network member should leave the
+        # network after deletion of vpn client object
+        if instance.vpn._is_backend_type('zerotier'):
+            instance.vpn._remove_zt_network_member(instance.zerotier_member_id)
         try:
             # only deletes related certificates
             # if auto_cert field is set to True
@@ -803,6 +1002,41 @@ def _auto_ip(self):
                 return
         self.ip = self.vpn.subnet.request_ip()
 
+    def _auto_secret(self):
+        if not self.vpn._is_backend_type('zerotier') or self.secret:
+            return
+        # If there's an existing ZeroTier VpnClient
+        # for the device, then re-use that secret
+        existing_zt_client = (
+            self.__class__.objects.only('secret')
+            .exclude(id=self.id)
+            .filter(config_id=self.config_id, vpn__backend=self.vpn.backend)
+            .first()
+        )
+        if existing_zt_client:
+            self.secret = existing_zt_client.secret
+        else:
+            self.secret = self._generate_zt_identity()
+
+    def _generate_zt_identity(self):
+        try:
+            result = subprocess.run(
+                'zerotier-idtool generate',
+                shell=True,
+                check=True,
+                # in seconds
+                timeout=5,
+                capture_output=True,
+            )
+        except (CalledProcessError, TimeoutExpired) as exc:
+            err = getattr(exc, 'stderr', None)
+            # In case of timeout
+            if err is None:
+                err = exc
+            err_msg = f'Unable to generate zerotier identity secret, Error: {err}'
+            raise ZeroTierIdentityGenerationError(err_msg)
+        return result.stdout.decode('utf-8')
+
     @classmethod
     def invalidate_clients_cache(cls, vpn):
         """
diff --git a/openwisp_controller/config/exceptions.py b/openwisp_controller/config/exceptions.py
index 29f358d83..bf02a956e 100644
--- a/openwisp_controller/config/exceptions.py
+++ b/openwisp_controller/config/exceptions.py
@@ -18,3 +18,7 @@ class OrganizationDeviceLimitExceeded(ValidationError):
     def __init__(self):
         error = {'device_limit': [self.error_message]}
         super().__init__(error, code=None, params=None)
+
+
+class ZeroTierIdentityGenerationError(Exception):
+    pass
diff --git a/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py b/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py
index d770cf957..23566c6e9 100644
--- a/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py
+++ b/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py
@@ -46,7 +46,10 @@ class Migration(migrations.Migration):
             name='auth_token',
             field=models.CharField(
                 blank=True,
-                help_text=('Authentication token for triggering "Webhook Endpoint"'),
+                help_text=(
+                    'Authentication token used for triggering '
+                    '"Webhook Endpoint" or for calling "ZerotierService" API'
+                ),
                 max_length=128,
                 null=True,
                 verbose_name='Webhook AuthToken',
diff --git a/openwisp_controller/config/migrations/0052_vpn_node_network_id.py b/openwisp_controller/config/migrations/0052_vpn_node_network_id.py
new file mode 100644
index 000000000..435ec68c0
--- /dev/null
+++ b/openwisp_controller/config/migrations/0052_vpn_node_network_id.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.19 on 2023-07-02 18:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('config', '0051_organizationconfigsettings_context'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vpn',
+            name='node_id',
+            field=models.CharField(blank=True, max_length=10),
+        ),
+        migrations.AddField(
+            model_name='vpn',
+            name='network_id',
+            field=models.CharField(blank=True, max_length=16),
+        ),
+    ]
diff --git a/openwisp_controller/config/migrations/0053_vpnclient_secret.py b/openwisp_controller/config/migrations/0053_vpnclient_secret.py
new file mode 100644
index 000000000..8745d8361
--- /dev/null
+++ b/openwisp_controller/config/migrations/0053_vpnclient_secret.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.19 on 2023-07-27 12:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('config', '0052_vpn_node_network_id'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vpnclient',
+            name='secret',
+            field=models.TextField(blank=True),
+        ),
+    ]
diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py
index 5f00e72da..01f79b5af 100644
--- a/openwisp_controller/config/settings.py
+++ b/openwisp_controller/config/settings.py
@@ -39,6 +39,7 @@ def get_settings_value(option, default):
         ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),
         ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'),
         ('openwisp_controller.vpn_backends.VxlanWireguard', 'VXLAN over WireGuard'),
+        ('openwisp_controller.vpn_backends.ZeroTier', 'ZeroTier'),
     ),
 )
 DEFAULT_BACKEND = get_settings_value('DEFAULT_BACKEND', BACKENDS[0][0])
@@ -79,3 +80,7 @@ def get_settings_value(option, default):
 DSA_OS_MAPPING = get_settings_value('DSA_OS_MAPPING', {})
 DSA_DEFAULT_FALLBACK = get_settings_value('DSA_DEFAULT_FALLBACK', True)
 GROUP_PIE_CHART = get_settings_value('GROUP_PIE_CHART', False)
+API_TASK_RETRY_OPTIONS = get_settings_value(
+    'API_TASK_RETRY_OPTIONS',
+    dict(max_retries=5, retry_backoff=True, retry_backoff_max=600, retry_jitter=True),
+)
diff --git a/openwisp_controller/config/static/config/css/admin.css b/openwisp_controller/config/static/config/css/admin.css
index f4da3ce6f..47cdcad4d 100644
--- a/openwisp_controller/config/static/config/css/admin.css
+++ b/openwisp_controller/config/static/config/css/admin.css
@@ -265,6 +265,15 @@ div.tabs-divider {
   padding: 17em 0;
   font-size: 16px;
 }
+
+/*
+Prevents the overflow of large text in system
+defined variables such as `secret`
+*/
+.form-row.field-system_context {
+  max-width: 51.25em;
+  word-wrap: break-word;
+}
 .form-row.field-system_context .readonly { padding: 0 }
 .form-row.field-system_context pre {
   margin: 0;
diff --git a/openwisp_controller/config/static/config/js/vpn.js b/openwisp_controller/config/static/config/js/vpn.js
index 45a4ea7d6..854b1afd1 100644
--- a/openwisp_controller/config/static/config/js/vpn.js
+++ b/openwisp_controller/config/static/config/js/vpn.js
@@ -47,6 +47,10 @@ django.jQuery(function ($) {
             $('label[for="id_ca"]').parent().parent().hide();
             $('label[for="id_cert"]').parent().parent().hide();
         }
+        // For Zerotier VPN backend
+        if(backendValue.includes('zerotier')){
+            $('label[for="id_auth_token"]').parent().parent().show();
+        }
     };
 
     // clean config when VPN backend is changed
diff --git a/openwisp_controller/config/tasks_zerotier.py b/openwisp_controller/config/tasks_zerotier.py
new file mode 100644
index 000000000..05591379c
--- /dev/null
+++ b/openwisp_controller/config/tasks_zerotier.py
@@ -0,0 +1,298 @@
+import logging
+from http import HTTPStatus
+from time import sleep
+
+from celery import shared_task
+from django.core.cache import cache
+from django.core.exceptions import ObjectDoesNotExist
+from openwisp_notifications.signals import notify
+from requests.exceptions import RequestException
+from swapper import load_model
+
+from openwisp_controller.config.api.zerotier_service import ZerotierService
+from openwisp_utils.tasks import OpenwispCeleryTask
+
+from .settings import API_TASK_RETRY_OPTIONS
+
+logger = logging.getLogger(__name__)
+
+
+class OpenwispApiTask(OpenwispCeleryTask):
+
+    _RECOVERABLE_API_CODES = [
+        HTTPStatus.TOO_MANY_REQUESTS,  # 429
+        HTTPStatus.INTERNAL_SERVER_ERROR,  # 500
+        HTTPStatus.BAD_GATEWAY,  # 502
+        HTTPStatus.SERVICE_UNAVAILABLE,  # 503
+        HTTPStatus.GATEWAY_TIMEOUT,  # 504
+    ]
+
+    def _send_api_task_notification(self, type, **kwargs):
+        vpn = kwargs.get('instance')
+        action = kwargs.get('action').replace('_', ' ')
+        status_code = kwargs.get('status_code')
+        # Adding some delay here to prevent overlapping
+        # of the django success message container
+        # with the ow-notification container
+        # https://github.com/openwisp/openwisp-notifications/issues/264
+        sleep(2)
+        notify.send(
+            type=f'api_task_{type}',
+            sender=vpn,
+            target=vpn,
+            action=action,
+            status_code=status_code,
+        )
+
+    def handle_api_call(self, fn, *args, send_notification=True, **kwargs):
+        """
+        This method handles API calls and their responses
+        and triggers appropriate web notifications, which include:
+
+        Error notification
+          - Sent on any unrecoverable API call failure
+        Recovery notification
+          - Sent only when an error notification was previously triggered
+
+        Also raises an exception for recoverable
+        API calls leading to the retrying of the API task
+
+        NOTE: The method utilizes a cache key
+        to prevent flooding of similar task notifications
+
+        Parameters:
+            fn: API service method
+            *args: Arguments for the API service method
+            send_notification: If True, send notifications for API tasks
+            **kwargs: Arguments used by the _send_api_task_notification method
+        """
+        updated_config = None
+        err_msg = kwargs.get('err')
+        info_msg = kwargs.get('info')
+        vpn = kwargs.get('instance')
+        if send_notification:
+            task_key = f'{self.name}_{vpn.pk.hex}_last_operation'
+        # Execute API call and get response
+        response = fn(*args)
+        if isinstance(response, tuple):
+            response, updated_config = response
+        try:
+            response.raise_for_status()
+            logger.info(info_msg)
+            if send_notification:
+                task_result = cache.get(task_key)
+                if task_result == 'error':
+                    self._send_api_task_notification('recovery', **kwargs)
+                    cache.set(task_key, 'success', None)
+        except RequestException as e:
+            if response.status_code in self._RECOVERABLE_API_CODES:
+                retry_logger = logger.warn
+                # When retry limit is reached, use error logging
+                if self.request.retries == self.max_retries:
+                    retry_logger = logger.error
+                retry_logger(
+                    f'Try [{self.request.retries}/{self.max_retries}] '
+                    f'{err_msg}, Error: {e}'
+                )
+                raise e
+            logger.error(f'{err_msg}, Error: {e}')
+            if send_notification:
+                task_result = cache.get(task_key)
+                if task_result in (None, 'success'):
+                    cache.set(task_key, 'error', None)
+                    self._send_api_task_notification(
+                        'error', status_code=response.status_code, **kwargs
+                    )
+        return (response, updated_config) if updated_config else response
+
+
+@shared_task(
+    bind=True,
+    base=OpenwispApiTask,
+    autoretry_for=(RequestException,),
+    **API_TASK_RETRY_OPTIONS,
+)
+def trigger_zerotier_server_update(self, config, vpn_id):
+    Vpn = load_model('config', 'Vpn')
+    vpn = Vpn.objects.get(pk=vpn_id)
+    network_id = vpn.network_id
+    service_method = ZerotierService(
+        vpn.host, vpn.auth_token, vpn.subnet.subnet
+    ).update_network
+    response, updated_config = self.handle_api_call(
+        service_method,
+        config,
+        network_id,
+        instance=vpn,
+        action='update',
+        info=(
+            f'Successfully updated the configuration of '
+            f'ZeroTier VPN Server with UUID: {vpn_id}'
+        ),
+        err=(
+            f'Failed to update ZeroTier VPN Server configuration, '
+            f'VPN Server UUID: {vpn_id}'
+        ),
+    )
+    if response.status_code == 200:
+        vpn.network_id = updated_config.pop('id', None)
+        vpn.config = {**vpn.config, 'zerotier': [updated_config]}
+        # Update zerotier network controller
+        trigger_zerotier_server_update_member.delay(vpn_id)
+
+
+@shared_task(
+    bind=True,
+    base=OpenwispApiTask,
+    autoretry_for=(RequestException,),
+    **API_TASK_RETRY_OPTIONS,
+)
+def trigger_zerotier_server_update_member(self, vpn_id, ip=None, node_id=None):
+    Vpn = load_model('config', 'Vpn')
+    vpn = Vpn.objects.get(pk=vpn_id)
+    network_id = vpn.network_id
+    node_id = node_id or vpn.node_id
+    member_ip = ip or vpn.ip.ip_address
+    service_method = ZerotierService(
+        vpn.host,
+        vpn.auth_token,
+    ).update_network_member
+    self.handle_api_call(
+        service_method,
+        node_id,
+        network_id,
+        member_ip,
+        instance=vpn,
+        action='update_member',
+        info=(
+            f'Successfully updated ZeroTier network member: {node_id}, '
+            f'ZeroTier network: {network_id}, '
+            f'ZeroTier VPN server UUID: {vpn_id}'
+        ),
+        err=(
+            f'Failed to update ZeroTier network member: {node_id}, '
+            f'ZeroTier network: {network_id}, '
+            f'ZeroTier VPN server UUID: {vpn_id}'
+        ),
+    )
+
+
+@shared_task(
+    bind=True,
+    base=OpenwispApiTask,
+    autoretry_for=(RequestException,),
+    **API_TASK_RETRY_OPTIONS,
+)
+def trigger_zerotier_server_remove_member(self, node_id=None, **vpn_kwargs):
+    Vpn = load_model('config', 'Vpn')
+    vpn_id = vpn_kwargs.get('id')
+    host = vpn_kwargs.get('host')
+    auth_token = vpn_kwargs.get('auth_token')
+    network_id = vpn_kwargs.get('network_id')
+    try:
+        vpn = Vpn.objects.get(pk=vpn_id)
+        notification_kwargs = dict(instance=vpn, action='remove_member')
+    # When a ZeroTier VPN server is deleted
+    # and this is followed by the deletion of ZeroTier VPN clients
+    # we won't have access to the VPN server instance. Therefore, we should
+    # refrain from sending a notification for the 'leave member' operation
+    except ObjectDoesNotExist:
+        notification_kwargs = dict(send_notification=False)
+    service_method = ZerotierService(
+        host,
+        auth_token,
+    ).remove_network_member
+    self.handle_api_call(
+        service_method,
+        node_id,
+        network_id,
+        **notification_kwargs,
+        info=(
+            f'Successfully left ZeroTier Network with ID: {network_id}, '
+            f'ZeroTier Member ID: {node_id}, '
+            f'ZeroTier VPN server UUID: {vpn_id}'
+        ),
+        err=(
+            f'Failed to leave ZeroTier Network with ID: {network_id}, '
+            f'ZeroTier Member ID: {node_id}, '
+            f'ZeroTier VPN server UUID: {vpn_id}'
+        ),
+    )
+
+
+@shared_task(
+    bind=True,
+    base=OpenwispApiTask,
+    autoretry_for=(RequestException,),
+    **API_TASK_RETRY_OPTIONS,
+)
+def trigger_zerotier_server_join(self, vpn_id):
+    Vpn = load_model('config', 'Vpn')
+    vpn = Vpn.objects.get(pk=vpn_id)
+    network_id = vpn.network_id
+    service_method = ZerotierService(
+        vpn.host,
+        vpn.auth_token,
+    ).join_network
+    response = self.handle_api_call(
+        service_method,
+        network_id,
+        instance=vpn,
+        action='network_join',
+        info=(
+            f'Successfully joined the ZeroTier network: {network_id}, '
+            f'ZeroTier VPN Server UUID: {vpn_id}'
+        ),
+        err=(
+            f'Failed to join ZeroTier network: {network_id}, '
+            f'VPN Server UUID: {vpn_id}'
+        ),
+    )
+    if response.ok:
+        # Update zerotier network controller
+        trigger_zerotier_server_update_member.delay(vpn_id)
+
+
+@shared_task(
+    bind=True,
+    base=OpenwispApiTask,
+    autoretry_for=(RequestException,),
+    **API_TASK_RETRY_OPTIONS,
+)
+def trigger_zerotier_server_delete(self, host, auth_token, network_id, vpn_id):
+    service_method = ZerotierService(host, auth_token).delete_network
+    response = self.handle_api_call(
+        service_method,
+        network_id,
+        info=(
+            f'Successfully deleted the ZeroTier VPN Server '
+            f'with UUID: {vpn_id}, Network ID: {network_id}'
+        ),
+        err=(
+            'Failed to delete ZeroTier VPN Server '
+            f'with UUID: {vpn_id}, Network ID: {network_id}, '
+            'as it does not exist on the ZeroTier Controller Networks'
+        ),
+        send_notification=False,
+    )
+    # In case of successful deletion of the network
+    # we should also remove controller node from the network
+    if response.status_code == 200:
+        trigger_zerotier_server_leave.delay(host, auth_token, network_id)
+
+
+@shared_task(
+    bind=True,
+    base=OpenwispApiTask,
+    autoretry_for=(RequestException,),
+    **API_TASK_RETRY_OPTIONS,
+)
+def trigger_zerotier_server_leave(self, host, auth_token, network_id):
+    service_method = ZerotierService(host, auth_token).leave_network
+    self.handle_api_call(
+        service_method,
+        network_id,
+        info=f'Successfully left the ZeroTier Network with ID: {network_id}',
+        err=f'Failed to leave ZeroTier Network with ID: {network_id}',
+        send_notification=False,
+    )
diff --git a/openwisp_controller/vpn_backends.py b/openwisp_controller/vpn_backends.py
index d164b0bf2..cbf9c5c4c 100644
--- a/openwisp_controller/vpn_backends.py
+++ b/openwisp_controller/vpn_backends.py
@@ -3,6 +3,7 @@
 from netjsonconfig import OpenVpn as BaseOpenVpn
 from netjsonconfig import VxlanWireguard as BaseVxlanWireguard
 from netjsonconfig import Wireguard as BaseWireguard
+from netjsonconfig import ZeroTier as BaseZeroTier
 from netjsonconfig.backends.vxlan.schema import base_vxlan_properties
 
 # adapt OpenVPN schema in order to limit it to 1 item only
@@ -76,3 +77,30 @@ class VxlanWireguard(BaseVxlanWireguard):
     """
 
     schema = limited_vxlan_wireguard_schema
+
+
+limited_zerotier_schema = deepcopy(BaseZeroTier.schema)
+zerotier_server_properties = limited_zerotier_schema['definitions']['zerotier_server'][
+    'properties'
+]
+# these properties handled automatically without the need of user input
+del zerotier_server_properties['name']
+del zerotier_server_properties['id']
+del zerotier_server_properties['nwid']
+del zerotier_server_properties['objtype']
+del zerotier_server_properties['routes']
+del zerotier_server_properties['revision']
+del zerotier_server_properties['creationTime']
+del zerotier_server_properties['ipAssignmentPools']
+limited_zerotier_schema['definitions']['zerotier_server']['required'].remove('name')
+
+zerotier_properties = limited_zerotier_schema['properties']['zerotier']
+zerotier_properties.update({'additionalItems': False, 'maxItems': 1, 'minItems': 1})
+
+
+class ZeroTier(BaseZeroTier):
+    """
+    ZeroTier
+    """
+
+    schema = limited_zerotier_schema
diff --git a/requirements.txt b/requirements.txt
index b40e517f2..c16ca097c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
 django-sortedm2m~=3.1.1
 django-reversion~=5.0.4
 django-taggit~=4.0.0
-netjsonconfig @ https://github.com/openwisp/netjsonconfig/tarball/master
+netjsonconfig @ https://github.com/openwisp/netjsonconfig/tarball/gsoc23
 django-x509 @ https://github.com/openwisp/django-x509/tarball/master
 django-loci @ https://github.com/openwisp/django-loci/tarball/master
 django-flat-json-widget @ https://github.com/openwisp/django-flat-json-widget/tarball/master


Test patch:
diff --git a/openwisp_controller/config/tests/test_notifications.py b/openwisp_controller/config/tests/test_notifications.py
index 819b919b9..9b2627921 100644
--- a/openwisp_controller/config/tests/test_notifications.py
+++ b/openwisp_controller/config/tests/test_notifications.py
@@ -1,22 +1,43 @@
 from unittest.mock import patch
 
+from celery.exceptions import Retry
 from django.apps.registry import apps
+from django.conf import settings
 from django.test import TransactionTestCase
+from openwisp_ipam.tests import CreateModelsMixin as CreateIpamModelsMixin
+from requests.exceptions import RequestException
 from swapper import load_model
 
-from openwisp_controller.config.tests.utils import CreateConfigMixin
+from openwisp_controller.config.tests.utils import (
+    CreateConfigMixin,
+    TestZeroTierVpnMixin,
+)
 from openwisp_users.tests.utils import TestOrganizationMixin
 
+from ..settings import API_TASK_RETRY_OPTIONS
 from ..signals import device_registered
 
+Vpn = load_model('config', 'Vpn')
 Device = load_model('config', 'Device')
 Notification = load_model('openwisp_notifications', 'Notification')
 
 notification_qs = Notification.objects.all()
 
 
-class TestNotifications(CreateConfigMixin, TestOrganizationMixin, TransactionTestCase):
+class TestNotifications(
+    CreateConfigMixin,
+    CreateIpamModelsMixin,
+    TestOrganizationMixin,
+    TestZeroTierVpnMixin,
+    TransactionTestCase,
+):
     app_label = 'config'
+    _ZT_SERVICE_REQUESTS = 'openwisp_controller.config.api.zerotier_service.requests'
+    _ZT_API_TASKS_INFO_LOGGER = 'openwisp_controller.config.tasks_zerotier.logger.info'
+    _ZT_API_TASKS_WARN_LOGGER = 'openwisp_controller.config.tasks_zerotier.logger.warn'
+    _ZT_API_TASKS_ERR_LOGGER = 'openwisp_controller.config.tasks_zerotier.logger.error'
+    # As the locmem cache does not support the redis backend cache.keys() method
+    _ZT_API_TASKS_LOCMEM_CACHE_KEYS = f"{settings.CACHES['default']['BACKEND']}.keys"
 
     def setUp(self):
         self.admin = self._get_admin()
@@ -79,3 +100,246 @@ def test_default_notification_type_already_unregistered(self):
         # No exception should be raised as the exception is already handled.
         app = apps.get_app_config(self.app_label)
         app.ready()
+
+    @patch(_ZT_API_TASKS_LOCMEM_CACHE_KEYS, create=True)
+    @patch(_ZT_API_TASKS_ERR_LOGGER)
+    @patch(_ZT_API_TASKS_WARN_LOGGER)
+    @patch(_ZT_API_TASKS_INFO_LOGGER)
+    @patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_api_tasks_notification(
+        self,
+        mock_requests,
+        mock_info,
+        mock_warn,
+        mock_error,
+        mock_locmem_cache_keys,
+    ):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200),
+            # For controller network join
+            self._get_mock_response(200),
+            # For controller auth and ip assignment
+            self._get_mock_response(200),
+        ]
+        mock_locmem_cache_keys.return_value = ['test_zt_api_tasks_notification_key']
+        vpn = self._create_zerotier_vpn()
+        self.assertEqual(Vpn.objects.count(), 1)
+        notification_qs = Notification.objects.all()
+        # Make sure no notification is generated
+        # for the vpn server creation
+        self.assertEqual(notification_qs.count(), 0)
+        self.assertEqual(mock_info.call_count, 2)
+        mock_info.reset_mock()
+        mock_requests.reset_mock()
+
+        with self.subTest(
+            'Test no notifications are generated for the vpn server update'
+        ):
+            mock_requests.get.side_effect = [
+                # For node status
+                self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+            ]
+            mock_requests.post.side_effect = [
+                # For update network
+                self._get_mock_response(200),
+                # For controller auth and ip assignment
+                self._get_mock_response(200),
+            ]
+            # Let's update the vpn config
+            config = vpn.get_config()['zerotier'][0]
+            config.update({'private': True})
+            vpn.full_clean()
+            vpn.save()
+            self.assertEqual(mock_info.call_count, 2)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(notification_qs.count(), 0)
+        mock_info.reset_mock()
+        mock_requests.reset_mock()
+
+        with self.subTest(
+            (
+                'Test no notifications are generated for '
+                'the vpn server api tasks (retry mechanism)'
+            )
+        ), patch('celery.app.task.Task.request') as mock_task_request:
+            max_retries = API_TASK_RETRY_OPTIONS.get('max_retries')
+            mock_task_request.called_directly = False
+            config = vpn.get_config()['zerotier'][0]
+            config.update({'private': True})
+
+            with self.subTest(
+                'Test notification when update when max retry limit is not reached'
+            ), self.assertRaises(Retry):
+                mock_requests.get.side_effect = [
+                    # For node status
+                    self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+                ]
+                mock_requests.post.side_effect = [
+                    # For update network
+                    self._get_mock_response(200),
+                    # For controller auth and ip assignment
+                    # (internal server error)
+                    self._get_mock_response(
+                        500,
+                        response={},
+                        exc=RequestException,
+                    ),
+                ]
+                # Second last retry attempt (4th)
+                mock_task_request.retries = max_retries - 1
+                vpn.full_clean()
+                vpn.save()
+            self.assertEqual(mock_info.call_count, 1)
+            # Ensure that it logs with the 'warning' level
+            self.assertEqual(mock_warn.call_count, 1)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(notification_qs.count(), 0)
+            mock_info.reset_mock()
+            mock_warn.reset_mock()
+            mock_requests.reset_mock()
+
+            with self.subTest(
+                'Test notification when update when max retry limit is reached'
+            ), self.assertRaises(RequestException):
+                mock_requests.get.side_effect = [
+                    # For node status
+                    self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+                ]
+                mock_requests.post.side_effect = [
+                    # For update network
+                    self._get_mock_response(200),
+                    # For controller auth and ip assignment
+                    # (internal server error)
+                    self._get_mock_response(
+                        500,
+                        response={},
+                        exc=RequestException,
+                    ),
+                ]
+                # Last retry attempt (5th)
+                mock_task_request.retries = max_retries
+                vpn.full_clean()
+                vpn.save()
+            self.assertEqual(mock_info.call_count, 1)
+            self.assertEqual(mock_warn.call_count, 0)
+            # Ensure that it logs last attempt with the 'error' level
+            self.assertEqual(mock_error.call_count, 1)
+            self.assertEqual(notification_qs.count(), 0)
+            mock_info.reset_mock()
+            mock_error.reset_mock()
+            mock_requests.reset_mock()
+
+        with self.subTest(
+            'Test notifications are generated for API tasks (unrecoverable errors)'
+        ):
+
+            with self.subTest(
+                'Test error notification on failure of first call (update network)'
+            ):
+                # mock_get.return_value = None
+                mock_requests.get.side_effect = [
+                    # For node status
+                    self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+                ]
+                mock_requests.post.side_effect = [
+                    # During an update network failure (bad request)
+                    # the task will not perform the update network member operation
+                    # and cache.get(task_key) will be 'None'
+                    self._get_mock_response(
+                        400,
+                        response={'message': 'body JSON is invalid'},
+                        exc=RequestException,
+                    ),
+                ]
+                config = vpn.get_config()['zerotier'][0]
+                config.update({'private': True})
+                vpn.full_clean()
+                vpn.save()
+                self.assertEqual(mock_info.call_count, 0)
+                self.assertEqual(mock_warn.call_count, 0)
+                # For unrecoverable error
+                self.assertEqual(mock_error.call_count, 1)
+                self.assertEqual(notification_qs.count(), 1)
+                notification = notification_qs.first()
+                self.assertEqual(notification.actor, vpn)
+                self.assertEqual(notification.target, vpn)
+                self.assertEqual(notification.type, 'api_task_error')
+                self.assertIn(
+                    'Unable to perform update operation', notification.message
+                )
+
+            mock_info.reset_mock()
+            mock_error.reset_mock()
+            mock_requests.reset_mock()
+            notification_qs.delete()
+
+            with self.subTest(
+                (
+                    'Test recovery notification on first call (update network)'
+                    'and error notification on second call (update network member)'
+                )
+            ):
+                mock_requests.get.side_effect = [
+                    # For node status
+                    self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+                ]
+                mock_requests.post.side_effect = [
+                    # For update network
+                    self._get_mock_response(200),
+                    # For controller auth and ip assignment
+                    # (bad request)
+                    self._get_mock_response(
+                        400,
+                        response={},
+                        exc=RequestException,
+                    ),
+                ]
+                config = vpn.get_config()['zerotier'][0]
+                config.update({'private': True})
+                vpn.full_clean()
+                vpn.save()
+                self.assertEqual(notification_qs.count(), 2)
+                # For successful network update
+                notification_error = notification_qs.first()
+                notification_recovery = notification_qs.last()
+                self.assertEqual(mock_info.call_count, 1)
+                self.assertEqual(notification_recovery.actor, vpn)
+                self.assertEqual(notification_recovery.target, vpn)
+                self.assertEqual(notification_recovery.type, 'api_task_recovery')
+                self.assertIn('The update operation on', notification_recovery.message)
+                # For unrecoverable error
+                self.assertEqual(mock_warn.call_count, 0)
+                self.assertEqual(mock_error.call_count, 1)
+                self.assertEqual(notification_error.actor, vpn)
+                self.assertEqual(notification_error.target, vpn)
+                self.assertEqual(notification_error.type, 'api_task_error')
+                self.assertIn(
+                    'Unable to perform update member operation',
+                    notification_error.message,
+                )
+
+            mock_info.reset_mock()
+            mock_error.reset_mock()
+            mock_requests.reset_mock()
+            notification_qs.delete()
+
+        with self.subTest('Test no notifications are generated for vpn deletion'):
+            mock_requests.delete.side_effect = [
+                # For delete network
+                self._get_mock_response(200, response={}),
+                # For controller leave network
+                self._get_mock_response(200, response={}),
+            ]
+            vpn.delete()
+            self.assertEqual(Vpn.objects.count(), 0)
+            # Only logging for vpn deletion & leave network
+            self.assertEqual(mock_info.call_count, 2)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(notification_qs.count(), 0)
diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py
index c8f56709c..4b69d1dc1 100644
--- a/openwisp_controller/config/tests/test_vpn.py
+++ b/openwisp_controller/config/tests/test_vpn.py
@@ -1,18 +1,24 @@
 import json
+from subprocess import CalledProcessError, TimeoutExpired
 from unittest import mock
 
-from celery.exceptions import SoftTimeLimitExceeded
+from celery.exceptions import Retry, SoftTimeLimitExceeded
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.db.models.signals import post_save
+from django.db.utils import IntegrityError
 from django.http.response import HttpResponse, HttpResponseNotFound
 from django.test import TestCase, TransactionTestCase
 from openwisp_ipam.tests import CreateModelsMixin as CreateIpamModelsMixin
+from requests.exceptions import ConnectionError, RequestException, Timeout
 from swapper import load_model
 
 from openwisp_utils.tests import catch_signal
 
 from ...vpn_backends import OpenVpn
 from .. import settings as app_settings
+from ..exceptions import ZeroTierIdentityGenerationError
+from ..settings import API_TASK_RETRY_OPTIONS
 from ..signals import config_modified, vpn_peers_changed, vpn_server_modified
 from ..tasks import create_vpn_dh
 from .utils import (
@@ -20,6 +26,7 @@
     TestVpnX509Mixin,
     TestVxlanWireguardVpnMixin,
     TestWireguardVpnMixin,
+    TestZeroTierVpnMixin,
 )
 
 Config = load_model('config', 'Config')
@@ -996,3 +1003,1036 @@ def test_unicast_vxlan_tunnels(self):
                 {'interface': 'vxlan1', 'remote': '10.0.0.3', 'vni': 1},
             ],
         )
+
+
+class TestZeroTier(BaseTestVpn, TestZeroTierVpnMixin, TestCase):
+    _ZT_SERVICE_REQUESTS = 'openwisp_controller.config.api.zerotier_service.requests'
+    _ZT_GENERATE_IDENTITY_SUBPROCESS = 'openwisp_controller.config.base.vpn.subprocess'
+
+    def _set_subprocess_mock(self, mock_sub):
+        mock_stdout = mock.MagicMock()
+        mock_stdout.stdout.decode.return_value = self._TEST_ZT_MEMBER_CONFIG['identity']
+        mock_sub.run.return_value = mock_stdout
+
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_config_creation(self, mock_requests):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        vpn = self._create_zerotier_vpn()
+
+        with self.subTest('test context'):
+            context = vpn.get_context()
+            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)
+            # Make sure zerotier network related context keys are present
+            self.assertEqual(context['node_id'], vpn.node_id)
+            self.assertEqual(context['network_id'], vpn.network_id)
+
+        with self.subTest('test context keys'):
+            context_keys = vpn._get_auto_context_keys()
+            self.assertIn('vpn_host', context_keys)
+            self.assertIn('vpn_port', context_keys)
+            self.assertIn('ip_address', context_keys)
+            self.assertIn('server_ip_address', context_keys)
+            self.assertIn('server_ip_network', context_keys)
+            # Make sure zerotier network related context keys are present
+            self.assertIn('node_id', context_keys)
+            self.assertIn('network_id', context_keys)
+            self.assertIn('network_name', context_keys)
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_auto_cert_false(self, mock_requests, mock_subprocess):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        self._set_subprocess_mock(mock_subprocess)
+        device, vpn, template = self._create_zerotier_vpn_template(auto_cert=False)
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 1)
+        # When auto_cert is 'False', make sure subprocess.run is not called
+        self.assertEqual(mock_subprocess.run.call_count, 0)
+        vpnclient = vpnclient_qs.first()
+        self.assertEqual(vpnclient.zerotier_member_id, '')
+        self.assertEqual(vpnclient.secret, '')
+        self.assertEqual(vpnclient.private_key, '')
+        self.assertEqual(vpnclient.public_key, '')
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_ip_deleted_when_vpnclient_deleted(self, mock_requests, mock_subprocess):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        self._set_subprocess_mock(mock_subprocess)
+        device, vpn, template = self._create_zerotier_vpn_template()
+        self.assertEqual(IpAddress.objects.count(), 2)
+        self.assertEqual(mock_subprocess.run.call_count, 1)
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        vpnclient_qs.first().delete()
+        self.assertEqual(IpAddress.objects.count(), 1)
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_ip_deleted_when_device_deleted(self, mock_requests, mock_subprocess):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        self._set_subprocess_mock(mock_subprocess)
+        device, vpn, template = self._create_zerotier_vpn_template()
+        self.assertEqual(mock_subprocess.run.call_count, 1)
+        self.assertEqual(device.config.vpnclient_set.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 2)
+        device.delete()
+        self.assertEqual(IpAddress.objects.count(), 1)
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_delete_vpnclient_ip(self, mock_requests, mock_subprocess):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        self._set_subprocess_mock(mock_subprocess)
+        device, vpn, template = self._create_zerotier_vpn_template()
+        self.assertEqual(mock_subprocess.run.call_count, 1)
+        self.assertEqual(device.config.vpnclient_set.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 2)
+        vpnclient = device.config.vpnclient_set.first()
+        vpnclient.ip.delete()
+        self.assertEqual(device.config.vpnclient_set.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 1)
+
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_ip_within_subnet(self, mock_requests):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        org = self._get_org()
+        subnet1 = self._create_subnet(subnet='10.0.1.0/24', organization=org)
+        subnet2 = self._create_subnet(subnet='10.150.0.0/24', organization=org)
+        ip_subnet2 = subnet2.request_ip()
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_zerotier_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']
+        )
+
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_creation_error(self, mock_requests):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            # (internal server error)
+            self._get_mock_response(500, response={}, exc=RequestException)
+        ]
+        expected_error = 'Failed to create ZeroTier network, Error: '
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_zerotier_vpn()
+        # Make sure zt vpn server is not created
+        self.assertEqual(Vpn.objects.count(), 0)
+        self.assertIn(expected_error, str(context_manager.exception))
+
+    def test_zerotier_schema(self):
+        with self.subTest('zerotier schema shall be valid'):
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_zerotier_vpn(config={'zerotier': []})
+            self.assertIn(
+                'Invalid configuration triggered by "#/zerotier"',
+                str(context_manager.exception),
+            )
+        # Delete subnet created for previous assertion
+        Subnet.objects.all().delete()
+
+        with self.subTest('zerotier property shall be present'):
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_zerotier_vpn(config={})
+            self.assertIn('zerotier', str(context_manager.exception))
+            self.assertIn('is a required property', str(context_manager.exception))
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_auto_client(self, mock_requests, mock_subprocess):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        self._set_subprocess_mock(mock_subprocess)
+        device, vpn, template = self._create_zerotier_vpn_template()
+        self.assertEqual(mock_subprocess.run.call_count, 1)
+        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.zerotier_auto_client(
+            name='ow_zt',
+            networks=[{'id': vpn.network_id, 'ifname': f'owzt{vpn.network_id[-6:]}'}],
+            identity_secret=context_keys['secret'],
+        )
+        self.assertEqual(auto, expected)
+
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_change_vpn_backend(self, mock_requests):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200)
+        ]
+        vpn = self._create_vpn(name='new', backend=self._BACKENDS['openvpn'])
+        subnet = self._create_subnet(
+            name='test-zerotier-subnet',
+            subnet='10.0.0.0/16',
+            organization=vpn.organization,
+        )
+        ca = vpn.ca
+
+        vpn.backend = self._BACKENDS['zerotier']
+        vpn.subnet = subnet
+        vpn.config = {
+            'zerotier': [
+                {
+                    'private': True,
+                    'enableBroadcast': True,
+                    'mtu': 2800,
+                    'multicastLimit': 32,
+                }
+            ]
+        }
+        vpn.auth_token = 'test_auth_token'
+        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, subnet)
+        self.assertNotEqual(vpn.ip, None)
+
+    def test_zerotier_vpn_without_subnet(self):
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_zerotier_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_zerotier_vpn_without_auth_token(self):
+        with self.assertRaises(ValidationError) as context_manager:
+            self._create_zerotier_vpn(auth_token=None)
+        expected_error_dict = {
+            'auth_token': ['Auth token is required for this VPN backend']
+        }
+        self.assertEqual(expected_error_dict, context_manager.exception.message_dict)
+
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_vpn_host_validation(self, mock_requests):
+        with self.subTest('Test host connection error'):
+            # node status (connection error)
+            mock_requests.get.side_effect = ConnectionError()
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_zerotier_vpn()
+            expected_error_dict = {
+                'host': ['Failed to connect to the ZeroTier controller, Error: ']
+            }
+            self.assertEqual(
+                expected_error_dict, context_manager.exception.message_dict
+            )
+        mock_requests.reset_mock()
+        # Delete subnet created for previous assertion
+        Subnet.objects.all().delete()
+
+        with self.subTest('Test host timeout error'):
+            # node status (timeout error)
+            mock_requests.get.side_effect = Timeout()
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_zerotier_vpn()
+            expected_error_dict = {
+                'host': ['Failed to connect to the ZeroTier controller, Error: ']
+            }
+            self.assertEqual(
+                expected_error_dict, context_manager.exception.message_dict
+            )
+        mock_requests.reset_mock()
+        Subnet.objects.all().delete()
+
+        with self.subTest('Test auth token unauthorized error'):
+            mock_requests.get.side_effect = [self._get_mock_response(401, response={})]
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_zerotier_vpn()
+            expected_error_dict = {
+                'auth_token': [
+                    (
+                        'Authorization failed for ZeroTier controller, '
+                        'ensure you are using the correct authorization token'
+                    )
+                ]
+            }
+            self.assertEqual(
+                expected_error_dict, context_manager.exception.message_dict
+            )
+        mock_requests.reset_mock()
+        Subnet.objects.all().delete()
+
+        with self.subTest('Test for any other request errors'):
+            # node status (internal server error)
+            mock_requests.get.side_effect = [
+                self._get_mock_response(500, response={}, err='Internal Server Error')
+            ]
+            with self.assertRaises(ValidationError) as context_manager:
+                self._create_zerotier_vpn()
+            expected_error_dict = {
+                'host': [
+                    (
+                        'Failed to connect to the ZeroTier controller, '
+                        'ensure you are using the correct hostname '
+                        '(error: Internal Server Error, status code: 500)'
+                    )
+                ]
+            }
+            self.assertEqual(
+                expected_error_dict, context_manager.exception.message_dict
+            )
+
+    def test_zerotier_change_vpn_backend_with_vpnclient(self):
+        vpn = self._create_vpn(name='new', backend=self._BACKENDS['openvpn'])
+        subnet = self._create_subnet(
+            name='zerotier', 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.subTest(
+            'Test validation error is not raised when backend is unchanged'
+        ):
+            try:
+                vpn.full_clean()
+            except ValidationError as error:
+                self.fail(f'Unexpected ValidationError: {error}')
+
+        with self.subTest('Test validation error is raised when backend is changed'):
+            with self.assertRaises(ValidationError) as context_manager:
+                vpn.backend = self._BACKENDS['zerotier']
+                vpn.subnet = subnet
+                vpn.full_clean()
+            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 TestZeroTierTransaction(
+    BaseTestVpn, TestZeroTierVpnMixin, TestWireguardVpnMixin, TransactionTestCase
+):
+    _ZT_SERVICE_REQUESTS = 'openwisp_controller.config.api.zerotier_service.requests'
+    _ZT_API_TASKS_INFO_LOGGER = 'openwisp_controller.config.tasks_zerotier.logger.info'
+    _ZT_API_TASKS_WARN_LOGGER = 'openwisp_controller.config.tasks_zerotier.logger.warn'
+    _ZT_API_TASKS_ERR_LOGGER = 'openwisp_controller.config.tasks_zerotier.logger.error'
+    # As the locmem cache does not support the redis backend cache.keys() method
+    _ZT_API_TASKS_LOCMEM_CACHE_KEYS = f"{settings.CACHES['default']['BACKEND']}.keys"
+    _ZT_GENERATE_IDENTITY_SUBPROCESS = 'openwisp_controller.config.base.vpn.subprocess'
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_auto_clients_configuration(self, mock_requests, mock_subprocess):
+        # We are testing zerotier transactions
+        # using `mock_requests` calls, this includes
+        # background tasks that execute on commit
+        # such as `join_network`,`update_network`, `delete_network`, etc
+
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200),
+            # For controller network join
+            self._get_mock_response(200),
+            # For controller auth and ip assignment
+            self._get_mock_response(200),
+            # For member auth and ip assignment
+            self._get_mock_response(200),
+        ]
+        mock_stdout = mock.MagicMock()
+        mock_stdout.stdout.decode.return_value = self._TEST_ZT_MEMBER_CONFIG['identity']
+        mock_subprocess.run.return_value = mock_stdout
+        self.assertEqual(IpAddress.objects.count(), 0)
+        device, vpn, template = self._create_zerotier_vpn_template()
+        vpnclient_qs = device.config.vpnclient_set
+        self.assertEqual(vpnclient_qs.count(), 1)
+        self.assertEqual(IpAddress.objects.count(), 2)
+        self.assertEqual(mock_subprocess.run.call_count, 1)
+        context = device.get_context()
+
+        with self.subTest('Test zerotier vpn related device 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'vpn_subnet_{pk}'], '10.0.0.0/16')
+            self.assertEqual(
+                context[f'node_id_{pk}'], self._TEST_ZT_NODE_CONFIG['address']
+            )
+            self.assertEqual(
+                context[f'network_id_{pk}'], self._TEST_ZT_NETWORK_CONFIG['id']
+            )
+            self.assertEqual(
+                context[f'network_name_{pk}'], self._TEST_ZT_NETWORK_CONFIG['name']
+            )
+
+        with self.subTest('Test zerotier vpn client device context'):
+            self.assertEqual(
+                context['zerotier_member_id'], self._TEST_ZT_MEMBER_CONFIG['address']
+            )
+            self.assertEqual(context['secret'], self._TEST_ZT_MEMBER_CONFIG['identity'])
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_vpn_client_identity_error(self, mock_requests, mock_subprocess):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200),
+            # For controller network join
+            self._get_mock_response(200),
+            # For controller auth and ip assignment
+            self._get_mock_response(200),
+            # For member auth and ip assignment
+            self._get_mock_response(200),
+        ]
+        self.assertEqual(IpAddress.objects.count(), 0)
+        zt_cmd = ['zerotier-idtool generate']
+        # command not found, timeout error
+        mock_subprocess.run.side_effect = [
+            CalledProcessError(returncode=127, cmd=zt_cmd),
+            TimeoutExpired(timeout=5, cmd=zt_cmd),
+        ]
+        mock_subprocess.stderr.return_value = None
+
+        vpn = self._create_zerotier_vpn()
+        self.assertEqual(IpAddress.objects.count(), 1)
+        template = self._create_template(
+            name='test-zerotier-template',
+            type='vpn',
+            vpn=vpn,
+            organization=vpn.organization,
+            auto_cert=True,
+        )
+        device = self._create_device_config()
+
+        with self.subTest(
+            'Test zt identity error due to CalledProcessError (zt is not installed)'
+        ):
+            expected_err = (
+                'Unable to generate zerotier identity secret, '
+                f"Error: Command '{zt_cmd}' returned non-zero exit status 127."
+            )
+            with self.assertRaises(ZeroTierIdentityGenerationError) as exc:
+                device.config.templates.add(template)
+
+            self.assertEqual(expected_err, str(exc.exception))
+
+        # Delete subnet created for previous assertion
+        Subnet.objects.all().delete()
+
+        with self.subTest('Test zt identity error due to TimeoutExpired'):
+            expected_err = (
+                'Unable to generate zerotier identity secret, '
+                f"Error: Command '{zt_cmd}' timed out after 5 seconds"
+            )
+
+            with self.assertRaises(ZeroTierIdentityGenerationError) as exc:
+                device.config.templates.add(template)
+            self.assertEqual(expected_err, str(exc.exception))
+
+    @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+    @mock.patch(_ZT_API_TASKS_ERR_LOGGER)
+    @mock.patch(_ZT_API_TASKS_WARN_LOGGER)
+    @mock.patch(_ZT_API_TASKS_INFO_LOGGER)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_vpn_client_template(
+        self, mock_requests, mock_info, mock_warn, mock_error, mock_subprocess
+    ):
+        def _reset_mocks():
+            mock_info.reset_mock()
+            mock_requests.reset_mock()
+            mock_subprocess.reset_mock()
+
+        mock_requests.get.side_effect = [
+            # For node status (vpn1)
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG),
+            # For node status (vpn2)
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG),
+        ]
+        mock_requests.post.side_effect = [
+            # For create network (vpn1)
+            self._get_mock_response(200),
+            # For controller network join (vpn1)
+            self._get_mock_response(200),
+            # For controller auth and ip assignment (vpn1)
+            self._get_mock_response(200),
+            # For create network (vpn2)
+            self._get_mock_response(200),
+            # For controller network join (vpn2)
+            self._get_mock_response(200),
+            # For controller auth and ip assignment (vpn2)
+            self._get_mock_response(200),
+            # For member auth and ip assignment (wirguard + vpn1)
+            self._get_mock_response(200),
+            # For member auth and ip assignment (only vpn1)
+            self._get_mock_response(200),
+            # For member auth and ip assignment (vpn1 + vpn2)
+            self._get_mock_response(200),
+        ]
+        mock_requests.delete.side_effect = [
+            # For remove network member (vpn1)
+            self._get_mock_response(200),
+        ]
+        mock_stdout = mock.MagicMock()
+        mock_stdout.stdout.decode.return_value = self._TEST_ZT_MEMBER_CONFIG['identity']
+        mock_subprocess.run.return_value = mock_stdout
+        self.assertEqual(IpAddress.objects.count(), 0)
+
+        subnet1 = self._create_subnet(
+            name='test-zerotier-subnet-1', subnet='10.150.0.0/24', organization=None
+        )
+        subnet2 = self._create_subnet(
+            name='test-zerotier-subnet-2', subnet='10.250.0.0/24', organization=None
+        )
+        zt_vpn1 = self._create_zerotier_vpn(subnet=subnet1)
+        zt_vpn2 = self._create_zerotier_vpn(name='test-zerotier-vpn-2', subnet=subnet2)
+        device = self._create_device_config()
+        vpnclient_qs = device.config.vpnclient_set
+        zt1 = self._create_template(
+            name='test-zt-template-1',
+            type='vpn',
+            vpn=zt_vpn1,
+            organization=zt_vpn1.organization,
+            auto_cert=True,
+        )
+        zt_1_copy = self._create_template(
+            name='test-zt-template-1-copy',
+            type='vpn',
+            vpn=zt_vpn1,
+            organization=zt_vpn1.organization,
+            auto_cert=True,
+        )
+        zt2 = self._create_template(
+            name='test-zt-template-2',
+            type='vpn',
+            vpn=zt_vpn2,
+            organization=zt_vpn2.organization,
+            auto_cert=True,
+        )
+        wg_vpn = self._create_wireguard_vpn()
+        wg_template = self._create_template(
+            name='wireguard',
+            type='vpn',
+            vpn=wg_vpn,
+            organization=wg_vpn.organization,
+            auto_cert=True,
+        )
+        # Let's add wireguard vpn client template to the device
+        device.config.templates.add(wg_template)
+        # Ensure ip address objects are created
+        # for all three vpn servers and wg vpn client
+        self.assertEqual(IpAddress.objects.count(), 4)
+        self.assertEqual(vpnclient_qs.count(), 1)
+        self.assertEqual(mock_info.call_count, 4)
+        self.assertEqual(mock_warn.call_count, 0)
+        self.assertEqual(mock_error.call_count, 0)
+        self.assertEqual(mock_subprocess.run.call_count, 0)
+        self.assertEqual(mock_requests.get.call_count, 2)
+        self.assertEqual(mock_requests.post.call_count, 6)
+        self.assertEqual(mock_requests.delete.call_count, 0)
+        _reset_mocks()
+
+        with self.subTest(
+            (
+                'Test zt identity generation when different vpn client '
+                '(wireguard) template is already applied to the device'
+            )
+        ):
+            device.config.templates.add(zt1)
+            # New vpn client object is created
+            self.assertEqual(vpnclient_qs.count(), 2)
+            self.assertEqual(vpnclient_qs.filter(vpn=zt_vpn1).exists(), True)
+            # New ip address object for zt vpn client object is created
+            self.assertEqual(IpAddress.objects.count(), 5)
+            # Make sure subprocess is called for identity generation
+            self.assertEqual(mock_subprocess.run.call_count, 1)
+            self.assertEqual(mock_info.call_count, 1)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(mock_requests.post.call_count, 1)
+
+        # Now remove zt and wg vpn template from the device
+        device.config.templates.remove(zt1)
+        device.config.templates.remove(wg_template)
+        self.assertEqual(IpAddress.objects.count(), 3)
+        self.assertEqual(mock_requests.delete.call_count, 1)
+        _reset_mocks()
+
+        with self.subTest(
+            (
+                'Test zt identity generation when only zt '
+                'vpn client template is applied to the device'
+            )
+        ):
+            device.config.templates.add(zt1)
+            self.assertEqual(vpnclient_qs.count(), 1)
+            self.assertEqual(vpnclient_qs.first().vpn, zt_vpn1)
+            # New ip address object for zt vpn client object is created
+            self.assertEqual(IpAddress.objects.count(), 4)
+            # Make sure subprocess is called for identity generation
+            self.assertEqual(mock_subprocess.run.call_count, 1)
+            self.assertEqual(mock_info.call_count, 1)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(mock_requests.post.call_count, 1)
+        _reset_mocks()
+
+        with self.subTest(
+            (
+                'Test zt no identity generation when same zt vpn server '
+                'vpn client template is already applied to the device'
+            )
+        ):
+            device.config.templates.add(zt_1_copy)
+            # No new zt vpn client object
+            self.assertEqual(vpnclient_qs.count(), 1)
+            # Make sure only previously applied 'zt_vpn1' vpn client object is exist
+            self.assertEqual(vpnclient_qs.first().vpn, zt_vpn1)
+            self.assertEqual(IpAddress.objects.count(), 4)
+            # Make sure subprocess is not called for identity generation
+            self.assertEqual(mock_subprocess.run.call_count, 0)
+            # No configuration changed
+            self.assertEqual(mock_info.call_count, 0)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(mock_requests.post.call_count, 0)
+        _reset_mocks()
+
+        with self.subTest(
+            (
+                'Test zt no identity generation when different '
+                'zt vpn server vpn client template is applied to the device'
+            )
+        ):
+            device.config.templates.add(zt2)
+            # New zt vpn client object is created
+            self.assertEqual(vpnclient_qs.count(), 2)
+            self.assertEqual(vpnclient_qs.first().vpn, zt_vpn1)
+            self.assertEqual(vpnclient_qs.last().vpn, zt_vpn2)
+            # Ensure that new ip address object are created
+            self.assertEqual(IpAddress.objects.count(), 5)
+            # Make sure subprocess is not called for identity generation
+            self.assertEqual(mock_subprocess.run.call_count, 0)
+            self.assertEqual(mock_info.call_count, 1)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertEqual(mock_requests.post.call_count, 1)
+
+    @mock.patch(_ZT_API_TASKS_ERR_LOGGER)
+    @mock.patch(_ZT_API_TASKS_WARN_LOGGER)
+    @mock.patch(_ZT_API_TASKS_INFO_LOGGER)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_update_vpn_server_configuration(
+        self, mock_requests, mock_info, mock_warn, mock_error
+    ):
+        mock_requests.get.side_effect = [
+            # For node status
+            self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+        ]
+        mock_requests.post.side_effect = [
+            # For create network
+            self._get_mock_response(200),
+            # For controller network join
+            self._get_mock_response(200),
+            # For controller auth and ip assignment
+            self._get_mock_response(200),
+        ]
+        # Now create zerotier network
+        vpn = self._create_zerotier_vpn()
+        self.assertEqual(Vpn.objects.count(), 1)
+        self.assertEqual(vpn.name, self._TEST_ZT_NETWORK_CONFIG['name'])
+        mock_info.reset_mock()
+        mock_requests.reset_mock()
+
+        with self.subTest('Test zerotier successful configuration update'):
+            mock_requests.get.side_effect = [
+                # For node status
+                self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+            ]
+            mock_requests.post.side_effect = [
+                # For update network
+                self._get_mock_response(200),
+                # For controller auth and ip assignment
+                self._get_mock_response(200),
+            ]
+            _EXPECTED_INFO_CALLS = [
+                mock.call(
+                    (
+                        f'Successfully updated the configuration of '
+                        f'ZeroTier VPN Server with UUID: {vpn.id}'
+                    )
+                ),
+                mock.call(
+                    (
+                        f'Successfully updated ZeroTier network member: {vpn.node_id}, '
+                        f'ZeroTier network: {vpn.network_id}, '
+                        f'ZeroTier VPN server UUID: {vpn.id}'
+                    )
+                ),
+            ]
+            config = vpn.get_config()['zerotier'][0]
+            config.update({'private': True})
+            vpn.full_clean()
+            vpn.save()
+            self.assertEqual(mock_info.call_count, 2)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            mock_info.assert_has_calls(_EXPECTED_INFO_CALLS)
+        mock_info.reset_mock()
+        mock_requests.reset_mock()
+
+        with self.subTest('Test zerotier configuration update (unrecoverable errors)'):
+            mock_requests.get.side_effect = [
+                # For node status
+                self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+            ]
+            mock_requests.post.side_effect = [
+                # For update network
+                self._get_mock_response(200),
+                # For controller auth and ip assignment (bad request)
+                self._get_mock_response(
+                    400,
+                    response={},
+                    exc=RequestException,
+                ),
+            ]
+            _EXPECTED_INFO_CALLS = [
+                mock.call(
+                    (
+                        f'Successfully updated the configuration of '
+                        f'ZeroTier VPN Server with UUID: {vpn.id}'
+                    )
+                ),
+            ]
+            _EXPECTED_ERROR_CALLS = [
+                mock.call(
+                    (
+                        f'Failed to update ZeroTier network member: {vpn.node_id}, '
+                        f'ZeroTier network: {vpn.network_id}, '
+                        f'ZeroTier VPN server UUID: {vpn.id}, Error: '
+                    )
+                ),
+            ]
+            config = vpn.get_config()['zerotier'][0]
+            config.update({'private': True})
+            vpn.full_clean()
+            vpn.save()
+            self.assertEqual(mock_info.call_count, 1)
+            self.assertEqual(mock_warn.call_count, 0)
+            # For unrecoverable error
+            self.assertEqual(mock_error.call_count, 1)
+            mock_info.assert_has_calls(_EXPECTED_INFO_CALLS)
+            mock_error.assert_has_calls(_EXPECTED_ERROR_CALLS)
+        mock_info.reset_mock()
+        mock_error.reset_mock()
+        mock_requests.reset_mock()
+
+        with self.subTest(
+            'Test zerotier configuration update '
+            'with retry mechanism (recoverable errors)'
+        ), mock.patch('celery.app.task.Task.request') as mock_task_request:
+            max_retries = API_TASK_RETRY_OPTIONS.get('max_retries')
+            mock_task_request.called_directly = False
+            config = vpn.get_config()['zerotier'][0]
+            config.update({'private': True})
+
+            with self.subTest(
+                'Test update when max retry limit is not reached'
+            ), self.assertRaises(Retry):
+                mock_requests.get.side_effect = [
+                    # For node status
+                    self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+                ]
+                mock_requests.post.side_effect = [
+                    # For update network
+                    self._get_mock_response(200),
+                    # For controller auth and ip assignment
+                    # (internal server error)
+                    self._get_mock_response(
+                        500,
+                        response={},
+                        exc=RequestException,
+                    ),
+                ]
+                _EXPECTED_INFO_CALLS = [
+                    mock.call(
+                        (
+                            f'Successfully updated the configuration of '
+                            f'ZeroTier VPN Server with UUID: {vpn.id}'
+                        )
+                    ),
+                ]
+                _EXPECTED_WARN_CALLS = [
+                    mock.call(
+                        (
+                            f'Try [{max_retries - 1}/{max_retries}] Failed to update '
+                            f'ZeroTier network member: {vpn.node_id}, '
+                            f'ZeroTier network: {vpn.network_id}, '
+                            f'ZeroTier VPN server UUID: {vpn.id}, Error: '
+                        )
+                    ),
+                ]
+                # Second last retry attempt (4th)
+                mock_task_request.retries = max_retries - 1
+                vpn.full_clean()
+                vpn.save()
+            self.assertEqual(mock_info.call_count, 1)
+            # Ensure that it logs with the 'warning' level
+            self.assertEqual(mock_warn.call_count, 1)
+            self.assertEqual(mock_error.call_count, 0)
+            mock_info.assert_has_calls(_EXPECTED_INFO_CALLS)
+            mock_warn.assert_has_calls(_EXPECTED_WARN_CALLS)
+            mock_info.reset_mock()
+            mock_warn.reset_mock()
+            mock_requests.reset_mock()
+
+            # During the last attempt, the task will give up
+            # retrying and raise a 'RequestException',
+            # which will be handled and logged as an error
+            with self.subTest(
+                'Test update when max retry limit is reached'
+            ), self.assertRaises(RequestException):
+                mock_requests.get.side_effect = [
+                    # For node status
+                    self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+                ]
+                mock_requests.post.side_effect = [
+                    # For update network
+                    self._get_mock_response(200),
+                    # For controller auth and ip assignment
+                    # (internal server error)
+                    self._get_mock_response(
+                        500,
+                        response={},
+                        exc=RequestException,
+                    ),
+                ]
+                _EXPECTED_INFO_CALLS = [
+                    mock.call(
+                        (
+                            f'Successfully updated the configuration of '
+                            f'ZeroTier VPN Server with UUID: {vpn.id}'
+                        )
+                    ),
+                ]
+                _EXPECTED_ERROR_CALLS = [
+                    mock.call(
+                        (
+                            f'Try [{max_retries}/{max_retries}] Failed to update '
+                            f'ZeroTier network member: {vpn.node_id}, '
+                            f'ZeroTier network: {vpn.network_id}, '
+                            f'ZeroTier VPN server UUID: {vpn.id}, Error: '
+                        )
+                    ),
+                ]
+                # Last retry attempt (5th)
+                mock_task_request.retries = max_retries
+                vpn.full_clean()
+                vpn.save()
+            self.assertEqual(mock_info.call_count, 1)
+            self.assertEqual(mock_warn.call_count, 0)
+            # Ensure that it logs last attempt with the 'error' level
+            self.assertEqual(mock_error.call_count, 1)
+            mock_info.assert_has_calls(_EXPECTED_INFO_CALLS)
+            mock_error.assert_has_calls(_EXPECTED_ERROR_CALLS)
+
+    @mock.patch(_ZT_API_TASKS_LOCMEM_CACHE_KEYS, create=True)
+    @mock.patch(_ZT_API_TASKS_ERR_LOGGER)
+    @mock.patch(_ZT_API_TASKS_WARN_LOGGER)
+    @mock.patch(_ZT_API_TASKS_INFO_LOGGER)
+    @mock.patch(_ZT_SERVICE_REQUESTS)
+    def test_zerotier_vpn_server_deletion(
+        self, mock_requests, mock_info, mock_warn, mock_error, mock_locmem_cache_keys
+    ):
+        def _setup_requests_mocks():
+            mock_requests.get.side_effect = [
+                # For node status
+                self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+            ]
+            mock_requests.post.side_effect = [
+                # For create network
+                self._get_mock_response(200),
+                # For controller network join
+                self._get_mock_response(200),
+                # For controller auth and ip assignment
+                self._get_mock_response(200),
+            ]
+            mock_locmem_cache_keys.return_value = ['test_zt_api_tasks_notification_key']
+
+        def _reset_requests_mocks():
+            mock_requests.reset_mock()
+            mock_locmem_cache_keys.reset_mock()
+            # Delete subnet created for previous assertion
+            Subnet.objects.all().delete()
+
+        with self.subTest(
+            'Test db transaction fails on vpn.save(), it should delete the zt network'
+        ):
+            invalid_response = {'test-key': 'raise integrity error on save'}
+            mock_requests.get.side_effect = [
+                # For node status
+                self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+            ]
+            mock_requests.post.side_effect = [
+                # For create network
+                self._get_mock_response(200, response=invalid_response),
+            ]
+            mock_requests.delete.side_effect = [
+                # For delete network
+                self._get_mock_response(200, response={}),
+                # For controller leave network
+                self._get_mock_response(200, response={}),
+            ]
+            with self.assertRaises(IntegrityError):
+                vpn = self._create_zerotier_vpn()
+            self.assertEqual(Vpn.objects.count(), 0)
+            _EXPECTED_INFO_MSG = (
+                'Successfully deleted the ZeroTier VPN Server with UUID:',
+                'Successfully left the ZeroTier Network with ID:',
+            )
+            self.assertEqual(mock_info.call_count, 2)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            self.assertIn(_EXPECTED_INFO_MSG[0], mock_info.call_args_list[0][0][0])
+            self.assertIn(_EXPECTED_INFO_MSG[1], mock_info.call_args_list[1][0][0])
+
+        _reset_requests_mocks()
+        _setup_requests_mocks()
+
+        with self.subTest('Test post_delete signal triggers deletion of vpn server'):
+            vpn = self._create_zerotier_vpn()
+            self.assertEqual(Vpn.objects.count(), 1)
+            vpn_id = vpn.id
+            network_id = vpn.network_id
+            # Reset vpn creation logs
+            mock_info.reset_mock()
+            mock_requests.delete.side_effect = [
+                # For delete network
+                self._get_mock_response(200, response={}),
+                # For controller leave network
+                self._get_mock_response(200, response={}),
+            ]
+            vpn.delete()
+            self.assertEqual(Vpn.objects.count(), 0)
+            _EXPECTED_INFO_CALLS = [
+                mock.call(
+                    (
+                        f'Successfully deleted the ZeroTier VPN Server '
+                        f'with UUID: {vpn_id}, Network ID: {network_id}'
+                    )
+                ),
+                mock.call(
+                    f'Successfully left the ZeroTier Network with ID: {network_id}'
+                ),
+            ]
+            self.assertEqual(mock_info.call_count, 2)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 0)
+            mock_info.assert_has_calls(_EXPECTED_INFO_CALLS)
+
+        _reset_requests_mocks()
+        _setup_requests_mocks()
+
+        with self.subTest('Test for zerotier vpn server deletion encountered an error'):
+            vpn = self._create_zerotier_vpn()
+            vpn_id = vpn.id
+            network_id = vpn.network_id
+            self.assertEqual(Vpn.objects.count(), 1)
+            # Reset vpn creation logs
+            mock_info.reset_mock()
+            mock_requests.delete.side_effect = [
+                # For delete network
+                self._get_mock_response(404, response={}, exc=RequestException),
+            ]
+            vpn.delete()
+            self.assertEqual(Vpn.objects.count(), 0)
+            _EXPECTED_ERROR_CALLS = [
+                mock.call(
+                    (
+                        'Failed to delete ZeroTier VPN Server with '
+                        f'UUID: {vpn_id}, Network ID: {network_id}, as it '
+                        'does not exist on the ZeroTier Controller Networks, Error: '
+                    )
+                ),
+            ]
+            self.assertEqual(mock_info.call_count, 0)
+            self.assertEqual(mock_warn.call_count, 0)
+            self.assertEqual(mock_error.call_count, 1)
+            mock_error.assert_has_calls(_EXPECTED_ERROR_CALLS)
diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py
index d09b5cc33..d5de34cab 100644
--- a/openwisp_controller/config/tests/utils.py
+++ b/openwisp_controller/config/tests/utils.py
@@ -3,6 +3,7 @@
 these mixins are reused also in openwisp2
 change with care.
 """
+from copy import deepcopy
 from unittest import mock
 from uuid import uuid4
 
@@ -85,6 +86,7 @@ class CreateVpnMixin(object):
         'openvpn': 'openwisp_controller.vpn_backends.OpenVpn',
         'wireguard': 'openwisp_controller.vpn_backends.Wireguard',
         'vxlan': 'openwisp_controller.vpn_backends.VxlanWireguard',
+        'zerotier': 'openwisp_controller.vpn_backends.ZeroTier',
     }
 
     _dh = """-----BEGIN DH PARAMETERS-----
@@ -202,6 +204,164 @@ def _create_vxlan_vpn_template(self):
         return device, vpn, template
 
 
+class TestZeroTierVpnMixin:
+    _TEST_ZT_NETWORK_CONFIG = {
+        'authTokens': [None],
+        'authorizationEndpoint': '',
+        'capabilities': [],
+        'clientId': '',
+        'creationTime': 1690149114186,
+        'dns': {'domain': '', 'servers': []},
+        'enableBroadcast': True,
+        'id': '9536600adf2e3db9',
+        'ipAssignmentPools': [],
+        'mtu': 2800,
+        'multicastLimit': 32,
+        'name': 'test-zerotier-vpn',
+        'nwid': '9536600adf2e3db9',
+        'objtype': 'network',
+        'private': True,
+        'remoteTraceLevel': 0,
+        'remoteTraceTarget': None,
+        'revision': 2,
+        'routes': [{'target': '0.0.0.0/24', 'via': None}],
+        'rules': [
+            {'etherType': 2048, 'not': True, 'or': False, 'type': 'MATCH_ETHERTYPE'},
+            {'etherType': 2054, 'not': True, 'or': False, 'type': 'MATCH_ETHERTYPE'},
+            {'etherType': 34525, 'not': True, 'or': False, 'type': 'MATCH_ETHERTYPE'},
+            {'type': 'ACTION_DROP'},
+            {'type': 'ACTION_ACCEPT'},
+        ],
+        'rulesSource': '',
+        'ssoEnabled': False,
+        'tags': [],
+        'v4AssignMode': {'zt': False},
+        'v6AssignMode': {'6plane': False, 'rfc4193': False, 'zt': False},
+    }
+    _TEST_ZT_NODE_CONFIG = {
+        'address': '9536600adf',
+        'clock': 1690196627974,
+        'config': {
+            'settings': {
+                'allowTcpFallbackRelay': True,
+                'forceTcpRelay': False,
+                'listeningOn': [
+                    '172.17.0.1/9993',
+                    '172.18.0.1/9993',
+                ],
+                'portMappingEnabled': True,
+                'primaryPort': 9993,
+                'secondaryPort': 64166,
+                'softwareUpdate': 'disable',
+                'softwareUpdateChannel': 'release',
+                'surfaceAddresses': ['103.172.72.9/36684'],
+                'tertiaryPort': 0,
+            }
+        },
+        'online': True,
+        'planetWorldId': 149604618,
+        'planetWorldTimestamp': 1644592324813,
+        'publicIdentity': (
+            '9536600adf:0:cd1e650df2b2e77abe8433'
+            'b44960b23823451216aeb7a0158eb6f3745'
+            'eb17bf724457c07e29de8b965871479ca77'
+            'be46261b6d570db6ab86fa960382ac89c8a1'
+        ),
+        'tcpFallbackActive': False,
+        'version': '1.10.6',
+        'versionBuild': 0,
+        'versionMajor': 1,
+        'versionMinor': 10,
+        'versionRev': 6,
+    }
+
+    _TEST_ZT_MEMBER_CONFIG = {
+        'activeBridge': False,
+        'address': '89e327619d',
+        'authenticationExpiryTime': 0,
+        'authorized': True,
+        'capabilities': [],
+        'creationTime': 1690971894880,
+        'id': '89e327619d',
+        'identity': (
+            '89e327619d:0:022a66a1939f1e786e45be7f46'
+            'baeac8c59e07a6a746eb02d8c42036072d0c67c'
+            '7eb8674d5c4a9869cc78b84549496c5e64b7552'
+            'def984effd9ea92ef5c109a9'
+        ),
+        'ipAssignments': ['10.0.0.155'],
+        'lastAuthorizedCredential': None,
+        'lastAuthorizedCredentialType': 'api',
+        'lastAuthorizedTime': 1690971894990,
+        'lastDeauthorizedTime': 0,
+        'noAutoAssignIps': False,
+        'nwid': '9536600adf1570e3',
+        'objtype': 'member',
+        'remoteTraceLevel': 0,
+        'remoteTraceTarget': None,
+        'revision': 2,
+        'ssoExempt': False,
+        'tags': [],
+        'vMajor': 1,
+        'vMinor': 10,
+        'vProto': 12,
+        'vRev': 3,
+    }
+
+    def _get_mock_response(self, status_code, response=None, err=None, exc=None):
+        response = (
+            deepcopy(self._TEST_ZT_NETWORK_CONFIG) if response is None else response
+        )
+        mock_response = mock.Mock(status_code=status_code, reason=err)
+        mock_response.json.return_value = response
+        mock_response.raise_for_status.side_effect = exc
+        return mock_response
+
+    def _create_zerotier_vpn(self, config=None, **kwargs):
+        if config is None:
+            config = {
+                'zerotier': [
+                    {
+                        'private': True,
+                        'enableBroadcast': True,
+                        'mtu': 2800,
+                        'multicastLimit': 32,
+                    }
+                ]
+            }
+        org = kwargs.get('organization', None)
+        if 'subnet' not in kwargs:
+            kwargs['subnet'] = self._create_subnet(
+                name='test-zerotier-subnet', subnet='10.0.0.0/16', organization=org
+            )
+        vpn_options = {
+            'backend': 'openwisp_controller.vpn_backends.ZeroTier',
+            'config': config,
+            'name': 'test-zerotier-vpn',
+            'host': 'localhost:9993',
+            'auth_token': 'a6uo9egbutyg9i399x3aocan',
+        }
+        vpn_options.update(kwargs)
+        vpn = Vpn(**vpn_options)
+        vpn.full_clean()
+        vpn.save()
+        return vpn
+
+    def _create_zerotier_vpn_template(self, auto_cert=True, **kwargs):
+        vpn = self._create_zerotier_vpn()
+        org1 = kwargs.get('organization', vpn.organization)
+        template = self._create_template(
+            name='test-zerotier-template',
+            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 TestVpnX509Mixin(CreateVpnMixin, TestPkiMixin):
     def _create_vpn(self, ca_options={}, **kwargs):
         if 'ca' not in kwargs:
diff --git a/tests/openwisp2/sample_config/migrations/0001_initial.py b/tests/openwisp2/sample_config/migrations/0001_initial.py
index 0a940775a..b50a15208 100644
--- a/tests/openwisp2/sample_config/migrations/0001_initial.py
+++ b/tests/openwisp2/sample_config/migrations/0001_initial.py
@@ -280,6 +280,7 @@ class Migration(migrations.Migration):
                                 'openwisp_controller.vpn_backends.VxlanWireguard',
                                 'VXLAN over WireGuard',
                             ),
+                            ('openwisp_controller.vpn_backends.ZeroTier', 'ZeroTier'),
                         ],
                         help_text='Select VPN configuration backend',
                         max_length=128,
@@ -354,7 +355,8 @@ class Migration(migrations.Migration):
                     models.CharField(
                         blank=True,
                         help_text=(
-                            'Authentication token for triggering "Webhook Endpoint"'
+                            'Authentication token used for triggering '
+                            '"Webhook Endpoint" or for calling "ZerotierService" API'
                         ),
                         max_length=128,
                         null=True,
@@ -373,6 +375,8 @@ class Migration(migrations.Migration):
                         verbose_name='Webhook Endpoint',
                     ),
                 ),
+                ('node_id', models.CharField(blank=True, max_length=10)),
+                ('network_id', models.CharField(blank=True, max_length=16)),
             ],
             options={
                 'verbose_name': 'VPN server',
@@ -447,6 +451,7 @@ class Migration(migrations.Migration):
                         to='sample_config.vpn',
                     ),
                 ),
+                ('secret', models.TextField(blank=True)),
             ],
             options={
                 'verbose_name': 'VPN client',