xref: /dpdk/dts/framework/testbed_model/traffic_generator/scapy.py (revision 1f01c07d3dab801c70b034c5b22da6212b3466be)
1840b1e01SJuraj Linkeš# SPDX-License-Identifier: BSD-3-Clause
2840b1e01SJuraj Linkeš# Copyright(c) 2022 University of New Hampshire
3840b1e01SJuraj Linkeš# Copyright(c) 2023 PANTHEON.tech s.r.o.
4840b1e01SJuraj Linkeš
56ef07151SJuraj Linkeš"""The Scapy traffic generator.
6840b1e01SJuraj Linkeš
76ef07151SJuraj LinkešA traffic generator used for functional testing, implemented with
86ef07151SJuraj Linkeš`the Scapy library <https://scapy.readthedocs.io/en/latest/>`_.
999740300SJeremy SpewockThe traffic generator uses an interactive shell to run Scapy on the remote TG node.
10840b1e01SJuraj Linkeš
1199740300SJeremy SpewockThe traffic generator extends :class:`framework.remote_session.python_shell.PythonShell` to
1299740300SJeremy Spewockimplement the methods for handling packets by sending commands into the interactive shell.
13840b1e01SJuraj Linkeš"""
14840b1e01SJuraj Linkeš
15840b1e01SJuraj Linkeš
1699740300SJeremy Spewockimport re
1799740300SJeremy Spewockimport time
1899740300SJeremy Spewockfrom typing import ClassVar
1999740300SJeremy Spewock
2099740300SJeremy Spewockfrom scapy.compat import base64_bytes  # type: ignore[import-untyped]
21282688eaSLuca Vizzarrofrom scapy.layers.l2 import Ether  # type: ignore[import-untyped]
22282688eaSLuca Vizzarrofrom scapy.packet import Packet  # type: ignore[import-untyped]
23840b1e01SJuraj Linkeš
24840b1e01SJuraj Linkešfrom framework.config import OS, ScapyTrafficGeneratorConfig
252b2f5a8aSLuca Vizzarrofrom framework.remote_session.python_shell import PythonShell
26840b1e01SJuraj Linkešfrom framework.testbed_model.node import Node
27840b1e01SJuraj Linkešfrom framework.testbed_model.port import Port
2899740300SJeremy Spewockfrom framework.testbed_model.traffic_generator.capturing_traffic_generator import (
29bad934bfSJeremy Spewock    PacketFilteringConfig,
30840b1e01SJuraj Linkeš)
3199740300SJeremy Spewockfrom framework.utils import REGEX_FOR_BASE64_ENCODING
32840b1e01SJuraj Linkeš
3399740300SJeremy Spewockfrom .capturing_traffic_generator import CapturingTrafficGenerator
34840b1e01SJuraj Linkeš
3599740300SJeremy Spewock
3699740300SJeremy Spewockclass ScapyTrafficGenerator(PythonShell, CapturingTrafficGenerator):
3799740300SJeremy Spewock    """Provides access to scapy functions on a traffic generator node.
3899740300SJeremy Spewock
3999740300SJeremy Spewock    This class extends the base with remote execution of scapy functions. All methods for
4099740300SJeremy Spewock    processing packets are implemented using an underlying
4199740300SJeremy Spewock    :class:`framework.remote_session.python_shell.PythonShell` which imports the Scapy library. This
4299740300SJeremy Spewock    class also extends :class:`.capturing_traffic_generator.CapturingTrafficGenerator` to expose
4399740300SJeremy Spewock    methods that utilize said packet processing functionality to test suites.
4499740300SJeremy Spewock
4599740300SJeremy Spewock    Because of the double inheritance, this class has both methods that wrap scapy commands
4699740300SJeremy Spewock    sent into the shell (running on the TG node) and methods that run locally to fulfill
4799740300SJeremy Spewock    traffic generation needs.
4899740300SJeremy Spewock    To help make a clear distinction between the two, the names of the methods
4999740300SJeremy Spewock    that wrap the logic of the underlying shell should be prepended with "shell".
5099740300SJeremy Spewock
5199740300SJeremy Spewock    Note that the order of inheritance is important for this class. In order to instantiate this
5299740300SJeremy Spewock    class, the abstract methods of :class:`~.capturing_traffic_generator.CapturingTrafficGenerator`
5399740300SJeremy Spewock    must be implemented. Since some of these methods are implemented in the underlying interactive
5499740300SJeremy Spewock    shell, according to Python's Method Resolution Order (MRO), the interactive shell must come
5599740300SJeremy Spewock    first.
56840b1e01SJuraj Linkeš    """
57840b1e01SJuraj Linkeš
58840b1e01SJuraj Linkeš    _config: ScapyTrafficGeneratorConfig
59840b1e01SJuraj Linkeš
6099740300SJeremy Spewock    #: Name of sniffer to ensure the same is used in all places
6199740300SJeremy Spewock    _sniffer_name: ClassVar[str] = "sniffer"
6299740300SJeremy Spewock    #: Name of variable that points to the list of packets inside the scapy shell.
6399740300SJeremy Spewock    _send_packet_list_name: ClassVar[str] = "packets"
6499740300SJeremy Spewock    #: Padding to add to the start of a line for python syntax compliance.
6599740300SJeremy Spewock    _python_indentation: ClassVar[str] = " " * 4
6699740300SJeremy Spewock
6799740300SJeremy Spewock    def __init__(self, tg_node: Node, config: ScapyTrafficGeneratorConfig, **kwargs):
686ef07151SJuraj Linkeš        """Extend the constructor with Scapy TG specifics.
696ef07151SJuraj Linkeš
7099740300SJeremy Spewock        Initializes both the traffic generator and the interactive shell used to handle Scapy
7199740300SJeremy Spewock        functions. The interactive shell will be started on `tg_node`. The additional keyword
7299740300SJeremy Spewock        arguments in `kwargs` are used to pass into the constructor for the interactive shell.
736ef07151SJuraj Linkeš
746ef07151SJuraj Linkeš        Args:
756ef07151SJuraj Linkeš            tg_node: The node where the traffic generator resides.
766ef07151SJuraj Linkeš            config: The traffic generator's test run configuration.
7799740300SJeremy Spewock            kwargs: Additional keyword arguments. Supported arguments correspond to the parameters
7899740300SJeremy Spewock                of :meth:`PythonShell.__init__` in this case.
796ef07151SJuraj Linkeš        """
80840b1e01SJuraj Linkeš        assert (
8199740300SJeremy Spewock            tg_node.config.os == OS.linux
82840b1e01SJuraj Linkeš        ), "Linux is the only supported OS for scapy traffic generation"
83840b1e01SJuraj Linkeš
8499740300SJeremy Spewock        super().__init__(tg_node, config=config, **kwargs)
8599740300SJeremy Spewock        self.start_application()
86840b1e01SJuraj Linkeš
8799740300SJeremy Spewock    def start_application(self) -> None:
8899740300SJeremy Spewock        """Extends :meth:`framework.remote_session.interactive_shell.start_application`.
892b648cd4SJeremy Spewock
9099740300SJeremy Spewock        Adds a command that imports everything from the scapy library immediately after starting
9199740300SJeremy Spewock        the shell for usage in later calls to the methods of this class.
9299740300SJeremy Spewock        """
9399740300SJeremy Spewock        super().start_application()
9499740300SJeremy Spewock        self.send_command("from scapy.all import *")
95840b1e01SJuraj Linkeš
96840b1e01SJuraj Linkeš    def _send_packets(self, packets: list[Packet], port: Port) -> None:
9799740300SJeremy Spewock        """Implementation for sending packets without capturing any received traffic.
9899740300SJeremy Spewock
9999740300SJeremy Spewock        Provides a "fire and forget" method of sending packets.
10099740300SJeremy Spewock        """
10199740300SJeremy Spewock        self._shell_set_packet_list(packets)
10299740300SJeremy Spewock        send_command = [
10399740300SJeremy Spewock            "sendp(",
10499740300SJeremy Spewock            f"{self._send_packet_list_name},",
10599740300SJeremy Spewock            f"iface='{port.logical_name}',",
10699740300SJeremy Spewock            "realtime=True,",
10799740300SJeremy Spewock            "verbose=True",
10899740300SJeremy Spewock            ")",
10999740300SJeremy Spewock        ]
11099740300SJeremy Spewock        self.send_command(f"\n{self._python_indentation}".join(send_command))
11199740300SJeremy Spewock
11299740300SJeremy Spewock    def _send_packets_and_capture(
11399740300SJeremy Spewock        self,
11499740300SJeremy Spewock        packets: list[Packet],
11599740300SJeremy Spewock        send_port: Port,
11699740300SJeremy Spewock        recv_port: Port,
11799740300SJeremy Spewock        filter_config: PacketFilteringConfig,
11899740300SJeremy Spewock        duration: float,
11999740300SJeremy Spewock    ) -> list[Packet]:
12099740300SJeremy Spewock        """Implementation for sending packets and capturing any received traffic.
12199740300SJeremy Spewock
12299740300SJeremy Spewock        This method first creates an asynchronous sniffer that holds the packets to send, then
12399740300SJeremy Spewock        starts and stops said sniffer, collecting any packets that it had received while it was
12499740300SJeremy Spewock        running.
12599740300SJeremy Spewock
12699740300SJeremy Spewock        Returns:
12799740300SJeremy Spewock            A list of packets received after sending `packets`.
12899740300SJeremy Spewock        """
12999740300SJeremy Spewock        self._shell_create_sniffer(
13099740300SJeremy Spewock            packets, send_port, recv_port, self._create_packet_filter(filter_config)
13199740300SJeremy Spewock        )
13299740300SJeremy Spewock        return self._shell_start_and_stop_sniffing(duration)
13399740300SJeremy Spewock
13499740300SJeremy Spewock    def _shell_set_packet_list(self, packets: list[Packet]) -> None:
13599740300SJeremy Spewock        """Build a list of packets to send later.
13699740300SJeremy Spewock
13799740300SJeremy Spewock        Sends the string that represents the Python command that was used to create each packet in
13899740300SJeremy Spewock        `packets` into the underlying Python session. The purpose behind doing this is to create a
13999740300SJeremy Spewock        list that is identical to `packets` inside the shell. This method should only be called by
14099740300SJeremy Spewock        methods for sending packets immediately prior to sending. The list of packets will continue
14199740300SJeremy Spewock        to exist in the scope of the shell until subsequent calls to this method, so failure to
14299740300SJeremy Spewock        rebuild the list prior to sending packets could lead to undesired "stale" packets to be
14399740300SJeremy Spewock        sent.
14499740300SJeremy Spewock
14599740300SJeremy Spewock        Args:
14699740300SJeremy Spewock            packets: The list of packets to recreate in the shell.
14799740300SJeremy Spewock        """
14899740300SJeremy Spewock        self._logger.info("Building a list of packets to send.")
14999740300SJeremy Spewock        self.send_command(
15099740300SJeremy Spewock            f"{self._send_packet_list_name} = [{', '.join(map(Packet.command, packets))}]"
15199740300SJeremy Spewock        )
152840b1e01SJuraj Linkeš
153bad934bfSJeremy Spewock    def _create_packet_filter(self, filter_config: PacketFilteringConfig) -> str:
15499740300SJeremy Spewock        """Combine filter settings from `filter_config` into a BPF that scapy can use.
155bad934bfSJeremy Spewock
156bad934bfSJeremy Spewock        Scapy allows for the use of Berkeley Packet Filters (BPFs) to filter what packets are
157bad934bfSJeremy Spewock        collected based on various attributes of the packet.
158bad934bfSJeremy Spewock
159bad934bfSJeremy Spewock        Args:
160bad934bfSJeremy Spewock            filter_config: Config class that specifies which filters should be applied.
161bad934bfSJeremy Spewock
162bad934bfSJeremy Spewock        Returns:
163bad934bfSJeremy Spewock            A string representing the combination of BPF filters to be passed to scapy. For
164bad934bfSJeremy Spewock            example:
165bad934bfSJeremy Spewock
166bad934bfSJeremy Spewock            "ether[12:2] != 0x88cc && ether[12:2] != 0x0806"
167bad934bfSJeremy Spewock        """
168bad934bfSJeremy Spewock        bpf_filter = []
169bad934bfSJeremy Spewock        if filter_config.no_arp:
170bad934bfSJeremy Spewock            bpf_filter.append("ether[12:2] != 0x0806")
171bad934bfSJeremy Spewock        if filter_config.no_lldp:
172bad934bfSJeremy Spewock            bpf_filter.append("ether[12:2] != 0x88cc")
173bad934bfSJeremy Spewock        return " && ".join(bpf_filter)
174bad934bfSJeremy Spewock
17599740300SJeremy Spewock    def _shell_create_sniffer(
17699740300SJeremy Spewock        self, packets_to_send: list[Packet], send_port: Port, recv_port: Port, filter_config: str
17799740300SJeremy Spewock    ) -> None:
17899740300SJeremy Spewock        """Create an asynchronous sniffer in the shell.
179840b1e01SJuraj Linkeš
18099740300SJeremy Spewock        A list of packets is passed to the sniffer's callback function so that they are immediately
18199740300SJeremy Spewock        sent at the time sniffing is started.
182840b1e01SJuraj Linkeš
18399740300SJeremy Spewock        Args:
18499740300SJeremy Spewock            packets_to_send: A list of packets to send when sniffing is started.
18599740300SJeremy Spewock            send_port: The port to send the packets on when sniffing is started.
18699740300SJeremy Spewock            recv_port: The port to collect the traffic from.
18799740300SJeremy Spewock            filter_config: An optional BPF format filter to use when sniffing for packets. Omitted
18899740300SJeremy Spewock                when set to an empty string.
18999740300SJeremy Spewock        """
19099740300SJeremy Spewock        self._shell_set_packet_list(packets_to_send)
191*1f01c07dSNicholas Pratte
192*1f01c07dSNicholas Pratte        self.send_command("import time")
19399740300SJeremy Spewock        sniffer_commands = [
19499740300SJeremy Spewock            f"{self._sniffer_name} = AsyncSniffer(",
19599740300SJeremy Spewock            f"iface='{recv_port.logical_name}',",
19699740300SJeremy Spewock            "store=True,",
19799740300SJeremy Spewock            # *args is used in the arguments of the lambda since Scapy sends parameters to the
19899740300SJeremy Spewock            # callback function which we do not need for our purposes.
199*1f01c07dSNicholas Pratte            "started_callback=lambda *args: (time.sleep(1), sendp(",
20099740300SJeremy Spewock            (
20199740300SJeremy Spewock                # Additional indentation is added to this line only for readability of the logs.
20299740300SJeremy Spewock                f"{self._python_indentation}{self._send_packet_list_name},"
203*1f01c07dSNicholas Pratte                f" iface='{send_port.logical_name}')),"
20499740300SJeremy Spewock            ),
20599740300SJeremy Spewock            ")",
20699740300SJeremy Spewock        ]
20799740300SJeremy Spewock        if filter_config:
20899740300SJeremy Spewock            sniffer_commands.insert(-1, f"filter='{filter_config}'")
209840b1e01SJuraj Linkeš
21099740300SJeremy Spewock        self.send_command(f"\n{self._python_indentation}".join(sniffer_commands))
21199740300SJeremy Spewock
21299740300SJeremy Spewock    def _shell_start_and_stop_sniffing(self, duration: float) -> list[Packet]:
21399740300SJeremy Spewock        """Start asynchronous sniffer, run for a set `duration`, then collect received packets.
21499740300SJeremy Spewock
21599740300SJeremy Spewock        This method expects that you have first created an asynchronous sniffer inside the shell
21699740300SJeremy Spewock        and will fail if you haven't. Received packets are collected by printing the base64
21799740300SJeremy Spewock        encoding of each packet in the shell and then harvesting these encodings using regex to
21899740300SJeremy Spewock        convert back into packet objects.
21999740300SJeremy Spewock
22099740300SJeremy Spewock        Args:
22199740300SJeremy Spewock            duration: The amount of time in seconds to sniff for received packets.
22299740300SJeremy Spewock
22399740300SJeremy Spewock        Returns:
22499740300SJeremy Spewock            A list of all packets that were received while the sniffer was running.
22599740300SJeremy Spewock        """
22699740300SJeremy Spewock        sniffed_packets_name = "gathered_packets"
22799740300SJeremy Spewock        self.send_command(f"{self._sniffer_name}.start()")
228*1f01c07dSNicholas Pratte        # Insert a one second delay to prevent timeout errors from occurring
229*1f01c07dSNicholas Pratte        time.sleep(duration + 1)
23099740300SJeremy Spewock        self.send_command(f"{sniffed_packets_name} = {self._sniffer_name}.stop(join=True)")
23199740300SJeremy Spewock        # An extra newline is required here due to the nature of interactive Python shells
23299740300SJeremy Spewock        packet_strs = self.send_command(
23399740300SJeremy Spewock            f"for pakt in {sniffed_packets_name}: print(bytes_base64(pakt.build()))\n"
23499740300SJeremy Spewock        )
23599740300SJeremy Spewock        # In the string of bytes "b'XXXX'", we only want the contents ("XXXX")
23699740300SJeremy Spewock        list_of_packets_base64 = re.findall(
23799740300SJeremy Spewock            f"^b'({REGEX_FOR_BASE64_ENCODING})'", packet_strs, re.MULTILINE
23899740300SJeremy Spewock        )
23999740300SJeremy Spewock        return [Ether(base64_bytes(pakt)) for pakt in list_of_packets_base64]
240