Skip to content

Releases: EONRaider/NETProtocols

netprotocols 2.2.0

Choose a tag to compare

@EONRaider EONRaider released this 04 Sep 20:54
401a1c6

Added

  • Typed accessors for every enum-backed field. _enums.py defines
    EtherType, IPProtocol, ARPOperation and ARPHardwareType, but
    until now no decoded field exposed them — only the *_name display
    strings, and the enums themselves were used only for dispatch. Every
    field with a fixed wire vocabulary now has an *_enum companion
    alongside the existing int field and its *_name string, mirroring
    the src/src_address precedent 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 — ARPHardwareType was exported but referenced nowhere), and
    DHCP.htype_name/DHCP.htype_enum. Each returns None — never
    raises — for a wire value this library does not enumerate, so
    bytes(decode(x)) == x is unaffected for unrecognized values; the
    raw int field 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 drafting case IPv4(...) actually reaches for.
    Every other header (including the still-wide IPv6, 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.py rather than left free to drift.
    ARCHITECTURE.md and docs/CLAIMS.md are 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_text decoded A/AAAA/MX/
    SOA/etc. into a display string only; a new rdata_value field adds
    the typed decoding alongside it (rdata_text is unchanged) —
    ipaddress.IPv4Address/IPv6Address for A/AAAA, the decompressed
    target str for NS/CNAME/PTR, a new MXRecord(preference, exchange)
    for MX, a new SOARecord(mname, rname, serial, refresh, retry, expire, minimum) for SOA, list[str] of character-strings for TXT,
    and None — never raises — for the types this library does not
    decode. Computed eagerly at parse time, like rdata_text always 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 context rdata_text already required, which a
    property computed lazily from rdata alone could not resolve.
    Separately, DNS.questions adds a tuple[DNSQuestion, ...] walking
    every entry of the question section — question_name/question_type
    /question_class exposed only the first and are unchanged (#96).
  • A typed DHCPOption, alongside DHCP.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 new parsed_options property
    wraps the same, already RFC-3396-concatenated, mapping into a
    tuple[DHCPOption, ...], mirroring TCPOption/IPv4Option: code,
    data, code_name, and a decoded .value for the option codes this
    library understands — a single ipaddress.IPv4Address for 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.value decodes Record Route, Timestamp and Router
    Alert.
    IPv4Option had kind_name and no .value — the three
    common kinds (RFC 791 §3.1, RFC 2113) were named but their contents
    left raw, unlike TCPOption, which already decodes its values.
    Record Route (7) decodes to a tuple[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 to tuple[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, read data raw for
    those. Router Alert (148) decodes to the 2-byte value as int.
    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.segments and IPv6Option.value for Router Alert /
    Jumbo Payload.
    IPv6Routing.data was 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; segments decodes that
    into a tuple[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 — data stays available
    raw. None for 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 typed ints, the last of #96's four
    pieces (IPv6Option "likewise decode[d] no values"); None for
    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 modules scapy/dpkt need are
    individually blocked. A new pyodide job (.github/workflows/ci.yml)
    boots actual Pyodide under Node via scripts/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 has AF_INET6 sockets disabled, so every IPv6 frame failed to
    decode there. _base.py's ipv6_to_bytes/bytes_to_ipv6 are now a
    pure-Python implementation (ipaddress for parsing; a hand-rolled
    RFC 5952 canonicalizer, differentially verified against glibc's
    inet_ntop across 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 of ci.yml deliberately — ci.yml is reused by
    release.yml via workflow_call, and a schedule: 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 via actions/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_examples alone 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 yields CapturedFrame
    (timestamp in nanoseconds since the Unix epoch, normalized from
    whatever resolution the source recorded, data the 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 its if_tsresol option), Enhanced Packet, and Simple
    Packet (which the format gives no timestamp at all, hence 0);
    every other bl...
Read more

netprotocols 2.0.0

Choose a tag to compare

@EONRaider EONRaider released this 04 Sep 09:42
ad3fc3e

Development

  • The round-trip property (bytes(decode(x)) == x) is now
    Hypothesis-generated for all 18 protocols, not 4.
    It previously
    held only for Ethernet, UDP, TCP and IPv6Fragment; the other
    14 rested on a single example each. tests/strategies.py adds one
    reusable strategy per protocol — reusable because a strategy that
    generates valid instances is useful for more than this one
    property — and tests/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
    draws ihl first and sizes options to match; the three IPv6
    extension headers draw hdr_ext_len first and size their TLV bytes
    to match; GRE draws flags first and computes fields' 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_trip and 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: DHCP and GRE were
    missing from test_fuzz.py::ALL_PROTOCOLS entirely, 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/case dissection 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 an IntEnum, so a value pattern binds the
    plain int the 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 from IntEnum to a bare int makes 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
    raised TruncatedHeaderError was args, 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 in src/ 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 the decode()
      buffer for a fixed-header error, or to the field attribute
      (options, body, sections) for an on-demand parse — a TCP
      option error's offset is relative to header.options, not the
      frame. None for a __post_init__ validation error, which sees
      field values, never the bytes they came from.
    • frame_offsetoffset rebased to the whole captured frame.
      decode_frame is 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 it None.
    • 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
    existing str(err) and match= assertion holds. New public name:
    MaxDepthExceededError gains the same attributes as every other
    ProtocolError subclass (it already existed as of the decode_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 new MaxDepthExceededError, rooted at ProtocolError
      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=True ends the
      walk on a ProtocolError and returns the layers decoded so far,
      with the reason on packet.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.

    Packet gains stopped_by (why a walk ended early, None for a
    packet you built) and consumed (the bytes its headers occupy).
    Both default to the constructed-packet values, so Packet(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
    memoryview runs at 0.95x the plain-bytes walk — for one small
    frame the view costs more to build than the copy it saves — while a
    memoryview over a large capture buffer keeps slices zero-copy.
    Converting internally would have been worse than either.

  • 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 by netprotocols.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.port and
    tcp.port, each named after the wire field it dispatches on.
    ip.proto.v6 inherits ip.proto, which is how the IPv6-only
    gating generalises: the four extension headers are registered in the
    v6 table alone, so an IPv4 packet with protocol=0 still 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 DEFAULT registry;
    Registry.from_defaults() gives an isolated one for embedding or test
    isolation, and Registry.derive() makes a copy-on-write child.
    Registering over an existing key raises RegistryConflictError unless
    override=True is 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, and next_protocol() read the process-wide tables
    directly. It now takes registry=None, passed to the same dispatch
    helpers as before, so no table knowledge is duplicated and the
    default path stays a single dict.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_class and tcp_app_class re-ran their deferred imports and
    rebuilt a dict literal 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.port 303.1 → 46.0 ns (6.6x), tcp.port 186.8 → 45.1 ns (4.1x),
    measured old and new shapes in one process. ethertype loses its
    lazy-build guard (51.8 → 47.7 ns) and ip.proto is 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 ...
Read more

netprotocols 1.3.0

Choose a tag to compare

@EONRaider EONRaider released this 01 Sep 21:16
6e43d94

Added

  • IEEE 802.1Q VLAN tags (VLAN, 802.1Q-2018 §9.6 / 802.1ad QinQ):
    single and stacked (QinQ 0x88A8, legacy double-tagged 0x9100)
    tags decode as one layer per tag; the Tag Control Information word is
    split into pcp/dei/vid dataclass fields validated in
    __post_init__ (InvalidFieldError), with the packed 16-bit view
    kept as the tci property; VLAN is registered in the
    property-based fuzz suite and the EtherType display names are
    covered by the enum completeness test.
  • IGMPv3 group records (IGMPv3GroupRecord, RFC 3376 §4.2): a v3
    Membership Report (type 0x22) now parses its group-record array on
    demand. IGMP.group_records yields one IGMPv3GroupRecord per record
    (record type + display name, multicast group, source-address list,
    raw auxiliary data) and IGMP.num_group_records reads the count;
    other message types return None. 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 — the s_flag
    (suppress router-side processing), qrv, qqic, and
    query_source_addresses accessors parse the raw body on demand. A v2
    (8-byte) query and non-query types return None, and a source count
    that runs past the message raises InvalidFieldError.
  • 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_map walks the options (concatenating a value split
    across appearances, RFC 3396) and message_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 (InvalidFieldError on 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
    the protocol_type EtherType, 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/verify now 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 raises InvalidFieldError
    otherwise; verify of a header whose Checksum-Present bit is clear
    returns True — 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 stdlib ipaddress objects alongside the canonical
    str fields, for comparison, subnet membership, and arithmetic —
    IPv4.src_address/dst_address and ARP.spa_address/tpa_address
    as ipaddress.IPv4Address, IPv6.src_address/dst_address as
    ipaddress.IPv6Address, and the four DHCP address fields as
    ciaddr_address/yiaddr_address/siaddr_address/giaddr_address.
    Purely additive: the str fields stay the round-tripping
    representation. MAC addresses stay str — 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_options yields one IPv4Option per option in wire
    order (kind + kind_name, raw data). 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
    data with a numeric fallback name. Parsing reads the raw options
    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 raises InvalidFieldError (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.additionals yield records
    (name, type + rtype_name, class, TTL, raw rdata, 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 raises InvalidFieldError.
  • TCP options (TCPOption, RFC 9293 §3.1): the options TLV list
    now parses on demand — TCP.parsed_options yields one TCPOption
    per option in wire order (kind + kind_name, raw data, and a
    decoded value where 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 raw data with value degrading to None.
    Parsing reads the raw options bytes 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/ICMPv6 gain a
    raw body field — the message data after the 8-byte header — so a
    decoded message is self-contained (like IGMP/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) expose identifier / sequence_number split from rest,
    and the error messages (v4 Destination Unreachable / Redirect / Time
    Exceeded / Parameter Problem; v6 types 1-4) expose embedded_packet
    the invoking datagram, decodable as IPv4/IPv6. The accessors read
    on demand and degrade to None for other message types or an empty
    body, never raising. A decoded message now carries its body in
    bytes(layer), so checksum.compute/verify need no separate
    payload for it (passing one for a header-only object still works).
  • IPv6 Neighbor Discovery (NDPOption, RFC 4861): ICMPv6 now
    parses NDP messages on demand — ndp_target_address reads the
    16-byte target of a Neighbor Solicitation/Advertisement (135/136),
    and ndp_options walks the option TLVs of Router
    Solicitation/Advertisement, Neighbor Solicitation/Advertisement, and
    Redirect at each message's own options offset. Each NDPOption
    carries its type + type_name and raw data; 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 return None. 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_options yields one IPv6Option per option
    in wire order, padding included (Pad1 is a lone type byte; everything
    else is type/length/data). Each option carries its type +
    type_name (Pad1, PadN, Router Alert per RFC 2711, Jumbo Payload per
    RFC 2675; unknown types keep their numeric value) and raw data, and
    exposes the action-on-unrecognized bits (the two high bits of the
    type) as unrecognized_action. Parsing reads the raw options bytes
    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-byte DNSOverTCP length shim — a layer
    between TCP and the DNS message, 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 the VLAN layer 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.md and scripts/capture_fixtures_vlan.sh.
  • The corpus gained real-capture dhcp.pcap (a DHCP DORA exchange from
    dnsmasq + dhclient) and gre.pcap (IPv4-in-GRE ICMPv4 echo over a
    plain and a keyed kernel tunnel), so the DHCP and GRE layers ride
    the corpus-wide invariants on genuine bytes.
    scripts/capture_fixtures_dhcp.sh and scripts/capture_fixtures_gre.sh
    produce them, and `scripts/check_fixtu...
Read more

netprotocols 1.2.0

Choose a tag to compare

@EONRaider EONRaider released this 30 Aug 16:49

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_address per 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

Choose a tag to compare

@EONRaider EONRaider released this 29 Aug 19:39

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) and Packet.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

Choose a tag to compare

@EONRaider EONRaider released this 21 Aug 22:05

Complete rewrite of the library. Full details in CHANGELOG.md and the new ARCHITECTURE.md.

Highlights

  • Frozen, slotted dataclasses parsed with struct.Struct replace the ctypes core; decoded and constructed instances are identical and compare equal
  • Typed decode chain: next_protocol() + instance-accurate header_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