-
Notifications
You must be signed in to change notification settings - Fork 43
[WIP] Draft / prototype for device discovery #354
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
base: develop
Are you sure you want to change the base?
Changes from 6 commits
aac090c
39682e6
ade20de
11b1f5c
d384675
e22c7d7
bd2c77f
58ddb95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,19 @@ | ||
"""Choices used througout the app.""" | ||
from nautobot.core.choices import ChoiceSet | ||
|
||
|
||
SSOT_JOB_TO_COMMAND_CHOICE = ( | ||
("sync_devices", "Sync Devices"), | ||
("sync_network_data", "Sync Network Data"), | ||
("both", "Both"), | ||
) | ||
|
||
|
||
class AutodiscoveryProtocolTypeChoices(ChoiceSet): | ||
"""Auto Discovery Protocol Type Choices.""" | ||
|
||
SSH = "ssh" | ||
|
||
CHOICES = ( | ||
(SSH, "ssh"), | ||
) |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,13 +1,17 @@ | ||||||
# pylint: disable=attribute-defined-outside-init | ||||||
"""Device Onboarding Jobs.""" | ||||||
|
||||||
|
||||||
import concurrent.futures | ||||||
import csv | ||||||
import ipaddress | ||||||
import json | ||||||
import logging | ||||||
from io import StringIO | ||||||
|
||||||
from diffsync.enum import DiffSyncFlags | ||||||
from django.conf import settings | ||||||
from django.contrib.auth import get_user_model | ||||||
from django.contrib.contenttypes.models import ContentType | ||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError | ||||||
from nautobot.apps.jobs import ( | ||||||
|
@@ -16,6 +20,7 @@ | |||||
FileVar, | ||||||
IntegerVar, | ||||||
Job, | ||||||
MultiChoiceVar, | ||||||
MultiObjectVar, | ||||||
ObjectVar, | ||||||
StringVar, | ||||||
|
@@ -29,18 +34,23 @@ | |||||
) | ||||||
from nautobot.extras.models import ( | ||||||
CustomField, | ||||||
Job, | ||||||
JobResult, | ||||||
Role, | ||||||
SecretsGroup, | ||||||
SecretsGroupAssociation, | ||||||
Status, | ||||||
Tag, | ||||||
) | ||||||
from nautobot.ipam.models import Namespace | ||||||
from nautobot.ipam.models import Namespace, Prefix | ||||||
from nautobot_plugin_nornir.constants import NORNIR_SETTINGS | ||||||
from netutils.ping import tcp_ping | ||||||
from nautobot_ssot.jobs.base import DataSource | ||||||
from nornir import InitNornir | ||||||
from nornir.core.plugins.inventory import InventoryPluginRegister | ||||||
|
||||||
from nautobot_device_onboarding.choices import SSOT_JOB_TO_COMMAND_CHOICE | ||||||
from nautobot_device_onboarding.choices import SSOT_JOB_TO_COMMAND_CHOICE, AutodiscoveryProtocolTypeChoices | ||||||
from nautobot_device_onboarding.constants import AUTODISCOVERY_PORTS | ||||||
from nautobot_device_onboarding.diffsync.adapters.sync_devices_adapters import ( | ||||||
SyncDevicesNautobotAdapter, | ||||||
SyncDevicesNetworkAdapter, | ||||||
|
@@ -61,6 +71,10 @@ | |||||
from nautobot_device_onboarding.nornir_plays.processor import TroubleshootingProcessor | ||||||
from nautobot_device_onboarding.utils.helper import onboarding_task_fqdn_to_ip | ||||||
|
||||||
from netmiko import SSHDetect | ||||||
from scapy.all import IP, TCP, sr1, sr, conf | ||||||
|
||||||
|
||||||
InventoryPluginRegister.register("empty-inventory", EmptyInventory) | ||||||
|
||||||
PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["nautobot_device_onboarding"] | ||||||
|
@@ -837,10 +851,285 @@ def run(self, *args, **kwargs): # pragma: no cover | |||||
return f"Successfully ran the following commands: {', '.join(list(compiled_results.keys()))}" | ||||||
|
||||||
|
||||||
class DeviceOnboardingDiscoveryJob(Job): | ||||||
"""Job to Discover Network Devices and queue for actual Onboarding.""" | ||||||
debug = BooleanVar( | ||||||
default=False, | ||||||
description="Enable for more verbose logging.", | ||||||
) | ||||||
prefix_tag = ObjectVar( | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
model=Tag, | ||||||
required=True, | ||||||
) | ||||||
protocols = MultiChoiceVar( | ||||||
choices=AutodiscoveryProtocolTypeChoices, | ||||||
required=True, | ||||||
) | ||||||
secrets_groups = MultiObjectVar( | ||||||
model=SecretsGroup, | ||||||
required=True, | ||||||
description="SecretsGroup for device connection credentials.", | ||||||
) | ||||||
scanning_threads_count = IntegerVar( | ||||||
description="Number of IPs to scan at a time.", | ||||||
label="Number of Threads", | ||||||
default=8, | ||||||
) | ||||||
login_threads_count = IntegerVar( | ||||||
description="Number of simultaneous SSH logins.", | ||||||
label="Number of sim. SSH logins..", | ||||||
default=2, | ||||||
) | ||||||
location = SSOTSyncNetworkData.location | ||||||
namespace = SSOTSyncDevices.namespace | ||||||
device_role = SSOTSyncDevices.device_role | ||||||
device_status = SSOTSyncDevices.device_status | ||||||
interface_status = SSOTSyncDevices.interface_status | ||||||
ip_address_status = SSOTSyncDevices.ip_address_status | ||||||
|
||||||
class Meta: | ||||||
"""Meta object.""" | ||||||
|
||||||
name = "Discovers devices within networks and runs onboarding." | ||||||
description = "Scan network prefixes and onboard devices." | ||||||
has_sensitive_variables = False | ||||||
hidden = False | ||||||
|
||||||
def _scan_target_ip(self, target_ip, protocols): | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
"""Scan target IP Address for open protocol-ports.""" | ||||||
open_ports = [] | ||||||
for protocol in protocols: | ||||||
open_ports.extend(getattr(self, f"_scan_target_{protocol}")(target_ip=target_ip)) | ||||||
|
||||||
return open_ports | ||||||
|
||||||
def _scan_target_ssh(self, target_ip): | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
"""Scan target IP address for TCP-SSH ports.""" | ||||||
ssh_targets = [] | ||||||
|
||||||
for target_ssh_port in AUTODISCOVERY_PORTS[AutodiscoveryProtocolTypeChoices.SSH]: | ||||||
self.logger.info(target_ssh_port) | ||||||
if tcp_ping(target_ip, target_ssh_port): # Report only opened ports. | ||||||
self.logger.info(target_ssh_port) | ||||||
|
||||||
open_ssh_port = { | ||||||
"port": target_ssh_port, | ||||||
"is_open": True, | ||||||
"protocol": AutodiscoveryProtocolTypeChoices.SSH, | ||||||
} | ||||||
|
||||||
ssh_targets.append(open_ssh_port) | ||||||
|
||||||
return ssh_targets | ||||||
|
||||||
def _scan(self): | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
"""Scan the selected IP Addresses for open protocol-ports - dispatcher method.""" | ||||||
scan_result = {} | ||||||
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=self.scanning_threads_count) as executor: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems odd to me that we're not making these tasks nornir-tasks to keep the pattern the same. |
||||||
future_to_ip = {} | ||||||
|
||||||
for target_ip in self.targets: | ||||||
if self.debug: | ||||||
self.logger.debug(f"Starting scan for IP: {target_ip}") | ||||||
|
||||||
future = executor.submit(self._scan_target_ip, target_ip, self.protocols) | ||||||
future_to_ip[future] = target_ip | ||||||
|
||||||
for future in concurrent.futures.as_completed(future_to_ip): | ||||||
target_ip = future_to_ip[future] | ||||||
try: | ||||||
scan_result[target_ip] = future.result() | ||||||
except Exception as e: | ||||||
self.logger.error(f"Error with future for IP {target_ip}: {e}") | ||||||
|
||||||
if self.debug: | ||||||
self.logger.info(f"Results: {scan_result}") | ||||||
|
||||||
return scan_result | ||||||
|
||||||
def _parse_credentials(self, credentials): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this would be the third time we define this same function in this app. Can you just import from
|
||||||
"""Parse and return dictionary of credentials.""" | ||||||
if self.debug: | ||||||
self.logger.debug("Attempting to parse credentials from selected SecretGroup") | ||||||
|
||||||
try: | ||||||
username = credentials.get_secret_value( | ||||||
access_type=SecretsGroupAccessTypeChoices.TYPE_GENERIC, | ||||||
secret_type=SecretsGroupSecretTypeChoices.TYPE_USERNAME, | ||||||
) | ||||||
password = credentials.get_secret_value( | ||||||
access_type=SecretsGroupAccessTypeChoices.TYPE_GENERIC, | ||||||
secret_type=SecretsGroupSecretTypeChoices.TYPE_PASSWORD, | ||||||
) | ||||||
return username, password | ||||||
except SecretsGroupAssociation.DoesNotExist as err: | ||||||
self.logger.exception( | ||||||
"Unable to use SecretsGroup selected, ensure Access Type is set to Generic & at minimum Username & Password types are set." | ||||||
) | ||||||
raise OnboardException("fail-credentials - Unable to parse selected credentials.") from err | ||||||
|
||||||
def _get_target_details_ssh(self, hostname, port): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. another one where we already have this
|
||||||
"""Guess the device platform of host, based on SSH.""" | ||||||
netmiko_optional_args = {"port": port} # , **NETMIKO_EXTRAS} | ||||||
guessed_platform = None | ||||||
valid_credentials = None | ||||||
|
||||||
for secret_group in self.secrets_groups: | ||||||
username, password = _parse_credentials(credentials=secret_group) | ||||||
exception = None | ||||||
|
||||||
remote_device = { | ||||||
"device_type": "autodetect", | ||||||
"host": hostname, | ||||||
"username": username, | ||||||
"password": password, | ||||||
**netmiko_optional_args, | ||||||
} | ||||||
|
||||||
try: | ||||||
guesser = SSHDetect(**remote_device) | ||||||
guessed_platform = guesser.autodetect() | ||||||
valid_credentials = secret_group | ||||||
|
||||||
break | ||||||
|
||||||
except Exception as err: # pylint: disable=broad-exception-caught | ||||||
guessed_platform = None | ||||||
valid_credentials = None | ||||||
exception = err | ||||||
|
||||||
return guessed_platform, exception, valid_credentials | ||||||
|
||||||
def _get_target_details(self, target_ip, target_port_details): | ||||||
"""Get target IP and protocol-port details.""" | ||||||
target_details = {**target_port_details} | ||||||
target_details["ip"] = target_ip | ||||||
|
||||||
# Dispatch SSH | ||||||
if target_port_details["protocol"] == AutodiscoveryProtocolTypeChoices.SSH: | ||||||
guessed_platform, exception, credentials = self._get_target_details_ssh( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we don't get proper details via SSH, should we remove the target from the self.targets? and log an issue? (because it seems to have the port SSH port open, but not able to connect with the credentials) |
||||||
hostname=target_ip, | ||||||
port=target_details["port"] | ||||||
) | ||||||
target_details["platform"] = guessed_platform | ||||||
target_details["exception"] = exception | ||||||
target_details["credentials"] = credentials | ||||||
|
||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
return target_details | ||||||
|
||||||
def _get_targets_details(self, scan_result): | ||||||
"""Get target IPs details and find valid credentials for an open protocol-port - dispatcher method.""" | ||||||
results = {} | ||||||
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=self.login_threads_count) as executor: | ||||||
future_to_ip = {} | ||||||
|
||||||
for target_ip in scan_result: | ||||||
for target_port_details in scan_result[target_ip]: | ||||||
target_port = target_port_details["port"] | ||||||
if self.debug: | ||||||
self.logger.debug(f"Starting get_details for IP: {target_ip}:{target_port}") | ||||||
|
||||||
future = executor.submit(self._get_target_details, target_ip, target_port_details) | ||||||
future_to_ip[future] = f"{target_ip}:{target_port}" | ||||||
|
||||||
for future in concurrent.futures.as_completed(future_to_ip): | ||||||
host = future_to_ip[future] | ||||||
try: | ||||||
results[host] = future.result() | ||||||
except Exception as e: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. which exception are you expecting? |
||||||
self.logger.error(f"Error with future for IP {host}: {e}") | ||||||
|
||||||
if self.debug: | ||||||
self.logger.info(f"Results: {results}") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
return results | ||||||
|
||||||
def run(self, | ||||||
debug, | ||||||
scanning_threads_count, | ||||||
login_threads_count, | ||||||
prefix_tag, | ||||||
secrets_groups, | ||||||
protocols, | ||||||
location, | ||||||
namespace, | ||||||
device_role, | ||||||
device_status, | ||||||
interface_status, | ||||||
ip_address_status, | ||||||
*args, | ||||||
**kwargs | ||||||
): # pragma: no cover | ||||||
"""Process discovering devices.""" | ||||||
self.debug = debug | ||||||
self.secrets_groups = secrets_groups | ||||||
self.scanning_threads_count = scanning_threads_count | ||||||
self.login_threads_count = login_threads_count | ||||||
|
||||||
# Pass through to onboarding task | ||||||
self.location = location | ||||||
self.namespace = namespace | ||||||
self.device_role = device_role | ||||||
self.device_status = device_status | ||||||
self.interface_status = interface_status | ||||||
self.ip_address_status = ip_address_status | ||||||
|
||||||
# TODO(mzb): Introduce "skip" / blacklist tag too. | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
# TODO(mzb): Skip devices with primary_ip4 set. | ||||||
self.targets = set() | ||||||
for prefix in Prefix.objects.filter(tags__in=[prefix_tag]): # TODO(mzb): Initial filtering is with tags, can extend to Location/Role/Status too. | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
network = ipaddress.ip_network(prefix.prefix) | ||||||
# Get a list of all IPs in the subnet | ||||||
for ip in network.hosts(): | ||||||
self.targets.add(str(ip)) | ||||||
|
||||||
self.protocols = protocols | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
scan_result = self._scan() | ||||||
self.logger.info(scan_result) | ||||||
ssh_result = self._get_targets_details(scan_result) | ||||||
self.logger.info(ssh_result) | ||||||
|
||||||
return ssh_result | ||||||
|
||||||
def on_success(self, retval, task_id, args, kwargs): | ||||||
mzbroch marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
User = get_user_model() | ||||||
try: | ||||||
user = User.objects.get(username="admin") | ||||||
except User.DoesNotExist as exc: | ||||||
raise CommandError("No such user") from exc | ||||||
for val in retval: | ||||||
data = { | ||||||
"location": self.location.id, | ||||||
"namespace": self.namespace.id, | ||||||
"ip_addresses": retval[val]["ip"], | ||||||
"port": retval[val]["port"].id, | ||||||
"timeout": 10, | ||||||
"device_role": self.device_role.id, | ||||||
"device_status": self.device_status.id, | ||||||
"ip_address_status": self.ip_address_status.id, | ||||||
"secrets_group": retval[val]["credentials"].id, | ||||||
'dryrun': False, | ||||||
'memory_profiling': False, | ||||||
'debug': False, | ||||||
'csv_file': None, | ||||||
'set_mgmt_only': False, | ||||||
'update_devices_without_primary_ip': True, | ||||||
'interface_status': self.interface_status.id, | ||||||
'platform': None, | ||||||
"connectivity_test": False, | ||||||
} | ||||||
job = Job.objects.get(name="Sync Devices From Network") | ||||||
JobResult.enqueue_job(job, user, **data) | ||||||
|
||||||
|
||||||
jobs = [ | ||||||
OnboardingTask, | ||||||
SSOTSyncDevices, | ||||||
SSOTSyncNetworkData, | ||||||
DeviceOnboardingTroubleshootingJob, | ||||||
DeviceOnboardingDiscoveryJob, | ||||||
] | ||||||
register_jobs(*jobs) |
Uh oh!
There was an error while loading. Please reload this page.