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,654 @@
|
|
|
1
|
+
"""Per-flow TCP stream reassembly feeding a stream scanner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from threading import Lock, RLock
|
|
8
|
+
from typing import Callable, Optional, Protocol
|
|
9
|
+
|
|
10
|
+
from ._protocol import (
|
|
11
|
+
GAP_RESET_SECONDS,
|
|
12
|
+
MAX_PENDING_SEGMENTS,
|
|
13
|
+
MAX_TARGET_MESSAGE_LENGTH,
|
|
14
|
+
TCP_SEQUENCE_HALF_RANGE,
|
|
15
|
+
TCP_SEQUENCE_MODULUS,
|
|
16
|
+
FlowKey,
|
|
17
|
+
PacketContext,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# When capture begins after the SYN, the first callback can be a later TCP
|
|
22
|
+
# segment that raced ahead of its prefix. Hold a bounded initial reorder set
|
|
23
|
+
# until framing proves an origin or an owning root services the grace period;
|
|
24
|
+
# capacity pressure and finish() provide the remaining bounded release paths.
|
|
25
|
+
_INITIAL_REORDER_GRACE_SECONDS = 0.25
|
|
26
|
+
_INITIAL_ANCHOR_PROBE_BYTES = (MAX_TARGET_MESSAGE_LENGTH * 2) + 5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StreamScanner(Protocol):
|
|
30
|
+
"""Anything that can consume reassembled stream bytes."""
|
|
31
|
+
|
|
32
|
+
def feed(self, data: bytes, context: PacketContext) -> None: ...
|
|
33
|
+
|
|
34
|
+
def scan_standalone(self, data: bytes, context: PacketContext) -> None: ...
|
|
35
|
+
|
|
36
|
+
def reset(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PendingSegment:
|
|
41
|
+
data: bytes
|
|
42
|
+
context: PacketContext
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TCPFlowState:
|
|
47
|
+
scanner: StreamScanner
|
|
48
|
+
max_pending_segments: int = MAX_PENDING_SEGMENTS
|
|
49
|
+
max_pending_bytes: Optional[int] = None
|
|
50
|
+
next_sequence: Optional[int] = None
|
|
51
|
+
pending: dict[int, PendingSegment] = field(default_factory=dict)
|
|
52
|
+
unanchored: dict[int, PendingSegment] = field(default_factory=dict)
|
|
53
|
+
unanchored_started_at: Optional[float] = None
|
|
54
|
+
gap_started_at: Optional[float] = None
|
|
55
|
+
generation: int = 0
|
|
56
|
+
on_gap_reset: Optional[Callable[[], None]] = None
|
|
57
|
+
last_activity_at: Optional[float] = None
|
|
58
|
+
fin_sequence: Optional[int] = None
|
|
59
|
+
fin_observed_at: Optional[float] = None
|
|
60
|
+
|
|
61
|
+
def reset(self) -> None:
|
|
62
|
+
self.next_sequence = None
|
|
63
|
+
self.pending.clear()
|
|
64
|
+
self._clear_unanchored()
|
|
65
|
+
self.gap_started_at = None
|
|
66
|
+
self.fin_sequence = None
|
|
67
|
+
self.fin_observed_at = None
|
|
68
|
+
self.scanner.reset()
|
|
69
|
+
|
|
70
|
+
def anchor_sequence(self, sequence: int) -> None:
|
|
71
|
+
"""Record a credible next-payload sequence without delivering bytes.
|
|
72
|
+
|
|
73
|
+
An empty SYN is enough to establish this anchor because SYN consumes
|
|
74
|
+
one TCP sequence number. Keeping the anchor lets a later payload that
|
|
75
|
+
arrives out of order wait for its missing prefix instead of becoming
|
|
76
|
+
the stream's accidental starting point.
|
|
77
|
+
"""
|
|
78
|
+
if self.next_sequence is None:
|
|
79
|
+
if self.unanchored:
|
|
80
|
+
self._commit_unanchored(sequence & 0xFFFFFFFF)
|
|
81
|
+
else:
|
|
82
|
+
self.next_sequence = sequence & 0xFFFFFFFF
|
|
83
|
+
|
|
84
|
+
def add_segment(
|
|
85
|
+
self, sequence: int, payload: bytes, context: PacketContext
|
|
86
|
+
) -> None:
|
|
87
|
+
if not payload:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
if self.next_sequence is None:
|
|
91
|
+
self._add_unanchored_segment(sequence, payload, context)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
self._add_anchored_segment(sequence, payload, context)
|
|
95
|
+
|
|
96
|
+
def mark_fin(self, sequence: int, timestamp: float) -> None:
|
|
97
|
+
"""Remember the first sequence after all payload preceding FIN."""
|
|
98
|
+
|
|
99
|
+
self.fin_sequence = sequence & 0xFFFFFFFF
|
|
100
|
+
self.fin_observed_at = timestamp
|
|
101
|
+
self._start_fin_gap_timer_if_needed()
|
|
102
|
+
|
|
103
|
+
def _start_fin_gap_timer_if_needed(self) -> None:
|
|
104
|
+
if (
|
|
105
|
+
self.fin_sequence is None
|
|
106
|
+
or self.fin_observed_at is None
|
|
107
|
+
or self.next_sequence is None
|
|
108
|
+
or self.unanchored
|
|
109
|
+
or self.pending
|
|
110
|
+
):
|
|
111
|
+
return
|
|
112
|
+
fin_sequence = _unwrap_tcp_sequence(
|
|
113
|
+
self.fin_sequence,
|
|
114
|
+
self.next_sequence,
|
|
115
|
+
)
|
|
116
|
+
if self.next_sequence < fin_sequence and self.gap_started_at is None:
|
|
117
|
+
self.gap_started_at = self.fin_observed_at
|
|
118
|
+
|
|
119
|
+
def fin_is_reassembled(self) -> bool:
|
|
120
|
+
"""Return whether every observed range through FIN is now contiguous."""
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
self.fin_sequence is None
|
|
124
|
+
or self.next_sequence is None
|
|
125
|
+
or self.unanchored
|
|
126
|
+
or self.pending
|
|
127
|
+
):
|
|
128
|
+
return False
|
|
129
|
+
return self.next_sequence >= _unwrap_tcp_sequence(
|
|
130
|
+
self.fin_sequence,
|
|
131
|
+
self.next_sequence,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _add_unanchored_segment(
|
|
135
|
+
self, sequence: int, payload: bytes, context: PacketContext
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Establish a credible stream origin when no SYN was captured."""
|
|
138
|
+
sequence &= 0xFFFFFFFF
|
|
139
|
+
previous = self.unanchored.get(sequence)
|
|
140
|
+
if previous is None or len(payload) > len(previous.data):
|
|
141
|
+
self.unanchored[sequence] = PendingSegment(payload, context)
|
|
142
|
+
if self.unanchored_started_at is None:
|
|
143
|
+
self.unanchored_started_at = context.timestamp
|
|
144
|
+
|
|
145
|
+
ordered = self._ordered_unanchored()
|
|
146
|
+
start_sequence, probe = self._contiguous_unanchored_probe(ordered)
|
|
147
|
+
can_anchor = getattr(self.scanner, "can_anchor_at_start", None)
|
|
148
|
+
if can_anchor is not None and can_anchor(probe):
|
|
149
|
+
self._commit_unanchored(start_sequence)
|
|
150
|
+
elif self._pending_capacity_exceeded(self.unanchored):
|
|
151
|
+
# Preserve the same hard bounds as ordinary gap buffering.
|
|
152
|
+
# Under pressure, the earliest observed byte is the least-bad
|
|
153
|
+
# origin and the scanner's normal resynchronization remains active.
|
|
154
|
+
self._commit_unanchored(start_sequence)
|
|
155
|
+
|
|
156
|
+
def _pending_capacity_exceeded(
|
|
157
|
+
self,
|
|
158
|
+
segments: dict[int, PendingSegment],
|
|
159
|
+
) -> bool:
|
|
160
|
+
if len(segments) > self.max_pending_segments:
|
|
161
|
+
return True
|
|
162
|
+
return (
|
|
163
|
+
self.max_pending_bytes is not None
|
|
164
|
+
and sum(len(segment.data) for segment in segments.values())
|
|
165
|
+
> self.max_pending_bytes
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _ordered_unanchored(self) -> list[tuple[int, PendingSegment]]:
|
|
169
|
+
if not self.unanchored:
|
|
170
|
+
return []
|
|
171
|
+
reference = next(iter(self.unanchored))
|
|
172
|
+
ordered = sorted(
|
|
173
|
+
(
|
|
174
|
+
_unwrap_tcp_sequence(sequence, reference),
|
|
175
|
+
segment,
|
|
176
|
+
)
|
|
177
|
+
for sequence, segment in self.unanchored.items()
|
|
178
|
+
)
|
|
179
|
+
if ordered[0][0] < 0:
|
|
180
|
+
ordered = [
|
|
181
|
+
(sequence + TCP_SEQUENCE_MODULUS, segment)
|
|
182
|
+
for sequence, segment in ordered
|
|
183
|
+
]
|
|
184
|
+
return ordered
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _contiguous_unanchored_probe(
|
|
188
|
+
ordered: list[tuple[int, PendingSegment]],
|
|
189
|
+
) -> tuple[int, bytes]:
|
|
190
|
+
assert ordered
|
|
191
|
+
start_sequence = ordered[0][0]
|
|
192
|
+
cursor = start_sequence
|
|
193
|
+
probe = bytearray()
|
|
194
|
+
for sequence, segment in ordered:
|
|
195
|
+
if sequence > cursor:
|
|
196
|
+
break
|
|
197
|
+
overlap = max(0, cursor - sequence)
|
|
198
|
+
if overlap >= len(segment.data):
|
|
199
|
+
continue
|
|
200
|
+
remaining = segment.data[overlap:]
|
|
201
|
+
capacity = _INITIAL_ANCHOR_PROBE_BYTES - len(probe)
|
|
202
|
+
if capacity <= 0:
|
|
203
|
+
break
|
|
204
|
+
probe.extend(remaining[:capacity])
|
|
205
|
+
cursor = max(cursor, sequence + len(segment.data))
|
|
206
|
+
if len(remaining) > capacity:
|
|
207
|
+
break
|
|
208
|
+
return start_sequence, bytes(probe)
|
|
209
|
+
|
|
210
|
+
def _commit_unanchored(self, sequence: Optional[int] = None) -> None:
|
|
211
|
+
# Origin commitment is intentionally one-way. Bytes already delivered
|
|
212
|
+
# to a scanner cannot later be retracted and joined to an older prefix.
|
|
213
|
+
if not self.unanchored:
|
|
214
|
+
return
|
|
215
|
+
ordered = self._ordered_unanchored()
|
|
216
|
+
if sequence is None:
|
|
217
|
+
sequence = ordered[0][0]
|
|
218
|
+
self._clear_unanchored()
|
|
219
|
+
self.next_sequence = sequence
|
|
220
|
+
for segment_sequence, segment in ordered:
|
|
221
|
+
self._add_anchored_segment(
|
|
222
|
+
segment_sequence,
|
|
223
|
+
segment.data,
|
|
224
|
+
segment.context,
|
|
225
|
+
scan_retransmission=False,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _clear_unanchored(self) -> None:
|
|
229
|
+
self.unanchored.clear()
|
|
230
|
+
self.unanchored_started_at = None
|
|
231
|
+
|
|
232
|
+
def _add_anchored_segment(
|
|
233
|
+
self,
|
|
234
|
+
sequence: int,
|
|
235
|
+
payload: bytes,
|
|
236
|
+
context: PacketContext,
|
|
237
|
+
*,
|
|
238
|
+
scan_retransmission: bool = True,
|
|
239
|
+
) -> None:
|
|
240
|
+
assert self.next_sequence is not None
|
|
241
|
+
sequence = _unwrap_tcp_sequence(sequence, self.next_sequence)
|
|
242
|
+
|
|
243
|
+
# Ignore bytes already delivered by an earlier copy/retransmission.
|
|
244
|
+
if sequence < self.next_sequence:
|
|
245
|
+
overlap = self.next_sequence - sequence
|
|
246
|
+
overlap_context = PacketContext(
|
|
247
|
+
timestamp=context.timestamp,
|
|
248
|
+
flow=context.flow,
|
|
249
|
+
stream_start=sequence,
|
|
250
|
+
flow_generation=context.flow_generation,
|
|
251
|
+
)
|
|
252
|
+
if overlap >= len(payload):
|
|
253
|
+
# Local Windows captures can occasionally present a complete
|
|
254
|
+
# earlier segment after a later sequence number. It cannot
|
|
255
|
+
# advance the reassembled stream, but it may still contain a
|
|
256
|
+
# self-contained target frame worth scanning.
|
|
257
|
+
if scan_retransmission:
|
|
258
|
+
self.scanner.scan_standalone(payload, overlap_context)
|
|
259
|
+
return
|
|
260
|
+
if scan_retransmission:
|
|
261
|
+
self.scanner.scan_standalone(payload[:overlap], overlap_context)
|
|
262
|
+
payload = payload[overlap:]
|
|
263
|
+
sequence = self.next_sequence
|
|
264
|
+
|
|
265
|
+
if sequence > self.next_sequence:
|
|
266
|
+
previous = self.pending.get(sequence)
|
|
267
|
+
if previous is None or len(payload) > len(previous.data):
|
|
268
|
+
self.pending[sequence] = PendingSegment(payload, context)
|
|
269
|
+
if self.gap_started_at is None:
|
|
270
|
+
self.gap_started_at = context.timestamp
|
|
271
|
+
|
|
272
|
+
# A local capture should rarely lose TCP segments. If it does, do
|
|
273
|
+
# not remain blocked forever: after a short gap, restart at the
|
|
274
|
+
# earliest available segment and let the target scanner resync.
|
|
275
|
+
if self._pending_capacity_exceeded(self.pending):
|
|
276
|
+
# One restart may expose another later gap whose retained
|
|
277
|
+
# segment is itself larger than the configured byte ceiling.
|
|
278
|
+
# Keep making bounded progress until the retained map is back
|
|
279
|
+
# within both limits. Every restart remains observable as
|
|
280
|
+
# capture loss through the ordinary gap-reset accounting.
|
|
281
|
+
while self._pending_capacity_exceeded(self.pending):
|
|
282
|
+
self._resume_after_gap()
|
|
283
|
+
else:
|
|
284
|
+
self.service_gaps(context.timestamp)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
self._deliver(payload, context)
|
|
288
|
+
self._flush_pending()
|
|
289
|
+
|
|
290
|
+
def _deliver(self, payload: bytes, context: PacketContext) -> None:
|
|
291
|
+
assert self.next_sequence is not None
|
|
292
|
+
delivery_context = PacketContext(
|
|
293
|
+
timestamp=context.timestamp,
|
|
294
|
+
flow=context.flow,
|
|
295
|
+
stream_start=self.next_sequence,
|
|
296
|
+
flow_generation=context.flow_generation,
|
|
297
|
+
)
|
|
298
|
+
self.scanner.feed(payload, delivery_context)
|
|
299
|
+
self.next_sequence += len(payload)
|
|
300
|
+
self.gap_started_at = None
|
|
301
|
+
|
|
302
|
+
def _flush_pending(self) -> None:
|
|
303
|
+
assert self.next_sequence is not None
|
|
304
|
+
|
|
305
|
+
while self.pending:
|
|
306
|
+
sequence = min(self.pending)
|
|
307
|
+
segment = self.pending[sequence]
|
|
308
|
+
|
|
309
|
+
if sequence > self.next_sequence:
|
|
310
|
+
if self.gap_started_at is None:
|
|
311
|
+
self.gap_started_at = min(
|
|
312
|
+
pending.context.timestamp
|
|
313
|
+
for pending in self.pending.values()
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
del self.pending[sequence]
|
|
318
|
+
payload = segment.data
|
|
319
|
+
|
|
320
|
+
if sequence < self.next_sequence:
|
|
321
|
+
overlap = self.next_sequence - sequence
|
|
322
|
+
if overlap >= len(payload):
|
|
323
|
+
continue
|
|
324
|
+
payload = payload[overlap:]
|
|
325
|
+
|
|
326
|
+
self._deliver(payload, segment.context)
|
|
327
|
+
|
|
328
|
+
def service_gaps(self, now: float) -> int:
|
|
329
|
+
"""Release gaps whose timeout elapsed, even without a new packet.
|
|
330
|
+
|
|
331
|
+
Returns the number of scanner resets performed. More than one reset
|
|
332
|
+
is possible when several independently missing ranges are already old
|
|
333
|
+
enough at the supplied clock value.
|
|
334
|
+
"""
|
|
335
|
+
resets = 0
|
|
336
|
+
if (
|
|
337
|
+
self.unanchored
|
|
338
|
+
and self.unanchored_started_at is not None
|
|
339
|
+
and now - self.unanchored_started_at
|
|
340
|
+
>= _INITIAL_REORDER_GRACE_SECONDS
|
|
341
|
+
):
|
|
342
|
+
self._commit_unanchored()
|
|
343
|
+
while (
|
|
344
|
+
self.pending
|
|
345
|
+
and self.gap_started_at is not None
|
|
346
|
+
and now - self.gap_started_at >= GAP_RESET_SECONDS
|
|
347
|
+
):
|
|
348
|
+
self._resume_after_gap()
|
|
349
|
+
resets += 1
|
|
350
|
+
self._start_fin_gap_timer_if_needed()
|
|
351
|
+
if (
|
|
352
|
+
not self.pending
|
|
353
|
+
and not self.unanchored
|
|
354
|
+
and self.fin_sequence is not None
|
|
355
|
+
and self.next_sequence is not None
|
|
356
|
+
and self.gap_started_at is not None
|
|
357
|
+
and now - self.gap_started_at >= GAP_RESET_SECONDS
|
|
358
|
+
):
|
|
359
|
+
fin_sequence = _unwrap_tcp_sequence(
|
|
360
|
+
self.fin_sequence,
|
|
361
|
+
self.next_sequence,
|
|
362
|
+
)
|
|
363
|
+
if self.next_sequence < fin_sequence:
|
|
364
|
+
self.scanner.reset()
|
|
365
|
+
if self.on_gap_reset is not None:
|
|
366
|
+
self.on_gap_reset()
|
|
367
|
+
self.next_sequence = fin_sequence
|
|
368
|
+
self.gap_started_at = None
|
|
369
|
+
resets += 1
|
|
370
|
+
return resets
|
|
371
|
+
|
|
372
|
+
def _resume_after_gap(self) -> None:
|
|
373
|
+
if not self.pending:
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
sequence = min(self.pending)
|
|
377
|
+
segment = self.pending.pop(sequence)
|
|
378
|
+
self.scanner.reset()
|
|
379
|
+
if self.on_gap_reset is not None:
|
|
380
|
+
self.on_gap_reset()
|
|
381
|
+
self.next_sequence = sequence
|
|
382
|
+
self.gap_started_at = None
|
|
383
|
+
self._deliver(segment.data, segment.context)
|
|
384
|
+
self._flush_pending()
|
|
385
|
+
|
|
386
|
+
def finish(self) -> None:
|
|
387
|
+
"""Drain segments still pending at end of capture.
|
|
388
|
+
|
|
389
|
+
Without this, a capture that ends during a sequence gap would strand
|
|
390
|
+
complete, decodable frames in ``pending`` forever; the gap timer only
|
|
391
|
+
fires when another packet arrives on the flow.
|
|
392
|
+
"""
|
|
393
|
+
self._commit_unanchored()
|
|
394
|
+
while self.pending:
|
|
395
|
+
self._resume_after_gap()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class FlowManager:
|
|
399
|
+
"""Route server-to-client TCP segments to per-flow reassembly state."""
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
*,
|
|
404
|
+
server_ports,
|
|
405
|
+
scanner_factory: Callable[[], StreamScanner],
|
|
406
|
+
max_flows: Optional[int] = None,
|
|
407
|
+
on_flow_eviction: Optional[Callable[[], None]] = None,
|
|
408
|
+
on_flow_close: Optional[Callable[[FlowKey], None]] = None,
|
|
409
|
+
idle_timeout: Optional[float] = None,
|
|
410
|
+
track_flow_generations: bool = False,
|
|
411
|
+
max_pending_segments: int = MAX_PENDING_SEGMENTS,
|
|
412
|
+
max_pending_bytes: Optional[int] = None,
|
|
413
|
+
) -> None:
|
|
414
|
+
if max_flows is not None and max_flows <= 0:
|
|
415
|
+
raise ValueError("max_flows must be positive or None")
|
|
416
|
+
if idle_timeout is not None and idle_timeout <= 0:
|
|
417
|
+
raise ValueError("idle_timeout must be positive or None")
|
|
418
|
+
if (
|
|
419
|
+
isinstance(max_pending_segments, bool)
|
|
420
|
+
or not isinstance(max_pending_segments, int)
|
|
421
|
+
):
|
|
422
|
+
raise TypeError("max_pending_segments must be an integer")
|
|
423
|
+
if max_pending_segments <= 0:
|
|
424
|
+
raise ValueError("max_pending_segments must be positive")
|
|
425
|
+
if max_pending_bytes is not None and (
|
|
426
|
+
isinstance(max_pending_bytes, bool)
|
|
427
|
+
or not isinstance(max_pending_bytes, int)
|
|
428
|
+
):
|
|
429
|
+
raise TypeError("max_pending_bytes must be an integer or None")
|
|
430
|
+
if max_pending_bytes is not None and max_pending_bytes <= 0:
|
|
431
|
+
raise ValueError("max_pending_bytes must be positive or None")
|
|
432
|
+
self.server_ports = frozenset(server_ports)
|
|
433
|
+
self._scanner_factory = scanner_factory
|
|
434
|
+
self._max_flows = max_flows
|
|
435
|
+
self._on_flow_eviction = on_flow_eviction
|
|
436
|
+
self._on_flow_close = on_flow_close
|
|
437
|
+
self._idle_timeout = idle_timeout
|
|
438
|
+
self._track_flow_generations = track_flow_generations
|
|
439
|
+
self._max_pending_segments = max_pending_segments
|
|
440
|
+
self._max_pending_bytes = max_pending_bytes
|
|
441
|
+
self._next_flow_generation = 0
|
|
442
|
+
self._flows: OrderedDict[FlowKey, TCPFlowState] = OrderedDict()
|
|
443
|
+
self._tcp_gap_resets = 0
|
|
444
|
+
# Counter-only: never acquire _lock or protect callbacks/delivery here.
|
|
445
|
+
self._diagnostics_lock = Lock()
|
|
446
|
+
# Packet delivery and an eventual wall-clock service hook may run on
|
|
447
|
+
# different threads. Serialize both paths around the same flow state.
|
|
448
|
+
self._lock = RLock()
|
|
449
|
+
|
|
450
|
+
def _new_flow_state(self) -> TCPFlowState:
|
|
451
|
+
generation = 0
|
|
452
|
+
if self._track_flow_generations:
|
|
453
|
+
self._next_flow_generation += 1
|
|
454
|
+
generation = self._next_flow_generation
|
|
455
|
+
return TCPFlowState(
|
|
456
|
+
scanner=self._scanner_factory(),
|
|
457
|
+
max_pending_segments=self._max_pending_segments,
|
|
458
|
+
max_pending_bytes=self._max_pending_bytes,
|
|
459
|
+
generation=generation,
|
|
460
|
+
on_gap_reset=self._record_gap_reset,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def _record_gap_reset(self) -> None:
|
|
464
|
+
with self._diagnostics_lock:
|
|
465
|
+
self._tcp_gap_resets += 1
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def tcp_gap_resets(self) -> int:
|
|
469
|
+
"""Total capture-gap recoveries, including flows already closed."""
|
|
470
|
+
with self._diagnostics_lock:
|
|
471
|
+
return self._tcp_gap_resets
|
|
472
|
+
|
|
473
|
+
def service_gaps(self, now: float) -> int:
|
|
474
|
+
"""Advance pending-gap timers using a caller-supplied wall clock.
|
|
475
|
+
|
|
476
|
+
``FlowManager`` does not own a timer thread. Live capture roots that
|
|
477
|
+
require wall-clock release should call this during their normal
|
|
478
|
+
poll/tick even when no packets arrived. The return value is the number
|
|
479
|
+
of new resets performed during this call; ``tcp_gap_resets`` is
|
|
480
|
+
cumulative.
|
|
481
|
+
"""
|
|
482
|
+
with self._lock:
|
|
483
|
+
before = self.tcp_gap_resets
|
|
484
|
+
completed_fin_flows: list[FlowKey] = []
|
|
485
|
+
for flow, state in self._flows.items():
|
|
486
|
+
state.service_gaps(now)
|
|
487
|
+
if state.fin_is_reassembled():
|
|
488
|
+
completed_fin_flows.append(flow)
|
|
489
|
+
for flow in completed_fin_flows:
|
|
490
|
+
state = self._flows.pop(flow)
|
|
491
|
+
state.finish()
|
|
492
|
+
self._notify_flow_close(flow)
|
|
493
|
+
if self._idle_timeout is not None:
|
|
494
|
+
expired = [
|
|
495
|
+
flow
|
|
496
|
+
for flow, state in self._flows.items()
|
|
497
|
+
if state.last_activity_at is not None
|
|
498
|
+
and now - state.last_activity_at >= self._idle_timeout
|
|
499
|
+
]
|
|
500
|
+
for flow in expired:
|
|
501
|
+
state = self._flows.pop(flow)
|
|
502
|
+
state.finish()
|
|
503
|
+
self._notify_flow_close(flow)
|
|
504
|
+
return self.tcp_gap_resets - before
|
|
505
|
+
|
|
506
|
+
def _notify_flow_close(self, flow: FlowKey) -> None:
|
|
507
|
+
if self._on_flow_close is not None:
|
|
508
|
+
self._on_flow_close(flow)
|
|
509
|
+
|
|
510
|
+
def process_tcp_segment(
|
|
511
|
+
self,
|
|
512
|
+
*,
|
|
513
|
+
source_ip: str,
|
|
514
|
+
source_port: int,
|
|
515
|
+
destination_ip: str,
|
|
516
|
+
destination_port: int,
|
|
517
|
+
sequence: int,
|
|
518
|
+
payload: bytes,
|
|
519
|
+
timestamp: float,
|
|
520
|
+
syn: bool = False,
|
|
521
|
+
rst: bool = False,
|
|
522
|
+
fin: bool = False,
|
|
523
|
+
) -> None:
|
|
524
|
+
with self._lock:
|
|
525
|
+
self._process_tcp_segment(
|
|
526
|
+
source_ip=source_ip,
|
|
527
|
+
source_port=source_port,
|
|
528
|
+
destination_ip=destination_ip,
|
|
529
|
+
destination_port=destination_port,
|
|
530
|
+
sequence=sequence,
|
|
531
|
+
payload=payload,
|
|
532
|
+
timestamp=timestamp,
|
|
533
|
+
syn=syn,
|
|
534
|
+
rst=rst,
|
|
535
|
+
fin=fin,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _process_tcp_segment(
|
|
539
|
+
self,
|
|
540
|
+
*,
|
|
541
|
+
source_ip: str,
|
|
542
|
+
source_port: int,
|
|
543
|
+
destination_ip: str,
|
|
544
|
+
destination_port: int,
|
|
545
|
+
sequence: int,
|
|
546
|
+
payload: bytes,
|
|
547
|
+
timestamp: float,
|
|
548
|
+
syn: bool = False,
|
|
549
|
+
rst: bool = False,
|
|
550
|
+
fin: bool = False,
|
|
551
|
+
) -> None:
|
|
552
|
+
# The observed item events are server-to-client and use a game-server
|
|
553
|
+
# source port. Ignore all other traffic.
|
|
554
|
+
if source_port not in self.server_ports:
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
flow = FlowKey(
|
|
558
|
+
source_ip=source_ip,
|
|
559
|
+
source_port=source_port,
|
|
560
|
+
destination_ip=destination_ip,
|
|
561
|
+
destination_port=destination_port,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
known_flow = flow in self._flows
|
|
565
|
+
# A capture may contain an ACK or a late FIN/RST for a connection that
|
|
566
|
+
# began before capture or whose state was already closed/evicted. Such
|
|
567
|
+
# a packet cannot contribute stream bytes, so it must not consume a
|
|
568
|
+
# bounded flow slot (or evict an active flow to make room).
|
|
569
|
+
if not known_flow and not payload and not syn:
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
if not known_flow and (
|
|
573
|
+
self._max_flows is not None
|
|
574
|
+
and len(self._flows) >= self._max_flows
|
|
575
|
+
):
|
|
576
|
+
# OrderedDict order is updated on every observed packet, so the
|
|
577
|
+
# first entry is the least recently active connection.
|
|
578
|
+
oldest, evicted = self._flows.popitem(last=False)
|
|
579
|
+
if self._on_flow_eviction is not None:
|
|
580
|
+
self._on_flow_eviction()
|
|
581
|
+
evicted.finish()
|
|
582
|
+
self._notify_flow_close(oldest)
|
|
583
|
+
|
|
584
|
+
if syn and flow in self._flows:
|
|
585
|
+
previous = self._flows.pop(flow)
|
|
586
|
+
previous.finish()
|
|
587
|
+
self._notify_flow_close(flow)
|
|
588
|
+
|
|
589
|
+
if flow not in self._flows:
|
|
590
|
+
self._flows[flow] = self._new_flow_state()
|
|
591
|
+
|
|
592
|
+
state = self._flows[flow]
|
|
593
|
+
state.last_activity_at = timestamp
|
|
594
|
+
self._flows.move_to_end(flow)
|
|
595
|
+
if syn:
|
|
596
|
+
# SYN itself consumes one sequence number, including when it has
|
|
597
|
+
# no payload. This establishes the first credible stream anchor.
|
|
598
|
+
state.anchor_sequence((sequence + 1) & 0xFFFFFFFF)
|
|
599
|
+
if payload:
|
|
600
|
+
# SYN consumes one sequence number before any TCP Fast Open data.
|
|
601
|
+
payload_sequence = (sequence + (1 if syn else 0)) & 0xFFFFFFFF
|
|
602
|
+
state.add_segment(
|
|
603
|
+
sequence=payload_sequence,
|
|
604
|
+
payload=payload,
|
|
605
|
+
context=PacketContext(
|
|
606
|
+
timestamp=timestamp,
|
|
607
|
+
flow=flow,
|
|
608
|
+
flow_generation=state.generation,
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if not rst and state.fin_sequence is not None and state.fin_is_reassembled():
|
|
613
|
+
state.finish()
|
|
614
|
+
self._flows.pop(flow, None)
|
|
615
|
+
self._notify_flow_close(flow)
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if rst:
|
|
619
|
+
# RST is an abortive close; drain what is already available, then
|
|
620
|
+
# discard the connection state immediately.
|
|
621
|
+
state.finish()
|
|
622
|
+
self._flows.pop(flow, None)
|
|
623
|
+
self._notify_flow_close(flow)
|
|
624
|
+
elif fin:
|
|
625
|
+
# FIN can race ahead of earlier payload callbacks. Keep bounded
|
|
626
|
+
# provisional/gap state until all observed bytes through FIN are
|
|
627
|
+
# contiguous, rather than splitting one frame across two states.
|
|
628
|
+
fin_sequence = (
|
|
629
|
+
sequence + (1 if syn else 0) + len(payload)
|
|
630
|
+
) & 0xFFFFFFFF
|
|
631
|
+
state.mark_fin(fin_sequence, timestamp)
|
|
632
|
+
if state.fin_is_reassembled():
|
|
633
|
+
state.finish()
|
|
634
|
+
self._flows.pop(flow, None)
|
|
635
|
+
self._notify_flow_close(flow)
|
|
636
|
+
|
|
637
|
+
def finish(self) -> None:
|
|
638
|
+
with self._lock:
|
|
639
|
+
flows = tuple(self._flows.items())
|
|
640
|
+
self._flows.clear()
|
|
641
|
+
for flow, state in flows:
|
|
642
|
+
state.finish()
|
|
643
|
+
self._notify_flow_close(flow)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _unwrap_tcp_sequence(sequence: int, reference: int) -> int:
|
|
647
|
+
"""Map a 32-bit TCP sequence to the nearest absolute value to reference."""
|
|
648
|
+
raw = sequence & 0xFFFFFFFF
|
|
649
|
+
candidate = (reference & ~(TCP_SEQUENCE_MODULUS - 1)) | raw
|
|
650
|
+
if candidate - reference > TCP_SEQUENCE_HALF_RANGE:
|
|
651
|
+
candidate -= TCP_SEQUENCE_MODULUS
|
|
652
|
+
elif reference - candidate > TCP_SEQUENCE_HALF_RANGE:
|
|
653
|
+
candidate += TCP_SEQUENCE_MODULUS
|
|
654
|
+
return candidate
|