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.
Files changed (81) hide show
  1. {netprotocols-1.1.0 → netprotocols-1.2.0}/ARCHITECTURE.md +54 -11
  2. {netprotocols-1.1.0 → netprotocols-1.2.0}/CHANGELOG.md +20 -0
  3. {netprotocols-1.1.0 → netprotocols-1.2.0}/PKG-INFO +15 -5
  4. {netprotocols-1.1.0 → netprotocols-1.2.0}/README.md +14 -4
  5. {netprotocols-1.1.0 → netprotocols-1.2.0}/pyproject.toml +1 -1
  6. netprotocols-1.2.0/scripts/capture_fixtures_igmp.sh +66 -0
  7. {netprotocols-1.1.0 → netprotocols-1.2.0}/scripts/check_fixtures.py +8 -0
  8. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/__init__.py +5 -1
  9. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/checksum.py +5 -0
  10. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/__init__.py +2 -0
  11. netprotocols-1.2.0/src/netprotocols/layer3/igmp.py +95 -0
  12. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/ip.py +2 -0
  13. netprotocols-1.2.0/src/netprotocols/layer4/_ports.py +34 -0
  14. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer4/udp.py +9 -0
  15. netprotocols-1.2.0/src/netprotocols/layer7/__init__.py +3 -0
  16. netprotocols-1.2.0/src/netprotocols/layer7/dns.py +234 -0
  17. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/MANIFEST.md +3 -2
  18. netprotocols-1.2.0/tests/fixtures/igmp.pcap +0 -0
  19. netprotocols-1.2.0/tests/fixtures/staging/igmp.pcap +0 -0
  20. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_checksum.py +25 -21
  21. netprotocols-1.2.0/tests/test_dns.py +168 -0
  22. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_fuzz.py +18 -0
  23. netprotocols-1.2.0/tests/test_igmp.py +113 -0
  24. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_udp.py +9 -2
  25. {netprotocols-1.1.0 → netprotocols-1.2.0}/uv.lock +1 -1
  26. {netprotocols-1.1.0 → netprotocols-1.2.0}/.github/workflows/ci.yml +0 -0
  27. {netprotocols-1.1.0 → netprotocols-1.2.0}/.github/workflows/release.yml +0 -0
  28. {netprotocols-1.1.0 → netprotocols-1.2.0}/.gitignore +0 -0
  29. {netprotocols-1.1.0 → netprotocols-1.2.0}/LICENSE +0 -0
  30. {netprotocols-1.1.0 → netprotocols-1.2.0}/scripts/capture_fixtures.sh +0 -0
  31. {netprotocols-1.1.0 → netprotocols-1.2.0}/scripts/capture_fixtures_supplement.sh +0 -0
  32. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/_base.py +0 -0
  33. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/_enums.py +0 -0
  34. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer2/__init__.py +0 -0
  35. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer2/arp.py +0 -0
  36. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer2/ethernet.py +0 -0
  37. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/icmp.py +0 -0
  38. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer3/ipv6_ext.py +0 -0
  39. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer4/__init__.py +0 -0
  40. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/layer4/tcp.py +0 -0
  41. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/packet.py +0 -0
  42. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/py.typed +0 -0
  43. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/__init__.py +0 -0
  44. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/exceptions.py +0 -0
  45. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/ipv4.py +0 -0
  46. {netprotocols-1.1.0 → netprotocols-1.2.0}/src/netprotocols/utils/mac.py +0 -0
  47. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/conftest.py +0 -0
  48. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/arp_exchange.pcap +0 -0
  49. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  50. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv4_external.pcap +0 -0
  51. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  52. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  53. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
  54. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
  55. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv6_mld.pcap +0 -0
  56. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  57. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
  58. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  59. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  60. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  61. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  62. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  63. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  64. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  65. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  66. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
  67. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
  68. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
  69. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/tcp_http.pcap +0 -0
  70. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/tcp_https.pcap +0 -0
  71. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/fixtures/udp_dns.pcap +0 -0
  72. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_arp.py +0 -0
  73. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_contract.py +0 -0
  74. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_corpus.py +0 -0
  75. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_ethernet.py +0 -0
  76. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_icmp.py +0 -0
  77. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_ip.py +0 -0
  78. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_ipv6_ext.py +0 -0
  79. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_packet.py +0 -0
  80. {netprotocols-1.1.0 → netprotocols-1.2.0}/tests/test_tcp.py +0 -0
  81. {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 exception: **checksums are carried verbatim**. The
178
- library neither computes nor verifies them (yet — it's on the
179
- roadmap), which is exactly what you want when faithfully re-encoding
180
- captured traffic.
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
- ├── layer4/ tcp.py, udp.py
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` and one mapping entry in
225
- `ethernet.py`/`ip.py`, using a deferred import.)
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.1.0
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
- [Packet-Sniffer](https://github.com/EONRaider/Packet-Sniffer), a
154
- network traffic monitor built on it.
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, DNS, DHCP, IGMP, GRE.
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. Start with
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
- [Packet-Sniffer](https://github.com/EONRaider/Packet-Sniffer), a
129
- network traffic monitor built on it.
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, DNS, DHCP, IGMP, GRE.
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. Start with
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "netprotocols"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Low-level implementations of common networking protocols"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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.1.0"
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"``."""
@@ -0,0 +1,3 @@
1
+ from netprotocols.layer7.dns import DNS
2
+
3
+ __all__ = ["DNS"]