netprotocols 1.1.0__tar.gz → 1.2.0__tar.gz
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.
- {netprotocols-1.1.0 → netprotocols-1.2.0}/ARCHITECTURE.md +54 -11
- {netprotocols-1.1.0 → netprotocols-1.2.0}/CHANGELOG.md +20 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/PKG-INFO +15 -5
- {netprotocols-1.1.0 → netprotocols-1.2.0}/README.md +14 -4
- {netprotocols-1.1.0 → netprotocols-1.2.0}/pyproject.toml +1 -1
- netprotocols-1.2.0/scripts/capture_fixtures_igmp.sh +66 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/scripts/check_fixtures.py +8 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/__init__.py +5 -1
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/checksum.py +5 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/__init__.py +2 -0
- netprotocols-1.2.0/src/netprotocols/layer3/igmp.py +95 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/ip.py +2 -0
- netprotocols-1.2.0/src/netprotocols/layer4/_ports.py +34 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer4/udp.py +9 -0
- netprotocols-1.2.0/src/netprotocols/layer7/__init__.py +3 -0
- netprotocols-1.2.0/src/netprotocols/layer7/dns.py +234 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/MANIFEST.md +3 -2
- netprotocols-1.2.0/tests/fixtures/igmp.pcap +0 -0
- netprotocols-1.2.0/tests/fixtures/staging/igmp.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_checksum.py +25 -21
- netprotocols-1.2.0/tests/test_dns.py +168 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_fuzz.py +18 -0
- netprotocols-1.2.0/tests/test_igmp.py +113 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_udp.py +9 -2
- {netprotocols-1.1.0 → netprotocols-1.2.0}/uv.lock +1 -1
- {netprotocols-1.1.0 → netprotocols-1.2.0}/.github/workflows/ci.yml +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/.github/workflows/release.yml +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/.gitignore +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/LICENSE +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/scripts/capture_fixtures.sh +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/scripts/capture_fixtures_supplement.sh +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/_base.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/_enums.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer2/__init__.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer2/arp.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer2/ethernet.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/icmp.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/ipv6_ext.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer4/__init__.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer4/tcp.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/packet.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/py.typed +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/__init__.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/exceptions.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/ipv4.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/mac.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/conftest.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/arp_exchange.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv4_external.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv6_mld.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/tcp_http.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/tcp_https.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/udp_dns.pcap +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_arp.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_contract.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_corpus.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_ethernet.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_icmp.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_ip.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_ipv6_ext.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_packet.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_tcp.py +0 -0
- {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_utils.py +0 -0
|
@@ -158,6 +158,26 @@ the graph acyclic: shared registries live in the dependency-free
|
|
|
158
158
|
`_enums.py`, and `next_protocol()` mappings import their target
|
|
159
159
|
classes *inside the function body*, at call time.
|
|
160
160
|
|
|
161
|
+
## Port-based dispatch is best-effort
|
|
162
|
+
|
|
163
|
+
The EtherType and IP-protocol registries dispatch on a single
|
|
164
|
+
authoritative field. Application protocols break that model: they are
|
|
165
|
+
identified by *port*, which is a heuristic — any service may run on any
|
|
166
|
+
port, and the discriminator is split across the source and destination
|
|
167
|
+
ports. `UDP.next_protocol()` therefore consults a well-known-port
|
|
168
|
+
registry (`netprotocols.layer4._ports`), checking the destination port
|
|
169
|
+
first (a request targets the server's port) and then the source port
|
|
170
|
+
(a response comes from it). `DNS` is the first such protocol; a DNS
|
|
171
|
+
message on UDP port 53 now decodes as a fourth layer.
|
|
172
|
+
|
|
173
|
+
Because the guess can be wrong, the application class validates
|
|
174
|
+
strictly on decode: a non-DNS datagram that happens to use port 53
|
|
175
|
+
raises a `ProtocolError`, which the ordinary decode-error path absorbs
|
|
176
|
+
(the chain keeps the layers it did decode and records the failure). So
|
|
177
|
+
a mis-dispatch degrades to a diagnosed frame, never to garbage. Only
|
|
178
|
+
UDP is wired this way for now — DNS over TCP carries a 2-byte length
|
|
179
|
+
prefix (RFC 1035 §4.2.2) that needs separate handling.
|
|
180
|
+
|
|
161
181
|
## The encode side, and the round-trip guarantee
|
|
162
182
|
|
|
163
183
|
Every class also works in reverse. Constructors take the friendly
|
|
@@ -174,10 +194,14 @@ Protocol.decode(bytes(header)) == header # object → wire → object
|
|
|
174
194
|
bytes(Protocol.decode(raw)) == raw[:header_len] # wire → object → wire
|
|
175
195
|
```
|
|
176
196
|
|
|
177
|
-
One deliberate
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
One deliberate boundary: **decode and encode carry checksums
|
|
198
|
+
verbatim** — exactly what you want when faithfully re-encoding
|
|
199
|
+
captured traffic. Computing and verifying them is explicit, via
|
|
200
|
+
`netprotocols.checksum`: `compute()`/`verify()` handle the IPv4 header
|
|
201
|
+
checksum, ICMPv4, and TCP/UDP/ICMPv6 with their IPv4/IPv6
|
|
202
|
+
pseudo-headers, and `Packet.with_checksums(payload)` fills a whole
|
|
203
|
+
stack before sending. (The RFC 768 subtlety lives where it belongs:
|
|
204
|
+
the `0x0000 → 0xFFFF` substitution applies to UDP alone.)
|
|
181
205
|
|
|
182
206
|
`Packet` is the thin composition layer: it holds an ordered tuple of
|
|
183
207
|
headers and `bytes(packet)` joins them, ready for a raw socket.
|
|
@@ -189,13 +213,24 @@ src/netprotocols/
|
|
|
189
213
|
├── __init__.py public API re-exports, __version__
|
|
190
214
|
├── _base.py Protocol ABC, decode contract, address helpers
|
|
191
215
|
├── _enums.py EtherType, IPProtocol, ARPOperation (imports nothing)
|
|
192
|
-
├── packet.py Packet composition
|
|
216
|
+
├── packet.py Packet composition, with_checksums()
|
|
217
|
+
├── checksum.py RFC 1071: internet_checksum, compute, verify
|
|
193
218
|
├── layer2/ ethernet.py, arp.py
|
|
194
|
-
├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6)
|
|
195
|
-
|
|
219
|
+
├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6),
|
|
220
|
+
│ igmp.py,
|
|
221
|
+
│ ipv6_ext.py (Hop-by-Hop, Routing, Fragment,
|
|
222
|
+
│ Destination Options)
|
|
223
|
+
├── layer4/ tcp.py, udp.py, _ports.py (well-known-port registry)
|
|
224
|
+
├── layer7/ dns.py
|
|
196
225
|
└── utils/ validators (mac.py, ipv4.py), exceptions.py
|
|
197
226
|
tests/ one file per protocol + test_contract.py
|
|
198
|
-
(truncation, lying lengths, chain walks)
|
|
227
|
+
(truncation, lying lengths, chain walks),
|
|
228
|
+
test_corpus.py (invariants over the real-capture
|
|
229
|
+
corpus in tests/fixtures/, 65 frames across 12
|
|
230
|
+
scenarios — see its MANIFEST.md), test_checksum.py
|
|
231
|
+
(corpus checksums recompute to wire values), and
|
|
232
|
+
test_fuzz.py (hypothesis properties: decode never
|
|
233
|
+
raises outside ProtocolError)
|
|
199
234
|
```
|
|
200
235
|
|
|
201
236
|
Tooling: [uv](https://docs.astral.sh/uv/) manages the environment
|
|
@@ -217,12 +252,20 @@ the same:
|
|
|
217
252
|
(raise `TruncatedHeaderError` when the buffer can't satisfy it) and
|
|
218
253
|
materialize the variable part as `bytes`.
|
|
219
254
|
3. **Implement `__bytes__()`.** Pack the same fields back; the
|
|
220
|
-
round-trip tests will hold you honest.
|
|
255
|
+
round-trip tests will hold you honest. When a payload is
|
|
256
|
+
self-referential — DNS names compress by pointing at earlier bytes,
|
|
257
|
+
so re-encoding a parsed record cannot reproduce the original — keep
|
|
258
|
+
that region as raw `bytes` and parse it through read-only accessors
|
|
259
|
+
that never re-encode. `bytes(decode(x)) == x` then holds by
|
|
260
|
+
construction (see `layer7/dns.py`).
|
|
221
261
|
4. **Wire the chain.** The layer below decides when your class is
|
|
222
262
|
next. For DNS that means UDP would implement `next_protocol()`
|
|
223
263
|
consulting the ports. (For a new EtherType or IP protocol number,
|
|
224
|
-
add the value to `_enums.py`
|
|
225
|
-
`ethernet.py`/`ip.py`, using
|
|
264
|
+
add the value to `_enums.py` — display name in lockstep, a test
|
|
265
|
+
enforces it — and one mapping entry in `ethernet.py`/`ip.py`, using
|
|
266
|
+
a deferred import. Numbers valid only inside an IPv6 chain follow
|
|
267
|
+
the extension headers' example: `_ip_protocol_class` hands them out
|
|
268
|
+
only when `ipv6=True`.)
|
|
226
269
|
5. **Add display properties** for anything a human would want rendered
|
|
227
270
|
(`flags_str`-style names, hex strings). Degrade gracefully on
|
|
228
271
|
unknown values — return `"unknown (47)"`, never raise from a
|
|
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.0] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **IGMP** (`netprotocols.IGMP`, RFC 1112/2236/3376) — IPv4 multicast
|
|
12
|
+
group management, dispatched from IPv4 protocol 2. The common
|
|
13
|
+
four-byte header (type, max-response code, checksum) is decoded; the
|
|
14
|
+
`group_address` accessor reads the group for the message types that
|
|
15
|
+
carry one, and `netprotocols.checksum` gains an IGMP arm. v3
|
|
16
|
+
group-record parsing is a roadmap follow-up.
|
|
17
|
+
- **DNS** (`netprotocols.DNS`, RFC 1035) — the first application-layer
|
|
18
|
+
protocol. The 12-byte header and flag bits are decoded; the four
|
|
19
|
+
message sections are kept raw with on-demand, read-only accessors
|
|
20
|
+
(`question_name`, `question_type`, `question_class`) that decompress
|
|
21
|
+
names safely (bounded pointer-following; malformed or looping names
|
|
22
|
+
raise `InvalidFieldError`, never hang). Round-trip stays byte-exact.
|
|
23
|
+
- **Port-based dispatch** on UDP: `UDP.next_protocol()` reaches an
|
|
24
|
+
application protocol by well-known port (`netprotocols.layer4._ports`,
|
|
25
|
+
`{53: DNS}` for now), checked destination-then-source. Documented as
|
|
26
|
+
best-effort — a mis-dispatch degrades to the malformed-frame path.
|
|
27
|
+
|
|
8
28
|
## [1.1.0] - 2026-08-29
|
|
9
29
|
|
|
10
30
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: netprotocols
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Low-level implementations of common networking protocols
|
|
5
5
|
Project-URL: Homepage, https://github.com/EONRaider/NETProtocols
|
|
6
6
|
Project-URL: Repository, https://github.com/EONRaider/NETProtocols
|
|
@@ -74,8 +74,10 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
|
|
|
74
74
|
| 3 | IPv6 Destination Options | `IPv6DestinationOptions` | RFC 8200 §4.6 |
|
|
75
75
|
| 3 | ICMPv4 | `ICMPv4` | RFC 792, 8-byte header |
|
|
76
76
|
| 3 | ICMPv6 | `ICMPv6` | RFC 4443, 8-byte header |
|
|
77
|
+
| 3 | IGMP | `IGMP` | RFC 1112/2236/3376, IPv4 multicast management |
|
|
77
78
|
| 4 | TCP | `TCP` | RFC 9293, data-offset/options aware |
|
|
78
79
|
| 4 | UDP | `UDP` | RFC 768 |
|
|
80
|
+
| 7 | DNS | `DNS` | RFC 1035, over UDP; header + on-demand name decompression |
|
|
79
81
|
|
|
80
82
|
## Decoding a captured frame
|
|
81
83
|
|
|
@@ -150,18 +152,26 @@ byte layout maps onto a dataclass, the decode contract, the
|
|
|
150
152
|
`next_protocol()` chain, and a cookbook for adding a new protocol.
|
|
151
153
|
|
|
152
154
|
This library is the engine behind
|
|
153
|
-
[
|
|
154
|
-
|
|
155
|
+
[RootWire](https://github.com/EONRaider/RootWire), a network traffic
|
|
156
|
+
monitor built on it (formerly Packet-Sniffer).
|
|
155
157
|
|
|
156
158
|
## Roadmap
|
|
157
159
|
|
|
158
|
-
- 802.1Q VLAN tags,
|
|
160
|
+
- More protocols: 802.1Q VLAN tags, DHCP, GRE (DNS and IGMP shipped in 1.2).
|
|
161
|
+
- Full DNS resource-record parsing (the header and question are decoded today; answer/authority/additional sections are kept raw).
|
|
162
|
+
- IGMPv3 group-record parsing (the common header is decoded today; v3 report records are kept raw in the body).
|
|
163
|
+
- Optional richer address accessors (`ipaddress` / EUI objects
|
|
164
|
+
alongside the `str` fields).
|
|
165
|
+
- TLV parsing inside IPv6 extension-header options.
|
|
159
166
|
|
|
160
167
|
## Contributing
|
|
161
168
|
|
|
162
169
|
Development uses [uv](https://docs.astral.sh/uv/): `uv sync`, then
|
|
163
170
|
`uv run pytest`, `uv run mypy`, and `uv run ruff check` — all three are
|
|
164
|
-
enforced by CI on Python 3.12–3.14.
|
|
171
|
+
enforced by CI on Python 3.12–3.14. The test suite is anchored by a
|
|
172
|
+
65-frame corpus of real captured traffic
|
|
173
|
+
([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md)) plus
|
|
174
|
+
property-based fuzzing of the decode path. Start with
|
|
165
175
|
[ARCHITECTURE.md](ARCHITECTURE.md).
|
|
166
176
|
|
|
167
177
|
## License
|
|
@@ -49,8 +49,10 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
|
|
|
49
49
|
| 3 | IPv6 Destination Options | `IPv6DestinationOptions` | RFC 8200 §4.6 |
|
|
50
50
|
| 3 | ICMPv4 | `ICMPv4` | RFC 792, 8-byte header |
|
|
51
51
|
| 3 | ICMPv6 | `ICMPv6` | RFC 4443, 8-byte header |
|
|
52
|
+
| 3 | IGMP | `IGMP` | RFC 1112/2236/3376, IPv4 multicast management |
|
|
52
53
|
| 4 | TCP | `TCP` | RFC 9293, data-offset/options aware |
|
|
53
54
|
| 4 | UDP | `UDP` | RFC 768 |
|
|
55
|
+
| 7 | DNS | `DNS` | RFC 1035, over UDP; header + on-demand name decompression |
|
|
54
56
|
|
|
55
57
|
## Decoding a captured frame
|
|
56
58
|
|
|
@@ -125,18 +127,26 @@ byte layout maps onto a dataclass, the decode contract, the
|
|
|
125
127
|
`next_protocol()` chain, and a cookbook for adding a new protocol.
|
|
126
128
|
|
|
127
129
|
This library is the engine behind
|
|
128
|
-
[
|
|
129
|
-
|
|
130
|
+
[RootWire](https://github.com/EONRaider/RootWire), a network traffic
|
|
131
|
+
monitor built on it (formerly Packet-Sniffer).
|
|
130
132
|
|
|
131
133
|
## Roadmap
|
|
132
134
|
|
|
133
|
-
- 802.1Q VLAN tags,
|
|
135
|
+
- More protocols: 802.1Q VLAN tags, DHCP, GRE (DNS and IGMP shipped in 1.2).
|
|
136
|
+
- Full DNS resource-record parsing (the header and question are decoded today; answer/authority/additional sections are kept raw).
|
|
137
|
+
- IGMPv3 group-record parsing (the common header is decoded today; v3 report records are kept raw in the body).
|
|
138
|
+
- Optional richer address accessors (`ipaddress` / EUI objects
|
|
139
|
+
alongside the `str` fields).
|
|
140
|
+
- TLV parsing inside IPv6 extension-header options.
|
|
134
141
|
|
|
135
142
|
## Contributing
|
|
136
143
|
|
|
137
144
|
Development uses [uv](https://docs.astral.sh/uv/): `uv sync`, then
|
|
138
145
|
`uv run pytest`, `uv run mypy`, and `uv run ruff check` — all three are
|
|
139
|
-
enforced by CI on Python 3.12–3.14.
|
|
146
|
+
enforced by CI on Python 3.12–3.14. The test suite is anchored by a
|
|
147
|
+
65-frame corpus of real captured traffic
|
|
148
|
+
([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md)) plus
|
|
149
|
+
property-based fuzzing of the decode path. Start with
|
|
140
150
|
[ARCHITECTURE.md](ARCHITECTURE.md).
|
|
141
151
|
|
|
142
152
|
## License
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Capture an IGMP fixture for the corpus (plan item I-2).
|
|
3
|
+
#
|
|
4
|
+
# sudo scripts/capture_fixtures_igmp.sh
|
|
5
|
+
#
|
|
6
|
+
# IGMP is IPv4 multicast group management — it never appeared in the
|
|
7
|
+
# original corpus (which provoked IPv6 MLD, not IPv4 IGMP). Joining an
|
|
8
|
+
# IPv4 multicast group makes the kernel emit a membership report
|
|
9
|
+
# (IGMPv2 type 0x16 or IGMPv3 type 0x22, depending on the host's
|
|
10
|
+
# configured version); a querier on the LAN may also produce a
|
|
11
|
+
# membership query (type 0x11). This captures `ip proto 2` on the
|
|
12
|
+
# default interface while joining a group, then validates.
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
if [[ $EUID -ne 0 ]]; then
|
|
17
|
+
echo "This script needs root for tcpdump (or run it via sudo)." >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
command -v tcpdump >/dev/null || { echo "tcpdump is required." >&2; exit 1; }
|
|
22
|
+
|
|
23
|
+
OUT="tests/fixtures/staging"
|
|
24
|
+
mkdir -p "$OUT"
|
|
25
|
+
IFACE=$(ip route show default | awk '/default/ {print $5; exit}')
|
|
26
|
+
[[ -n $IFACE ]] || { echo "No default interface found." >&2; exit 1; }
|
|
27
|
+
echo "[*] Default interface: $IFACE"
|
|
28
|
+
|
|
29
|
+
join_group() {
|
|
30
|
+
python3 - "$IFACE" <<'PY' || true
|
|
31
|
+
import socket, struct, sys, time
|
|
32
|
+
group = "239.255.42.99" # an administratively-scoped group (RFC 2365)
|
|
33
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
34
|
+
mreq = socket.inet_aton(group) + socket.inet_aton("0.0.0.0")
|
|
35
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
36
|
+
time.sleep(3) # report is emitted on join; hold briefly
|
|
37
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq)
|
|
38
|
+
sys.stderr.write(f"joined and left {group} on {sys.argv[1]}\n")
|
|
39
|
+
PY
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
echo "[>] igmp (join 239.255.42.99 to provoke a membership report)"
|
|
43
|
+
tcpdump -i "$IFACE" -c 4 -w "$OUT/igmp.pcap" --immediate-mode -q \
|
|
44
|
+
ip proto 2 >/dev/null 2>&1 &
|
|
45
|
+
pid=$!
|
|
46
|
+
sleep 1
|
|
47
|
+
join_group
|
|
48
|
+
for _ in $(seq 15); do kill -0 $pid 2>/dev/null || break; sleep 1; done
|
|
49
|
+
kill $pid 2>/dev/null || true
|
|
50
|
+
wait $pid 2>/dev/null || true
|
|
51
|
+
|
|
52
|
+
echo
|
|
53
|
+
echo "[*] Validating..."
|
|
54
|
+
CHECKER_PY=$(command -v python3)
|
|
55
|
+
if [[ -n ${SUDO_USER:-} ]]; then
|
|
56
|
+
chown -R "$SUDO_USER" "$OUT"
|
|
57
|
+
sudo -u "$SUDO_USER" "$CHECKER_PY" scripts/check_fixtures.py "$OUT"
|
|
58
|
+
else
|
|
59
|
+
"$CHECKER_PY" scripts/check_fixtures.py "$OUT"
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
echo
|
|
63
|
+
echo "[i] If no frames were captured, your host may emit IGMPv3 only when"
|
|
64
|
+
echo " a querier is present, or multicast may be disabled on $IFACE."
|
|
65
|
+
echo " The igmp.pcap in staging (if non-empty and OK above) is ready to"
|
|
66
|
+
echo " fold into the corpus."
|
|
@@ -98,6 +98,14 @@ def check_frame(frame: bytes, stats: Counter) -> list[str]:
|
|
|
98
98
|
!= struct.unpack_from("!H", upper, 2)[0]
|
|
99
99
|
):
|
|
100
100
|
failures.append("icmpv4-checksum")
|
|
101
|
+
elif proto == 2 and upper_len >= 8:
|
|
102
|
+
stats["igmp"] += 1
|
|
103
|
+
# IGMP: whole message, checksum at bytes 2-3, no pseudo-header.
|
|
104
|
+
if (
|
|
105
|
+
internet_checksum(upper[:2] + b"\x00\x00" + upper[4:])
|
|
106
|
+
!= struct.unpack_from("!H", upper, 2)[0]
|
|
107
|
+
):
|
|
108
|
+
failures.append("igmp-checksum")
|
|
101
109
|
elif proto == 6 and upper_len >= 20:
|
|
102
110
|
stats["tcp"] += 1
|
|
103
111
|
zeroed = upper[:16] + b"\x00\x00" + upper[18:]
|
|
@@ -16,6 +16,7 @@ from netprotocols.checksum import compute, internet_checksum, verify
|
|
|
16
16
|
from netprotocols.layer2.arp import ARP
|
|
17
17
|
from netprotocols.layer2.ethernet import Ethernet
|
|
18
18
|
from netprotocols.layer3.icmp import ICMPv4, ICMPv6
|
|
19
|
+
from netprotocols.layer3.igmp import IGMP
|
|
19
20
|
from netprotocols.layer3.ip import IPv4, IPv6
|
|
20
21
|
from netprotocols.layer3.ipv6_ext import (
|
|
21
22
|
IPv6DestinationOptions,
|
|
@@ -25,6 +26,7 @@ from netprotocols.layer3.ipv6_ext import (
|
|
|
25
26
|
)
|
|
26
27
|
from netprotocols.layer4.tcp import TCP
|
|
27
28
|
from netprotocols.layer4.udp import UDP
|
|
29
|
+
from netprotocols.layer7.dns import DNS
|
|
28
30
|
from netprotocols.packet import Packet
|
|
29
31
|
from netprotocols.utils.exceptions import (
|
|
30
32
|
InvalidFieldError,
|
|
@@ -37,10 +39,12 @@ from netprotocols.utils.exceptions import (
|
|
|
37
39
|
from netprotocols.utils.ipv4 import validate_ipv4_addr
|
|
38
40
|
from netprotocols.utils.mac import random_mac, validate_mac_addr
|
|
39
41
|
|
|
40
|
-
__version__ = "1.
|
|
42
|
+
__version__ = "1.2.0"
|
|
41
43
|
|
|
42
44
|
__all__ = [
|
|
43
45
|
"ARP",
|
|
46
|
+
"DNS",
|
|
47
|
+
"IGMP",
|
|
44
48
|
"TCP",
|
|
45
49
|
"UDP",
|
|
46
50
|
"ARPHardwareType",
|
|
@@ -24,6 +24,7 @@ from netprotocols._base import (
|
|
|
24
24
|
)
|
|
25
25
|
from netprotocols._enums import IPProtocol
|
|
26
26
|
from netprotocols.layer3.icmp import ICMPv4, ICMPv6
|
|
27
|
+
from netprotocols.layer3.igmp import IGMP
|
|
27
28
|
from netprotocols.layer3.ip import IPv4, IPv6
|
|
28
29
|
from netprotocols.layer4.tcp import TCP
|
|
29
30
|
from netprotocols.layer4.udp import UDP
|
|
@@ -109,6 +110,10 @@ def compute(
|
|
|
109
110
|
return internet_checksum(_zeroed(bytes(layer), 10))
|
|
110
111
|
if isinstance(layer, ICMPv4):
|
|
111
112
|
return internet_checksum(_zeroed(bytes(layer), 2) + payload)
|
|
113
|
+
if isinstance(layer, IGMP):
|
|
114
|
+
# Whole message, checksum field (bytes 2-3) zeroed, no
|
|
115
|
+
# pseudo-header; the body is already part of bytes(layer).
|
|
116
|
+
return internet_checksum(_zeroed(bytes(layer), 2))
|
|
112
117
|
if isinstance(layer, ICMPv6):
|
|
113
118
|
enclosing = _require_ip(layer, ip)
|
|
114
119
|
segment = _zeroed(bytes(layer), 2) + payload
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from netprotocols.layer3.icmp import ICMPv4, ICMPv6
|
|
2
|
+
from netprotocols.layer3.igmp import IGMP
|
|
2
3
|
from netprotocols.layer3.ip import IPv4, IPv6
|
|
3
4
|
from netprotocols.layer3.ipv6_ext import (
|
|
4
5
|
IPv6DestinationOptions,
|
|
@@ -8,6 +9,7 @@ from netprotocols.layer3.ipv6_ext import (
|
|
|
8
9
|
)
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
12
|
+
"IGMP",
|
|
11
13
|
"ICMPv4",
|
|
12
14
|
"ICMPv6",
|
|
13
15
|
"IPv4",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""IGMP header — IPv4 multicast group management (RFC 1112/2236/3376).
|
|
2
|
+
|
|
3
|
+
IGMP rides directly on IPv4 (protocol 2) and never encapsulates another
|
|
4
|
+
protocol. The message types do not share one fixed layout — a v3
|
|
5
|
+
membership report replaces the group-address field with a record count
|
|
6
|
+
— so only the common first four bytes (type, max-response code,
|
|
7
|
+
checksum) are modelled as fields; the remainder is kept as raw
|
|
8
|
+
``body``. The ``group_address`` accessor reads bytes 4-7 as an IPv4
|
|
9
|
+
address for the message types that carry one there, and returns
|
|
10
|
+
``None`` otherwise. Parsing the v3 group-record array is a roadmap
|
|
11
|
+
follow-up.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from struct import Struct
|
|
18
|
+
from typing import ClassVar, Self
|
|
19
|
+
|
|
20
|
+
from netprotocols._base import Protocol, bytes_to_ipv4
|
|
21
|
+
|
|
22
|
+
__all__ = ["IGMP"]
|
|
23
|
+
|
|
24
|
+
#: Message types whose bytes 4-7 hold a group address (RFC 2236 §2;
|
|
25
|
+
#: RFC 3376 §4.1 for the v3 query). The v3 report (0x22) does not.
|
|
26
|
+
_GROUP_ADDRESS_TYPES = frozenset({0x11, 0x12, 0x16, 0x17})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class IGMP(Protocol):
|
|
31
|
+
"""An IGMP message.
|
|
32
|
+
|
|
33
|
+
:param type: Message type — ``0x11`` query, ``0x12`` v1 report,
|
|
34
|
+
``0x16`` v2 report, ``0x17`` leave group, ``0x22`` v3 report.
|
|
35
|
+
:param max_resp_code: Maximum response code (queries; 0 in reports).
|
|
36
|
+
:param checksum: Checksum over the whole IGMP message, carried
|
|
37
|
+
verbatim (compute/verify via :mod:`netprotocols.checksum`).
|
|
38
|
+
:param body: The message-type-specific remainder (group address, or
|
|
39
|
+
a v3 record count and group records), kept raw.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
type: int
|
|
43
|
+
max_resp_code: int
|
|
44
|
+
checksum: int
|
|
45
|
+
body: bytes = b""
|
|
46
|
+
|
|
47
|
+
_struct: ClassVar[Struct] = Struct("!BBH")
|
|
48
|
+
|
|
49
|
+
type_names: ClassVar[dict[int, str]] = {
|
|
50
|
+
0x11: "Membership Query",
|
|
51
|
+
0x12: "IGMPv1 Membership Report",
|
|
52
|
+
0x16: "IGMPv2 Membership Report",
|
|
53
|
+
0x17: "Leave Group",
|
|
54
|
+
0x22: "IGMPv3 Membership Report",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def decode(cls, data: bytes | memoryview) -> Self:
|
|
59
|
+
type_, max_resp_code, checksum = cls._unpack_fixed(data)
|
|
60
|
+
return cls(
|
|
61
|
+
type=type_,
|
|
62
|
+
max_resp_code=max_resp_code,
|
|
63
|
+
checksum=checksum,
|
|
64
|
+
body=bytes(data[cls._struct.size :]),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __bytes__(self) -> bytes:
|
|
68
|
+
return (
|
|
69
|
+
self._struct.pack(self.type, self.max_resp_code, self.checksum)
|
|
70
|
+
+ self.body
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def header_len(self) -> int:
|
|
75
|
+
# IGMP is the whole IP payload: consume it all (terminal).
|
|
76
|
+
return self._struct.size + len(self.body)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def type_name(self) -> str:
|
|
80
|
+
"""Display name of the message type, e.g. ``"Membership Query"``."""
|
|
81
|
+
return self.type_names.get(self.type, f"unknown ({self.type:#04x})")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def group_address(self) -> str | None:
|
|
85
|
+
"""The multicast group in dotted-decimal, for the message types
|
|
86
|
+
that carry it in bytes 4-7 (query, v1/v2 report, leave); ``None``
|
|
87
|
+
for the v3 report, which uses that space for a record count."""
|
|
88
|
+
if self.type in _GROUP_ADDRESS_TYPES and len(self.body) >= 4:
|
|
89
|
+
return bytes_to_ipv4(self.body[:4])
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def checksum_hex_str(self) -> str:
|
|
94
|
+
"""The checksum as a hexadecimal string, e.g. ``"0x0b3a"``."""
|
|
95
|
+
return f"{self.checksum:#06x}"
|
|
@@ -47,6 +47,7 @@ def _ip_protocol_class(number: int, *, ipv6: bool) -> type[Protocol] | None:
|
|
|
47
47
|
decode a Hop-by-Hop layer.
|
|
48
48
|
"""
|
|
49
49
|
from netprotocols.layer3.icmp import ICMPv4, ICMPv6
|
|
50
|
+
from netprotocols.layer3.igmp import IGMP
|
|
50
51
|
from netprotocols.layer3.ipv6_ext import (
|
|
51
52
|
IPv6DestinationOptions,
|
|
52
53
|
IPv6Fragment,
|
|
@@ -61,6 +62,7 @@ def _ip_protocol_class(number: int, *, ipv6: bool) -> type[Protocol] | None:
|
|
|
61
62
|
mapping: dict[int, type[Protocol]] = {
|
|
62
63
|
IPProtocol.HOPOPT: IPv6HopByHopOptions,
|
|
63
64
|
IPProtocol.ICMP: ICMPv4,
|
|
65
|
+
IPProtocol.IGMP: IGMP,
|
|
64
66
|
IPProtocol.IPV6_ROUTE: IPv6Routing,
|
|
65
67
|
IPProtocol.IPV6_FRAG: IPv6Fragment,
|
|
66
68
|
IPProtocol.IPV6_ICMP: ICMPv6,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Port-based application-protocol dispatch for the transport layer.
|
|
2
|
+
|
|
3
|
+
Unlike the EtherType and IP-protocol registries — where a single
|
|
4
|
+
authoritative field names what follows — application protocols are
|
|
5
|
+
identified by *port*, which is a heuristic: any service may run on any
|
|
6
|
+
port, and the discriminator is split across two fields (source and
|
|
7
|
+
destination). Dispatch therefore checks the destination port first (a
|
|
8
|
+
request targets the server's well-known port) and then the source port
|
|
9
|
+
(a response comes from it), and the resulting class is expected to
|
|
10
|
+
validate strictly on decode so that a wrong guess degrades to the
|
|
11
|
+
library's ordinary malformed-frame path rather than yielding garbage.
|
|
12
|
+
|
|
13
|
+
Only UDP is wired this round. TCP application protocols (DNS over TCP
|
|
14
|
+
carries a 2-byte length prefix, RFC 1035 §4.2.2) need their own
|
|
15
|
+
handling and are left for later.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from netprotocols._base import Protocol
|
|
21
|
+
|
|
22
|
+
__all__ = ["udp_app_class"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def udp_app_class(src_port: int, dst_port: int) -> type[Protocol] | None:
|
|
26
|
+
"""The class that decodes a UDP payload, chosen by well-known port.
|
|
27
|
+
|
|
28
|
+
Returns ``None`` when neither port names a known application
|
|
29
|
+
protocol — the common case, where the chain ends at UDP.
|
|
30
|
+
"""
|
|
31
|
+
from netprotocols.layer7.dns import DNS
|
|
32
|
+
|
|
33
|
+
registry: dict[int, type[Protocol]] = {53: DNS}
|
|
34
|
+
return registry.get(dst_port) or registry.get(src_port)
|
|
@@ -44,6 +44,15 @@ class UDP(Protocol):
|
|
|
44
44
|
self.src_port, self.dst_port, self.length, self.checksum
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
def next_protocol(self) -> type[Protocol] | None:
|
|
48
|
+
"""The application protocol carried by this datagram, chosen by
|
|
49
|
+
well-known port (best-effort — see
|
|
50
|
+
:mod:`netprotocols.layer4._ports`); ``None`` when neither port
|
|
51
|
+
is recognized."""
|
|
52
|
+
from netprotocols.layer4._ports import udp_app_class
|
|
53
|
+
|
|
54
|
+
return udp_app_class(self.src_port, self.dst_port)
|
|
55
|
+
|
|
47
56
|
@property
|
|
48
57
|
def checksum_hex_str(self) -> str:
|
|
49
58
|
"""The checksum as a hexadecimal string, e.g. ``"0x1c2a"``."""
|