Skip to content

feat(layer2): decode MPLS label stacks (RFC 3032 §2.1) - #252

Merged
EONRaider merged 7 commits into
masterfrom
claude/mpls
Oct 25, 2026
Merged

EONRaider merged 7 commits into
masterfrom
claude/mpls

Conversation

@EONRaider

@EONRaider EONRaider commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds netprotocols.MPLS, which decodes an MPLS label stack. EtherType 0x8847 (MPLS) and 0x8848 (MPLS with upstream-assigned label) both dispatch to it. The header has no next-protocol field, so the labelled packet is resolved by documented convention. A script-built fixture around kernel-built datagrams joins the corpus. Part of Tier 3 (#173).

Closes #187

What's included

  • src/netprotocols/layer2/mpls.py:

    • MPLSLabel holds one entry: label, traffic class (RFC 5462), S, TTL.
    • MPLS.labels holds the whole stack down to the entry with S set, as a single layer.
    • Special-purpose labels are named exactly as IANA's registry names them (fetched 2026-10-25).
  • Dispatch by convention. The reserved bottom labels decide first:

    Bottom label Payload
    0 (IPv4 Explicit NULL) IPv4
    2 (IPv6 Explicit NULL) IPv6
    13 (GAL), 14 (OAM Alert) ends the walk

    Otherwise the payload's first nibble decides: 4 is IPv4, 6 is IPv6, and anything else ends the walk. That covers a pseudowire control word (nibble 0) and an associated channel header (nibble 1).

    • next_protocol() is handed no bytes, so decode() records the nibble in a compare=False field, as BGP does for "another message follows".
    • _base.py, ARCHITECTURE and registry.py now describe two decode-context exceptions instead of one.
    • The RFC 4928 failure mode is documented and tested: a pseudowire without a control word, whose inner destination MAC happens to start with 4 or 6, is misread as IP.
  • MPLS in GRE (RFC 4023) decodes with no extra code, since GRE reads the ethertype table. A test pins it.

  • Registry examples move off 0x8847. The README, ARCHITECTURE, CLAIMS and registry.py used @register("ethertype", 0x8847) class MPLS as the "add a protocol yourself" example. That would now raise RegistryConflictError, so the examples move to 0x88B5 (IEEE 802 Local Experimental EtherType 1). tests/test_docs.py now executes every one of those examples on a derived registry. The CHANGELOG tells users who registered at 0x8847/0x8848 to pass override=True or use decode_as.

  • Fixture:

    • scripts/capture_fixtures_mpls.sh and scripts/mpls_fixture_packets.py produce tests/fixtures/mpls.pcap, 8 frames. They cover:
      • one-, two- and three-entry stacks;
      • ELI plus an entropy label;
      • IPv4 and IPv6 Explicit NULL;
      • 0x8848;
      • an Ethernet pseudowire with a control word.
    • This kernel has CONFIG_MPLS_ROUTING off. So the kernel built the inner ICMP/UDP datagrams, and the script added the label stacks and outer Ethernet headers and sent them through AF_PACKET. MANIFEST says which bytes each one built.
    • tests/fixtures/mpls/mpls_snaplen.pcap holds the same frames cut by a 20-byte snap length, so truncation is tested on captured bytes. It sits outside the corpus counts.
    • The corpus is now 273 frames across 28 scenarios.
  • scripts/check_fixtures.py walks the stack independently with the same convention and verifies the inner checksums. Tests cover each bottom-label rule, including cases where the label and the nibble disagree.

  • Test suites: MPLS joins the fuzz, round-trip, accessor-safety, padding/trailer, contract and corpus-coverage suites. CLAIMS §5.1–5.3 and the coverage comment are re-derived: 31 protocols, 27 round-trip strategies, 4,304 statements with 1 missed, and MPLS added to the accessors that never raise.

Verification

  • uv run ruff check and uv run ruff format --check are clean
  • uv run mypy is clean (strict)
  • uv run pytest passes locally: 4938 passed
  • CHANGELOG.md has an entry under ## [Unreleased]
  • python3 scripts/check_fixtures.py tests/fixtures reports all files consistent, and uvx bandit -q -r -t B324 src scripts tests is clean. Nothing new is at radon C.

New protocol or dispatch change — also:

  • Followed the add-a-protocol cookbook in ARCHITECTURE.md and the decode contract in src/netprotocols/_base.py
  • New EtherType numbers carry display names in lockstep
  • Registered the class in the fuzz suite (tests/test_fuzz.py::ALL_PROTOCOLS)
  • Added a fixture and updated tests/fixtures/MANIFEST.md (script-built headers, as the kernel cannot route MPLS)
  • Updated the README coverage table and the ARCHITECTURE.md chain diagram

Notes

  • Naming: 0x8848 is named as IANA names it, "MPLS with upstream-assigned label". The issue called it "multicast", but RFC 5332 §4 retired that meaning.
  • Mutation testing: checksum.py and _tlv.py are untouched, so no mutation dispatch is needed.
  • Review: independent review and two independent fix-and-review rounds closed every finding. They added tests for the checker's bottom-label and pseudowire rules, killed the surviving mutants, and corrected docs and citations.

Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for decoding and serializing MPLS label stacks, including IPv4 and IPv6 payload detection.
    • Added MPLS recognition to the default protocol registry and made the new MPLS types available through the public API.
  • Documentation

    • Updated protocol coverage, architecture, and security documentation to describe MPLS handling and its payload-detection conventions.
    • Expanded the packet capture corpus with MPLS examples.

The registry's own examples, README.md, ARCHITECTURE.md, docs/CLAIMS.md
and tests/test_registry.py all used EtherType 0x8847 (MPLS) as the
"protocol this library does not implement" a third party would
register. MPLS is about to ship as a built-in decoder (#187), after
which that registration raises RegistryConflictError.

Every stand-in now uses 0x88B5, which IANA's ieee-802-numbers registry
(fetched 2026-10-25) lists as "IEEE Std 802 - Local Experimental
Ethertype", with a hypothetical Telemetry class, as the SCTP work moved
the ip.proto stand-ins to RFC 3692's 253. ARCHITECTURE no longer names
MPLS as unimplemented. tests/test_docs.py now executes the example in
each of the three documents and checks it registers on a free key, so
the next built-in to take a stand-in's number fails a test instead of
leaving a broken example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
EtherTypes 0x8847 ("MPLS") and 0x8848 ("MPLS with upstream-assigned
label", RFC 5332 §4; IANA's names) now dispatch to netprotocols.MPLS,
one layer holding the whole label stack as a tuple of MPLSLabel
entries: label, traffic_class (RFC 5462), bottom_of_stack and ttl.
Decode reads entries in place until the first with the S bit set, so
the stack bounds itself and bytes(MPLS.decode(x)) == x[:header_len];
a stack whose S bit never comes raises TruncatedHeaderError with
field="labels" and the offset of the missing entry, in time linear in
the buffer. Construction requires S on the last entry alone, which is
all decode produces, so every buildable stack round-trips.

MPLS names no payload protocol, so next_protocol() works by
convention: a bottom label of 0 or 2 (IPv4/IPv6 Explicit NULL) names
IPv4 or IPv6, 13 (GAL) and 14 (OAM Alert) stop the walk, and otherwise
the first nibble after the stack is read as an IP version (RFC 4928),
so a pseudowire control word stops it. next_protocol() is handed no
bytes, so decode() records the nibble in a compare=False field, as BGP
records whether another message follows; a stack built rather than
decoded answers from its bottom label alone. The class comes from the
ethertype table at 0x0800/0x86DD, so registry and decode_as overrides
reach the labelled packet, and GRE's Protocol Type 0x8847 reaches MPLS
with no extra code (RFC 4023). MPLSLabel.label_name names the base
special-purpose labels as IANA's registry (updated 2026-06-30, fetched
2026-10-25) does.

_base.py and ARCHITECTURE.md no longer call BGP the one header that
answers from decode context. MPLS joins the fuzz suite (ALL_PROTOCOLS,
a round-trip strategy, accessor-safety and trailer properties), the
truncation and memoryview contract tests, the layout map, README's
coverage table and CONTRIBUTING's layout list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
The capture host's kernel has CONFIG_MPLS_ROUTING off, so nothing here
can push a label. scripts/capture_fixtures_mpls.sh has the kernel send
two ICMP echo requests and two UDP datagrams between two netns over a
veth pair, offload off, captured inbound; scripts/mpls_fixture_packets.py
(stdlib only) then wraps them in script-built Ethernet headers and label
stacks and sends them through an AF_PACKET socket, and reads the capture
back to confirm every byte arrived as built. The capture script fails
unless it holds exactly the eight planned frames: one, two and three
labels; an Entropy Label Indicator and entropy label; IPv4 and IPv6
Explicit NULL bottoms (the IPv6 datagram script-built, the IPv4 frame
zero padded to 60 bytes); EtherType 0x8848 to a multicast MAC; and an
Ethernet pseudowire with a control word, where the walk stops.
MANIFEST.md says which bytes the script and the kernel built.

scripts/check_fixtures.py walks each stack to its bottom entry in its
own code and verifies the labelled packet's checksums, choosing IPv4 or
IPv6 by an Explicit NULL label or the first nibble, and reads the
Ethernet frame after the pseudowire's control word. ARP moved into a
_check_arp helper so _check_l3 dispatches both through a table and
stays at radon B. tests/test_mpls.py checks the captured chains and
fields, recomputes every checksum through the stack, and holds the
checker to failing a flipped byte in each frame's labelled packet.

The corpus is 273 frames across 28 scenarios; README's "Why" sentence
now names the 265 frames its figures were measured on at 2.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
The issue asks for a fixture with truncation, and every truncation test
so far cut a hand-built stack. capture_fixtures_mpls.sh now runs a
second tcpdump with a 20-byte snap length beside the full capture and
checks that each record is the first 20 bytes of its full frame. The
file, tests/fixtures/mpls/mpls_snaplen.pcap, sits outside the corpus's
non-recursive globs, as bgp/bgp_session.pcap does, because a cut frame
does not walk cleanly in strict mode. tests/test_mpls.py holds its six
multi-entry stacks to TruncatedHeaderError from MPLS (field "labels",
offset 4, frame offset 18) and its two one-entry stacks to a whole
MPLS layer that hands a 2-byte IPv4 header to IPv4.

MPLS's constructor now keeps any iterable of entries as a tuple, so a
stack built from a list compares equal to its decode and stays
hashable, as the class docstring claims. The checker's new helpers get
PEP 257 docstrings, SECURITY.md says the payload convention is
documented behaviour, and MANIFEST's row sizes the padded datagram by
its payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
An independent review of the MPLS branch (#187) found that
scripts/check_fixtures.py's bottom-label branch was never exercised:
in every corpus frame and test the bottom label and the payload's first
nibble agree, so dropping the Explicit NULL rules, dropping OAM Alert
(14) or shifting the label out by 11 bits instead of 12 all survived.
tests/test_mpls.py now feeds the checker frames where the two disagree:
IPv4 Explicit NULL (0, at traffic class 0 and 7) over an IPv6 packet
fails the IPv4 header check, IPv6 Explicit NULL (2) over an IPv4 packet
is read as IPv6, GAL (13) and OAM Alert (14) over an IPv4 packet stay
unread, and Router Alert (1) defers to the nibble. Each of those
mutants, and dropping 0, 2 or 13 alone, now fails the suite.

tests/test_docs.py also runs the three register() examples in
registry.py's docstrings, in a copy of the default registry, so they
are held to free keys as the README, ARCHITECTURE and CLAIMS examples
are.

Documentation nits from the same review:
- MANIFEST: the snap-length capture is cut at byte 20, 2 bytes past
  each stack's first entry (not "4 bytes into each label stack"), and
  the script built every outer Ethernet header (the pseudowire's inner
  one is the kernel's).
- mpls.py: labels 0-15 are special-purpose per RFC 7274 §1, not §2;
  is_special_purpose and label_name note that they judge one entry
  alone, so they cannot see an Extended Special-Purpose Label after an
  Extension Label (RFC 7274 §3.1).
- registry.py: the module docstring's table list and best-effort
  paragraph say MPLS looks up the ethertype table by convention, not
  by a wire field, as ARCHITECTURE.md does.
- CHANGELOG: the MPLS entry tells users who registered their own class
  at 0x8847/0x8848 that it now raises RegistryConflictError and how to
  override it; the Development entry names the registry.py examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
…nits

The independent checker reads a payload with first nibble 0 as an
Ethernet pseudowire only when the bottom label is not GAL (13) or OAM
Alert (14). Dropping that guard survived tests/test_mpls.py; the
unread-unless-strict cases now include a control word under each label,
which the guardless checker would walk as Ethernet.

The IPv6 Explicit NULL test asserted the checker's exact stats, which
pinned its silent pass of an IPv4 packet read as IPv6 (_check_ipv6
checks neither version nor payload length). It now asserts only the
route: mpls-stack, ipv6 present, ipv4 absent. The >>11, >>13 and
dropped-label-2 mutants still fail it.

The README/ARCHITECTURE/CLAIMS register test now runs each example in
a derived registry patched in as DEFAULT, like the registry.py examples
test, instead of popping the flattened table and leaving the entry in
DEFAULT's own.

Docs: the CHANGELOG entry says a registry from from_defaults() or
derive() raises too, adds ARCHITECTURE.md and docs/CLAIMS.md to the
2.5.0 documents whose register() example used 0x8847, and shows
decode_as with both keys; registry.py no longer calls MPLS the only
next_protocol() that is not a table lookup (VXLAN, DNSOverTCP and BGP
return fixed successors); a MANIFEST line is re-wrapped to its
neighbours' width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
CLAIMS 5.1: 273 frames across 28 scenarios, and the script-built MPLS
stacks join the list of captures (the over-long line master carried
there is re-wrapped). 5.2: MPLS joins the accessors that never raise
(TestMPLSAccessorSafety), ALL_PROTOCOLS is 31 and
TestGeneralizedRoundTrips 27. 5.3 and the pyproject comment: 4,304
statements, 1 missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Walkthrough

This change adds MPLS label-stack decoding, registers both MPLS EtherTypes, and selects IPv4 or IPv6 payload handling from bottom labels or the first payload nibble. It also adds MPLS capture fixtures, validation, tests, and documentation.

Changes: MPLS protocol support

Layer / File(s) Summary
Label stack model and decoding
src/netprotocols/layer2/mpls.py, src/netprotocols/_base.py, tests/test_mpls.py, tests/test_contract.py, tests/strategies.py, tests/conftest.py
Adds MPLSLabel and MPLS, including label-field validation, stack construction, serialization, bottom-of-stack decoding, payload context, and truncation errors. Tests cover label and stack behavior, decoding contracts, and generated stacks.
EtherType dispatch and packet traversal
src/netprotocols/_enums.py, src/netprotocols/_defaults.py, src/netprotocols/__init__.py, src/netprotocols/layer2/__init__.py, src/netprotocols/registry.py, tests/test_mpls.py, tests/test_registry.py, tests/test_docs.py
Registers 0x8847 and 0x8848 to MPLS and exports both MPLS types. Payload dispatch uses bottom labels or the first payload nibble, then looks up the inferred EtherType. Tests cover registry lookup, frame traversal, and documented registration examples.
Fixture generation and protocol validation
scripts/capture_fixtures_mpls.sh, scripts/mpls_fixture_packets.py, scripts/check_fixtures.py, tests/fixtures/MANIFEST.md, tests/test_mpls.py, tests/test_fuzz.py, tests/test_corpus.py
Adds an eight-frame MPLS capture and a 20-byte snap-length capture. The fixture checker validates MPLS stacks and supported payloads. Tests cover captured frames, strict checks, fuzzing, and corpus coverage.
Protocol documentation and project records
ARCHITECTURE.md, README.md, SECURITY.md, CHANGELOG.md, CONTRIBUTING.md, docs/CLAIMS.md, pyproject.toml
Documents MPLS dispatch conventions and payload identification. Updates corpus, fuzzing, and coverage records and replaces registry examples with a telemetry registration.

Priority: Not assessed

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Ethernet
  participant MPLS
  participant Registry
  participant IPDecoder as IPv4 or IPv6 decoder
  Ethernet->>MPLS: Dispatch EtherType 0x8847 or 0x8848
  MPLS->>MPLS: Decode entries through Bottom of Stack
  MPLS->>Registry: Look up inferred payload EtherType
  Registry->>IPDecoder: Resolve IPv4 or IPv6 decoder
Loading

Possibly related issues

Suggested reviewers: claude

Merge Risk: Low · up to e1851

The multicast fixture should be corrected before it is used as a standards-conformant example. The decoder itself is not shown to be affected, so this is a bounded merge risk.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EONRaider
EONRaider merged commit ca73b1a into master Oct 25, 2026
15 of 16 checks passed
@EONRaider
EONRaider deleted the claude/mpls branch October 25, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MPLS label-stack decoding (RFC 3032)

2 participants