Releases: EONRaider/NETProtocols
Release list
netprotocols 2.2.0
Added
- Typed accessors for every enum-backed field.
_enums.pydefines
EtherType,IPProtocol,ARPOperationandARPHardwareType, but
until now no decoded field exposed them — only the*_namedisplay
strings, and the enums themselves were used only for dispatch. Every
field with a fixed wire vocabulary now has an*_enumcompanion
alongside the existingintfield and its*_namestring, mirroring
thesrc/src_addressprecedent 1.3.0 established for IP addresses:
Ethernet.ethertype_enum,VLAN.ethertype_enum,GRE.protocol_enum,
IPv4.protocol_enum,IPv6.next_header_enum(and the three IPv6
extension headers that share the field:IPv6HopByHopOptions,
IPv6DestinationOptions,IPv6Routing,IPv6Fragment),
ARP.oper_enum,ARP.ptype_enum,ARP.htype_name/ARP.htype_enum
(new —ARPHardwareTypewas exported but referenced nowhere), and
DHCP.htype_name/DHCP.htype_enum. Each returnsNone— never
raises — for a wire value this library does not enumerate, so
bytes(decode(x)) == xis unaffected for unrecognized values; the
rawintfield stays canonical and round-trips regardless (#95). - IPv4, TCP and DHCP curate a short
__match_args__by hand. Their
full auto-generated positional form runs to 11-15 fields — unusable
positionally, since nobody writes, or gets right, a fourteen-slot
pattern.IPv4.__match_args__ == ('src', 'dst', 'protocol'),
TCP.__match_args__ == ('src_port', 'dst_port', 'flags'),
DHCP.__match_args__ == ('op', 'chaddr', 'yiaddr')— the two or
three fields someone draftingcase IPv4(...)actually reaches for.
Every other header (including the still-wideIPv6, at 8 fields)
keeps the plain auto-generated tuple, and keyword patterns
(case IPv4(protocol=6)) are unaffected either way and stay the
documented default.__match_args__is part of the public API once
documented, so the chosen order is pinned by
tests/test_pattern_matching.pyrather than left free to drift.
ARCHITECTURE.md anddocs/CLAIMS.mdare updated to describe this as
a deliberate, tested exception rather than the "this library never
hand-declares__match_args__" absolute they stated before (#94). - DNS resource records expose typed RDATA, and every question, not
just the first.DNSResourceRecord.rdata_textdecoded A/AAAA/MX/
SOA/etc. into a display string only; a newrdata_valuefield adds
the typed decoding alongside it (rdata_textis unchanged) —
ipaddress.IPv4Address/IPv6Addressfor A/AAAA, the decompressed
targetstrfor NS/CNAME/PTR, a newMXRecord(preference, exchange)
for MX, a newSOARecord(mname, rname, serial, refresh, retry, expire, minimum)for SOA,list[str]of character-strings for TXT,
andNone— never raises — for the types this library does not
decode. Computed eagerly at parse time, likerdata_textalways has
been: a name inside RDATA (a CNAME's target, for instance) can use
DNS compression against the whole message, so decoding it needs
the same message-wide contextrdata_textalready required, which a
property computed lazily fromrdataalone could not resolve.
Separately,DNS.questionsadds atuple[DNSQuestion, ...]walking
every entry of the question section —question_name/question_type
/question_classexposed only the first and are unchanged (#96). - A typed
DHCPOption, alongsideDHCP.option_map.option_map
was the only accessor in the library returning a bare
dict[int, bytes]rather than typed objects; it is unchanged (still
returns exactly what it always did). A newparsed_optionsproperty
wraps the same, already RFC-3396-concatenated, mapping into a
tuple[DHCPOption, ...], mirroringTCPOption/IPv4Option:code,
data,code_name, and a decoded.valuefor the option codes this
library understands — a singleipaddress.IPv4Addressfor Subnet
Mask (1)/Requested IP Address (50)/Server Identifier (54), a tuple of
one or more for Router (3)/Domain Name Server (6) (RFC 2132 permits
repeating either), the integer seconds for IP Address Lease Time
(51), the raw byte for Message Type (53).None— never raises —
for codes this library does not decode and malformed option data
(#96). IPv4Option.valuedecodes Record Route, Timestamp and Router
Alert.IPv4Optionhadkind_nameand no.value— the three
common kinds (RFC 791 §3.1, RFC 2113) were named but their contents
left raw, unlikeTCPOption, which already decodes its values.
Record Route (7) decodes to atuple[ipaddress.IPv4Address, ...]of
the addresses recorded so far — the option's own pointer byte says
how many of the address slots are actually filled, not the option's
total length. Timestamp (68) decodes totuple[int, ...]of plain
millisecond timestamps when its flag selects that shape, or
tuple[tuple[ipaddress.IPv4Address, int], ...]of (address,
timestamp) pairs when the flag says each entry carries one — the
overflow counter and pointer are not decoded, readdataraw for
those. Router Alert (148) decodes to the 2-byte value asint.
None— never raises — for every other kind and for malformed data
on one of these three (a bad pointer, an unrecognized Timestamp
flag, a short buffer) (#96).IPv6Routing.segmentsandIPv6Option.valuefor Router Alert /
Jumbo Payload.IPv6Routing.datawas entirely unparsed — no
segment-list extraction for any routing type. RH0 (routing_type
0, deprecated by RFC 5095 but still seen — RFC 2460 §4.4) and
Mobile IPv6 (2, RFC 6275 §6.4) both store a 4-byte reserved field
followed by one IPv6 address per segment;segmentsdecodes that
into atuple[ipaddress.IPv6Address, ...]. RPL Source Routing (3,
RFC 6554) is deliberately not decoded: RFC 6554 §3 elides a
shared prefix from each intermediate address relative to the
enclosing packet's destination address, context this
one-extension-header accessor does not have —datastays available
raw.Nonefor every other routing type and for malformed address
data. Separately,IPv6Option.value(Hop-by-Hop / Destination
Options TLVs) now decodes Router Alert (5, RFC 2711 §2.1) and Jumbo
Payload (194, RFC 2675 §2) into typedints, the last of #96's four
pieces (IPv6Option"likewise decode[d] no values");Nonefor
every other type and malformed data, same contract throughout this
tier (#96).- CI proves the library runs under a real Pyodide (WebAssembly)
runtime, not just that the modulesscapy/dpktneed are
individually blocked. A newpyodidejob (.github/workflows/ci.yml)
boots actual Pyodide under Node viascripts/pyodide/run_in_pyodide.mjs,
installs the wheel this job just built, and decodes the entire
real-capture fixture corpus with it — see
scripts/pyodide/check_in_pyodide.py. That surfaced a genuine bug
along the way:IPv6.decode()/bytes(IPv6(...))used
socket.inet_ntop/inet_pton(AF_INET6, ...), and Pyodide's CPython
build hasAF_INET6sockets disabled, so every IPv6 frame failed to
decode there._base.py'sipv6_to_bytes/bytes_to_ipv6are now a
pure-Python implementation (ipaddressfor parsing; a hand-rolled
RFC 5952 canonicalizer, differentially verified against glibc's
inet_ntopacross 500,000+ random addresses plus both of its
dotted-quad special cases, for formatting —str(ipaddress.IPv6Address)
was tried first and rejected: it disagrees with glibc on IPv4-mapped
addresses, and disagrees with itself between Python 3.11 and 3.12).
IPv4 addressing is unaffected; behavior for every existing platform
is unchanged, exception type included (#99). - Nightly fuzzing with a moving seed.
tests/test_fuzz.py's
"netprotocols"Hypothesis profile is deterministic on purpose (200
examples,derandomize=True) so a pull request is reproducible — but
that also means every build since it was written has run the same
200 inputs. A new"nightly"profile (10,000 examples, a real random
seed each run) runs the whole suite on a new schedule,
.github/workflows/fuzz.yml(03:00 UTC daily, plus manual dispatch),
kept out ofci.ymldeliberately —ci.ymlis reused by
release.ymlviaworkflow_call, and aschedule:trigger there
would fire the whole PR/release gate on a cron, not just this
exploratory job..hypothesis/'s example database accumulates
across nightly runs viaactions/cache(keyed by run id with a
prefix restore-key, since a fixed key would restore forever but
never actually save a new entry) and uploads as a workflow artifact
on failure, so a counterexample is recoverable without repo write
access; a failed scheduled run's own red build is the notification
(GitHub already does this by default), so nothing here files an
issue on top of it. Reproduce locally:
HYPOTHESIS_PROFILE=nightly uv run pytest. Also adds a targeted
strategy building well-formed TCP SYN options (MSS, window scale,
SACK-Permitted, SACK, timestamps) — the real-capture corpus never
caught a SYN, so unlike NOP/Timestamps these otherwise depend on
max_examplesalone stumbling into a well-formed TLV by chance
(#98). - A pcap/pcapng reader that takes bytes, not filenames. New
netprotocols.pcap:read_captures(buffer)auto-detects classic
pcap vs. pcapng from its magic number and yieldsCapturedFrame
(timestampin nanoseconds since the Unix epoch, normalized from
whatever resolution the source recorded,datathe frame's raw
bytes);read_pcap()/read_pcapng()are the same for a caller who
already knows the format. pcapng support covers exactly the block
types frames can come from — Section Header, Interface Description
(read only for itsif_tsresoloption), Enhanced Packet, and Simple
Packet (which the format gives no timestamp at all, hence0);
every other bl...
netprotocols 2.0.0
Development
-
The round-trip property (
bytes(decode(x)) == x) is now
Hypothesis-generated for all 18 protocols, not 4. It previously
held only forEthernet,UDP,TCPandIPv6Fragment; the other
14 rested on a single example each.tests/strategies.pyadds one
reusable strategy per protocol — reusable because a strategy that
generates valid instances is useful for more than this one
property — andtests/test_fuzz.py::TestGeneralizedRoundTrips
asserts the property in one method, parametrized over all 14.The interdependent-field protocols the issue specifically flagged
are generated as consistent combinations, never independently: IPv4
drawsihlfirst and sizesoptionsto match; the three IPv6
extension headers drawhdr_ext_lenfirst and size their TLV bytes
to match; GRE drawsflagsfirst and computesfields' length from
the same_optional_len()the decoder itself uses, so the two can
never silently disagree.The 14 single-example assertions already living in each protocol's
own test file (test_arp.py::test_round_tripand so on) are kept as
regression anchors — a fixed example pinned to a real captured header
catches a specific regression fast and readably, which a generated
property does not replace.One adjacent gap closed while surveying this:
DHCPandGREwere
missing fromtest_fuzz.py::ALL_PROTOCOLSentirely, so neither had
ever been fuzzed with untrusted bytes for basic decode safety (any input either decodes or raises ProtocolError, never anything else).
Both are now included.
Added
-
match/casedissection is documented. It already worked on
every decoded header, with zero code changes needed — a frozen
dataclass auto-generates__match_args__, and every field with a
fixed wire vocabulary is anIntEnum, so a value pattern binds the
plainintthe decoder stored. None of that appeared anywhere:match packet.layers: case [_, IPv4(protocol=IPProtocol.TCP) as ip, TCP(flags_str=f), *_] \ if "SYN" in f and "ACK" not in f: print(f"connection attempt from {ip.src}")
README gains a first-screen example and a dedicated section;
ARCHITECTURE.md explains the two mechanisms and the two ways a
contributor can quietly break them (reordering a class's fields
changes what a positional pattern binds to; degrading a fixed-
vocabulary field fromIntEnumto a bareintmakes a value
pattern stop matching without raising).tests/test_pattern_matching.py
pins both mechanisms directly and runs the README's own example over
the corpus, so a regression in either is caught rather than
incidental. -
Structured diagnostics on every
ProtocolError. Every exception
used to carry only a formatted string — the full attribute set on a
raisedTruncatedHeaderErrorwasargs,add_note,
with_traceback. A fuzzing harness, conformance suite, or
protocol-validation tool that wanted to know where a parse failed
had to regex the message. Every raise site insrc/now attaches:try: packet = decode_frame(frame) except ProtocolError as e: print(e.protocol, e.field, e.offset, e.frame_offset, e.expected, e.actual) # <class 'netprotocols.layer3.ip.IPv4'> ihl 0 14 >=5 0
protocol— the class that raised. Set on every single raise site.field— the attribute at fault, where one field is at fault.offset— byte offset of the problem, relative to thedecode()
buffer for a fixed-header error, or to thefieldattribute
(options,body,sections) for an on-demand parse — a TCP
option error's offset is relative toheader.options, not the
frame.Nonefor a__post_init__validation error, which sees
field values, never the bytes they came from.frame_offset—offsetrebased to the whole captured frame.
decode_frameis the only code holding the cursor needed to do
this, so it's the only thing that sets it; a bare
SomeClass.decode()call leaves itNone.expected/actual— added wherever there's a concrete pair to
state beyond the message text.
All five default to
None, and no message string changed — every
existingstr(err)andmatch=assertion holds. New public name:
MaxDepthExceededErrorgains the same attributes as every other
ProtocolErrorsubclass (it already existed as of thedecode_frame
work; this is the first release to give it structured context too). -
decode_frame(): the chain walker ships with the library. The
README taught a hand-rolled eight-line loop, ARCHITECTURE.md showed
it again, and the test suite kept its own copy — so the library's
most-used function was the one function it did not provide. It does
now, with the parts a copy-pasted loop never has:from netprotocols import decode_frame packet = decode_frame(frame) # Packet(Ethernet(...), IPv4(...), TCP(...)) frame[packet.consumed:] # whatever the chain did not decode
- An explicit starting layer.
decode_frame(buf, start=IPv4)
walks a buffer that begins mid-stack — a tunnel payload, a packet
quoted inside an ICMP error, a non-Ethernet link type. There was
previously no way to ask for this. - Bounded depth.
max_depth(default 32) caps the chain and
raises the newMaxDepthExceededError, rooted atProtocolError
like everything else. Every header already validated its own
length, so a chain always terminated; the bound turns a crafted
frame's very long walk into an immediate named error. The deepest
chain in the 97-frame corpus is 5. - A lax mode that reports instead of raising.
lax=Trueends the
walk on aProtocolErrorand returns the layers decoded so far,
with the reason onpacket.stopped_by— what a capture tool needs
when frame 4,000,001 is malformed. It relaxes the walk, never a
decoder: every layer returned was decoded under the ordinary strict
rules. - Per-call decoder overrides.
decode_as={"udp.port": {6969: DNS}}
reads DNS on a nonstandard port for one call, without touching
global state;registry=takes a prepared registry for bulk work.
Packetgainsstopped_by(why a walk ended early,Nonefor a
packet you built) andconsumed(the bytes its headers occupy).
Both default to the constructed-packet values, soPacket(eth, ip)
is unchanged.On
memoryview: the walker slices what it is given and does not
convert. Measured on the corpus, wrapping each frame in a
memoryviewruns at 0.95x the plain-byteswalk — for one small
frame the view costs more to build than the copy it saves — while a
memoryviewover a large capture buffer keeps slices zero-copy.
Converting internally would have been worse than either. - An explicit starting layer.
-
A public protocol registry: third parties can now extend the decode
walk without editing library source. Dispatch was four hardcoded
functions with dict literals inside them and no hook of any kind — a
protocol this library does not implement (MPLS, VXLAN, a proprietary
telemetry header) could only be added by forking. It is now five named
tables owned bynetprotocols.registry, and registering against one
is all it takes:from netprotocols.registry import register @register("ethertype", 0x8847) class MPLS(Protocol): ... register("ip.proto", 132, SCTP) # for a class you did not write
The tables are
ethertype,ip.proto,ip.proto.v6,udp.portand
tcp.port, each named after the wire field it dispatches on.
ip.proto.v6inheritsip.proto, which is how the IPv6-only
gating generalises: the four extension headers are registered in the
v6 table alone, so an IPv4 packet withprotocol=0still cannot
conjure a Hop-by-Hop layer — and because inheritance is resolved at
registration rather than at lookup, the gating costs nothing on the
hot path. Dispatch behaviour is unchanged entry for entry.Registrations land in the process-wide
DEFAULTregistry;
Registry.from_defaults()gives an isolated one for embedding or test
isolation, andRegistry.derive()makes a copy-on-write child.
Registering over an existing key raisesRegistryConflictErrorunless
override=Trueis passed, so two packages claiming the same port
cannot silently resolve by import order; re-registering the same
class to the same key is a no-op, since a decorator re-runs whenever
its module does.New public names:
Registry,register,register_all,DEFAULT,
RegistryConflictError,UnknownTableError.
Changed
next_protocol()accepts an optional registry. Threading a
per-call registry through the walk needs the dispatch to be
redirectable, andnext_protocol()read the process-wide tables
directly. It now takesregistry=None, passed to the same dispatch
helpers as before, so no table knowledge is duplicated and the
default path stays a singledict.get. Measured in one process, the
optional parameter costs +2.1 ns per dispatch — roughly 0.1% of a
frame decode. Every existing zero-argument call is unaffected.- Port-based dispatch is a table lookup rather than a rebuild.
udp_app_classandtcp_app_classre-ran their deferred imports and
rebuilt adictliteral on every call — they were outside the scope
of the earlier dispatch-hoisting work, which named only the EtherType
and IP-protocol functions. Both now read the registry's flat tables:
udp.port303.1 → 46.0 ns (6.6x),tcp.port186.8 → 45.1 ns (4.1x),
measured old and new shapes in one process.ethertypeloses its
lazy-build guard (51.8 → 47.7 ns) andip.protois unchanged, having
already been a table. Corpus throughput moves about 1% — only 28 of
the 97 corpus frames reach a transport header and therefore do ...
netprotocols 1.3.0
Added
- IEEE 802.1Q VLAN tags (
VLAN, 802.1Q-2018 §9.6 / 802.1ad QinQ):
single and stacked (QinQ0x88A8, legacy double-tagged0x9100)
tags decode as one layer per tag; the Tag Control Information word is
split intopcp/dei/viddataclass fields validated in
__post_init__(InvalidFieldError), with the packed 16-bit view
kept as thetciproperty;VLANis registered in the
property-based fuzz suite and theEtherTypedisplay names are
covered by the enum completeness test. - IGMPv3 group records (
IGMPv3GroupRecord, RFC 3376 §4.2): a v3
Membership Report (type0x22) now parses its group-record array on
demand.IGMP.group_recordsyields oneIGMPv3GroupRecordper record
(record type + display name, multicast group, source-address list,
raw auxiliary data) andIGMP.num_group_recordsreads the count;
other message types returnNone. Parsing reads the raw body and
never re-encodes, so the byte-exact round-trip is preserved, and a
lying record/source count or a truncated record raises
InvalidFieldError(bounded — never hangs or over-reads). - IGMPv3 query fields (RFC 3376 §4.1): a v3 Membership Query (type
0x11) now exposes its fields past the group address — thes_flag
(suppress router-side processing),qrv,qqic, and
query_source_addressesaccessors parse the raw body on demand. A v2
(8-byte) query and non-query types returnNone, and a source count
that runs past the message raisesInvalidFieldError. - DHCP (
netprotocols.DHCP, RFC 2131/2132) — the fixed BOOTP header
(op, xid, the client/your/server/gateway addresses, the 16-byte client
hardware address, and the server-name / boot-file fields) decodes in
full; the magic cookie and TLV options are kept raw and parsed on
demand.option_mapwalks the options (concatenating a value split
across appearances, RFC 3396) andmessage_type/message_type_name
read the DHCP message type (option 53). Dispatched from UDP by
well-known port (67/68); terminal, with a byte-exact round-trip and
the same bounded-parse contract as DNS (InvalidFieldErroron a
missing cookie or an option that overruns the buffer). - GRE (
netprotocols.GRE, RFC 2784/2890) — Generic Routing
Encapsulation, dispatched from IPv4/IPv6 protocol 47. The four-byte
header (flags/version + protocol type) decodes with the optional
checksum, key, and sequence-number fields that the flag bits announce
kept raw and surfaced through accessors. The payload chains onward by
theprotocol_typeEtherType, so a GRE-tunnelled IPv4/IPv6 packet
keeps decoding; the round-trip stays byte-exact regardless of which
optional fields are present. - GRE checksum arm (
netprotocols.checksum, RFC 2784 §2.5):
compute/verifynow cover GRE — the internet checksum over the GRE
header plus its payload with the checksum field zeroed and no
pseudo-header.compute(gre, payload=...)requires the
Checksum-Present bit (and its field) and raisesInvalidFieldError
otherwise;verifyof a header whose Checksum-Present bit is clear
returnsTrue— a frame cannot fail a checksum it does not carry —
mirroring the UDP-over-IPv4 zero rule. - Richer address accessors (README roadmap): read-only
_address
properties return stdlibipaddressobjects alongside the canonical
strfields, for comparison, subnet membership, and arithmetic —
IPv4.src_address/dst_addressandARP.spa_address/tpa_address
asipaddress.IPv4Address,IPv6.src_address/dst_addressas
ipaddress.IPv6Address, and the fourDHCPaddress fields as
ciaddr_address/yiaddr_address/siaddr_address/giaddr_address.
Purely additive: thestrfields stay the round-tripping
representation. MAC addresses staystr— the stdlib has no EUI
type. - IPv4 options (
IPv4Option, RFC 791 §3.1): the options TLV list
now parses on demand, mirroring the TCP-option work —
IPv4.parsed_optionsyields oneIPv4Optionper option in wire
order (kind +kind_name, rawdata). The common kinds are named:
End of Option List and No-Operation (single-byte; EOL ends the parse,
so padding after it is not returned), Record Route (7), Timestamp
(68), and Router Alert (148, RFC 2113); unknown kinds keep their raw
datawith a numeric fallback name. Parsing reads the rawoptions
bytes and never re-encodes, so the byte-exact round-trip is
preserved; an option length below the 2-byte minimum or one that runs
past the options raisesInvalidFieldError(bounded — never hangs or
over-reads). - DNS resource records (
DNSResourceRecord, RFC 1035 §4.1.3): the
answer, authority, and additional sections parse on demand —
DNS.answers/DNS.authorities/DNS.additionalsyield records
(name, type +rtype_name, class, TTL, rawrdata, and a decoded
rdata_text: IPv4/IPv6 for A/AAAA, the target name for CNAME/NS/PTR,
and MX/TXT/SOA; hexadecimal otherwise). Parsing reads the raw sections
and never re-encodes, so the byte-exact round-trip holds; a record or
compressed name that runs past the message raisesInvalidFieldError. - TCP options (
TCPOption, RFC 9293 §3.1): the options TLV list
now parses on demand —TCP.parsed_optionsyields oneTCPOption
per option in wire order (kind +kind_name, rawdata, and a
decodedvaluewhere this library understands the kind: the segment
size for MSS, the shift count for Window Scale, a(tsval, tsecr)
pair for Timestamps, and(left_edge, right_edge)pairs for SACK;
RFC 7323/2018). EOL and NOP are single-byte (EOL ends the parse);
unknown kinds keep their rawdatawithvaluedegrading toNone.
Parsing reads the rawoptionsbytes and never re-encodes, so the
byte-exact round-trip is preserved, and an option length below the
TLV minimum or one that runs past the options raises
InvalidFieldError(bounded — never hangs or over-reads). - ICMP message bodies (RFC 792 / RFC 4443):
ICMPv4/ICMPv6gain a
rawbodyfield — the message data after the 8-byte header — so a
decoded message is self-contained (likeIGMP/DNS),header_len
consumes the whole IP payload, and the layer stays terminal with a
byte-exact round-trip. Echo requests/replies (v4 types 8/0, v6
128/129) exposeidentifier/sequence_numbersplit fromrest,
and the error messages (v4 Destination Unreachable / Redirect / Time
Exceeded / Parameter Problem; v6 types 1-4) exposeembedded_packet—
the invoking datagram, decodable asIPv4/IPv6. The accessors read
on demand and degrade toNonefor other message types or an empty
body, never raising. A decoded message now carries its body in
bytes(layer), sochecksum.compute/verifyneed no separate
payloadfor it (passing one for a header-only object still works). - IPv6 Neighbor Discovery (
NDPOption, RFC 4861):ICMPv6now
parses NDP messages on demand —ndp_target_addressreads the
16-byte target of a Neighbor Solicitation/Advertisement (135/136),
andndp_optionswalks the option TLVs of Router
Solicitation/Advertisement, Neighbor Solicitation/Advertisement, and
Redirect at each message's own options offset. EachNDPOption
carries itstype+type_nameand rawdata; a Source/Target
Link-Layer Address option (1/2) reads back as a MAC string via
link_layer_address, and Prefix Information (3) / MTU (5) stay raw.
Non-NDP message types returnNone. Option lengths count in 8-octet
units: a zero length (which must not loop), a length past the
message, or a body shorter than the message's fixed fields raises - IPv6 extension-header options (
IPv6Option, RFC 8200 §4.2): the
Hop-by-Hop and Destination Options headers now parse their option
TLVs on demand —parsed_optionsyields oneIPv6Optionper option
in wire order, padding included (Pad1 is a lone type byte; everything
else is type/length/data). Each option carries itstype+
type_name(Pad1, PadN, Router Alert per RFC 2711, Jumbo Payload per
RFC 2675; unknown types keep their numeric value) and rawdata, and
exposes the action-on-unrecognized bits (the two high bits of the
type) asunrecognized_action. Parsing reads the rawoptionsbytes
and never re-encodes, so the byte-exact round-trip is preserved; a
missing length byte or option data that runs past the header raises
InvalidFieldError(bounded — never hangs or over-reads). - DNS over TCP (
DNSOverTCP, RFC 1035 §4.2.2):TCP.next_protocol()
now dispatches application protocols by well-known port
(layer4._ports.tcp_app_class). DNS over TCP is length-prefixed, so
port 53 chains through a 2-byteDNSOverTCPlength shim — a layer
betweenTCPand theDNSmessage, like a VLAN tag between Ethernet
and its payload — and the walk reaches the DNS message (records and
all) at its true offset. Non-DNS ports still end the chain at TCP.
Development
- The real-capture fixture corpus gained
vlan_icmp.pcap(802.1Q
single tag VID 100 + 802.1ad QinQ S-VID 200 / C-VID 30, over ARP and
ICMPv4), so theVLANlayer rides the corpus-wide invariants and the
transport-checksum recompute like every other protocol. It is a
direct tcpdump capture of real kernel-tagged frames — built over
802.1Q / 802.1ad vlan devices on a veth pair with VLAN and checksum
offload disabled so the tags stay in-band in the saved bytes and the
inner checksums are genuine kernel output; see
tests/fixtures/MANIFEST.mdandscripts/capture_fixtures_vlan.sh. - The corpus gained real-capture
dhcp.pcap(a DHCP DORA exchange from
dnsmasq+dhclient) andgre.pcap(IPv4-in-GRE ICMPv4 echo over a
plain and a keyed kernel tunnel), so theDHCPandGRElayers ride
the corpus-wide invariants on genuine bytes.
scripts/capture_fixtures_dhcp.shandscripts/capture_fixtures_gre.sh
produce them, and `scripts/check_fixtu...
netprotocols 1.2.0
The first application-layer protocol lands, plus IPv4 multicast management. Full details in CHANGELOG.md.
Highlights
- DNS (RFC 1035): the 12-byte header and flags decode; message sections stay raw with on-demand, safe name decompression (bounded pointer-following). Reached by heuristic port dispatch on UDP (
{53: DNS}, best-effort) — a new mechanism for application protocols. - IGMP (RFC 1112/2236/3376): IPv4 multicast group management, dispatched from IPv4 protocol 2;
group_addressper message type; checksum compute/verify. - Real-capture corpus grown to 69 frames across 13 scenarios (adds captured DNS-over-IPv6 and IGMPv3 reports); fuzz-covered decode path.
Part of #22 — 802.1Q (community PR), DHCP, and GRE remain.
`pip install netprotocols`
netprotocols 1.1.0
Milestone v1.1. Full details in CHANGELOG.md.
Highlights
- IPv6 extension headers (RFC 8200): Hop-by-Hop Options, Routing, Fragment, Destination Options — the decode chain now reaches ICMPv6/TCP/UDP behind them (MLD reports decode fully). Extension headers dispatch only inside an IPv6 chain; fragments chain onward only from offset 0.
- Checksums:
compute()/verify()(RFC 1071, IPv4/IPv6 pseudo-headers) andPacket.with_checksums()— verified against a real-capture corpus: every inbound frame's checksums recompute to their wire values. - Property-based fuzzing of the decode path (deterministic CI profile).
- Real-capture fixture corpus: 65 frames / 12 live-captured scenarios.
pip install netprotocols
netprotocols 1.0.0
Complete rewrite of the library. Full details in CHANGELOG.md and the new ARCHITECTURE.md.
Highlights
- Frozen, slotted dataclasses parsed with
struct.Structreplace the ctypes core; decoded and constructed instances are identical and compare equal - Typed decode chain:
next_protocol()+ instance-accurateheader_len(IPv4 IHL and TCP data offset honored — fixes long-standing mis-slicing) - Typed error hierarchy rooted at
ProtocolError; malformed input can no longer leak arbitrary exceptions - Byte-lossless round trips, options included
- Python 3.12+, fully typed (
py.typed, mypy strict), MIT licensed, published via PyPI trusted publishing
pip install netprotocols