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

[feature] Add support for SNMP credentials #471 #496

Merged
merged 16 commits into from
May 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,14 @@ the administration dashboard, it also adds different endpoints to the
Connection App
~~~~~~~~~~~~~~

This app enables the controller to instantiate connections to the devices
This app allows OpenWISP Controller to use different protocols to reach network devices.
Currently, the default connnection protocols are SSH and SNMP, but the protocol
mechanism is extensible and more protocols can be implemented if needed.

SSH
###

The SSH connector allows the controller to initialize connections to the devices
in order perform `push operations <#how-to-configure-push-updates>`__:

- Sending configuration updates.
Expand All @@ -137,6 +144,13 @@ Access via SSH key is recommended, the SSH key algorithms supported are:
- RSA
- Ed25519

SNMP
####

The SNMP connector is useful to collect monitoring information and it's used in
`openwisp-monitoring`_ for performing checks to collect monitoring information.
`Read more <https://github.com/openwisp/openwisp-monitoring/pull/309#discussion_r692566202>`_ on how to use it.

Geo App
~~~~~~~

Expand Down Expand Up @@ -2269,15 +2283,17 @@ Configure timeout for the TCP connect when establishing a SSH connection.
``OPENWISP_CONNECTORS``
~~~~~~~~~~~~~~~~~~~~~~~

+--------------+--------------------------------------------------------------------+
| **type**: | ``tuple`` |
+--------------+--------------------------------------------------------------------+
| **default**: | .. code-block:: python |
| | |
| | ( |
| | ('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'), |
| | ) |
+--------------+--------------------------------------------------------------------+
+--------------+------------------------------------------------------------------------------------------------+
| **type**: | ``tuple`` |
+--------------+------------------------------------------------------------------------------------------------+
| **default**: | .. code-block:: python |
| | |
| | ( |
| | ('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'), |
| | ('openwisp_controller.connection.connectors.openwrt.snmp.OpenWRTSnmp', 'OpenWRT SNMP'), |
| | ('openwisp_controller.connection.connectors.airos.snmp.AirOsSnmp', 'Ubiquiti AirOS SNMP'), |
nemesifier marked this conversation as resolved.
Show resolved Hide resolved
| | ) |
+--------------+------------------------------------------------------------------------------------------------+

Available connector classes. Connectors are python classes that specify ways
in which OpenWISP can connect to devices in order to launch commands.
Expand Down
3 changes: 2 additions & 1 deletion openwisp_controller/config/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin):
'system',
'devicelocation__location__address',
]
readonly_fields = ['last_ip', 'management_ip', 'uuid']
readonly_fields = ['last_ip', 'uuid']
autocomplete_fields = ['group']
fields = [
'name',
Expand Down Expand Up @@ -475,6 +475,7 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin):
class Media(BaseConfigAdmin.Media):
js = BaseConfigAdmin.Media.js + [
f'{prefix}js/tabs.js',
f'{prefix}js/management_ip.js',
f'{prefix}js/relevant_templates.js',
]

Expand Down
7 changes: 6 additions & 1 deletion openwisp_controller/config/base/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ class AbstractDevice(OrgMixin, BaseModel):
blank=True,
null=True,
db_index=True,
help_text=_('ip address of the management interface, if available'),
help_text=_(
'IP address used by OpenWISP to reach the device when performing '
'any type of push operation or active check. The value of this field is '
'generally sent by the device and hence does not need to be changed, '
'but can be changed or cleared manually if needed.'
),
)
Copy link
Member

Choose a reason for hiding this comment

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

we should not add a new DB column for this.
I think in OpenWISP Monitoring we should remove the field from being readonly, make it readonly with JS unless there's an SNMP check added, in that case we allow to change it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nemesisdesign but we need some way to track that the user wants to change it, otherwise it will automatically update from the API requests (https://github.com/openwisp/openwisp-controller/pull/496/files#diff-47782e8d264d4907d7893cff2e77b42523d4fb90af8aaef26e78c7a34e996713R75). What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

should not be an issue.. openwrt devices will send the right management IP so we can keep it, non openwrt devices will not send management IP and will be empty.

Please verify the following:

  • modify your openwrt device to not have "management_interface" in /etc/config/openwisp
  • restart openwisp config
  • try setting a custom management IP
  • restart openwisp config again
  • check whether the management IP is modified or not

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nemesisdesign in that case, the management IP resets to an empty string.

Copy link
Member

Choose a reason for hiding this comment

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

I confirm that the management IP changes after config restarts. I believe this is right. We should add a hint in the admin UI.

"IP address of the management interface configured on the device. The information received from the device will overwrite any changes done here"

Copy link
Member

@nemesifier nemesifier Aug 3, 2021

Choose a reason for hiding this comment

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

Ok thank you @purhan and @pandafy for double checking.

Here's my reasoning.

1. OpenWrt devices running openwisp-config and snmpd

In this case, we can assume the management IP will be set if management_ip is specified correctly in /etc/config/openwisp.

Hence we do not to add code that excludes the management IP update for this case.

2. AirOS devices running only snmp deamon, or OpenWrt running only SNMPd

Management IP will not be set at all, nor reset automatically because openwisp-config is not running.

Hence we do not to add code that excludes the management IP update for this case as well.

This code is not useful

For the reasons explained above I believe this code is not useful.

What should we look for? What would be useful?

I think we should strive for a solution which is general enough so that it can be flexibly used in different cases.

I believe a solution which can work reliably in many cases is the following:

  • allow the field to be writable
  • use JS to make it readonly by default, but provide two buttons: one to clear the management IP and one to edit it
    • clear action just resets it to empty string
    • edit action will allow the user to changethe value
  • make the help text more detailed, something like @pandafy indicated, eg:

This is the IP address used by OpenWISP to reach the device when performing any type of push operation or active check. The value of this field is generally sent by the device and hence does not need to be changed, but can be changed or cleared manually if needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nemesisdesign @pandafy Do you think perhaps a clear ip button is redundant? If the user wants to reset it, they can just edit it to be empty? This is how I have made it for now but I can change if you say so.

Copy link
Member

Choose a reason for hiding this comment

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

Will check and let you know.

This comment was marked as resolved.

hardware_id = models.CharField(**(app_settings.HARDWARE_ID_OPTIONS))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ class Migration(migrations.Migration):
name='management_ip',
field=models.GenericIPAddressField(
blank=True,
help_text='ip address of the management interface, if available',
help_text=(
'IP address used by OpenWISP to reach the device when '
'performing any type of push operation or active check. The '
'value of this field is generally sent by the device and hence '
'does not need to be changed, but can be changed or cleared '
'manually if needed.'
),
null=True,
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ class Migration(migrations.Migration):
field=models.GenericIPAddressField(
blank=True,
db_index=True,
help_text='ip address of the management interface, if available',
help_text=(
'IP address used by OpenWISP to reach the device when '
'performing any type of push operation or active check. The '
'value of this field is generally sent by the device and hence '
'does not need to be changed, but can be changed or cleared '
'manually if needed.'
),
null=True,
),
),
Expand Down
4 changes: 4 additions & 0 deletions openwisp_controller/config/static/config/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ button.show-sc{
#main .form-row.field-location .item-label {
bottom: -7px
}
#edit_management_ip {
margin-left: 7px;
opacity: 0.8;
}

@media (max-width: 767px) {
ul.tabs li {
Expand Down
50 changes: 50 additions & 0 deletions openwisp_controller/config/static/config/js/management_ip.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use strict";
django.jQuery(function ($) {
if ($(".add-form").length || !$("#device_form").length) {
return;
}

// replaces the management ip field with text with the option to edit it
var ip_input = $(".field-management_ip > div > input");
var initial_ip = ip_input.val();
var management_ip_alt = gettext("Edit");
ip_input.after(function () {
ip_input.hide();
return (
'<span class="readonly" id="management_ip_text">' +
(initial_ip === "" ? "-" : initial_ip) +
"</span>"
);
});
$("#management_ip_text").after(function () {
return (
'<a id="edit_management_ip"><img value="edit" src="/static/admin/img/icon-changelink.svg" alt="' +
management_ip_alt +
'" title="' +
management_ip_alt +
'"></a>'
);
});
$("#edit_management_ip").click(function () {
var ip_text = $("#management_ip_text");
var img_element = $("#edit_management_ip > img");
if (img_element.attr("value") === "edit") {
ip_input.show();
ip_text.hide();
management_ip_alt = gettext("Cancel");
img_element.attr("src", "/static/admin/img/icon-deletelink.svg");
img_element.attr("value", "cancel");
img_element.attr("alt", management_ip_alt);
img_element.attr("title", management_ip_alt);
} else {
ip_text.show();
ip_input.hide();
ip_input.val(initial_ip);
management_ip_alt = gettext("Edit");
img_element.attr("src", "/static/admin/img/icon-changelink.svg");
img_element.attr("value", "edit");
img_element.attr("alt", management_ip_alt);
img_element.attr("title", management_ip_alt);
}
});
});
8 changes: 8 additions & 0 deletions openwisp_controller/connection/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ def clean(self):
self._validate_connector_schema()

def _get_connector(self):
try:
if not getattr(
import_string(self.credentials.connector), 'has_update_strategy'
):
return self.credentials.connector
except AttributeError:
pass
purhan marked this conversation as resolved.
Show resolved Hide resolved
return getattr(self, self._connector_field)

def _validate_connector_schema(self):
Expand Down Expand Up @@ -203,6 +210,7 @@ def auto_add_credentials_to_device(cls, instance, created, **kwargs):
for cred in credentials:
DeviceConnection = load_model('connection', 'DeviceConnection')
conn = DeviceConnection(device=device, credentials=cred, enabled=True)
conn.set_connector(cred.connector_instance)
conn.full_clean()
conn.save()

Expand Down
5 changes: 5 additions & 0 deletions openwisp_controller/connection/connectors/airos/snmp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ..snmp import Snmp as BaseSnmp


class AirOsSnmp(BaseSnmp):
pass
Aryamanz29 marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 5 additions & 0 deletions openwisp_controller/connection/connectors/openwrt/snmp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ..snmp import Snmp as BaseSnmp


class OpenWRTSnmp(BaseSnmp):
pass
34 changes: 34 additions & 0 deletions openwisp_controller/connection/connectors/snmp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import logging

from jsonschema import validate

logger = logging.getLogger(__name__)


class Snmp(object):
Aryamanz29 marked this conversation as resolved.
Show resolved Hide resolved
schema = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'required': ['community', 'agent'],
'additionalProperties': False,
'properties': {
'community': {'type': 'string', 'default': 'public'},
'agent': {'type': 'string'},
'port': {
'type': 'integer',
'default': 161,
'minimum': 1,
'maximum': 65535,
},
},
}

has_update_strategy = False

def __init__(self, params, addresses):
self.params = params
self.addresses = addresses

@classmethod
def validate(cls, params):
validate(params, cls.schema)
2 changes: 2 additions & 0 deletions openwisp_controller/connection/connectors/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class Ssh(object):
],
}

has_update_strategy = True

def __init__(self, params, addresses):
self._params = params
self.addresses = addresses
Expand Down
12 changes: 11 additions & 1 deletion openwisp_controller/connection/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from django.conf import settings

DEFAULT_CONNECTORS = (('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'),)
DEFAULT_CONNECTORS = (
('openwisp_controller.connection.connectors.ssh.Ssh', 'SSH'),
(
'openwisp_controller.connection.connectors.openwrt.snmp.OpenWRTSnmp',
'OpenWRT SNMP',
),
(
'openwisp_controller.connection.connectors.airos.snmp.AirOsSnmp',
'Ubiquiti AirOS SNMP',
),
)

CONNECTORS = getattr(settings, 'OPENWISP_CONNECTORS', DEFAULT_CONNECTORS)

Expand Down
59 changes: 59 additions & 0 deletions openwisp_controller/connection/tests/test_snmp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from swapper import load_model

from .. import settings as app_settings
from .utils import CreateConnectionsMixin

Credentials = load_model('connection', 'Credentials')


class TestSnmp(CreateConnectionsMixin, TestCase):
def test_create_snmp_connector(self):
init_credentials_count = Credentials.objects.count()
params = {'community': 'public', 'agent': 'my-agent', 'port': 161}

with self.subTest('test openwrt'):
obj = self._create_credentials(
params=params, connector=app_settings.CONNECTORS[1][0], auto_add=True
)
obj.save()
obj.connector_instance.validate(params)
self.assertEqual(params, obj.params)
self.assertEqual(Credentials.objects.count(), init_credentials_count + 1)
self.assertEqual(
obj.connector,
'openwisp_controller.connection.connectors.openwrt.snmp.OpenWRTSnmp',
)

with self.subTest('test airos'):
obj = self._create_credentials(
name='airos snmp',
params=params,
connector=app_settings.CONNECTORS[2][0],
auto_add=True,
)
obj.save()
obj.connector_instance.validate(params)
self.assertEqual(params, obj.params)
self.assertEqual(Credentials.objects.count(), init_credentials_count + 2)
self.assertEqual(
obj.connector,
'openwisp_controller.connection.connectors.airos.snmp.AirOsSnmp',
)

def test_validation(self):
with self.subTest('test validation unsuccessful'):
params = {'agent': 'my-agent', 'port': 161}
with self.assertRaises(ValidationError):
self._create_credentials(
params=params, connector=app_settings.CONNECTORS[1][0]
)

with self.subTest('test validation successful'):
params = {'community': 'public', 'agent': 'my-agent', 'port': 161}
obj = self._create_credentials(
params=params, connector=app_settings.CONNECTORS[1][0], auto_add=True
)
obj.save()
obj.connector_instance.validate(params)
6 changes: 5 additions & 1 deletion tests/openwisp2/sample_config/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,11 @@ class Migration(migrations.Migration):
blank=True,
db_index=True,
help_text=(
'ip address of the management interface, if available'
'IP address used by OpenWISP to reach the device when '
'performing any type of push operation or active check. '
'The value of this field is generally sent by the device '
'and hence does not need to be changed, but can be '
'changed or cleared manually if needed.'
purhan marked this conversation as resolved.
Show resolved Hide resolved
),
null=True,
),
Expand Down