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,194 @@
|
|
|
1
|
+
"""Scapy-backed packet sources for live capture and pcap replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Iterator, Optional, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SegmentConsumer(Protocol):
|
|
11
|
+
"""Anything that accepts TCP segments (PacketEngine, FlowManager)."""
|
|
12
|
+
|
|
13
|
+
def process_tcp_segment(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
source_ip: str,
|
|
17
|
+
source_port: int,
|
|
18
|
+
destination_ip: str,
|
|
19
|
+
destination_port: int,
|
|
20
|
+
sequence: int,
|
|
21
|
+
payload: bytes,
|
|
22
|
+
timestamp: float,
|
|
23
|
+
syn: bool = False,
|
|
24
|
+
rst: bool = False,
|
|
25
|
+
fin: bool = False,
|
|
26
|
+
) -> None: ...
|
|
27
|
+
|
|
28
|
+
def finish(self) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CaptureTarget:
|
|
33
|
+
interface: Optional[str]
|
|
34
|
+
local_ip: Optional[str]
|
|
35
|
+
gateway: Optional[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def import_scapy():
|
|
39
|
+
try:
|
|
40
|
+
from scapy.interfaces import get_if_list # type: ignore
|
|
41
|
+
from scapy.layers.inet import IP, TCP # type: ignore
|
|
42
|
+
from scapy.sendrecv import sniff # type: ignore
|
|
43
|
+
from scapy.utils import PcapReader # type: ignore
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"Scapy is required for packet capture and pcap replay. "
|
|
47
|
+
"Install it with: pip install scapy"
|
|
48
|
+
) from exc
|
|
49
|
+
return IP, TCP, get_if_list, sniff, PcapReader
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_default_capture_target() -> CaptureTarget:
|
|
53
|
+
try:
|
|
54
|
+
from scapy.config import conf # type: ignore
|
|
55
|
+
|
|
56
|
+
route = conf.route.route("8.8.8.8")
|
|
57
|
+
except Exception:
|
|
58
|
+
return CaptureTarget(interface=None, local_ip=None, gateway=None)
|
|
59
|
+
|
|
60
|
+
interface = str(route[0]) if len(route) > 0 and route[0] else None
|
|
61
|
+
local_ip = str(route[1]) if len(route) > 1 and route[1] else None
|
|
62
|
+
gateway = str(route[2]) if len(route) > 2 and route[2] else None
|
|
63
|
+
|
|
64
|
+
if local_ip in {None, "0.0.0.0"}:
|
|
65
|
+
local_ip = None
|
|
66
|
+
if gateway in {None, "0.0.0.0"}:
|
|
67
|
+
gateway = None
|
|
68
|
+
|
|
69
|
+
return CaptureTarget(interface=interface, local_ip=local_ip, gateway=gateway)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_bpf_filter(ports: Iterable[int], local_ip: Optional[str] = None) -> str:
|
|
73
|
+
normalized_ports = validate_server_ports(ports)
|
|
74
|
+
port_expression = " or ".join(f"src port {port}" for port in normalized_ports)
|
|
75
|
+
bpf_filter = f"tcp and ({port_expression})"
|
|
76
|
+
if local_ip is not None:
|
|
77
|
+
bpf_filter += f" and dst host {local_ip}"
|
|
78
|
+
return bpf_filter
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_server_ports(ports: Iterable[int]) -> tuple[int, ...]:
|
|
82
|
+
"""Return unique validated TCP ports, preserving caller order."""
|
|
83
|
+
normalized: list[int] = []
|
|
84
|
+
for port in ports:
|
|
85
|
+
if isinstance(port, bool) or not isinstance(port, int):
|
|
86
|
+
raise ValueError(f"server port must be an integer, got {port!r}")
|
|
87
|
+
if not 1 <= port <= 65535:
|
|
88
|
+
raise ValueError(f"server port out of range: {port}")
|
|
89
|
+
if port not in normalized:
|
|
90
|
+
normalized.append(port)
|
|
91
|
+
if not normalized:
|
|
92
|
+
raise ValueError("at least one server port is required")
|
|
93
|
+
return tuple(normalized)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def make_packet_handler(engine: SegmentConsumer):
|
|
97
|
+
IP, TCP, _, _, _ = import_scapy()
|
|
98
|
+
|
|
99
|
+
def handle(packet) -> None:
|
|
100
|
+
if IP not in packet or TCP not in packet:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
ip = packet[IP]
|
|
104
|
+
tcp = packet[TCP]
|
|
105
|
+
payload = bytes(tcp.payload)
|
|
106
|
+
flags = int(tcp.flags)
|
|
107
|
+
|
|
108
|
+
engine.process_tcp_segment(
|
|
109
|
+
source_ip=str(ip.src),
|
|
110
|
+
source_port=int(tcp.sport),
|
|
111
|
+
destination_ip=str(ip.dst),
|
|
112
|
+
destination_port=int(tcp.dport),
|
|
113
|
+
sequence=int(tcp.seq),
|
|
114
|
+
payload=payload,
|
|
115
|
+
timestamp=float(packet.time),
|
|
116
|
+
syn=bool(flags & 0x02),
|
|
117
|
+
rst=bool(flags & 0x04),
|
|
118
|
+
fin=bool(flags & 0x01),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return handle
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def iter_pcap_file(path: Path, engine: SegmentConsumer) -> Iterator[None]:
|
|
125
|
+
"""Process one capture packet at a time, yielding after each packet.
|
|
126
|
+
|
|
127
|
+
The yield point lets public replay drain decoded events incrementally
|
|
128
|
+
instead of retaining the entire capture's results in memory.
|
|
129
|
+
"""
|
|
130
|
+
_, _, _, _, PcapReader = import_scapy()
|
|
131
|
+
from scapy.error import Scapy_Exception # type: ignore
|
|
132
|
+
|
|
133
|
+
handler = make_packet_handler(engine)
|
|
134
|
+
|
|
135
|
+
if not path.is_file():
|
|
136
|
+
raise FileNotFoundError(f"Capture file does not exist: {path}")
|
|
137
|
+
|
|
138
|
+
source = None
|
|
139
|
+
packets = None
|
|
140
|
+
try:
|
|
141
|
+
# Passing our own handle lets us close it even when PcapReader's
|
|
142
|
+
# constructor rejects the file. Some Scapy versions otherwise leave
|
|
143
|
+
# invalid captures locked on Windows.
|
|
144
|
+
source = path.open("rb")
|
|
145
|
+
packets = PcapReader(source)
|
|
146
|
+
except (OSError, ValueError, Scapy_Exception) as exc:
|
|
147
|
+
if source is not None:
|
|
148
|
+
try:
|
|
149
|
+
source.close()
|
|
150
|
+
except BaseException:
|
|
151
|
+
pass
|
|
152
|
+
raise ValueError(f"Could not read capture {path}: {exc}") from exc
|
|
153
|
+
|
|
154
|
+
active_error = False
|
|
155
|
+
close_error: BaseException | None = None
|
|
156
|
+
try:
|
|
157
|
+
while True:
|
|
158
|
+
try:
|
|
159
|
+
packet = next(packets)
|
|
160
|
+
except StopIteration:
|
|
161
|
+
break
|
|
162
|
+
except (OSError, ValueError, Scapy_Exception) as exc:
|
|
163
|
+
raise ValueError(f"Could not read capture {path}: {exc}") from exc
|
|
164
|
+
# Consumer/decoder failures are deliberately outside the reader
|
|
165
|
+
# error wrapper so callers receive the original exception object.
|
|
166
|
+
handler(packet)
|
|
167
|
+
yield None
|
|
168
|
+
except BaseException:
|
|
169
|
+
active_error = True
|
|
170
|
+
raise
|
|
171
|
+
finally:
|
|
172
|
+
if packets is not None:
|
|
173
|
+
try:
|
|
174
|
+
packets.close()
|
|
175
|
+
except BaseException as exc:
|
|
176
|
+
close_error = exc
|
|
177
|
+
if source is not None:
|
|
178
|
+
try:
|
|
179
|
+
source.close()
|
|
180
|
+
except BaseException as exc:
|
|
181
|
+
if close_error is None:
|
|
182
|
+
close_error = exc
|
|
183
|
+
if close_error is not None and not active_error:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Could not close capture {path}: {close_error}"
|
|
186
|
+
) from close_error
|
|
187
|
+
|
|
188
|
+
engine.finish()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def replay_pcap_file(path: Path, engine: SegmentConsumer) -> None:
|
|
192
|
+
"""Process an entire capture file eagerly (calibration convenience)."""
|
|
193
|
+
for _ in iter_pcap_file(path, engine):
|
|
194
|
+
pass
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Validated configuration objects for live packet capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ._capture_backend import validate_server_ports
|
|
10
|
+
from ._protocol import DEFAULT_SERVER_PORTS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PacketCaptureOptions:
|
|
15
|
+
"""Packet-acquisition settings shared by live decoding and calibration."""
|
|
16
|
+
|
|
17
|
+
interface: Optional[str] = None
|
|
18
|
+
local_ip: Optional[str] = None
|
|
19
|
+
ports: tuple[int, ...] = DEFAULT_SERVER_PORTS
|
|
20
|
+
use_bpf: bool = True
|
|
21
|
+
auto_local_ip: bool = True
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
object.__setattr__(self, "ports", validate_server_ports(self.ports))
|
|
25
|
+
if not isinstance(self.use_bpf, bool):
|
|
26
|
+
raise ValueError("use_bpf must be a boolean")
|
|
27
|
+
if not isinstance(self.auto_local_ip, bool):
|
|
28
|
+
raise ValueError("auto_local_ip must be a boolean")
|
|
29
|
+
if self.local_ip is not None:
|
|
30
|
+
try:
|
|
31
|
+
normalized_ip = str(ipaddress.IPv4Address(self.local_ip))
|
|
32
|
+
except ipaddress.AddressValueError as exc:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"local_ip must be an IPv4 address: {self.local_ip!r}"
|
|
35
|
+
) from exc
|
|
36
|
+
object.__setattr__(self, "local_ip", normalized_ip)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LiveCaptureOptions(PacketCaptureOptions):
|
|
41
|
+
"""Packet capture plus bounded packet/event buffering for live APIs.
|
|
42
|
+
|
|
43
|
+
Packets are handed off from the native capture callback to a decoder
|
|
44
|
+
worker through ``packet_queue_size``. Decoded events are then buffered
|
|
45
|
+
for the application through ``event_queue_size``. Keeping those two
|
|
46
|
+
bounds separate prevents a slow application callback from blocking the
|
|
47
|
+
native capture thread without making memory use unbounded.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
event_queue_size: int = 1024
|
|
51
|
+
packet_queue_size: int = 4096
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
super().__post_init__()
|
|
55
|
+
if (
|
|
56
|
+
isinstance(self.event_queue_size, bool)
|
|
57
|
+
or not isinstance(self.event_queue_size, int)
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("event_queue_size must be an integer")
|
|
60
|
+
if self.event_queue_size <= 0:
|
|
61
|
+
raise ValueError("event_queue_size must be greater than zero")
|
|
62
|
+
if (
|
|
63
|
+
isinstance(self.packet_queue_size, bool)
|
|
64
|
+
or not isinstance(self.packet_queue_size, int)
|
|
65
|
+
):
|
|
66
|
+
raise ValueError("packet_queue_size must be an integer")
|
|
67
|
+
if self.packet_queue_size <= 0:
|
|
68
|
+
raise ValueError("packet_queue_size must be greater than zero")
|