From 277c4895f72e142ce813636d03dda1477cd6baf4 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Sun, 6 May 2018 22:22:13 +0200 Subject: [PATCH] [connection] Init new connection module Requires django-netjsonconfig 0.8.1 Allows to - connect to devices using different protocols - SSH protocol supported by default - centrally launch update config operations when config/templates are changed Signed-off-by: Federico Capoano --- .travis.yml | 2 + openwisp_controller/config/tests/__init__.py | 2 + openwisp_controller/connection/__init__.py | 1 + openwisp_controller/connection/admin.py | 35 +++ openwisp_controller/connection/apps.py | 14 ++ .../connection/connectors/__init__.py | 0 .../connection/connectors/openwrt/__init__.py | 0 .../connection/connectors/openwrt/ssh.py | 6 + .../connection/connectors/ssh.py | 103 +++++++++ .../connection/migrations/0001_initial.py | 74 +++++++ .../connection/migrations/__init__.py | 0 openwisp_controller/connection/models.py | 167 ++++++++++++++ .../connection/tests/__init__.py | 0 .../connection/tests/test_models.py | 203 ++++++++++++++++++ openwisp_controller/connection/utils.py | 27 +++ requirements.txt | 3 + tests/settings.py | 23 ++ tests/test-key.rsa | 15 ++ 18 files changed, 675 insertions(+) create mode 100644 openwisp_controller/connection/__init__.py create mode 100644 openwisp_controller/connection/admin.py create mode 100644 openwisp_controller/connection/apps.py create mode 100644 openwisp_controller/connection/connectors/__init__.py create mode 100644 openwisp_controller/connection/connectors/openwrt/__init__.py create mode 100644 openwisp_controller/connection/connectors/openwrt/ssh.py create mode 100644 openwisp_controller/connection/connectors/ssh.py create mode 100644 openwisp_controller/connection/migrations/0001_initial.py create mode 100644 openwisp_controller/connection/migrations/__init__.py create mode 100644 openwisp_controller/connection/models.py create mode 100644 openwisp_controller/connection/tests/__init__.py create mode 100644 openwisp_controller/connection/tests/test_models.py create mode 100644 openwisp_controller/connection/utils.py create mode 100644 tests/test-key.rsa diff --git a/.travis.yml b/.travis.yml index 52ec84b2b..c5e9fc996 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,6 +36,8 @@ before_install: install: - pip install $DJANGO + # temporary, TODO: release django-netjsonconfig 0.8.1 and remove this + - pip install https://github.com/openwisp/django-netjsonconfig/tarball/master - python setup.py -q develop - if [[ $TRAVIS_PYTHON_VERSION == "2.7" ]]; then pip install git+git://github.com/tinio/pysqlite.git@extension-enabled#egg=pysqlite; fi diff --git a/openwisp_controller/config/tests/__init__.py b/openwisp_controller/config/tests/__init__.py index 6edd6e0b6..cba2205f2 100644 --- a/openwisp_controller/config/tests/__init__.py +++ b/openwisp_controller/config/tests/__init__.py @@ -19,4 +19,6 @@ def _create_config(self, **kwargs): if 'device' not in kwargs: kwargs['device'] = self._create_device(name='test-device', organization=kwargs.get('organization', None)) + if 'organization' not in kwargs: + kwargs['organization'] = kwargs['device'].organization return super(CreateConfigTemplateMixin, self)._create_config(**kwargs) 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..9175fca03 --- /dev/null +++ b/openwisp_controller/connection/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin + +from openwisp_utils.admin import MultitenantOrgFilter, 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', '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(admin.StackedInline): + model = DeviceConnection + exclude = ['params', 'created', 'modified'] + readonly_fields = ['is_working', 'failure_reason', 'last_attempt'] + extra = 0 + + +DeviceAdmin.inlines += [DeviceConnectionInline, DeviceIpInline] diff --git a/openwisp_controller/connection/apps.py b/openwisp_controller/connection/apps.py new file mode 100644 index 000000000..c25b8ae3c --- /dev/null +++ b/openwisp_controller/connection/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ +from django_netjsonconfig.signals import config_modified + + +class ConnectionConfig(AppConfig): + name = 'openwisp_controller.connection' + label = 'connection' + verbose_name = _('Network Device Credentials') + + def ready(self): + # connect to config_modified signal + from .models import DeviceConnection + config_modified.connect(DeviceConnection.config_modified_receiver) diff --git a/openwisp_controller/connection/connectors/__init__.py b/openwisp_controller/connection/connectors/__init__.py new file mode 100644 index 000000000..e69de29bb 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..ea646afde --- /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.shell.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..61a57863f --- /dev/null +++ b/openwisp_controller/connection/connectors/ssh.py @@ -0,0 +1,103 @@ +import ipaddress +import logging + +import paramiko +from django.core.exceptions import ObjectDoesNotExist +from django.utils.functional import cached_property +from jsonschema import validate +from jsonschema.exceptions import ValidationError as SchemaError +from scp import SCPClient + +from ..utils import get_interfaces + +try: + from io import StringIO +except ImportError: + from StringIO import StringIO + + +logger = logging.getLogger(__name__) + + +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, device_connection): + self.connection = device_connection + self.device = device_connection.device + 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 _addresses(self): + 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)) + try: + address_list.append(self.device.config.last_ip) + except ObjectDoesNotExist: + pass + return address_list + + @cached_property + def _params(self): + params = self.connection.get_params() + 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, **self._params) + except Exception as e: + exception = e + else: + success = True + break + if not success: + raise exception + + def disconnect(self): + self.shell.close() + + def update_config(self): + raise NotImplementedError() + + def upload(self, fl, remote_path): + scp = SCPClient(self.shell.get_transport()) + 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..5096ec1b8 --- /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', '0002_auto_20180219_1409'), + ] + + 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/__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..ca4fccba9 --- /dev/null +++ b/openwisp_controller/connection/models.py @@ -0,0 +1,167 @@ +import collections + +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 + + +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 + + @cached_property + def connector_class(self): + return import_string(getattr(self, self._connector_field)) + + @cached_property + def connector_instance(self): + return self.connector_class(self) + + +class Credentials(ConnectorMixin, ShareableOrgMixin, BaseModel): + """ + Credentials for access + """ + CONNECTOR_CHOICES = ( + ('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'), + ) + connector = models.CharField(_('connection type'), + choices=CONNECTOR_CHOICES, + 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}) + + class Meta: + verbose_name = _('Access credentials') + verbose_name_plural = verbose_name + + def __str__(self): + return '{0} ({1})'.format(self.name, self.get_connector_display()) + + +@python_2_unicode_compatible +class DeviceConnection(ConnectorMixin, TimeStampedEditableModel): + UPDATE_STRATEGY_CHOICES = ( + ('openwisp_controller.connection.connectors.openwrt.ssh.OpenWrt', 'OpenWRT SSH'), + ) + CONFIG_BACKEND_MAPPING = { + 'netjsonconfig.OpenWrt': UPDATE_STRATEGY_CHOICES[0][0], + } + 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=UPDATE_STRATEGY_CHOICES, + 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) + _connector_field = 'update_strategy' + + class Meta: + verbose_name = _('Device connection') + verbose_name_plural = _('Device connections') + + def clean(self): + if not self.update_strategy and hasattr(self.device, 'config'): + try: + self.update_strategy = self.CONFIG_BACKEND_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_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() + + @classmethod + def config_modified_receiver(cls, **kwargs): + """ + receiver for ``config_modified`` signal + triggers the ``update_config`` operation + """ + qs = kwargs['device'].deviceconnection_set.filter(enabled=True) + if qs.count() > 0: + conn = qs.first() + conn.connector_instance.connect() + if conn.is_working: + conn.connector_instance.update_config() + + +@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/tests/__init__.py b/openwisp_controller/connection/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py new file mode 100644 index 000000000..2e3bc6a42 --- /dev/null +++ b/openwisp_controller/connection/tests/test_models.py @@ -0,0 +1,203 @@ +import os + +import paramiko +from django.conf import settings +from django.core.exceptions import ValidationError +from django.test import TestCase +from mockssh import Server as SshServer +from openwisp_controller.config.models import Config, Device +from openwisp_controller.config.tests import CreateConfigTemplateMixin + +from openwisp_users.tests.utils import TestOrganizationMixin + +from ..models import Credentials, DeviceConnection, DeviceIp + + +class TestConnectionMixin(CreateConfigTemplateMixin, TestOrganizationMixin): + device_model = Device + config_model = Config + _TEST_RSA_KEY_PATH = os.path.join(settings.BASE_DIR, 'test-key.rsa') + with open(_TEST_RSA_KEY_PATH, 'r') as f: + _SSH_PRIVATE_KEY = f.read() + + @classmethod + def setUpClass(cls): + cls.ssh_server = SshServer({'root': cls._TEST_RSA_KEY_PATH}) + cls.ssh_server.__enter__() + + @classmethod + def tearDownClass(cls): + try: + cls.ssh_server.__exit__() + except OSError: + pass + + def _create_credentials(self, **kwargs): + opts = dict(name='Test credentials', + connector=Credentials.CONNECTOR_CHOICES[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}) + return self._create_credentials(**opts) + + def _create_device_connection(self, **kwargs): + opts = dict(enabled=True, + params={}) + opts.update(kwargs) + if 'credentials' not in opts: + opts['credentials'] = self._create_credentials() + 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 TestModels(TestConnectionMixin, TestCase): + def test_connection_str(self): + c = Credentials(name='Dev Key', connector=Credentials.CONNECTOR_CHOICES[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, dc.UPDATE_STRATEGY_CHOICES[0][0]) + + def test_device_connection_auto_update_strategy_key_error(self): + orig_strategy = DeviceConnection.UPDATE_STRATEGY_CHOICES + orig_mapping = DeviceConnection.CONFIG_BACKEND_MAPPING + DeviceConnection.UPDATE_STRATEGY_CHOICES = (('meddle', 'meddle'),) + DeviceConnection.CONFIG_BACKEND_MAPPING = {'wrong': 'wrong'} + try: + self._create_device_connection() + except ValidationError: + failed = False + else: + failed = True + # restore + DeviceConnection.UPDATE_STRATEGY_CHOICES = orig_strategy + DeviceConnection.CONFIG_BACKEND_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') 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/requirements.txt b/requirements.txt index 7b9055a9c..a7d2f8b0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ +# TODO: must increase to django-netjsonconfig>=0.8.1 when released django-netjsonconfig>=0.8.0,<0.9.0 openwisp-utils[users]<0.3 django-loci>=0.1.1,<0.3.0 djangorestframework-gis>=0.12.0,<0.13.0 +paramiko>=2.4.1,<2.5.0 +scp>=0.11.0,<0.12.0 diff --git a/tests/settings.py b/tests/settings.py index bda8dc79d..bf107456b 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -38,6 +38,7 @@ 'openwisp_controller.pki', 'openwisp_controller.config', 'openwisp_controller.geo', + 'openwisp_controller.connection', # admin 'django.contrib.admin', 'django.forms', @@ -119,6 +120,28 @@ # during development only EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +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 * diff --git a/tests/test-key.rsa b/tests/test-key.rsa new file mode 100644 index 000000000..bdd4846ed --- /dev/null +++ b/tests/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-----