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,267 @@
1
+ """
2
+ API ViewSet for Command resources
3
+ """
4
+ from rest_framework import status
5
+ from rest_framework.decorators import action
6
+ from rest_framework.response import Response
7
+ from django.db import transaction
8
+ from drf_spectacular.utils import extend_schema_view
9
+ from dcim.models import Device
10
+ from netbox.api.viewsets import NetBoxModelViewSet
11
+
12
+ from ... import models, filtersets
13
+ from ...services.command_service import CommandExecutionService
14
+ from ...services.rate_limiting_service import RateLimitingService
15
+ from ..serializers import CommandSerializer, CommandExecutionSerializer
16
+ from ..mixins import APIResponseMixin, PermissionCheckMixin
17
+ from ..schemas import (
18
+ COMMAND_LIST_SCHEMA,
19
+ COMMAND_RETRIEVE_SCHEMA,
20
+ COMMAND_CREATE_SCHEMA,
21
+ COMMAND_UPDATE_SCHEMA,
22
+ COMMAND_PARTIAL_UPDATE_SCHEMA,
23
+ COMMAND_DESTROY_SCHEMA,
24
+ COMMAND_EXECUTE_SCHEMA,
25
+ COMMAND_BULK_EXECUTE_SCHEMA,
26
+ )
27
+
28
+
29
+ @extend_schema_view(
30
+ list=COMMAND_LIST_SCHEMA,
31
+ retrieve=COMMAND_RETRIEVE_SCHEMA,
32
+ create=COMMAND_CREATE_SCHEMA,
33
+ update=COMMAND_UPDATE_SCHEMA,
34
+ partial_update=COMMAND_PARTIAL_UPDATE_SCHEMA,
35
+ destroy=COMMAND_DESTROY_SCHEMA,
36
+ )
37
+ class CommandViewSet(NetBoxModelViewSet, APIResponseMixin, PermissionCheckMixin):
38
+ queryset = models.Command.objects.all()
39
+ serializer_class = CommandSerializer
40
+ filterset_class = filtersets.CommandFilterSet
41
+ # Using custom RateLimitingService instead of generic API throttling
42
+ # NetBox automatically handles object-based permissions - no need for explicit permission_classes
43
+
44
+ def get_queryset(self):
45
+ """NetBox will automatically filter based on user's ObjectPermissions"""
46
+ # NetBox's object-based permission system will automatically filter this queryset
47
+ # based on the user's ObjectPermissions for 'view' action on Command objects
48
+ return super().get_queryset()
49
+
50
+ @COMMAND_EXECUTE_SCHEMA
51
+ @action(detail=True, methods=['post'], url_path='execute')
52
+ def execute_command(self, request, pk=None):
53
+ """Execute a command on a device via API"""
54
+ command = self.get_object()
55
+
56
+ # Validate input using serializer
57
+ execution_serializer = CommandExecutionSerializer(data=request.data)
58
+ if not execution_serializer.is_valid():
59
+ return Response(
60
+ {
61
+ 'error': 'Invalid input data',
62
+ 'details': execution_serializer.errors
63
+ },
64
+ status=status.HTTP_400_BAD_REQUEST
65
+ )
66
+
67
+ validated_data = execution_serializer.validated_data
68
+ device_id = validated_data['device_id']
69
+ username = validated_data['username']
70
+ password = validated_data['password']
71
+
72
+ # Get device object
73
+ try:
74
+ device = Device.objects.get(id=device_id)
75
+ except Device.DoesNotExist:
76
+ return Response(
77
+ {'error': f'Device with ID {device_id} not found'},
78
+ status=status.HTTP_404_NOT_FOUND
79
+ )
80
+
81
+ # Check permissions based on command type using NetBox's object-based permissions
82
+ if command.command_type == 'config':
83
+ if not self._user_has_action_permission(request.user, command, 'execute_config'):
84
+ return Response(
85
+ {'error': 'You do not have permission to execute configuration commands'},
86
+ status=status.HTTP_403_FORBIDDEN
87
+ )
88
+ elif command.command_type == 'show':
89
+ if not self._user_has_action_permission(request.user, command, 'execute_show'):
90
+ return Response(
91
+ {'error': 'You do not have permission to execute show commands'},
92
+ status=status.HTTP_403_FORBIDDEN
93
+ )
94
+
95
+ # Check custom rate limiting (device-specific with bypass rules)
96
+ rate_limiting_service = RateLimitingService()
97
+ rate_limit_check = rate_limiting_service.check_rate_limit(device, request.user)
98
+
99
+ if not rate_limit_check['allowed']:
100
+ return Response(
101
+ {
102
+ 'error': 'Rate limit exceeded',
103
+ 'details': {
104
+ 'reason': rate_limit_check['reason'],
105
+ 'current_count': rate_limit_check['current_count'],
106
+ 'limit': rate_limit_check['limit'],
107
+ 'time_window_minutes': rate_limit_check['time_window_minutes']
108
+ }
109
+ },
110
+ status=status.HTTP_429_TOO_MANY_REQUESTS
111
+ )
112
+
113
+ # Execute command using the service
114
+ command_service = CommandExecutionService()
115
+ result = command_service.execute_command_with_retry(
116
+ command, device, username, password, max_retries=1
117
+ )
118
+
119
+ # Determine overall success - failed if either execution failed or syntax error detected
120
+ overall_success = result.success and not result.has_syntax_error
121
+
122
+ # Prepare response data
123
+ response_data = {
124
+ 'success': overall_success,
125
+ 'output': result.output,
126
+ 'error_message': result.error_message,
127
+ 'execution_time': result.execution_time,
128
+ 'command': {
129
+ 'id': command.id,
130
+ 'name': command.name,
131
+ 'command_type': command.command_type
132
+ },
133
+ 'device': {
134
+ 'id': device.id,
135
+ 'name': device.name
136
+ }
137
+ }
138
+
139
+ # Add syntax error information if detected
140
+ if result.has_syntax_error:
141
+ response_data['syntax_error'] = {
142
+ 'detected': True,
143
+ 'type': result.syntax_error_type,
144
+ 'vendor': result.syntax_error_vendor,
145
+ 'guidance_provided': True
146
+ }
147
+ else:
148
+ response_data['syntax_error'] = {
149
+ 'detected': False
150
+ }
151
+
152
+ # Add parsing information if available
153
+ if result.parsing_success and result.parsed_output:
154
+ response_data['parsed_output'] = {
155
+ 'success': True,
156
+ 'method': result.parsing_method,
157
+ 'data': result.parsed_output
158
+ }
159
+ else:
160
+ response_data['parsed_output'] = {
161
+ 'success': False,
162
+ 'method': None,
163
+ 'error': result.parsing_error
164
+ }
165
+
166
+ # Return appropriate status code
167
+ status_code = status.HTTP_200_OK if overall_success else status.HTTP_400_BAD_REQUEST
168
+
169
+ return Response(response_data, status=status_code)
170
+
171
+ @COMMAND_BULK_EXECUTE_SCHEMA
172
+ @action(detail=False, methods=['post'], url_path='bulk-execute')
173
+ def bulk_execute(self, request):
174
+ """Execute multiple commands on multiple devices"""
175
+ executions = request.data.get('executions', [])
176
+
177
+ if not executions:
178
+ return Response(
179
+ {'error': 'No executions provided'},
180
+ status=status.HTTP_400_BAD_REQUEST
181
+ )
182
+
183
+ results = []
184
+
185
+ with transaction.atomic():
186
+ for i, execution_data in enumerate(executions):
187
+ try:
188
+ # Validate each execution
189
+ command_id = execution_data.get('command_id')
190
+ device_id = execution_data.get('device_id')
191
+ username = execution_data.get('username')
192
+ password = execution_data.get('password')
193
+
194
+ if not all([command_id, device_id, username, password]):
195
+ results.append({
196
+ 'execution_id': i + 1,
197
+ 'success': False,
198
+ 'error': 'Missing required fields'
199
+ })
200
+ continue
201
+
202
+ # Get command and device objects
203
+ try:
204
+ command = models.Command.objects.get(id=command_id)
205
+ device = Device.objects.get(id=device_id)
206
+ except (models.Command.DoesNotExist, Device.DoesNotExist) as e:
207
+ results.append({
208
+ 'execution_id': i + 1,
209
+ 'success': False,
210
+ 'error': f'Object not found: {str(e)}'
211
+ })
212
+ continue
213
+
214
+ # Check permissions
215
+ action = 'execute_config' if command.command_type == 'config' else 'execute_show'
216
+ if not self._user_has_action_permission(request.user, command, action):
217
+ results.append({
218
+ 'execution_id': i + 1,
219
+ 'success': False,
220
+ 'error': 'Insufficient permissions'
221
+ })
222
+ continue
223
+
224
+ # Execute command
225
+ command_service = CommandExecutionService()
226
+ result = command_service.execute_command_with_retry(
227
+ command, device, username, password, max_retries=1
228
+ )
229
+
230
+ # Create command log entry (this would typically be done by the service)
231
+ log_entry = models.CommandLog.objects.create(
232
+ command=command,
233
+ device=device,
234
+ user=request.user,
235
+ output=result.output,
236
+ error_message=result.error_message,
237
+ execution_time=result.execution_time,
238
+ success=result.success and not result.has_syntax_error
239
+ )
240
+
241
+ results.append({
242
+ 'execution_id': i + 1,
243
+ 'success': result.success and not result.has_syntax_error,
244
+ 'command_log_id': log_entry.id,
245
+ 'execution_time': result.execution_time
246
+ })
247
+
248
+ except Exception as e:
249
+ results.append({
250
+ 'execution_id': i + 1,
251
+ 'success': False,
252
+ 'error': f'Unexpected error: {str(e)}'
253
+ })
254
+
255
+ # Generate summary
256
+ total = len(results)
257
+ successful = sum(1 for r in results if r.get('success', False))
258
+ failed = total - successful
259
+
260
+ return Response({
261
+ 'results': results,
262
+ 'summary': {
263
+ 'total': total,
264
+ 'successful': successful,
265
+ 'failed': failed
266
+ }
267
+ }, status=status.HTTP_200_OK)
@@ -0,0 +1,159 @@
1
+ """Configuration settings for the NetBox Toolkit plugin."""
2
+ from typing import Dict, Any
3
+ from django.conf import settings
4
+
5
+
6
+ class ToolkitConfig:
7
+ """Configuration class for toolkit settings."""
8
+
9
+ # Default connection timeouts
10
+ DEFAULT_TIMEOUTS = {
11
+ 'socket': 15,
12
+ 'transport': 15,
13
+ 'ops': 30,
14
+ 'banner': 15,
15
+ 'auth': 15,
16
+ }
17
+
18
+ # Device-specific timeout overrides
19
+ DEVICE_TIMEOUTS = {
20
+ 'catalyst': {
21
+ 'socket': 20,
22
+ 'transport': 20,
23
+ 'ops': 45,
24
+ },
25
+ 'nexus': {
26
+ 'socket': 25,
27
+ 'transport': 25,
28
+ 'ops': 60,
29
+ },
30
+ }
31
+
32
+ # SSH transport options
33
+ SSH_TRANSPORT_OPTIONS = {
34
+ 'disabled_algorithms': {
35
+ 'kex': [], # Don't disable any key exchange methods
36
+ },
37
+ 'allowed_kex': [
38
+ # Modern algorithms
39
+ 'diffie-hellman-group-exchange-sha256',
40
+ 'diffie-hellman-group16-sha512',
41
+ 'diffie-hellman-group18-sha512',
42
+ 'diffie-hellman-group14-sha256',
43
+ # Legacy algorithms for older devices
44
+ 'diffie-hellman-group-exchange-sha1',
45
+ 'diffie-hellman-group14-sha1',
46
+ 'diffie-hellman-group1-sha1',
47
+ ],
48
+ }
49
+
50
+ # Netmiko configuration for fallback connections
51
+ NETMIKO_CONFIG = {
52
+ 'banner_timeout': 20,
53
+ 'auth_timeout': 20,
54
+ 'global_delay_factor': 1,
55
+ 'use_keys': False, # Disable SSH key authentication
56
+ 'allow_agent': False, # Disable SSH agent
57
+ # Session logging (disabled by default)
58
+ 'session_log': None,
59
+ # Connection options for legacy devices
60
+ 'fast_cli': False, # Disable for older devices
61
+ 'session_log_record_writes': False,
62
+ 'session_log_file_mode': 'write',
63
+ }
64
+
65
+ # Retry configuration
66
+ RETRY_CONFIG = {
67
+ 'max_retries': 2,
68
+ 'retry_delay': 1, # Reduced from 3s to 1s for faster fallback
69
+ 'backoff_multiplier': 1.5, # Reduced from 2 to 1.5 for faster progression
70
+ }
71
+
72
+ # Fast connection test timeouts (for initial Scrapli viability testing)
73
+ FAST_TEST_TIMEOUTS = {
74
+ 'socket': 8, # Reduced from 15s to 8s for faster detection
75
+ 'transport': 8, # Reduced from 15s to 8s for faster detection
76
+ 'ops': 15, # Keep ops timeout reasonable for actual commands
77
+ }
78
+
79
+ # Error patterns that should trigger immediate fallback to Netmiko
80
+ SCRAPLI_FAST_FAIL_PATTERNS = [
81
+ "No matching key exchange",
82
+ "No matching cipher",
83
+ "No matching MAC",
84
+ "connection not opened",
85
+ "Error reading SSH protocol banner",
86
+ "Connection refused",
87
+ "Operation timed out",
88
+ "SSH handshake failed",
89
+ "Protocol version not supported",
90
+ "Unable to connect to port 22",
91
+ "Name or service not known",
92
+ "Network is unreachable"
93
+ ]
94
+
95
+ # Platform mappings for better recognition
96
+ PLATFORM_ALIASES = {
97
+ 'ios': 'cisco_ios',
98
+ 'iosxe': 'cisco_ios',
99
+ 'nxos': 'cisco_nxos',
100
+ 'iosxr': 'cisco_iosxr',
101
+ 'junos': 'juniper_junos',
102
+ 'eos': 'arista_eos',
103
+ }
104
+
105
+ @classmethod
106
+ def get_fast_test_timeouts(cls) -> Dict[str, int]:
107
+ """Get fast connection test timeouts for initial viability testing."""
108
+ return cls.FAST_TEST_TIMEOUTS.copy()
109
+
110
+ @classmethod
111
+ def should_fast_fail_to_netmiko(cls, error_message: str) -> bool:
112
+ """Check if error message indicates immediate fallback to Netmiko is needed."""
113
+ error_lower = error_message.lower()
114
+ return any(pattern.lower() in error_lower for pattern in cls.SCRAPLI_FAST_FAIL_PATTERNS)
115
+
116
+ @classmethod
117
+ def get_timeouts_for_device(cls, device_type_model: str = None) -> Dict[str, int]:
118
+ """Get timeout configuration for a specific device type."""
119
+ timeouts = cls.DEFAULT_TIMEOUTS.copy()
120
+
121
+ if device_type_model:
122
+ model_lower = device_type_model.lower()
123
+ for device_keyword, custom_timeouts in cls.DEVICE_TIMEOUTS.items():
124
+ if device_keyword in model_lower:
125
+ timeouts.update(custom_timeouts)
126
+ break
127
+
128
+ return timeouts
129
+
130
+ @classmethod
131
+ def normalize_platform(cls, platform: str) -> str:
132
+ """Normalize platform name using aliases."""
133
+ if not platform:
134
+ return None
135
+
136
+ platform_lower = platform.lower()
137
+ return cls.PLATFORM_ALIASES.get(platform_lower, platform_lower)
138
+
139
+ @classmethod
140
+ def get_ssh_options(cls) -> Dict[str, Any]:
141
+ """Get SSH transport options."""
142
+ return cls.SSH_TRANSPORT_OPTIONS.copy()
143
+
144
+ @classmethod
145
+ def get_retry_config(cls) -> Dict[str, int]:
146
+ """Get retry configuration."""
147
+ return cls.RETRY_CONFIG.copy()
148
+
149
+ @classmethod
150
+ def get_ssh_transport_options(cls) -> Dict[str, Any]:
151
+ """Get SSH transport options for Scrapli."""
152
+ user_config = getattr(settings, 'NETBOX_TOOLKIT', {})
153
+ return {**cls.SSH_TRANSPORT_OPTIONS, **user_config.get('ssh_options', {})}
154
+
155
+ @classmethod
156
+ def get_netmiko_config(cls) -> Dict[str, Any]:
157
+ """Get Netmiko configuration for fallback connections."""
158
+ user_config = getattr(settings, 'NETBOX_TOOLKIT', {})
159
+ return {**cls.NETMIKO_CONFIG, **user_config.get('netmiko', {})}
@@ -0,0 +1,15 @@
1
+ """Connectors package for device connection logic."""
2
+
3
+ from .base import BaseDeviceConnector, ConnectionConfig, CommandResult
4
+ from .scrapli_connector import ScrapliConnector
5
+ from .netmiko_connector import NetmikoConnector
6
+ from .factory import ConnectorFactory
7
+
8
+ __all__ = [
9
+ 'BaseDeviceConnector',
10
+ 'ConnectionConfig',
11
+ 'CommandResult',
12
+ 'ScrapliConnector',
13
+ 'NetmikoConnector',
14
+ 'ConnectorFactory',
15
+ ]
@@ -0,0 +1,97 @@
1
+ """Base connector interface for device connections."""
2
+ from abc import ABC, abstractmethod
3
+ from typing import Dict, Any, Optional
4
+ from dataclasses import dataclass
5
+
6
+ from ..exceptions import DeviceConnectionError, CommandExecutionError
7
+
8
+
9
+ @dataclass
10
+ class ConnectionConfig:
11
+ """Configuration for device connections."""
12
+ hostname: str
13
+ username: str
14
+ password: str
15
+ port: int = 22
16
+ timeout_socket: int = 15
17
+ timeout_transport: int = 15
18
+ timeout_ops: int = 30
19
+ auth_strict_key: bool = False
20
+ transport: str = 'system' # Default to system transport for Scrapli
21
+ platform: Optional[str] = None
22
+ extra_options: Optional[Dict[str, Any]] = None
23
+
24
+
25
+ @dataclass
26
+ class CommandResult:
27
+ """Result of command execution."""
28
+ command: str
29
+ output: str
30
+ success: bool
31
+ error_message: Optional[str] = None
32
+ execution_time: Optional[float] = None
33
+ # New fields for syntax error detection
34
+ has_syntax_error: bool = False
35
+ syntax_error_type: Optional[str] = None
36
+ syntax_error_vendor: Optional[str] = None
37
+ syntax_error_guidance: Optional[str] = None
38
+ # New fields for command output parsing
39
+ parsed_output: Optional[Dict[str, Any]] = None
40
+ parsing_success: bool = False
41
+ parsing_method: Optional[str] = None # 'textfsm', 'genie', 'ttp'
42
+ parsing_error: Optional[str] = None
43
+
44
+
45
+ class BaseDeviceConnector(ABC):
46
+ """Abstract base class for device connectors."""
47
+
48
+ def __init__(self, config: ConnectionConfig):
49
+ self.config = config
50
+ self._connection = None
51
+
52
+ @abstractmethod
53
+ def connect(self) -> None:
54
+ """Establish connection to the device."""
55
+ pass
56
+
57
+ @abstractmethod
58
+ def disconnect(self) -> None:
59
+ """Close connection to the device."""
60
+ pass
61
+
62
+ @abstractmethod
63
+ def execute_command(self, command: str, command_type: str = 'show') -> CommandResult:
64
+ """Execute a command on the device.
65
+
66
+ Args:
67
+ command: The command string to execute
68
+ command_type: Type of command ('show' or 'config') for proper handling
69
+
70
+ Returns:
71
+ CommandResult with execution details
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def is_connected(self) -> bool:
77
+ """Check if connection is active."""
78
+ pass
79
+
80
+ def __enter__(self):
81
+ """Context manager entry."""
82
+ self.connect()
83
+ return self
84
+
85
+ def __exit__(self, exc_type, exc_val, exc_tb):
86
+ """Context manager exit."""
87
+ self.disconnect()
88
+
89
+ @property
90
+ def hostname(self) -> str:
91
+ """Get the hostname for this connection."""
92
+ return self.config.hostname
93
+
94
+ @property
95
+ def platform(self) -> Optional[str]:
96
+ """Get the platform for this connection."""
97
+ return self.config.platform