bdo-toolkit 1.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.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Private live packet-acquisition lifecycle shared by toolkit features.
|
|
2
|
+
|
|
3
|
+
This module deliberately stops below protocol decoding. It owns interface
|
|
4
|
+
selection, capture filters, Scapy's background sniffer, and the enlarged
|
|
5
|
+
Windows/Npcap capture socket, while callers retain control of packet queues,
|
|
6
|
+
decoder finalization, and app-facing result delivery.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from threading import Event, Lock, current_thread
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
from ._capture_backend import (
|
|
20
|
+
build_bpf_filter,
|
|
21
|
+
detect_default_capture_target,
|
|
22
|
+
import_scapy,
|
|
23
|
+
)
|
|
24
|
+
from ._capture_options import PacketCaptureOptions
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_CAPTURE_BUFFER_BYTES = 64 * 1024 * 1024
|
|
28
|
+
DEFAULT_STARTUP_TIMEOUT_SECONDS = 10.0
|
|
29
|
+
_CAPTURE_JOIN_TIMEOUT_SECONDS = 2.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _capture_is_clean(
|
|
33
|
+
*,
|
|
34
|
+
tcp_gap_resets: int,
|
|
35
|
+
pcap_dropped: Optional[int],
|
|
36
|
+
pcap_interface_dropped: Optional[int],
|
|
37
|
+
packet_queue_overflows: int,
|
|
38
|
+
flow_state_evictions: int,
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Apply the shared acquisition-loss predicate used by public health types."""
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
tcp_gap_resets == 0
|
|
44
|
+
and pcap_dropped in (None, 0)
|
|
45
|
+
and pcap_interface_dropped in (None, 0)
|
|
46
|
+
and packet_queue_overflows == 0
|
|
47
|
+
and flow_state_evictions == 0
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _attach_cleanup_owner(
|
|
52
|
+
error: BaseException,
|
|
53
|
+
owner: object,
|
|
54
|
+
*,
|
|
55
|
+
context: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Keep retry ownership reachable when startup/context entry cannot return."""
|
|
58
|
+
|
|
59
|
+
attached = False
|
|
60
|
+
try:
|
|
61
|
+
setattr(error, "cleanup_owner", owner)
|
|
62
|
+
attached = True
|
|
63
|
+
except BaseException:
|
|
64
|
+
# Built-in and ordinary application exceptions expose __dict__. Keep
|
|
65
|
+
# the original failure identity even for an exotic immutable subtype.
|
|
66
|
+
pass
|
|
67
|
+
if hasattr(error, "add_note"):
|
|
68
|
+
if attached:
|
|
69
|
+
error.add_note(
|
|
70
|
+
f"{context} cleanup is incomplete; retry stop() on "
|
|
71
|
+
"exception.cleanup_owner"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
error.add_note(
|
|
75
|
+
f"{context} cleanup is incomplete; this exception subtype "
|
|
76
|
+
"cannot expose cleanup_owner, so retain and stop the original "
|
|
77
|
+
"session object"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class CaptureEndpoint:
|
|
83
|
+
"""Resolved interface and filters used by one live capture."""
|
|
84
|
+
|
|
85
|
+
interface: Optional[str]
|
|
86
|
+
local_ip: Optional[str]
|
|
87
|
+
bpf_filter: Optional[str]
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Optional[str]]:
|
|
90
|
+
"""Return the resolved endpoint as a JSON-ready mapping."""
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"interface": self.interface,
|
|
94
|
+
"local_ip": self.local_ip,
|
|
95
|
+
"bpf_filter": self.bpf_filter,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class CaptureStats:
|
|
101
|
+
"""Best-effort kernel capture statistics collected during shutdown."""
|
|
102
|
+
|
|
103
|
+
received: Optional[int] = None
|
|
104
|
+
dropped: Optional[int] = None
|
|
105
|
+
interface_dropped: Optional[int] = None
|
|
106
|
+
capture_buffer_bytes: Optional[int] = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_windows() -> bool:
|
|
110
|
+
return os.name == "nt"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _new_async_sniffer(**options: Any) -> Any:
|
|
114
|
+
from scapy.sendrecv import AsyncSniffer # type: ignore
|
|
115
|
+
|
|
116
|
+
return AsyncSniffer(**options)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _open_enlarged_windows_socket(
|
|
120
|
+
*,
|
|
121
|
+
interface: Optional[str],
|
|
122
|
+
bpf_filter: Optional[str],
|
|
123
|
+
buffer_bytes: int,
|
|
124
|
+
) -> Any:
|
|
125
|
+
"""Open one libpcap socket and enlarge its Npcap kernel buffer."""
|
|
126
|
+
|
|
127
|
+
from scapy.arch.libpcap import L2pcapListenSocket # type: ignore
|
|
128
|
+
from scapy.libs.winpcapy import pcap_setbuff # type: ignore
|
|
129
|
+
|
|
130
|
+
capture_socket = L2pcapListenSocket(
|
|
131
|
+
iface=interface,
|
|
132
|
+
filter=bpf_filter,
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
capture_handle = capture_socket.pcap_fd.pcap
|
|
136
|
+
if pcap_setbuff(capture_handle, buffer_bytes) != 0:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"Npcap rejected the requested {buffer_bytes}-byte capture buffer"
|
|
139
|
+
)
|
|
140
|
+
except BaseException:
|
|
141
|
+
capture_socket.close()
|
|
142
|
+
raise
|
|
143
|
+
return capture_socket
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_windows_capture_stats(
|
|
147
|
+
capture_socket: Any,
|
|
148
|
+
) -> tuple[int, int, int] | None:
|
|
149
|
+
"""Read Npcap counters while the caller-owned handle is still open."""
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
from ctypes import byref
|
|
153
|
+
|
|
154
|
+
from scapy.libs.winpcapy import pcap_stat, pcap_stats # type: ignore
|
|
155
|
+
|
|
156
|
+
capture_stats = pcap_stat()
|
|
157
|
+
if pcap_stats(
|
|
158
|
+
capture_socket.pcap_fd.pcap,
|
|
159
|
+
byref(capture_stats),
|
|
160
|
+
) != 0:
|
|
161
|
+
return None
|
|
162
|
+
return (
|
|
163
|
+
int(capture_stats.ps_recv),
|
|
164
|
+
int(capture_stats.ps_drop),
|
|
165
|
+
int(capture_stats.ps_ifdrop),
|
|
166
|
+
)
|
|
167
|
+
except BaseException:
|
|
168
|
+
# Statistics are diagnostic evidence. Their absence must not turn an
|
|
169
|
+
# otherwise valid capture into a decoder failure.
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class LivePacketCapture:
|
|
174
|
+
"""Single-use private owner of one passive Scapy capture handle.
|
|
175
|
+
|
|
176
|
+
``on_packet`` runs in Scapy's capture thread. Callers that need a tiny
|
|
177
|
+
capture callback (notably Solare's multi-megabyte leaderboard burst) should
|
|
178
|
+
pass a queue's non-blocking ``put`` method and process packets elsewhere.
|
|
179
|
+
Existing synchronous decoders can keep their present callback semantics.
|
|
180
|
+
|
|
181
|
+
The enlarged Npcap buffer is best-effort by default so existing toolkit
|
|
182
|
+
features do not acquire a dependency on Scapy's private Windows bindings.
|
|
183
|
+
Integrity-sensitive classifiers can set ``require_capture_buffer=True``.
|
|
184
|
+
That requirement applies only on Windows, where the Npcap buffer exists.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
capture_options: PacketCaptureOptions,
|
|
191
|
+
on_packet: Callable[[object], None],
|
|
192
|
+
capture_buffer_bytes: int = DEFAULT_CAPTURE_BUFFER_BYTES,
|
|
193
|
+
require_capture_buffer: bool = False,
|
|
194
|
+
startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
195
|
+
) -> None:
|
|
196
|
+
if not isinstance(capture_options, PacketCaptureOptions):
|
|
197
|
+
raise TypeError("capture_options must be a PacketCaptureOptions")
|
|
198
|
+
if not callable(on_packet):
|
|
199
|
+
raise TypeError("on_packet must be callable")
|
|
200
|
+
if (
|
|
201
|
+
isinstance(capture_buffer_bytes, bool)
|
|
202
|
+
or not isinstance(capture_buffer_bytes, int)
|
|
203
|
+
or capture_buffer_bytes <= 0
|
|
204
|
+
):
|
|
205
|
+
raise ValueError("capture_buffer_bytes must be a positive integer")
|
|
206
|
+
if not isinstance(require_capture_buffer, bool):
|
|
207
|
+
raise ValueError("require_capture_buffer must be a boolean")
|
|
208
|
+
if (
|
|
209
|
+
isinstance(startup_timeout, bool)
|
|
210
|
+
or not isinstance(startup_timeout, (int, float))
|
|
211
|
+
or not math.isfinite(startup_timeout)
|
|
212
|
+
or startup_timeout <= 0
|
|
213
|
+
):
|
|
214
|
+
raise ValueError("startup_timeout must be finite and greater than zero")
|
|
215
|
+
|
|
216
|
+
self._capture_options = capture_options
|
|
217
|
+
self._on_packet = on_packet
|
|
218
|
+
self._capture_buffer_bytes = capture_buffer_bytes
|
|
219
|
+
self._require_capture_buffer = require_capture_buffer
|
|
220
|
+
self._startup_timeout = float(startup_timeout)
|
|
221
|
+
|
|
222
|
+
self._capture_ready = Event()
|
|
223
|
+
self._state_lock = Lock()
|
|
224
|
+
self._cleanup_lock = Lock()
|
|
225
|
+
self._start_attempted = False
|
|
226
|
+
self._started = False
|
|
227
|
+
self._stopped = False
|
|
228
|
+
self._capture: Any = None
|
|
229
|
+
self._capture_socket: Any = None
|
|
230
|
+
self._endpoint: Optional[CaptureEndpoint] = None
|
|
231
|
+
self._stats = CaptureStats()
|
|
232
|
+
self._error: Optional[BaseException] = None
|
|
233
|
+
self._buffer_error: Optional[BaseException] = None
|
|
234
|
+
self._cleanup_incomplete = False
|
|
235
|
+
self._cleanup_error: Optional[BaseException] = None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def endpoint(self) -> Optional[CaptureEndpoint]:
|
|
239
|
+
"""Resolved endpoint after capture startup has been attempted."""
|
|
240
|
+
|
|
241
|
+
return self._endpoint
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def stats(self) -> CaptureStats:
|
|
245
|
+
"""Latest capture counters; kernel values appear after ``stop()``."""
|
|
246
|
+
|
|
247
|
+
return self._stats
|
|
248
|
+
|
|
249
|
+
def snapshot_stats(self) -> CaptureStats:
|
|
250
|
+
"""Read best-effort counters without stopping the capture.
|
|
251
|
+
|
|
252
|
+
This is used to bind acquisition health to a decoder's first latched
|
|
253
|
+
result even when the caller intentionally keeps recording afterward.
|
|
254
|
+
Backends without a caller-owned statistics handle return the counters
|
|
255
|
+
that are currently available.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
with self._cleanup_lock:
|
|
259
|
+
if not self._started:
|
|
260
|
+
raise RuntimeError("live packet capture was not started")
|
|
261
|
+
if self._stopped:
|
|
262
|
+
return self._stats
|
|
263
|
+
received = dropped = interface_dropped = None
|
|
264
|
+
if self._capture_socket is not None:
|
|
265
|
+
raw_stats = _read_windows_capture_stats(self._capture_socket)
|
|
266
|
+
if raw_stats is not None:
|
|
267
|
+
received, dropped, interface_dropped = raw_stats
|
|
268
|
+
return CaptureStats(
|
|
269
|
+
received=received,
|
|
270
|
+
dropped=dropped,
|
|
271
|
+
interface_dropped=interface_dropped,
|
|
272
|
+
capture_buffer_bytes=self._stats.capture_buffer_bytes,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def buffer_error(self) -> Optional[BaseException]:
|
|
277
|
+
"""Why best-effort enlarged-buffer setup fell back, if it did."""
|
|
278
|
+
|
|
279
|
+
return self._buffer_error
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def error(self) -> Optional[BaseException]:
|
|
283
|
+
"""First callback, sniffer, or shutdown failure."""
|
|
284
|
+
|
|
285
|
+
with self._state_lock:
|
|
286
|
+
error = self._error
|
|
287
|
+
if error is not None:
|
|
288
|
+
return error
|
|
289
|
+
capture_error = getattr(self._capture, "exception", None)
|
|
290
|
+
return capture_error if isinstance(capture_error, BaseException) else None
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def cleanup_incomplete(self) -> bool:
|
|
294
|
+
"""Whether shutdown still owns resources that require another attempt."""
|
|
295
|
+
|
|
296
|
+
return self._cleanup_incomplete
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def cleanup_error(self) -> Optional[BaseException]:
|
|
300
|
+
"""Explicit failure explaining why the last shutdown was incomplete."""
|
|
301
|
+
|
|
302
|
+
return self._cleanup_error
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def running(self) -> bool:
|
|
306
|
+
capture = self._capture
|
|
307
|
+
capture_thread = getattr(capture, "thread", None)
|
|
308
|
+
thread_finished = (
|
|
309
|
+
capture_thread is not None
|
|
310
|
+
and capture_thread.ident is not None
|
|
311
|
+
and not capture_thread.is_alive()
|
|
312
|
+
)
|
|
313
|
+
return (
|
|
314
|
+
(self._started or self._cleanup_incomplete)
|
|
315
|
+
and not self._stopped
|
|
316
|
+
and capture is not None
|
|
317
|
+
and not thread_finished
|
|
318
|
+
and (not self._capture_ready.is_set() or bool(capture.running))
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def stopped(self) -> bool:
|
|
323
|
+
return self._stopped
|
|
324
|
+
|
|
325
|
+
def start(self) -> None:
|
|
326
|
+
"""Resolve the endpoint, open the adapter, and wait until it is ready."""
|
|
327
|
+
|
|
328
|
+
with self._cleanup_lock:
|
|
329
|
+
if self._start_attempted:
|
|
330
|
+
raise RuntimeError("live packet capture is single-use")
|
|
331
|
+
self._start_attempted = True
|
|
332
|
+
self._capture_ready.clear()
|
|
333
|
+
|
|
334
|
+
# Scapy lazily initializes its Windows interface table. Import it
|
|
335
|
+
# before route detection so the selected interface is reliable.
|
|
336
|
+
IP, TCP, _, _, _ = import_scapy()
|
|
337
|
+
|
|
338
|
+
detected_target = None
|
|
339
|
+
if self._capture_options.interface is None:
|
|
340
|
+
detected_target = detect_default_capture_target()
|
|
341
|
+
capture_interface = detected_target.interface
|
|
342
|
+
else:
|
|
343
|
+
capture_interface = self._capture_options.interface
|
|
344
|
+
|
|
345
|
+
capture_local_ip = self._capture_options.local_ip
|
|
346
|
+
if (
|
|
347
|
+
capture_local_ip is None
|
|
348
|
+
and self._capture_options.interface is None
|
|
349
|
+
and self._capture_options.auto_local_ip
|
|
350
|
+
):
|
|
351
|
+
assert detected_target is not None
|
|
352
|
+
capture_local_ip = detected_target.local_ip
|
|
353
|
+
|
|
354
|
+
bpf_filter = (
|
|
355
|
+
build_bpf_filter(self._capture_options.ports, capture_local_ip)
|
|
356
|
+
if self._capture_options.use_bpf
|
|
357
|
+
else None
|
|
358
|
+
)
|
|
359
|
+
lfilter = None
|
|
360
|
+
if not self._capture_options.use_bpf:
|
|
361
|
+
ports = frozenset(self._capture_options.ports)
|
|
362
|
+
|
|
363
|
+
def lfilter(packet: object) -> bool:
|
|
364
|
+
return bool(
|
|
365
|
+
IP in packet # type: ignore[operator]
|
|
366
|
+
and TCP in packet # type: ignore[operator]
|
|
367
|
+
and int(packet[TCP].sport) in ports # type: ignore[index]
|
|
368
|
+
and (
|
|
369
|
+
capture_local_ip is None
|
|
370
|
+
or str(packet[IP].dst) == capture_local_ip # type: ignore[index]
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
self._endpoint = CaptureEndpoint(
|
|
375
|
+
interface=capture_interface,
|
|
376
|
+
local_ip=capture_local_ip,
|
|
377
|
+
bpf_filter=bpf_filter,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
capture_socket = None
|
|
381
|
+
configured_buffer_bytes = None
|
|
382
|
+
if _is_windows():
|
|
383
|
+
try:
|
|
384
|
+
capture_socket = _open_enlarged_windows_socket(
|
|
385
|
+
interface=capture_interface,
|
|
386
|
+
bpf_filter=bpf_filter,
|
|
387
|
+
buffer_bytes=self._capture_buffer_bytes,
|
|
388
|
+
)
|
|
389
|
+
configured_buffer_bytes = self._capture_buffer_bytes
|
|
390
|
+
except BaseException as exc:
|
|
391
|
+
self._buffer_error = exc
|
|
392
|
+
if self._require_capture_buffer:
|
|
393
|
+
self._record_error(exc)
|
|
394
|
+
raise RuntimeError(
|
|
395
|
+
"failed to configure the enlarged Npcap capture buffer"
|
|
396
|
+
) from exc
|
|
397
|
+
|
|
398
|
+
self._stats = CaptureStats(
|
|
399
|
+
capture_buffer_bytes=configured_buffer_bytes,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def handle_packet(packet: object) -> None:
|
|
403
|
+
try:
|
|
404
|
+
self._on_packet(packet)
|
|
405
|
+
except BaseException as exc:
|
|
406
|
+
self._record_error(exc)
|
|
407
|
+
# Scapy intentionally ends a capture whose callback raises.
|
|
408
|
+
# Retaining the error here lets the owning session surface
|
|
409
|
+
# the original decoder or queue failure.
|
|
410
|
+
raise
|
|
411
|
+
|
|
412
|
+
sniffer_options: dict[str, Any] = {
|
|
413
|
+
"lfilter": lfilter,
|
|
414
|
+
"prn": handle_packet,
|
|
415
|
+
"store": False,
|
|
416
|
+
"started_callback": self._capture_ready.set,
|
|
417
|
+
}
|
|
418
|
+
if capture_socket is not None:
|
|
419
|
+
# AsyncSniffer must use exactly this enlarged socket. Supplying
|
|
420
|
+
# iface/filter too would make Scapy open another default-sized
|
|
421
|
+
# handle and silently bypass it.
|
|
422
|
+
sniffer_options["opened_socket"] = capture_socket
|
|
423
|
+
else:
|
|
424
|
+
sniffer_options["iface"] = capture_interface
|
|
425
|
+
sniffer_options["filter"] = bpf_filter
|
|
426
|
+
|
|
427
|
+
capture = None
|
|
428
|
+
self._capture_socket = capture_socket
|
|
429
|
+
try:
|
|
430
|
+
capture = _new_async_sniffer(**sniffer_options)
|
|
431
|
+
self._capture = capture
|
|
432
|
+
capture.start()
|
|
433
|
+
deadline = time.monotonic() + self._startup_timeout
|
|
434
|
+
while not self._capture_ready.wait(timeout=0.05):
|
|
435
|
+
capture_error = getattr(capture, "exception", None)
|
|
436
|
+
if isinstance(capture_error, BaseException):
|
|
437
|
+
raise capture_error
|
|
438
|
+
capture_thread = getattr(capture, "thread", None)
|
|
439
|
+
if (
|
|
440
|
+
capture_thread is not None
|
|
441
|
+
and capture_thread.ident is not None
|
|
442
|
+
and not capture_thread.is_alive()
|
|
443
|
+
):
|
|
444
|
+
raise RuntimeError(
|
|
445
|
+
"live capture thread ended during startup"
|
|
446
|
+
)
|
|
447
|
+
if time.monotonic() >= deadline:
|
|
448
|
+
raise RuntimeError(
|
|
449
|
+
"timed out while opening the live capture interface"
|
|
450
|
+
)
|
|
451
|
+
except BaseException as exc:
|
|
452
|
+
self._record_error(exc)
|
|
453
|
+
try:
|
|
454
|
+
self._shutdown_backend()
|
|
455
|
+
except BaseException as cleanup_error:
|
|
456
|
+
# Startup remains the primary failure, but incomplete
|
|
457
|
+
# cleanup must be explicit in its traceback and remains
|
|
458
|
+
# retryable through stop().
|
|
459
|
+
if hasattr(exc, "add_note"):
|
|
460
|
+
exc.add_note(
|
|
461
|
+
"live capture startup cleanup also failed: "
|
|
462
|
+
f"{cleanup_error!r}"
|
|
463
|
+
)
|
|
464
|
+
if not self._cleanup_incomplete:
|
|
465
|
+
self._capture = None
|
|
466
|
+
self._capture_socket = None
|
|
467
|
+
else:
|
|
468
|
+
_attach_cleanup_owner(
|
|
469
|
+
exc,
|
|
470
|
+
self,
|
|
471
|
+
context="live packet capture startup",
|
|
472
|
+
)
|
|
473
|
+
raise
|
|
474
|
+
|
|
475
|
+
self._started = True
|
|
476
|
+
|
|
477
|
+
def stop(self) -> CaptureStats:
|
|
478
|
+
"""Stop and join capture, read counters, then close the owned socket."""
|
|
479
|
+
|
|
480
|
+
with self._cleanup_lock:
|
|
481
|
+
if not self._started and not self._cleanup_incomplete:
|
|
482
|
+
raise RuntimeError("live packet capture was not started")
|
|
483
|
+
if self._stopped:
|
|
484
|
+
return self._stats
|
|
485
|
+
self._shutdown_backend()
|
|
486
|
+
return self._stats
|
|
487
|
+
|
|
488
|
+
def _shutdown_backend(self) -> None:
|
|
489
|
+
"""Attempt every safe shutdown step and verify resource ownership.
|
|
490
|
+
|
|
491
|
+
Scapy's ordinary ``AsyncSniffer.stop()`` joins without a timeout. Ask
|
|
492
|
+
it only to signal the backend, then perform our own bounded join. A
|
|
493
|
+
caller-owned Npcap socket is closed before that join so a blocking read
|
|
494
|
+
has an independent way to wake even when the stop request raises.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
capture = self._capture
|
|
498
|
+
capture_socket = self._capture_socket
|
|
499
|
+
failures: list[BaseException] = []
|
|
500
|
+
|
|
501
|
+
def retain(error: BaseException) -> None:
|
|
502
|
+
self._record_error(error)
|
|
503
|
+
if not any(error is previous for previous in failures):
|
|
504
|
+
failures.append(error)
|
|
505
|
+
|
|
506
|
+
capture_error = getattr(capture, "exception", None)
|
|
507
|
+
if isinstance(capture_error, BaseException):
|
|
508
|
+
self._record_error(capture_error)
|
|
509
|
+
|
|
510
|
+
capture_thread = getattr(capture, "thread", None)
|
|
511
|
+
thread_alive = False
|
|
512
|
+
if capture_thread is not None:
|
|
513
|
+
thread_alive = bool(capture_thread.is_alive())
|
|
514
|
+
|
|
515
|
+
if capture is not None and (
|
|
516
|
+
bool(getattr(capture, "running", False)) or thread_alive
|
|
517
|
+
):
|
|
518
|
+
try:
|
|
519
|
+
stop_method = capture.stop
|
|
520
|
+
try:
|
|
521
|
+
parameters = inspect.signature(stop_method).parameters.values()
|
|
522
|
+
except (TypeError, ValueError):
|
|
523
|
+
# The real supported Scapy backend accepts ``join``. This
|
|
524
|
+
# fallback is for non-introspectable compatible wrappers.
|
|
525
|
+
stop_method(join=False)
|
|
526
|
+
else:
|
|
527
|
+
supports_join = any(
|
|
528
|
+
parameter.name == "join"
|
|
529
|
+
or parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
530
|
+
for parameter in parameters
|
|
531
|
+
)
|
|
532
|
+
if supports_join:
|
|
533
|
+
stop_method(join=False)
|
|
534
|
+
else:
|
|
535
|
+
# Lightweight test/application fakes may expose only a
|
|
536
|
+
# synchronous no-argument stop method.
|
|
537
|
+
stop_method()
|
|
538
|
+
except BaseException as exc:
|
|
539
|
+
retain(exc)
|
|
540
|
+
|
|
541
|
+
received = self._stats.received
|
|
542
|
+
dropped = self._stats.dropped
|
|
543
|
+
interface_dropped = self._stats.interface_dropped
|
|
544
|
+
if capture_socket is not None:
|
|
545
|
+
raw_stats = _read_windows_capture_stats(capture_socket)
|
|
546
|
+
if raw_stats is not None:
|
|
547
|
+
received, dropped, interface_dropped = raw_stats
|
|
548
|
+
try:
|
|
549
|
+
capture_socket.close()
|
|
550
|
+
except BaseException as exc:
|
|
551
|
+
# Keep the socket reference so a later stop() can retry it.
|
|
552
|
+
retain(exc)
|
|
553
|
+
else:
|
|
554
|
+
self._capture_socket = None
|
|
555
|
+
|
|
556
|
+
self._stats = CaptureStats(
|
|
557
|
+
received=received,
|
|
558
|
+
dropped=dropped,
|
|
559
|
+
interface_dropped=interface_dropped,
|
|
560
|
+
capture_buffer_bytes=self._stats.capture_buffer_bytes,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if capture_thread is not None:
|
|
564
|
+
thread_alive = bool(capture_thread.is_alive())
|
|
565
|
+
if thread_alive:
|
|
566
|
+
if capture_thread is current_thread():
|
|
567
|
+
retain(
|
|
568
|
+
RuntimeError(
|
|
569
|
+
"live capture cannot join its own capture thread"
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
try:
|
|
574
|
+
capture_thread.join(
|
|
575
|
+
timeout=_CAPTURE_JOIN_TIMEOUT_SECONDS
|
|
576
|
+
)
|
|
577
|
+
except BaseException as exc:
|
|
578
|
+
retain(exc)
|
|
579
|
+
thread_alive = bool(capture_thread.is_alive())
|
|
580
|
+
|
|
581
|
+
backend_running_without_thread = (
|
|
582
|
+
capture is not None
|
|
583
|
+
and capture_thread is None
|
|
584
|
+
and bool(getattr(capture, "running", False))
|
|
585
|
+
)
|
|
586
|
+
incomplete = (
|
|
587
|
+
thread_alive
|
|
588
|
+
or backend_running_without_thread
|
|
589
|
+
or self._capture_socket is not None
|
|
590
|
+
)
|
|
591
|
+
self._cleanup_incomplete = incomplete
|
|
592
|
+
self._stopped = not incomplete
|
|
593
|
+
|
|
594
|
+
if incomplete:
|
|
595
|
+
reasons: list[str] = []
|
|
596
|
+
if thread_alive or backend_running_without_thread:
|
|
597
|
+
reasons.append(
|
|
598
|
+
"the capture thread/backend is still running after the "
|
|
599
|
+
f"{_CAPTURE_JOIN_TIMEOUT_SECONDS:g}-second join deadline"
|
|
600
|
+
)
|
|
601
|
+
if self._capture_socket is not None:
|
|
602
|
+
reasons.append("the caller-owned capture socket did not close")
|
|
603
|
+
cleanup_error = RuntimeError(
|
|
604
|
+
"live packet capture cleanup is incomplete: " + "; ".join(reasons)
|
|
605
|
+
)
|
|
606
|
+
self._cleanup_error = cleanup_error
|
|
607
|
+
self._record_error(cleanup_error)
|
|
608
|
+
if failures:
|
|
609
|
+
raise cleanup_error from failures[0]
|
|
610
|
+
raise cleanup_error
|
|
611
|
+
|
|
612
|
+
self._cleanup_error = None
|
|
613
|
+
if failures:
|
|
614
|
+
raise failures[0]
|
|
615
|
+
|
|
616
|
+
def raise_if_failed(self) -> None:
|
|
617
|
+
"""Raise the first retained capture failure, if one occurred."""
|
|
618
|
+
|
|
619
|
+
error = self.error
|
|
620
|
+
if error is not None:
|
|
621
|
+
raise error
|
|
622
|
+
|
|
623
|
+
def _record_error(self, error: BaseException) -> None:
|
|
624
|
+
with self._state_lock:
|
|
625
|
+
if self._error is None:
|
|
626
|
+
self._error = error
|