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,157 @@
1
+ """Network utility functions."""
2
+ import socket
3
+ import time
4
+ from typing import Tuple, Optional
5
+
6
+ from ..exceptions import DeviceReachabilityError, SSHBannerError
7
+ from .logging import get_toolkit_logger
8
+
9
+ logger = get_toolkit_logger(__name__)
10
+
11
+
12
+ def check_device_reachability(hostname: str, port: int = 22, timeout: int = 3) -> Tuple[bool, bool, Optional[bytes]]:
13
+ """
14
+ Check if a device is reachable and if it's running SSH.
15
+
16
+ Args:
17
+ hostname: The hostname or IP address to check
18
+ port: The port to check (default: 22 for SSH)
19
+ timeout: Connection timeout in seconds
20
+
21
+ Returns:
22
+ Tuple of (is_reachable, is_ssh_server, ssh_banner)
23
+
24
+ Raises:
25
+ DeviceReachabilityError: If device is not reachable
26
+ SSHBannerError: If SSH banner cannot be read
27
+ """
28
+ logger.debug(f"Checking device reachability for {hostname}:{port} with timeout {timeout}s")
29
+
30
+ is_reachable = False
31
+ is_ssh_server = False
32
+ ssh_banner = None
33
+
34
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
35
+
36
+ try:
37
+ sock.settimeout(timeout)
38
+
39
+ # Attempt connection
40
+ logger.debug(f"Attempting TCP connection to {hostname}:{port}")
41
+ sock.connect((hostname, port))
42
+ is_reachable = True
43
+ logger.debug(f"TCP connection successful to {hostname}:{port}")
44
+
45
+ # Try to read SSH banner
46
+ ssh_banner, is_ssh_server = _read_ssh_banner(sock, hostname)
47
+ logger.debug(f"SSH banner check: is_ssh_server={is_ssh_server}, banner_length={len(ssh_banner) if ssh_banner else 0}")
48
+
49
+ except socket.timeout:
50
+ logger.warning(f"Connection to {hostname}:{port} timed out after {timeout}s")
51
+ raise DeviceReachabilityError(f"Connection to {hostname}:{port} timed out")
52
+ except ConnectionRefusedError:
53
+ logger.warning(f"Connection to {hostname}:{port} refused")
54
+ raise DeviceReachabilityError(f"Connection to {hostname}:{port} refused")
55
+ except socket.gaierror as e:
56
+ logger.error(f"Could not resolve hostname: {hostname} - {str(e)}")
57
+ raise DeviceReachabilityError(f"Could not resolve hostname: {hostname}")
58
+ except Exception as e:
59
+ logger.error(f"Socket error when connecting to {hostname}: {str(e)}")
60
+ raise DeviceReachabilityError(f"Socket error when connecting to {hostname}: {str(e)}")
61
+ finally:
62
+ try:
63
+ sock.close()
64
+ except:
65
+ pass
66
+
67
+ return is_reachable, is_ssh_server, ssh_banner
68
+
69
+
70
+ def _read_ssh_banner(sock: socket.socket, hostname: str, attempts: int = 3) -> Tuple[Optional[bytes], bool]:
71
+ """
72
+ Try to read SSH banner from socket.
73
+
74
+ Args:
75
+ sock: Connected socket
76
+ hostname: Hostname for logging
77
+ attempts: Number of attempts to read banner
78
+
79
+ Returns:
80
+ Tuple of (banner, is_ssh_server)
81
+ """
82
+ logger.debug(f"Attempting to read SSH banner from {hostname} with {attempts} attempts")
83
+
84
+ ssh_banner = None
85
+ is_ssh_server = False
86
+
87
+ for i in range(attempts):
88
+ try:
89
+ logger.debug(f"SSH banner read attempt {i + 1}/{attempts}")
90
+ sock.settimeout(1)
91
+ banner = sock.recv(1024)
92
+ if banner:
93
+ ssh_banner = banner
94
+ logger.debug(f"Received banner: {banner[:50]}..." if len(banner) > 50 else f"Received banner: {banner}")
95
+ if banner.startswith(b'SSH-'):
96
+ is_ssh_server = True
97
+ logger.debug("Banner indicates SSH server")
98
+ break
99
+ else:
100
+ logger.debug("Banner received but not SSH protocol")
101
+ pass # Non-SSH banner received
102
+ else:
103
+ logger.debug("No banner data received")
104
+
105
+ # If no banner received but still connected, pause briefly and try again
106
+ if i < attempts - 1:
107
+ logger.debug("Waiting 0.5s before next banner read attempt")
108
+ time.sleep(0.5)
109
+ except socket.timeout:
110
+ logger.debug(f"Banner read attempt {i + 1} timed out")
111
+ if i < attempts - 1:
112
+ continue
113
+ except Exception as e:
114
+ logger.warning(f"Error reading SSH banner: {str(e)}")
115
+ break
116
+
117
+ logger.debug(f"SSH banner read completed: is_ssh_server={is_ssh_server}")
118
+ return ssh_banner, is_ssh_server
119
+
120
+
121
+ def validate_device_connectivity(hostname: str, port: int = 22) -> None:
122
+ """
123
+ Validate that a device is reachable and has SSH available.
124
+
125
+ Args:
126
+ hostname: The hostname or IP address to validate
127
+ port: The port to check (default: 22)
128
+
129
+ Raises:
130
+ DeviceReachabilityError: If device is not reachable
131
+ SSHBannerError: If SSH service issues are detected
132
+ """
133
+ logger.debug(f"Validating device connectivity for {hostname}:{port}")
134
+
135
+ try:
136
+ is_reachable, is_ssh_server, ssh_banner = check_device_reachability(hostname, port)
137
+
138
+ if not is_reachable:
139
+ logger.error(f"Device {hostname}:{port} is not reachable")
140
+ raise DeviceReachabilityError(
141
+ f"Cannot connect to {hostname} on port {port}. "
142
+ f"Please verify the device is reachable and SSH is enabled."
143
+ )
144
+
145
+ if is_reachable and not is_ssh_server:
146
+ banner_msg = f" (received banner: {ssh_banner})" if ssh_banner else ""
147
+ logger.warning(f"Device {hostname}:{port} is reachable but SSH banner not detected{banner_msg}")
148
+ # Device is reachable but didn't provide SSH banner - connection might fail
149
+ pass
150
+ elif is_ssh_server:
151
+ logger.debug(f"Device {hostname}:{port} is reachable and SSH server detected")
152
+
153
+ except (DeviceReachabilityError, SSHBannerError):
154
+ raise
155
+ except Exception as e:
156
+ logger.error(f"Unexpected error validating connectivity to {hostname}: {str(e)}")
157
+ raise DeviceReachabilityError(f"Unexpected error validating connectivity to {hostname}: {str(e)}")
@@ -0,0 +1,385 @@
1
+ from django.shortcuts import render
2
+ from django.views.generic import View
3
+ from django.contrib import messages
4
+ from django.core.exceptions import PermissionDenied
5
+ from dcim.models import Device
6
+ from .models import Command, CommandLog
7
+ from .forms import CommandForm, CommandLogForm, CommandExecutionForm
8
+ from netbox.views.generic import ObjectView, ObjectListView, ObjectEditView, ObjectDeleteView, ObjectChangeLogView
9
+ from utilities.views import ViewTab, register_model_view
10
+ from .services.command_service import CommandExecutionService
11
+ from .services.device_service import DeviceService
12
+ from .services.rate_limiting_service import RateLimitingService
13
+
14
+ @register_model_view(Device, name='toolkit', path='toolkit')
15
+ class DeviceToolkitView(ObjectView):
16
+ queryset = Device.objects.all()
17
+ template_name = 'netbox_toolkit/device_toolkit.html'
18
+
19
+ # Define tab without a badge counter
20
+ tab = ViewTab(
21
+ label='Toolkit'
22
+ )
23
+
24
+ def __init__(self, *args, **kwargs):
25
+ super().__init__(*args, **kwargs)
26
+ self.command_service = CommandExecutionService()
27
+ self.device_service = DeviceService()
28
+ self.rate_limiting_service = RateLimitingService()
29
+
30
+ def get_object(self, **kwargs):
31
+ """Override get_object to properly filter by pk"""
32
+ return Device.objects.get(pk=self.kwargs.get('pk', kwargs.get('pk')))
33
+
34
+ def get(self, request, pk):
35
+ self.kwargs = {'pk': pk} # Set kwargs for get_object
36
+ device = self.get_object()
37
+
38
+ # Validate device is ready for commands
39
+ is_valid, error_message, validation_checks = self.device_service.validate_device_for_commands(device)
40
+ if not is_valid:
41
+ messages.warning(request, f"Device validation warning: {error_message}")
42
+
43
+ # Get connection info for the device
44
+ connection_info = self.device_service.get_device_connection_info(device)
45
+
46
+ # Get available commands for the device with permission filtering
47
+ commands = self._get_filtered_commands(request.user, device)
48
+
49
+ # Get rate limit status for UI display
50
+ rate_limit_status = self.rate_limiting_service.get_rate_limit_status(device, request.user)
51
+
52
+ form = CommandExecutionForm()
53
+
54
+ # No credential storage - credentials required for each command execution
55
+
56
+ return render(request, self.template_name, {
57
+ 'object': device,
58
+ 'tab': self.tab,
59
+ 'commands': commands,
60
+ 'form': form,
61
+ 'device_valid': is_valid,
62
+ 'validation_message': error_message,
63
+ 'validation_checks': validation_checks,
64
+ 'connection_info': connection_info,
65
+ 'rate_limit_status': rate_limit_status,
66
+ })
67
+
68
+ def _user_has_action_permission(self, user, obj, action):
69
+ """Check if user has permission for a specific action on an object using NetBox's ObjectPermission system"""
70
+ from django.contrib.contenttypes.models import ContentType
71
+ from users.models import ObjectPermission
72
+
73
+ # Get content type for the object
74
+ content_type = ContentType.objects.get_for_model(obj)
75
+
76
+ # Check if user has any ObjectPermissions with the required action
77
+ user_permissions = ObjectPermission.objects.filter(
78
+ object_types__in=[content_type],
79
+ actions__contains=[action],
80
+ enabled=True
81
+ )
82
+
83
+ # Check if user is assigned to any groups with this permission
84
+ user_groups = user.groups.all()
85
+ for permission in user_permissions:
86
+ # Check if permission applies to user or user's groups
87
+ if (permission.users.filter(id=user.id).exists() or
88
+ permission.groups.filter(id__in=user_groups.values_list('id', flat=True)).exists()):
89
+
90
+ # If there are constraints, evaluate them
91
+ if permission.constraints:
92
+ # Create a queryset with the constraints and check if the object matches
93
+ queryset = content_type.model_class().objects.filter(**permission.constraints)
94
+ if queryset.filter(id=obj.id).exists():
95
+ return True
96
+ else:
97
+ # No constraints means permission applies to all objects of this type
98
+ return True
99
+
100
+ return False
101
+
102
+ def _get_filtered_commands(self, user, device):
103
+ """Get commands for a device filtered by user permissions"""
104
+ # Get all available commands for the device
105
+ all_commands = self.device_service.get_available_commands(device)
106
+
107
+ # Filter commands based on user permissions for custom actions
108
+ commands = []
109
+ for command in all_commands:
110
+ # Check if user has permission for the specific action on this command
111
+ if command.command_type == 'show':
112
+ # Check for 'execute_show' action permission
113
+ if self._user_has_action_permission(user, command, 'execute_show'):
114
+ commands.append(command)
115
+ elif command.command_type == 'config':
116
+ # Check for 'execute_config' action permission
117
+ if self._user_has_action_permission(user, command, 'execute_config'):
118
+ commands.append(command)
119
+
120
+ return commands
121
+
122
+ def post(self, request, pk):
123
+ self.kwargs = {'pk': pk} # Set kwargs for get_object
124
+ device = self.get_object()
125
+ command_id = request.POST.get('command_id')
126
+
127
+ try:
128
+ command = Command.objects.get(id=command_id)
129
+ except Command.DoesNotExist:
130
+ messages.error(request, "Selected command not found.")
131
+ return self.get(request, pk)
132
+
133
+ # Check permissions based on command type using NetBox's object-based permissions
134
+ if command.command_type == 'config':
135
+ if not self._user_has_action_permission(request.user, command, 'execute_config'):
136
+ messages.error(request, "You don't have permission to execute configuration commands.")
137
+ return self.get(request, pk)
138
+ elif command.command_type == 'show':
139
+ if not self._user_has_action_permission(request.user, command, 'execute_show'):
140
+ messages.error(request, "You don't have permission to execute show commands.")
141
+ return self.get(request, pk)
142
+
143
+ # Create a form with the POST data
144
+ form_data = {
145
+ 'username': request.POST.get('username', ''),
146
+ 'password': request.POST.get('password', '')
147
+ }
148
+ form = CommandExecutionForm(form_data)
149
+ commands = self._get_filtered_commands(request.user, device)
150
+
151
+ if form.is_valid():
152
+ username = form.cleaned_data['username']
153
+ password = form.cleaned_data['password']
154
+
155
+ # Check rate limiting before command execution
156
+ rate_limit_check = self.rate_limiting_service.check_rate_limit(device, request.user)
157
+
158
+ if not rate_limit_check['allowed']:
159
+ messages.error(request, f"Rate limit exceeded: {rate_limit_check['reason']}")
160
+
161
+ # Get rate limit status and other context for display
162
+ rate_limit_status = self.rate_limiting_service.get_rate_limit_status(device, request.user)
163
+ is_valid, error_message, validation_checks = self.device_service.validate_device_for_commands(device)
164
+ connection_info = self.device_service.get_device_connection_info(device)
165
+
166
+ return render(request, self.template_name, {
167
+ 'object': device,
168
+ 'tab': self.tab,
169
+ 'commands': commands,
170
+ 'form': form,
171
+ 'device_valid': is_valid,
172
+ 'validation_message': error_message,
173
+ 'validation_checks': validation_checks,
174
+ 'connection_info': connection_info,
175
+ 'rate_limit_status': rate_limit_status,
176
+ })
177
+
178
+ # Execute command using the service with retry for socket error recovery
179
+ result = self.command_service.execute_command_with_retry(
180
+ command, device, username, password, max_retries=1
181
+ )
182
+
183
+ # No credential storage for security
184
+
185
+ # Determine overall success and appropriate message
186
+ overall_success = result.success and not result.has_syntax_error
187
+
188
+ if overall_success:
189
+ messages.success(request, f"Command '{command.name}' executed successfully.")
190
+ elif result.has_syntax_error:
191
+ messages.warning(request, f"Command '{command.name}' executed but syntax error detected: {result.syntax_error_type}")
192
+ else:
193
+ messages.error(request, f"Command execution failed: {result.error_message}")
194
+
195
+ # Return a new empty form after execution
196
+ empty_form = CommandExecutionForm()
197
+
198
+ # Get validation checks and rate limit status for display
199
+ is_valid, error_message, validation_checks = self.device_service.validate_device_for_commands(device)
200
+ connection_info = self.device_service.get_device_connection_info(device)
201
+ rate_limit_status = self.rate_limiting_service.get_rate_limit_status(device, request.user)
202
+
203
+ return render(request, self.template_name, {
204
+ 'object': device,
205
+ 'tab': self.tab,
206
+ 'commands': commands,
207
+ 'form': empty_form, # Use an empty form for the next command
208
+ 'command_output': result.output,
209
+ 'executed_command': command,
210
+ 'execution_success': overall_success,
211
+ 'execution_time': result.execution_time,
212
+ 'has_syntax_error': result.has_syntax_error,
213
+ 'syntax_error_type': result.syntax_error_type,
214
+ 'syntax_error_vendor': result.syntax_error_vendor,
215
+ 'parsed_data': result.parsed_output,
216
+ 'parsing_success': result.parsing_success,
217
+ 'parsing_template': result.parsing_method,
218
+ 'device_valid': is_valid,
219
+ 'validation_message': error_message,
220
+ 'validation_checks': validation_checks,
221
+ 'connection_info': connection_info,
222
+ 'rate_limit_status': rate_limit_status,
223
+ })
224
+ else:
225
+ messages.error(request, "Please correct the form errors.")
226
+ # Get validation checks and rate limit status for display
227
+ is_valid, error_message, validation_checks = self.device_service.validate_device_for_commands(device)
228
+ connection_info = self.device_service.get_device_connection_info(device)
229
+ rate_limit_status = self.rate_limiting_service.get_rate_limit_status(device, request.user)
230
+ return render(request, self.template_name, {
231
+ 'object': device,
232
+ 'tab': self.tab,
233
+ 'commands': commands,
234
+ 'form': form,
235
+ 'device_valid': is_valid,
236
+ 'validation_message': error_message,
237
+ 'validation_checks': validation_checks,
238
+ 'connection_info': connection_info,
239
+ 'rate_limit_status': rate_limit_status,
240
+ })
241
+
242
+ # Command views
243
+ class CommandListView(ObjectListView):
244
+ queryset = Command.objects.all()
245
+ filterset = None # Will update this after import
246
+ table = None # Will update this after import
247
+ template_name = 'netbox_toolkit/command_list.html'
248
+
249
+ def __init__(self, *args, **kwargs):
250
+ super().__init__(*args, **kwargs)
251
+ from .filtersets import CommandFilterSet
252
+ from .tables import CommandTable
253
+ self.filterset = CommandFilterSet
254
+ self.table = CommandTable
255
+
256
+ class CommandEditView(ObjectEditView):
257
+ queryset = Command.objects.all()
258
+ form = CommandForm
259
+ template_name = 'netbox_toolkit/command_edit.html'
260
+
261
+ def get_success_url(self):
262
+ """Override to use correct plugin namespace"""
263
+ # Try hardcoded URL first to see if the issue is with reverse()
264
+ if self.object and self.object.pk:
265
+ return f'/plugins/toolkit/commands/{self.object.pk}/'
266
+ return '/plugins/toolkit/commands/'
267
+
268
+ def get_return_url(self, request, instance):
269
+ """Override to use correct plugin namespace for cancel/return links"""
270
+ # Check if there's a return URL in the request
271
+ return_url = request.GET.get('return_url')
272
+ if return_url:
273
+ return return_url
274
+ # Return hardcoded URL
275
+ return '/plugins/toolkit/commands/'
276
+
277
+ def get_extra_context(self, request, instance):
278
+ """Override to provide additional context with correct URLs"""
279
+ context = super().get_extra_context(request, instance)
280
+
281
+ # Override any auto-generated URLs that might be using wrong namespace
282
+ context['base_template'] = 'generic/object_edit.html'
283
+ context['return_url'] = self.get_return_url(request, instance)
284
+
285
+ return context
286
+
287
+ def form_valid(self, form):
288
+ """Override form_valid to ensure correct URL handling"""
289
+ # Let the parent handle the form saving
290
+ response = super().form_valid(form)
291
+ # The parent should redirect to get_success_url()
292
+ return response
293
+
294
+ class CommandView(ObjectView):
295
+ queryset = Command.objects.all()
296
+ template_name = 'netbox_toolkit/command.html'
297
+
298
+ def get_extra_context(self, request, instance):
299
+ """Add permission context to the template"""
300
+ context = super().get_extra_context(request, instance)
301
+
302
+ # Add permission information for the template using NetBox's object-based permissions
303
+ context['can_execute'] = False
304
+ if instance.command_type == 'show':
305
+ context['can_execute'] = self._user_has_action_permission(request.user, instance, 'execute_show')
306
+ elif instance.command_type == 'config':
307
+ context['can_execute'] = self._user_has_action_permission(request.user, instance, 'execute_config')
308
+
309
+ # NetBox will automatically handle 'change' and 'delete' permissions through standard actions
310
+ context['can_edit'] = self._user_has_action_permission(request.user, instance, 'change')
311
+ context['can_delete'] = self._user_has_action_permission(request.user, instance, 'delete')
312
+
313
+ return context
314
+
315
+ def _user_has_action_permission(self, user, obj, action):
316
+ """Check if user has permission for a specific action on an object using NetBox's ObjectPermission system"""
317
+ from django.contrib.contenttypes.models import ContentType
318
+ from users.models import ObjectPermission
319
+
320
+ # Get content type for the object
321
+ content_type = ContentType.objects.get_for_model(obj)
322
+
323
+ # Check if user has any ObjectPermissions with the required action
324
+ user_permissions = ObjectPermission.objects.filter(
325
+ object_types__in=[content_type],
326
+ actions__contains=[action],
327
+ enabled=True
328
+ )
329
+
330
+ # Check if user is assigned to any groups with this permission
331
+ user_groups = user.groups.all()
332
+ for permission in user_permissions:
333
+ # Check if permission applies to user or user's groups
334
+ if (permission.users.filter(id=user.id).exists() or
335
+ permission.groups.filter(id__in=user_groups.values_list('id', flat=True)).exists()):
336
+
337
+ # If there are constraints, evaluate them
338
+ if permission.constraints:
339
+ # Create a queryset with the constraints and check if the object matches
340
+ queryset = content_type.model_class().objects.filter(**permission.constraints)
341
+ if queryset.filter(id=obj.id).exists():
342
+ return True
343
+ else:
344
+ # No constraints means permission applies to all objects of this type
345
+ return True
346
+
347
+ return False
348
+
349
+ class CommandDeleteView(ObjectDeleteView):
350
+ queryset = Command.objects.all()
351
+
352
+ def get_success_url(self):
353
+ """Override to use correct plugin namespace"""
354
+ from django.urls import reverse
355
+ return reverse('plugins:netbox_toolkit:command_list')
356
+
357
+ class CommandChangeLogView(ObjectChangeLogView):
358
+ queryset = Command.objects.all()
359
+
360
+ # CommandLog views
361
+ class CommandLogListView(ObjectListView):
362
+ queryset = CommandLog.objects.all()
363
+ filterset = None # Will update this after import
364
+ table = None # Will update this after import
365
+ template_name = 'netbox_toolkit/commandlog_list.html'
366
+
367
+ def __init__(self, *args, **kwargs):
368
+ super().__init__(*args, **kwargs)
369
+ from .filtersets import CommandLogFilterSet
370
+ from .tables import CommandLogTable
371
+ self.filterset = CommandLogFilterSet
372
+ self.table = CommandLogTable
373
+
374
+ def get_extra_context(self, request):
375
+ """Override to disable 'Add' button since logs are created automatically"""
376
+ context = super().get_extra_context(request)
377
+ context['add_button_url'] = None # Disable the add button
378
+ return context
379
+
380
+ class CommandLogView(ObjectView):
381
+ queryset = CommandLog.objects.all()
382
+ template_name = 'netbox_toolkit/commandlog.html'
383
+
384
+ class CommandLogChangeLogView(ObjectChangeLogView):
385
+ queryset = CommandLog.objects.all()
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-toolkit-plugin
3
+ Version: 0.1.0
4
+ Summary: NetBox plugin for running pre-defined commands on network devices
5
+ Author: Andy Norwood
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: System Administrators
8
+ Classifier: Natural Language :: English
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.10.0
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: scrapli>=2023.1.30
17
+ Requires-Dist: scrapli-netconf>=2023.1.30
18
+ Requires-Dist: scrapli-community>=2023.1.30
19
+ Requires-Dist: netmiko>=4.0.0
20
+ Dynamic: license-file
21
+
22
+ # NetBox Command Toolkit Plugin
23
+
24
+ > ⚠️ **EARLY DEVELOPMENT WARNING** ⚠️
25
+ > This plugin is in very early development and not recommended for production use. There will be bugs and possible incomplete functionality. Use at your own risk! If you do, give some feedback in [Discussions](https://github.com/bonzo81/netbox-toolkit-plugin/discussions)
26
+
27
+ A NetBox plugin that allows you to run commands on network devices directly from the device page.
28
+
29
+
30
+
31
+ ### 📋 Feature Overview
32
+ - **🔧 Command Creation**: Define platform-specific commands (show/config types)
33
+ - **🔐 Command Permissions**: Granular access control using NetBox's permission system
34
+ - **⚡ Command Execution**: Run commands directly from device pages via "Toolkit" tab
35
+ - **📄 Raw Output**: View complete, unfiltered command responses
36
+ - **🔍 Parsed Output**: Automatic JSON parsing using textFSM templates
37
+ - **📊 Command Logs**: Complete execution history with timestamps
38
+ - **🐛 Debug Logging**: Optional detailed logging for troubleshooting
39
+
40
+
41
+ ### Built with:
42
+ - Scrapli for device connections
43
+ - Netmiko as a fallback for problematic devices
44
+ - TextFSM for structured data parsing
45
+
46
+ ### Created with:
47
+ - VSCode
48
+ - Copilot
49
+ - RooCode
50
+
51
+ > This project is a work in progress and in early development. It is not recommended for production use. Feedback and contributions are welcome!
52
+
53
+ ## 📚 Essential Guides
54
+
55
+ #### 🚀 Getting Started
56
+ - [📦 Installation](./docs/user/installation.md) - Install the plugin in your NetBox environment
57
+ - [⚙️ Configuration](./docs/user/configuration.md) - Configure plugin settings and options
58
+
59
+ #### 📋 Command Management
60
+ - [📋 Command Creation](./docs/user/command-creation.md) - Create platform-specific commands
61
+ - [🔐 Permissions Setup](./docs/user/PERMISSIONS_SETUP_GUIDE.md) - Configure granular access control
62
+ - [📝 Permission Examples](./docs/user/permission-examples.md) - Example permission configuration
63
+
64
+ #### 🔧 Troubleshooting
65
+ - [🐛 Debug Logging](./docs/user/debug-logging.md) - Enable detailed logging for debugging
66
+
67
+
68
+ ## Contributing
69
+
70
+ **🚀 Want to Contribute?** Start with the [Contributor Guide](./docs/contributing.md) for a fast overview of the codebase.
71
+
72
+
73
+ ## Future ideas:
74
+ - Enhance API to allow execution of commands and return either parsed or raw data.
75
+ - Enable variable use in the command creation and execution, based on device attributes.
76
+
@@ -0,0 +1,56 @@
1
+ netbox_toolkit/__init__.py,sha256=eRGsuq2UwhZmpxPq-G6JJoW66E4UpATmI1bhY1ztoVw,823
2
+ netbox_toolkit/admin.py,sha256=j0O2hke80XbGCfU3lkxl8__tRmBArNLoQtLyA39hepI,690
3
+ netbox_toolkit/config.py,sha256=IKDDR1CMzTNCL3DQPXVKZ7WCw6kl53PD9D4jrBs6fpc,5481
4
+ netbox_toolkit/exceptions.py,sha256=qh7SxXLTqwsduxUMiTA_pXQ67VHSpxRuQgdlfXMl6Bk,785
5
+ netbox_toolkit/filtersets.py,sha256=ppmnfQ8IUHm60BiJR9_cdn1ygvuFlMfj6YIFLJ-XeMc,2722
6
+ netbox_toolkit/forms.py,sha256=6aUaXuJtx3ZL2iEbIq2zXuiXCu2bAHDUMI2EJJBPsWg,1037
7
+ netbox_toolkit/models.py,sha256=G08tjm6N2Ob8yf3503VvzOy249iWDYlDIpyYwht75JM,2791
8
+ netbox_toolkit/navigation.py,sha256=Ew0rx2vcLqArvEcSb9WLImz0PUBWXNOMCMbPCUMV_Bk,941
9
+ netbox_toolkit/search.py,sha256=v8HPgp_0Nq-s_IoV7n4Kfi63_u535jVAoT-31ntdZlA,564
10
+ netbox_toolkit/tables.py,sha256=qiP2Mub4saH_EhaZd43zf7wOfjgBBlMXA5Rj6iorDQ0,1238
11
+ netbox_toolkit/urls.py,sha256=Kr9xkrK_CXHGjCOTlNRE-8siqFH6nhqi74Um14LHcvA,1079
12
+ netbox_toolkit/views.py,sha256=K-LNcJoSYbAXDstxqA_hRemhR5wIf9-xk8bvqbSt428,17730
13
+ netbox_toolkit/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ netbox_toolkit/api/mixins.py,sha256=Yxog3LQQqmzIiXjxZ5VxSbwV3er-Pxs2GpPEJUipvfY,2241
15
+ netbox_toolkit/api/schemas.py,sha256=Xza5-H2ZqBPCEPyMzDkbmSofY9nE73epu0PKvCB1_Ys,7334
16
+ netbox_toolkit/api/serializers.py,sha256=ZSfBmICUwLEQoMvxPK5348mG7j5v3I5A4Yvu2fLui_Y,5791
17
+ netbox_toolkit/api/urls.py,sha256=sllNjhMFm6pjTXtVphAJg2FU9Am_v0AQy0xtRwrZbQA,273
18
+ netbox_toolkit/api/views/__init__.py,sha256=CksHVk9Jj81kImVlr_JnP9VGwFCAhtrnRzqw7H7pjcI,189
19
+ netbox_toolkit/api/views/command_logs.py,sha256=4PdUQPvle9R9pJcJalYS4MCKYhmG_5EbAUowbjVetgw,6255
20
+ netbox_toolkit/api/views/commands.py,sha256=dv6sjN3L0FTyAWmM4u7zZBPOug6H_WcIR6egd3XE3A4,10939
21
+ netbox_toolkit/connectors/__init__.py,sha256=lL1WCo-rlcjNGVwmwSN1PT592xr5LEPT8k5zEVZlltE,419
22
+ netbox_toolkit/connectors/base.py,sha256=Z88tVG87H8IXdub55t-_uu1ptdhKo0uaz9l9iO-Xi0I,2829
23
+ netbox_toolkit/connectors/factory.py,sha256=XeyIooBu1CwZpvtE9giFaFc5kKODck3fMQwY0OVWALU,13296
24
+ netbox_toolkit/connectors/netmiko_connector.py,sha256=4DZFpvatSQgjG6wp0reYYCwpwjGzVDILvh98QkYsM8o,18793
25
+ netbox_toolkit/connectors/scrapli_connector.py,sha256=ojQgXkUZVQ9vFAdtKEM4aA5HUX7JDO8mLPe4r_YTd7c,22859
26
+ netbox_toolkit/migrations/0001_initial.py,sha256=O8DWY0jMBfRLgxaCETgjOVxbcE0Ngr7nVXVg_zJCJwA,2533
27
+ netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py,sha256=8DaaDfzzkVQB4ZEGhoWjyjncvUPjCwNDrKaToFtfmsY,2089
28
+ netbox_toolkit/migrations/0003_permission_system_update.py,sha256=KDhXY9UGiBpXLfL_4yPEQfzm5F_bGXmjy10upuykQ_c,1783
29
+ netbox_toolkit/migrations/0004_remove_django_permissions.py,sha256=o_-5dWkSL1JtASkLpGPFg3bDi_Vx63USMX9sw6zNv_4,2638
30
+ netbox_toolkit/migrations/0005_alter_command_options_and_more.py,sha256=O7VQ0Nq4xpyheX8BmsoOL-Y7fyEvXyPTx503WSce0Is,603
31
+ netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py,sha256=1NyrQuBdnFEU7oTE_SIOHPLeFzNuOAP51Q5n8y_Sifc,765
32
+ netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py,sha256=SqVl4LafwmGm2sTKhsAvvrJkjm4cD5Oa3EwTaqZXaeU,471
33
+ netbox_toolkit/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ netbox_toolkit/services/__init__.py,sha256=nzdv0yOAyoAgm3ReKn-zLCbxkzEZfDpfnVDy9UExtyg,273
35
+ netbox_toolkit/services/command_service.py,sha256=3dDrTNmdh1I25ZfzjXFa5-rP4K7sWo9HuimP28LrGMg,15159
36
+ netbox_toolkit/services/device_service.py,sha256=SWqWw7c5cBhbXE_jCJwfgc3KvxchQq_6NeS2hlb3NDA,2945
37
+ netbox_toolkit/services/rate_limiting_service.py,sha256=63plMX25qJkiEztRt8NnJMDRQlp7QVtI6TNSfk8IPTc,8750
38
+ netbox_toolkit/static/netbox_toolkit/css/toolkit.css,sha256=VmyM2_rBJhiP43whtMFaYYP4EW4MUGHPPr84mOo_0Qg,4236
39
+ netbox_toolkit/static/netbox_toolkit/js/toolkit.js,sha256=0oF2p8g1Yu28hKQswj1-2Kctt_09YtT__Xeo39LfQ-I,25721
40
+ netbox_toolkit/templates/netbox_toolkit/command.html,sha256=lTLe4TJmXtiOx1lSbowegwklLY1te7vfieXM3gVtZYs,3387
41
+ netbox_toolkit/templates/netbox_toolkit/command_edit.html,sha256=Dx3QVrtLegViZl9_VWeVayZ1DIgtzWDnrmgS5_TfOCg,175
42
+ netbox_toolkit/templates/netbox_toolkit/command_list.html,sha256=SVgov1SbpBj_hgmnmgpajAT5_ur-lxzK8PinXp5eEIY,368
43
+ netbox_toolkit/templates/netbox_toolkit/commandlog.html,sha256=8qix7GG937QPRxquUEj-GUGDQOxdjX0ERKveQ6OyuMo,5658
44
+ netbox_toolkit/templates/netbox_toolkit/commandlog_list.html,sha256=ggZDtjUOx4kyyD0zAybImF4_gGqe7eTx6JOx_CxjnQ0,105
45
+ netbox_toolkit/templates/netbox_toolkit/device_toolkit.html,sha256=f-hGEXqQhf3FWHQNCUNw1vlA6jJfDWm3AdPgggtuoW0,32314
46
+ netbox_toolkit/utils/__init__.py,sha256=YPk8W2lP8BYeYpZFzemTS4V4BK9s5DqXrAjyQrMAAeg,43
47
+ netbox_toolkit/utils/connection.py,sha256=XWUNOaJ9TBIO6XuESTPi3RSwe8bKlyZ0MLV4_b-ZJiY,4349
48
+ netbox_toolkit/utils/error_parser.py,sha256=tl6QrFW_0bugZKXHl5V_UPY3VDKdp6jDskLtM5oaYAI,16811
49
+ netbox_toolkit/utils/logging.py,sha256=zxLy4c5FXaSR-ndVvY8OnKEn6hyvEDJRMKSDKc8ukjo,1933
50
+ netbox_toolkit/utils/network.py,sha256=__Xz3pYhd7FtMYAsnFZGbW2akQ8uRY2tjs4axIoZMhA,6100
51
+ netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE,sha256=oxRw-SpalZWfCdc8ZALEDboD9OV22cBRrLIpXz2f7a8,11273
52
+ netbox_toolkit_plugin-0.1.0.dist-info/METADATA,sha256=OIKy1epeypZpwW_GTHKySD2GnA0BqgJidd4yNehJ1MI,3157
53
+ netbox_toolkit_plugin-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
54
+ netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt,sha256=QG7lS9AhTN2AdEwFbO-DIyXVW4Q1avIC0Ha-CdNT4VA,69
55
+ netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt,sha256=uPBIiwEx-X6sCRLNFfFtd7LN7zq2zJWIkP-hReJrKfY,15
56
+ netbox_toolkit_plugin-0.1.0.dist-info/RECORD,,