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,357 @@
|
|
1
|
+
"""Service for handling command execution on devices."""
|
2
|
+
import traceback
|
3
|
+
from typing import Optional, Any
|
4
|
+
|
5
|
+
from dcim.models import Device
|
6
|
+
|
7
|
+
from ..models import Command, CommandLog
|
8
|
+
from ..connectors.factory import ConnectorFactory
|
9
|
+
from ..connectors.base import CommandResult
|
10
|
+
from ..connectors.netmiko_connector import NetmikoConnector
|
11
|
+
from ..exceptions import DeviceConnectionError, CommandExecutionError
|
12
|
+
from ..config import ToolkitConfig
|
13
|
+
from ..utils.logging import get_toolkit_logger
|
14
|
+
|
15
|
+
logger = get_toolkit_logger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class CommandExecutionService:
|
19
|
+
"""Service for executing commands on devices."""
|
20
|
+
|
21
|
+
def __init__(self):
|
22
|
+
self.connector_factory = ConnectorFactory()
|
23
|
+
|
24
|
+
def execute_command_with_retry(
|
25
|
+
self,
|
26
|
+
command: "PredefinedCommand",
|
27
|
+
device: Any,
|
28
|
+
username: str,
|
29
|
+
password: str,
|
30
|
+
max_retries: int = 1
|
31
|
+
) -> "CommandResult":
|
32
|
+
"""
|
33
|
+
Execute a command with connection retry capability.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
command: Command to execute
|
37
|
+
device: Target device
|
38
|
+
username: Authentication username
|
39
|
+
password: Authentication password
|
40
|
+
max_retries: Maximum number of retry attempts
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
CommandResult with execution details
|
44
|
+
"""
|
45
|
+
last_error = None
|
46
|
+
|
47
|
+
logger.info("Executing command '%s' on device %s (max_retries=%d)",
|
48
|
+
command.name, device.name, max_retries)
|
49
|
+
|
50
|
+
for attempt in range(max_retries + 1):
|
51
|
+
try:
|
52
|
+
logger.debug("Attempt %d/%d for command execution", attempt + 1, max_retries + 1)
|
53
|
+
|
54
|
+
# Create appropriate connector for the device
|
55
|
+
connector = self.connector_factory.create_connector(device, username, password)
|
56
|
+
logger.debug("Created %s connector for device %s",
|
57
|
+
type(connector).__name__, device.name)
|
58
|
+
|
59
|
+
# Execute command using context manager for proper cleanup
|
60
|
+
with connector:
|
61
|
+
result = connector.execute_command(command.command, command.command_type)
|
62
|
+
logger.debug("Command executed successfully, output length: %d chars",
|
63
|
+
len(result.output) if result.output else 0)
|
64
|
+
|
65
|
+
# If successful, log and return
|
66
|
+
logger.info("Command execution completed successfully on %s", device.name)
|
67
|
+
self._log_command_execution(command, device, result, username)
|
68
|
+
return result
|
69
|
+
|
70
|
+
except Exception as e:
|
71
|
+
last_error = e
|
72
|
+
error_msg = str(e)
|
73
|
+
logger.warning("Command execution attempt %d failed: %s", attempt + 1, error_msg)
|
74
|
+
|
75
|
+
# Check for fast-fail scenario and automatically retry with Netmiko
|
76
|
+
if ("Fast-fail to Netmiko" in error_msg or
|
77
|
+
ToolkitConfig.should_fast_fail_to_netmiko(error_msg)):
|
78
|
+
logger.info("Fast-fail pattern detected, attempting fallback to Netmiko for device %s", device.name)
|
79
|
+
try:
|
80
|
+
# Create Netmiko connector directly for fallback
|
81
|
+
base_config = self.connector_factory._build_connection_config(device, username, password)
|
82
|
+
netmiko_config = self.connector_factory._prepare_connector_config(base_config, NetmikoConnector)
|
83
|
+
fallback_connector = NetmikoConnector(netmiko_config)
|
84
|
+
|
85
|
+
# Execute command using Netmiko fallback connector
|
86
|
+
with fallback_connector:
|
87
|
+
result = fallback_connector.execute_command(command.command, command.command_type)
|
88
|
+
logger.info("Command executed successfully using Netmiko fallback on %s", device.name)
|
89
|
+
self._log_command_execution(command, device, result, username)
|
90
|
+
return result
|
91
|
+
|
92
|
+
except Exception as fallback_error:
|
93
|
+
logger.warning("Netmiko fallback also failed for device %s: %s", device.name, str(fallback_error))
|
94
|
+
last_error = fallback_error
|
95
|
+
break # Don't retry after fallback failure
|
96
|
+
|
97
|
+
# If this was a socket/connection error and we have retries left, continue
|
98
|
+
elif (attempt < max_retries and
|
99
|
+
("socket" in error_msg.lower() or
|
100
|
+
"connection" in error_msg.lower() or
|
101
|
+
"Bad file descriptor" in error_msg)):
|
102
|
+
logger.debug("Connection error detected, will retry")
|
103
|
+
continue
|
104
|
+
else:
|
105
|
+
logger.error("Max retries reached or non-retryable error")
|
106
|
+
break
|
107
|
+
|
108
|
+
# All attempts failed, create error result
|
109
|
+
logger.error("All command execution attempts failed for device %s", device.name)
|
110
|
+
error_result = CommandResult(
|
111
|
+
command=command.command,
|
112
|
+
output="",
|
113
|
+
success=False,
|
114
|
+
error_message=str(last_error)
|
115
|
+
)
|
116
|
+
|
117
|
+
# Add detailed error information
|
118
|
+
error_result = self._enhance_error_result(error_result, last_error, device)
|
119
|
+
|
120
|
+
# Log the failed execution
|
121
|
+
self._log_command_execution(command, device, error_result, username)
|
122
|
+
|
123
|
+
return error_result
|
124
|
+
|
125
|
+
def execute_command(
|
126
|
+
self,
|
127
|
+
command: Command,
|
128
|
+
device: Device,
|
129
|
+
username: str,
|
130
|
+
password: str
|
131
|
+
) -> CommandResult:
|
132
|
+
"""
|
133
|
+
Execute a command on a device and log the result.
|
134
|
+
|
135
|
+
Args:
|
136
|
+
command: Command to execute
|
137
|
+
device: Target device
|
138
|
+
username: Authentication username
|
139
|
+
password: Authentication password
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
CommandResult with execution details
|
143
|
+
"""
|
144
|
+
try:
|
145
|
+
# Create appropriate connector for the device
|
146
|
+
connector = self.connector_factory.create_connector(device, username, password)
|
147
|
+
|
148
|
+
# Execute command using context manager for proper cleanup
|
149
|
+
with connector:
|
150
|
+
result = connector.execute_command(command.command, command.command_type)
|
151
|
+
|
152
|
+
# Log the execution
|
153
|
+
self._log_command_execution(command, device, result, username)
|
154
|
+
|
155
|
+
return result
|
156
|
+
|
157
|
+
except Exception as e:
|
158
|
+
# Create error result
|
159
|
+
error_result = CommandResult(
|
160
|
+
command=command.command,
|
161
|
+
output="",
|
162
|
+
success=False,
|
163
|
+
error_message=str(e)
|
164
|
+
)
|
165
|
+
|
166
|
+
# Add detailed error information
|
167
|
+
error_result = self._enhance_error_result(error_result, e, device)
|
168
|
+
|
169
|
+
# Log the failed execution
|
170
|
+
self._log_command_execution(command, device, error_result, username)
|
171
|
+
|
172
|
+
return error_result
|
173
|
+
|
174
|
+
def _log_command_execution(
|
175
|
+
self,
|
176
|
+
command: Command,
|
177
|
+
device: Device,
|
178
|
+
result: CommandResult,
|
179
|
+
username: str
|
180
|
+
) -> CommandLog:
|
181
|
+
"""Log command execution to database."""
|
182
|
+
if result.success:
|
183
|
+
output = result.output
|
184
|
+
# If syntax error was detected, note it in the success flag
|
185
|
+
if result.has_syntax_error:
|
186
|
+
success = False # Mark as failed due to syntax error
|
187
|
+
error_message = f"Syntax error detected: {result.syntax_error_type}"
|
188
|
+
else:
|
189
|
+
success = True
|
190
|
+
error_message = ""
|
191
|
+
else:
|
192
|
+
output = f"Error executing command: {result.error_message}"
|
193
|
+
if result.output:
|
194
|
+
output += f"\n\nOutput: {result.output}"
|
195
|
+
success = False
|
196
|
+
error_message = result.error_message or ''
|
197
|
+
|
198
|
+
# Create log entry with execution details
|
199
|
+
command_log = CommandLog.objects.create(
|
200
|
+
command=command,
|
201
|
+
device=device,
|
202
|
+
output=output,
|
203
|
+
username=username,
|
204
|
+
success=success,
|
205
|
+
error_message=error_message,
|
206
|
+
execution_duration=result.execution_time,
|
207
|
+
parsed_data=result.parsed_output,
|
208
|
+
parsing_success=result.parsing_success,
|
209
|
+
parsing_template=result.parsing_method
|
210
|
+
)
|
211
|
+
|
212
|
+
if result.has_syntax_error:
|
213
|
+
pass # Syntax error detected but not logging
|
214
|
+
else:
|
215
|
+
pass # Command executed successfully but not logging
|
216
|
+
|
217
|
+
return command_log
|
218
|
+
|
219
|
+
def _enhance_error_result(self, result: CommandResult, error: Exception, device: Device) -> CommandResult:
|
220
|
+
"""Enhance error result with detailed troubleshooting information."""
|
221
|
+
error_message = str(error)
|
222
|
+
error_details = traceback.format_exc()
|
223
|
+
|
224
|
+
enhanced_output = f"Error executing command: {error_message}"
|
225
|
+
|
226
|
+
# Add specific guidance for common errors
|
227
|
+
guidance_added = False
|
228
|
+
|
229
|
+
if isinstance(error, DeviceConnectionError):
|
230
|
+
enhanced_output += self._get_connection_error_guidance(error_message, device)
|
231
|
+
guidance_added = True
|
232
|
+
elif "Bad file descriptor" in error_details:
|
233
|
+
enhanced_output += self._get_bad_descriptor_guidance(device)
|
234
|
+
guidance_added = True
|
235
|
+
elif "Error reading SSH protocol banner" in error_details:
|
236
|
+
enhanced_output += self._get_banner_error_guidance(device)
|
237
|
+
guidance_added = True
|
238
|
+
|
239
|
+
# Check for connection/authentication errors in the error message even if not DeviceConnectionError
|
240
|
+
if not guidance_added:
|
241
|
+
error_lower = error_message.lower()
|
242
|
+
if any(error_term in error_lower for error_term in [
|
243
|
+
"connect", "connection", "authentication", "failed to connect",
|
244
|
+
"ssh", "timeout", "unreachable", "refused"
|
245
|
+
]):
|
246
|
+
enhanced_output += self._get_connection_error_guidance(error_message, device)
|
247
|
+
guidance_added = True
|
248
|
+
|
249
|
+
# Add general troubleshooting if no specific guidance was provided
|
250
|
+
if not guidance_added:
|
251
|
+
enhanced_output += (
|
252
|
+
"\n\nGeneral Troubleshooting:"
|
253
|
+
"\n- Verify device connectivity and SSH service status"
|
254
|
+
"\n- Check credentials and device configuration"
|
255
|
+
"\n- Review the debug information below for more details"
|
256
|
+
)
|
257
|
+
|
258
|
+
enhanced_output += f"\n\nDebug information:\n{error_details}"
|
259
|
+
|
260
|
+
return CommandResult(
|
261
|
+
command=result.command,
|
262
|
+
output=enhanced_output,
|
263
|
+
success=False,
|
264
|
+
error_message=result.error_message,
|
265
|
+
execution_time=result.execution_time
|
266
|
+
)
|
267
|
+
|
268
|
+
def _get_connection_error_guidance(self, error_message: str, device: Device) -> str:
|
269
|
+
"""Get guidance for connection errors."""
|
270
|
+
hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
|
271
|
+
|
272
|
+
guidance = "\n\nConnection Error Troubleshooting:"
|
273
|
+
|
274
|
+
# Convert to lowercase for case-insensitive matching
|
275
|
+
error_lower = error_message.lower()
|
276
|
+
|
277
|
+
if "no matching key exchange" in error_lower:
|
278
|
+
guidance += (
|
279
|
+
"\n- This is an SSH key exchange error"
|
280
|
+
)
|
281
|
+
elif any(conn_error in error_lower for conn_error in [
|
282
|
+
"connection not opened",
|
283
|
+
"connection refused",
|
284
|
+
"connection timed out",
|
285
|
+
"network is unreachable",
|
286
|
+
"no route to host"
|
287
|
+
]):
|
288
|
+
guidance += (
|
289
|
+
"\n- Verify the device is reachable on the network"
|
290
|
+
"\n- Check that SSH service is running on the device"
|
291
|
+
"\n- Verify there's no firewall blocking the connection"
|
292
|
+
"\n- Ensure the correct NetBox has correct device details (IP, Hostname)"
|
293
|
+
)
|
294
|
+
elif any(auth_error in error_lower for auth_error in [
|
295
|
+
"authentication failed",
|
296
|
+
"all authentication methods failed",
|
297
|
+
"permission denied",
|
298
|
+
"invalid user",
|
299
|
+
"login incorrect",
|
300
|
+
"authentication error"
|
301
|
+
]):
|
302
|
+
guidance += (
|
303
|
+
"\n- Verify username and password are correct"
|
304
|
+
"\n- Ensure the user has SSH access permissions on the device"
|
305
|
+
"\n- Check if the device requires specific authentication methods"
|
306
|
+
)
|
307
|
+
elif any(timeout_error in error_lower for timeout_error in [
|
308
|
+
"timeout",
|
309
|
+
"timed out",
|
310
|
+
"operation timed out"
|
311
|
+
]):
|
312
|
+
guidance += (
|
313
|
+
"\n- The connection or operation timed out"
|
314
|
+
"\n- Check network connectivity to the device"
|
315
|
+
"\n- Verify the device is responding"
|
316
|
+
)
|
317
|
+
else:
|
318
|
+
# Generic connection guidance
|
319
|
+
guidance += (
|
320
|
+
"\n- Verify the device IP address is correct and reachable"
|
321
|
+
"\n- Check that SSH service is running on the device (usually port 22)"
|
322
|
+
"\n- Verify network connectivity and firewall settings"
|
323
|
+
"\n- Ensure your credentials are correct"
|
324
|
+
)
|
325
|
+
|
326
|
+
guidance += f"\n- Try connecting manually: ssh {hostname}"
|
327
|
+
|
328
|
+
return guidance
|
329
|
+
|
330
|
+
def _get_bad_descriptor_guidance(self, device: Device) -> str:
|
331
|
+
"""Get guidance for 'Bad file descriptor' errors."""
|
332
|
+
hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
|
333
|
+
|
334
|
+
return (
|
335
|
+
"\n\n'Bad file descriptor' Error Guidance:"
|
336
|
+
"\n- This often indicates network connectivity issues"
|
337
|
+
"\n- Verify the device IP address is correct"
|
338
|
+
"\n- Check that the device is reachable (try pinging it)"
|
339
|
+
"\n- Confirm SSH service is running on the device"
|
340
|
+
f"\n- Try connecting manually: ssh {hostname}"
|
341
|
+
)
|
342
|
+
|
343
|
+
def _get_banner_error_guidance(self, device: Device) -> str:
|
344
|
+
"""Get guidance for SSH banner errors."""
|
345
|
+
hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
|
346
|
+
|
347
|
+
return (
|
348
|
+
"\n\nSSH Banner Error Guidance:"
|
349
|
+
"\n- The device accepts connections but doesn't provide an SSH banner"
|
350
|
+
"\n- This could indicate:"
|
351
|
+
"\n * A different service is running on port 22"
|
352
|
+
"\n * The SSH server is very slow to respond"
|
353
|
+
"\n * A firewall is intercepting the connection"
|
354
|
+
"\n * The SSH implementation is non-standard"
|
355
|
+
f"\n- Try manual SSH with verbose logging: ssh -v {hostname}"
|
356
|
+
"\n- Check what service is on port 22: nmap -sV -p 22 " + hostname
|
357
|
+
)
|
@@ -0,0 +1,87 @@
|
|
1
|
+
"""Service for device-related operations."""
|
2
|
+
from typing import List, Optional
|
3
|
+
|
4
|
+
from dcim.models import Device
|
5
|
+
|
6
|
+
from ..models import Command
|
7
|
+
|
8
|
+
|
9
|
+
class DeviceService:
|
10
|
+
"""Service for device-related operations."""
|
11
|
+
|
12
|
+
@staticmethod
|
13
|
+
def get_available_commands(device: Device) -> List[Command]:
|
14
|
+
"""
|
15
|
+
Get all commands available for a device based on its platform.
|
16
|
+
|
17
|
+
Args:
|
18
|
+
device: The device to get commands for
|
19
|
+
|
20
|
+
Returns:
|
21
|
+
List of available commands
|
22
|
+
"""
|
23
|
+
if not device.platform:
|
24
|
+
return []
|
25
|
+
|
26
|
+
commands = Command.objects.filter(platform=device.platform)
|
27
|
+
|
28
|
+
return list(commands)
|
29
|
+
|
30
|
+
@staticmethod
|
31
|
+
def get_device_connection_info(device: Device) -> dict:
|
32
|
+
"""
|
33
|
+
Get connection information for a device.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
device: The device to get connection info for
|
37
|
+
|
38
|
+
Returns:
|
39
|
+
Dictionary with connection information
|
40
|
+
"""
|
41
|
+
hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
|
42
|
+
platform = str(device.platform).lower() if device.platform else None
|
43
|
+
|
44
|
+
info = {
|
45
|
+
'hostname': hostname,
|
46
|
+
'platform': platform,
|
47
|
+
'has_primary_ip': device.primary_ip is not None,
|
48
|
+
}
|
49
|
+
|
50
|
+
return info
|
51
|
+
|
52
|
+
@staticmethod
|
53
|
+
def validate_device_for_commands(device: Device) -> tuple[bool, Optional[str], dict]:
|
54
|
+
"""
|
55
|
+
Validate if a device is ready for command execution.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
device: The device to validate
|
59
|
+
|
60
|
+
Returns:
|
61
|
+
Tuple of (is_valid, error_message, validation_checks)
|
62
|
+
validation_checks is a dict with check names as keys and booleans as values
|
63
|
+
"""
|
64
|
+
# Initialize validation checks
|
65
|
+
validation_checks = {
|
66
|
+
'has_platform': device.platform is not None,
|
67
|
+
'has_primary_ip': device.primary_ip is not None,
|
68
|
+
'has_hostname': bool(device.name), # Check if device has a name
|
69
|
+
'platform_supported': False, # Will be set below if platform exists
|
70
|
+
}
|
71
|
+
|
72
|
+
# Additional logic for more complex checks
|
73
|
+
if validation_checks['has_platform']:
|
74
|
+
validation_checks['platform_supported'] = True # If platform exists, we consider it supported
|
75
|
+
|
76
|
+
# Determine overall validity and error message
|
77
|
+
is_valid = True
|
78
|
+
error_message = None
|
79
|
+
|
80
|
+
if not validation_checks['has_platform']:
|
81
|
+
is_valid = False
|
82
|
+
error_message = "Device has no platform assigned"
|
83
|
+
elif not validation_checks['has_primary_ip'] and not validation_checks['has_hostname']:
|
84
|
+
is_valid = False
|
85
|
+
error_message = "Device has no primary IP address or hostname"
|
86
|
+
|
87
|
+
return is_valid, error_message, validation_checks
|
@@ -0,0 +1,228 @@
|
|
1
|
+
from datetime import datetime, timedelta
|
2
|
+
from django.utils import timezone
|
3
|
+
from django.conf import settings
|
4
|
+
from django.contrib.auth.models import User, Group
|
5
|
+
from ..models import CommandLog
|
6
|
+
|
7
|
+
|
8
|
+
class RateLimitingService:
|
9
|
+
"""Service for managing command execution rate limiting"""
|
10
|
+
|
11
|
+
def __init__(self):
|
12
|
+
"""Initialize the rate limiting service with plugin settings"""
|
13
|
+
self.plugin_settings = getattr(settings, 'PLUGINS_CONFIG', {}).get('netbox_toolkit', {})
|
14
|
+
|
15
|
+
def is_rate_limiting_enabled(self):
|
16
|
+
"""Check if rate limiting is enabled in plugin settings"""
|
17
|
+
return self.plugin_settings.get('rate_limiting_enabled', False)
|
18
|
+
|
19
|
+
def get_device_command_limit(self):
|
20
|
+
"""Get the maximum number of commands allowed per device within the time window"""
|
21
|
+
return self.plugin_settings.get('device_command_limit', 10)
|
22
|
+
|
23
|
+
def get_time_window_minutes(self):
|
24
|
+
"""Get the time window in minutes for rate limiting"""
|
25
|
+
return self.plugin_settings.get('time_window_minutes', 5)
|
26
|
+
|
27
|
+
def get_bypass_users(self):
|
28
|
+
"""Get list of usernames that bypass rate limiting"""
|
29
|
+
return self.plugin_settings.get('bypass_users', [])
|
30
|
+
|
31
|
+
def get_bypass_groups(self):
|
32
|
+
"""Get list of group names that bypass rate limiting"""
|
33
|
+
return self.plugin_settings.get('bypass_groups', [])
|
34
|
+
|
35
|
+
def user_bypasses_rate_limiting(self, user):
|
36
|
+
"""
|
37
|
+
Check if a user bypasses rate limiting based on username or group membership
|
38
|
+
|
39
|
+
Args:
|
40
|
+
user: Django User object
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
bool: True if user bypasses rate limiting, False otherwise
|
44
|
+
"""
|
45
|
+
# Check if user is in bypass users list
|
46
|
+
bypass_users = self.get_bypass_users()
|
47
|
+
if user.username in bypass_users:
|
48
|
+
return True
|
49
|
+
|
50
|
+
# Check if user is in any bypass groups
|
51
|
+
bypass_groups = self.get_bypass_groups()
|
52
|
+
if bypass_groups:
|
53
|
+
user_groups = user.groups.values_list('name', flat=True)
|
54
|
+
if any(group in bypass_groups for group in user_groups):
|
55
|
+
return True
|
56
|
+
|
57
|
+
return False
|
58
|
+
|
59
|
+
def get_recent_command_count(self, device, user=None):
|
60
|
+
"""
|
61
|
+
Get the number of successful commands executed on a device within the time window
|
62
|
+
|
63
|
+
Args:
|
64
|
+
device: Device object
|
65
|
+
user: Optional User object to count only commands by this user
|
66
|
+
|
67
|
+
Returns:
|
68
|
+
int: Number of recent successful commands
|
69
|
+
"""
|
70
|
+
time_window = self.get_time_window_minutes()
|
71
|
+
cutoff_time = timezone.now() - timedelta(minutes=time_window)
|
72
|
+
|
73
|
+
query = CommandLog.objects.filter(
|
74
|
+
device=device,
|
75
|
+
execution_time__gte=cutoff_time,
|
76
|
+
success=True # Only count successful commands
|
77
|
+
)
|
78
|
+
|
79
|
+
if user:
|
80
|
+
query = query.filter(username=user.username)
|
81
|
+
|
82
|
+
return query.count()
|
83
|
+
|
84
|
+
def check_rate_limit(self, device, user):
|
85
|
+
"""
|
86
|
+
Check if a command execution would exceed rate limits
|
87
|
+
|
88
|
+
Args:
|
89
|
+
device: Device object to check rate limits for
|
90
|
+
user: User object attempting to execute the command
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
dict: {
|
94
|
+
'allowed': bool,
|
95
|
+
'current_count': int,
|
96
|
+
'limit': int,
|
97
|
+
'time_window_minutes': int,
|
98
|
+
'reason': str (if not allowed)
|
99
|
+
}
|
100
|
+
"""
|
101
|
+
# If rate limiting is disabled, always allow
|
102
|
+
if not self.is_rate_limiting_enabled():
|
103
|
+
return {
|
104
|
+
'allowed': True,
|
105
|
+
'current_count': 0,
|
106
|
+
'limit': self.get_device_command_limit(),
|
107
|
+
'time_window_minutes': self.get_time_window_minutes(),
|
108
|
+
'reason': 'Rate limiting disabled'
|
109
|
+
}
|
110
|
+
|
111
|
+
# If user bypasses rate limiting, always allow
|
112
|
+
if self.user_bypasses_rate_limiting(user):
|
113
|
+
return {
|
114
|
+
'allowed': True,
|
115
|
+
'current_count': 0,
|
116
|
+
'limit': self.get_device_command_limit(),
|
117
|
+
'time_window_minutes': self.get_time_window_minutes(),
|
118
|
+
'reason': 'User bypasses rate limiting'
|
119
|
+
}
|
120
|
+
|
121
|
+
# Check current command count
|
122
|
+
current_count = self.get_recent_command_count(device)
|
123
|
+
limit = self.get_device_command_limit()
|
124
|
+
time_window = self.get_time_window_minutes()
|
125
|
+
|
126
|
+
if current_count >= limit:
|
127
|
+
return {
|
128
|
+
'allowed': False,
|
129
|
+
'current_count': current_count,
|
130
|
+
'limit': limit,
|
131
|
+
'time_window_minutes': time_window,
|
132
|
+
'reason': f'Rate limit exceeded: {current_count}/{limit} successful commands in last {time_window} minutes'
|
133
|
+
}
|
134
|
+
|
135
|
+
return {
|
136
|
+
'allowed': True,
|
137
|
+
'current_count': current_count,
|
138
|
+
'limit': limit,
|
139
|
+
'time_window_minutes': time_window,
|
140
|
+
'reason': 'Within rate limits'
|
141
|
+
}
|
142
|
+
|
143
|
+
def get_rate_limit_status(self, device, user):
|
144
|
+
"""
|
145
|
+
Get rate limit status for display in UI
|
146
|
+
|
147
|
+
Args:
|
148
|
+
device: Device object
|
149
|
+
user: User object
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
dict: Rate limit status information for UI display
|
153
|
+
"""
|
154
|
+
if not self.is_rate_limiting_enabled():
|
155
|
+
return {
|
156
|
+
'enabled': False,
|
157
|
+
'message': 'Rate limiting is disabled'
|
158
|
+
}
|
159
|
+
|
160
|
+
if self.user_bypasses_rate_limiting(user):
|
161
|
+
return {
|
162
|
+
'enabled': True,
|
163
|
+
'bypassed': True,
|
164
|
+
'message': 'You have unlimited command execution (bypass enabled)'
|
165
|
+
}
|
166
|
+
|
167
|
+
current_count = self.get_recent_command_count(device)
|
168
|
+
limit = self.get_device_command_limit()
|
169
|
+
time_window = self.get_time_window_minutes()
|
170
|
+
remaining = max(0, limit - current_count)
|
171
|
+
|
172
|
+
# Determine status and appropriate message
|
173
|
+
if current_count >= limit:
|
174
|
+
status = 'exceeded'
|
175
|
+
# Get time until reset for exceeded status
|
176
|
+
time_until_reset = self.get_time_until_reset(device)
|
177
|
+
if time_until_reset:
|
178
|
+
minutes_until_reset = int(time_until_reset.total_seconds() / 60) + 1
|
179
|
+
message = f'Rate limit exceeded! ({current_count}/{limit} successful commands) - Try again in {minutes_until_reset} minutes'
|
180
|
+
else:
|
181
|
+
message = f'Rate limit exceeded! ({current_count}/{limit} successful commands in last {time_window} minutes)'
|
182
|
+
elif remaining <= 2 and current_count < limit: # Only warning if we haven't exceeded the limit
|
183
|
+
status = 'warning'
|
184
|
+
message = f'{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)'
|
185
|
+
else:
|
186
|
+
status = 'normal'
|
187
|
+
message = f'{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)'
|
188
|
+
|
189
|
+
return {
|
190
|
+
'enabled': True,
|
191
|
+
'bypassed': False,
|
192
|
+
'current_count': current_count,
|
193
|
+
'limit': limit,
|
194
|
+
'remaining': remaining,
|
195
|
+
'time_window_minutes': time_window,
|
196
|
+
'status': status,
|
197
|
+
'message': message,
|
198
|
+
'is_exceeded': current_count >= limit,
|
199
|
+
'is_warning': remaining <= 2 and current_count < limit,
|
200
|
+
'time_until_reset': self.get_time_until_reset(device) if current_count >= limit else None,
|
201
|
+
}
|
202
|
+
|
203
|
+
def get_time_until_reset(self, device):
|
204
|
+
"""
|
205
|
+
Get the time until the rate limit resets (oldest successful command in window expires)
|
206
|
+
|
207
|
+
Args:
|
208
|
+
device: Device object
|
209
|
+
|
210
|
+
Returns:
|
211
|
+
timedelta or None: Time until oldest successful command expires, None if no recent successful commands
|
212
|
+
"""
|
213
|
+
time_window = self.get_time_window_minutes()
|
214
|
+
cutoff_time = timezone.now() - timedelta(minutes=time_window)
|
215
|
+
|
216
|
+
oldest_command = CommandLog.objects.filter(
|
217
|
+
device=device,
|
218
|
+
execution_time__gte=cutoff_time,
|
219
|
+
success=True # Only consider successful commands
|
220
|
+
).order_by('execution_time').first()
|
221
|
+
|
222
|
+
if not oldest_command:
|
223
|
+
return None
|
224
|
+
|
225
|
+
reset_time = oldest_command.execution_time + timedelta(minutes=time_window)
|
226
|
+
time_until_reset = reset_time - timezone.now()
|
227
|
+
|
228
|
+
return time_until_reset if time_until_reset > timedelta(0) else None
|