netbox-toolkit-plugin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_toolkit/__init__.py +30 -0
- netbox_toolkit/admin.py +16 -0
- netbox_toolkit/api/__init__.py +0 -0
- netbox_toolkit/api/mixins.py +54 -0
- netbox_toolkit/api/schemas.py +234 -0
- netbox_toolkit/api/serializers.py +158 -0
- netbox_toolkit/api/urls.py +10 -0
- netbox_toolkit/api/views/__init__.py +10 -0
- netbox_toolkit/api/views/command_logs.py +170 -0
- netbox_toolkit/api/views/commands.py +267 -0
- netbox_toolkit/config.py +159 -0
- netbox_toolkit/connectors/__init__.py +15 -0
- netbox_toolkit/connectors/base.py +97 -0
- netbox_toolkit/connectors/factory.py +301 -0
- netbox_toolkit/connectors/netmiko_connector.py +443 -0
- netbox_toolkit/connectors/scrapli_connector.py +486 -0
- netbox_toolkit/exceptions.py +36 -0
- netbox_toolkit/filtersets.py +85 -0
- netbox_toolkit/forms.py +31 -0
- netbox_toolkit/migrations/0001_initial.py +54 -0
- netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +66 -0
- netbox_toolkit/migrations/0003_permission_system_update.py +48 -0
- netbox_toolkit/migrations/0004_remove_django_permissions.py +77 -0
- netbox_toolkit/migrations/0005_alter_command_options_and_more.py +25 -0
- netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +28 -0
- netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py +18 -0
- netbox_toolkit/migrations/__init__.py +0 -0
- netbox_toolkit/models.py +89 -0
- netbox_toolkit/navigation.py +30 -0
- netbox_toolkit/search.py +21 -0
- netbox_toolkit/services/__init__.py +7 -0
- netbox_toolkit/services/command_service.py +357 -0
- netbox_toolkit/services/device_service.py +87 -0
- netbox_toolkit/services/rate_limiting_service.py +228 -0
- netbox_toolkit/static/netbox_toolkit/css/toolkit.css +143 -0
- netbox_toolkit/static/netbox_toolkit/js/toolkit.js +657 -0
- netbox_toolkit/tables.py +37 -0
- netbox_toolkit/templates/netbox_toolkit/command.html +108 -0
- netbox_toolkit/templates/netbox_toolkit/command_edit.html +10 -0
- netbox_toolkit/templates/netbox_toolkit/command_list.html +12 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog.html +170 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog_list.html +4 -0
- netbox_toolkit/templates/netbox_toolkit/device_toolkit.html +536 -0
- netbox_toolkit/urls.py +22 -0
- netbox_toolkit/utils/__init__.py +1 -0
- netbox_toolkit/utils/connection.py +125 -0
- netbox_toolkit/utils/error_parser.py +428 -0
- netbox_toolkit/utils/logging.py +58 -0
- netbox_toolkit/utils/network.py +157 -0
- netbox_toolkit/views.py +385 -0
- netbox_toolkit_plugin-0.1.0.dist-info/METADATA +76 -0
- netbox_toolkit_plugin-0.1.0.dist-info/RECORD +56 -0
- netbox_toolkit_plugin-0.1.0.dist-info/WHEEL +5 -0
- netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE +200 -0
- netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,486 @@
|
|
1
|
+
"""Scrapli-based device connector implementation."""
|
2
|
+
import time
|
3
|
+
from typing import Dict, Any, Type, Optional
|
4
|
+
|
5
|
+
from scrapli.driver.core import IOSXEDriver, NXOSDriver, IOSXRDriver
|
6
|
+
from scrapli.driver.generic import GenericDriver
|
7
|
+
from scrapli.exceptions import ScrapliException
|
8
|
+
|
9
|
+
from .base import BaseDeviceConnector, ConnectionConfig, CommandResult
|
10
|
+
from ..config import ToolkitConfig
|
11
|
+
from ..exceptions import DeviceConnectionError, CommandExecutionError, UnsupportedPlatformError
|
12
|
+
from ..utils.network import validate_device_connectivity
|
13
|
+
from ..utils.connection import cleanup_connection_resources, validate_connection_health, wait_for_socket_cleanup
|
14
|
+
from ..utils.error_parser import VendorErrorParser
|
15
|
+
from ..utils.logging import get_toolkit_logger
|
16
|
+
|
17
|
+
logger = get_toolkit_logger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
class ScrapliConnector(BaseDeviceConnector):
|
21
|
+
"""Scrapli-based implementation of device connector."""
|
22
|
+
|
23
|
+
# Platform to driver mapping - expanded for better NetBox platform support
|
24
|
+
DRIVER_MAP = {
|
25
|
+
'cisco_ios': IOSXEDriver,
|
26
|
+
'cisco_nxos': NXOSDriver,
|
27
|
+
'cisco_iosxr': IOSXRDriver,
|
28
|
+
'cisco_xe': IOSXEDriver, # Alternative naming
|
29
|
+
'ios': IOSXEDriver, # Generic iOS
|
30
|
+
'nxos': NXOSDriver, # Shorter form
|
31
|
+
'iosxr': IOSXRDriver, # Shorter form
|
32
|
+
'ios-xe': IOSXEDriver, # Hyphenated form
|
33
|
+
'ios-xr': IOSXRDriver, # Hyphenated form
|
34
|
+
}
|
35
|
+
|
36
|
+
def __init__(self, config: ConnectionConfig):
|
37
|
+
super().__init__(config)
|
38
|
+
self._driver_class = self._get_driver_class()
|
39
|
+
self._retry_config = ToolkitConfig.get_retry_config()
|
40
|
+
self._error_parser = VendorErrorParser()
|
41
|
+
self._fast_fail_mode = False # Flag for using reduced timeouts on initial attempts
|
42
|
+
|
43
|
+
logger.debug(f"Initialized ScrapliConnector for {config.hostname} with platform '{config.platform}'")
|
44
|
+
|
45
|
+
@classmethod
|
46
|
+
def get_supported_platforms(cls) -> list:
|
47
|
+
"""Get list of supported platform names."""
|
48
|
+
return list(cls.DRIVER_MAP.keys()) + ['generic']
|
49
|
+
|
50
|
+
@classmethod
|
51
|
+
def normalize_platform_name(cls, platform_name: str) -> str:
|
52
|
+
"""Normalize platform name for consistent mapping."""
|
53
|
+
if not platform_name:
|
54
|
+
return 'generic'
|
55
|
+
|
56
|
+
normalized = platform_name.lower().strip()
|
57
|
+
|
58
|
+
# Handle common variations
|
59
|
+
if normalized in ['cisco ios', 'ios', 'cisco_ios']:
|
60
|
+
return 'cisco_ios'
|
61
|
+
elif normalized in ['cisco nxos', 'nxos', 'nexus']:
|
62
|
+
return 'cisco_nxos'
|
63
|
+
elif normalized in ['cisco iosxr', 'iosxr', 'ios-xr']:
|
64
|
+
return 'cisco_iosxr'
|
65
|
+
elif normalized in ['cisco xe', 'ios-xe']:
|
66
|
+
return 'cisco_xe'
|
67
|
+
|
68
|
+
return normalized
|
69
|
+
|
70
|
+
def _get_driver_class(self) -> Type:
|
71
|
+
"""Get the appropriate Scrapli driver class for the platform."""
|
72
|
+
if not self.config.platform:
|
73
|
+
logger.debug("No platform specified, using GenericDriver")
|
74
|
+
return GenericDriver
|
75
|
+
|
76
|
+
normalized_platform = self.normalize_platform_name(self.config.platform)
|
77
|
+
driver_class = self.DRIVER_MAP.get(normalized_platform, GenericDriver)
|
78
|
+
|
79
|
+
if driver_class == GenericDriver:
|
80
|
+
logger.debug(f"Platform '{normalized_platform}' not in driver map, using GenericDriver")
|
81
|
+
else:
|
82
|
+
logger.debug(f"Platform '{normalized_platform}' mapped to {driver_class.__name__}")
|
83
|
+
|
84
|
+
return driver_class
|
85
|
+
|
86
|
+
def _build_connection_params(self) -> Dict[str, Any]:
|
87
|
+
"""Build connection parameters for Scrapli."""
|
88
|
+
# Use fast test timeouts for initial attempts if in fast fail mode
|
89
|
+
if self._fast_fail_mode:
|
90
|
+
fast_timeouts = ToolkitConfig.get_fast_test_timeouts()
|
91
|
+
socket_timeout = fast_timeouts['socket']
|
92
|
+
transport_timeout = fast_timeouts['transport']
|
93
|
+
ops_timeout = fast_timeouts['ops']
|
94
|
+
logger.debug("Using fast test timeouts for initial connection attempt")
|
95
|
+
else:
|
96
|
+
socket_timeout = self.config.timeout_socket
|
97
|
+
transport_timeout = self.config.timeout_transport
|
98
|
+
ops_timeout = self.config.timeout_ops
|
99
|
+
|
100
|
+
params = {
|
101
|
+
'host': self.config.hostname,
|
102
|
+
'auth_username': self.config.username,
|
103
|
+
'auth_password': self.config.password,
|
104
|
+
'auth_strict_key': self.config.auth_strict_key,
|
105
|
+
'transport': self.config.transport,
|
106
|
+
'timeout_socket': socket_timeout,
|
107
|
+
'timeout_transport': transport_timeout,
|
108
|
+
'timeout_ops': ops_timeout,
|
109
|
+
'transport_options': self._get_transport_options()
|
110
|
+
}
|
111
|
+
|
112
|
+
# Add any extra options (but filter out Netmiko-specific ones)
|
113
|
+
if self.config.extra_options:
|
114
|
+
# Filter out Netmiko-specific parameters that Scrapli doesn't understand
|
115
|
+
netmiko_only_params = {
|
116
|
+
'look_for_keys', 'use_keys', 'allow_agent', 'global_delay_factor',
|
117
|
+
'banner_timeout', 'auth_timeout', 'session_log', 'fast_cli',
|
118
|
+
'session_log_record_writes', 'session_log_file_mode', 'conn_timeout',
|
119
|
+
'read_timeout_override', 'auto_connect'
|
120
|
+
}
|
121
|
+
|
122
|
+
filtered_options = {
|
123
|
+
k: v for k, v in self.config.extra_options.items()
|
124
|
+
if k not in netmiko_only_params
|
125
|
+
}
|
126
|
+
|
127
|
+
if filtered_options:
|
128
|
+
params.update(filtered_options)
|
129
|
+
logger.debug(f"Added filtered extra options: {list(filtered_options.keys())}")
|
130
|
+
|
131
|
+
if len(filtered_options) != len(self.config.extra_options):
|
132
|
+
excluded = set(self.config.extra_options.keys()) - set(filtered_options.keys())
|
133
|
+
logger.debug(f"Excluded Netmiko-specific options: {excluded}")
|
134
|
+
|
135
|
+
logger.debug(f"Built connection params for {self.config.hostname}: transport={params['transport']}, "
|
136
|
+
f"timeouts=[socket:{params['timeout_socket']}, transport:{params['timeout_transport']}, "
|
137
|
+
f"ops:{params['timeout_ops']}]")
|
138
|
+
|
139
|
+
return params
|
140
|
+
|
141
|
+
def _get_transport_options(self) -> Dict[str, Any]:
|
142
|
+
"""Get transport-specific options for Scrapli."""
|
143
|
+
ssh_options = ToolkitConfig.get_ssh_transport_options()
|
144
|
+
|
145
|
+
transport_options = {}
|
146
|
+
|
147
|
+
# Add system transport options (Scrapli's native transport)
|
148
|
+
if self.config.transport == 'system':
|
149
|
+
transport_options['system'] = {
|
150
|
+
'open_cmd': ['ssh'],
|
151
|
+
'auth_bypass': False,
|
152
|
+
}
|
153
|
+
|
154
|
+
# Add SSH algorithm configurations
|
155
|
+
if 'disabled_algorithms' in ssh_options:
|
156
|
+
transport_options['disabled_algorithms'] = ssh_options['disabled_algorithms']
|
157
|
+
|
158
|
+
if 'allowed_kex' in ssh_options:
|
159
|
+
transport_options['kex_algorithms'] = ssh_options['allowed_kex']
|
160
|
+
|
161
|
+
logger.debug(f"Transport options for {self.config.transport}: {transport_options}")
|
162
|
+
return transport_options
|
163
|
+
|
164
|
+
def connect(self) -> None:
|
165
|
+
"""Establish connection to the device with retry logic and fast-fail detection."""
|
166
|
+
logger.debug(f"Attempting to connect to {self.config.hostname}:{self.config.port}")
|
167
|
+
|
168
|
+
# Clean up any existing connection first
|
169
|
+
if self._connection:
|
170
|
+
logger.debug("Cleaning up existing connection before reconnecting")
|
171
|
+
self.disconnect()
|
172
|
+
|
173
|
+
# First validate basic connectivity
|
174
|
+
try:
|
175
|
+
logger.debug(f"Validating basic connectivity to {self.config.hostname}:{self.config.port}")
|
176
|
+
validate_device_connectivity(self.config.hostname, self.config.port)
|
177
|
+
except Exception as e:
|
178
|
+
logger.error(f"Pre-connection validation failed for {self.config.hostname}: {str(e)}")
|
179
|
+
raise DeviceConnectionError(f"Pre-connection validation failed: {str(e)}")
|
180
|
+
|
181
|
+
# Use fast-fail mode for first attempt to quickly detect incompatible scenarios
|
182
|
+
self._fast_fail_mode = True
|
183
|
+
conn_params = self._build_connection_params()
|
184
|
+
|
185
|
+
# Attempt connection with retry logic
|
186
|
+
last_error = None
|
187
|
+
retry_delay = self._retry_config['retry_delay']
|
188
|
+
max_retries = self._retry_config['max_retries']
|
189
|
+
|
190
|
+
logger.debug(f"Starting connection attempts with max_retries={max_retries}, initial_delay={retry_delay}s")
|
191
|
+
|
192
|
+
for attempt in range(max_retries + 1):
|
193
|
+
try:
|
194
|
+
if attempt > 0:
|
195
|
+
logger.debug(f"Connection attempt {attempt + 1}/{max_retries + 1} after {retry_delay}s delay")
|
196
|
+
time.sleep(retry_delay)
|
197
|
+
|
198
|
+
# Switch to normal timeouts after first attempt
|
199
|
+
if attempt == 1:
|
200
|
+
self._fast_fail_mode = False
|
201
|
+
conn_params = self._build_connection_params()
|
202
|
+
logger.debug("Switched to normal timeouts for subsequent attempts")
|
203
|
+
|
204
|
+
# Adjust timeouts for SSH banner issues
|
205
|
+
if "banner" in str(last_error).lower() or "timed out" in str(last_error).lower():
|
206
|
+
logger.debug("Detected banner/timeout issue, increasing timeouts by 5s")
|
207
|
+
conn_params['timeout_socket'] += 5
|
208
|
+
conn_params['timeout_transport'] += 5
|
209
|
+
|
210
|
+
retry_delay *= self._retry_config['backoff_multiplier']
|
211
|
+
else:
|
212
|
+
logger.debug(f"Initial connection attempt to {self.config.hostname} (fast-fail mode)")
|
213
|
+
|
214
|
+
# Create and open connection
|
215
|
+
logger.debug(f"Creating {self._driver_class.__name__} instance")
|
216
|
+
self._connection = self._driver_class(**conn_params)
|
217
|
+
|
218
|
+
logger.debug("Opening connection to device")
|
219
|
+
self._connection.open()
|
220
|
+
|
221
|
+
logger.info(f"Successfully connected to {self.config.hostname} using {self._driver_class.__name__}")
|
222
|
+
return
|
223
|
+
|
224
|
+
except Exception as e:
|
225
|
+
last_error = e
|
226
|
+
error_msg = str(e)
|
227
|
+
logger.warning(f"Connection attempt {attempt + 1} failed for {self.config.hostname}: {error_msg}")
|
228
|
+
|
229
|
+
# Check for fast-fail patterns on first attempt
|
230
|
+
if attempt == 0 and ToolkitConfig.should_fast_fail_to_netmiko(error_msg):
|
231
|
+
logger.info(f"Fast-fail pattern detected: {error_msg}")
|
232
|
+
logger.info("Triggering immediate fallback to Netmiko")
|
233
|
+
raise DeviceConnectionError(f"Fast-fail to Netmiko: {error_msg}")
|
234
|
+
|
235
|
+
# Clean up failed connection attempt
|
236
|
+
if self._connection:
|
237
|
+
try:
|
238
|
+
logger.debug("Cleaning up failed connection attempt")
|
239
|
+
self._connection.close()
|
240
|
+
except Exception:
|
241
|
+
pass
|
242
|
+
self._connection = None
|
243
|
+
|
244
|
+
if attempt >= max_retries:
|
245
|
+
error_msg = self._format_connection_error(e)
|
246
|
+
logger.error(f"All connection attempts failed for {self.config.hostname}: {error_msg}")
|
247
|
+
raise DeviceConnectionError(error_msg)
|
248
|
+
|
249
|
+
def disconnect(self) -> None:
|
250
|
+
"""Close connection to the device with proper socket cleanup."""
|
251
|
+
if self._connection:
|
252
|
+
logger.debug(f"Disconnecting from {self.config.hostname}")
|
253
|
+
try:
|
254
|
+
# Use the robust cleanup utility
|
255
|
+
cleanup_connection_resources(self._connection)
|
256
|
+
logger.debug("Connection cleanup completed successfully")
|
257
|
+
except Exception as e:
|
258
|
+
logger.warning(f"Error during connection cleanup: {str(e)}")
|
259
|
+
pass # Cleanup error ignored
|
260
|
+
finally:
|
261
|
+
self._connection = None
|
262
|
+
# Give time for socket cleanup to complete
|
263
|
+
wait_for_socket_cleanup()
|
264
|
+
logger.debug("Socket cleanup wait completed")
|
265
|
+
else:
|
266
|
+
logger.debug("No active connection to disconnect")
|
267
|
+
|
268
|
+
def is_connected(self) -> bool:
|
269
|
+
"""Check if connection is active with proper error handling."""
|
270
|
+
if not self._connection:
|
271
|
+
logger.debug("No connection object exists")
|
272
|
+
return False
|
273
|
+
|
274
|
+
try:
|
275
|
+
# Check if connection object exists and is alive
|
276
|
+
is_alive = self._connection.isalive()
|
277
|
+
logger.debug(f"Connection status check: {'alive' if is_alive else 'dead'}")
|
278
|
+
return is_alive
|
279
|
+
except Exception as e:
|
280
|
+
# If checking connection status fails, assume disconnected
|
281
|
+
logger.warning(f"Error checking connection status: {str(e)}")
|
282
|
+
# Clean up the bad connection
|
283
|
+
self._connection = None
|
284
|
+
return False
|
285
|
+
|
286
|
+
def _validate_and_recover_connection(self) -> bool:
|
287
|
+
"""Validate connection and attempt recovery if needed."""
|
288
|
+
try:
|
289
|
+
if not self._connection:
|
290
|
+
logger.debug("No connection to validate")
|
291
|
+
return False
|
292
|
+
|
293
|
+
# Use the robust validation utility
|
294
|
+
is_healthy = validate_connection_health(self._connection)
|
295
|
+
logger.debug(f"Connection health validation: {'healthy' if is_healthy else 'unhealthy'}")
|
296
|
+
return is_healthy
|
297
|
+
|
298
|
+
except Exception as e:
|
299
|
+
logger.warning(f"Connection validation failed: {str(e)}")
|
300
|
+
self._connection = None
|
301
|
+
return False
|
302
|
+
|
303
|
+
def execute_command(self, command: str, command_type: str = 'show') -> CommandResult:
|
304
|
+
"""Execute a command on the device with robust error handling.
|
305
|
+
|
306
|
+
Args:
|
307
|
+
command: The command string to execute
|
308
|
+
command_type: Type of command ('show' or 'config') for proper scrapli method selection
|
309
|
+
|
310
|
+
Returns:
|
311
|
+
CommandResult with execution details
|
312
|
+
"""
|
313
|
+
logger.debug(f"Executing {command_type} command on {self.config.hostname}: {command}")
|
314
|
+
|
315
|
+
# Validate connection first
|
316
|
+
if not self._validate_and_recover_connection():
|
317
|
+
logger.error(f"Connection validation failed before executing command: {command}")
|
318
|
+
raise DeviceConnectionError("Connection is not available or has been lost")
|
319
|
+
|
320
|
+
start_time = time.time()
|
321
|
+
|
322
|
+
try:
|
323
|
+
# Use appropriate scrapli method based on command type
|
324
|
+
if command_type == 'config':
|
325
|
+
logger.debug("Using send_config method for configuration command")
|
326
|
+
# Use send_config for configuration commands - automatically handles config mode
|
327
|
+
response = self._connection.send_config(command)
|
328
|
+
else:
|
329
|
+
logger.debug("Using send_command method for show/operational command")
|
330
|
+
# Use send_command for show/operational commands
|
331
|
+
response = self._connection.send_command(command)
|
332
|
+
|
333
|
+
execution_time = time.time() - start_time
|
334
|
+
logger.debug(f"Command completed in {execution_time:.2f}s, output length: {len(response.result)} chars")
|
335
|
+
|
336
|
+
# Create initial result
|
337
|
+
result = CommandResult(
|
338
|
+
command=command,
|
339
|
+
output=response.result,
|
340
|
+
success=True,
|
341
|
+
execution_time=execution_time
|
342
|
+
)
|
343
|
+
|
344
|
+
# Check for syntax errors in the output even if command executed successfully
|
345
|
+
parsed_error = self._error_parser.parse_command_output(response.result, self.config.platform)
|
346
|
+
if parsed_error:
|
347
|
+
logger.warning(f"Syntax error detected in command output: {parsed_error.error_type.value}")
|
348
|
+
# Update result with syntax error information
|
349
|
+
result.has_syntax_error = True
|
350
|
+
result.syntax_error_type = parsed_error.error_type.value
|
351
|
+
result.syntax_error_vendor = parsed_error.vendor
|
352
|
+
result.syntax_error_guidance = parsed_error.guidance
|
353
|
+
|
354
|
+
# Enhance the output with error information
|
355
|
+
enhanced_output = response.result + "\n\n" + "="*50 + "\n"
|
356
|
+
enhanced_output += "SYNTAX ERROR DETECTED\n" + "="*50 + "\n"
|
357
|
+
enhanced_output += f"Error Type: {parsed_error.error_type.value.replace('_', ' ').title()}\n"
|
358
|
+
enhanced_output += f"Vendor: {self._error_parser._get_vendor_display_name(parsed_error.vendor)}\n"
|
359
|
+
enhanced_output += f"Confidence: {parsed_error.confidence:.0%}\n\n"
|
360
|
+
enhanced_output += parsed_error.enhanced_message + "\n\n"
|
361
|
+
enhanced_output += parsed_error.guidance
|
362
|
+
|
363
|
+
result.output = enhanced_output
|
364
|
+
|
365
|
+
# Attempt to parse command output using TextFSM (only for successful commands without syntax errors)
|
366
|
+
if result.success and not result.has_syntax_error:
|
367
|
+
logger.debug("Attempting to parse command output with TextFSM")
|
368
|
+
result = self._attempt_parsing(result, response)
|
369
|
+
|
370
|
+
return result
|
371
|
+
|
372
|
+
except OSError as e:
|
373
|
+
# Handle socket-related errors specifically
|
374
|
+
execution_time = time.time() - start_time
|
375
|
+
if "Bad file descriptor" in str(e) or e.errno == 9:
|
376
|
+
# Socket has been closed or is invalid
|
377
|
+
logger.error(f"Socket error during command execution: {str(e)}")
|
378
|
+
self._connection = None # Mark connection as invalid
|
379
|
+
error_msg = f"Connection lost due to socket error: {str(e)}"
|
380
|
+
else:
|
381
|
+
logger.error(f"OS error during command execution: {str(e)}")
|
382
|
+
error_msg = f"OS error during command execution: {str(e)}"
|
383
|
+
|
384
|
+
return CommandResult(
|
385
|
+
command=command,
|
386
|
+
output="",
|
387
|
+
success=False,
|
388
|
+
error_message=error_msg,
|
389
|
+
execution_time=execution_time
|
390
|
+
)
|
391
|
+
|
392
|
+
except Exception as e:
|
393
|
+
execution_time = time.time() - start_time
|
394
|
+
error_msg = f"Command execution failed: {str(e)}"
|
395
|
+
logger.error(f"Command execution failed for '{command}': {str(e)}")
|
396
|
+
|
397
|
+
# Check if this is a connection-related error
|
398
|
+
if "connection" in str(e).lower() or "socket" in str(e).lower():
|
399
|
+
logger.warning("Detected connection-related error, marking connection as invalid")
|
400
|
+
self._connection = None # Mark connection as invalid
|
401
|
+
|
402
|
+
return CommandResult(
|
403
|
+
command=command,
|
404
|
+
output="",
|
405
|
+
success=False,
|
406
|
+
error_message=error_msg,
|
407
|
+
execution_time=execution_time
|
408
|
+
)
|
409
|
+
|
410
|
+
def _attempt_parsing(self, result: CommandResult, response) -> CommandResult:
|
411
|
+
"""Attempt to parse command output using available parsers.
|
412
|
+
|
413
|
+
Args:
|
414
|
+
result: The current CommandResult
|
415
|
+
response: The scrapli response object
|
416
|
+
|
417
|
+
Returns:
|
418
|
+
Updated CommandResult with parsing information
|
419
|
+
"""
|
420
|
+
# Try TextFSM parsing first (most comprehensive template library)
|
421
|
+
try:
|
422
|
+
parsed_data = response.textfsm_parse_output()
|
423
|
+
|
424
|
+
if parsed_data:
|
425
|
+
# TextFSM parsing successful
|
426
|
+
logger.debug(f"TextFSM parsing successful, parsed {len(parsed_data)} records")
|
427
|
+
result.parsed_output = parsed_data
|
428
|
+
result.parsing_success = True
|
429
|
+
result.parsing_method = 'textfsm'
|
430
|
+
|
431
|
+
return result
|
432
|
+
else:
|
433
|
+
logger.debug("TextFSM parsing returned empty result (no matching template)")
|
434
|
+
pass # TextFSM parsing returned empty result
|
435
|
+
|
436
|
+
except Exception as e:
|
437
|
+
# TextFSM parsing failed - this is common for commands without templates
|
438
|
+
error_msg = str(e)
|
439
|
+
logger.debug(f"TextFSM parsing failed: {error_msg}")
|
440
|
+
|
441
|
+
# Store parsing error for debugging (but don't fail the command)
|
442
|
+
result.parsing_error = f"TextFSM: {error_msg}"
|
443
|
+
|
444
|
+
# Could add other parsers here in the future (Genie, TTP)
|
445
|
+
# For now, we only attempt TextFSM
|
446
|
+
|
447
|
+
return result
|
448
|
+
|
449
|
+
def _format_connection_error(self, error: Exception) -> str:
|
450
|
+
"""Format connection error with helpful troubleshooting information."""
|
451
|
+
error_message = str(error)
|
452
|
+
|
453
|
+
# Base error message
|
454
|
+
formatted_msg = f"Failed to connect to {self.config.hostname}: {error_message}"
|
455
|
+
|
456
|
+
# Add specific guidance for common SSH errors
|
457
|
+
if "No matching key exchange" in error_message:
|
458
|
+
formatted_msg += (
|
459
|
+
"\n\nThis appears to be an SSH key exchange error. The device is offering "
|
460
|
+
"encryption algorithms that are not supported by default."
|
461
|
+
)
|
462
|
+
elif "connection not opened" in error_message:
|
463
|
+
formatted_msg += (
|
464
|
+
"\n\nUnable to establish SSH connection. This could be due to:"
|
465
|
+
"\n- The device is not reachable on the network"
|
466
|
+
"\n- SSH service is not running on the device"
|
467
|
+
"\n- There's a firewall blocking the connection"
|
468
|
+
"\n- The device has reached its maximum number of SSH sessions"
|
469
|
+
)
|
470
|
+
elif "Error reading SSH protocol banner" in error_message:
|
471
|
+
formatted_msg += (
|
472
|
+
"\n\nCould not read the SSH protocol banner from the device. This typically happens when:"
|
473
|
+
"\n- The device accepts TCP connections on port 22 but is not running SSH"
|
474
|
+
"\n- The device's SSH server is too slow to respond with a banner (timeout)"
|
475
|
+
"\n- A firewall or security device is intercepting the connection"
|
476
|
+
"\n- The SSH implementation on the device is non-standard or very old"
|
477
|
+
)
|
478
|
+
elif "Authentication failed" in error_message:
|
479
|
+
formatted_msg += (
|
480
|
+
"\n\nAuthentication failed. Please verify:"
|
481
|
+
"\n- Username and password are correct"
|
482
|
+
"\n- The account is not locked"
|
483
|
+
"\n- The device allows the authentication method being used"
|
484
|
+
)
|
485
|
+
|
486
|
+
return formatted_msg
|
@@ -0,0 +1,36 @@
|
|
1
|
+
"""Custom exceptions for the NetBox Toolkit plugin."""
|
2
|
+
|
3
|
+
|
4
|
+
class ToolkitError(Exception):
|
5
|
+
"""Base exception for toolkit-related errors."""
|
6
|
+
pass
|
7
|
+
|
8
|
+
|
9
|
+
class DeviceConnectionError(ToolkitError):
|
10
|
+
"""Raised when device connection fails."""
|
11
|
+
pass
|
12
|
+
|
13
|
+
|
14
|
+
class DeviceReachabilityError(DeviceConnectionError):
|
15
|
+
"""Raised when device is not reachable."""
|
16
|
+
pass
|
17
|
+
|
18
|
+
|
19
|
+
class SSHBannerError(DeviceConnectionError):
|
20
|
+
"""Raised when SSH banner issues occur."""
|
21
|
+
pass
|
22
|
+
|
23
|
+
|
24
|
+
class AuthenticationError(DeviceConnectionError):
|
25
|
+
"""Raised when authentication fails."""
|
26
|
+
pass
|
27
|
+
|
28
|
+
|
29
|
+
class CommandExecutionError(ToolkitError):
|
30
|
+
"""Raised when command execution fails."""
|
31
|
+
pass
|
32
|
+
|
33
|
+
|
34
|
+
class UnsupportedPlatformError(ToolkitError):
|
35
|
+
"""Raised when device platform is not supported."""
|
36
|
+
pass
|
@@ -0,0 +1,85 @@
|
|
1
|
+
import django_filters
|
2
|
+
from netbox.filtersets import NetBoxModelFilterSet
|
3
|
+
from dcim.models import Platform, Device
|
4
|
+
from .models import Command, CommandLog
|
5
|
+
|
6
|
+
class CommandFilterSet(NetBoxModelFilterSet):
|
7
|
+
"""Enhanced filtering for commands"""
|
8
|
+
platform_id = django_filters.ModelMultipleChoiceFilter(
|
9
|
+
queryset=Platform.objects.all(),
|
10
|
+
label='Platform (ID)',
|
11
|
+
)
|
12
|
+
platform_slug = django_filters.CharFilter(
|
13
|
+
field_name='platform__slug',
|
14
|
+
lookup_expr='icontains',
|
15
|
+
label='Platform slug'
|
16
|
+
)
|
17
|
+
command_type = django_filters.ChoiceFilter(
|
18
|
+
choices=[('show', 'Show Command'), ('config', 'Configuration Command')]
|
19
|
+
)
|
20
|
+
created_after = django_filters.DateTimeFilter(
|
21
|
+
field_name='created',
|
22
|
+
lookup_expr='gte'
|
23
|
+
)
|
24
|
+
created_before = django_filters.DateTimeFilter(
|
25
|
+
field_name='created',
|
26
|
+
lookup_expr='lte'
|
27
|
+
)
|
28
|
+
name_icontains = django_filters.CharFilter(
|
29
|
+
field_name='name',
|
30
|
+
lookup_expr='icontains',
|
31
|
+
label='Name contains'
|
32
|
+
)
|
33
|
+
description_icontains = django_filters.CharFilter(
|
34
|
+
field_name='description',
|
35
|
+
lookup_expr='icontains',
|
36
|
+
label='Description contains'
|
37
|
+
)
|
38
|
+
|
39
|
+
class Meta:
|
40
|
+
model = Command
|
41
|
+
fields = ('name', 'platform', 'command_type', 'description')
|
42
|
+
|
43
|
+
class CommandLogFilterSet(NetBoxModelFilterSet):
|
44
|
+
"""Enhanced filtering for command logs"""
|
45
|
+
device_id = django_filters.ModelMultipleChoiceFilter(
|
46
|
+
queryset=Device.objects.all(),
|
47
|
+
label='Device (ID)'
|
48
|
+
)
|
49
|
+
command_id = django_filters.ModelMultipleChoiceFilter(
|
50
|
+
queryset=Command.objects.all(),
|
51
|
+
label='Command (ID)'
|
52
|
+
)
|
53
|
+
execution_time_after = django_filters.DateTimeFilter(
|
54
|
+
field_name='execution_time',
|
55
|
+
lookup_expr='gte'
|
56
|
+
)
|
57
|
+
execution_time_before = django_filters.DateTimeFilter(
|
58
|
+
field_name='execution_time',
|
59
|
+
lookup_expr='lte'
|
60
|
+
)
|
61
|
+
has_parsed_data = django_filters.BooleanFilter(
|
62
|
+
field_name='parsed_data',
|
63
|
+
lookup_expr='isnull',
|
64
|
+
exclude=True,
|
65
|
+
label='Has parsed data'
|
66
|
+
)
|
67
|
+
username_icontains = django_filters.CharFilter(
|
68
|
+
field_name='username',
|
69
|
+
lookup_expr='icontains',
|
70
|
+
label='Username contains'
|
71
|
+
)
|
72
|
+
device_name_icontains = django_filters.CharFilter(
|
73
|
+
field_name='device__name',
|
74
|
+
lookup_expr='icontains',
|
75
|
+
label='Device name contains'
|
76
|
+
)
|
77
|
+
command_name_icontains = django_filters.CharFilter(
|
78
|
+
field_name='command__name',
|
79
|
+
lookup_expr='icontains',
|
80
|
+
label='Command name contains'
|
81
|
+
)
|
82
|
+
|
83
|
+
class Meta:
|
84
|
+
model = CommandLog
|
85
|
+
fields = ('command', 'device', 'username', 'success', 'parsing_success')
|
netbox_toolkit/forms.py
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
from django import forms
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
3
|
+
from netbox.forms import NetBoxModelForm
|
4
|
+
from utilities.forms.fields import DynamicModelChoiceField
|
5
|
+
from dcim.models import Platform
|
6
|
+
from .models import Command, CommandLog
|
7
|
+
|
8
|
+
class CommandForm(NetBoxModelForm):
|
9
|
+
platform = DynamicModelChoiceField(
|
10
|
+
queryset=Platform.objects.all(),
|
11
|
+
help_text="Platform this command is designed for (e.g., cisco_ios, cisco_nxos, generic)"
|
12
|
+
)
|
13
|
+
|
14
|
+
class Meta:
|
15
|
+
model = Command
|
16
|
+
fields = ('name', 'command', 'description', 'platform', 'command_type', 'tags')
|
17
|
+
|
18
|
+
class CommandLogForm(NetBoxModelForm):
|
19
|
+
class Meta:
|
20
|
+
model = CommandLog
|
21
|
+
fields = ('command', 'device', 'output', 'username')
|
22
|
+
|
23
|
+
class CommandExecutionForm(forms.Form):
|
24
|
+
username = forms.CharField(
|
25
|
+
max_length=100,
|
26
|
+
help_text="Username for device authentication"
|
27
|
+
)
|
28
|
+
password = forms.CharField(
|
29
|
+
widget=forms.PasswordInput,
|
30
|
+
help_text="Password for device authentication"
|
31
|
+
)
|