Skip to content

fix(toolkit): key tunnelled TCP by the innermost IP header (#1581) - #1605

Merged
JarryShaw merged 1 commit into
mainfrom
fix/tunnelled-tcp-flow-key
Oct 10, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/tunnelled-tcp-flow-key

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Please follow the guide below

What is the purpose of your pull request?

Tick the commit type your subject line carries.

  • fix — corrects a defect
  • feat — adds a feature
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • release — bumps the version or rolls up a distribution
  • chore — anything else

Description of your pull request and other information

Closes #1581.

Re-measured on c43e9318c (two connections, same port pair, one tunnel): default keyed all four of 4in4/6in4/4in6/6in6 by the outer addresses and merged them; scapy and pyshark the same except 4in6; dpkt found no TCP; pypcapfile left all four out with its tunnel warning.

  • default (toolkit/pcap.py): tcp_segment keys by the IP layer nearest above TCP. The fix(engines): TCP trace flows disagree between engines on snapped frames and on IPv4 total_length=0 #1518 raw fall-back now applies to that layer, tunnelled or not.
  • dpkt: walks IP-in-IP to the innermost packet, entering protocols 4 and 41 only, as the default engine does. dpkt also decodes IPv4 protocol 0 as IPv4.
  • scapy, pyshark: the IP layer nearest above TCP, not the first IP layer.
  • pypcapfile: _innermost walks the tunnels and decodes an innermost IPv4 packet. The 'tunnel' warning is removed. TCP whose innermost layer is IPv6 (6in4, 6in6) warns as TCP over IPv6.
  • Tests: new tests/toolkit/test_tunnelled_tcp_flow_key_1581_unit.py (default, dpkt, scapy, pyshark) and rewritten pypcapfile tunnel tests. They fail on main, except three guards that pass there:
    • test_no_segment_where_the_inner_header_carries_none
    • test_detection_leaves_the_warnings_filters_and_the_logger_alone
    • test_protocol_0_is_no_tunnel, which fails against the unrestricted dpkt walk.
  • No KNOWN_FAILURES row cited fix(toolkit): a TCP flow inside an IP-in-IP tunnel is keyed by the tunnel's outer addresses #1581.

Not changed here:

@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) review: running A cross-review is in flight against the current head - no verdict yet labels Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Coverage: 89.72% (unit tier, Python 3.14, 143d691ed, Unit Tests run success)

Package Statements Missed Branches Partial Cover
pcapkit (top level) 104 3 20 3 95.16%
pcapkit/const 18858 956 2366 870 91.04%
pcapkit/corekit 2366 67 760 35 96.48%
pcapkit/dumpkit 275 3 100 3 98.40%
pcapkit/foundation 2883 147 1010 36 94.07%
pcapkit/interface 138 7 52 5 93.68%
pcapkit/protocols 17089 227 4566 192 98.05%
pcapkit/toolkit 901 123 320 6 85.34%
pcapkit/utilities 494 4 146 4 98.75%
pcapkit/vendor 4409 2342 1006 157 43.25%

Per-file detail: the coverage-html artifact of this run.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Verdict: NEEDS CHANGES at e05e0c0dc. This was a Sonnet cross-review; the author ran on Opus.

The four-engine claims hold. Across 4in4, 6in4, 4in6, 6in6, 4in4in4 and 4in6in4, default, dpkt, scapy and pypcapfile give identical flow labels, with 158 field comparisons all matching. Plain IPv4 and IPv6, options, fragments, VLAN tags and snaplen cuts are unchanged from main. Three things need fixing:

  1. dpkt treats IPv4 protocol 0 as a tunnel. _tcp_carrier (toolkit/dpkt.py:450) walks into any dpkt.ip.IP or IP6 payload, and dpkt decodes protocol 0 as IPv4. On Ether/IPv4 (protocol 0)/IPv4/TCP, dpkt now reassembles the 10.0.0.1 flows, while default and scapy give none, as dpkt did on main. Fix: walk only on protocol 4 or 41.

  2. pyshark now disagrees with default on every tunnel. tcp_traceflow (toolkit/pyshark.py:485) reads packet.ip, which is the outermost IP layer. On 4in4, the trace labels are:

    • default: 10.0.0.1_40000-10.0.0.2_9
    • pyshark: 192.0.2.1_40000-198.51.100.2_9

    On main both engines used the outer pair, so this PR opens the divergence.

  3. The body says every test fails on main, but two pass there. They are test_no_segment_where_the_inner_header_carries_none (test_tunnelled_tcp_flow_key_1581_unit.py:234) and test_detection_leaves_the_warnings_filters_and_the_logger_alone (test_pypcapfile_unit.py:912). Please name them as guards.

I re-measured items 1 and 2 myself on e05e0c0dc and on c43e9318c.

@JarryShaw JarryShaw added review: needs-changes Cross-review at the current head says changes are required; see the verdict comment and removed review: running A cross-review is in flight against the current head - no verdict yet labels Oct 10, 2026
TCP reassembly and flow tracing keyed a segment by the frame's first IP
layer, which in an IP-in-IP tunnel is the tunnel's: connections through
one tunnel sharing a port pair merged into one flow.

- pcap: tcp_segment takes the IP layer nearest above TCP; the #1518
  raw fall-back applies to that layer, tunnelled or not
- dpkt: walk IP-in-IP to the innermost packet (it found no TCP at all),
  entering protocols 4 and 41 only, as the default engine does
- scapy, pyshark: the IP layer nearest above TCP, not the first one
- pypcapfile: walk tunnels, decode an innermost IPv4 and read its TCP;
  the 'tunnel' warning is gone, TCP over IPv6 in a tunnel warns as such
- docs: pypcapfile tunnel handling

Toolkit tests pass file by file on 3.14 and 3.11; engine agreement
passes on both.
@JarryShaw
JarryShaw force-pushed the fix/tunnelled-tcp-flow-key branch from e05e0c0 to 143d691 Compare October 10, 2026 16:14
@JarryShaw JarryShaw added review: pending No verdict for the current head - never reviewed, or the head moved since the last one review: running A cross-review is in flight against the current head - no verdict yet and removed review: needs-changes Cross-review at the current head says changes are required; see the verdict comment review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Oct 10, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Verdict: GOOD TO GO at 143d691ed. This was the Sonnet cross-review's round 2; the author ran on Opus.

All three round-1 items are fixed. I re-measured the first two myself.

  • Protocol 0: no flows on default, dpkt or scapy.
  • pyshark 4in4: trace labels equal default's (10.0.0.1_40000-10.0.0.2_9).
  • Body: it names the guards correctly.

The review confirmed the rest:

  • Tunnel shapes: on 69 structured captures (4in4, 6in4, 4in6, 6in6, 4in4in4, 4in6in4, 6in4in6, QinQ, padding), default, dpkt, scapy and pyshark give identical trace flows. pypcapfile differs only where the innermost layer is IPv6, as documented.
  • Untunnelled traffic: identical to main on all five engines.
  • Fuzz: the only disagreements are malformed IPv4 options (fix(toolkit): the dpkt and scapy engines accept IPv4 headers the default engine rejects #1607) and pyshark's first fragments.
  • Tests: every new test fails on main except the three named guards.
  • Fake change: the test_pyshark_unit.py fake change matches real pyshark, which derives both in and attribute access from layers.

Non-blocking:

  • pyshark already traces IPv4/IPv6 first fragments differently from default, because tshark defragments. The PR excludes them from the pyshark comparison and says so, but no issue tracks it yet.
  • test_tunnelled_tcp_flow_key_1581_unit.py:135 reads records =[, and its imports are out of order. Cosmetic.

JarryShaw added a commit that referenced this pull request Oct 14, 2026
Third-party engines are best effort (#1687), so the pyshark code that
exists only to match the default engine comes out:

- revert #1686, #1670 and #1642: the second tshark pass that read a
  frame's octets, the link type read from the capture file, the 64-layer
  bound, reading past tshark's 802.1Q limit, and keying a source-routed
  segment by ip.cur_rt
- revert #1613's tshark preferences that turned IP reassembly off, with
  its FileCapture assertion in test_runtime_engines.py and its sentence
  in the engines index
- keep #1613's partial-TCP-header guard and #1605's keying by the IP
  header that carries the segment
- re-land #1671's crash fix: a TCP layer whose IP layer lacks ip.src or
  ip.dst is not traced, rather than raising AttributeError
- delete the three test modules those reverts added and the agreement
  checks in test_pyshark_unit.py; the #1611 and #1671 checks assert
  constants

The pyshark test modules, test_runtime_engines.py, test_engine_runtime.py
and the project guards pass on 3.11 (tshark 4.6.9) and 3.14.
JarryShaw added a commit that referenced this pull request Oct 14, 2026
…1721)

Third-party engines are best effort (#1687), so the pypcapfile code that
exists only to match the default engine comes out:

- revert #1682, #1600, #1575 and #1535, and the pypcapfile hunks of #1655,
  #1608 and #1605: reading loopback and raw-IP link types, the frame layer
  bound, the VLAN and tunnel walks, keying tunnelled TCP by the innermost
  header, stepping over AH and extension headers, and checking options
  with the default engine's parsers
- keep the helpers the dpkt and scapy adapters import, unchanged, until
  their own reverts drop them; their docstrings no longer say pypcapfile
  asks the default engine's parsers too
- re-land #1576, #1577, #1590 and #1591 as guards in _network and
  _transport, #1513's once-per-capture warning for TCP straight over
  IPv6, and #1667's packet2dict fix: an undecoded frame is reported as
  its octets, not as hex text twice as long
- keep #1569's record reader and #1690's packet2timestamp
- delete the three test modules those reverts added, the pypcapfile
  timestamp agreement class, and #1682's link-type warning test; the
  re-landed guards' tests assert constants
- drop what the reverts removed from the docs, READ_LINKTYPES included,
  and say that TCP behind a VLAN tag, in a tunnel or behind an
  extension header (an IPv6 one, or AH in IPv4) is now left out
  without a warning

The pypcapfile modules, test_new_engine_parity_runtime.py,
test_engine_runtime.py, the timestamp module and the project guards pass
on 3.11 (pypcapfile 0.12.0) and 3.14.
JarryShaw added a commit that referenced this pull request Oct 14, 2026
Third-party engines are best effort (#1687), so the scapy code that
exists only to match the default engine comes out:

- revert #1683, #1663, #1652 and #1631, and the scapy hunks of #1614: the
  link-type and loopback-family walk, the frame and extension-header
  bounds, the IPv6 chain walk over the octets, the stops at GRE, VXLAN,
  PPPoE and the other layers the default engine leaves raw and at ICMP
  quotes, and the IPv4 header checks
- keep #1624's walk (TCP behind AH or an IPv6 extension header in IPv4,
  and the later-fragment stop), #1605's keying by the carrying IP header
  and #1589's trailer cut; the walk calls _walk_extension_headers, which
  the pypcapfile revert keeps
- re-land as guards: a zero-length frame is no IPv4 input and has no
  packet2dict octets (#1676); IPv4 in IPv6 ends at the Payload Length
  (#1623); a packet built by hand is read off its octets (#1676);
  ipv6_reassembly takes only its own IPv6 layer's Fragment header, and
  none in a later IPv4 fragment
- delete the four scapy agreement modules and test_scapy_unit's
  default-engine checks; the guards' tests assert constants
- docs: scapy applies the extension-header bound only behind IPv4 and the
  frame-layer bound nowhere, and its silent gaps are listed
  where gaps are said to be announced

The scapy toolkit and engine modules, the shared modules that run scapy,
and the project and tier guards pass on 3.14 (scapy 2.7.0) and 3.11
(scapy 2.8.0).
JarryShaw added a commit that referenced this pull request Oct 14, 2026
Third-party engines are best effort (#1687), so the scapy code that
exists only to match the default engine comes out:

- revert #1683, #1663, #1652 and #1631, and the scapy hunks of #1614: the
  link-type and loopback-family walk, the frame and extension-header
  bounds, the IPv6 chain walk over the octets, the stops at GRE, VXLAN,
  PPPoE and the other layers the default engine leaves raw and at ICMP
  quotes, and the IPv4 header checks
- keep #1624's walk (TCP behind AH or an IPv6 extension header in IPv4,
  and the later-fragment stop), #1605's keying by the carrying IP header
  and #1589's trailer cut; the walk calls _walk_extension_headers, which
  the pypcapfile revert keeps
- re-land as guards: a zero-length frame is no IPv4 input and has no
  packet2dict octets (#1676); IPv4 in IPv6 ends at the Payload Length
  (#1623); a packet built by hand is read off its octets (#1676);
  ipv6_reassembly takes only its own IPv6 layer's Fragment header, and
  none in a later IPv4 fragment
- delete the four scapy agreement modules and test_scapy_unit's
  default-engine checks; the guards' tests assert constants
- docs: scapy applies the extension-header bound only behind IPv4 and the
  frame-layer bound nowhere, and its silent gaps are listed
  where gaps are said to be announced

The scapy toolkit and engine modules, the shared modules that run scapy,
and the project and tier guards pass on 3.14 (scapy 2.7.0) and 3.11
(scapy 2.8.0).
JarryShaw added a commit that referenced this pull request Oct 14, 2026
Third-party engines are best effort (#1687), so the dpkt code that exists
only to match the default engine comes out:

- revert #1662, #1655, #1624, #1622, #1614 and the dpkt hunks of #1605:
  the octet walk and its link-type stash, IPv6 in IPv4 and IPv4 in IPv6,
  refusing MPLS and ISL, the tunnel and extension-header walks, the IPv4
  header checks and the layer bounds
- keep #1690's timestamps, #1594's parser choice, #1621 and #1653
- re-land as guards: a layer dpkt's own lengths misplace (#1646) is
  re-serialised, not refused with ProtocolError; a Total Length below the
  IHL keeps the header whole (#1616), and a header the capture cuts is no
  input; packet2dict and packet2chain take a str payload and a raw frame
- delete the modules #1662 and #1655 added; the 1615/1616 module keeps
  only what its #1616 tests use, as no scapy test imports it now; dpkt's
  comparisons with the default engine assert constants or the capture's
  octets, or are dropped
- drop pypcapfile's VLAN_TAGS, VLAN_TAG_LEN and IPV6_EXTENSION_HEADERS,
  which only dpkt imported, and the 1668 module's _Agreement base, which
  nothing subclasses now
- the docs drop the depth claims and name what dpkt leaves out silently

The dpkt modules, the shared modules that run dpkt, tests/foundation/engines
and the project guards pass on 3.14 and 3.11 (dpkt 1.9.8, pypcapfile 0.12.0).
JarryShaw added a commit that referenced this pull request Oct 14, 2026
Third-party engines are best effort (#1687), so the dpkt code that exists
only to match the default engine comes out:

- revert #1662, #1655, #1624, #1622, #1614 and the dpkt hunks of #1605:
  the octet walk and its link-type stash, IPv6 in IPv4 and IPv4 in IPv6,
  refusing MPLS and ISL, the tunnel and extension-header walks, the IPv4
  header checks and the layer bounds
- keep #1690's timestamps, #1594's parser choice, #1621 and #1653
- re-land as guards: a layer dpkt's own lengths misplace (#1646) is
  re-serialised, not refused with ProtocolError; a Total Length below the
  IHL keeps the header whole (#1616), and a header the capture cuts is no
  input; packet2dict and packet2chain take a str payload and a raw frame
- delete the modules #1662 and #1655 added; the 1615/1616 module keeps
  only what its #1616 tests use, as no scapy test imports it now; dpkt's
  comparisons with the default engine assert constants or the capture's
  octets, or are dropped
- drop pypcapfile's VLAN_TAGS, VLAN_TAG_LEN and IPV6_EXTENSION_HEADERS,
  which only dpkt imported, and the 1668 module's _Agreement base, which
  nothing subclasses now
- the docs drop the depth claims and name what dpkt leaves out silently

The dpkt modules, the shared modules that run dpkt, tests/foundation/engines
and the project guards pass on 3.14 and 3.11 (dpkt 1.9.8, pypcapfile 0.12.0).
@JarryShaw JarryShaw added this to the PyPCAPKit v1.5 milestone Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

fix(toolkit): a TCP flow inside an IP-in-IP tunnel is keyed by the tunnel's outer addresses

1 participant