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.
Files changed (56) hide show
  1. netbox_toolkit/__init__.py +30 -0
  2. netbox_toolkit/admin.py +16 -0
  3. netbox_toolkit/api/__init__.py +0 -0
  4. netbox_toolkit/api/mixins.py +54 -0
  5. netbox_toolkit/api/schemas.py +234 -0
  6. netbox_toolkit/api/serializers.py +158 -0
  7. netbox_toolkit/api/urls.py +10 -0
  8. netbox_toolkit/api/views/__init__.py +10 -0
  9. netbox_toolkit/api/views/command_logs.py +170 -0
  10. netbox_toolkit/api/views/commands.py +267 -0
  11. netbox_toolkit/config.py +159 -0
  12. netbox_toolkit/connectors/__init__.py +15 -0
  13. netbox_toolkit/connectors/base.py +97 -0
  14. netbox_toolkit/connectors/factory.py +301 -0
  15. netbox_toolkit/connectors/netmiko_connector.py +443 -0
  16. netbox_toolkit/connectors/scrapli_connector.py +486 -0
  17. netbox_toolkit/exceptions.py +36 -0
  18. netbox_toolkit/filtersets.py +85 -0
  19. netbox_toolkit/forms.py +31 -0
  20. netbox_toolkit/migrations/0001_initial.py +54 -0
  21. netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +66 -0
  22. netbox_toolkit/migrations/0003_permission_system_update.py +48 -0
  23. netbox_toolkit/migrations/0004_remove_django_permissions.py +77 -0
  24. netbox_toolkit/migrations/0005_alter_command_options_and_more.py +25 -0
  25. netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +28 -0
  26. netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py +18 -0
  27. netbox_toolkit/migrations/__init__.py +0 -0
  28. netbox_toolkit/models.py +89 -0
  29. netbox_toolkit/navigation.py +30 -0
  30. netbox_toolkit/search.py +21 -0
  31. netbox_toolkit/services/__init__.py +7 -0
  32. netbox_toolkit/services/command_service.py +357 -0
  33. netbox_toolkit/services/device_service.py +87 -0
  34. netbox_toolkit/services/rate_limiting_service.py +228 -0
  35. netbox_toolkit/static/netbox_toolkit/css/toolkit.css +143 -0
  36. netbox_toolkit/static/netbox_toolkit/js/toolkit.js +657 -0
  37. netbox_toolkit/tables.py +37 -0
  38. netbox_toolkit/templates/netbox_toolkit/command.html +108 -0
  39. netbox_toolkit/templates/netbox_toolkit/command_edit.html +10 -0
  40. netbox_toolkit/templates/netbox_toolkit/command_list.html +12 -0
  41. netbox_toolkit/templates/netbox_toolkit/commandlog.html +170 -0
  42. netbox_toolkit/templates/netbox_toolkit/commandlog_list.html +4 -0
  43. netbox_toolkit/templates/netbox_toolkit/device_toolkit.html +536 -0
  44. netbox_toolkit/urls.py +22 -0
  45. netbox_toolkit/utils/__init__.py +1 -0
  46. netbox_toolkit/utils/connection.py +125 -0
  47. netbox_toolkit/utils/error_parser.py +428 -0
  48. netbox_toolkit/utils/logging.py +58 -0
  49. netbox_toolkit/utils/network.py +157 -0
  50. netbox_toolkit/views.py +385 -0
  51. netbox_toolkit_plugin-0.1.0.dist-info/METADATA +76 -0
  52. netbox_toolkit_plugin-0.1.0.dist-info/RECORD +56 -0
  53. netbox_toolkit_plugin-0.1.0.dist-info/WHEEL +5 -0
  54. netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +2 -0
  55. netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE +200 -0
  56. 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')
@@ -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
+ )