get:
Show a patch.

patch:
Update a patch.

put:
Update a patch.

GET /api/patches/129646/?format=api
HTTP 200 OK
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "id": 129646,
    "url": "http://patchwork.dpdk.org/api/patches/129646/?format=api",
    "web_url": "http://patchwork.dpdk.org/project/dpdk/patch/20230719141303.33284-7-juraj.linkes@pantheon.tech/",
    "project": {
        "id": 1,
        "url": "http://patchwork.dpdk.org/api/projects/1/?format=api",
        "name": "DPDK",
        "link_name": "dpdk",
        "list_id": "dev.dpdk.org",
        "list_email": "dev@dpdk.org",
        "web_url": "http://core.dpdk.org",
        "scm_url": "git://dpdk.org/dpdk",
        "webscm_url": "http://git.dpdk.org/dpdk",
        "list_archive_url": "https://inbox.dpdk.org/dev",
        "list_archive_url_format": "https://inbox.dpdk.org/dev/{}",
        "commit_url_format": ""
    },
    "msgid": "<20230719141303.33284-7-juraj.linkes@pantheon.tech>",
    "list_archive_url": "https://inbox.dpdk.org/dev/20230719141303.33284-7-juraj.linkes@pantheon.tech",
    "date": "2023-07-19T14:13:03",
    "name": "[v3,6/6] dts: add basic UDP test case",
    "commit_ref": null,
    "pull_url": null,
    "state": "accepted",
    "archived": true,
    "hash": "597d998a06ea02120290b0778c2df738eeb4acdc",
    "submitter": {
        "id": 1626,
        "url": "http://patchwork.dpdk.org/api/people/1626/?format=api",
        "name": "Juraj Linkeš",
        "email": "juraj.linkes@pantheon.tech"
    },
    "delegate": {
        "id": 1,
        "url": "http://patchwork.dpdk.org/api/users/1/?format=api",
        "username": "tmonjalo",
        "first_name": "Thomas",
        "last_name": "Monjalon",
        "email": "thomas@monjalon.net"
    },
    "mbox": "http://patchwork.dpdk.org/project/dpdk/patch/20230719141303.33284-7-juraj.linkes@pantheon.tech/mbox/",
    "series": [
        {
            "id": 28973,
            "url": "http://patchwork.dpdk.org/api/series/28973/?format=api",
            "web_url": "http://patchwork.dpdk.org/project/dpdk/list/?series=28973",
            "date": "2023-07-19T14:12:57",
            "name": "dts: tg abstractions and scapy tg",
            "version": 3,
            "mbox": "http://patchwork.dpdk.org/series/28973/mbox/"
        }
    ],
    "comments": "http://patchwork.dpdk.org/api/patches/129646/comments/",
    "check": "warning",
    "checks": "http://patchwork.dpdk.org/api/patches/129646/checks/",
    "tags": {},
    "related": [],
    "headers": {
        "Return-Path": "<dev-bounces@dpdk.org>",
        "X-Original-To": "patchwork@inbox.dpdk.org",
        "Delivered-To": "patchwork@inbox.dpdk.org",
        "Received": [
            "from mails.dpdk.org (mails.dpdk.org [217.70.189.124])\n\tby inbox.dpdk.org (Postfix) with ESMTP id EFDCF42EB9;\n\tWed, 19 Jul 2023 16:13:47 +0200 (CEST)",
            "from mails.dpdk.org (localhost [127.0.0.1])\n\tby mails.dpdk.org (Postfix) with ESMTP id 8A52842D33;\n\tWed, 19 Jul 2023 16:13:14 +0200 (CEST)",
            "from mail-ed1-f53.google.com (mail-ed1-f53.google.com\n [209.85.208.53]) by mails.dpdk.org (Postfix) with ESMTP id 26FD042D1A\n for <dev@dpdk.org>; Wed, 19 Jul 2023 16:13:12 +0200 (CEST)",
            "by mail-ed1-f53.google.com with SMTP id\n 4fb4d7f45d1cf-51e5e4c6026so9856917a12.0\n for <dev@dpdk.org>; Wed, 19 Jul 2023 07:13:12 -0700 (PDT)",
            "from jlinkes-PT-Latitude-5530.. (ip-46.34.247.144.o2inet.sk.\n [46.34.247.144]) by smtp.gmail.com with ESMTPSA id\n q8-20020a056402040800b0051e2809395bsm2721979edv.63.2023.07.19.07.13.10\n (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);\n Wed, 19 Jul 2023 07:13:11 -0700 (PDT)"
        ],
        "DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;\n d=pantheon.tech; s=google; t=1689775992; x=1690380792;\n h=content-transfer-encoding:mime-version:references:in-reply-to\n :message-id:date:subject:cc:to:from:from:to:cc:subject:date\n :message-id:reply-to;\n bh=hbBQxh6qZUKii8+3FXzVySz2o7TnPCxR6Sn2yjaMZdI=;\n b=tfUajYQN6sob9oasohOuLTXR4ZJqP9x8kUDWyBIpxryBJg4Fni5y+AbP+MEhWDr9NQ\n YRGM38FKTT9TsG0x7uDXPgXMPPSxw8Mi6nPRfBoSSvx3be3cQktZoLqhtaYskvWSJACw\n ANbAalIya+tiKdAQpI0e46PNuyqquJkYyg0/amNiON9syzjoxg0Scs0HQsSJgXjAMlDT\n HaqsD7Hro765qXSOC/oBNi4qmQeXVG5jfG0pN4cqypvdv+3bLVLOvWBNyMc6Tlc5P2nK\n 54uZXEHs+3+YLdFkTuztR1GerNb9B6M5cc3DcsZAF2HNWcxGHrkugOOwM4Av/ntFsn8M\n 9Eqg==",
        "X-Google-DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;\n d=1e100.net; s=20221208; t=1689775992; x=1690380792;\n h=content-transfer-encoding:mime-version:references:in-reply-to\n :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc\n :subject:date:message-id:reply-to;\n bh=hbBQxh6qZUKii8+3FXzVySz2o7TnPCxR6Sn2yjaMZdI=;\n b=MJnwXc/LEcpXxMchYxpxbbK65eOspo1XdhgXFKQK6+Yuxj3aF6k36SNLuEn7KOTbew\n bq7K4YUHI26ZLXZDRDyFgmrABgRCy2Z/7k3gdGsXxLnx3qLg68BRvRzVTZ2tcrhfK9IO\n y9zobI0gjC0GpJ9fJ1vX+6+OfKKdqEO6shqXiTl7SPo6hU0m4Q3xFUusg73Zo1d0c99Q\n 5KoXRf2MVqot2gErs1m82UzcFwnQbbXe7QssHQMpHKWwZchEWoYMvszyzjXSOCxoRrVk\n kFL8Jjj9G9LvDOiRqqBlFZHeSha0nPCnK4Kyey+DGN/kAQhTJj5COnJQPqYibAF2cTqH\n s2yQ==",
        "X-Gm-Message-State": "ABy/qLbiPmqItm5ZVFaSRNrCnFmdz5LRdPYbuYiXwS39EEuwVUVU0GBC\n Cic9vB1HBxSwYlapLg86yo7OGg==",
        "X-Google-Smtp-Source": "\n APBJJlHRYCqwazyA+z069RXCuC2RIwGo4tLN5yCScktIUL+bZcIRnHAq4yGIyPJyRxe08YXI/6cDqw==",
        "X-Received": "by 2002:a05:6402:517b:b0:51e:5169:6262 with SMTP id\n d27-20020a056402517b00b0051e51696262mr2629814ede.15.1689775991730;\n Wed, 19 Jul 2023 07:13:11 -0700 (PDT)",
        "From": "=?utf-8?q?Juraj_Linke=C5=A1?= <juraj.linkes@pantheon.tech>",
        "To": "thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com,\n bruce.richardson@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu",
        "Cc": "dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= <juraj.linkes@pantheon.tech>",
        "Subject": "[PATCH v3 6/6] dts: add basic UDP test case",
        "Date": "Wed, 19 Jul 2023 16:13:03 +0200",
        "Message-Id": "<20230719141303.33284-7-juraj.linkes@pantheon.tech>",
        "X-Mailer": "git-send-email 2.34.1",
        "In-Reply-To": "<20230719141303.33284-1-juraj.linkes@pantheon.tech>",
        "References": "<20230717110709.39220-1-juraj.linkes@pantheon.tech>\n <20230719141303.33284-1-juraj.linkes@pantheon.tech>",
        "MIME-Version": "1.0",
        "Content-Type": "text/plain; charset=UTF-8",
        "Content-Transfer-Encoding": "8bit",
        "X-BeenThere": "dev@dpdk.org",
        "X-Mailman-Version": "2.1.29",
        "Precedence": "list",
        "List-Id": "DPDK patches and discussions <dev.dpdk.org>",
        "List-Unsubscribe": "<https://mails.dpdk.org/options/dev>,\n <mailto:dev-request@dpdk.org?subject=unsubscribe>",
        "List-Archive": "<http://mails.dpdk.org/archives/dev/>",
        "List-Post": "<mailto:dev@dpdk.org>",
        "List-Help": "<mailto:dev-request@dpdk.org?subject=help>",
        "List-Subscribe": "<https://mails.dpdk.org/listinfo/dev>,\n <mailto:dev-request@dpdk.org?subject=subscribe>",
        "Errors-To": "dev-bounces@dpdk.org"
    },
    "content": "The test cases showcases the scapy traffic generator code.\n\nSigned-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>\n---\n dts/conf.yaml                                 |   1 +\n dts/framework/config/conf_yaml_schema.json    |   3 +-\n dts/framework/remote_session/linux_session.py |  20 +-\n dts/framework/remote_session/os_session.py    |  20 +-\n dts/framework/test_suite.py                   | 217 +++++++++++++++++-\n dts/framework/testbed_model/node.py           |  14 +-\n dts/framework/testbed_model/sut_node.py       |   3 +\n dts/tests/TestSuite_os_udp.py                 |  45 ++++\n 8 files changed, 315 insertions(+), 8 deletions(-)\n create mode 100644 dts/tests/TestSuite_os_udp.py",
    "diff": "diff --git a/dts/conf.yaml b/dts/conf.yaml\nindex 0440d1d20a..37967daea0 100644\n--- a/dts/conf.yaml\n+++ b/dts/conf.yaml\n@@ -13,6 +13,7 @@ executions:\n     skip_smoke_tests: false # optional flag that allows you to skip smoke tests\n     test_suites:\n       - hello_world\n+      - os_udp\n     system_under_test_node:\n       node_name: \"SUT 1\"\n       vdevs: # optional; if removed, vdevs won't be used in the execution\ndiff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json\nindex 936a4bac5b..84e45fe3c2 100644\n--- a/dts/framework/config/conf_yaml_schema.json\n+++ b/dts/framework/config/conf_yaml_schema.json\n@@ -185,7 +185,8 @@\n     \"test_suite\": {\n       \"type\": \"string\",\n       \"enum\": [\n-        \"hello_world\"\n+        \"hello_world\",\n+        \"os_udp\"\n       ]\n     },\n     \"test_target\": {\ndiff --git a/dts/framework/remote_session/linux_session.py b/dts/framework/remote_session/linux_session.py\nindex decce4039c..a3f1a6bf3b 100644\n--- a/dts/framework/remote_session/linux_session.py\n+++ b/dts/framework/remote_session/linux_session.py\n@@ -3,7 +3,8 @@\n # Copyright(c) 2023 University of New Hampshire\n \n import json\n-from typing import TypedDict\n+from ipaddress import IPv4Interface, IPv6Interface\n+from typing import TypedDict, Union\n \n from typing_extensions import NotRequired\n \n@@ -181,3 +182,20 @@ def configure_port_state(self, port: Port, enable: bool) -> None:\n         self.send_command(\n             f\"ip link set dev {port.logical_name} {state}\", privileged=True\n         )\n+\n+    def configure_port_ip_address(\n+        self,\n+        address: Union[IPv4Interface, IPv6Interface],\n+        port: Port,\n+        delete: bool,\n+    ) -> None:\n+        command = \"del\" if delete else \"add\"\n+        self.send_command(\n+            f\"ip address {command} {address} dev {port.logical_name}\",\n+            privileged=True,\n+            verify=True,\n+        )\n+\n+    def configure_ipv4_forwarding(self, enable: bool) -> None:\n+        state = 1 if enable else 0\n+        self.send_command(f\"sysctl -w net.ipv4.ip_forward={state}\", privileged=True)\ndiff --git a/dts/framework/remote_session/os_session.py b/dts/framework/remote_session/os_session.py\nindex ab4bfbfe4c..8a709eac1c 100644\n--- a/dts/framework/remote_session/os_session.py\n+++ b/dts/framework/remote_session/os_session.py\n@@ -4,8 +4,9 @@\n \n from abc import ABC, abstractmethod\n from collections.abc import Iterable\n+from ipaddress import IPv4Interface, IPv6Interface\n from pathlib import PurePath\n-from typing import Type, TypeVar\n+from typing import Type, TypeVar, Union\n \n from framework.config import Architecture, NodeConfiguration, NodeInfo\n from framework.logger import DTSLOG\n@@ -264,3 +265,20 @@ def configure_port_state(self, port: Port, enable: bool) -> None:\n         \"\"\"\n         Enable/disable port.\n         \"\"\"\n+\n+    @abstractmethod\n+    def configure_port_ip_address(\n+        self,\n+        address: Union[IPv4Interface, IPv6Interface],\n+        port: Port,\n+        delete: bool,\n+    ) -> None:\n+        \"\"\"\n+        Configure (add or delete) an IP address of the input port.\n+        \"\"\"\n+\n+    @abstractmethod\n+    def configure_ipv4_forwarding(self, enable: bool) -> None:\n+        \"\"\"\n+        Enable IPv4 forwarding in the underlying OS.\n+        \"\"\"\ndiff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py\nindex 056460dd05..3b890c0451 100644\n--- a/dts/framework/test_suite.py\n+++ b/dts/framework/test_suite.py\n@@ -9,7 +9,13 @@\n import importlib\n import inspect\n import re\n+from ipaddress import IPv4Interface, IPv6Interface, ip_interface\n from types import MethodType\n+from typing import Union\n+\n+from scapy.layers.inet import IP  # type: ignore[import]\n+from scapy.layers.l2 import Ether  # type: ignore[import]\n+from scapy.packet import Packet, Padding  # type: ignore[import]\n \n from .exception import (\n     BlockingTestSuiteError,\n@@ -21,6 +27,8 @@\n from .settings import SETTINGS\n from .test_result import BuildTargetResult, Result, TestCaseResult, TestSuiteResult\n from .testbed_model import SutNode, TGNode\n+from .testbed_model.hw.port import Port, PortLink\n+from .utils import get_packet_summaries\n \n \n class TestSuite(object):\n@@ -47,6 +55,15 @@ class TestSuite(object):\n     _test_cases_to_run: list[str]\n     _func: bool\n     _result: TestSuiteResult\n+    _port_links: list[PortLink]\n+    _sut_port_ingress: Port\n+    _sut_port_egress: Port\n+    _sut_ip_address_ingress: Union[IPv4Interface, IPv6Interface]\n+    _sut_ip_address_egress: Union[IPv4Interface, IPv6Interface]\n+    _tg_port_ingress: Port\n+    _tg_port_egress: Port\n+    _tg_ip_address_ingress: Union[IPv4Interface, IPv6Interface]\n+    _tg_ip_address_egress: Union[IPv4Interface, IPv6Interface]\n \n     def __init__(\n         self,\n@@ -63,6 +80,31 @@ def __init__(\n         self._test_cases_to_run.extend(SETTINGS.test_cases)\n         self._func = func\n         self._result = build_target_result.add_test_suite(self.__class__.__name__)\n+        self._port_links = []\n+        self._process_links()\n+        self._sut_port_ingress, self._tg_port_egress = (\n+            self._port_links[0].sut_port,\n+            self._port_links[0].tg_port,\n+        )\n+        self._sut_port_egress, self._tg_port_ingress = (\n+            self._port_links[1].sut_port,\n+            self._port_links[1].tg_port,\n+        )\n+        self._sut_ip_address_ingress = ip_interface(\"192.168.100.2/24\")\n+        self._sut_ip_address_egress = ip_interface(\"192.168.101.2/24\")\n+        self._tg_ip_address_egress = ip_interface(\"192.168.100.3/24\")\n+        self._tg_ip_address_ingress = ip_interface(\"192.168.101.3/24\")\n+\n+    def _process_links(self) -> None:\n+        for sut_port in self.sut_node.ports:\n+            for tg_port in self.tg_node.ports:\n+                if (sut_port.identifier, sut_port.peer) == (\n+                    tg_port.peer,\n+                    tg_port.identifier,\n+                ):\n+                    self._port_links.append(\n+                        PortLink(sut_port=sut_port, tg_port=tg_port)\n+                    )\n \n     def set_up_suite(self) -> None:\n         \"\"\"\n@@ -85,14 +127,181 @@ def tear_down_test_case(self) -> None:\n         Tear down the previously created test fixtures after each test case.\n         \"\"\"\n \n+    def configure_testbed_ipv4(self, restore: bool = False) -> None:\n+        delete = True if restore else False\n+        enable = False if restore else True\n+        self._configure_ipv4_forwarding(enable)\n+        self.sut_node.configure_port_ip_address(\n+            self._sut_ip_address_egress, self._sut_port_egress, delete\n+        )\n+        self.sut_node.configure_port_state(self._sut_port_egress, enable)\n+        self.sut_node.configure_port_ip_address(\n+            self._sut_ip_address_ingress, self._sut_port_ingress, delete\n+        )\n+        self.sut_node.configure_port_state(self._sut_port_ingress, enable)\n+        self.tg_node.configure_port_ip_address(\n+            self._tg_ip_address_ingress, self._tg_port_ingress, delete\n+        )\n+        self.tg_node.configure_port_state(self._tg_port_ingress, enable)\n+        self.tg_node.configure_port_ip_address(\n+            self._tg_ip_address_egress, self._tg_port_egress, delete\n+        )\n+        self.tg_node.configure_port_state(self._tg_port_egress, enable)\n+\n+    def _configure_ipv4_forwarding(self, enable: bool) -> None:\n+        self.sut_node.configure_ipv4_forwarding(enable)\n+\n+    def send_packet_and_capture(\n+        self, packet: Packet, duration: float = 1\n+    ) -> list[Packet]:\n+        \"\"\"\n+        Send a packet through the appropriate interface and\n+        receive on the appropriate interface.\n+        Modify the packet with l3/l2 addresses corresponding\n+        to the testbed and desired traffic.\n+        \"\"\"\n+        packet = self._adjust_addresses(packet)\n+        return self.tg_node.send_packet_and_capture(\n+            packet, self._tg_port_egress, self._tg_port_ingress, duration\n+        )\n+\n+    def get_expected_packet(self, packet: Packet) -> Packet:\n+        return self._adjust_addresses(packet, expected=True)\n+\n+    def _adjust_addresses(self, packet: Packet, expected: bool = False) -> Packet:\n+        \"\"\"\n+        Assumptions:\n+            Two links between SUT and TG, one link is TG -> SUT,\n+            the other SUT -> TG.\n+        \"\"\"\n+        if expected:\n+            # The packet enters the TG from SUT\n+            # update l2 addresses\n+            packet.src = self._sut_port_egress.mac_address\n+            packet.dst = self._tg_port_ingress.mac_address\n+\n+            # The packet is routed from TG egress to TG ingress\n+            # update l3 addresses\n+            packet.payload.src = self._tg_ip_address_egress.ip.exploded\n+            packet.payload.dst = self._tg_ip_address_ingress.ip.exploded\n+        else:\n+            # The packet leaves TG towards SUT\n+            # update l2 addresses\n+            packet.src = self._tg_port_egress.mac_address\n+            packet.dst = self._sut_port_ingress.mac_address\n+\n+            # The packet is routed from TG egress to TG ingress\n+            # update l3 addresses\n+            packet.payload.src = self._tg_ip_address_egress.ip.exploded\n+            packet.payload.dst = self._tg_ip_address_ingress.ip.exploded\n+\n+        return Ether(packet.build())\n+\n     def verify(self, condition: bool, failure_description: str) -> None:\n         if not condition:\n+            self._fail_test_case_verify(failure_description)\n+\n+    def _fail_test_case_verify(self, failure_description: str) -> None:\n+        self._logger.debug(\n+            \"A test case failed, showing the last 10 commands executed on SUT:\"\n+        )\n+        for command_res in self.sut_node.main_session.remote_session.history[-10:]:\n+            self._logger.debug(command_res.command)\n+        self._logger.debug(\n+            \"A test case failed, showing the last 10 commands executed on TG:\"\n+        )\n+        for command_res in self.tg_node.main_session.remote_session.history[-10:]:\n+            self._logger.debug(command_res.command)\n+        raise TestCaseVerifyError(failure_description)\n+\n+    def verify_packets(\n+        self, expected_packet: Packet, received_packets: list[Packet]\n+    ) -> None:\n+        for received_packet in received_packets:\n+            if self._compare_packets(expected_packet, received_packet):\n+                break\n+        else:\n+            self._logger.debug(\n+                f\"The expected packet {get_packet_summaries(expected_packet)} \"\n+                f\"not found among received {get_packet_summaries(received_packets)}\"\n+            )\n+            self._fail_test_case_verify(\n+                \"An expected packet not found among received packets.\"\n+            )\n+\n+    def _compare_packets(\n+        self, expected_packet: Packet, received_packet: Packet\n+    ) -> bool:\n+        self._logger.debug(\n+            \"Comparing packets: \\n\"\n+            f\"{expected_packet.summary()}\\n\"\n+            f\"{received_packet.summary()}\"\n+        )\n+\n+        l3 = IP in expected_packet.layers()\n+        self._logger.debug(\"Found l3 layer\")\n+\n+        received_payload = received_packet\n+        expected_payload = expected_packet\n+        while received_payload and expected_payload:\n+            self._logger.debug(\"Comparing payloads:\")\n+            self._logger.debug(f\"Received: {received_payload}\")\n+            self._logger.debug(f\"Expected: {expected_payload}\")\n+            if received_payload.__class__ == expected_payload.__class__:\n+                self._logger.debug(\"The layers are the same.\")\n+                if received_payload.__class__ == Ether:\n+                    if not self._verify_l2_frame(received_payload, l3):\n+                        return False\n+                elif received_payload.__class__ == IP:\n+                    if not self._verify_l3_packet(received_payload, expected_payload):\n+                        return False\n+            else:\n+                # Different layers => different packets\n+                return False\n+            received_payload = received_payload.payload\n+            expected_payload = expected_payload.payload\n+\n+        if expected_payload:\n             self._logger.debug(\n-                \"A test case failed, showing the last 10 commands executed on SUT:\"\n+                f\"The expected packet did not contain {expected_payload}.\"\n             )\n-            for command_res in self.sut_node.main_session.remote_session.history[-10:]:\n-                self._logger.debug(command_res.command)\n-            raise TestCaseVerifyError(failure_description)\n+            return False\n+        if received_payload and received_payload.__class__ != Padding:\n+            self._logger.debug(\n+                \"The received payload had extra layers which were not padding.\"\n+            )\n+            return False\n+        return True\n+\n+    def _verify_l2_frame(self, received_packet: Ether, l3: bool) -> bool:\n+        self._logger.debug(\"Looking at the Ether layer.\")\n+        self._logger.debug(\n+            f\"Comparing received dst mac '{received_packet.dst}' \"\n+            f\"with expected '{self._tg_port_ingress.mac_address}'.\"\n+        )\n+        if received_packet.dst != self._tg_port_ingress.mac_address:\n+            return False\n+\n+        expected_src_mac = self._tg_port_egress.mac_address\n+        if l3:\n+            expected_src_mac = self._sut_port_egress.mac_address\n+        self._logger.debug(\n+            f\"Comparing received src mac '{received_packet.src}' \"\n+            f\"with expected '{expected_src_mac}'.\"\n+        )\n+        if received_packet.src != expected_src_mac:\n+            return False\n+\n+        return True\n+\n+    def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:\n+        self._logger.debug(\"Looking at the IP layer.\")\n+        if (\n+            received_packet.src != expected_packet.src\n+            or received_packet.dst != expected_packet.dst\n+        ):\n+            return False\n+        return True\n \n     def run(self) -> None:\n         \"\"\"\ndiff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py\nindex c666dfbf4e..fc01e0bf8e 100644\n--- a/dts/framework/testbed_model/node.py\n+++ b/dts/framework/testbed_model/node.py\n@@ -8,7 +8,8 @@\n \"\"\"\n \n from abc import ABC\n-from typing import Any, Callable, Type\n+from ipaddress import IPv4Interface, IPv6Interface\n+from typing import Any, Callable, Type, Union\n \n from framework.config import (\n     BuildTargetConfiguration,\n@@ -221,6 +222,17 @@ def configure_port_state(self, port: Port, enable: bool = True) -> None:\n         \"\"\"\n         self.main_session.configure_port_state(port, enable)\n \n+    def configure_port_ip_address(\n+        self,\n+        address: Union[IPv4Interface, IPv6Interface],\n+        port: Port,\n+        delete: bool = False,\n+    ) -> None:\n+        \"\"\"\n+        Configure the IP address of a port on this node.\n+        \"\"\"\n+        self.main_session.configure_port_ip_address(address, port, delete)\n+\n     def close(self) -> None:\n         \"\"\"\n         Close all connections and free other resources.\ndiff --git a/dts/framework/testbed_model/sut_node.py b/dts/framework/testbed_model/sut_node.py\nindex f0b017a383..202aebfd06 100644\n--- a/dts/framework/testbed_model/sut_node.py\n+++ b/dts/framework/testbed_model/sut_node.py\n@@ -351,6 +351,9 @@ def run_dpdk_app(\n             f\"{app_path} {eal_args}\", timeout, privileged=True, verify=True\n         )\n \n+    def configure_ipv4_forwarding(self, enable: bool) -> None:\n+        self.main_session.configure_ipv4_forwarding(enable)\n+\n     def create_interactive_shell(\n         self,\n         shell_cls: Type[InteractiveShellType],\ndiff --git a/dts/tests/TestSuite_os_udp.py b/dts/tests/TestSuite_os_udp.py\nnew file mode 100644\nindex 0000000000..9b5f39711d\n--- /dev/null\n+++ b/dts/tests/TestSuite_os_udp.py\n@@ -0,0 +1,45 @@\n+# SPDX-License-Identifier: BSD-3-Clause\n+# Copyright(c) 2023 PANTHEON.tech s.r.o.\n+\n+\"\"\"\n+Configure SUT node to route traffic from if1 to if2.\n+Send a packet to the SUT node, verify it comes back on the second port on the TG node.\n+\"\"\"\n+\n+from scapy.layers.inet import IP, UDP  # type: ignore[import]\n+from scapy.layers.l2 import Ether  # type: ignore[import]\n+\n+from framework.test_suite import TestSuite\n+\n+\n+class TestOSUdp(TestSuite):\n+    def set_up_suite(self) -> None:\n+        \"\"\"\n+        Setup:\n+            Configure SUT ports and SUT to route traffic from if1 to if2.\n+        \"\"\"\n+\n+        self.configure_testbed_ipv4()\n+\n+    def test_os_udp(self) -> None:\n+        \"\"\"\n+        Steps:\n+            Send a UDP packet.\n+        Verify:\n+            The packet with proper addresses arrives at the other TG port.\n+        \"\"\"\n+\n+        packet = Ether() / IP() / UDP()\n+\n+        received_packets = self.send_packet_and_capture(packet)\n+\n+        expected_packet = self.get_expected_packet(packet)\n+\n+        self.verify_packets(expected_packet, received_packets)\n+\n+    def tear_down_suite(self) -> None:\n+        \"\"\"\n+        Teardown:\n+            Remove the SUT port configuration configured in setup.\n+        \"\"\"\n+        self.configure_testbed_ipv4(restore=True)\n",
    "prefixes": [
        "v3",
        "6/6"
    ]
}