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,443 @@
1
+ """Netmiko-based device connector implementation."""
2
+ import time
3
+ from typing import Dict, Any, Optional, Tuple
4
+
5
+ from netmiko import ConnectHandler, SSHDetect
6
+ from netmiko.exceptions import (
7
+ NetmikoTimeoutException,
8
+ NetmikoAuthenticationException,
9
+ NetmikoBaseException,
10
+ ConnectionException,
11
+ ConfigInvalidException
12
+ )
13
+
14
+ from .base import BaseDeviceConnector, ConnectionConfig, CommandResult
15
+ from ..config import ToolkitConfig
16
+ from ..exceptions import DeviceConnectionError, CommandExecutionError, UnsupportedPlatformError
17
+ from ..utils.network import validate_device_connectivity
18
+ from ..utils.error_parser import VendorErrorParser
19
+ from ..utils.logging import get_toolkit_logger
20
+
21
+ logger = get_toolkit_logger(__name__)
22
+
23
+
24
+ class NetmikoConnector(BaseDeviceConnector):
25
+ """Netmiko-based implementation of device connector for legacy/fallback support."""
26
+
27
+ # NetBox platform to Netmiko device_type mapping
28
+ DEVICE_TYPE_MAP = {
29
+ 'cisco_ios': 'cisco_ios',
30
+ 'cisco_nxos': 'cisco_nxos',
31
+ 'cisco_iosxr': 'cisco_xr',
32
+ 'cisco_xe': 'cisco_ios',
33
+ 'cisco_asa': 'cisco_asa',
34
+ 'arista_eos': 'arista_eos',
35
+ 'juniper_junos': 'juniper_junos',
36
+ 'hp_procurve': 'hp_procurve',
37
+ 'hp_comware': 'hp_comware',
38
+ 'dell_os10': 'dell_os10',
39
+ 'dell_powerconnect': 'dell_powerconnect',
40
+ 'linux': 'linux',
41
+ 'paloalto_panos': 'paloalto_panos',
42
+ 'fortinet': 'fortinet',
43
+ 'mikrotik_routeros': 'mikrotik_routeros',
44
+ 'ubiquiti_edge': 'ubiquiti_edge',
45
+ # Generic fallback
46
+ 'generic': 'generic_termserver',
47
+ 'autodetect': 'autodetect'
48
+ }
49
+
50
+ def __init__(self, config: ConnectionConfig):
51
+ super().__init__(config)
52
+ self._error_parser = VendorErrorParser()
53
+ self._retry_config = ToolkitConfig.get_retry_config()
54
+
55
+ # Use config from extra_options if available, otherwise get from ToolkitConfig
56
+ if config.extra_options:
57
+ self._netmiko_config = config.extra_options.copy()
58
+ logger.debug(f"Using Netmiko config from extra_options: {list(self._netmiko_config.keys())}")
59
+ else:
60
+ self._netmiko_config = ToolkitConfig.get_netmiko_config()
61
+ logger.debug("Using default Netmiko config from ToolkitConfig")
62
+
63
+ logger.debug(f"Initialized NetmikoConnector for {config.hostname} with platform '{config.platform}'")
64
+
65
+ def _filter_valid_netmiko_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
66
+ """Filter parameters to only include those supported by Netmiko."""
67
+ # Define valid Netmiko connection parameters
68
+ valid_netmiko_params = {
69
+ 'device_type', 'host', 'username', 'password', 'port', 'timeout',
70
+ 'banner_timeout', 'auth_timeout', 'global_delay_factor', 'use_keys',
71
+ 'key_file', 'allow_agent', 'session_log', 'session_log_record_writes',
72
+ 'session_log_file_mode', 'fast_cli', 'secret', 'blocking_timeout',
73
+ 'verbose', 'conn_timeout', 'read_timeout', 'keepalive'
74
+ }
75
+
76
+ filtered_params = {}
77
+ for key, value in params.items():
78
+ if key in valid_netmiko_params:
79
+ filtered_params[key] = value
80
+ else:
81
+ logger.debug(f"Filtering out unsupported Netmiko parameter: {key}")
82
+
83
+ return filtered_params
84
+
85
+ @classmethod
86
+ def get_supported_platforms(cls) -> list:
87
+ """Get list of supported platform names."""
88
+ return list(cls.DEVICE_TYPE_MAP.keys())
89
+
90
+ @classmethod
91
+ def normalize_platform_name(cls, platform_name: str) -> str:
92
+ """Normalize platform name for consistent mapping."""
93
+ if not platform_name:
94
+ return 'autodetect'
95
+
96
+ normalized = platform_name.lower().strip()
97
+
98
+ # Handle common variations
99
+ platform_mappings = {
100
+ 'ios': 'cisco_ios',
101
+ 'nxos': 'cisco_nxos',
102
+ 'nexus': 'cisco_nxos',
103
+ 'iosxr': 'cisco_iosxr',
104
+ 'ios-xr': 'cisco_iosxr',
105
+ 'ios-xe': 'cisco_xe',
106
+ 'eos': 'arista_eos',
107
+ 'junos': 'juniper_junos',
108
+ 'asa': 'cisco_asa',
109
+ }
110
+
111
+ return platform_mappings.get(normalized, normalized)
112
+
113
+ def _get_device_type(self) -> str:
114
+ """Get the appropriate Netmiko device_type for the platform."""
115
+ if not self.config.platform:
116
+ logger.debug("No platform specified, attempting auto-detection")
117
+ return 'autodetect'
118
+
119
+ normalized_platform = self.normalize_platform_name(self.config.platform)
120
+ device_type = self.DEVICE_TYPE_MAP.get(normalized_platform, 'autodetect')
121
+
122
+ logger.debug(f"Platform '{normalized_platform}' mapped to device_type '{device_type}'")
123
+ return device_type
124
+
125
+ def _auto_detect_device_type(self) -> str:
126
+ """Use Netmiko's auto-detection for unknown platforms."""
127
+ try:
128
+ logger.debug(f"Attempting auto-detection for {self.config.hostname}")
129
+
130
+ device_dict = {
131
+ 'device_type': 'autodetect',
132
+ 'host': self.config.hostname,
133
+ 'username': self.config.username,
134
+ 'password': self.config.password,
135
+ 'port': self.config.port,
136
+ 'timeout': self.config.timeout_socket,
137
+ }
138
+
139
+ guesser = SSHDetect(**device_dict)
140
+ best_match = guesser.autodetect()
141
+
142
+ if best_match:
143
+ logger.info(f"Auto-detected device type '{best_match}' for {self.config.hostname}")
144
+ return best_match
145
+ else:
146
+ logger.warning(f"Auto-detection failed for {self.config.hostname}, using generic")
147
+ return 'generic_termserver'
148
+
149
+ except Exception as e:
150
+ logger.warning(f"Auto-detection error for {self.config.hostname}: {str(e)}")
151
+ return 'generic_termserver'
152
+
153
+ def _build_connection_params(self) -> Dict[str, Any]:
154
+ """Build connection parameters for Netmiko."""
155
+ device_type = self._get_device_type()
156
+
157
+ # Handle auto-detection
158
+ if device_type == 'autodetect':
159
+ device_type = self._auto_detect_device_type()
160
+
161
+ params = {
162
+ 'device_type': device_type,
163
+ 'host': self.config.hostname,
164
+ 'username': self.config.username,
165
+ 'password': self.config.password,
166
+ 'port': self.config.port,
167
+ 'timeout': self.config.timeout_socket,
168
+ 'banner_timeout': self._netmiko_config.get('banner_timeout', 15),
169
+ 'auth_timeout': self._netmiko_config.get('auth_timeout', 15),
170
+ 'blocking_timeout': self.config.timeout_ops,
171
+ }
172
+
173
+ # Add advanced options from Netmiko config
174
+ if 'global_delay_factor' in self._netmiko_config:
175
+ params['global_delay_factor'] = self._netmiko_config['global_delay_factor']
176
+
177
+ if 'session_log' in self._netmiko_config and self._netmiko_config['session_log']:
178
+ params['session_log'] = self._netmiko_config['session_log']
179
+
180
+ # SSH key options - only set if explicitly enabled
181
+ if self._netmiko_config.get('use_keys', False):
182
+ params['use_keys'] = True
183
+ if 'key_file' in self._netmiko_config:
184
+ params['key_file'] = self._netmiko_config['key_file']
185
+ else:
186
+ # Explicitly disable key authentication for faster connections
187
+ params['use_keys'] = False
188
+
189
+ # SSH agent options
190
+ if not self._netmiko_config.get('allow_agent', True):
191
+ params['allow_agent'] = False
192
+
193
+ # Add any other valid Netmiko options from extra_options
194
+ if self.config.extra_options:
195
+ # Filter to only include valid Netmiko parameters
196
+ valid_params = self._filter_valid_netmiko_params(self.config.extra_options)
197
+ params.update(valid_params)
198
+
199
+ logger.debug(f"Netmiko connection params: device_type={device_type}, host={params['host']}")
200
+ return params
201
+
202
+ def connect(self) -> None:
203
+ """Establish connection to the device using Netmiko with retry logic."""
204
+ if self._connection:
205
+ logger.debug(f"Already connected to {self.config.hostname}")
206
+ return
207
+
208
+ # Validate connectivity first
209
+ validate_device_connectivity(self.config.hostname, self.config.port)
210
+
211
+ max_retries = self._retry_config['max_retries']
212
+ retry_delay = self._retry_config['retry_delay']
213
+ last_error = None
214
+
215
+ for attempt in range(max_retries + 1):
216
+ try:
217
+ if attempt > 0:
218
+ logger.debug(f"Connection attempt {attempt + 1}/{max_retries + 1} after {retry_delay}s delay")
219
+ time.sleep(retry_delay)
220
+ retry_delay *= self._retry_config['backoff_multiplier']
221
+ else:
222
+ logger.debug(f"Initial connection attempt to {self.config.hostname}")
223
+
224
+ # Build connection parameters
225
+ conn_params = self._build_connection_params()
226
+
227
+ # Create and establish connection
228
+ logger.debug(f"Creating Netmiko ConnectHandler for {self.config.hostname}")
229
+ self._connection = ConnectHandler(**conn_params)
230
+
231
+ logger.info(f"Successfully connected to {self.config.hostname} using Netmiko")
232
+ return
233
+
234
+ except NetmikoAuthenticationException as e:
235
+ logger.error(f"Authentication failed for {self.config.hostname}: {str(e)}")
236
+ raise DeviceConnectionError(f"Authentication failed: {str(e)}")
237
+
238
+ except NetmikoTimeoutException as e:
239
+ last_error = e
240
+ logger.warning(f"Connection timeout for {self.config.hostname}: {str(e)}")
241
+
242
+ if attempt >= max_retries:
243
+ raise DeviceConnectionError(f"Connection timeout after {max_retries + 1} attempts: {str(e)}")
244
+
245
+ except NetmikoBaseException as e:
246
+ last_error = e
247
+ logger.warning(f"Netmiko error for {self.config.hostname}: {str(e)}")
248
+
249
+ if attempt >= max_retries:
250
+ raise DeviceConnectionError(f"Netmiko connection failed: {str(e)}")
251
+
252
+ except Exception as e:
253
+ last_error = e
254
+ logger.warning(f"Unexpected error for {self.config.hostname}: {str(e)}")
255
+
256
+ if attempt >= max_retries:
257
+ raise DeviceConnectionError(f"Connection failed: {str(e)}")
258
+
259
+ def disconnect(self) -> None:
260
+ """Close connection to the device."""
261
+ if self._connection:
262
+ logger.debug(f"Disconnecting from {self.config.hostname}")
263
+ try:
264
+ self._connection.disconnect()
265
+ logger.debug("Successfully disconnected")
266
+ except Exception as e:
267
+ logger.warning(f"Error during disconnect: {str(e)}")
268
+ finally:
269
+ self._connection = None
270
+
271
+ def execute_command(self, command: str, command_type: str = 'show') -> CommandResult:
272
+ """Execute a command on the device.
273
+
274
+ Args:
275
+ command: The command string to execute
276
+ command_type: Type of command ('show' or 'config') for proper handling
277
+
278
+ Returns:
279
+ CommandResult with execution details
280
+ """
281
+ if not self._connection:
282
+ raise CommandExecutionError("Not connected to device")
283
+
284
+ logger.debug(f"Executing {command_type} command on {self.config.hostname}: {command}")
285
+ start_time = time.time()
286
+
287
+ try:
288
+ # Use command_type parameter to determine execution method
289
+ if command_type == 'config':
290
+ output = self._execute_config_command(command)
291
+ parsed_data = None # Config commands don't get parsed
292
+ else:
293
+ output, parsed_data = self._execute_show_command(command)
294
+
295
+ execution_time = time.time() - start_time
296
+
297
+ # Create initial result
298
+ result = CommandResult(
299
+ command=command,
300
+ output=output,
301
+ success=True,
302
+ execution_time=execution_time
303
+ )
304
+
305
+ # Add parsed data if available
306
+ if parsed_data:
307
+ result.parsed_output = parsed_data
308
+ result.parsing_success = True
309
+ result.parsing_method = 'textfsm'
310
+
311
+ # Check for syntax errors in the output even if command executed successfully
312
+ parsed_error = self._error_parser.parse_command_output(output, self.config.platform)
313
+ if parsed_error:
314
+ logger.warning(f"Syntax error detected in command output: {parsed_error.error_type.value}")
315
+ # Update result with syntax error information
316
+ result.has_syntax_error = True
317
+ result.syntax_error_type = parsed_error.error_type.value
318
+ result.syntax_error_vendor = parsed_error.vendor
319
+ result.syntax_error_guidance = parsed_error.guidance
320
+
321
+ # Enhance the output with error information
322
+ enhanced_output = output + "\n\n" + "="*50 + "\n"
323
+ enhanced_output += "SYNTAX ERROR DETECTED\n" + "="*50 + "\n"
324
+ enhanced_output += f"Error Type: {parsed_error.error_type.value.replace('_', ' ').title()}\n"
325
+ enhanced_output += f"Vendor: {self._error_parser._get_vendor_display_name(parsed_error.vendor)}\n"
326
+ enhanced_output += f"Confidence: {parsed_error.confidence:.0%}\n\n"
327
+ enhanced_output += parsed_error.enhanced_message + "\n\n"
328
+ enhanced_output += parsed_error.guidance
329
+
330
+ result.output = enhanced_output
331
+
332
+ # Log final result summary
333
+ if parsed_data:
334
+ logger.debug(f"Command completed in {execution_time:.2f}s with {len(parsed_data)} parsed records")
335
+ else:
336
+ logger.debug(f"Command completed in {execution_time:.2f}s")
337
+ return result
338
+
339
+ except NetmikoBaseException as e:
340
+ execution_time = time.time() - start_time
341
+ error_msg = str(e)
342
+ logger.error(f"Command execution failed: {error_msg}")
343
+
344
+ return CommandResult(
345
+ command=command,
346
+ output="",
347
+ success=False,
348
+ error_message=error_msg,
349
+ execution_time=execution_time
350
+ )
351
+
352
+ except Exception as e:
353
+ execution_time = time.time() - start_time
354
+ error_msg = f"Unexpected error: {str(e)}"
355
+ logger.error(f"Command execution failed: {error_msg}")
356
+
357
+ return CommandResult(
358
+ command=command,
359
+ output="",
360
+ success=False,
361
+ error_message=error_msg,
362
+ execution_time=execution_time
363
+ )
364
+
365
+ def _execute_show_command(self, command: str) -> Tuple[str, Optional[list]]:
366
+ """Execute a show/display command and return both raw output and parsed data.
367
+
368
+ Args:
369
+ command: The command to execute
370
+
371
+ Returns:
372
+ tuple: (raw_output, parsed_data) where parsed_data is None if parsing failed
373
+ """
374
+ try:
375
+ # Execute command once and get raw output
376
+ raw_output = self._connection.send_command(command)
377
+
378
+ # Now attempt TextFSM parsing using the textfsm library directly
379
+ # This avoids re-executing the command on the device
380
+ parsed_data = None
381
+ try:
382
+ # Import textfsm here to avoid dependency issues if not installed
383
+ import textfsm
384
+ import os
385
+ from ntc_templates.parse import parse_output
386
+
387
+ # Try to parse using ntc-templates (which is what Netmiko uses)
388
+ try:
389
+ parsed_result = parse_output(
390
+ platform=self._connection.device_type,
391
+ command=command,
392
+ data=raw_output
393
+ )
394
+
395
+ if (isinstance(parsed_result, list) and
396
+ len(parsed_result) > 0 and
397
+ isinstance(parsed_result[0], dict)):
398
+ parsed_data = parsed_result
399
+ logger.debug(f"TextFSM parsed {len(parsed_data)} records")
400
+ else:
401
+ logger.debug("No TextFSM template found")
402
+
403
+ except Exception as ntc_error:
404
+ logger.debug(f"TextFSM parsing failed: {str(ntc_error)}")
405
+
406
+ except ImportError:
407
+ logger.debug("TextFSM or ntc-templates not available, skipping parsing")
408
+ except Exception as parse_error:
409
+ logger.debug(f"TextFSM parsing failed: {str(parse_error)}")
410
+
411
+ return raw_output, parsed_data
412
+
413
+ except Exception as e:
414
+ logger.error(f"Show command failed: {str(e)}")
415
+ raise CommandExecutionError(f"Show command failed: {str(e)}")
416
+
417
+ def _execute_config_command(self, command: str) -> str:
418
+ """Execute a configuration command."""
419
+ try:
420
+ if isinstance(command, str):
421
+ commands = [command]
422
+ else:
423
+ commands = command
424
+
425
+ return self._connection.send_config_set(commands)
426
+ except Exception as e:
427
+ logger.error(f"Config command failed: {str(e)}")
428
+ raise CommandExecutionError(f"Config command failed: {str(e)}")
429
+
430
+ # Note: The _attempt_parsing method has been removed as parsing is now handled
431
+ # directly in _execute_show_command to avoid re-executing commands on the device
432
+
433
+ def is_connected(self) -> bool:
434
+ """Check if device is connected."""
435
+ if not self._connection:
436
+ return False
437
+
438
+ try:
439
+ # Simple connectivity test
440
+ self._connection.find_prompt()
441
+ return True
442
+ except Exception:
443
+ return False