netbox-toolkit-plugin 0.1.0__py3-none-any.whl → 0.1.1__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 (68) hide show
  1. netbox_toolkit_plugin/__init__.py +32 -0
  2. {netbox_toolkit → netbox_toolkit_plugin}/api/serializers.py +71 -35
  3. {netbox_toolkit → netbox_toolkit_plugin}/api/urls.py +3 -3
  4. {netbox_toolkit → netbox_toolkit_plugin}/config.py +80 -73
  5. {netbox_toolkit → netbox_toolkit_plugin}/connectors/factory.py +170 -111
  6. {netbox_toolkit → netbox_toolkit_plugin}/connectors/netmiko_connector.py +242 -179
  7. {netbox_toolkit → netbox_toolkit_plugin}/connectors/scrapli_connector.py +256 -172
  8. netbox_toolkit_plugin/migrations/0001_initial.py +108 -0
  9. netbox_toolkit_plugin/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +70 -0
  10. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0003_permission_system_update.py +26 -12
  11. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0004_remove_django_permissions.py +27 -29
  12. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0005_alter_command_options_and_more.py +7 -8
  13. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +7 -8
  14. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0007_alter_commandlog_parsing_template.py +6 -4
  15. {netbox_toolkit → netbox_toolkit_plugin}/models.py +31 -32
  16. {netbox_toolkit → netbox_toolkit_plugin}/navigation.py +6 -6
  17. {netbox_toolkit → netbox_toolkit_plugin}/services/command_service.py +188 -128
  18. {netbox_toolkit → netbox_toolkit_plugin}/services/rate_limiting_service.py +104 -97
  19. netbox_toolkit_plugin/tables.py +51 -0
  20. netbox_toolkit_plugin/templates/netbox_toolkit/command.html +108 -0
  21. netbox_toolkit_plugin/templates/netbox_toolkit/command_list.html +12 -0
  22. netbox_toolkit_plugin/templates/netbox_toolkit/commandlog.html +170 -0
  23. netbox_toolkit_plugin/templates/netbox_toolkit/device_toolkit.html +557 -0
  24. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command.html +5 -5
  25. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_list.html +2 -2
  26. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog.html +2 -2
  27. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/device_toolkit.html +6 -6
  28. netbox_toolkit_plugin/urls.py +38 -0
  29. {netbox_toolkit → netbox_toolkit_plugin}/utils/logging.py +20 -19
  30. {netbox_toolkit → netbox_toolkit_plugin}/views.py +251 -169
  31. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.dist-info}/METADATA +2 -2
  32. netbox_toolkit_plugin-0.1.1.dist-info/RECORD +60 -0
  33. netbox_toolkit_plugin-0.1.1.dist-info/entry_points.txt +2 -0
  34. netbox_toolkit_plugin-0.1.1.dist-info/top_level.txt +1 -0
  35. netbox_toolkit/__init__.py +0 -30
  36. netbox_toolkit/migrations/0001_initial.py +0 -54
  37. netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +0 -66
  38. netbox_toolkit/tables.py +0 -37
  39. netbox_toolkit/urls.py +0 -22
  40. netbox_toolkit_plugin-0.1.0.dist-info/RECORD +0 -56
  41. netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +0 -2
  42. netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +0 -1
  43. {netbox_toolkit → netbox_toolkit_plugin}/admin.py +0 -0
  44. {netbox_toolkit → netbox_toolkit_plugin}/api/__init__.py +0 -0
  45. {netbox_toolkit → netbox_toolkit_plugin}/api/mixins.py +0 -0
  46. {netbox_toolkit → netbox_toolkit_plugin}/api/schemas.py +0 -0
  47. {netbox_toolkit → netbox_toolkit_plugin}/api/views/__init__.py +0 -0
  48. {netbox_toolkit → netbox_toolkit_plugin}/api/views/command_logs.py +0 -0
  49. {netbox_toolkit → netbox_toolkit_plugin}/api/views/commands.py +0 -0
  50. {netbox_toolkit → netbox_toolkit_plugin}/connectors/__init__.py +0 -0
  51. {netbox_toolkit → netbox_toolkit_plugin}/connectors/base.py +0 -0
  52. {netbox_toolkit → netbox_toolkit_plugin}/exceptions.py +0 -0
  53. {netbox_toolkit → netbox_toolkit_plugin}/filtersets.py +0 -0
  54. {netbox_toolkit → netbox_toolkit_plugin}/forms.py +0 -0
  55. {netbox_toolkit → netbox_toolkit_plugin}/migrations/__init__.py +0 -0
  56. {netbox_toolkit → netbox_toolkit_plugin}/search.py +0 -0
  57. {netbox_toolkit → netbox_toolkit_plugin}/services/__init__.py +0 -0
  58. {netbox_toolkit → netbox_toolkit_plugin}/services/device_service.py +0 -0
  59. {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/css/toolkit.css +0 -0
  60. {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/js/toolkit.js +0 -0
  61. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_edit.html +0 -0
  62. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog_list.html +0 -0
  63. {netbox_toolkit → netbox_toolkit_plugin}/utils/__init__.py +0 -0
  64. {netbox_toolkit → netbox_toolkit_plugin}/utils/connection.py +0 -0
  65. {netbox_toolkit → netbox_toolkit_plugin}/utils/error_parser.py +0 -0
  66. {netbox_toolkit → netbox_toolkit_plugin}/utils/network.py +0 -0
  67. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.dist-info}/WHEEL +0 -0
  68. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,5 @@
1
1
  """Service for handling command execution on devices."""
2
+
2
3
  import traceback
3
4
  from typing import Optional, Any
4
5
 
@@ -9,7 +10,7 @@ from ..connectors.factory import ConnectorFactory
9
10
  from ..connectors.base import CommandResult
10
11
  from ..connectors.netmiko_connector import NetmikoConnector
11
12
  from ..exceptions import DeviceConnectionError, CommandExecutionError
12
- from ..config import ToolkitConfig
13
+ from ..config import ToolkitSettings
13
14
  from ..utils.logging import get_toolkit_logger
14
15
 
15
16
  logger = get_toolkit_logger(__name__)
@@ -17,166 +18,201 @@ logger = get_toolkit_logger(__name__)
17
18
 
18
19
  class CommandExecutionService:
19
20
  """Service for executing commands on devices."""
20
-
21
+
21
22
  def __init__(self):
22
23
  self.connector_factory = ConnectorFactory()
23
-
24
+
24
25
  def execute_command_with_retry(
25
26
  self,
26
27
  command: "PredefinedCommand",
27
28
  device: Any,
28
29
  username: str,
29
30
  password: str,
30
- max_retries: int = 1
31
+ max_retries: int = 1,
31
32
  ) -> "CommandResult":
32
33
  """
33
34
  Execute a command with connection retry capability.
34
-
35
+
35
36
  Args:
36
37
  command: Command to execute
37
38
  device: Target device
38
39
  username: Authentication username
39
40
  password: Authentication password
40
41
  max_retries: Maximum number of retry attempts
41
-
42
+
42
43
  Returns:
43
44
  CommandResult with execution details
44
45
  """
45
46
  last_error = None
46
-
47
- logger.info("Executing command '%s' on device %s (max_retries=%d)",
48
- command.name, device.name, max_retries)
49
-
47
+
48
+ logger.info(
49
+ "Executing command '%s' on device %s (max_retries=%d)",
50
+ command.name,
51
+ device.name,
52
+ max_retries,
53
+ )
54
+
50
55
  for attempt in range(max_retries + 1):
51
56
  try:
52
- logger.debug("Attempt %d/%d for command execution", attempt + 1, max_retries + 1)
53
-
57
+ logger.debug(
58
+ "Attempt %d/%d for command execution", attempt + 1, max_retries + 1
59
+ )
60
+
54
61
  # 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
-
62
+ connector = self.connector_factory.create_connector(
63
+ device, username, password
64
+ )
65
+ logger.debug(
66
+ "Created %s connector for device %s",
67
+ type(connector).__name__,
68
+ device.name,
69
+ )
70
+
59
71
  # Execute command using context manager for proper cleanup
60
72
  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
-
73
+ result = connector.execute_command(
74
+ command.command, command.command_type
75
+ )
76
+ logger.debug(
77
+ "Command executed successfully, output length: %d chars",
78
+ len(result.output) if result.output else 0,
79
+ )
80
+
65
81
  # If successful, log and return
66
- logger.info("Command execution completed successfully on %s", device.name)
82
+ logger.info(
83
+ "Command execution completed successfully on %s", device.name
84
+ )
67
85
  self._log_command_execution(command, device, result, username)
68
86
  return result
69
-
87
+
70
88
  except Exception as e:
71
89
  last_error = e
72
90
  error_msg = str(e)
73
- logger.warning("Command execution attempt %d failed: %s", attempt + 1, error_msg)
74
-
91
+ logger.warning(
92
+ "Command execution attempt %d failed: %s", attempt + 1, error_msg
93
+ )
94
+
75
95
  # 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)
96
+ if (
97
+ "Fast-fail to Netmiko" in error_msg
98
+ or ToolkitSettings.should_fast_fail_to_netmiko(error_msg)
99
+ ):
100
+ logger.info(
101
+ "Fast-fail pattern detected, attempting fallback to Netmiko for device %s",
102
+ device.name,
103
+ )
79
104
  try:
80
105
  # 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)
106
+ base_config = self.connector_factory._build_connection_config(
107
+ device, username, password
108
+ )
109
+ netmiko_config = (
110
+ self.connector_factory._prepare_connector_config(
111
+ base_config, NetmikoConnector
112
+ )
113
+ )
83
114
  fallback_connector = NetmikoConnector(netmiko_config)
84
-
115
+
85
116
  # Execute command using Netmiko fallback connector
86
117
  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)
118
+ result = fallback_connector.execute_command(
119
+ command.command, command.command_type
120
+ )
121
+ logger.info(
122
+ "Command executed successfully using Netmiko fallback on %s",
123
+ device.name,
124
+ )
125
+ self._log_command_execution(
126
+ command, device, result, username
127
+ )
90
128
  return result
91
-
129
+
92
130
  except Exception as fallback_error:
93
- logger.warning("Netmiko fallback also failed for device %s: %s", device.name, str(fallback_error))
131
+ logger.warning(
132
+ "Netmiko fallback also failed for device %s: %s",
133
+ device.name,
134
+ str(fallback_error),
135
+ )
94
136
  last_error = fallback_error
95
137
  break # Don't retry after fallback failure
96
-
138
+
97
139
  # 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)):
140
+ elif attempt < max_retries and (
141
+ "socket" in error_msg.lower()
142
+ or "connection" in error_msg.lower()
143
+ or "Bad file descriptor" in error_msg
144
+ ):
102
145
  logger.debug("Connection error detected, will retry")
103
146
  continue
104
147
  else:
105
148
  logger.error("Max retries reached or non-retryable error")
106
149
  break
107
-
150
+
108
151
  # All attempts failed, create error result
109
152
  logger.error("All command execution attempts failed for device %s", device.name)
110
153
  error_result = CommandResult(
111
154
  command=command.command,
112
155
  output="",
113
156
  success=False,
114
- error_message=str(last_error)
157
+ error_message=str(last_error),
115
158
  )
116
-
159
+
117
160
  # Add detailed error information
118
161
  error_result = self._enhance_error_result(error_result, last_error, device)
119
-
162
+
120
163
  # Log the failed execution
121
164
  self._log_command_execution(command, device, error_result, username)
122
-
165
+
123
166
  return error_result
124
167
 
125
168
  def execute_command(
126
- self,
127
- command: Command,
128
- device: Device,
129
- username: str,
130
- password: str
169
+ self, command: Command, device: Device, username: str, password: str
131
170
  ) -> CommandResult:
132
171
  """
133
172
  Execute a command on a device and log the result.
134
-
173
+
135
174
  Args:
136
175
  command: Command to execute
137
176
  device: Target device
138
177
  username: Authentication username
139
178
  password: Authentication password
140
-
179
+
141
180
  Returns:
142
181
  CommandResult with execution details
143
182
  """
144
183
  try:
145
184
  # Create appropriate connector for the device
146
- connector = self.connector_factory.create_connector(device, username, password)
147
-
185
+ connector = self.connector_factory.create_connector(
186
+ device, username, password
187
+ )
188
+
148
189
  # Execute command using context manager for proper cleanup
149
190
  with connector:
150
- result = connector.execute_command(command.command, command.command_type)
151
-
191
+ result = connector.execute_command(
192
+ command.command, command.command_type
193
+ )
194
+
152
195
  # Log the execution
153
196
  self._log_command_execution(command, device, result, username)
154
-
197
+
155
198
  return result
156
-
199
+
157
200
  except Exception as e:
158
201
  # Create error result
159
202
  error_result = CommandResult(
160
- command=command.command,
161
- output="",
162
- success=False,
163
- error_message=str(e)
203
+ command=command.command, output="", success=False, error_message=str(e)
164
204
  )
165
-
205
+
166
206
  # Add detailed error information
167
207
  error_result = self._enhance_error_result(error_result, e, device)
168
-
208
+
169
209
  # Log the failed execution
170
210
  self._log_command_execution(command, device, error_result, username)
171
-
211
+
172
212
  return error_result
173
-
213
+
174
214
  def _log_command_execution(
175
- self,
176
- command: Command,
177
- device: Device,
178
- result: CommandResult,
179
- username: str
215
+ self, command: Command, device: Device, result: CommandResult, username: str
180
216
  ) -> CommandLog:
181
217
  """Log command execution to database."""
182
218
  if result.success:
@@ -193,8 +229,8 @@ class CommandExecutionService:
193
229
  if result.output:
194
230
  output += f"\n\nOutput: {result.output}"
195
231
  success = False
196
- error_message = result.error_message or ''
197
-
232
+ error_message = result.error_message or ""
233
+
198
234
  # Create log entry with execution details
199
235
  command_log = CommandLog.objects.create(
200
236
  command=command,
@@ -206,28 +242,32 @@ class CommandExecutionService:
206
242
  execution_duration=result.execution_time,
207
243
  parsed_data=result.parsed_output,
208
244
  parsing_success=result.parsing_success,
209
- parsing_template=result.parsing_method
245
+ parsing_template=result.parsing_method,
210
246
  )
211
-
247
+
212
248
  if result.has_syntax_error:
213
249
  pass # Syntax error detected but not logging
214
250
  else:
215
251
  pass # Command executed successfully but not logging
216
-
252
+
217
253
  return command_log
218
-
219
- def _enhance_error_result(self, result: CommandResult, error: Exception, device: Device) -> CommandResult:
254
+
255
+ def _enhance_error_result(
256
+ self, result: CommandResult, error: Exception, device: Device
257
+ ) -> CommandResult:
220
258
  """Enhance error result with detailed troubleshooting information."""
221
259
  error_message = str(error)
222
260
  error_details = traceback.format_exc()
223
-
261
+
224
262
  enhanced_output = f"Error executing command: {error_message}"
225
-
263
+
226
264
  # Add specific guidance for common errors
227
265
  guidance_added = False
228
-
266
+
229
267
  if isinstance(error, DeviceConnectionError):
230
- enhanced_output += self._get_connection_error_guidance(error_message, device)
268
+ enhanced_output += self._get_connection_error_guidance(
269
+ error_message, device
270
+ )
231
271
  guidance_added = True
232
272
  elif "Bad file descriptor" in error_details:
233
273
  enhanced_output += self._get_bad_descriptor_guidance(device)
@@ -235,17 +275,28 @@ class CommandExecutionService:
235
275
  elif "Error reading SSH protocol banner" in error_details:
236
276
  enhanced_output += self._get_banner_error_guidance(device)
237
277
  guidance_added = True
238
-
278
+
239
279
  # Check for connection/authentication errors in the error message even if not DeviceConnectionError
240
280
  if not guidance_added:
241
281
  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)
282
+ if any(
283
+ error_term in error_lower
284
+ for error_term in [
285
+ "connect",
286
+ "connection",
287
+ "authentication",
288
+ "failed to connect",
289
+ "ssh",
290
+ "timeout",
291
+ "unreachable",
292
+ "refused",
293
+ ]
294
+ ):
295
+ enhanced_output += self._get_connection_error_guidance(
296
+ error_message, device
297
+ )
247
298
  guidance_added = True
248
-
299
+
249
300
  # Add general troubleshooting if no specific guidance was provided
250
301
  if not guidance_added:
251
302
  enhanced_output += (
@@ -254,61 +305,66 @@ class CommandExecutionService:
254
305
  "\n- Check credentials and device configuration"
255
306
  "\n- Review the debug information below for more details"
256
307
  )
257
-
308
+
258
309
  enhanced_output += f"\n\nDebug information:\n{error_details}"
259
-
310
+
260
311
  return CommandResult(
261
312
  command=result.command,
262
313
  output=enhanced_output,
263
314
  success=False,
264
315
  error_message=result.error_message,
265
- execution_time=result.execution_time
316
+ execution_time=result.execution_time,
266
317
  )
267
-
318
+
268
319
  def _get_connection_error_guidance(self, error_message: str, device: Device) -> str:
269
320
  """Get guidance for connection errors."""
270
- hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
271
-
321
+ hostname = (
322
+ str(device.primary_ip.address.ip) if device.primary_ip else device.name
323
+ )
324
+
272
325
  guidance = "\n\nConnection Error Troubleshooting:"
273
-
326
+
274
327
  # Convert to lowercase for case-insensitive matching
275
328
  error_lower = error_message.lower()
276
-
329
+
277
330
  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
- ]):
331
+ guidance += "\n- This is an SSH key exchange error"
332
+ elif any(
333
+ conn_error in error_lower
334
+ for conn_error in [
335
+ "connection not opened",
336
+ "connection refused",
337
+ "connection timed out",
338
+ "network is unreachable",
339
+ "no route to host",
340
+ ]
341
+ ):
288
342
  guidance += (
289
343
  "\n- Verify the device is reachable on the network"
290
344
  "\n- Check that SSH service is running on the device"
291
345
  "\n- Verify there's no firewall blocking the connection"
292
346
  "\n- Ensure the correct NetBox has correct device details (IP, Hostname)"
293
347
  )
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
- ]):
348
+ elif any(
349
+ auth_error in error_lower
350
+ for auth_error in [
351
+ "authentication failed",
352
+ "all authentication methods failed",
353
+ "permission denied",
354
+ "invalid user",
355
+ "login incorrect",
356
+ "authentication error",
357
+ ]
358
+ ):
302
359
  guidance += (
303
360
  "\n- Verify username and password are correct"
304
361
  "\n- Ensure the user has SSH access permissions on the device"
305
362
  "\n- Check if the device requires specific authentication methods"
306
363
  )
307
- elif any(timeout_error in error_lower for timeout_error in [
308
- "timeout",
309
- "timed out",
310
- "operation timed out"
311
- ]):
364
+ elif any(
365
+ timeout_error in error_lower
366
+ for timeout_error in ["timeout", "timed out", "operation timed out"]
367
+ ):
312
368
  guidance += (
313
369
  "\n- The connection or operation timed out"
314
370
  "\n- Check network connectivity to the device"
@@ -322,15 +378,17 @@ class CommandExecutionService:
322
378
  "\n- Verify network connectivity and firewall settings"
323
379
  "\n- Ensure your credentials are correct"
324
380
  )
325
-
381
+
326
382
  guidance += f"\n- Try connecting manually: ssh {hostname}"
327
-
383
+
328
384
  return guidance
329
-
385
+
330
386
  def _get_bad_descriptor_guidance(self, device: Device) -> str:
331
387
  """Get guidance for 'Bad file descriptor' errors."""
332
- hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
333
-
388
+ hostname = (
389
+ str(device.primary_ip.address.ip) if device.primary_ip else device.name
390
+ )
391
+
334
392
  return (
335
393
  "\n\n'Bad file descriptor' Error Guidance:"
336
394
  "\n- This often indicates network connectivity issues"
@@ -339,11 +397,13 @@ class CommandExecutionService:
339
397
  "\n- Confirm SSH service is running on the device"
340
398
  f"\n- Try connecting manually: ssh {hostname}"
341
399
  )
342
-
400
+
343
401
  def _get_banner_error_guidance(self, device: Device) -> str:
344
402
  """Get guidance for SSH banner errors."""
345
- hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
346
-
403
+ hostname = (
404
+ str(device.primary_ip.address.ip) if device.primary_ip else device.name
405
+ )
406
+
347
407
  return (
348
408
  "\n\nSSH Banner Error Guidance:"
349
409
  "\n- The device accepts connections but doesn't provide an SSH banner"