isptools-pro 3.0.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.
- autofix/__init__.py +5 -0
- autofix/engine.py +91 -0
- autofix/permissions.py +31 -0
- autofix/rules.py +64 -0
- core/__init__.py +4 -0
- core/base.py +47 -0
- core/exceptions.py +15 -0
- drivers/__init__.py +6 -0
- drivers/cisco.py +165 -0
- drivers/mikrotik.py +179 -0
- drivers/snmp.py +114 -0
- drivers/ubiquiti.py +273 -0
- isptools_pro-3.0.0.dist-info/METADATA +261 -0
- isptools_pro-3.0.0.dist-info/RECORD +31 -0
- isptools_pro-3.0.0.dist-info/WHEEL +5 -0
- isptools_pro-3.0.0.dist-info/entry_points.txt +2 -0
- isptools_pro-3.0.0.dist-info/licenses/LICENSE +21 -0
- isptools_pro-3.0.0.dist-info/top_level.txt +7 -0
- models/__init__.py +5 -0
- models/device.py +13 -0
- models/station.py +14 -0
- models/stats.py +13 -0
- monitor/__init__.py +4 -0
- monitor/alerts.py +65 -0
- monitor/poller.py +65 -0
- reports/__init__.py +4 -0
- reports/collector.py +100 -0
- reports/reporter.py +121 -0
- topology/__init__.py +4 -0
- topology/exporter.py +36 -0
- topology/mapper.py +62 -0
autofix/__init__.py
ADDED
autofix/engine.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from .permissions import PermissionManager, PermissionLevel
|
|
3
|
+
from .rules import AUTOFIX_RULES
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class AutoFixEngine:
|
|
8
|
+
"""Engine for automatically detecting and fixing network issues."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, permission_manager: PermissionManager):
|
|
11
|
+
self.permission_manager = permission_manager
|
|
12
|
+
self.history = []
|
|
13
|
+
|
|
14
|
+
def analyze(self, device_host: str, stats: dict) -> list[dict]:
|
|
15
|
+
"""Analyze stats against rules and return potential fixes."""
|
|
16
|
+
detected_problems = []
|
|
17
|
+
for rule in AUTOFIX_RULES:
|
|
18
|
+
if rule["trigger"](stats):
|
|
19
|
+
detected_problems.append({
|
|
20
|
+
"name": rule["name"],
|
|
21
|
+
"description": rule["description"],
|
|
22
|
+
"fixes": rule["fixes"]
|
|
23
|
+
})
|
|
24
|
+
return detected_problems
|
|
25
|
+
|
|
26
|
+
def apply_fixes(self, driver, stats: dict) -> list[dict]:
|
|
27
|
+
"""Analyze and apply allowed fixes."""
|
|
28
|
+
device_host = driver.device.host
|
|
29
|
+
device_type = driver.device.device_type
|
|
30
|
+
problems = self.analyze(device_host, stats)
|
|
31
|
+
results = []
|
|
32
|
+
|
|
33
|
+
for problem in problems:
|
|
34
|
+
for fix in problem["fixes"]:
|
|
35
|
+
# Check if fix applies to this device type
|
|
36
|
+
if fix["devices"] != ["all"] and device_type not in fix["devices"]:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
action = fix["action"]
|
|
40
|
+
required_level = fix["permission"]
|
|
41
|
+
|
|
42
|
+
# Request permission
|
|
43
|
+
if self.permission_manager.request_permission(action, device_host, required_level, problem["description"]):
|
|
44
|
+
status = self._execute_fix(driver, action)
|
|
45
|
+
else:
|
|
46
|
+
status = 'denied' if self.permission_manager.level < required_level else 'skipped'
|
|
47
|
+
|
|
48
|
+
result = {
|
|
49
|
+
"problem": problem["name"],
|
|
50
|
+
"action": action,
|
|
51
|
+
"status": status
|
|
52
|
+
}
|
|
53
|
+
self.history.append(result)
|
|
54
|
+
results.append(result)
|
|
55
|
+
|
|
56
|
+
if status == 'applied':
|
|
57
|
+
logger.info(f"AutoFix: Applied {action} to {device_host} for {problem['name']}")
|
|
58
|
+
else:
|
|
59
|
+
logger.debug(f"AutoFix: {action} on {device_host} status: {status}")
|
|
60
|
+
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
def _execute_fix(self, driver, action: str) -> str:
|
|
64
|
+
"""Execute the actual fix on the driver."""
|
|
65
|
+
try:
|
|
66
|
+
if action == "reboot_device":
|
|
67
|
+
driver.reboot()
|
|
68
|
+
return 'applied'
|
|
69
|
+
elif action == "send_alert":
|
|
70
|
+
# Alerting is handled externally usually, but we mark as applied
|
|
71
|
+
return 'applied'
|
|
72
|
+
|
|
73
|
+
# Call driver-specific methods
|
|
74
|
+
method = getattr(driver, action, None)
|
|
75
|
+
if method:
|
|
76
|
+
if method():
|
|
77
|
+
return 'applied'
|
|
78
|
+
else:
|
|
79
|
+
return 'failed'
|
|
80
|
+
else:
|
|
81
|
+
logger.warning(f"Driver {driver.__class__.__name__} does not implement {action}")
|
|
82
|
+
return 'failed'
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.error(f"Error executing fix {action} on {driver.device.host}: {e}")
|
|
85
|
+
return 'failed'
|
|
86
|
+
|
|
87
|
+
def get_fix_log(self, device_host: str = None) -> list[dict]:
|
|
88
|
+
"""Return history of all applied fixes."""
|
|
89
|
+
if device_host:
|
|
90
|
+
return [h for h in self.history if h.get('host') == device_host]
|
|
91
|
+
return self.history
|
autofix/permissions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
class PermissionLevel(IntEnum):
|
|
4
|
+
READ_ONLY = 0 # فقط قراءة، بدون أي تعديل
|
|
5
|
+
SAFE_FIX = 1 # إصلاح آمن (restart interface, clear arp)
|
|
6
|
+
MODERATE_FIX = 2 # إصلاح متوسط (change tx power, channel)
|
|
7
|
+
FULL_AUTO = 3 # صلاحية كاملة (reboot, change config)
|
|
8
|
+
|
|
9
|
+
class PermissionManager:
|
|
10
|
+
def __init__(self, level: PermissionLevel = PermissionLevel.READ_ONLY, require_confirm: bool = True):
|
|
11
|
+
self.level = level
|
|
12
|
+
self.require_confirm = require_confirm
|
|
13
|
+
self.confirm_callback = None
|
|
14
|
+
|
|
15
|
+
def set_confirm_callback(self, callback: callable):
|
|
16
|
+
"""Set a callback function for confirmation.
|
|
17
|
+
Callback receives: (action, device_host, details) -> returns bool
|
|
18
|
+
"""
|
|
19
|
+
self.confirm_callback = callback
|
|
20
|
+
|
|
21
|
+
def request_permission(self, action: str, device_host: str, required_level: PermissionLevel, details: str = "") -> bool:
|
|
22
|
+
"""Check if action is allowed based on current permission level and confirmation."""
|
|
23
|
+
if self.level < required_level:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
if self.require_confirm:
|
|
27
|
+
if self.confirm_callback:
|
|
28
|
+
return self.confirm_callback(action, device_host, details)
|
|
29
|
+
return False # No callback set but confirmation required
|
|
30
|
+
|
|
31
|
+
return True
|
autofix/rules.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from .permissions import PermissionLevel
|
|
2
|
+
|
|
3
|
+
AUTOFIX_RULES = [
|
|
4
|
+
{
|
|
5
|
+
"name": "low_signal",
|
|
6
|
+
"trigger": lambda stats: stats.get("signal", 0) < -80,
|
|
7
|
+
"description": "Signal dropped below -80 dBm",
|
|
8
|
+
"fixes": [
|
|
9
|
+
{"action": "increase_tx_power", "permission": PermissionLevel.MODERATE_FIX, "devices": ["ubiquiti"]},
|
|
10
|
+
{"action": "send_alert", "permission": PermissionLevel.READ_ONLY, "devices": ["all"]},
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "high_cpu",
|
|
15
|
+
"trigger": lambda stats: stats.get("cpu_load", 0) > 90,
|
|
16
|
+
"description": "CPU load exceeded 90%",
|
|
17
|
+
"fixes": [
|
|
18
|
+
{"action": "clear_connections", "permission": PermissionLevel.SAFE_FIX, "devices": ["mikrotik", "cisco"]},
|
|
19
|
+
{"action": "reboot_device", "permission": PermissionLevel.FULL_AUTO, "devices": ["all"]},
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "interface_down",
|
|
24
|
+
"trigger": lambda stats: stats.get("interface_status") == "down",
|
|
25
|
+
"description": "Interface went down",
|
|
26
|
+
"fixes": [
|
|
27
|
+
{"action": "restart_interface", "permission": PermissionLevel.SAFE_FIX, "devices": ["all"]},
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "high_noise",
|
|
32
|
+
"trigger": lambda stats: stats.get("noise", -95) > -85,
|
|
33
|
+
"description": "Noise floor too high",
|
|
34
|
+
"fixes": [
|
|
35
|
+
{"action": "change_channel", "permission": PermissionLevel.MODERATE_FIX, "devices": ["ubiquiti", "mikrotik"]},
|
|
36
|
+
{"action": "scan_best_channel", "permission": PermissionLevel.MODERATE_FIX, "devices": ["ubiquiti"]},
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "low_ccq",
|
|
41
|
+
"trigger": lambda stats: stats.get("ccq", 100) < 60,
|
|
42
|
+
"description": "CCQ dropped below 60%",
|
|
43
|
+
"fixes": [
|
|
44
|
+
{"action": "reset_link", "permission": PermissionLevel.SAFE_FIX, "devices": ["ubiquiti"]},
|
|
45
|
+
{"action": "change_channel_width", "permission": PermissionLevel.MODERATE_FIX, "devices": ["ubiquiti"]},
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "bandwidth_overload",
|
|
50
|
+
"trigger": lambda stats: stats.get("tx_bytes", 0) > 900_000_000, # ~900 Mbps
|
|
51
|
+
"description": "Bandwidth near capacity",
|
|
52
|
+
"fixes": [
|
|
53
|
+
{"action": "enable_queue", "permission": PermissionLevel.MODERATE_FIX, "devices": ["mikrotik"]},
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "arp_flood",
|
|
58
|
+
"trigger": lambda stats: stats.get("arp_entries", 0) > 500,
|
|
59
|
+
"description": "ARP table flooding",
|
|
60
|
+
"fixes": [
|
|
61
|
+
{"action": "clear_arp", "permission": PermissionLevel.SAFE_FIX, "devices": ["mikrotik"]},
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
]
|
core/__init__.py
ADDED
core/base.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Dict, Any, Optional
|
|
3
|
+
from ..models.device import Device
|
|
4
|
+
from ..models.stats import DeviceStats
|
|
5
|
+
from ..models.station import Station
|
|
6
|
+
|
|
7
|
+
class BaseDevice(ABC):
|
|
8
|
+
"""Abstract base class for all ISP device drivers."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, device: Device):
|
|
11
|
+
self.device = device
|
|
12
|
+
self.is_connected = False
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def connect(self) -> bool:
|
|
16
|
+
"""Establish a connection to the device."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def disconnect(self):
|
|
21
|
+
"""Close the connection to the device."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def get_stats(self) -> DeviceStats:
|
|
26
|
+
"""Fetch device performance statistics."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get_clients(self) -> List[Station]:
|
|
31
|
+
"""Fetch connected stations/clients."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def get_signal(self) -> Dict[str, Any]:
|
|
36
|
+
"""Fetch signal strength, noise, and CCQ for wireless devices."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def apply_config(self, config: Dict[str, Any]) -> bool:
|
|
41
|
+
"""Apply a configuration to the device."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def reboot(self) -> bool:
|
|
46
|
+
"""Reboot the device."""
|
|
47
|
+
pass
|
core/exceptions.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class ISPToolsError(Exception):
|
|
2
|
+
"""Base exception for all isptools errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class ConnectionError(ISPToolsError):
|
|
6
|
+
"""Raised when connection to a device fails."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(ISPToolsError):
|
|
10
|
+
"""Raised when authentication fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class CommandError(ISPToolsError):
|
|
14
|
+
"""Raised when a command fails to execute on the device."""
|
|
15
|
+
pass
|
drivers/__init__.py
ADDED
drivers/cisco.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Dict, Any
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from netmiko import ConnectHandler
|
|
6
|
+
from ..core.base import BaseDevice
|
|
7
|
+
from ..core.exceptions import ConnectionError, AuthenticationError, CommandError
|
|
8
|
+
from ..models.stats import DeviceStats
|
|
9
|
+
from ..models.station import Station
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class CiscoDriver(BaseDevice):
|
|
14
|
+
"""Driver for Cisco devices using Netmiko (SSH)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, device):
|
|
17
|
+
super().__init__(device)
|
|
18
|
+
self.port = device.port or 22
|
|
19
|
+
self.connection = None
|
|
20
|
+
|
|
21
|
+
def connect(self) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
device_params = {
|
|
24
|
+
'device_type': 'cisco_ios',
|
|
25
|
+
'host': self.device.host,
|
|
26
|
+
'username': self.device.username,
|
|
27
|
+
'password': self.device.password,
|
|
28
|
+
'port': self.port,
|
|
29
|
+
}
|
|
30
|
+
self.connection = ConnectHandler(**device_params)
|
|
31
|
+
self.is_connected = True
|
|
32
|
+
logger.info(f"Connected to Cisco device at {self.device.host}")
|
|
33
|
+
return True
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Failed to connect to Cisco at {self.device.host}: {e}")
|
|
36
|
+
raise ConnectionError(f"Cisco connection failed: {e}")
|
|
37
|
+
|
|
38
|
+
def disconnect(self):
|
|
39
|
+
if self.connection:
|
|
40
|
+
self.connection.disconnect()
|
|
41
|
+
self.is_connected = False
|
|
42
|
+
logger.info(f"Disconnected from Cisco device at {self.device.host}")
|
|
43
|
+
|
|
44
|
+
def get_stats(self) -> DeviceStats:
|
|
45
|
+
try:
|
|
46
|
+
# Get CPU load
|
|
47
|
+
cpu_output = self.connection.send_command("show processes cpu sorted | exclude 0.00%")
|
|
48
|
+
cpu_match = re.search(r"five seconds: (\d+)%", cpu_output)
|
|
49
|
+
cpu_load = float(cpu_match.group(1)) if cpu_match else 0.0
|
|
50
|
+
|
|
51
|
+
# Get Memory
|
|
52
|
+
mem_output = self.connection.send_command("show memory statistics")
|
|
53
|
+
mem_match = re.search(r"Processor.* (\d+) total,.* (\d+) used", mem_output)
|
|
54
|
+
if mem_match:
|
|
55
|
+
total_mem = int(mem_match.group(1))
|
|
56
|
+
used_mem = int(mem_match.group(2))
|
|
57
|
+
mem_used_pct = (used_mem / total_mem) * 100
|
|
58
|
+
else:
|
|
59
|
+
mem_used_pct = 0.0
|
|
60
|
+
|
|
61
|
+
# Get Interfaces/Bytes
|
|
62
|
+
int_output = self.connection.send_command("show interfaces")
|
|
63
|
+
tx_bytes = sum(int(b) for b in re.findall(r"(\d+) bytes output", int_output))
|
|
64
|
+
rx_bytes = sum(int(b) for b in re.findall(r"(\d+) bytes input", int_output))
|
|
65
|
+
|
|
66
|
+
# Get Uptime
|
|
67
|
+
ver_output = self.connection.send_command("show version")
|
|
68
|
+
uptime_match = re.search(r"uptime is (.*)", ver_output)
|
|
69
|
+
uptime = uptime_match.group(1) if uptime_match else ""
|
|
70
|
+
|
|
71
|
+
return DeviceStats(
|
|
72
|
+
timestamp=datetime.now(),
|
|
73
|
+
cpu_load=cpu_load,
|
|
74
|
+
memory_used=mem_used_pct,
|
|
75
|
+
tx_bytes=tx_bytes,
|
|
76
|
+
rx_bytes=rx_bytes,
|
|
77
|
+
uptime=uptime
|
|
78
|
+
)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise CommandError(f"Failed to get stats from Cisco: {e}")
|
|
81
|
+
|
|
82
|
+
def get_clients(self) -> List[Station]:
|
|
83
|
+
try:
|
|
84
|
+
stations = []
|
|
85
|
+
# Try DHCP bindings first
|
|
86
|
+
dhcp_output = self.connection.send_command("show ip dhcp binding")
|
|
87
|
+
# Format: 192.168.1.10 0100.50b6.075c.e1 Feb 16 2026 12:00 PM Automatic
|
|
88
|
+
dhcp_matches = re.findall(r"(\d+\.\d+\.\d+\.\d+)\s+([0-9a-f\.]+)", dhcp_output)
|
|
89
|
+
|
|
90
|
+
for ip, mac in dhcp_matches:
|
|
91
|
+
stations.append(Station(mac=mac, ip=ip))
|
|
92
|
+
|
|
93
|
+
# If no DHCP, try ARP
|
|
94
|
+
if not stations:
|
|
95
|
+
arp_output = self.connection.send_command("show arp")
|
|
96
|
+
# Format: Internet 192.168.1.1 - 0000.0c07.ac01 ARPA FastEthernet0/0
|
|
97
|
+
arp_matches = re.findall(r"Internet\s+(\d+\.\d+\.\d+\.\d+)\s+.*?\s+([0-9a-f\.]+)", arp_output)
|
|
98
|
+
for ip, mac in arp_matches:
|
|
99
|
+
stations.append(Station(mac=mac, ip=ip))
|
|
100
|
+
|
|
101
|
+
return stations
|
|
102
|
+
except Exception as e:
|
|
103
|
+
raise CommandError(f"Failed to get clients from Cisco: {e}")
|
|
104
|
+
|
|
105
|
+
def get_signal(self) -> Dict[str, Any]:
|
|
106
|
+
# Cisco is not wireless by default
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def apply_config(self, config: Dict[str, Any]) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
# Expecting config as a list of commands under 'commands' key
|
|
112
|
+
commands = config.get('commands', [])
|
|
113
|
+
if commands:
|
|
114
|
+
self.connection.send_config_set(commands)
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
except Exception as e:
|
|
118
|
+
raise CommandError(f"Failed to apply config to Cisco: {e}")
|
|
119
|
+
|
|
120
|
+
def reboot(self) -> bool:
|
|
121
|
+
try:
|
|
122
|
+
# reload command usually requires confirmation
|
|
123
|
+
self.connection.send_command("reload", expect_string=r"confirm")
|
|
124
|
+
self.connection.send_command("\n")
|
|
125
|
+
return True
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.warning(f"Reboot command sent to Cisco, connection may drop: {e}")
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
# --- AutoFix Actions ---
|
|
131
|
+
|
|
132
|
+
def restart_interface(self, name: str) -> bool:
|
|
133
|
+
"""Shutdown and no shutdown an interface."""
|
|
134
|
+
try:
|
|
135
|
+
commands = [
|
|
136
|
+
f"interface {name}",
|
|
137
|
+
"shutdown",
|
|
138
|
+
"no shutdown"
|
|
139
|
+
]
|
|
140
|
+
self.connection.send_config_set(commands)
|
|
141
|
+
return True
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"Failed to restart interface {name} on Cisco: {e}")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
def get_neighbors(self) -> List[Dict[str, Any]]:
|
|
147
|
+
"""Get neighbors via LLDP."""
|
|
148
|
+
try:
|
|
149
|
+
output = self.connection.send_command("show lldp neighbors", use_textfsm=True)
|
|
150
|
+
# If textfsm is not available or fails, output is just a string
|
|
151
|
+
if isinstance(output, str):
|
|
152
|
+
# Simple regex parsing if textfsm fails
|
|
153
|
+
neighbors = []
|
|
154
|
+
matches = re.findall(r"(\S+)\s+(\S+)\s+\d+\s+.*?\s+(\S+)", output)
|
|
155
|
+
for neighbor_id, local_int, remote_int in matches:
|
|
156
|
+
neighbors.append({
|
|
157
|
+
"neighbor_id": neighbor_id,
|
|
158
|
+
"local_interface": local_int,
|
|
159
|
+
"neighbor_interface": remote_int
|
|
160
|
+
})
|
|
161
|
+
return neighbors
|
|
162
|
+
return output
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.error(f"Failed to get neighbors from Cisco: {e}")
|
|
165
|
+
return []
|
drivers/mikrotik.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import routeros_api
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from ..core.base import BaseDevice
|
|
7
|
+
from ..core.exceptions import ConnectionError, AuthenticationError, CommandError
|
|
8
|
+
from ..models.stats import DeviceStats
|
|
9
|
+
from ..models.station import Station
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class MikroTikDriver(BaseDevice):
|
|
14
|
+
"""Driver for MikroTik devices using RouterOS API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, device):
|
|
17
|
+
super().__init__(device)
|
|
18
|
+
self.port = device.port or 8728
|
|
19
|
+
self.connection = None
|
|
20
|
+
self.api = None
|
|
21
|
+
|
|
22
|
+
def connect(self) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
self.connection = routeros_api.RouterOsApiConnection(
|
|
25
|
+
self.device.host,
|
|
26
|
+
username=self.device.username,
|
|
27
|
+
password=self.device.password,
|
|
28
|
+
port=self.port
|
|
29
|
+
)
|
|
30
|
+
self.api = self.connection.get_api()
|
|
31
|
+
self.is_connected = True
|
|
32
|
+
logger.info(f"Connected to MikroTik device at {self.device.host}")
|
|
33
|
+
return True
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Failed to connect to MikroTik at {self.device.host}: {e}")
|
|
36
|
+
raise ConnectionError(f"MikroTik connection failed: {e}")
|
|
37
|
+
|
|
38
|
+
def disconnect(self):
|
|
39
|
+
if self.connection:
|
|
40
|
+
self.connection.disconnect()
|
|
41
|
+
self.is_connected = False
|
|
42
|
+
logger.info(f"Disconnected from MikroTik device at {self.device.host}")
|
|
43
|
+
|
|
44
|
+
def get_stats(self) -> DeviceStats:
|
|
45
|
+
try:
|
|
46
|
+
resource = self.api.get_resource('/system/resource').get()[0]
|
|
47
|
+
interfaces = self.api.get_resource('/interface').get()
|
|
48
|
+
|
|
49
|
+
tx_bytes = sum(int(i.get('tx-byte', 0)) for i in interfaces)
|
|
50
|
+
rx_bytes = sum(int(i.get('rx-byte', 0)) for i in interfaces)
|
|
51
|
+
|
|
52
|
+
return DeviceStats(
|
|
53
|
+
timestamp=datetime.now(),
|
|
54
|
+
cpu_load=float(resource.get('cpu-load', 0)),
|
|
55
|
+
memory_used=100 - (float(resource.get('free-memory', 0)) / float(resource.get('total-memory', 1)) * 100),
|
|
56
|
+
tx_bytes=tx_bytes,
|
|
57
|
+
rx_bytes=rx_bytes,
|
|
58
|
+
uptime=resource.get('uptime', ''),
|
|
59
|
+
temperature=float(resource.get('board-temperature1', 0)) or float(resource.get('cpu-temperature', 0))
|
|
60
|
+
)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
raise CommandError(f"Failed to get stats from MikroTik: {e}")
|
|
63
|
+
|
|
64
|
+
def get_clients(self) -> List[Station]:
|
|
65
|
+
try:
|
|
66
|
+
stations = []
|
|
67
|
+
reg_table = self.api.get_resource('/interface/wireless/registration-table').get()
|
|
68
|
+
leases = self.api.get_resource('/ip/dhcp-server/lease').get()
|
|
69
|
+
|
|
70
|
+
lease_map = {l.get('mac-address'): l.get('address', '') for l in leases}
|
|
71
|
+
|
|
72
|
+
for s in reg_table:
|
|
73
|
+
mac = s.get('mac-address')
|
|
74
|
+
stations.append(Station(
|
|
75
|
+
mac=mac,
|
|
76
|
+
ip=lease_map.get(mac, ''),
|
|
77
|
+
signal=int(s.get('signal-strength', '0').split('dBm')[0]),
|
|
78
|
+
noise=int(s.get('signal-to-noise', '0')), # Note: ROS might not provide direct noise in some versions
|
|
79
|
+
ccq=int(s.get('tx-ccq', '0')),
|
|
80
|
+
tx_rate=float(s.get('tx-rate', '0').split('Mbps')[0] or 0),
|
|
81
|
+
rx_rate=float(s.get('rx-rate', '0').split('Mbps')[0] or 0),
|
|
82
|
+
uptime=s.get('uptime', '')
|
|
83
|
+
))
|
|
84
|
+
return stations
|
|
85
|
+
except Exception as e:
|
|
86
|
+
raise CommandError(f"Failed to get clients from MikroTik: {e}")
|
|
87
|
+
|
|
88
|
+
def get_signal(self) -> Dict[str, Any]:
|
|
89
|
+
try:
|
|
90
|
+
reg_table = self.api.get_resource('/interface/wireless/registration-table').get()
|
|
91
|
+
if not reg_table:
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
# Returning stats for the first client as a summary, or could be averaged
|
|
95
|
+
s = reg_table[0]
|
|
96
|
+
return {
|
|
97
|
+
'signal': s.get('signal-strength'),
|
|
98
|
+
'noise': s.get('signal-to-noise'),
|
|
99
|
+
'ccq': s.get('tx-ccq')
|
|
100
|
+
}
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise CommandError(f"Failed to get signal from MikroTik: {e}")
|
|
103
|
+
|
|
104
|
+
def apply_config(self, config: Dict[str, Any]) -> bool:
|
|
105
|
+
try:
|
|
106
|
+
# Example: config = {'/ip/address': {'address': '1.1.1.1/24', 'interface': 'ether1'}}
|
|
107
|
+
for path, params in config.items():
|
|
108
|
+
resource = self.api.get_resource(path)
|
|
109
|
+
resource.set(**params)
|
|
110
|
+
return True
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise CommandError(f"Failed to apply config to MikroTik: {e}")
|
|
113
|
+
|
|
114
|
+
def reboot(self) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
self.api.get_resource('/system/reboot').call('reboot')
|
|
117
|
+
return True
|
|
118
|
+
except Exception as e:
|
|
119
|
+
# Rebooting might break the connection and throw an error, which is expected
|
|
120
|
+
logger.warning(f"Reboot command sent to MikroTik, connection may drop: {e}")
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# --- AutoFix Actions ---
|
|
124
|
+
|
|
125
|
+
def clear_connections(self) -> bool:
|
|
126
|
+
"""Remove all firewall connections."""
|
|
127
|
+
try:
|
|
128
|
+
resource = self.api.get_resource('/ip/firewall/connection')
|
|
129
|
+
conns = resource.get()
|
|
130
|
+
for conn in conns:
|
|
131
|
+
resource.remove(id=conn['.id'])
|
|
132
|
+
return True
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to clear connections on MikroTik: {e}")
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def clear_arp(self) -> bool:
|
|
138
|
+
"""Flush ARP table."""
|
|
139
|
+
try:
|
|
140
|
+
# ROS API doesn't have a direct 'flush' command exposed easily, we remove all dynamic entries
|
|
141
|
+
resource = self.api.get_resource('/ip/arp')
|
|
142
|
+
arps = resource.get()
|
|
143
|
+
for arp in arps:
|
|
144
|
+
if arp.get('dynamic') == 'true':
|
|
145
|
+
resource.remove(id=arp['.id'])
|
|
146
|
+
return True
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error(f"Failed to clear ARP on MikroTik: {e}")
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def restart_interface(self, name: str = 'ether1') -> bool:
|
|
152
|
+
"""Disable then enable an interface."""
|
|
153
|
+
try:
|
|
154
|
+
resource = self.api.get_resource('/interface')
|
|
155
|
+
resource.set(id=name, disabled='yes')
|
|
156
|
+
time.sleep(1)
|
|
157
|
+
resource.set(id=name, disabled='no')
|
|
158
|
+
return True
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"Failed to restart interface {name} on MikroTik: {e}")
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def enable_queue(self, limit: str = '10M/10M') -> bool:
|
|
164
|
+
"""Add a simple queue for bandwidth control."""
|
|
165
|
+
try:
|
|
166
|
+
resource = self.api.get_resource('/queue/simple')
|
|
167
|
+
resource.add(name='autofix-limit', target='0.0.0.0/0', max_limit=limit)
|
|
168
|
+
return True
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"Failed to enable queue on MikroTik: {e}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
def get_neighbors(self) -> List[Dict[str, Any]]:
|
|
174
|
+
"""Get neighbors via /ip/neighbor."""
|
|
175
|
+
try:
|
|
176
|
+
return self.api.get_resource('/ip/neighbor').get()
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f"Failed to get neighbors from MikroTik: {e}")
|
|
179
|
+
return []
|