Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

46 changes: 43 additions & 3 deletions docs/source/changelog/1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,13 @@ pull requests between #326 and #509.
``UnsupportedCall``, matching the other four families. Backed by the new
``pcapkit.foundation.registry.protocols.register_protocol_code``, which can
also be called directly to register a class that declined at class-definition
time. This is the mechanism #548 (``TransType.L2TP`` registered nowhere)
needs and does not yet use -- fixing it is now a one-declaration change, left
for its own issue rather than folded in here.
time. This was written up as the mechanism #548 (``TransType.L2TP``
registered nowhere) needs, with fixing that issue described here as "now a
one-declaration change". Investigating #548 found otherwise -- 115 is an
:rfc:`3931` L2TPv3-over-IP header with no class to dispatch to, so the
declaration would have pointed the :rfc:`2661` parser at it. See the
corresponding **Fixed** entry below; the mechanism itself is unaffected, and
its worked example now names ``L2TPv3`` rather than ``L2TPv2``.
* **Changed** -- extraction is around 46% faster on a 1,117-frame HTTP capture,
with byte-identical output (#420). A reassembled datagram's payload is now
analysed on first read rather than eagerly, which cuts IP reassembly's own
Expand Down Expand Up @@ -527,6 +531,42 @@ pull requests between #326 and #509.
the live one. Proposed by ``@Ts-Boom`` in #563, whose profiling found the miss
path; the implementation differs because that one added a second,
never-invalidated cache of resolved classes (#574).
* **Fixed** -- ``L2TPv2`` parsed any version nibble, so an L2TPv3 datagram was
reported as v2 with a tunnel and session ID read out of v3's Control
Connection ID. :rfc:`2661` §3.1 fixes ``Ver`` at 2 and reserves 1 for L2F, and
``L2TPv2.version`` already documented that "a datagram carrying any other
value is a different protocol reached through a different class" -- but nothing
enforced it, so the hard-coded ``Literal[2]`` property and ``info.version``
disagreed on the same octets, answering 2 and 3. ``read`` now raises
``ProtocolError``, which degrades the payload to ``Raw`` through the existing
``beholder`` path with the reason recorded. **This affects real captures**:
:rfc:`3931` §4.1.2 puts L2TPv3 on port 1701 too, the port ``UDP.__proto__``
already binds, so ``Ethernet:IPv4:UDP:L2TPv2:Raw`` with invented field values
becomes ``Ethernet:IPv4:UDP:Raw`` with the octets preserved.

This is the resolution of #548, which reported ``TransType.L2TP`` (115) as
registered nowhere and proposed binding ``L2TPv2`` there. That binding is
wrong rather than merely awkward: :rfc:`3931` §4.1.1 gives 115 to *L2TPv3 over
IP*, whose session header is "free of any restrictions imposed by coexistence
with L2TPv2 and L2F" and carries **no version nibble at all**, so a v2 parser
cannot even detect that the datagram is not its own. Measured, it produced
``version=4``, ``tunnelid=0x5678`` and ``sessionid=0xff03`` from the top half
of a Session ID and two octets of the PPP frame behind it. 115 is a missing
*class*, not a missing registration, and stays unbound until an ``L2TPv3``
class exists; no dissector was invented here to fill it. The reasoning is now
recorded in ``pcapkit.protocols.link.l2tp`` rather than only in a test, and
``register_protocol_code``'s worked example -- which named ``L2TPv2`` at 115 --
names ``L2TPv3`` instead (#548).
* **Added** -- ``tests/protocols/test_dispatch_reachability_unit.py``, the
coverage #548 asked for: every ``ProtocolBase`` descendant whose ``__index__``
returns an enum member is checked to be reachable under that code in the
registry its enum *type* designates, read from the same
``_CODE_DESTINATIONS`` table backing ``code=`` so the two cannot drift. Where
``test_dispatch_registry_unit.py`` walks the 38 entries that exist and checks
each parses, this walks the classes and catches one nothing registered at all
-- the shape in which ``OSPF`` once shipped reachable from no table. 23 claims
verified, no gaps; a companion case injects a gap and confirms the audit
reports it, so the guard cannot rot into a permanently green no-op (#548).

Preceded by ``1.5.0a1`` (2026-09-15), ``1.5.0b1`` and ``1.5.0b2`` (both
2026-09-18) and ``1.5.0b3`` (2026-09-19), all published as prereleases and so
Expand Down
15 changes: 13 additions & 2 deletions pcapkit/foundation/registry/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def register_protocol(protocol: 'Type[Protocol]') -> 'None':
#: Enum type -> the class(es) owning the :attr:`ProtocolBase.__proto__
#: <pcapkit.protocols.protocol.ProtocolBase.__proto__>` dispatch registry
#: keyed by that enum type -- the "registry-of-registries" that lets
#: ``code=`` infer a destination from a key's own type, per GH-514. This is
#: ``code=`` infer a destination from a key's own type, per #514. This is
#: not an invention: it is exactly the targeting
#: :func:`register_ethertype`, :func:`register_transtype`,
#: :func:`register_linktype` and :func:`register_sctp` already hard-code by
Expand Down Expand Up @@ -259,12 +259,23 @@ def register_protocol_code(protocol: 'Type[Protocol]', code: 'Any') -> 'None':

.. code-block:: python

register_protocol_code(L2TPv2, [TransType.L2TP, {UDP: 1701}])
register_protocol_code(L2TPv3, [TransType.L2TP, {UDP: 1701}])

The explicit mapping form is accepted for any key, even one whose type
could be inferred -- being more explicit than required is never an
error.

Note:
That example names ``L2TPv3``, which this package does not implement
yet, rather than :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2`. It is
v3 that is genuinely reachable both ways: :rfc:`3931` §4.1.1 puts it
directly over IP on protocol 115 and §4.1.2 puts it over UDP on port
1701. :class:`L2TPv2 <pcapkit.protocols.link.l2tpv2.L2TPv2>` answers on
port 1701 only, and registering *it* at ``TransType.L2TP`` would point
the :rfc:`2661` parser at a v3-over-IP header -- see
:class:`~pcapkit.protocols.link.l2tp.L2TP` and GitHub issue #548 for
what that produced when measured.

Args:
protocol: Protocol class to register.
code: Registration key(s); see above.
Expand Down
35 changes: 30 additions & 5 deletions pcapkit/protocols/link/l2tp.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,28 @@
---------------------------

**L2TPv3** [:rfc:`3931`] has a different session header and a different control
message header from v2, and is reachable two ways -- over UDP port 1701 like v2,
and directly over IP as **protocol number 115**. That second route is why
message header from v2, and is reachable two ways -- over UDP port 1701 like v2
(§4.1.2), and directly over IP as **protocol number 115** (§4.1.1: *"L2TPv3 over
IP (both versions) utilizes the IANA-assigned IP protocol ID 115"*). That second
route is why
:attr:`Internet.__proto__ <pcapkit.protocols.internet.internet.Internet.__proto__>`
leaves 115 unbound today: the binding waits on an ``L2TPv3`` class, not on a
different framing decision. It also means v3 is the first member of this family
to have a real :meth:`~pcapkit.protocols.protocol.Protocol.__index__`.

GitHub issue #548 proposed closing that gap by binding
:class:`~pcapkit.protocols.link.l2tpv2.L2TPv2` at 115 instead, which does not
work and is worth recording so it is not proposed again. Over IP the v3 session
header is, in :rfc:`3931` §4.1.1's own words, *"free of any restrictions imposed
by coexistence with L2TPv2 and L2F"* -- a data message opens with the raw 32-bit
Session ID and carries **no version nibble at all**, so there is nothing a v2
parser could even test to recognise that the datagram is not its own. Measured,
that binding reported ``version=4``, ``tunnelid=0x5678`` and ``sessionid=0xff03``
for a v3-over-IP datagram: a complete header assembled out of the top half of a
Session ID and the first two octets of the PPP frame behind it. 115 is a missing
*class*, not a missing registration, and until that class exists an undissected
payload is the honest answer.

**L2F** [:rfc:`2341`] is reached when the version nibble reads ``1``. It is *not*
an earlier version of L2TP: :rfc:`2661` §3.1 requires ``Ver`` to be 2 and reserves
the value 1 "to permit detection of L2F packets should they arrive intermixed
Expand All @@ -54,16 +69,26 @@
Selecting a version
-------------------

Nothing dispatches on the version nibble yet, because only one version exists.
When a second lands, the mechanism it wants already has a precedent in
Nothing *dispatches* on the version nibble yet, because only one version exists
-- but :meth:`L2TPv2.read <pcapkit.protocols.link.l2tpv2.L2TPv2.read>` does
**check** it, and refuses anything other than ``2``. That is the half of the
mechanism which is useful with one version implemented: it keeps v3 traffic on
port 1701 (:rfc:`3931` §4.1.2 shares the port, so this is ordinary capture
traffic rather than a corner case) from being reported as v2 with a tunnel and
session ID read out of v3's Control Connection ID.

When a second version lands, the remaining half -- delegation rather than refusal
-- already has a precedent in
:class:`~pcapkit.protocols.application.http.HTTP`, which reads a version and
delegates to a per-version class. L2TP is the easier case: HTTP has to
*trial-parse* each candidate in
:meth:`~pcapkit.protocols.application.http.HTTP._guess_version` because the wire
format carries no version field, whereas L2TP states its version explicitly in
those four bits. So a deterministic switch on ``Ver`` is enough, and no new
registry is needed -- the class bound at UDP 1701 reads two octets, masks out the
nibble, and hands the datagram to the matching class.
nibble, and hands the datagram to the matching class. Note the switch belongs on
the **UDP** path only: over IP protocol 115 there is no nibble to switch on, per
the §4.1.1 note above.

.. [*] https://en.wikipedia.org/wiki/Layer_2_Tunneling_Protocol

Expand Down
36 changes: 34 additions & 2 deletions pcapkit/protocols/link/l2tpv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
from pcapkit.protocols.data.link.l2tp import Flags as Data_Flags
from pcapkit.protocols.link.l2tp import L2TP
from pcapkit.protocols.schema.link.l2tp import L2TP as Schema_L2TP
from pcapkit.utilities.exceptions import UnsupportedCall
from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall

if TYPE_CHECKING:
from enum import IntEnum as StdlibEnum
Expand Down Expand Up @@ -88,11 +88,20 @@ class L2TPv2(L2TP[Data_L2TP, Schema_L2TP],
The protocol is dispatched from :attr:`UDP.__proto__
<pcapkit.protocols.transport.udp.UDP.__proto__>` at port 1701.

Only the :rfc:`2661` framing is read: :meth:`read` refuses a datagram whose
version nibble is not ``2``, so a v3 datagram arriving on port 1701
(:rfc:`3931` §4.1.2 shares the port) degrades to
:class:`~pcapkit.protocols.misc.raw.Raw` instead of being reported as v2.

Note:
IANA protocol number 115 (``L2TP``) is deliberately left unbound. It
references :rfc:`3931`, i.e. **L2TPv3**, whose session and control
message headers are a different shape -- so the binding waits on an
``L2TPv3`` class rather than on this one.
``L2TPv3`` class rather than on this one. Binding *this* class there was
proposed in GitHub issue #548 and does not work: over IP the v3 session
header carries no version nibble at all, so this class cannot recognise
that the datagram is not its own. See
:class:`~pcapkit.protocols.link.l2tp.L2TP` for the measurement.

As with :class:`~pcapkit.protocols.link.ospf.OSPF`, the class subclasses
:class:`~pcapkit.protocols.link.link.Link` and so reports
Expand Down Expand Up @@ -124,6 +133,13 @@ def version(self) -> 'Literal[2]':
carrying any other value is a different protocol reached through a
different class. c.f. :class:`~pcapkit.protocols.link.l2tp.L2TP`.

This is enforced rather than merely asserted -- :meth:`read` refuses a
datagram whose nibble is not ``2``, so this hard-coded answer cannot
disagree with
:attr:`info.version <pcapkit.protocols.data.link.l2tp.L2TP.version>`
on the same octets. It did before that guard landed, reporting ``2``
here and ``3`` there.

"""
return 2

Expand Down Expand Up @@ -171,6 +187,22 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_L2TP':
schema = self.__header__
_flag = schema.flags

# NOTE: :rfc:`2661` §3.1 fixes ``Ver`` at 2 and reserves 1 "to permit
# detection of L2F packets should they arrive intermixed with L2TP
# packets", while :rfc:`3931` uses 3 -- so a datagram carrying any other
# nibble is a different protocol, exactly as ``version`` documents.
# Refuse it rather than parse it: every field after this word has a
# different meaning (or no meaning) in another version, so continuing
# reports a tunnel and session ID assembled out of octets that are
# neither. Reported as GitHub issue #548, where an :rfc:`3931` §4.1.1
# L2TPv3-over-IP datagram yielded ``version=4``, ``tunnelid=0x5678`` and
# ``sessionid=0xff03`` -- read out of the top half of a Session ID and
# the first two octets of the PPP frame behind it. This is also what
# makes the hard-coded ``Literal[2]`` of ``version`` true, instead of
# disagreeing with ``info.version`` on the same datagram.
if _flag['version'] != 2:
raise ProtocolError(f'{self.alias}: invalid version: {_flag["version"]}')

flags = Data_Flags(
type=Enum_Type(_flag['type']),
len=bool(_flag['len']),
Expand Down
Loading
Loading