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/core/engine.py ADDED
@@ -0,0 +1,353 @@
1
+ import time
2
+ import logging
3
+ import threading
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from sqlalchemy import text, inspect
7
+ from sqlalchemy.exc import SQLAlchemyError
8
+ from icmplib import SocketPermissionError, SocketAddressError
9
+ from woninet.core.models import Device, MetricRecord
10
+ from woninet.core.collectors import PingCollector
11
+ from woninet.core.storage import StorageEngine
12
+ from woninet.core.alerts import AlertEngine, AlertRule
13
+ from woninet.core.subnet_enumerator import SubnetEnumerator
14
+ from woninet.core.manual_ip_enumerator import ManualIPEnumerator
15
+ from woninet.core.graph import GraphEngine
16
+ from woninet.database.engine import DatabaseEngine
17
+ from woninet.database.tables import AlertEventTable
18
+ from woninet.utilities.ip_validator import is_device_ip_valid
19
+ from woninet.utilities.detect_ip_range import detect_ip_range
20
+
21
+ core_logger = logging.getLogger("core")
22
+
23
+
24
+ class NetworkMonitorCore:
25
+ """
26
+ Central controller coordinating database initialization, telemetry
27
+ collection, persistence, and heuristic alert processing.
28
+
29
+ This class acts as the orchestrator for the network monitoring lifecycle,
30
+ managing thread pools for asynchronous ICMP polling and interfacing with
31
+ the SQLite persistence layer via SQLAlchemy.
32
+
33
+ Attributes:
34
+ local_ip (str): The source interface IP used for packet origination.
35
+ arp_noise_limit (float): Statistical threshold for filtering jitter
36
+ in ARP resolution timings.
37
+ max_thread_workers (int): The concurrency limit for the
38
+ `ThreadPoolExecutor` handling ICMP probes.
39
+ database_engine (DatabaseEngine): The high-level wrapper for
40
+ SQLAlchemy engine configuration.
41
+ storage (StorageEngine): The Data Access Object (DAO) layer for
42
+ CRUD operations on network metrics.
43
+ alert_engine (AlertEngine): Logic processor for evaluating
44
+ latency-based threshold breaches.
45
+ candidate_devices (list): A list of IP addresses identified during
46
+ the initial enumeration phase.
47
+ _stop_event (threading.Event): Primitive used to signal graceful
48
+ shutdown to the background worker thread.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ local_ip: str,
54
+ target_ip: str,
55
+ arp_noise_limit: float,
56
+ database_path: str,
57
+ max_thread_workers: int,
58
+ alert_rules: dict,
59
+ ) -> None:
60
+ """
61
+ Initialize the monitor core and prepare the persistence layer.
62
+
63
+ Args:
64
+ local_ip (str): Source IP address used to send packets.
65
+ target_ip (str): User-provided IP address or range to scan.
66
+ arp_noise_limit (float): Threshold in milliseconds above which ARP
67
+ fluctuations are treated as noise.
68
+ database_path (str): Path to SQLite database.
69
+ max_thread_workers (int): Maximum number of thread workers used to send ICMP pings.
70
+ alert_rules (dict): Provided alert rules, loaded from config.json.
71
+ """
72
+ self._running: bool = False
73
+ self._thread: threading.Thread | None = None
74
+ self._stop_event: threading.Event = threading.Event()
75
+ self._start_uptime = datetime.now()
76
+
77
+ self.local_ip: str = local_ip
78
+ self.arp_noise_limit: float = arp_noise_limit
79
+ self.max_thread_workers: int = max_thread_workers
80
+
81
+ # Initialize database
82
+ self.database_engine = DatabaseEngine(database_path=database_path)
83
+ self.database_engine.init_db()
84
+ self._engine = self.database_engine.get_database_enigne()
85
+ self.session_factory = self.database_engine.get_session_factory()
86
+
87
+ core_logger.info(f"Initializing network monitor for {self.local_ip}")
88
+
89
+ self.subnet_enumerator = SubnetEnumerator()
90
+ self.manual_ip_enumerator = ManualIPEnumerator()
91
+ self.storage = StorageEngine(session_factory=self.session_factory)
92
+
93
+ self.candidate_devices = self.enumerate_candidate_devices(target_ip)
94
+
95
+ self.ping_collector = PingCollector()
96
+
97
+ self.alert_engine = AlertEngine(
98
+ storage=self.storage,
99
+ rules=[
100
+ AlertRule(item["metric"], item["threshold"], item["consecutive_checks"])
101
+ for item in alert_rules
102
+ ],
103
+ )
104
+
105
+ self.consecutive_checks = {
106
+ item["metric"]: item["consecutive_checks"]
107
+ for item in alert_rules
108
+ }
109
+
110
+ self.graph_engine = GraphEngine()
111
+
112
+ def start(self) -> None:
113
+ """
114
+ Start the monitoring worker thread.
115
+ """
116
+ if self._running:
117
+ return
118
+
119
+ self._running = True
120
+ self._stop_event.clear()
121
+ self._thread = threading.Thread(
122
+ target=self.worker_loop, name="woninet-worker", daemon=False
123
+ )
124
+ self._thread.start()
125
+
126
+ def stop(self) -> None:
127
+ """
128
+ Stop the monitoring worker thread.
129
+ """
130
+ if not self._running:
131
+ return
132
+
133
+ self._running = False
134
+ self._stop_event.set()
135
+ if self._thread and self._thread.is_alive():
136
+ self._thread.join(timeout=3)
137
+
138
+ def wait(self) -> None:
139
+ """
140
+ Block until the worker thread finishes.
141
+ """
142
+ if self._thread:
143
+ self._thread.join()
144
+
145
+ def worker_loop(self) -> None:
146
+ """
147
+ Main monitoring loop.
148
+ Start the collectors and evaluate alerts.
149
+ """
150
+ try:
151
+ while self._running and not self._stop_event.is_set():
152
+ if not self._running or self._stop_event.is_set():
153
+ break
154
+
155
+ for result in self.ping_collector.collect(
156
+ candidate_devices=self.candidate_devices,
157
+ local_ip=self.local_ip,
158
+ device_records=self.get_device_history(),
159
+ stop_event=self._stop_event,
160
+ arp_noise_limit=self.arp_noise_limit,
161
+ max_thread_workers=self.max_thread_workers,
162
+ ):
163
+ try:
164
+ self.submit_to_history(
165
+ device=result.device,
166
+ metrics=[result.latency, result.packet_loss],
167
+ )
168
+ if result.device is not None:
169
+ self.alert_engine.evaluate(
170
+ ip=result.device.ip,
171
+ metrics_list=[result.latency, result.packet_loss],
172
+ default_consecutive_checks={
173
+ "latency": self.consecutive_checks["latency"],
174
+ "packet_loss": self.consecutive_checks[
175
+ "packet_loss"
176
+ ],
177
+ },
178
+ )
179
+ except AttributeError:
180
+ # If the tuple doesn't have enough values to unpack
181
+ pass
182
+ time.sleep(1)
183
+ except (PermissionError, SocketPermissionError):
184
+ core_logger.error("woninet requires root privileges to scan.")
185
+ self._running = False
186
+ self._stop_event.set()
187
+ except SocketAddressError:
188
+ core_logger.error("Network connection failed. Quitting.")
189
+ self._running = False
190
+ self._stop_event.set()
191
+ except Exception as e:
192
+ core_logger.error(f"Unexpected error in NetworkMonitorCore: {e}")
193
+ self._running = False
194
+ self._stop_event.set()
195
+ finally:
196
+ self._running = False
197
+ self._stop_event.set()
198
+
199
+ def is_alive(self) -> bool:
200
+ """
201
+ Return the running state of monitor.
202
+ """
203
+ return self._running
204
+
205
+ def get_device_history(self) -> list[Device]:
206
+ """
207
+ Return a list of all devices in the history.
208
+ """
209
+ return self.storage.list_device_history()
210
+
211
+ def get_device_info(
212
+ self, ip: str
213
+ ) -> tuple[Device | None, dict[str, str] | None, list[AlertEventTable]]:
214
+ """
215
+ Retrieve device information and related alert data for a given IP address.
216
+
217
+ Args:
218
+ ip (str): IP address of the device to search for.
219
+
220
+ Returns:
221
+ tuple[Device|None,dict[str,str]|None,list[AlertEventTable]]:
222
+ A tuple containing
223
+
224
+ 1. The found device, or `None` if no device exists for the given IP.
225
+ 2. The current alert state as `(metric_name, value)`, or `None` if no state exists.
226
+ 3. The last 10 alert events for the device, or an empty list if none exist.
227
+ """
228
+ device_info = self.storage.fetch_device_info(ip=ip)
229
+ device_alert_state = self.storage.get_device_alert_state(ip=ip)
230
+ recent_device_alert_events = self.storage.get_recent_device_alert_events(ip=ip)
231
+ return (device_info, device_alert_state, recent_device_alert_events)
232
+
233
+ def device_exists(self, ip: str) -> bool:
234
+ """
235
+ Return boolean indicating if device exists for a given IP address.
236
+ """
237
+ exists = self.storage.fetch_device_info(ip=ip)
238
+ if exists:
239
+ return True
240
+ return False
241
+
242
+ def classify_recent_alert_events(self) -> list[dict[str, Any]]:
243
+ """
244
+ Re-order and organize the recent alert events
245
+ dictionary keys and values.
246
+ """
247
+ result = []
248
+ for event in self.storage.get_recent_alert_events():
249
+ result.append(
250
+ {
251
+ "device_ip": event.device_ip,
252
+ "id": event.id,
253
+ "metric": event.metric,
254
+ "event_type": event.event_type,
255
+ "value": event.value,
256
+ "timestamp": event.timestamp.isoformat(),
257
+ }
258
+ )
259
+ return result
260
+
261
+ def count_resources(self) -> tuple[int, int]:
262
+ """
263
+ Return number of devices and metrics in the history.
264
+ """
265
+ return self.storage.count_devices_and_metrics()
266
+
267
+ def submit_to_history(self, device: str, metrics: list[MetricRecord]) -> None:
268
+ """
269
+ Handle storing new devices and metrics in history.
270
+ """
271
+ if isinstance(device, Device):
272
+ self.storage.store_device(device=device)
273
+ for metric in metrics:
274
+ if isinstance(metric, MetricRecord):
275
+ self.storage.store_metric(metric=metric)
276
+
277
+ def enumerate_candidate_devices(
278
+ self, target_ip_list: list[str] | None
279
+ ) -> dict[str, Device]:
280
+ """
281
+ Control enumeration based on user-provided IP addresses. Use range enumeration
282
+ if IP range is detected, otherwise use the single IP address. Enumerate on the
283
+ /24 subnet if no target IP address is provided.
284
+
285
+ Args:
286
+ target_ip (list[str]|None): Target IP addresses to create candidate devices on.
287
+
288
+ Returns:
289
+ dict[str,Device]: Candidate devices.
290
+
291
+ Raises:
292
+ ValueError: If IP addresses are not valid.
293
+ """
294
+ candidate_devices = {}
295
+ if target_ip_list is not None:
296
+ for target_ip in target_ip_list:
297
+ if detect_ip_range(ip=target_ip):
298
+ candidate_devices.update(
299
+ self.manual_ip_enumerator.enumerate_range(target_ip)
300
+ )
301
+ else:
302
+ candidate_devices[target_ip] = Device(ip=target_ip)
303
+ else:
304
+ candidate_devices.update(self.subnet_enumerator.enumerate(self.local_ip))
305
+ if not is_device_ip_valid(candidate_devices):
306
+ raise ValueError("IP address is not valid")
307
+ return candidate_devices
308
+
309
+ def uptime(self) -> int:
310
+ """
311
+ Calculate the uptime of program from the initialization of the
312
+ NetworkMonitorCore class.
313
+
314
+ Returns:
315
+ int: Uptime in seconds.
316
+ """
317
+ uptime_seconds = datetime.now() - self._start_uptime
318
+ return uptime_seconds.seconds
319
+
320
+ def database_health(self) -> dict[str, str]:
321
+ """
322
+ Check the health status of the database.
323
+
324
+ Returns:
325
+ dict[str,str]: A dictionary with two keys
326
+
327
+ - 'connection': 'ok' if executing `SELECT 1` succeeds, otherwise 'error'.
328
+ - 'schema': 'ok' if database table inspection succeeds, otherwise 'error'.
329
+ If the database connection fails, this value is 'unknown'.
330
+ """
331
+ result = {
332
+ "connection": "error",
333
+ "schema": "unknown",
334
+ }
335
+ try:
336
+ # Check connection
337
+ with self._engine.connect() as conn:
338
+ conn.execute(text("SELECT 1"))
339
+ result["connection"] = "ok"
340
+
341
+ # Check tables existence
342
+ inspect_engine = inspect(self._engine)
343
+ tables = inspect_engine.get_table_names()
344
+ required = {"devices", "metrics"}
345
+ if required.issubset(set(tables)):
346
+ result["schema"] = "ok"
347
+ else:
348
+ result["schema"] = "error"
349
+ except SQLAlchemyError:
350
+ # Keep default values
351
+ pass
352
+
353
+ return result
woninet/core/graph.py ADDED
@@ -0,0 +1,63 @@
1
+ from pathlib import Path
2
+ from matplotlib import pyplot as plt
3
+ from woninet.database.tables import AlertEventTable
4
+
5
+ CHARTS_PATH = Path(__file__).parent.parent / "charts"
6
+
7
+
8
+ class GraphEngine:
9
+ """
10
+ Engine for generating graphs and visualizations of network device metrics.
11
+ """
12
+
13
+ def design_device_latency_events(
14
+ self, ip: str, recent_device_alert_events: list[AlertEventTable]
15
+ ) -> None:
16
+ """
17
+ Generate and save a latency chart for a device.
18
+
19
+ Creates a line plot showing latency values over time with trigger and recovery events
20
+ marked as scatter points. The chart is saved as a PNG file.
21
+
22
+ Args:
23
+ ip: The IP address of the device for the chart title and filename.
24
+ recent_device_alert_events: List of alert events containing latency metrics and event types.
25
+ """
26
+ times = []
27
+ latency_values = []
28
+ trigger_times = []
29
+ trigger_values = []
30
+ recover_times = []
31
+ recover_values = []
32
+
33
+ for event in recent_device_alert_events:
34
+ if event.metric == "latency":
35
+ times.append(event.timestamp)
36
+ latency_values.append(event.value)
37
+ if event.event_type == "trigger":
38
+ trigger_times.append(event.timestamp)
39
+ trigger_values.append(event.value)
40
+ elif event.event_type == "recover":
41
+ recover_times.append(event.timestamp)
42
+ recover_values.append(event.value)
43
+
44
+ fig, ax = plt.subplots(figsize=(16, 8), dpi=150)
45
+
46
+ ax.plot(times, latency_values, label="Latency")
47
+ ax.axhline(100, linestyle="--", label="Alert Threshold")
48
+ ax.scatter(
49
+ trigger_times, trigger_values, marker="^", color="red", label="Trigger"
50
+ )
51
+ ax.scatter(
52
+ recover_times, recover_values, marker="v", color="blue", label="Recover"
53
+ )
54
+
55
+ ax.set_title(f"{ip} - Latency")
56
+ ax.set_xlabel("Time")
57
+ ax.set_ylabel("Latency (ms)")
58
+ ax.legend()
59
+ ax.grid(True, alpha=0.3)
60
+
61
+ fig.autofmt_xdate()
62
+ fig.savefig(f"{CHARTS_PATH}/{ip}.png", bbox_inches="tight")
63
+ plt.close(fig)
@@ -0,0 +1,33 @@
1
+ from woninet.core.models import Device
2
+
3
+
4
+ class ManualIPEnumerator:
5
+ """
6
+ Build a dictionary of candidate devices for user-provided IP addresses.
7
+ """
8
+
9
+ def enumerate(self, ip_list: list[str]) -> dict[str, str]:
10
+ devices = {}
11
+ for ip in ip_list:
12
+ devices[ip] = Device(ip=ip)
13
+ return devices
14
+
15
+ def enumerate_range(self, ip_range: str) -> dict[str, Device]:
16
+ """
17
+ Build a dictionary of candidate devices for user-provided IP addresses range.
18
+
19
+ Args:
20
+ ip_range (str): IP address as a range (e.g. 192.168.1.10-50).
21
+
22
+ Raises:
23
+ ValueError: If the range notation is not in the last octet.
24
+ """
25
+ base, last_octect = ip_range.rsplit(".", maxsplit=1)
26
+ if "-" in base:
27
+ raise ValueError("Range enumeraton is only supported on the last octet.")
28
+ start, stop = last_octect.split("-")
29
+ devices = {}
30
+ for i in range(int(start), int(stop) + 1):
31
+ ip = f"{base}.{i}"
32
+ devices[ip] = Device(ip=ip)
33
+ return devices
woninet/core/models.py ADDED
@@ -0,0 +1,124 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+
5
+ class Device:
6
+ """
7
+ Represents a discovered network device and stores identity information
8
+ and device state.
9
+
10
+ Attributes:
11
+ ip (str): IP address of the host.
12
+ last_seen (datetime|None): Last time device responded to ICMP request; Otherwise `None`.
13
+ exists (bool): If device responded to the last ARP scan.
14
+ reachable (bool): If device responded to the last ICMP scan.
15
+ mac (str): MAC address if known; Otherwise `None`.
16
+ latency (float): The last latency of host if reachable; Otherwise zero.
17
+ packet_loss (float): Most recent packet-loss ratio in the range [0.0, 100.0].
18
+ A value of 100.0 indicates 100% packet loss or that the device is unreachable.
19
+ """
20
+
21
+ def __init__(self, ip: str) -> None:
22
+ """
23
+ Initialize the device model.
24
+
25
+ Args:
26
+ ip: IP address of the host
27
+ """
28
+ self.ip: str = ip
29
+ self.last_seen: datetime | None = None
30
+ self.exists: bool = False
31
+ self.reachable: bool = False
32
+ self.mac: str | None = None
33
+ self.latency: float = 0
34
+ self.packet_loss: float = 100
35
+
36
+ def update_seen(self) -> None:
37
+ """
38
+ Update timestamp indicating the device responded recently.
39
+ """
40
+ self.last_seen = datetime.now()
41
+
42
+
43
+ class MetricRecord:
44
+ """
45
+ Represents a single collected metric measurement
46
+ for a specific device at a specific time.
47
+
48
+ Attributes:
49
+ device_ip (str): IP address of the host.
50
+ metric (str): Name of the metric.
51
+ value (float): Recorded value of the metric.
52
+ timestamp (datetime|None): Timestamp when the metric was captured.
53
+ If `None`, defaults to the current time.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ device_ip: str,
59
+ metric: str,
60
+ value: float,
61
+ timestamp: datetime | None = None,
62
+ ) -> None:
63
+ """
64
+ Initialize a metric record.
65
+
66
+ Args:
67
+ device_ip (str): IP address of the host the metric belongs to.
68
+ metric (str): Name of the recorded metric.
69
+ value (float): Recorded value of the metric.
70
+ timestamp (datetime|None): Timestamp of the measurement.
71
+ If omitted, the current time is used.
72
+ """
73
+ self.device_ip = device_ip
74
+ self.metric = metric
75
+ self.value = value
76
+ self.timestamp = timestamp or datetime.now()
77
+
78
+
79
+ class HostStatus:
80
+ """
81
+ Represents a Combined ARP + ICMP status for a given IP.
82
+
83
+ Attributes:
84
+ ip (str): IP address of the host.
85
+ exists (bool): If device responded to the ARP scan
86
+ reachable (bool): If device responded to the ICMP scan
87
+ latency (float): The latency of host if reachable; Otherwise zero.
88
+ packet_loss (float): Most recent packet-loss ratio in the range [0.0, 100.0].
89
+ A value of 100.0 indicates 100% packet loss or that the device is unreachable.
90
+ mac (str): MAC address if known; Otherwise `None`.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ ip: str,
96
+ exists: bool = False,
97
+ reachable: bool = False,
98
+ latency: float = 0.0,
99
+ packet_loss: float = 100.0,
100
+ mac: str | None = None,
101
+ ) -> None:
102
+ """
103
+ Initialize the host status model.
104
+
105
+ Args:
106
+ ip (str): IP address of the host.
107
+ exists (bool): If device responded to the ARP scan
108
+ reachable (bool): If device responded to the ICMP scan
109
+ latency (float): The latency of host if reachable; Otherwise zero.
110
+ mac (str): MAC address if known; Otherwise `None`.
111
+ """
112
+ self.ip: str = ip
113
+ self.exists: bool = exists
114
+ self.reachable: bool = reachable
115
+ self.latency: float = latency
116
+ self.packet_loss: float = packet_loss
117
+ self.mac: str | None = mac
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ScanResult:
122
+ device: Device | None
123
+ latency: float | None
124
+ packet_loss: float | None