Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

openwisp_controller.connection module (SSH connections) #31

Merged
merged 57 commits into from Apr 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
b0c32bd
[connection] Init new connection module
nemesifier May 6, 2018
077b02c
[connections] Execute update_config in the background
nemesifier May 7, 2018
c39456d
[connections] Keep connector class and model logic decoupled
nemesifier May 8, 2018
18af642
[connection] Fixed migration dependency on openwisp-users
nemesifier May 8, 2018
0dc46dd
[connections] Added settings
nemesifier May 8, 2018
704b209
[connections] Updated runtests.py to use new setting file
nemesifier May 8, 2018
990b667
Fix markdown in README.rst
okraits May 9, 2018
96fe450
[connection] Made config_modified_receiver more resilient to exceptions
nemesifier May 9, 2018
4bb7d2d
[connection] Updated MEDIA_ROOT
nemesifier May 9, 2018
b8a1e2a
[connections] Update dependencies in README.rst and add setting for m…
okraits May 9, 2018
7f3b651
[connection] Fixed admin tests
nemesifier May 9, 2018
6c59641
[test] Removed `SPATIALITE_LIBRARY_PATH = 'mod_spatialite`
nemesifier May 9, 2018
0049315
[connection] Added exec_command method
nemesifier May 10, 2018
4535ba2
[connection] Close connection after update_config
nemesifier May 10, 2018
e3d105c
[connection] Added default settings
nemesifier May 10, 2018
43041d7
[connection] Made test mixins reusable (for extensions)
nemesifier May 25, 2018
5124449
[connection] Enforce multitenancy in DeviceConnection.credentials
nemesifier Jun 15, 2018
1e7ecb7
[connections] Added support for management interface
nemesifier Dec 2, 2018
5663926
[connections] Updated deprecated set_status_running to set_status_app…
nemesifier Dec 2, 2018
32f5a54
[connection] Added auto_add feature to Credentials
nemesifier Dec 6, 2018
96f2ea3
[connection] Upgraded celery to 4.2.0
nemesifier Dec 8, 2018
a2590ba
[test] Resolved Conflict
NoumbissiValere Apr 3, 2019
1d25860
Merge branch 'master' of https://github.com/openwisp/openwisp-control…
NoumbissiValere Apr 3, 2019
a7258d2
[connection] Init new connection module
nemesifier May 6, 2018
792ae82
[connections] Execute update_config in the background
nemesifier May 7, 2018
0ac107a
[connections] Keep connector class and model logic decoupled
nemesifier May 8, 2018
5cfa509
[connection] Fixed migration dependency on openwisp-users
nemesifier May 8, 2018
5d28948
[connections] Added settings
nemesifier May 8, 2018
df755e3
[connections] Updated runtests.py to use new setting file
nemesifier May 8, 2018
5f1e755
Fix markdown in README.rst
okraits May 9, 2018
79ac031
[connection] Made config_modified_receiver more resilient to exceptions
nemesifier May 9, 2018
5745e2b
[connection] Updated MEDIA_ROOT
nemesifier May 9, 2018
657ee64
[connections] Update dependencies in README.rst and add setting for m…
okraits May 9, 2018
afc8d22
[connection] Fixed admin tests
nemesifier May 9, 2018
0670693
[test] Removed `SPATIALITE_LIBRARY_PATH = 'mod_spatialite`
nemesifier May 9, 2018
4afab31
[connection] Added exec_command method
nemesifier May 10, 2018
e2c81f8
[connection] Close connection after update_config
nemesifier May 10, 2018
445611f
[connection] Added default settings
nemesifier May 10, 2018
b055fc6
[connection] Made test mixins reusable (for extensions)
nemesifier May 25, 2018
d32877c
[connection] Enforce multitenancy in DeviceConnection.credentials
nemesifier Jun 15, 2018
7366f91
[connections] Added support for management interface
nemesifier Dec 2, 2018
a5e89e8
[connections] Updated deprecated set_status_running to set_status_app…
nemesifier Dec 2, 2018
7477e75
[connection] Added auto_add feature to Credentials
nemesifier Dec 6, 2018
a8a2af7
[connection] Upgraded celery to 4.2.0
nemesifier Dec 8, 2018
fe2393a
Merge branch 'connections' of https://github.com/openwisp/openwisp-co…
NoumbissiValere Apr 13, 2019
bb49a48
[tests] Added admin tests
NoumbissiValere Apr 14, 2019
3575048
[tests] Moved test_device_config_update
nemesifier Apr 14, 2019
21f1f4a
[connections] Commented out Ssh.upload
nemesifier Apr 14, 2019
c233366
[connections] Corrected "Previus command failed" message
nemesifier Apr 14, 2019
5719995
[connections] Excluded not implemented method from test coverage
nemesifier Apr 14, 2019
d760329
[tests] Fixed model tests
NoumbissiValere Apr 19, 2019
8900958
[update] Added mock as a required package
NoumbissiValere Apr 19, 2019
c0d99f9
[connections] Improved tests for SSH connector
nemesifier Apr 22, 2019
026e2f6
[connections] Update status (running > applied)
nemesifier Apr 22, 2019
0436445
[connections] Honor timeout
nemesifier Apr 22, 2019
c5f89c6
[connections] Fixed remaining failing tests
nemesifier Apr 22, 2019
0b1fd0a
[connections] Move mock dependency to dev-packages
nemesifier Apr 22, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions Pipfile
Expand Up @@ -11,6 +11,8 @@ coverage = "*"
coveralls = "*"
isort = "*"
flake8 = "*"
mock = "*"
mock-ssh-server = {version = ">=0.5.0,<0.6.0"}

[scripts]
lint = "python -m flake8"
Expand Down
72 changes: 69 additions & 3 deletions README.rst
Expand Up @@ -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.
Copy link
Member

@okraits okraits Dec 28, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing char: configuration


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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A dictionary which maps instead of A dictionary that maps. Just a language thing. Noticed the same thing some lines above, too.

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 <https://pipenv.readthedocs.io/en/latest/>`_:

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions openwisp_controller/config/tests/test_admin.py
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion openwisp_controller/config/tests/test_controller.py
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion openwisp_controller/config/views.py
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions openwisp_controller/connection/__init__.py
@@ -0,0 +1 @@
default_app_config = 'openwisp_controller.connection.apps.ConnectionConfig'
49 changes: 49 additions & 0 deletions 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]
52 changes: 52 additions & 0 deletions 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
File renamed without changes.
Empty file.
6 changes: 6 additions & 0 deletions 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')
133 changes: 133 additions & 0 deletions 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 = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atb00ker this is the schema for SSH credentials. In the future we may have other ways to access devices. For example, OpenWrt has an HTTP-RPC API served by ubus. Ubiquiti AirOS also has some sort o HTTP access that allows to update the config via HTTP calls (not sure if they have an API but surely calls to their web interface can be simulated).
That's where we want to get at some point with openwisp/netjsonconfig#91

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems great. I see a lot of things i need to learn about and i'll start with reading the same.
Thanks. 😄

"$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()