netbox-toolkit-plugin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_toolkit/__init__.py +30 -0
- netbox_toolkit/admin.py +16 -0
- netbox_toolkit/api/__init__.py +0 -0
- netbox_toolkit/api/mixins.py +54 -0
- netbox_toolkit/api/schemas.py +234 -0
- netbox_toolkit/api/serializers.py +158 -0
- netbox_toolkit/api/urls.py +10 -0
- netbox_toolkit/api/views/__init__.py +10 -0
- netbox_toolkit/api/views/command_logs.py +170 -0
- netbox_toolkit/api/views/commands.py +267 -0
- netbox_toolkit/config.py +159 -0
- netbox_toolkit/connectors/__init__.py +15 -0
- netbox_toolkit/connectors/base.py +97 -0
- netbox_toolkit/connectors/factory.py +301 -0
- netbox_toolkit/connectors/netmiko_connector.py +443 -0
- netbox_toolkit/connectors/scrapli_connector.py +486 -0
- netbox_toolkit/exceptions.py +36 -0
- netbox_toolkit/filtersets.py +85 -0
- netbox_toolkit/forms.py +31 -0
- netbox_toolkit/migrations/0001_initial.py +54 -0
- netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +66 -0
- netbox_toolkit/migrations/0003_permission_system_update.py +48 -0
- netbox_toolkit/migrations/0004_remove_django_permissions.py +77 -0
- netbox_toolkit/migrations/0005_alter_command_options_and_more.py +25 -0
- netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +28 -0
- netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py +18 -0
- netbox_toolkit/migrations/__init__.py +0 -0
- netbox_toolkit/models.py +89 -0
- netbox_toolkit/navigation.py +30 -0
- netbox_toolkit/search.py +21 -0
- netbox_toolkit/services/__init__.py +7 -0
- netbox_toolkit/services/command_service.py +357 -0
- netbox_toolkit/services/device_service.py +87 -0
- netbox_toolkit/services/rate_limiting_service.py +228 -0
- netbox_toolkit/static/netbox_toolkit/css/toolkit.css +143 -0
- netbox_toolkit/static/netbox_toolkit/js/toolkit.js +657 -0
- netbox_toolkit/tables.py +37 -0
- netbox_toolkit/templates/netbox_toolkit/command.html +108 -0
- netbox_toolkit/templates/netbox_toolkit/command_edit.html +10 -0
- netbox_toolkit/templates/netbox_toolkit/command_list.html +12 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog.html +170 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog_list.html +4 -0
- netbox_toolkit/templates/netbox_toolkit/device_toolkit.html +536 -0
- netbox_toolkit/urls.py +22 -0
- netbox_toolkit/utils/__init__.py +1 -0
- netbox_toolkit/utils/connection.py +125 -0
- netbox_toolkit/utils/error_parser.py +428 -0
- netbox_toolkit/utils/logging.py +58 -0
- netbox_toolkit/utils/network.py +157 -0
- netbox_toolkit/views.py +385 -0
- netbox_toolkit_plugin-0.1.0.dist-info/METADATA +76 -0
- netbox_toolkit_plugin-0.1.0.dist-info/RECORD +56 -0
- netbox_toolkit_plugin-0.1.0.dist-info/WHEEL +5 -0
- netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE +200 -0
- netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)}")
|
netbox_toolkit/views.py
ADDED
@@ -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,,
|