diff --git a/Pipfile b/Pipfile index af36b47f4..852de7262 100644 --- a/Pipfile +++ b/Pipfile @@ -11,6 +11,8 @@ coverage = "*" coveralls = "*" isort = "*" flake8 = "*" +mock = "*" +mock-ssh-server = {version = ">=0.5.0,<0.6.0"} [scripts] lint = "python -m flake8" diff --git a/README.rst b/README.rst index 1a5baa474..3b281d007 100755 --- a/README.rst +++ b/README.rst @@ -188,15 +188,75 @@ Add the following settings to ``settings.py``: urlpatterns += staticfiles_urlpatterns() +Settings +-------- + +``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 configuratio 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. + Installing for development -------------------------- -Install sqlite: +Install the dependencies: .. code-block:: shell - sudo apt-get install sqlite3 libsqlite3-dev libsqlite3-mod-spatialite openssl libssl-dev - sudo apt-get install gdal-bin libproj-dev libgeos-dev libspatialite-dev + sudo apt-get install sqlite3 libsqlite3-dev openssl libssl-dev + sudo apt-get install gdal-bin libproj-dev libgeos-dev libspatialite-dev libsqlite3-mod-spatialite + sudo apt-get install redis Install your forked repo with `pipenv `_: @@ -215,6 +275,12 @@ Create database: pipenv run ./manage.py migrate pipenv run ./manage.py createsuperuser +Launch celery worker (for background jobs): + +.. code-block:: shell + + celery -A openwisp2 worker -l info + Launch development server: .. code-block:: shell diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 8fa3a44f6..5f7cddf1e 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -41,6 +41,15 @@ class TestAdmin(CreateConfigTemplateMixin, TestAdminMixin, 'config-INITIAL_FORMS': 0, 'config-MIN_NUM_FORMS': 0, 'config-MAX_NUM_FORMS': 1, + # openwisp_controller.connection + 'deviceconnection_set-TOTAL_FORMS': 0, + 'deviceconnection_set-INITIAL_FORMS': 0, + 'deviceconnection_set-MIN_NUM_FORMS': 0, + 'deviceconnection_set-MAX_NUM_FORMS': 1000, + 'deviceip_set-TOTAL_FORMS': 0, + 'deviceip_set-INITIAL_FORMS': 0, + 'deviceip_set-MIN_NUM_FORMS': 0, + 'deviceip_set-MAX_NUM_FORMS': 1000, } # WARNING - WATCHOUT # this class attribute is changed dinamically diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py index b6cf43583..6a3d1daa7 100644 --- a/openwisp_controller/config/tests/test_controller.py +++ b/openwisp_controller/config/tests/test_controller.py @@ -126,7 +126,7 @@ def test_report_status_404_disabled_org(self): org = self._create_org(is_active=False) c = self._create_config(organization=org) response = self.client.post(reverse('controller:report_status', args=[c.device.pk]), - {'key': c.device.key, 'status': 'running'}) + {'key': c.device.key, 'status': 'applied'}) self.assertEqual(response.status_code, 404) def test_checksum_200(self): diff --git a/openwisp_controller/config/views.py b/openwisp_controller/config/views.py index a63b8cec9..9cabf590f 100644 --- a/openwisp_controller/config/views.py +++ b/openwisp_controller/config/views.py @@ -13,7 +13,7 @@ def get_default_templates(request, organization_id): """ user = request.user authenticated = user.is_authenticated - if callable(authenticated): + if callable(authenticated): # pragma: nocover authenticated = authenticated() if not authenticated and not user.is_staff: return HttpResponse(status=403) diff --git a/openwisp_controller/connection/__init__.py b/openwisp_controller/connection/__init__.py new file mode 100644 index 000000000..07f6254df --- /dev/null +++ b/openwisp_controller/connection/__init__.py @@ -0,0 +1 @@ +default_app_config = 'openwisp_controller.connection.apps.ConnectionConfig' diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py new file mode 100644 index 000000000..41fbd3254 --- /dev/null +++ b/openwisp_controller/connection/admin.py @@ -0,0 +1,49 @@ +from django.contrib import admin + +from openwisp_users.multitenancy import MultitenantOrgFilter +from openwisp_utils.admin import TimeReadonlyAdminMixin + +from ..admin import MultitenantAdminMixin +from ..config.admin import DeviceAdmin +from .models import Credentials, DeviceConnection, DeviceIp + + +@admin.register(Credentials) +class CredentialsAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin): + list_display = ('name', + 'organization', + 'connector', + 'auto_add', + 'created', + 'modified') + list_filter = [('organization', MultitenantOrgFilter), + 'connector'] + list_select_related = ('organization',) + + +class DeviceIpInline(admin.TabularInline): + model = DeviceIp + exclude = ('created', 'modified') + extra = 0 + + def get_queryset(self, request): + qs = super(DeviceIpInline, self).get_queryset(request) + return qs.order_by('priority') + + +class DeviceConnectionInline(MultitenantAdminMixin, admin.StackedInline): + model = DeviceConnection + exclude = ['params', 'created', 'modified'] + readonly_fields = ['is_working', 'failure_reason', 'last_attempt'] + extra = 0 + + multitenant_shared_relations = ('credentials',) + + def get_queryset(self, request): + """ + Override MultitenantAdminMixin.get_queryset() because it breaks + """ + return super(admin.StackedInline, self).get_queryset(request) + + +DeviceAdmin.inlines += [DeviceConnectionInline, DeviceIpInline] diff --git a/openwisp_controller/connection/apps.py b/openwisp_controller/connection/apps.py new file mode 100644 index 000000000..6dc7b9366 --- /dev/null +++ b/openwisp_controller/connection/apps.py @@ -0,0 +1,52 @@ +from celery.task.control import inspect +from django.apps import AppConfig +from django.db.models.signals import post_save +from django.utils.translation import ugettext_lazy as _ +from django_netjsonconfig.signals import config_modified + +_TASK_NAME = 'openwisp_controller.connection.tasks.update_config' + + +class ConnectionConfig(AppConfig): + name = 'openwisp_controller.connection' + label = 'connection' + verbose_name = _('Network Device Credentials') + + def ready(self): + """ + connects the ``config_modified`` signal + to the ``update_config`` celery task + which will be executed in the background + """ + config_modified.connect(self.config_modified_receiver, + dispatch_uid='connection.update_config') + + from ..config.models import Config + from .models import Credentials + + post_save.connect(Credentials.auto_add_credentials_to_device, + sender=Config, + dispatch_uid='connection.auto_add_credentials') + + @classmethod + def config_modified_receiver(cls, **kwargs): + from .tasks import update_config + d = kwargs['device'] + conn_count = d.deviceconnection_set.count() + # if device has no connection specified + # or update is already in progress, stop here + if conn_count < 1 or cls._is_update_in_progress(d.id): + return + update_config.delay(d.id) + + @classmethod + def _is_update_in_progress(cls, device_id): + active = inspect().active() + if not active: + return False + # check if there's any other running task before adding it + for task_list in active.values(): + for task in task_list: + if task['name'] == _TASK_NAME and str(device_id) in task['args']: + return True + return False diff --git a/tests/__init__.py b/openwisp_controller/connection/connectors/__init__.py similarity index 100% rename from tests/__init__.py rename to openwisp_controller/connection/connectors/__init__.py diff --git a/openwisp_controller/connection/connectors/openwrt/__init__.py b/openwisp_controller/connection/connectors/openwrt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openwisp_controller/connection/connectors/openwrt/ssh.py b/openwisp_controller/connection/connectors/openwrt/ssh.py new file mode 100644 index 000000000..1874e6f9e --- /dev/null +++ b/openwisp_controller/connection/connectors/openwrt/ssh.py @@ -0,0 +1,6 @@ +from ..ssh import Ssh + + +class OpenWrt(Ssh): + def update_config(self): + self.exec_command('/etc/init.d/openwisp_config restart') diff --git a/openwisp_controller/connection/connectors/ssh.py b/openwisp_controller/connection/connectors/ssh.py new file mode 100644 index 000000000..86899f7d5 --- /dev/null +++ b/openwisp_controller/connection/connectors/ssh.py @@ -0,0 +1,133 @@ +import logging +import socket +import sys + +import paramiko +from django.utils.functional import cached_property +from jsonschema import validate +from jsonschema.exceptions import ValidationError as SchemaError + +if sys.version_info.major > 2: # pragma: nocover + from io import StringIO +else: # pragma: nocover + from StringIO import StringIO + + +logger = logging.getLogger(__name__) +SSH_CONNECTION_TIMEOUT = 5 +SSH_AUTH_TIMEOUT = 2 +SSH_COMMAND_TIMEOUT = 30 + + +class Ssh(object): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "additionalProperties": False, + "required": ["username"], + "properties": { + "username": {"type": "string"}, + "password": {"type": "string"}, + "key": {"type": "string"}, + "port": {"type": "integer"}, + } + } + + def __init__(self, params, addresses): + self._params = params + self.addresses = addresses + self.shell = paramiko.SSHClient() + self.shell.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + @classmethod + def validate(cls, params): + validate(params, cls.schema) + cls.custom_validation(params) + + @classmethod + def custom_validation(cls, params): + if 'password' not in params and 'key' not in params: + raise SchemaError('Missing password or key') + + @cached_property + def params(self): + params = self._params.copy() + if 'key' in params: + key_fileobj = StringIO(params.pop('key')) + params['pkey'] = paramiko.RSAKey.from_private_key(key_fileobj) + return params + + def connect(self): + success = False + exception = None + for address in self.addresses: + try: + self.shell.connect(address, + timeout=SSH_CONNECTION_TIMEOUT, + auth_timeout=SSH_AUTH_TIMEOUT, + **self.params) + except Exception as e: + exception = e + else: + success = True + break + if not success: + raise exception + + def disconnect(self): + self.shell.close() + + def exec_command(self, command, timeout=SSH_COMMAND_TIMEOUT, + exit_codes=[0], raise_unexpected_exit=True): + """ + Executes a command and performs the following operations + - logs executed command + - logs standard output + - logs standard error + - aborts on exceptions + - raises socket.timeout exceptions + """ + print('$:> {0}'.format(command)) + # execute commmand + try: + stdin, stdout, stderr = self.shell.exec_command(command, + timeout=timeout) + # re-raise socket.timeout to avoid being catched + # by the subsequent `except Exception as e` block + except socket.timeout: + raise socket.timeout() + # any other exception will abort the operation + except Exception as e: + logger.exception(e) + raise e + # store command exit status + exit_status = stdout.channel.recv_exit_status() + # log standard output + output = stdout.read().decode('utf8').strip() + if output: + print(output) + # log standard error + error = stderr.read().decode('utf8').strip() + if error: + print(error) + # abort the operation if any of the command + # returned with a non-zero exit status + if exit_status not in exit_codes and raise_unexpected_exit: + print('# Previus command failed, aborting...') + message = error if error else output + raise Exception(message) + return output, exit_status + + def update_config(self): # pragma: no cover + raise NotImplementedError() + + # TODO: this method is not used yet + # but will be necessary in the future to support other OSes + # def upload(self, fl, remote_path): + # scp = SCPClient(self.shell.get_transport()) + # if not hasattr(fl, 'getvalue'): + # fl_memory = BytesIO(fl.read()) + # fl.seek(0) + # fl = fl_memory + # scp.putfo(fl, remote_path) + # scp.close() diff --git a/openwisp_controller/connection/migrations/0001_initial.py b/openwisp_controller/connection/migrations/0001_initial.py new file mode 100644 index 000000000..2a5f43646 --- /dev/null +++ b/openwisp_controller/connection/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 2.0.5 on 2018-05-05 17:33 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import jsonfield.fields +import model_utils.fields +import openwisp_users.mixins +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('config', '0012_auto_20180219_1501'), + ('openwisp_users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Credentials', + 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')), + ('name', models.CharField(db_index=True, max_length=64, unique=True)), + ('connector', models.CharField(choices=[('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH')], db_index=True, max_length=128, verbose_name='connection type')), + ('params', jsonfield.fields.JSONField(default=dict, help_text='global connection parameters', verbose_name='parameters')), + ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='openwisp_users.Organization', verbose_name='organization')), + ], + options={ + 'verbose_name_plural': 'Access credentials', + 'verbose_name': 'Access credentials', + }, + bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), + ), + migrations.CreateModel( + name='DeviceConnection', + 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')), + ('update_strategy', models.CharField(blank=True, choices=[('openwisp_controller.connection.connectors.openwrt.ssh.OpenWrt', 'OpenWRT SSH')], db_index=True, help_text='leave blank to determine automatically', max_length=128, verbose_name='update strategy')), + ('enabled', models.BooleanField(db_index=True, default=True)), + ('params', jsonfield.fields.JSONField(blank=True, default=dict, help_text='local connection parameters (will override the global parameters if specified)', verbose_name='parameters')), + ('is_working', models.NullBooleanField(default=None)), + ('last_attempt', models.DateTimeField(blank=True, null=True)), + ('failure_reason', models.CharField(blank=True, max_length=128, verbose_name='reason of failure')), + ('credentials', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='connection.Credentials')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='config.Device')), + ], + options={ + 'verbose_name_plural': 'Device connections', + 'verbose_name': 'Device connection', + }, + ), + migrations.CreateModel( + name='DeviceIp', + 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')), + ('address', models.GenericIPAddressField(verbose_name='IP address')), + ('priority', models.PositiveSmallIntegerField()), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='config.Device')), + ], + options={ + 'verbose_name_plural': 'Device IP addresses', + 'verbose_name': 'Device IP', + }, + ), + ] diff --git a/openwisp_controller/connection/migrations/0002_credentials_auto_add.py b/openwisp_controller/connection/migrations/0002_credentials_auto_add.py new file mode 100644 index 000000000..d7373f79e --- /dev/null +++ b/openwisp_controller/connection/migrations/0002_credentials_auto_add.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-12-05 13:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('connection', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='credentials', + name='auto_add', + field=models.BooleanField(default=False, help_text='automatically add these credentials to the devices of this organization; if no organization is specified will be added to all the new devices', verbose_name='auto add'), + ), + ] diff --git a/openwisp_controller/connection/migrations/__init__.py b/openwisp_controller/connection/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openwisp_controller/connection/models.py b/openwisp_controller/connection/models.py new file mode 100644 index 000000000..dfbc63e4b --- /dev/null +++ b/openwisp_controller/connection/models.py @@ -0,0 +1,256 @@ +import collections +import ipaddress +import logging + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible +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_netjsonconfig.base.base import BaseModel +from jsonfield import JSONField +from jsonschema.exceptions import ValidationError as SchemaError + +from openwisp_users.mixins import ShareableOrgMixin +from openwisp_utils.base import TimeStampedEditableModel + +from ..config.models import Device +from . import settings as app_settings +from .utils import get_interfaces + +logger = logging.getLogger(__name__) + + +class ConnectorMixin(object): + _connector_field = 'connector' + + def clean(self): + self._validate_connector_schema() + + def _validate_connector_schema(self): + try: + self.connector_class.validate(self.get_params()) + except SchemaError as e: + raise ValidationError({'params': e.message}) + + def get_params(self): + return self.params + + def get_addresses(self): + return [] + + @cached_property + def connector_class(self): + return import_string(getattr(self, self._connector_field)) + + @cached_property + def connector_instance(self): + return self.connector_class(params=self.get_params(), + addresses=self.get_addresses()) + + +@python_2_unicode_compatible +class Credentials(ConnectorMixin, ShareableOrgMixin, BaseModel): + """ + Credentials for access + """ + connector = models.CharField(_('connection type'), + choices=app_settings.CONNECTORS, + max_length=128, + db_index=True) + params = JSONField(_('parameters'), + default=dict, + help_text=_('global connection parameters'), + load_kwargs={'object_pairs_hook': collections.OrderedDict}, + dump_kwargs={'indent': 4}) + auto_add = models.BooleanField(_('auto add'), + default=False, + help_text=_('automatically add these credentials ' + 'to the devices of this organization; ' + 'if no organization is specified will ' + 'be added to all the new devices')) + + class Meta: + verbose_name = _('Access credentials') + verbose_name_plural = verbose_name + + def __str__(self): + return '{0} ({1})'.format(self.name, self.get_connector_display()) + + def save(self, *args, **kwargs): + super(Credentials, self).save(*args, **kwargs) + self.auto_add_to_devices() + + def auto_add_to_devices(self): + """ + When ``auto_add`` is ``True``, adds the credentials + to each relevant ``Device`` and ``DeviceConnection`` objects + """ + if not self.auto_add: + return + devices = Device.objects.all() + org = self.organization + if org: + devices = devices.filter(organization=org) + # exclude devices which have been already added + devices = devices.exclude(deviceconnection__credentials=self) + for device in devices: + conn = DeviceConnection(device=device, + credentials=self, + enabled=True) + conn.full_clean() + conn.save() + + @classmethod + def auto_add_credentials_to_device(cls, instance, created, **kwargs): + """ + Adds relevant credentials as ``DeviceConnection`` + when a device is created, this is called from a + post_save signal receiver hooked to the ``Config`` model + (why ``Config`` and not ``Device``? because at the moment + we can automatically create a DeviceConnection if we have + a ``Config`` object) + """ + if not created: + return + device = instance.device + # select credentials which + # - are flagged as auto_add + # - 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)) + credentials = cls.objects.filter(conditions) \ + .filter(auto_add=True) + for cred in credentials: + conn = DeviceConnection(device=device, + credentials=cred, + enabled=True) + conn.full_clean() + conn.save() + + +class DeviceConnection(ConnectorMixin, TimeStampedEditableModel): + _connector_field = 'update_strategy' + device = models.ForeignKey('config.Device', on_delete=models.CASCADE) + credentials = models.ForeignKey(Credentials, on_delete=models.CASCADE) + update_strategy = models.CharField(_('update strategy'), + help_text=_('leave blank to determine automatically'), + choices=app_settings.UPDATE_STRATEGIES, + max_length=128, + blank=True, + db_index=True) + enabled = models.BooleanField(default=True, db_index=True) + params = JSONField(_('parameters'), + default=dict, + blank=True, + help_text=_('local connection parameters (will override ' + 'the global parameters if specified)'), + load_kwargs={'object_pairs_hook': collections.OrderedDict}, + dump_kwargs={'indent': 4}) + # usability improvements + is_working = models.NullBooleanField(default=None) + failure_reason = models.CharField(_('reason of failure'), + max_length=128, + blank=True) + last_attempt = models.DateTimeField(blank=True, null=True) + + class Meta: + verbose_name = _('Device connection') + verbose_name_plural = _('Device connections') + + def clean(self): + cred_org = self.credentials.organization + if cred_org and cred_org != self.device.organization: + raise ValidationError({ + 'credentials': _('The organization of these credentials doesn\'t ' + 'match the organization of the device') + }) + if not self.update_strategy and hasattr(self.device, 'config'): + try: + self.update_strategy = app_settings.CONFIG_UPDATE_MAPPING[self.device.config.backend] + except KeyError as e: + raise ValidationError({ + 'update_stragy': _('could not determine update strategy ' + ' automatically, exception: {0}'.format(e)) + }) + elif not self.update_strategy: + raise ValidationError({ + 'update_strategy': _('the update strategy can be determined automatically ' + 'only if the device has a configuration specified, ' + 'because it is inferred from the configuration backend. ' + 'Please select the update strategy manually.') + }) + self._validate_connector_schema() + + def get_addresses(self): + """ + returns a list of ip addresses for the related device + (used to pass a list of ip addresses to a DeviceConnection instance) + """ + deviceip_set = list(self.device.deviceip_set.all() + .only('address') + .order_by('priority')) + address_list = [] + for deviceip in deviceip_set: + address = deviceip.address + ip = ipaddress.ip_address(address) + if not ip.is_link_local: + address_list.append(address) + else: + for interface in get_interfaces(): + address_list.append('{0}%{1}'.format(address, interface)) + if self.device.management_ip: + address_list.append(self.device.management_ip) + if self.device.last_ip: + address_list.append(self.device.last_ip) + return address_list + + def get_params(self): + params = self.credentials.params.copy() + params.update(self.params) + return params + + def connect(self): + try: + self.connector_instance.connect() + except Exception as e: + self.is_working = False + self.failure_reason = str(e) + else: + self.is_working = True + self.failure_reason = '' + finally: + self.last_attempt = timezone.now() + self.save() + + def disconnect(self): + self.connector_instance.disconnect() + + def update_config(self): + self.connect() + if self.is_working: + try: + self.connector_instance.update_config() + except Exception as e: + logger.exception(e) + else: + self.device.config.set_status_applied() + self.disconnect() + + +@python_2_unicode_compatible +class DeviceIp(TimeStampedEditableModel): + device = models.ForeignKey('config.Device', on_delete=models.CASCADE) + address = models.GenericIPAddressField(_('IP address')) + priority = models.PositiveSmallIntegerField() + + class Meta: + verbose_name = _('Device IP') + verbose_name_plural = _('Device IP addresses') + + def __str__(self): + return self.address diff --git a/openwisp_controller/connection/settings.py b/openwisp_controller/connection/settings.py new file mode 100644 index 000000000..5b4f159b4 --- /dev/null +++ b/openwisp_controller/connection/settings.py @@ -0,0 +1,17 @@ +from django.conf import settings + +DEFAULT_CONNECTORS = ( + ('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'), +) + +CONNECTORS = getattr(settings, 'OPENWISP_CONNECTORS', DEFAULT_CONNECTORS) + +DEFAULT_UPDATE_STRATEGIES = ( + ('openwisp_controller.connection.connectors.openwrt.ssh.OpenWrt', 'OpenWRT SSH'), +) + +UPDATE_STRATEGIES = getattr(settings, 'OPENWISP_UPDATE_STRATEGIES', DEFAULT_UPDATE_STRATEGIES) + +CONFIG_UPDATE_MAPPING = getattr(settings, 'OPENWISP_CONFIG_UPDATE_MAPPING', { + 'netjsonconfig.OpenWrt': DEFAULT_UPDATE_STRATEGIES[0][0], +}) diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py new file mode 100644 index 000000000..ccf3151b7 --- /dev/null +++ b/openwisp_controller/connection/tasks.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import, unicode_literals + +from time import sleep + +from celery import shared_task + +from ..config.models import Device + + +@shared_task +def update_config(device_id): + """ + Launches the ``update_config()`` operation + of a specific device in the background + """ + # wait for the saving operations of this device to complete + # (there may be multiple ones happening at the same time) + sleep(4) + # avoid repeating the operation multiple times + device = Device.objects.select_related('config').get(pk=device_id) + if device.config.status == 'applied': + return + qs = device.deviceconnection_set.filter(device_id=device_id, enabled=True) + conn = qs.first() + if conn: + conn.update_config() diff --git a/openwisp_controller/connection/tests/__init__.py b/openwisp_controller/connection/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openwisp_controller/connection/tests/base.py b/openwisp_controller/connection/tests/base.py new file mode 100644 index 000000000..b434ed8ae --- /dev/null +++ b/openwisp_controller/connection/tests/base.py @@ -0,0 +1,101 @@ +import os + +from django.conf import settings +from mockssh.server import Server as BaseSshServer +from openwisp_controller.config.models import Config, Device +from openwisp_controller.config.tests import CreateConfigTemplateMixin + +from openwisp_users.tests.utils import TestOrganizationMixin + +from .. import settings as app_settings +from ..models import Credentials, DeviceConnection, DeviceIp + + +class CreateConnectionsMixin(CreateConfigTemplateMixin, TestOrganizationMixin): + device_model = Device + config_model = Config + + def _create_credentials(self, **kwargs): + opts = dict(name='Test credentials', + connector=app_settings.CONNECTORS[0][0], + params={'username': 'root', + 'password': 'password', + 'port': 22}) + opts.update(kwargs) + if 'organization' not in opts: + opts['organization'] = self._create_org() + c = Credentials(**opts) + c.full_clean() + c.save() + return c + + def _create_credentials_with_key(self, username='root', port=22, **kwargs): + opts = dict(name='Test SSH Key', + params={'username': username, + 'key': self._SSH_PRIVATE_KEY, + 'port': port}) + opts.update(kwargs) + return self._create_credentials(**opts) + + def _create_device_connection(self, **kwargs): + opts = dict(enabled=True, + params={}) + opts.update(kwargs) + if 'credentials' not in opts: + cred_opts = {} + if 'device' in opts: + cred_opts = {'organization': opts['device'].organization} + opts['credentials'] = self._create_credentials(**cred_opts) + org = opts['credentials'].organization + if 'device' not in opts: + opts['device'] = self._create_device(organization=org) + self._create_config(device=opts['device']) + dc = DeviceConnection(**opts) + dc.full_clean() + dc.save() + return dc + + def _create_device_ip(self, **kwargs): + opts = dict(address='10.40.0.1', + priority=1) + opts.update(kwargs) + if 'device' not in opts: + dc = self._create_device_connection() + opts['device'] = dc.device + ip = DeviceIp(**opts) + ip.full_clean() + ip.save() + return ip + + +class SshServer(BaseSshServer): + is_teardown = False + + def _run(self): + try: + super(SshServer, self)._run() + # do not raise exceptions during tear down + except Exception as e: + if not self.is_teardown: + raise e + + +class SshServerMixin(object): + _TEST_RSA_KEY_PATH = os.path.join(settings.BASE_DIR, 'test-key.rsa') + _SSH_PRIVATE_KEY = None + + @classmethod + def setUpClass(cls): + super(SshServerMixin, cls).setUpClass() + with open(cls._TEST_RSA_KEY_PATH, 'r') as f: + cls._SSH_PRIVATE_KEY = f.read() + cls.ssh_server = SshServer({'root': cls._TEST_RSA_KEY_PATH}) + cls.ssh_server.__enter__() + + @classmethod + def tearDownClass(cls): + cls.ssh_server.is_teardown = True + try: + cls.ssh_server.__exit__() + except OSError: + pass diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py new file mode 100644 index 000000000..3133a5c01 --- /dev/null +++ b/openwisp_controller/connection/tests/test_admin.py @@ -0,0 +1,84 @@ +from django.test import TestCase +from django.urls import reverse + +from ...config.models import Template +from ...config.tests.test_admin import TestAdmin as TestConfigAdmin +from ...tests.utils import TestAdminMixin +from ..models import Credentials, DeviceConnection, DeviceIp +from .base import CreateConnectionsMixin, SshServerMixin + + +class TestAdmin(TestAdminMixin, CreateConnectionsMixin, + SshServerMixin, TestCase): + template_model = Template + credentials_model = Credentials + deviceip_model = DeviceIp + connection_model = DeviceConnection + operator_permission_filters = [ + {'codename__endswith': 'config'}, + {'codename__endswith': 'device'}, + {'codename__endswith': 'template'}, + {'codename__endswith': 'connection'}, + {'codename__endswith': 'credentials'}, + {'codename__endswith': 'device_connection'}, + {'codename__endswith': 'device_ip'}, + ] + _device_params = TestConfigAdmin._device_params.copy() + + def _get_device_params(self, org): + p = self._device_params.copy() + p['organization'] = org.pk + return p + + def _create_multitenancy_test_env(self): + 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]) + cred1 = self._create_credentials(organization=org1, name='test1cred') + cred2 = self._create_credentials(organization=org2, name='test2cred') + cred3 = self._create_credentials(organization=inactive, name='test3cred') + dc1 = self._create_device_connection(credentials=cred1) + dc2 = self._create_device_connection(credentials=cred2) + dc3 = self._create_device_connection(credentials=cred3) + data = dict(cred1=cred1, cred2=cred2, cred3_inactive=cred3, + dc1=dc1, dc2=dc2, dc3_inactive=dc3, + org1=org1, org2=org2, inactive=inactive, + operator=operator) + return data + + def test_credentials_queryset(self): + data = self._create_multitenancy_test_env() + self._test_multitenant_admin( + url=reverse('admin:connection_credentials_changelist'), + visible=[data['cred1'].name, data['org1'].name], + hidden=[data['cred2'].name, data['org2'].name, + data['cred3_inactive'].name] + ) + + def test_credentials_organization_fk_queryset(self): + data = self._create_multitenancy_test_env() + self._test_multitenant_admin( + url=reverse('admin:connection_credentials_add'), + visible=[data['org1'].name], + hidden=[data['org2'].name, data['inactive']], + select_widget=True + ) + + def test_connection_queryset(self): + data = self._create_multitenancy_test_env() + self._test_multitenant_admin( + url=reverse('admin:connection_credentials_changelist'), + visible=[data['dc1'].credentials.name, data['org1'].name], + hidden=[data['dc2'].credentials.name, data['org2'].name, + data['dc3_inactive'].credentials.name] + ) + + def test_connection_credentials_fk_queryset(self): + data = self._create_multitenancy_test_env() + self._test_multitenant_admin( + url=reverse('admin:config_device_add'), + visible=[str(data['cred1'].name) + str(" (SSH)")], + hidden=[str(data['cred2'].name) + str(" (SSH)"), data['cred3_inactive']], + select_widget=True + ) diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py new file mode 100644 index 000000000..55e0fbade --- /dev/null +++ b/openwisp_controller/connection/tests/test_models.py @@ -0,0 +1,339 @@ +import socket + +import mock +import paramiko +from django.core.exceptions import ValidationError +from django.test import TestCase + +from openwisp_users.models import Organization + +from .. import settings as app_settings +from ..models import Credentials, DeviceIp +from ..utils import get_interfaces +from .base import CreateConnectionsMixin, SshServerMixin + + +class TestModels(SshServerMixin, CreateConnectionsMixin, TestCase): + def test_connection_str(self): + c = Credentials(name='Dev Key', connector=app_settings.CONNECTORS[0][0]) + self.assertIn(c.name, str(c)) + self.assertIn(c.get_connector_display(), str(c)) + + def test_deviceip_str(self): + di = DeviceIp(address='10.40.0.1') + self.assertIn(di.address, str(di)) + + def test_device_connection_get_params(self): + dc = self._create_device_connection() + self.assertEqual(dc.get_params(), dc.credentials.params) + dc.params = {'port': 2400} + self.assertEqual(dc.get_params()['port'], 2400) + self.assertEqual(dc.get_params()['username'], 'root') + + def test_device_connection_auto_update_strategy(self): + dc = self._create_device_connection() + self.assertEqual(dc.update_strategy, app_settings.UPDATE_STRATEGIES[0][0]) + + def test_device_connection_auto_update_strategy_key_error(self): + orig_strategy = app_settings.UPDATE_STRATEGIES + orig_mapping = app_settings.CONFIG_UPDATE_MAPPING + app_settings.UPDATE_STRATEGIES = (('meddle', 'meddle'),) + app_settings.CONFIG_UPDATE_MAPPING = {'wrong': 'wrong'} + try: + self._create_device_connection() + except ValidationError: + failed = False + else: + failed = True + # restore + app_settings.UPDATE_STRATEGIES = orig_strategy + app_settings.CONFIG_UPDATE_MAPPING = orig_mapping + if failed: + self.fail('ValidationError not raised') + + def test_device_connection_auto_update_strategy_missing_config(self): + device = self._create_device(organization=self._create_org()) + self.assertFalse(hasattr(device, 'config')) + try: + self._create_device_connection(device=device) + except ValidationError as e: + self.assertIn('inferred from', str(e)) + else: + self.fail('ValidationError not raised') + + def test_device_connection_connector_instance(self): + dc = self._create_device_connection() + self.assertIsInstance(dc.connector_instance, dc.connector_class) + + def test_device_connection_ssh_key_param(self): + ckey = self._create_credentials_with_key() + dc = self._create_device_connection(credentials=ckey) + self.assertIn('pkey', dc.connector_instance.params) + self.assertIsInstance(dc.connector_instance.params['pkey'], + paramiko.rsakey.RSAKey) + self.assertNotIn('key', dc.connector_instance.params) + + def test_ssh_connect(self): + ckey = self._create_credentials_with_key(port=self.ssh_server.port) + dc = self._create_device_connection(credentials=ckey) + self._create_device_ip(address=self.ssh_server.host, + device=dc.device) + dc.connect() + self.assertTrue(dc.is_working) + self.assertIsNotNone(dc.last_attempt) + self.assertEqual(dc.failure_reason, '') + try: + dc.disconnect() + except OSError: + pass + + def test_ssh_connect_failure(self): + ckey = self._create_credentials_with_key(username='wrong', + port=self.ssh_server.port) + dc = self._create_device_connection(credentials=ckey) + self._create_device_ip(address=self.ssh_server.host, + device=dc.device) + dc.connect() + self.assertEqual(dc.is_working, False) + self.assertIsNotNone(dc.last_attempt) + self.assertEqual(dc.failure_reason, 'Authentication failed.') + + def test_credentials_schema(self): + # unrecognized parameter + try: + self._create_credentials(params={ + 'username': 'root', + 'password': 'password', + 'unrecognized': True + }) + except ValidationError as e: + self.assertIn('params', e.message_dict) + else: + self.fail('ValidationError not raised') + # missing password or key + try: + self._create_credentials(params={ + 'username': 'root', + 'port': 22 + }) + except ValidationError as e: + self.assertIn('params', e.message_dict) + else: + self.fail('ValidationError not raised') + + def test_device_connection_schema(self): + # unrecognized parameter + try: + self._create_device_connection(params={ + 'username': 'root', + 'password': 'password', + 'unrecognized': True + }) + except ValidationError as e: + self.assertIn('params', e.message_dict) + else: + self.fail('ValidationError not raised') + + def _prepare_address_list_test(self, addresses, + last_ip=None, + management_ip=None): + update_strategy = app_settings.UPDATE_STRATEGIES[0][0] + device = self._create_device(organization=self._create_org(), + last_ip=last_ip, + management_ip=management_ip) + dc = self._create_device_connection(device=device, + update_strategy=update_strategy) + for index, address in enumerate(addresses): + self._create_device_ip(device=device, + address=address, + priority=index + 1) + return dc + + def test_address_list(self): + dc = self._prepare_address_list_test(['10.40.0.1', '192.168.40.1']) + self.assertEqual(dc.get_addresses(), [ + '10.40.0.1', + '192.168.40.1' + ]) + + def test_address_list_with_device_ip(self): + dc = self._prepare_address_list_test( + ['192.168.40.1'], + management_ip='10.0.0.2', + last_ip='84.32.46.153', + ) + self.assertEqual(dc.get_addresses(), [ + '192.168.40.1', + '10.0.0.2', + '84.32.46.153' + ]) + + def test_address_list_link_local_ip(self): + ipv6_linklocal = 'fe80::2dae:a0d4:94da:7f61' + dc = self._prepare_address_list_test([ipv6_linklocal]) + address_list = dc.get_addresses() + interfaces = get_interfaces() + self.assertEqual(len(address_list), len(interfaces)) + self.assertIn(ipv6_linklocal, address_list[0]) + + def test_device_connection_credential_org_validation(self): + dc = self._create_device_connection() + shared = self._create_credentials(name='cred-shared', + organization=None) + dc.credentials = shared + dc.full_clean() + # ensure credentials of other orgs aren't accepted + org2 = self._create_org(name='org2') + cred2 = self._create_credentials(name='cred2', + organization=org2) + try: + dc.credentials = cred2 + dc.full_clean() + except ValidationError as e: + self.assertIn('credentials', e.message_dict) + else: + self.fail('ValidationError not raised') + + def test_auto_add_to_new_device(self): + c = self._create_credentials(auto_add=True, + organization=None) + self._create_credentials(name='cred2', + auto_add=False, + organization=None) + d = self._create_device(organization=Organization.objects.first()) + self._create_config(device=d) + d.refresh_from_db() + self.assertEqual(d.deviceconnection_set.count(), 1) + self.assertEqual(d.deviceconnection_set.first().credentials, c) + + def test_auto_add_to_existing_device_on_creation(self): + d = self._create_device(organization=Organization.objects.first()) + self._create_config(device=d) + self.assertEqual(d.deviceconnection_set.count(), 0) + c = self._create_credentials(auto_add=True, + organization=None) + org2 = Organization.objects.create(name='org2', slug='org2') + self._create_credentials(name='cred2', + auto_add=True, + organization=org2) + d.refresh_from_db() + self.assertEqual(d.deviceconnection_set.count(), 1) + self.assertEqual(d.deviceconnection_set.first().credentials, c) + self._create_credentials(name='cred3', + auto_add=False, + organization=None) + d.refresh_from_db() + self.assertEqual(d.deviceconnection_set.count(), 1) + self.assertEqual(d.deviceconnection_set.first().credentials, c) + + def test_auto_add_to_existing_device_on_edit(self): + d = self._create_device(organization=Organization.objects.first()) + self._create_config(device=d) + self.assertEqual(d.deviceconnection_set.count(), 0) + c = self._create_credentials(auto_add=False, + organization=None) + org2 = Organization.objects.create(name='org2', slug='org2') + self._create_credentials(name='cred2', + auto_add=True, + organization=org2) + d.refresh_from_db() + self.assertEqual(d.deviceconnection_set.count(), 0) + c.auto_add = True + c.full_clean() + c.save() + d.refresh_from_db() + self.assertEqual(d.deviceconnection_set.count(), 1) + self.assertEqual(d.deviceconnection_set.first().credentials, c) + # ensure further edits are idempotent + c.name = 'changed' + c.full_clean() + c.save() + d.refresh_from_db() + self.assertEqual(d.deviceconnection_set.count(), 1) + self.assertEqual(d.deviceconnection_set.first().credentials, c) + + _exec_command_path = 'paramiko.SSHClient.exec_command' + + def _exec_command_return_value(self, stdin='', stdout='mocked', + stderr='', exit_code=0): + stdin_ = mock.Mock() + stdout_ = mock.Mock() + stderr_ = mock.Mock() + stdin_.read().decode('utf8').strip.return_value = stdin + stdout_.read().decode('utf8').strip.return_value = stdout + stdout_.channel.recv_exit_status.return_value = exit_code + stderr_.read().decode('utf8').strip.return_value = stderr + return (stdin_, stdout_, stderr_) + + def test_device_config_update(self): + org1 = self._create_org(name='org1') + cred = self._create_credentials_with_key(organization=org1, port=self.ssh_server.port) + device = self._create_device(organization=org1) + update_strategy = app_settings.UPDATE_STRATEGIES[0][0] + c = self._create_config(device=device) + self._create_device_connection(device=device, + credentials=cred, + update_strategy=update_strategy) + self._create_device_ip(device=device, + address=self.ssh_server.host) + c.config = { + 'interfaces': [ + { + 'name': 'eth10', + 'type': 'ethernet', + 'addresses': [ + { + 'family': 'ipv4', + 'proto': 'dhcp' + } + ] + } + ] + } + c.full_clean() + + with mock.patch(self._exec_command_path) as mocked: + mocked.return_value = self._exec_command_return_value() + c.save() + mocked.assert_called_once() + c.refresh_from_db() + self.assertEqual(c.status, 'applied') + + def test_ssh_exec_exit_code(self): + ckey = self._create_credentials_with_key(port=self.ssh_server.port) + dc = self._create_device_connection(credentials=ckey) + self._create_device_ip(address=self.ssh_server.host, + device=dc.device) + dc.connector_instance.connect() + with mock.patch(self._exec_command_path) as mocked: + mocked.return_value = self._exec_command_return_value(exit_code=1) + with self.assertRaises(Exception): + dc.connector_instance.exec_command('trigger_command_not_found') + dc.connector_instance.disconnect() + mocked.assert_called_once() + + def test_ssh_exec_timeout(self): + ckey = self._create_credentials_with_key(port=self.ssh_server.port) + dc = self._create_device_connection(credentials=ckey) + self._create_device_ip(address=self.ssh_server.host, + device=dc.device) + dc.connector_instance.connect() + with mock.patch(self._exec_command_path) as mocked: + mocked.side_effect = socket.timeout() + with self.assertRaises(socket.timeout): + dc.connector_instance.exec_command('trigger_timeout') + dc.connector_instance.disconnect() + mocked.assert_called_once() + + def test_ssh_exec_exception(self): + ckey = self._create_credentials_with_key(port=self.ssh_server.port) + dc = self._create_device_connection(credentials=ckey) + self._create_device_ip(address=self.ssh_server.host, + device=dc.device) + dc.connector_instance.connect() + with mock.patch(self._exec_command_path) as mocked: + mocked.side_effect = RuntimeError('test') + with self.assertRaises(RuntimeError): + dc.connector_instance.exec_command('trigger_exception') + dc.connector_instance.disconnect() + mocked.assert_called_once() diff --git a/openwisp_controller/connection/utils.py b/openwisp_controller/connection/utils.py new file mode 100644 index 000000000..acfcf976b --- /dev/null +++ b/openwisp_controller/connection/utils.py @@ -0,0 +1,27 @@ +import array +import fcntl +import socket +import struct + + +def get_interfaces(): + """ + returns all non loopback interfaces available on the system + """ + max_possible = 128 + bytes_ = max_possible * 32 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + names = array.array('B', b'\0' * bytes_) + outbytes = struct.unpack('iL', fcntl.ioctl( + s.fileno(), + 0x8912, + struct.pack('iL', bytes_, names.buffer_info()[0]) + ))[0] + namestr = names.tostring() + interfaces = [] + for i in range(0, outbytes, 40): + name = namestr[i:i + 16].split(b'\0', 1)[0] + name = name.decode() + if name != 'lo': + interfaces.append(name) + return interfaces diff --git a/runtests.py b/runtests.py index 6dd62fa93..84dd794f0 100755 --- a/runtests.py +++ b/runtests.py @@ -5,7 +5,7 @@ import sys sys.path.insert(0, "tests") -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openwisp2.settings") if __name__ == "__main__": from django.core.management import execute_from_command_line diff --git a/setup.py b/setup.py index 1a24d8836..310bcbd7b 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,10 @@ "django-netjsonconfig>=0.8.1,<0.10.0", "openwisp-utils[users]<0.3.1", "django-loci>=0.1.1,<0.3.0", - "djangorestframework-gis>=0.12.0,<0.14.0" + "djangorestframework-gis>=0.12.0,<0.14.0", + "paramiko>=2.4.1,<2.5.0", + "scp>=0.13.0,<0.14.0", + "celery>=4.2.0,<4.3.0", ], classifiers=[ 'Development Status :: 3 - Alpha', diff --git a/tests/manage.py b/tests/manage.py index 9811d22f7..dd989d36d 100755 --- a/tests/manage.py +++ b/tests/manage.py @@ -3,7 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", 'settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", 'openwisp2.settings') from django.core.management import execute_from_command_line diff --git a/tests/openwisp2/__init__.py b/tests/openwisp2/__init__.py new file mode 100644 index 000000000..76be18a55 --- /dev/null +++ b/tests/openwisp2/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import, unicode_literals + +from .celery import app as celery_app + +__all__ = ['celery_app'] diff --git a/tests/openwisp2/celery.py b/tests/openwisp2/celery.py new file mode 100644 index 000000000..440c69813 --- /dev/null +++ b/tests/openwisp2/celery.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import, unicode_literals + +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'openwisp2.settings') + +app = Celery('openwisp2') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/tests/local_settings.example.py b/tests/openwisp2/local_settings.example.py similarity index 100% rename from tests/local_settings.example.py rename to tests/openwisp2/local_settings.example.py diff --git a/tests/settings.py b/tests/openwisp2/settings.py similarity index 80% rename from tests/settings.py rename to tests/openwisp2/settings.py index e26e612d3..e81727a00 100644 --- a/tests/settings.py +++ b/tests/openwisp2/settings.py @@ -38,6 +38,7 @@ 'openwisp_controller.pki', 'openwisp_controller.config', 'openwisp_controller.geo', + 'openwisp_controller.connection', # admin 'django.contrib.admin', 'django.forms', @@ -73,7 +74,7 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] -ROOT_URLCONF = 'urls' +ROOT_URLCONF = 'openwisp2.urls' CHANNEL_LAYERS = { 'default': { @@ -89,7 +90,7 @@ USE_L10N = False STATIC_URL = '/static/' MEDIA_URL = '/media/' -MEDIA_ROOT = '{0}/media/'.format(BASE_DIR) +MEDIA_ROOT = '{0}/media/'.format(os.path.dirname(BASE_DIR)) TEMPLATES = [ { @@ -121,8 +122,37 @@ # during development only EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +if not TESTING: + CELERY_BROKER_URL = 'redis://localhost/1' +else: + CELERY_TASK_ALWAYS_EAGER = True + CELERY_TASK_EAGER_PROPAGATES = True + CELERY_BROKER_URL = 'memory://' + +LOGGING = { + 'version': 1, + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + } + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + } + }, + 'loggers': { + 'django.db.backends': { + 'level': 'DEBUG', + 'handlers': ['console'], + } + } +} + # local settings must be imported before test runner otherwise they'll be ignored try: - from local_settings import * + from .local_settings import * except ImportError: pass diff --git a/tests/openwisp2/test-key.rsa b/tests/openwisp2/test-key.rsa new file mode 100644 index 000000000..bdd4846ed --- /dev/null +++ b/tests/openwisp2/test-key.rsa @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDQwuFrDNYUCi5Doy2xrc71P06vEuNG5i2rNgIHm95IuP8WrFwZ +W/VfNviGyhA8JwmWwHco9uzgKthaMKrGKB5Oeu/Z2F6SZPdCAdamCdbCcihXZ4g1 +RGbX5wECH7UjTx0th4GV6jwRAvJM/MpVJcCkTIzBHVHOC5jYotDuTnjJdwIDAQAB +AoGAHvfp7LF4yHxCJLJ+Qs9f1i3QBFSu9oOK3s0iO/K5ZNxcqwZimzhzC+7hq00q +X2IDICPpCWCn/xEcCzURAFhPNlx0RYZUzXOiW1JL7MzLYny87UAuW+TDaS4eEV9r +YX8acLWfg+aEw/pF0FRb2AuoRClztAyNF6GJtR/ky4z7vnECQQD3NEcEL1s913HW +1yV4RHBZO8n8oH2WidXtFDstmdmAvDQv7KC8c6rPJ6VVH5IlY+WyDIzI6X1IJFew +DXhO3A8zAkEA2DBvhy5TbAOPX7wQN53SA9+z4sdhOlYwcDpq2YuYvKH3ZFIWQEAX +cTQSjvaI35jWyKNYL+8T+Pqsngd3AUNsrQJBAI1yCSx42FFDRCz0v8jYCBzW3BVD +03hed9yGlfHatRw3E/lUAQizekm72pshTGM+jMBa8/dFulycBtyCaJNe0QcCQQCQ +uoxPcWIDs7ZuHta0hQEt+rrQnS2oAj9XQqR5kwzja4LVNGcVCFMpQ/UQpFcpaYaQ +t1m4bVNvoVGiUdkHjX3ZAkEAmHvrBB2TvcPZkhuUGviIlXbIeHWZMRF7wh0wZ7SH +SZWnv9EqwFcOGqqoLhQDznTI9TmWdpkxPxLzVwnjWLT4qw== +-----END RSA PRIVATE KEY----- diff --git a/tests/urls.py b/tests/openwisp2/urls.py similarity index 100% rename from tests/urls.py rename to tests/openwisp2/urls.py