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.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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")