From 90cf941308e8fefccf093c9cd975f0e3328355e4 Mon Sep 17 00:00:00 2001 From: Rouven Czerwinski Date: Thu, 20 Apr 2023 13:50:29 +0200 Subject: [PATCH 1/3] helpers: add labgrid-raw-interface helper Wrapper script to be deployed on machines whose network interfaces should be controllable via the RawNetworkInterfaceDriver. A /etc/labgrid/helpers.yaml can deny access to network interfaces. Intended to be used via sudo. Signed-off-by: Rouven Czerwinski Signed-off-by: Bastian Krause --- debian/labgrid.install | 1 + helpers/labgrid-raw-interface | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100755 helpers/labgrid-raw-interface diff --git a/debian/labgrid.install b/debian/labgrid.install index 87e162fa8..cfea6dd96 100755 --- a/debian/labgrid.install +++ b/debian/labgrid.install @@ -5,4 +5,5 @@ debian/labgrid-exporter /usr/bin debian/labgrid-pytest /usr/bin debian/labgrid-suggest /usr/bin helpers/labgrid-bound-connect /usr/sbin +helpers/labgrid-raw-interface /usr/sbin contrib/completion/labgrid-client.bash => /usr/share/bash-completion/completions/labgrid-client diff --git a/helpers/labgrid-raw-interface b/helpers/labgrid-raw-interface new file mode 100755 index 000000000..ad54dcf10 --- /dev/null +++ b/helpers/labgrid-raw-interface @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# +# Wrapper script to be deployed on machines whose network interfaces should be +# controllable via the RawNetworkInterfaceDriver. A /etc/labgrid/helpers.yaml +# can deny access to network interfaces. See below. +# +# This is intended to be used via sudo. For example, add via visudo: +# %developers ALL = NOPASSWD: /usr/sbin/labgrid-raw-interface + +import argparse +import os +import sys + +import yaml + + +def get_denylist(): + denylist_file = "/etc/labgrid/helpers.yaml" + try: + with open(denylist_file) as stream: + data = yaml.load(stream, Loader=yaml.SafeLoader) + except (PermissionError, FileNotFoundError, AttributeError) as e: + raise Exception(f"No configuration file ({denylist_file}), inaccessable or invalid yaml") from e + + denylist = data.get("raw-interface", {}).get("denied-interfaces", []) + + if not isinstance(denylist, list): + raise Exception("No explicit denied-interfaces or not a list, please check your configuration") + + denylist.append("lo") + + return denylist + + +def main(program, ifname, count): + if not ifname: + raise ValueError("Empty interface name.") + if any((c == "/" or c.isspace()) for c in ifname): + raise ValueError(f"Interface name '{ifname}' contains invalid characters.") + if len(ifname) > 16: + raise ValueError(f"Interface name '{ifname}' is too long.") + + denylist = get_denylist() + + if ifname in denylist: + raise ValueError(f"Interface name '{ifname}' is denied in denylist.") + + programs = ["tcpreplay", "tcpdump"] + if program not in programs: + raise ValueError(f"Invalid program {program} called with wrapper, valid programs are: {programs}") + + args = [ + program, + ] + + if program == "tcpreplay": + args.append(f"--intf1={ifname}") + args.append('-') + + if program == "tcpdump": + args.append("-n") + args.append(f"--interface={ifname}") + args.append("-w") + args.append('-') + + if count: + args.append("-c") + args.append(str(count)) + + try: + os.execvp(args[0], args) + except FileNotFoundError as e: + raise RuntimeError(f"Missing {program} binary") from e + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + '-d', + '--debug', + action='store_true', + default=False, + help="enable debug mode" + ) + parser.add_argument('program', type=str, help='program to run, either tcpreplay or tcpdump') + parser.add_argument('interface', type=str, help='interface name') + parser.add_argument('count', nargs="?", type=int, default=None, help='amount of frames to capture while recording') + args = parser.parse_args() + try: + main(args.program, args.interface, args.count) + except Exception as e: # pylint: disable=broad-except + if args.debug: + import traceback + traceback.print_exc(file=sys.stderr) + print(f"ERROR: {e}", file=sys.stderr) + exit(1) From 0cf3fe364373d30ff0d178b21d8f94dc498f230e Mon Sep 17 00:00:00 2001 From: Bastian Krause Date: Fri, 9 Sep 2022 13:48:05 +0200 Subject: [PATCH 2/3] driver: add RawNetworkInterfaceDriver This driver allows "raw" control of a network interface (such as Ethernet or WiFi). Signed-off-by: Bastian Krause Signed-off-by: Rouven Czerwinski --- doc/configuration.rst | 42 +++++ labgrid/driver/__init__.py | 1 + labgrid/driver/rawnetworkinterfacedriver.py | 187 ++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 labgrid/driver/rawnetworkinterfacedriver.py diff --git a/doc/configuration.rst b/doc/configuration.rst index eabd2e905..b28545141 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -2955,6 +2955,48 @@ It supports: - connection sharing (DHCP server with NAT) - listing DHCP leases (if the client has sufficient permissions) +Binds to: + iface: + - `NetworkInterface`_ + - `USBNetworkInterface`_ + - `RemoteNetworkInterface`_ + +Implements: + - None yet + +Arguments: + - None + +RawNetworkInterfaceDriver +~~~~~~~~~~~~~~~~~~~~~~~~~ +This driver allows "raw" control of a network interface (such as Ethernet or +WiFi). + +The labgrid-raw-interface helper (``helpers/labgrid-raw-interface``) needs to +be installed in the PATH and usable via sudo without password. +A configuration file ``/etc/labgrid/helpers.yaml`` must be installed on hosts +exporting network interfaces for the RawNetworkInterfaceDriver, e.g.: + +.. code-block:: yaml + + raw-interface: + denied-interfaces: + - eth1 + +It supports: +- recording traffic +- replaying traffic +- basic statistic collection + +For now, the RawNetworkInterfaceDriver leaves pre-configuration of the exported +network interface to the user, including: +- disabling DHCP +- disabling IPv6 Duplicate Address Detection (DAD) by SLAAC (Stateless +Address Autoconfiguration) and Neighbor Discovery +- disabling Generic Receive Offload (GRO) + +This might change in the future. + Binds to: iface: - `NetworkInterface`_ diff --git a/labgrid/driver/__init__.py b/labgrid/driver/__init__.py index 4cda6be5f..721256bbf 100644 --- a/labgrid/driver/__init__.py +++ b/labgrid/driver/__init__.py @@ -41,6 +41,7 @@ from .httpvideodriver import HTTPVideoDriver from .networkinterfacedriver import NetworkInterfaceDriver from .provider import HTTPProviderDriver, NFSProviderDriver, TFTPProviderDriver +from .rawnetworkinterfacedriver import RawNetworkInterfaceDriver from .mqtt import TasmotaPowerDriver from .manualswitchdriver import ManualSwitchDriver from .usbtmcdriver import USBTMCDriver diff --git a/labgrid/driver/rawnetworkinterfacedriver.py b/labgrid/driver/rawnetworkinterfacedriver.py new file mode 100644 index 000000000..3be80960f --- /dev/null +++ b/labgrid/driver/rawnetworkinterfacedriver.py @@ -0,0 +1,187 @@ +# pylint: disable=no-member +import contextlib +import json +import subprocess + +import attr + +from .common import Driver +from ..factory import target_factory +from ..step import step +from ..util.helper import processwrapper +from ..util.managedfile import ManagedFile +from ..resource.common import NetworkResource + + +@target_factory.reg_driver +@attr.s(eq=False) +class RawNetworkInterfaceDriver(Driver): + bindings = { + "iface": {"NetworkInterface", "RemoteNetworkInterface", "USBNetworkInterface"}, + } + + def __attrs_post_init__(self): + super().__attrs_post_init__() + self._record_handle = None + self._replay_handle = None + + def _wrap_command(self, args): + wrapper = ["sudo", "labgrid-raw-interface"] + + if self.iface.command_prefix: + # add ssh prefix, convert command passed via ssh (including wrapper) to single argument + return self.iface.command_prefix + [" ".join(wrapper + args)] + else: + # keep wrapper and args as-is + return wrapper + args + + def _stop(self, proc, *, timeout=None): + assert proc is not None + + try: + _, err = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.terminate() + _, err = proc.communicate() + raise + + if proc.returncode: + raise subprocess.CalledProcessError( + returncode=proc.returncode, + cmd=proc.args, + stderr=err, + ) + + @Driver.check_active + @step(args=["filename", "count"]) + def start_record(self, filename, *, count=None): + """ + Starts tcpdump on bound network interface resource. + + Args: + filename (str): name of a file to record to + count (int): optional, exit after receiving this many number of packets + Returns: + Popen object of tcpdump process + """ + assert self._record_handle is None + + cmd = ["tcpdump", self.iface.ifname] + if count is not None: + cmd.append(str(count)) + cmd = self._wrap_command(cmd) + with open(filename, "wb") as outdata: + self._record_handle = subprocess.Popen(cmd, stdout=outdata, stderr=subprocess.PIPE) + return self._record_handle + + @Driver.check_active + @step(args=["timeout"]) + def stop_record(self, *, timeout=None): + """ + Stops previously started tcpdump on bound network interface resource. + + Args: + timeout (int): optional, maximum number of seconds to wait for the tcpdump process to + terminate + """ + try: + self._stop(self._record_handle, timeout=timeout) + finally: + self._record_handle = None + + @contextlib.contextmanager + def record(self, filename, *, count=None, timeout=None): + """ + Context manager to start/stop tcpdump on bound network interface resource. + + Either count or timeout must be specified. + + Args: + filename (str): name of a file to record to + count (int): optional, exit after receiving this many number of packets + timeout (int): optional, maximum number of seconds to wait for the tcpdump process to + terminate + """ + assert count or timeout + + try: + yield self.start_record(filename, count=count) + finally: + self.stop_record(timeout=timeout) + + @Driver.check_active + @step(args=["filename"]) + def start_replay(self, filename): + """ + Starts tcpreplay on bound network interface resource. + + Args: + filename (str): name of a file to replay from + Returns: + Popen object of tcpreplay process + """ + assert self._replay_handle is None + + if isinstance(self.iface, NetworkResource): + mf = ManagedFile(filename, self.iface) + mf.sync_to_resource() + cmd = self._wrap_command([f"tcpreplay {self.iface.ifname} < {mf.get_remote_path()}"]) + self._replay_handle = subprocess.Popen(cmd, stderr=subprocess.PIPE) + else: + cmd = self._wrap_command(["tcpreplay", self.iface.ifname]) + with open(filename, "rb") as indata: + self._replay_handle = subprocess.Popen(cmd, stdin=indata) + + return self._replay_handle + + @Driver.check_active + @step(args=["timeout"]) + def stop_replay(self, *, timeout=None): + """ + Stops previously started tcpreplay on bound network interface resource. + + Args: + timeout (int): optional, maximum number of seconds to wait for the tcpreplay process to + terminate + """ + try: + self._stop(self._replay_handle, timeout=timeout) + finally: + self._replay_handle = None + + @contextlib.contextmanager + def replay(self, filename, *, timeout=None): + """ + Context manager to start/stop tcpreplay on bound network interface resource. + + Args: + filename (str): name of a file to replay from + timeout (int): optional, maximum number of seconds to wait for the tcpreplay process to + terminate + """ + try: + yield self.start_replay(filename) + finally: + self.stop_replay(timeout=timeout) + + @Driver.check_active + @step() + def get_statistics(self): + """ + Returns basic interface statistics of bound network interface resource. + """ + cmd = self.iface.command_prefix + [ + "ip", + "--json", + "-stats", "-stats", + "link", "show", + self.iface.ifname] + output = processwrapper.check_output(cmd) + return json.loads(output)[0] + + @Driver.check_active + def get_address(self): + """ + Returns the MAC address of the bound network interface resource. + """ + return self.get_statistics()["address"] From e3ee7f6bdc26237c87b26a3e1a0cef2ea9be5e85 Mon Sep 17 00:00:00 2001 From: Bastian Krause Date: Wed, 26 Oct 2022 15:54:13 +0200 Subject: [PATCH 3/3] examples: add network test using RawNetworkDriver Generates an Ethernet frame via scapy using pcap, copies pcap to DUT, replays pcap on interface, records frame locally (or on exporter, adjust env.yaml accordingly), and compares both. Signed-off-by: Bastian Krause --- examples/network-test/env.yaml | 14 ++++++ examples/network-test/pkg-replay-record.py | 55 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 examples/network-test/env.yaml create mode 100755 examples/network-test/pkg-replay-record.py diff --git a/examples/network-test/env.yaml b/examples/network-test/env.yaml new file mode 100644 index 000000000..1f8b9b10f --- /dev/null +++ b/examples/network-test/env.yaml @@ -0,0 +1,14 @@ +targets: + main: + resources: + NetworkService: + address: 192.168.1.5 + username: root + NetworkInterface: + ifname: enp2s0f3 + drivers: + SSHDriver: {} + RawNetworkInterfaceDriver: {} + options: + local_iface_to_dut_iface: + enp2s0f3: uplink diff --git a/examples/network-test/pkg-replay-record.py b/examples/network-test/pkg-replay-record.py new file mode 100755 index 000000000..65cb51702 --- /dev/null +++ b/examples/network-test/pkg-replay-record.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# Generates an Ethernet frame via scapy using pcap, copies pcap to DUT, replays pcap on interface, +# records frame locally (or on exporter, adjust env.yaml accordingly), and compares both. + +import logging +import os +from tempfile import NamedTemporaryFile, TemporaryDirectory + +from scapy.all import Ether, Raw, rdpcap, wrpcap, conf + +from labgrid import Environment +from labgrid.logging import basicConfig, StepLogger + +def generate_frame(): + frame = [Ether(dst="11:22:33:44:55:66", src="66:55:44:33:22:11", type=0x9000)] + padding = "\x00" * (conf.min_pkt_size - len(frame)) + frame = frame[0] / Raw(load=padding) + return frame + + +basicConfig(level=logging.INFO) +StepLogger.start() +env = Environment("env.yaml") +target = env.get_target() + +netdrv = target.get_driver("RawNetworkInterfaceDriver") +ssh = target.get_driver("SSHDriver") + +# get DUT interface +exporter_iface = netdrv.iface.ifname +dut_iface = env.config.get_target_option(target.name, "local_iface_to_dut_iface")[exporter_iface] + +# generate test frame +generated_frame = generate_frame() + +# write pcap, copy to DUT +remote_pcap = "/tmp/pcap" +with NamedTemporaryFile() as pcap: + wrpcap(pcap.name, generated_frame) + ssh.put(pcap.name, remote_pcap) + +# copy recorded pcap from DUT, compare with generated frame +with TemporaryDirectory() as tempdir: + # start record on exporter + tempf = os.path.join(tempdir, "record.pcap") + with netdrv.record(tempf, count=1) as record: + # replay pcap on DUT + ssh.run_check(f"ip link set {dut_iface} up") + ssh.run_check(f"tcpreplay -i {dut_iface} {remote_pcap}") + + remote_frame = rdpcap(tempf) + assert remote_frame[0] == generated_frame[0] + +print("statistics", netdrv.get_statistics()) +print("address", netdrv.get_address())