woninet 2.0.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.
- woninet/__init__.py +2 -0
- woninet/__main__.py +4 -0
- woninet/core/__init__.py +2 -0
- woninet/core/alerts.py +251 -0
- woninet/core/collectors.py +303 -0
- woninet/core/engine.py +353 -0
- woninet/core/graph.py +63 -0
- woninet/core/manual_ip_enumerator.py +33 -0
- woninet/core/models.py +124 -0
- woninet/core/storage.py +163 -0
- woninet/core/subnet_enumerator.py +32 -0
- woninet/database/__init__.py +2 -0
- woninet/database/base.py +9 -0
- woninet/database/engine.py +44 -0
- woninet/database/repositories/__init__.py +2 -0
- woninet/database/repositories/alert_event_repository.py +69 -0
- woninet/database/repositories/alert_state_repository.py +81 -0
- woninet/database/repositories/device_repository.py +105 -0
- woninet/database/repositories/metric_repository.py +75 -0
- woninet/database/tables.py +89 -0
- woninet/logging.yaml +49 -0
- woninet/main.py +225 -0
- woninet/server/__init__.py +2 -0
- woninet/server/app.py +101 -0
- woninet/server/dependencies.py +25 -0
- woninet/server/routes/__init__.py +2 -0
- woninet/server/routes/api/__init__.py +0 -0
- woninet/server/routes/api/devices.py +43 -0
- woninet/server/routes/api/stats.py +34 -0
- woninet/server/routes/page/__init__.py +0 -0
- woninet/server/routes/page/devices.py +28 -0
- woninet/server/static/css/404.css +164 -0
- woninet/server/static/css/style.css +368 -0
- woninet/server/static/js/app.js +242 -0
- woninet/server/static/js/device_info.js +95 -0
- woninet/server/static/swagger-ui/favicon-32x32.png +0 -0
- woninet/server/static/swagger-ui/swagger-ui-bundle.js +2 -0
- woninet/server/static/swagger-ui/swagger-ui.css +3 -0
- woninet/server/templates/404.html +76 -0
- woninet/server/templates/dashboard.html +118 -0
- woninet/server/templates/device_info.html +93 -0
- woninet/utilities/__init__.py +2 -0
- woninet/utilities/arguments.py +94 -0
- woninet/utilities/cli_parser.py +90 -0
- woninet/utilities/detect_ip_range.py +21 -0
- woninet/utilities/ip_validator.py +39 -0
- woninet/utilities/logger.py +97 -0
- woninet-2.0.1.dist-info/METADATA +243 -0
- woninet-2.0.1.dist-info/RECORD +53 -0
- woninet-2.0.1.dist-info/WHEEL +5 -0
- woninet-2.0.1.dist-info/entry_points.txt +2 -0
- woninet-2.0.1.dist-info/licenses/LICENSE +21 -0
- woninet-2.0.1.dist-info/top_level.txt +1 -0
woninet/__init__.py
ADDED
woninet/__main__.py
ADDED
woninet/core/__init__.py
ADDED
woninet/core/alerts.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from woninet.core.storage import StorageEngine
|
|
4
|
+
from woninet.core.models import MetricRecord
|
|
5
|
+
from woninet.database.tables import AlertEventTable
|
|
6
|
+
|
|
7
|
+
core_logger = logging.getLogger("core")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AlertRule:
|
|
11
|
+
"""
|
|
12
|
+
Define a monitoring rule that triggers an alert
|
|
13
|
+
when a metric exceeds a defined threshold.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
metric (str): Metric name to be evaluated.
|
|
17
|
+
threshold (float): The Limit the metric must exceed to trigger the alert
|
|
18
|
+
consecutive_checks (int): Number of consecutive evaluations required before
|
|
19
|
+
a state change.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, metric: str, threshold: float, consecutive_checks: int) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initialize the alert rule.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
metric: Name of the metric the rule evaluates.
|
|
28
|
+
threshold: The Limit the metric must exceed to trigger the alert.
|
|
29
|
+
consecutive_checks: Number of consecutive evaluations required before
|
|
30
|
+
a state change.
|
|
31
|
+
"""
|
|
32
|
+
self.metric: str = metric
|
|
33
|
+
self.threshold: float = threshold
|
|
34
|
+
self.consecutive_checks: int = consecutive_checks
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AlertEngine:
|
|
38
|
+
"""
|
|
39
|
+
Evaluate stored metrics against alert rules
|
|
40
|
+
and generates alerts when conditions are violated.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
storage (StorageEngine): Backend interface for alert state persistence.
|
|
44
|
+
rules (list[AlertRule]): Configuration describing metric threshold and evaluation logic.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, storage: StorageEngine, rules: list[AlertRule]) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Initialize the alert engine.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
storage (StorageEngine): Storage backend used to manage database sessions.
|
|
53
|
+
rules (list[AlertRule]): Alert rules evaluated against stored metrics.
|
|
54
|
+
"""
|
|
55
|
+
self.storage: StorageEngine = storage
|
|
56
|
+
self.rules: list[AlertRule] = rules
|
|
57
|
+
|
|
58
|
+
def is_metric_violated(self, metric: str, value: float) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Return True if the given metric exceeds the configured threshold.
|
|
61
|
+
"""
|
|
62
|
+
for rule in self.rules:
|
|
63
|
+
if metric == rule.metric:
|
|
64
|
+
return value > rule.threshold
|
|
65
|
+
# Fallback - No metric matched the configured rule.
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def evaluate(
|
|
69
|
+
self,
|
|
70
|
+
ip: str,
|
|
71
|
+
metrics_list: list[MetricRecord],
|
|
72
|
+
default_consecutive_checks: dict[str, int],
|
|
73
|
+
) -> None:
|
|
74
|
+
"""
|
|
75
|
+
The central dispatcher.
|
|
76
|
+
|
|
77
|
+
Search the metrics list for known metric's names (`latency`, and `packet_loss`)
|
|
78
|
+
and pass the found metric to the correct evaluation method.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
ip (str): IP address to evaluate.
|
|
82
|
+
metrics_name (list[MetricRecord]): List of recorded metrics (e.g. `latency`, `packet_loss`).
|
|
83
|
+
default_consecutive_checks (dict[str,int]): Dictionary of consecutive evaluations
|
|
84
|
+
required to confirm a state transition for each metric.
|
|
85
|
+
"""
|
|
86
|
+
for metric in metrics_list:
|
|
87
|
+
if metric is not None:
|
|
88
|
+
if metric.metric == "latency":
|
|
89
|
+
self._evaluate_latency(
|
|
90
|
+
ip=ip,
|
|
91
|
+
metric="latency",
|
|
92
|
+
value=metric.value,
|
|
93
|
+
default_consecutive_checks=default_consecutive_checks[
|
|
94
|
+
"latency"
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
elif metric.metric == "packet_loss":
|
|
98
|
+
self._evaluate_packet_loss(
|
|
99
|
+
ip=ip,
|
|
100
|
+
metric="packet_loss",
|
|
101
|
+
value=metric.value,
|
|
102
|
+
default_consecutive_checks=default_consecutive_checks[
|
|
103
|
+
"packet_loss"
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _evaluate_latency(
|
|
108
|
+
self, ip: str, metric: str, value: float, default_consecutive_checks: int
|
|
109
|
+
):
|
|
110
|
+
"""
|
|
111
|
+
Evaluate a latency metric against the related alert rule.
|
|
112
|
+
|
|
113
|
+
If the metric crosses its threshold for a consecutive number of checks,
|
|
114
|
+
trigger or resolve an alert accordingly. Persists state transitions
|
|
115
|
+
and logs any alert changes.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
ip (str): IP address to evaluate.
|
|
119
|
+
metric (str): Metric name (e.g. `latency`).
|
|
120
|
+
value (float): Value of the current metric.
|
|
121
|
+
default_consecutive_checks (int): Number of consecutive evaluations
|
|
122
|
+
required to confirm a state transition.
|
|
123
|
+
|
|
124
|
+
Side Effects:
|
|
125
|
+
- Persists alert state in the database via `StorageEngine`.
|
|
126
|
+
- Emits log events.
|
|
127
|
+
- Creates `AlertEventTable` records on trigger and recover.
|
|
128
|
+
"""
|
|
129
|
+
state = self.storage.get_or_create_alert_state(
|
|
130
|
+
ip=ip, metric=metric, consecutive_checks=default_consecutive_checks
|
|
131
|
+
)
|
|
132
|
+
violated = self.is_metric_violated(metric=metric, value=value)
|
|
133
|
+
if violated:
|
|
134
|
+
if state.state == "ok":
|
|
135
|
+
# Confirm remaining consecutive checks before a state transition
|
|
136
|
+
if state.consecutive_checks > 0:
|
|
137
|
+
state.consecutive_checks = state.consecutive_checks - 1
|
|
138
|
+
self.storage.update_alert_state(state)
|
|
139
|
+
return
|
|
140
|
+
# Update "ok" to "warning"
|
|
141
|
+
state.state = "warning"
|
|
142
|
+
state.triggered_at = datetime.now()
|
|
143
|
+
state.consecutive_checks = default_consecutive_checks
|
|
144
|
+
self.storage.update_alert_state(state)
|
|
145
|
+
|
|
146
|
+
event = AlertEventTable(
|
|
147
|
+
device_ip=ip, metric=metric, value=value, event_type="trigger"
|
|
148
|
+
)
|
|
149
|
+
self.storage.store_alert_event(event=event)
|
|
150
|
+
core_logger.warning(
|
|
151
|
+
f"ALERT TRIGGERED | {ip}, metric={metric}, value={value} ms"
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
# Reset consecutive checks to its default value
|
|
155
|
+
state.consecutive_checks = default_consecutive_checks
|
|
156
|
+
self.storage.update_alert_state(state)
|
|
157
|
+
else:
|
|
158
|
+
if state.state == "warning":
|
|
159
|
+
# Confirm remaining consecutive checks before a state transition
|
|
160
|
+
if state.consecutive_checks > 0:
|
|
161
|
+
state.consecutive_checks = state.consecutive_checks - 1
|
|
162
|
+
self.storage.update_alert_state(state)
|
|
163
|
+
return
|
|
164
|
+
# Update "warning" to "ok"
|
|
165
|
+
state.state = "ok"
|
|
166
|
+
state.triggered_at = datetime.now()
|
|
167
|
+
state.consecutive_checks = default_consecutive_checks
|
|
168
|
+
self.storage.update_alert_state(state)
|
|
169
|
+
|
|
170
|
+
event = AlertEventTable(
|
|
171
|
+
device_ip=ip, metric=metric, value=value, event_type="recover"
|
|
172
|
+
)
|
|
173
|
+
self.storage.store_alert_event(event=event)
|
|
174
|
+
core_logger.warning(
|
|
175
|
+
f"ALERT RECOVERED | {ip}, metric={metric}, value={value} ms"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
# Reset consecutive checks to its default value
|
|
179
|
+
state.consecutive_checks = default_consecutive_checks
|
|
180
|
+
self.storage.update_alert_state(state)
|
|
181
|
+
|
|
182
|
+
def _evaluate_packet_loss(
|
|
183
|
+
self, ip: str, metric: str, value: float, default_consecutive_checks: int
|
|
184
|
+
):
|
|
185
|
+
"""
|
|
186
|
+
Evaluate a packet loss metric against the related alert rule.
|
|
187
|
+
|
|
188
|
+
If the metric crosses its threshold for a consecutive number of checks,
|
|
189
|
+
trigger or resolve an alert accordingly. Persists state transitions
|
|
190
|
+
and logs any alert changes.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
ip (str): IP address to evaluate.
|
|
194
|
+
metric (str): Metric name (e.g. `packet_loss`).
|
|
195
|
+
value (float): Value of the current metric.
|
|
196
|
+
default_consecutive_checks (int): Number of consecutive evaluations
|
|
197
|
+
required to confirm a state transition.
|
|
198
|
+
|
|
199
|
+
**Side Effects**:
|
|
200
|
+
- Persists alert state in the database via `StorageEngine`.
|
|
201
|
+
- Emits log events.
|
|
202
|
+
- Creates `AlertEventTable` records on trigger and recover.
|
|
203
|
+
"""
|
|
204
|
+
state = self.storage.get_or_create_alert_state(
|
|
205
|
+
ip=ip, metric=metric, consecutive_checks=default_consecutive_checks
|
|
206
|
+
)
|
|
207
|
+
violated = self.is_metric_violated(metric=metric, value=value)
|
|
208
|
+
if violated:
|
|
209
|
+
if state.state == "ok":
|
|
210
|
+
# Confirm remaining consecutive checks before a state transition
|
|
211
|
+
if state.consecutive_checks > 0:
|
|
212
|
+
state.consecutive_checks = state.consecutive_checks - 1
|
|
213
|
+
self.storage.update_alert_state(state)
|
|
214
|
+
return
|
|
215
|
+
# Update "ok" to "warning"
|
|
216
|
+
state.state = "warning"
|
|
217
|
+
state.triggered_at = datetime.now()
|
|
218
|
+
self.storage.update_alert_state(state)
|
|
219
|
+
event = AlertEventTable(
|
|
220
|
+
device_ip=ip, metric=metric, value=value, event_type="trigger"
|
|
221
|
+
)
|
|
222
|
+
self.storage.store_alert_event(event=event)
|
|
223
|
+
core_logger.warning(
|
|
224
|
+
f"ALERT TRIGGERED | {ip}, metric={metric}, value={value} %"
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
# Reset consecutive checks to its default value
|
|
228
|
+
state.consecutive_checks = default_consecutive_checks
|
|
229
|
+
self.storage.update_alert_state(state)
|
|
230
|
+
else:
|
|
231
|
+
if state.state == "warning":
|
|
232
|
+
# Confirm remaining consecutive checks before a state transition
|
|
233
|
+
if state.consecutive_checks > 0:
|
|
234
|
+
state.consecutive_checks = state.consecutive_checks - 1
|
|
235
|
+
self.storage.update_alert_state(state)
|
|
236
|
+
return
|
|
237
|
+
# Update "warning" to "ok"
|
|
238
|
+
state.state = "ok"
|
|
239
|
+
state.triggered_at = datetime.now()
|
|
240
|
+
self.storage.update_alert_state(state)
|
|
241
|
+
event = AlertEventTable(
|
|
242
|
+
device_ip=ip, metric=metric, value=value, event_type="recover"
|
|
243
|
+
)
|
|
244
|
+
self.storage.store_alert_event(event=event)
|
|
245
|
+
core_logger.warning(
|
|
246
|
+
f"ALERT RECOVERED | {ip}, metric={metric}, value={value} %"
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
# Reset consecutive checks to its default value
|
|
250
|
+
state.consecutive_checks = default_consecutive_checks
|
|
251
|
+
self.storage.update_alert_state(state)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import logging
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Generator, Any
|
|
6
|
+
from icmplib import ping, SocketPermissionError, SocketAddressError
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from woninet.core.models import Device, MetricRecord, HostStatus, ScanResult
|
|
9
|
+
|
|
10
|
+
core_logger = logging.getLogger("core")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_arp_table() -> dict[str, str]:
|
|
14
|
+
"""
|
|
15
|
+
Return all valid MAC addresses from the ARP table.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
dict[str,str]: Dictionary contaning IP addressses and the related MAC addresses.
|
|
19
|
+
"""
|
|
20
|
+
table = {}
|
|
21
|
+
try:
|
|
22
|
+
os_name = platform.system()
|
|
23
|
+
command = ["arp", "-an"] if os_name != "Windows" else ["arp", "-a"]
|
|
24
|
+
# Suppress strerr to avoid clutter when ARP table is empty or limited
|
|
25
|
+
output = subprocess.check_output(command, stderr=subprocess.DEVNULL, text=True)
|
|
26
|
+
except Exception:
|
|
27
|
+
return table
|
|
28
|
+
|
|
29
|
+
regex = r"\((.*?)\)\s+at\s+([0-9a-fA-F:]+)"
|
|
30
|
+
if os_name == "Windows":
|
|
31
|
+
regex = r"([0-9.]+)\s+([0-9a-fA-F-]+)"
|
|
32
|
+
for line in output.splitlines():
|
|
33
|
+
match = re.search(regex, line)
|
|
34
|
+
if match:
|
|
35
|
+
table[match.group(1)] = match.group(2)
|
|
36
|
+
return table
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_arp_mac(ip: str) -> str | None:
|
|
40
|
+
"""
|
|
41
|
+
Return MAC address from ARP table for the given IP, or None if not present.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
os_name = platform.system()
|
|
45
|
+
command = ["arp", "-an"] if os_name != "Windows" else ["arp", "-a"]
|
|
46
|
+
# Suppress strerr to avoid clutter when ARP table is empty or limited
|
|
47
|
+
output = subprocess.check_output(command, stderr=subprocess.DEVNULL, text=True)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
core_logger.error(f"Failed to read ARP table: {e}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
regex = r"\((.*?)\)\s+at\s+([0-9a-fA-F:]+)"
|
|
53
|
+
if os_name == "Windows":
|
|
54
|
+
regex = rf"({re.escape(ip)})\s+([0-9a-fA-F-]+)"
|
|
55
|
+
match = re.search(regex, output)
|
|
56
|
+
if match:
|
|
57
|
+
return match.group(2)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_host(
|
|
62
|
+
target_ip: str,
|
|
63
|
+
source_ip: str,
|
|
64
|
+
timeout: float = 1.0,
|
|
65
|
+
stop_event=None,
|
|
66
|
+
arp_noise_limit: float = 300.0,
|
|
67
|
+
arp_table: dict[str, str] = None,
|
|
68
|
+
) -> HostStatus | None:
|
|
69
|
+
"""
|
|
70
|
+
Combined ARP + ICMP detection for accurate device status.
|
|
71
|
+
|
|
72
|
+
Detection_Rules:
|
|
73
|
+
- ARP_FAIL and ICMP_FAIL → Host doesn't exist
|
|
74
|
+
- ARP_OK and ICMP_FAIL → Host exists but is unreachable or blocks ICMP
|
|
75
|
+
- ARP_OK and ICMP_OK < arp_noise_limit → Host is reachable
|
|
76
|
+
- ARP_OK and ICMP_OK >= arp_noise_limit → Treat as ARP-timeout noise (unreachable)
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
target_ip: IP address of the host to scan.
|
|
80
|
+
source_ip: Source IP address used to send packets.
|
|
81
|
+
timeout: Timeout for ICMP scan.
|
|
82
|
+
stop_event: Event used to control the monitoring life cycle.
|
|
83
|
+
arp_noise_limit: Threshold above which ARP fluctuations are treated as noise.
|
|
84
|
+
arp_table: Dictionary of ARP table contaning IP addressses and the related
|
|
85
|
+
MAC addresses.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
HostStatus|None: A `HostStatus` instance if the stop event is not triggered; otherwise, None.
|
|
89
|
+
"""
|
|
90
|
+
status = HostStatus(ip=target_ip)
|
|
91
|
+
|
|
92
|
+
# Skip source ip to prevent confusion.
|
|
93
|
+
if target_ip == source_ip:
|
|
94
|
+
status.exists = False
|
|
95
|
+
status.reachable = False
|
|
96
|
+
status.mac = None
|
|
97
|
+
status.latency = 0.0
|
|
98
|
+
status.packet_loss = 100.0
|
|
99
|
+
return status
|
|
100
|
+
|
|
101
|
+
if stop_event and stop_event.is_set():
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# ARP check
|
|
105
|
+
mac = arp_table.get(target_ip)
|
|
106
|
+
if mac:
|
|
107
|
+
status.exists = True
|
|
108
|
+
status.mac = mac
|
|
109
|
+
|
|
110
|
+
# ICMP check
|
|
111
|
+
try:
|
|
112
|
+
response = ping(
|
|
113
|
+
source=source_ip,
|
|
114
|
+
address=target_ip.strip(),
|
|
115
|
+
timeout=timeout,
|
|
116
|
+
count=2,
|
|
117
|
+
privileged=True,
|
|
118
|
+
interval=1,
|
|
119
|
+
)
|
|
120
|
+
if stop_event and stop_event.is_set():
|
|
121
|
+
return None
|
|
122
|
+
except (PermissionError, SocketPermissionError):
|
|
123
|
+
raise
|
|
124
|
+
except SocketAddressError:
|
|
125
|
+
raise
|
|
126
|
+
except Exception as e:
|
|
127
|
+
core_logger.error(
|
|
128
|
+
f"Error during ICMP ping at detect_host function, to {target_ip}: {e}"
|
|
129
|
+
)
|
|
130
|
+
response = 0
|
|
131
|
+
|
|
132
|
+
status.latency = response.avg_rtt
|
|
133
|
+
status.packet_loss = response.packet_loss * 100
|
|
134
|
+
latency: float = status.latency
|
|
135
|
+
if latency == 0:
|
|
136
|
+
# Latency 0 means device is offline.
|
|
137
|
+
# Packet loss shoulnd't be determined for offline devices.
|
|
138
|
+
status.packet_loss = 0.0
|
|
139
|
+
|
|
140
|
+
# Classification logic
|
|
141
|
+
if not mac and latency == 0:
|
|
142
|
+
# Host is not in ARP table and doesn't respond to ICMP
|
|
143
|
+
status.exists = False
|
|
144
|
+
status.reachable = False
|
|
145
|
+
return status
|
|
146
|
+
|
|
147
|
+
if mac and latency == 0:
|
|
148
|
+
# Device exists but doesn't respond to ICMP
|
|
149
|
+
status.exists = True
|
|
150
|
+
status.reachable = False
|
|
151
|
+
return status
|
|
152
|
+
|
|
153
|
+
if latency != 0:
|
|
154
|
+
if arp_noise_limit == 0 or latency < arp_noise_limit:
|
|
155
|
+
# Acceptable latency (reachable)
|
|
156
|
+
status.exists = bool(mac)
|
|
157
|
+
status.reachable = True
|
|
158
|
+
else:
|
|
159
|
+
# High-latency ICMP, often ARP resolution noise (unreachable)
|
|
160
|
+
status.exists = bool(mac)
|
|
161
|
+
status.reachable = False
|
|
162
|
+
status.latency = 0
|
|
163
|
+
|
|
164
|
+
# Refresh MAC if host responded to ICMP but ARP cache was stale
|
|
165
|
+
if status.reachable and mac is None:
|
|
166
|
+
new_mac = get_arp_mac(ip=target_ip)
|
|
167
|
+
if new_mac:
|
|
168
|
+
status.exists = True
|
|
169
|
+
status.mac = new_mac
|
|
170
|
+
|
|
171
|
+
return status
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class BaseCollector:
|
|
175
|
+
"""
|
|
176
|
+
Abstract base class for all monitoring collectors.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def collect(self, *args, **kwargs) -> Any:
|
|
180
|
+
raise NotImplementedError
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class PingCollector(BaseCollector):
|
|
184
|
+
"""
|
|
185
|
+
Collector that measures ICMP latency to devices,
|
|
186
|
+
enhanced with ARP-based existence detection.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def collect(
|
|
190
|
+
self,
|
|
191
|
+
candidate_devices: dict[str, Device],
|
|
192
|
+
local_ip: str,
|
|
193
|
+
device_records: list[Device],
|
|
194
|
+
stop_event=None,
|
|
195
|
+
arp_noise_limit: float = 300.0,
|
|
196
|
+
max_thread_workers: int = 4,
|
|
197
|
+
) -> Generator[ScanResult, Any, None]:
|
|
198
|
+
"""
|
|
199
|
+
For each device:
|
|
200
|
+
- Use ARP + ICMP to determine existence and reachability.
|
|
201
|
+
- Update Device fields.
|
|
202
|
+
- Record latency metric only when reachable and latency is sane.
|
|
203
|
+
"""
|
|
204
|
+
if stop_event and stop_event.is_set():
|
|
205
|
+
yield ()
|
|
206
|
+
|
|
207
|
+
arp_table = read_arp_table()
|
|
208
|
+
|
|
209
|
+
def worker(
|
|
210
|
+
ip: str, dev: Device
|
|
211
|
+
) -> tuple[Device, list[MetricRecord]] | tuple[None, list[MetricRecord]]:
|
|
212
|
+
try:
|
|
213
|
+
status = detect_host(
|
|
214
|
+
target_ip=ip,
|
|
215
|
+
source_ip=local_ip,
|
|
216
|
+
timeout=1.0,
|
|
217
|
+
stop_event=stop_event,
|
|
218
|
+
arp_noise_limit=arp_noise_limit,
|
|
219
|
+
arp_table=arp_table,
|
|
220
|
+
)
|
|
221
|
+
except (PermissionError, SocketPermissionError):
|
|
222
|
+
raise
|
|
223
|
+
except SocketAddressError:
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
# Update device state from status
|
|
227
|
+
dev.exists = status.exists
|
|
228
|
+
dev.reachable = status.reachable
|
|
229
|
+
if status.mac is not None:
|
|
230
|
+
dev.mac = status.mac
|
|
231
|
+
dev.latency = status.latency
|
|
232
|
+
dev.packet_loss = status.packet_loss
|
|
233
|
+
|
|
234
|
+
if dev.reachable:
|
|
235
|
+
# Only consider reachable hosts as recently seen
|
|
236
|
+
dev.update_seen()
|
|
237
|
+
|
|
238
|
+
# Check if the found device is already stored in the database.
|
|
239
|
+
is_known = any(
|
|
240
|
+
device_record.ip == dev.ip for device_record in device_records
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if dev.reachable:
|
|
244
|
+
core_logger.debug(
|
|
245
|
+
f"Device {ip}: \tUP,\t MAC={dev.mac}, latency={dev.latency:.2f} ms"
|
|
246
|
+
)
|
|
247
|
+
elif is_known:
|
|
248
|
+
core_logger.trace(
|
|
249
|
+
f"Device {ip}: \tDOWN,\t MAC={dev.mac}, latency=OFFLINE"
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
core_logger.trace(
|
|
253
|
+
f"Device {ip}: \tABSENT,\t MAC={dev.mac}, latency=OFFLINE"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
recorded_metrics = [
|
|
257
|
+
MetricRecord(ip, "latency", dev.latency),
|
|
258
|
+
MetricRecord(ip, "packet_loss", dev.packet_loss),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
if dev.reachable or is_known:
|
|
262
|
+
return (dev, recorded_metrics)
|
|
263
|
+
else:
|
|
264
|
+
return (None, recorded_metrics)
|
|
265
|
+
|
|
266
|
+
with ThreadPoolExecutor(max_workers=max_thread_workers) as executor:
|
|
267
|
+
future_to_ip = {
|
|
268
|
+
executor.submit(worker, ip, dev): ip
|
|
269
|
+
for ip, dev in candidate_devices.items()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for future in as_completed(future_to_ip):
|
|
273
|
+
if stop_event and stop_event.is_set():
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
future_device, future_metric = future.result()
|
|
278
|
+
except (PermissionError, SocketPermissionError):
|
|
279
|
+
raise
|
|
280
|
+
except SocketAddressError:
|
|
281
|
+
raise
|
|
282
|
+
except Exception as e:
|
|
283
|
+
ip = future_to_ip[future]
|
|
284
|
+
core_logger.error(f"Error in PingCollector for {ip}: {e}")
|
|
285
|
+
continue
|
|
286
|
+
else:
|
|
287
|
+
latency_metric, packet_loss_metric = future_metric
|
|
288
|
+
|
|
289
|
+
latency = (
|
|
290
|
+
latency_metric.value if latency_metric.value != 0 else None
|
|
291
|
+
)
|
|
292
|
+
packet_loss_metric = (
|
|
293
|
+
packet_loss_metric.value
|
|
294
|
+
if (future_device is not None and latency is not None)
|
|
295
|
+
else None
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
finally:
|
|
299
|
+
yield ScanResult(
|
|
300
|
+
device=future_device,
|
|
301
|
+
latency=latency_metric,
|
|
302
|
+
packet_loss=packet_loss_metric,
|
|
303
|
+
)
|