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.
Files changed (53) hide show
  1. woninet/__init__.py +2 -0
  2. woninet/__main__.py +4 -0
  3. woninet/core/__init__.py +2 -0
  4. woninet/core/alerts.py +251 -0
  5. woninet/core/collectors.py +303 -0
  6. woninet/core/engine.py +353 -0
  7. woninet/core/graph.py +63 -0
  8. woninet/core/manual_ip_enumerator.py +33 -0
  9. woninet/core/models.py +124 -0
  10. woninet/core/storage.py +163 -0
  11. woninet/core/subnet_enumerator.py +32 -0
  12. woninet/database/__init__.py +2 -0
  13. woninet/database/base.py +9 -0
  14. woninet/database/engine.py +44 -0
  15. woninet/database/repositories/__init__.py +2 -0
  16. woninet/database/repositories/alert_event_repository.py +69 -0
  17. woninet/database/repositories/alert_state_repository.py +81 -0
  18. woninet/database/repositories/device_repository.py +105 -0
  19. woninet/database/repositories/metric_repository.py +75 -0
  20. woninet/database/tables.py +89 -0
  21. woninet/logging.yaml +49 -0
  22. woninet/main.py +225 -0
  23. woninet/server/__init__.py +2 -0
  24. woninet/server/app.py +101 -0
  25. woninet/server/dependencies.py +25 -0
  26. woninet/server/routes/__init__.py +2 -0
  27. woninet/server/routes/api/__init__.py +0 -0
  28. woninet/server/routes/api/devices.py +43 -0
  29. woninet/server/routes/api/stats.py +34 -0
  30. woninet/server/routes/page/__init__.py +0 -0
  31. woninet/server/routes/page/devices.py +28 -0
  32. woninet/server/static/css/404.css +164 -0
  33. woninet/server/static/css/style.css +368 -0
  34. woninet/server/static/js/app.js +242 -0
  35. woninet/server/static/js/device_info.js +95 -0
  36. woninet/server/static/swagger-ui/favicon-32x32.png +0 -0
  37. woninet/server/static/swagger-ui/swagger-ui-bundle.js +2 -0
  38. woninet/server/static/swagger-ui/swagger-ui.css +3 -0
  39. woninet/server/templates/404.html +76 -0
  40. woninet/server/templates/dashboard.html +118 -0
  41. woninet/server/templates/device_info.html +93 -0
  42. woninet/utilities/__init__.py +2 -0
  43. woninet/utilities/arguments.py +94 -0
  44. woninet/utilities/cli_parser.py +90 -0
  45. woninet/utilities/detect_ip_range.py +21 -0
  46. woninet/utilities/ip_validator.py +39 -0
  47. woninet/utilities/logger.py +97 -0
  48. woninet-2.0.1.dist-info/METADATA +243 -0
  49. woninet-2.0.1.dist-info/RECORD +53 -0
  50. woninet-2.0.1.dist-info/WHEEL +5 -0
  51. woninet-2.0.1.dist-info/entry_points.txt +2 -0
  52. woninet-2.0.1.dist-info/licenses/LICENSE +21 -0
  53. woninet-2.0.1.dist-info/top_level.txt +1 -0
woninet/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # woninet/core/__init__.py
2
+ __version__ = "2.0.1"
woninet/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,2 @@
1
+ # woninet/core/__init__.py
2
+ # (This file intentionally left blank)
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
+ )