From 870e7e9e0ac3c567a509e7194d5e289b10719c99 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 17:19:31 -0400 Subject: [PATCH] feat(link): add Netlink dissector for ARPHRD_NETLINK on LINUX_SLL/SLL2 Part of #2460. - Add Netlink (nlmsghdr 16-octet framing: nlmsg_len/type/flags/seq/pid), with schema/data modules, registered in all 7 places LinuxSLL/VLAN use (plus pcapkit/all.py, an 8th aggregator caught by test_all_exports_unit.py). - Byte order is ambiguous for Netlink (kernel/userspace IPC, not wire format); resolved by nlmsg_byteorder(), the same heuristic Wireshark's own netlink dissector uses. - Wire LinuxSLL/LinuxSLL2's _import_next_layer to dispatch ARPHRD_NETLINK to Netlink instead of Raw; update the two LinuxSLL test modules' now-stale Raw assertions. - Per-family message payloads and multiple nlmsghdr messages packed into one capture are out of scope for this first cut; every payload is kept as Raw. IPGRE/IP6GRE and IEEE80211_RADIOTAP remain Raw. - Add tests/protocols/link/test_netlink_unit.py, a short-read case, and exemptions in test_rfc_constants_one_home_1739_unit.py for the 16/65535 literals this module shares with unrelated homed constants. - Changelog entry in 1.6.0.rst, regenerated CHANGELOG.md, and a docs page. Build: pylint 10.00/10, mypy clean. Tests: tests/protocols/link/ (176 passed, 2301 subtests), tests/protocols/test_short_read_roundtrip_unit.py (9 passed, 1374 subtests), tests/project/ (2132 passed, 80 skipped, 10555 subtests, 0 failed) -- all green, confirmed by a full re-run after the two project-wide fixes above. --- CHANGELOG.md | 2 + docs/source/changelog/1.6.0.rst | 18 + docs/source/pcapkit/protocols/link/index.rst | 1 + .../source/pcapkit/protocols/link/netlink.rst | 77 ++++ pcapkit/__init__.py | 2 +- pcapkit/all.py | 2 +- pcapkit/protocols/__init__.py | 2 +- pcapkit/protocols/data/__init__.py | 3 + pcapkit/protocols/data/link/__init__.py | 6 + pcapkit/protocols/data/link/netlink.py | 40 ++ pcapkit/protocols/link/__init__.py | 3 +- pcapkit/protocols/link/linux_sll.py | 19 +- pcapkit/protocols/link/netlink.py | 358 ++++++++++++++++++ pcapkit/protocols/schema/__init__.py | 1 + pcapkit/protocols/schema/link/__init__.py | 2 + pcapkit/protocols/schema/link/netlink.py | 44 +++ .../test_rfc_constants_one_home_1739_unit.py | 4 + tests/protocols/link/test_linux_sll1_unit.py | 43 ++- tests/protocols/link/test_linux_sll2_unit.py | 41 +- tests/protocols/link/test_netlink_unit.py | 229 +++++++++++ .../test_short_read_roundtrip_unit.py | 1 + 21 files changed, 875 insertions(+), 23 deletions(-) create mode 100644 docs/source/pcapkit/protocols/link/netlink.rst create mode 100644 pcapkit/protocols/data/link/netlink.py create mode 100644 pcapkit/protocols/link/netlink.py create mode 100644 pcapkit/protocols/schema/link/netlink.py create mode 100644 tests/protocols/link/test_netlink_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a616c60e..c30e363283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Wave 2 of the roadmap, per the contributing docs' enhancement-proposal page: the protocol pairs that wanted a shared abstract base, and the two that simply had no dissector at all. +- **Added** -- `Netlink`, a dissector for the fixed 16-octet `nlmsghdr` framing of Linux's own kernel/userspace IPC transport (the kernel's own `netlink(7)` man page; [RFC 3549 Section 2.3.2](https://datatracker.ietf.org/doc/html/rfc3549#section-2.3.2)). `LinuxSLL`/ `LinuxSLL1`/ `LinuxSLL2` dispatch to it for the `ARPHRD_NETLINK` case instead of falling back to `Raw`. The byte order the header was written in is ambiguous -- Netlink is kernel/userspace IPC, not wire format -- and is guessed the way Wireshark's own netlink dissector does, by `nlmsg_byteorder()`. Per-family message payloads (routing table entries and the like) and multiple messages packed into one capture are out of scope for this first cut; every message's payload is kept as `Raw`, unconditionally. One of [#2460](https://github.com/JarryShaw/PyPCAPKit/issues/2460)'s five `arphrd_type` follow-ups to get a dissector (`FRAD` is tracked separately on [#2499](https://github.com/JarryShaw/PyPCAPKit/pull/2499)); `IPGRE`/`IP6GRE` and `IEEE80211_RADIOTAP` remain `Raw`. + --- Full changelog: diff --git a/docs/source/changelog/1.6.0.rst b/docs/source/changelog/1.6.0.rst index 4800343b1f..0a8a1d7e00 100644 --- a/docs/source/changelog/1.6.0.rst +++ b/docs/source/changelog/1.6.0.rst @@ -4,3 +4,21 @@ Wave 2 of the roadmap, per the contributing docs' enhancement-proposal page: the protocol pairs that wanted a shared abstract base, and the two that simply had no dissector at all. + +* **Added** -- :class:`~pcapkit.protocols.link.netlink.Netlink`, a dissector for + the fixed 16-octet ``nlmsghdr`` framing of Linux's own kernel/userspace IPC + transport (the kernel's own ``netlink(7)`` man page; :rfc:`3549#section-2.3.2`). + :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`/ + :class:`~pcapkit.protocols.link.linux_sll1.LinuxSLL1`/ + :class:`~pcapkit.protocols.link.linux_sll2.LinuxSLL2` dispatch to it for the + ``ARPHRD_NETLINK`` case instead of falling back to + :class:`~pcapkit.protocols.misc.raw.Raw`. The byte order the header was written + in is ambiguous -- Netlink is kernel/userspace IPC, not wire format -- and is + guessed the way Wireshark's own netlink dissector does, by + :func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder`. Per-family message + payloads (routing table entries and the like) and multiple messages packed into + one capture are out of scope for this first cut; every message's payload is kept + as :class:`~pcapkit.protocols.misc.raw.Raw`, unconditionally. One of + :issue:`2460`'s five ``arphrd_type`` follow-ups to get a dissector (``FRAD`` is + tracked separately on :pr:`2499`); ``IPGRE``/``IP6GRE`` and + ``IEEE80211_RADIOTAP`` remain :class:`~pcapkit.protocols.misc.raw.Raw`. diff --git a/docs/source/pcapkit/protocols/link/index.rst b/docs/source/pcapkit/protocols/link/index.rst index f0b47982e2..8337d89c42 100644 --- a/docs/source/pcapkit/protocols/link/index.rst +++ b/docs/source/pcapkit/protocols/link/index.rst @@ -30,6 +30,7 @@ link layer, with detailed implementation and methods. linux_sll1 linux_sll2 loopback + netlink vlan c_tag s_tag diff --git a/docs/source/pcapkit/protocols/link/netlink.rst b/docs/source/pcapkit/protocols/link/netlink.rst new file mode 100644 index 0000000000..0544718aa3 --- /dev/null +++ b/docs/source/pcapkit/protocols/link/netlink.rst @@ -0,0 +1,77 @@ +Netlink - Linux Netlink Socket Framing +======================================= + +.. module:: pcapkit.protocols.link.netlink + +:mod:`pcapkit.protocols.link.netlink` contains +:class:`~pcapkit.protocols.link.netlink.Netlink` +only, which implements the extractor for the ``struct nlmsghdr`` framing of +Linux's own kernel/user IPC transport, as seen on a +``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` capture whose ``arphrd_type`` +is ``ARPHRD_NETLINK`` (an ``nlmon`` interface). Its structure is described as +below: + +.. table:: + + ====== ==== ======================= ================================ + Octets Bits Name Description + ====== ==== ======================= ================================ + 0 0 ``netlink.nlmsg_len`` Message Length (header included) + 4 32 ``netlink.nlmsg_type`` Message Type + 6 48 ``netlink.nlmsg_flags`` Message Flags + 8 64 ``netlink.nlmsg_seq`` Sequence Number + 12 96 ``netlink.nlmsg_pid`` Sender Port ID + ====== ==== ======================= ================================ + +Only this fixed 16-octet header is dissected; per-family message payloads and +multiple messages packed into one capture are out of scope for this first +cut -- see the module docstring's *Scope* section. The byte order the header +was written in is ambiguous (Netlink is kernel/userspace IPC, not wire +format) and is guessed by :func:`nlmsg_byteorder`; either way it is kept as +``netlink.byteorder``. + +.. autoclass:: pcapkit.protocols.link.netlink.Netlink + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + .. autoproperty:: protocol + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: __length_hint__ + .. automethod:: __index__ + + .. automethod:: _make_data + + .. autoattribute:: __proto__ + :no-value: + +.. autofunction:: pcapkit.protocols.link.netlink.nlmsg_byteorder + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.link.netlink + +.. autoclass:: pcapkit.protocols.schema.link.netlink.Netlink + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.link.netlink + +.. autoclass:: pcapkit.protocols.data.link.netlink.Netlink + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] :manpage:`netlink(7)`; :rfc:`3549#section-2.3.2` diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 19841fab0c..fa0adea185 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -112,7 +112,7 @@ 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', # Link Layer - 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', diff --git a/pcapkit/all.py b/pcapkit/all.py index 20b9563aa7..02ae46aa6f 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -121,7 +121,7 @@ 'NoPayload', # No Payload 'Raw', # Raw Packet 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', # Link Layer + 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', # Link Layer 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', # Internet Layer 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', 'HIP', 'HOPOPT', 'IPv6_Ext', 'IPv6_Frag', 'IPv6_Opts', 'IPv6_Route', 'MH', diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 5aab10622d..4645f8210b 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -51,7 +51,7 @@ # Link Layer 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', # Internet Layer 'AH', 'ESP', 'IGMP', 'IGMPv1', 'IGMPv2', 'IP', 'IPsec', 'IPv4', 'IPv6', 'IPX', diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 4fee25a195..95e6ef235c 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -71,6 +71,9 @@ # BSD Loopback Encapsulation 'Loopback', + # Netlink Socket Framing + 'Netlink', + # 802.1Q/802.1ad VLAN Tag Types 'VLAN', 'VLAN_TCI', diff --git a/pcapkit/protocols/data/link/__init__.py b/pcapkit/protocols/data/link/__init__.py index cae293e6cc..94b74b76dc 100644 --- a/pcapkit/protocols/data/link/__init__.py +++ b/pcapkit/protocols/data/link/__init__.py @@ -20,6 +20,9 @@ # BSD Loopback Encapsulation from pcapkit.protocols.data.link.loopback import Loopback +# Netlink Socket Framing +from pcapkit.protocols.data.link.netlink import Netlink + # 802.1Q/802.1ad VLAN Tag Types from pcapkit.protocols.data.link.vlan import TCI as VLAN_TCI from pcapkit.protocols.data.link.vlan import VLAN @@ -40,6 +43,9 @@ # BSD Loopback Encapsulation 'Loopback', + # Netlink Socket Framing + 'Netlink', + # 802.1Q/802.1ad VLAN Tag Types 'VLAN', 'VLAN_TCI', ] diff --git a/pcapkit/protocols/data/link/netlink.py b/pcapkit/protocols/data/link/netlink.py new file mode 100644 index 0000000000..aa9d1db13c --- /dev/null +++ b/pcapkit/protocols/data/link/netlink.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""data models for Linux Netlink socket framing""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from typing_extensions import Literal + +__all__ = ['Netlink'] + + +@info_final +class Netlink(Protocol): + """Data model for Linux Netlink socket framing (``nlmsghdr``).""" + + #: Message length, in octets, including this 16-octet header -- as + #: written, in whichever byte order :attr:`byteorder` records. + nlmsg_len: 'int' + #: Message type; ``1``-``4`` are the generic control values + #: (``NLMSG_NOOP``/``NLMSG_ERROR``/``NLMSG_DONE``/``NLMSG_OVERRUN``) + #: shared by every Netlink family, ``16`` and above are family-specific + #: and not interpreted by this class. + nlmsg_type: 'int' + #: Message flags; their bit meanings depend on ``nlmsg_type`` and on + #: whether the message is a request, so they are not decoded further. + nlmsg_flags: 'int' + #: Sequence number. + nlmsg_seq: 'int' + #: Sender port ID; ``0`` when sent by the kernel. + nlmsg_pid: 'int' + #: Byte order the header was written in. + byteorder: "Literal['big', 'little']" + + if TYPE_CHECKING: + def __init__(self, nlmsg_len: 'int', nlmsg_type: 'int', nlmsg_flags: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + nlmsg_seq: 'int', nlmsg_pid: 'int', # pylint: disable=unused-argument + byteorder: "Literal['big', 'little']") -> 'None': ... # pylint: disable=unused-argument diff --git a/pcapkit/protocols/link/__init__.py b/pcapkit/protocols/link/__init__.py index 0a35e6a5db..3cce2c3956 100644 --- a/pcapkit/protocols/link/__init__.py +++ b/pcapkit/protocols/link/__init__.py @@ -18,6 +18,7 @@ from pcapkit.protocols.link.arp import ARP, InARP from pcapkit.protocols.link.ethernet import Ethernet from pcapkit.protocols.link.loopback import Loopback +from pcapkit.protocols.link.netlink import Netlink # Linux "Cooked" Capture Encapsulation Family from pcapkit.protocols.link.linux_sll import LinuxSLL @@ -42,5 +43,5 @@ # Link Layer Protocols 'ARP', 'C_Tag', 'Ethernet', 'InARP', 'L2TP', 'L2TPv2', 'LinuxSLL', 'LinuxSLL1', - 'LinuxSLL2', 'Loopback', 'S_Tag', 'VLAN', + 'LinuxSLL2', 'Loopback', 'Netlink', 'S_Tag', 'VLAN', ] diff --git a/pcapkit/protocols/link/linux_sll.py b/pcapkit/protocols/link/linux_sll.py index bc5209d8fc..098ef8415d 100644 --- a/pcapkit/protocols/link/linux_sll.py +++ b/pcapkit/protocols/link/linux_sll.py @@ -150,16 +150,27 @@ def _import_next_layer(self, proto: 'int', length: 'Optional[int]' = None, *, # `) Returns: - Instance of next layer: :class:`~pcapkit.protocols.misc.raw.Raw`, - recording ``proto``, when ``arphrd_type`` is in - :data:`_ARPHRD_NOT_ETHERTYPE`. + Instance of next layer: :class:`~pcapkit.protocols.link.netlink.Netlink` + when ``arphrd_type`` is :attr:`ARPHRD.NETLINK + `; otherwise + :class:`~pcapkit.protocols.misc.raw.Raw`, recording ``proto``, for + every other ``arphrd_type`` in :data:`_ARPHRD_NOT_ETHERTYPE`. """ file_ = self._get_payload() if length is None: length = len(file_) arphrd_type = int(self.__header__.arphrd_type) # type: ignore[attr-defined] - if not min(length, len(file_)) or arphrd_type not in _ARPHRD_NOT_ETHERTYPE: + if not min(length, len(file_)): + return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] + + if arphrd_type == ARPHRD.NETLINK: + from pcapkit.protocols.link.netlink import Netlink # pylint: disable=import-outside-toplevel # isort: skip + return self._parse_next_layer(Netlink, file_, length, alias=proto, packet=packet, + layer=self._exlayer, protocol=self._exproto, + __context__=self._exctx) + + if arphrd_type not in _ARPHRD_NOT_ETHERTYPE: return super()._import_next_layer(proto, length, packet=packet) # type: ignore[misc,call-arg] from pcapkit.protocols.misc.raw import Raw # pylint: disable=import-outside-toplevel # isort: skip diff --git a/pcapkit/protocols/link/netlink.py b/pcapkit/protocols/link/netlink.py new file mode 100644 index 0000000000..3fbc7fef00 --- /dev/null +++ b/pcapkit/protocols/link/netlink.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""Netlink - Linux Netlink Socket Framing +============================================ + +.. module:: pcapkit.protocols.link.netlink + +:mod:`pcapkit.protocols.link.netlink` contains +:class:`~pcapkit.protocols.link.netlink.Netlink` only, which implements the +extractor for the ``struct nlmsghdr`` framing [*]_ of Linux's own kernel/user +IPC transport, as seen on a ``LINKTYPE_LINUX_SLL``/``LINKTYPE_LINUX_SLL2`` +capture whose ``arphrd_type`` is ``ARPHRD_NETLINK`` (an ``nlmon`` interface) +-- :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL` dispatches here +instead of falling back to :class:`~pcapkit.protocols.misc.raw.Raw` for that +one case; see :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. +Netlink is the kernel's own IPC mechanism (routing table dumps, interface +notifications and the like), not a "next protocol over this link" the way +every other ``arphrd_type`` exception is -- there is no next-layer dispatch +table here at all, every message's payload is kept as +:class:`~pcapkit.protocols.misc.raw.Raw`. + +.. table:: + + ====== ==== ======================= ================================ + Octets Bits Name Description + ====== ==== ======================= ================================ + 0 0 ``netlink.nlmsg_len`` Message Length (header included) + 4 32 ``netlink.nlmsg_type`` Message Type + 6 48 ``netlink.nlmsg_flags`` Message Flags + 8 64 ``netlink.nlmsg_seq`` Sequence Number + 12 96 ``netlink.nlmsg_pid`` Sender Port ID + ====== ==== ======================= ================================ + +Scope +----- + +Only the fixed 16-octet ``nlmsghdr`` framing is dissected -- ``nlmsg_len``, +``nlmsg_type``, ``nlmsg_flags``, ``nlmsg_seq`` and ``nlmsg_pid``. Two things a +complete Netlink dissector would eventually need are deliberately left out of +this first cut: + +* **Per-family message payloads.** What a given ``nlmsg_type`` means, and how + its payload is laid out, is defined independently by each Netlink family + (``NETLINK_ROUTE``, ``NETLINK_GENERIC``, ...) and is a large surface of its + own -- routing table entries, link attributes (``rtattr``/``nlattr`` TLV + chains), and so on. The payload is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, unconditionally. +* **Multiple messages packed into one read.** The kernel's own netlink(7) + page documents that a single read from a netlink socket, and so a single + captured frame, commonly carries more than one ``nlmsghdr`` message back to + back (:rfc:`3549#section-2.3.2` numbers the header fields; it does not + itself restate the "one read, several messages" framing convention, which + is :manpage:`netlink(7)`'s). This class dissects only the *first* message's + header; everything after it -- that message's own payload, and any sibling + messages the frame may carry -- is kept together as the undissected + :class:`~pcapkit.protocols.misc.raw.Raw` payload, exactly as if it were one + larger opaque blob. Splitting the frame on every ``nlmsg_len`` boundary is + future work, tracked on :issue:`2460`. + +Byte Order +---------- + +Netlink is a kernel/userspace IPC mechanism, not a protocol with octets +specified for the wire -- its messages are written in the *capturing host's* +native byte order, and neither :manpage:`netlink(7)`, :rfc:`3549` nor +tcpdump.org's `LINKTYPE_NETLINK`_ page record a marker for which order that +was. :func:`nlmsg_byteorder` resolves it with the same heuristic Wireshark's +own netlink dissector uses (``packet-netlink.c``, ``dissect_netlink()``, +comment: *"We do not know the endianness of the capture host, we have to +guess."*): decode ``nlmsg_len`` both ways and keep whichever reading comes +closer to the number of octets actually available, since a genuine message +length is normally well under the size either misreading would imply. See +:func:`~pcapkit.protocols.link.loopback.family_byteorder` for the same +problem solved the same way for ``LINKTYPE_NULL``. + +.. [*] :manpage:`netlink(7)`; :rfc:`3549#section-2.3.2` + +.. _LINKTYPE_NETLINK: https://www.tcpdump.org/linktypes/LINKTYPE_NETLINK.html + +.. versionadded:: 1.6.0 + +""" +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.corekit.module import ModuleDescriptor +from pcapkit.protocols.data.link.netlink import Netlink as Data_Netlink +from pcapkit.protocols.link.link import Link +from pcapkit.protocols.schema.link.netlink import Netlink as Schema_Netlink +from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall + +if TYPE_CHECKING: + from typing import Any, DefaultDict, NoReturn, Optional, Type + + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['Netlink', 'nlmsg_byteorder'] + + +def nlmsg_byteorder(nlmsg_len: 'bytes', length: 'int') -> "Literal['big', 'little']": + """Guess the byte order a Netlink message header was written in. + + Args: + nlmsg_len: The 4 raw octets of ``nlmsg_len``. + length: Octets available from the start of this header to the end of + the frame -- ``nlmsg_len`` counts the header itself + (:rfc:`3549#section-2.3.2`: *"the length of the message in + bytes, including the header"*), so this is the quantity to + compare against, not the payload alone. + + Returns: + ``'big'`` or ``'little'``, whichever reading of ``nlmsg_len`` comes + closer to ``length``; ties favour ``'little'``, the overwhelmingly + common case (x86/ARM hosts). + + Note: + This is the heuristic Wireshark's own netlink dissector uses + (``packet-netlink.c``, ``dissect_netlink()``): a genuine message + length is normally well under the size either misreading of 4 + arbitrary octets would imply, so the reading closer to the octets + actually on hand is overwhelmingly likely to be the real one. + + """ + len_be = int.from_bytes(nlmsg_len, 'big') + len_le = int.from_bytes(nlmsg_len, 'little') + if abs(len_be - length) < abs(len_le - length): + return 'big' + return 'little' + + +class Netlink(Link[Data_Netlink, Schema_Netlink], + schema=Schema_Netlink, data=Data_Netlink): + """This class implements Linux Netlink socket framing. + + Every message's payload is kept as + :class:`~pcapkit.protocols.misc.raw.Raw`, unconditionally -- see the + module docstring's *Scope* section for why. :attr:`self.__proto__ + ` is kept, as an empty + registry, only so :meth:`register` works the same way every other + :class:`~pcapkit.protocols.link.link.Link` subclass's does. + + """ + + ########################################################################## + # Defaults. + ########################################################################## + + #: Protocol index mapping for decoding next layer. Deliberately empty -- + #: see the class docstring -- so every key, including the ``nlmsg_type`` + #: this class dispatches with, resolves through the + #: :class:`~collections.defaultdict` fallback to + #: :class:`~pcapkit.protocols.misc.raw.Raw`. + __proto__ = collections.defaultdict( + lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), + ) # type: DefaultDict[int, ModuleDescriptor[ProtocolBase] | Type[ProtocolBase]] + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Netlink"]': + """Name of current protocol.""" + return 'Netlink' + + @property + def alias(self) -> 'Literal["Netlink"]': + """Acronym of corresponding protocol.""" + return 'Netlink' + + @property + def length(self) -> 'Literal[16]': + """Header length of current protocol.""" + return 16 + + @property + def protocol(self) -> 'int': # type: ignore[override] + """Message type of the dissected ``nlmsghdr``.""" + return self._info.nlmsg_type + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["Netlink"]]': + """Index ID of the protocol.""" + return ('Netlink',) + + def read(self, length: 'Optional[int]' = None, *, + byteorder: "Optional[Literal['big', 'little']]" = None, + **kwargs: 'Any') -> 'Data_Netlink': # pylint: disable=unused-argument + """Read Linux Netlink socket framing. + + Structure of the ``nlmsghdr`` this class dissects, the first message + in the frame: + + .. code-block:: text + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_len | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_type | nlmsg_flags | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_seq | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | nlmsg_pid | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Args: + length: Length of packet data. + byteorder: Byte order to read the header in, ahead of guessing it + from :func:`nlmsg_byteorder`. :meth:`make` is handed the one + :attr:`~pcapkit.protocols.protocol.Protocol.info` holds by + :meth:`from_data `, + and it reaches here too, so a rebuild reads the header as it + was parsed rather than guessing again. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If ``byteorder`` is neither ``'big'`` nor + ``'little'``. + + """ + if length is None: + length = len(self) + schema = self.__header__ + + if byteorder is None: + _byte = nlmsg_byteorder(schema.nlmsg_len, length) + elif byteorder in ('big', 'little'): + _byte = byteorder + else: + raise ProtocolError(f'{self.alias}: invalid byte order: {byteorder!r}') + + netlink = Data_Netlink( + nlmsg_len=int.from_bytes(schema.nlmsg_len, _byte), + nlmsg_type=int.from_bytes(schema.nlmsg_type, _byte), + nlmsg_flags=int.from_bytes(schema.nlmsg_flags, _byte), + nlmsg_seq=int.from_bytes(schema.nlmsg_seq, _byte), + nlmsg_pid=int.from_bytes(schema.nlmsg_pid, _byte), + byteorder=_byte, + ) + return self._decode_next_layer(netlink, netlink.nlmsg_type, length - self.length) + + def make(self, + nlmsg_type: 'int' = 0, + nlmsg_flags: 'int' = 0, + nlmsg_seq: 'int' = 0, + nlmsg_pid: 'int' = 0, + nlmsg_len: 'Optional[int]' = None, + byteorder: "Literal['big', 'little']" = sys.byteorder, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_Netlink': + """Make (construct) packet data. + + Args: + nlmsg_type: Message type. + nlmsg_flags: Message flags. + nlmsg_seq: Sequence number. + nlmsg_pid: Sender port ID. + nlmsg_len: Message length, header included; computed from the + header and ``payload`` when omitted. + byteorder: Byte order to write the header in, by default this + host's, as a genuine capture is written in the capturing + host's own order. + payload: Payload data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``byteorder`` is neither ``'big'`` nor + ``'little'``; if ``nlmsg_type`` or ``nlmsg_flags`` is not an + :class:`int` (a :class:`bool` included) or does not fit in 16 + bits; if ``nlmsg_seq`` or ``nlmsg_pid`` is not an :class:`int` + (a :class:`bool` included) or does not fit in 32 bits; or if + ``nlmsg_len`` is given and does not fit in 32 bits. + + """ + if byteorder not in ('big', 'little'): + raise ProtocolError(f'{self.alias}: invalid byte order: {byteorder!r}') + for name, value in (('nlmsg_type', nlmsg_type), ('nlmsg_flags', nlmsg_flags)): + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFFFF: + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + for name, value in (('nlmsg_seq', nlmsg_seq), ('nlmsg_pid', nlmsg_pid)): + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFFFF_FFFF: + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + + if nlmsg_len is None: + nlmsg_len = 16 + len(payload) + elif (not isinstance(nlmsg_len, int) or isinstance(nlmsg_len, bool) + or not 0 <= nlmsg_len <= 0xFFFF_FFFF): + raise ProtocolError(f'{self.alias}: invalid nlmsg_len: {nlmsg_len!r}') + + return Schema_Netlink( + nlmsg_len=nlmsg_len.to_bytes(4, byteorder), + nlmsg_type=nlmsg_type.to_bytes(2, byteorder), + nlmsg_flags=nlmsg_flags.to_bytes(2, byteorder), + nlmsg_seq=nlmsg_seq.to_bytes(4, byteorder), + nlmsg_pid=nlmsg_pid.to_bytes(4, byteorder), + payload=payload, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[16]': + """Return an estimated length for the object.""" + return 16 + + @classmethod + def __index__(cls) -> 'NoReturn': # pylint: disable=invalid-index-returned + """Numeral registry index of the protocol. + + Raises: + UnsupportedCall: This protocol is reached only through + :class:`~pcapkit.protocols.link.linux_sll.LinuxSLL`'s + ``arphrd_type`` dispatch, not through a link-layer header + type of its own. + + """ + raise UnsupportedCall(f'{cls.__name__!r} object cannot be interpreted as an integer') + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_Netlink') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'nlmsg_type': data.nlmsg_type, + 'nlmsg_flags': data.nlmsg_flags, + 'nlmsg_seq': data.nlmsg_seq, + 'nlmsg_pid': data.nlmsg_pid, + 'nlmsg_len': data.nlmsg_len, + 'byteorder': data.byteorder, + 'payload': cls._make_payload(data), + } diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index cf7cca6790..bb7e7298a7 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -30,6 +30,7 @@ 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', + 'Netlink', 'VLAN', 'VLAN_TCI', # Internet Layer Protocols diff --git a/pcapkit/protocols/schema/link/__init__.py b/pcapkit/protocols/schema/link/__init__.py index b9eae9ffab..49010d216e 100644 --- a/pcapkit/protocols/schema/link/__init__.py +++ b/pcapkit/protocols/schema/link/__init__.py @@ -7,6 +7,7 @@ from pcapkit.protocols.schema.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.schema.link.linux_sll2 import LinuxSLL2 from pcapkit.protocols.schema.link.loopback import Loopback +from pcapkit.protocols.schema.link.netlink import Netlink from pcapkit.protocols.schema.link.vlan import TCI as VLAN_TCI from pcapkit.protocols.schema.link.vlan import VLAN @@ -16,5 +17,6 @@ 'L2TP', 'LinuxSLL1', 'LinuxSLL2', 'Loopback', + 'Netlink', 'VLAN', 'VLAN_TCI', ] diff --git a/pcapkit/protocols/schema/link/netlink.py b/pcapkit/protocols/schema/link/netlink.py new file mode 100644 index 0000000000..909beefdff --- /dev/null +++ b/pcapkit/protocols/schema/link/netlink.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for Linux Netlink socket framing + +Every field of ``nlmsghdr`` is kept as raw octets rather than unpacked by a +fixed byte order -- the byte order itself is ambiguous (see +:func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder`) and has to be +resolved before any of the fields can be read as integers. + +""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['Netlink'] + +if TYPE_CHECKING: + from pcapkit.protocols.protocol import ProtocolBase + + +@schema_final +class Netlink(Schema): + """Header schema for Linux Netlink socket framing (``nlmsghdr``).""" + + #: Message length, including this header; 4 octets, byte order ambiguous. + nlmsg_len: 'bytes' = BytesField(length=4) + #: Message type; 2 octets, byte order ambiguous. + nlmsg_type: 'bytes' = BytesField(length=2) + #: Message flags; 2 octets, byte order ambiguous. + nlmsg_flags: 'bytes' = BytesField(length=2) + #: Sequence number; 4 octets, byte order ambiguous. + nlmsg_seq: 'bytes' = BytesField(length=4) + #: Sender port ID; 4 octets, byte order ambiguous. + nlmsg_pid: 'bytes' = BytesField(length=4) + #: Payload. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, nlmsg_len: 'bytes', nlmsg_type: 'bytes', nlmsg_flags: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + nlmsg_seq: 'bytes', nlmsg_pid: 'bytes', # pylint: disable=unused-argument + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument diff --git a/tests/project/test_rfc_constants_one_home_1739_unit.py b/tests/project/test_rfc_constants_one_home_1739_unit.py index 73c50ff8da..d4cb33111d 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -410,6 +410,10 @@ 'pcapkit/protocols/link/linux_sll1.py': ((65535,), "the max value of LINUX_SLL's own " '2-octet link_addr_length field, not an IP total ' 'length'), + 'pcapkit/protocols/link/netlink.py': ((16, 65535), "the nlmsghdr fixed header length (16 " + 'octets, not a PCAP record header) and the max value ' + "of its 16-bit nlmsg_type/nlmsg_flags fields (not " + 'an IPv4 total length)'), 'pcapkit/protocols/schema/link/ethernet.py': ((6,), 'a MAC address is 6 octets'), 'pcapkit/protocols/schema/link/l2tp.py': ((6, 12), 'an L2TP header field offset, and the ' 'version bit-field'), diff --git a/tests/protocols/link/test_linux_sll1_unit.py b/tests/protocols/link/test_linux_sll1_unit.py index 54813ffd0e..68c395fab9 100644 --- a/tests/protocols/link/test_linux_sll1_unit.py +++ b/tests/protocols/link/test_linux_sll1_unit.py @@ -73,15 +73,18 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self._assert_rebuilds(parsed) def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """``NETLINK``, ``IPGRE``, ``IP6GRE``, ``IEEE80211_RADIOTAP`` and - ``FRAD``, per tcpdump.org's LINUX_SLL page, never have ``protocol_type`` looked up as an - EtherType, even though 0x0800 is IPv4's. + """``IPGRE``, ``IP6GRE``, ``IEEE80211_RADIOTAP`` and ``FRAD``, per + tcpdump.org's LINUX_SLL page, never have ``protocol_type`` looked up + as an EtherType, even though 0x0800 is IPv4's. ``NETLINK`` is also in + :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE` but + dispatches to :class:`~pcapkit.protocols.link.netlink.Netlink` + instead; see :meth:`test_netlink_arphrd_dispatches_to_netlink`. """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 from pcapkit.protocols.misc.raw import Raw - for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, + for arphrd in (ARPHRD.IPGRE, ARPHRD.IP6GRE, ARPHRD.IEEE80211_RADIOTAP, ARPHRD.FRAD): with self.subTest(arphrd=arphrd): # protocol_type 0x0800 is IPv4's EtherType, but these @@ -97,6 +100,28 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_netlink_arphrd_dispatches_to_netlink(self) -> None: + """``ARPHRD_NETLINK`` dispatches to + :class:`~pcapkit.protocols.link.netlink.Netlink`, not + :class:`~pcapkit.protocols.misc.raw.Raw` (:issue:`2500`), even though + it too is in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. + """ + import struct + + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 + from pcapkit.protocols.link.netlink import Netlink + + nlmsg = struct.pack(' None: """Loopback, PPP and SLIP/CSLIP are not :attr:`~pcapkit.const.arp.hardware.Hardware.Ethernet`, and have no @@ -201,7 +226,7 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.link.link import Link from pcapkit.protocols.link.linux_sll1 import LinuxSLL1 - from pcapkit.protocols.misc.raw import Raw + from pcapkit.protocols.link.netlink import Netlink registry = Link.__dict__['__proto__'] had_original = 0x1234 in registry @@ -218,9 +243,11 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: self.assertEqual(parsed.protochain.chain, 'LINUX_SLL1:IPv4:UDP') self._assert_rebuilds(parsed) + # A registered override on Link.__proto__ does not leak into + # NETLINK's dispatch -- it never looks EtherType up at all. raw = _sll(0x1234, ARPHRD.NETLINK) + IPV4 parsed = LinuxSLL1(raw, len(raw)) - self.assertIsInstance(parsed.payload, Raw) + self.assertIsInstance(parsed.payload, Netlink) finally: if had_original: registry[0x1234] = original @@ -319,7 +346,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: raw = (_header(0, 1, bytes.fromhex('001122334455'), 6, 0x0800) + IPV4, # loopback (772): not Ethernet, not one of the exceptions. _header(2, 772, b'', 0, 0x0800) + IPV4, - # NETLINK (824): one of the exceptions, stays Raw. + # NETLINK (824): one of the exceptions, dispatches to Netlink. _header(2, ARPHRD.NETLINK, b'', 0, 0x0800) + IPV4) with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, 'any.pcap') @@ -330,7 +357,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: extractor = pcapkit.extract(fin=path, nofile=True, store=True) self.assertEqual([frame.protochain.chain for frame in extractor.frame], - ['LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:Raw']) + ['LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:IPv4:UDP', 'LINUX_SLL1:Netlink:Raw']) for frame in extractor.frame: self._assert_rebuilds(frame, num=frame._fnum, header=frame._ghdr) diff --git a/tests/protocols/link/test_linux_sll2_unit.py b/tests/protocols/link/test_linux_sll2_unit.py index 619dd93277..c50061a1b0 100644 --- a/tests/protocols/link/test_linux_sll2_unit.py +++ b/tests/protocols/link/test_linux_sll2_unit.py @@ -75,14 +75,17 @@ def test_ethernet_arphrd_dispatches_by_ethertype(self) -> None: self._assert_rebuilds(parsed) def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: - """``NETLINK``, ``IPGRE``, ``IP6GRE``, ``IEEE80211_RADIOTAP`` and - ``FRAD``, per tcpdump.org's LINUX_SLL2 page, are kept raw. + """``IPGRE``, ``IP6GRE``, ``IEEE80211_RADIOTAP`` and ``FRAD``, per + tcpdump.org's LINUX_SLL2 page, are kept raw. ``NETLINK`` is also in + :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE` but + dispatches to :class:`~pcapkit.protocols.link.netlink.Netlink` + instead; see :meth:`test_netlink_arphrd_dispatches_to_netlink`. """ from pcapkit.const.linux.arphrd import ARPHRD from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 from pcapkit.protocols.misc.raw import Raw - for arphrd in (ARPHRD.NETLINK, ARPHRD.IPGRE, ARPHRD.IP6GRE, + for arphrd in (ARPHRD.IPGRE, ARPHRD.IP6GRE, ARPHRD.IEEE80211_RADIOTAP, ARPHRD.FRAD): with self.subTest(arphrd=arphrd): raw = _header(0x0800, 0, 0, arphrd, 2, b'', 0) + IPV4 @@ -96,6 +99,28 @@ def test_the_non_ethertype_arphrds_are_kept_raw(self) -> None: self.assertEqual(parsed.data, raw) self._assert_rebuilds(parsed) + def test_netlink_arphrd_dispatches_to_netlink(self) -> None: + """``ARPHRD_NETLINK`` dispatches to + :class:`~pcapkit.protocols.link.netlink.Netlink`, not + :class:`~pcapkit.protocols.misc.raw.Raw` (:issue:`2500`), even though + it too is in :data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`. + """ + import struct + + from pcapkit.const.linux.arphrd import ARPHRD + from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 + from pcapkit.protocols.link.netlink import Netlink + + nlmsg = struct.pack(' None: """Loopback, PPP and SLIP/CSLIP are not among the exceptions, so ``protocol_type`` is still an EtherType for them. @@ -190,7 +215,7 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: from pcapkit.protocols.internet.ipv4 import IPv4 from pcapkit.protocols.link.link import Link from pcapkit.protocols.link.linux_sll2 import LinuxSLL2 - from pcapkit.protocols.misc.raw import Raw + from pcapkit.protocols.link.netlink import Netlink registry = Link.__dict__['__proto__'] had_original = 0x1234 in registry @@ -207,9 +232,11 @@ def test_registered_override_is_reached_as_from_ethernet(self) -> None: self.assertEqual(parsed.protochain.chain, 'LINUX_SLL2:IPv4:UDP') self._assert_rebuilds(parsed) + # A registered override on Link.__proto__ does not leak into + # NETLINK's dispatch -- it never looks EtherType up at all. raw = _sll(0x1234, ARPHRD.NETLINK) + IPV4 parsed = LinuxSLL2(raw, len(raw)) - self.assertIsInstance(parsed.payload, Raw) + self.assertIsInstance(parsed.payload, Netlink) finally: if had_original: registry[0x1234] = original @@ -307,7 +334,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: raw = (_header(0x0800, 0, 1, 1, 0, bytes.fromhex('001122334455'), 6) + IPV4, # loopback (772): not Ethernet, not one of the exceptions. _header(0x0800, 0, 1, 772, 2, b'', 0) + IPV4, - # NETLINK (824): one of the exceptions, stays Raw. + # NETLINK (824): one of the exceptions, dispatches to Netlink. _header(0x0800, 0, 1, ARPHRD.NETLINK, 2, b'', 0) + IPV4) with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, 'any2.pcap') @@ -318,7 +345,7 @@ def test_full_extraction_dissects_an_any_capture(self) -> None: extractor = pcapkit.extract(fin=path, nofile=True, store=True) self.assertEqual([frame.protochain.chain for frame in extractor.frame], - ['LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:Raw']) + ['LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:IPv4:UDP', 'LINUX_SLL2:Netlink:Raw']) for frame in extractor.frame: self._assert_rebuilds(frame, num=frame._fnum, header=frame._ghdr) diff --git a/tests/protocols/link/test_netlink_unit.py b/tests/protocols/link/test_netlink_unit.py new file mode 100644 index 0000000000..848c2d23d6 --- /dev/null +++ b/tests/protocols/link/test_netlink_unit.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2500: Linux Netlink socket framing (``nlmsghdr``). + +:class:`~pcapkit.protocols.link.netlink.Netlink` dissects the ``arphrd_type`` +``ARPHRD_NETLINK`` payload that +:class:`~pcapkit.protocols.link.linux_sll.LinuxSLL` previously kept whole as +:class:`~pcapkit.protocols.misc.raw.Raw` (see +:data:`~pcapkit.protocols.link.linux_sll._ARPHRD_NOT_ETHERTYPE`). Covers the +16-octet ``nlmsghdr`` fields, the byte-order heuristic +:func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder` resolves (see +:rfc:`3549#section-2.3.2`), and byte-exact round trips. The NETLINK-dispatch +side of ``LinuxSLL1``/``LinuxSLL2`` is covered in their own test modules. + +Each test imports :mod:`pcapkit` itself, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import struct +import unittest + +from tests._support import reimport_once_per_class + + +def _nlmsghdr(byteorder: str, nlmsg_type: int = 0, nlmsg_flags: int = 0, + nlmsg_seq: int = 0, nlmsg_pid: int = 0, payload: bytes = b'', + nlmsg_len: 'int | None' = None) -> bytes: + """Pack a bare ``nlmsghdr`` plus ``payload`` in the given byte order.""" + if nlmsg_len is None: + nlmsg_len = 16 + len(payload) + fmt = ('<' if byteorder == 'little' else '>') + 'IHHII' + return struct.pack(fmt, nlmsg_len, nlmsg_type, nlmsg_flags, nlmsg_seq, nlmsg_pid) + payload + + +class TestNetlink(unittest.TestCase): + """Pin the Netlink layer, alone.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _assert_rebuilds(self, layer, **kwargs) -> None: # type: ignore[no-untyped-def] + """``layer`` rebuilds byte-exactly from its info, and from ``info.to_dict()``.""" + for data in (layer.info, layer.info.to_dict()): + with self.subTest(layer=type(layer).__name__, data=type(data).__name__): + self.assertEqual(type(layer).from_data(data, **kwargs).data.hex(), layer.data.hex()) + + def test_little_endian_header_decodes_every_field(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + raw = _nlmsghdr('little', nlmsg_type=3, nlmsg_flags=0x0101, nlmsg_seq=42, + nlmsg_pid=1234, payload=b'\xaa\xbb\xcc\xdd') + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.nlmsg_len, 20) + self.assertEqual(parsed.info.nlmsg_type, 3) + self.assertEqual(parsed.info.nlmsg_flags, 0x0101) + self.assertEqual(parsed.info.nlmsg_seq, 42) + self.assertEqual(parsed.info.nlmsg_pid, 1234) + self.assertEqual(parsed.info.byteorder, 'little') + self.assertEqual(parsed.protocol, 3) + self.assertEqual((parsed.name, parsed.alias, parsed.length), ('Netlink', 'Netlink', 16)) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self.assertEqual(parsed.payload.data, b'\xaa\xbb\xcc\xdd') + self.assertEqual(parsed.protochain.chain, 'Netlink:Raw') + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_big_endian_header_is_detected_by_heuristic(self) -> None: + """A length field that only makes sense read big-endian is read + that way, per :func:`~pcapkit.protocols.link.netlink.nlmsg_byteorder`. + """ + from pcapkit.protocols.link.netlink import Netlink + + raw = _nlmsghdr('big', nlmsg_type=16, nlmsg_flags=5, nlmsg_seq=7, + nlmsg_pid=0, payload=b'\x01\x02\x03\x04\x05\x06') + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.byteorder, 'big') + self.assertEqual(parsed.info.nlmsg_len, 22) + self.assertEqual(parsed.info.nlmsg_type, 16) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_heuristic_matches_wireshark_on_both_byteorders_of_the_same_payload(self) -> None: + """The same field values, packed each way, are read back the same + way they were packed -- the heuristic is not biased toward one + reading regardless of the octets. + """ + from pcapkit.protocols.link.netlink import Netlink + + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + raw = _nlmsghdr(byteorder, nlmsg_type=1, nlmsg_flags=0, nlmsg_seq=99, + nlmsg_pid=500, payload=bytes(32)) + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.byteorder, byteorder) + self.assertEqual(parsed.info.nlmsg_seq, 99) + self.assertEqual(parsed.info.nlmsg_pid, 500) + self._assert_rebuilds(parsed) + + def test_payload_is_always_raw_regardless_of_type(self) -> None: + """Per-family payload decoding is out of scope: every ``nlmsg_type``, + including the generic control values, resolves to + :class:`~pcapkit.protocols.misc.raw.Raw`. + """ + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.protocols.misc.raw import Raw + + # NLMSG_NOOP, NLMSG_ERROR, NLMSG_DONE, NLMSG_OVERRUN, and a + # family-specific value (RTM_NEWLINK = 16, NETLINK_ROUTE's own). + for nlmsg_type in (1, 2, 3, 4, 16): + with self.subTest(nlmsg_type=nlmsg_type): + raw = _nlmsghdr('little', nlmsg_type=nlmsg_type, payload=b'\x00\x00\x00\x00') + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.nlmsg_type, nlmsg_type) + self.assertIsInstance(parsed.payload, Raw) + self.assertEqual(parsed.protochain.chain, 'Netlink:Raw') + self._assert_rebuilds(parsed) + + def test_zero_length_payload_is_no_payload(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.protocols.misc.null import NoPayload + + raw = _nlmsghdr('little', nlmsg_type=3) + parsed = Netlink(raw, len(raw)) + self.assertEqual(parsed.info.nlmsg_len, 16) + self.assertIsInstance(parsed.payload, NoPayload) + self.assertEqual(parsed.data, raw) + self._assert_rebuilds(parsed) + + def test_make_then_parse(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink(nlmsg_type=3, nlmsg_flags=0x0101, nlmsg_seq=1, nlmsg_pid=0, + byteorder='little', payload=b'\xaa\xbb\xcc\xdd').data + self.assertEqual(made, _nlmsghdr('little', nlmsg_type=3, nlmsg_flags=0x0101, + nlmsg_seq=1, nlmsg_pid=0, payload=b'\xaa\xbb\xcc\xdd')) + + parsed = Netlink(made, len(made)) + self.assertEqual(parsed.info.nlmsg_type, 3) + self.assertEqual(type(parsed.payload).__name__, 'Raw') + self._assert_rebuilds(parsed) + + def test_make_computes_nlmsg_len_from_payload(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink(payload=bytes(10)) + self.assertEqual(made.info.nlmsg_len, 26) + + def test_make_defaults(self) -> None: + import sys + + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink() + self.assertEqual(made.data, struct.pack( + ('<' if sys.byteorder == 'little' else '>') + 'IHHII', 16, 0, 0, 0, 0)) + + def test_make_explicit_byteorder_round_trips(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + made = Netlink(nlmsg_type=5, byteorder=byteorder, payload=b'\x01\x02') + self.assertEqual(made.data, _nlmsghdr(byteorder, nlmsg_type=5, payload=b'\x01\x02')) + parsed = Netlink(made.data, len(made.data)) + self.assertEqual(parsed.info.byteorder, byteorder) + self.assertEqual(parsed.info.nlmsg_type, 5) + + def test_make_rejects_invalid_byteorder(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaises(ProtocolError): + Netlink(byteorder='middle') # type: ignore[arg-type] + + def test_make_rejects_what_does_not_fit(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.utilities.exceptions import ProtocolError + + for kwargs in ( + {'nlmsg_type': -1}, {'nlmsg_type': 0x1_0000}, {'nlmsg_type': True}, + {'nlmsg_flags': -1}, {'nlmsg_flags': 0x1_0000}, + {'nlmsg_seq': -1}, {'nlmsg_seq': 0x1_0000_0000}, + {'nlmsg_pid': -1}, {'nlmsg_pid': 0x1_0000_0000}, + {'nlmsg_len': -1}, {'nlmsg_len': 0x1_0000_0000}, {'nlmsg_len': True}, + ): + with self.subTest(**kwargs), self.assertRaises(ProtocolError): + Netlink(**kwargs) + + def test_make_explicit_nlmsg_len_is_written_as_given(self) -> None: + """An explicit ``nlmsg_len`` is written as given, not recomputed -- + the same reason :meth:`UDP.make ` + keeps an explicit ``total_length`` (:issue:`1155`). + """ + from pcapkit.protocols.link.netlink import Netlink + + made = Netlink(nlmsg_len=999, payload=b'\x01\x02') + self.assertEqual(made.info.nlmsg_len, 999) + + def test_id_and_index(self) -> None: + from pcapkit.protocols.link.netlink import Netlink + from pcapkit.utilities.exceptions import UnsupportedCall + + self.assertEqual(Netlink.id(), ('Netlink',)) + with self.assertRaises(UnsupportedCall): + Netlink.__index__() + + def test_truncated_header_rebuilds_as_captured(self) -> None: + """A capture ending inside the header must not blow up the parse, and + rebuilds to exactly the octets captured. See + ``tests/protocols/test_short_read_roundtrip_unit.py`` for the sweep + that cuts this (and every other) family at every offset. + """ + import warnings + + from pcapkit.protocols.link.netlink import Netlink + + raw = _nlmsghdr('little', nlmsg_type=3, payload=b'\xaa\xbb\xcc\xdd') + for keep in (1, 2, 3, 4, 8, 15): + with self.subTest(keep=keep): + cut = raw[:keep] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + parsed = Netlink(cut, len(cut)) + self.assertEqual(parsed.data, cut) + self.assertEqual(Netlink.from_data(parsed.info).data, cut) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/test_short_read_roundtrip_unit.py b/tests/protocols/test_short_read_roundtrip_unit.py index f3c81b28c0..a4d3b64597 100644 --- a/tests/protocols/test_short_read_roundtrip_unit.py +++ b/tests/protocols/test_short_read_roundtrip_unit.py @@ -52,6 +52,7 @@ def _ipv6(code: int, body: bytes) -> bytes: ('l2tpv2', 'link.l2tpv2:L2TPv2', {}, bytes.fromhex('fff2000e00000000000000000000')), ('loopback-null', 'link.loopback:Loopback', {}, bytes.fromhex('020000004500')), ('loopback-loop', 'link.loopback:Loopback', {'alias': 108}, bytes.fromhex('000000024500')), + ('netlink', 'link.netlink:Netlink', {}, struct.pack('