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/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/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()) 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) 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"]