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
# scripts/setup-veth-topology.sh
set -e

# Create network namespaces
ip netns add ns-src
ip netns add ns-dst

# Create veth pair
ip link add veth-src type veth peer name veth-dst

# Move interfaces to namespaces
ip link set veth-src netns ns-src
ip link set veth-dst netns ns-dst

# Assign IPs
ip netns exec ns-src ip addr add 10.0.0.1/24 dev veth-src
ip netns exec ns-dst ip addr add 10.0.0.2/24 dev veth-dst

# Bring interfaces up
ip netns exec ns-src ip link set veth-src up
ip netns exec ns-dst ip link set veth-dst up

echo "Topology ready"
echo "  ns-src (10.0.0.1) <-> ns-dst (10.0.0.2)"

Attaching and Testing the XDP Program

#!/bin/bash
# tests/test-veth-integration.sh
set -e

source scripts/setup-veth-topology.sh

# Compile and load the XDP program onto veth-dst
ip netns exec ns-dst \
    ip link set veth-dst xdp obj ./xdp_filter.bpf.o sec xdp

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

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

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

set +e
ip netns exec ns-src nc -z -w1 10.0.0.2 80 2>/dev/null
NC_EXIT=$?
set -e
kill $LISTENER_PID 2>/dev/null || true

if [ "$NC_EXIT" -eq 0 ]; then
    echo "PASS: TCP to port 80 passed through"
else
    echo "FAIL: TCP to port 80 was incorrectly blocked"
    exit 1
fi

# Cleanup
ip netns exec ns-dst ip link set veth-dst xdp off
ip netns del ns-src
ip netns del ns-dst

echo "=== 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
# scripts/xdp-perf-test.sh

IFACE=${1:-veth-dst}
DURATION=10  # seconds

echo "=== XDP Performance Test on $IFACE ==="

# Configure pktgen
cat > /tmp/pktgen.conf <<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
echo "add_device $IFACE" > /proc/net/pktgen/pgctrl
cat /tmp/pktgen.conf > "/proc/net/pktgen/$IFACE"
sleep "$DURATION"
echo "stop" > /proc/net/pktgen/pgctrl

# Read results
cat "/proc/net/pktgen/$IFACE" | grep -E "pps|Mpps|pkts"

Assert on throughput in CI to catch performance regressions:

MPPS=$(cat "/proc/net/pktgen/$IFACE" | grep -oP '\d+\.\d+(?=Mpps)')
MIN_MPPS=5.0

if awk "BEGIN {exit !($MPPS >= $MIN_MPPS)}"; then
    echo "PASS: XDP throughput ${MPPS}Mpps >= minimum ${MIN_MPPS}Mpps"
else
    echo "FAIL: XDP throughput ${MPPS}Mpps below minimum ${MIN_MPPS}Mpps"
    exit 1
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
# scripts/staging-xdp-validation.sh
IFACE=${1:-eth0}  # Real NIC

echo "=== Staging XDP Validation on $IFACE ==="

# Verify XDP native mode support
XDPFEATURES=$(ethtool -i "$IFACE" 2>/dev/null | grep driver)
echo "Driver: $XDPFEATURES"

# Load program in native mode
ip link set "$IFACE" xdp obj xdp_filter.bpf.o sec xdp

# Verify load
ip link show "$IFACE" | grep -q "xdp" && \
    echo "PASS: XDP program loaded in native mode" || \
    echo "FAIL: XDP program did not attach"

# Run traffic test (requires traffic generator on another host)
# This integrates with your load generation infrastructure

# Unload
ip link set "$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 with usage-based pricing ($0.003/run), 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

Start now free