Skip to content

Commit

Permalink
[connection] Init new connection module
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
nemesifier committed May 6, 2018
1 parent cc7e9ff commit 277c489
Show file tree
Hide file tree
Showing 18 changed files with 675 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions openwisp_controller/config/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions openwisp_controller/connection/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = 'openwisp_controller.connection.apps.ConnectionConfig'
35 changes: 35 additions & 0 deletions openwisp_controller/connection/admin.py
Original file line number Diff line number Diff line change
@@ -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]
14 changes: 14 additions & 0 deletions openwisp_controller/connection/apps.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
Empty file.
6 changes: 6 additions & 0 deletions openwisp_controller/connection/connectors/openwrt/ssh.py
Original file line number Diff line number Diff line change
@@ -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')
103 changes: 103 additions & 0 deletions openwisp_controller/connection/connectors/ssh.py
Original file line number Diff line number Diff line change
@@ -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()
74 changes: 74 additions & 0 deletions openwisp_controller/connection/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
Empty file.
Loading

0 comments on commit 277c489

Please sign in to comment.