diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4d7c0d82..b2c1c6b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,11 +26,18 @@ jobs: fail-fast: false matrix: python-version: - - 3.6 - 3.7 + - 3.8 + - 3.9 django-version: - django~=3.0.0 - django~=3.1.0 + - django~=3.2.0 + include: + - django-version: django~=4.0.0 + python-version: 3.9 + - django-version: django~=4.0.0 + python-version: 3.8 steps: - uses: actions/checkout@v2 diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 000000000..364582c20 --- /dev/null +++ b/.jshintignore @@ -0,0 +1,2 @@ +openwisp_controller/config/static/config/js/lib/*.js +openwisp_controller/connection/static/connection/js/lib/*.js diff --git a/CHANGES.rst b/CHANGES.rst index 2b2125894..b510cc4de 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,18 @@ Version 0.9.0 [unreleased] WIP +Changes +~~~~~~~ + +- **Backward incompatible**: The default behaviour for the resolution of conflicting management + IPs between devices of different organizations has been changed. By default, in this new version, + the system assumes it's using only 1 management tunnel for all the organizations, so different devices + from any organization will not have the same management IP to avoid conflicts. + The old behavior can be restored by setting + `OPENWISP_CONTROLLER_SHARED_MANAGEMENT_IP_ADDRESS_SPACE + `_ + to ``False``. + Version 0.8.3 [2020-12-18] -------------------------- diff --git a/README.rst b/README.rst old mode 100755 new mode 100644 index 2c0337700..93e3172e5 --- a/README.rst +++ b/README.rst @@ -9,9 +9,9 @@ openwisp-controller :target: https://coveralls.io/r/openwisp/openwisp-controller :alt: Test Coverage -.. image:: https://requires.io/github/openwisp/openwisp-controller/requirements.svg?branch=master - :target: https://requires.io/github/openwisp/openwisp-controller/requirements/?branch=master - :alt: Requirements Status +.. image:: https://img.shields.io/librariesio/release/github/openwisp/openwisp-controller + :target: https://libraries.io/github/openwisp/openwisp-controller#repository_dependencies + :alt: Dependency monitoring .. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg :target: https://gitter.im/openwisp/general @@ -64,6 +64,11 @@ Other popular building blocks that are part of the OpenWISP ecosystem are: - `openwisp-notifications `_: allows users to be aware of important events happening in the network. +**For a more complete overview of the OpenWISP modules and architecture**, +see the +`OpenWISP Architecture Overview +`_. + .. image:: https://raw.githubusercontent.com/openwisp/openwisp2-docs/master/assets/design/openwisp-logo-black.svg :target: http://openwisp.org :alt: OpenWISP @@ -79,44 +84,6 @@ Other popular building blocks that are part of the OpenWISP ecosystem are: ------------ -Deploy it in production ------------------------ - -An automated installer is available at `ansible-openwisp2 `_. - -Dependencies ------------- - -* Python >= 3.6 -* OpenSSL - -Install stable version from pypi --------------------------------- - -Install from pypi: - -.. code-block:: shell - - pip install openwisp-controller - -Install development version ---------------------------- - -Install tarball: - -.. code-block:: shell - - pip install https://github.com/openwisp/openwisp-controller/tarball/master - -Alternatively you can install via pip using git: - -.. code-block:: shell - - pip install -e git+git://github.com/openwisp/openwisp-controller#egg=openwisp_controller - -If you want to contribute, follow the instructions in -`Installing for development <#installing-for-development>`_. - Project Structure & main features ---------------------------------- @@ -131,19 +98,25 @@ Config App - support for additional firmware can be added by `specifying custom backends <#netjsonconfig-backends>`_ * **configuration editor** based on `JSON-Schema editor `_ * **advanced edit mode**: edit `NetJSON `_ *DeviceConfiguration* objects for maximum flexibility -* **configuration templates**: reduce repetition to the minimum -* `configuration variables <#how-to-use-configuration-variables>`_: reference ansible-like variables in the configuration and templates +* `configuration templates `_: + reduce repetition to the minimum, configure default and required templates +* `configuration variables <#how-to-use-configuration-variables>`_: + reference ansible-like variables in the configuration and templates * **template tags**: tag templates to automate different types of auto-configurations (eg: mesh, WDS, 4G) -* **device groups**: add `devices to dedicated groups <#device-groups>`_ for easy management +* **device groups**: add `devices to dedicated groups <#device-groups>`_ to + ease management of group of devices * **simple HTTP resources**: allow devices to automatically download configuration updates -* **VPN management**: automatically provision VPN tunnels with unique x509 certificates +* **VPN management**: `automatically provision VPN tunnels <#openwisp-controller-default-auto-cert>`_, + including cryptographic keys, IP addresses +* `REST API <#rest-api-reference>`_ PKI App ~~~~~~~ The PKI app is based on `django-x509 `_, it allows to create, import and view x509 CAs and certificates directly from -the administration dashboard. +the administration dashboard, it also adds different endpoints to the +`REST API <#rest-api-reference>`_. Connection App ~~~~~~~~~~~~~~ @@ -154,6 +127,7 @@ in order perform `push operations <#how-to-configure-push-updates>`__: - Sending configuration updates. - `Executing shell commands <#sending-commands-to-devices>`_. - Perform `firmware upgrades via the additional firmware upgrade module `_. +- `REST API <#rest-api-reference>`_ The default connection protocol implemented is SSH, but other protocol mechanism is extensible and custom protocols can be implemented as well. @@ -170,1609 +144,2663 @@ The geographic app is based on `django-loci `_. -You can change the values for the following variables in -``settings.py`` to configure your instance of openwisp-controller. +Subnet Division App +~~~~~~~~~~~~~~~~~~~ -``OPENWISP_SSH_AUTH_TIMEOUT`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This app allows to automatically provision subnets and IP addresses which will be +available as `system defined configuration variables <#system-defined-variables>`_ +that can be used in templates. The purpose of this app is to allow users to automatically +provision and configure specific +subnets and IP addresses to the devices without the need of manual intervention. -+--------------+-------------+ -| **type**: | ``int`` | -+--------------+-------------+ -| **default**: | ``2`` | -+--------------+-------------+ -| **unit**: | ``seconds`` | -+--------------+-------------+ +Refer to `"How to configure automatic provisioning of subnets and IPs" +section of this documentation +<#how-to-configure-automatic-provisioning-of-subnets-and-ips>`_ +to learn about features provided by this app. -Configure timeout to wait for an authentication response when establishing a SSH connection. +This app is optional, if you don't need it you can avoid adding it to +``settings.INSTALLED_APPS``. -``OPENWISP_SSH_BANNER_TIMEOUT`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Installation instructions +------------------------- -+--------------+-------------+ -| **type**: | ``int`` | -+--------------+-------------+ -| **default**: | ``60`` | -+--------------+-------------+ -| **unit**: | ``seconds`` | -+--------------+-------------+ +Deploy it in production +~~~~~~~~~~~~~~~~~~~~~~~ -Configure timeout to wait for the banner to be presented when establishing a SSH connection. +See -``OPENWISP_SSH_COMMAND_TIMEOUT`` +- `ansible-openwisp2 `_ +- `docker-openwisp `_ + +Dependencies +~~~~~~~~~~~~ + +* Python >= 3.7 +* OpenSSL + +Install stable version from pypi ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -+--------------+-------------+ -| **type**: | ``int`` | -+--------------+-------------+ -| **default**: | ``30`` | -+--------------+-------------+ -| **unit**: | ``seconds`` | -+--------------+-------------+ +Install from pypi: -Configure timeout on blocking read/write operations when executing a command in a SSH connection. +.. code-block:: shell -``OPENWISP_SSH_CONNECTION_TIMEOUT`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + pip install openwisp-controller -+--------------+-------------+ -| **type**: | ``int`` | -+--------------+-------------+ -| **default**: | ``5`` | -+--------------+-------------+ -| **unit**: | ``seconds`` | -+--------------+-------------+ +Install development version +~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Configure timeout for the TCP connect when establishing a SSH connection. +Install tarball: -``OPENWISP_CONNECTORS`` -~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: shell -+--------------+--------------------------------------------------------------------+ -| **type**: | ``tuple`` | -+--------------+--------------------------------------------------------------------+ -| **default**: | .. code-block:: python | -| | | -| | ( | -| | ('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'), | -| | ) | -+--------------+--------------------------------------------------------------------+ + pip install https://github.com/openwisp/openwisp-controller/tarball/master -Available connector classes. Connectors are python classes that specify ways -in which OpenWISP can connect to devices in order to launch commands. +Alternatively you can install via pip using git: -``OPENWISP_UPDATE_STRATEGIES`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: shell -+--------------+----------------------------------------------------------------------------------------+ -| **type**: | ``tuple`` | -+--------------+----------------------------------------------------------------------------------------+ -| **default**: | .. code-block:: python | -| | | -| | ( | -| | ('openwisp_controller.connection.connectors.openwrt.ssh.OpenWrt', 'OpenWRT SSH'), | -| | ) | -+--------------+----------------------------------------------------------------------------------------+ + pip install -e git+git://github.com/openwisp/openwisp-controller#egg=openwisp_controller -Available update strategies. An update strategy is a subclass of a -connector class which defines an ``update_config`` method which is -in charge of updating the configuration of the device. +If you want to contribute, follow the instructions in +`Installing for development <#installing-for-development>`_. -This operation is launched in a background worker when the configuration -of a device is changed. +Installing for development +~~~~~~~~~~~~~~~~~~~~~~~~~~ -It's possible to write custom update strategies and add them to this -setting to make them available in OpenWISP. +Install the system dependencies: -``OPENWISP_CONFIG_UPDATE_MAPPING`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: shell -+--------------+--------------------------------------------------------------------+ -| **type**: | ``dict`` | -+--------------+--------------------------------------------------------------------+ -| **default**: | .. code-block:: python | -| | | -| | { | -| | 'netjsonconfig.OpenWrt': OPENWISP_UPDATE_STRATEGIES[0][0], | -| | } | -+--------------+--------------------------------------------------------------------+ + sudo apt update + sudo apt install -y sqlite3 libsqlite3-dev openssl libssl-dev + sudo apt install -y gdal-bin libproj-dev libgeos-dev libspatialite-dev libsqlite3-mod-spatialite + sudo apt install -y chromium -A dictionary that maps configuration backends to update strategies in order to -automatically determine the update strategy of a device connection if the -update strategy field is left blank by the user. +Fork and clone the forked repository: -``OPENWISP_CONTROLLER_BACKENDS`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: shell -+--------------+-----------------------------------------------+ -| **type**: | ``tuple`` | -+--------------+-----------------------------------------------+ -| **default**: | .. code-block:: python | -| | | -| | ( | -| | ('netjsonconfig.OpenWrt', 'OpenWRT'), | -| | ('netjsonconfig.OpenWisp', 'OpenWISP'), | -| | ) | -+--------------+-----------------------------------------------+ + git clone git://github.com//openwisp-controller -Available configuration backends. For more information, see `netjsonconfig backends -`_. +Navigate into the cloned repository: -``OPENWISP_CONTROLLER_VPN_BACKENDS`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: shell -+--------------+----------------------------------------------------------------+ -| **type**: | ``tuple`` | -+--------------+----------------------------------------------------------------+ -| **default**: | .. code-block:: python | -| | | -| | ( | -| | ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'), | -| | ) | -+--------------+----------------------------------------------------------------+ + cd openwisp-controller/ -Available VPN backends for VPN Server objects. For more information, see `OpenVPN netjsonconfig backend -`_. +Launch Redis: -A VPN backend must follow some basic rules in order to be compatible with *openwisp-controller*: +.. code-block:: shell -* it MUST allow at minimum and at maximum one VPN instance -* the main *NetJSON* property MUST match the lowercase version of the class name, - eg: when using the ``OpenVpn`` backend, the system will look into - ``config['openvpn']`` -* it SHOULD focus on the server capabilities of the VPN software being used + docker-compose up -d redis -``OPENWISP_CONTROLLER_DEFAULT_BACKEND`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Setup and activate a virtual-environment. (we'll be using `virtualenv `_) -+--------------+----------------------------------------+ -| **type**: | ``str`` | -+--------------+----------------------------------------+ -| **default**: | ``OPENWISP_CONTROLLER_BACKENDS[0][0]`` | -+--------------+----------------------------------------+ +.. code-block:: shell -The preferred backend that will be used as initial value when adding new ``Config`` or -``Template`` objects in the admin. + python -m virtualenv env + source env/bin/activate -This setting defaults to the raw value of the first item in the ``OPENWISP_CONTROLLER_BACKENDS`` setting, -which is ``netjsonconfig.OpenWrt``. +Make sure that you are using pip version 20.2.4 before moving to the next step: -Setting it to ``None`` will force the user to choose explicitly. +.. code-block:: shell -``OPENWISP_CONTROLLER_DEFAULT_VPN_BACKEND`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + pip install -U pip wheel setuptools -+--------------+--------------------------------------------+ -| **type**: | ``str`` | -+--------------+--------------------------------------------+ -| **default**: | ``OPENWISP_CONTROLLER_VPN_BACKENDS[0][0]`` | -+--------------+--------------------------------------------+ +Install development dependencies: -The preferred backend that will be used as initial value when adding new ``Vpn`` objects in the admin. +.. code-block:: shell -This setting defaults to the raw value of the first item in the ``OPENWISP_CONTROLLER_VPN_BACKENDS`` setting, -which is ``openwisp_controller.vpn_backends.OpenVpn``. + pip install -e . + pip install -r requirements-test.txt + npm install -g jshint stylelint -Setting it to ``None`` will force the user to choose explicitly. +Install WebDriver for Chromium for your browser version from ``_ +and Extract ``chromedriver`` to one of directories from your ``$PATH`` (example: ``~/.local/bin/``). -``OPENWISP_CONTROLLER_REGISTRATION_ENABLED`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Create database: -+--------------+-------------+ -| **type**: | ``bool`` | -+--------------+-------------+ -| **default**: | ``True`` | -+--------------+-------------+ +.. code-block:: shell -Whether devices can automatically register through the controller or not. + cd tests/ + ./manage.py migrate + ./manage.py createsuperuser -This feature is enabled by default. +Launch celery worker (for background jobs): -Autoregistration must be supported on the devices in order to work, see `openwisp-config automatic -registration `_ for more information. +.. code-block:: shell -``OPENWISP_CONTROLLER_CONSISTENT_REGISTRATION`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + celery -A openwisp2 worker -l info -+--------------+-------------+ -| **type**: | ``bool`` | -+--------------+-------------+ -| **default**: | ``True`` | -+--------------+-------------+ +Launch development server: -Whether devices that are already registered are recognized when reflashed or reset, hence keeping -the existing configuration without creating a new one. +.. code-block:: shell -This feature is enabled by default. + ./manage.py runserver 0.0.0.0:8000 -Autoregistration must be enabled also on the devices in order to work, see `openwisp-config -consistent key generation `_ -for more information. +You can access the admin interface at http://127.0.0.1:8000/admin/. -``OPENWISP_CONTROLLER_REGISTRATION_SELF_CREATION`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Run tests with: -+--------------+-------------+ -| **type**: | ``bool`` | -+--------------+-------------+ -| **default**: | ``True`` | -+--------------+-------------+ +.. code-block:: shell -Whether devices that are not already present in the system are allowed to register or not. + ./runtests.py --parallel -Turn this off if you still want to use auto-registration to avoid having to -manually set the device UUID and key in its configuration file but also want -to avoid indiscriminate registration of new devices without explicit permission. +Run quality assurance tests with: -``OPENWISP_CONTROLLER_CONTEXT`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: shell -+--------------+------------------+ -| **type**: | ``dict`` | -+--------------+------------------+ -| **default**: | ``{}`` | -+--------------+------------------+ + ./run-qa-checks -Additional context that is passed to the default context of each device object. +Install and run on docker +~~~~~~~~~~~~~~~~~~~~~~~~~ -``OPENWISP_CONTROLLER_CONTEXT`` can be used to define system-wide configuration variables. +NOTE: This Docker image is for development purposes only. +For the official OpenWISP Docker images, see: `docker-openwisp +`_. -For more information regarding how to use configuration variables in OpenWISP, -see `How to use configuration variables <#how-to-use-configuration-variables>`_. +Build from the Dockerfile: -For technical information about how variables are handled in the lower levels -of OpenWISP, see `netjsonconfig context: configuration variables -`_. +.. code-block:: shell -``OPENWISP_CONTROLLER_DEFAULT_AUTO_CERT`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + docker-compose build -+--------------+---------------------------+ -| **type**: | ``bool`` | -+--------------+---------------------------+ -| **default**: | ``True`` | -+--------------+---------------------------+ +Run the docker container: -The default value of the ``auto_cert`` field for new ``Template`` objects. +.. code-block:: shell -The ``auto_cert`` field is valid only for templates which have ``type`` -set to ``VPN`` and indicates whether a new x509 certificate should be created -automatically for each configuration using that template. + docker-compose up -The automatically created certificates will also be removed when they are not -needed anymore (eg: when the VPN template is removed from a configuration object). +Troubleshooting steps for common installation issues +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``OPENWISP_CONTROLLER_CERT_PATH`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +You may encounter some issues while installing GeoDjango. -+--------------+---------------------------+ -| **type**: | ``str`` | -+--------------+---------------------------+ -| **default**: | ``/etc/x509`` | -+--------------+---------------------------+ +Unable to load SpatiaLite library extension? +############################################ -The filesystem path where x509 certificate will be installed when -downloaded on routers when ``auto_cert`` is being used (enabled by default). +If you are getting below exception:: -``OPENWISP_CONTROLLER_COMMON_NAME_FORMAT`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + django.core.exceptions.ImproperlyConfigured: Unable to load the SpatiaLite library extension -+--------------+------------------------------+ -| **type**: | ``str`` | -+--------------+------------------------------+ -| **default**: | ``{mac_address}-{name}`` | -+--------------+------------------------------+ +then, You need to specify ``SPATIALITE_LIBRARY_PATH`` in your ``settings.py`` as explained in +`django documentation regarding how to install and configure spatialte +`_. -Defines the format of the ``common_name`` attribute of VPN client certificates that are automatically -created when using VPN templates which have ``auto_cert`` set to ``True``. +Having Issues with other geospatial libraries? +############################################## -``OPENWISP_CONTROLLER_MANAGEMENT_IP_DEVICE_LIST`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Please refer +`troubleshooting issues related to geospatial libraries +`_. -+--------------+------------------------------+ -| **type**: | ``bool`` | -+--------------+------------------------------+ -| **default**: | ``True`` | -+--------------+------------------------------+ +Setup (integrate in an existing django project) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In the device list page, the column ``IP`` will show the ``management_ip`` if -available, defaulting to ``last_ip`` otherwise. +Add ``openwisp_controller`` applications to ``INSTALLED_APPS``: -If this setting is set to ``False`` the ``management_ip`` won't be shown -in the device list page even if present, it will be shown only in the device -detail page. +.. code-block:: python -You may set this to ``False`` if for some reason the majority of your user -doesn't care about the management ip address. + INSTALLED_APPS = [ + ... + # openwisp2 modules + 'openwisp_controller.config', + 'openwisp_controller.pki', + 'openwisp_controller.geo', + 'openwisp_controller.connection', + 'openwisp_controller.subnet_division', # Optional + 'openwisp_controller.notifications', + 'openwisp_users', + 'openwisp_notifications', + 'openwisp_ipam', + # openwisp2 admin theme + # (must be loaded here) + 'openwisp_utils.admin_theme', + 'django.contrib.admin', + 'django.forms', + ... + ] + EXTENDED_APPS = ('django_x509', 'django_loci') -``OPENWISP_CONTROLLER_CONFIG_BACKEND_FIELD_SHOWN`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +**Note**: The order of applications in ``INSTALLED_APPS`` should be maintained, +otherwise it might not work properly. -+--------------+------------------------------+ -| **type**: | ``bool`` | -+--------------+------------------------------+ -| **default**: | ``True`` | -+--------------+------------------------------+ +Other settings needed in ``settings.py``: -This setting toggles the ``backend`` fields in add/edit pages in Device and Template configuration, -as well as the ``backend`` field/filter in Device list and Template list. +.. code-block:: python -If this setting is set to ``False`` these items will be removed from the UI. + STATICFILES_FINDERS = [ + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'openwisp_utils.staticfiles.DependencyFinder', + ] -Note: This setting affects only the configuration backend and NOT the VPN backend. + ASGI_APPLICATION = 'openwisp_controller.geo.channels.routing.channel_routing' + CHANNEL_LAYERS = { + # in production you should use another channel layer backend + 'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}, + } -``OPENWISP_CONTROLLER_DEVICE_NAME_UNIQUE`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'loaders': [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + 'openwisp_utils.loaders.DependencyLoader', + ], + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'openwisp_utils.admin_theme.context_processor.menu_items', + 'openwisp_notifications.context_processors.notification_api_settings', + ], + }, + } + ] -+--------------+-------------+ -| **type**: | ``bool`` | -+--------------+-------------+ -| **default**: | ``True`` | -+--------------+-------------+ + FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' -This setting conditionally enforces unique Device names in an Organization. -The query to enforce this is case-insensitive. +Add the URLs to your main ``urls.py``: -Note: For this constraint to be optional, it is enforced on an application level and not on database. +.. code-block:: python -``OPENWISP_CONTROLLER_HARDWARE_ID_ENABLED`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + urlpatterns = [ + # ... other urls in your project ... + # openwisp-controller urls + url(r'^admin/', admin.site.urls), + url(r'', include('openwisp_controller.urls')), + url(r'', include('openwisp_notifications.urls')), + url(r'', include('openwisp_ipam.urls')), + ] -+--------------+-------------+ -| **type**: | ``bool`` | -+--------------+-------------+ -| **default**: | ``False`` | -+--------------+-------------+ +Configure caching (you may use a different cache storage if you want): -The field ``hardware_id`` can be used to store a unique hardware id, for example a serial number. +.. code-block:: python -If this setting is set to ``True`` then this field will be shown first in the device list page -and in the add/edit device page. + CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://localhost/0', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + } + } + } -This feature is disabled by default. + SESSION_ENGINE = 'django.contrib.sessions.backends.cache' + SESSION_CACHE_ALIAS = 'default' -``OPENWISP_CONTROLLER_HARDWARE_ID_OPTIONS`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Configure celery (you may use a different broker if you want): -+--------------+--------------------------------------------------------------+ -| **type**: | ``dict`` | -+--------------+--------------------------------------------------------------+ -| **default**: | .. code-block:: python | -| | | -| | { | -| | 'blank': not OPENWISP_CONTROLLER_HARDWARE_ID_ENABLED, | -| | 'null': True, | -| | 'max_length': 32, | -| | 'unique': True, | -| | 'verbose_name': _('Serial number'), | -| | 'help_text': _('Serial number of this device') | -| | } | -+--------------+--------------------------------------------------------------+ +.. code-block:: python -Options for the model field ``hardware_id``. + # here we show how to configure celery with redis but you can + # use other brokers if you want, consult the celery docs + CELERY_BROKER_URL = 'redis://localhost/1' -* ``blank``: wether the field is allowed to be blank -* ``null``: wether an empty value will be stored as ``NULL`` in the database -* ``max_length``: maximum length of the field -* ``unique``: wether the value of the field must be unique -* ``verbose_name``: text for the human readable label of the field -* ``help_text``: help text to be displayed with the field + INSTALLED_APPS.append('djcelery_email') + EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' -``OPENWISP_CONTROLLER_HARDWARE_ID_AS_NAME`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +If you decide to use redis (as shown in these examples), +install the required python packages:: -+--------------+-------------+ -| **type**: | ``bool`` | -+--------------+-------------+ -| **default**: | ``True`` | -+--------------+-------------+ + pip install redis django-redis -When the hardware ID feature is enabled, devices will be referenced with -their hardware ID instead of their name. +Then run: -If you still want to reference devices by their name, set this to ``False``. +.. code-block:: shell -``OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ./manage.py migrate -+--------------+----------------------------+ -| **type**: | ``tuple`` | -+--------------+----------------------------+ -| **default**: | ``('Device', 'Devices')`` | -+--------------+----------------------------+ +Usage reference +--------------- -Defines the ``verbose_name`` attribute of the ``Device`` model, which is displayed in the -admin site. The first and second element of the tuple represent the singular and plural forms. - -For example, if we want to change the verbose name to "Hotspot", we could write: - -.. code-block:: python - - OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME = ('Hotspot', 'Hotspots') - -``OPENWISP_CONTROLLER_API`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default Templates +~~~~~~~~~~~~~~~~~ -+--------------+-----------+ -| **type**: | ``bool`` | -+--------------+-----------+ -| **default**: | ``True`` | -+--------------+-----------+ +When templates are flagged as default, they will be automatically assigned to new devices. -Indicates whether the API for Openwisp Controller is enabled or not. -To disable the API by default add `OPENWISP_CONTROLLER_API = False` in `settings.py` file. +If there are multiple default templates, these are assigned to the device in alphabetical +order based on their names, for example, given the following default templates: -``OPENWISP_CONTROLLER_API_HOST`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Access +- Interfaces +- SSH Keys -+--------------+-----------+ -| **type**: | ``str`` | -+--------------+-----------+ -| **default**: | ``None`` | -+--------------+-----------+ +They will be assigned to devices in exactly that order. -Allows to specify backend URL for API requests, if the frontend is hosted separately. +If for some technical reason (eg: one default template depends on the presence of another +default template which must be assigned earlier) you need to change the ordering, you can +simply rename the templates by prefixing them with numbers, eg: -``OPENWISP_CONTROLLER_USER_COMMANDS`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- 1 Interfaces +- 2. SSH Keys +- 3. Access -+--------------+----------+ -| **type**: | ``list`` | -+--------------+----------+ -| **default**: | ``[]`` | -+--------------+----------+ +Required Templates +~~~~~~~~~~~~~~~~~~ -Allows to specify a `list` of tuples for adding commands as described in -`'How to add commands" <#how-to-add-commands>`_ section. +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/required-templates.png + :alt: Required template example -``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Required templates are similar to `Default templates <#default-templates>`__ +but cannot be unassigned from a device configuration, they can only be overridden. -+--------------+------------------------------------------+ -| **type**: | ``dict`` | -+--------------+------------------------------------------+ -| **default**: | ``{'type': 'object', 'properties': {}}`` | -+--------------+------------------------------------------+ +They will be always assigned earlier than default templates, +so they can be overridden if needed. -Allows specifying JSONSchema used for validating meta-data of `Device Group <#device-groups>`_. +In the example above, the "SSID" template is flagged as "(required)" +and its checkbox is always checked and disabled. -REST API --------- +How to use configuration variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Live documentation -~~~~~~~~~~~~~~~~~~ +Sometimes the configuration is not exactly equal on all the devices, +some parameters are unique to each device or need to be changed +by the user. -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/live-docu-api.png +In these cases it is possible to use configuration variables in conjunction +with templates, this feature is also known as *configuration context*, think of +it like a dictionary which is passed to the function which renders the +configuration, so that it can fill variables according to the passed context. -A general live API documentation (following the OpenAPI specification) at ``/api/v1/docs/``. +The different ways in which variables are defined are described below. -Browsable web interface -~~~~~~~~~~~~~~~~~~~~~~~ +Predefined device variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/browsable-api-ui.png +Each device gets the following attributes passed as configuration variables: -Additionally, opening any of the endpoints `listed below <#list-of-endpoints>`_ -directly in the browser will show the `browsable API interface of Django-REST-Framework -`_, -which makes it even easier to find out the details of each endpoint. +* ``id`` +* ``key`` +* ``name`` +* ``mac_address`` -Authentication -~~~~~~~~~~~~~~ +User defined device variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -See openwisp-users: `authenticating with the user token -`_. +In the device configuration section you can find a section named +"Configuration variables" where it is possible to define the configuration +variables and their values, as shown in the example below: -When browsing the API via the `Live documentation <#live-documentation>`_ -or the `Browsable web page <#browsable-web-interface>`_, you can also use -the session authentication by logging in the django admin. +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-context.png + :alt: context -Pagination -~~~~~~~~~~ +Template default values +~~~~~~~~~~~~~~~~~~~~~~~ -All *list* endpoints support the ``page_size`` parameter that allows paginating -the results in conjunction with the ``page`` parameter. +It's possible to specify the default values of variables defined in a template. -.. code-block:: text +This allows to achieve 2 goals: - GET /api/v1/controller/template/?page_size=10 - GET /api/v1/controller/template/?page_size=10&page=2 +1. pass schema validation without errors (otherwise it would not be possible + to save the template in the first place) +2. provide good default values that are valid in most cases but can be + overridden in the device if needed -List of endpoints -~~~~~~~~~~~~~~~~~ +These default values will be overridden by the +`User defined device variables <#user-defined-device-variables>`_. -Since the detailed explanation is contained in the `Live documentation <#live-documentation>`_ -and in the `Browsable web page <#browsable-web-interface>`_ of each point, -here we'll provide just a list of the available endpoints, -for further information please open the URL of the endpoint in your browser. +The default values of variables can be manipulated from the section +"configuration variables" in the edit template page: -List devices -^^^^^^^^^^^^ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/template-default-values.png + :alt: default values -.. code-block:: text +Global variables +~~~~~~~~~~~~~~~~ - GET /api/v1/controller/device/ +Variables can also be defined globally using the +`OPENWISP_CONTROLLER_CONTEXT <#openwisp-controller-context>`_ setting. -Create device -^^^^^^^^^^^^^ +System defined variables +~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: text +Predefined device variables, global variables and other variables that +are automatically managed by the system (eg: when using templates of +type VPN-client) are displayed in the admin UI as *System Defined Variables* +in read-only mode. - POST /api/v1/controller/device/ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/system-defined-variables.png + :alt: system defined variables -Get device detail -^^^^^^^^^^^^^^^^^ +Example usage of variables +~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: text +Here's a typical use case, the WiFi SSID and WiFi password. +You don't want to define this for every device, but you may want to +allow operators to easily change the SSID or WiFi password for a +specific device without having to re-define the whole wifi interface +to avoid duplicating information. - GET /api/v1/controller/device/{id}/ +This would be the template: -Download device configuration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: json -.. code-block:: text + { + "interfaces": [ + { + "type": "wireless", + "name": "wlan0", + "wireless": { + "mode": "access_point", + "radio": "radio0", + "ssid": "{{wlan0_ssid}}", + "encryption": { + "protocol": "wpa2_personal", + "key": "{{wlan0_password}}", + "cipher": "auto" + } + } + } + ] + } - GET /api/v1/controller/device/{id}/configuration/ +These would be the default values in the template: -The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific device. +.. code-block:: json -Change details of device -^^^^^^^^^^^^^^^^^^^^^^^^ + { + "wlan0_ssid": "SnakeOil PublicWiFi", + "wlan0_password": "Snakeoil_pwd!321654" + } -.. code-block:: text +The default values can then be overridden at +`device level <#user-defined-device-variables>`_ if needed, eg: - PUT /api/v1/controller/device/{id}/ +.. code-block:: json -Patch details of device -^^^^^^^^^^^^^^^^^^^^^^^ + { + "wlan0_ssid": "Room 23 ACME Hotel", + "wlan0_password": "room_23pwd!321654" + } -.. code-block:: text +How to configure push updates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - PATCH /api/v1/controller/device/{id}/ +Follow the procedure described below to enable secure SSH access from OpenWISP to your +devices, this is required to enable push updates (whenever the configuration is changed, +OpenWISP will trigger the update in the background) and/or +`firmware upgrades (via the additional module openwisp-firmware-upgrader) +`_. -**Note**: To assign, unassign, and change the order of the assigned templates add, -remove, and change the order of the ``{id}`` of the templates under the ``config`` field in the JSON response respectively. -Moreover, you can also select and unselect templates in the HTML Form of the Browsable API. +**Note**: If you have installed OpenWISP with `openwisp2 Ansbile role `_ +then you can skip the following steps. The Ansible role automatically creates a +default template to update ``authorized_keys`` on networking devices using the +default access credentials. -The required template(s) from the organization(s) of the device will added automatically -to the ``config`` and cannot be removed. +1. Generate SSH key +################### -**Example usage**: For assigning template(s) add the/their {id} to the config of a device, +First of all, we need to generate the SSH key which will be +used by OpenWISP to access the devices, to do so, you can use the following command: .. code-block:: shell - echo '{"config":{"templates": ["4791fa4c-2cef-4f42-8bb4-c86018d71bd3"]}}' | \ - http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ - "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc" + echo './sshkey' | ssh-keygen -t rsa -b 4096 -C "openwisp" -**Example usage**: For removing assigned templates, simply remove the/their {id} from the config of a device, +This will create two files in the current directory, one called ``sshkey`` (the private key) and one called +``sshkey.pub`` (the public key). -.. code-block:: shell +Store the content of these files in a secure location. - echo '{"config":{"templates": []}}' | \ - http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ - "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc" +2. Save SSH private key in OpenWISP (access credentials) +######################################################## -**Example usage**: For reordering the templates simply change their order from the config of a device, +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/add-ssh-credentials-private-key.png + :alt: add SSH private key as access credential in OpenWISP -.. code-block:: shell +From the first page of OpenWISP click on "Access credentials", then click +on the **"ADD ACCESS CREDENTIALS"** button in the upper right corner +(alternatively, go to the following URL: ``/admin/connection/credentials/add/``). - echo '{"config":{"templates": ["c5bbc697-170e-44bc-8eb7-b944b55ee88f","4791fa4c-2cef-4f42-8bb4-c86018d71bd3"]}}' | \ - http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ - "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc" +Select SSH as ``type``, enable the **Auto add** checkbox, then at the field +"Credentials type" select "SSH (private key)", now type "root" in the ``username`` field, +while in the ``key`` field you have to paste the contents of the private key just created. -Delete device -^^^^^^^^^^^^^ +Now hit save. -.. code-block:: text +The credentials just created will be automatically enabled for all the devices in the system +(both existing devices and devices which will be added in the future). - DELETE /api/v1/controller/device/{id}/ +3. Add the public key to your devices +##################################### -List device connections -^^^^^^^^^^^^^^^^^^^^^^^ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/add-authorized-ssh-keys-template.png + :alt: Add authorized SSH public keys template to OpenWISP (OpenWRT) -.. code-block:: text +Now we need to instruct your devices to allow OpenWISP accessing via SSH, +in order to do this we need to add the contents of the public key file created in step 1 +(``sshkey.pub``) in the file ``/etc/dropbear/authorized_keys`` on the devices, the +recommended way to do this is to create a configuration template in OpenWISP: +from the first page of OpenWISP, click on "Templates", then and click on the +**"ADD TEMPLATE"** button in the upper right corner (alternatively, go to the following URL: +``/admin/config/template/add/``). - GET /api/v1/controller/device/{id}/connection/ +Check **enabled by default**, then scroll down the configuration section, +click on "Configuration Menu", scroll down, click on "Files" then close the menu +by clicking again on "Configuration Menu". Now type ``/etc/dropbear/authorized_keys`` +in the ``path`` field of the file, then paste the contents of ``sshkey.pub`` in ``contents``. -Create device connection -^^^^^^^^^^^^^^^^^^^^^^^^ +Now hit save. -.. code-block:: text +**There's a catch**: you will need to assign the template to any existing device. - POST /api/v1/controller/device/{id}/connection/ +4. Test it +########## -Get device connection detail -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Once you have performed the 3 steps above, you can test it as follows: -.. code-block:: text +1. Ensure there's at least one device turned on and connected to OpenWISP, ensure + this device has the "SSH Authorized Keys" assigned to it. +2. Ensure the celery worker of OpenWISP Controller is running (eg: ``ps aux | grep celery``) +3. SSH into the device and wait (maximum 2 minutes) until ``/etc/dropbear/authorized_keys`` + appears as specified in the template. +4. While connected via SSH to the device run the following command in the console: + ``logread -f``, now try changing the device name in OpenWISP +5. Shortly after you change the name in OpenWISP, you should see some output in the + SSH console indicating another SSH access and the configuration update being performed. - GET /api/v1/controller/device/{id}/connection/{id}/ +Sending Commands to Devices +~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Change device connection detail -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +By default, there are three options in the **Send Command** dropdown: -.. code-block:: text +1. Reboot +2. Change Password +3. Custom Command - PUT /api/v1/controller/device/{id}/connection/{id}/ +While the first two options are self-explanatory, the **custom command** option +allows you to execute any command on the device as shown in the example below. -Patch device connection detail -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/commands_demo.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/commands_demo.gif + :alt: Executing commands on device example -.. code-block:: text +**Note**: in order for this feature to work, a device needs to have at least +one **Access Credential** (see `How to configure push updates <#how-to-configure-push-updates>`__). - PATCH /api/v1/controller/device/{id}/connection/{id}/ +The **Send Command** button will be hidden until the device +has at least one **Access Credential**. -Delete device connection -^^^^^^^^^^^^^^^^^^^^^^^^ +If you need to allow your users to quickly send specific commands that are used often in your +network regardless of your users' knowledge of Linux shell commands, you can add new commands +by following instructions in `"How to add commands" <#how-to-add-commands>`_ section. -.. code-block:: text +If you are an advanced user and want to register commands programatically, then refer to +`"Register / Unregistering commands" <#registering--unregistering-commands>`_ section. - DELETE /api/v1/controller/device/{id}/connection/{id}/ +How to add commands +################### -List credentials -^^^^^^^^^^^^^^^^ +Let's explore to add new commands to the UI to help users perform +additional actions without having to be Linux/Unix experts. -.. code-block:: text +This example defines a simple command that could ``ping`` an input +``destination_address`` through a network interface, ``interface_name``. - GET /api/v1/connection/credential/ +.. code-block:: python -Create credential -^^^^^^^^^^^^^^^^^ + # In yourproject/settings.py -.. code-block:: text + def ping_command_callable(destination_address, interface_name=None): + command = f'ping -c 4 {destination_address}' + if interface_name: + command += f' -I {interface_name}' + return command - POST /api/v1/connection/credential/ + OPENWISP_CONTROLLER_USER_COMMANDS = [ + ( + 'ping', + { + 'label': 'Ping', + 'schema': { + 'title': 'Ping', + 'type': 'object', + 'required': ['destination_address'], + 'properties': { + 'destination_address': { + 'type': 'string', + 'title': 'Destination Address', + }, + 'interface_name': { + 'type': 'string', + 'title': 'Interface Name', + }, + }, + 'message': 'Destination Address cannot be empty', + 'additionalProperties': False, + }, + 'callable': ping_command_callable, + } + ) + ] -Get credential detail -^^^^^^^^^^^^^^^^^^^^^ +The above code will add "Ping" command as show in the GIF below: -.. code-block:: text +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/ping_command_example.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/ping_command_example.gif + :alt: Adding a "ping" command - GET /api/v1/connection/credential/{id}/ +``OPENWISP_CONTROLLER_USER_COMMANDS`` setting takes a ``list`` of ``tuple`` +each containing two elements. The first element of the tuple should contain an +identifier for the command and the second element should contain a ``dict`` +defining configuration of the command. -Change credential detail -^^^^^^^^^^^^^^^^^^^^^^^^ +Command Configuration +^^^^^^^^^^^^^^^^^^^^^ -.. code-block:: text +The ``dict`` defining configuration for command should contain following keys: - PUT /api/v1/connection/credential/{id}/ +1. ``label`` +"""""""""""" -Patch credential detail -^^^^^^^^^^^^^^^^^^^^^^^ +A ``str`` defining label for the command used internally by Django. -.. code-block:: text +2. ``schema`` +""""""""""""" - PATCH /api/v1/connection/credential/{id}/ +A ``dict`` defining `JSONSchema `_ for inputs of command. +You can specify the inputs for your command, add rules for performing validation +and make inputs required or optional. -Delete credential -^^^^^^^^^^^^^^^^^ +Here is a detailed explanation of the schema used in above example: -.. code-block:: text +.. code-block:: python - DELETE /api/v1/connection/credential/{id}/ + { + # Name of the command displayed in "Send Command" widget + 'title': 'Ping', + # Use type "object" if the command needs to accept inputs + # Use type "null" if the command does not accepts any input + 'type': 'object', + # Specify list of inputs that are required + 'required': ['destination_address'], + # Define the inputs for the commands along with their properties + 'properties': { + 'destination_address': { + # type of the input value + 'type': 'string', + # label used for displaying this input field + 'title': 'Destination Address', + }, + 'interface_name': { + 'type': 'string', + 'title': 'Interface Name', + }, + }, + # Error message to be shown if validation fails + 'message': 'Destination Address cannot be empty'), + # Whether specifying addtionaly inputs is allowed from the input form + 'additionalProperties': False, + } -List commands of a device -^^^^^^^^^^^^^^^^^^^^^^^^^ +This example uses only handful of properties available in JSONSchema. You can +experiment with other properties of JSONSchema for schema of your command. -.. code-block:: text +3. ``callable`` +""""""""""""""" - GET /api/v1/controller/device/{id}/command/ +A ``callable`` or ``str`` defining dotted path to a callable. It should return +the command (``str``) to be executed on the device. Inputs of the command are +passed as arguments to this callable. -Execute a command a device -^^^^^^^^^^^^^^^^^^^^^^^^^^ +The example above includes a callable(``ping_command_callable``) for +``ping`` command. -.. code-block:: text +Registering / Unregistering Commands +#################################### - POST /api/v1/controller/device/{id}/command/ +OpenWISP Controller provides registering and unregistering commands +through utility functions ``openwisp_controller.connection.commands.register_command`` +and ``openwisp_notifications.types.unregister_notification_type``. +You can use these functions to register or unregister commands +from your code. -Get command details -^^^^^^^^^^^^^^^^^^^ +**Note**: These functions are to be used as an alternative to the +`"OPENWISP_CONTROLLER_USER_COMMANDS" <#openwisp-controller-user-commands>`_ +when `developing custom modules based on openwisp-controller +<#extending-openwisp-controller>`_ or when developing custom third party +apps. -.. code-block:: text +``register_command`` +^^^^^^^^^^^^^^^^^^^^ - GET /api/v1/controller/device/{device_id}/command/{command_id}/ ++--------------------+------------------------------------------------------------------+ +| Parameter | Description | ++--------------------+------------------------------------------------------------------+ +| ``command_name`` | A ``str`` defining identifier for the command. | ++--------------------+------------------------------------------------------------------+ +| ``command_config`` | A ``dict`` defining configuration of the command | +| | as shown in `"Command Configuration" <#command-configuration>`_. | ++--------------------+------------------------------------------------------------------+ -Get device coordinates +**Note:** It will raise ``ImproperlyConfigured`` exception if a command is already +registered with the same name. + +``unregister_command`` ^^^^^^^^^^^^^^^^^^^^^^ -.. code-block:: text ++--------------------+-----------------------------------------+ +| Parameter | Description | ++--------------------+-----------------------------------------+ +| ``command_name`` | A ``str`` defining name of the command. | ++--------------------+-----------------------------------------+ - GET /api/v1/controller/device/{id}/location/ +**Note:** It will raise ``ImproperlyConfigured`` exception if such command does not exists. -Update device coordinates -^^^^^^^^^^^^^^^^^^^^^^^^^ +Device Groups +~~~~~~~~~~~~~ -.. code-block:: text +Device Groups provide an easy way to organize devices of a particular organization. +Device Groups provide the following features: - PUT /api/v1/controller/device/{id}/location/ +- Group similar devices by having dedicated groups for access points, routers, etc. +- Store additional information regarding a group in the structured metadata field. +- Customize structure and validation of metadata field of DeviceGroup to standardize + information across all groups using `"OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA" <#openwisp-controller-device-group-schema>`_ + setting. -List of devices in a location -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-groups.png + :alt: Device Group example -.. code:: text +How to setup WireGuard tunnels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - GET /api/v1/controller/location/{id}/device/ +Follow the procedure described below to setup WireGuard tunnels on your devices. + +**Note:** This example uses **Shared systemwide (no organization)** option as +the organization for VPN server and VPN client template. You can use any +organization as long as VPN server, VPN client template and Device has same +organization. + +1. Create VPN server configuration for WireGuard +################################################ + +1. Visit ``/admin/config/vpn/add/`` to add a new VPN server. +2. We will set **Name** of this VPN server ``Wireguard`` and **Host** as + ``wireguard-server.mydomain.com`` (update this to point to your + WireGuard VPN server). +3. Select ``WireGuard`` from the dropdown as **VPN Backend**. +4. When using WireGuard, OpenWISP takes care of managing IP addresses + (assigning an IP address to each VPN peer). You can create a new subnet or + select an existing one from the dropdown menu. You can also assign an + **Internal IP** to the WireGuard Server or leave it empty for OpenWISP to + configure. This IP address will be used by the WireGuard interface on + server. +5. We have set the **Webhook Endpoint** as ``https://wireguard-server.mydomain.com:8081/trigger-update`` + for this example. You will need to update this according to you VPN upgrader + endpoint. Set **Webhook AuthToken** to any strong passphrase, this will be + used to ensure that configuration upgrades are requested from trusted + sources. + + **Note**: If you are following this tutorial for also setting up WireGuard + VPN server, just substitute ``wireguard-server.mydomain.com`` with hostname + of your VPN server and follow the steps in next section. + +6. Under the configuration section, set the name of WireGuard tunnel 1 interface. + We have used ``wg0`` in this example. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/vpn-server-1.png + :alt: WireGuard VPN server configuration example 1 + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/vpn-server-2.png + :alt: WireGuard VPN server configuration example 2 + +7. After clicking on **Save and continue editing**, you will see that OpenWISP + has automatically created public and private key for WireGuard server in + **System Defined Variables** along with internal IP address information. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/vpn-server-3.png + :alt: WireGuard VPN server configuration example 3 + +2. Deploy Wireguard VPN Server +############################## + +If you haven't already setup WireGuard on your VPN server, this will be a good +time do so. We stress on using `ansible-wireguard-openwisp `_ +role for installing WireGuard since it also installs scripts that allows +OpenWISP to manage WireGuard VPN server. + +Pay attention to the VPN server attributes used in your playbook. It should be same as +VPN server configuration in OpenWISP. + +3. Create VPN client template for WireGuard VPN Server +###################################################### + +1. Visit ``/admin/config/template/add/`` to add a new template. +2. Set ``Wireguard Client`` as **Name** (you can set whatever you want) and + select ``VPN-client`` as **type** from the dropdown list. +3. The **Backend** field refers to the backend of the device this template can + be applied to. For this example, we will leave it to ``OpenWRT``. +4. Select the correct VPN server from the dropdown for the **VPN** field. Here + it is ``Wireguard``. +5. Ensure that **Automatic tunnel provisioning** is checked. This will make + OpenWISP to automatically generate public and private keys and provision IP + address for each WireGuard VPN client. +6. After clicking on **Save and continue editing** button, you will see details + of *Wireguard* VPN server in **System Defined Variables**. The template + configuration will be automatically generated which you can tweak + accordingly. We will use the automatically generated VPN client configuration + for this example. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/template.png + :alt: WireGuard VPN client template example + +4. Apply Wireguard VPN template to devices +########################################## + +**Note**: This step assumes that you already have a device registered on +OpenWISP. Register or create a device before proceeding. + +1. Open the **Configuration** tab of the concerned device. +2. Select the *WireGuard Client* template. +3. Upon clicking on **Save and continue editing** button, you will see some + entries in **System Defined Variables**. It will contain internal IP address, + private and public key for the WireGuard client on the device along with + details of WireGuard VPN server. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-tutorial/device-configuration.png + :alt: WireGuard VPN device configuration example + +**Voila!** You have successfully configured OpenWISP to manage WireGuard +tunnels for your devices. + +How to setup VXLAN over WireGuard tunnels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -List locations with devices deployed (in GeoJSON format) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +By following these steps, you will be able to setup layer 2 VXLAN tunnels +encapsulated in WireGuard tunnels which work on layer 3. + +**Note:** This example uses **Shared systemwide (no organization)** option as +the organization for VPN server and VPN client template. You can use any +organization as long as VPN server, VPN client template and Device has same +organization. + +1. Create VPN server configuration for VXLAN over WireGuard +########################################################### + +1. Visit ``/admin/config/vpn/add/`` to add a new VPN server. +2. We will set **Name** of this VPN server ``Wireguard VXLAN`` and **Host** as + ``wireguard-vxlan-server.mydomain.com`` (update this to point to your + WireGuard VXLAN VPN server). +3. Select ``VXLAN over WireGuard`` from the dropdown as **VPN Backend**. +4. When using VXLAN over WireGuard, OpenWISP takes care of managing IP addresses + (assigning an IP address to each VPN peer). You can create a new subnet or + select an existing one from the dropdown menu. You can also assign an + **Internal IP** to the WireGuard Server or leave it empty for OpenWISP to + configure. This IP address will be used by the WireGuard interface on + server. +5. We have set the **Webhook Endpoint** as ``https://wireguard-vxlan-server.mydomain.com:8081/trigger-update`` + for this example. You will need to update this according to you VPN upgrader + endpoint. Set **Webhook AuthToken** to any strong passphrase, this will be + used to ensure that configuration upgrades are requested from trusted + sources. + + **Note**: If you are following this tutorial for also setting up WireGuard + VPN server, just substitute ``wireguard-server.mydomain.com`` with hostname + of your VPN server and follow the steps in next section. + +6. Under the configuration section, set the name of WireGuard tunnel 1 interface. + We have used ``wg0`` in this example. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/vpn-server-1.png + :alt: WireGuard VPN VXLAN server configuration example 1 + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/vpn-server-2.png + :alt: WireGuard VPN VXLAN server configuration example 2 + +7. After clicking on **Save and continue editing**, you will see that OpenWISP + has automatically created public and private key for WireGuard server in + **System Defined Variables** along with internal IP address information. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/vpn-server-3.png + :alt: WireGuard VXLAN VPN server configuration example 3 + +2. Deploy Wireguard VXLAN VPN Server +#################################### + +If you haven't already setup WireGuard on your VPN server, this will be a good +time do so. We stress on using `ansible-wireguard-openwisp `_ +role for installing WireGuard since it also installs scripts that allows +OpenWISP to manage WireGuard VPN server along with VXLAN tunnels. + +Pay attention to the VPN server attributes used in your playbook. It should be same as +VPN server configuration in OpenWISP. + +3. Create VPN client template for WireGuard VXLAN VPN Server +############################################################ + +1. Visit ``/admin/config/template/add/`` to add a new template. +2. Set ``Wireguard VXLAN Client`` as **Name** (you can set whatever you want) and + select ``VPN-client`` as **type** from the dropdown list. +3. The **Backend** field refers to the backend of the device this template can + be applied to. For this example, we will leave it to ``OpenWRT``. +4. Select the correct VPN server from the dropdown for the **VPN** field. Here + it is ``Wireguard VXLAN``. +5. Ensure that **Automatic tunnel provisioning** is checked. This will make + OpenWISP to automatically generate public and private keys and provision IP + address for each WireGuard VPN client along with VXLAN Network Indentifier(VNI). +6. After clicking on **Save and continue editing** button, you will see details + of *Wireguard VXLAN* VPN server in **System Defined Variables**. The template + configuration will be automatically generated which you can tweak + accordingly. We will use the automatically generated VPN client configuration + for this example. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/template.png + :alt: WireGuard VXLAN VPN client template example + +4. Apply Wireguard VXLAN VPN template to devices +################################################ + +**Note**: This step assumes that you already have a device registered on +OpenWISP. Register or create a device before proceeding. + +1. Open the **Configuration** tab of the concerned device. +2. Select the *WireGuard VXLAN Client* template. +3. Upon clicking on **Save and continue editing** button, you will see some + entries in **System Defined Variables**. It will contain internal IP address, + private and public key for the WireGuard client on the device and details of + WireGuard VPN server along with VXLAN Network Identifier(VNI) of this device. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/wireguard-vxlan-tutorial/device-configuration.png + :alt: WireGuard VXLAN VPN device configuration example + +**Voila!** You have successfully configured OpenWISP to manage VXLAN over +WireGuard tunnels for your devices. + +How to configure automatic provisioning of subnets and IPs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following steps will help you configure automatic provisioning of subnets and IPs +for devices: + +1. Create a Subnet and a Subnet Division Rule +############################################# + +Create a master subnet under which automatically generated subnets will be provisioned. + +**Note**: Choose the size of the subnet appropriately considering your use case. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/subnet.png + :alt: Creating a master subnet example + +On the same page, add a **subnet division rule** that will be used to provision subnets +under the master subnet. -.. code:: text +The type of subnet division rule controls when subnets and IP addresses will be provisioned +for a device. The subnet division rule types currently implemented are described below. - GET /api/v1/controller/location/geojson/ +Device Subnet Division Rule +^^^^^^^^^^^^^^^^^^^^^^^^^^^ -You can filter using ``organization_slug`` to list location of -devices from that organization +This rule type is triggered whenever a device configuration (``config.Config`` model) +is created for the organization specified in the rule. -.. code:: text +Creating a new rule of "Device" type will also provision subnets and +IP addresses for existing devices of the organization automatically. - GET /api/v1/controller/location/geojson/?organization_slug= +**Note**: a device without a configuration will not trigger this rule. -List device groups -^^^^^^^^^^^^^^^^^^ +VPN Subnet Division Rule +^^^^^^^^^^^^^^^^^^^^^^^^ -.. code:: text +This rule is triggered when a VPN client template is assigned to a device, +provided the VPN server to which the VPN client template relates to has +the same subnet for which the subnet division rule is created. - GET api/v1/controller/group/ +**Note:** This rule will only work for **WireGuard** and **VXLAN over WireGuard** +VPN servers. -Create device group -^^^^^^^^^^^^^^^^^^^ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/subnet-division-rule.png + :alt: Creating a subnet division rule example -.. code:: text +In this example, **VPN subnet division rule** is used. - POST api/v1/controller/group/ +2. Create a VPN Server +###################### -Get device group detail -^^^^^^^^^^^^^^^^^^^^^^^ +Now create a VPN Server and choose the previously created **master subnet** as the subnet for +this VPN Server. -.. code-block:: text +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/vpn-server.png + :alt: Creating a VPN Server example - GET /api/v1/controller/group/{id}/ +3. Create a VPN Client Template +############################### -Get device group from certificate common name -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Create a template, setting the **Type** field to **VPN Client** and **VPN** field to use the +previously created VPN Server. -.. code-block:: text +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/vpn-client.png + :alt: Creating a VPN Client template example - GET /api/v1/controller/cert/{common_name}/group/ +**Note**: You can also check the **Enable by default** field if you want to automatically +apply this template to devices that will register in future. -This endpoint can be used to retrieve group information and metadata by the -common name of a certificate used in a VPN client tunnel, this endpoint is -used in layer 2 tunneling solutions for firewall/captive portals. +4. Apply VPN Client Template to Devices +####################################### -It is also possible to filter device group by providing organization slug -of certificate's organization as show in the example below: +With everything in place, you can now apply the VPN Client Template to devices. -.. code-block:: text +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/apply-template-to-device.png + :alt: Adding template to device example - GET /api/v1/controller/cert/{common_name}/group/?org={org1_slug},{org2_slug} +After saving the device, you should see all provisioned Subnets and IPs for this device +under the `System Defined Variables <#system-defined-variables>`_. -List templates -^^^^^^^^^^^^^^ +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/subnet-division-rule/system-defined-variables.png + :alt: Provisioned Subnets and IPs available as System Defined Variables example -.. code-block:: text +Voila! You can now use these variables in configuration of the device. Refer to `How to use configuration variables <#how-to-use-configuration-variables>`_ +section of this documentation to learn how to use configuration variables. - GET /api/v1/controller/template/ +Important notes for using Subnet Division +######################################### -Create template -^^^^^^^^^^^^^^^ +- In the above example Subnet, VPN Server, and VPN Client Template belonged to the **default** organization. + You can use **Systemwide Shared** Subnet, VPN Server, or VPN Client Template too, but + Subnet Division Rule will be always related to an organization. The Subnet Division Rule will only be + triggered when such VPN Client Template will be applied to a Device having the same organization as Subnet Division Rule. -.. code-block:: text +- You can also use the configuration variables for provisioned subnets and IPs in the Template. + Each variable will be resolved differently for different devices. E.g. ``OW_subnet1_ip1`` will resolve to + ``10.0.0.1`` for one device and ``10.0.0.55`` for another. Every device gets its own set of subnets and IPs. + But don't forget to provide the default fall back values in the "default values" template field + (used mainly for validation). - POST /api/v1/controller/template/ +- The Subnet Division Rule will automatically create a reserved subnet, this subnet can be used + to provision any IP addresses that have to be created manually. The rest of the master subnet + address space **must not** be interfered with or the automation implemented in this module + will not work. -Get template detail -^^^^^^^^^^^^^^^^^^^ +- The above example used `VPN subnet division rule <#vpn-subnet-division-rule>`_. Similarly, + `device subnet division rule <#device-subnet-division-rule>`_ can be used, which only requires + `creating a subnet and a subnet division rule <#1-create-a-subnet-and-a-subnet-division-rule>`_. -.. code-block:: text +Limitations of Subnet Division +############################## - GET /api/v1/controller/template/{id}/ +In the current implementation, it is not possible to change "Size", "Number of Subnets" and +"Number of IPs" fields of an existing subnet division rule due to following reasons: -Download template configuration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Size +^^^^ -.. code-block:: text +Allowing to change size of provisioned subnets of an existing subnet division rule +will require rebuilding of Subnets and IP addresses which has possibility of breaking +existing configurations. - GET /api/v1/controller/template/{id}/configuration/ +Number of Subnets +^^^^^^^^^^^^^^^^^ -The above endpoint triggers the download of a ``tar.gz`` file -containing the generated configuration for that specific template. +Allowing to decrease number of subnets of an existing subnet division +rule can create patches of unused subnets dispersed everywhere in the master subnet. +Allowing to increase number of subnets will break the continuous allocation of subnets for +every device. It can also break configuration of devices. -Change details of template -^^^^^^^^^^^^^^^^^^^^^^^^^^ +Number of IPs +^^^^^^^^^^^^^ -.. code-block:: text +Allowing to decrease number of IPs of an existing subnet division rule +will lead to deletion of IP Addresses which can break configuration of devices being used. +It **is allowed** to increase number of IPs. - PUT /api/v1/controller/template/{id}/ +If you want to make changes to any of above fields, delete the existing rule and create a +new one. The automation will provision for all existing devices that meets the criteria +for provisioning. **WARNING**: It is possible that devices get different subnets and IPs +from previous provisioning. -Patch details of template -^^^^^^^^^^^^^^^^^^^^^^^^^ +Default Alerts / Notifications +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: text ++-----------------------+---------------------------------------------------------------------+ +| Notification Type | Use | ++-----------------------+---------------------------------------------------------------------+ +| ``config_error`` | Fires when status of a device configuration changes to ``error``. | ++-----------------------+---------------------------------------------------------------------+ +| ``device_registered`` | Fires when a new device is registered automatically on the network. | ++-----------------------+---------------------------------------------------------------------+ - PATCH /api/v1/controller/template/{id}/ +REST API Reference +------------------ -Delete template -^^^^^^^^^^^^^^^ +Live documentation +~~~~~~~~~~~~~~~~~~ -.. code-block:: text +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/live-docu-api.png - DELETE /api/v1/controller/template/{id}/ +A general live API documentation (following the OpenAPI specification) at ``/api/v1/docs/``. -List VPNs -^^^^^^^^^ +Browsable web interface +~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: text +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/browsable-api-ui.png - GET /api/v1/controller/vpn/ +Additionally, opening any of the endpoints `listed below <#list-of-endpoints>`_ +directly in the browser will show the `browsable API interface of Django-REST-Framework +`_, +which makes it even easier to find out the details of each endpoint. -Create VPN -^^^^^^^^^^ +Authentication +~~~~~~~~~~~~~~ -.. code-block:: text +See openwisp-users: `authenticating with the user token +`_. - POST /api/v1/controller/vpn/ +When browsing the API via the `Live documentation <#live-documentation>`_ +or the `Browsable web page <#browsable-web-interface>`_, you can also use +the session authentication by logging in the django admin. -Get VPN detail -^^^^^^^^^^^^^^ +Pagination +~~~~~~~~~~ + +All *list* endpoints support the ``page_size`` parameter that allows paginating +the results in conjunction with the ``page`` parameter. .. code-block:: text - GET /api/v1/controller/vpn/{id}/ + GET /api/v1/controller/template/?page_size=10 + GET /api/v1/controller/template/?page_size=10&page=2 -Download VPN configuration -^^^^^^^^^^^^^^^^^^^^^^^^^^ +List of endpoints +~~~~~~~~~~~~~~~~~ -.. code-block:: text +Since the detailed explanation is contained in the `Live documentation <#live-documentation>`_ +and in the `Browsable web page <#browsable-web-interface>`_ of each point, +here we'll provide just a list of the available endpoints, +for further information please open the URL of the endpoint in your browser. - GET /api/v1/controller/vpn/{id}/configuration/ +List devices +############ -The above endpoint triggers the download of a ``tar.gz`` file -containing the generated configuration for that specific VPN. +.. code-block:: text -Change details of VPN -^^^^^^^^^^^^^^^^^^^^^ + GET /api/v1/controller/device/ + +Create device +############# .. code-block:: text - PUT /api/v1/controller/vpn/{id}/ + POST /api/v1/controller/device/ -Patch details of VPN -^^^^^^^^^^^^^^^^^^^^ +Get device detail +################# .. code-block:: text - PATCH /api/v1/controller/vpn/{id}/ + GET /api/v1/controller/device/{id}/ -Delete VPN -^^^^^^^^^^ +Download device configuration +############################# .. code-block:: text - DELETE /api/v1/controller/vpn/{id}/ + GET /api/v1/controller/device/{id}/configuration/ -List CA -^^^^^^^ +The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific device. + +Change details of device +######################## .. code-block:: text - GET /api/v1/controller/ca/ + PUT /api/v1/controller/device/{id}/ -Create new CA -^^^^^^^^^^^^^ +Patch details of device +####################### .. code-block:: text - POST /api/v1/controller/ca/ + PATCH /api/v1/controller/device/{id}/ -Import existing CA -^^^^^^^^^^^^^^^^^^ +**Note**: To assign, unassign, and change the order of the assigned templates add, +remove, and change the order of the ``{id}`` of the templates under the ``config`` field in the JSON response respectively. +Moreover, you can also select and unselect templates in the HTML Form of the Browsable API. -.. code-block:: text +The required template(s) from the organization(s) of the device will added automatically +to the ``config`` and cannot be removed. - POST /api/v1/controller/ca/ +**Example usage**: For assigning template(s) add the/their {id} to the config of a device, -**Note**: To import an existing CA, only ``name``, ``certificate`` -and ``private_key`` fields have to be filled in the ``HTML`` form or -included in the ``JSON`` format. +.. code-block:: shell -Get CA Detail -^^^^^^^^^^^^^ + curl -X PATCH \ + http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: application/json' \ + -d '{ + "config": { + "templates": ["4791fa4c-2cef-4f42-8bb4-c86018d71bd3"] + } + }' -.. code-block:: text +**Example usage**: For removing assigned templates, simply remove the/their {id} from the config of a device, - GET /api/v1/controller/ca/{id}/ +.. code-block:: shell -Change details of CA -^^^^^^^^^^^^^^^^^^^^ + curl -X PATCH \ + http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: application/json' \ + -d '{ + "config": { + "templates": [] + } + }' -.. code-block:: text +**Example usage**: For reordering the templates simply change their order from the config of a device, - PUT /api/v1/controller/ca/{id}/ +.. code-block:: shell -Patch details of CA -^^^^^^^^^^^^^^^^^^^ + curl -X PATCH \ + http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'cache-control: no-cache' \ + -H 'content-type: application/json' \ + -H 'postman-token: b3f6a1cc-ff13-5eba-e460-8f394e485801' \ + -d '{ + "config": { + "templates": [ + "c5bbc697-170e-44bc-8eb7-b944b55ee88f", + "4791fa4c-2cef-4f42-8bb4-c86018d71bd3" + ] + } + }' + +Delete device +############# .. code-block:: text - PATCH /api/v1/controller/ca/{id}/ + DELETE /api/v1/controller/device/{id}/ -Download CA(crl) -^^^^^^^^^^^^^^^^ +List device connections +####################### .. code-block:: text - GET /api/v1/controller/ca/{id}/crl/ - -The above endpoint triggers the download of ``{id}.crl`` file containing -up to date CRL of that specific CA. + GET /api/v1/controller/device/{id}/connection/ -Delete CA -^^^^^^^^^ +Create device connection +######################## .. code-block:: text - DELETE /api/v1/controller/ca/{id}/ + POST /api/v1/controller/device/{id}/connection/ -Renew CA -^^^^^^^^ +Get device connection detail +############################ .. code-block:: text - POST /api/v1/controller/ca/{id}/renew/ + GET /api/v1/controller/device/{id}/connection/{id}/ -List Cert -^^^^^^^^^ +Change device connection detail +############################### .. code-block:: text - GET /api/v1/controller/cert/ + PUT /api/v1/controller/device/{id}/connection/{id}/ -Create new Cert -^^^^^^^^^^^^^^^ +Patch device connection detail +############################## .. code-block:: text - POST /api/v1/controller/cert/ + PATCH /api/v1/controller/device/{id}/connection/{id}/ -Import existing Cert -^^^^^^^^^^^^^^^^^^^^ +Delete device connection +######################## .. code-block:: text - POST /api/v1/controller/cert/ - -**Note**: To import an existing Cert, only ``name``, ``ca``, -``certificate`` and ``private_key`` fields have to be filled -in the ``HTML`` form or included in the ``JSON`` format. + DELETE /api/v1/controller/device/{id}/connection/{id}/ -Get Cert Detail -^^^^^^^^^^^^^^^ +List credentials +################ .. code-block:: text - GET /api/v1/controller/cert/{id}/ + GET /api/v1/connection/credential/ -Change details of Cert -^^^^^^^^^^^^^^^^^^^^^^ +Create credential +################# .. code-block:: text - PUT /api/v1/controller/cert/{id}/ + POST /api/v1/connection/credential/ -Patch details of Cert -^^^^^^^^^^^^^^^^^^^^^ +Get credential detail +##################### .. code-block:: text - PATCH /api/v1/controller/cert/{id}/ + GET /api/v1/connection/credential/{id}/ -Delete Cert -^^^^^^^^^^^ +Change credential detail +######################## .. code-block:: text - DELETE /api/v1/controller/cert/{id}/ + PUT /api/v1/connection/credential/{id}/ -Renew Cert -^^^^^^^^^^ +Patch credential detail +####################### .. code-block:: text - POST /api/v1/controller/cert/{id}/renew/ + PATCH /api/v1/connection/credential/{id}/ -Revoke Cert -^^^^^^^^^^^ +Delete credential +################# .. code-block:: text - POST /api/v1/controller/cert/{id}/revoke/ + DELETE /api/v1/connection/credential/{id}/ -Default Alerts / Notifications ------------------------------- +List commands of a device +######################### -+-----------------------+---------------------------------------------------------------------+ -| Notification Type | Use | -+-----------------------+---------------------------------------------------------------------+ -| ``config_error`` | Fires when status of a device configuration changes to ``error``. | -+-----------------------+---------------------------------------------------------------------+ -| ``device_registered`` | Fires when a new device is registered automatically on the network. | -+-----------------------+---------------------------------------------------------------------+ +.. code-block:: text -Installing for development --------------------------- + GET /api/v1/controller/device/{id}/command/ -Install the system dependencies: +Execute a command a device +########################## -.. code-block:: shell +.. code-block:: text - sudo apt install -y sqlite3 libsqlite3-dev openssl libssl-dev - sudo apt install -y gdal-bin libproj-dev libgeos-dev libspatialite-dev libsqlite3-mod-spatialite - sudo snap install chromium + POST /api/v1/controller/device/{id}/command/ -Fork and clone the forked repository: +Get command details +################### -.. code-block:: shell +.. code-block:: text - git clone git://github.com//openwisp-controller + GET /api/v1/controller/device/{device_id}/command/{command_id}/ -Navigate into the cloned repository: +List device groups +################## -.. code-block:: shell +.. code-block:: text - cd openwisp-controller/ + GET /api/v1/controller/group/ -Launch Redis: +Create device group +################### -.. code-block:: shell +.. code-block:: text - docker-compose up -d redis + POST /api/v1/controller/group/ -Setup and activate a virtual-environment. (we'll be using `virtualenv `_) +Get device group detail +####################### -.. code-block:: shell +.. code-block:: text - python -m virtualenv env - source env/bin/activate + GET /api/v1/controller/group/{id}/ -Make sure that you are using pip version 20.2.4 before moving to the next step: +Get device group from certificate common name +############################################# -.. code-block:: shell +.. code-block:: text - pip install -U "pip==20.2.4" wheel setuptools + GET /api/v1/controller/cert/{common_name}/group/ +This endpoint can be used to retrieve group information and metadata by the +common name of a certificate used in a VPN client tunnel, this endpoint is +used in layer 2 tunneling solutions for firewall/captive portals. -Install development dependencies: +It is also possible to filter device group by providing organization slug +of certificate's organization as show in the example below: -.. code-block:: shell +.. code-block:: text - pip install -e . - pip install -r requirements-test.txt - npm install -g jshint stylelint + GET /api/v1/controller/cert/{common_name}/group/?org={org1_slug},{org2_slug} -Install WebDriver for Chromium for your browser version from ``_ -and Extract ``chromedriver`` to one of directories from your ``$PATH`` (example: ``~/.local/bin/``). +Get device location +################### -Create database: +.. code-block:: text -.. code-block:: shell - cd tests/ - ./manage.py migrate - ./manage.py createsuperuser + GET /api/v1/controller/device/{id}/location/ -Launch celery worker (for background jobs): -.. code-block:: shell +Create device location +###################### - celery -A openwisp2 worker -l info +.. code-block:: text -Launch development server: + PUT /api/v1/controller/device/{id}/location/ -.. code-block:: shell +You can create ``DeviceLocation`` object by using primary +keys of existing ``Location`` and ``FloorPlan`` objects as shown in +the example below. - ./manage.py runserver 0.0.0.0:8000 +.. code-block:: json -You can access the admin interface at http://127.0.0.1:8000/admin/. + { + "location": "f0cb5762-3711-4791-95b6-c2f6656249fa", + "floorplan": "dfeb6724-aab4-4533-aeab-f7feb6648acd", + "indoor": "-36,264" + } -Run tests with: +**Note:** The ``indoor`` field represents the coordinates of the +point placed on the image from the top left corner. E.g. if you +placed the pointer on the top left corner of the floorplan image, +its indoor coordinates will be ``0,0``. -.. code-block:: shell +.. code-block:: text - ./runtests.py --parallel + curl -X PUT \ + http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: application/json' \ + -d '{ + "location": "f0cb5762-3711-4791-95b6-c2f6656249fa", + "floorplan": "dfeb6724-aab4-4533-aeab-f7feb6648acd", + "indoor": "-36,264" + }' -Run quality assurance tests with: +You can also create related ``Location`` and ``FloorPlan`` objects for the +device directly from this endpoint. -.. code-block:: shell +The following example demonstrates creating related location +object in a single request. - ./run-qa-checks +.. code-block:: json -Install and run on docker --------------------------- + { + "location": { + "name": "Via del Corso", + "address": "Via del Corso, Roma, Italia", + "geometry": { + "type": "Point", + "coordinates": [12.512124, 41.898903] + }, + "type": "outdoor", + } + } -NOTE: This Docker image is for development purposes only. -For the official OpenWISP Docker images, see: `docker-openwisp -`_. +.. code-block:: text -Build from the Dockerfile: + curl -X PUT \ + http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: application/json' \ + -d '{ + "location": { + "name": "Via del Corso", + "address": "Via del Corso, Roma, Italia", + "geometry": { + "type": "Point", + "coordinates": [12.512124, 41.898903] + }, + "type": "outdoor" + } + }' -.. code-block:: shell +**Note:** You can also specify the ``geometry`` in **Well-known text (WKT)** +format, like following: - docker-compose build +.. code-block:: json -Run the docker container: + { + "location": { + "name": "Via del Corso", + "address": "Via del Corso, Roma, Italia", + "geometry": "POINT (12.512124 41.898903)", + "type": "outdoor", + } + } -.. code-block:: shell +Similarly, you can create ``Floorplan`` object with the same request. +But, note that a ``FloorPlan`` can be added to ``DeviceLocation`` only +if the related ``Location`` object defines an indoor location. The example +below demonstrates creating both ``Location`` and ``FloorPlan`` objects. - docker-compose up +.. code-block:: text -Troubleshooting Steps ---------------------- + // This is not a valid JSON object. The JSON format is + // only used for showing available fields. + { + "location.name": "Via del Corso", + "location.address": "Via del Corso, Roma, Italia", + "location.geometry.type": "Point", + "location.geometry.coordinates": [12.512124, 41.898903] + "location.type": "outdoor", + "floorplan.floor": 1, + "floorplan.image": floorplan.png, + } -You may encounter some issues while installing GeoDjango. +.. code-block:: text -Unable to load SpatiaLite library extension? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + curl -X PUT \ + http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ + -F 'location.name=Via del Corso' \ + -F 'location.address=Via del Corso, Roma, Italia' \ + -F location.geometry.type=Point \ + -F 'location.geometry.coordinates=[12.512124, 41.898903]' \ + -F location.type=indoor \ + -F floorplan.floor=1 \ + -F 'floorplan.image=@floorplan.png' -If you are getting below exception:: +**Note:** The request in above example uses ``multipart content-type`` +for uploading floorplan image. - django.core.exceptions.ImproperlyConfigured: Unable to load the SpatiaLite library extension +You can also use an existing ``Location`` object and create a new +floorplan for that location using this endpoint. -then, You need to specify ``SPATIALITE_LIBRARY_PATH`` in your ``settings.py`` as explained in -`django documentation regarding how to install and configure spatialte -`_. +.. code-block:: text -Having Issues with other geospatial libraries? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // This is not a valid JSON object. The JSON format is + // only used for showing available fields. + { + "location": "f0cb5762-3711-4791-95b6-c2f6656249fa", + "floorplan.floor": 1, + "floorplan.image": floorplan.png + } -Please refer -`troubleshooting issues related to geospatial libraries -`_. +.. code-block:: text -Device Groups -------------- + curl -X PUT \ + http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ + -F location=f0cb5762-3711-4791-95b6-c2f6656249fa \ + -F floorplan.floor=1 \ + -F 'floorplan.image=@floorplan.png' -Device Groups provide an easy way to organize devices of a particular organization. -You can achieve following by using Device Groups: +Change details of device location +################################# -- Group similar devices by having dedicated groups for access points, routers, etc. -- Store additional information regarding a group in the structured metadata field. -- Customize structure and validation of metadata field of DeviceGroup to standardize - information across all groups using `"OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA" <#openwisp-controller-device-group-schema>`_ - setting. +.. code-block:: text -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-groups.png - :alt: Device Group example + PUT /api/v1/controller/device/{id}/location/ -How to use configuration variables ----------------------------------- +**Note:** This endpoint can be used to update related ``Location`` +and ``Floorplan`` objects. Refer `examples of "Create device location" +section for information on payload format <#create-device-location>`_. -Sometimes the configuration is not exactly equal on all the devices, -some parameters are unique to each device or need to be changed -by the user. +Delete device location +###################### -In these cases it is possible to use configuration variables in conjunction -with templates, this feature is also known as *configuration context*, think of -it like a dictionary which is passed to the function which renders the -configuration, so that it can fill variables according to the passed context. +.. code-block:: text -The different ways in which variables are defined are described below. + DELETE /api/v1/controller/device/{id}/location/ -Predefined device variables -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Get device coordinates +###################### -Each device gets the following attributes passed as configuration variables: +.. code-block:: text -* ``id`` -* ``key`` -* ``name`` -* ``mac_address`` + GET /api/v1/controller/device/{id}/coordinates/ -User defined device variables -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +**Note:** This endpoint is intended to be used by devices. -In the device configuration section you can find a section named -"Configuration variables" where it is possible to define the configuration -variables and their values, as shown in the example below: +This endpoint skips multi-tenancy and permission checks if the +device ``key`` is passed as ``query_param`` because the system +assumes that the device is updating it's position. -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-context.png - :alt: context +.. code-block:: text -Template default values -~~~~~~~~~~~~~~~~~~~~~~~ + curl -X GET \ + 'http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/coordinates/?key=10a0cb5a553c71099c0e4ef236435496' -It's possible to specify the default values of variables defined in a template. +Update device coordinates +######################### -This allows to achieve 2 goals: +.. code-block:: text -1. pass schema validation without errors (otherwise it would not be possible - to save the template in the first place) -2. provide good default values that are valid in most cases but can be - overridden in the device if needed + PUT /api/v1/controller/device/{id}/coordinates/ -These default values will be overridden by the -`User defined device variables <#user-defined-device-variables>`_. +**Note:** This endpoint is intended to be used by devices. -The default values of variables can be manipulated from the section -"configuration variables" in the edit template page: +This endpoint skips multi-tenancy and permission checks if the +device ``key`` is passed as ``query_param`` because the system +assumes that the device is updating it's position. -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/template-default-values.png - :alt: default values +.. code-block:: json -Global variables -~~~~~~~~~~~~~~~~ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [12.512124, 41.898903] + }, + } -Variables can also be defined globally using the -`OPENWISP_CONTROLLER_CONTEXT <#openwisp-controller-context>`_ setting. +.. code-block:: text -System defined variables -~~~~~~~~~~~~~~~~~~~~~~~~ + curl -X PUT \ + 'http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/coordinates/?key=10a0cb5a553c71099c0e4ef236435496' \ + -H 'content-type: application/json' \ + -d '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [12.512124, 41.898903] + }, + }' -Predefined device variables, global variables and other variables that -are automatically managed by the system (eg: when using templates of -type VPN-client) are displayed in the admin UI as *System Defined Variables* -in read-only mode. +List locations +############## -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/system-defined-variables.png - :alt: system defined variables +.. code-block:: text -Example usage of variables -~~~~~~~~~~~~~~~~~~~~~~~~~~ + GET /api/v1/controller/location/ -Here's a typical use case, the WiFi SSID and WiFi password. -You don't want to define this for every device, but you may want to -allow operators to easily change the SSID or WiFi password for a -specific device without having to re-define the whole wifi interface -to avoid duplicating information. +You can filter using ``organization_slug`` to get list locations that +belongs to an organization. -This would be the template: +.. code-block:: text -.. code-block:: json + GET /api/v1/controller/location/?organization_slug= - { - "interfaces": [ - { - "type": "wireless", - "name": "wlan0", - "wireless": { - "mode": "access_point", - "radio": "radio0", - "ssid": "{{wlan0_ssid}}", - "encryption": { - "protocol": "wpa2_personal", - "key": "{{wlan0_password}}", - "cipher": "auto" - } - } - } - ] - } +Create location +############### -These would be the default values in the template: +.. code-block:: text -.. code-block:: json + POST /api/v1/controller/location/ + +If you are creating an ``indoor`` location, you can use this endpoint +to create floorplan for the location. + +The following example demonstrates creating floorplan along with location +in a single request. + +.. code-block:: text { - "wlan0_ssid": "SnakeOil PublicWiFi", - "wlan0_password": "Snakeoil_pwd!321654" + "name": "Via del Corso", + "address": "Via del Corso, Roma, Italia", + "geometry.type": "Point", + "geometry.location": [12.512124, 41.898903], + "type": "indoor", + "is_mobile": "false", + "floorplan.floor": "1", + "floorplan.image": floorplan.png, + "organization": "1f6c5666-1011-4f1d-bce9-fc6fcb4f3a05" } -The default values can then be overridden at -`device level <#user-defined-device-variables>`_ if needed, eg: +.. code-block:: text -.. code-block:: json + curl -X POST \ + http://127.0.0.1:8000/api/v1/controller/location/ \ + -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ + -F 'name=Via del Corso' \ + -F 'address=Via del Corso, Roma, Italia' \ + -F geometry.type=Point \ + -F 'geometry.coordinates=[12.512124, 41.898903]' \ + -F type=indoor \ + -F is_mobile=false \ + -F floorplan.floor=1 \ + -F 'floorplan.image=@floorplan.png' \ + -F organization=1f6c5666-1011-4f1d-bce9-fc6fcb4f3a05 + +**Note:** You can also specify the ``geometry`` in **Well-known text (WKT)** +format, like following: + +.. code-block:: text { - "wlan0_ssid": "Room 23 ACME Hotel", - "wlan0_password": "room_23pwd!321654" + "name": "Via del Corso", + "address": "Via del Corso, Roma, Italia", + "geometry": "POINT (12.512124 41.898903)", + "type": "indoor", + "is_mobile": "false", + "floorplan.floor": "1", + "floorplan.image": floorplan.png, + "organization": "1f6c5666-1011-4f1d-bce9-fc6fcb4f3a05" } -How to configure push updates ------------------------------ +Get location details +#################### -Follow the procedure described below to enable secure SSH access from OpenWISP to your -devices, this is required to enable push updates (whenever the configuration is changed, -OpenWISP will trigger the update in the background) and/or -`firmware upgrades (via the additional module openwisp-firmware-upgrader) -`_. +.. code-block:: text -**Note**: If you have installed OpenWISP with `openwisp2 Ansbile role `_ -then you can skip the following steps. The Ansible role automatically creates a -default template to update ``authorized_keys`` on networking devices using the -default access credentials. + GET /api/v1/controller/location/{pk}/ -1. Generate SSH key -~~~~~~~~~~~~~~~~~~~ +Change location details +####################### -First of all, we need to generate the SSH key which will be -used by OpenWISP to access the devices, to do so, you can use the following command: +.. code-block:: text -.. code-block:: shell + PUT /api/v1/controller/location/{pk}/ - echo './sshkey' | ssh-keygen -t rsa -b 4096 -C "openwisp" +**Note**: Only the first floorplan data present can be +edited or changed. Setting the ``type`` of location to +outdoor will remove all the floorplans associated with it. -This will create two files in the current directory, one called ``sshkey`` (the private key) and one called -``sshkey.pub`` (the public key). +Refer `examples of "Create location" +section for information on payload format <#create-location>`_. -Store the content of these files in a secure location. +Delete location +############### -2. Save SSH private key in OpenWISP (access credentials) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: text -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/add-ssh-credentials-private-key.png - :alt: add SSH private key as access credential in OpenWISP + DELETE /api/v1/controller/location/{pk}/ -From the first page of OpenWISP click on "Access credentials", then click -on the **"ADD ACCESS CREDENTIALS"** button in the upper right corner -(alternatively, go to the following URL: ``/admin/connection/credentials/add/``). +List devices in a location +########################## -Select SSH as ``type``, enable the **Auto add** checkbox, then at the field -"Credentials type" select "SSH (private key)", now type "root" in the ``username`` field, -while in the ``key`` field you have to paste the contents of the private key just created. +.. code-block:: text -Now hit save. + GET /api/v1/controller/location/{id}/device/ -The credentials just created will be automatically enabled for all the devices in the system -(both existing devices and devices which will be added in the future). +List locations with devices deployed (in GeoJSON format) +######################################################## -3. Add the public key to your devices -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +**Note**: this endpoint will only list locations that have been assigned to a device. -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/add-authorized-ssh-keys-template.png - :alt: Add authorized SSH public keys template to OpenWISP (OpenWRT) +.. code-block:: text -Now we need to instruct your devices to allow OpenWISP accessing via SSH, -in order to do this we need to add the contents of the public key file created in step 1 -(``sshkey.pub``) in the file ``/etc/dropbear/authorized_keys`` on the devices, the -recommended way to do this is to create a configuration template in OpenWISP: -from the first page of OpenWISP, click on "Templates", then and click on the -**"ADD TEMPLATE"** button in the upper right corner (alternatively, go to the following URL: -``/admin/config/template/add/``). + GET /api/v1/controller/location/geojson/ -Check **enabled by default**, then scroll down the configuration section, -click on "Configuration Menu", scroll down, click on "Files" then close the menu -by clicking again on "Configuration Menu". Now type ``/etc/dropbear/authorized_keys`` -in the ``path`` field of the file, then paste the contents of ``sshkey.pub`` in ``contents``. +You can filter using ``organization_slug`` to get list location of +devices from that organization. -Now hit save. +.. code-block:: text -**There's a catch**: you will need to assign the template to any existing device. + GET /api/v1/controller/location/geojson/?organization_slug= -4. Test it -~~~~~~~~~~ +List floorplans +############### -Once you have performed the 3 steps above, you can test it as follows: +.. code-block:: text -1. Ensure there's at least one device turned on and connected to OpenWISP, ensure - this device has the "SSH Authorized Keys" assigned to it. -2. Ensure the celery worker of OpenWISP Controller is running (eg: ``ps aux | grep celery``) -3. SSH into the device and wait (maximum 2 minutes) until ``/etc/dropbear/authorized_keys`` - appears as specified in the template. -4. While connected via SSH to the device run the following command in the console: - ``logread -f``, now try changing the device name in OpenWISP -5. Shortly after you change the name in OpenWISP, you should see some output in the - SSH console indicating another SSH access and the configuration update being performed. + GET /api/v1/controller/floorplan/ + +You can filter using ``organization_slug`` to get list floorplans that +belongs to an organization. + +.. code-block:: text + + GET /api/v1/controller/floorplan/?organization_slug= + +Create floorplan +################ + +.. code-block:: text + + POST /api/v1/controller/floorplan/ + +Get floorplan details +##################### + +.. code-block:: text + + GET /api/v1/controller/floorplan/{pk}/ + +Change floorplan details +######################## + +.. code-block:: text + + PUT /api/v1/controller/floorplan/{pk}/ + +Delete floorplan +################ + +.. code-block:: text + + DELETE /api/v1/controller/floorplan/{pk}/ + +List templates +############## + +.. code-block:: text + + GET /api/v1/controller/template/ + +Create template +############### + +.. code-block:: text + + POST /api/v1/controller/template/ + +Get template detail +################### + +.. code-block:: text + + GET /api/v1/controller/template/{id}/ + +Download template configuration +############################### + +.. code-block:: text + + GET /api/v1/controller/template/{id}/configuration/ + +The above endpoint triggers the download of a ``tar.gz`` file +containing the generated configuration for that specific template. + +Change details of template +########################## + +.. code-block:: text + + PUT /api/v1/controller/template/{id}/ + +Patch details of template +######################### + +.. code-block:: text + + PATCH /api/v1/controller/template/{id}/ + +Delete template +############### + +.. code-block:: text + + DELETE /api/v1/controller/template/{id}/ + +List VPNs +######### + +.. code-block:: text + + GET /api/v1/controller/vpn/ + +Create VPN +########## + +.. code-block:: text + + POST /api/v1/controller/vpn/ + +Get VPN detail +############## + +.. code-block:: text + + GET /api/v1/controller/vpn/{id}/ + +Download VPN configuration +########################## + +.. code-block:: text + + GET /api/v1/controller/vpn/{id}/configuration/ + +The above endpoint triggers the download of a ``tar.gz`` file +containing the generated configuration for that specific VPN. + +Change details of VPN +##################### + +.. code-block:: text + + PUT /api/v1/controller/vpn/{id}/ + +Patch details of VPN +#################### + +.. code-block:: text + + PATCH /api/v1/controller/vpn/{id}/ + +Delete VPN +########## + +.. code-block:: text + + DELETE /api/v1/controller/vpn/{id}/ + +List CA +####### + +.. code-block:: text + + GET /api/v1/controller/ca/ + +Create new CA +############# + +.. code-block:: text + + POST /api/v1/controller/ca/ + +Import existing CA +################## + +.. code-block:: text + + POST /api/v1/controller/ca/ + +**Note**: To import an existing CA, only ``name``, ``certificate`` +and ``private_key`` fields have to be filled in the ``HTML`` form or +included in the ``JSON`` format. + +Get CA Detail +############# + +.. code-block:: text + + GET /api/v1/controller/ca/{id}/ + +Change details of CA +#################### + +.. code-block:: text + + PUT /api/v1/controller/ca/{id}/ + +Patch details of CA +################### + +.. code-block:: text + + PATCH /api/v1/controller/ca/{id}/ + +Download CA(crl) +################ + +.. code-block:: text + + GET /api/v1/controller/ca/{id}/crl/ + +The above endpoint triggers the download of ``{id}.crl`` file containing +up to date CRL of that specific CA. + +Delete CA +######### + +.. code-block:: text + + DELETE /api/v1/controller/ca/{id}/ + +Renew CA +######## + +.. code-block:: text + + POST /api/v1/controller/ca/{id}/renew/ + +List Cert +######### + +.. code-block:: text + + GET /api/v1/controller/cert/ + +Create new Cert +############### + +.. code-block:: text + + POST /api/v1/controller/cert/ + +Import existing Cert +#################### + +.. code-block:: text + + POST /api/v1/controller/cert/ + +**Note**: To import an existing Cert, only ``name``, ``ca``, +``certificate`` and ``private_key`` fields have to be filled +in the ``HTML`` form or included in the ``JSON`` format. + +Get Cert Detail +############### + +.. code-block:: text + + GET /api/v1/controller/cert/{id}/ + +Change details of Cert +###################### + +.. code-block:: text + + PUT /api/v1/controller/cert/{id}/ + +Patch details of Cert +##################### + +.. code-block:: text + + PATCH /api/v1/controller/cert/{id}/ + +Delete Cert +########### + +.. code-block:: text + + DELETE /api/v1/controller/cert/{id}/ + +Renew Cert +########## + +.. code-block:: text + + POST /api/v1/controller/cert/{id}/renew/ + +Revoke Cert +########### + +.. code-block:: text + + POST /api/v1/controller/cert/{id}/revoke/ + +Settings +-------- + +You can change the values for the following variables in +``settings.py`` to configure your instance of openwisp-controller. + +``OPENWISP_SSH_AUTH_TIMEOUT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``int`` | ++--------------+-------------+ +| **default**: | ``2`` | ++--------------+-------------+ +| **unit**: | ``seconds`` | ++--------------+-------------+ + +Configure timeout to wait for an authentication response when establishing a SSH connection. + +``OPENWISP_SSH_BANNER_TIMEOUT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``int`` | ++--------------+-------------+ +| **default**: | ``60`` | ++--------------+-------------+ +| **unit**: | ``seconds`` | ++--------------+-------------+ + +Configure timeout to wait for the banner to be presented when establishing a SSH connection. + +``OPENWISP_SSH_COMMAND_TIMEOUT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``int`` | ++--------------+-------------+ +| **default**: | ``30`` | ++--------------+-------------+ +| **unit**: | ``seconds`` | ++--------------+-------------+ + +Configure timeout on blocking read/write operations when executing a command in a SSH connection. + +``OPENWISP_SSH_CONNECTION_TIMEOUT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``int`` | ++--------------+-------------+ +| **default**: | ``5`` | ++--------------+-------------+ +| **unit**: | ``seconds`` | ++--------------+-------------+ + +Configure timeout for the TCP connect when establishing a SSH connection. + +``OPENWISP_CONNECTORS`` +~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+--------------------------------------------------------------------+ +| **type**: | ``tuple`` | ++--------------+--------------------------------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | ( | +| | ('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'), | +| | ) | ++--------------+--------------------------------------------------------------------+ + +Available connector classes. Connectors are python classes that specify ways +in which OpenWISP can connect to devices in order to launch commands. + +``OPENWISP_UPDATE_STRATEGIES`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+----------------------------------------------------------------------------------------+ +| **type**: | ``tuple`` | ++--------------+----------------------------------------------------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | ( | +| | ('openwisp_controller.connection.connectors.openwrt.ssh.OpenWrt', 'OpenWRT SSH'), | +| | ) | ++--------------+----------------------------------------------------------------------------------------+ + +Available update strategies. An update strategy is a subclass of a +connector class which defines an ``update_config`` method which is +in charge of updating the configuration of the device. + +This operation is launched in a background worker when the configuration +of a device is changed. + +It's possible to write custom update strategies and add them to this +setting to make them available in OpenWISP. + +``OPENWISP_CONFIG_UPDATE_MAPPING`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+--------------------------------------------------------------------+ +| **type**: | ``dict`` | ++--------------+--------------------------------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | { | +| | 'netjsonconfig.OpenWrt': OPENWISP_UPDATE_STRATEGIES[0][0], | +| | } | ++--------------+--------------------------------------------------------------------+ + +A dictionary that maps configuration backends to update strategies in order to +automatically determine the update strategy of a device connection if the +update strategy field is left blank by the user. + +``OPENWISP_CONTROLLER_BACKENDS`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-----------------------------------------------+ +| **type**: | ``tuple`` | ++--------------+-----------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | ( | +| | ('netjsonconfig.OpenWrt', 'OpenWRT'), | +| | ('netjsonconfig.OpenWisp', 'OpenWISP'), | +| | ) | ++--------------+-----------------------------------------------+ + +Available configuration backends. For more information, see `netjsonconfig backends +`_. + +``OPENWISP_CONTROLLER_VPN_BACKENDS`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+----------------------------------------------------------------------------------+ +| **type**: | ``tuple`` | ++--------------+----------------------------------------------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | ( | +| | ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'), | +| | ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'), | +| | ('openwisp_controller.vpn_backends.VxlanWireguard', 'VXLAN over WireGuard'), | +| | ) | ++--------------+----------------------------------------------------------------------------------+ + +Available VPN backends for VPN Server objects. For more information, see `netjsonconfig VPN backends +`_. + +A VPN backend must follow some basic rules in order to be compatible with *openwisp-controller*: + +* it MUST allow at minimum and at maximum one VPN instance +* the main *NetJSON* property MUST match the lowercase version of the class name, + eg: when using the ``OpenVpn`` backend, the system will look into + ``config['openvpn']`` +* it SHOULD focus on the server capabilities of the VPN software being used + +``OPENWISP_CONTROLLER_DEFAULT_BACKEND`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+----------------------------------------+ +| **type**: | ``str`` | ++--------------+----------------------------------------+ +| **default**: | ``OPENWISP_CONTROLLER_BACKENDS[0][0]`` | ++--------------+----------------------------------------+ + +The preferred backend that will be used as initial value when adding new ``Config`` or +``Template`` objects in the admin. + +This setting defaults to the raw value of the first item in the ``OPENWISP_CONTROLLER_BACKENDS`` setting, +which is ``netjsonconfig.OpenWrt``. + +Setting it to ``None`` will force the user to choose explicitly. + +``OPENWISP_CONTROLLER_DEFAULT_VPN_BACKEND`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+--------------------------------------------+ +| **type**: | ``str`` | ++--------------+--------------------------------------------+ +| **default**: | ``OPENWISP_CONTROLLER_VPN_BACKENDS[0][0]`` | ++--------------+--------------------------------------------+ + +The preferred backend that will be used as initial value when adding new ``Vpn`` objects in the admin. + +This setting defaults to the raw value of the first item in the ``OPENWISP_CONTROLLER_VPN_BACKENDS`` setting, +which is ``openwisp_controller.vpn_backends.OpenVpn``. + +Setting it to ``None`` will force the user to choose explicitly. + +``OPENWISP_CONTROLLER_REGISTRATION_ENABLED`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``True`` | ++--------------+-------------+ + +Whether devices can automatically register through the controller or not. + +This feature is enabled by default. + +Autoregistration must be supported on the devices in order to work, see `openwisp-config automatic +registration `_ for more information. + +``OPENWISP_CONTROLLER_CONSISTENT_REGISTRATION`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``True`` | ++--------------+-------------+ + +Whether devices that are already registered are recognized when reflashed or reset, hence keeping +the existing configuration without creating a new one. + +This feature is enabled by default. + +Autoregistration must be enabled also on the devices in order to work, see `openwisp-config +consistent key generation `_ +for more information. + +``OPENWISP_CONTROLLER_REGISTRATION_SELF_CREATION`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``True`` | ++--------------+-------------+ + +Whether devices that are not already present in the system are allowed to register or not. + +Turn this off if you still want to use auto-registration to avoid having to +manually set the device UUID and key in its configuration file but also want +to avoid indiscriminate registration of new devices without explicit permission. + +``OPENWISP_CONTROLLER_CONTEXT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+------------------+ +| **type**: | ``dict`` | ++--------------+------------------+ +| **default**: | ``{}`` | ++--------------+------------------+ + +Additional context that is passed to the default context of each device object. + +``OPENWISP_CONTROLLER_CONTEXT`` can be used to define system-wide configuration variables. + +For more information regarding how to use configuration variables in OpenWISP, +see `How to use configuration variables <#how-to-use-configuration-variables>`_. + +For technical information about how variables are handled in the lower levels +of OpenWISP, see `netjsonconfig context: configuration variables +`_. + +``OPENWISP_CONTROLLER_DEFAULT_AUTO_CERT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+---------------------------+ +| **type**: | ``bool`` | ++--------------+---------------------------+ +| **default**: | ``True`` | ++--------------+---------------------------+ + +The default value of the ``auto_cert`` field for new ``Template`` objects. + +The ``auto_cert`` field is valid only for templates which have ``type`` +set to ``VPN`` and indicates whether configuration regarding the VPN tunnel is +provisioned automatically to each device using the template, eg: + +- when using OpenVPN, new `x509 `_ certificates + will be generated automatically using the same CA assigned to the related VPN object +- when using WireGuard, new pair of private and public keys + (using `Curve25519 `_) will be generated, as well as + an IP address of the subnet assigned to the related VPN object +- when using `VXLAN `_ tunnels over Wireguad, + in addition to the configuration generated for WireGuard, a new VID will be generated + automatically for each device if the configuration option "auto VNI" is turned on in + the VPN object + +All these auto generated configuration options will be available as +template variables. + +The objects that are automatically created will also be removed when they are not +needed anymore (eg: when the VPN template is removed from a configuration object). + +``OPENWISP_CONTROLLER_CERT_PATH`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Sending Commands to Devices ---------------------------- ++--------------+---------------------------+ +| **type**: | ``str`` | ++--------------+---------------------------+ +| **default**: | ``/etc/x509`` | ++--------------+---------------------------+ -By default, there are three options in the **Send Command** dropdown: +The filesystem path where x509 certificate will be installed when +downloaded on routers when ``auto_cert`` is being used (enabled by default). -1. Reboot -2. Change Password -3. Custom Command +``OPENWISP_CONTROLLER_COMMON_NAME_FORMAT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -While the first two options are self-explanatory, the **custom command** option -allows you to execute any command on the device as shown in the example below. ++--------------+------------------------------+ +| **type**: | ``str`` | ++--------------+------------------------------+ +| **default**: | ``{mac_address}-{name}`` | ++--------------+------------------------------+ -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/commands_demo.gif - :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/commands_demo.gif - :alt: Executing commands on device example +Defines the format of the ``common_name`` attribute of VPN client certificates +that are automatically created when using VPN templates which have ``auto_cert`` +set to ``True``. A unique slug generated using `shortuuid `_ +is appended to the common name to introduce uniqueness. Therefore, resulting +common names will have ``{OPENWISP_CONTROLLER_COMMON_NAME_FORMAT}-{unique-slug}`` +format. -**Note**: in order for this feature to work, a device needs to have at least -one **Access Credential** (see `How to configure push updates <#how-to-configure-push-updates>`__). +**Note:** If the ``name`` and ``mac address`` of the device are equal, +the ``name`` of the device will be omitted from the common name to avoid redundancy. -The **Send Command** button will be hidden until the device -has at least one **Access Credential**. +``OPENWISP_CONTROLLER_MANAGEMENT_IP_DEVICE_LIST`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you need to allow your users to quickly send specific commands that are used often in your -network regardless of your users' knowledge of Linux shell commands, you can add new commands -by following instructions in `"How to add commands" <#how-to-add-commands>`_ section. ++--------------+------------------------------+ +| **type**: | ``bool`` | ++--------------+------------------------------+ +| **default**: | ``True`` | ++--------------+------------------------------+ -If you are an advanced user and want to register commands programatically, then refer to -`"Register / Unregistering commands" <#registering--unregistering-commands>`_ section. +In the device list page, the column ``IP`` will show the ``management_ip`` if +available, defaulting to ``last_ip`` otherwise. -How to add commands -~~~~~~~~~~~~~~~~~~~ +If this setting is set to ``False`` the ``management_ip`` won't be shown +in the device list page even if present, it will be shown only in the device +detail page. -This example introduces a simple command that could ``ping`` an input -``destination_address`` through an interface, ``interface_name``. +You may set this to ``False`` if for some reason the majority of your user +doesn't care about the management ip address. -.. code-block:: python +``OPENWISP_CONTROLLER_CONFIG_BACKEND_FIELD_SHOWN`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # In yourproject/settings.py ++--------------+------------------------------+ +| **type**: | ``bool`` | ++--------------+------------------------------+ +| **default**: | ``True`` | ++--------------+------------------------------+ - def ping_command_callable(destination_address, interface_name=None): - command = f'ping -c 4 {destination_address}' - if interface_name: - command += f' -I {interface_name}' - return command +This setting toggles the ``backend`` fields in add/edit pages in Device and Template configuration, +as well as the ``backend`` field/filter in Device list and Template list. - OPENWISP_CONTROLLER_USER_COMMANDS = [ - ( - 'ping', - { - 'label': 'Ping', - 'schema': { - 'title': 'Ping', - 'type': 'object', - 'required': ['destination_address'], - 'properties': { - 'destination_address': { - 'type': 'string', - 'title': 'Destination Address', - }, - 'interface_name': { - 'type': 'string', - 'title': 'Interface Name', - }, - }, - 'message': 'Destination Address cannot be empty', - 'additionalProperties': False, - }, - 'callable': ping_command_callable, - } - ) - ] +If this setting is set to ``False`` these items will be removed from the UI. -The above code will add "Ping" command as show in the GIF below: +Note: This setting affects only the configuration backend and NOT the VPN backend. -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/ping_command_example.gif - :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/ping_command_example.gif - :alt: Adding a "ping" command +``OPENWISP_CONTROLLER_DEVICE_NAME_UNIQUE`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``OPENWISP_CONTROLLER_USER_COMMANDS`` setting takes a ``list`` of ``tuple`` -each containing two elements. The first element of the tuple should contain an -identifier for the command and the second element should contain a ``dict`` -defining configuration of the command. ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``True`` | ++--------------+-------------+ -Command Configuration -^^^^^^^^^^^^^^^^^^^^^ +This setting conditionally enforces unique Device names in an Organization. +The query to enforce this is case-insensitive. -The ``dict`` defining configuration for command should contain following keys: +Note: For this constraint to be optional, it is enforced on an application level and not on database. -1. ``label`` -"""""""""""" +``OPENWISP_CONTROLLER_HARDWARE_ID_ENABLED`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -A ``str`` defining label for the command used internally by Django. ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``False`` | ++--------------+-------------+ -2. ``schema`` -""""""""""""" +The field ``hardware_id`` can be used to store a unique hardware id, for example a serial number. -A ``dict`` defining `JSONSchema `_ for inputs of command. -You can specify the inputs for your command, add rules for performing validation -and make inputs required or optional. +If this setting is set to ``True`` then this field will be shown first in the device list page +and in the add/edit device page. -Here is a detailed explanation of the schema used in above example: +This feature is disabled by default. + +``OPENWISP_CONTROLLER_HARDWARE_ID_OPTIONS`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+--------------------------------------------------------------+ +| **type**: | ``dict`` | ++--------------+--------------------------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | { | +| | 'blank': not OPENWISP_CONTROLLER_HARDWARE_ID_ENABLED, | +| | 'null': True, | +| | 'max_length': 32, | +| | 'unique': True, | +| | 'verbose_name': _('Serial number'), | +| | 'help_text': _('Serial number of this device') | +| | } | ++--------------+--------------------------------------------------------------+ + +Options for the model field ``hardware_id``. + +* ``blank``: wether the field is allowed to be blank +* ``null``: wether an empty value will be stored as ``NULL`` in the database +* ``max_length``: maximum length of the field +* ``unique``: wether the value of the field must be unique +* ``verbose_name``: text for the human readable label of the field +* ``help_text``: help text to be displayed with the field + +``OPENWISP_CONTROLLER_HARDWARE_ID_AS_NAME`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``True`` | ++--------------+-------------+ + +When the hardware ID feature is enabled, devices will be referenced with +their hardware ID instead of their name. + +If you still want to reference devices by their name, set this to ``False``. + +``OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+----------------------------+ +| **type**: | ``tuple`` | ++--------------+----------------------------+ +| **default**: | ``('Device', 'Devices')`` | ++--------------+----------------------------+ + +Defines the ``verbose_name`` attribute of the ``Device`` model, which is displayed in the +admin site. The first and second element of the tuple represent the singular and plural forms. + +For example, if we want to change the verbose name to "Hotspot", we could write: .. code-block:: python - { - # Name of the command displayed in "Send Command" widget - 'title': 'Ping', - # Use type "object" if the command needs to accept inputs - # Use type "null" if the command does not accepts any input - 'type': 'object', - # Specify list of inputs that are required - 'required': ['destination_address'], - # Define the inputs for the commands along with their properties - 'properties': { - 'destination_address': { - # type of the input value - 'type': 'string', - # label used for displaying this input field - 'title': 'Destination Address', - }, - 'interface_name': { - 'type': 'string', - 'title': 'Interface Name', - }, - }, - # Error message to be shown if validation fails - 'message': 'Destination Address cannot be empty'), - # Whether specifying addtionaly inputs is allowed from the input form - 'additionalProperties': False, - } + OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME = ('Hotspot', 'Hotspots') -This example uses only handful of properties available in JSONSchema. You can -experiment with other properties of JSONSchema for schema of your command. +``OPENWISP_CONTROLLER_HIDE_AUTOMATICALLY_GENERATED_SUBNETS_AND_IPS`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -3. ``callable`` -""""""""""""""" ++--------------+-----------+ +| **type**: | ``bool`` | ++--------------+-----------+ +| **default**: | ``False`` | ++--------------+-----------+ -A ``callable`` or ``str`` defining dotted path to a callable. It should return -the command (``str``) to be executed on the device. Inputs of the command are -passed as arguments to this callable. +Setting this to ``True`` will hide subnets and IPs generated using `subnet division rules <#subnet-division-app>`_ +from being displayed on the changelist view of Subnet and IP admin. + +``OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+---------------------------------------------------------------------------------------------------------+ +| **type**: | ``tuple`` | ++--------------+---------------------------------------------------------------------------------------------------------+ +| **default**: | .. code-block:: python | +| | | +| | ( | +| | ('openwisp_controller.subnet_division.rule_types.device.DeviceSubnetDivisionRuleType', 'Device'), | +| | ('openwisp_controller.subnet_division.rule_types.vpn.VpnSubnetDivisionRuleType', 'VPN'), | +| | ) | +| | | ++--------------+---------------------------------------------------------------------------------------------------------+ + +`Available types for Subject Division Rule <#device-subnet-division-rule>`_ objects. +For more information on how to write your own types, read +`"Custom Subnet Division Rule Types" section of this documentation <#custom-subnet-division-rule-types>`_ + +``OPENWISP_CONTROLLER_API`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-----------+ +| **type**: | ``bool`` | ++--------------+-----------+ +| **default**: | ``True`` | ++--------------+-----------+ + +Indicates whether the API for Openwisp Controller is enabled or not. +To disable the API by default add `OPENWISP_CONTROLLER_API = False` in `settings.py` file. + +``OPENWISP_CONTROLLER_API_HOST`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-----------+ +| **type**: | ``str`` | ++--------------+-----------+ +| **default**: | ``None`` | ++--------------+-----------+ + +Allows to specify backend URL for API requests, if the frontend is hosted separately. + +``OPENWISP_CONTROLLER_USER_COMMANDS`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The example above includes a callable(``ping_command_callable``) for -``ping`` command. ++--------------+----------+ +| **type**: | ``list`` | ++--------------+----------+ +| **default**: | ``[]`` | ++--------------+----------+ -Registering / Unregistering Commands -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to specify a `list` of tuples for adding commands as described in +`'How to add commands" <#how-to-add-commands>`_ section. -OpenWISP Controller provides registering and unregistering commands -through utility functions ``openwisp_controller.connection.commands.register_command`` -and ``openwisp_notifications.types.unregister_notification_type``. -Using these functions you can register or unregister commands from your code. +``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Note**: These functions are to be used as an alternative to the -`"OPENWISP_CONTROLLER_USER_COMMANDS" <#openwisp-controller-user-commands>`_ when -`developing custom modules based on openwisp-controller <#extending-openwisp-controller>`_ ++--------------+------------------------------------------+ +| **type**: | ``dict`` | ++--------------+------------------------------------------+ +| **default**: | ``{'type': 'object', 'properties': {}}`` | ++--------------+------------------------------------------+ -``register_command`` -^^^^^^^^^^^^^^^^^^^^ +Allows specifying JSONSchema used for validating meta-data of `Device Group <#device-groups>`_. -+--------------------+------------------------------------------------------------------+ -| Parameter | Description | -+--------------------+------------------------------------------------------------------+ -| ``command_name`` | A ``str`` defining identifier for the command. | -+--------------------+------------------------------------------------------------------+ -| ``command_config`` | A ``dict`` defining configuration of the command | -| | as shown in `"Command Configuration" <#command-configuration>`_. | -+--------------------+------------------------------------------------------------------+ +``OPENWISP_CONTROLLER_SHARED_MANAGEMENT_IP_ADDRESS_SPACE`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Note:** It will raise ``ImproperlyConfigured`` exception if a command is already -registered with the same name. ++--------------+----------+ +| **type**: | ``bool`` | ++--------------+----------+ +| **default**: | ``True`` | ++--------------+----------+ -``unregister_command`` -^^^^^^^^^^^^^^^^^^^^^^ +By default, the system assumes that the address space of the management +tunnel is shared among all the organizations using the system, that is, +the system assumes there's only one management VPN, tunnel or other +networking technology to reach the devices it controls. -+--------------------+-----------------------------------------+ -| Parameter | Description | -+--------------------+-----------------------------------------+ -| ``command_name`` | A ``str`` defining name of the command. | -+--------------------+-----------------------------------------+ +When set to ``True``, any device belonging to any +organization will never have the same ``management_ip`` as another device, +the latest device declaring the management IP will take the IP and any +other device who declared the same IP in the past will have the field +reset to empty state to avoid potential conflicts. -**Note:** It will raise ``ImproperlyConfigured`` exception if such command does not exists. +Set this to ``False`` if every organization has its dedicated management +tunnel with a dedicated address space that is reachable by the OpenWISP server. -Default Templates ------------------ +``OPENWISP_CONTROLLER_DSA_OS_MAPPING`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -When templates are flagged as default, they will be automatically assigned to new devices. ++--------------+----------+ +| **type**: | ``dict`` | ++--------------+----------+ +| **default**: | ``{}`` | ++--------------+----------+ -If there are multiple default templates, these are assigned to the device in alphabetical -order based on their names, for example, given the following default templates: +OpenWISP Controller can figure out whether it should use the new OpenWrt syntax +for DSA interfaces (Distributed Switch Architecture) introduced in OpenWrt 21 by +reading the ``os`` field of the ``Device`` object. However, if the firmware you +are using has a custom firmware identifier, the system will not be able to figure +out whether it should use the new syntax and it will default to +`OPENWISP_CONTROLLER_DSA_DEFAULT_FALLBACK <#openwisp_controller_dsa_default_fallback>`_. -- Access -- Interfaces -- SSH Keys +If you want to make sure the system can parse your custom firmware +identifier properly, you can follow the example below. -They will be assigned to devices in exactly that order. +For the sake of the example, the OS identifier ``MyCustomFirmware 2.0`` +corresponds to ``OpenWrt 19.07``, while ``MyCustomFirmware 2.1`` corresponds to +``OpenWrt 21.02``. Configuring this setting as indicated below will allow +OpenWISP to supply the right syntax automatically. -If for some technical reason (eg: one default template depends on the presence of another -default template which must be assigned earlier) you need to change the ordering, you can -simply rename the templates by prefixing them with numbers, eg: +Example: -- 1 Interfaces -- 2. SSH Keys -- 3. Access +.. code-block:: python -Required Templates ------------------- + OPENWISP_CONTROLLER_DSA_OS_MAPPING = { + 'netjsonconfig.OpenWrt': { + # OpenWrt >=21.02 configuration syntax will be used for + # these OS identifiers. + '>=21.02': [r'MyCustomFirmware 2.1(.*)'], + # OpenWrt <=21.02 configuration syntax will be used for + # these OS identifiers. + '<21.02': [r'MyCustomFirmware 2.0(.*)'] + } + } -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/required-templates.png - :alt: Required template example +**Note**: The OS identifier should be a regular expression as shown in above example. -Required templates are similar to `Default templates <#default-templates>`__ -but cannot be unassigned from a device configuration, they can only be overridden. +``OPENWISP_CONTROLLER_DSA_DEFAULT_FALLBACK`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -They will be always assigned earlier than default templates, -so they can be overridden if needed. ++--------------+----------+ +| **type**: | ``bool`` | ++--------------+----------+ +| **default**: | ``True`` | ++--------------+----------+ -In the example above, the "SSID" template is flagged as "(required)" -and its checkbox is always checked and disabled. +The value of this setting decides whether to use DSA syntax +(OpenWrt >=21 configuration syntax) if openwisp-controller fails +to make that decision automatically. Signals ------- @@ -1805,7 +2833,7 @@ The signal is also emitted when one of the templates used by the device is modified or if the templates assigned to the device are changed. Special cases in which ``config_modified`` is not emitted -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +######################################################### This signal is not emitted when the device is created for the first time. @@ -1880,6 +2908,7 @@ it is not sent if the response was not successful. - ``old_is_working``: previous value of ``DeviceConnection.is_working``, either ``None`` (for new connections), ``True`` or ``False`` - ``failure_reason``: error message explaining reason for failure in establishing connection +- ``old_failure_reason``: previous value of ``DeviceConnection.failure_reason`` This signal is emitted every time ``DeviceConnection.is_working`` changes. @@ -1943,125 +2972,34 @@ The signal is emitted when the device group changes. It is not emitted when the device is created. -Setup (integrate in an existing django project) ------------------------------------------------ - -Add ``openwisp_controller`` applications to ``INSTALLED_APPS``: - -.. code-block:: python - - INSTALLED_APPS = [ - ... - # openwisp2 modules - 'openwisp_controller.config', - 'openwisp_controller.pki', - 'openwisp_controller.geo', - 'openwisp_controller.connection', - 'openwisp_controller.notifications', - 'openwisp_users', - 'openwisp_notifications', - # openwisp2 admin theme - # (must be loaded here) - 'openwisp_utils.admin_theme', - 'django.contrib.admin', - 'django.forms', - ... - ] - EXTENDED_APPS = ('django_x509', 'django_loci') - -**Note**: The order of applications in ``INSTALLED_APPS`` should be maintained, -otherwise it might not work properly. - -Other settings needed in ``settings.py``: - -.. code-block:: python - - STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'openwisp_utils.staticfiles.DependencyFinder', - ] - - ASGI_APPLICATION = 'openwisp_controller.geo.channels.routing.channel_routing' - CHANNEL_LAYERS = { - # in production you should use another channel layer backend - 'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}, - } - - TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'OPTIONS': { - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - 'openwisp_utils.loaders.DependencyLoader', - ], - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'openwisp_utils.admin_theme.context_processor.menu_items', - 'openwisp_notifications.context_processors.notification_api_settings', - ], - }, - } - ] - - FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' - -Add the URLs to your main ``urls.py``: - -.. code-block:: python - - urlpatterns = [ - # ... other urls in your project ... - # openwisp-controller urls - url(r'^admin/', admin.site.urls), - url(r'', include('openwisp_controller.urls')), - url(r'', include('openwisp_notifications.urls')), - ] - -Configure caching (you may use a different cache storage if you want): - -.. code-block:: python - - CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': 'redis://localhost/0', - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', - } - } - } +``subnet_provisioned`` +~~~~~~~~~~~~~~~~~~~~~~ - SESSION_ENGINE = 'django.contrib.sessions.backends.cache' - SESSION_CACHE_ALIAS = 'default' +**Path**: ``openwisp_controller.subnet_division.signals.subnet_provisioned`` -Configure celery (you may use a different broker if you want): +**Arguments**: -.. code-block:: python +- ``instance``: instance of ``VpnClient``. +- ``provisioned``: dictionary of ``Subnet`` and ``IpAddress`` provisioned, + ``None`` if nothing is provisioned - # here we show how to configure celery with redis but you can - # use other brokers if you want, consult the celery docs - CELERY_BROKER_URL = 'redis://localhost/1' +The signal is emitted when subnets and IP addresses have been provisioned +for a ``VpnClient`` for a VPN server with a subnet with +`subnet division rule <#subnet-division-app>`_. - INSTALLED_APPS.append('djcelery_email') - EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' +``vpn_peers_changed`` +~~~~~~~~~~~~~~~~~~~~~ -If you decide to use redis (as shown in these examples), -install the requierd python packages:: +**Path**: ``openwisp_controller.config.signals.vpn_peers_changed`` - pip install redis django-redis +**Arguments**: -Then run: +- ``instance``: instance of ``Vpn``. -.. code-block:: shell +The signal is emitted when the peers of VPN server gets changed. - ./manage.py migrate +It is only emitted for ``Vpn`` object with **WireGuard** or +**VXLAN over WireGuard** backend. Extending openwisp-controller ----------------------------- @@ -2106,13 +3044,14 @@ You'll need to create 4 apps in your project for each app in openwisp_controller A django app is nothing more than a `python package `_ (a directory of python scripts), in the following examples we'll call these django app -``sample_config``, ``sample_pki``, ``sample_connection`` & ``sample_geo`` -but you can name it how you want:: +``sample_config``, ``sample_pki``, ``sample_connection``, ``sample_geo`` +& ``sample_subnet_division``. but you can name it how you want:: django-admin startapp sample_config django-admin startapp sample_pki django-admin startapp sample_connection django-admin startapp sample_geo + django-admin startapp sample_subnet_division Keep in mind that the command mentioned above must be called from a directory which is available in your `PYTHON_PATH `_ @@ -2132,10 +3071,12 @@ Install (and add to the requirement of your project) openwisp-controller:: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Now you need to add ``mycontroller.sample_config``, -``mycontroller.sample_pki``, ``mycontroller.sample_connection`` -& ``mycontroller.sample_geo`` to ``INSTALLED_APPS`` in your ``settings.py``, -ensuring also that ``openwisp_controller.config``, ``openwisp_controller.geo``, -``openwisp_controller.pki``, ``openwisp_controller.connnection`` have been removed: +``mycontroller.sample_pki``, ``mycontroller.sample_connection``, +``mycontroller.sample_geo`` & ``mycontroller.sample_subnet_division`` to +``INSTALLED_APPS`` in your ``settings.py``, ensuring also that +``openwisp_controller.config``, ``openwisp_controller.geo``, +``openwisp_controller.pki``, ``openwisp_controller.connnection`` & +``openwisp_controller.subnet_division`` have been removed: .. code-block:: python @@ -2153,10 +3094,12 @@ ensuring also that ``openwisp_controller.config``, ``openwisp_controller.geo``, # 'openwisp_controller.pki', <-- comment out or delete this line # 'openwisp_controller.geo', <-- comment out or delete this line # 'openwisp_controller.connection', <-- comment out or delete this line + # 'openwisp_controller.subnet_division', <-- comment out or delete this line 'mycontroller.sample_config', 'mycontroller.sample_pki', 'mycontroller.sample_geo', 'mycontroller.sample_connection', + 'mycontroller.sample_subnet_division', 'openwisp_users', # admin 'django.contrib.admin', @@ -2171,8 +3114,8 @@ ensuring also that ``openwisp_controller.config``, ``openwisp_controller.geo``, 'channels', ] -Substitute ``mycontroller``, ``sample_config``, ``sample_pki``, ``sample_connection`` & -``sample_geo`` with the name you chose in step 1. +Substitute ``mycontroller``, ``sample_config``, ``sample_pki``, ``sample_connection``, +``sample_geo`` & ``sample_subnet_division`` with the name you chose in step 1. 4. Add ``EXTENDED_APPS`` ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2188,6 +3131,7 @@ Add the following to your ``settings.py``: 'openwisp_controller.pki', 'openwisp_controller.geo', 'openwisp_controller.connection', + 'openwisp_controller.subnet_division', ) 5. Add ``openwisp_utils.staticfiles.DependencyFinder`` @@ -2250,7 +3194,31 @@ Ensure you are using one of the available geodjango backends, eg: For more information about GeoDjango, please refer to the `geodjango documentation `_. -6. Other Settings +6. Django Channels Setup +~~~~~~~~~~~~~~~~~~~~~~~~ + +Create ``asgi.py`` in your project folder and add following lines in it: + +.. code-block:: python + + from channels.auth import AuthMiddlewareStack + from channels.routing import ProtocolTypeRouter, URLRouter + from channels.security.websocket import AllowedHostsOriginValidator + from django.core.asgi import get_asgi_application + + from openwisp_controller.routing import get_routes + # You can also add your routes like this + from my_app.routing import my_routes + + application = ProtocolTypeRouter( + { "http": get_asgi_application(), + 'websocket': AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(get_routes() + my_routes)) + ) + } + ) + +7. Other Settings ~~~~~~~~~~~~~~~~~ Add the following settings to ``settings.py``: @@ -2259,7 +3227,7 @@ Add the following settings to ``settings.py``: FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' - ASGI_APPLICATION = 'openwisp_controller.geo.channels.routing.channel_routing' + ASGI_APPLICATION = 'my_project.asgi.application' CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels.layers.InMemoryChannelLayer' @@ -2294,6 +3262,10 @@ Please refer to the following files in the sample app of the test project: - `sample_connection/__init__.py `_. - `sample_connection/apps.py `_. +- sample_subnet_division: + - `sample_subnet_division/__init__.py `_. + - `sample_subnet_division/apps.py `_. + You have to replicate and adapt that code in your project. For more information regarding the concept of ``AppConfig`` please refer to @@ -2309,6 +3281,7 @@ to the models of the sample app in the test project. - `sample_geo models `_ - `sample_pki models `_ - `sample_connection models `_ +- `sample_subnet_division `_ You can add fields in a similar way in your ``models.py`` file. @@ -2340,9 +3313,11 @@ Once you have created the models, add the following to your ``settings.py``: CONNECTION_CREDENTIALS_MODEL = 'sample_connection.Credentials' CONNECTION_DEVICECONNECTION_MODEL = 'sample_connection.DeviceConnection' CONNECTION_COMMAND_MODEL = 'sample_connection.Command' + SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = 'sample_subnet_division.SubnetDivisionRule' + SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL = 'sample_subnet_division.SubnetDivisionIndex' -Substitute ``sample_config``, ``sample_pki``, ``sample_connection`` & -``sample_geo`` with the name you chose in step 1. +Substitute ``sample_config``, ``sample_pki``, ``sample_connection``, +``sample_geo`` & ``sample_subnet_division`` with the name you chose in step 1. 9. Create database migrations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2356,9 +3331,10 @@ like the used in the openwisp_controller module, you'll manually need to make a migrations file which would look like: - `sample_config/migrations/0002_default_groups_permissions.py `_ -- `sample_geo/migrations/0002_default_groups_permissions.py `_ -- `sample_pki/migrations/0002_default_groups_permissions.py `_ -- `sample_connection/migrations/0002_default_groups_permissions.py `_ +- `sample_geo/migrations/0002_default_group_permissions.py `_ +- `sample_pki/migrations/0002_default_group_permissions.py `_ +- `sample_connection/migrations/0002_default_group_permissions.py `_ +- `sample_subnet_division/migrations/0002_default_group_permissions.py `_ Create database migrations:: @@ -2376,6 +3352,7 @@ Refer to the ``admin.py`` file of the sample app. - `sample_geo admin.py `_. - `sample_pki admin.py `_. - `sample_connection admin.py `_. +- `sample_subnet_division admin.py `_. To introduce changes to the admin, you can do it in two main ways which are described below. @@ -2383,14 +3360,14 @@ To introduce changes to the admin, you can do it in two main ways which are desc please refer to `"The django admin site" section in the django documentation `_. 1. Monkey patching -^^^^^^^^^^^^^^^^^^ +################## If the changes you need to add are relatively small, you can resort to monkey patching. For example: sample_config -""""""""""""" +^^^^^^^^^^^^^ .. code-block:: python @@ -2404,7 +3381,7 @@ sample_config # DeviceAdmin.fields += ['example'] <-- monkey patching example sample_connection -""""""""""""""""" +^^^^^^^^^^^^^^^^^ .. code-block:: python @@ -2413,7 +3390,7 @@ sample_connection # CredentialsAdmin.fields += ['example'] <-- monkey patching example sample_geo -"""""""""" +^^^^^^^^^^ .. code-block:: python @@ -2422,22 +3399,31 @@ sample_geo # FloorPlanAdmin.fields += ['example'] <-- monkey patching example sample_pki -"""""""""" +^^^^^^^^^^ .. code-block:: python - from openwisp_controller.geo.admin import CaAdmin, CertAdmin + from openwisp_controller.pki.admin import CaAdmin, CertAdmin # CaAdmin.fields += ['example'] <-- monkey patching example +sample_subnet_division +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from openwisp_controller.subnet_division.admin import SubnetDivisionRuleInlineAdmin + + # SubnetDivisionRuleInlineAdmin.fields += ['example'] <-- monkey patching example + 2. Inheriting admin classes -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +########################### If you need to introduce significant changes and/or you don't want to resort to monkey patching, you can proceed as follows: sample_config -""""""""""""" +^^^^^^^^^^^^^ .. code-block:: python @@ -2475,9 +3461,8 @@ sample_config class TemplateAdmin(BaseTemplateAdmin): # add your changes here - sample_connection -""""""""""""""""" +^^^^^^^^^^^^^^^^^ .. code-block:: python @@ -2494,7 +3479,7 @@ sample_connection # add your changes here sample_geo -"""""""""" +^^^^^^^^^^ .. code-block:: python @@ -2520,7 +3505,7 @@ sample_geo # add your changes here sample_pki -"""""""""" +^^^^^^^^^^ .. code-block:: python @@ -2545,6 +3530,39 @@ sample_pki class CertAdmin(BaseCertAdmin): # add your changes here +sample_subnet_division +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from openwisp_controller.subnet_division.admin import ( + SubnetAdmin as BaseSubnetAdmin, + IpAddressAdmin as BaseIpAddressAdmin, + SubnetDivisionRuleInlineAdmin as BaseSubnetDivisionRuleInlineAdmin, + ) + from django.contrib import admin + from swapper import load_model + + Subnet = load_model('openwisp_ipam', 'Subnet') + IpAddress = load_model('openwisp_ipam', 'IpAddress') + SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule') + + admin.site.unregister(Subnet) + admin.site.unregister(IpAddress) + admin.site.unregister(SubnetDivisionRule) + + @admin.register(Subnet) + class SubnetAdmin(BaseSubnetAdmin): + # add your changes here + + @admin.register(IpAddress) + class IpAddressAdmin(BaseIpAddressAdmin): + # add your changes here + + @admin.register(SubnetDivisionRule) + class SubnetDivisionRuleInlineAdmin(BaseSubnetDivisionRuleInlineAdmin): + # add your changes here + 11. Create root URL configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2590,6 +3608,7 @@ See the tests in sample_app to find out how to do this. - `sample_geo pytest.py `_ - `sample_pki tests.py `_ - `sample_connection tests.py `_ +- `sample_subnet_division tests.py `_ For running the tests, you need to copy fixtures as well: @@ -2611,7 +3630,7 @@ Other base classes that can be inherited and extended The following steps are not required and are intended for more advanced customization. 1. Extending the Controller API Views -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##################################### Extending the `sample_config/views.py `_ is required only when you want to make changes in the controller API, @@ -2620,7 +3639,7 @@ Remember to change ``config_views`` location in ``urls.py`` in point 11 for exte For more information about django views, please refer to the `views section in the django documentation `_. 2. Extending the Geo API Views -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +############################## Extending the `sample_geo/views.py `_ is required only when you want to make changes in the geo API, @@ -2628,15 +3647,102 @@ Remember to change ``geo_views`` location in ``urls.py`` in point 11 for extendi For more information about django views, please refer to the `views section in the django documentation `_. +Custom Subnet Division Rule Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is possible to create your own `subnet division rule types <#subnet-division-app>`_. +The rule type determines when subnets and IPs will be provisioned and when they +will be destroyed. + +You can create your custom rule types by extending +``openwisp_controller.subnet_division.rule_types.base.BaseSubnetDivisionRuleType``. + +Below is an example to create a subnet division rule type that will provision +subnets and IPs when a new device is created and will delete them upon deletion +for that device. + +.. code-block:: python + + # In mycontroller/sample_subnet_division/rules_types/custom.py + + from django.db.models.signals import post_delete, post_save + from swapper import load_model + + from openwisp_controller.subnet_division.rule_types.base import ( + BaseSubnetDivisionRuleType, + ) + + Device = load_model('config', 'Device') + + class CustomRuleType(BaseSubnetDivisionRuleType): + # The signal on which provisioning should be triggered + provision_signal = post_save + # The sender of the provision_signal + provision_sender = Device + # Dispatch UID for connecting provision_signal to provision_receiver + provision_dispatch_uid = 'some_unique_identifier_string' + + # The signal on which deletion should be triggered + destroyer_signal = post_delete + # The sender of the destroyer_signal + destroyer_sender = Device + # Dispatch UID for connecting destroyer_signal to destroyer_receiver + destroyer_dispatch_uid = 'another_unique_identifier_string' + + # Attribute path to organization_id + # Example 1: If organization_id is direct attribute of provision_signal + # sender instance, then + # organization_id_path = 'organization_id' + # Example 2: If organization_id is indirect attribute of provision signal + # sender instance, then + # organization_id_path = 'some_attribute.another_intermediate.organization_id' + organization_id_path = 'organization_id' + + # Similar to organization_id_path but for the required subnet attribute + subnet_path = 'subnet' + + # An intermediate method through which you can specify conditions for provisions + @classmethod + def should_create_subnets_ips(cls, instance, **kwargs): + # Using "post_save" provision_signal, the rule should be only + # triggered when a new object is created. + return kwargs['created'] + + # You can define logic to trigger provisioning for existing objects + # using following classmethod. By default, BaseSubnetDivisionRuleType + # performs no operation for existing objects. + @classmethod + def provision_for_existing_objects(cls, rule_obj): + for device in Device.objects.filter( + organization=rule_obj.organization + ): + cls.provision_receiver(device, created=True) + +After creating a class for your custom rule type, you will need to set +`OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES <#openwisp-controller-subnet-division-types>`_ +setting as follows: + +.. code-block:: python + + OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES = ( | + ('openwisp_controller.subnet_division.rule_types.vpn.VpnSubnetDivisionRuleType', 'VPN'), + ('openwisp_controller.subnet_division.rule_types.device.DeviceSubnetDivisionRuleType', 'Device'), + ('mycontroller.sample_subnet_division.rules_types.custom.CustomRuleType', 'Custom Rule'), + ) + Registering new notification types ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can define your own notification types using ``register_notification_type`` function from OpenWISP -Notifications. For more information, see the relevant -`documentation section about registering notification types in openwisp-notifications `_. +You can define your own notification types using +``register_notification_type`` function from OpenWISP Notifications. + +For more information, see the relevant `documentation section about +registering notification types in openwisp-notifications +`_. -Once a new notification type is registered, you have to use the `"notify" signal provided in -openwisp-notifications `_ +Once a new notification type is registered, you have to use the +`"notify" signal provided in openwisp-notifications +`_ to send notifications for this type. Contributing diff --git a/docs/1.1/device-groups.png b/docs/1.1/device-groups.png new file mode 100644 index 000000000..bc2d97bdc Binary files /dev/null and b/docs/1.1/device-groups.png differ diff --git a/docs/1.1/import-export/device-list.png b/docs/1.1/import-export/device-list.png new file mode 100644 index 000000000..03886b651 Binary files /dev/null and b/docs/1.1/import-export/device-list.png differ diff --git a/docs/1.1/import-export/export-page.png b/docs/1.1/import-export/export-page.png new file mode 100644 index 000000000..fb6369c58 Binary files /dev/null and b/docs/1.1/import-export/export-page.png differ diff --git a/docs/1.1/import-export/import-page.png b/docs/1.1/import-export/import-page.png new file mode 100644 index 000000000..095df108a Binary files /dev/null and b/docs/1.1/import-export/import-page.png differ diff --git a/docs/1.1/organization-limits.png b/docs/1.1/organization-limits.png new file mode 100644 index 000000000..cd99fc9e7 Binary files /dev/null and b/docs/1.1/organization-limits.png differ diff --git a/docs/1.3/estimated-locations/admin-list.png b/docs/1.3/estimated-locations/admin-list.png new file mode 100644 index 000000000..bf55a9b92 Binary files /dev/null and b/docs/1.3/estimated-locations/admin-list.png differ diff --git a/docs/1.3/estimated-locations/admin-setting.png b/docs/1.3/estimated-locations/admin-setting.png new file mode 100644 index 000000000..ff8e0264e Binary files /dev/null and b/docs/1.3/estimated-locations/admin-setting.png differ diff --git a/docs/1.3/estimated-locations/estimated-warning.png b/docs/1.3/estimated-locations/estimated-warning.png new file mode 100644 index 000000000..b59f7f425 Binary files /dev/null and b/docs/1.3/estimated-locations/estimated-warning.png differ diff --git a/docs/1.3/estimated-locations/filter-devices-by-estimated-location.png b/docs/1.3/estimated-locations/filter-devices-by-estimated-location.png new file mode 100644 index 000000000..52ca8c914 Binary files /dev/null and b/docs/1.3/estimated-locations/filter-devices-by-estimated-location.png differ diff --git a/docs/1.3/estimated-locations/is-estimated-flag.png b/docs/1.3/estimated-locations/is-estimated-flag.png new file mode 100644 index 000000000..cf79b8564 Binary files /dev/null and b/docs/1.3/estimated-locations/is-estimated-flag.png differ diff --git a/docs/1.3/whois/admin-details.png b/docs/1.3/whois/admin-details.png new file mode 100644 index 000000000..62eb54041 Binary files /dev/null and b/docs/1.3/whois/admin-details.png differ diff --git a/docs/1.3/whois/admin-setting.png b/docs/1.3/whois/admin-setting.png new file mode 100644 index 000000000..2f5c251d3 Binary files /dev/null and b/docs/1.3/whois/admin-setting.png differ diff --git a/docs/1.4/certificate-templates/certificate-template.png b/docs/1.4/certificate-templates/certificate-template.png new file mode 100644 index 000000000..ba3188b8a Binary files /dev/null and b/docs/1.4/certificate-templates/certificate-template.png differ diff --git a/docs/add-authorized-ssh-keys-template.png b/docs/add-authorized-ssh-keys-template.png index 85fa32d76..a48fc4ffd 100644 Binary files a/docs/add-authorized-ssh-keys-template.png and b/docs/add-authorized-ssh-keys-template.png differ diff --git a/docs/add-ssh-credentials-private-key.png b/docs/add-ssh-credentials-private-key.png index a2c40fa73..e4b116108 100644 Binary files a/docs/add-ssh-credentials-private-key.png and b/docs/add-ssh-credentials-private-key.png differ diff --git a/docs/browsable-api-ui.png b/docs/browsable-api-ui.png index 7a58bf98d..f90f679c7 100644 Binary files a/docs/browsable-api-ui.png and b/docs/browsable-api-ui.png differ diff --git a/docs/commands_demo.gif b/docs/commands_demo.gif new file mode 100644 index 000000000..242526f9d Binary files /dev/null and b/docs/commands_demo.gif differ diff --git a/docs/controller_demo.gif b/docs/controller_demo.gif index 7e6ea2d5f..9ed98a95c 100644 Binary files a/docs/controller_demo.gif and b/docs/controller_demo.gif differ diff --git a/docs/device-context.png b/docs/device-context.png index dd4ee7788..81e811437 100644 Binary files a/docs/device-context.png and b/docs/device-context.png differ diff --git a/docs/device-groups.png b/docs/device-groups.png index 57e63380d..876cc4f06 100644 Binary files a/docs/device-groups.png and b/docs/device-groups.png differ diff --git a/docs/devicegroups-piechart.png b/docs/devicegroups-piechart.png new file mode 100644 index 000000000..9b6ae9a41 Binary files /dev/null and b/docs/devicegroups-piechart.png differ diff --git a/docs/live-docu-api.png b/docs/live-docu-api.png index dde1c0d05..b804e98a2 100644 Binary files a/docs/live-docu-api.png and b/docs/live-docu-api.png differ diff --git a/docs/organization-variables.png b/docs/organization-variables.png new file mode 100644 index 000000000..b80546c5c Binary files /dev/null and b/docs/organization-variables.png differ diff --git a/docs/ping_command_example.gif b/docs/ping_command_example.gif new file mode 100644 index 000000000..a06b75162 Binary files /dev/null and b/docs/ping_command_example.gif differ diff --git a/docs/required-templates.png b/docs/required-templates.png index 1b6f6c64f..b2b4af68d 100644 Binary files a/docs/required-templates.png and b/docs/required-templates.png differ diff --git a/docs/subnet-division-rule/apply-template-to-device.png b/docs/subnet-division-rule/apply-template-to-device.png new file mode 100644 index 000000000..59eba7afb Binary files /dev/null and b/docs/subnet-division-rule/apply-template-to-device.png differ diff --git a/docs/subnet-division-rule/subnet-division-rule.png b/docs/subnet-division-rule/subnet-division-rule.png new file mode 100644 index 000000000..65f615ea3 Binary files /dev/null and b/docs/subnet-division-rule/subnet-division-rule.png differ diff --git a/docs/subnet-division-rule/subnet.png b/docs/subnet-division-rule/subnet.png new file mode 100644 index 000000000..bb86d1cfd Binary files /dev/null and b/docs/subnet-division-rule/subnet.png differ diff --git a/docs/subnet-division-rule/system-defined-variables.png b/docs/subnet-division-rule/system-defined-variables.png new file mode 100644 index 000000000..ceadcacd7 Binary files /dev/null and b/docs/subnet-division-rule/system-defined-variables.png differ diff --git a/docs/subnet-division-rule/vpn-client.png b/docs/subnet-division-rule/vpn-client.png new file mode 100644 index 000000000..3e0152752 Binary files /dev/null and b/docs/subnet-division-rule/vpn-client.png differ diff --git a/docs/subnet-division-rule/vpn-server.png b/docs/subnet-division-rule/vpn-server.png new file mode 100644 index 000000000..1d2ebe1e0 Binary files /dev/null and b/docs/subnet-division-rule/vpn-server.png differ diff --git a/docs/system-defined-variables.png b/docs/system-defined-variables.png index 1a03b2026..f8bfe4efa 100644 Binary files a/docs/system-defined-variables.png and b/docs/system-defined-variables.png differ diff --git a/docs/template-default-values.png b/docs/template-default-values.png index 9b08b2927..e9fe18d4a 100644 Binary files a/docs/template-default-values.png and b/docs/template-default-values.png differ diff --git a/docs/wireguard-tutorial/device-configuration.png b/docs/wireguard-tutorial/device-configuration.png new file mode 100644 index 000000000..c12d38961 Binary files /dev/null and b/docs/wireguard-tutorial/device-configuration.png differ diff --git a/docs/wireguard-tutorial/template.png b/docs/wireguard-tutorial/template.png new file mode 100644 index 000000000..79c120734 Binary files /dev/null and b/docs/wireguard-tutorial/template.png differ diff --git a/docs/wireguard-tutorial/vpn-server-1.png b/docs/wireguard-tutorial/vpn-server-1.png new file mode 100644 index 000000000..fd4e627fc Binary files /dev/null and b/docs/wireguard-tutorial/vpn-server-1.png differ diff --git a/docs/wireguard-tutorial/vpn-server-2.png b/docs/wireguard-tutorial/vpn-server-2.png new file mode 100644 index 000000000..20d7c8811 Binary files /dev/null and b/docs/wireguard-tutorial/vpn-server-2.png differ diff --git a/docs/wireguard-tutorial/vpn-server-3.png b/docs/wireguard-tutorial/vpn-server-3.png new file mode 100644 index 000000000..19af67db2 Binary files /dev/null and b/docs/wireguard-tutorial/vpn-server-3.png differ diff --git a/docs/wireguard-vxlan-tutorial/device-configuration.png b/docs/wireguard-vxlan-tutorial/device-configuration.png new file mode 100644 index 000000000..344ce27ed Binary files /dev/null and b/docs/wireguard-vxlan-tutorial/device-configuration.png differ diff --git a/docs/wireguard-vxlan-tutorial/template.png b/docs/wireguard-vxlan-tutorial/template.png new file mode 100644 index 000000000..8d43d337b Binary files /dev/null and b/docs/wireguard-vxlan-tutorial/template.png differ diff --git a/docs/wireguard-vxlan-tutorial/vpn-server-1.png b/docs/wireguard-vxlan-tutorial/vpn-server-1.png new file mode 100644 index 000000000..eeba73802 Binary files /dev/null and b/docs/wireguard-vxlan-tutorial/vpn-server-1.png differ diff --git a/docs/wireguard-vxlan-tutorial/vpn-server-2.png b/docs/wireguard-vxlan-tutorial/vpn-server-2.png new file mode 100644 index 000000000..b3118f100 Binary files /dev/null and b/docs/wireguard-vxlan-tutorial/vpn-server-2.png differ diff --git a/docs/wireguard-vxlan-tutorial/vpn-server-3.png b/docs/wireguard-vxlan-tutorial/vpn-server-3.png new file mode 100644 index 000000000..a4f51632f Binary files /dev/null and b/docs/wireguard-vxlan-tutorial/vpn-server-3.png differ diff --git a/docs/zerotier-tutorial/device-configuration-1.png b/docs/zerotier-tutorial/device-configuration-1.png new file mode 100644 index 000000000..461dc6104 Binary files /dev/null and b/docs/zerotier-tutorial/device-configuration-1.png differ diff --git a/docs/zerotier-tutorial/device-configuration-2.png b/docs/zerotier-tutorial/device-configuration-2.png new file mode 100644 index 000000000..0d11683cd Binary files /dev/null and b/docs/zerotier-tutorial/device-configuration-2.png differ diff --git a/docs/zerotier-tutorial/template.png b/docs/zerotier-tutorial/template.png new file mode 100644 index 000000000..b5f11fefb Binary files /dev/null and b/docs/zerotier-tutorial/template.png differ diff --git a/docs/zerotier-tutorial/vpn-server-1.png b/docs/zerotier-tutorial/vpn-server-1.png new file mode 100644 index 000000000..b44596289 Binary files /dev/null and b/docs/zerotier-tutorial/vpn-server-1.png differ diff --git a/docs/zerotier-tutorial/vpn-server-2.png b/docs/zerotier-tutorial/vpn-server-2.png new file mode 100644 index 000000000..bbd079e29 Binary files /dev/null and b/docs/zerotier-tutorial/vpn-server-2.png differ diff --git a/docs/zerotier-tutorial/vpn-server-3.png b/docs/zerotier-tutorial/vpn-server-3.png new file mode 100644 index 000000000..eab5c8c94 Binary files /dev/null and b/docs/zerotier-tutorial/vpn-server-3.png differ diff --git a/docs/zerotier-tutorial/vpn-server-4.png b/docs/zerotier-tutorial/vpn-server-4.png new file mode 100644 index 000000000..76d906952 Binary files /dev/null and b/docs/zerotier-tutorial/vpn-server-4.png differ diff --git a/docs/zerotier-tutorial/vpn-server-5.png b/docs/zerotier-tutorial/vpn-server-5.png new file mode 100644 index 000000000..8ee0845bf Binary files /dev/null and b/docs/zerotier-tutorial/vpn-server-5.png differ diff --git a/openwisp_controller/admin.py b/openwisp_controller/admin.py index 6ff534475..84bf11d8f 100644 --- a/openwisp_controller/admin.py +++ b/openwisp_controller/admin.py @@ -14,7 +14,7 @@ class OrgVersionMixin(object): """ def recoverlist_view(self, request, extra_context=None): - """ only superusers are allowed to recover deleted objects """ + """only superusers are allowed to recover deleted objects""" if not request.user.is_superuser: raise PermissionDenied return super().recoverlist_view(request, extra_context) diff --git a/openwisp_controller/base.py b/openwisp_controller/base.py index 842892ba1..37ea2d9d0 100644 --- a/openwisp_controller/base.py +++ b/openwisp_controller/base.py @@ -1,5 +1,5 @@ from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from openwisp_users.mixins import ShareableOrgMixin diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index 85397d1de..9e5001fbb 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -1,9 +1,9 @@ import json import logging +import reversion from django import forms from django.conf import settings -from django.conf.urls import url from django.contrib import admin, messages from django.contrib.admin import helpers from django.contrib.admin.models import ADDITION, LogEntry @@ -17,8 +17,8 @@ from django.shortcuts import get_object_or_404 from django.template.loader import get_template from django.template.response import TemplateResponse -from django.urls import path, reverse -from django.utils.translation import ugettext_lazy as _ +from django.urls import path, re_path, reverse +from django.utils.translation import gettext_lazy as _ from flat_json_widget.widgets import FlatJsonWidget from swapper import load_model @@ -138,17 +138,17 @@ def get_urls(self): options = getattr(self.model, '_meta') url_prefix = '{0}_{1}'.format(options.app_label, options.model_name) return [ - url( + re_path( r'^download/(?P[^/]+)/$', self.admin_site.admin_view(self.download_view), name='{0}_download'.format(url_prefix), ), - url( - r'^preview/$', + path( + 'preview/', self.admin_site.admin_view(self.preview_view), name='{0}_preview'.format(url_prefix), ), - url( + re_path( r'^(?P[^/]+)/context\.json$', self.admin_site.admin_view(self.context_view), name='{0}_context'.format(url_prefix), @@ -163,10 +163,11 @@ def _get_config_model(self): def _get_preview_instance(self, request): """ - returns a temporary preview instance used for preview + returns a temporary instance used for preview """ kwargs = {} config_model = self._get_config_model() + instance = config_model() for key, value in request.POST.items(): # skip keys that are not model fields try: @@ -191,9 +192,14 @@ def _get_preview_instance(self, request): # default context to None to avoid exception if 'context' in kwargs: kwargs['context'] = kwargs['context'] or None - # this object is instanciated only to generate the preview - # it won't be saved to the database - instance = config_model(**kwargs) + try: + instance = config_model.objects.get(pk=request.POST['id']) + for key, value in kwargs.items(): + setattr(instance, key, value) + except (KeyError, ValidationError, config_model.DoesNotExist): + # this object is instanciated only to generate the preview + # it won't be saved to the database + instance = config_model(**kwargs) # turn off special name validation # (see ``ShareableOrgMixinUniqueName``) instance._validate_name = False @@ -224,18 +230,24 @@ def preview_view(self, request): template_ids = request.POST.get('templates') if template_ids: template_model = config_model.get_template_model() + template_ids = template_ids.split(',') try: - templates = template_model.objects.filter( - pk__in=template_ids.split(',') - ) + templates = template_model.objects.filter(pk__in=template_ids) templates = list(templates) # evaluating queryset performs query + # ensure the order of templates is maintained + templates.sort( + key=lambda template: template_ids.index(str(template.id)) + ) except ValidationError as e: logger.exception(error_msg, extra={'request': request}) return HttpResponse(str(e), status=400) else: templates = None if not error: - backend = instance.get_backend_instance(template_instances=templates) + context = instance.get_context() + backend = instance.get_backend_instance( + template_instances=templates, context=context + ) try: instance.clean_netjsonconfig_backend(backend) output = backend.render() @@ -383,6 +395,7 @@ def get_queryset(self, request): class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin): + recover_form_template = 'admin/config/device_recover_form.html' list_display = [ 'name', 'backend', @@ -472,25 +485,30 @@ def config_status(self, obj): def _get_preview_instance(self, request): c = super()._get_preview_instance(request) - c.device = self.model( - id=request.POST.get('id'), - name=request.POST.get('name'), - mac_address=request.POST.get('mac_address'), - key=request.POST.get('key'), - ) + id_ = request.POST.get('id') + # instantiate new device if it's a new config + try: + c.device + except ObjectDoesNotExist: + c.device = self.model() + # fill attributes with up to date data + c.device.id = id_ + c.device.name = request.POST.get('name') + c.device.mac_address = request.POST.get('mac_address') + c.device.key = request.POST.get('key') if 'hardware_id' in request.POST: c.device.hardware_id = request.POST.get('hardware_id') return c def get_urls(self): urls = [ - url( - r'^config/get-relevant-templates/(?P[^/]+)/$', + path( + 'config/get-relevant-templates//', self.admin_site.admin_view(get_relevant_templates), name='get_relevant_templates', ), - url( - r'^get-template-default-values/$', + path( + 'get-template-default-values/', self.admin_site.admin_view(get_template_default_values), name='get_template_default_values', ), @@ -534,14 +552,47 @@ def get_inlines(self, request, obj): inlines.append(inline) return inlines + @classmethod + def add_reversion_following(cls, follow): + """ + DeviceAdmin is used by other modules that register InlineModelAdmin + using monkey patching. The default implementation of reversion.register + ignores such inlines and does not update the "follow" field accordingly. + This method updates the "follow" fields of the Device model + by unregistering the Device model from reversion and re-registering it. + Only the" "follow" option is updated. + """ + device_reversion_options = reversion.revisions._registered_models[ + reversion.revisions._get_registration_key(Device) + ] + following = set(device_reversion_options.follow).union(set(follow)) + reversion.unregister(Device) + reversion.register( + model=Device, + fields=device_reversion_options.fields, + follow=following, + format=device_reversion_options.format, + for_concrete_model=device_reversion_options.for_concrete_model, + ignore_duplicates=device_reversion_options.ignore_duplicates, + use_natural_foreign_keys=device_reversion_options.use_natural_foreign_keys, + ) + class CloneOrganizationForm(forms.Form): - organization = forms.ModelChoiceField(queryset=Organization.objects.none()) + organization = forms.ModelChoiceField( + queryset=Organization.objects.none(), + required=False, + empty_label=_('Shared systemwide (no organization)'), + ) def __init__(self, *args, **kwargs): queryset = kwargs.pop('queryset') + user = kwargs.pop('user') super().__init__(*args, **kwargs) - self.fields['organization'].queryset = queryset + org_field = self.fields.get('organization') + org_field.queryset = queryset + if not user.is_superuser: + org_field.empty_label = None class TemplateForm(BaseForm): @@ -621,12 +672,13 @@ def create_log_entry(user, clone): pk__in=user.organizations_dict.keys() ) if selectable_orgs: - if request.POST.get('organization'): + organization = request.POST.get('organization') + if organization or organization == '': for template in queryset: clone = template.clone(user) - clone.organization = Organization.objects.get( - pk=request.POST.get('organization') - ) + clone.organization = None + if organization: + clone.organization = Organization.objects.get(pk=organization) create_log_entry(user, clone) clone.save() self.message_user( @@ -640,7 +692,7 @@ def create_log_entry(user, clone): 'queryset': queryset, 'opts': self.model._meta, 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, - 'form': CloneOrganizationForm(queryset=selectable_orgs), + 'form': CloneOrganizationForm(queryset=selectable_orgs, user=user), 'changelist_url': ( f'{request.resolver_match.app_name}:' f'{request.resolver_match.url_name}' @@ -690,16 +742,21 @@ class VpnAdmin( list_filter = [('organization', MultitenantOrgFilter), 'backend', 'created'] search_fields = ['id', 'name', 'host', 'key'] readonly_fields = ['id', 'uuid', 'system_context'] - multitenant_shared_relations = ('ca', 'cert') + multitenant_shared_relations = ('ca', 'cert', 'subnet') + autocomplete_fields = ['ip', 'subnet'] fields = [ + 'organization', 'name', 'host', - 'organization', 'uuid', 'key', + 'backend', 'ca', 'cert', - 'backend', + 'subnet', + 'ip', + 'webhook_endpoint', + 'auth_token', 'notes', 'dh', 'system_context', diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py index 01192bcc4..4920e7d8a 100644 --- a/openwisp_controller/config/api/serializers.py +++ b/openwisp_controller/config/api/serializers.py @@ -1,6 +1,6 @@ from django.db import transaction from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from swapper import load_model diff --git a/openwisp_controller/config/api/urls.py b/openwisp_controller/config/api/urls.py index e0bec110f..102d2574b 100644 --- a/openwisp_controller/config/api/urls.py +++ b/openwisp_controller/config/api/urls.py @@ -13,7 +13,9 @@ def get_api_urls(api_views): if getattr(settings, 'OPENWISP_CONTROLLER_API', True): return [ path( - 'controller/template/', api_views.template_list, name='template_list', + 'controller/template/', + api_views.template_list, + name='template_list', ), path( 'controller/template//', @@ -25,14 +27,26 @@ def get_api_urls(api_views): api_views.download_template_config, name='download_template_config', ), - path('controller/vpn/', api_views.vpn_list, name='vpn_list',), - path('controller/vpn//', api_views.vpn_detail, name='vpn_detail',), + path( + 'controller/vpn/', + api_views.vpn_list, + name='vpn_list', + ), + path( + 'controller/vpn//', + api_views.vpn_detail, + name='vpn_detail', + ), path( 'controller/vpn//configuration/', api_views.download_vpn_config, name='download_vpn_config', ), - path('controller/device/', api_views.device_list, name='device_list',), + path( + 'controller/device/', + api_views.device_list, + name='device_list', + ), path( 'controller/device//', api_views.device_detail, diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index 74a931a75..0eedef453 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -4,19 +4,14 @@ from django.http import Http404 from django.urls.base import reverse from rest_framework import pagination -from rest_framework.authentication import SessionAuthentication from rest_framework.generics import ( ListCreateAPIView, RetrieveAPIView, RetrieveUpdateDestroyAPIView, ) -from rest_framework.permissions import IsAuthenticated from swapper import load_model -from openwisp_users.api.authentication import BearerAuthentication -from openwisp_users.api.mixins import FilterByOrganizationManaged -from openwisp_users.api.permissions import DjangoModelPermissions - +from ...mixins import ProtectedAPIMixin from ..admin import BaseConfigAdmin from .serializers import ( DeviceDetailSerializer, @@ -42,14 +37,6 @@ class ListViewPagination(pagination.PageNumberPagination): max_page_size = 100 -class ProtectedAPIMixin(FilterByOrganizationManaged): - authentication_classes = [BearerAuthentication, SessionAuthentication] - permission_classes = [ - IsAuthenticated, - DjangoModelPermissions, - ] - - class TemplateListCreateView(ProtectedAPIMixin, ListCreateAPIView): serializer_class = TemplateSerializer queryset = Template.objects.order_by('-created') @@ -134,7 +121,10 @@ class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name): - url = reverse('config_api:devicegroup_x509_commonname', args=[common_name],) + url = reverse( + 'config_api:devicegroup_x509_commonname', + args=[common_name], + ) url = f'{url}?org={org_slugs}' return url @@ -194,7 +184,11 @@ def _invalidate_from_queryset(cls, queryset): @classmethod def device_change_invalidates_cache(cls, device_id): qs = ( - VpnClient.objects.select_related('config', 'organization', 'cert',) + VpnClient.objects.select_related( + 'config', + 'organization', + 'cert', + ) .filter(config__device_id=device_id) .annotate( organization__slug=F('cert__organization__slug'), diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py index c93189e88..23d70c1a8 100644 --- a/openwisp_controller/config/apps.py +++ b/openwisp_controller/config/apps.py @@ -1,8 +1,8 @@ from django.apps import AppConfig from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.db.models.signals import m2m_changed, post_delete, post_save -from django.utils.translation import ugettext_lazy as _ +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete +from django.utils.translation import gettext_lazy as _ from openwisp_notifications.types import ( register_notification_type, unregister_notification_type, @@ -13,7 +13,12 @@ from openwisp_utils.admin_theme.menu import register_menu_group from . import settings as app_settings -from .signals import config_modified, device_group_changed, device_name_changed +from .signals import ( + config_modified, + device_group_changed, + device_name_changed, + vpn_peers_changed, +) # ensure Device.hardware_id field is not flagged as unique # (because it's flagged as unique_together with organization) @@ -24,6 +29,7 @@ class ConfigConfig(AppConfig): name = 'openwisp_controller.config' label = 'config' verbose_name = _('Network Configuration') + default_auto_field = 'django.db.models.AutoField' def ready(self, *args, **kwargs): self.__setmodels__() @@ -39,6 +45,7 @@ def __setmodels__(self): self.device_model = load_model('config', 'Device') self.devicegroup_model = load_model('config', 'DeviceGroup') self.config_model = load_model('config', 'Config') + self.vpn_model = load_model('config', 'Vpn') self.vpnclient_model = load_model('config', 'VpnClient') self.cert_model = load_model('django_x509', 'Cert') @@ -74,11 +81,21 @@ def connect_signals(self): sender=self.config_model.templates.through, dispatch_uid='template.enforce_required_template', ) + post_save.connect( + self.vpnclient_model.post_save, + sender=self.vpnclient_model, + dispatch_uid='vpnclient.post_save', + ) post_delete.connect( self.vpnclient_model.post_delete, sender=self.vpnclient_model, dispatch_uid='vpnclient.post_delete', ) + vpn_peers_changed.connect( + self.vpn_model.update_vpn_server_configuration, + sender=self.vpn_model, + dispatch_uid='vpn.update_vpn_server_configuration', + ) post_save.connect( self.config_model.certificate_updated, sender=self.cert_model, @@ -190,7 +207,11 @@ def enable_cache_invalidation(self): device config checksum (view and model method) """ from .controller.views import DeviceChecksumView - from .handlers import devicegroup_change_handler, devicegroup_delete_handler + from .handlers import ( + device_cache_invalidation_handler, + devicegroup_change_handler, + devicegroup_delete_handler, + ) post_save.connect( DeviceChecksumView.invalidate_get_device_cache, @@ -226,6 +247,11 @@ def enable_cache_invalidation(self): sender=self.cert_model, dispatch_uid='invalidate_devicegroup_cache_on_certificate_delete', ) + pre_delete.connect( + device_cache_invalidation_handler, + sender=self.device_model, + dispatch_uid='device.invalidate_cache', + ) def register_dashboard_charts(self): register_dashboard_chart( diff --git a/openwisp_controller/config/base/base.py b/openwisp_controller/config/base/base.py index 662aee1a8..db0ae3bad 100644 --- a/openwisp_controller/config/base/base.py +++ b/openwisp_controller/config/base/base.py @@ -7,7 +7,7 @@ from django.db import models from django.utils.functional import cached_property from django.utils.module_loading import import_string -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from jsonfield import JSONField from netjsonconfig.exceptions import ValidationError as SchemaError @@ -141,14 +141,14 @@ def backend_instance(self): """ return self.get_backend_instance() - def get_backend_instance(self, template_instances=None): + def get_backend_instance(self, template_instances=None, context=None, **kwargs): """ allows overriding config and templates needed for pre validation of m2m """ backend = self.backend_class - kwargs = {'config': self.get_config()} - context = {} + kwargs.update({'config': self.get_config()}) + context = context or {} # determine if we can pass templates # expecting a many2many relationship if hasattr(self, 'templates'): diff --git a/openwisp_controller/config/base/channels_consumer.py b/openwisp_controller/config/base/channels_consumer.py index 1645ef4a9..4634f6466 100644 --- a/openwisp_controller/config/base/channels_consumer.py +++ b/openwisp_controller/config/base/channels_consumer.py @@ -25,7 +25,9 @@ def is_user_authorized(self): def _user_has_permissions(self, add=True, change=True, delete=True): permissions = [] model_identifier = '{0}.{1}_{2}'.format( - self.model._meta.app_label, '{permission}', self.model._meta.model_name, + self.model._meta.app_label, + '{permission}', + self.model._meta.model_name, ) if add: permissions.append(model_identifier.format(permission='add')) diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py index c2cdec514..225618e84 100644 --- a/openwisp_controller/config/base/config.py +++ b/openwisp_controller/config/base/config.py @@ -1,13 +1,16 @@ import collections import logging +import re from cache_memoize import cache_memoize from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.db import models, transaction -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from jsonfield import JSONField from model_utils import Choices from model_utils.fields import StatusField +from netjsonconfig import OpenWrt +from packaging import version from swapper import get_model_name from .. import settings as app_settings @@ -82,6 +85,7 @@ class AbstractConfig(BaseConfig): ) _CHECKSUM_CACHE_TIMEOUT = 60 * 60 * 24 * 30 # 10 days + _config_context_functions = list() class Meta: abstract = True @@ -329,6 +333,17 @@ def certificate_updated(cls, instance, created, **kwargs): else: transaction.on_commit(config.set_status_modified) + @classmethod + def register_context_function(cls, func): + """ + Adds "func" to "_config_context_functions". + These functions are called in the "get_context" method. + Output from these functions is added to the context + of Config. + """ + if func not in cls._config_context_functions: + cls._config_context_functions.append(func) + def get_default_templates(self): """ retrieves default templates of a Config object @@ -344,6 +359,47 @@ def get_default_templates(self): organization_id=org_id, queryset=queryset, backend=self.backend ) + def _should_use_dsa(self): + if not hasattr(self, 'device') or not issubclass(self.backend_class, OpenWrt): + return + + if not self.device.os: + # Device os field is empty. Early return to + # prevent unnecessary computation. + return app_settings.DSA_DEFAULT_FALLBACK + + # Check if the device is using stock OpenWrt. + openwrt_match = re.search( + '[oO][pP][eE][nN][wW][rR][tT]\s*([\d.]+)', self.device.os + ) + if openwrt_match: + if version.parse(openwrt_match.group(1)) >= version.parse('21'): + return True + else: + return False + + # Device is using custom firmware + if app_settings.DSA_OS_MAPPING: + openwrt_based_firmware = app_settings.DSA_OS_MAPPING.get( + 'netjsonconfig.OpenWrt', {} + ) + dsa_enabled_os = openwrt_based_firmware.get('>=21.02', []) + dsa_disabled_os = openwrt_based_firmware.get('<21.02', []) + for os in dsa_enabled_os: + if re.search(os, self.device.os): + return True + for os in dsa_disabled_os: + if re.search(os, self.device.os): + return False + + return app_settings.DSA_DEFAULT_FALLBACK + + def get_backend_instance(self, template_instances=None, context=None, **kwargs): + dsa_enabled = self._should_use_dsa() + if dsa_enabled is not None: + kwargs['dsa'] = dsa_enabled + return super().get_backend_instance(template_instances, context, **kwargs) + def clean(self): """ * validates context field @@ -443,25 +499,13 @@ def _has_device(self): return hasattr(self, 'device') def get_vpn_context(self): - c = super().get_context() + context = super().get_context() for vpnclient in self.vpnclient_set.all().select_related('vpn', 'cert'): vpn = vpnclient.vpn vpn_id = vpn.pk.hex - context_keys = vpn._get_auto_context_keys() - ca = vpn.ca + context.update(vpn.get_vpn_server_context()) + vpn_context_keys = vpn._get_auto_context_keys() cert = vpnclient.cert - # CA - ca_filename = 'ca-{0}-{1}.pem'.format( - ca.pk, ca.common_name.replace(' ', '_') - ) - ca_path = '{0}/{1}'.format(app_settings.CERT_PATH, ca_filename) - # update context - c.update( - { - context_keys['ca_path']: ca_path, - context_keys['ca_contents']: ca.certificate, - } - ) # conditional needed for VPN without x509 authentication # eg: simple password authentication if cert: @@ -472,15 +516,24 @@ def get_vpn_context(self): key_filename = 'key-{0}.pem'.format(vpn_id) key_path = '{0}/{1}'.format(app_settings.CERT_PATH, key_filename) # update context - c.update( + context.update( { - context_keys['cert_path']: cert_path, - context_keys['cert_contents']: cert.certificate, - context_keys['key_path']: key_path, - context_keys['key_contents']: cert.private_key, + vpn_context_keys['cert_path']: cert_path, + vpn_context_keys['cert_contents']: cert.certificate, + vpn_context_keys['key_path']: key_path, + vpn_context_keys['key_contents']: cert.private_key, } ) - return c + if vpnclient.public_key: + context['public_key'] = vpnclient.public_key + if vpnclient.private_key: + context['private_key'] = vpnclient.private_key + if vpn.subnet: + if vpnclient.ip: + context[vpn_context_keys['ip_address']] = vpnclient.ip.ip_address + if 'vni' in vpn_context_keys and vpnclient.vni: + context[vpn_context_keys['vni']] = f'{vpnclient.vni}' + return context def get_context(self, system=False): """ @@ -500,6 +553,8 @@ def get_context(self, system=False): if self.context and not system: extra.update(self.context) extra.update(self.get_vpn_context()) + for func in self._config_context_functions: + extra.update(func(config=self)) if app_settings.HARDWARE_ID_ENABLED and self._has_device(): extra.update({'hardware_id': str(self.device.hardware_id)}) c.update(sorted(extra.items())) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 08b133654..a5b6157b0 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -3,7 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from swapper import get_model_name from openwisp_users.mixins import OrgMixin @@ -230,7 +230,8 @@ def _check_name_changed(self): if self._initial_name != self.name: device_name_changed.send( - sender=self.__class__, instance=self, + sender=self.__class__, + instance=self, ) if self._has_config(): diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index d4cb33bbc..830209e30 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -3,7 +3,7 @@ import jsonschema from django.core.exceptions import ValidationError from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from jsonfield import JSONField from jsonschema.exceptions import ValidationError as SchemaError diff --git a/openwisp_controller/config/base/multitenancy.py b/openwisp_controller/config/base/multitenancy.py index 86a8c2a99..8d2f357c9 100644 --- a/openwisp_controller/config/base/multitenancy.py +++ b/openwisp_controller/config/base/multitenancy.py @@ -1,6 +1,6 @@ import swapper from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from openwisp_utils.base import KeyField, UUIDModel diff --git a/openwisp_controller/config/base/tag.py b/openwisp_controller/config/base/tag.py index 153ba471c..f574091cd 100644 --- a/openwisp_controller/config/base/tag.py +++ b/openwisp_controller/config/base/tag.py @@ -1,5 +1,5 @@ from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from swapper import get_model_name from taggit.models import GenericUUIDTaggedItemBase, TagBase, TaggedItemBase diff --git a/openwisp_controller/config/base/template.py b/openwisp_controller/config/base/template.py index ca0b3fd46..f8e2dcd7e 100644 --- a/openwisp_controller/config/base/template.py +++ b/openwisp_controller/config/base/template.py @@ -2,9 +2,9 @@ from collections import OrderedDict from copy import copy -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models, transaction -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from jsonfield import JSONField from swapper import get_model_name from taggit.managers import TaggableManager @@ -73,15 +73,15 @@ class AbstractTemplate(ShareableOrgMixinUniqueName, BaseConfig): 'be required for every device in the system)' ), ) + # auto_cert naming kept for backward compatibility auto_cert = models.BooleanField( - _('auto certificate'), + _('automatic tunnel provisioning'), default=default_auto_cert, db_index=True, help_text=_( - 'whether x509 client certificates should ' - 'be automatically managed behind the scenes ' - 'for each configuration using this template, ' - 'valid only for the VPN type' + 'whether tunnel specific configuration (cryptographic keys, ip addresses, ' + 'etc) should be automatically generated and managed behind the scenes ' + 'for each configuration using this template, valid only for the VPN type' ), ) default_values = JSONField( @@ -169,7 +169,9 @@ def clean(self, *args, **kwargs): self.vpn = None self.auto_cert = False if self.type == 'vpn' and not self.config: - self.config = self.vpn.auto_client(auto_cert=self.auto_cert) + self.config = self.vpn.auto_client( + auto_cert=self.auto_cert, template_backend_class=self.backend_class + ) if self.required and not self.default: self.default = True super().clean(*args, **kwargs) @@ -180,6 +182,7 @@ def get_context(self, system=False): context = {} if self.default_values and not system: context = copy(self.default_values) + context.update(self.get_vpn_server_context()) context.update(super().get_context()) return context @@ -187,6 +190,12 @@ def get_system_context(self): system_context = self.get_context(system=True) return OrderedDict(sorted(system_context.items())) + def get_vpn_server_context(self): + try: + return self.vpn.get_vpn_server_context() + except (ObjectDoesNotExist, AttributeError): + return {} + def clone(self, user): clone = copy(self) clone.name = self.__get_clone_name() diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py index 3f513099c..a76ac16f6 100644 --- a/openwisp_controller/config/base/vpn.py +++ b/openwisp_controller/config/base/vpn.py @@ -1,20 +1,34 @@ import collections +import ipaddress +import json +import logging import subprocess import shortuuid +from cache_memoize import cache_memoize from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction from django.utils.text import slugify -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from swapper import get_model_name from openwisp_utils.base import KeyField from ...base import ShareableOrgMixinUniqueName +from .. import crypto from .. import settings as app_settings -from ..tasks import create_vpn_dh +from ..signals import vpn_peers_changed +from ..tasks import create_vpn_dh, trigger_vpn_server_endpoint from .base import BaseConfig +logger = logging.getLogger(__name__) + + +def _peer_cache_key(vpn): + """used to generate a unique cache key""" + return str(vpn.pk) + class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig): """ @@ -28,6 +42,8 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig): get_model_name('django_x509', 'Ca'), verbose_name=_('Certification Authority'), on_delete=models.CASCADE, + blank=True, + null=True, ) cert = models.ForeignKey( get_model_name('django_x509', 'Cert'), @@ -45,6 +61,41 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig): help_text=_('Select VPN configuration backend'), ) notes = models.TextField(blank=True) + # optional, needed for VPNs which do not support automatic IP allocation + subnet = models.ForeignKey( + get_model_name('openwisp_ipam', 'Subnet'), + verbose_name=_('Subnet'), + help_text=_('Subnet IP addresses used by VPN clients, if applicable'), + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + ip = models.ForeignKey( + get_model_name('openwisp_ipam', 'IpAddress'), + verbose_name=_('Internal IP'), + help_text=_('Internal IP address of the VPN server interface, if applicable'), + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + # optional, helpful for updating WireGuard and VXLAN server configuration + webhook_endpoint = models.CharField( + verbose_name=_('Webhook Endpoint'), + help_text=_( + 'Webhook to trigger for updating server configuration ' + '(e.g. https://openwisp2.mydomain.com:8081/trigger-update)' + ), + max_length=128, + blank=True, + null=True, + ) + auth_token = models.CharField( + verbose_name=_('Webhook AuthToken'), + help_text=_('Authentication token for triggering "Webhook Endpoint"'), + max_length=128, + blank=True, + null=True, + ) # diffie hellman parameters are required # in some VPN solutions (eg: OpenVPN) dh = models.TextField(blank=True) @@ -61,9 +112,15 @@ class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig): 'BbOcwKkB+eBE/B9jqmbG5YYhDo9fQGmNEwIBAg==\n' '-----END DH PARAMETERS-----\n' ) + # needed for wireguard + public_key = models.CharField(blank=True, max_length=44) + private_key = models.CharField(blank=True, max_length=44) __vpn__ = True + # cache wireguard / vxlan peers for 7 days (generation is expensive) + _PEER_CACHE_TIMEOUT = 60 * 60 * 24 * 7 + class Meta: verbose_name = _('VPN server') verbose_name_plural = _('VPN servers') @@ -71,29 +128,82 @@ class Meta: abstract = True def clean(self, *args, **kwargs): - """ - * ensure certificate matches CA - """ super().clean(*args, **kwargs) + self._validate_backend() + self._validate_certs() + self._validate_keys() + self._validate_org_relation('ca') + self._validate_org_relation('cert') + self._validate_org_relation('subnet') + self._validate_subnet_ip() + + def _validate_backend(self): + if self._state.adding: + return + if ( + 'backend' not in self.get_deferred_fields() + and self._meta.model.objects.only('backend').get(id=self.id).backend + != self.backend + and self.vpnclient_set.exists() + ): + raise ValidationError( + { + 'backend': _( + 'Backend cannot be changed because the VPN is currently in use.' + ) + } + ) + + def _validate_certs(self): + if not self._is_backend_type('openvpn'): + self.ca = None + self.cert = None + return + + if not self.ca: + raise ValidationError({'ca': _('CA is required with this VPN backend')}) # certificate must be related to CA if self.cert and self.cert.ca.pk != self.ca.pk: msg = _('The selected certificate must match the selected CA.') raise ValidationError({'cert': msg}) - self._validate_org_relation('ca') - self._validate_org_relation('cert') + + def _validate_keys(self): + if not self._is_backend_type('wireguard'): + self.public_key = '' + self.private_key = '' + + def _validate_subnet_ip(self): + if self._is_backend_type('openvpn'): + self.subnet = None + self.ip = None + elif self._is_backend_type('wireguard'): + if not self.subnet: + raise ValidationError( + {'subnet': _('Subnet is required for this VPN backend.')} + ) + if self.ip and self.ip.subnet != self.subnet: + raise ValidationError( + {'ip': _('VPN IP address must be within the VPN subnet')} + ) def save(self, *args, **kwargs): """ Calls _auto_create_cert() if cert is not set """ - if not self.cert: + create_dh = False + if not self.cert and self.ca: self.cert = self._auto_create_cert() - if not self.dh: + if self._is_backend_type('openvpn') and not self.dh: self.dh = self._placeholder_dh - is_adding = self._state.adding + create_dh = True + if self._is_backend_type('wireguard'): + self._generate_wireguard_keys() + if self.subnet and not self.ip: + self.ip = self._auto_create_ip() super().save(*args, **kwargs) - if is_adding and self.dh == self._placeholder_dh: + if create_dh: transaction.on_commit(lambda: create_vpn_dh.delay(self.id)) + self.update_vpn_server_configuration() @classmethod def dhparam(cls, length): @@ -104,6 +214,23 @@ def dhparam(cls, length): 'openssl dhparam {0} 2> /dev/null'.format(length), shell=True ).decode('utf-8') + def update_vpn_server_configuration(instance, **kwargs): + if not instance._is_backend_type('wireguard'): + return + if instance.webhook_endpoint and instance.auth_token: + transaction.on_commit( + lambda: trigger_vpn_server_endpoint.delay( + endpoint=instance.webhook_endpoint, + auth_token=instance.auth_token, + vpn_id=instance.pk, + ) + ) + else: + logger.info( + f'Cannot update configuration of {instance.name} VPN server, ' + 'webhook endpoint and authentication token are empty.' + ) + def _auto_create_cert(self): """ Automatically generates server x509 certificate @@ -130,24 +257,82 @@ def _auto_create_cert(self): cert.save() return cert + def _auto_create_ip(self): + """ + Automatically generates host IP address + """ + return self.subnet.request_ip() + def get_context(self): """ prepares context for netjsonconfig VPN backend """ - try: - c = collections.OrderedDict([('ca', self.ca.certificate)]) - except ObjectDoesNotExist: - c = collections.OrderedDict() + c = collections.OrderedDict() + if self.ca: + try: + c['ca'] = self.ca.certificate + except ObjectDoesNotExist: + pass if self.cert: - c.update([('cert', self.cert.certificate), ('key', self.cert.private_key)]) + c['cert'] = self.cert.certificate + c['key'] = self.cert.private_key if self.dh: - c.update([('dh', self.dh)]) + c['dh'] = self.dh + if self.private_key: + c['private_key'] = self.private_key + if self.public_key: + c['public_key'] = self.public_key + if self.subnet: + c['subnet'] = str(self.subnet.subnet) + c['subnet_prefixlen'] = str(self.subnet.subnet.prefixlen) + if self.ip: + c['ip_address'] = self.ip.ip_address c.update(sorted(super().get_context().items())) return c + def get_vpn_server_context(self): + context = {} + context_keys = self._get_auto_context_keys() + if self.host: + context[context_keys['vpn_host']] = self.host + if self._is_backend_type('wireguard'): + context[context_keys['vpn_port']] = self.config['wireguard'][0]['port'] + if self.ca: + ca = self.ca + # CA + ca_filename = 'ca-{0}-{1}.pem'.format( + ca.pk, ca.common_name.replace(' ', '_') + ) + ca_path = '{0}/{1}'.format(app_settings.CERT_PATH, ca_filename) + context.update( + { + context_keys['ca_path']: ca_path, + context_keys['ca_contents']: ca.certificate, + } + ) + if self.public_key: + context[context_keys['public_key']] = self.public_key + if self.ip: + context[context_keys['server_ip_address']] = self.ip.ip_address + context[ + context_keys['server_ip_network'] + ] = f'{self.ip.ip_address}/{self.subnet.subnet.max_prefixlen}' + return context + def get_system_context(self): return self.get_context() + def _is_backend_type(self, backend_type): + """ + returns true if the backend path used converted to lowercase + contains ``backend_type``. + Checking for the exact path may not be the best choices + given backends can be extended and customized. + By using this method, customizations will just have + to maintain the naming consistent. + """ + return backend_type.lower() in self.backend.lower() + def _get_auto_context_keys(self): """ returns a dictionary which indicates the names of @@ -158,18 +343,47 @@ def _get_auto_context_keys(self): * cert in PEM format * path to key file * key in PEM format + WireGuard: + * public key + * ip address + VXLAN: + * vni (VXLAN Network Identifier) """ pk = self.pk.hex - return { - 'ca_path': 'ca_path_{0}'.format(pk), - 'ca_contents': 'ca_contents_{0}'.format(pk), - 'cert_path': 'cert_path_{0}'.format(pk), - 'cert_contents': 'cert_contents_{0}'.format(pk), - 'key_path': 'key_path_{0}'.format(pk), - 'key_contents': 'key_contents_{0}'.format(pk), + context_keys = { + 'vpn_host': 'vpn_host_{}'.format(pk), + 'vpn_port': 'vpn_port_{}'.format(pk), } + if self._is_backend_type('openvpn'): + context_keys.update( + { + 'ca_path': 'ca_path_{0}'.format(pk), + 'ca_contents': 'ca_contents_{0}'.format(pk), + 'cert_path': 'cert_path_{0}'.format(pk), + 'cert_contents': 'cert_contents_{0}'.format(pk), + 'key_path': 'key_path_{0}'.format(pk), + 'key_contents': 'key_contents_{0}'.format(pk), + } + ) + if self._is_backend_type('wireguard'): + context_keys.update( + { + 'public_key': 'public_key_{}'.format(pk), + 'ip_address': 'ip_address_{}'.format(pk), + } + ) + if self._is_backend_type('vxlan'): + context_keys.update({'vni': 'vni_{}'.format(pk)}) + if self.ip: + context_keys.update( + { + 'server_ip_address': 'server_ip_address_{}'.format(pk), + 'server_ip_network': 'server_ip_network_{}'.format(pk), + } + ) + return context_keys - def auto_client(self, auto_cert=True): + def auto_client(self, auto_cert=True, template_backend_class=None): """ calls backend ``auto_client`` method and returns a configuration dictionary that is suitable to be used as a template @@ -187,10 +401,24 @@ def auto_client(self, auto_cert=True): if not auto_cert: for key in ['cert_path', 'cert_contents', 'key_path', 'key_contents']: del context_keys[key] - conifg_dict_key = self.backend_class.__name__.lower() - auto = backend.auto_client( - host=self.host, server=self.config[conifg_dict_key][0], **context_keys - ) + config_dict_key = self.backend_class.__name__.lower() + vpn_host = context_keys.pop('vpn_host', self.host) + if self._is_backend_type('wireguard') and template_backend_class: + vpn_auto_client = '{}wireguard_auto_client'.format( + 'vxlan_' if self._is_backend_type('vxlan') else '' + ) + auto = getattr(template_backend_class, vpn_auto_client)( + host=vpn_host, + server=self.config['wireguard'][0], + **context_keys, + ) + else: + del context_keys['vpn_port'] + auto = backend.auto_client( + host=self.host, + server=self.config[config_dict_key][0], + **context_keys, + ) config.update(auto) return config @@ -201,6 +429,122 @@ def _auto_create_cert_extra(self, cert): cert.organization = self.organization return cert + def _generate_wireguard_keys(self): + """ + generates wireguard private and public keys + and set the respctive attributes + """ + if not self.private_key or not self.public_key: + self.private_key, self.public_key = crypto.generate_wireguard_keys() + + def get_config(self): + config = super().get_config() + if self._is_backend_type('wireguard'): + self._add_wireguard(config) + if self._is_backend_type('vxlan'): + self._add_vxlan(config) + return config + + def _invalidate_peer_cache(self, update=False): + """ + Invalidates peer cache, if update=True is passed, + the peer cache will be regenerated + """ + if self._is_backend_type('wireguard'): + self._get_wireguard_peers.invalidate(self) + if update: + self._get_wireguard_peers() + if self._is_backend_type('vxlan'): + self._get_vxlan_peers.invalidate(self) + if update: + self._get_vxlan_peers() + # Send signal for peers changed + vpn_peers_changed.send(sender=self.__class__, instance=self) + + def _get_peer_queryset(self): + """ + returns an iterator to iterate over tunnel peers + used to generate the list of peers of a tunnel (WireGuard/VXLAN) + """ + return ( + self.vpnclient_set.select_related('config', 'ip') + .filter(auto_cert=True) + .only( + 'id', + 'vpn_id', + 'vni', + 'public_key', + 'config__device_id', + 'config__status', + 'ip__ip_address', + ) + .iterator() + ) + + def _add_wireguard(self, config): + """ + Adds wireguard peers and private key to the generated + configuration without the need of manual intervention. + Modifies the config data structure as a side effect. + """ + try: + config['wireguard'][0].setdefault('peers', []) + except (KeyError, IndexError): + # this error will be handled by + # schema validation in subsequent steps + return config + # private key is added to the config automatically + config['wireguard'][0]['private_key'] = self.private_key + # peers are also added automatically (and cached) + config['wireguard'][0]['peers'] = self._get_wireguard_peers() + # internal IP address of wireguard interface + config['wireguard'][0]['address'] = '{{ ip_address }}/{{ subnet_prefixlen }}' + + @cache_memoize(_PEER_CACHE_TIMEOUT, args_rewrite=_peer_cache_key) + def _get_wireguard_peers(self): + """ + Returns list of wireguard peers, the result is cached. + """ + peers = [] + for vpnclient in self._get_peer_queryset(): + if vpnclient.ip: + ip_address = ipaddress.ip_address(vpnclient.ip.ip_address) + peers.append( + { + 'public_key': vpnclient.public_key, + 'allowed_ips': f'{ip_address}/{ip_address.max_prefixlen}', + } + ) + return peers + + def _add_vxlan(self, config): + """ + Adds VXLAN peers to the generated configuration + without the need of manual intervention. + Modifies the config data structure as a side effect. + """ + peers = self._get_vxlan_peers() + # add peer list to conifg as a JSON file + config.setdefault('files', []) + config['files'].append( + { + 'mode': '0644', + 'path': 'vxlan.json', + 'contents': json.dumps(peers, indent=4, sort_keys=True), + } + ) + + @cache_memoize(_PEER_CACHE_TIMEOUT, args_rewrite=_peer_cache_key) + def _get_vxlan_peers(self): + """ + Returns list of vxlan peers, the result is cached. + """ + peers = [] + for vpnclient in self._get_peer_queryset(): + if vpnclient.ip: + peers.append({'vni': vpnclient.vni, 'remote': vpnclient.ip.ip_address}) + return peers + class AbstractVpnClient(models.Model): """ @@ -220,22 +564,66 @@ class AbstractVpnClient(models.Model): # this flags indicates whether the certificate must be # automatically managed, which is going to be almost in all cases auto_cert = models.BooleanField(default=False) + # optional, needed for VPNs which require setting a specific known IP (wireguard) + ip = models.ForeignKey( + get_model_name('openwisp_ipam', 'IpAddress'), + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + # needed for wireguard + public_key = models.CharField(blank=True, max_length=44) + private_key = models.CharField(blank=True, max_length=44) + # needed for vxlan + vni = models.PositiveIntegerField( + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(16777216)], + db_index=True, + ) + _auto_ip_stopper_funcs = [] class Meta: abstract = True - unique_together = ('config', 'vpn') + unique_together = ( + ('config', 'vpn'), + ('vpn', 'vni'), + ) verbose_name = _('VPN client') verbose_name_plural = _('VPN clients') + @classmethod + def register_auto_ip_stopper(cls, func): + """ + Adds "func" to "_auto_ip_stopper_funcs". + These functions are called in the "_auto_ip" method. + Output from these functions are used to determine + skipping automatic IP assignment. + """ + if func not in cls._auto_ip_stopper_funcs: + cls._auto_ip_stopper_funcs.append(func) + def save(self, *args, **kwargs): """ - automatically creates an x509 certificate when ``auto_cert`` is True + automatically provisions tunnel keys + and configuration if ``auto_cert`` is True """ if self.auto_cert: - cn = self._get_common_name() - self._auto_create_cert(name=self.config.device.name, common_name=cn) + self._auto_x509() + self._auto_ip() + self._auto_wireguard() + self._auto_vxlan() super().save(*args, **kwargs) + def _auto_x509(self): + """ + automatically creates an x509 certificate + """ + if not self.vpn._is_backend_type('openvpn') or self.cert: + return + cn = self._get_common_name() + self._auto_create_cert(name=self.config.device.name, common_name=cn) + def _get_common_name(self): """ returns the common name for a new certificate @@ -252,14 +640,30 @@ def _get_common_name(self): return common_name @classmethod - def post_delete(cls, **kwargs): + def post_save(cls, instance, **kwargs): + def _post_save(): + instance.vpn._invalidate_peer_cache(update=True) + + transaction.on_commit(_post_save) + + @classmethod + def post_delete(cls, instance, **kwargs): """ class method for ``post_delete`` signal - automatically deletes certificates when ``auto_cert`` is ``True`` + automatically deletes related certificates + and ip addresses if necessary """ - instance = kwargs['instance'] - if instance.auto_cert: + # only invalidates, does not regenerate the cache + # to avoid generating high load during bulk deletes + instance.vpn._invalidate_peer_cache() + + if instance.cert: instance.cert.delete() + try: + if instance.ip: + instance.ip.delete() + except ObjectDoesNotExist: + pass def _auto_create_cert_extra(self, cert): """ @@ -295,3 +699,35 @@ def _auto_create_cert(self, name, common_name): cert.save() self.cert = cert return cert + + def _auto_wireguard(self): + """ + Automatically generates private and public key for wireguard + """ + if not self.vpn._is_backend_type('wireguard') or ( + self.private_key and self.public_key + ): + return + self.private_key, self.public_key = crypto.generate_wireguard_keys() + + def _auto_vxlan(self): + """ + Automatically generates VNI for VXLAN + """ + if not self.vpn._is_backend_type('vxlan') or self.vni: + return + last_tunnel = ( + self._meta.model.objects.filter(vpn=self.vpn).order_by('vni').last() + ) + if last_tunnel: + self.vni = last_tunnel.vni + 1 + else: + self.vni = 1 + + def _auto_ip(self): + if not self.vpn.subnet: + return + for func in self._auto_ip_stopper_funcs: + if func(self): + return + self.ip = self.vpn.subnet.request_ip() diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index c112bb698..242bdb470 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -78,18 +78,18 @@ def update_last_ip(self, device, request): return result def _remove_duplicated_management_ip(self, device): - # avoid that any other device in the - # same org stays with the same management_ip + # Ensures that two devices does not have same management_ip. # This can happen when management interfaces are using DHCP # and they get a new address which was previously used by another - # device that may now be offline, without this fix, we will end up - # with two devices having the same management_ip, which will - # cause OpenWISP to be confused + # device that may now be offline. Without this, two devices will + # have the same management_ip which will confuse OpenWISP. if not device.management_ip: return - queryset = self.model.objects.filter( - organization_id=device.organization_id, management_ip=device.management_ip - ).exclude(pk=device.pk) + where = Q(management_ip=device.management_ip) + if not app_settings.SHARED_MANAGEMENT_IP_ADDRESS_SPACE: + where &= Q(organization_id=device.organization_id) + + queryset = self.model.objects.filter(where).exclude(pk=device.pk) for dupe in queryset.only('pk', 'key', 'management_ip'): dupe.management_ip = '' dupe.save(update_fields=['management_ip']) @@ -100,10 +100,12 @@ def _remove_duplicated_last_ip(self, device): # allow it to be duplicated if not device.last_ip or not ip_address(device.last_ip).is_private: return - queryset = Device.objects.filter( - organization_id=device.organization_id, last_ip=device.last_ip - ).exclude(pk=device.pk) - for dupe in queryset.only('pk', 'key', 'last_ip', 'management_ip'): + where = Q(last_ip=device.last_ip) + if not app_settings.SHARED_MANAGEMENT_IP_ADDRESS_SPACE: + where &= Q(organization_id=device.organization_id) + + queryset = self.model.objects.filter(where).exclude(pk=device.pk) + for dupe in queryset.only('pk', 'key', 'last_ip'): dupe.last_ip = '' dupe.save(update_fields=['last_ip']) diff --git a/openwisp_controller/config/crypto.py b/openwisp_controller/config/crypto.py new file mode 100644 index 000000000..772d23018 --- /dev/null +++ b/openwisp_controller/config/crypto.py @@ -0,0 +1,20 @@ +import codecs + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + + +def generate_wireguard_keys(): + private_key = X25519PrivateKey.generate() + bytes_ = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + private_key_str = codecs.encode(bytes_, 'base64').decode('utf8').strip() + # private key + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + public_key_str = codecs.encode(public_key, 'base64').decode('utf8').strip() + return private_key_str, public_key_str diff --git a/openwisp_controller/config/handlers.py b/openwisp_controller/config/handlers.py index 99a37c9c4..8d74b936f 100644 --- a/openwisp_controller/config/handlers.py +++ b/openwisp_controller/config/handlers.py @@ -1,8 +1,10 @@ from django.dispatch import receiver -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from openwisp_notifications.signals import notify from swapper import load_model +from openwisp_controller.config.controller.views import DeviceChecksumView + from . import tasks from .signals import config_status_changed, device_registered @@ -54,3 +56,9 @@ def devicegroup_delete_handler(instance, **kwargs): if isinstance(instance, Cert): kwargs['common_name'] = instance.common_name tasks.invalidate_devicegroup_cache_delete.delay(instance.id, model_name, **kwargs) + + +def device_cache_invalidation_handler(instance, **kwargs): + view = DeviceChecksumView() + setattr(view, 'kwargs', {'pk': str(instance.pk)}) + view.get_device.invalidate(view) diff --git a/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py b/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py index 25617dd5d..526d4c003 100644 --- a/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py +++ b/openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py @@ -9,13 +9,15 @@ import django.utils.timezone import jsonfield.fields import model_utils.fields +from django.conf import settings from django.db import migrations, models -from swapper import get_model_name +from swapper import dependency, get_model_name, split import openwisp_controller.config.base.template import openwisp_utils.base import openwisp_utils.utils +from .. import settings as app_settings from ..sortedm2m.fields import SortedManyToManyField @@ -25,7 +27,10 @@ class Migration(migrations.Migration): initial = True - dependencies = [('pki', '0001_initial'), ('openwisp_users', '0001_initial')] + dependencies = [ + ('pki', '0001_initial'), + dependency(*split(settings.AUTH_USER_MODEL), version='0004_default_groups'), + ] operations = [ migrations.CreateModel( @@ -384,9 +389,7 @@ class Migration(migrations.Migration): ( 'backend', models.CharField( - choices=[ - ('django_netjsonconfig.vpn_backends.OpenVpn', 'OpenVPN') - ], + choices=app_settings.VPN_BACKENDS, help_text='Select VPN configuration backend', max_length=128, verbose_name='VPN backend', diff --git a/openwisp_controller/config/migrations/0004_add_device_model.py b/openwisp_controller/config/migrations/0004_add_device_model.py index 17d641329..98e8c2190 100644 --- a/openwisp_controller/config/migrations/0004_add_device_model.py +++ b/openwisp_controller/config/migrations/0004_add_device_model.py @@ -19,7 +19,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0001_initial'), ('config', '0003_template_tags'), ] diff --git a/openwisp_controller/config/migrations/0013_last_ip_management_ip_and_status_applied.py b/openwisp_controller/config/migrations/0013_last_ip_management_ip_and_status_applied.py index 6fdc5496b..95fd8e5a2 100644 --- a/openwisp_controller/config/migrations/0013_last_ip_management_ip_and_status_applied.py +++ b/openwisp_controller/config/migrations/0013_last_ip_management_ip_and_status_applied.py @@ -72,7 +72,11 @@ class Migration(migrations.Migration): model_name='config', name='status', field=model_utils.fields.StatusField( - choices=[(0, 'dummy')], + choices=[ + ('modified', 'modified'), + ('applied', 'applied'), + ('error', 'error'), + ], default='modified', help_text=( '"modified" means the configuration is not applied yet; ' diff --git a/openwisp_controller/config/migrations/0015_default_groups_permissions.py b/openwisp_controller/config/migrations/0015_default_groups_permissions.py index 9ca61d9a7..3dc2f491b 100644 --- a/openwisp_controller/config/migrations/0015_default_groups_permissions.py +++ b/openwisp_controller/config/migrations/0015_default_groups_permissions.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0004_default_groups'), ('config', '0014_device_hardware_id'), ] diff --git a/openwisp_controller/config/migrations/0016_default_organization_config_settings.py b/openwisp_controller/config/migrations/0016_default_organization_config_settings.py index 7f808c235..56753b487 100644 --- a/openwisp_controller/config/migrations/0016_default_organization_config_settings.py +++ b/openwisp_controller/config/migrations/0016_default_organization_config_settings.py @@ -1,8 +1,10 @@ from django.db import migrations +from ...migrations import get_swapped_model + def create_default_config_settings_organization(apps, schema_editor): - organization_model = apps.get_model('openwisp_users', 'Organization') + organization_model = get_swapped_model(apps, 'openwisp_users', 'Organization') config_settings_model = apps.get_model('config', 'OrganizationConfigSettings') for organization in organization_model.objects.all(): try: @@ -15,7 +17,6 @@ def create_default_config_settings_organization(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0003_default_organization'), ('config', '0015_default_groups_permissions'), ] operations = [ diff --git a/openwisp_controller/config/migrations/0017_template_name_organization_unique_together.py b/openwisp_controller/config/migrations/0017_template_name_organization_unique_together.py index 2e944b464..7777caa3a 100644 --- a/openwisp_controller/config/migrations/0017_template_name_organization_unique_together.py +++ b/openwisp_controller/config/migrations/0017_template_name_organization_unique_together.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0004_default_groups'), ('config', '0016_default_organization_config_settings'), ] diff --git a/openwisp_controller/config/migrations/0019_organization_mac_add_hardware_id_name_unique_together.py b/openwisp_controller/config/migrations/0019_organization_mac_add_hardware_id_name_unique_together.py index 3d1ae571e..7b207c5d5 100644 --- a/openwisp_controller/config/migrations/0019_organization_mac_add_hardware_id_name_unique_together.py +++ b/openwisp_controller/config/migrations/0019_organization_mac_add_hardware_id_name_unique_together.py @@ -9,7 +9,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0004_default_groups'), ('config', '0018_config_context'), ] diff --git a/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py b/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py index 891eeceb6..40421c4b2 100644 --- a/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py +++ b/openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py @@ -5,6 +5,8 @@ import django.core.validators from django.db import migrations, models +from .. import settings as app_settings + class Migration(migrations.Migration): @@ -35,7 +37,7 @@ class Migration(migrations.Migration): model_name='vpn', name='backend', field=models.CharField( - choices=[('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN')], + choices=app_settings.VPN_BACKENDS, help_text='Select VPN configuration backend', max_length=128, verbose_name='VPN backend', diff --git a/openwisp_controller/config/migrations/0033_name_unique_per_organization.py b/openwisp_controller/config/migrations/0033_name_unique_per_organization.py index ffe88efac..83ffff0e7 100644 --- a/openwisp_controller/config/migrations/0033_name_unique_per_organization.py +++ b/openwisp_controller/config/migrations/0033_name_unique_per_organization.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0011_user_first_name_150_max_length'), ('config', '0032_update_legacy_vpn_backend'), ] diff --git a/openwisp_controller/config/migrations/0035_device_name_unique_optional.py b/openwisp_controller/config/migrations/0035_device_name_unique_optional.py index 8796c05a2..cbbd0866b 100644 --- a/openwisp_controller/config/migrations/0035_device_name_unique_optional.py +++ b/openwisp_controller/config/migrations/0035_device_name_unique_optional.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0014_user_notes'), ('config', '0034_template_required'), ] diff --git a/openwisp_controller/config/migrations/0036_device_group.py b/openwisp_controller/config/migrations/0036_device_group.py index 59e71f2fd..10c12ea2c 100644 --- a/openwisp_controller/config/migrations/0036_device_group.py +++ b/openwisp_controller/config/migrations/0036_device_group.py @@ -18,7 +18,6 @@ class Migration(migrations.Migration): dependencies = [ - ('openwisp_users', '0014_user_notes'), ('config', '0035_device_name_unique_optional'), ] @@ -69,7 +68,7 @@ class Migration(migrations.Migration): 'organization', models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - to='openwisp_users.organization', + to=swapper.get_model_name('openwisp_users', 'Organization'), verbose_name='organization', ), ), @@ -83,7 +82,8 @@ class Migration(migrations.Migration): bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), ), migrations.AlterUniqueTogether( - name='devicegroup', unique_together={('organization', 'name')}, + name='devicegroup', + unique_together={('organization', 'name')}, ), migrations.AddField( model_name='device', diff --git a/openwisp_controller/config/migrations/0037_alter_taggedtemplate.py b/openwisp_controller/config/migrations/0037_alter_taggedtemplate.py new file mode 100644 index 000000000..93315a247 --- /dev/null +++ b/openwisp_controller/config/migrations/0037_alter_taggedtemplate.py @@ -0,0 +1,34 @@ +# Generated by Django 4.0 on 2021-12-17 16:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('config', '0036_device_group'), + ] + + operations = [ + migrations.AlterField( + model_name='taggedtemplate', + name='content_type', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_tagged_items', + to='contenttypes.contenttype', + verbose_name='content type', + ), + ), + migrations.AlterField( + model_name='taggedtemplate', + name='tag', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_items', + to='config.templatetag', + ), + ), + ] diff --git a/openwisp_controller/config/migrations/0038_vpn_subnet.py b/openwisp_controller/config/migrations/0038_vpn_subnet.py new file mode 100644 index 000000000..171e45c5f --- /dev/null +++ b/openwisp_controller/config/migrations/0038_vpn_subnet.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2021-03-08 12:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL), + ('config', '0037_alter_taggedtemplate'), + ] + + operations = [ + migrations.AddField( + model_name='vpn', + name='subnet', + field=models.ForeignKey( + blank=True, + help_text='Subnet IP addresses used by VPN clients, if applicable', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.OPENWISP_IPAM_SUBNET_MODEL, + verbose_name='Subnet', + ), + ), + ] diff --git a/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py b/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py new file mode 100644 index 000000000..d770cf957 --- /dev/null +++ b/openwisp_controller/config/migrations/0039_wireguard_vxlan_ipam.py @@ -0,0 +1,132 @@ +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import swapper +from django.db import migrations, models + +import openwisp_controller.config.base.template + + +class Migration(migrations.Migration): + + dependencies = [ + swapper.dependency('pki', 'Ca'), + swapper.dependency('openwisp_ipam', 'IpAddress'), + swapper.dependency('openwisp_ipam', 'Subnet'), + ('config', '0038_vpn_subnet'), + ] + + operations = [ + migrations.AddField( + model_name='vpn', + name='ip', + field=models.ForeignKey( + blank=True, + help_text=( + 'Internal IP address of the VPN server interface, if applicable' + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=swapper.get_model_name('openwisp_ipam', 'IpAddress'), + verbose_name='Internal IP', + ), + ), + migrations.AddField( + model_name='vpn', + name='private_key', + field=models.CharField(blank=True, max_length=44), + ), + migrations.AddField( + model_name='vpn', + name='public_key', + field=models.CharField(blank=True, max_length=44), + ), + migrations.AddField( + model_name='vpn', + name='auth_token', + field=models.CharField( + blank=True, + help_text=('Authentication token for triggering "Webhook Endpoint"'), + max_length=128, + null=True, + verbose_name='Webhook AuthToken', + ), + ), + migrations.AddField( + model_name='vpn', + name='webhook_endpoint', + field=models.CharField( + blank=True, + help_text=( + 'Webhook to trigger for updating server configuration ' + '(e.g. https://openwisp2.mydomain.com:8081/trigger-update)' + ), + max_length=128, + null=True, + verbose_name='Webhook Endpoint', + ), + ), + migrations.AddField( + model_name='vpnclient', + name='private_key', + field=models.CharField(blank=True, max_length=44), + ), + migrations.AddField( + model_name='vpnclient', + name='public_key', + field=models.CharField(blank=True, max_length=44), + ), + migrations.AddField( + model_name='vpnclient', + name='vni', + field=models.PositiveIntegerField( + blank=True, + db_index=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(16777216), + ], + ), + ), + migrations.AlterField( + model_name='template', + name='auto_cert', + field=models.BooleanField( + db_index=True, + default=openwisp_controller.config.base.template.default_auto_cert, + help_text=( + 'whether tunnel specific configuration (cryptographic keys, ' + 'ip addresses, etc) should be automatically generated and ' + 'managed behind the scenes for each configuration using this ' + 'template, valid only for the VPN type' + ), + verbose_name='automatic tunnel provisioning', + ), + ), + migrations.AlterField( + model_name='vpn', + name='ca', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=swapper.get_model_name('pki', 'Ca'), + verbose_name='Certification Authority', + ), + ), + migrations.AlterUniqueTogether( + name='vpnclient', + unique_together={('config', 'vpn'), ('vpn', 'vni')}, + ), + migrations.AddField( + model_name='vpnclient', + name='ip', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=swapper.get_model_name('openwisp_ipam', 'IpAddress'), + ), + ), + ] diff --git a/openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py b/openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py new file mode 100644 index 000000000..0f359bd8f --- /dev/null +++ b/openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1.13 on 2021-09-20 13:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL), + ('config', '0039_wireguard_vxlan_ipam'), + ] + + operations = [ + migrations.AlterField( + model_name='vpn', + name='ip', + field=models.ForeignKey( + blank=True, + help_text=( + 'Internal IP address of the VPN server interface, if applicable' + ), + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.OPENWISP_IPAM_IPADDRESS_MODEL, + verbose_name='Internal IP', + ), + ), + migrations.AlterField( + model_name='vpnclient', + name='ip', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.OPENWISP_IPAM_IPADDRESS_MODEL, + ), + ), + ] diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py index 76693337d..475419a4f 100644 --- a/openwisp_controller/config/settings.py +++ b/openwisp_controller/config/settings.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ logger = logging.getLogger(__name__) @@ -34,7 +34,12 @@ def get_settings_value(option, default): ) VPN_BACKENDS = get_settings_value( - 'VPN_BACKENDS', (('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'),) + 'VPN_BACKENDS', + ( + ('openwisp_controller.vpn_backends.OpenVpn', 'OpenVPN'), + ('openwisp_controller.vpn_backends.Wireguard', 'WireGuard'), + ('openwisp_controller.vpn_backends.VxlanWireguard', 'VXLAN over WireGuard'), + ), ) DEFAULT_BACKEND = get_settings_value('DEFAULT_BACKEND', BACKENDS[0][0]) DEFAULT_VPN_BACKEND = get_settings_value('DEFAULT_VPN_BACKEND', VPN_BACKENDS[0][0]) @@ -68,3 +73,8 @@ def get_settings_value(option, default): DEVICE_GROUP_SCHEMA = get_settings_value( 'DEVICE_GROUP_SCHEMA', {'type': 'object', 'properties': {}} ) +SHARED_MANAGEMENT_IP_ADDRESS_SPACE = get_settings_value( + 'SHARED_MANAGEMENT_IP_ADDRESS_SPACE', True +) +DSA_OS_MAPPING = get_settings_value('DSA_OS_MAPPING', {}) +DSA_DEFAULT_FALLBACK = get_settings_value('DSA_DEFAULT_FALLBACK', True) diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py index 2c0b8ed4a..35bb4ead7 100644 --- a/openwisp_controller/config/signals.py +++ b/openwisp_controller/config/signals.py @@ -1,15 +1,39 @@ from django.dispatch import Signal -checksum_requested = Signal(providing_args=['instance', 'request']) -config_download_requested = Signal(providing_args=['instance', 'request']) -config_status_changed = Signal(providing_args=['instance']) +checksum_requested = Signal() +checksum_requested.__doc__ = """ +Providing arguments: ['instance', 'request'] +""" +config_download_requested = Signal() +config_download_requested.__doc__ = """ +Providing arguments: ['instance', 'request'] +""" +config_status_changed = Signal() +config_status_changed.__doc__ = """ +Providing arguments: ['instance'] +""" # device and config args are maintained for backward compatibility -config_modified = Signal( - providing_args=['instance', 'device', 'config', 'previous_status', 'action'] -) -device_registered = Signal(providing_args=['instance', 'is_new']) -management_ip_changed = Signal( - providing_args=['instance', 'management_ip', 'old_management_ip'] -) -device_name_changed = Signal(providing_args=['instance']) -device_group_changed = Signal(providing_args=['instance', 'group', 'old_group']) +config_modified = Signal() +config_modified.__doc__ = """ +Providing arguments: ['instance', 'device', 'config', 'previous_status', 'action'] +""" +device_registered = Signal() +device_registered.__doc__ = """ +Providing arguments: ['instance', 'is_new'] +""" +management_ip_changed = Signal() +management_ip_changed.__doc__ = """ +Providing arguments: ['instance', 'management_ip', 'old_management_ip'] +""" +device_name_changed = Signal() +device_name_changed.__doc__ = """ +Providing arguments: ['instance'] +""" +device_group_changed = Signal() +device_group_changed.__doc__ = """ +Providing arguments: ['instance'] +""" +vpn_peers_changed = Signal() +vpn_peers_changed.__doc__ = """ +providing arguments: ['instance'] +""" diff --git a/openwisp_controller/config/sortedm2m/fields.py b/openwisp_controller/config/sortedm2m/fields.py index 7b4ee9ebc..ea9fa0280 100644 --- a/openwisp_controller/config/sortedm2m/fields.py +++ b/openwisp_controller/config/sortedm2m/fields.py @@ -54,7 +54,9 @@ class SortedManyToManyDescriptor(BaseSortedManyToManyDescriptor): def related_manager_cls(self): model = self.rel.model return create_sorted_many_related_manager( - model._default_manager.__class__, self.rel, reverse=False, + model._default_manager.__class__, + self.rel, + reverse=False, ) diff --git a/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css b/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css index 4de47cb1a..71f54e865 100644 --- a/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css +++ b/openwisp_controller/config/static/config/css/lib/jsonschema-ui.css @@ -177,15 +177,15 @@ div[data-schematype="object"] > .inline-related > .grid-container > div > .jsoneditor-wrapper div.jsoneditor .grid-column > label, .jsoneditor-wrapper div.jsoneditor .grid-column > select{ position: absolute; - top: 14px; left: 0; + top: 12px; z-index: 1; font-weight: bold; } .jsoneditor-wrapper div.jsoneditor .inline-related > select, .jsoneditor-wrapper div.jsoneditor .grid-column > select{ margin-left: 188px; - background-color: #fff + background-color: #fff; } .jsoneditor-wrapper div.jsoneditor .grid-row .grid-column{ position: relative } .jsoneditor-wrapper div.jsoneditor .modal{ @@ -290,3 +290,12 @@ div.jsoneditor > div > h3.controls{ bottom: -52px; z-index: 99; } +.jsoneditor-wrapper div.jsoneditor .grid-row .grid-column > select.switcher{ + top: 11px; +} +.jsoneditor-wrapper div.jsoneditor .grid-row > .grid-column > label { + display: none; +} +.jsoneditor-wrapper div.jsoneditor .inline-group > .form-row > label { + display: block !important; +} diff --git a/openwisp_controller/config/static/config/js/vpn.js b/openwisp_controller/config/static/config/js/vpn.js index 257487d9c..c5e951528 100644 --- a/openwisp_controller/config/static/config/js/vpn.js +++ b/openwisp_controller/config/static/config/js/vpn.js @@ -1,33 +1,66 @@ 'use strict'; django.jQuery(function ($) { - if (!$('.add-form').length) { - return; - } - - var showOverlay = function () { - var loading = $('#loading-overlay'); - if (!loading.length) { - $('body').append( - '
' - ); - loading = $('#loading-overlay'); - } - loading.fadeIn(100, function () { - loading.css('display', 'flex'); - var spinner = loading.find('.spinner'); - spinner.fadeOut(100, function () { - var message = gettext( - 'Please be patient, we are creating all the necessary ' + - 'cyrptographic keys which may take some time' + if ($('.add-form').length) { + var showOverlay = function () { + var loading = $('#loading-overlay'); + if (!loading.length) { + $('body').append( + '
' ); - spinner.remove(); - loading.append('

'); - loading.find('p').hide().text(message).fadeIn(250); + loading = $('#loading-overlay'); + } + loading.fadeIn(100, function () { + loading.css('display', 'flex'); + var spinner = loading.find('.spinner'); + spinner.fadeOut(100, function () { + var message = gettext( + 'Please be patient, we are creating all the necessary ' + + 'cyrptographic keys which may take some time' + ); + spinner.remove(); + loading.append('

'); + loading.find('p').hide().text(message).fadeIn(250); + }); }); + }; + + $('#vpn_form').submit(function () { + showOverlay(); }); + } + + var toggleRelatedFields = function () { + // Show IP and Subnet field only for WireGuard backend + var backendValue = $('#id_backend').val() === undefined ? '' : $('#id_backend').val().toLocaleLowerCase().toLocaleLowerCase(); + if (backendValue.includes('wireguard') || backendValue.includes('vxlan')) { + $('label[for="id_subnet"]').parent().parent().show(); + $('label[for="id_ip"]').parent().parent().show(); + $('label[for="id_webhook_endpoint"]').parent().parent().show(); + $('label[for="id_auth_token"]').parent().parent().show(); + } else { + $('label[for="id_subnet"]').parent().parent().hide(); + $('label[for="id_ip"]').parent().parent().hide(); + $('label[for="id_webhook_endpoint"]').parent().parent().hide(); + $('label[for="id_auth_token"]').parent().parent().hide(); + // Reset IP and Subnet fields + $('#id_subnet').val(null); + $('#id_ip').val(null); + } + + if (backendValue.includes('openvpn')) { + $('label[for="id_ca"]').parent().parent().show(); + $('label[for="id_cert"]').parent().parent().show(); + } else { + $('label[for="id_ca"]').parent().parent().hide(); + $('label[for="id_cert"]').parent().parent().hide(); + } }; - $('#vpn_form').submit(function () { - showOverlay(); + // clean config when VPN backend is changed + $('#id_backend').change(function () { + $('#id_config').val('{}'); + toggleRelatedFields(); }); + + toggleRelatedFields(); }); diff --git a/openwisp_controller/config/tasks.py b/openwisp_controller/config/tasks.py index 339cabb7b..fb0db4599 100644 --- a/openwisp_controller/config/tasks.py +++ b/openwisp_controller/config/tasks.py @@ -1,10 +1,14 @@ import logging +import requests from celery import shared_task from celery.exceptions import SoftTimeLimitExceeded +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from swapper import load_model +from openwisp_utils.tasks import OpenwispCeleryTask + logger = logging.getLogger(__name__) @@ -52,7 +56,7 @@ def create_vpn_dh(vpn_pk): vpn.save() -@shared_task +@shared_task(base=OpenwispCeleryTask) def invalidate_devicegroup_cache_change(instance_id, model_name): from .api.views import DeviceGroupCommonName @@ -68,7 +72,7 @@ def invalidate_devicegroup_cache_change(instance_id, model_name): DeviceGroupCommonName.certificate_change_invalidates_cache(instance_id) -@shared_task +@shared_task(base=OpenwispCeleryTask) def invalidate_devicegroup_cache_delete(instance_id, model_name, **kwargs): from .api.views import DeviceGroupCommonName @@ -83,3 +87,20 @@ def invalidate_devicegroup_cache_delete(instance_id, model_name, **kwargs): DeviceGroupCommonName.certificate_delete_invalidates_cache( kwargs['organization_id'], kwargs['common_name'] ) + + +@shared_task(base=OpenwispCeleryTask) +def trigger_vpn_server_endpoint(endpoint, auth_token, vpn_id): + response = requests.post( + endpoint, + params={'key': auth_token}, + verify=False if getattr(settings, 'DEBUG') else True, + ) + if response.status_code == 200: + logger.info(f'Triggered update webhook of VPN Server UUID: {vpn_id}') + else: + logger.error( + 'Failed to update VPN Server configuration. ' + f'Response status code: {response.status_code}, ' + f'VPN Server UUID: {vpn_id}', + ) diff --git a/openwisp_controller/config/templates/admin/config/device_recover_form.html b/openwisp_controller/config/templates/admin/config/device_recover_form.html new file mode 100644 index 000000000..f732abf3d --- /dev/null +++ b/openwisp_controller/config/templates/admin/config/device_recover_form.html @@ -0,0 +1,23 @@ +{% extends "admin/config/change_form.html" %} +{% load i18n admin_urls %} + + +{% comment %} Following content is take from "reversion/templates/reversion/recover_form.html" {% endcomment %} +{% block breadcrumbs %} +

+{% endblock %} + +{% block object-tools %}{% endblock %} + +{% block form_top %} +

{% blocktrans %}Press the save button below to recover this version of the object.{% endblocktrans %}

+{% endblock %} + +{% block submit_buttons_top %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} +{% block submit_buttons_bottom %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} diff --git a/openwisp_controller/config/tests/pytest.py b/openwisp_controller/config/tests/pytest.py index b4c5bd005..59d145eb9 100644 --- a/openwisp_controller/config/tests/pytest.py +++ b/openwisp_controller/config/tests/pytest.py @@ -5,7 +5,7 @@ from channels.security.websocket import AllowedHostsOriginValidator from channels.testing import WebsocketCommunicator from django.contrib.auth.models import Permission -from django.urls import path +from django.urls import re_path from swapper import load_model from openwisp_users.tests.utils import TestOrganizationMixin @@ -25,7 +25,12 @@ class TestDeviceConsumer(CreateDeviceMixin, TestOrganizationMixin): 'websocket': AllowedHostsOriginValidator( AuthMiddlewareStack( URLRouter( - [path('ws/controller/device//', BaseDeviceConsumer)] + [ + re_path( + r'^ws/controller/device/(?P[^/]+)/$', + BaseDeviceConsumer.as_asgi(), + ) + ] ) ) ) @@ -37,7 +42,12 @@ async def _get_communicator(self, admin_client, device_id): communicator = WebsocketCommunicator( self.application, path=f'ws/controller/device/{device_id}/', - headers=[(b'cookie', f'sessionid={session_id}'.encode('ascii'),)], + headers=[ + ( + b'cookie', + f'sessionid={session_id}'.encode('ascii'), + ) + ], ) connected, subprotocol = await communicator.connect() assert connected is True @@ -87,6 +97,11 @@ async def test_silent_disconnection(self, admin_user, admin_client): communicator = WebsocketCommunicator( self.application, path=f'ws/controller/device/{device.pk}/', - headers=[(b'cookie', f'sessionid={session_id}'.encode('ascii'),)], + headers=[ + ( + b'cookie', + f'sessionid={session_id}'.encode('ascii'), + ) + ], ) await communicator.disconnect() diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 293dcccc5..b14a85d9c 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -4,6 +4,7 @@ from django.contrib.admin.models import LogEntry from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.test import TestCase from django.urls import reverse from swapper import load_model @@ -46,12 +47,6 @@ class TestAdmin( object_model = Device object_location_model = DeviceLocation maxDiff = None - operator_permission_filters = [ - {'codename__endswith': 'config'}, - {'codename__endswith': 'device'}, - {'codename__endswith': 'template'}, - {'codename__endswith': 'vpn'}, - ] _device_params = { 'name': 'test-device', 'hardware_id': '1234', @@ -197,6 +192,7 @@ def _create_multitenancy_test_env(self, vpn=False): org2 = self._create_org(name='test2org') inactive = self._create_org(name='inactive-org', is_active=False) operator = self._create_operator(organizations=[org1, inactive]) + administrator = self._create_administrator(organizations=[org1, inactive]) t1 = self._create_template(name='template1org', organization=org1) t2 = self._create_template(name='template2org', organization=org2) t3 = self._create_template(name='t3-inactive', organization=inactive) @@ -229,6 +225,7 @@ def _create_multitenancy_test_env(self, vpn=False): org2=org2, inactive=inactive, operator=operator, + administrator=administrator, ) if vpn: v1 = self._create_vpn(name='vpn1org', organization=org1) @@ -316,6 +313,7 @@ def test_vpn_organization_fk_queryset(self): visible=[data['org1'].name], hidden=[data['org2'].name, data['inactive']], select_widget=True, + administrator=True, ) def test_vpn_ca_fk_queryset(self): @@ -325,6 +323,7 @@ def test_vpn_ca_fk_queryset(self): visible=[data['vpn1'].ca.name, data['vpn_shared'].ca.name], hidden=[data['vpn2'].ca.name, data['vpn_inactive'].ca.name], select_widget=True, + administrator=True, ) def test_vpn_cert_fk_queryset(self): @@ -334,6 +333,7 @@ def test_vpn_cert_fk_queryset(self): visible=[data['vpn1'].cert.name, data['vpn_shared'].cert.name], hidden=[data['vpn2'].cert.name, data['vpn_inactive'].cert.name], select_widget=True, + administrator=True, ) def test_changelist_recover_deleted_button(self): @@ -450,6 +450,7 @@ def test_clone_templates_superuser_multi_orgs(self): self.client.force_login(self._get_admin()) response = self.client.post(path, post_data) self.assertContains(response, 'Clone templates') + self.assertContains(response, 'Shared systemwide') def test_clone_templates_operator_1_org(self): path = reverse(f'admin:{self.app_label}_template_changelist') @@ -470,6 +471,7 @@ def test_clone_templates_operator_multi_orgs(self): self.client.force_login(operator) response = self.client.post(path, post_data) self.assertContains(response, 'Clone templates') + self.assertNotContains(response, 'Shared systemwide') def test_change_device_clean_templates(self): o = self._get_org() @@ -488,7 +490,10 @@ def test_change_device_clean_templates(self): } ) # ensure it fails with error - response = self.client.post(path, params) + with patch.object( + Config, 'clean_templates', side_effect=ValidationError('test') + ): + response = self.client.post(path, params) self.assertContains(response, 'errors field-templates') # remove conflicting template and ensure doesn't error params['config-0-templates'] = '' @@ -549,7 +554,7 @@ def test_change_device_required_template(self): def test_download_device_config(self): d = self._create_device(name='download') self._create_config(device=d) - path = reverse(f'admin:{self.app_label}_device_download', args=[d.pk]) + path = reverse(f'admin:{self.app_label}_device_download', args=[d.pk.hex]) response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertEqual(response.get('content-type'), 'application/octet-stream') @@ -700,10 +705,17 @@ def test_preview_device_jsonerror(self): def test_preview_device_showerror(self): t1 = Template.objects.get(name='dhcp') - t2 = Template(name='t', config=t1.config, backend='netjsonconfig.OpenWrt') - t2.full_clean() - t2.save() - templates = [t1, t2] + t2 = Template(name='t2', config=t1.config, backend=t1.backend) + t3 = Template(name='t3', config=t1.config, backend=t1.backend) + t4 = Template( + name='t4', + config={"interfaces": [{"name": "eth0", "type": "bridge", "stp": "WRONG"}]}, + backend='netjsonconfig.OpenWrt', + ) + # skip validating config to raise error later + t4.save() + # adding multiple templates to ensure the order is retained correctly + templates = [t1, t2, t3, t4] path = reverse(f'admin:{self.app_label}_device_preview') data = { 'name': 'test-device', @@ -714,7 +726,7 @@ def test_preview_device_showerror(self): 'csrfmiddlewaretoken': 'test', } response = self.client.post(path, data) - # expect duplicate error + # expect error self.assertContains(response, '
[^/]+)/$',
+        re_path(
+            'controller/device/checksum/(?P[^/]+)/$',
             views_module.device_checksum,
             name='device_checksum',
         ),
-        url(
-            r'^controller/device/download-config/(?P[^/]+)/$',
+        re_path(
+            'controller/device/download-config/(?P[^/]+)/$',
             views_module.device_download_config,
             name='device_download_config',
         ),
-        url(
-            r'^controller/device/update-info/(?P[^/]+)/$',
+        re_path(
+            'controller/device/update-info/(?P[^/]+)/$',
             views_module.device_update_info,
             name='device_update_info',
         ),
-        url(
-            r'^controller/device/report-status/(?P[^/]+)/$',
+        re_path(
+            'controller/device/report-status/(?P[^/]+)/$',
             views_module.device_report_status,
             name='device_report_status',
         ),
-        url(
-            r'^controller/device/register/$',
+        path(
+            'controller/device/register/',
             views_module.device_register,
             name='device_register',
         ),
-        url(
-            r'^controller/vpn/checksum/(?P[^/]+)/$',
+        re_path(
+            'controller/vpn/checksum/(?P[^/]+)/$',
             views_module.vpn_checksum,
             name='vpn_checksum',
         ),
-        url(
-            r'^controller/vpn/download-config/(?P[^/]+)/$',
+        re_path(
+            'controller/vpn/download-config/(?P[^/]+)/$',
             views_module.vpn_download_config,
             name='vpn_download_config',
         ),
         # legacy URLs
-        url(
-            r'^controller/checksum/(?P[^/]+)/$',
+        re_path(
+            'controller/checksum/(?P[^/]+)/$',
             views_module.device_checksum,
             name='checksum_legacy',
         ),
-        url(
-            r'^controller/download-config/(?P[^/]+)/$',
+        re_path(
+            'controller/download-config/(?P[^/]+)/$',
             views_module.device_download_config,
             name='download_config_legacy',
         ),
-        url(
-            r'^controller/update-info/(?P[^/]+)/$',
+        re_path(
+            'controller/update-info/(?P[^/]+)/$',
             views_module.device_update_info,
             name='update_info_legacy',
         ),
-        url(
-            r'^controller/report-status/(?P[^/]+)/$',
+        re_path(
+            'controller/report-status/(?P[^/]+)/$',
             views_module.device_report_status,
             name='report_status_legacy',
         ),
-        url(
-            r'^controller/register/$',
+        path(
+            'controller/register/',
             views_module.device_register,
             name='register_legacy',
         ),
diff --git a/openwisp_controller/config/validators.py b/openwisp_controller/config/validators.py
index d62711587..837361a70 100644
--- a/openwisp_controller/config/validators.py
+++ b/openwisp_controller/config/validators.py
@@ -1,5 +1,5 @@
 from django.core.validators import RegexValidator, _lazy_re_compile
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 
 key_validator = RegexValidator(
     _lazy_re_compile('^[^\s/\.]+$'),
diff --git a/openwisp_controller/config/views.py b/openwisp_controller/config/views.py
index 3011514fc..84a26f20d 100644
--- a/openwisp_controller/config/views.py
+++ b/openwisp_controller/config/views.py
@@ -6,7 +6,7 @@
 from django.http import HttpResponse, JsonResponse
 from django.utils import timezone
 from django.utils.module_loading import import_string
-from django.utils.translation import ugettext as _
+from django.utils.translation import gettext as _
 from django.views.decorators.http import last_modified
 from swapper import load_model
 
diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py
index 6faf3470f..f366589df 100644
--- a/openwisp_controller/connection/admin.py
+++ b/openwisp_controller/connection/admin.py
@@ -1,13 +1,13 @@
 from datetime import timedelta
 
+import reversion
 import swapper
 from django import forms
-from django.conf.urls import url
 from django.contrib import admin
 from django.http import JsonResponse
 from django.urls import path, resolve
 from django.utils.timezone import localtime
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 
 from openwisp_users.multitenancy import MultitenantOrgFilter
 from openwisp_utils.admin import TimeReadonlyAdminMixin
@@ -62,8 +62,8 @@ def get_urls(self):
         options = getattr(self.model, '_meta')
         url_prefix = f'{options.app_label}_{options.model_name}'
         return [
-            url(
-                r'^ui/schema.json$',
+            path(
+                'ui/schema.json',
                 self.admin_site.admin_view(self.schema_view),
                 name=f'{url_prefix}_schema',
             )
@@ -166,9 +166,11 @@ def schema_view(self, request):
 
 
 DeviceAdmin.inlines += [DeviceConnectionInline]
+reversion.register(model=DeviceConnection, follow=['device'])
 DeviceAdmin.conditional_inlines += [
     CommandWritableInline,
     # this inline must come after CommandWritableInline
     # or the JS logic will not work
     CommandInline,
 ]
+DeviceAdmin.add_reversion_following(follow=['deviceconnection_set'])
diff --git a/openwisp_controller/connection/api/urls.py b/openwisp_controller/connection/api/urls.py
index 7e05aa3d6..91ce2d7a0 100644
--- a/openwisp_controller/connection/api/urls.py
+++ b/openwisp_controller/connection/api/urls.py
@@ -11,12 +11,12 @@ def get_api_urls(api_views):
     """
     return [
         path(
-            'api/v1/controller/device//command/',
+            'api/v1/controller/device//command/',
             api_views.command_list_create_view,
             name='device_command_list',
         ),
         path(
-            'api/v1/controller/device//command//',
+            'api/v1/controller/device//command//',
             api_views.command_details_view,
             name='device_command_details',
         ),
diff --git a/openwisp_controller/connection/api/views.py b/openwisp_controller/connection/api/views.py
index d332628a5..28d0d04ab 100644
--- a/openwisp_controller/connection/api/views.py
+++ b/openwisp_controller/connection/api/views.py
@@ -9,13 +9,11 @@
     RetrieveUpdateDestroyAPIView,
     get_object_or_404,
 )
-from rest_framework.permissions import IsAuthenticated
 from swapper import load_model
 
 from openwisp_users.api.authentication import BearerAuthentication
-from openwisp_users.api.mixins import FilterByOrganizationManaged
-from openwisp_users.api.permissions import DjangoModelPermissions
 
+from ...mixins import ProtectedAPIMixin
 from .serializer import (
     CommandSerializer,
     CredentialSerializer,
@@ -84,14 +82,6 @@ def get_object(self):
         return obj
 
 
-class ProtectedAPIMixin(FilterByOrganizationManaged):
-    authentication_classes = [BearerAuthentication, SessionAuthentication]
-    permission_classes = [
-        IsAuthenticated,
-        DjangoModelPermissions,
-    ]
-
-
 class CredentialListCreateView(ProtectedAPIMixin, ListCreateAPIView):
     queryset = Credentials.objects.order_by('-created')
     serializer_class = CredentialSerializer
diff --git a/openwisp_controller/connection/apps.py b/openwisp_controller/connection/apps.py
index 8282a1c3f..048ab553d 100644
--- a/openwisp_controller/connection/apps.py
+++ b/openwisp_controller/connection/apps.py
@@ -1,10 +1,10 @@
 from asgiref.sync import async_to_sync
-from celery.task.control import inspect
+from celery import current_app
 from channels import layers
 from django.apps import AppConfig
 from django.db import transaction
 from django.db.models.signals import post_save
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 from openwisp_notifications.signals import notify
 from openwisp_notifications.types import register_notification_type
 from swapper import get_model_name, load_model
@@ -21,6 +21,11 @@ class ConnectionConfig(AppConfig):
     name = 'openwisp_controller.connection'
     label = 'connection'
     verbose_name = _('Network Device Credentials')
+    # List of reasons for which notifications should
+    # not be generated if a device connection errors out.
+    # Intended to be used internally by OpenWISP to
+    # ignore notifications generated due to connectivity issues.
+    _ignore_connection_notification_reasons = []
 
     def ready(self):
         """
@@ -94,7 +99,7 @@ def _launch_update_config(cls, device_pk):
 
     @classmethod
     def _is_update_in_progress(cls, device_pk):
-        active = inspect().active()
+        active = current_app.control.inspect().active()
         if not active:
             return False
         # check if there's any other running task before adding it
@@ -106,12 +111,22 @@ def _is_update_in_progress(cls, device_pk):
 
     @classmethod
     def is_working_changed_receiver(
-        cls, instance, is_working, old_is_working, **kwargs
+        cls,
+        instance,
+        is_working,
+        old_is_working,
+        failure_reason,
+        old_failure_reason,
+        **kwargs,
     ):
         # if old_is_working is None, it's a new device connection which wasn't
         # used yet, so nothing is really changing and we won't notify the user
         if old_is_working is None:
             return
+        # don't send notification if error occurred due to connectivity issues
+        for ignore_reason in cls._ignore_connection_notification_reasons:
+            if ignore_reason in failure_reason or ignore_reason in old_failure_reason:
+                return
         device = instance.device
         notification_opts = dict(sender=instance, target=device)
         if not is_working:
diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py
index f30465699..ac9a5db50 100644
--- a/openwisp_controller/connection/base/models.py
+++ b/openwisp_controller/connection/base/models.py
@@ -160,6 +160,11 @@ def auto_add_credentials_to_device(cls, instance, created, **kwargs):
          we can automatically create a DeviceConnection if we have
          a ``Config`` object)
         """
+        from django.contrib.contenttypes.models import ContentType
+        from reversion.models import Version
+
+        DeviceConnection = load_model('connection', 'DeviceConnection')
+
         if not created:
             return
         device = instance.device
@@ -168,10 +173,33 @@ def auto_add_credentials_to_device(cls, instance, created, **kwargs):
         #   - belong to the same organization of the device
         #     OR
         #     belong to no organization (hence are shared)
-        conditions = models.Q(organization=device.organization) | models.Q(
-            organization=None
+        where = models.Q(auto_add=True) & (
+            models.Q(organization=device.organization) | models.Q(organization=None)
+        )
+        # Exclude credentials for which DeviceConnection object already
+        # exists for the device. This condition is required when a
+        # deleted device is recovered through django-reversions.
+        not_where = models.Q(
+            id__in=device.deviceconnection_set.values_list('credentials_id', flat=True)
+        )
+        # A race condition might occur while recovering a deleted device.
+        # The code for creating new DeviceConnection might be executed
+        # before the deleted DeviceConnection object is restored from the database.
+        # Therefore, when creating DeviceConnection objects in this method,
+        # we make sure to avoid creating objects for credentials which are
+        # stored in the revision history of django-reversion so that when a
+        # deleted device is restored from the revision history we avoid
+        # this race condition which would generate two identical DeviceConnection
+        # objects and hence prevent the restoration of a deleted device.
+        device_connection_versions = Version.objects.filter(
+            content_type=ContentType.objects.get_for_model(DeviceConnection),
+            serialized_data__contains=str(device.id),
         )
-        credentials = cls.objects.filter(conditions).filter(auto_add=True)
+        versioned_credentials = []
+        for version in device_connection_versions:
+            versioned_credentials.append(version.field_dict['credentials_id'])
+        not_where |= models.Q(id__in=versioned_credentials)
+        credentials = cls.objects.filter(where).exclude(not_where)
         for cred in credentials:
             DeviceConnection = load_model('connection', 'DeviceConnection')
             conn = DeviceConnection(device=device, credentials=cred, enabled=True)
@@ -220,6 +248,7 @@ class Meta:
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self._initial_is_working = self.is_working
+        self._initial_failure_reason = self.failure_reason
 
     def clean(self):
         cred_org = self.credentials.organization
@@ -312,6 +341,7 @@ def save(self, *args, **kwargs):
         if self.is_working != self._initial_is_working:
             self.send_is_working_changed_signal()
         self._initial_is_working = self.is_working
+        self._initial_failure_reason = self.failure_reason
 
     def send_is_working_changed_signal(self):
         is_working_changed.send(
@@ -319,6 +349,7 @@ def send_is_working_changed_signal(self):
             is_working=self.is_working,
             old_is_working=self._initial_is_working,
             failure_reason=self.failure_reason,
+            old_failure_reason=self._initial_failure_reason,
             instance=self,
         )
 
diff --git a/openwisp_controller/connection/channels/routing.py b/openwisp_controller/connection/channels/routing.py
index 9840e2b65..70c426332 100644
--- a/openwisp_controller/connection/channels/routing.py
+++ b/openwisp_controller/connection/channels/routing.py
@@ -1,7 +1,12 @@
-from django.urls import path
+from django.urls import re_path
 
 from . import consumers as ow_consumer
 
 
 def get_routes(consumer=ow_consumer):
-    return [path('ws/controller/device//command', consumer.CommandConsumer)]
+    return [
+        re_path(
+            r'^ws/controller/device/(?P[^/]+)/command$',
+            consumer.CommandConsumer.as_asgi(),
+        )
+    ]
diff --git a/openwisp_controller/connection/connectors/ssh.py b/openwisp_controller/connection/connectors/ssh.py
index 7723638f6..02a7fa797 100644
--- a/openwisp_controller/connection/connectors/ssh.py
+++ b/openwisp_controller/connection/connectors/ssh.py
@@ -163,7 +163,6 @@ def exec_command(
         # log standard error
         error = stderr.read().decode('utf-8', 'ignore')
         if error:
-            logger.error(error)
             if not output.endswith('\n'):
                 output += '\n'
             output += error
@@ -171,7 +170,7 @@ def exec_command(
         # returned with a non-zero exit status
         if exit_status not in exit_codes and raise_unexpected_exit:
             log_message = 'Unexpected exit code: {0}'.format(exit_status)
-            logger.error(log_message)
+            logger.info(log_message)
             message = error if error else output
             # if message is empty, use log_message
             raise CommandFailedException(message or log_message)
diff --git a/openwisp_controller/connection/migrations/0001_initial.py b/openwisp_controller/connection/migrations/0001_initial.py
index da5b30eb5..fcb4f8eb7 100644
--- a/openwisp_controller/connection/migrations/0001_initial.py
+++ b/openwisp_controller/connection/migrations/0001_initial.py
@@ -8,6 +8,7 @@
 import jsonfield.fields
 import model_utils.fields
 import swapper
+from django.conf import settings
 from django.db import migrations, models
 
 import openwisp_users.mixins
@@ -20,7 +21,9 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
-        ('openwisp_users', '0001_initial'),
+        swapper.dependency(
+            *swapper.split(settings.AUTH_USER_MODEL), version='0004_default_groups'
+        ),
         swapper.dependency('config', 'Device'),
     ]
 
@@ -156,7 +159,7 @@ class Migration(migrations.Migration):
                     'credentials',
                     models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='connection.Credentials',
+                        to=swapper.get_model_name('connection', 'Credentials'),
                     ),
                 ),
                 (
diff --git a/openwisp_controller/connection/migrations/0006_name_unique_per_organization.py b/openwisp_controller/connection/migrations/0006_name_unique_per_organization.py
index 42a460280..9d02320ee 100644
--- a/openwisp_controller/connection/migrations/0006_name_unique_per_organization.py
+++ b/openwisp_controller/connection/migrations/0006_name_unique_per_organization.py
@@ -6,7 +6,6 @@
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('openwisp_users', '0011_user_first_name_150_max_length'),
         ('connection', '0005_device_connection_failure_reason'),
     ]
 
diff --git a/openwisp_controller/connection/migrations/0007_command.py b/openwisp_controller/connection/migrations/0007_command.py
index c5cfec6a2..c94af8356 100644
--- a/openwisp_controller/connection/migrations/0007_command.py
+++ b/openwisp_controller/connection/migrations/0007_command.py
@@ -63,7 +63,13 @@ class Migration(migrations.Migration):
                         max_length=12,
                     ),
                 ),
-                ('type', models.CharField(choices=COMMAND_CHOICES, max_length=16,),),
+                (
+                    'type',
+                    models.CharField(
+                        choices=COMMAND_CHOICES,
+                        max_length=16,
+                    ),
+                ),
                 (
                     'input',
                     jsonfield.fields.JSONField(
diff --git a/openwisp_controller/connection/signals.py b/openwisp_controller/connection/signals.py
index 421c8fc70..65cb3a40d 100644
--- a/openwisp_controller/connection/signals.py
+++ b/openwisp_controller/connection/signals.py
@@ -1,5 +1,12 @@
 from django.dispatch import Signal
 
-is_working_changed = Signal(
-    providing_args=['is_working', 'old_is_working', 'instance', 'failure_reason']
-)
+is_working_changed = Signal()
+is_working_changed.__doc__ = """
+Providing araguments: [
+    'is_working',
+    'old_is_working',
+    'instance',
+    'failure_reason',
+    'old_failure_reason'
+]
+"""
diff --git a/openwisp_controller/connection/static/connection/js/commands.js b/openwisp_controller/connection/static/connection/js/commands.js
index a6e9101e2..46d37f930 100644
--- a/openwisp_controller/connection/static/connection/js/commands.js
+++ b/openwisp_controller/connection/static/connection/js/commands.js
@@ -8,11 +8,16 @@ const commandApiUrl = `${owControllerApiHost.origin}${owCommandApiEndpoint.repla
 const commandWebSocket = new ReconnectingWebSocket(
     `${getWebSocketProtocol()}${owControllerApiHost.host}/ws/controller/device/${deviceId}/command`,
     null, {
-        debug: false
+        debug: false,
+        automaticOpen: false,
     }
 );
 
 django.jQuery(function ($) {
+    if (isDeviceRecoverForm()) {
+        return;
+    }
+    commandWebSocket.open();
     let selector = $('#id_command_set-0-type'),
         showFields = function () {
             var fields = $('#command_set-group fieldset > .form-row:not(.field-type):not(.field-params), #command_set-group .jsoneditor-wrapper'),
@@ -571,3 +576,7 @@ function getFormattedDateTimeString(DateTimeString) {
     stringArray[4] = (stringArray[4] == 'AM') ? 'a.m.' : 'p.m.';
     return stringArray.join(' ');
 }
+
+function isDeviceRecoverForm() {
+    return document.getElementsByTagName('title')[0].innerText.indexOf('Recover') > -1;
+}
diff --git a/openwisp_controller/connection/tests/pytest.py b/openwisp_controller/connection/tests/pytest.py
index 4a7f1dc80..63558d31e 100644
--- a/openwisp_controller/connection/tests/pytest.py
+++ b/openwisp_controller/connection/tests/pytest.py
@@ -25,7 +25,12 @@ async def _get_communicator(self, admin_client, device_id):
         communicator = WebsocketCommunicator(
             self.application,
             path=f'ws/controller/device/{device_id}/command',
-            headers=[(b'cookie', f'sessionid={session_id}'.encode('ascii'),)],
+            headers=[
+                (
+                    b'cookie',
+                    f'sessionid={session_id}'.encode('ascii'),
+                )
+            ],
         )
         connected, subprotocol = await communicator.connect()
         assert connected is True
diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py
index af24ff733..90560ef61 100644
--- a/openwisp_controller/connection/tests/test_admin.py
+++ b/openwisp_controller/connection/tests/test_admin.py
@@ -24,14 +24,6 @@
 class TestConnectionAdmin(TestAdminMixin, CreateConnectionsMixin, TestCase):
     config_app_label = 'config'
     app_label = 'connection'
-    operator_permission_filters = [
-        {'codename__endswith': 'config'},
-        {'codename__endswith': 'device'},
-        {'codename__endswith': 'template'},
-        {'codename__endswith': 'connection'},
-        {'codename__endswith': 'credentials'},
-        {'codename__endswith': 'device_connection'},
-    ]
     _device_params = TestConfigAdmin._device_params.copy()
 
     def _get_device_params(self, org):
@@ -44,6 +36,7 @@ def _create_multitenancy_test_env(self):
         org2 = self._create_org(name='test2org')
         inactive = self._create_org(name='inactive-org', is_active=False)
         operator = self._create_operator(organizations=[org1, inactive])
+        administrator = self._create_administrator(organizations=[org1, inactive])
         cred1 = self._create_credentials(organization=org1, name='test1cred')
         cred2 = self._create_credentials(organization=org2, name='test2cred')
         cred3 = self._create_credentials(organization=inactive, name='test3cred')
@@ -61,6 +54,7 @@ def _create_multitenancy_test_env(self):
             org2=org2,
             inactive=inactive,
             operator=operator,
+            administrator=administrator,
         )
         return data
 
@@ -70,6 +64,7 @@ def test_credentials_queryset(self):
             url=reverse(f'admin:{self.app_label}_credentials_changelist'),
             visible=[data['cred1'].name, data['org1'].name],
             hidden=[data['cred2'].name, data['org2'].name, data['cred3_inactive'].name],
+            administrator=True,
         )
 
     def test_credentials_organization_fk_queryset(self):
@@ -79,6 +74,7 @@ def test_credentials_organization_fk_queryset(self):
             visible=[data['org1'].name],
             hidden=[data['org2'].name, data['inactive']],
             select_widget=True,
+            administrator=True,
         )
 
     def test_connection_queryset(self):
@@ -91,6 +87,7 @@ def test_connection_queryset(self):
                 data['org2'].name,
                 data['dc3_inactive'].credentials.name,
             ],
+            administrator=True,
         )
 
     def test_connection_credentials_fk_queryset(self):
@@ -190,7 +187,9 @@ def test_commands_schema_view(self):
         self.assertIn('reboot', result)
 
     @patch.object(
-        module_settings, 'OPENWISP_CONTROLLER_API_HOST', 'https://example.com',
+        module_settings,
+        'OPENWISP_CONTROLLER_API_HOST',
+        'https://example.com',
     )
     def test_notification_host_setting(self, ctx_processors=[]):
         url = reverse(
diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py
index d22516f57..79c7b101d 100644
--- a/openwisp_controller/connection/tests/test_api.py
+++ b/openwisp_controller/connection/tests/test_api.py
@@ -71,7 +71,8 @@ def test_command_list_api(self):
             self.assertEqual(next_response.status_code, 200)
             self.assertEqual(next_response.data['count'], number_of_commands)
             self.assertEqual(
-                next_response.data['next'], None,
+                next_response.data['next'],
+                None,
             )
             self.assertIn(
                 self._get_path('device_command_list', self.device_id),
@@ -87,7 +88,10 @@ def test_command_list_api(self):
             self.assertEqual(response.data['count'], number_of_commands)
             self.assertIn(
                 self._get_path(
-                    'device_command_list', self.device_id, page=2, page_size=page_size,
+                    'device_command_list',
+                    self.device_id,
+                    page=2,
+                    page_size=page_size,
                 ),
                 response.data['next'],
             )
@@ -100,7 +104,9 @@ def test_command_list_api(self):
             self.assertEqual(next_response.data['next'], None)
             self.assertIn(
                 self._get_path(
-                    'device_command_list', self.device_id, page_size=page_size,
+                    'device_command_list',
+                    self.device_id,
+                    page_size=page_size,
                 ),
                 next_response.data['previous'],
             )
@@ -135,7 +141,9 @@ def test_command_attributes(self, payload):
                 'input': None,
             }
             response = self.client.post(
-                url, data=payload, content_type='application/json',
+                url,
+                data=payload,
+                content_type='application/json',
             )
             self.assertEqual(response.status_code, 201)
             test_command_attributes(self, payload)
@@ -146,7 +154,9 @@ def test_command_attributes(self, payload):
                 'input': {'password': 'ass@1234', 'confirm_password': 'Pass@1234'},
             }
             response = self.client.post(
-                url, data=json.dumps(payload), content_type='application/json',
+                url,
+                data=json.dumps(payload),
+                content_type='application/json',
             )
             self.assertEqual(response.status_code, 201)
             test_command_attributes(self, payload)
@@ -157,7 +167,9 @@ def test_command_attributes(self, payload):
                 'input': {'command': 'echo test'},
             }
             response = self.client.post(
-                url, data=json.dumps(payload), content_type='application/json',
+                url,
+                data=json.dumps(payload),
+                content_type='application/json',
             )
             self.assertEqual(response.status_code, 201)
             test_command_attributes(self, payload)
@@ -201,13 +213,19 @@ def test_bearer_authentication(self):
             url = self._get_path(
                 'device_command_details', self.device_id, command_obj.id
             )
-            response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token}',)
+            response = self.client.get(
+                url,
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
             self.assertEqual(response.status_code, 200)
             self.assertIn('id', response.data)
 
         with self.subTest('Test listing command'):
             url = self._get_path('device_command_list', self.device_id)
-            response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token}',)
+            response = self.client.get(
+                url,
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
             self.assertEqual(response.status_code, 200)
             self.assertEqual(len(response.data['results']), 2)
 
@@ -217,7 +235,9 @@ def test_endpoints_for_non_existent_device(self):
 
         with self.subTest('Test listing commands'):
             url = self._get_path('device_command_list', device_id)
-            response = self.client.get(url,)
+            response = self.client.get(
+                url,
+            )
             self.assertEqual(response.status_code, 404)
             self.assertDictEqual(response.data, device_not_found)
 
@@ -227,13 +247,18 @@ def test_endpoints_for_non_existent_device(self):
                 'type': 'custom',
                 'input': {'command': 'echo test'},
             }
-            response = self.client.post(url, data=payload,)
+            response = self.client.post(
+                url,
+                data=payload,
+            )
             self.assertEqual(response.status_code, 404)
             self.assertDictEqual(response.data, device_not_found)
 
         with self.subTest('Test retrieving commands'):
             url = self._get_path('device_command_details', device_id, uuid.uuid4())
-            response = self.client.get(url,)
+            response = self.client.get(
+                url,
+            )
             self.assertEqual(response.status_code, 404)
             self.assertDictEqual(response.data, device_not_found)
 
@@ -261,7 +286,9 @@ def test_non_superuser(self):
             self.assertEqual(response.data['count'], 1)
 
 
-class TestConnectionApi(TestAdminMixin, TestCase, CreateConnectionsMixin):
+class TestConnectionApi(
+    TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin
+):
     def setUp(self):
         super().setUp()
         self._login()
@@ -441,6 +468,44 @@ def test_delete_deviceconnection_detail(self):
         dc = self._create_device_connection()
         d1 = dc.device.id
         path = reverse('connection_api:deviceconnection_detail', args=(d1, dc.pk))
-        with self.assertNumQueries(10):
+        with self.assertNumQueries(9):
             response = self.client.delete(path)
         self.assertEqual(response.status_code, 204)
+
+    def test_bearer_authentication(self):
+        self.client.logout()
+        token = self._obtain_auth_token(username='admin', password='tester')
+        credentials = self._create_credentials(auto_add=True)
+        device_conn = self._create_device_connection(credentials=credentials)
+        device = device_conn.device
+
+        with self.subTest('Test CredentialListCreateView'):
+            response = self.client.get(
+                reverse('connection_api:credential_list'),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CredentialDetailView'):
+            response = self.client.get(
+                reverse('connection_api:credential_detail', args=[credentials.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test DeviceConnenctionListCreateView'):
+            response = self.client.get(
+                reverse('connection_api:deviceconnection_list', args=[device.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test DeviceConnectionDetailView'):
+            response = self.client.get(
+                reverse(
+                    'connection_api:deviceconnection_detail',
+                    args=[device.id, device_conn.id],
+                ),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py
index cbd21ef31..d962dc32b 100644
--- a/openwisp_controller/connection/tests/test_models.py
+++ b/openwisp_controller/connection/tests/test_models.py
@@ -334,6 +334,7 @@ def test_is_working_change_signal_emitted(self):
             dc.save()
         handler.assert_called_once_with(
             failure_reason='',
+            old_failure_reason='',
             instance=dc,
             is_working=True,
             old_is_working=None,
@@ -450,7 +451,8 @@ def test_command_validation(self):
             e = context_manager.exception
             self.assertIn('input', e.message_dict)
             self.assertIn(
-                'Additional properties are not allowed', e.message_dict['input'][0],
+                'Additional properties are not allowed',
+                e.message_dict['input'][0],
             )
 
         with self.subTest('JSON check on arguments'):
@@ -758,7 +760,8 @@ def _assert_version_check_command(mocked_exec):
         def _assert_applying_conf_test_command(mocked_exec):
             args, _ = mocked_exec_command.call_args_list[1]
             self.assertEqual(
-                args[0], 'test -f /tmp/openwisp/applying_conf',
+                args[0],
+                'test -f /tmp/openwisp/applying_conf',
             )
 
         conf = self._prepare_conf_object()
diff --git a/openwisp_controller/connection/tests/test_notifications.py b/openwisp_controller/connection/tests/test_notifications.py
index 890271095..533c606c8 100644
--- a/openwisp_controller/connection/tests/test_notifications.py
+++ b/openwisp_controller/connection/tests/test_notifications.py
@@ -1,8 +1,10 @@
 import os
+from unittest.mock import patch
 
 from django.apps.registry import apps
 from django.test import TestCase, TransactionTestCase
 from django.urls import reverse
+from openwisp_notifications.signals import notify
 from openwisp_notifications.types import unregister_notification_type
 from swapper import load_model
 
@@ -142,6 +144,57 @@ def test_default_notification_type_already_unregistered(self):
         app = apps.get_app_config(self.app_label)
         app.register_notification_types()
 
+    @patch(
+        'openwisp_controller.connection.apps.ConnectionConfig'
+        '._ignore_connection_notification_reasons',
+        ['timed out'],
+    )
+    @patch.object(notify, 'send')
+    def test_connection_is_working_changed_timed_out(self, notify_send, *args):
+        credentials = self._create_credentials_with_key(port=self.ssh_server.port)
+        self._create_config(device=self.d)
+        device_conn = self._create_device_connection(
+            credentials=credentials, device=self.d, is_working=True
+        )
+        self.assertEqual(device_conn.is_working, True)
+        device_conn.is_working = False
+        device_conn.failure_reason = 'timed out'
+        device_conn.full_clean()
+        device_conn.save()
+        notify_send.assert_not_called()
+        # Connection recovers, device is reachable again
+        device_conn.is_working = True
+        device_conn.failure_reason = ''
+        device_conn.full_clean()
+        device_conn.save()
+        notify_send.assert_not_called()
+
+    @patch(
+        'openwisp_controller.connection.apps.ConnectionConfig'
+        '._ignore_connection_notification_reasons',
+        ['Unable to connect'],
+    )
+    @patch.object(notify, 'send')
+    def test_connection_is_working_changed_unable_to_connect(self, notify_send, *args):
+        credentials = self._create_credentials_with_key(port=self.ssh_server.port)
+        self._create_config(device=self.d)
+        device_conn = self._create_device_connection(
+            credentials=credentials, device=self.d, is_working=True
+        )
+        device_conn.failure_reason = (
+            '[Errno None] Unable to connect to port 5555 on 127.0.0.1'
+        )
+        device_conn.is_working = False
+        device_conn.full_clean()
+        device_conn.save()
+        notify_send.assert_not_called()
+        # Connection makes recovery.
+        device_conn.failure_reason = ''
+        device_conn.is_working = True
+        device_conn.full_clean()
+        device_conn.save()
+        notify_send.assert_not_called()
+
 
 class TestNotificationTransaction(
     CreateConnectionsMixin, BaseTestNotification, TransactionTestCase
diff --git a/openwisp_controller/connection/tests/test_ssh.py b/openwisp_controller/connection/tests/test_ssh.py
index ed00b8fab..fc28117d2 100644
--- a/openwisp_controller/connection/tests/test_ssh.py
+++ b/openwisp_controller/connection/tests/test_ssh.py
@@ -47,11 +47,9 @@ def test_connection_failed_command(self, mocked_debug, mocked_info):
         dc = self._create_device_connection(credentials=ckey)
         dc.connector_instance.connect()
         with self.assertRaises(Exception):
-            with mock.patch('logging.Logger.error') as mocked_logger:
-                dc.connector_instance.exec_command('wrongcommand')
-        mocked_logger.assert_has_calls(
+            dc.connector_instance.exec_command('wrongcommand')
+        mocked_info.assert_has_calls(
             [
-                mock.call('/bin/sh: 1: wrongcommand: not found\n'),
                 mock.call('Unexpected exit code: 127'),
             ]
         )
@@ -65,12 +63,11 @@ def test_connection_failed_command_suppressed_output(
         dc = self._create_device_connection(credentials=ckey)
         dc.connector_instance.connect()
         with self.assertRaises(Exception) as ctx:
-            with mock.patch('logging.Logger.error') as mocked_logger:
-                dc.connector_instance.exec_command(
-                    'rm /thisfilesurelydoesnotexist 2> /dev/null'
-                )
+            dc.connector_instance.exec_command(
+                'rm /thisfilesurelydoesnotexist 2> /dev/null'
+            )
         log_message = 'Unexpected exit code: 1'
-        mocked_logger.assert_has_calls([mock.call(log_message)])
+        mocked_info.assert_has_calls([mock.call(log_message)])
         self.assertEqual(str(ctx.exception), log_message)
 
     @mock.patch('scp.SCPClient.putfo')
diff --git a/openwisp_controller/geo/admin.py b/openwisp_controller/geo/admin.py
index 08343bc02..2b47fa9f8 100644
--- a/openwisp_controller/geo/admin.py
+++ b/openwisp_controller/geo/admin.py
@@ -1,5 +1,6 @@
+import reversion
 from django.contrib import admin
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 from django_loci.base.admin import (
     AbstractFloorPlanAdmin,
     AbstractFloorPlanForm,
@@ -100,3 +101,5 @@ def queryset(self, request, queryset):
 # Prepend DeviceLocationInline to config.DeviceAdmin
 DeviceAdmin.inlines.insert(1, DeviceLocationInline)
 DeviceAdmin.list_filter.append(DeviceLocationFilter)
+reversion.register(model=DeviceLocation, follow=['device'])
+DeviceAdmin.add_reversion_following(follow=['devicelocation'])
diff --git a/openwisp_controller/geo/api/serializers.py b/openwisp_controller/geo/api/serializers.py
index 81dad294b..dcabe4690 100644
--- a/openwisp_controller/geo/api/serializers.py
+++ b/openwisp_controller/geo/api/serializers.py
@@ -1,21 +1,20 @@
+from django.contrib.humanize.templatetags.humanize import ordinal
+from django.core.exceptions import ValidationError
+from django.db import transaction
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
 from rest_framework.serializers import IntegerField, SerializerMethodField
 from rest_framework_gis import serializers as gis_serializers
 from swapper import load_model
 
+from openwisp_users.api.mixins import FilterSerializerByOrgManaged
 from openwisp_utils.api.serializers import ValidatedModelSerializer
 
 Device = load_model('config', 'Device')
 Location = load_model('geo', 'Location')
 DeviceLocation = load_model('geo', 'DeviceLocation')
-
-
-class LocationSerializer(gis_serializers.GeoFeatureModelSerializer):
-    class Meta:
-        model = Location
-        geo_field = 'geometry'
-        fields = ('name', 'geometry')
-        read_only_fields = ('name',)
+FloorPlan = load_model('geo', 'FloorPlan')
 
 
 class LocationDeviceSerializer(ValidatedModelSerializer):
@@ -38,3 +37,317 @@ class Meta:
         model = Location
         geo_field = 'geometry'
         fields = '__all__'
+
+
+class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer):
+    pass
+
+
+class BaseFloorPlanSerializer(BaseSerializer):
+    name = serializers.SerializerMethodField()
+
+    class Meta:
+        model = FloorPlan
+        fields = [
+            'id',
+            'name',
+            'floor',
+            'image',
+            'created',
+            'modified',
+        ]
+        read_only_fields = [
+            'id',
+            'created',
+            'modified',
+        ]
+
+    def get_name(self, obj):
+        name = '{0} {1} Floor'.format(obj.location.name, ordinal(obj.floor))
+        return name
+
+
+class FloorPlanSerializer(BaseFloorPlanSerializer, ValidatedModelSerializer):
+    class Meta(BaseFloorPlanSerializer.Meta):
+        fields = BaseFloorPlanSerializer.Meta.fields + [
+            'location',
+            'organization',
+        ]
+        extra_kwargs = {'organization': {'required': False}}
+
+    def validate(self, data):
+        if data.get('location'):
+            data['organization'] = data.get('location').organization
+        return super().validate(data)
+
+
+class NestedFloorplanSerializer(BaseFloorPlanSerializer):
+    class Meta(BaseFloorPlanSerializer.Meta):
+        pass
+
+    def validate(self, data):
+        # This method has been overridden because this
+        # serializer does not handle all fields of FloorPlan
+        # model and ValidatedModelSerializer.validate complains
+        # for non-handled fields.
+        return data
+
+    def to_internal_value(self, data):
+        if isinstance(data, str):
+            try:
+                self.instance = FloorPlan.objects.get(id=data)
+                return self.instance
+            except (ValidationError, FloorPlan.DoesNotExist):
+                raise serializers.ValidationError(
+                    detail={
+                        'floorplan': _(
+                            'FloorPlan object with entered ID does not exists.'
+                        )
+                    }
+                )
+        return super().to_internal_value(data)
+
+    def get_attribute(self, instance):
+        return instance.floorplan
+
+
+class FloorPlanLocationSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = FloorPlan
+        fields = (
+            'floor',
+            'image',
+        )
+        extra_kwargs = {'floor': {'required': False}, 'image': {'required': False}}
+
+
+class DeviceCoordinatesSerializer(gis_serializers.GeoFeatureModelSerializer):
+    class Meta:
+        model = Location
+        geo_field = 'geometry'
+        fields = ('name', 'geometry')
+        read_only_fields = ('name',)
+
+
+class LocationSerializer(FilterSerializerByOrgManaged, serializers.ModelSerializer):
+    floorplan = FloorPlanLocationSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = Location
+        fields = (
+            'id',
+            'organization',
+            'name',
+            'type',
+            'is_mobile',
+            'address',
+            'geometry',
+            'created',
+            'modified',
+            'floorplan',
+        )
+        read_only_fields = ('id', 'created', 'modified')
+
+    def validate(self, data):
+        if data.get('type') == 'outdoor' and data.get('floorplan'):
+            raise serializers.ValidationError(
+                {
+                    'type': _(
+                        "Floorplan can only be added with location of "
+                        "the type indoor"
+                    )
+                }
+            )
+        return data
+
+    def to_representation(self, instance):
+        request = self.context['request']
+        data = super().to_representation(instance)
+        floorplans = instance.floorplan_set.all().order_by('-modified')
+        floorplan_list = []
+        for floorplan in floorplans:
+            dict_ = {
+                'floor': floorplan.floor,
+                'image': request.build_absolute_uri(floorplan.image.url),
+            }
+            floorplan_list.append(dict_)
+        data['floorplan'] = floorplan_list
+        return data
+
+    def create(self, validated_data):
+        floorplan_data = None
+
+        if validated_data.get('floorplan'):
+            floorplan_data = validated_data.pop('floorplan')
+
+        instance = self.instance or self.Meta.model(**validated_data)
+        with transaction.atomic():
+            instance.full_clean()
+            instance.save()
+
+        if floorplan_data:
+            floorplan_data['location'] = instance
+            floorplan_data['organization'] = instance.organization
+            with transaction.atomic():
+                fl = FloorPlan.objects.create(**floorplan_data)
+                fl.full_clean()
+                fl.save()
+
+        return instance
+
+    def update(self, instance, validated_data):
+        floorplan_data = None
+        if validated_data.get('floorplan'):
+            floorplan_data = validated_data.pop('floorplan')
+
+        if floorplan_data:
+            floorplan_obj = instance.floorplan_set.order_by('-created').first()
+            if floorplan_obj:
+                # Update the first floorplan object
+                floorplan_obj.floor = floorplan_data.get('floor', floorplan_obj.floor)
+                floorplan_obj.image = floorplan_data.get('image', floorplan_obj.image)
+                with transaction.atomic():
+                    floorplan_obj.full_clean()
+                    floorplan_obj.save()
+            else:
+                if validated_data.get('type') == 'indoor':
+                    instance.type = 'indoor'
+                    instance.save()
+                floorplan_data['location'] = instance
+                floorplan_data['organization'] = instance.organization
+                fl = FloorPlan.objects.create(**floorplan_data)
+                with transaction.atomic():
+                    fl.full_clean()
+                    fl.save()
+
+        if instance.type == 'indoor' and validated_data.get('type') == 'outdoor':
+            floorplans = instance.floorplan_set.all()
+            for floorplan in floorplans:
+                floorplan.delete()
+
+        return super().update(instance, validated_data)
+
+
+class NestedtLocationSerializer(gis_serializers.GeoFeatureModelSerializer):
+    class Meta:
+        model = Location
+        geo_field = 'geometry'
+        fields = (
+            'id',
+            'type',
+            'is_mobile',
+            'name',
+            'address',
+            'geometry',
+        )
+        read_only_fields = ('id',)
+
+    def get_value(self, dictionary):
+        if isinstance(dictionary.get('location'), str):
+            return dictionary.get(self.field_name)
+        return super().get_value(dictionary)
+
+    def to_internal_value(self, data):
+        if isinstance(data, str):
+            try:
+                return Location.objects.get(id=data)
+            except (ValidationError, Location.DoesNotExist):
+                raise serializers.ValidationError(
+                    detail={
+                        'location': _(
+                            'Location object with entered ID does not exists.'
+                        )
+                    }
+                )
+        return super().to_internal_value(data)
+
+    def get_attribute(self, instance):
+        return instance.location
+
+
+class DeviceLocationSerializer(serializers.ModelSerializer):
+    location = NestedtLocationSerializer()
+    floorplan = NestedFloorplanSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = DeviceLocation
+        fields = (
+            'location',
+            'floorplan',
+            'indoor',
+        )
+
+    @property
+    def device_organization_id(self):
+        return (
+            Device.objects.only('organization_id')
+            .get(id=self.context.get('device_id'))
+            .organization_id
+        )
+
+    def get_or_create_location_object(self, validated_data, location_instance=None):
+        location_data = validated_data.pop('location', None)
+        if not location_data:
+            return
+        if isinstance(location_data, dict):
+            if 'organization' not in location_data:
+                location_data['organization'] = self.device_organization_id
+            location_serializer = LocationSerializer(
+                data=location_data, instance=location_instance
+            )
+            location_serializer.is_valid(raise_exception=True)
+            return location_serializer.save()
+        return location_data
+
+    def get_or_create_floorplan_object(self, validated_data, floorplan_instance=None):
+        floorplan_data = validated_data.pop('floorplan', None)
+        if not floorplan_data:
+            return
+        if isinstance(floorplan_data, dict):
+            if 'organization' not in floorplan_data:
+                floorplan_data['organization'] = self.device_organization_id
+            if 'location' not in floorplan_data:
+                floorplan_data['location'] = getattr(
+                    validated_data['location'], 'id', validated_data['location']
+                )
+            floorplan_serializer = FloorPlanSerializer(
+                data=floorplan_data, instance=floorplan_instance
+            )
+            try:
+                floorplan_serializer.is_valid(raise_exception=True)
+            except serializers.ValidationError as error:
+                raise serializers.ValidationError(detail={'floorplan': error.detail})
+            else:
+                return floorplan_serializer.save()
+        return floorplan_data
+
+    def _validate(self, data):
+        instance = self.instance or self.Meta.model(**data)
+        try:
+            instance.full_clean()
+        except ValidationError as error:
+            raise serializers.ValidationError(detail=error.error_dict)
+        return data
+
+    def create(self, validated_data):
+        validated_data['location'] = self.get_or_create_location_object(validated_data)
+        validated_data['floorplan'] = self.get_or_create_floorplan_object(
+            validated_data
+        )
+        validated_data.update(
+            {
+                'content_object_id': self.context.get('device_id'),
+            }
+        )
+        validated_data = self._validate(validated_data)
+        return super().create(validated_data)
+
+    def update(self, instance, validated_data):
+        validated_data['location'] = self.get_or_create_location_object(
+            validated_data, instance.location
+        )
+        validated_data['floorplan'] = self.get_or_create_floorplan_object(
+            validated_data, instance.floorplan
+        )
+        validated_data = self._validate(validated_data)
+        return super().update(instance, validated_data)
diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py
index 74d184097..cb1de1bbf 100644
--- a/openwisp_controller/geo/api/views.py
+++ b/openwisp_controller/geo/api/views.py
@@ -1,14 +1,22 @@
-from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db.models import Count
+from django.http import Http404
 from django_filters import rest_framework as filters
-from rest_framework import generics, pagination
+from rest_framework import generics, pagination, status
+from rest_framework.exceptions import NotFound
 from rest_framework.permissions import BasePermission
+from rest_framework.request import clone_request
+from rest_framework.response import Response
 from rest_framework_gis.pagination import GeoJsonPagination
 from swapper import load_model
 
 from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged
 
+from ...mixins import ProtectedAPIMixin
 from .serializers import (
+    DeviceCoordinatesSerializer,
+    DeviceLocationSerializer,
+    FloorPlanSerializer,
     GeoJsonLocationSerializer,
     LocationDeviceSerializer,
     LocationSerializer,
@@ -17,11 +25,34 @@
 Device = load_model('config', 'Device')
 Location = load_model('geo', 'Location')
 DeviceLocation = load_model('geo', 'DeviceLocation')
+FloorPlan = load_model('geo', 'FloorPlan')
 
 
 class DevicePermission(BasePermission):
     def has_object_permission(self, request, view, obj):
-        return request.query_params.get('key') == obj.key
+        # checks for presence of key attribute first
+        # because in the browsable UI this method is
+        # getting passed also Location instances,
+        # which do not have the key attribute
+        return hasattr(obj, 'key') and request.query_params.get('key') == obj.key
+
+
+class BaseOrganizationSlugFilter(filters.FilterSet):
+    organization_slug = filters.CharFilter(field_name='organization__slug')
+
+    class Meta:
+        fields = ['organization_slug']
+
+
+class LocationOrganizationSlugFilter(BaseOrganizationSlugFilter):
+    class Meta(BaseOrganizationSlugFilter.Meta):
+        model = Location
+        fields = BaseOrganizationSlugFilter.Meta.fields + ['is_mobile', 'type']
+
+
+class FloorPlanOrganizationSlugFilter(BaseOrganizationSlugFilter):
+    class Meta(BaseOrganizationSlugFilter.Meta):
+        model = FloorPlan
 
 
 class ListViewPagination(pagination.PageNumberPagination):
@@ -30,13 +61,18 @@ class ListViewPagination(pagination.PageNumberPagination):
     max_page_size = 100
 
 
-class DeviceLocationView(generics.RetrieveUpdateAPIView):
-    serializer_class = LocationSerializer
+class DeviceCoordinatesView(ProtectedAPIMixin, generics.RetrieveUpdateAPIView):
+    serializer_class = DeviceCoordinatesSerializer
     permission_classes = (DevicePermission,)
     queryset = Device.objects.select_related(
         'devicelocation', 'devicelocation__location'
     )
 
+    def get_queryset(self):
+        # It is required to override ProtectedAPIMixin.get_queryset
+        # which filters the queryset for organizations managed.
+        return self.queryset
+
     def get_location(self, device):
         try:
             return device.devicelocation.location
@@ -48,8 +84,9 @@ def get_object(self, *args, **kwargs):
         location = self.get_location(device)
         if location:
             return location
-        # if no location present, automatically create it
-        return self.create_location(device)
+        if self.request.method == 'PUT':
+            return self.create_location(device)
+        raise NotFound
 
     def create_location(self, device):
         location = Location(
@@ -63,32 +100,92 @@ def create_location(self, device):
         dl = DeviceLocation(content_object=device, location=location)
         dl.full_clean()
         dl.save()
+        self.get_serializer_context()
+
         return location
 
 
-class GeoJsonLocationListPagination(GeoJsonPagination):
-    page_size = 1000
+class DeviceLocationView(
+    ProtectedAPIMixin,
+    generics.RetrieveUpdateDestroyAPIView,
+):
+    serializer_class = DeviceLocationSerializer
+    queryset = DeviceLocation.objects.select_related(
+        'content_object', 'location', 'floorplan', 'content_object__organization'
+    )
+    lookup_field = 'content_object'
+    lookup_url_kwarg = 'pk'
+    organization_field = 'content_object__organization'
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        try:
+            return qs.filter(content_object=self.kwargs['pk'])
+        except ValidationError:
+            return qs.none()
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        context.update({'device_id': self.kwargs['pk']})
+        return context
+
+    def update(self, request, *args, **kwargs):
+        partial = kwargs.pop('partial', False)
+        instance = self.get_object_or_none()
+        serializer = self.get_serializer(instance, data=request.data, partial=partial)
+        serializer.is_valid(raise_exception=True)
+
+        if instance is None:
+            self.perform_create(serializer)
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_create(self, serializer):
+        serializer.save()
+
+    def perform_update(self, serializer):
+        serializer.save()
+
+    def get_object_or_none(self):
+        try:
+            return self.get_object()
+        except Http404:
+            if self.request.method == 'PUT':
+                # For PUT-as-create operation, we need to ensure that we have
+                # relevant permissions, as if this was a POST request. This
+                # will either raise a PermissionDenied exception, or simply
+                # return None.
+                self.check_permissions(clone_request(self.request, 'POST'))
+            else:
+                # PATCH requests where the object does not exist should still
+                # return a 404 response.
+                raise
 
 
-class GeoJsonLocationFilter(filters.FilterSet):
-    organization_slug = filters.CharFilter(field_name='organization__slug')
+class GeoJsonLocationListPagination(GeoJsonPagination):
+    page_size = 1000
 
-    class Meta:
-        model = Location
-        fields = ['organization_slug']
 
+class GeoJsonLocationList(
+    ProtectedAPIMixin, FilterByOrganizationManaged, generics.ListAPIView
+):
+    """
+    Shows only locations which are assigned to devices.
+    """
 
-class GeoJsonLocationList(FilterByOrganizationManaged, generics.ListAPIView):
     queryset = Location.objects.filter(devicelocation__isnull=False).annotate(
         device_count=Count('devicelocation')
     )
     serializer_class = GeoJsonLocationSerializer
     pagination_class = GeoJsonLocationListPagination
     filter_backends = [filters.DjangoFilterBackend]
-    filterset_class = GeoJsonLocationFilter
+    filterset_class = LocationOrganizationSlugFilter
 
 
-class LocationDeviceList(FilterByParentManaged, generics.ListAPIView):
+class LocationDeviceList(
+    FilterByParentManaged, ProtectedAPIMixin, generics.ListAPIView
+):
     serializer_class = LocationDeviceSerializer
     pagination_class = ListViewPagination
     queryset = Device.objects.none()
@@ -103,6 +200,43 @@ def get_queryset(self):
         return qs
 
 
+class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
+    serializer_class = FloorPlanSerializer
+    queryset = FloorPlan.objects.select_related().order_by('-created')
+    pagination_class = ListViewPagination
+    filter_backends = [filters.DjangoFilterBackend]
+    filter_class = FloorPlanOrganizationSlugFilter
+
+
+class FloorPlanDetailView(
+    ProtectedAPIMixin,
+    generics.RetrieveUpdateDestroyAPIView,
+):
+    serializer_class = FloorPlanSerializer
+    queryset = FloorPlan.objects.select_related()
+
+
+class LocationListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
+    serializer_class = LocationSerializer
+    queryset = Location.objects.order_by('-created')
+    pagination_class = ListViewPagination
+    filter_backends = [filters.DjangoFilterBackend]
+    filterset_class = LocationOrganizationSlugFilter
+
+
+class LocationDetailView(
+    ProtectedAPIMixin,
+    generics.RetrieveUpdateDestroyAPIView,
+):
+    serializer_class = LocationSerializer
+    queryset = Location.objects.all()
+
+
+device_coordinates = DeviceCoordinatesView.as_view()
 device_location = DeviceLocationView.as_view()
 geojson = GeoJsonLocationList.as_view()
 location_device_list = LocationDeviceList.as_view()
+list_floorplan = FloorPlanListCreateView.as_view()
+detail_floorplan = FloorPlanDetailView.as_view()
+list_location = LocationListCreateView.as_view()
+detail_location = LocationDetailView.as_view()
diff --git a/openwisp_controller/geo/apps.py b/openwisp_controller/geo/apps.py
index 7f91a0074..32bc939e4 100644
--- a/openwisp_controller/geo/apps.py
+++ b/openwisp_controller/geo/apps.py
@@ -1,7 +1,7 @@
 import swapper
 from django.conf import settings
 from django.db.models import Case, Count, Sum, When
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 from django_loci.apps import LociConfig
 from swapper import get_model_name
 
@@ -58,10 +58,20 @@ def register_dashboard_charts(self):
                     'model': 'device',
                     'annotate': {
                         'with_geo': Count(
-                            Case(When(devicelocation__isnull=False, then=1,))
+                            Case(
+                                When(
+                                    devicelocation__isnull=False,
+                                    then=1,
+                                )
+                            )
                         ),
                         'without_geo': Count(
-                            Case(When(devicelocation__isnull=True, then=1,))
+                            Case(
+                                When(
+                                    devicelocation__isnull=True,
+                                    then=1,
+                                )
+                            )
                         ),
                     },
                     'aggregate': {
diff --git a/openwisp_controller/geo/channels/routing.py b/openwisp_controller/geo/channels/routing.py
index 000feb557..13b66a1d3 100644
--- a/openwisp_controller/geo/channels/routing.py
+++ b/openwisp_controller/geo/channels/routing.py
@@ -1,7 +1,7 @@
 from channels.auth import AuthMiddlewareStack
 from channels.routing import ProtocolTypeRouter, URLRouter
 from channels.security.websocket import AllowedHostsOriginValidator
-from django.conf.urls import url
+from django.urls import path
 from django_loci.channels.base import location_broadcast_path
 from openwisp_notifications.websockets.routing import (
     get_routes as get_notification_routes,
@@ -11,7 +11,11 @@
 
 
 def get_routes():
-    return [url(location_broadcast_path, LocationBroadcast, name='LocationChannel')]
+    return [
+        path(
+            location_broadcast_path, LocationBroadcast.as_asgi(), name='LocationChannel'
+        )
+    ]
 
 
 # Kept for backward compatibility
diff --git a/openwisp_controller/geo/migrations/0001_initial.py b/openwisp_controller/geo/migrations/0001_initial.py
index 3e1cb0d22..562c178b2 100644
--- a/openwisp_controller/geo/migrations/0001_initial.py
+++ b/openwisp_controller/geo/migrations/0001_initial.py
@@ -8,6 +8,7 @@
 import django_loci.storage
 import model_utils.fields
 import swapper
+from django.conf import settings
 from django.db import migrations, models
 
 import openwisp_users.mixins
@@ -18,7 +19,9 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
-        ('openwisp_users', '0001_initial'),
+        swapper.dependency(
+            *swapper.split(settings.AUTH_USER_MODEL), version='0004_default_groups'
+        ),
         swapper.dependency('config', 'Device'),
     ]
 
@@ -226,7 +229,7 @@ class Migration(migrations.Migration):
             name='organization',
             field=models.ForeignKey(
                 on_delete=django.db.models.deletion.CASCADE,
-                to='openwisp_users.Organization',
+                to=swapper.get_model_name('openwisp_users', 'Organization'),
                 verbose_name='organization',
             ),
         ),
diff --git a/openwisp_controller/geo/migrations/0002_default_groups_permissions.py b/openwisp_controller/geo/migrations/0002_default_groups_permissions.py
index a8179a900..576bfd9bf 100644
--- a/openwisp_controller/geo/migrations/0002_default_groups_permissions.py
+++ b/openwisp_controller/geo/migrations/0002_default_groups_permissions.py
@@ -4,7 +4,9 @@
 
 
 class Migration(migrations.Migration):
-    dependencies = [('openwisp_users', '0004_default_groups'), ('geo', '0001_initial')]
+    dependencies = [
+        ('geo', '0001_initial'),
+    ]
     operations = [
         migrations.RunPython(
             assign_permissions_to_groups, reverse_code=migrations.RunPython.noop
diff --git a/openwisp_controller/geo/tests/pytest.py b/openwisp_controller/geo/tests/pytest.py
index 576f51867..01114aaef 100644
--- a/openwisp_controller/geo/tests/pytest.py
+++ b/openwisp_controller/geo/tests/pytest.py
@@ -13,8 +13,6 @@
 from django.utils.module_loading import import_string
 from swapper import load_model
 
-from openwisp_controller.geo.channels.consumers import LocationBroadcast
-
 from .utils import TestGeoMixin
 
 Device = load_model('config', 'Device')
@@ -26,6 +24,7 @@
 
 @skipIf(os.environ.get('SAMPLE_APP', False), 'Running tests on SAMPLE_APP')
 class TestChannels(TestGeoMixin):
+    application = import_string(getattr(settings, 'ASGI_APPLICATION'))
     object_model = Device
     location_model = Location
     object_location_model = DeviceLocation
@@ -55,7 +54,7 @@ async def _get_request_dict(self, pk=None, user=None):
         return {'pk': pk, 'path': path, 'session': session}
 
     def _get_communicator(self, request_vars, user=None):
-        communicator = WebsocketCommunicator(LocationBroadcast, request_vars['path'])
+        communicator = WebsocketCommunicator(self.application, request_vars['path'])
         if user:
             communicator.scope.update(
                 {
@@ -107,6 +106,5 @@ async def test_consumer_staff_but_no_change_permission(self):
         assert connected
         await communicator.disconnect()
 
-    def test_routing(self):
-        application = import_string(getattr(settings, 'ASGI_APPLICATION'))
-        assert isinstance(application, ProtocolTypeRouter)
+    def test_asgi_application_router(self):
+        assert isinstance(self.application, ProtocolTypeRouter)
diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py
index 8c70383e7..22439690b 100644
--- a/openwisp_controller/geo/tests/test_admin.py
+++ b/openwisp_controller/geo/tests/test_admin.py
@@ -21,18 +21,8 @@ class TestAdmin(TestAdminMixin, TestGeoMixin, BaseTestAdmin, TestCase):
     object_location_model = DeviceLocation
     user_model = get_user_model()
 
-    operator_permission_filters = [
-        {'codename__endswith': 'config'},
-        {'codename__endswith': 'device'},
-        {'codename__endswith': 'template'},
-        {'codename__endswith': 'vpn'},
-        {'codename__endswith': 'location'},
-        {'codename__endswith': 'floorplan'},
-        {'codename__endswith': 'devicelocation'},
-    ]
-
     def setUp(self):
-        """ override TestAdminMixin.setUp """
+        """override TestAdminMixin.setUp"""
         pass
 
     def _create_multitenancy_test_env(self, vpn=False):
diff --git a/openwisp_controller/geo/tests/test_admin_inline.py b/openwisp_controller/geo/tests/test_admin_inline.py
index 549535d9f..1531fa7fb 100644
--- a/openwisp_controller/geo/tests/test_admin_inline.py
+++ b/openwisp_controller/geo/tests/test_admin_inline.py
@@ -75,3 +75,6 @@ def test_add_mobile(self):
             loc.objectlocation_set.first().content_object.name, params['name']
         )
         self.assertEqual(loc.name, params['name'])
+
+
+del TestConfigAdmin
diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py
index c02bb5f1a..361e3277f 100644
--- a/openwisp_controller/geo/tests/test_api.py
+++ b/openwisp_controller/geo/tests/test_api.py
@@ -1,24 +1,34 @@
 import json
+import tempfile
+import uuid
 
+from django.contrib.auth import get_user_model
 from django.contrib.gis.geos import Point
 from django.test import TestCase
+from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
 from django.urls import reverse
+from PIL import Image
+from rest_framework.authtoken.models import Token
 from swapper import load_model
 
 from openwisp_controller.config.tests.utils import CreateConfigTemplateMixin
+from openwisp_controller.tests.utils import TestAdminMixin
 from openwisp_users.tests.utils import TestOrganizationMixin
-from openwisp_utils.tests import capture_any_output
+from openwisp_utils.tests import AssertNumQueriesSubTestMixin, capture_any_output
 
 from .utils import TestGeoMixin
 
 Device = load_model('config', 'Device')
 Location = load_model('geo', 'Location')
+FloorPlan = load_model('geo', 'FloorPlan')
 DeviceLocation = load_model('geo', 'DeviceLocation')
 OrganizationUser = load_model('openwisp_users', 'OrganizationUser')
+Group = load_model('openwisp_users', 'Group')
+User = get_user_model()
 
 
 class TestApi(TestGeoMixin, TestCase):
-    url_name = 'geo_api:device_location'
+    url_name = 'geo_api:device_coordinates'
     object_location_model = DeviceLocation
     location_model = Location
     object_model = Device
@@ -29,6 +39,11 @@ def test_permission_404(self):
         self.assertEqual(r.status_code, 404)
 
     def test_permission_403(self):
+        user = User.objects.create(
+            username='tester',
+            password='tester',
+        )
+        self.client.force_login(user)
         dl = self._create_object_location()
         url = reverse(self.url_name, args=[dl.device.pk])
         r = self.client.get(url)
@@ -57,17 +72,34 @@ def test_get_existing_location(self):
         )
         self.assertEqual(self.location_model.objects.count(), 1)
 
+    def test_get_existing_location_html(self):
+        """
+        Regression test for browsable web UI bug
+        """
+        dl = self._create_object_location()
+        url = reverse(self.url_name, args=[dl.device.pk])
+        r = self.client.get(url, {'key': dl.device.key}, HTTP_ACCEPT='text/html')
+        self.assertEqual(r.status_code, 200)
+
     def test_get_create_location(self):
         self.assertEqual(self.location_model.objects.count(), 0)
         device = self._create_object()
         url = reverse(self.url_name, args=[device.pk])
         r = self.client.get(url, {'key': device.key})
+        self.assertEqual(r.status_code, 404)
+
+    def test_put_create_location(self):
+        device = self._create_object()
+        self.assertEqual(self.location_model.objects.count(), 0)
+        url = reverse(self.url_name, args=[device.pk])
+        r = self.client.put(f'{url}?key={device.key }')
         self.assertEqual(r.status_code, 200)
         self.assertDictEqual(
             r.json(),
             {'type': 'Feature', 'geometry': None, 'properties': {'name': device.name}},
         )
         self.assertEqual(self.location_model.objects.count(), 1)
+        self.assertEqual(r.status_code, 200)
 
     def test_put_update_coordinates(self):
         self.assertEqual(self.location_model.objects.count(), 0)
@@ -89,6 +121,40 @@ def test_put_update_coordinates(self):
         )
         self.assertEqual(self.location_model.objects.count(), 1)
 
+    @capture_any_output()
+    def test_bearer_authentication(self):
+        user = User.objects.create(
+            username='admin', password='password', is_staff=True, is_superuser=True
+        )
+        token = Token.objects.create(user=user).key
+        device = self._create_object_location().device
+
+        with self.subTest('Test DeviceLocationView'):
+            response = self.client.get(
+                reverse(self.url_name, args=[device.pk]),
+                data={'key': device.key},
+                content_type='application/json',
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test GeoJsonLocationListView'):
+            response = self.client.get(
+                reverse('geo_api:location_geojson'),
+                content_type='application/json',
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test LocationDeviceList'):
+            location = self._create_location(organization=device.organization)
+            response = self.client.get(
+                reverse('geo_api:location_device_list', args=[location.id]),
+                content_type='application/json',
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
 
 class TestMultitenantApi(
     TestOrganizationMixin, TestGeoMixin, TestCase, CreateConfigTemplateMixin
@@ -102,10 +168,11 @@ def setUp(self):
         # create 2 orgs
         self._create_org(name='org_b', slug='org_b')
         org_a = self._create_org(name='org_a', slug='org_a')
+        user = self._create_operator()
+        admin_group = Group.objects.get(name='Administrator')
+        admin_group.user_set.add(user)
         # create an operator for org_a
-        ou = OrganizationUser.objects.create(
-            user=self._create_operator(), organization=org_a
-        )
+        ou = OrganizationUser.objects.create(user=user, organization=org_a)
         ou.is_admin = True
         ou.save()
         # create a superuser
@@ -147,7 +214,7 @@ def test_location_device_list(self):
         with self.subTest('Test location device list for unauthenticated user'):
             self.client.logout()
             r = self.client.get(reverse(url, args=[location_a.id]))
-            self.assertEqual(r.status_code, 403)
+            self.assertEqual(r.status_code, 401)
 
     @capture_any_output()
     def test_geojson_list(self):
@@ -188,4 +255,647 @@ def test_geojson_list(self):
         with self.subTest('Test geojson list unauthenticated user'):
             self.client.logout()
             r = self.client.get(reverse(url))
-            self.assertEqual(r.status_code, 403)
+            self.assertEqual(r.status_code, 401)
+
+
+class TestGeoApi(
+    AssertNumQueriesSubTestMixin,
+    TestOrganizationMixin,
+    TestGeoMixin,
+    TestAdminMixin,
+    TestCase,
+):
+    object_model = Device
+    location_model = Location
+    floorplan_model = FloorPlan
+    object_location_model = DeviceLocation
+
+    def setUp(self):
+        admin = self._create_admin()
+        self.client.force_login(admin)
+
+    def _create_device_location(self, **kwargs):
+        options = dict()
+        options.update(kwargs)
+        device_location = self.object_location_model(**options)
+        device_location.full_clean()
+        device_location.save()
+        return device_location
+
+    def test_get_floorplan_list(self):
+        path = reverse('geo_api:list_floorplan')
+        with self.assertNumQueries(3):
+            response = self.client.get(path)
+        self.assertEqual(response.status_code, 200)
+
+    def test_filter_floorplan_list(self):
+        org1 = self._create_org(name='org1')
+        org2 = self._create_org(name='org2')
+        org1_floorplan = self._create_floorplan(
+            location=self._create_location(organization=org1, type='indoor')
+        )
+        org2_floorplan = self._create_floorplan(
+            location=self._create_location(organization=org2, type='indoor')
+        )
+        path = reverse('geo_api:list_floorplan')
+
+        with self.subTest('Test without organization filtering'):
+            with self.assertNumQueries(4):
+                response = self.client.get(path)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 2)
+            self.assertContains(response, org1_floorplan.id)
+            self.assertContains(response, org2_floorplan.id)
+
+        with self.subTest('Test filtering with organization slug'):
+            with self.assertNumQueries(4):
+                response = self.client.get(path, {'organization_slug': org1.slug})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 1)
+            self.assertContains(response, org1_floorplan.id)
+            self.assertNotContains(response, org2_floorplan.id)
+
+        with self.subTest('Test multi-tenancy filtering'):
+            self.client.logout()
+            user = self._create_administrator([org1])
+            self.client.force_login(user)
+            with self.assertNumQueries(6):
+                response = self.client.get(path)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 1)
+            self.assertContains(response, org1_floorplan.id)
+            self.assertNotContains(response, org2_floorplan.id)
+
+    def test_post_floorplan_list(self):
+        location = self._create_location(type='indoor')
+        path = reverse('geo_api:list_floorplan')
+        data = {
+            'floor': 1,
+            'image': self._get_simpleuploadedfile(),
+            'location': location.pk,
+        }
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+        with self.assertNumQueries(10):
+            response = self.client.post(path, data, format='multipart')
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(self.floorplan_model.objects.count(), 1)
+        self.assertEqual(response.data['organization'], location.organization_id)
+        self.assertEqual(response.data['location'], location.id)
+
+    def test_get_floorplan_detail(self):
+        f1 = self._create_floorplan()
+        path = reverse('geo_api:detail_floorplan', args=[f1.pk])
+        with self.assertNumQueries(3):
+            response = self.client.get(path)
+        self.assertEqual(response.status_code, 200)
+
+    def test_put_floorplan_detail(self):
+        f1 = self._create_floorplan()
+        l1 = self._create_location()
+        path = reverse('geo_api:detail_floorplan', args=[f1.pk])
+        temporary_image = tempfile.NamedTemporaryFile(suffix='.jpg')
+        image = Image.new('RGB', (100, 100))
+        image.save(temporary_image.name)
+        data = {'floor': 12, 'image': temporary_image, 'location': l1.pk}
+        with self.assertNumQueries(10):
+            response = self.client.put(
+                path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+            )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data['floor'], 12)
+        self.assertEqual(response.data['location'], l1.pk)
+
+    def test_patch_floorplan_detail(self):
+        f1 = self._create_floorplan()
+        self.assertEqual(f1.floor, 1)
+        path = reverse('geo_api:detail_floorplan', args=[f1.pk])
+        data = {'floor': 12}
+        with self.assertNumQueries(8):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data['floor'], 12)
+
+    def test_delete_floorplan_detail(self):
+        f1 = self._create_floorplan()
+        path = reverse('geo_api:detail_floorplan', args=[f1.pk])
+        with self.assertNumQueries(5):
+            response = self.client.delete(path)
+        self.assertEqual(response.status_code, 204)
+
+    def test_get_location_list(self):
+        path = reverse('geo_api:list_location')
+        with self.assertNumQueries(3):
+            response = self.client.get(path)
+        self.assertEqual(response.status_code, 200)
+
+    def test_filter_location_list(self):
+        org1 = self._create_org(name='org1')
+        org2 = self._create_org(name='org2')
+        org1_location = self._create_location(
+            name='org1-location', organization=org1, type='indoor', is_mobile=True
+        )
+        org2_location = self._create_location(name='org2-location', organization=org2)
+        path = reverse('geo_api:list_location')
+
+        with self.subTest('Test without organization filtering'):
+            with self.assertNumQueries(6):
+                response = self.client.get(path)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 2)
+            self.assertContains(response, org1_location.id)
+            self.assertContains(response, org2_location.id)
+
+        with self.subTest('Test filtering with organization slug'):
+            with self.assertNumQueries(5):
+                response = self.client.get(path, {'organization_slug': org1.slug})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 1)
+            self.assertContains(response, org1_location.id)
+            self.assertNotContains(response, org2_location.id)
+
+        with self.subTest('Test filtering with location type'):
+            with self.assertNumQueries(5):
+                response = self.client.get(path, {'type': 'indoor'})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 1)
+            self.assertContains(response, org1_location.id)
+            self.assertNotContains(response, org2_location.id)
+
+        with self.subTest('Test filtering with "is_mobile"'):
+            with self.assertNumQueries(5):
+                response = self.client.get(path, {'is_mobile': True})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 1)
+            self.assertContains(response, org1_location.id)
+            self.assertNotContains(response, org2_location.id)
+
+        with self.subTest('Test multi-tenancy filtering'):
+            self.client.logout()
+            user = self._create_administrator([org1])
+            self.client.force_login(user)
+            with self.assertNumQueries(7):
+                response = self.client.get(path)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data['count'], 1)
+            self.assertContains(response, org1_location.id)
+            self.assertNotContains(response, org2_location.id)
+
+    def test_post_location_list(self):
+        path = reverse('geo_api:list_location')
+        coords = json.loads(Point(2, 23).geojson)
+        data = {
+            'organization': self._get_org().pk,
+            'name': 'test-location',
+            'type': 'outdoor',
+            'is_mobile': False,
+            'address': 'Via del Corso, Roma, Italia',
+            'geometry': coords,
+        }
+        with self.assertNumQueries(9):
+            response = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+
+    def test_get_location_detail(self):
+        with self.subTest('Test with invalid pk'):
+            path = reverse('geo_api:detail_location', args=['wrong-pk'])
+            with self.assertNumQueries(2):
+                response = self.client.get(path)
+            self.assertEqual(response.status_code, 404)
+
+        with self.subTest('Test with correct pk'):
+            location = self._create_location()
+            path = reverse('geo_api:detail_location', args=[location.pk])
+            with self.assertNumQueries(4):
+                response = self.client.get(path)
+            self.assertEqual(response.status_code, 200)
+
+    def test_put_location_detail(self):
+        l1 = self._create_location()
+        path = reverse('geo_api:detail_location', args=[l1.pk])
+        org1 = self._create_org(name='org1')
+        coords = json.loads(Point(2, 23).geojson)
+        data = {
+            'organization': org1.pk,
+            'name': 'change-test-location',
+            'type': 'outdoor',
+            'is_mobile': False,
+            'address': 'Via del Corso, Roma, Italia',
+            'geometry': coords,
+        }
+        with self.assertNumQueries(6):
+            response = self.client.put(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data['organization'], org1.pk)
+        self.assertEqual(response.data['name'], 'change-test-location')
+
+    def test_patch_location_detail(self):
+        l1 = self._create_location()
+        self.assertEqual(l1.name, 'test-location')
+        path = reverse('geo_api:detail_location', args=[l1.pk])
+        data = {'name': 'change-test-location'}
+        with self.assertNumQueries(5):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data['name'], 'change-test-location')
+
+    def test_create_location_outdoor_with_floorplan(self):
+        path = reverse('geo_api:list_location')
+        coords = json.loads(Point(2, 23).geojson)
+        data = {
+            'organization': self._get_org().pk,
+            'name': 'test-location',
+            'type': 'outdoor',
+            'is_mobile': False,
+            'address': 'Via del Corso, Roma, Italia',
+            'geometry': coords,
+            'floorplan': {'floor': 12},
+        }
+        with self.assertNumQueries(3):
+            response = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 400)
+        self.assertIn(
+            "Floorplan can only be added with location of the type indoor",
+            str(response.content),
+        )
+
+    def test_patch_floorplan_detail_api(self):
+        l1 = self._create_location(type='indoor')
+        fl = self._create_floorplan(location=l1)
+        path = reverse('geo_api:detail_location', args=[l1.pk])
+        data = {'floorplan': {'floor': 13}}
+        with self.assertNumQueries(13):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        fl.refresh_from_db()
+        self.assertEqual(fl.floor, 13)
+
+    def test_change_location_type_to_outdoor_api(self):
+        l1 = self._create_location(type='indoor')
+        self._create_floorplan(location=l1)
+        path = reverse('geo_api:detail_location', args=[l1.pk])
+        data = {'type': 'outdoor'}
+        with self.assertNumQueries(8):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data['floorplan'], [])
+
+    def test_delete_location_detail(self):
+        l1 = self._create_location()
+        path = reverse('geo_api:detail_location', args=[l1.pk])
+        with self.assertNumQueries(6):
+            response = self.client.delete(path)
+        self.assertEqual(response.status_code, 204)
+
+    def test_create_location_with_floorplan(self):
+        path = reverse('geo_api:list_location')
+        fl_image = self._get_simpleuploadedfile()
+        coords = json.loads(Point(2, 23).geojson)
+        data = {
+            'organization': self._get_org().pk,
+            'name': 'GSoC21',
+            'type': 'indoor',
+            'is_mobile': False,
+            'address': 'Via del Corso, Roma, Italia',
+            'geometry': [coords],
+            'floorplan.floor': ['23'],
+            'floorplan.image': [fl_image],
+        }
+        with self.assertNumQueries(16):
+            response = self.client.post(path, data, format='multipart')
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(Location.objects.count(), 1)
+        self.assertEqual(FloorPlan.objects.count(), 1)
+
+    def test_create_new_floorplan_with_put_location_api(self):
+        org1 = self._get_org()
+        l1 = self._create_location(
+            name='location1org', type='outdoor', organization=org1
+        )
+        path = reverse('geo_api:detail_location', args=(l1.pk,))
+        coords = json.loads(Point(2, 23).geojson)
+        fl_image = self._get_simpleuploadedfile()
+        data = {
+            'organization': self._get_org().pk,
+            'name': 'GSoC21',
+            'type': 'indoor',
+            'is_mobile': False,
+            'address': 'Via del Corso, Roma, Italia',
+            'geometry': [coords],
+            'floorplan.floor': '23',
+            'floorplan.image': fl_image,
+        }
+        with self.assertNumQueries(16):
+            response = self.client.put(
+                path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+            )
+        self.assertEqual(response.status_code, 200)
+
+    def test_create_devicelocation_outdoor_location_with_floorplan(self):
+        device = self._create_object()
+        path = reverse('geo_api:device_location', args=[device.pk])
+        data = {
+            'location.name': 'test-location',
+            'location.address': 'Via del Corso, Roma, Italia',
+            'location.geometry': 'SRID=4326;POINT (12.512124 41.898903)',
+            'location.type': 'outdoor',
+            'floorplan.floor': 1,
+            'floorplan.image': self._get_simpleuploadedfile(),
+        }
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+        response = self.client.put(
+            path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertIn(
+            'floorplans can only be associated to locations of type "indoor"',
+            response.data['floorplan']['__all__'][0],
+        )
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+
+    def test_create_devicelocation_using_non_existing_related_ids(self):
+        device = self._create_object()
+        floorplan = self._create_floorplan()
+        location = floorplan.location
+        url = reverse('geo_api:device_location', args=[device.id])
+
+        with self.subTest('Test non-existing location object'):
+            response = self.client.put(
+                url,
+                data={
+                    'location': uuid.uuid4(),
+                },
+                content_type='application/json',
+            )
+            self.assertEqual(response.status_code, 400)
+            self.assertIn(
+                'Location object with entered ID does not exists',
+                str(response.data['location']),
+            )
+
+        with self.subTest('Test non-existing floorplan object'):
+            response = self.client.put(
+                url,
+                data={
+                    'location': str(location.id),
+                    'floorplan': uuid.uuid4(),
+                },
+                content_type='application/json',
+            )
+            self.assertEqual(response.status_code, 400)
+            self.assertIn(
+                'FloorPlan object with entered ID does not exists',
+                str(response.data['floorplan']),
+            )
+
+    def test_create_devicelocation_using_related_ids(self):
+        device = self._create_object()
+        floorplan = self._create_floorplan()
+        location = floorplan.location
+        url = reverse('geo_api:device_location', args=[device.id])
+        with self.assertNumQueries(13):
+            response = self.client.put(
+                url,
+                data={
+                    'location': location.id,
+                    'floorplan': floorplan.id,
+                    'indoor': '12.342,23.541',
+                },
+                content_type='application/json',
+            )
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(response.data['location']['id'], str(location.id))
+        self.assertIn('type', response.data['location'].keys())
+        self.assertIn('geometry', response.data['location'].keys())
+        self.assertIn('properties', response.data['location'].keys())
+        self.assertEqual(response.data['floorplan']['id'], str(floorplan.id))
+        self.assertIn('name', response.data['floorplan'].keys())
+        self.assertIn('floor', response.data['floorplan'].keys())
+        self.assertIn('image', response.data['floorplan'].keys())
+        # New location and floorplan objects are not created.
+        self.assertEqual(self.location_model.objects.count(), 1)
+        self.assertEqual(self.floorplan_model.objects.count(), 1)
+
+    def test_create_devicelocation_location_floorplan(self):
+        device = self._create_object()
+        self.assertEqual(self.location_model.objects.count(), 0)
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+        url = reverse('geo_api:device_location', args=[device.id])
+        data = {
+            'location.name': 'test-location',
+            'location.address': 'Via del Corso, Roma, Italia',
+            'location.geometry': 'SRID=4326;POINT (12.512124 41.898903)',
+            'location.type': 'indoor',
+            'floorplan.floor': 1,
+            'floorplan.image': self._get_simpleuploadedfile(),
+            'indoor': ['12.342,23.541'],
+        }
+        with self.assertNumQueries(27):
+            response = self.client.put(
+                url, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+            )
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(self.location_model.objects.count(), 1)
+        self.assertEqual(self.object_location_model.objects.count(), 1)
+        self.assertEqual(self.floorplan_model.objects.count(), 1)
+
+    def test_create_devicelocation_location_floorplan_validation(self):
+        org1 = self._create_org(name='org1', slug='org1')
+        org2 = self._create_org(name='org2', slug='org2')
+        location = self._create_location(organization=org2, type='indoor')
+        device = self._create_object(organization=org1)
+        floorplan = self._create_floorplan(
+            location=self._create_location(organization=org1, type='indoor')
+        )
+        url = reverse('geo_api:device_location', args=[device.id])
+
+        with self.subTest('Test location validation'):
+            response = self.client.put(
+                url,
+                data={'location': str(location.id)},
+                content_type='application/json',
+            )
+            self.assertEqual(response.status_code, 400)
+            self.assertIn(
+                'Please ensure that the organization of this device '
+                'location and the organization of the related location match',
+                str(response.data['location']),
+            )
+
+        location.organization = org1
+        location.type = 'indoor'
+        location.full_clean()
+        location.save()
+
+        with self.subTest('Test floorplan validation'):
+            response = self.client.put(
+                url,
+                data={
+                    'location': str(location.id),
+                    'floorplan': str(floorplan.id),
+                    'indoor': '1,1',
+                },
+                content_type='application/json',
+            )
+            self.assertEqual(response.status_code, 400)
+            self.assertIn(
+                'Invalid floorplan (belongs to a different location)',
+                str(response.data['__all__']),
+            )
+
+    def test_create_devicelocation_only_location(self):
+        device = self._create_object()
+        self.assertEqual(self.location_model.objects.count(), 0)
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+        url = reverse('geo_api:device_location', args=[device.id])
+        data = {
+            'location': {
+                'name': 'test-location',
+                'address': 'Via del Corso, Roma, Italia',
+                'geometry': 'SRID=4326;POINT (12.512124 41.898903)',
+                'type': 'indoor',
+            }
+        }
+        with self.assertNumQueries(16):
+            response = self.client.put(url, data=data, content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(self.location_model.objects.count(), 1)
+        self.assertEqual(self.object_location_model.objects.count(), 1)
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+
+    def test_create_devicelocation_only_floorplan(self):
+        device = self._create_object()
+        self.assertEqual(self.location_model.objects.count(), 0)
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+        url = reverse('geo_api:device_location', args=[device.id])
+        data = {
+            'floorplan.floor': 1,
+            'floorplan.image': self._get_simpleuploadedfile(),
+        }
+        with self.assertNumQueries(3):
+            response = self.client.put(
+                url, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+            )
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('This field is required.', response.data['location'][0])
+        self.assertEqual(self.location_model.objects.count(), 0)
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+
+    def test_create_devicelocation_existing_location_new_floorplan(self):
+        device = self._create_object()
+        location = self._create_location(type='indoor')
+        self.assertEqual(self.location_model.objects.count(), 1)
+        self.assertEqual(self.object_location_model.objects.count(), 0)
+        self.assertEqual(self.floorplan_model.objects.count(), 0)
+        url = reverse('geo_api:device_location', args=[device.id])
+        data = {
+            'location': str(location.id),
+            'floorplan.floor': 1,
+            'floorplan.image': self._get_simpleuploadedfile(),
+            'indoor': ['12.342,23.541'],
+        }
+        with self.assertNumQueries(21):
+            response = self.client.put(
+                url, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+            )
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(self.location_model.objects.count(), 1)
+        self.assertEqual(self.object_location_model.objects.count(), 1)
+        self.assertEqual(self.floorplan_model.objects.count(), 1)
+
+    def test_update_devicelocation_change_location_outdoor_to_indoor(self):
+        device_location = self._create_object_location()
+        path = reverse('geo_api:device_location', args=[device_location.device.pk])
+        data = {
+            'location.type': 'indoor',
+            'location.name': 'test-location',
+            'location.address': 'Via del Corso, Roma, Italia',
+            'location.geometry': 'SRID=4326;POINT (12.512124 41.898903)',
+            'floorplan.floor': ['21'],
+            'floorplan.image': self._get_simpleuploadedfile(),
+            'indoor': ['12.342,23.541'],
+        }
+        self.assertEqual(device_location.location.type, 'outdoor')
+        self.assertEqual(device_location.floorplan, None)
+        with self.assertNumQueries(20):
+            response = self.client.put(
+                path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT
+            )
+        self.assertEqual(response.status_code, 200)
+        device_location.refresh_from_db()
+        self.assertEqual(device_location.location.type, 'indoor')
+        self.assertNotEqual(device_location.floorplan, None)
+
+    def test_update_devicelocation_patch_indoor(self):
+        floorplan = self._create_floorplan()
+        device_location = self._create_object_location(
+            floorplan=floorplan, location=floorplan.location
+        )
+        path = reverse('geo_api:device_location', args=[device_location.device.pk])
+        data = {
+            'indoor': '0,0',
+        }
+        self.assertEqual(device_location.indoor, '-140.38620,40.369227')
+        with self.assertNumQueries(9):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        device_location.refresh_from_db()
+        self.assertEqual(device_location.indoor, '0,0')
+
+    def test_update_devicelocation_floorplan_related_id(self):
+        location = self._create_location(type='indoor')
+        floor1 = self._create_floorplan(floor=1, location=location)
+        floor2 = self._create_floorplan(floor=2, location=location)
+        device_location = self._create_object_location(
+            location=location, floorplan=floor1
+        )
+        path = reverse('geo_api:device_location', args=[device_location.device.pk])
+        data = {
+            'floorplan': str(floor2.id),
+        }
+        with self.assertNumQueries(11):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        device_location.refresh_from_db()
+        self.assertEqual(device_location.floorplan, floor2)
+
+    def test_update_devicelocation_location_related_id(self):
+        location1 = self._create_location(name='test-location-1')
+        location2 = self._create_location(name='test-location-2')
+        device_location = self._create_object_location(location=location1)
+        path = reverse('geo_api:device_location', args=[device_location.device.pk])
+        data = {
+            'location': str(location2.id),
+        }
+        with self.assertNumQueries(8):
+            response = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        device_location.refresh_from_db()
+        self.assertEqual(device_location.location, location2)
+
+    def test_retrieve_devicelocation(self):
+        floorplan = self._create_floorplan()
+        device_location = self._create_object_location(
+            location=floorplan.location, floorplan=floorplan
+        )
+        url = reverse('geo_api:device_location', args=[device_location.device.pk])
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.data['location']['id'], str(device_location.location.id)
+        )
+        self.assertIn('type', response.data['location'].keys())
+        self.assertIn('geometry', response.data['location'].keys())
+        self.assertIn('properties', response.data['location'].keys())
+        self.assertEqual(
+            response.data['floorplan']['id'], str(device_location.floorplan.id)
+        )
+        self.assertIn('id', response.data['floorplan'].keys())
+        self.assertIn('name', response.data['floorplan'].keys())
+        self.assertIn('floor', response.data['floorplan'].keys())
+        self.assertIn('image', response.data['floorplan'].keys())
+        self.assertIn('created', response.data['floorplan'].keys())
+        self.assertIn('modified', response.data['floorplan'].keys())
diff --git a/openwisp_controller/geo/utils.py b/openwisp_controller/geo/utils.py
index bad397465..a0c1e5d3a 100644
--- a/openwisp_controller/geo/utils.py
+++ b/openwisp_controller/geo/utils.py
@@ -3,6 +3,11 @@
 
 def get_geo_urls(geo_views):
     return [
+        path(
+            'api/v1/controller/device//coordinates/',
+            geo_views.device_coordinates,
+            name='device_coordinates',
+        ),
         path(
             'api/v1/controller/device//location/',
             geo_views.device_location,
@@ -18,4 +23,22 @@ def get_geo_urls(geo_views):
             geo_views.location_device_list,
             name='location_device_list',
         ),
+        path(
+            'api/v1/controller/floorplan/',
+            geo_views.list_floorplan,
+            name='list_floorplan',
+        ),
+        path(
+            'api/v1/controller/floorplan//',
+            geo_views.detail_floorplan,
+            name='detail_floorplan',
+        ),
+        path(
+            'api/v1/controller/location/', geo_views.list_location, name='list_location'
+        ),
+        path(
+            'api/v1/controller/location//',
+            geo_views.detail_location,
+            name='detail_location',
+        ),
     ]
diff --git a/openwisp_controller/mixins.py b/openwisp_controller/mixins.py
new file mode 100644
index 000000000..5e9e38659
--- /dev/null
+++ b/openwisp_controller/mixins.py
@@ -0,0 +1,14 @@
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.permissions import IsAuthenticated
+
+from openwisp_users.api.authentication import BearerAuthentication
+from openwisp_users.api.mixins import FilterByOrganizationManaged
+from openwisp_users.api.permissions import DjangoModelPermissions
+
+
+class ProtectedAPIMixin(FilterByOrganizationManaged):
+    authentication_classes = [BearerAuthentication, SessionAuthentication]
+    permission_classes = [
+        IsAuthenticated,
+        DjangoModelPermissions,
+    ]
diff --git a/openwisp_controller/pki/api/serializers.py b/openwisp_controller/pki/api/serializers.py
index 48b6c2288..4d0b60757 100644
--- a/openwisp_controller/pki/api/serializers.py
+++ b/openwisp_controller/pki/api/serializers.py
@@ -1,4 +1,4 @@
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 from django_x509.base.models import (
     default_ca_validity_end,
     default_cert_validity_end,
diff --git a/openwisp_controller/pki/api/views.py b/openwisp_controller/pki/api/views.py
index 4eda76b5a..66b7c99f5 100644
--- a/openwisp_controller/pki/api/views.py
+++ b/openwisp_controller/pki/api/views.py
@@ -1,20 +1,16 @@
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404
 from rest_framework import pagination, serializers
-from rest_framework.authentication import SessionAuthentication
 from rest_framework.generics import (
     GenericAPIView,
     ListCreateAPIView,
     RetrieveAPIView,
     RetrieveUpdateDestroyAPIView,
 )
-from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
 from rest_framework.response import Response
 from swapper import load_model
 
-from openwisp_users.api.authentication import BearerAuthentication
-from openwisp_users.api.mixins import FilterByOrganizationManaged
-
+from ...mixins import ProtectedAPIMixin
 from .serializers import (
     CaDetailSerializer,
     CaListSerializer,
@@ -34,14 +30,6 @@ class ListViewPagination(pagination.PageNumberPagination):
     max_page_size = 100
 
 
-class ProtectedAPIMixin(FilterByOrganizationManaged):
-    authentication_classes = [BearerAuthentication, SessionAuthentication]
-    permission_classes = [
-        IsAuthenticated,
-        DjangoModelPermissions,
-    ]
-
-
 class CaListCreateView(ProtectedAPIMixin, ListCreateAPIView):
     serializer_class = CaListSerializer
     queryset = Ca.objects.order_by('-created')
diff --git a/openwisp_controller/pki/apps.py b/openwisp_controller/pki/apps.py
index fec581b21..c214c78e9 100644
--- a/openwisp_controller/pki/apps.py
+++ b/openwisp_controller/pki/apps.py
@@ -1,5 +1,5 @@
 from django.conf import settings
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 from django_x509.apps import DjangoX509Config
 from swapper import get_model_name
 
diff --git a/openwisp_controller/pki/base/models.py b/openwisp_controller/pki/base/models.py
index e36f45a06..281a36219 100644
--- a/openwisp_controller/pki/base/models.py
+++ b/openwisp_controller/pki/base/models.py
@@ -1,5 +1,5 @@
 from django.db import models
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
 from django_x509.base.models import AbstractCa as BaseCa
 from django_x509.base.models import AbstractCert as BaseCert
 from swapper import get_model_name
diff --git a/openwisp_controller/pki/migrations/0001_initial.py b/openwisp_controller/pki/migrations/0001_initial.py
index 80c64b960..1d1a1b774 100644
--- a/openwisp_controller/pki/migrations/0001_initial.py
+++ b/openwisp_controller/pki/migrations/0001_initial.py
@@ -8,15 +8,18 @@
 import django_x509.base.models
 import jsonfield.fields
 import model_utils.fields
+from django.conf import settings
 from django.db import migrations, models
-from swapper import get_model_name
+from swapper import dependency, get_model_name, split
 
 
 class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [('openwisp_users', '0001_initial')]
+    dependencies = [
+        dependency(*split(settings.AUTH_USER_MODEL), version='0004_default_groups'),
+    ]
 
     operations = [
         migrations.CreateModel(
diff --git a/openwisp_controller/pki/migrations/0007_default_groups_permissions.py b/openwisp_controller/pki/migrations/0007_default_groups_permissions.py
index 976ba2bc6..cfa27ecea 100644
--- a/openwisp_controller/pki/migrations/0007_default_groups_permissions.py
+++ b/openwisp_controller/pki/migrations/0007_default_groups_permissions.py
@@ -5,7 +5,6 @@
 
 class Migration(migrations.Migration):
     dependencies = [
-        ('openwisp_users', '0004_default_groups'),
         ('pki', '0006_add_x509_passphrase_field'),
     ]
     operations = [
diff --git a/openwisp_controller/pki/migrations/0011_disallowed_blank_key_length_or_digest.py b/openwisp_controller/pki/migrations/0011_disallowed_blank_key_length_or_digest.py
new file mode 100644
index 000000000..fcbcc4339
--- /dev/null
+++ b/openwisp_controller/pki/migrations/0011_disallowed_blank_key_length_or_digest.py
@@ -0,0 +1,80 @@
+# Generated by Django 4.0.2 on 2022-02-28 17:12
+
+import django_x509.base.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('pki', '0010_common_name_organization_unique'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ca',
+            name='digest',
+            field=models.CharField(
+                choices=[
+                    ('sha1', 'SHA1'),
+                    ('sha224', 'SHA224'),
+                    ('sha256', 'SHA256'),
+                    ('sha384', 'SHA384'),
+                    ('sha512', 'SHA512'),
+                ],
+                default=django_x509.base.models.default_digest_algorithm,
+                help_text='bits',
+                max_length=8,
+                verbose_name='digest algorithm',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='ca',
+            name='key_length',
+            field=models.CharField(
+                choices=[
+                    ('512', '512'),
+                    ('1024', '1024'),
+                    ('2048', '2048'),
+                    ('4096', '4096'),
+                ],
+                default=django_x509.base.models.default_key_length,
+                help_text='bits',
+                max_length=6,
+                verbose_name='key length',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='cert',
+            name='digest',
+            field=models.CharField(
+                choices=[
+                    ('sha1', 'SHA1'),
+                    ('sha224', 'SHA224'),
+                    ('sha256', 'SHA256'),
+                    ('sha384', 'SHA384'),
+                    ('sha512', 'SHA512'),
+                ],
+                default=django_x509.base.models.default_digest_algorithm,
+                help_text='bits',
+                max_length=8,
+                verbose_name='digest algorithm',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='cert',
+            name='key_length',
+            field=models.CharField(
+                choices=[
+                    ('512', '512'),
+                    ('1024', '1024'),
+                    ('2048', '2048'),
+                    ('4096', '4096'),
+                ],
+                default=django_x509.base.models.default_key_length,
+                help_text='bits',
+                max_length=6,
+                verbose_name='key length',
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py
index 96c899fd4..bb9e65339 100644
--- a/openwisp_controller/pki/tests/test_admin.py
+++ b/openwisp_controller/pki/tests/test_admin.py
@@ -14,16 +14,12 @@
 class TestAdmin(TestPkiMixin, TestAdminMixin, TestOrganizationMixin, TestCase):
     app_label = 'pki'
 
-    operator_permission_filters = [
-        {'codename__endswith': 'ca'},
-        {'codename__endswith': 'cert'},
-    ]
-
     def _create_multitenancy_test_env(self, cert=False):
         org1 = self._create_org(name='test1org')
         org2 = self._create_org(name='test2org')
         inactive = self._create_org(name='inactive-org', is_active=False)
         operator = self._create_operator(organizations=[org1, inactive])
+        administrator = self._create_administrator(organizations=[org1, inactive])
         ca1 = self._create_ca(name='ca1', organization=org1)
         ca2 = self._create_ca(name='ca2', organization=org2)
         ca_shared = self._create_ca(name='ca-shared', organization=None)
@@ -37,6 +33,7 @@ def _create_multitenancy_test_env(self, cert=False):
             org2=org2,
             inactive=inactive,
             operator=operator,
+            administrator=administrator,
         )
         if cert:
             cert1 = self._create_cert(name='cert1', ca=ca1, organization=org1)
@@ -77,6 +74,7 @@ def test_ca_organization_fk_queryset(self):
             visible=[data['org1'].name],
             hidden=[data['org2'].name, data['inactive']],
             select_widget=True,
+            administrator=True,
         )
 
     def test_cert_queryset(self):
@@ -99,6 +97,7 @@ def test_cert_organization_fk_queryset(self):
             visible=[data['org1'].name],
             hidden=[data['org2'].name, data['inactive']],
             select_widget=True,
+            administrator=True,
         )
 
     def test_cert_ca_fk_queryset(self):
@@ -108,6 +107,7 @@ def test_cert_ca_fk_queryset(self):
             visible=[data['ca1'].name, data['ca_shared'].name],
             hidden=[data['ca2'].name, data['ca_inactive'].name],
             select_widget=True,
+            administrator=True,
         )
 
     def test_cert_changeform_200(self):
diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py
index 69ffd47a9..e94623aca 100644
--- a/openwisp_controller/pki/tests/test_api.py
+++ b/openwisp_controller/pki/tests/test_api.py
@@ -3,8 +3,9 @@
 from swapper import load_model
 
 from openwisp_controller.tests.utils import TestAdminMixin
+from openwisp_users.tests.test_api import AuthenticationMixin
 from openwisp_users.tests.utils import TestOrganizationMixin
-from openwisp_utils.tests import AssertNumQueriesSubTestMixin
+from openwisp_utils.tests import AssertNumQueriesSubTestMixin, capture_any_output
 
 from .utils import TestPkiMixin
 
@@ -17,6 +18,7 @@ class TestPkiApi(
     TestAdminMixin,
     TestPkiMixin,
     TestOrganizationMixin,
+    AuthenticationMixin,
     TestCase,
 ):
     def setUp(self):
@@ -291,3 +293,65 @@ def test_post_cert_revoke_api(self):
         self.assertEqual(r.status_code, 200)
         self.assertTrue(cert1.revoked)
         self.assertTrue(r.data['revoked'])
+
+    @capture_any_output()
+    def test_bearer_authentication(self):
+        self.client.logout()
+        token = self._obtain_auth_token(username='admin', password='tester')
+        ca = self._create_ca()
+        cert = self._create_cert(ca=ca)
+        with self.subTest('Test CaListCreateView'):
+            response = self.client.get(
+                reverse('pki_api:ca_list'),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CaDetailView'):
+            response = self.client.get(
+                reverse('pki_api:ca_detail', args=[ca.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CaRenewView'):
+            response = self.client.post(
+                reverse('pki_api:ca_renew', args=[ca.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CertListCreateView'):
+            response = self.client.get(
+                reverse('pki_api:cert_list'),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CertDetailView'):
+            response = self.client.get(
+                reverse('pki_api:cert_detail', args=[cert.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CrlDownloadView'):
+            response = self.client.get(
+                reverse('pki_api:crl_download', args=[ca.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CertRenewView'):
+            response = self.client.post(
+                reverse('pki_api:cert_renew', args=[cert.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
+
+        with self.subTest('Test CertRevokeView'):
+            response = self.client.post(
+                reverse('pki_api:cert_revoke', args=[cert.id]),
+                HTTP_AUTHORIZATION=f'Bearer {token}',
+            )
+            self.assertEqual(response.status_code, 200)
diff --git a/openwisp_controller/subnet_division/__init__.py b/openwisp_controller/subnet_division/__init__.py
new file mode 100644
index 000000000..dd3a7036d
--- /dev/null
+++ b/openwisp_controller/subnet_division/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'openwisp_controller.subnet_division.apps.SubnetDivisionConfig'
diff --git a/openwisp_controller/subnet_division/admin.py b/openwisp_controller/subnet_division/admin.py
new file mode 100644
index 000000000..8de21608d
--- /dev/null
+++ b/openwisp_controller/subnet_division/admin.py
@@ -0,0 +1,138 @@
+from django.contrib import admin
+from django.urls import reverse
+from django.utils.html import mark_safe
+from django.utils.translation import gettext_lazy as _
+from openwisp_ipam.admin import IpAddressAdmin as BaseIpAddressAdmin
+from openwisp_ipam.admin import SubnetAdmin as BaseSubnetAdmin
+from swapper import load_model
+
+from openwisp_controller.config.admin import DeviceAdmin
+from openwisp_users.multitenancy import MultitenantAdminMixin, MultitenantOrgFilter
+from openwisp_utils.admin import HelpTextStackedInline, TimeReadonlyAdminMixin
+
+from . import settings as app_settings
+from .filters import DeviceFilter, SubnetFilter, SubnetListFilter
+
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+Device = load_model('config', 'Device')
+
+
+class SubnetDivisionRuleInlineAdmin(
+    MultitenantAdminMixin, TimeReadonlyAdminMixin, HelpTextStackedInline
+):
+    model = SubnetDivisionRule
+    extra = 0
+    help_text = {
+        'text': _(
+            'Please keep in mind that once the subnet division rule is created '
+            'changing changing "Size", "Number of Subnets" or decreasing '
+            '"Number of IPs" will not be possible.'
+        ),
+        'documentation_url': (
+            'https://github.com/openwisp/openwisp-controller'
+            '#limitations-of-subnet-division'
+        ),
+    }
+
+    class Media:
+        js = ['admin/js/jquery.init.js', 'subnet-division/js/subnet-division.js']
+
+
+# Monkey patching DeviceAdmin to allow filtering using subnet
+DeviceAdmin.list_filter.append(SubnetFilter)
+
+# NOTE: Monkey patching SubnetAdmin didn't work for adding readonly_field
+# to change_view because of TimeReadonlyAdminMixin.
+
+admin.site.unregister(Subnet)
+admin.site.unregister(IpAddress)
+
+
+@admin.register(Subnet)
+class SubnetAdmin(BaseSubnetAdmin):
+    list_filter = BaseSubnetAdmin.list_filter + [DeviceFilter]
+    inlines = [SubnetDivisionRuleInlineAdmin] + BaseSubnetAdmin.inlines
+
+    def get_queryset(self, request):
+        qs = super().get_queryset(request)
+        subnet_division_index_qs = (
+            SubnetDivisionIndex.objects.filter(
+                subnet_id__in=qs.filter(master_subnet__isnull=False).values('id'),
+                ip__isnull=True,
+            )
+            .select_related('config__device')
+            .values_list('subnet_id', 'config__device__name')
+        )
+        self._lookup = {}
+        for subnet_id, device_name in subnet_division_index_qs:
+            self._lookup[subnet_id] = device_name
+
+        if app_settings.HIDE_GENERATED_SUBNETS:
+            qs = qs.exclude(
+                id__in=SubnetDivisionIndex.objects.filter(
+                    ip__isnull=True, subnet__isnull=False
+                ).values_list('subnet_id')
+            )
+
+        return qs
+
+    def get_readonly_fields(self, request, obj=None):
+        fields = super().get_readonly_fields(request, obj)
+        if obj is not None and 'related_device' not in fields:
+            fields = ('related_device',) + fields
+        return fields
+
+    def get_list_display(self, request):
+        fields = super().get_list_display(request)
+        return fields + ['related_device']
+
+    def related_device(self, obj):
+        app_label = Device._meta.app_label
+        url = reverse(f'admin:{app_label}_device_changelist')
+        if obj.master_subnet is None:
+            msg_string = _('See all devices')
+            return mark_safe(
+                f'{msg_string}'
+            )
+        else:
+            if obj.id in self._lookup:
+                device = self._lookup[obj.id]
+                return mark_safe(
+                    f'{device}'
+                )
+
+    def has_change_permission(self, request, obj=None):
+        permission = super().has_change_permission(request, obj)
+        if not obj:
+            return permission
+        automated = SubnetDivisionIndex.objects.filter(subnet_id=obj.id).exists()
+        return permission and not automated
+
+
+@admin.register(IpAddress)
+class IpAddressAdmin(BaseIpAddressAdmin):
+    list_filter = [
+        ('subnet', SubnetListFilter),
+        ('subnet__organization', MultitenantOrgFilter),
+    ]
+
+    def get_queryset(self, request):
+        qs = super().get_queryset(request)
+
+        if app_settings.HIDE_GENERATED_SUBNETS:
+            qs = qs.exclude(
+                id__in=SubnetDivisionIndex.objects.filter(ip__isnull=False).values_list(
+                    'ip_id'
+                )
+            )
+        return qs
+
+    def has_change_permission(self, request, obj=None):
+        permission = super().has_change_permission(request, obj)
+        if not obj:
+            return permission
+        automated = SubnetDivisionIndex.objects.filter(ip_id=obj.id).exists()
+        return permission and not automated
diff --git a/openwisp_controller/subnet_division/apps.py b/openwisp_controller/subnet_division/apps.py
new file mode 100644
index 000000000..d4e6bc1cb
--- /dev/null
+++ b/openwisp_controller/subnet_division/apps.py
@@ -0,0 +1,76 @@
+from django.apps import AppConfig
+from django.db.models.signals import post_delete, post_save, pre_save
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+from . import settings as app_settings
+from .utils import get_subnet_division_config_context, subnet_division_vpnclient_auto_ip
+
+
+class SubnetDivisionConfig(AppConfig):
+    name = 'openwisp_controller.subnet_division'
+    verbose_name = _('Subnet Division')
+    default_auto_field = 'django.db.models.AutoField'
+
+    def ready(self):
+        super().ready()
+        self._load_models()
+        self._add_config_context_method()
+
+        for rule_path, name in app_settings.SUBNET_DIVISION_TYPES:
+            rule_class = import_string(rule_path)
+            rule_class.validate_rule_type()
+            rule_class.provision_signal.connect(
+                receiver=rule_class.provision_receiver,
+                sender=rule_class.provision_sender,
+                dispatch_uid=rule_class.provision_dispatch_uid,
+            )
+            rule_class.destroyer_signal.connect(
+                receiver=rule_class.destroyer_receiver,
+                sender=rule_class.destroyer_sender,
+                dispatch_uid=rule_class.destroyer_dispatch_uid,
+            )
+
+        pre_save.connect(
+            receiver=self.subnetdivisionrule_model_.pre_save,
+            sender=self.subnetdivisionrule_model_,
+            dispatch_uid='subnetdivisionrule_pre_save',
+        )
+        post_save.connect(
+            receiver=self.subnetdivisionrule_model_.post_save,
+            sender=self.subnetdivisionrule_model_,
+            dispatch_uid='subnetdivisionrule_post_save',
+        )
+        post_delete.connect(
+            receiver=self.subnetdivisionrule_model_.post_delete,
+            sender=self.subnetdivisionrule_model_,
+            dispatch_uid='subnetdivisionrule_post_delete',
+        )
+
+    def _load_models(self):
+        self.subnetdivisionrule_model_ = load_model(
+            'subnet_division', 'SubnetDivisionRule'
+        )
+
+    def _add_config_context_method(self):
+        from openwisp_controller.config.tests import CreateConfigTemplateMixin
+
+        from .tests.helpers import subnetdivision_patched_assertNumQueries
+
+        Config = load_model('config', 'Config')
+        VpnClient = load_model('config', 'VpnClient')
+
+        Config.register_context_function(get_subnet_division_config_context)
+        VpnClient.register_auto_ip_stopper(subnet_division_vpnclient_auto_ip)
+
+        # Monkeypatching of "CreateConfigTemplateMixin" is required because
+        # subnet_division app updates context of the Config object
+        # which creates additional database queries.
+        # Usage of subnet_division app is optional hence, tests in
+        # "openwisp_controller.config" are written assuming
+        # subnet_division is not used. But when it is used, the number
+        # of queries should be increased.
+        CreateConfigTemplateMixin.assertNumQueries = (
+            subnetdivision_patched_assertNumQueries
+        )
diff --git a/openwisp_controller/subnet_division/base/models.py b/openwisp_controller/subnet_division/base/models.py
new file mode 100644
index 000000000..06eb61a2f
--- /dev/null
+++ b/openwisp_controller/subnet_division/base/models.py
@@ -0,0 +1,267 @@
+from ipaddress import ip_network
+
+import swapper
+from django.core.exceptions import ValidationError
+from django.db import models, transaction
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
+
+from openwisp_users.mixins import OrgMixin
+from openwisp_utils.base import TimeStampedEditableModel
+
+from .. import settings as app_settings
+
+
+class AbstractSubnetDivisionRule(TimeStampedEditableModel, OrgMixin):
+    _subnet_division_rule_update_queue = dict()
+    # It is used to monitor changes in fields of a SubnetDivisionRule object
+    # An entry is added to the queue from pre_save signal in the following format
+    #
+    # ': {
+    #   '': '',
+    # }
+    #
+    # In post_save signal, it is checked whether entry for SubnetDivisionRule object
+    # exists in this queue. If it exists changes are made to related objects.
+
+    type = models.CharField(max_length=200, choices=app_settings.SUBNET_DIVISION_TYPES)
+    master_subnet = models.ForeignKey(
+        swapper.get_model_name('openwisp_ipam', 'Subnet'), on_delete=models.CASCADE
+    )
+    label = models.CharField(
+        max_length=30,
+        help_text=_('Label used to calculate the configuration variables'),
+    )
+    number_of_subnets = models.PositiveIntegerField(
+        verbose_name=_('Number of Subnets'),
+        help_text=_('Indicates how many subnets will be created'),
+    )
+    size = models.PositiveIntegerField(
+        verbose_name=_('Size of subnets'),
+        help_text=_('Indicates the size of each created subnet'),
+    )
+    number_of_ips = models.PositiveIntegerField(
+        verbose_name=_('Number of IPs'),
+        help_text=_('Indicates how many IP addresses will be created for each subnet'),
+    )
+
+    class Meta:
+        abstract = True
+        constraints = [
+            models.UniqueConstraint(
+                fields=['organization', 'label'],
+                name='unique_subnet_division_rule_label',
+            ),
+            models.UniqueConstraint(
+                fields=['organization', 'label', 'type', 'master_subnet'],
+                name='unique_subnet_division_rule',
+            ),
+        ]
+
+    def __str__(self):
+        return f'{self.label}'
+
+    @property
+    def rule_class(self):
+        return import_string(self.type)
+
+    def clean(self):
+        super().clean()
+        self._validate_label()
+        self._validate_master_subnet_consistency()
+        self._validate_ip_address_consistency()
+        if not self._state.adding:
+            self._validate_existing_fields()
+
+    def _validate_label(self):
+        if not self.label.isidentifier():
+            raise ValidationError(
+                {
+                    'label': _(
+                        'Only alphanumeric characters and underscores are allowed.'
+                    )
+                }
+            )
+
+    def _validate_existing_fields(self):
+        db_instance = self._meta.model.objects.get(id=self.id)
+        # The size field should not be changed
+        if self.size != db_instance.size:
+            raise ValidationError({'size': _('Subnet size cannot be changed')})
+        # Number of IPs should not decreased
+        if self.number_of_ips < db_instance.number_of_ips:
+            raise ValidationError(
+                {'number_of_ips': _('Number of IPs cannot be decreased')}
+            )
+        # Number of subnets should not be changed
+        if self.number_of_subnets != db_instance.number_of_subnets:
+            raise ValidationError(
+                {'number_of_subnets': _('Number of Subnets cannot be changed')}
+            )
+
+    def _validate_master_subnet_consistency(self):
+        master_subnet = self.master_subnet.subnet
+        # Validate size of generated subnet is not greater than size of master subnet
+        try:
+            next(master_subnet.subnets(new_prefix=self.size))
+        except ValueError:
+            raise ValidationError(
+                {
+                    'size': _(
+                        'Master subnet cannot accommodate subnets of size /{0}'.format(
+                            self.size
+                        )
+                    )
+                }
+            )
+
+        # Validate master subnet can accommodate required number of generated subnets
+        if self.number_of_subnets > (2 ** (self.size - master_subnet.prefixlen)):
+            raise ValidationError(
+                {
+                    'number_of_subnets': _(
+                        f'Master subnet cannot accommodate {self.number_of_subnets} '
+                        f'subnets of size /{self.size}'
+                    )
+                }
+            )
+
+        # Validate organization of master subnet
+        if (
+            self.master_subnet.organization is not None
+            and self.master_subnet.organization != self.organization
+        ):
+            raise ValidationError(
+                {'organization': _('Organization should be same as the subnet')}
+            )
+
+    def _validate_ip_address_consistency(self):
+        # Validate individual generated subnet can accommodate required number of IPs
+        try:
+            next(
+                ip_network(str(self.master_subnet.subnet)).subnets(new_prefix=self.size)
+            )[self.number_of_ips]
+        except IndexError:
+            raise ValidationError(
+                {
+                    'number_of_ips': _(
+                        f'Generated subnets of size /{self.size} cannot accommodate '
+                        f'{self.number_of_ips} IP Addresses.'
+                    )
+                }
+            )
+
+    def check_and_queue_modified_fields(self):
+        try:
+            db_instance = self._meta.model.objects.get(id=self.id)
+        except self._meta.model.DoesNotExist:
+            # This rule does not exists in database.
+            # No operation is needed to be performed.
+            return
+        else:
+            # Check which fields of instance is modified
+            # NOTE: Open-ended implementation to allow change in all
+            # fields of SubnetDivisionRule in future.
+            # Currently only changing label and number of IPs is allowed.
+            modified_fields = {}
+            for field in db_instance._meta.fields:
+                instance_value = getattr(self, field.name)
+                db_value = getattr(db_instance, field.name)
+                if instance_value != db_value:
+                    modified_fields[field.name] = db_value
+            if modified_fields:
+                self._subnet_division_rule_update_queue[str(self.id)] = modified_fields
+
+    def update_related_objects(self):
+        from .. import tasks
+
+        # Update related objects appropriately.
+        # NOTE: Currently only changing label and number of IPs is implemented/allowed.
+        try:
+            modified_fields = self._subnet_division_rule_update_queue.pop(str(self.id))
+        except KeyError:
+            return
+        else:
+            if 'label' in modified_fields:
+                tasks.update_subnet_division_index.delay(rule_id=str(self.id))
+                tasks.update_subnet_name_description(rule_id=str(self.id))
+            if 'number_of_ips' in modified_fields:
+                tasks.provision_extra_ips.delay(
+                    rule_id=str(self.id),
+                    old_number_of_ips=modified_fields['number_of_ips'],
+                )
+
+    def delete_provisioned_subnets(self):
+        # Deleting an object of SubnetDivisionRule will set the rule field
+        # of related SubnetDivisionIndex to "None" due to "on_delete=SET_NULL".
+        # These indexes are used delete subnets that were provisioned by the
+        # deleted rule. Deleting a Subnet object will automatically delete
+        # related IpAddress objects.
+        Subnet = swapper.load_model('openwisp_ipam', 'Subnet')
+        SubnetDivisionIndex = swapper.load_model(
+            'subnet_division', 'SubnetDivisionIndex'
+        )
+
+        Subnet.objects.filter(
+            id__in=SubnetDivisionIndex.objects.filter(rule_id=None).values('subnet_id')
+        ).delete()
+
+    @classmethod
+    def pre_save(cls, instance, **kwargs):
+        instance.check_and_queue_modified_fields()
+
+    @classmethod
+    def post_save(cls, instance, created, **kwargs):
+        from ..tasks import provision_subnet_ip_for_existing_devices
+
+        if created:
+            transaction.on_commit(
+                lambda: provision_subnet_ip_for_existing_devices.delay(
+                    rule_id=instance.id
+                )
+            )
+        else:
+            transaction.on_commit(instance.update_related_objects)
+
+    @classmethod
+    def post_delete(cls, instance, **kwargs):
+        transaction.on_commit(instance.delete_provisioned_subnets)
+
+
+class AbstractSubnetDivisionIndex(models.Model):
+    keyword = models.CharField(max_length=30)
+    subnet = models.ForeignKey(
+        swapper.get_model_name('openwisp_ipam', 'Subnet'),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    ip = models.ForeignKey(
+        swapper.get_model_name('openwisp_ipam', 'IpAddress'),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    rule = models.ForeignKey(
+        swapper.get_model_name('subnet_division', 'SubnetDivisionRule'),
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    config = models.ForeignKey(
+        swapper.get_model_name('config', 'Config'),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+
+    class Meta:
+        abstract = True
+        indexes = [
+            models.Index(fields=['keyword']),
+        ]
+        constraints = [
+            models.UniqueConstraint(
+                fields=['keyword', 'subnet', 'ip', 'config'],
+                name='unique_subnet_division_index',
+            ),
+        ]
diff --git a/openwisp_controller/subnet_division/filters.py b/openwisp_controller/subnet_division/filters.py
new file mode 100644
index 000000000..e9c079f87
--- /dev/null
+++ b/openwisp_controller/subnet_division/filters.py
@@ -0,0 +1,61 @@
+from django.contrib import admin
+from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+from openwisp_utils.admin_theme.filters import SimpleInputFilter
+
+from . import settings as app_settings
+
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+
+
+class SubnetFilter(SimpleInputFilter):
+    parameter_name = 'subnet'
+    title = _('subnet')
+
+    def queryset(self, request, queryset):
+        if self.value() is not None:
+            master_subnet_key = (
+                'config__subnetdivisionindex__subnet__master_subnet__subnet'
+            )
+            return queryset.filter(
+                Q(**{master_subnet_key: self.value()})
+                | Q(config__subnetdivisionindex__subnet__subnet=self.value())
+            ).distinct()
+
+
+class DeviceFilter(SimpleInputFilter):
+    """
+    Filters Subnet queryset for input device name
+    using SubnetDivisionIndex
+    """
+
+    parameter_name = 'device'
+    title = _('device name')
+
+    def queryset(self, request, queryset):
+        if self.value() is not None:
+            return queryset.filter(
+                id__in=SubnetDivisionIndex.objects.filter(
+                    config__device__name=self.value()
+                ).values_list('subnet_id')
+            )
+
+
+class SubnetListFilter(admin.RelatedFieldListFilter):
+    def field_choices(self, field, request, model_admin):
+        if app_settings.HIDE_GENERATED_SUBNETS and field.name == 'subnet':
+            return field.get_choices(
+                include_blank=False,
+                limit_choices_to={
+                    'id__in': Subnet.objects.exclude(
+                        id__in=SubnetDivisionIndex.objects.filter(
+                            ip__isnull=True, subnet__isnull=False
+                        ).values_list('subnet_id')
+                    )
+                },
+            )
+        choices = super().field_choices(field, request, model_admin)
+        return choices
diff --git a/openwisp_controller/subnet_division/migrations/0001_initial.py b/openwisp_controller/subnet_division/migrations/0001_initial.py
new file mode 100644
index 000000000..b222f9712
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0001_initial.py
@@ -0,0 +1,189 @@
+# Generated by Django 3.1.7 on 2021-03-08 01:37
+
+import uuid
+
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+from django.conf import settings
+from django.db import migrations, models
+
+from .. import settings as app_settings
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.CONFIG_CONFIG_MODEL),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SubnetDivisionRule',
+            fields=[
+                (
+                    'id',
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                (
+                    'created',
+                    model_utils.fields.AutoCreatedField(
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name='created',
+                    ),
+                ),
+                (
+                    'modified',
+                    model_utils.fields.AutoLastModifiedField(
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name='modified',
+                    ),
+                ),
+                (
+                    'type',
+                    models.CharField(
+                        choices=app_settings.SUBNET_DIVISION_TYPES, max_length=200
+                    ),
+                ),
+                (
+                    'master_subnet',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                    ),
+                ),
+                (
+                    'label',
+                    models.CharField(
+                        help_text='Label used to calculate the configuration variables',
+                        max_length=30,
+                    ),
+                ),
+                (
+                    'number_of_subnets',
+                    models.PositiveIntegerField(
+                        help_text='Indicates how many subnets will be created',
+                        verbose_name='Number of Subnets',
+                    ),
+                ),
+                (
+                    'size',
+                    models.PositiveIntegerField(
+                        help_text='Indicates the size of each created subnet',
+                        verbose_name='Size of subnets',
+                    ),
+                ),
+                (
+                    'number_of_ips',
+                    models.PositiveIntegerField(
+                        help_text=(
+                            'Indicates how many IP addresses will '
+                            'be created for each subnet'
+                        ),
+                        verbose_name='Number of IPs',
+                    ),
+                ),
+                (
+                    'organization',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='openwisp_users.organization',
+                        verbose_name='organization',
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+                'swappable': 'SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL',
+            },
+        ),
+        migrations.CreateModel(
+            name='SubnetDivisionIndex',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                ('keyword', models.CharField(max_length=30)),
+                (
+                    'config',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.CONFIG_CONFIG_MODEL,
+                    ),
+                ),
+                (
+                    'ip',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+                    ),
+                ),
+                (
+                    'rule',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL,
+                    ),
+                ),
+                (
+                    'subnet',
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+                'swappable': 'SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL',
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionrule',
+            constraint=models.UniqueConstraint(
+                fields=('organization', 'label'),
+                name='unique_subnet_division_rule_label',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionrule',
+            constraint=models.UniqueConstraint(
+                fields=('organization', 'label', 'type', 'master_subnet'),
+                name='unique_subnet_division_rule',
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='subnetdivisionindex',
+            index=models.Index(
+                fields=['keyword'], name='subnet_divi_keyword_c76db3_idx'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='subnetdivisionindex',
+            constraint=models.UniqueConstraint(
+                fields=('keyword', 'subnet', 'ip', 'config'),
+                name='unique_subnet_division_index',
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/0002_default_group_migration.py b/openwisp_controller/subnet_division/migrations/0002_default_group_migration.py
new file mode 100644
index 000000000..e269a4434
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0002_default_group_migration.py
@@ -0,0 +1,13 @@
+from django.db import migrations
+
+from . import assign_permissions_to_groups
+
+
+class Migration(migrations.Migration):
+    dependencies = [('subnet_division', '0001_initial')]
+
+    operations = [
+        migrations.RunPython(
+            assign_permissions_to_groups, reverse_code=migrations.RunPython.noop
+        )
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py b/openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py
new file mode 100644
index 000000000..f8631f1b6
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.1.12 on 2021-06-23 18:01
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.CONFIG_CONFIG_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL),
+        migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL),
+        ('subnet_division', '0002_default_group_migration'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='config',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.CONFIG_CONFIG_MODEL,
+            ),
+        ),
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='ip',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.OPENWISP_IPAM_IPADDRESS_MODEL,
+            ),
+        ),
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='subnet',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.OPENWISP_IPAM_SUBNET_MODEL,
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py b/openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py
new file mode 100644
index 000000000..b9b1911d0
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.13 on 2021-09-27 19:25
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('subnet_division', '0003_related_field_allow_blank'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='subnetdivisionindex',
+            name='rule',
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL,
+            ),
+        ),
+    ]
diff --git a/openwisp_controller/subnet_division/migrations/__init__.py b/openwisp_controller/subnet_division/migrations/__init__.py
new file mode 100644
index 000000000..7b127fb5b
--- /dev/null
+++ b/openwisp_controller/subnet_division/migrations/__init__.py
@@ -0,0 +1,33 @@
+from django.contrib.auth.models import Permission
+
+from ...migrations import create_default_permissions, get_swapped_model
+
+
+def assign_permissions_to_groups(apps, schema_editor):
+    create_default_permissions(apps, schema_editor)
+    operators_and_admins_can_manage = ['subnetdivisionrule']
+    admin_manage_operations = ['add', 'change', 'delete', 'view']
+    operator_manage_operations = ['view']
+    Group = get_swapped_model(apps, 'openwisp_users', 'Group')
+
+    try:
+        admin = Group.objects.get(name='Administrator')
+        operator = Group.objects.get(name='Operator')
+    # consider failures custom cases
+    # that do not have to be dealt with
+    except Group.DoesNotExist:
+        return
+
+    for model_name in operators_and_admins_can_manage:
+        for operation in admin_manage_operations:
+            permission = Permission.objects.get(
+                codename='{}_{}'.format(operation, model_name)
+            )
+            admin.permissions.add(permission.pk)
+
+    for model_name in operators_and_admins_can_manage:
+        for operation in operator_manage_operations:
+            permission = Permission.objects.get(
+                codename='{}_{}'.format(operation, model_name)
+            )
+            operator.permissions.add(permission.pk)
diff --git a/openwisp_controller/subnet_division/models.py b/openwisp_controller/subnet_division/models.py
new file mode 100644
index 000000000..e713ef165
--- /dev/null
+++ b/openwisp_controller/subnet_division/models.py
@@ -0,0 +1,15 @@
+from swapper import swappable_setting
+
+from .base.models import AbstractSubnetDivisionIndex, AbstractSubnetDivisionRule
+
+
+class SubnetDivisionRule(AbstractSubnetDivisionRule):
+    class Meta(AbstractSubnetDivisionRule.Meta):
+        abstract = False
+        swappable = swappable_setting('subnet_division', 'SubnetDivisionRule')
+
+
+class SubnetDivisionIndex(AbstractSubnetDivisionIndex):
+    class Meta(AbstractSubnetDivisionIndex.Meta):
+        abstract = False
+        swappable = swappable_setting('subnet_division', 'SubnetDivisionIndex')
diff --git a/openwisp_controller/subnet_division/rule_types/__init__.py b/openwisp_controller/subnet_division/rule_types/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openwisp_controller/subnet_division/rule_types/base.py b/openwisp_controller/subnet_division/rule_types/base.py
new file mode 100644
index 000000000..2992edd1e
--- /dev/null
+++ b/openwisp_controller/subnet_division/rule_types/base.py
@@ -0,0 +1,255 @@
+import logging
+from ipaddress import ip_network
+from operator import attrgetter
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import transaction
+from django.dispatch import Signal
+from django.utils.translation import gettext_lazy as _
+from netaddr import IPNetwork
+from swapper import load_model
+
+from ..signals import subnet_provisioned
+
+logger = logging.getLogger(__name__)
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+VpnClient = load_model('config', 'VpnClient')
+
+
+class BaseSubnetDivisionRuleType(object):
+    provision_signal = None
+    provision_sender = None
+    provision_dispatch_uid = None
+
+    destroyer_signal = None
+    destroyer_sender = None
+    destroyer_dispatch_uid = None
+
+    organization_id_path = None
+    subnet_path = None
+    config_path = 'config'
+
+    @classmethod
+    def validate_rule_type(cls):
+        assert issubclass(cls, BaseSubnetDivisionRuleType)
+
+        assert isinstance(cls.provision_signal, Signal)
+        assert isinstance(cls.provision_dispatch_uid, str)
+        cls.provision_sender = load_model(*cls.provision_sender)
+
+        assert isinstance(cls.destroyer_signal, Signal)
+        assert isinstance(cls.destroyer_dispatch_uid, str)
+        cls.destroyer_sender = load_model(*cls.destroyer_sender)
+
+        assert isinstance(cls.organization_id_path, str)
+        assert isinstance(cls.subnet_path, str)
+
+    @classmethod
+    def provision_receiver(cls, instance, **kwargs):
+        def _provision_receiver():
+            # If any of following operations fail, the database transaction
+            # should fail/rollback.
+
+            # This method is also called by "provision_for_existing_objects"
+            # which passes the "rule" keyword argument. In such case,
+            # provisioning should be only triggered for received rule.
+            if 'rule' in kwargs:
+                rules = [kwargs['rule']]
+            else:
+                try:
+                    rules = cls.get_subnet_division_rules(instance)
+                except (AttributeError, ObjectDoesNotExist):
+                    return
+            for rule in rules:
+                provisioned = cls.create_subnets_ips(instance, rule, **kwargs)
+                cls.post_provision_handler(instance, provisioned, **kwargs)
+                cls.subnet_provisioned_signal_emitter(instance, provisioned)
+
+        if not cls.should_create_subnets_ips(instance, **kwargs):
+            return
+
+        transaction.on_commit(_provision_receiver)
+
+    @classmethod
+    def destroyer_receiver(cls, instance, **kwargs):
+        cls.destroy_provisioned_subnets_ips(instance, **kwargs)
+
+    @staticmethod
+    def post_provision_handler(instance, provisioned, **kwargs):
+        """
+        This method should be overridden in inherited rule types to
+        perform any operation on provisioned subnets and IP addresses.
+        :param instance: object that triggered provisioning
+        :param provisioned: dictionary containing subnets and IP addresses
+            provisioned, None if nothing is provisioned
+        """
+        pass
+
+    @staticmethod
+    def subnet_provisioned_signal_emitter(instance, provisioned):
+        subnet_provisioned.send(
+            sender=SubnetDivisionRule, instance=instance, provisioned=provisioned
+        )
+
+    @classmethod
+    def should_create_subnets_ips(cls, instance, **kwargs):
+        """
+        return a boolean value whether subnets and IPs should
+        be provisioned for "instance" object
+        """
+        raise NotImplementedError()
+
+    @classmethod
+    def provision_for_existing_objects(cls, rule_obj):
+        """
+        Contains logic to trigger provisioning for existing objects
+        """
+        raise NotImplementedError()
+
+    @classmethod
+    def create_subnets_ips(cls, instance, division_rule, **kwargs):
+        try:
+            config = cls.get_config(instance)
+        except (AttributeError, ObjectDoesNotExist):
+            return
+
+        master_subnet = division_rule.master_subnet
+        max_subnet = cls.get_max_subnet(master_subnet, division_rule)
+        generated_indexes = []
+        generated_subnets = cls.create_subnets(
+            config, division_rule, max_subnet, generated_indexes
+        )
+        generated_ips = cls.create_ips(
+            config, division_rule, generated_subnets, generated_indexes
+        )
+        SubnetDivisionIndex.objects.bulk_create(generated_indexes)
+        return {'subnets': generated_subnets, 'ip_addresses': generated_ips}
+
+    @classmethod
+    def get_organization(cls, instance):
+        return attrgetter(cls.organization_id_path)(instance)
+
+    @classmethod
+    def get_subnet(cls, instance):
+        return attrgetter(cls.subnet_path)(instance)
+
+    @classmethod
+    def get_subnet_division_rules(cls, instance):
+        rule_type = f'{cls.__module__}.{cls.__name__}'
+        organization_id = cls.get_organization(instance)
+        subnet = cls.get_subnet(instance)
+        return subnet.subnetdivisionrule_set.filter(
+            organization_id__in=(organization_id, None),
+            type=rule_type,
+        ).iterator()
+
+    @classmethod
+    def get_config(cls, instance):
+        if cls.config_path == 'self':
+            return instance
+        else:
+            return attrgetter(cls.config_path)(instance)
+
+    @staticmethod
+    def get_max_subnet(master_subnet, division_rule):
+        try:
+            max_subnet = (
+                # Get the highest subnet created for this master_subnet
+                Subnet.objects.filter(master_subnet_id=master_subnet.id)
+                .order_by('-created')
+                .first()
+                .subnet
+            )
+        except AttributeError:
+            # If there is no existing subnet, create a reserved subnet
+            # and use it as starting point
+            required_subnet = next(
+                IPNetwork(str(master_subnet.subnet)).subnet(
+                    prefixlen=division_rule.size
+                )
+            )
+            subnet_obj = Subnet(
+                name=f'Reserved Subnet {required_subnet}',
+                subnet=str(required_subnet),
+                description=_('Automatically generated reserved subnet.'),
+                master_subnet_id=master_subnet.id,
+                organization_id=master_subnet.organization_id,
+            )
+            subnet_obj.full_clean()
+            subnet_obj.save()
+            max_subnet = subnet_obj.subnet
+        finally:
+            return max_subnet
+
+    @staticmethod
+    def create_subnets(config, division_rule, max_subnet, generated_indexes):
+        master_subnet = division_rule.master_subnet
+        required_subnet = IPNetwork(str(max_subnet)).next()
+        generated_subnets = []
+
+        for subnet_id in range(1, division_rule.number_of_subnets + 1):
+            if not ip_network(str(required_subnet)).subnet_of(master_subnet.subnet):
+                logger.error(f'Cannot create more subnets of {master_subnet}')
+                break
+            subnet_obj = Subnet(
+                name=f'{division_rule.label}_subnet{subnet_id}',
+                subnet=str(required_subnet),
+                description=_(
+                    f'Automatically generated using {division_rule.label} rule.'
+                ),
+                master_subnet_id=master_subnet.id,
+                organization_id=division_rule.organization_id,
+            )
+            subnet_obj.full_clean()
+            generated_subnets.append(subnet_obj)
+            generated_indexes.append(
+                SubnetDivisionIndex(
+                    keyword=f'{division_rule.label}_subnet{subnet_id}',
+                    subnet_id=subnet_obj.id,
+                    rule_id=division_rule.id,
+                    config=config,
+                )
+            )
+            required_subnet = required_subnet.next()
+        Subnet.objects.bulk_create(generated_subnets)
+        return generated_subnets
+
+    @staticmethod
+    def create_ips(config, division_rule, generated_subnets, generated_indexes):
+        generated_ips = []
+        for subnet_obj in generated_subnets:
+            for ip_id in range(1, division_rule.number_of_ips + 1):
+                ip_obj = IpAddress(
+                    subnet_id=subnet_obj.id,
+                    ip_address=str(subnet_obj.subnet[ip_id]),
+                )
+                ip_obj.full_clean()
+                generated_ips.append(ip_obj)
+
+                generated_indexes.append(
+                    SubnetDivisionIndex(
+                        keyword=f'{subnet_obj.name}_ip{ip_id}',
+                        subnet_id=subnet_obj.id,
+                        ip_id=ip_obj.id,
+                        rule_id=division_rule.id,
+                        config=config,
+                    )
+                )
+
+        IpAddress.objects.bulk_create(generated_ips)
+        return generated_ips
+
+    @classmethod
+    def destroy_provisioned_subnets_ips(cls, instance, **kwargs):
+        # Deleting related subnets automatically deletes related IpAddress
+        # and SubnetDivisionIndex objects
+        config = cls.get_config(instance)
+        rule_type = f'{cls.__module__}.{cls.__name__}'
+        subnet_ids = config.subnetdivisionindex_set.filter(
+            rule__type=rule_type
+        ).values_list('subnet_id')
+        Subnet.objects.filter(id__in=subnet_ids).delete()
diff --git a/openwisp_controller/subnet_division/rule_types/device.py b/openwisp_controller/subnet_division/rule_types/device.py
new file mode 100644
index 000000000..2fceb3c4d
--- /dev/null
+++ b/openwisp_controller/subnet_division/rule_types/device.py
@@ -0,0 +1,52 @@
+from django.db.models.signals import post_delete, post_save
+from swapper import load_model
+
+from .base import BaseSubnetDivisionRuleType
+
+Config = load_model('config', 'Config')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+
+
+class DeviceSubnetDivisionRuleType(BaseSubnetDivisionRuleType):
+    provision_signal = post_save
+    provision_sender = ('config', 'Config')
+    provision_dispatch_uid = 'device_registered_provision_subnet'
+
+    destroyer_signal = post_delete
+    destroyer_sender = ('config', 'Config')
+    destroyer_dispatch_uid = 'device_registered_destroy_subnet'
+
+    organization_id_path = 'device.organization_id'
+    subnet_path = ''
+    config_path = 'self'
+
+    @classmethod
+    def get_subnet(cls, instance):
+        pass
+
+    @classmethod
+    def get_subnet_division_rules(cls, instance):
+        rule_type = f'{cls.__module__}.{cls.__name__}'
+        return instance.device.organization.subnetdivisionrule_set.filter(
+            type=rule_type
+        ).iterator()
+
+    @classmethod
+    def should_create_subnets_ips(cls, instance, **kwargs):
+        return kwargs.get('created', False)
+
+    @staticmethod
+    def destroy_provisioned_subnets_ips(instance, **kwargs):
+        # Deleting related subnets automatically deletes related IpAddress
+        # and SubnetDivisionIndex objects
+        subnet_ids = instance.subnetdivisionindex_set.values_list('subnet_id')
+        Subnet.objects.filter(id__in=subnet_ids).delete()
+
+    @classmethod
+    def provision_for_existing_objects(cls, rule_obj):
+        for config in (
+            Config.objects.select_related('device', 'device__organization')
+            .filter(device__organization_id=rule_obj.organization_id)
+            .iterator()
+        ):
+            cls.provision_receiver(config, created=True, rule=rule_obj)
diff --git a/openwisp_controller/subnet_division/rule_types/vpn.py b/openwisp_controller/subnet_division/rule_types/vpn.py
new file mode 100644
index 000000000..e4407146e
--- /dev/null
+++ b/openwisp_controller/subnet_division/rule_types/vpn.py
@@ -0,0 +1,53 @@
+from django.db.models import Q
+from django.db.models.signals import post_delete, post_save
+from swapper import load_model
+
+from .base import BaseSubnetDivisionRuleType
+
+Vpn = load_model('config', 'Vpn')
+VpnClient = load_model('config', 'VpnClient')
+
+
+class VpnSubnetDivisionRuleType(BaseSubnetDivisionRuleType):
+    provision_signal = post_save
+    provision_sender = ('config', 'VpnClient')
+    provision_dispatch_uid = 'vpn_client_provision_subnet'
+
+    destroyer_signal = post_delete
+    destroyer_sender = provision_sender
+    destroyer_dispatch_uid = 'vpn_client_destroy_subnet'
+
+    organization_id_path = 'config.device.organization_id'
+    subnet_path = 'vpn.subnet'
+
+    @classmethod
+    def should_create_subnets_ips(cls, instance, **kwargs):
+        return kwargs.get('created', False)
+
+    @classmethod
+    def provision_for_existing_objects(cls, rule_obj):
+        organization_filter = Q(organization_id=rule_obj.organization_id) | Q(
+            organization_id=None
+        )
+        vpn_qs = (
+            Vpn.objects.filter(subnet=rule_obj.master_subnet)
+            .filter(organization_filter)
+            .values_list('id')
+        )
+        qs = VpnClient.objects.filter(
+            vpn__in=vpn_qs, config__device__organization_id=rule_obj.organization_id
+        )
+        for vpn_client in qs:
+            cls.provision_receiver(instance=vpn_client, created=True)
+
+    @staticmethod
+    def post_provision_handler(instance, provisioned, **kwargs):
+        # Assign the first provisioned IP address to the VPNClient
+        # only when subnets and IPs have been provisioned
+        if provisioned and provisioned['ip_addresses']:
+            # Delete any previously assigned IP address
+            if instance.ip:
+                instance.ip.delete()
+            instance.ip = provisioned['ip_addresses'][0]
+            instance.full_clean()
+            instance.save()
diff --git a/openwisp_controller/subnet_division/settings.py b/openwisp_controller/subnet_division/settings.py
new file mode 100644
index 000000000..3de884393
--- /dev/null
+++ b/openwisp_controller/subnet_division/settings.py
@@ -0,0 +1,28 @@
+from django.conf import settings
+
+SUBNET_DIVISION_TYPES = getattr(
+    settings,
+    'OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES',
+    (
+        (
+            (
+                'openwisp_controller.subnet_division.rule_types.'
+                'vpn.VpnSubnetDivisionRuleType'
+            ),
+            'VPN',
+        ),
+        (
+            (
+                'openwisp_controller.subnet_division.rule_types.'
+                'device.DeviceSubnetDivisionRuleType'
+            ),
+            'Device',
+        ),
+    ),
+)
+
+HIDE_GENERATED_SUBNETS = getattr(
+    settings,
+    'OPENWISP_CONTROLLER_HIDE_AUTOMATICALLY_GENERATED_SUBNETS_AND_IPS',
+    False,
+)
diff --git a/openwisp_controller/subnet_division/signals.py b/openwisp_controller/subnet_division/signals.py
new file mode 100644
index 000000000..f0e205b12
--- /dev/null
+++ b/openwisp_controller/subnet_division/signals.py
@@ -0,0 +1,6 @@
+from django.dispatch import Signal
+
+subnet_provisioned = Signal()
+subnet_provisioned.__doc__ = """
+Providing arguments: ['instance', 'provisioned']
+"""
diff --git a/openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css b/openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css
new file mode 100644
index 000000000..d8c56f092
--- /dev/null
+++ b/openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css
@@ -0,0 +1,14 @@
+input.readonly {
+  border: 1px solid rgba(0, 0, 0, 0.05) !important;
+  background-color: rgba(0, 0, 0, 0.07);
+}
+.help-text-warning {
+  background-color: #ffe5e5;
+  padding: 5px 10px;
+  font-size: 15px;
+  font-weight: bolder;
+  display: flex;
+}
+.help-text-warning img {
+  min-width: 30px;
+}
diff --git a/openwisp_controller/subnet_division/static/subnet-division/js/subnet-division.js b/openwisp_controller/subnet_division/static/subnet-division/js/subnet-division.js
new file mode 100644
index 000000000..f2edb9038
--- /dev/null
+++ b/openwisp_controller/subnet_division/static/subnet-division/js/subnet-division.js
@@ -0,0 +1,51 @@
+'use strict';
+
+if (typeof gettext === 'undefined') {
+    var gettext = function (word) {
+        return word;
+    };
+}
+
+django.jQuery(function ($) {
+    if ($('#subnetdivisionrule_set-group').length === 0) {
+        return;
+    }
+    // Do not allow decreasing number_of_ips
+    $('input[name$="-number_of_ips"]:visible').each(function (index, el) {
+        if (($(el).val() !== '') && ($(el).attr('min') === "0")) {
+            $(el).attr('min', $(el).val());
+        }
+    });
+
+    // Disable size and number_of_subnets fields for existing rules
+    $('.inline-related.dynamic-subnetdivisionrule_set:visible').each(function (index, el) {
+        // Delete link appears only on unsaved rules.
+        if ($(el).find('.inline-deletelink').length === 0) {
+            $(el).find('input[name$="-size"]').prop('readonly', true);
+            $(el).find('input[name$="-size"]').addClass('readonly');
+            $(el).find('input[name$="-number_of_subnets"]').prop('readonly', true);
+            $(el).find('input[name$="-number_of_subnets"]').addClass('readonly');
+        }
+    });
+
+    // If subnet is not shared, hide organization field from Subnet Division Rule
+    function hideOrganizationFieldForNonSharedSubnet() {
+        if ($('#id_organization').val() !== '') {
+            $('#subnetdivisionrule_set-group select[name$="-organization"]').each(
+                function (index, element) {
+                    element = $(element);
+                    if ((element.val() === '') || (element.val() === $('#id_organization').val())) {
+                        element.val($('#id_organization').val());
+                        element.parent().parent().parent().hide();
+                    } else {
+                        element.parent().parent().parent().show();
+                    }
+                });
+        } else {
+            $('#subnetdivisionrule_set-group .form-row.field-organization').show();
+        }
+    }
+    hideOrganizationFieldForNonSharedSubnet();
+    $('#subnetdivisionrule_set-group .add-row a').click(hideOrganizationFieldForNonSharedSubnet);
+    $('#id_organization').change(hideOrganizationFieldForNonSharedSubnet);
+});
diff --git a/openwisp_controller/subnet_division/tasks.py b/openwisp_controller/subnet_division/tasks.py
new file mode 100644
index 000000000..51ecfb7cb
--- /dev/null
+++ b/openwisp_controller/subnet_division/tasks.py
@@ -0,0 +1,138 @@
+import logging
+
+from celery import shared_task
+from django.db import transaction
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+from openwisp_utils.tasks import OpenwispCeleryTask
+
+logger = logging.getLogger(__name__)
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+IpAddress = load_model('openwisp_ipam', 'IpAddress')
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Config = load_model('config', 'Config')
+Vpn = load_model('config', 'Vpn')
+VpnClient = load_model('config', 'VpnClient')
+
+
+@shared_task
+def update_subnet_division_index(rule_id):
+    try:
+        division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as e:
+        logger.warning(
+            'Failed to update indexes for Subnet Division Rule '
+            f'with id: "{rule_id}", reason: {e}'
+        )
+        return
+
+    for index in division_rule.subnetdivisionindex_set.only('keyword').iterator():
+        identifiers = index.keyword.split('_')
+        if index.ip_id is not None:
+            required_identifiers = 2
+        else:
+            required_identifiers = 1
+        index.keyword = '_'.join(
+            [division_rule.label] + identifiers[-required_identifiers:]
+        )
+        index.save()
+
+
+@shared_task
+def update_subnet_name_description(rule_id):
+    try:
+        division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as e:
+        logger.warning(
+            'Failed to update subnets related to Subnet Division Rule '
+            f'with id: "{rule_id}", reason: {e}'
+        )
+        return
+
+    related_subnet_ids = division_rule.subnetdivisionindex_set.filter(
+        subnet_id__isnull=False,
+        ip_id__isnull=True,
+    ).values_list('subnet_id')
+    subnet_queryset = Subnet.objects.filter(id__in=related_subnet_ids)
+
+    for subnet in subnet_queryset:
+        identifiers = subnet.name.split('_')
+        identifiers[0] = division_rule.label
+        subnet.name = '_'.join(identifiers)
+        subnet.description = _(
+            f'Automatically generated using {division_rule.label} rule.'
+        )
+
+    Subnet.objects.bulk_update(
+        subnet_queryset, fields=['name', 'description'], batch_size=20
+    )
+
+
+@shared_task(base=OpenwispCeleryTask)
+def provision_extra_ips(rule_id, old_number_of_ips):
+    def _create_ipaddress_and_subnetdivision_index_objects(ips, indexes):
+        IpAddress.objects.bulk_create(ips)
+        SubnetDivisionIndex.objects.bulk_create(indexes)
+
+    generated_ips = []
+    generated_indexes = []
+
+    try:
+        division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as e:
+        logger.warning(
+            'Failed to provision extra IPs for Subnet Division Rule '
+            f'with id: "{rule_id}", reason: {e}'
+        )
+        return
+
+    index_queryset = division_rule.subnetdivisionindex_set.filter(
+        subnet_id__isnull=False,
+        config_id__isnull=False,
+        ip_id__isnull=True,
+    ).select_related('subnet')
+
+    starting_ip_id = old_number_of_ips + 1
+    ending_ip_id = division_rule.number_of_ips + 1
+
+    for index in index_queryset:
+        subnet = index.subnet
+        for ip_id in range(starting_ip_id, ending_ip_id):
+            ip = IpAddress(
+                subnet_id=subnet.id,
+                ip_address=str(subnet.subnet[ip_id]),
+            )
+
+            generated_ips.append(ip)
+            generated_indexes.append(
+                SubnetDivisionIndex(
+                    keyword=f'{division_rule.label}_subnet{subnet.id}_ip{ip_id}',
+                    subnet_id=subnet.id,
+                    ip_id=ip.id,
+                    rule_id=division_rule.id,
+                    config_id=index.config_id,
+                )
+            )
+
+    transaction.on_commit(
+        lambda: _create_ipaddress_and_subnetdivision_index_objects(
+            generated_ips, generated_indexes
+        )
+    )
+
+
+@shared_task
+def provision_subnet_ip_for_existing_devices(rule_id):
+    try:
+        rule = SubnetDivisionRule.objects.get(id=rule_id)
+    except SubnetDivisionRule.DoesNotExist as error:
+        logger.warning(
+            'Failed to provision IPs on existing devices for Subnet '
+            f'Division Rule with id: "{rule_id}", reason: {error}'
+        )
+        return
+    else:
+        rule.rule_class.provision_for_existing_objects(rule)
diff --git a/openwisp_controller/subnet_division/tests/__init__.py b/openwisp_controller/subnet_division/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openwisp_controller/subnet_division/tests/helpers.py b/openwisp_controller/subnet_division/tests/helpers.py
new file mode 100644
index 000000000..db139e412
--- /dev/null
+++ b/openwisp_controller/subnet_division/tests/helpers.py
@@ -0,0 +1,149 @@
+from django.db import connections
+from django.db.utils import DEFAULT_DB_ALIAS
+from django.test.testcases import _AssertNumQueriesContext
+from netaddr import IPNetwork
+from openwisp_ipam.tests import CreateModelsMixin as SubnetIpamMixin
+from swapper import load_model
+
+from openwisp_controller.subnet_division.rule_types.device import (
+    DeviceSubnetDivisionRuleType,
+)
+
+from ...config.tests.utils import CreateConfigTemplateMixin, TestWireguardVpnMixin
+from ..rule_types.vpn import VpnSubnetDivisionRuleType
+
+SubnetDivisionRule = load_model('subnet_division', 'SubnetDivisionRule')
+SubnetDivisionIndex = load_model('subnet_division', 'SubnetDivisionIndex')
+Subnet = load_model('openwisp_ipam', 'Subnet')
+
+
+class SubnetDivisionTestMixin(
+    CreateConfigTemplateMixin, TestWireguardVpnMixin, SubnetIpamMixin
+):
+    @property
+    def subnet_query(self):
+        return Subnet.objects.exclude(name__contains='Reserved')
+
+    def _create_subnet_division_rule(self, **kwargs):
+        options = dict()
+        options.update(self._get_extra_fields(**kwargs))
+        options.update(kwargs)
+        instance = SubnetDivisionRule(**options)
+        instance.full_clean()
+        instance.save()
+        return instance
+
+    def _get_subnet_division_rule(self, type, **kwargs):
+        options = {
+            'label': 'OW',
+            'size': 28,
+            'number_of_ips': 2,
+            'number_of_subnets': 2,
+            'type': type,
+        }
+        options.update(**kwargs)
+        if 'master_subnet' not in kwargs:
+            options['master_subnet'] = self._get_master_subnet()
+        return self._create_subnet_division_rule(**options)
+
+    def _get_vpn_subdivision_rule(self, **kwargs):
+        path = (
+            f'{VpnSubnetDivisionRuleType.__module__}.'
+            f'{VpnSubnetDivisionRuleType.__name__}'
+        )
+        return self._get_subnet_division_rule(type=path, **kwargs)
+
+    def _get_device_subdivision_rule(self, **kwargs):
+        path = (
+            f'{DeviceSubnetDivisionRuleType.__module__}.'
+            f'{DeviceSubnetDivisionRuleType.__name__}'
+        )
+        return self._get_subnet_division_rule(type=path, **kwargs)
+
+    def _get_master_subnet(self, subnet='10.0.0.0/16', **kwargs):
+        try:
+            return Subnet.objects.get(subnet=subnet, **kwargs)
+        except Subnet.DoesNotExist:
+            return self._create_subnet(subnet=subnet, **kwargs)
+
+    def _mock_subnet_division_rule(self, config, master_subnet, rule):
+        """
+        Imitates triggering of subnet division rule and provisions subnets.
+        Useful when subnet division rules are not triggered due to
+        working of django.test.TestCase class.
+        """
+        try:
+            max_subnet = (
+                # Get the highest subnet created for this master_subnet
+                Subnet.objects.filter(master_subnet_id=master_subnet.id)
+                .order_by('-created')
+                .first()
+                .subnet
+            )
+        except AttributeError:
+            # If there is no existing subnet, create a reserved subnet
+            # and use it as starting point
+            required_subnet = next(
+                IPNetwork(str(master_subnet.subnet)).subnet(prefixlen=32)
+            )
+        else:
+            required_subnet = IPNetwork(str(max_subnet)).next()
+
+        subnet = self._create_subnet(
+            organization=config.device.organization,
+            subnet=required_subnet,
+            master_subnet=master_subnet,
+            name='TEST_subnet1',
+        )
+        ip = subnet.request_ip()
+        SubnetDivisionIndex.objects.create(
+            rule=rule, config=config, subnet=subnet, keyword='TEST_subnet1'
+        )
+        SubnetDivisionIndex.objects.create(
+            rule=rule,
+            config=config,
+            # subnet=subnet,
+            ip=ip,
+            keyword='TEST_subnet1_ip1',
+        )
+
+
+class SubnetDivisionAdminTestMixin(SubnetDivisionTestMixin):
+    def setUp(self):
+        org = self._get_org()
+        self.master_subnet = self._get_master_subnet()
+        self.config = self._create_config(organization=org)
+        self.rule = self._get_vpn_subdivision_rule(number_of_ips=1, number_of_subnets=1)
+        self._mock_subnet_division_rule(self.config, self.master_subnet, self.rule)
+        admin = self._get_admin()
+        self.client.force_login(admin)
+
+
+class _CustomAssertnumQueriesContext(_AssertNumQueriesContext):
+    def __exit__(self, exc_type, exc_value, traceback):
+        """
+        This method increases the number of expected database
+        queries if subnet_division app is enabled. Tests in
+        "openwisp_controller.config" are written assuming
+        subnet_division is disabled. Therefore, it is required
+        to increase the number of expected queries in those tests.
+        """
+        if exc_type is not None:
+            return
+        for query in self.captured_queries:
+            if 'subnetdivision' in query['sql']:
+                self.num += 1
+        super().__exit__(exc_type, exc_value, traceback)
+
+
+def subnetdivision_patched_assertNumQueries(
+    self, num, func=None, *args, using=DEFAULT_DB_ALIAS, **kwargs
+):
+    conn = connections[using]
+
+    context = _CustomAssertnumQueriesContext(self, num, conn)
+    if func is None:
+        return context
+
+    with context:
+        func(*args, **kwargs)
diff --git a/openwisp_controller/subnet_division/tests/test_admin.py b/openwisp_controller/subnet_division/tests/test_admin.py
new file mode 100644
index 000000000..ae41fd891
--- /dev/null
+++ b/openwisp_controller/subnet_division/tests/test_admin.py
@@ -0,0 +1,184 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from django.urls import reverse
+from swapper import load_model
+
+from openwisp_users.tests.utils import TestMultitenantAdminMixin
+
+from .helpers import SubnetDivisionAdminTestMixin
+
+Subnet = load_model('openwisp_ipam', 'Subnet')
+Device = load_model('config', 'Device')
+
+
+class TestSubnetAdmin(
+    SubnetDivisionAdminTestMixin, TestMultitenantAdminMixin, TestCase
+):
+    ipam_label = 'openwisp_ipam'
+    config_label = 'config'
+
+    def test_related_links(self):
+        device_changelist = reverse(f'admin:{self.config_label}_device_changelist')
+        subnet = self.config.subnetdivisionindex_set.first().subnet
+        url = f'{device_changelist}?subnet={subnet.subnet}'
+        with self.subTest('Test changelist view'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_changelist')
+            )
+            self.assertContains(
+                response,
+                f'{self.config.device.name}',
+            )
+
+        with self.subTest('Test change view'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_change', args=[subnet.pk])
+            )
+            self.assertContains(
+                response,
+                f'{self.config.device.name}',
+            )
+
+    def test_device_filter(self):
+        subnet_changelist = reverse(f'admin:{self.ipam_label}_subnet_changelist')
+        config2 = self._create_config(
+            device=self._create_device(name='device-2', mac_address='00:11:22:33:44:56')
+        )
+        self._mock_subnet_division_rule(config2, self.master_subnet, self.rule)
+        url = f'{subnet_changelist}?device={self.config.device.name}'
+        response = self.client.get(url)
+        self.assertContains(
+            response,
+            self.config.device.name,
+        )
+        self.assertNotContains(response, config2.device.name)
+
+    def test_device_filter_mutitenancy(self):
+        # Create subnet and device for another organization
+        org2 = self._create_org(name='org2')
+        master_subnet2 = self._get_master_subnet(organization=org2)
+        config2 = self._create_config(
+            device=self._create_device(name='org2-device', organization=org2)
+        )
+        rule2 = self._get_vpn_subdivision_rule(
+            number_of_ips=1,
+            number_of_subnets=1,
+            organization=org2,
+            master_subnet=master_subnet2,
+        )
+        self._mock_subnet_division_rule(config2, master_subnet2, rule2)
+        administrator = self._create_administrator([org2])
+        self.client.logout()
+        self.client.force_login(administrator)
+
+        response = self.client.get(
+            reverse(f'admin:{self.ipam_label}_subnet_changelist')
+        )
+        self.assertNotContains(response, self.config.device.name)
+        self.assertContains(response, config2.device.name)
+
+    @patch('openwisp_controller.subnet_division.settings.HIDE_GENERATED_SUBNETS', True)
+    def test_hide_generated_subnets(self):
+        with self.subTest('Test SubnetAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_changelist')
+            )
+            self.assertNotContains(response, f'{self.rule.label}_subnet')
+
+        with self.subTest('Test IpAddressAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_ipaddress_changelist')
+            )
+            self.assertNotContains(response, f'{self.rule.label}_subnet')
+
+    def test_not_hide_generated_subnets(self):
+        with self.subTest('Test SubnetAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_subnet_changelist')
+            )
+            self.assertContains(response, 'TEST_subnet')
+
+        with self.subTest('Test IpAddressAdmin'):
+            response = self.client.get(
+                reverse(f'admin:{self.ipam_label}_ipaddress_changelist')
+            )
+            self.assertContains(response, 'TEST_subnet')
+
+
+class TestIPAdmin(SubnetDivisionAdminTestMixin, TestMultitenantAdminMixin, TestCase):
+    ipam_label = 'openwisp_ipam'
+
+    def test_provisioned_ip_readonly_change_view(self):
+        ip_id = self.rule.subnetdivisionindex_set.filter(ip__isnull=False).first().ip_id
+        response = self.client.get(
+            reverse(f'admin:{self.ipam_label}_ipaddress_change', args=[ip_id])
+        )
+        self.assertNotContains(
+            response, '