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