XDP Network Test Automation: Testing XDP Programs

XDP Network Test Automation: Testing XDP Programs

XDP (eXpress Data Path) is the fastest packet processing layer in Linux — hooks into the NIC driver before the kernel's network stack, processes packets at wire speed, and acts on them: drop, pass, redirect, or transmit. The same properties that make XDP powerful make it dangerous to ship untested. A bug in an XDP program does not manifest as a slow response or a logged error. It manifests as silent packet drops, misclassified traffic, or a kernel panic. Testing XDP programs requires a different approach from testing application code.

This post covers the full XDP testing stack: unit tests with BPF_PROG_RUN, integration tests with virtual network topologies, and CI configurations that make XDP testing repeatable.

The XDP Testing Problem

XDP programs operate on raw packet bytes. They see struct xdp_md (the XDP metadata context) and a contiguous memory region containing the packet. Test inputs are raw packet buffers. Test outputs are action codes: XDP_DROP, XDP_PASS, XDP_TX, XDP_REDIRECT, XDP_ABORTED.

This means:

  • Tests must construct valid packet payloads (Ethernet frame, IP header, L4 header, payload)
  • Tests must verify the action code returned for each input
  • Tests must also verify that the program modifies packet contents correctly (for NAT, encapsulation, VLAN tagging programs)
  • Performance tests must verify throughput and latency at relevant packet sizes

None of this is application testing. There is no HTTP response to assert on, no database query to verify. The test artifact is a packet buffer and a return code.

Unit Testing with BPF_PROG_RUN

The Linux kernel provides BPF_PROG_RUN (also called BPF_PROG_TEST_RUN), a syscall interface that runs a BPF program against a user-supplied input buffer and returns the output. This is the XDP unit testing primitive — no real NIC required, no network traffic, deterministic results.

The XDP Program Under Test

// src/xdp_filter.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#define ETH_P_IP  0x0800
#define IPPROTO_TCP 6

/* Drop all TCP traffic to port 6666 */
SEC("xdp")
int xdp_filter(struct xdp_md *ctx)
{
    void *data     = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;

    if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
        return XDP_PASS;

    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > data_end)
        return XDP_PASS;

    if (ip->protocol != IPPROTO_TCP)
        return XDP_PASS;

    struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
    if ((void *)(tcp + 1) > data_end)
        return XDP_PASS;

    if (bpf_ntohs(tcp->dest) == 6666)
        return XDP_DROP;

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Packet Construction Helpers

// tests/packet_helpers.h
#pragma once
#include <stdint.h>
#include <string.h>
#include <arpa/inet.h>

/* Ethernet + IPv4 + TCP packet construction */
struct test_packet {
    uint8_t  eth_dst[6];
    uint8_t  eth_src[6];
    uint16_t eth_proto;

    uint8_t  ip_ihl_ver;
    uint8_t  ip_tos;
    uint16_t ip_tot_len;
    uint16_t ip_id;
    uint16_t ip_frag_off;
    uint8_t  ip_ttl;
    uint8_t  ip_proto;
    uint16_t ip_check;
    uint32_t ip_saddr;
    uint32_t ip_daddr;

    uint16_t tcp_sport;
    uint16_t tcp_dport;
    uint32_t tcp_seq;
    uint32_t tcp_ack;
    uint8_t  tcp_doff_flags;
    uint8_t  tcp_flags;
    uint16_t tcp_window;
    uint16_t tcp_check;
    uint16_t tcp_urg;
} __attribute__((packed));

static inline void build_tcp_packet(struct test_packet *pkt,
                                    uint16_t src_port,
                                    uint16_t dst_port)
{
    memset(pkt, 0, sizeof(*pkt));

    /* Ethernet */
    memset(pkt->eth_dst, 0xFF, 6);
    memset(pkt->eth_src, 0xAA, 6);
    pkt->eth_proto = htons(0x0800);  /* IPv4 */

    /* IPv4 */
    pkt->ip_ihl_ver  = 0x45;         /* version=4, ihl=5 */
    pkt->ip_ttl      = 64;
    pkt->ip_proto    = 6;            /* TCP */
    pkt->ip_tot_len  = htons(sizeof(*pkt) - 14);  /* minus eth header */
    pkt->ip_saddr    = inet_addr("10.0.0.1");
    pkt->ip_daddr    = inet_addr("10.0.0.2");

    /* TCP */
    pkt->tcp_sport   = htons(src_port);
    pkt->tcp_dport   = htons(dst_port);
    pkt->tcp_doff_flags = 0x50;      /* data offset = 5 (20 bytes) */
    pkt->tcp_flags   = 0x02;         /* SYN */
    pkt->tcp_window  = htons(65535);
}

BPF_PROG_RUN Test Runner

// tests/test_xdp_filter.c
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include <linux/bpf.h>
#include "xdp_filter.skel.h"
#include "packet_helpers.h"

#define XDP_DROP  1
#define XDP_PASS  2

static struct xdp_filter_bpf *skel;

static int run_xdp_prog(void *pkt, size_t pkt_len, uint32_t *retval)
{
    int prog_fd = bpf_program__fd(skel->progs.xdp_filter);
    DECLARE_LIBBPF_OPTS(bpf_test_run_opts, opts,
        .data_in    = pkt,
        .data_size_in = pkt_len,
        .retval     = retval,
        .repeat     = 1,
    );
    return bpf_prog_test_run_opts(prog_fd, &opts);
}

static void test_drops_tcp_port_6666(void)
{
    struct test_packet pkt;
    uint32_t retval = 0;

    build_tcp_packet(&pkt, 12345, 6666);  /* dst_port = 6666 */

    int ret = run_xdp_prog(&pkt, sizeof(pkt), &retval);
    if (ret != 0) {
        printf("FAIL: test_drops_tcp_port_6666 — BPF_PROG_RUN error: %d\n", ret);
        return;
    }

    if (retval == XDP_DROP) {
        printf("PASS: test_drops_tcp_port_6666\n");
    } else {
        printf("FAIL: test_drops_tcp_port_6666 — expected XDP_DROP(%d), got %d\n",
               XDP_DROP, retval);
    }
}

static void test_passes_tcp_other_port(void)
{
    struct test_packet pkt;
    uint32_t retval = 0;

    build_tcp_packet(&pkt, 12345, 80);  /* dst_port = 80, should pass */

    int ret = run_xdp_prog(&pkt, sizeof(pkt), &retval);
    if (ret != 0) {
        printf("FAIL: test_passes_tcp_other_port — BPF_PROG_RUN error: %d\n", ret);
        return;
    }

    if (retval == XDP_PASS) {
        printf("PASS: test_passes_tcp_other_port\n");
    } else {
        printf("FAIL: test_passes_tcp_other_port — expected XDP_PASS(%d), got %d\n",
               XDP_PASS, retval);
    }
}

static void test_passes_non_tcp(void)
{
    /* Build a minimal UDP packet by modifying the protocol field */
    struct test_packet pkt;
    uint32_t retval = 0;

    build_tcp_packet(&pkt, 12345, 6666);
    pkt.ip_proto = 17;  /* UDP */

    int ret = run_xdp_prog(&pkt, sizeof(pkt), &retval);
    if (ret != 0) {
        printf("FAIL: test_passes_non_tcp — BPF_PROG_RUN error: %d\n", ret);
        return;
    }

    if (retval == XDP_PASS) {
        printf("PASS: test_passes_non_tcp\n");
    } else {
        printf("FAIL: test_passes_non_tcp — expected XDP_PASS for UDP, got %d\n",
               retval);
    }
}

static void test_drops_short_packet(void)
{
    /* A packet too short for a valid TCP header should be passed (not crash) */
    uint8_t short_pkt[20] = {0};  /* Too short to parse */
    uint32_t retval = 0;

    int ret = run_xdp_prog(short_pkt, sizeof(short_pkt), &retval);
    if (ret != 0) {
        printf("FAIL: test_drops_short_packet — BPF_PROG_RUN error: %d\n", ret);
        return;
    }

    if (retval == XDP_PASS) {
        printf("PASS: test_drops_short_packet — correctly passed malformed packet\n");
    } else {
        printf("FAIL: test_drops_short_packet — unexpected action %d\n", retval);
    }
}

int main(void)
{
    skel = xdp_filter_bpf__open_and_load();
    if (!skel) {
        fprintf(stderr, "Failed to load XDP skeleton\n");
        return 1;
    }

    test_drops_tcp_port_6666();
    test_passes_tcp_other_port();
    test_passes_non_tcp();
    test_drops_short_packet();

    xdp_filter_bpf__destroy(skel);
    return 0;
}

Integration Testing with veth Pairs

BPF_PROG_RUN tests XDP program logic, but does not verify real-world attachment behavior. Integration tests use Linux virtual ethernet (veth) pairs and network namespaces to create isolated test topologies.

Setting Up a veth Test Topology

#!/bin/bash
<span class="hljs-comment"># scripts/setup-veth-topology.sh
<span class="hljs-built_in">set -e

<span class="hljs-comment"># Create network namespaces
ip netns add ns-src
ip netns add ns-dst

<span class="hljs-comment"># Create veth pair
ip <span class="hljs-built_in">link add veth-src <span class="hljs-built_in">type veth peer name veth-dst

<span class="hljs-comment"># Move interfaces to namespaces
ip <span class="hljs-built_in">link <span class="hljs-built_in">set veth-src netns ns-src
ip <span class="hljs-built_in">link <span class="hljs-built_in">set veth-dst netns ns-dst

<span class="hljs-comment"># Assign IPs
ip netns <span class="hljs-built_in">exec ns-src ip addr add 10.0.0.1/24 dev veth-src
ip netns <span class="hljs-built_in">exec ns-dst ip addr add 10.0.0.2/24 dev veth-dst

<span class="hljs-comment"># Bring interfaces up
ip netns <span class="hljs-built_in">exec ns-src ip <span class="hljs-built_in">link <span class="hljs-built_in">set veth-src up
ip netns <span class="hljs-built_in">exec ns-dst ip <span class="hljs-built_in">link <span class="hljs-built_in">set veth-dst up

<span class="hljs-built_in">echo <span class="hljs-string">"Topology ready"
<span class="hljs-built_in">echo <span class="hljs-string">"  ns-src (10.0.0.1) <-> ns-dst (10.0.0.2)"

Attaching and Testing the XDP Program

#!/bin/bash
<span class="hljs-comment"># tests/test-veth-integration.sh
<span class="hljs-built_in">set -e

<span class="hljs-built_in">source scripts/setup-veth-topology.sh

<span class="hljs-comment"># Compile and load the XDP program onto veth-dst
ip netns <span class="hljs-built_in">exec ns-dst \
    ip <span class="hljs-built_in">link <span class="hljs-built_in">set veth-dst xdp obj ./xdp_filter.bpf.o sec xdp

<span class="hljs-comment"># Test 1: TCP to port 6666 should be dropped
<span class="hljs-built_in">echo <span class="hljs-string">"=== Test 1: TCP port 6666 is dropped ==="
<span class="hljs-built_in">set +e
ip netns <span class="hljs-built_in">exec ns-src nc -z -w1 10.0.0.2 6666 2>/dev/null
NC_EXIT=$?
<span class="hljs-built_in">set -e

<span class="hljs-comment"># nc should fail (connection refused or timeout) because XDP dropped the packets
<span class="hljs-keyword">if [ <span class="hljs-string">"$NC_EXIT" -ne 0 ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"PASS: TCP to port 6666 was blocked"
<span class="hljs-keyword">else
    <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: TCP to port 6666 was not blocked"
    <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi

<span class="hljs-comment"># Test 2: TCP to port 80 should pass
<span class="hljs-built_in">echo <span class="hljs-string">"=== Test 2: TCP port 80 passes through ==="
<span class="hljs-comment"># Start a simple listener in ns-dst
ip netns <span class="hljs-built_in">exec ns-dst nc -l -p 80 &
LISTENER_PID=$!
<span class="hljs-built_in">sleep 0.5

<span class="hljs-built_in">set +e
ip netns <span class="hljs-built_in">exec ns-src nc -z -w1 10.0.0.2 80 2>/dev/null
NC_EXIT=$?
<span class="hljs-built_in">set -e
<span class="hljs-built_in">kill <span class="hljs-variable">$LISTENER_PID 2>/dev/null <span class="hljs-pipe">|| <span class="hljs-literal">true

<span class="hljs-keyword">if [ <span class="hljs-string">"$NC_EXIT" -eq 0 ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"PASS: TCP to port 80 passed through"
<span class="hljs-keyword">else
    <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: TCP to port 80 was incorrectly blocked"
    <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi

<span class="hljs-comment"># Cleanup
ip netns <span class="hljs-built_in">exec ns-dst ip <span class="hljs-built_in">link <span class="hljs-built_in">set veth-dst xdp off
ip netns del ns-src
ip netns del ns-dst

<span class="hljs-built_in">echo <span class="hljs-string">"=== All integration tests passed ==="

Performance Testing with pktgen

XDP performance is measured in millions of packets per second (Mpps). Kernel's pktgen module generates packets at line rate for performance testing:

#!/bin/bash
<span class="hljs-comment"># scripts/xdp-perf-test.sh

IFACE=<span class="hljs-variable">${1:-veth-dst}
DURATION=10  <span class="hljs-comment"># seconds

<span class="hljs-built_in">echo <span class="hljs-string">"=== XDP Performance Test on $IFACE ==="

<span class="hljs-comment"># Configure pktgen
<span class="hljs-built_in">cat > /tmp/pktgen.conf <<<span class="hljs-string">EOF
pgset "clone_skb 0"
pgset "pkt_size 64"
pgset "dst_mac FF:FF:FF:FF:FF:FF"
pgset "src_mac AA:BB:CC:DD:EE:FF"
pgset "dst 10.0.0.2"
pgset "dport 6666"
pgset "count 0"
pgset "delay 0"
pgset "start"
EOF

modprobe pktgen
<span class="hljs-built_in">echo <span class="hljs-string">"add_device $IFACE" > /proc/net/pktgen/pgctrl
<span class="hljs-built_in">cat /tmp/pktgen.conf > <span class="hljs-string">"/proc/net/pktgen/$IFACE"
<span class="hljs-built_in">sleep <span class="hljs-string">"$DURATION"
<span class="hljs-built_in">echo <span class="hljs-string">"stop" > /proc/net/pktgen/pgctrl

<span class="hljs-comment"># Read results
<span class="hljs-built_in">cat <span class="hljs-string">"/proc/net/pktgen/$IFACE" <span class="hljs-pipe">| grep -E <span class="hljs-string">"pps|Mpps|pkts"

Assert on throughput in CI to catch performance regressions:

MPPS=$(cat <span class="hljs-string">"/proc/net/pktgen/$IFACE" <span class="hljs-pipe">| grep -oP <span class="hljs-string">'\d+\.\d+(?=Mpps)')
MIN_MPPS=5.0

<span class="hljs-keyword">if awk <span class="hljs-string">"BEGIN {exit !($MPPS >= <span class="hljs-variable">$MIN_MPPS)}"; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"PASS: XDP throughput ${MPPS}Mpps >= minimum <span class="hljs-variable">${MIN_MPPS}Mpps"
<span class="hljs-keyword">else
    <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: XDP throughput ${MPPS}Mpps below minimum <span class="hljs-variable">${MIN_MPPS}Mpps"
    <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi

GitHub Actions CI for XDP

name: XDP Tests

on:
  push:
    paths:
      - "src/**"
      - "tests/**"

jobs:
  unit-tests:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: |
          sudo apt-get update -q
          sudo apt-get install -y -q \
            clang llvm libelf-dev libbpf-dev bpftool

      - name: Build XDP program and tests
        run: |
          clang -target bpf -O2 -g -c src/xdp_filter.bpf.c -o xdp_filter.bpf.o
          bpftool gen skeleton xdp_filter.bpf.o > xdp_filter.skel.h
          gcc -O2 -g tests/test_xdp_filter.c -o test_xdp_filter -lbpf -lelf -lz

      - name: Run XDP unit tests
        run: sudo ./test_xdp_filter

  integration-tests:
    runs-on: self-hosted
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4

      - name: Build XDP program
        run: |
          clang -target bpf -O2 -c src/xdp_filter.bpf.c -o xdp_filter.bpf.o

      - name: Run veth integration tests
        run: sudo bash tests/test-veth-integration.sh

      - name: Clean up namespaces
        if: always()
        run: |
          sudo ip netns del ns-src 2>/dev/null || true
          sudo ip netns del ns-dst 2>/dev/null || true

Testing XDP with Real NICs in Staging

Unit tests with BPF_PROG_RUN and veth tests cover the logic layer. Before production deployment, run attachment tests on real NIC hardware with real drivers — driver-specific XDP support varies, and XDP_TX behavior differs between drivers.

Automate the staging validation:

#!/bin/bash
<span class="hljs-comment"># scripts/staging-xdp-validation.sh
IFACE=<span class="hljs-variable">${1:-eth0}  <span class="hljs-comment"># Real NIC

<span class="hljs-built_in">echo <span class="hljs-string">"=== Staging XDP Validation on $IFACE ==="

<span class="hljs-comment"># Verify XDP native mode support
XDPFEATURES=$(ethtool -i <span class="hljs-string">"$IFACE" 2>/dev/null <span class="hljs-pipe">| grep driver)
<span class="hljs-built_in">echo <span class="hljs-string">"Driver: $XDPFEATURES"

<span class="hljs-comment"># Load program in native mode
ip <span class="hljs-built_in">link <span class="hljs-built_in">set <span class="hljs-string">"$IFACE" xdp obj xdp_filter.bpf.o sec xdp

<span class="hljs-comment"># Verify load
ip <span class="hljs-built_in">link show <span class="hljs-string">"$IFACE" <span class="hljs-pipe">| grep -q <span class="hljs-string">"xdp" && \
    <span class="hljs-built_in">echo <span class="hljs-string">"PASS: XDP program loaded in native mode" <span class="hljs-pipe">|| \
    <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: XDP program did not attach"

<span class="hljs-comment"># Run traffic test (requires traffic generator on another host)
<span class="hljs-comment"># This integrates with your load generation infrastructure

<span class="hljs-comment"># Unload
ip <span class="hljs-built_in">link <span class="hljs-built_in">set <span class="hljs-string">"$IFACE" xdp off

Connecting XDP Testing to the Application Stack

XDP tests validate the dataplane. They tell you whether packets are dropped or passed correctly at the NIC level. They do not validate the control plane: the dashboard that shows drop statistics, the API that configures XDP rules dynamically, or the alerting system that fires when drop rates exceed a threshold.

Testing the control plane layer is where cloud-based test automation tools come in. Teams building XDP-based firewalls, load balancers, or DDoS protection systems use HelpMeTest to automate the application tier — the management UI, the rule configuration API, the observability dashboards — while XDP unit and integration tests cover the dataplane. HelpMeTest's AI-powered Robot Framework and Playwright runner handles this layer at $100/month Pro, with no additional infrastructure to maintain.

Summary: The XDP Test Pyramid

Layer Tool What it verifies
Unit BPF_PROG_RUN XDP action codes for known packet inputs
Integration veth pairs + netns Real attachment, real packet flow
Performance pktgen Throughput and latency at packet size ranges
Staging Real NIC Driver compatibility, native mode attachment
Application HelpMeTest Control plane, UI, API, alerting

Each layer catches a different class of bug. Skip any layer and you ship a gap.

Conclusion

Testing XDP programs requires tools appropriate to their environment: BPF_PROG_RUN for deterministic unit tests against known packet inputs, veth pair topologies for integration tests with real kernel networking, and pktgen for performance regression detection. The BPF_PROG_RUN interface is the most underused tool in the XDP developer's toolkit — it eliminates the need for real traffic generators in unit testing and makes XDP tests runnable in any CI environment with a modern kernel. Build your XDP test suite from the bottom up: unit tests first, integration tests second, performance tests third. By the time code reaches production, packet handling behavior should already be proven across all three layers.

Read more