DPDK-dev Archive on lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH v6 0/5] Wireshark external capture for DPDK
@ 2026-07-24 21:11 Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 1/5] pcapng: extend interface statistics Stephen Hemminger
                   ` (5 more replies)
  0 siblings, 6 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-24 21:11 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This series adds live packet capture from a running DPDK primary over the
telemetry socket. A new capture library installs Rx/Tx callbacks on an
ethdev port and writes pcapng to a caller-supplied path or FIFO, with an
optional libpcap filter and per-queue selection. No application changes are
needed beyond linking the library.

Two front ends use it: a Wireshark extcap plugin that makes each DPDK port
appear in the Wireshark interface list, and a dpdk-dumpcap.py for
file capture without a secondary process.

The pcapng interface-statistics block gains a filter-accepted count,
which changes the rte_pcapng_write_stats() signature.

dpdk-dumpcap.py is not installed yet, since the name would shadow the
existing C dpdk-dumpcap.The long term plan is to remove pdump and
old dumpcap code and only support this new code.
Will take several releases to stablize first.

Stephen Hemminger (5):
  pcapng: extend interface statistics
  capture: infrastructure wireshark packet capture
  test: add test for capture hooks
  usertools/dpdk-wireshark-extcap.py: script for external capture
  usertools/dpdk-dumpcap: add script for file capture

 MAINTAINERS                            |    4 +
 app/dumpcap/main.c                     |   16 +-
 app/test/meson.build                   |    1 +
 app/test/test_capture.c                |  512 ++++++++++++
 app/test/test_pcapng.c                 |    6 +-
 doc/guides/rel_notes/release_26_11.rst |   11 +
 doc/guides/tools/index.rst             |    1 +
 doc/guides/tools/wireshark_extcap.rst  |  144 ++++
 lib/capture/capture.c                  | 1032 ++++++++++++++++++++++++
 lib/capture/capture_impl.h             |   56 ++
 lib/capture/filter.c                   |  108 +++
 lib/capture/meson.build                |   19 +
 lib/meson.build                        |    1 +
 lib/pcapng/rte_pcapng.c                |   32 +-
 lib/pcapng/rte_pcapng.h                |   30 +-
 usertools/dpdk-dumpcap.py              |  426 ++++++++++
 usertools/dpdk-wireshark-extcap.py     |  374 +++++++++
 17 files changed, 2744 insertions(+), 29 deletions(-)
 create mode 100644 app/test/test_capture.c
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/capture_impl.h
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build
 create mode 100755 usertools/dpdk-dumpcap.py
 create mode 100755 usertools/dpdk-wireshark-extcap.py

-- 
2.53.0


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [PATCH 1/5] pcapng: extend interface statistics
  2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
@ 2026-07-24 21:11 ` Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 2/5] capture: infrastructure wireshark packet capture Stephen Hemminger
                   ` (4 subsequent siblings)
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-24 21:11 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

The number of packets filtered was not implemented in original
code but useful for analysis.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 app/dumpcap/main.c                     | 16 ++++++-------
 app/test/test_pcapng.c                 |  6 +++--
 doc/guides/rel_notes/release_26_11.rst |  5 ++++
 lib/pcapng/rte_pcapng.c                | 32 ++++++++++++++++++--------
 lib/pcapng/rte_pcapng.h                | 30 ++++++++++++++++--------
 5 files changed, 60 insertions(+), 29 deletions(-)

diff --git a/app/dumpcap/main.c b/app/dumpcap/main.c
index 46a6cb251e..baec68b0b9 100644
--- a/app/dumpcap/main.c
+++ b/app/dumpcap/main.c
@@ -574,8 +574,8 @@ static void
 report_packet_stats(dumpcap_out_t out)
 {
 	struct rte_pdump_stats pdump_stats;
+	struct rte_pcapng_interface_stats isb;
 	struct interface *intf;
-	uint64_t ifrecv, ifdrop;
 	double percent;
 
 	fputc('\n', stderr);
@@ -584,22 +584,22 @@ report_packet_stats(dumpcap_out_t out)
 			continue;
 
 		/* do what Wiretap does */
-		ifrecv = pdump_stats.accepted + pdump_stats.filtered;
-		ifdrop = pdump_stats.nombuf + pdump_stats.ringfull;
+		isb.ifrecv = pdump_stats.accepted + pdump_stats.filtered;
+		isb.ifdrop = pdump_stats.nombuf + pdump_stats.ringfull;
+		isb.filteraccept = pdump_stats.accepted;
 
 		if (use_pcapng)
-			rte_pcapng_write_stats(out.pcapng, intf->port,
-					       ifrecv, ifdrop, NULL);
+			rte_pcapng_write_stats(out.pcapng, intf->port, &isb, sizeof(isb), NULL);
 
-		if (ifrecv == 0)
+		if (isb.ifrecv == 0)
 			percent = 0;
 		else
-			percent = 100. * ifrecv / (ifrecv + ifdrop);
+			percent = 100. * isb.ifrecv / (isb.ifrecv + isb.ifdrop);
 
 		fprintf(stderr,
 			"Packets received/dropped on interface '%s': "
 			"%"PRIu64 "/%" PRIu64 " (%.1f)\n",
-			intf->name, ifrecv, ifdrop, percent);
+			intf->name, isb.ifrecv, isb.ifdrop, percent);
 	}
 }
 
diff --git a/app/test/test_pcapng.c b/app/test/test_pcapng.c
index d14ea84f0d..f6fd85faad 100644
--- a/app/test/test_pcapng.c
+++ b/app/test/test_pcapng.c
@@ -570,8 +570,10 @@ test_write_packets(void)
 		goto fail;
 
 	/* write a statistics block */
-	ret = rte_pcapng_write_stats(pcapng, port_id,
-				     count, 0, "end of test");
+	struct rte_pcapng_interface_stats isb = {
+		.ifrecv = count,
+	};
+	ret = rte_pcapng_write_stats(pcapng, port_id, &isb, sizeof(isb), "end of test");
 	if (ret <= 0) {
 		printf("Write of statistics failed\n");
 		goto fail;
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index 938617ca75..b27494fee7 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -84,6 +84,11 @@ API Changes
    Also, make sure to start the actual text at the margin.
    =======================================================
 
+* **pcapng: add packet filtering statistic.**
+
+  The API for ``rte_pcapng_write_stats`` was changed to include
+  recording the number of filtered packets.
+
 
 ABI Changes
 -----------
diff --git a/lib/pcapng/rte_pcapng.c b/lib/pcapng/rte_pcapng.c
index b5d1026891..455809d412 100644
--- a/lib/pcapng/rte_pcapng.c
+++ b/lib/pcapng/rte_pcapng.c
@@ -394,9 +394,10 @@ rte_pcapng_add_interface(rte_pcapng_t *self, uint16_t port, uint16_t link_type,
 RTE_EXPORT_SYMBOL(rte_pcapng_write_stats)
 ssize_t
 rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
-		       uint64_t ifrecv, uint64_t ifdrop,
-		       const char *comment)
+		       const struct rte_pcapng_interface_stats *stats,
+		       size_t size, const char *comment)
 {
+	struct rte_pcapng_interface_stats isb;
 	struct pcapng_statistics *hdr;
 	struct pcapng_option *opt;
 	uint64_t start_time = self->clock.ns_base;
@@ -410,12 +411,19 @@ rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
 	if (comment && strlen(comment) > PCAPNG_STR_MAX)
 		return -EINVAL;
 
+	/* Future proof for more/less stats - all UINT64_MAX */
+	memset(&isb, 0xff, sizeof(isb));
+	memcpy(&isb, stats, RTE_MIN(size, sizeof(*stats)));
+
 	optlen = 0;
 
-	if (ifrecv != UINT64_MAX)
-		optlen += pcapng_optlen(sizeof(ifrecv));
-	if (ifdrop != UINT64_MAX)
-		optlen += pcapng_optlen(sizeof(ifdrop));
+	/* compute how many stats will be added. */
+	if (isb.ifrecv != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.ifrecv));
+	if (isb.ifdrop != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.ifdrop));
+	if (isb.filteraccept != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.filteraccept));
 
 	if (start_time != 0)
 		optlen += pcapng_optlen(sizeof(start_time));
@@ -439,12 +447,16 @@ rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
 	if (start_time != 0)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_STARTTIME,
 					 &start_time, sizeof(start_time));
-	if (ifrecv != UINT64_MAX)
+	if (isb.ifrecv != UINT64_MAX)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_IFRECV,
-				&ifrecv, sizeof(ifrecv));
-	if (ifdrop != UINT64_MAX)
+					&isb.ifrecv, sizeof(uint64_t));
+	if (isb.ifdrop != UINT64_MAX)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_IFDROP,
-				&ifdrop, sizeof(ifdrop));
+					&isb.ifdrop, sizeof(uint64_t));
+	if (isb.filteraccept != UINT64_MAX)
+		opt = pcapng_add_option(opt, PCAPNG_ISB_FILTERACCEPT,
+					&isb.filteraccept, sizeof(uint64_t));
+
 	if (optlen != 0)
 		opt = pcapng_add_option(opt, PCAPNG_OPT_END, NULL, 0);
 
diff --git a/lib/pcapng/rte_pcapng.h b/lib/pcapng/rte_pcapng.h
index d8d328f710..172455af60 100644
--- a/lib/pcapng/rte_pcapng.h
+++ b/lib/pcapng/rte_pcapng.h
@@ -178,21 +178,33 @@ ssize_t
 rte_pcapng_write_packets(rte_pcapng_t *self,
 			 struct rte_mbuf *pkts[], uint16_t nb_pkts);
 
+/**
+ * A structure to store interface statistics in pcapng
+ * Interface statistics block.
+ * If the statistic is unavailable or unknown, use UINT64_MAX.
+ *
+ * Subset of definitions come from IETF pcapng standard.
+ * The osdrop and usrdeliv statistics are not included because
+ * DPDK does not use OS and does not deliver to application over sockets.
+ */
+struct rte_pcapng_interface_stats {
+	uint64_t ifrecv;	/**< Packets received on interface during capture. */
+	uint64_t ifdrop;	/**< Packets dropped by interface due to lack of resources. */
+	uint64_t filteraccept;	/**< Packets accepted by filter. */
+};
+
 /**
  * Write an Interface statistics block.
- * For statistics, use 0 if don't know or care to report it.
  * Should be called before closing capture to report results.
  *
  * @param self
  *  The handle to the packet capture file
  * @param port
  *  The Ethernet port to report stats on.
- * @param ifrecv
- *  The number of packets received by capture.
- *  Optional: use UINT64_MAX if not known.
- * @param ifdrop
- *  The number of packets missed by the capture process.
- *  Optional: use UINT64_MAX if not known.
+ * @param stats
+ *  The statistics to write.
+ * @param stats_sz
+ *  The sizeof statistics structure.
  * @param comment
  *  Optional comment to add to statistics.
  * @return
@@ -203,8 +215,8 @@ rte_pcapng_write_packets(rte_pcapng_t *self,
  */
 ssize_t
 rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port,
-		       uint64_t ifrecv, uint64_t ifdrop,
-		       const char *comment);
+		       const struct rte_pcapng_interface_stats *stats,
+		       size_t stats_sz, const char *comment);
 
 #ifdef __cplusplus
 }
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH 2/5] capture: infrastructure wireshark packet capture
  2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 1/5] pcapng: extend interface statistics Stephen Hemminger
@ 2026-07-24 21:11 ` Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 3/5] test: add test for capture hooks Stephen Hemminger
                   ` (3 subsequent siblings)
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-24 21:11 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This provides a telemetry extension to provide packet capture.
It is intended to be used with a front end script to provide
external packet capture for wireshark.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |    1 +
 doc/guides/rel_notes/release_26_11.rst |    4 +
 lib/capture/capture.c                  | 1032 ++++++++++++++++++++++++
 lib/capture/capture_impl.h             |   56 ++
 lib/capture/filter.c                   |  108 +++
 lib/capture/meson.build                |   19 +
 lib/meson.build                        |    1 +
 7 files changed, 1221 insertions(+)
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/capture_impl.h
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build

diff --git a/MAINTAINERS b/MAINTAINERS
index e99a65d197..fcd350ad94 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1720,6 +1720,7 @@ F: doc/guides/sample_app_ug/qos_scheduler.rst
 Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
+F: lib/capture/
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index b27494fee7..8291c2c4e5 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -55,6 +55,10 @@ New Features
      Also, make sure to start the actual text at the margin.
      =======================================================
 
+* **Added wireshark capture support.**
+
+  * Added ``capture`` library for packet capture via telemetry API.
+
 
 Removed Items
 -------------
diff --git a/lib/capture/capture.c b/lib/capture/capture.c
new file mode 100644
index 0000000000..1b115ccf4a
--- /dev/null
+++ b/lib/capture/capture.c
@@ -0,0 +1,1032 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/queue.h>
+#include <sys/stat.h>
+#include <sys/utsname.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <rte_branch_prediction.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_debug.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_memory.h>
+#include <rte_mempool.h>
+#include <rte_mbuf.h>
+#include <rte_pcapng.h>
+#include <rte_pause.h>
+#include <rte_ring.h>
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+#include <rte_string_fns.h>
+#include <rte_telemetry.h>
+#include <rte_thread.h>
+#include <rte_version.h>
+
+#include "capture_impl.h"
+
+#define CAPTURE_EXTCAP_PREFIX	"dpdk"
+#define CAP_CMD_MAX		1024
+#define DEFAULT_SNAPLEN		262144u	/* from tcpdump et.al. */
+#define CAPTURE_BURST_SIZE	32u
+#define MBUF_POOL_CACHE_SIZE	32
+#define CAPTURE_RING_SIZE	256
+#define CAPTURE_POOL_SIZE	1024
+#define SLEEP_THRESHOLD		100
+#define SLEEP_US		100
+
+#define ALL_QUEUES		-1
+
+RTE_LOG_REGISTER_DEFAULT(rte_capture_logtype, NOTICE);
+
+/*
+ * List of active captures.
+ *
+ * This is a control-plane only structure: it is created, walked and torn down
+ * from the telemetry handler thread and from the per-capture drain threads,
+ * never from the dataplane. A plain spinlock is therefore enough; the EAL
+ * shared tailq (rte_tailq) is not used because captures are not visible to
+ * secondary processes in this design.
+ */
+TAILQ_HEAD(capture_list, capture);
+static struct capture_list capture_list = TAILQ_HEAD_INITIALIZER(capture_list);
+static rte_spinlock_t capture_lock = RTE_SPINLOCK_INITIALIZER;
+
+/* Parameter values: only used on stack inside parsing */
+struct capture_config {
+	uint16_t port_id;
+	uint32_t snaplen;
+	int queue;
+	const char *filter_str;
+	const char *output;
+};
+
+/*
+ * Data used by callback
+ * This per-queue to avoid cache thrashing
+ */
+struct __rte_cache_aligned capture_rxtx_cb {
+	RTE_ATOMIC(uint32_t) use_count;
+	const struct rte_eth_rxtx_callback *cb;
+
+	struct capture_stats {
+		RTE_ATOMIC(uint64_t) accepted; /**< Number of packets accepted by filter. */
+		RTE_ATOMIC(uint64_t) filtered; /**< Number of packets rejected by filter. */
+		RTE_ATOMIC(uint64_t) nombuf;   /**< Number of mbuf allocation failures. */
+		RTE_ATOMIC(uint64_t) ringfull; /**< Number of missed packets due to ring full. */
+	} stats;
+};
+
+/*
+ * Per-capture instance state.
+ */
+struct capture {
+	RTE_ATOMIC(bool) running;
+	struct rte_capture_filter *filter;
+	struct rte_ring *ring;		/* ring from dataplane to capture thread */
+	struct rte_mempool *mp;		/* mempool for capture mbufs */
+
+	uint32_t snaplen;		/* amount of data to copy */
+	int queue;
+	uint16_t tx_queues;
+	uint16_t rx_queues;
+	uint16_t port_id;
+
+	unsigned int idx;
+	char *output;			/* filename of out */
+
+	TAILQ_ENTRY(capture) next;	/* links into capture_list */
+
+	/* per-queue data sized to max(tx_queue, rx_queues) */
+	struct capture_cbs {
+		struct capture_rxtx_cb tx_cb;
+		struct capture_rxtx_cb rx_cb;
+	} cbs[];
+};
+
+/* Wait for callbacks to be idle before free */
+static void
+capture_cb_wait(struct capture_rxtx_cb *cbs)
+{
+	/* make sure all previous loads and stores are completed */
+	rte_atomic_thread_fence(rte_memory_order_seq_cst);
+	uint32_t puse = rte_atomic_load_explicit(&cbs->use_count,
+						 rte_memory_order_acquire);
+
+	/* in use, busy wait till current RX/TX iteration is finished */
+	if (puse & 1)
+		RTE_WAIT_UNTIL_MASKED(&cbs->use_count, UINT32_MAX, !=, puse,
+				      rte_memory_order_acquire);
+}
+
+/* Mark datapath iteration in progress: count becomes odd. */
+static inline __rte_hot void
+capture_cb_hold(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_seq_cst);
+}
+
+/* Iteration finished: count becomes even again. */
+static inline __rte_hot void
+capture_cb_release(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_release);
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static inline __rte_hot void
+capture_copy_burst(uint16_t port_id, uint16_t queue_id,
+		   enum rte_pcapng_direction direction,
+		   struct rte_mbuf **pkts, unsigned int nb_pkts,
+		   const struct capture *cap,
+		   struct capture_stats *stats)
+{
+	unsigned int i, ring_enq, d_pkts = 0;
+	struct rte_mbuf *dup_bufs[CAPTURE_BURST_SIZE]; /* duplicated packets */
+	struct rte_ring *ring = cap->ring;
+	struct rte_mempool *mp = cap->mp;
+	uint32_t snaplen = cap->snaplen;
+	struct rte_mbuf *p;
+
+	RTE_ASSERT(nb_pkts <= CAPTURE_BURST_SIZE);
+
+	for (i = 0; i < nb_pkts; i++) {
+		/*
+		 * This uses same BPF return value convention as socket filter
+		 * and pcap_offline_filter. If program returns zero then packet
+		 * doesn't match the filter (will be ignored).
+		 */
+		if (cap->filter) {
+			if (__rte_capture_filter(cap->filter, pkts[i]) == 0) {
+				rte_atomic_fetch_add_explicit(&stats->filtered, 1,
+							      rte_memory_order_relaxed);
+				continue;
+			}
+		}
+
+		p = rte_pcapng_copy(port_id, queue_id, pkts[i], mp, snaplen, direction, NULL);
+		if (unlikely(p == NULL))
+			rte_atomic_fetch_add_explicit(&stats->nombuf, 1, rte_memory_order_relaxed);
+		else
+			dup_bufs[d_pkts++] = p;
+	}
+
+	if (d_pkts == 0)
+		return;
+
+	rte_atomic_fetch_add_explicit(&stats->accepted, d_pkts, rte_memory_order_relaxed);
+
+	ring_enq = rte_ring_enqueue_burst(ring, (void *)&dup_bufs[0], d_pkts, NULL);
+	if (unlikely(ring_enq < d_pkts)) {
+		unsigned int drops = d_pkts - ring_enq;
+
+		rte_atomic_fetch_add_explicit(&stats->ringfull, drops, rte_memory_order_relaxed);
+		rte_pktmbuf_free_bulk(&dup_bufs[ring_enq], drops);
+	}
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static __rte_hot inline void
+capture_copy(uint16_t port_id, uint16_t queue_id,
+	     enum rte_pcapng_direction direction,
+	     struct rte_mbuf **pkts, uint16_t nb_pkts,
+	     const struct capture *cap,
+	     struct capture_stats *stats)
+{
+	unsigned int offs = 0;
+
+	do {
+		unsigned int n = RTE_MIN(nb_pkts - offs, CAPTURE_BURST_SIZE);
+
+		capture_copy_burst(port_id, queue_id, direction, &pkts[offs], n, cap, stats);
+		offs += n;
+	} while (offs < nb_pkts);
+}
+
+static __rte_hot uint16_t
+capture_rx(uint16_t port, uint16_t queue,
+	struct rte_mbuf **pkts, uint16_t nb_pkts,
+	uint16_t max_pkts __rte_unused, void *user_params)
+{
+	struct capture *cap = user_params;
+	struct capture_rxtx_cb *cbs = &cap->cbs[queue].rx_cb;
+
+	capture_cb_hold(cbs);
+	capture_copy(port, queue, RTE_PCAPNG_DIRECTION_IN, pkts, nb_pkts, cap, &cbs->stats);
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+static __rte_hot uint16_t
+capture_tx(uint16_t port, uint16_t queue,
+	   struct rte_mbuf **pkts, uint16_t nb_pkts, void *user_params)
+{
+	struct capture *capture = user_params;
+	struct capture_rxtx_cb *cbs = &capture->cbs[queue].tx_cb;
+
+	capture_cb_hold(cbs);
+	capture_copy(port, queue, RTE_PCAPNG_DIRECTION_OUT, pkts, nb_pkts, capture, &cbs->stats);
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+
+/* Install callbacks */
+static int
+capture_add_callbacks(struct capture *cap)
+{
+	for (unsigned int q = 0; q < cap->tx_queues; q++) {
+		struct capture_rxtx_cb *tx_cb = &cap->cbs[q].tx_cb;
+
+		if (cap->queue >= 0 && (unsigned int)cap->queue != q)
+			continue;
+
+		tx_cb->cb = rte_eth_add_tx_callback(cap->port_id, q, capture_tx, cap);
+		if (tx_cb->cb == NULL) {
+			CAPTURE_LOG(ERR, "Register tx callback for %u:%u failed",
+				    cap->port_id, q);
+			return -1;
+		}
+	}
+
+	for (unsigned int q = 0; q < cap->rx_queues; q++) {
+		struct capture_rxtx_cb *rx_cb = &cap->cbs[q].rx_cb;
+
+		if (cap->queue >= 0 && (unsigned int)cap->queue != q)
+			continue;
+
+		rx_cb->cb = rte_eth_add_rx_callback(cap->port_id, q, capture_rx, cap);
+		if (rx_cb->cb == NULL) {
+			CAPTURE_LOG(ERR, "Register rx callback for %u:%u failed",
+				    cap->port_id, q);
+			return -1;
+		}
+	}
+	return 0;
+}
+
+/* Cleanup call backs */
+static void
+capture_remove_callbacks(struct capture *cap)
+{
+	for (unsigned int q = 0; q < cap->tx_queues; q++) {
+		struct capture_rxtx_cb *tx_cb = &cap->cbs[q].tx_cb;
+		if (tx_cb->cb) {
+			rte_eth_remove_tx_callback(cap->port_id, q, tx_cb->cb);
+			capture_cb_wait(tx_cb);
+			tx_cb->cb = NULL;
+		}
+	}
+
+	for (unsigned int q = 0; q < cap->rx_queues; q++) {
+		struct capture_rxtx_cb *rx_cb = &cap->cbs[q].rx_cb;
+		if (rx_cb->cb) {
+			rte_eth_remove_rx_callback(cap->port_id, q, rx_cb->cb);
+			capture_cb_wait(rx_cb);
+			rx_cb->cb = NULL;
+		}
+	}
+}
+
+/* Helper that returns error to telemetry and logs it */
+static void __rte_format_printf(2, 3)
+capture_err(struct rte_tel_data *d, const char *format, ...)
+{
+	va_list ap;
+	char msg[1024];
+
+	va_start(ap, format);
+	vsnprintf(msg, sizeof(msg), format, ap);
+	va_end(ap);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "error", msg);
+	rte_log(RTE_LOG_NOTICE, RTE_LOGTYPE_CAPTURE, "CAPTURE: %s\n", msg);
+}
+
+/*
+ * Break the comma separated parameter string into tokens
+ * and fill in the capture config structure.
+ *
+ * Does not use rte_kvargs because that would mangle [] etc in filter expression.
+ */
+static int
+parse_params(char *str, struct capture_config *cfg, struct rte_tel_data *d)
+{
+	uint32_t snaplen = DEFAULT_SNAPLEN;
+	char *args[8];
+	int nargs;
+
+	/* Need at least the port id */
+	nargs = rte_strsplit(str, strlen(str), args, RTE_DIM(args), ',');
+	if (nargs < 1) {
+		capture_err(d, "missing parameters '%s'", str);
+		return -1;
+	}
+
+	/* Parse port id (required) */
+	char *endp;
+	errno = 0;
+	unsigned long port_id = strtoul(args[0], &endp, 10);
+	if (errno != 0 || *endp != '\0' || port_id >= RTE_MAX_ETHPORTS) {
+		capture_err(d, "invalid port_id='%s'", args[0]);
+		return -1;
+	}
+
+	/* parse remainder as name=value parameters */
+	for (int i = 1; i < nargs; i++) {
+		char *key = args[i];
+
+		/* split at the = */
+		char *eq = strchr(args[i], '=');
+
+		/* all current options require argument after = */
+		if (eq == NULL || eq[1] == '\0') {
+			capture_err(d,  "missing value for '%s'", key);
+			return -1;
+		}
+		*eq = '\0';
+		char *value = eq + 1;
+
+		if (strcmp(key, "out") == 0) {
+			cfg->output = value;
+		} else if (strcmp(key, "filter") == 0) {
+			cfg->filter_str = value;
+		} else if (strcmp(key, "queue") == 0) {
+			errno = 0;
+			unsigned long q = strtoul(value, &endp, 10);
+			if (errno != 0 || *endp != '\0' || q >= RTE_MAX_QUEUES_PER_PORT) {
+				capture_err(d, "invalid queue '%lu'", q);
+				return -1;
+			}
+			cfg->queue = q;
+		} else if (strcmp(key, "snaplen") == 0) {
+			errno = 0;
+			unsigned long len = strtoul(value, &endp, 10);
+			if (errno != 0 || *endp != '\0' || len >= UINT32_MAX) {
+				capture_err(d, "invalid snaplen '%lu'", len);
+				return -1;
+			}
+			snaplen = len;
+		} else {
+			capture_err(d, "unknown parameter '%s'", key);
+			return -1;
+		}
+	}
+
+	if (cfg->output == NULL) {
+		capture_err(d, "missing output parameter");
+		return -1;
+	}
+
+	cfg->port_id = port_id;
+
+	/*
+	 * Default is 256K from tcpdump legacy
+	 * using snaplen=0 means everything.
+	 */
+	cfg->snaplen = snaplen > 0 ? snaplen : UINT32_MAX;
+	return 0;
+}
+
+static bool is_empty_or_fifo(const struct stat *stb)
+{
+	if (S_ISFIFO(stb->st_mode))
+		return true;
+	else if (S_ISREG(stb->st_mode))
+		return stb->st_size == 0;
+	else
+		return false; /* not a FIFO or regular file */
+}
+
+
+/*
+ * Create the file handle for pcapng output
+ * Note: can't really tell wireshark about errors since this in an
+ * independent thread.
+ */
+static __rte_cold rte_pcapng_t *
+capture_pcapng_open(const char *path, int *fd, uint16_t port_id, const char *filter )
+{
+	rte_pcapng_t *pcapng = NULL;
+	char port_name[RTE_ETH_NAME_MAX_LEN];
+	char appname[128];
+	char ifname[IFNAMSIZ];
+	char *ifdescr = NULL;
+	struct utsname uts;
+	char *osname = NULL;
+
+	/* OS name is optional, just keep going if not found */
+	if (uname(&uts) == 0 && asprintf(&osname, "%s %s", uts.sysname, uts.release) < 0)
+		osname = NULL;
+
+	/* add DPDK internal name */
+	if (rte_eth_dev_get_name_by_port(port_id, port_name) != 0) {
+		CAPTURE_LOG(NOTICE, "Could not find port name for %u", port_id);
+		return NULL;
+	}
+
+	/* match name convention used by dpdk-wireshark-extcap.py */
+	snprintf(ifname, sizeof(ifname), CAPTURE_EXTCAP_PREFIX ":%u", port_id);
+	if (asprintf(&ifdescr, "DPDK %s", port_name) < 0)
+		ifdescr = NULL;
+
+	/* mirror what other applications do for name */
+	snprintf(appname, sizeof(appname), CAPTURE_EXTCAP_PREFIX " (%s)", rte_version());
+
+	/*
+	 * Open the output in non-block mode in case it is a FIFO
+	 * without a reader. Wireshark must open the read end before
+	 * asking us to capture.
+	 */
+	*fd = open(path, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
+	if (*fd < 0) {
+		CAPTURE_LOG(ERR, "Could not open %s: %s", path, strerror(errno));
+		return NULL;
+	}
+
+	/* recheck that it is ok to use */
+	struct stat sb;
+	if (fstat(*fd, &sb) < 0 || !is_empty_or_fifo(&sb)) {
+		CAPTURE_LOG(ERR, "Not safe to use %s", path);
+		goto close_fd;
+	}
+
+	/* writes in the drain loop should block normally */
+	int flags = fcntl(*fd, F_GETFL, 0);
+	if (flags < 0 || fcntl(*fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
+		CAPTURE_LOG(ERR, "fcntl %s: %s",  path,  strerror(errno));
+		goto close_fd;
+	}
+
+	/* put pcapng header on and setup */
+	pcapng = rte_pcapng_fdopen(*fd, osname, NULL, appname, NULL);
+	if (pcapng == NULL) {
+		CAPTURE_LOG(ERR, "Add section block failed");
+		goto close_fd;
+	}
+
+	if (rte_pcapng_add_interface(pcapng, port_id, DLT_EN10MB, ifname, ifdescr, filter) < 0) {
+		CAPTURE_LOG(ERR, "Add interface for port %u:%s failed", port_id, ifname);
+		rte_pcapng_close(pcapng);  /* closes fd */
+		pcapng = NULL;
+	}
+	goto cleanup;
+
+close_fd:
+	close(*fd);
+cleanup:
+	free(osname);
+	free(ifdescr);
+	return pcapng;
+}
+
+static void
+capture_link(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+static void
+capture_unlink(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_REMOVE(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+static void
+capture_free(struct capture *cap)
+{
+	if (cap == NULL)
+		return;
+
+	free(cap->output);
+	__rte_capture_filter_free(cap->filter);
+	rte_ring_free(cap->ring);
+	rte_mempool_free(cap->mp);
+	rte_free(cap);
+}
+
+/* Generate unique id for naming and telemetry */
+static unsigned int
+get_unique_id(void)
+{
+	static RTE_ATOMIC(unsigned int) capture_instance;
+
+	return rte_atomic_fetch_add_explicit(&capture_instance, 1, rte_memory_order_relaxed);
+}
+
+/*
+ * Convert configuration into running state
+ */
+static struct capture *
+capture_alloc(const struct capture_config *cfg,
+	      const struct rte_eth_dev_info *dev_info,
+	      struct rte_tel_data *d)
+{
+	struct capture *cap;
+	char ring_name[RTE_RING_NAMESIZE];
+	uint16_t mbuf_size;
+
+	/* try and put capture data struct on same node as device. */
+	int socket_id = rte_eth_dev_socket_id(cfg->port_id);
+	if (socket_id < 0)
+		socket_id = SOCKET_ID_ANY;
+
+	uint16_t num_queues = RTE_MAX(dev_info->nb_tx_queues, dev_info->nb_rx_queues);
+	size_t cb_size = sizeof(*cap) + num_queues * sizeof(cap->cbs[0]);
+	cap = rte_zmalloc_socket("capture", cb_size, RTE_CACHE_LINE_SIZE, socket_id);
+	if (cap == NULL) {
+		capture_err(d, "Could not allocate capture struct");
+		goto error;
+	}
+
+	cap->idx = get_unique_id();
+	snprintf(ring_name, sizeof(ring_name), "capture-%u", cap->idx);
+	cap->ring = rte_ring_create(ring_name, CAPTURE_RING_SIZE, socket_id, 0);
+	if (cap->ring == NULL) {
+		capture_err(d, "Could not create ring");
+		goto error;
+	}
+
+	/*
+	 * If snapshot length is smaller than one mbuf segment then pool
+	 * element size can be reduced; otherwise can just use the default
+	 * and rte_pktmbuf_copy handle multiple segments.
+	 */
+	if (cfg->snaplen < RTE_MBUF_DEFAULT_BUF_SIZE)
+		mbuf_size = rte_pcapng_mbuf_size(cfg->snaplen);
+	else
+		mbuf_size = RTE_MBUF_DEFAULT_BUF_SIZE;
+
+	cap->mp = rte_pktmbuf_pool_create_by_ops(ring_name, CAPTURE_POOL_SIZE,
+						 MBUF_POOL_CACHE_SIZE, 0, mbuf_size,
+						 socket_id, "ring_mp_mc");
+	if (cap->mp == NULL) {
+		capture_err(d,  "Could not create mempool");
+		goto error;
+	}
+
+	if (cfg->filter_str) {
+		cap->filter = __rte_capture_filter_create(cfg->filter_str);
+		if (cap->filter == NULL) {
+			capture_err(d,  "Could not compile filter: %s", cfg->filter_str);
+			goto error;
+		}
+	}
+
+	cap->port_id = cfg->port_id;
+	cap->output = strdup(cfg->output);
+	if (cap->output == NULL) {
+		capture_err(d, "Could not strdup '%s'", cfg->output);
+		goto error;
+	}
+	rte_atomic_store_explicit(&cap->running, true, rte_memory_order_relaxed);
+	cap->snaplen = cfg->snaplen;
+	cap->queue = cfg->queue;
+	cap->tx_queues = dev_info->nb_tx_queues;
+	cap->rx_queues = dev_info->nb_rx_queues;
+
+	return cap;
+
+error:
+	capture_free(cap);
+	return NULL;
+}
+
+/* Aggregate per-queue counters of a capture instance. */
+struct capture_total {
+	uint64_t accepted;
+	uint64_t filtered;
+	uint64_t nombuf;
+	uint64_t ringfull;
+};
+
+static void
+capture_sum_one(struct capture_total *t, const struct capture_stats *s)
+{
+	t->accepted += rte_atomic_load_explicit(&s->accepted, rte_memory_order_relaxed);
+	t->filtered += rte_atomic_load_explicit(&s->filtered, rte_memory_order_relaxed);
+	t->nombuf   += rte_atomic_load_explicit(&s->nombuf, rte_memory_order_relaxed);
+	t->ringfull += rte_atomic_load_explicit(&s->ringfull, rte_memory_order_relaxed);
+}
+
+/* Sum the rx and tx counters across all queues. Caller holds capture_lock. */
+static void
+capture_sum_stats(const struct capture *cap, struct capture_total *t)
+{
+	*t = (struct capture_total){ };
+
+	for (unsigned int q = 0; q < cap->rx_queues; q++)
+		capture_sum_one(t, &cap->cbs[q].rx_cb.stats);
+	for (unsigned int q = 0; q < cap->tx_queues; q++)
+		capture_sum_one(t, &cap->cbs[q].tx_cb.stats);
+}
+
+static void
+capture_write_stats(rte_pcapng_t *pcapng, const struct capture *cap)
+{
+	struct capture_total t;
+	struct rte_pcapng_interface_stats isb;
+
+	capture_sum_stats(cap, &t);
+
+	/* Unlike libpcap the ifrecv is the total number of packets
+	 * and filteraccept is the subset that passed.
+	 */
+	isb.ifrecv = t.accepted + t.filtered + t.nombuf;
+	isb.filteraccept = t.accepted + t.nombuf;
+	isb.ifdrop = t.nombuf + t.ringfull;
+
+	rte_pcapng_write_stats(pcapng, cap->port_id, &isb, sizeof(isb), NULL);
+}
+
+/*
+ * Check that output file is still ok to write (i.e FIFO not closed)
+ * Also block for up to SLEEP_US to wait for wireshark to catch up
+ */
+static int
+check_fifo_status(int fd)
+{
+	struct pollfd pfd = { .fd = fd };
+
+	if (poll(&pfd, 1, 0) < 0) {
+		CAPTURE_LOG(ERR, "poll failed: %s", strerror(errno));
+		return -1;
+	}
+	if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
+		CAPTURE_LOG(NOTICE, "fifo reader closed");
+		return -1;
+	}
+	return 0;
+}
+
+static ssize_t
+capture_process_ring(struct rte_ring *ring, rte_pcapng_t *pcapng,
+		     unsigned int *available)
+{
+	struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+	unsigned int n;
+	ssize_t written;
+
+	n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+				      CAPTURE_BURST_SIZE, available);
+	if (n == 0)
+		return 0;
+
+	written = rte_pcapng_write_packets(pcapng, pkts, n);
+	rte_pktmbuf_free_bulk(pkts, n);
+
+	return written;
+}
+
+static void
+capture_flush_ring(struct rte_ring *ring)
+{
+	for (;;) {
+		struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+		unsigned int n;
+
+		n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+					      CAPTURE_BURST_SIZE, NULL);
+		if (n == 0)
+			break;
+
+		rte_pktmbuf_free_bulk(pkts, n);
+	}
+}
+
+/* The capture thread that moves packets from ring to the pcapng out */
+static uint32_t __rte_hot
+capture_thread(void *arg)
+{
+	struct capture *cap = arg;
+	unsigned int empty_count = 0;
+	bool reader_gone = false;
+	int fd = -1;
+
+	CAPTURE_LOG(INFO, "capture thread starting");
+
+	char name[RTE_THREAD_NAME_SIZE];
+	snprintf(name, sizeof(name), "dpdk-cap-%u", cap->idx);
+	rte_thread_set_name(rte_thread_self(), name);
+
+	/* This thread wants to detect when file gets closed (for FIFO) */
+	sigset_t set;
+	sigemptyset(&set);
+	sigaddset(&set, SIGPIPE);
+	pthread_sigmask(SIG_BLOCK, &set, NULL);
+
+	rte_pcapng_t *pcapng = capture_pcapng_open(cap->output, &fd, cap->port_id,
+						   __rte_capture_filter_string(cap->filter));
+	if (pcapng == NULL) {
+		capture_remove_callbacks(cap);
+		goto error;
+	}
+
+	while (rte_atomic_load_explicit(&cap->running, rte_memory_order_relaxed)) {
+		ssize_t written;
+		unsigned int avail;
+
+		written = capture_process_ring(cap->ring, pcapng, &avail);
+		if (written < 0) {
+			CAPTURE_LOG(NOTICE, "write to file failed: %s",
+				    strerror(errno));
+			reader_gone = true;
+			break;
+		}
+
+		if (written > 0) {
+			/* are there more packets? */
+			empty_count = (avail == 0);
+		} else if (empty_count < SLEEP_THRESHOLD) {
+			/* spin a few times before checking */
+			++empty_count;
+			rte_pause();
+		} else if (check_fifo_status(fd) != 0) {
+			/* output FIFO has closed */
+			reader_gone = true;
+			break;
+		} else {
+			/* avoid consuming 100% CPU polling */
+			rte_delay_us_sleep(SLEEP_US);
+		}
+	}
+
+	/* Capture exiting */
+	CAPTURE_LOG(INFO, "capture thread stopping");
+	capture_remove_callbacks(cap);
+
+	/* Process residual */
+	if (reader_gone)
+		capture_flush_ring(cap->ring);
+	else {
+		while (capture_process_ring(cap->ring, pcapng, NULL) > 0)
+			continue;
+
+		capture_write_stats(pcapng, cap);
+	}
+
+	rte_pcapng_close(pcapng);
+
+error:
+	capture_unlink(cap);
+	capture_free(cap);
+
+	return 0;
+}
+
+/*
+ * Callback handler for telemetry library to start capture.
+ *
+ * Need to handle: <iface>,snaplen=<n>,filter=<str>
+ */
+static int
+capture_start_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	struct capture *cap = NULL;
+	struct capture_config cfg = { .queue = ALL_QUEUES };
+	struct rte_eth_dev_info dev_info;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry: %s %s", cmd, params);
+
+	if (rte_eal_process_type() != RTE_PROC_PRIMARY) {
+		CAPTURE_LOG(ERR, "capture can only be started from primary");
+		return -1;
+	}
+
+	/* Note: params is const so need non-const copy for parsing
+	 * Alternative would be to change telemetry to allow this.
+	 */
+	char tmp[CAP_CMD_MAX];
+	if (strlcpy(tmp, params, CAP_CMD_MAX) >= CAP_CMD_MAX) {
+		CAPTURE_LOG(ERR, "params too long");
+		return -1;
+	}
+
+	if (parse_params(tmp, &cfg, d) < 0)
+		return 0;
+
+	/* Lookup number of queues etc, also validates port_id */
+	if (rte_eth_dev_info_get(cfg.port_id, &dev_info) < 0) {
+		capture_err(d, "can not get info for port %u", cfg.port_id);
+		return 0;
+	}
+
+	if (cfg.queue != ALL_QUEUES &&
+	    cfg.queue >= RTE_MAX(dev_info.nb_rx_queues, dev_info.nb_tx_queues)) {
+		capture_err(d, "queue %d out of range", cfg.queue);
+		return 0;
+	}
+
+	struct stat sb;
+	if (stat(cfg.output, &sb) < 0) {
+		capture_err(d, "output %s:%s", cfg.output, strerror(errno));
+		return 0;
+	}
+
+	if (!is_empty_or_fifo(&sb)) {
+		capture_err(d, "output %s is not empty", cfg.output);
+		return 0;
+	}
+
+	cap = capture_alloc(&cfg, &dev_info, d);
+	if (cap == NULL)
+		return 0;
+
+	if (capture_add_callbacks(cap) < 0) {
+		capture_err(d, "can not register callbacks");
+		goto error_callback_remove;
+	}
+
+	/*
+	 * Publish into the active list before starting the drain thread so the
+	 * thread is guaranteed to find itself there when it removes itself on
+	 * exit (it may exit immediately, e.g. if the FIFO reader is already
+	 * gone). On thread-create failure we undo the insertion here.
+	 */
+	unsigned int idx = cap->idx;
+	capture_link(cap);
+
+	/*
+	 * Make a new thread to do the capture work
+	 * Thread will inherit affinity from the telemetry handler that calls us
+	 */
+	rte_thread_t thread_id;
+	int ret = rte_thread_create(&thread_id, NULL, capture_thread, cap);
+	if (ret != 0) {
+		capture_err(d, "thread start failed: %s", strerror(ret));
+		goto error_unlink;
+	}
+
+	rte_thread_detach(thread_id);
+
+	/* Return id back for later use. */
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "id", idx);
+	rte_tel_data_add_dict_string(d, "status", "running");
+	return 0;
+
+error_unlink:
+	capture_unlink(cap);
+error_callback_remove:
+	capture_remove_callbacks(cap);
+	capture_free(cap);
+
+	/* return 0 since error reported "error":"XXX" in respons */
+	return 0;
+}
+
+/* Telemetry: stop active capture. */
+static int
+capture_stop_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	errno = 0;
+	char *endp;
+	unsigned long idx = strtoul(params, &endp, 10);
+	if (errno != 0 || *endp != '\0') {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	rte_spinlock_lock(&capture_lock);
+	struct capture *cap;
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		capture_err(d, "capture index %lu not found", idx);
+		return 0;
+	}
+
+	rte_atomic_store_explicit(&cap->running, false, rte_memory_order_relaxed);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "status", "stopped");
+	return 0;
+}
+
+/* Telemetry: list the ids of all active captures. */
+static int
+capture_list_req(const char *cmd __rte_unused, const char *params __rte_unused,
+		 struct rte_tel_data *d)
+{
+	struct capture *cap;
+
+	CAPTURE_LOG(DEBUG, "telemetry %s", cmd);
+	rte_tel_data_start_array(d, RTE_TEL_UINT_VAL);
+
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next)
+		rte_tel_data_add_array_uint(d, cap->idx);
+	rte_spinlock_unlock(&capture_lock);
+
+	return 0;
+}
+
+
+/* Telemetry: report configuration and counters for one capture. */
+static int
+capture_stats_req(const char *cmd, const char *params,
+		  struct rte_tel_data *d)
+{
+	struct capture *cap;
+	struct capture_total t;
+	char *endp;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	errno = 0;
+	unsigned long idx = strtoul(params, &endp, 10);
+	if (errno != 0 || *endp != '\0') {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	/* Find the instance and snapshot what we need while holding the lock. */
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		capture_err(d, "capture index %lu not found", idx);
+		return 0;
+	}
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "port_id", cap->port_id);
+	if (cap->filter)
+		rte_tel_data_add_dict_string(d, "filter",
+					     __rte_capture_filter_string(cap->filter));
+	rte_tel_data_add_dict_int(d, "running",
+				  rte_atomic_load_explicit(&cap->running,
+							   rte_memory_order_relaxed));
+	rte_tel_data_add_dict_uint(d, "snaplen", cap->snaplen);
+	rte_tel_data_add_dict_uint(d, "rx_queues", cap->rx_queues);
+	rte_tel_data_add_dict_uint(d, "tx_queues", cap->tx_queues);
+	capture_sum_stats(cap, &t);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_add_dict_uint(d, "accepted", t.accepted);
+	rte_tel_data_add_dict_uint(d, "filtered", t.filtered);
+	rte_tel_data_add_dict_uint(d, "nombuf", t.nombuf);
+	rte_tel_data_add_dict_uint(d, "ringfull", t.ringfull);
+
+	return 0;
+}
+
+RTE_INIT(capture_telemetry)
+{
+	rte_telemetry_register_cmd("/ethdev/capture/list", capture_list_req,
+		"List ids of active captures. Takes no parameters.");
+	rte_telemetry_register_cmd("/ethdev/capture/stats", capture_stats_req,
+		"Report configuration and counters for a capture. Parameters: id");
+	rte_telemetry_register_cmd("/ethdev/capture/start", capture_start_req,
+		"Start capture. Parameters: "
+		"port_id,out=<path>,snaplen=N(optional),queue=N(optional),filter=string(optional)");
+	rte_telemetry_register_cmd("/ethdev/capture/stop", capture_stop_req,
+				   "Stop an active capture. Parameters: id");
+}
diff --git a/lib/capture/capture_impl.h b/lib/capture/capture_impl.h
new file mode 100644
index 0000000000..adee734b6c
--- /dev/null
+++ b/lib/capture/capture_impl.h
@@ -0,0 +1,56 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+#ifndef CAPTURE_IMPL_H
+#define CAPTURE_IMPL_H
+
+#define RTE_LOGTYPE_CAPTURE rte_capture_logtype
+extern int rte_capture_logtype;
+#define CAPTURE_LOG(level, ...) \
+	RTE_LOG_LINE_PREFIX(level, CAPTURE, "%s(): ", __func__, __VA_ARGS__)
+
+struct rte_capture_filter;
+
+#ifdef RTE_HAS_LIBPCAP
+struct rte_capture_filter *__rte_capture_filter_create(const char *str);
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter);
+void __rte_capture_filter_free(struct rte_capture_filter *filter);
+uint64_t __rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb);
+
+#else /* !RTE_HAS_LIBPCAP */
+
+/* Stub version if pcap is not available */
+static inline struct rte_capture_filter *
+__rte_capture_filter_create(const char *str)
+{
+	RTE_SET_USED(str);
+	return NULL; /* not supported */
+}
+
+static inline const char *
+__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+	return NULL;
+}
+
+static inline void
+__rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+}
+
+/*
+ * This will  be zero if the packet doesn't match the filter and non-zero if
+ * the packet matches the filter.
+ */
+static inline uint64_t
+__rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb)
+{
+	RTE_SET_USED(filter);
+	RTE_SET_USED(mb);
+	return 1;
+}
+
+#endif  /* !RTE_HAS_LIBPCAP */
+#endif /* CAPTURE_IMPL_H */
diff --git a/lib/capture/filter.c b/lib/capture/filter.c
new file mode 100644
index 0000000000..ecb5e8a765
--- /dev/null
+++ b/lib/capture/filter.c
@@ -0,0 +1,108 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#include <pcap/pcap.h>
+
+#include <rte_bpf.h>
+#include <rte_errno.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_mbuf.h>
+
+#include "capture_impl.h"
+
+struct rte_capture_filter {
+	struct rte_bpf *bpf;
+	struct rte_bpf_jit jit;
+	char expr[];	/* original filter text */
+};
+
+/*
+ * Convert text string into an eBPF program
+ */
+struct rte_capture_filter *
+__rte_capture_filter_create(const char *filter)
+{
+	struct rte_capture_filter *flt = NULL;
+	struct rte_bpf_prm *prm = NULL;
+
+	/* libpcap needs a handle */
+	pcap_t *pcap = pcap_open_dead(DLT_EN10MB, UINT16_MAX);
+	if (!pcap) {
+		CAPTURE_LOG(ERR, "pcap: can not open handle");
+		return NULL;
+	}
+
+	flt = rte_zmalloc("capture_filter", sizeof(*flt) + strlen(filter) + 1, 0);
+	if (flt == NULL) {
+		CAPTURE_LOG(ERR, "capture filter alloc failed");
+		goto error;
+	}
+
+	/* convert string to cBPF program */
+	struct bpf_program bf;
+	if (pcap_compile(pcap, &bf, filter, 1, PCAP_NETMASK_UNKNOWN) != 0) {
+		CAPTURE_LOG(ERR, "pcap: can not compile filter: %s",
+			    pcap_geterr(pcap));
+		goto error;
+	}
+	strcpy(flt->expr, filter);
+
+	/* convert cBPF to eBPF */
+	prm = rte_bpf_convert(&bf);
+	pcap_freecode(&bf); /* drop the cBPF program */
+
+	if (prm == NULL)  {
+		CAPTURE_LOG(ERR, "BPF convert interface %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	flt->bpf = rte_bpf_load(prm);
+	if (flt->bpf == NULL) {
+		CAPTURE_LOG(ERR, "BPF load failed: %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	rte_bpf_get_jit(flt->bpf, &flt->jit);
+	if (flt->jit.func == NULL)
+		CAPTURE_LOG(NOTICE, "No JIT available for filter");
+
+	pcap_close(pcap);
+	rte_free(prm);
+	return flt;
+
+error:
+	pcap_close(pcap);
+	rte_free(prm);
+	rte_free(flt);
+	return NULL;
+}
+
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	return filter ? filter->expr : NULL;
+}
+
+void __rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	if (filter == NULL)
+		return;
+
+	rte_bpf_destroy(filter->bpf);
+	rte_free(filter);
+}
+
+uint64_t __rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb)
+{
+	if (filter->jit.func)
+		return filter->jit.func(mb);
+	else
+		return rte_bpf_exec(filter->bpf, mb);
+}
diff --git a/lib/capture/meson.build b/lib/capture/meson.build
new file mode 100644
index 0000000000..4dbe0d1a78
--- /dev/null
+++ b/lib/capture/meson.build
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Stephen Hemminger
+
+if is_windows
+    build = false
+    reason = 'not supported on Windows'
+    subdir_done()
+endif
+
+sources = files('capture.c')
+
+deps += ['ethdev', 'pcapng', 'bpf']
+
+if dpdk_conf.has('RTE_HAS_LIBPCAP')
+    sources += files('filter.c')
+    ext_deps += pcap_dep
+else
+    warning('libpcap is missing, capture filtering will be disabled')
+endif
diff --git a/lib/meson.build b/lib/meson.build
index af5c160cb8..6d9992f61f 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -49,6 +49,7 @@ libraries = [
         'lpm',
         'member',
         'pcapng',
+        'capture', # depends on pcapng and bpf
         'power',
         'rawdev',
         'regexdev',
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH 3/5] test: add test for capture hooks
  2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 1/5] pcapng: extend interface statistics Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 2/5] capture: infrastructure wireshark packet capture Stephen Hemminger
@ 2026-07-24 21:11 ` Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 4/5] usertools/dpdk-wireshark-extcap.py: script for external capture Stephen Hemminger
                   ` (2 subsequent siblings)
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-24 21:11 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

Provide tests to exercise telemetry based packet capture.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS             |   2 +
 app/test/meson.build    |   1 +
 app/test/test_capture.c | 512 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 515 insertions(+)
 create mode 100644 app/test/test_capture.c

diff --git a/MAINTAINERS b/MAINTAINERS
index fcd350ad94..5ce969d02f 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1721,6 +1721,8 @@ Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
 F: lib/capture/
+F: usertools/dpdk-wireshark-extcap.py
+F: doc/guides/tools/wireshark_extcap.rst
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/app/test/meson.build b/app/test/meson.build
index 51abeeb732..b73ae8ca07 100644
--- a/app/test/meson.build
+++ b/app/test/meson.build
@@ -37,6 +37,7 @@ source_file_deps = {
     'test_bpf.c': ['bpf', 'net'],
     'test_bpf_validate.c': ['bpf'],
     'test_byteorder.c': [],
+    'test_capture.c': ['net_null', 'net', 'ethdev', 'bus_vdev', 'capture'],
     'test_cfgfile.c': ['cfgfile'],
     'test_cksum.c': ['net'],
     'test_cksum_perf.c': ['net'],
diff --git a/app/test/test_capture.c b/app/test/test_capture.c
new file mode 100644
index 0000000000..c37b2a961d
--- /dev/null
+++ b/app/test/test_capture.c
@@ -0,0 +1,512 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Functional test for the capture library.
+ *
+ * The capture library has no public C API: it is driven entirely through the
+ * telemetry socket. The output is a path ('out=') that the primary opens
+ * itself and writes pcapng into; the caller never receives packet data over
+ * the control socket. This test therefore behaves like an external capture
+ * tool. It:
+ *
+ *   1. builds a virtual ethdev backed by net_null. Rx synthesizes packets and
+ *      Tx is a sink that frees whatever it is handed, which is all this test
+ *      needs and avoids the per-queue ring bookkeeping of net_ring. The port
+ *      is given NB_QUEUES queues so per-queue selection can be exercised;
+ *   2. creates a FIFO and opens its read end (so the primary's non-blocking
+ *      O_WRONLY open finds a reader);
+ *   3. connects to this process's own telemetry socket;
+ *   4. runs three phases:
+ *        - lifecycle: start an all-queue capture, check a pcapng stream
+ *          appears, the capture is listed, stats account for traffic injected
+ *          on every queue in both directions, and it tears down when the
+ *          reader disconnects;
+ *        - parameters: an out-of-range queue index is rejected, not started;
+ *        - selection: a queue=CAPTURE_QUEUE capture installs callbacks on that
+ *          queue only, so traffic on any other queue is not captured while
+ *          traffic on that one is, in both directions. The selected queue is
+ *          neither the first nor the last of NB_QUEUES, so an off-by-one in
+ *          either install loop is caught. Both directions are steerable here:
+ *          the test names the queue in rte_eth_rx_burst()/rte_eth_tx_burst()
+ *          and net_null sources or sinks on whichever queue it is handed.
+ *
+ * The test is skipped (not failed) if telemetry is not enabled or the net_null
+ * driver is not available.
+ */
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/select.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+
+#include <rte_bus_vdev.h>
+#include <rte_cycles.h>
+#include <rte_eal.h>
+#include <rte_ethdev.h>
+#include <rte_mbuf.h>
+
+#include "test.h"
+
+#define TELEMETRY_VERSION	"v2"
+#define CAPTURE_START		"/ethdev/capture/start"
+#define CAPTURE_LIST		"/ethdev/capture/list"
+#define CAPTURE_STATS		"/ethdev/capture/stats"
+
+#define NULL_VDEV_NAME		"net_null_capture"
+#define NB_QUEUES		4
+#define CAPTURE_QUEUE		2	/* not first or last of NB_QUEUES */
+#define RING_SIZE		256
+#define NB_MBUFS		1024
+#define MBUF_CACHE		32
+#define NB_PKTS			32
+#define PKT_LEN			64
+#define REPLY_LEN		16384
+
+/* pcapng Section Header Block type, byte-order independent on disk. */
+static const uint8_t pcapng_shb_magic[4] = { 0x0a, 0x0d, 0x0d, 0x0a };
+
+static struct rte_mempool *test_mp;
+static uint16_t test_port = RTE_MAX_ETHPORTS;
+static char fifo_path[PATH_MAX];
+
+/* --- telemetry client helpers ------------------------------------------ */
+
+/* Connect to this process's telemetry socket; -1 (and skip) if unavailable. */
+static int
+tel_connect(void)
+{
+	struct sockaddr_un addr = { .sun_family = AF_UNIX };
+	char buf[REPLY_LEN];
+	int s;
+
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/dpdk_telemetry.%s",
+		 rte_eal_get_runtime_dir(), TELEMETRY_VERSION);
+
+	s = socket(AF_UNIX, SOCK_SEQPACKET, 0);
+	if (s < 0)
+		return -1;
+
+	if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+		close(s);
+		return -1;
+	}
+
+	/* Server greets with an info message; consume it. */
+	if (recv(s, buf, sizeof(buf), 0) <= 0) {
+		close(s);
+		return -1;
+	}
+	return s;
+}
+
+/* Send a command and read the reply. */
+static int
+tel_cmd(int s, const char *cmd, char *reply, size_t reply_sz)
+{
+	ssize_t n;
+
+	if (send(s, cmd, strlen(cmd), 0) < 0)
+		return -1;
+	n = recv(s, reply, reply_sz - 1, 0);
+	if (n < 0)
+		return -1;
+	reply[n] = '\0';
+	return 0;
+}
+
+/* Minimal JSON scanning: find "key" and read the unsigned number after it. */
+static int
+json_uint(const char *s, const char *key, uint64_t *out)
+{
+	const char *p = strstr(s, key);
+
+	if (p == NULL)
+		return -1;
+	for (p += strlen(key); *p != '\0' && !isdigit((unsigned char)*p); p++)
+		;
+	if (*p == '\0')
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Read the first element of the array in a list reply; -1 if empty/absent. */
+static int
+json_first_array_uint(const char *s, uint64_t *out)
+{
+	const char *p = strchr(s, '[');
+
+	if (p == NULL)
+		return -1;
+	for (p++; *p == ' '; p++)
+		;
+	if (*p == ']' || !isdigit((unsigned char)*p))
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Query the accepted counter for a capture id via telemetry. */
+static int
+capture_accepted(int sock, uint64_t id, uint64_t *accepted)
+{
+	char cmd[64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STATS, id);
+	if (tel_cmd(sock, cmd, reply, sizeof(reply)) < 0)
+		return -1;
+	return json_uint(reply, "\"accepted\"", accepted);
+}
+
+/*
+ * Poll the capture list until it is empty (the capture has torn down).
+ * Returns 0 once empty, -1 on timeout (~2s) or telemetry failure.
+ */
+static int
+wait_list_empty(int sock)
+{
+	char reply[REPLY_LEN];
+	uint64_t id;
+
+	for (int i = 0; i < 200; i++) {
+		if (tel_cmd(sock, CAPTURE_LIST, reply, sizeof(reply)) < 0)
+			return -1;
+		if (json_first_array_uint(reply, &id) < 0)
+			return 0;
+		rte_delay_ms(10);
+	}
+	return -1;
+}
+
+/* --- packet injection --------------------------------------------------- */
+
+/*
+ * Pull count packets from a queue. net_null synthesizes them and pulling runs
+ * the capture Rx callback on each; the mbufs are ours to free.
+ */
+static int
+inject_rx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t got;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	got = rte_eth_rx_burst(test_port, queue, bufs, count);
+	rte_pktmbuf_free_bulk(bufs, got);
+	return got == count ? 0 : -1;	/* net_null fills the whole request */
+}
+
+/*
+ * Transmit count packets on a queue. The capture Tx callback runs first (it
+ * only copies) and net_null then frees the originals, so nothing is reclaimed
+ * here.
+ */
+static int
+inject_tx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	for (unsigned int i = 0; i < count; i++) {
+		struct rte_mbuf *m = rte_pktmbuf_alloc(test_mp);
+
+		if (m == NULL) {
+			rte_pktmbuf_free_bulk(bufs, i);
+			return -1;
+		}
+		m->pkt_len = m->data_len = PKT_LEN;
+		memset(rte_pktmbuf_mtod(m, void *), 0, PKT_LEN);
+		bufs[i] = m;
+	}
+
+	/* net_null accepts the whole burst. */
+	return rte_eth_tx_burst(test_port, queue, bufs, count) == count ? 0 : -1;
+}
+
+/*
+ * Inject on every queue except skip; pass NB_QUEUES to cover them all.
+ */
+static int
+inject_rx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_rx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+static int
+inject_tx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_tx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+/* --- fixture ------------------------------------------------------------ */
+
+static int
+build_port(void)
+{
+	struct rte_eth_conf conf = { 0 };
+
+	test_mp = rte_pktmbuf_pool_create("capture_test_mp", NB_MBUFS, MBUF_CACHE,
+					  0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
+	if (test_mp == NULL)
+		return -1;
+
+	if (rte_vdev_init(NULL_VDEV_NAME, NULL) < 0)
+		return -1;
+	if (rte_eth_dev_get_port_by_name(NULL_VDEV_NAME, &test_port) != 0)
+		return -1;
+
+	if (rte_eth_dev_configure(test_port, NB_QUEUES, NB_QUEUES, &conf) < 0)
+		return -1;
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (rte_eth_rx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL, test_mp) < 0)
+			return -1;
+		if (rte_eth_tx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL) < 0)
+			return -1;
+	}
+
+	return rte_eth_dev_start(test_port);
+}
+
+static void
+teardown_port(void)
+{
+	if (test_port != RTE_MAX_ETHPORTS) {
+		rte_eth_dev_stop(test_port);
+		/*
+		 * Uninit drives the driver's remove, which closes and releases
+		 * the ethdev port and removes the vdev in one path. No separate
+		 * rte_eth_dev_close() is needed and null's close is idempotent,
+		 * so this is safe even if a tree's close already released it.
+		 */
+		rte_vdev_uninit(NULL_VDEV_NAME);
+		test_port = RTE_MAX_ETHPORTS;
+	}
+	rte_mempool_free(test_mp);
+	test_mp = NULL;
+}
+
+/* Create the capture FIFO in the runtime dir; -1 on failure. */
+static int
+make_fifo(void)
+{
+	snprintf(fifo_path, sizeof(fifo_path), "%s/capture_test.%d",
+		 rte_eal_get_runtime_dir(), (int)getpid());
+	unlink(fifo_path);			/* clear any stale node */
+	if (mkfifo(fifo_path, 0600) < 0)
+		return -1;
+	return 0;
+}
+
+/* --- the test ----------------------------------------------------------- */
+
+static int
+test_capture(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN], pcapng[REPLY_LEN];
+	int sock = -1, rd = -1;
+	int ret = TEST_FAILED;
+	uint64_t id, accepted;
+	struct timeval tv;
+	fd_set rfds;
+	ssize_t n;
+
+	fifo_path[0] = '\0';
+
+	/*
+	 * The library writes to the FIFO; a closed reader must give the writer
+	 * EPIPE, not a fatal SIGPIPE. The library masks SIGPIPE on its drain
+	 * thread, but ignore it here too so the test process is safe regardless.
+	 */
+	signal(SIGPIPE, SIG_IGN);
+
+	sock = tel_connect();
+	if (sock < 0) {
+		printf("telemetry socket not available, skipping\n");
+		return TEST_SKIPPED;
+	}
+
+	if (build_port() < 0) {
+		printf("could not build net_null test port, skipping\n");
+		ret = TEST_SKIPPED;
+		goto out;
+	}
+
+	if (make_fifo() < 0)
+		goto out;
+
+	/*
+	 * Phase 1: all-queue lifecycle.
+	 *
+	 * Open the read end before starting: the primary opens the FIFO
+	 * O_WRONLY|O_NONBLOCK and would get ENXIO with no reader present.
+	 * O_RDONLY|O_NONBLOCK returns immediately even with no writer yet.
+	 */
+	rd = open(fifo_path, O_RDONLY | O_NONBLOCK);
+	if (rd < 0)
+		goto out;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(sock, cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "capture start returned an error: %s", reply);
+
+	/*
+	 * Inject traffic on every queue: with no queue= the callbacks must be
+	 * installed on all of them. Rx callbacks run synchronously inside
+	 * rx_burst, so the accepted counter is up to date as soon as this
+	 * returns.
+	 */
+	TEST_ASSERT_SUCCESS(inject_rx_except(NB_QUEUES, NB_PKTS),
+			    "rx injection failed");
+
+	/* A pcapng stream (at least the section header) must appear. */
+	FD_ZERO(&rfds);
+	FD_SET(rd, &rfds);
+	tv = (struct timeval){ .tv_sec = 2 };
+	TEST_ASSERT(select(rd + 1, &rfds, NULL, NULL, &tv) > 0,
+		    "no pcapng output within timeout");
+	n = read(rd, pcapng, sizeof(pcapng));
+	TEST_ASSERT(n >= 4, "short pcapng read (%zd)", n);
+	TEST_ASSERT(memcmp(pcapng, pcapng_shb_magic, sizeof(pcapng_shb_magic)) == 0,
+		    "output does not start with a pcapng section header block");
+
+	/* The capture must show up in the list. */
+	TEST_ASSERT_SUCCESS(tel_cmd(sock, CAPTURE_LIST, reply, sizeof(reply)),
+			    "capture list command failed");
+	TEST_ASSERT_SUCCESS(json_first_array_uint(reply, &id),
+			    "no capture id in list reply: %s", reply);
+
+	/* Stats must report exactly the packets we injected, on every queue. */
+	TEST_ASSERT_SUCCESS(capture_accepted(sock, id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, NB_QUEUES * NB_PKTS);
+
+	/* Same for the Tx side of every queue. */
+	TEST_ASSERT_SUCCESS(inject_tx_except(NB_QUEUES, NB_PKTS),
+			    "tx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(sock, id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, 2 * NB_QUEUES * NB_PKTS);
+
+	/*
+	 * Close the reader: the capture should detect the hangup and tear down.
+	 * The drain thread's idle poll notices POLLERR on the write end on its
+	 * own; the extra injection just shortens the wait if it was mid-drain.
+	 */
+	close(rd);
+	rd = -1;
+	inject_rx(0, NB_PKTS);	/* any queue; all are captured here */
+	TEST_ASSERT_SUCCESS(wait_list_empty(sock),
+			    "capture did not tear down after reader closed");
+
+	/*
+	 * Phase 2: parameter validation. An out-of-range queue index must be
+	 * rejected. The range check runs before the output is opened, so no
+	 * reader is needed and no capture should be left behind.
+	 */
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, NB_QUEUES);
+	TEST_ASSERT_SUCCESS(tel_cmd(sock, cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") != NULL,
+		    "out-of-range queue=%u was not rejected: %s", NB_QUEUES, reply);
+	TEST_ASSERT_SUCCESS(wait_list_empty(sock),
+			    "rejected start left a capture behind");
+
+	/*
+	 * Phase 3: single-queue selection. With queue=CAPTURE_QUEUE the
+	 * callbacks are installed on that queue only: traffic on any other
+	 * queue must not be captured, traffic on that one must be, in both
+	 * directions. The counters are bumped synchronously inside
+	 * rx_burst/tx_burst, so every check here is race-free.
+	 */
+	rd = open(fifo_path, O_RDONLY | O_NONBLOCK);
+	TEST_ASSERT(rd >= 0, "reopen fifo failed: %s", strerror(errno));
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(tel_cmd(sock, cmd, reply, sizeof(reply)),
+			    "queue=%u capture start failed", CAPTURE_QUEUE);
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "queue=%u capture start returned an error: %s",
+		    CAPTURE_QUEUE, reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id),
+			    "no id in start reply: %s", reply);
+
+	/* Unselected queues: callbacks never installed, nothing captured. */
+	TEST_ASSERT_SUCCESS(inject_rx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(inject_tx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(sock, id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)0,
+			  "other queues captured under queue=%u (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* Selected queue: captured, on Rx and on Tx. */
+	TEST_ASSERT_SUCCESS(inject_rx(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(sock, id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)NB_PKTS,
+			  "rx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	TEST_ASSERT_SUCCESS(inject_tx(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(sock, id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_PKTS),
+			  "tx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	close(rd);
+	rd = -1;
+	TEST_ASSERT_SUCCESS(wait_list_empty(sock),
+			    "queue=%u capture did not tear down", CAPTURE_QUEUE);
+
+	ret = TEST_SUCCESS;
+out:
+	if (rd >= 0)
+		close(rd);
+	if (fifo_path[0] != '\0')
+		unlink(fifo_path);
+	if (sock >= 0)
+		close(sock);
+	teardown_port();
+	return ret;
+}
+
+REGISTER_FAST_TEST(capture_autotest, NOHUGE_OK, ASAN_OK, test_capture);
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH 4/5] usertools/dpdk-wireshark-extcap.py: script for external capture
  2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
                   ` (2 preceding siblings ...)
  2026-07-24 21:11 ` [PATCH 3/5] test: add test for capture hooks Stephen Hemminger
@ 2026-07-24 21:11 ` Stephen Hemminger
  2026-07-24 21:11 ` [PATCH 5/5] usertools/dpdk-dumpcap: add script for file capture Stephen Hemminger
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-24 21:11 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

Provide glue script that wireshark can use to access
telemetry based packet capture. It is dual licensed because
it maybe desirable to put this in wireshark repository.

See https://www.wireshark.org/docs/man-pages/extcap.html

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 doc/guides/rel_notes/release_26_11.rst |   1 +
 doc/guides/tools/index.rst             |   1 +
 doc/guides/tools/wireshark_extcap.rst  | 144 ++++++++++
 usertools/dpdk-wireshark-extcap.py     | 374 +++++++++++++++++++++++++
 4 files changed, 520 insertions(+)
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100755 usertools/dpdk-wireshark-extcap.py

diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index 8291c2c4e5..9835807fcc 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -58,6 +58,7 @@ New Features
 * **Added wireshark capture support.**
 
   * Added ``capture`` library for packet capture via telemetry API.
+  * Added python script for wireshark external capture integration.
 
 
 Removed Items
diff --git a/doc/guides/tools/index.rst b/doc/guides/tools/index.rst
index 13f75a5bc6..01db27511a 100644
--- a/doc/guides/tools/index.rst
+++ b/doc/guides/tools/index.rst
@@ -14,6 +14,7 @@ DPDK Tools User Guides
     pmdinfo
     dumpcap
     pdump
+    wireshark_extcap
     telemetrywatcher
     dmaperf
     flow-perf
diff --git a/doc/guides/tools/wireshark_extcap.rst b/doc/guides/tools/wireshark_extcap.rst
new file mode 100644
index 0000000000..f3d932f651
--- /dev/null
+++ b/doc/guides/tools/wireshark_extcap.rst
@@ -0,0 +1,144 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Wireshark Extcap Plugin
+=======================
+
+The ``dpdk-wireshark-extcap.py`` script is an external capture (extcap)
+plugin that lets Wireshark capture live traffic from the Ethernet ports of a
+running DPDK application.
+Each DPDK port appears as a capture interface in the Wireshark interface list,
+alongside the host's own network interfaces.
+
+The plugin uses the DPDK telemetry API to query and start capture.
+It passes the path of the fifo file that Wireshark created to the DPDK application,
+which opens the file itself and writes pcapng packets straight into it;
+the plugin never touches packet data.
+Wireshark signals the plugin on exit and that results in closing
+the capture session.
+
+
+Requirements
+------------
+
+* A DPDK application built with the capture library and with telemetry enabled.
+  Telemetry is enabled by default.
+
+* Since the plugin is started by Wireshark, Wireshark and the DPDK application
+  must have the same permissions. See `Permissions`_.
+
+
+Installation
+------------
+
+For Wireshark to discover the plugin it must be present in an extcap
+directory. The configured locations are listed in Wireshark under
+*Help > About Wireshark > Folders*. Copy or symbolically link the script into
+the personal extcap directory, for example::
+
+    ln -s $RTE_SDK/usertools/dpdk-wireshark-extcap.py \
+        ~/.local/lib/wireshark/extcap/
+
+The DPDK ports then appear in the interface list the next time the capture
+options dialog is opened.
+
+
+Usage
+-----
+
+In normal use the plugin is not run by hand; Wireshark invokes it.
+The ports of a running DPDK application appear in the interface list as
+``DPDK <name>``, where ``<name>`` is the device name reported by
+DPDK ethdev, such as ``net_tap0``.
+
+The plugin can also be run directly, which is useful for confirming that a
+DPDK application is reachable::
+
+    $ usertools/dpdk-wireshark-extcap.py --extcap-interfaces
+    extcap {version=0.1}{display=DPDK telemetry capture}
+    interface {value=dpdk:0}{display=DPDK net_tap0}
+
+
+Capture options
+---------------
+
+The following options are offered in the Wireshark capture options dialog for
+a DPDK interface:
+
+Snapshot length
+    Number of bytes captured from each packet. ``0`` captures the whole
+    packet. The default is 262144.
+
+Queue
+    Capture a single hardware queue, or all queues (the default). The choices
+    are bounded to the port's configured queue count.
+
+Capture filter
+    A libpcap filter expression, applied by the DPDK application to the
+    captured traffic.
+
+
+Permissions
+-----------
+
+The DPDK runtime directory is created mode ``0700``, so only the user that
+started the DPDK application can reach its telemetry socket.
+Wireshark, and the plugin it launches, must run as that same user. If run as a
+different user, the interface list is simply empty; running the plugin directly
+with ``--extcap-interfaces`` prints a diagnostic to standard error explaining
+the permission failure.
+
+No privilege beyond access to the telemetry socket is required: if you can
+run ``dpdk-dumpcap`` against an application, you can capture from it with this
+plugin.
+
+
+Selecting a DPDK application
+----------------------------
+
+A host usually runs a single DPDK application, started with the default
+file-prefix (``rte``), and no configuration is needed: its ports appear
+automatically as ``DPDK <name>``.
+
+Running several DPDK applications on one host is also supported. In that case
+each application is started with a distinct ``--file-prefix`` so that its
+runtime state is kept separate. The plugin lists the ports of *all* running
+applications at once, so the application is chosen from the interface list
+rather than before Wireshark is launched. There is no environment variable to
+select one.
+
+Ports of the default prefix are shown plainly, as ``DPDK <name>`` with the
+interface value ``dpdk:<port>``. Ports of any other prefix are qualified with
+the prefix, shown as ``DPDK <name>@<prefix>`` with the interface value
+``dpdk:<prefix>:<port>``, so applications with colliding port numbers stay
+distinct in the list.
+
+
+Environment variables
+----------------------
+
+``DPDK_EXTCAP_PATH``
+    Overrides the base DPDK runtime directory that holds the per-prefix
+    subdirectories. Use it when the runtime directory is in a non-standard
+    location. It gives the base directory that holds the per-prefix
+    subdirectories, one of which is scanned for each running application.
+
+
+Troubleshooting
+---------------
+
+The DPDK ports do not appear in Wireshark
+    Confirm the application is running and was built with the capture library
+    and telemetry. Confirm Wireshark runs as the same user as the application;
+    see `Permissions`_. An application started with a non-default
+    ``--file-prefix`` is listed with its ports qualified as
+    ``DPDK <name>@<prefix>``; see `Selecting a DPDK application`_.
+
+    Running the plugin directly with ``--extcap-interfaces`` prints
+    diagnostics to standard error that the Wireshark GUI does not surface.
+
+A port is listed as ``portN`` instead of a device name
+    The port was reported by the application, but its details could not be
+    read, usually because the application stopped between listing and naming
+    its ports. A capture started against it will fail; restart the
+    application.
diff --git a/usertools/dpdk-wireshark-extcap.py b/usertools/dpdk-wireshark-extcap.py
new file mode 100755
index 0000000000..6c6b792a77
--- /dev/null
+++ b/usertools/dpdk-wireshark-extcap.py
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause OR GPL-2.0-or-later
+# Copyright(c) 2026 Stephen Hemminger
+
+"""
+Wireshark extcap plugin for live capture from DPDK ethdev ports.
+
+Capture path: Wireshark creates a FIFO and hands this plugin its path. The
+plugin opens the FIFO -- which rendezvous with Wireshark's read end -- and then
+passes the FIFO *path* to the DPDK primary process over the telemetry socket as
+the 'out=' parameter. DPDK opens the FIFO itself and writes pcapng straight into
+it; this plugin never touches packet data.
+
+Stopping: Wireshark stops a capture by closing the FIFO read end and/or sending
+SIGTERM. On either, this plugin asks DPDK to stop (/ethdev/capture/stop,<id>) so
+it can flush the trailing packets and the closing interface statistics block
+while a reader is still attached. If the read end is already gone, DPDK detects
+the same hangup and stops on its own; the explicit stop is then a harmless
+no-op. Note that when stopped from the Wireshark GUI the read-end close usually
+precedes the signal, so the statistics block is delivered reliably only for
+standalone (Ctrl+C) runs and for file output.
+
+Interface values: 'dpdk:<port>' for the default file-prefix ('rte'), and
+'dpdk:<prefix>:<port>' for any other primary. The default prefix is left
+implicit so the common single-instance case stays unadorned.
+"""
+
+import argparse
+import json
+import os
+import select
+import signal
+import socket
+import sys
+
+EXTCAP_VERSION = "0.1"
+TELEMETRY_SOCKET = "dpdk_telemetry.v2"
+CAPTURE_START_CMD = "/ethdev/capture/start"
+CAPTURE_STOP_CMD = "/ethdev/capture/stop"
+ETHDEV_LIST = "/ethdev/list"
+ETHDEV_INFO = "/ethdev/info"
+DEFAULT_SNAPLEN = 262144
+DEFAULT_PREFIX = "rte"  # EAL HUGEFILE_PREFIX_DEFAULT
+DLT_EN10MB = 1
+
+
+# --- DPDK runtime directory / socket discovery ---------------------------
+
+
+def dpdk_dir():
+    """Directory holding the per-file-prefix runtime subdirectories."""
+    override = os.environ.get("DPDK_EXTCAP_PATH")
+    if override:
+        return override
+    if os.geteuid() == 0:
+        base = "/var/run"
+    else:
+        base = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
+    return os.path.join(base, "dpdk")
+
+
+def socket_path(prefix):
+    return os.path.join(dpdk_dir(), prefix, TELEMETRY_SOCKET)
+
+
+def list_prefixes():
+    """Yield (prefix, path) for each DPDK telemetry socket found."""
+    root = dpdk_dir()
+    try:
+        entries = os.listdir(root)
+    except FileNotFoundError:
+        # No DPDK runtime dir -> no DPDK application is running.
+        return
+    except PermissionError:
+        # The runtime dir is mode 0700; a different user can see nothing.
+        sys.stderr.write(
+            f"cannot read {root}: permission denied. The DPDK runtime "
+            "directory is created mode 0700, so capture must run as the same "
+            "user as the DPDK application, or set DPDK_EXTCAP_PATH.\n"
+        )
+        return
+
+    for prefix in sorted(entries):
+        path = os.path.join(root, prefix, TELEMETRY_SOCKET)
+        if os.path.exists(path):
+            yield prefix, path
+
+
+def iface_value(prefix, port):
+    """Interface value for a (prefix, port). The default prefix is left
+    implicit so a single default-prefix instance shows 'dpdk:<port>'; any
+    other primary is spelled out as 'dpdk:<prefix>:<port>'."""
+    if prefix == DEFAULT_PREFIX:
+        return f"dpdk:{port}"
+    return f"dpdk:{prefix}:{port}"
+
+
+# --- Telemetry transport -------------------------------------------------
+
+
+class Telemetry:
+    """Minimal client for the DPDK v2 telemetry socket (SOCK_SEQPACKET)."""
+
+    def __init__(self, path):
+        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
+        self.sock.connect(path)
+        info = json.loads(self.sock.recv(1024).decode())
+        self.max_output_len = info.get("max_output_len", 16384)
+        self.pid = info.get("pid")
+        self.version = info.get("version")
+
+    def command(self, cmd):
+        """Send a command, return the decoded JSON reply, or None if empty."""
+        self.sock.send(cmd.encode())
+        reply = self.sock.recv(self.max_output_len)
+        if not reply:
+            return None
+        return json.loads(reply.decode())
+
+    def close(self):
+        self.sock.close()
+
+
+def port_queue_count(tel, port):
+    """Max of the rx/tx queue counts for a port, 0 if unknown."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return max(info.get("nb_rx_queues", 0), info.get("nb_tx_queues", 0))
+
+
+# --- extcap query operations --------------------------------------------
+
+
+def port_name(tel, port):
+    """Device name for a port via /ethdev/info, or 'port<N>' if unreadable.
+    For a physical port this is the PCI address (0000:c1:00.0); for a vdev it
+    is the vdev name (net_tap0). Reuses the caller's open connection."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return info.get("name") or f"port{port}"
+
+
+def cmd_interfaces():
+    print(f"extcap {{version={EXTCAP_VERSION}}}{{display=DPDK telemetry capture}}")
+    for prefix, path in list_prefixes():
+        try:
+            tel = Telemetry(path)
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+            continue
+        # One connection per prefix for the whole enumeration: list the ports,
+        # then name each over the same socket (each connection costs the
+        # primary a handler thread).
+        try:
+            ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+            for port in ports:
+                name = port_name(tel, port)
+                # Name the prefix in the label only when it is not the default.
+                if prefix == DEFAULT_PREFIX:
+                    display = f"DPDK {name}"
+                else:
+                    display = f"DPDK {name}@{prefix}"
+                print(
+                    f"interface {{value={iface_value(prefix, port)}}}"
+                    f"{{display={display}}}"
+                )
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+        finally:
+            tel.close()
+
+
+def cmd_dlts(_iface):
+    print(f"dlt {{number={DLT_EN10MB}}}{{name=EN10MB}}{{display=Ethernet}}")
+
+
+def cmd_config(iface):
+    print(
+        f"arg {{number=0}}{{call=--snaplen}}{{display=Snapshot length}}"
+        f"{{tooltip=Bytes captured per packet (0 = whole packet)}}"
+        f"{{type=integer}}{{range=0,{DEFAULT_SNAPLEN}}}"
+        f"{{default={DEFAULT_SNAPLEN}}}{{group=Capture}}"
+    )
+
+    # Bound the queue dropdown to the port's actual queue count. If the primary
+    # is unreachable, just omit the selector -- capture still defaults to all.
+    nqueues = 0
+    if iface:
+        prefix, port = parse_iface(iface)
+        try:
+            tel = Telemetry(socket_path(prefix))
+        except OSError:
+            tel = None
+        if tel is not None:
+            try:
+                nqueues = port_queue_count(tel, port)
+            finally:
+                tel.close()
+
+    if nqueues > 0:
+        print(
+            f"arg {{number=1}}{{call=--queue}}{{display=Queue}}"
+            f"{{tooltip=Capture a single hardware queue}}"
+            f"{{type=selector}}{{group=Capture}}"
+        )
+        print("value {arg=1}{value=-1}{display=All queues}{default=true}")
+        for q in range(nqueues):
+            print(f"value {{arg=1}}{{value={q}}}{{display=Queue {q}}}")
+
+
+# --- capture -------------------------------------------------------------
+
+
+def parse_iface(iface):
+    """Inverse of iface_value: accept 'dpdk:<port>' (default prefix) or
+    'dpdk:<prefix>:<port>'."""
+    parts = iface.split(":")
+    if parts[0] != "dpdk":
+        raise SystemExit(f"unsupported interface scheme in '{iface}'")
+    if len(parts) == 2:
+        prefix, port = DEFAULT_PREFIX, parts[1]
+    elif len(parts) == 3:
+        prefix, port = parts[1], parts[2]
+    else:
+        raise SystemExit(f"malformed interface '{iface}'")
+    try:
+        port = int(port)
+    except ValueError:
+        raise SystemExit(f"malformed interface '{iface}'")
+    return prefix, port
+
+
+def wait_for_stop(fifo_fd):
+    """Block until Wireshark stops us: either it closes the FIFO read end
+    (POLLERR on our write fd) or it sends SIGINT/SIGTERM. Watching the fd as
+    well as the signal matters because the signal may be missed -- Wireshark's
+    reliable stop is closing the pipe."""
+    rd, wr = os.pipe()
+    os.set_blocking(wr, False)
+    signal.set_wakeup_fd(wr)
+    for sig in (signal.SIGINT, signal.SIGTERM):
+        signal.signal(sig, lambda *_: None)
+
+    poller = select.poll()
+    poller.register(fifo_fd, select.POLLERR)
+    poller.register(rd, select.POLLIN)
+    poller.poll()
+
+    signal.set_wakeup_fd(-1)
+    os.close(rd)
+    os.close(wr)
+
+
+def cmd_capture(iface, fifo, snaplen, queue, cfilter):
+    prefix, port = parse_iface(iface)
+    path = socket_path(prefix)
+
+    # Blocking open of the FIFO Wireshark created. This rendezvous guarantees a
+    # reader is attached before we ask DPDK to open the write end (DPDK opens
+    # O_WRONLY|O_NONBLOCK and would fail with ENXIO if no reader were present).
+    # We then hold this fd for the whole session: it keeps the pipe from
+    # EOF-ing in the window before the DPDK capture thread opens its own write
+    # end, and it is our reliable stop signal -- POLLERR here means Wireshark
+    # closed the read end.
+    fifo_fd = os.open(fifo, os.O_WRONLY)
+
+    try:
+        tel = Telemetry(path)
+    except OSError as e:
+        os.close(fifo_fd)
+        raise SystemExit(f"cannot connect to DPDK telemetry at {path}: {e}")
+
+    params = [str(port), f"out={fifo}"]
+    if snaplen is not None:
+        params.append(f"snaplen={snaplen}")
+    if queue is not None and queue >= 0:
+        params.append(f"queue={queue}")
+    if cfilter:
+        params.append(f"filter={cfilter}")
+    cmd = CAPTURE_START_CMD + "," + ",".join(params)
+
+    try:
+        reply = tel.command(cmd)
+    except OSError as e:
+        os.close(fifo_fd)
+        tel.close()
+        raise SystemExit(f"capture start failed: {e}")
+
+    result = (reply or {}).get(CAPTURE_START_CMD) or {}
+    if "error" in result:
+        os.close(fifo_fd)
+        tel.close()
+        raise SystemExit(f"capture start failed: {result['error']}")
+
+    cap_id = result.get("id")
+
+    # Run until Wireshark stops us (signal or read-end close). DPDK now holds
+    # its own write end, so closing ours below won't EOF the reader prematurely.
+    wait_for_stop(fifo_fd)
+
+    # Ask DPDK to stop while we still hold the write end, so if the reader is
+    # still attached it drains the tail and writes the statistics block before
+    # the final EOF. If the read end is already gone DPDK has already stopped
+    # on the same hangup and this is a no-op.
+    if cap_id is not None:
+        try:
+            tel.command(f"{CAPTURE_STOP_CMD},{cap_id}")
+        except OSError:
+            pass
+
+    os.close(fifo_fd)
+    tel.close()
+
+
+# --- entry point ---------------------------------------------------------
+
+
+def main():
+    p = argparse.ArgumentParser(
+        prog="dpdk-wireshark-extcap.py",
+        allow_abbrev=False,
+        description="Wireshark extcap plugin for live packet capture from the "
+        "Ethernet ports of a running DPDK application. Normally "
+        "invoked by Wireshark; see the DPDK Wireshark extcap guide.",
+    )
+    p.add_argument("--version", action="version", version=f"%(prog)s {EXTCAP_VERSION}")
+
+    p.add_argument("--extcap-interfaces", action="store_true")
+    p.add_argument("--extcap-dlts", action="store_true")
+    p.add_argument("--extcap-config", action="store_true")
+    p.add_argument("--capture", action="store_true")
+    p.add_argument("--extcap-interface")
+    p.add_argument("--fifo")
+    p.add_argument("--extcap-capture-filter")
+    p.add_argument("--extcap-version", nargs="?")
+    p.add_argument("--snaplen", type=int)
+    p.add_argument("--queue", type=int)
+    args, _ = p.parse_known_args()
+
+    if args.extcap_interfaces:
+        cmd_interfaces()
+    elif args.extcap_dlts:
+        cmd_dlts(args.extcap_interface)
+    elif args.extcap_config:
+        cmd_config(args.extcap_interface)
+    elif args.capture:
+        if not args.extcap_interface or not args.fifo:
+            raise SystemExit("--capture requires --extcap-interface and --fifo")
+        cmd_capture(
+            args.extcap_interface,
+            args.fifo,
+            args.snaplen,
+            args.queue,
+            args.extcap_capture_filter,
+        )
+    elif args.extcap_capture_filter:
+        # Wireshark validates a capture filter by invoking the extcap with the
+        # filter but without --capture (see doc/extcap_example.py upstream). We
+        # accept it: Wireshark already syntax-checks it with libpcap against our
+        # DLT (EN10MB), and DPDK compiles it again at capture start. Exiting 0
+        # with no output means "accepted"; printing a line would mark it invalid.
+        pass
+    else:
+        raise SystemExit("no extcap operation specified")
+
+
+if __name__ == "__main__":
+    main()
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH 5/5] usertools/dpdk-dumpcap: add script for file capture
  2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
                   ` (3 preceding siblings ...)
  2026-07-24 21:11 ` [PATCH 4/5] usertools/dpdk-wireshark-extcap.py: script for external capture Stephen Hemminger
@ 2026-07-24 21:11 ` Stephen Hemminger
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-24 21:11 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This is a python version using capture library that provides
the same syntax and functionality as wireshark dumpcap and
the existing secondary process version dpdk-dumpcap.

Since it is a python script and uses capture it does not
need secondary process support and should be portable
to other OS environments.

It is designed to be a replacement for dpdk-dumpcap
program which uses pdump.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |   1 +
 doc/guides/rel_notes/release_26_11.rst |   1 +
 usertools/dpdk-dumpcap.py              | 426 +++++++++++++++++++++++++
 3 files changed, 428 insertions(+)
 create mode 100755 usertools/dpdk-dumpcap.py

diff --git a/MAINTAINERS b/MAINTAINERS
index 5ce969d02f..e682823df9 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1722,6 +1722,7 @@ M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
 F: lib/capture/
 F: usertools/dpdk-wireshark-extcap.py
+F: usertools/dpdk-dumpcap.py
 F: doc/guides/tools/wireshark_extcap.rst
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index 9835807fcc..6590899585 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -59,6 +59,7 @@ New Features
 
   * Added ``capture`` library for packet capture via telemetry API.
   * Added python script for wireshark external capture integration.
+  * Added python version of dpdk-dumpcap.
 
 
 Removed Items
diff --git a/usertools/dpdk-dumpcap.py b/usertools/dpdk-dumpcap.py
new file mode 100755
index 0000000000..bdec55964a
--- /dev/null
+++ b/usertools/dpdk-dumpcap.py
@@ -0,0 +1,426 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Stephen Hemminger
+
+"""
+dpdk-dumpcap: capture packets from a running DPDK primary process.
+
+A tcpdump/dumpcap-style front end for the DPDK 'capture' library. Unlike the
+legacy secondary-process dpdk-dumpcap (which used pdump and required the primary
+to initialise the capture framework), this drives the primary directly over its
+telemetry socket: the primary opens the output file itself and writes pcapng
+into it. It works with any primary that links lib/capture; no application
+changes are needed.
+
+Examples:
+    dpdk-dumpcap --list-interfaces
+    dpdk-dumpcap -i 0 -w /tmp/port0.pcapng
+    dpdk-dumpcap -i 0 -c 100 -f 'tcp port 80'
+"""
+
+import argparse
+import json
+import os
+import signal
+import socket
+import sys
+import tempfile
+import time
+
+TELEMETRY_SOCKET = "dpdk_telemetry.v2"
+CAPTURE_START_CMD = "/ethdev/capture/start"
+CAPTURE_STOP_CMD = "/ethdev/capture/stop"
+CAPTURE_STATS_CMD = "/ethdev/capture/stats"
+ETHDEV_LIST = "/ethdev/list"
+ETHDEV_INFO = "/ethdev/info"
+DEFAULT_SNAPLEN = 262144
+COUNT_POLL_INTERVAL = 0.1  # fast stats poll cadence when -c is set
+POLL_TICK = 0.5  # loop wake granularity; bounds Ctrl-C latency when idle
+DEFAULT_STATS_INTERVAL = 0.5  # seconds between liveness updates
+
+
+# --- DPDK runtime directory / socket discovery ---------------------------
+
+
+def dpdk_dir():
+    override = os.environ.get("DPDK_EXTCAP_PATH")
+    if override:
+        return override
+    base = (
+        "/var/run" if os.geteuid() == 0 else os.environ.get("XDG_RUNTIME_DIR", "/tmp")
+    )
+    return os.path.join(base, "dpdk")
+
+
+def socket_path(prefix):
+    return os.path.join(dpdk_dir(), prefix, TELEMETRY_SOCKET)
+
+
+def list_prefixes():
+    """Yield (prefix, path) for each DPDK telemetry socket found."""
+    root = dpdk_dir()
+    try:
+        entries = os.listdir(root)
+    except FileNotFoundError:
+        return
+    except PermissionError:
+        sys.stderr.write(
+            f"cannot read {root}: permission denied. The DPDK runtime directory "
+            "is mode 0700, so capture must run as the same user as the DPDK "
+            "application, or set DPDK_EXTCAP_PATH.\n"
+        )
+        return
+    for prefix in sorted(entries):
+        path = os.path.join(root, prefix, TELEMETRY_SOCKET)
+        if os.path.exists(path):
+            yield prefix, path
+
+
+def resolve_prefix(requested):
+    """Pick the file-prefix to talk to. If one was requested, use it; else use
+    the sole running prefix, or error if there are zero or several."""
+    prefixes = [p for p, _ in list_prefixes()]
+    if requested is not None:
+        if requested not in prefixes:
+            raise SystemExit(f"no DPDK process with file-prefix '{requested}'")
+        return requested
+    if not prefixes:
+        raise SystemExit("no running DPDK process found")
+    if len(prefixes) > 1:
+        raise SystemExit(
+            "multiple DPDK processes running; select one with --file-prefix: "
+            + ", ".join(prefixes)
+        )
+    return prefixes[0]
+
+
+# --- Telemetry transport -------------------------------------------------
+
+
+class Telemetry:
+    """Minimal client for the DPDK v2 telemetry socket (SOCK_SEQPACKET)."""
+
+    def __init__(self, path):
+        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
+        self.sock.connect(path)
+        info = json.loads(self.sock.recv(1024).decode())
+        self.max_output_len = info.get("max_output_len", 16384)
+
+    def command(self, cmd):
+        self.sock.send(cmd.encode())
+        reply = self.sock.recv(self.max_output_len)
+        if not reply:
+            return None
+        return json.loads(reply.decode())
+
+    def close(self):
+        self.sock.close()
+
+
+# --- operations ----------------------------------------------------------
+
+
+def port_name(tel, port):
+    """Device name for a port via /ethdev/info, or 'port<N>' if unreadable.
+    For a physical port this is the PCI address; for a vdev, the vdev name."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return info.get("name") or f"port{port}"
+
+
+def list_interfaces():
+    """List ports as '<port>. <name>', matching Wireshark/C dpdk-dumpcap -D.
+    Port ids are per-primary; if several primaries run, capture disambiguates
+    with --file-prefix (see resolve_prefix)."""
+    any_found = False
+    for _prefix, path in list_prefixes():
+        try:
+            tel = Telemetry(path)
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+            continue
+        try:
+            ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+            for port in ports:
+                any_found = True
+                print(f"{port}. {port_name(tel, port)}")
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+        finally:
+            tel.close()
+    if not any_found:
+        sys.stderr.write("no DPDK interfaces available for capture\n")
+
+
+def resolve_interface(tel, spec):
+    """Port id to capture on the connected primary. spec None selects the first
+    interface (like Wireshark and the C dpdk-dumpcap); otherwise it is a port id
+    or a device name as shown by --list-interfaces."""
+    ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+    if not ports:
+        raise SystemExit("no interfaces on this DPDK process")
+    if spec is None:
+        return ports[0]
+    try:
+        port = int(spec)
+    except ValueError:
+        port = None
+    if port is not None:
+        if port in ports:
+            return port
+        raise SystemExit(f"no port {port} on this DPDK process")
+    for port in ports:
+        if port_name(tel, port) == spec:
+            return port
+    raise SystemExit(f"no interface '{spec}' on this DPDK process")
+
+
+def default_output(name):
+    r"""Auto-named output in the temp dir, matching how current Wireshark
+    dumpcap names its default file: '<tool>_<iface><unique>.pcapng', with a
+    random unique suffix and no timestamp.
+    basename() strips any path-like parts of the device name, as Wireshark does;
+    mkstemp pre-creates the file empty, which is what the capture library wants."""
+    basename = os.path.basename(name)
+    fd, path = tempfile.mkstemp(prefix=f"dpdk-dumpcap_{basename}", suffix=".pcapng")
+    os.close(fd)
+    return path
+
+
+def read_stats(tel, cap_id):
+    """Return the stats dict, or None if the telemetry peer has closed (the
+    primary exited). An empty reply from a SEQPACKET socket is EOF."""
+    reply = tel.command(f"{CAPTURE_STATS_CMD},{cap_id}")
+    if reply is None:
+        return None
+    return reply.get(CAPTURE_STATS_CMD) or {}
+
+
+def print_progress(stats):
+    """Live packet counter, overwritten in place (carriage return, no newline)
+    like Wireshark: the running line reads 'Packets:', and print_summary
+    finalises it as 'Packets captured:' with a trailing newline."""
+    captured = stats.get("accepted", 0) - stats.get("ringfull", 0)
+    sys.stderr.write(f"\rPackets: {captured} ")
+    sys.stderr.flush()
+
+
+def print_summary(stats, name):
+    r"""dumpcap-style end-of-capture summary on stderr. The leading '\r'
+    finalises the in-place live counter (same line, overwritten). Counters map
+    onto the pcapng ISB the primary writes, so this agrees with the file trailer:
+        received (ifrecv) = accepted + filtered + nombuf   -- true total seen
+        dropped  (ifdrop) = nombuf + ringfull
+        captured          = accepted - ringfull            -- written to file
+    The percentage is 'fraction not dropped', off the true total (ifrecv already
+    includes the drops). An empty capture prints 0.0%, as dumpcap does."""
+    accepted = stats.get("accepted", 0)
+    filtered = stats.get("filtered", 0)
+    nombuf = stats.get("nombuf", 0)
+    ringfull = stats.get("ringfull", 0)
+
+    captured = accepted - ringfull
+    received = accepted + filtered + nombuf
+    dropped = nombuf + ringfull
+    pct = 100.0 * (received - dropped) / received if received else 0.0
+
+    print(f"\rPackets captured: {captured}", file=sys.stderr)
+    print(
+        f"Packets received/dropped on interface '{name}': "
+        f"{received}/{dropped} ({pct:.1f}%)",
+        file=sys.stderr,
+    )
+
+
+def capture(args):
+    prefix = resolve_prefix(args.file_prefix)
+    path = socket_path(prefix)
+
+    try:
+        tel = Telemetry(path)
+    except OSError as e:
+        raise SystemExit(f"cannot connect to DPDK telemetry at {path}: {e}")
+
+    port = resolve_interface(tel, args.interface)
+    name = port_name(tel, port)
+
+    output = args.write or default_output(name)
+
+    # The capture library opens the output O_WRONLY|O_NOFOLLOW without O_CREAT
+    # and rejects a non-empty file, so create/truncate it to an empty regular
+    # file here before handing over the path.
+    try:
+        open(output, "wb").close()
+    except OSError as e:
+        tel.close()
+        raise SystemExit(f"cannot create output {output}: {e}")
+
+    params = [str(port), f"out={output}"]
+    if args.snaplen is not None:
+        params.append(f"snaplen={args.snaplen}")
+    if args.queue is not None and args.queue >= 0:
+        params.append(f"queue={args.queue}")
+    if args.filter:
+        params.append(f"filter={args.filter}")
+    cmd = CAPTURE_START_CMD + "," + ",".join(params)
+
+    result = (tel.command(cmd) or {}).get(CAPTURE_START_CMD) or {}
+    if "error" in result:
+        tel.close()
+        raise SystemExit(f"capture start failed: {result['error']}")
+    cap_id = result.get("id")
+    if cap_id is None:
+        tel.close()
+        raise SystemExit("capture start did not return an id")
+
+    qnote = "" if args.queue is None or args.queue < 0 else f" queue {args.queue}"
+    print(f"File: {output}", file=sys.stderr)
+    print(f"Capturing on '{name}'{qnote}", file=sys.stderr)
+
+    # Stop on Ctrl-C/SIGTERM; the handler flips a flag the loop checks.
+    stop = {"now": False}
+    for sig in (signal.SIGINT, signal.SIGTERM):
+        signal.signal(sig, lambda *_: stop.update(now=True))
+
+    interval = args.stats_interval
+    # Wake on a short tick so a signal stops us promptly; poll fast when -c must
+    # bound overshoot, otherwise just often enough to notice the signal. Stats
+    # are read only when -c is set or a heartbeat is due, so an idle capture
+    # barely touches the telemetry socket.
+    tick = COUNT_POLL_INTERVAL if args.count is not None else POLL_TICK
+    next_beat = time.monotonic() + interval if interval > 0 else None
+
+    stats = {}
+    try:
+        while not stop["now"]:
+            time.sleep(tick)
+            now = time.monotonic()
+            due = next_beat is not None and now >= next_beat
+            if args.count is not None or due:
+                # The stats round-trip is itself the liveness check: if the
+                # primary has exited, the telemetry socket errors or returns
+                # EOF, and the capture (and its half-written pcapng) is gone.
+                try:
+                    latest = read_stats(tel, cap_id)
+                except OSError:
+                    latest = None
+                if latest is None:
+                    print(
+                        "\nDPDK telemetry connection lost; capture stopped",
+                        file=sys.stderr,
+                    )
+                    break
+                if "error" in latest:
+                    break  # capture instance no longer present
+                if latest:
+                    stats = latest
+                if args.count is not None and stats.get("accepted", 0) >= args.count:
+                    break
+            if due:
+                print_progress(stats)
+                next_beat = now + interval
+        # snapshot final counters while the instance is still alive, then stop:
+        # after stop the primary tears the instance down and stats disappear.
+        try:
+            latest = read_stats(tel, cap_id)
+            if latest and "error" not in latest:
+                stats = latest
+        except OSError:
+            pass
+    finally:
+        try:
+            tel.command(f"{CAPTURE_STOP_CMD},{cap_id}")
+        except OSError:
+            pass
+
+    print_summary(stats, name)
+    tel.close()
+
+
+# --- entry point ---------------------------------------------------------
+
+
+def main():
+    p = argparse.ArgumentParser(
+        prog="dpdk-dumpcap",
+        description="Capture packets from a running DPDK primary process.",
+    )
+    p.add_argument(
+        "-D",
+        "--list-interfaces",
+        action="store_true",
+        help="list available DPDK interfaces and exit",
+    )
+    p.add_argument(
+        "-i",
+        "--interface",
+        metavar="IFACE",
+        help="port id or device name to capture from "
+        "(default: first interface; see --list-interfaces)",
+    )
+    p.add_argument(
+        "--file-prefix",
+        help="EAL file-prefix of the target primary "
+        "(required only if several are running)",
+    )
+    p.add_argument(
+        "-w", "--write", metavar="FILE", help="output pcapng file (default: auto-named)"
+    )
+    p.add_argument(
+        "-s",
+        "--snapshot-length",
+        dest="snaplen",
+        type=int,
+        metavar="LEN",
+        help="bytes to capture per packet (0 = all)",
+    )
+    p.add_argument(
+        "--queue",
+        type=int,
+        metavar="ID",
+        help="capture a single hardware queue (default: all queues)",
+    )
+    p.add_argument(
+        "-c",
+        "--packet-count",
+        dest="count",
+        type=int,
+        metavar="N",
+        help="stop after roughly N packets",
+    )
+    p.add_argument(
+        "--stats-interval",
+        type=float,
+        metavar="SECS",
+        default=DEFAULT_STATS_INTERVAL,
+        help="seconds between liveness updates "
+        f"(0 to disable; default {DEFAULT_STATS_INTERVAL})",
+    )
+    p.add_argument(
+        "-f",
+        "--filter",
+        dest="filter",
+        metavar="EXPR",
+        help="capture filter in libpcap syntax",
+    )
+    p.add_argument(
+        "filter_args", nargs="*", help="capture filter (if not given with -f)"
+    )
+    args = p.parse_args()
+
+    if args.list_interfaces:
+        list_interfaces()
+        return
+
+    # tcpdump-style trailing filter expression, if -f was not used.
+    if not args.filter and args.filter_args:
+        args.filter = " ".join(args.filter_args)
+
+    capture(args)
+
+
+if __name__ == "__main__":
+    main()
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v2 0/4] Wireshark external capture interface
  2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
                   ` (4 preceding siblings ...)
  2026-07-24 21:11 ` [PATCH 5/5] usertools/dpdk-dumpcap: add script for file capture Stephen Hemminger
@ 2026-07-27 22:42 ` Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 1/4] pcapng: extend interface statistics Stephen Hemminger
                     ` (5 more replies)
  5 siblings, 6 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-27 22:42 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This series adds live packet capture from a running DPDK primary over the
telemetry socket. A new capture library installs Rx/Tx callbacks on an
ethdev port and writes pcapng to a caller-supplied path or FIFO, with an
optional libpcap filter and per-queue selection. No application changes are
needed beyond linking the library.

The pcapng interface-statistics block gains a filter-accepted count,
which changes the rte_pcapng_write_stats() signature.

v2 - review feedback from checkpatch and AI
   - defer dpdk-dumpcap.py until later
   - add more documentation

Stephen Hemminger (4):
  pcapng: extend interface statistics
  capture: infrastructure wireshark packet capture
  test: add test for capture hooks
  usertools: add script for wireshark capture

 MAINTAINERS                            |    4 +
 app/dumpcap/main.c                     |   16 +-
 app/test/meson.build                   |    1 +
 app/test/test_capture.c                |  660 +++++++++++++
 app/test/test_pcapng.c                 |   10 +-
 doc/guides/prog_guide/capture_lib.rst  |  168 ++++
 doc/guides/prog_guide/index.rst        |    1 +
 doc/guides/rel_notes/release_26_11.rst |   14 +
 doc/guides/tools/index.rst             |    1 +
 doc/guides/tools/wireshark_extcap.rst  |  144 +++
 lib/capture/capture.c                  | 1199 ++++++++++++++++++++++++
 lib/capture/capture_impl.h             |   56 ++
 lib/capture/filter.c                   |  110 +++
 lib/capture/meson.build                |   19 +
 lib/meson.build                        |    1 +
 lib/pcapng/rte_pcapng.c                |   50 +-
 lib/pcapng/rte_pcapng.h                |   33 +-
 usertools/dpdk-wireshark-extcap.py     |  416 ++++++++
 usertools/meson.build                  |    1 +
 19 files changed, 2871 insertions(+), 33 deletions(-)
 create mode 100644 app/test/test_capture.c
 create mode 100644 doc/guides/prog_guide/capture_lib.rst
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/capture_impl.h
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build
 create mode 100755 usertools/dpdk-wireshark-extcap.py

-- 
2.53.0


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [PATCH v2 1/4] pcapng: extend interface statistics
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
@ 2026-07-27 22:42   ` Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
                     ` (4 subsequent siblings)
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-27 22:42 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Reshma Pattan

The number of packets filtered was not implemented in original
code but useful for analysis.
Modify the API for writing pcapng statistics.
Use a structure and make it extensible in future.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 app/dumpcap/main.c                     | 16 ++++-----
 app/test/test_pcapng.c                 | 10 +++---
 doc/guides/rel_notes/release_26_11.rst |  9 +++++
 lib/pcapng/rte_pcapng.c                | 50 +++++++++++++++++++-------
 lib/pcapng/rte_pcapng.h                | 33 ++++++++++++-----
 5 files changed, 85 insertions(+), 33 deletions(-)

diff --git a/app/dumpcap/main.c b/app/dumpcap/main.c
index 46a6cb251e..baec68b0b9 100644
--- a/app/dumpcap/main.c
+++ b/app/dumpcap/main.c
@@ -574,8 +574,8 @@ static void
 report_packet_stats(dumpcap_out_t out)
 {
 	struct rte_pdump_stats pdump_stats;
+	struct rte_pcapng_interface_stats isb;
 	struct interface *intf;
-	uint64_t ifrecv, ifdrop;
 	double percent;
 
 	fputc('\n', stderr);
@@ -584,22 +584,22 @@ report_packet_stats(dumpcap_out_t out)
 			continue;
 
 		/* do what Wiretap does */
-		ifrecv = pdump_stats.accepted + pdump_stats.filtered;
-		ifdrop = pdump_stats.nombuf + pdump_stats.ringfull;
+		isb.ifrecv = pdump_stats.accepted + pdump_stats.filtered;
+		isb.ifdrop = pdump_stats.nombuf + pdump_stats.ringfull;
+		isb.filteraccept = pdump_stats.accepted;
 
 		if (use_pcapng)
-			rte_pcapng_write_stats(out.pcapng, intf->port,
-					       ifrecv, ifdrop, NULL);
+			rte_pcapng_write_stats(out.pcapng, intf->port, &isb, sizeof(isb), NULL);
 
-		if (ifrecv == 0)
+		if (isb.ifrecv == 0)
 			percent = 0;
 		else
-			percent = 100. * ifrecv / (ifrecv + ifdrop);
+			percent = 100. * isb.ifrecv / (isb.ifrecv + isb.ifdrop);
 
 		fprintf(stderr,
 			"Packets received/dropped on interface '%s': "
 			"%"PRIu64 "/%" PRIu64 " (%.1f)\n",
-			intf->name, ifrecv, ifdrop, percent);
+			intf->name, isb.ifrecv, isb.ifdrop, percent);
 	}
 }
 
diff --git a/app/test/test_pcapng.c b/app/test/test_pcapng.c
index d14ea84f0d..c62fdc1458 100644
--- a/app/test/test_pcapng.c
+++ b/app/test/test_pcapng.c
@@ -558,8 +558,9 @@ test_write_packets(void)
 		goto fail;
 	}
 
-	/* write a statistics block */
-	ret = rte_pcapng_write_stats(pcapng, port_id, 0, 0, NULL);
+	/* write an initial statistics block */
+	struct rte_pcapng_interface_stats isb = { };
+	ret = rte_pcapng_write_stats(pcapng, port_id, &isb, sizeof(isb), NULL);
 	if (ret <= 0) {
 		printf("Write of statistics failed\n");
 		goto fail;
@@ -569,9 +570,10 @@ test_write_packets(void)
 	if (count < 0)
 		goto fail;
 
-	/* write a statistics block */
+	/* write a final statistics block with comment */
+	isb.ifrecv = count;
 	ret = rte_pcapng_write_stats(pcapng, port_id,
-				     count, 0, "end of test");
+				     &isb, sizeof(isb), "end of test");
 	if (ret <= 0) {
 		printf("Write of statistics failed\n");
 		goto fail;
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index c8cc86295d..cdc3c99f64 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -93,6 +93,11 @@ API Changes
    Also, make sure to start the actual text at the margin.
    =======================================================
 
+* **pcapng: add packet filtering statistic.**
+
+  The API for ``rte_pcapng_write_stats`` was changed to include
+  recording the number of filtered packets.
+
 
 ABI Changes
 -----------
@@ -109,6 +114,10 @@ ABI Changes
    Also, make sure to start the actual text at the margin.
    =======================================================
 
+* **pcapng: add packet filtering statistic.**
+
+  The ABI for ``rte_pcapng_write_stats`` now takes pointer to statistics.
+
 
 Known Issues
 ------------
diff --git a/lib/pcapng/rte_pcapng.c b/lib/pcapng/rte_pcapng.c
index b5d1026891..fed1581772 100644
--- a/lib/pcapng/rte_pcapng.c
+++ b/lib/pcapng/rte_pcapng.c
@@ -277,6 +277,9 @@ rte_pcapng_add_interface(rte_pcapng_t *self, uint16_t port, uint16_t link_type,
 	uint64_t speed = 0;
 	int ret;
 
+	if (self == NULL)
+		return -EINVAL;
+
 	ret = rte_eth_dev_info_get(port, &dev_info);
 	if (ret < 0)
 		return -1;  /* should be ret */
@@ -394,29 +397,43 @@ rte_pcapng_add_interface(rte_pcapng_t *self, uint16_t port, uint16_t link_type,
 RTE_EXPORT_SYMBOL(rte_pcapng_write_stats)
 ssize_t
 rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
-		       uint64_t ifrecv, uint64_t ifdrop,
-		       const char *comment)
+		       const struct rte_pcapng_interface_stats *stats,
+		       size_t stats_sz, const char *comment)
 {
+	struct rte_pcapng_interface_stats isb;
 	struct pcapng_statistics *hdr;
 	struct pcapng_option *opt;
-	uint64_t start_time = self->clock.ns_base;
-	uint64_t sample_time;
+	uint64_t start_time, sample_time;
 	uint32_t optlen, len;
 	uint32_t *buf;
 	ssize_t ret;
 
+	if (self == NULL)
+		return -EINVAL;
+
 	RTE_ETH_VALID_PORTID_OR_ERR_RET(port_id, -EINVAL);
 
+	if (stats == NULL || stats_sz == 0 || stats_sz > sizeof(*stats))
+		return -EINVAL;
+
 	if (comment && strlen(comment) > PCAPNG_STR_MAX)
 		return -EINVAL;
 
+	/* Future proof for more/less stats - all UINT64_MAX */
+	memset(&isb, 0xff, sizeof(isb));
+	memcpy(&isb, stats, stats_sz);
+
 	optlen = 0;
 
-	if (ifrecv != UINT64_MAX)
-		optlen += pcapng_optlen(sizeof(ifrecv));
-	if (ifdrop != UINT64_MAX)
-		optlen += pcapng_optlen(sizeof(ifdrop));
+	/* compute how many stats will be added. */
+	if (isb.ifrecv != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.ifrecv));
+	if (isb.ifdrop != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.ifdrop));
+	if (isb.filteraccept != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.filteraccept));
 
+	start_time = self->clock.ns_base;
 	if (start_time != 0)
 		optlen += pcapng_optlen(sizeof(start_time));
 
@@ -439,12 +456,16 @@ rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
 	if (start_time != 0)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_STARTTIME,
 					 &start_time, sizeof(start_time));
-	if (ifrecv != UINT64_MAX)
+	if (isb.ifrecv != UINT64_MAX)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_IFRECV,
-				&ifrecv, sizeof(ifrecv));
-	if (ifdrop != UINT64_MAX)
+					&isb.ifrecv, sizeof(uint64_t));
+	if (isb.ifdrop != UINT64_MAX)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_IFDROP,
-				&ifdrop, sizeof(ifdrop));
+					&isb.ifdrop, sizeof(uint64_t));
+	if (isb.filteraccept != UINT64_MAX)
+		opt = pcapng_add_option(opt, PCAPNG_ISB_FILTERACCEPT,
+					&isb.filteraccept, sizeof(uint64_t));
+
 	if (optlen != 0)
 		opt = pcapng_add_option(opt, PCAPNG_OPT_END, NULL, 0);
 
@@ -717,6 +738,11 @@ rte_pcapng_write_packets(rte_pcapng_t *self,
 	unsigned int i, cnt = 0;
 	ssize_t ret, total = 0;
 
+	if (self == NULL) {
+		rte_errno = EINVAL;
+		return -1;
+	}
+
 	for (i = 0; i < nb_pkts; i++) {
 		struct rte_mbuf *m = pkts[i];
 		struct pcapng_enhance_packet_block *epb;
diff --git a/lib/pcapng/rte_pcapng.h b/lib/pcapng/rte_pcapng.h
index d8d328f710..495b6d3204 100644
--- a/lib/pcapng/rte_pcapng.h
+++ b/lib/pcapng/rte_pcapng.h
@@ -178,21 +178,36 @@ ssize_t
 rte_pcapng_write_packets(rte_pcapng_t *self,
 			 struct rte_mbuf *pkts[], uint16_t nb_pkts);
 
+/**
+ * A structure to store interface statistics in pcapng
+ * Interface statistics block.
+ * If the statistic is unavailable or unknown, use UINT64_MAX.
+ *
+ * Subset of definitions come from IETF pcapng standard.
+ * The osdrop and usrdeliv statistics are not included because
+ * DPDK does not use OS and does not deliver to application over sockets.
+ */
+struct rte_pcapng_interface_stats {
+	uint64_t ifrecv;	/**< Packets received on interface during capture. */
+	uint64_t ifdrop;	/**< Packets dropped by interface due to lack of resources. */
+	uint64_t filteraccept;	/**< Packets accepted by filter. */
+};
+
 /**
  * Write an Interface statistics block.
- * For statistics, use 0 if don't know or care to report it.
  * Should be called before closing capture to report results.
  *
  * @param self
  *  The handle to the packet capture file
  * @param port
  *  The Ethernet port to report stats on.
- * @param ifrecv
- *  The number of packets received by capture.
- *  Optional: use UINT64_MAX if not known.
- * @param ifdrop
- *  The number of packets missed by the capture process.
- *  Optional: use UINT64_MAX if not known.
+ * @param stats
+ *  The statistics to write.
+ * @param stats_sz
+ *  The sizeof statistics structure.
+ *  Must be greater than 0 and not more than sizeof(*stats).
+ *  This allows for future expansion of interface statistics but
+ *  with ABI compatibility.
  * @param comment
  *  Optional comment to add to statistics.
  * @return
@@ -203,8 +218,8 @@ rte_pcapng_write_packets(rte_pcapng_t *self,
  */
 ssize_t
 rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port,
-		       uint64_t ifrecv, uint64_t ifdrop,
-		       const char *comment);
+		       const struct rte_pcapng_interface_stats *stats,
+		       size_t stats_sz, const char *comment);
 
 #ifdef __cplusplus
 }
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v2 2/4] capture: infrastructure wireshark packet capture
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 1/4] pcapng: extend interface statistics Stephen Hemminger
@ 2026-07-27 22:42   ` Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 3/4] test: add test for capture hooks Stephen Hemminger
                     ` (3 subsequent siblings)
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-27 22:42 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Thomas Monjalon, Reshma Pattan,
	Anatoly Burakov

This provides a telemetry extension to provide packet capture.
It is intended to be used with a front end script to provide
external packet capture for wireshark.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |    1 +
 doc/guides/prog_guide/capture_lib.rst  |  168 ++++
 doc/guides/prog_guide/index.rst        |    1 +
 doc/guides/rel_notes/release_26_11.rst |    4 +
 lib/capture/capture.c                  | 1199 ++++++++++++++++++++++++
 lib/capture/capture_impl.h             |   56 ++
 lib/capture/filter.c                   |  110 +++
 lib/capture/meson.build                |   19 +
 lib/meson.build                        |    1 +
 9 files changed, 1559 insertions(+)
 create mode 100644 doc/guides/prog_guide/capture_lib.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/capture_impl.h
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build

diff --git a/MAINTAINERS b/MAINTAINERS
index e99a65d197..fcd350ad94 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1720,6 +1720,7 @@ F: doc/guides/sample_app_ug/qos_scheduler.rst
 Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
+F: lib/capture/
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/doc/guides/prog_guide/capture_lib.rst b/doc/guides/prog_guide/capture_lib.rst
new file mode 100644
index 0000000000..a956ab9f20
--- /dev/null
+++ b/doc/guides/prog_guide/capture_lib.rst
@@ -0,0 +1,168 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Packet Capture Library
+======================
+
+The capture library records packets going in and out of the Ethernet ports of a
+running DPDK primary process, and writes them in pcapng format to a file or a
+FIFO.
+
+Unlike the older pdump library, no secondary process is involved and the
+application needs no source changes and no additional EAL arguments. The
+library registers itself at load time and is driven entirely over the
+:doc:`telemetry <telemetry_lib>` socket, so capture can be started on an
+already running application, including a sealed vendor appliance.
+
+Any telemetry client can drive it; see :doc:`../howto/telemetry` for the
+``dpdk-telemetry.py`` script used in the examples below.
+
+Operation
+---------
+
+When a capture is started the library installs Rx and Tx callbacks on the
+requested queues of the port. Each callback copies matching packets into a
+new mbuf already formatted as a pcapng Enhanced Packet Block
+(``rte_pcapng_copy``) and enqueues it on a ring. A dedicated capture thread
+drains that ring and writes the blocks to the output.
+The receive and transmit callbacks do not block, but the capture
+thread may block. The capture file is set into blocking mode so that if an
+external reader cannot keep up, the capture thread will block writing to FIFO.
+
+The callbacks copy the packet; the original mbuf is not modified and is
+returned to the datapath unchanged. Packets are dropped, and counted,
+if an mbuf cannot be allocated or if the ring is full.
+
+Several captures can be active at once, including on the same port and queue.
+Each has its own id, filter, ring, output and callbacks, and stopping one does
+not disturb the others. Each capture runs its own filter
+and copies the packets that match.
+
+Requirements
+------------
+
+* Capture can only be started in a primary process.
+
+* Filtering requires that DPDK was built with ``libpcap`` available. Without
+  it, a capture that specifies a filter will fail to start.
+
+* The library can be excluded from a build with
+  ``-Ddisable_libs=capture``.
+
+Telemetry commands
+------------------
+
+.. csv-table:: Capture telemetry commands
+   :header: "Command", "Parameters"
+   :widths: 30, 50
+
+   "``/ethdev/capture/start``", "port id and options, see below"
+   "``/ethdev/capture/stop``", "capture id"
+   "``/ethdev/capture/list``", "none"
+   "``/ethdev/capture/stats``", "capture id"
+
+``/ethdev/capture/start`` takes the port id as its first parameter, followed by
+``name=value`` options separated by commas:
+
+``out=<path>``
+   Required. Path of the output, which must already exist and be either a FIFO
+   or an empty regular file.
+
+``snaplen=<n>``
+   Number of bytes to capture from each packet. Default is 262144;
+   ``snaplen=0`` means capture the whole packet.
+
+``queue=<n>``
+   Capture only this hardware queue. Default is all queues of the port.
+
+``filter=<expression>``
+   A libpcap filter expression, compiled to eBPF and run over each packet.
+
+Because the parameter string is split on commas, neither the output path nor
+the filter expression may contain one.
+
+On success the reply contains the capture ``id``, which is used to stop the
+capture and to query its statistics. On failure the reply contains an
+``error`` string. The whole parameter string is limited to 1024 bytes.
+
+.. note::
+
+   The telemetry commands for capture are experimental
+   and may change without warning in future releases.
+
+Output
+------
+
+The output file or FIFO must exist otherwise the capture fails to start.
+If it is a regular file, it must be empty. A pcapng file begins with a
+section header and an interface description block,
+which the library writes before the first packet;
+therefore it is not valid to append to an existing file.
+
+If the output is a FIFO the reader must have already opened it.
+This is to prevent capture thread blocking waiting for reader.
+
+A capture runs until ``/ethdev/capture/stop`` is called,
+or an error is detected such as when the reader of a FIFO goes away.
+
+Statistics
+----------
+
+``/ethdev/capture/stats`` reports the configuration of a capture and the
+following counters, summed over all queues:
+
+``accepted``
+   Packets copied for capture.
+
+``filtered``
+   Packets rejected by the filter.
+
+``nombuf``
+   Packets missed because no mbuf was available for the copy.
+
+``ringfull``
+   Packets missed because the capture thread was not keeping up.
+
+The same values are mapped onto the Interface Statistics Block written at the
+end of the capture: ``ifrecv`` is the number of packets seen, ``filteraccept``
+the subset that passed the filter, and ``ifdrop`` the packets that were lost.
+
+Example
+-------
+
+Normally, the capture API is intended to be used by the Wireshark
+external capture script, but it can be tested using ``dpdk-telemetry.py``
+against a running application.
+
+An example, capturing TCP traffic on port 0 into a file::
+
+   --> /ethdev/capture/start,0,out=/tmp/port0.pcapng,snaplen=128,filter=tcp
+   {"/ethdev/capture/start": {"id": 0, "status": "running"}}
+
+   --> /ethdev/capture/stats,0
+   {"/ethdev/capture/stats": {"port_id": 0, "filter": "tcp", "running": 1, \
+    "snaplen": 128, "rx_queues": 4, "tx_queues": 4, "accepted": 1200, \
+    "filtered": 87, "nombuf": 0, "ringfull": 0}}
+
+   --> /ethdev/capture/stop,0
+   {"/ethdev/capture/stop": {"status": "stopped"}}
+
+Using Wireshark
+---------------
+
+The ``dpdk-wireshark-extcap.py`` script presents the ports of a running DPDK
+application as Wireshark capture interfaces, using these telemetry commands
+with a FIFO created by Wireshark as the output.
+See :doc:`../tools/wireshark_extcap`.
+
+Limitations
+-----------
+
+* Only the primary process can capture; packets handled by a secondary process
+  are not seen.
+
+* Each capture covers a single port. Capturing several ports means starting
+  several captures.
+
+* Captured packets are those seen by the ethdev layer, so packets dropped by
+  the hardware or the driver do not appear.
diff --git a/doc/guides/prog_guide/index.rst b/doc/guides/prog_guide/index.rst
index b9c0d59728..7e869db132 100644
--- a/doc/guides/prog_guide/index.rst
+++ b/doc/guides/prog_guide/index.rst
@@ -129,6 +129,7 @@ Utility Libraries
     log_lib
     metrics_lib
     telemetry_lib
+    capture_lib
     pdump_lib
     pcapng_lib
     bpf_lib
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index cdc3c99f64..00acc02ac3 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -55,6 +55,10 @@ New Features
      Also, make sure to start the actual text at the margin.
      =======================================================
 
+* **Added wireshark capture support.**
+
+  * Added ``capture`` library for packet capture via telemetry API.
+
 
 Removed Items
 -------------
diff --git a/lib/capture/capture.c b/lib/capture/capture.c
new file mode 100644
index 0000000000..5056605bb5
--- /dev/null
+++ b/lib/capture/capture.c
@@ -0,0 +1,1199 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/queue.h>
+#include <sys/stat.h>
+#include <sys/utsname.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <rte_branch_prediction.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_debug.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_memory.h>
+#include <rte_mempool.h>
+#include <rte_mbuf.h>
+#include <rte_pcapng.h>
+#include <rte_pause.h>
+#include <rte_ring.h>
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+#include <rte_string_fns.h>
+#include <rte_telemetry.h>
+#include <rte_thread.h>
+#include <rte_version.h>
+
+#include "capture_impl.h"
+
+#define CAPTURE_EXTCAP_PREFIX	"dpdk"
+#define CAP_CMD_MAX		1024
+#define DEFAULT_SNAPLEN		262144u	/* from tcpdump et.al. */
+#define CAPTURE_BURST_SIZE	32u
+#define MBUF_POOL_CACHE_SIZE	32
+#define CAPTURE_RING_SIZE	256
+#define CAPTURE_POOL_SIZE	1024
+#define SLEEP_THRESHOLD		100
+#define SLEEP_US		100
+
+#define ALL_QUEUES		UINT16_MAX
+
+RTE_LOG_REGISTER_DEFAULT(rte_capture_logtype, NOTICE);
+
+/*
+ * List of active captures.
+ *
+ * This is a control-plane only structure: it is created, walked and torn down
+ * from the telemetry handler thread and from the per-capture drain threads,
+ * never from the dataplane. A plain spinlock is therefore enough; the EAL
+ * shared tailq (rte_tailq) is not used because captures are not visible to
+ * secondary processes in this design.
+ */
+TAILQ_HEAD(capture_list, capture);
+static struct capture_list capture_list = TAILQ_HEAD_INITIALIZER(capture_list);
+static rte_spinlock_t capture_lock = RTE_SPINLOCK_INITIALIZER;
+
+/* Parameter values: only used on stack inside parsing */
+struct capture_config {
+	uint16_t port_id;
+	uint16_t queue;
+	uint32_t snaplen;
+	const char *filter_str;
+	const char *output;
+};
+
+/* Per port/queue/direction counters. */
+struct capture_stats {
+	uint64_t accepted;	/**< Number of packets accepted by filter. */
+	uint64_t filtered;	/**< Number of packets rejected by filter. */
+	uint64_t nombuf;	/**< Number of mbuf allocation failures. */
+	uint64_t ringfull;	/**< Number of missed packets due to ring full. */
+};
+
+/* Aggregate per-queue counters of a capture instance. */
+struct capture_total {
+	uint64_t accepted;
+	uint64_t filtered;
+	uint64_t nombuf;
+	uint64_t ringfull;
+};
+
+/*
+ * Data used by callback, one per capture per port/queue/direction.
+ *
+ * These are allocated on demand and never freed, following the same reasoning
+ * as lib/bpf/bpf_pkt.c: rte_eth_remove_{rx,tx}_callback() only unlinks the
+ * callback, so a datapath thread that loaded the callback list just before the
+ * removal still enters capture_rx()/capture_tx() afterwards. Keeping this block
+ * alive for the life of the process gives that thread something valid to
+ * synchronize on. The struct capture it points at is freed at teardown, and
+ * that is what the handshake below protects.
+ *
+ * A detached block is recycled, but only by a later capture on the same
+ * port/queue/direction. That bounds the pool at the peak number of concurrent
+ * captures of a queue, and means a datapath thread arriving late can only ever
+ * be handed a capture that covers the packets it already holds.
+ */
+struct __rte_cache_aligned capture_rxtx_cb {
+	/* used by both data and control path */
+	RTE_ATOMIC(uint32_t) use_count;
+	RTE_ATOMIC(struct capture *) cap;	/* owner, NULL when idle */
+	struct capture_stats stats;
+
+	/* used by control path only */
+	TAILQ_ENTRY(capture_rxtx_cb) next;	/* links into capture_cb_freelist */
+	const struct rte_eth_rxtx_callback *cb;
+	const struct rte_eth_rxtx_callback *stale;
+	uint16_t port_id;
+	uint16_t queue;
+	bool is_rx;
+};
+
+/*
+ * Idle callback blocks, guarded by capture_lock.
+ *
+ * A block is on this list exactly when no capture owns it; an owned one is
+ * reachable only from its owner's cbs[] array. Blocks are moved between the
+ * two, never freed.
+ */
+TAILQ_HEAD(capture_cb_list, capture_rxtx_cb);
+static struct capture_cb_list capture_cb_freelist =
+	TAILQ_HEAD_INITIALIZER(capture_cb_freelist);
+
+/*
+ * Per-capture instance state.
+ */
+struct capture {
+	RTE_ATOMIC(bool) running;
+	struct rte_capture_filter *filter;
+	struct rte_ring *ring;		/* ring from dataplane to capture thread */
+	struct rte_mempool *mp;		/* mempool for capture mbufs */
+
+	uint32_t snaplen;		/* amount of data to copy */
+	uint16_t queue;
+	uint16_t tx_queues;
+	uint16_t rx_queues;
+	uint16_t port_id;
+	int socket_id;
+
+	uint32_t idx;
+	char *output;			/* filename of out */
+
+	TAILQ_ENTRY(capture) next;	/* links into capture_list */
+
+	/* counters of queues already detached; control path only */
+	struct capture_total total;
+
+	/* callback blocks attached by this capture; control path only */
+	uint16_t nb_cbs;
+	struct capture_rxtx_cb *cbs[];
+};
+
+/*
+ * Detach a callback block and wait for the datapath to let go of it.
+ *
+ * Clearing cap and then loading use_count is the mirror image of what
+ * capture_cb_hold() does (bump use_count, then load cap). With a sequentially
+ * consistent fence between the store and the load on both sides, at least one
+ * of the two observes the other: either this thread sees an odd use_count and
+ * waits for the burst in progress to finish, or that burst sees a NULL cap and
+ * returns without ever dereferencing it. Neither can slip through, so once
+ * this returns the capture is safe to free.
+ */
+static void
+capture_cb_detach(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_store_explicit(&cbs->cap, NULL, rte_memory_order_relaxed);
+
+	/* the store of cap must be visible before the use_count load below */
+	rte_atomic_thread_fence(rte_memory_order_seq_cst);
+
+	/* wait until use_count is even (not in use) */
+	RTE_WAIT_UNTIL_MASKED(&cbs->use_count, 1, ==, 0, rte_memory_order_acquire);
+}
+
+/* Mark datapath iteration in progress: count becomes odd. */
+static inline __rte_hot void
+capture_cb_hold(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_relaxed);
+
+	/* the store of use_count must be visible before the cap load */
+	rte_atomic_thread_fence(rte_memory_order_seq_cst);
+}
+
+/* Iteration finished: count becomes even again. */
+static inline __rte_hot void
+capture_cb_release(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_release);
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static inline __rte_hot void
+capture_copy_burst(uint16_t port_id, uint16_t queue_id,
+		   enum rte_pcapng_direction direction,
+		   struct rte_mbuf **pkts, unsigned int nb_pkts,
+		   const struct capture *cap,
+		   struct capture_stats *stats)
+{
+	unsigned int i, ring_enq, d_pkts = 0;
+	struct rte_mbuf *dup_bufs[CAPTURE_BURST_SIZE]; /* duplicated packets */
+	struct rte_ring *ring = cap->ring;
+	struct rte_mempool *mp = cap->mp;
+	uint32_t snaplen = cap->snaplen;
+	struct rte_mbuf *p;
+
+	RTE_ASSERT(nb_pkts <= CAPTURE_BURST_SIZE);
+
+	for (i = 0; i < nb_pkts; i++) {
+		/*
+		 * This uses same BPF return value convention as socket filter
+		 * and pcap_offline_filter. If program returns zero then packet
+		 * doesn't match the filter (will be ignored).
+		 */
+		if (cap->filter) {
+			if (__rte_capture_filter(cap->filter, pkts[i]) == 0) {
+				stats->filtered++;
+				continue;
+			}
+		}
+
+		p = rte_pcapng_copy(port_id, queue_id, pkts[i], mp, snaplen, direction, NULL);
+		if (unlikely(p == NULL))
+			stats->nombuf++;
+		else
+			dup_bufs[d_pkts++] = p;
+	}
+
+	if (d_pkts == 0)
+		return;
+
+	stats->accepted += d_pkts;
+
+	ring_enq = rte_ring_enqueue_burst(ring, (void *)&dup_bufs[0], d_pkts, NULL);
+	if (unlikely(ring_enq < d_pkts)) {
+		unsigned int drops = d_pkts - ring_enq;
+
+		stats->ringfull += drops;
+		rte_pktmbuf_free_bulk(&dup_bufs[ring_enq], drops);
+	}
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static __rte_hot inline void
+capture_copy(uint16_t port_id, uint16_t queue_id,
+	     enum rte_pcapng_direction direction,
+	     struct rte_mbuf **pkts, uint16_t nb_pkts,
+	     const struct capture *cap,
+	     struct capture_stats *stats)
+{
+	unsigned int offs = 0;
+
+	do {
+		unsigned int n = RTE_MIN(nb_pkts - offs, CAPTURE_BURST_SIZE);
+
+		capture_copy_burst(port_id, queue_id, direction, &pkts[offs], n, cap, stats);
+		offs += n;
+	} while (offs < nb_pkts);
+}
+
+static __rte_hot uint16_t
+capture_rx(uint16_t port, uint16_t queue,
+	struct rte_mbuf **pkts, uint16_t nb_pkts,
+	uint16_t max_pkts __rte_unused, void *user_params)
+{
+	struct capture_rxtx_cb *cbs = user_params;
+	const struct capture *cap;
+
+	capture_cb_hold(cbs);
+
+	/*
+	 * A NULL owner means the capture was torn down while this burst was on
+	 * its way in. See capture_cb_detach() for why this load cannot miss it.
+	 * Acquire pairs with the release store in capture_attach(), so a block
+	 * recycled by a new capture is fully prepared before it is used here.
+	 */
+	cap = rte_atomic_load_explicit(&cbs->cap, rte_memory_order_acquire);
+	if (likely(cap != NULL))
+		capture_copy(port, queue, RTE_PCAPNG_DIRECTION_IN,
+			     pkts, nb_pkts, cap, &cbs->stats);
+
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+static __rte_hot uint16_t
+capture_tx(uint16_t port, uint16_t queue,
+	   struct rte_mbuf **pkts, uint16_t nb_pkts, void *user_params)
+{
+	struct capture_rxtx_cb *cbs = user_params;
+	const struct capture *cap;
+
+	capture_cb_hold(cbs);
+
+	cap = rte_atomic_load_explicit(&cbs->cap, rte_memory_order_acquire);
+	if (likely(cap != NULL))
+		capture_copy(port, queue, RTE_PCAPNG_DIRECTION_OUT,
+			     pkts, nb_pkts, cap, &cbs->stats);
+
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+
+/*
+ * Take an idle callback block for a port/queue/direction off the free list,
+ * allocating another when every block for that queue is already in use.
+ *
+ * Several captures may cover the same queue, each with its own filter and its
+ * own ethdev callback, so this hands out one block per capture.
+ */
+static struct capture_rxtx_cb *
+capture_cb_get(struct capture *cap, uint16_t queue, bool is_rx)
+{
+	struct capture_rxtx_cb *cbs;
+
+	rte_spinlock_lock(&capture_lock);
+
+	TAILQ_FOREACH(cbs, &capture_cb_freelist, next) {
+		if (cbs->port_id == cap->port_id && cbs->queue == queue &&
+		    cbs->is_rx == is_rx) {
+			TAILQ_REMOVE(&capture_cb_freelist, cbs, next);
+			break;
+		}
+	}
+
+	rte_spinlock_unlock(&capture_lock);
+
+	if (cbs == NULL) {
+		cbs = rte_zmalloc_socket("capture_cb", sizeof(*cbs),
+					 RTE_CACHE_LINE_SIZE, cap->socket_id);
+		if (cbs == NULL)
+			return NULL;
+		cbs->port_id = cap->port_id;
+		cbs->queue = queue;
+		cbs->is_rx = is_rx;
+	}
+
+	return cbs;
+}
+
+/* Hand a detached block back for reuse. */
+static void
+capture_cb_put(struct capture_rxtx_cb *cbs)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_cb_freelist, cbs, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+/* Install one callback, on a block that is now ours. */
+static int
+capture_attach(struct capture *cap, uint16_t queue, bool is_rx)
+{
+	struct capture_rxtx_cb *cbs;
+
+	cbs = capture_cb_get(cap, queue, is_rx);
+	if (cbs == NULL) {
+		CAPTURE_LOG(ERR, "No callback block for %u:%u %s",
+			    cap->port_id, queue, is_rx ? "rx" : "tx");
+		return -1;
+	}
+
+	/* the block may have been used by an earlier capture of this queue */
+	memset(&cbs->stats, 0, sizeof(cbs->stats));
+
+	/*
+	 * Release the callback object left over by that earlier capture. It
+	 * cannot be freed at removal time because ethdev reads cb->next after
+	 * our callback returns, so there is no point at which the burst loop is
+	 * known to be done with it. Deferring it to the next attach puts a
+	 * whole capture session in between, which is ample.
+	 */
+	rte_free((void *)(uintptr_t)cbs->stale);
+	cbs->stale = NULL;
+
+	/* publish before the callback can be reached by the datapath */
+	rte_atomic_store_explicit(&cbs->cap, cap, rte_memory_order_release);
+
+	if (is_rx)
+		cbs->cb = rte_eth_add_rx_callback(cap->port_id, queue, capture_rx, cbs);
+	else
+		cbs->cb = rte_eth_add_tx_callback(cap->port_id, queue, capture_tx, cbs);
+
+	if (cbs->cb == NULL) {
+		rte_atomic_store_explicit(&cbs->cap, NULL, rte_memory_order_relaxed);
+		capture_cb_put(cbs);
+		CAPTURE_LOG(ERR, "Register %s callback for %u:%u failed",
+			    is_rx ? "rx" : "tx", cap->port_id, queue);
+		return -1;
+	}
+
+	cap->cbs[cap->nb_cbs++] = cbs;
+	return 0;
+}
+
+/* Install callbacks */
+static int
+capture_add_callbacks(struct capture *cap)
+{
+	for (uint16_t q = 0; q < cap->tx_queues; q++) {
+		if (cap->queue != ALL_QUEUES && cap->queue != q)
+			continue;
+
+		if (capture_attach(cap, q, false) < 0)
+			return -1;
+	}
+
+	for (uint16_t q = 0; q < cap->rx_queues; q++) {
+		if (cap->queue != ALL_QUEUES && cap->queue != q)
+			continue;
+
+		if (capture_attach(cap, q, true) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+static void
+capture_sum_one(struct capture_total *t, const struct capture_stats *s)
+{
+	t->accepted += s->accepted;
+	t->filtered += s->filtered;
+	t->nombuf   += s->nombuf;
+	t->ringfull += s->ringfull;
+}
+
+/*
+ * Sum the counters of the queues still attached plus those already detached.
+ * Caller holds capture_lock.
+ */
+static void
+capture_sum_stats(const struct capture *cap, struct capture_total *t)
+{
+	*t = cap->total;
+
+	for (uint16_t i = 0; i < cap->nb_cbs; i++)
+		capture_sum_one(t, &cap->cbs[i]->stats);
+}
+
+/*
+ * Cleanup call backs.
+ *
+ * Once this returns no datapath thread can reach the capture any more, so the
+ * caller is free to release it. The callback blocks themselves stay behind for
+ * the next capture on the same queue.
+ */
+static void
+capture_remove_callbacks(struct capture *cap)
+{
+	struct capture_total total;
+
+	for (uint16_t i = 0; i < cap->nb_cbs; i++) {
+		struct capture_rxtx_cb *cbs = cap->cbs[i];
+
+		if (cbs->is_rx)
+			rte_eth_remove_rx_callback(cap->port_id, cbs->queue, cbs->cb);
+		else
+			rte_eth_remove_tx_callback(cap->port_id, cbs->queue, cbs->cb);
+
+		/* freed by the next capture that attaches to this queue */
+		cbs->stale = cbs->cb;
+		cbs->cb = NULL;
+
+		capture_cb_detach(cbs);
+	}
+
+	/*
+	 * The blocks go back on the free list, so take the final counters
+	 * before letting go: whoever picks them up next zeroes the stats.
+	 */
+	rte_spinlock_lock(&capture_lock);
+	capture_sum_stats(cap, &total);
+	cap->total = total;
+	for (uint16_t i = 0; i < cap->nb_cbs; i++)
+		TAILQ_INSERT_TAIL(&capture_cb_freelist, cap->cbs[i], next);
+	cap->nb_cbs = 0;
+	rte_spinlock_unlock(&capture_lock);
+}
+
+/* Helper that returns error to telemetry and logs it */
+static void __rte_format_printf(2, 3)
+capture_err(struct rte_tel_data *d, const char *format, ...)
+{
+	va_list ap;
+	char msg[1024];
+
+	va_start(ap, format);
+	vsnprintf(msg, sizeof(msg), format, ap);
+	va_end(ap);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "error", msg);
+
+	CAPTURE_LOG(NOTICE, "%s", msg);
+}
+
+static int
+parse_uint32(const char *str, uint32_t *result, uint32_t limit)
+{
+	unsigned long val;
+	char *endp;
+
+	errno = 0;
+	val = strtoul(str, &endp, 10);
+	if (errno != 0 || *endp != '\0' || val >= limit)
+		return -1;
+
+	*result = val;
+	return 0;
+}
+
+/*
+ * Break the comma separated parameter string into tokens
+ * and fill in the capture config structure.
+ *
+ * Does not use rte_kvargs because that would mangle [] etc in filter expression.
+ */
+static int
+parse_params(char *str, struct capture_config *cfg, struct rte_tel_data *d)
+{
+	uint32_t snaplen = DEFAULT_SNAPLEN;
+	char *args[8];
+	int nargs;
+
+	/* Need at least the port id */
+	nargs = rte_strsplit(str, strlen(str), args, RTE_DIM(args), ',');
+	if (nargs < 1) {
+		capture_err(d, "missing parameters '%s'", str);
+		return -1;
+	}
+
+	/* Parse port id (required) */
+	uint32_t port_id;
+	if (parse_uint32(args[0], &port_id, RTE_MAX_ETHPORTS) < 0) {
+		capture_err(d, "invalid port_id='%s'", args[0]);
+		return -1;
+	}
+
+	/* parse remainder as name=value parameters */
+	for (int i = 1; i < nargs; i++) {
+		char *key = args[i];
+
+		/* split at the = */
+		char *eq = strchr(args[i], '=');
+
+		/* all current options require argument after = */
+		if (eq == NULL || eq[1] == '\0') {
+			capture_err(d,  "missing value for '%s'", key);
+			return -1;
+		}
+		*eq = '\0';
+		char *value = eq + 1;
+
+		if (strcmp(key, "out") == 0) {
+			cfg->output = value;
+		} else if (strcmp(key, "filter") == 0) {
+			cfg->filter_str = value;
+		} else if (strcmp(key, "queue") == 0) {
+			uint32_t q;
+
+			if (parse_uint32(value, &q, RTE_MAX_QUEUES_PER_PORT) < 0) {
+				capture_err(d, "invalid queue '%s'", value);
+				return -1;
+			}
+			cfg->queue = q;
+		} else if (strcmp(key, "snaplen") == 0) {
+			if (parse_uint32(value, &snaplen, UINT32_MAX) < 0) {
+				capture_err(d, "invalid snaplen '%s'", value);
+				return -1;
+			}
+		} else {
+			capture_err(d, "unknown parameter '%s'", key);
+			return -1;
+		}
+	}
+
+	if (cfg->output == NULL) {
+		capture_err(d, "missing output parameter");
+		return -1;
+	}
+
+	cfg->port_id = port_id;
+
+	/*
+	 * Default is 256K from tcpdump legacy
+	 * using snaplen=0 means everything.
+	 */
+	cfg->snaplen = snaplen > 0 ? snaplen : UINT32_MAX;
+	return 0;
+}
+
+static bool is_empty_or_fifo(const struct stat *stb)
+{
+	if (S_ISFIFO(stb->st_mode))
+		return true;
+	else if (S_ISREG(stb->st_mode))
+		return stb->st_size == 0;
+	else
+		return false; /* not a FIFO or regular file */
+}
+
+
+/*
+ * Create the file handle for pcapng output
+ * Note: can't really tell wireshark about errors since this in an
+ * independent thread.
+ */
+static __rte_cold rte_pcapng_t *
+capture_pcapng_open(const char *path, int *fd, uint16_t port_id, const char *filter)
+{
+	rte_pcapng_t *pcapng = NULL;
+	char port_name[RTE_ETH_NAME_MAX_LEN];
+	char appname[128];
+	char ifname[IFNAMSIZ];
+	char *ifdescr = NULL;
+	struct utsname uts;
+	char *osname = NULL;
+
+	/* OS name is optional, just keep going if not found */
+	if (uname(&uts) == 0 && asprintf(&osname, "%s %s", uts.sysname, uts.release) < 0)
+		osname = NULL;
+
+	/* add DPDK internal name */
+	if (rte_eth_dev_get_name_by_port(port_id, port_name) != 0) {
+		CAPTURE_LOG(NOTICE, "Could not find port name for %u", port_id);
+		goto cleanup;
+	}
+
+	/* match name convention used by dpdk-wireshark-extcap.py */
+	snprintf(ifname, sizeof(ifname), CAPTURE_EXTCAP_PREFIX ":%u", port_id);
+	if (asprintf(&ifdescr, "DPDK %s", port_name) < 0)
+		ifdescr = NULL;
+
+	/* mirror what other applications do for name */
+	snprintf(appname, sizeof(appname), CAPTURE_EXTCAP_PREFIX " (%s)", rte_version());
+
+	/*
+	 * Open the output in non-block mode in case it is a FIFO
+	 * without a reader. Wireshark must open the read end before
+	 * asking us to capture.
+	 */
+	*fd = open(path, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
+	if (*fd < 0) {
+		CAPTURE_LOG(ERR, "Could not open %s: %s", path, strerror(errno));
+		goto cleanup;
+	}
+
+	/* recheck that it is ok to use */
+	struct stat sb;
+	if (fstat(*fd, &sb) < 0 || !is_empty_or_fifo(&sb)) {
+		CAPTURE_LOG(ERR, "Not safe to use %s", path);
+		goto close_fd;
+	}
+
+	/* writes in the drain loop should block normally */
+	int flags = fcntl(*fd, F_GETFL, 0);
+	if (flags < 0 || fcntl(*fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
+		CAPTURE_LOG(ERR, "fcntl %s: %s",  path,  strerror(errno));
+		goto close_fd;
+	}
+
+	/* put pcapng header on and setup */
+	pcapng = rte_pcapng_fdopen(*fd, osname, NULL, appname, NULL);
+	if (pcapng == NULL) {
+		CAPTURE_LOG(ERR, "Add section block failed");
+		goto close_fd;
+	}
+
+	if (rte_pcapng_add_interface(pcapng, port_id, DLT_EN10MB, ifname, ifdescr, filter) < 0) {
+		CAPTURE_LOG(ERR, "Add interface for port %u:%s failed", port_id, ifname);
+		rte_pcapng_close(pcapng);  /* closes fd */
+		pcapng = NULL;
+	}
+	goto cleanup;
+
+close_fd:
+	close(*fd);
+cleanup:
+	free(osname);
+	free(ifdescr);
+	return pcapng;
+}
+
+static void
+capture_link(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+static void
+capture_unlink(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_REMOVE(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+static void
+capture_free(struct capture *cap)
+{
+	if (cap == NULL)
+		return;
+
+	free(cap->output);
+	__rte_capture_filter_free(cap->filter);
+	rte_ring_free(cap->ring);
+	rte_mempool_free(cap->mp);
+	rte_free(cap);
+}
+
+/* Generate unique id for naming and telemetry */
+static unsigned int
+get_unique_id(void)
+{
+	static RTE_ATOMIC(unsigned int) capture_instance;
+
+	return rte_atomic_fetch_add_explicit(&capture_instance, 1, rte_memory_order_relaxed);
+}
+
+/*
+ * Convert configuration into running state
+ */
+static struct capture *
+capture_alloc(const struct capture_config *cfg,
+	      const struct rte_eth_dev_info *dev_info,
+	      struct rte_tel_data *d)
+{
+	struct capture *cap;
+	char ring_name[RTE_RING_NAMESIZE];
+	uint16_t mbuf_size;
+
+	/* try and put capture data struct on same node as device. */
+	int socket_id = rte_eth_dev_socket_id(cfg->port_id);
+	if (socket_id < 0)
+		socket_id = SOCKET_ID_ANY;
+
+	uint16_t num_queues = dev_info->nb_tx_queues + dev_info->nb_rx_queues;
+	size_t cb_size = sizeof(*cap) + num_queues * sizeof(cap->cbs[0]);
+	cap = rte_zmalloc_socket("capture", cb_size, RTE_CACHE_LINE_SIZE, socket_id);
+	if (cap == NULL) {
+		capture_err(d, "Could not allocate capture struct");
+		goto error;
+	}
+
+	cap->idx = get_unique_id();
+	snprintf(ring_name, sizeof(ring_name), "capture-%u", cap->idx);
+	cap->ring = rte_ring_create(ring_name, CAPTURE_RING_SIZE, socket_id, 0);
+	if (cap->ring == NULL) {
+		capture_err(d, "Could not create ring");
+		goto error;
+	}
+
+	/*
+	 * If snapshot length is smaller than one mbuf segment then pool
+	 * element size can be reduced; otherwise can just use the default
+	 * and rte_pktmbuf_copy handle multiple segments.
+	 */
+	if (cfg->snaplen < RTE_MBUF_DEFAULT_BUF_SIZE)
+		mbuf_size = rte_pcapng_mbuf_size(cfg->snaplen);
+	else
+		mbuf_size = RTE_MBUF_DEFAULT_BUF_SIZE;
+
+	cap->mp = rte_pktmbuf_pool_create_by_ops(ring_name, CAPTURE_POOL_SIZE,
+						 MBUF_POOL_CACHE_SIZE, 0, mbuf_size,
+						 socket_id, "ring_mp_mc");
+	if (cap->mp == NULL) {
+		capture_err(d,  "Could not create mempool");
+		goto error;
+	}
+
+	if (cfg->filter_str) {
+		cap->filter = __rte_capture_filter_create(cfg->filter_str);
+		if (cap->filter == NULL) {
+			capture_err(d,  "Could not compile filter: %s", cfg->filter_str);
+			goto error;
+		}
+	}
+
+	cap->port_id = cfg->port_id;
+	cap->output = strdup(cfg->output);
+	if (cap->output == NULL) {
+		capture_err(d, "Could not strdup '%s'", cfg->output);
+		goto error;
+	}
+	rte_atomic_store_explicit(&cap->running, true, rte_memory_order_relaxed);
+	cap->snaplen = cfg->snaplen;
+	cap->queue = cfg->queue;
+	cap->socket_id = socket_id;
+	cap->tx_queues = dev_info->nb_tx_queues;
+	cap->rx_queues = dev_info->nb_rx_queues;
+
+	return cap;
+
+error:
+	capture_free(cap);
+	return NULL;
+}
+
+static void
+capture_write_stats(rte_pcapng_t *pcapng, const struct capture *cap)
+{
+	struct capture_total t;
+	struct rte_pcapng_interface_stats isb;
+
+	capture_sum_stats(cap, &t);
+
+	/* Unlike libpcap the ifrecv is the total number of packets
+	 * and filteraccept is the subset that passed.
+	 */
+	isb.ifrecv = t.accepted + t.filtered + t.nombuf;
+	isb.filteraccept = t.accepted + t.nombuf;
+	isb.ifdrop = t.nombuf + t.ringfull;
+
+	rte_pcapng_write_stats(pcapng, cap->port_id, &isb, sizeof(isb), NULL);
+}
+
+/* Check that output file is still ok to write (i.e FIFO not closed).
+ * Used to detect when wireshark has exited, and idle.
+ */
+static int
+check_fifo_status(int fd)
+{
+	struct pollfd pfd = { .fd = fd };
+
+	if (poll(&pfd, 1, 0) < 0) {
+		CAPTURE_LOG(ERR, "poll failed: %s", strerror(errno));
+		return -1;
+	}
+	if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
+		CAPTURE_LOG(NOTICE, "fifo reader closed");
+		return -1;
+	}
+	return 0;
+}
+
+static ssize_t
+capture_process_ring(struct rte_ring *ring, rte_pcapng_t *pcapng,
+		     unsigned int *available)
+{
+	struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+	unsigned int n;
+	ssize_t written;
+
+	n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+				      CAPTURE_BURST_SIZE, available);
+	if (n == 0)
+		return 0;
+
+	written = rte_pcapng_write_packets(pcapng, pkts, n);
+	rte_pktmbuf_free_bulk(pkts, n);
+
+	return written;
+}
+
+static void
+capture_flush_ring(struct rte_ring *ring)
+{
+	for (;;) {
+		struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+		unsigned int n;
+
+		n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+					      CAPTURE_BURST_SIZE, NULL);
+		if (n == 0)
+			break;
+
+		rte_pktmbuf_free_bulk(pkts, n);
+	}
+}
+
+/* The capture thread that moves packets from ring to the pcapng out */
+static uint32_t __rte_hot
+capture_thread(void *arg)
+{
+	struct capture *cap = arg;
+	unsigned int empty_count = 0;
+	bool reader_gone = false;
+	int fd = -1;
+
+	CAPTURE_LOG(INFO, "capture thread starting");
+
+	char name[RTE_THREAD_NAME_SIZE];
+	snprintf(name, sizeof(name), "cap-%u", cap->idx);
+	rte_thread_set_prefixed_name(rte_thread_self(), name);
+
+	/* This thread wants to detect when file gets closed (for FIFO) */
+	sigset_t set;
+	sigemptyset(&set);
+	sigaddset(&set, SIGPIPE);
+	pthread_sigmask(SIG_BLOCK, &set, NULL);
+
+	rte_pcapng_t *pcapng = capture_pcapng_open(cap->output, &fd, cap->port_id,
+						   __rte_capture_filter_string(cap->filter));
+	if (pcapng == NULL) {
+		capture_remove_callbacks(cap);
+		goto error;
+	}
+
+	while (rte_atomic_load_explicit(&cap->running, rte_memory_order_relaxed)) {
+		ssize_t written;
+		unsigned int avail;
+
+		written = capture_process_ring(cap->ring, pcapng, &avail);
+		if (written < 0) {
+			CAPTURE_LOG(NOTICE, "write to file failed: %s",
+				    strerror(errno));
+			reader_gone = true;
+			break;
+		}
+
+		if (written > 0) {
+			/* are there more packets? */
+			empty_count = (avail == 0);
+		} else if (empty_count < SLEEP_THRESHOLD) {
+			/* spin a few times before checking */
+			++empty_count;
+			rte_pause();
+		} else if (check_fifo_status(fd) == 0) {
+			/* avoid consuming 100% CPU polling */
+			rte_delay_us_sleep(SLEEP_US);
+		} else {
+			/* output FIFO has closed */
+			reader_gone = true;
+			break;
+		}
+	}
+
+	/* Capture exiting */
+	CAPTURE_LOG(INFO, "capture thread stopping");
+	capture_remove_callbacks(cap);
+
+	/* Process residual */
+	if (reader_gone)
+		capture_flush_ring(cap->ring);
+	else {
+		while (capture_process_ring(cap->ring, pcapng, NULL) > 0)
+			continue;
+
+		capture_write_stats(pcapng, cap);
+	}
+
+	rte_pcapng_close(pcapng);
+
+error:
+	capture_unlink(cap);
+	capture_free(cap);
+
+	return 0;
+}
+
+/*
+ * Callback handler for telemetry library to start capture.
+ *
+ * Need to handle: <iface>,snaplen=<n>,filter=<str>
+ */
+static int
+capture_start_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	struct capture *cap = NULL;
+	struct capture_config cfg = { .queue = ALL_QUEUES };
+	struct rte_eth_dev_info dev_info;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry: %s %s", cmd, params);
+
+	if (rte_eal_process_type() != RTE_PROC_PRIMARY) {
+		CAPTURE_LOG(ERR, "capture can only be started from primary");
+		return -1;
+	}
+
+	/* Note: params is const so need non-const copy for parsing
+	 * Alternative would be to change telemetry to allow this.
+	 */
+	char tmp[CAP_CMD_MAX];
+	if (strlcpy(tmp, params, CAP_CMD_MAX) >= CAP_CMD_MAX) {
+		CAPTURE_LOG(ERR, "params too long");
+		return -1;
+	}
+
+	if (parse_params(tmp, &cfg, d) < 0)
+		return 0;
+
+	/* Lookup number of queues etc, also validates port_id */
+	if (rte_eth_dev_info_get(cfg.port_id, &dev_info) < 0) {
+		capture_err(d, "can not get info for port %u", cfg.port_id);
+		return 0;
+	}
+
+	if (cfg.queue != ALL_QUEUES &&
+	    cfg.queue >= RTE_MAX(dev_info.nb_rx_queues, dev_info.nb_tx_queues)) {
+		capture_err(d, "queue %d out of range", cfg.queue);
+		return 0;
+	}
+
+	struct stat sb;
+	if (stat(cfg.output, &sb) < 0) {
+		capture_err(d, "output %s:%s", cfg.output, strerror(errno));
+		return 0;
+	}
+
+	if (!is_empty_or_fifo(&sb)) {
+		capture_err(d, "output %s is not empty", cfg.output);
+		return 0;
+	}
+
+	cap = capture_alloc(&cfg, &dev_info, d);
+	if (cap == NULL)
+		return 0;
+
+	if (capture_add_callbacks(cap) < 0) {
+		capture_err(d, "can not register callbacks");
+		goto error_callback_remove;
+	}
+
+	/*
+	 * Publish into the active list before starting the drain thread so the
+	 * thread is guaranteed to find itself there when it removes itself on
+	 * exit (it may exit immediately, e.g. if the FIFO reader is already
+	 * gone). On thread-create failure we undo the insertion here.
+	 */
+	uint32_t idx = cap->idx;
+	capture_link(cap);
+
+	/*
+	 * Make a new thread to do the capture work
+	 * Thread will inherit affinity from the telemetry handler that calls us
+	 */
+	rte_thread_t thread_id;
+	int ret = rte_thread_create(&thread_id, NULL, capture_thread, cap);
+	if (ret != 0) {
+		capture_err(d, "thread start failed: %s", strerror(ret));
+		goto error_unlink;
+	}
+
+	rte_thread_detach(thread_id);
+
+	/* Return id back for later use. */
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "id", idx);
+	rte_tel_data_add_dict_string(d, "status", "running");
+	return 0;
+
+error_unlink:
+	capture_unlink(cap);
+error_callback_remove:
+	capture_remove_callbacks(cap);
+	capture_free(cap);
+
+	/* return 0 since error reported "error":"XXX" in response */
+	return 0;
+}
+
+/* Telemetry: stop active capture. */
+static int
+capture_stop_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	uint32_t idx;
+	if (parse_uint32(params, &idx, UINT32_MAX) < 0) {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	rte_spinlock_lock(&capture_lock);
+	struct capture *cap;
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		capture_err(d, "capture index %" PRIu32 " not found", idx);
+		return 0;
+	}
+
+	rte_atomic_store_explicit(&cap->running, false, rte_memory_order_relaxed);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "status", "stopped");
+	return 0;
+}
+
+/* Telemetry: list the ids of all active captures. */
+static int
+capture_list_req(const char *cmd __rte_unused, const char *params __rte_unused,
+		 struct rte_tel_data *d)
+{
+	struct capture *cap;
+
+	CAPTURE_LOG(DEBUG, "telemetry %s", cmd);
+	rte_tel_data_start_array(d, RTE_TEL_UINT_VAL);
+
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next)
+		rte_tel_data_add_array_uint(d, cap->idx);
+	rte_spinlock_unlock(&capture_lock);
+
+	return 0;
+}
+
+
+/* Telemetry: report configuration and counters for one capture. */
+static int
+capture_stats_req(const char *cmd, const char *params,
+		  struct rte_tel_data *d)
+{
+	struct capture *cap;
+	struct capture_total t;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	uint32_t idx;
+	if (parse_uint32(params, &idx, UINT32_MAX) < 0) {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	/* Find the instance and snapshot what we need while holding the lock. */
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		capture_err(d, "capture index %" PRIu32 " not found", idx);
+		return 0;
+	}
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "port_id", cap->port_id);
+	if (cap->filter)
+		rte_tel_data_add_dict_string(d, "filter",
+					     __rte_capture_filter_string(cap->filter));
+	rte_tel_data_add_dict_int(d, "running",
+				  rte_atomic_load_explicit(&cap->running,
+							   rte_memory_order_relaxed));
+	rte_tel_data_add_dict_uint(d, "snaplen", cap->snaplen);
+	rte_tel_data_add_dict_uint(d, "rx_queues", cap->rx_queues);
+	rte_tel_data_add_dict_uint(d, "tx_queues", cap->tx_queues);
+	capture_sum_stats(cap, &t);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_add_dict_uint(d, "accepted", t.accepted);
+	rte_tel_data_add_dict_uint(d, "filtered", t.filtered);
+	rte_tel_data_add_dict_uint(d, "nombuf", t.nombuf);
+	rte_tel_data_add_dict_uint(d, "ringfull", t.ringfull);
+
+	return 0;
+}
+
+RTE_INIT(capture_telemetry)
+{
+	rte_telemetry_register_cmd("/ethdev/capture/list", capture_list_req,
+		"List ids of active captures. Takes no parameters.");
+	rte_telemetry_register_cmd("/ethdev/capture/stats", capture_stats_req,
+		"Report configuration and counters for a capture. Parameters: id");
+	rte_telemetry_register_cmd("/ethdev/capture/start", capture_start_req,
+		"Start capture. Parameters: "
+		"port_id,out=<path>,snaplen=N(optional),queue=N(optional),filter=string(optional)");
+	rte_telemetry_register_cmd("/ethdev/capture/stop", capture_stop_req,
+				   "Stop an active capture. Parameters: id");
+}
diff --git a/lib/capture/capture_impl.h b/lib/capture/capture_impl.h
new file mode 100644
index 0000000000..adee734b6c
--- /dev/null
+++ b/lib/capture/capture_impl.h
@@ -0,0 +1,56 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+#ifndef CAPTURE_IMPL_H
+#define CAPTURE_IMPL_H
+
+#define RTE_LOGTYPE_CAPTURE rte_capture_logtype
+extern int rte_capture_logtype;
+#define CAPTURE_LOG(level, ...) \
+	RTE_LOG_LINE_PREFIX(level, CAPTURE, "%s(): ", __func__, __VA_ARGS__)
+
+struct rte_capture_filter;
+
+#ifdef RTE_HAS_LIBPCAP
+struct rte_capture_filter *__rte_capture_filter_create(const char *str);
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter);
+void __rte_capture_filter_free(struct rte_capture_filter *filter);
+uint64_t __rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb);
+
+#else /* !RTE_HAS_LIBPCAP */
+
+/* Stub version if pcap is not available */
+static inline struct rte_capture_filter *
+__rte_capture_filter_create(const char *str)
+{
+	RTE_SET_USED(str);
+	return NULL; /* not supported */
+}
+
+static inline const char *
+__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+	return NULL;
+}
+
+static inline void
+__rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+}
+
+/*
+ * This will  be zero if the packet doesn't match the filter and non-zero if
+ * the packet matches the filter.
+ */
+static inline uint64_t
+__rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb)
+{
+	RTE_SET_USED(filter);
+	RTE_SET_USED(mb);
+	return 1;
+}
+
+#endif  /* !RTE_HAS_LIBPCAP */
+#endif /* CAPTURE_IMPL_H */
diff --git a/lib/capture/filter.c b/lib/capture/filter.c
new file mode 100644
index 0000000000..28be22fe2e
--- /dev/null
+++ b/lib/capture/filter.c
@@ -0,0 +1,110 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#include <pcap/pcap.h>
+
+#include <rte_bpf.h>
+#include <rte_errno.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_mbuf.h>
+#include <rte_string_fns.h>
+
+#include "capture_impl.h"
+
+struct rte_capture_filter {
+	struct rte_bpf *bpf;
+	struct rte_bpf_jit jit;
+	char expr[];	/* original filter text */
+};
+
+/*
+ * Convert text string into an eBPF program
+ */
+struct rte_capture_filter *
+__rte_capture_filter_create(const char *filter)
+{
+	struct rte_bpf_prm *prm = NULL;
+
+	/* libpcap needs a handle */
+	pcap_t *pcap = pcap_open_dead(DLT_EN10MB, UINT16_MAX);
+	if (!pcap) {
+		CAPTURE_LOG(ERR, "pcap: can not open handle");
+		return NULL;
+	}
+
+	size_t filter_len = strlen(filter) + 1;
+	struct rte_capture_filter *flt;
+	flt = rte_zmalloc("capture_filter", sizeof(*flt) + filter_len, 0);
+	if (flt == NULL) {
+		CAPTURE_LOG(ERR, "could not allocate for filter");
+		goto error;
+	}
+
+	/* convert string to cBPF program */
+	struct bpf_program bf;
+	if (pcap_compile(pcap, &bf, filter, 1, PCAP_NETMASK_UNKNOWN) != 0) {
+		CAPTURE_LOG(ERR, "pcap: can not compile filter: %s",
+			    pcap_geterr(pcap));
+		goto error;
+	}
+	strlcpy(flt->expr, filter, filter_len);
+
+	/* convert cBPF to eBPF */
+	prm = rte_bpf_convert(&bf);
+	pcap_freecode(&bf); /* drop the cBPF program */
+
+	if (prm == NULL)  {
+		CAPTURE_LOG(ERR, "BPF convert interface %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	flt->bpf = rte_bpf_load(prm);
+	if (flt->bpf == NULL) {
+		CAPTURE_LOG(ERR, "BPF load failed: %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	rte_bpf_get_jit(flt->bpf, &flt->jit);
+	if (flt->jit.func == NULL)
+		CAPTURE_LOG(NOTICE, "No JIT available for filter");
+
+	pcap_close(pcap);
+	rte_free(prm);
+	return flt;
+
+error:
+	pcap_close(pcap);
+	rte_free(prm);
+	rte_free(flt);
+	return NULL;
+}
+
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	return filter ? filter->expr : NULL;
+}
+
+void __rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	if (filter == NULL)
+		return;
+
+	rte_bpf_destroy(filter->bpf);
+	rte_free(filter);
+}
+
+uint64_t __rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb)
+{
+	if (filter->jit.func)
+		return filter->jit.func(mb);
+	else
+		return rte_bpf_exec(filter->bpf, mb);
+}
diff --git a/lib/capture/meson.build b/lib/capture/meson.build
new file mode 100644
index 0000000000..4dbe0d1a78
--- /dev/null
+++ b/lib/capture/meson.build
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Stephen Hemminger
+
+if is_windows
+    build = false
+    reason = 'not supported on Windows'
+    subdir_done()
+endif
+
+sources = files('capture.c')
+
+deps += ['ethdev', 'pcapng', 'bpf']
+
+if dpdk_conf.has('RTE_HAS_LIBPCAP')
+    sources += files('filter.c')
+    ext_deps += pcap_dep
+else
+    warning('libpcap is missing, capture filtering will be disabled')
+endif
diff --git a/lib/meson.build b/lib/meson.build
index af5c160cb8..6d9992f61f 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -49,6 +49,7 @@ libraries = [
         'lpm',
         'member',
         'pcapng',
+        'capture', # depends on pcapng and bpf
         'power',
         'rawdev',
         'regexdev',
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v2 3/4] test: add test for capture hooks
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 1/4] pcapng: extend interface statistics Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
@ 2026-07-27 22:42   ` Stephen Hemminger
  2026-07-27 22:42   ` [PATCH v2 4/4] usertools: add script for wireshark capture Stephen Hemminger
                     ` (2 subsequent siblings)
  5 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-27 22:42 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Thomas Monjalon, Reshma Pattan

Provide tests to exercise telemetry based packet capture.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS             |   1 +
 app/test/meson.build    |   1 +
 app/test/test_capture.c | 660 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 662 insertions(+)
 create mode 100644 app/test/test_capture.c

diff --git a/MAINTAINERS b/MAINTAINERS
index fcd350ad94..608cb4b77d 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1721,6 +1721,7 @@ Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
 F: lib/capture/
+F: app/test/test_capture.c
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/app/test/meson.build b/app/test/meson.build
index 51abeeb732..b73ae8ca07 100644
--- a/app/test/meson.build
+++ b/app/test/meson.build
@@ -37,6 +37,7 @@ source_file_deps = {
     'test_bpf.c': ['bpf', 'net'],
     'test_bpf_validate.c': ['bpf'],
     'test_byteorder.c': [],
+    'test_capture.c': ['net_null', 'net', 'ethdev', 'bus_vdev', 'capture'],
     'test_cfgfile.c': ['cfgfile'],
     'test_cksum.c': ['net'],
     'test_cksum_perf.c': ['net'],
diff --git a/app/test/test_capture.c b/app/test/test_capture.c
new file mode 100644
index 0000000000..c5fdb1b784
--- /dev/null
+++ b/app/test/test_capture.c
@@ -0,0 +1,660 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Functional test for the capture library.
+ *
+ * The capture library has no public C API: it is driven entirely through the
+ * telemetry socket. The output is a path ('out=') that the primary opens
+ * itself and writes pcapng into; the caller never receives packet data over
+ * the control socket. This test therefore behaves like an external capture
+ * tool.
+ *
+ * The suite setup builds the fixture shared by every case:
+ *
+ *   - a virtual ethdev backed by net_null. Rx synthesizes packets and Tx is a
+ *     sink that frees whatever it is handed, which is all this test needs and
+ *     avoids the per-queue ring bookkeeping of net_ring. The port is given
+ *     NB_QUEUES queues so per-queue selection can be exercised;
+ *   - a FIFO for capture output;
+ *   - a connection to this process's own telemetry socket.
+ *
+ * Each case then runs with the FIFO read end open (the primary's non-blocking
+ * O_WRONLY open needs a reader present) and hands it back in the common
+ * teardown, which is also what tears down a capture left running by a failed
+ * assertion.
+ *
+ * The cases are:
+ *
+ *   lifecycle
+ *     start an all-queue capture, check a pcapng stream appears, the capture
+ *     is listed, stats account for traffic injected on every queue in both
+ *     directions, and it tears down when the reader disconnects;
+ *   bad_queue
+ *     an out-of-range queue index is rejected, not started;
+ *   queue_select
+ *     a queue=CAPTURE_QUEUE capture installs callbacks on that queue only, so
+ *     traffic on any other queue is not captured while traffic on that one is,
+ *     in both directions. The selected queue is neither the first nor the last
+ *     of NB_QUEUES, so an off-by-one in either install loop is caught. Both
+ *     directions are steerable here: the test names the queue in
+ *     rte_eth_rx_burst()/rte_eth_tx_burst() and net_null sources or sinks on
+ *     whichever queue it is handed.
+ *
+ * The suite is skipped (not failed) if telemetry is not enabled or the
+ * net_null driver is not available.
+ */
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/select.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/un.h>
+
+#include <rte_bus_vdev.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_eal.h>
+#include <rte_ethdev.h>
+#include <rte_lcore.h>
+#include <rte_log.h>
+#include <rte_mbuf.h>
+
+#include "test.h"
+
+#define TELEMETRY_VERSION	"v2"
+#define CAPTURE_START		"/ethdev/capture/start"
+#define CAPTURE_LIST		"/ethdev/capture/list"
+#define CAPTURE_STATS		"/ethdev/capture/stats"
+#define CAPTURE_STOP		"/ethdev/capture/stop"
+
+#define NULL_VDEV_NAME		"net_null_capture"
+#define NB_QUEUES		4
+#define CAPTURE_QUEUE		2	/* not first or last of NB_QUEUES */
+#define RING_SIZE		256
+#define NB_MBUFS		1024
+#define MBUF_CACHE		32
+#define NB_PKTS			32
+#define PKT_LEN			64
+#define REPLY_LEN		16384
+
+/* pcapng Section Header Block type, byte-order independent on disk. */
+static const uint8_t pcapng_shb_magic[4] = { 0x0a, 0x0d, 0x0d, 0x0a };
+
+static struct rte_mempool *test_mp;
+static uint16_t test_port = RTE_MAX_ETHPORTS;
+static char fifo_path[PATH_MAX];
+static int tel_sock = -1;
+static int fifo_rd = -1;
+
+/* --- telemetry client helpers ------------------------------------------ */
+
+/* Connect to this process's telemetry socket; -1 if unavailable. */
+static int
+tel_connect(void)
+{
+	struct sockaddr_un addr = { .sun_family = AF_UNIX };
+	char buf[REPLY_LEN];
+	int s;
+
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/dpdk_telemetry.%s",
+		 rte_eal_get_runtime_dir(), TELEMETRY_VERSION);
+
+	s = socket(AF_UNIX, SOCK_SEQPACKET, 0);
+	if (s < 0)
+		return -1;
+
+	if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+		close(s);
+		return -1;
+	}
+
+	/* Server greets with an info message; consume it. */
+	if (recv(s, buf, sizeof(buf), 0) <= 0) {
+		close(s);
+		return -1;
+	}
+	return s;
+}
+
+/* Send a command and read the reply. */
+static int
+tel_cmd(const char *cmd, char *reply, size_t reply_sz)
+{
+	ssize_t n;
+
+	if (send(tel_sock, cmd, strlen(cmd), 0) < 0)
+		return -1;
+	n = recv(tel_sock, reply, reply_sz - 1, 0);
+	if (n < 0)
+		return -1;
+	reply[n] = '\0';
+	return 0;
+}
+
+/* Minimal JSON scanning: find "key" and read the unsigned number after it. */
+static int
+json_uint(const char *s, const char *key, uint64_t *out)
+{
+	const char *p = strstr(s, key);
+
+	if (p == NULL)
+		return -1;
+	for (p += strlen(key); *p != '\0' && !isdigit((unsigned char)*p); p++)
+		;
+	if (*p == '\0')
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Read the first element of the array in a list reply; -1 if empty/absent. */
+static int
+json_first_array_uint(const char *s, uint64_t *out)
+{
+	const char *p = strchr(s, '[');
+
+	if (p == NULL)
+		return -1;
+	for (p++; *p == ' '; p++)
+		;
+	if (*p == ']' || !isdigit((unsigned char)*p))
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Query the accepted counter for a capture id via telemetry. */
+static int
+capture_accepted(uint64_t id, uint64_t *accepted)
+{
+	char cmd[64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STATS, id);
+	if (tel_cmd(cmd, reply, sizeof(reply)) < 0)
+		return -1;
+	return json_uint(reply, "\"accepted\"", accepted);
+}
+
+/*
+ * Poll the capture list until it is empty (the capture has torn down).
+ * Returns 0 once empty, -1 on timeout (~2s) or telemetry failure.
+ */
+static int
+wait_list_empty(void)
+{
+	char reply[REPLY_LEN];
+	uint64_t id;
+
+	for (int i = 0; i < 200; i++) {
+		if (tel_cmd(CAPTURE_LIST, reply, sizeof(reply)) < 0)
+			return -1;
+		if (json_first_array_uint(reply, &id) < 0)
+			return 0;
+		rte_delay_ms(10);
+	}
+	return -1;
+}
+
+/* --- packet injection --------------------------------------------------- */
+
+/*
+ * Pull count packets from a queue. net_null synthesizes them and pulling runs
+ * the capture Rx callback on each; the mbufs are ours to free.
+ */
+static int
+inject_rx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t got;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	got = rte_eth_rx_burst(test_port, queue, bufs, count);
+	rte_pktmbuf_free_bulk(bufs, got);
+	return got == count ? 0 : -1;	/* net_null fills the whole request */
+}
+
+/*
+ * Transmit count packets on a queue. The capture Tx callback runs first (it
+ * only copies) and net_null then frees the originals, so only mbufs it did not
+ * take are reclaimed here.
+ */
+static int
+inject_tx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t sent;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	for (unsigned int i = 0; i < count; i++) {
+		struct rte_mbuf *m = rte_pktmbuf_alloc(test_mp);
+
+		if (m == NULL) {
+			rte_pktmbuf_free_bulk(bufs, i);
+			return -1;
+		}
+		m->pkt_len = m->data_len = PKT_LEN;
+		memset(rte_pktmbuf_mtod(m, void *), 0, PKT_LEN);
+		bufs[i] = m;
+	}
+
+	/* net_null accepts the whole burst; free the tail if it ever does not. */
+	sent = rte_eth_tx_burst(test_port, queue, bufs, count);
+	if (sent < count) {
+		rte_pktmbuf_free_bulk(&bufs[sent], count - sent);
+		return -1;
+	}
+	return 0;
+}
+
+/*
+ * Inject on every queue except skip; pass NB_QUEUES to cover them all.
+ */
+static int
+inject_rx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_rx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+static int
+inject_tx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_tx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+/* --- fixture ------------------------------------------------------------ */
+
+static int
+build_port(void)
+{
+	struct rte_eth_conf conf = { 0 };
+
+	test_mp = rte_pktmbuf_pool_create("capture_test_mp", NB_MBUFS, MBUF_CACHE,
+					  0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
+	if (test_mp == NULL)
+		return -1;
+
+	if (rte_vdev_init(NULL_VDEV_NAME, NULL) < 0)
+		return -1;
+	if (rte_eth_dev_get_port_by_name(NULL_VDEV_NAME, &test_port) != 0)
+		return -1;
+
+	if (rte_eth_dev_configure(test_port, NB_QUEUES, NB_QUEUES, &conf) < 0)
+		return -1;
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (rte_eth_rx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL, test_mp) < 0)
+			return -1;
+		if (rte_eth_tx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL) < 0)
+			return -1;
+	}
+
+	return rte_eth_dev_start(test_port);
+}
+
+static void
+teardown_port(void)
+{
+	if (test_port != RTE_MAX_ETHPORTS) {
+		rte_eth_dev_stop(test_port);
+		/*
+		 * Uninit drives the driver's remove, which closes and releases
+		 * the ethdev port and removes the vdev in one path. No separate
+		 * rte_eth_dev_close() is needed and null's close is idempotent,
+		 * so this is safe even if a tree's close already released it.
+		 */
+		rte_vdev_uninit(NULL_VDEV_NAME);
+		test_port = RTE_MAX_ETHPORTS;
+	}
+	rte_mempool_free(test_mp);
+	test_mp = NULL;
+}
+
+/* Create the capture FIFO in the runtime dir; -1 on failure. */
+static int
+make_fifo(void)
+{
+	snprintf(fifo_path, sizeof(fifo_path), "%s/capture_test.%d",
+		 rte_eal_get_runtime_dir(), (int)getpid());
+	unlink(fifo_path);			/* clear any stale node */
+	if (mkfifo(fifo_path, 0600) < 0)
+		return -1;
+	return 0;
+}
+
+/*
+ * Release everything the suite setup built. Idempotent, because the runner
+ * does not call the suite teardown when the suite setup fails, so the setup
+ * has to unwind itself by calling this directly.
+ */
+static void
+testsuite_teardown(void)
+{
+	if (fifo_rd >= 0) {
+		close(fifo_rd);
+		fifo_rd = -1;
+	}
+	if (fifo_path[0] != '\0') {
+		unlink(fifo_path);
+		fifo_path[0] = '\0';
+	}
+	if (tel_sock >= 0) {
+		close(tel_sock);
+		tel_sock = -1;
+	}
+	teardown_port();
+}
+
+static int
+testsuite_setup(void)
+{
+	int ret = TEST_SKIPPED;
+
+	fifo_path[0] = '\0';
+
+	/*
+	 * The library writes to the FIFO; a closed reader must give the writer
+	 * EPIPE, not a fatal SIGPIPE. The library masks SIGPIPE on its drain
+	 * thread, but ignore it here too so the test process is safe regardless.
+	 */
+	signal(SIGPIPE, SIG_IGN);
+
+	tel_sock = tel_connect();
+	if (tel_sock < 0) {
+		printf("telemetry socket not available, skipping\n");
+		goto error;
+	}
+
+	if (build_port() < 0) {
+		printf("could not build net_null test port, skipping\n");
+		goto error;
+	}
+
+	if (make_fifo() < 0) {
+		printf("could not create fifo %s: %s\n", fifo_path, strerror(errno));
+		ret = TEST_FAILED;
+		goto error;
+	}
+
+	return TEST_SUCCESS;
+
+error:
+	testsuite_teardown();
+	return ret;
+}
+
+/*
+ * Open the read end before each case: the library opens the FIFO
+ * O_WRONLY|O_NONBLOCK and would get ENXIO with no reader present.
+ * O_RDONLY|O_NONBLOCK returns immediately even with no writer yet.
+ */
+static int
+ut_setup(void)
+{
+	fifo_rd = open(fifo_path, O_RDONLY | O_NONBLOCK);
+	if (fifo_rd < 0) {
+		printf("open %s: %s\n", fifo_path, strerror(errno));
+		return TEST_FAILED;
+	}
+	return TEST_SUCCESS;
+}
+
+/*
+ * Common cleanup. Closing the read end is the hangup that makes any capture
+ * still running -- including one that a failed assertion returned out from
+ * under -- tear itself down, so no case can leak a capture into the next.
+ */
+static void
+ut_teardown(void)
+{
+	if (fifo_rd >= 0) {
+		close(fifo_rd);
+		fifo_rd = -1;
+	}
+	if (wait_list_empty() < 0)
+		printf("warning: capture still active after test case\n");
+}
+
+/* --- test cases --------------------------------------------------------- */
+
+static int
+test_capture_lifecycle(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN], pcapng[REPLY_LEN];
+	uint64_t id, accepted;
+	struct timeval tv;
+	fd_set rfds;
+	ssize_t n;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "capture start returned an error: %s", reply);
+
+	/*
+	 * Inject traffic on every queue: with no queue= the callbacks must be
+	 * installed on all of them. Rx callbacks run synchronously inside
+	 * rx_burst, so the accepted counter is up to date as soon as this
+	 * returns.
+	 */
+	TEST_ASSERT_SUCCESS(inject_rx_except(NB_QUEUES, NB_PKTS),
+			    "rx injection failed");
+
+	/* A pcapng stream (at least the section header) must appear. */
+	FD_ZERO(&rfds);
+	FD_SET(fifo_rd, &rfds);
+	tv = (struct timeval){ .tv_sec = 2 };
+	TEST_ASSERT(select(fifo_rd + 1, &rfds, NULL, NULL, &tv) > 0,
+		    "no pcapng output within timeout");
+	n = read(fifo_rd, pcapng, sizeof(pcapng));
+	TEST_ASSERT(n >= 4, "short pcapng read (%zd)", n);
+	TEST_ASSERT(memcmp(pcapng, pcapng_shb_magic, sizeof(pcapng_shb_magic)) == 0,
+		    "output does not start with a pcapng section header block");
+
+	/* The capture must show up in the list. */
+	TEST_ASSERT_SUCCESS(tel_cmd(CAPTURE_LIST, reply, sizeof(reply)),
+			    "capture list command failed");
+	TEST_ASSERT_SUCCESS(json_first_array_uint(reply, &id),
+			    "no capture id in list reply: %s", reply);
+
+	/* Stats must report exactly the packets we injected, on every queue. */
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, NB_QUEUES * NB_PKTS);
+
+	/* Same for the Tx side of every queue. */
+	TEST_ASSERT_SUCCESS(inject_tx_except(NB_QUEUES, NB_PKTS),
+			    "tx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, 2 * NB_QUEUES * NB_PKTS);
+
+	/*
+	 * Close the reader: the capture should detect the hangup and tear down.
+	 * The drain thread's idle poll notices POLLERR on the write end on its
+	 * own; the extra injection just shortens the wait if it was mid-drain.
+	 * Done here rather than left to the teardown because the hangup is what
+	 * this case is asserting.
+	 */
+	close(fifo_rd);
+	fifo_rd = -1;
+	inject_rx(0, NB_PKTS);	/* any queue; all are captured here */
+	TEST_ASSERT_SUCCESS(wait_list_empty(),
+			    "capture did not tear down after reader closed");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * An out-of-range queue index must be rejected. The range check runs before
+ * the output is opened, so no capture should be left behind even though a
+ * reader is attached.
+ */
+static int
+test_capture_bad_queue(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, NB_QUEUES);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") != NULL,
+		    "out-of-range queue=%u was not rejected: %s", NB_QUEUES, reply);
+	TEST_ASSERT_SUCCESS(wait_list_empty(),
+			    "rejected start left a capture behind");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * With queue=CAPTURE_QUEUE the callbacks are installed on that queue only:
+ * traffic on any other queue must not be captured, traffic on that one must
+ * be, in both directions. The counters are bumped synchronously inside
+ * rx_burst/tx_burst, so every check here is race-free.
+ */
+static int
+test_capture_queue_select(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+	uint64_t id, accepted;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "queue=%u capture start failed", CAPTURE_QUEUE);
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "queue=%u capture start returned an error: %s",
+		    CAPTURE_QUEUE, reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id),
+			    "no id in start reply: %s", reply);
+
+	/* Unselected queues: callbacks never installed, nothing captured. */
+	TEST_ASSERT_SUCCESS(inject_rx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(inject_tx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)0,
+			  "other queues captured under queue=%u (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* Selected queue: captured, on Rx and on Tx. */
+	TEST_ASSERT_SUCCESS(inject_rx(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)NB_PKTS,
+			  "rx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	TEST_ASSERT_SUCCESS(inject_tx(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_PKTS),
+			  "tx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* The teardown closes the reader, which stops this capture. */
+	return TEST_SUCCESS;
+}
+
+/*
+ * Two captures may cover the same queue, each with its own filter and its own
+ * callback. Both must start, both must be listed separately, and traffic on a
+ * shared queue must be counted by both.
+ */
+static int
+test_capture_dual(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN], path2[PATH_MAX];
+	uint64_t id1, id2, a1, a2;
+	int fd2;
+
+	/* the second capture writes to an empty regular file, so it needs no reader */
+	snprintf(path2, sizeof(path2), "%s/capture_test2.%d",
+		 rte_eal_get_runtime_dir(), (int)getpid());
+	unlink(path2);
+	fd2 = open(path2, O_CREAT | O_WRONLY, 0600);
+	TEST_ASSERT(fd2 >= 0, "could not create %s: %s", path2, strerror(errno));
+	close(fd2);
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "first start failed");
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id1), "no id in reply: %s", reply);
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, path2);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "second start failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "second capture of the same queues was rejected: %s", reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id2), "no id in reply: %s", reply);
+	TEST_ASSERT_NOT_EQUAL(id1, id2, "both captures got id %" PRIu64, id1);
+
+	/* one burst on a queue both cover must be counted twice */
+	TEST_ASSERT_SUCCESS(inject_rx(0, NB_PKTS), "rx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id1, &a1), "stats query failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id2, &a2), "stats query failed");
+	TEST_ASSERT_EQUAL(a1, (uint64_t)NB_PKTS,
+			  "first capture accepted %" PRIu64 " != %d", a1, NB_PKTS);
+	TEST_ASSERT_EQUAL(a2, (uint64_t)NB_PKTS,
+			  "second capture accepted %" PRIu64 " != %d", a2, NB_PKTS);
+
+	/* the fifo one is stopped by the teardown closing the reader */
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STOP, id2);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "stop command failed");
+	unlink(path2);
+
+	return TEST_SUCCESS;
+}
+
+static struct unit_test_suite capture_testsuite = {
+	.suite_name = "capture autotest",
+	.setup = testsuite_setup,
+	.teardown = testsuite_teardown,
+	.unit_test_cases = {
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_lifecycle),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_bad_queue),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_queue_select),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_dual),
+		TEST_CASES_END()
+	}
+};
+
+static int
+test_capture(void)
+{
+	return unit_test_suite_runner(&capture_testsuite);
+}
+
+REGISTER_FAST_TEST(capture_autotest, NOHUGE_OK, ASAN_OK, test_capture);
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v2 4/4] usertools: add script for wireshark capture
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
                     ` (2 preceding siblings ...)
  2026-07-27 22:42   ` [PATCH v2 3/4] test: add test for capture hooks Stephen Hemminger
@ 2026-07-27 22:42   ` Stephen Hemminger
  2026-07-29 23:28     ` Stephen Hemminger
  2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
  2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
  5 siblings, 1 reply; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-27 22:42 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Thomas Monjalon, Reshma Pattan, Robin Jarry

Provide glue script that wireshark can use to access
telemetry based packet capture. It is dual licensed because
it maybe desirable to put this in wireshark repository.

See https://www.wireshark.org/docs/man-pages/extcap.html

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |   2 +
 doc/guides/rel_notes/release_26_11.rst |   1 +
 doc/guides/tools/index.rst             |   1 +
 doc/guides/tools/wireshark_extcap.rst  | 144 +++++++++
 usertools/dpdk-wireshark-extcap.py     | 416 +++++++++++++++++++++++++
 usertools/meson.build                  |   1 +
 6 files changed, 565 insertions(+)
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100755 usertools/dpdk-wireshark-extcap.py

diff --git a/MAINTAINERS b/MAINTAINERS
index 608cb4b77d..7f220cc2c7 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1732,6 +1732,8 @@ F: app/pdump/
 F: doc/guides/tools/pdump.rst
 F: app/dumpcap/
 F: doc/guides/tools/dumpcap.rst
+F: usertools/dpdk-wireshark-extcap.py
+F: doc/guides/tools/wireshark_extcap.rst
 
 
 Packet Framework
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index 00acc02ac3..c79ffb38cf 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -58,6 +58,7 @@ New Features
 * **Added wireshark capture support.**
 
   * Added ``capture`` library for packet capture via telemetry API.
+  * Added python script for wireshark external capture integration.
 
 
 Removed Items
diff --git a/doc/guides/tools/index.rst b/doc/guides/tools/index.rst
index 13f75a5bc6..01db27511a 100644
--- a/doc/guides/tools/index.rst
+++ b/doc/guides/tools/index.rst
@@ -14,6 +14,7 @@ DPDK Tools User Guides
     pmdinfo
     dumpcap
     pdump
+    wireshark_extcap
     telemetrywatcher
     dmaperf
     flow-perf
diff --git a/doc/guides/tools/wireshark_extcap.rst b/doc/guides/tools/wireshark_extcap.rst
new file mode 100644
index 0000000000..99fb94df72
--- /dev/null
+++ b/doc/guides/tools/wireshark_extcap.rst
@@ -0,0 +1,144 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Wireshark Extcap Plugin
+=======================
+
+The ``dpdk-wireshark-extcap.py`` script is an external capture (extcap)
+plugin that enables Wireshark capture of live traffic from
+the Ethernet ports of a DPDK application.
+Each DPDK port appears as a capture interface in the Wireshark interface list,
+alongside the host's own network interfaces.
+
+The plugin uses the DPDK telemetry API to query and start capture.
+It passes the path of the fifo file that Wireshark created to the DPDK application,
+which opens the file itself and writes pcapng packets straight into it;
+the plugin never touches packet data.
+Wireshark signals the plugin on exit and that results in closing
+the capture session.
+
+
+Requirements
+------------
+
+* A DPDK application built with the capture library and with telemetry enabled.
+  Telemetry is enabled by default.
+
+* Since the plugin is started by Wireshark, Wireshark and the DPDK application
+  must have the same permissions. See `Permissions`_.
+
+
+Installation
+------------
+
+For Wireshark to discover the plugin it must be present in an extcap
+directory. The configured locations are listed in Wireshark under
+*Help > About Wireshark > Folders*. Copy or symbolically link the script into
+the personal extcap directory, for example::
+
+    ln -s $RTE_SDK/usertools/dpdk-wireshark-extcap.py \
+        ~/.local/lib/wireshark/extcap/
+
+The DPDK ports then appear in the interface list the next time the capture
+options dialog is opened.
+
+
+Usage
+-----
+
+In normal use the plugin is not run by hand; Wireshark invokes it.
+The ports of a running DPDK application appear in the interface list as
+``DPDK <name>``, where ``<name>`` is the device name reported by
+DPDK ethdev, such as ``net_tap0``.
+
+The plugin can also be run directly, which is useful for confirming that a
+DPDK application is reachable::
+
+    $ usertools/dpdk-wireshark-extcap.py --extcap-interfaces
+    extcap {version=0.1}{display=DPDK telemetry capture}
+    interface {value=dpdk:0}{display=DPDK net_tap0}
+
+
+Capture options
+---------------
+
+The following options are offered in the Wireshark capture options dialog for
+a DPDK interface:
+
+Snapshot length
+    Number of bytes captured from each packet. ``0`` captures the whole
+    packet. The default is 262144.
+
+Queue
+    Capture a single hardware queue, or all queues (the default). The choices
+    are bounded to the port's configured queue count.
+
+Capture filter
+    A libpcap filter expression, applied by the DPDK application to the
+    captured traffic.
+
+
+Permissions
+-----------
+
+The DPDK runtime directory is created mode ``0700``, so only the user that
+started the DPDK application can reach its telemetry socket.
+Wireshark, and the plugin it launches, must run as that same user. If run as a
+different user, the interface list is simply empty; running the plugin directly
+with ``--extcap-interfaces`` prints a diagnostic to standard error explaining
+the permission failure.
+
+No privilege beyond access to the telemetry socket is required: if you can
+run ``dpdk-dumpcap`` against an application, you can capture from it with this
+plugin.
+
+
+Selecting a DPDK application
+----------------------------
+
+A host usually runs a single DPDK application, started with the default
+file-prefix (``rte``), and no configuration is needed: its ports appear
+automatically as ``DPDK <name>``.
+
+Running several DPDK applications on one host is also supported. In that case
+each application is started with a distinct ``--file-prefix`` so that its
+runtime state is kept separate. The plugin lists the ports of *all* running
+applications at once, so the application is chosen from the interface list
+rather than before Wireshark is launched. There is no environment variable to
+select one.
+
+Ports of the default prefix are shown plainly, as ``DPDK <name>`` with the
+interface value ``dpdk:<port>``. Ports of any other prefix are qualified with
+the prefix, shown as ``DPDK <name>@<prefix>`` with the interface value
+``dpdk:<prefix>:<port>``, so applications with colliding port numbers stay
+distinct in the list.
+
+
+Environment variables
+----------------------
+
+``DPDK_EXTCAP_PATH``
+    Overrides the base DPDK runtime directory that holds the per-prefix
+    subdirectories. Use it when the runtime directory is in a non-standard
+    location. It gives the base directory that holds the per-prefix
+    subdirectories, one of which is scanned for each running application.
+
+
+Troubleshooting
+---------------
+
+The DPDK ports do not appear in Wireshark
+    Confirm the application is running and was built with the capture library
+    and telemetry. Confirm Wireshark runs as the same user as the application;
+    see `Permissions`_. An application started with a non-default
+    ``--file-prefix`` is listed with its ports qualified as
+    ``DPDK <name>@<prefix>``; see `Selecting a DPDK application`_.
+
+    Running the plugin directly with ``--extcap-interfaces`` prints
+    diagnostics to standard error that the Wireshark GUI does not surface.
+
+A port is listed as ``portN`` instead of a device name
+    The port was reported by the application, but its details could not be
+    read, usually because the application stopped between listing and naming
+    its ports. A capture started against it will fail; restart the
+    application.
diff --git a/usertools/dpdk-wireshark-extcap.py b/usertools/dpdk-wireshark-extcap.py
new file mode 100755
index 0000000000..ba7323b857
--- /dev/null
+++ b/usertools/dpdk-wireshark-extcap.py
@@ -0,0 +1,416 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause OR GPL-2.0-or-later
+# Copyright(c) 2026 Stephen Hemminger
+
+"""
+Wireshark extcap plugin for live capture from DPDK ethdev ports.
+
+Capture path: Wireshark creates a FIFO and hands this plugin its path. The
+plugin opens the FIFO -- which rendezvous with Wireshark's read end -- and then
+passes the FIFO *path* to the DPDK primary process over the telemetry socket as
+the 'out=' parameter. DPDK opens the FIFO itself and writes pcapng straight into
+it; this plugin never touches packet data.
+
+Stopping: Wireshark stops a capture by closing the FIFO read end and/or sending
+SIGTERM. On either, this plugin asks DPDK to stop (/ethdev/capture/stop,<id>) so
+it can flush the trailing packets and the closing interface statistics block
+while a reader is still attached. If the read end is already gone, DPDK detects
+the same hangup and stops on its own; the explicit stop is then a harmless
+no-op. Note that when stopped from the Wireshark GUI the read-end close usually
+precedes the signal, so the statistics block is delivered reliably only for
+standalone (Ctrl+C) runs and for file output.
+
+Interface values: 'dpdk:<port>' for the default file-prefix ('rte'), and
+'dpdk:<prefix>:<port>' for any other primary. The default prefix is left
+implicit so the common single-instance case stays unadorned.
+"""
+
+import argparse
+import json
+import os
+import select
+import signal
+import socket
+import sys
+
+EXTCAP_VERSION = "0.1"
+TELEMETRY_SOCKET = "dpdk_telemetry.v2"
+CAPTURE_START_CMD = "/ethdev/capture/start"
+CAPTURE_STOP_CMD = "/ethdev/capture/stop"
+ETHDEV_LIST = "/ethdev/list"
+ETHDEV_INFO = "/ethdev/info"
+DEFAULT_SNAPLEN = 262144
+DEFAULT_PREFIX = "rte"  # EAL HUGEFILE_PREFIX_DEFAULT
+DLT_EN10MB = 1
+
+
+# --- DPDK runtime directory / socket discovery ---------------------------
+
+
+def dpdk_dir():
+    """Directory holding the per-file-prefix runtime subdirectories."""
+    override = os.environ.get("DPDK_EXTCAP_PATH")
+    if override:
+        return override
+    if os.geteuid() == 0:
+        base = "/var/run"
+    else:
+        base = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
+    return os.path.join(base, "dpdk")
+
+
+def socket_path(prefix):
+    return os.path.join(dpdk_dir(), prefix, TELEMETRY_SOCKET)
+
+
+def list_prefixes():
+    """Yield (prefix, path) for each DPDK telemetry socket found."""
+    root = dpdk_dir()
+    try:
+        entries = os.listdir(root)
+    except FileNotFoundError:
+        # No DPDK runtime dir is present.
+        sys.stderr.write(
+            f"no DPDK runtime directory {root}. Either no DPDK application "
+            "is running, or it runs as a different user\n"
+        )
+        return
+    except PermissionError:
+        # The runtime dir is mode 0700; a different user can see nothing.
+        sys.stderr.write(
+            f"cannot read {root}: permission denied. The DPDK runtime "
+            "directory is created mode 0700, so capture must run as the same "
+            "user as the DPDK application, or set DPDK_EXTCAP_PATH.\n"
+        )
+        return
+
+    found = False
+    for prefix in sorted(entries):
+        path = os.path.join(root, prefix, TELEMETRY_SOCKET)
+        if os.path.exists(path):
+            found = True
+            yield prefix, path
+
+    # Handle case where leftover unix domain socket
+    if not found:
+        sys.stderr.write(f"no DPDK telemetry socket under {root}\n")
+
+
+def iface_value(prefix, port):
+    """Interface value for a (prefix, port). The default prefix is left
+    implicit so a single default-prefix instance shows 'dpdk:<port>'; any
+    other primary is spelled out as 'dpdk:<prefix>:<port>'."""
+    if prefix == DEFAULT_PREFIX:
+        return f"dpdk:{port}"
+    return f"dpdk:{prefix}:{port}"
+
+
+# --- Telemetry transport -------------------------------------------------
+
+
+class Telemetry:
+    """Minimal client for the DPDK v2 telemetry socket (SOCK_SEQPACKET)."""
+
+    def __init__(self, path):
+        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
+        self.sock.connect(path)
+        info = json.loads(self.sock.recv(1024).decode())
+        self.max_output_len = info.get("max_output_len", 16384)
+        self.pid = info.get("pid")
+        self.version = info.get("version")
+
+    def command(self, cmd):
+        """Send a command, return the decoded JSON reply, or None if empty."""
+        self.sock.send(cmd.encode())
+        reply = self.sock.recv(self.max_output_len)
+        if not reply:
+            return None
+        return json.loads(reply.decode())
+
+    def close(self):
+        self.sock.close()
+
+
+def port_queue_count(tel, port):
+    """Max of the rx/tx queue counts for a port, 0 if unknown."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return max(info.get("nb_rx_queues", 0), info.get("nb_tx_queues", 0))
+
+
+# --- extcap query operations --------------------------------------------
+
+
+def port_name(tel, port):
+    """Device name for a port via /ethdev/info, or 'port<N>' if unreadable.
+    For a physical port this is the PCI address (0000:c1:00.0); for a vdev it
+    is the vdev name (net_tap0). Reuses the caller's open connection."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return info.get("name") or f"port{port}"
+
+
+def cmd_interfaces():
+    print(f"extcap {{version={EXTCAP_VERSION}}}{{display=DPDK telemetry capture}}")
+    for prefix, path in list_prefixes():
+        try:
+            tel = Telemetry(path)
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+            continue
+        # One connection per prefix for the whole enumeration: list the ports,
+        # then name each over the same socket (each connection costs the
+        # primary a handler thread).
+        try:
+            ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+            for port in ports:
+                name = port_name(tel, port)
+                # Name the prefix in the label only when it is not the default.
+                if prefix == DEFAULT_PREFIX:
+                    display = f"DPDK {name}"
+                else:
+                    display = f"DPDK {name}@{prefix}"
+                print(
+                    f"interface {{value={iface_value(prefix, port)}}}"
+                    f"{{display={display}}}"
+                )
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+        finally:
+            tel.close()
+
+
+def cmd_dlts(_iface):
+    print(f"dlt {{number={DLT_EN10MB}}}{{name=EN10MB}}{{display=Ethernet}}")
+
+
+def cmd_config(iface):
+    print(
+        f"arg {{number=0}}{{call=--snaplen}}{{display=Snapshot length}}"
+        f"{{tooltip=Bytes captured per packet (0 = whole packet)}}"
+        f"{{type=integer}}{{range=0,{DEFAULT_SNAPLEN}}}"
+        f"{{default={DEFAULT_SNAPLEN}}}{{group=Capture}}"
+    )
+
+    # Bound the queue dropdown to the port's actual queue count. If the primary
+    # is unreachable, just omit the selector -- capture still defaults to all.
+    nqueues = 0
+    if iface:
+        prefix, port = parse_iface(iface)
+        try:
+            tel = Telemetry(socket_path(prefix))
+        except OSError:
+            tel = None
+        if tel is not None:
+            try:
+                nqueues = port_queue_count(tel, port)
+            finally:
+                tel.close()
+
+    if nqueues > 0:
+        print(
+            "arg {number=1}{call=--queue}{display=Queue}"
+            "{tooltip=Capture a single hardware queue}"
+            "{type=selector}{group=Capture}"
+        )
+        print("value {arg=1}{value=-1}{display=All queues}{default=true}")
+        for q in range(nqueues):
+            print(f"value {{arg=1}}{{value={q}}}{{display=Queue {q}}}")
+
+
+# --- capture -------------------------------------------------------------
+
+
+def comma_error(what):
+    """Telemetry command parameters are a comma separated list and there is no
+    escaping, so a comma inside a value is parsed as the start of the next
+    parameter. Reject it here rather than let the primary mis-parse it."""
+    return f"{what} must not contain a comma"
+
+
+def parse_iface(iface):
+    """Inverse of iface_value: accept 'dpdk:<port>' (default prefix) or
+    'dpdk:<prefix>:<port>'."""
+    parts = iface.split(":")
+    if parts[0] != "dpdk":
+        raise SystemExit(f"unsupported interface scheme in '{iface}'")
+    if len(parts) == 2:
+        prefix, port = DEFAULT_PREFIX, parts[1]
+    elif len(parts) == 3:
+        prefix, port = parts[1], parts[2]
+    else:
+        raise SystemExit(f"malformed interface '{iface}'")
+    try:
+        port = int(port)
+    except ValueError as e:
+        raise SystemExit(f"malformed interface '{iface}'") from e
+    return prefix, port
+
+
+def wait_for_stop(fifo_fd):
+    """Block until Wireshark stops us: either it closes the FIFO read end
+    (POLLERR on our write fd) or it sends SIGINT/SIGTERM. Watching the fd as
+    well as the signal matters because the signal may be missed -- Wireshark's
+    reliable stop is closing the pipe.
+
+    The handlers and the wakeup fd are restored before returning: the caller
+    still has telemetry work to do, and a second signal arriving during it
+    should terminate us the usual way rather than be swallowed."""
+    sigs = (signal.SIGINT, signal.SIGTERM)
+    old_handlers = {}
+    old_wakeup = None
+    rd, wr = os.pipe()
+    try:
+        os.set_blocking(wr, False)
+        old_wakeup = signal.set_wakeup_fd(wr)
+        for sig in sigs:
+            old_handlers[sig] = signal.signal(sig, lambda *_: None)
+
+        poller = select.poll()
+        poller.register(fifo_fd, select.POLLERR)
+        poller.register(rd, select.POLLIN)
+        poller.poll()
+    finally:
+        for sig, handler in old_handlers.items():
+            signal.signal(sig, handler)
+        if old_wakeup is not None:
+            signal.set_wakeup_fd(old_wakeup)
+        os.close(rd)
+        os.close(wr)
+
+
+def cmd_capture(iface, fifo, snaplen, queue, cfilter):
+    prefix, port = parse_iface(iface)
+    path = socket_path(prefix)
+
+    # Both are passed to the primary as telemetry parameters; check before
+    # opening anything so a bad one costs nothing.
+    if "," in fifo:
+        raise SystemExit(f"{comma_error('fifo path')}: {fifo}")
+    if cfilter and "," in cfilter:
+        raise SystemExit(comma_error("capture filter"))
+
+    # Blocking open of the FIFO Wireshark created. This rendezvous guarantees a
+    # reader is attached before we ask DPDK to open the write end (DPDK opens
+    # O_WRONLY|O_NONBLOCK and would fail with ENXIO if no reader were present).
+    # We then hold this fd for the whole session: it keeps the pipe from
+    # EOF-ing in the window before the DPDK capture thread opens its own write
+    # end, and it is our reliable stop signal -- POLLERR here means Wireshark
+    # closed the read end.
+    fifo_fd = os.open(fifo, os.O_WRONLY)
+
+    try:
+        tel = Telemetry(path)
+    except OSError as e:
+        os.close(fifo_fd)
+        raise SystemExit(f"cannot connect to DPDK telemetry at {path}: {e}") from e
+
+    params = [str(port), f"out={fifo}"]
+    if snaplen is not None:
+        params.append(f"snaplen={snaplen}")
+    if queue is not None and queue >= 0:
+        params.append(f"queue={queue}")
+    if cfilter:
+        params.append(f"filter={cfilter}")
+    cmd = CAPTURE_START_CMD + "," + ",".join(params)
+
+    try:
+        reply = tel.command(cmd)
+    except OSError as e:
+        os.close(fifo_fd)
+        tel.close()
+        raise SystemExit(f"capture start failed: {e}") from e
+
+    result = (reply or {}).get(CAPTURE_START_CMD) or {}
+    if "error" in result:
+        os.close(fifo_fd)
+        tel.close()
+        raise SystemExit(f"capture start failed: {result['error']}")
+
+    cap_id = result.get("id")
+
+    # Run until Wireshark stops us (signal or read-end close). DPDK now holds
+    # its own write end, so closing ours below won't EOF the reader prematurely.
+    wait_for_stop(fifo_fd)
+
+    # Ask DPDK to stop while we still hold the write end, so if the reader is
+    # still attached it drains the tail and writes the statistics block before
+    # the final EOF. If the read end is already gone DPDK has already stopped
+    # on the same hangup and this is a no-op.
+    if cap_id is not None:
+        try:
+            tel.command(f"{CAPTURE_STOP_CMD},{cap_id}")
+        except OSError:
+            pass
+
+    os.close(fifo_fd)
+    tel.close()
+
+
+# --- entry point ---------------------------------------------------------
+
+
+def main():
+    p = argparse.ArgumentParser(
+        prog="dpdk-wireshark-extcap.py",
+        allow_abbrev=False,
+        description="Wireshark extcap plugin for live packet capture from the "
+        "Ethernet ports of a running DPDK application. Normally "
+        "invoked by Wireshark; see the DPDK Wireshark extcap guide.",
+    )
+    p.add_argument("--version", action="version", version=f"%(prog)s {EXTCAP_VERSION}")
+
+    p.add_argument("--extcap-interfaces", action="store_true")
+    p.add_argument("--extcap-dlts", action="store_true")
+    p.add_argument("--extcap-config", action="store_true")
+    p.add_argument("--capture", action="store_true")
+    p.add_argument("--extcap-interface")
+    p.add_argument("--fifo")
+    p.add_argument("--extcap-capture-filter")
+    p.add_argument("--extcap-version", nargs="?")
+    p.add_argument("--snaplen", type=int)
+    p.add_argument("--queue", type=int)
+    args, _ = p.parse_known_args()
+
+    if args.extcap_interfaces:
+        cmd_interfaces()
+    elif args.extcap_dlts:
+        cmd_dlts(args.extcap_interface)
+    elif args.extcap_config:
+        cmd_config(args.extcap_interface)
+    elif args.capture:
+        if not args.extcap_interface or not args.fifo:
+            raise SystemExit("--capture requires --extcap-interface and --fifo")
+        cmd_capture(
+            args.extcap_interface,
+            args.fifo,
+            args.snaplen,
+            args.queue,
+            args.extcap_capture_filter,
+        )
+    elif args.extcap_capture_filter:
+        # Wireshark validates a capture filter by invoking the extcap with the
+        # filter but without --capture (see doc/extcap_example.py upstream).
+        # Wireshark already syntax-checks it with libpcap against our DLT
+        # (EN10MB) and DPDK compiles it again at capture start, so the only
+        # thing left to reject here is what the telemetry encoding cannot
+        # carry.
+        #
+        # No output means "accepted"; any output marks the filter invalid and
+        # its first line is shown to the user. The exit status must stay 0
+        # either way: Wireshark discards the output of a child that exited
+        # non-zero and reports the filter as unknown rather than invalid.
+        if "," in args.extcap_capture_filter:
+            print(comma_error("capture filter"))
+    else:
+        raise SystemExit("no extcap operation specified")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/usertools/meson.build b/usertools/meson.build
index 114d0a65b4..5debf73702 100644
--- a/usertools/meson.build
+++ b/usertools/meson.build
@@ -13,6 +13,7 @@ install_data([
             'dpdk-rss-flows.py',
             'dpdk-telemetry-exporter.py',
             'dpdk-telemetry-watcher.py',
+            'dpdk-wireshark-extcap.py',
         ],
         install_dir: 'bin')
 
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* Re: [PATCH v2 4/4] usertools: add script for wireshark capture
  2026-07-27 22:42   ` [PATCH v2 4/4] usertools: add script for wireshark capture Stephen Hemminger
@ 2026-07-29 23:28     ` Stephen Hemminger
  0 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-29 23:28 UTC (permalink / raw)
  To: dev; +Cc: Thomas Monjalon, Reshma Pattan, Robin Jarry

On Mon, 27 Jul 2026 15:42:47 -0700
Stephen Hemminger <stephen@networkplumber.org> wrote:

> Provide glue script that wireshark can use to access
> telemetry based packet capture. It is dual licensed because
> it maybe desirable to put this in wireshark repository.
> 
> See https://www.wireshark.org/docs/man-pages/extcap.html
> 
> Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
> ---

Recheck-request: iol-unit-arm64-testing

^ permalink raw reply	[flat|nested] 23+ messages in thread

* [PATCH v3 0/4] Wireshark external capture
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
                     ` (3 preceding siblings ...)
  2026-07-27 22:42   ` [PATCH v2 4/4] usertools: add script for wireshark capture Stephen Hemminger
@ 2026-07-30 18:22   ` Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 1/4] pcapng: extend interface statistics Stephen Hemminger
                       ` (3 more replies)
  2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
  5 siblings, 4 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-30 18:22 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This series adds live packet capture from a running DPDK primary over the
telemetry socket. A new capture library installs Rx/Tx callbacks on an
ethdev port and writes pcapng to a caller-supplied path or FIFO, with an
optional libpcap filter and per-queue selection.

Advantages of this:
 - no primary/secondary needed; works on Linux and FreeBSD.
   can also work on Windows with pcap support.
 - installation is just putting script in Wireshark directory.
 - script has no dependency on DPDK libraries; can be packaged with Wireshark.
 - allows for live capture
 - supports BPF (with JIT) for filtering

How it works:
 - The capture library registers telemetry commands (/ethdev/capture/*)
 - Callbacks are installed on the specified port/queues to copy packets
 - Packets are formatted as pcapng and written to the specified output
 - No packet data passes through the telemetry socket itself

The pcapng statistics interface has been extended to include the
filter-accepted packet count, which was previously missing.

Testing:
  - Run: dpdk-wireshark-extcap.py --extcap-interfaces to list available ports
  - The script should be installed in Wireshark's extcap directory for GUI integration
  - Comprehensive unit tests are included in app/test/test_capture.c
  - Tested on Linux and FreeBSD

v3 - fixed AI review feedback

Stephen Hemminger (4):
  pcapng: extend interface statistics
  capture: infrastructure wireshark packet capture
  test: add test for capture hooks
  usertools: add script for wireshark capture

 MAINTAINERS                            |    4 +
 app/dumpcap/main.c                     |   16 +-
 app/test/meson.build                   |    1 +
 app/test/test_capture.c                |  660 +++++++++++++
 app/test/test_pcapng.c                 |   10 +-
 doc/guides/prog_guide/capture_lib.rst  |  168 ++++
 doc/guides/prog_guide/index.rst        |    1 +
 doc/guides/rel_notes/release_26_11.rst |   14 +
 doc/guides/tools/index.rst             |    1 +
 doc/guides/tools/wireshark_extcap.rst  |  144 +++
 lib/capture/capture.c                  | 1202 ++++++++++++++++++++++++
 lib/capture/capture_impl.h             |   56 ++
 lib/capture/filter.c                   |  110 +++
 lib/capture/meson.build                |   19 +
 lib/meson.build                        |    1 +
 lib/pcapng/rte_pcapng.c                |   50 +-
 lib/pcapng/rte_pcapng.h                |   33 +-
 usertools/dpdk-wireshark-extcap.py     |  416 ++++++++
 usertools/meson.build                  |    1 +
 19 files changed, 2874 insertions(+), 33 deletions(-)
 create mode 100644 app/test/test_capture.c
 create mode 100644 doc/guides/prog_guide/capture_lib.rst
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/capture_impl.h
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build
 create mode 100755 usertools/dpdk-wireshark-extcap.py

-- 
2.53.0


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [PATCH v3 1/4] pcapng: extend interface statistics
  2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
@ 2026-07-30 18:22     ` Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
                       ` (2 subsequent siblings)
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-30 18:22 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Reshma Pattan

The number of packets filtered was not implemented in original
code but useful for analysis.
Modify the API for writing pcapng statistics.
Use a structure and make it extensible in future.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 app/dumpcap/main.c                     | 16 ++++-----
 app/test/test_pcapng.c                 | 10 +++---
 doc/guides/rel_notes/release_26_11.rst |  9 +++++
 lib/pcapng/rte_pcapng.c                | 50 +++++++++++++++++++-------
 lib/pcapng/rte_pcapng.h                | 33 ++++++++++++-----
 5 files changed, 85 insertions(+), 33 deletions(-)

diff --git a/app/dumpcap/main.c b/app/dumpcap/main.c
index 46a6cb251e..baec68b0b9 100644
--- a/app/dumpcap/main.c
+++ b/app/dumpcap/main.c
@@ -574,8 +574,8 @@ static void
 report_packet_stats(dumpcap_out_t out)
 {
 	struct rte_pdump_stats pdump_stats;
+	struct rte_pcapng_interface_stats isb;
 	struct interface *intf;
-	uint64_t ifrecv, ifdrop;
 	double percent;
 
 	fputc('\n', stderr);
@@ -584,22 +584,22 @@ report_packet_stats(dumpcap_out_t out)
 			continue;
 
 		/* do what Wiretap does */
-		ifrecv = pdump_stats.accepted + pdump_stats.filtered;
-		ifdrop = pdump_stats.nombuf + pdump_stats.ringfull;
+		isb.ifrecv = pdump_stats.accepted + pdump_stats.filtered;
+		isb.ifdrop = pdump_stats.nombuf + pdump_stats.ringfull;
+		isb.filteraccept = pdump_stats.accepted;
 
 		if (use_pcapng)
-			rte_pcapng_write_stats(out.pcapng, intf->port,
-					       ifrecv, ifdrop, NULL);
+			rte_pcapng_write_stats(out.pcapng, intf->port, &isb, sizeof(isb), NULL);
 
-		if (ifrecv == 0)
+		if (isb.ifrecv == 0)
 			percent = 0;
 		else
-			percent = 100. * ifrecv / (ifrecv + ifdrop);
+			percent = 100. * isb.ifrecv / (isb.ifrecv + isb.ifdrop);
 
 		fprintf(stderr,
 			"Packets received/dropped on interface '%s': "
 			"%"PRIu64 "/%" PRIu64 " (%.1f)\n",
-			intf->name, ifrecv, ifdrop, percent);
+			intf->name, isb.ifrecv, isb.ifdrop, percent);
 	}
 }
 
diff --git a/app/test/test_pcapng.c b/app/test/test_pcapng.c
index d14ea84f0d..c62fdc1458 100644
--- a/app/test/test_pcapng.c
+++ b/app/test/test_pcapng.c
@@ -558,8 +558,9 @@ test_write_packets(void)
 		goto fail;
 	}
 
-	/* write a statistics block */
-	ret = rte_pcapng_write_stats(pcapng, port_id, 0, 0, NULL);
+	/* write an initial statistics block */
+	struct rte_pcapng_interface_stats isb = { };
+	ret = rte_pcapng_write_stats(pcapng, port_id, &isb, sizeof(isb), NULL);
 	if (ret <= 0) {
 		printf("Write of statistics failed\n");
 		goto fail;
@@ -569,9 +570,10 @@ test_write_packets(void)
 	if (count < 0)
 		goto fail;
 
-	/* write a statistics block */
+	/* write a final statistics block with comment */
+	isb.ifrecv = count;
 	ret = rte_pcapng_write_stats(pcapng, port_id,
-				     count, 0, "end of test");
+				     &isb, sizeof(isb), "end of test");
 	if (ret <= 0) {
 		printf("Write of statistics failed\n");
 		goto fail;
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index c8cc86295d..cdc3c99f64 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -93,6 +93,11 @@ API Changes
    Also, make sure to start the actual text at the margin.
    =======================================================
 
+* **pcapng: add packet filtering statistic.**
+
+  The API for ``rte_pcapng_write_stats`` was changed to include
+  recording the number of filtered packets.
+
 
 ABI Changes
 -----------
@@ -109,6 +114,10 @@ ABI Changes
    Also, make sure to start the actual text at the margin.
    =======================================================
 
+* **pcapng: add packet filtering statistic.**
+
+  The ABI for ``rte_pcapng_write_stats`` now takes pointer to statistics.
+
 
 Known Issues
 ------------
diff --git a/lib/pcapng/rte_pcapng.c b/lib/pcapng/rte_pcapng.c
index b5d1026891..fed1581772 100644
--- a/lib/pcapng/rte_pcapng.c
+++ b/lib/pcapng/rte_pcapng.c
@@ -277,6 +277,9 @@ rte_pcapng_add_interface(rte_pcapng_t *self, uint16_t port, uint16_t link_type,
 	uint64_t speed = 0;
 	int ret;
 
+	if (self == NULL)
+		return -EINVAL;
+
 	ret = rte_eth_dev_info_get(port, &dev_info);
 	if (ret < 0)
 		return -1;  /* should be ret */
@@ -394,29 +397,43 @@ rte_pcapng_add_interface(rte_pcapng_t *self, uint16_t port, uint16_t link_type,
 RTE_EXPORT_SYMBOL(rte_pcapng_write_stats)
 ssize_t
 rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
-		       uint64_t ifrecv, uint64_t ifdrop,
-		       const char *comment)
+		       const struct rte_pcapng_interface_stats *stats,
+		       size_t stats_sz, const char *comment)
 {
+	struct rte_pcapng_interface_stats isb;
 	struct pcapng_statistics *hdr;
 	struct pcapng_option *opt;
-	uint64_t start_time = self->clock.ns_base;
-	uint64_t sample_time;
+	uint64_t start_time, sample_time;
 	uint32_t optlen, len;
 	uint32_t *buf;
 	ssize_t ret;
 
+	if (self == NULL)
+		return -EINVAL;
+
 	RTE_ETH_VALID_PORTID_OR_ERR_RET(port_id, -EINVAL);
 
+	if (stats == NULL || stats_sz == 0 || stats_sz > sizeof(*stats))
+		return -EINVAL;
+
 	if (comment && strlen(comment) > PCAPNG_STR_MAX)
 		return -EINVAL;
 
+	/* Future proof for more/less stats - all UINT64_MAX */
+	memset(&isb, 0xff, sizeof(isb));
+	memcpy(&isb, stats, stats_sz);
+
 	optlen = 0;
 
-	if (ifrecv != UINT64_MAX)
-		optlen += pcapng_optlen(sizeof(ifrecv));
-	if (ifdrop != UINT64_MAX)
-		optlen += pcapng_optlen(sizeof(ifdrop));
+	/* compute how many stats will be added. */
+	if (isb.ifrecv != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.ifrecv));
+	if (isb.ifdrop != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.ifdrop));
+	if (isb.filteraccept != UINT64_MAX)
+		optlen += pcapng_optlen(sizeof(isb.filteraccept));
 
+	start_time = self->clock.ns_base;
 	if (start_time != 0)
 		optlen += pcapng_optlen(sizeof(start_time));
 
@@ -439,12 +456,16 @@ rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port_id,
 	if (start_time != 0)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_STARTTIME,
 					 &start_time, sizeof(start_time));
-	if (ifrecv != UINT64_MAX)
+	if (isb.ifrecv != UINT64_MAX)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_IFRECV,
-				&ifrecv, sizeof(ifrecv));
-	if (ifdrop != UINT64_MAX)
+					&isb.ifrecv, sizeof(uint64_t));
+	if (isb.ifdrop != UINT64_MAX)
 		opt = pcapng_add_option(opt, PCAPNG_ISB_IFDROP,
-				&ifdrop, sizeof(ifdrop));
+					&isb.ifdrop, sizeof(uint64_t));
+	if (isb.filteraccept != UINT64_MAX)
+		opt = pcapng_add_option(opt, PCAPNG_ISB_FILTERACCEPT,
+					&isb.filteraccept, sizeof(uint64_t));
+
 	if (optlen != 0)
 		opt = pcapng_add_option(opt, PCAPNG_OPT_END, NULL, 0);
 
@@ -717,6 +738,11 @@ rte_pcapng_write_packets(rte_pcapng_t *self,
 	unsigned int i, cnt = 0;
 	ssize_t ret, total = 0;
 
+	if (self == NULL) {
+		rte_errno = EINVAL;
+		return -1;
+	}
+
 	for (i = 0; i < nb_pkts; i++) {
 		struct rte_mbuf *m = pkts[i];
 		struct pcapng_enhance_packet_block *epb;
diff --git a/lib/pcapng/rte_pcapng.h b/lib/pcapng/rte_pcapng.h
index d8d328f710..495b6d3204 100644
--- a/lib/pcapng/rte_pcapng.h
+++ b/lib/pcapng/rte_pcapng.h
@@ -178,21 +178,36 @@ ssize_t
 rte_pcapng_write_packets(rte_pcapng_t *self,
 			 struct rte_mbuf *pkts[], uint16_t nb_pkts);
 
+/**
+ * A structure to store interface statistics in pcapng
+ * Interface statistics block.
+ * If the statistic is unavailable or unknown, use UINT64_MAX.
+ *
+ * Subset of definitions come from IETF pcapng standard.
+ * The osdrop and usrdeliv statistics are not included because
+ * DPDK does not use OS and does not deliver to application over sockets.
+ */
+struct rte_pcapng_interface_stats {
+	uint64_t ifrecv;	/**< Packets received on interface during capture. */
+	uint64_t ifdrop;	/**< Packets dropped by interface due to lack of resources. */
+	uint64_t filteraccept;	/**< Packets accepted by filter. */
+};
+
 /**
  * Write an Interface statistics block.
- * For statistics, use 0 if don't know or care to report it.
  * Should be called before closing capture to report results.
  *
  * @param self
  *  The handle to the packet capture file
  * @param port
  *  The Ethernet port to report stats on.
- * @param ifrecv
- *  The number of packets received by capture.
- *  Optional: use UINT64_MAX if not known.
- * @param ifdrop
- *  The number of packets missed by the capture process.
- *  Optional: use UINT64_MAX if not known.
+ * @param stats
+ *  The statistics to write.
+ * @param stats_sz
+ *  The sizeof statistics structure.
+ *  Must be greater than 0 and not more than sizeof(*stats).
+ *  This allows for future expansion of interface statistics but
+ *  with ABI compatibility.
  * @param comment
  *  Optional comment to add to statistics.
  * @return
@@ -203,8 +218,8 @@ rte_pcapng_write_packets(rte_pcapng_t *self,
  */
 ssize_t
 rte_pcapng_write_stats(rte_pcapng_t *self, uint16_t port,
-		       uint64_t ifrecv, uint64_t ifdrop,
-		       const char *comment);
+		       const struct rte_pcapng_interface_stats *stats,
+		       size_t stats_sz, const char *comment);
 
 #ifdef __cplusplus
 }
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v3 2/4] capture: infrastructure wireshark packet capture
  2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 1/4] pcapng: extend interface statistics Stephen Hemminger
@ 2026-07-30 18:22     ` Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 3/4] test: add test for capture hooks Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 4/4] usertools: add script for wireshark capture Stephen Hemminger
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-30 18:22 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Thomas Monjalon, Reshma Pattan,
	Anatoly Burakov

This provides a telemetry extension to provide packet capture.
It is intended to be used with a front end script to provide
external packet capture for wireshark.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |    1 +
 doc/guides/prog_guide/capture_lib.rst  |  168 ++++
 doc/guides/prog_guide/index.rst        |    1 +
 doc/guides/rel_notes/release_26_11.rst |    5 +
 lib/capture/capture.c                  | 1202 ++++++++++++++++++++++++
 lib/capture/capture_impl.h             |   56 ++
 lib/capture/filter.c                   |  110 +++
 lib/capture/meson.build                |   19 +
 lib/meson.build                        |    1 +
 9 files changed, 1563 insertions(+)
 create mode 100644 doc/guides/prog_guide/capture_lib.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/capture_impl.h
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build

diff --git a/MAINTAINERS b/MAINTAINERS
index e99a65d197..fcd350ad94 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1720,6 +1720,7 @@ F: doc/guides/sample_app_ug/qos_scheduler.rst
 Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
+F: lib/capture/
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/doc/guides/prog_guide/capture_lib.rst b/doc/guides/prog_guide/capture_lib.rst
new file mode 100644
index 0000000000..a956ab9f20
--- /dev/null
+++ b/doc/guides/prog_guide/capture_lib.rst
@@ -0,0 +1,168 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Packet Capture Library
+======================
+
+The capture library records packets going in and out of the Ethernet ports of a
+running DPDK primary process, and writes them in pcapng format to a file or a
+FIFO.
+
+Unlike the older pdump library, no secondary process is involved and the
+application needs no source changes and no additional EAL arguments. The
+library registers itself at load time and is driven entirely over the
+:doc:`telemetry <telemetry_lib>` socket, so capture can be started on an
+already running application, including a sealed vendor appliance.
+
+Any telemetry client can drive it; see :doc:`../howto/telemetry` for the
+``dpdk-telemetry.py`` script used in the examples below.
+
+Operation
+---------
+
+When a capture is started the library installs Rx and Tx callbacks on the
+requested queues of the port. Each callback copies matching packets into a
+new mbuf already formatted as a pcapng Enhanced Packet Block
+(``rte_pcapng_copy``) and enqueues it on a ring. A dedicated capture thread
+drains that ring and writes the blocks to the output.
+The receive and transmit callbacks do not block, but the capture
+thread may block. The capture file is set into blocking mode so that if an
+external reader cannot keep up, the capture thread will block writing to FIFO.
+
+The callbacks copy the packet; the original mbuf is not modified and is
+returned to the datapath unchanged. Packets are dropped, and counted,
+if an mbuf cannot be allocated or if the ring is full.
+
+Several captures can be active at once, including on the same port and queue.
+Each has its own id, filter, ring, output and callbacks, and stopping one does
+not disturb the others. Each capture runs its own filter
+and copies the packets that match.
+
+Requirements
+------------
+
+* Capture can only be started in a primary process.
+
+* Filtering requires that DPDK was built with ``libpcap`` available. Without
+  it, a capture that specifies a filter will fail to start.
+
+* The library can be excluded from a build with
+  ``-Ddisable_libs=capture``.
+
+Telemetry commands
+------------------
+
+.. csv-table:: Capture telemetry commands
+   :header: "Command", "Parameters"
+   :widths: 30, 50
+
+   "``/ethdev/capture/start``", "port id and options, see below"
+   "``/ethdev/capture/stop``", "capture id"
+   "``/ethdev/capture/list``", "none"
+   "``/ethdev/capture/stats``", "capture id"
+
+``/ethdev/capture/start`` takes the port id as its first parameter, followed by
+``name=value`` options separated by commas:
+
+``out=<path>``
+   Required. Path of the output, which must already exist and be either a FIFO
+   or an empty regular file.
+
+``snaplen=<n>``
+   Number of bytes to capture from each packet. Default is 262144;
+   ``snaplen=0`` means capture the whole packet.
+
+``queue=<n>``
+   Capture only this hardware queue. Default is all queues of the port.
+
+``filter=<expression>``
+   A libpcap filter expression, compiled to eBPF and run over each packet.
+
+Because the parameter string is split on commas, neither the output path nor
+the filter expression may contain one.
+
+On success the reply contains the capture ``id``, which is used to stop the
+capture and to query its statistics. On failure the reply contains an
+``error`` string. The whole parameter string is limited to 1024 bytes.
+
+.. note::
+
+   The telemetry commands for capture are experimental
+   and may change without warning in future releases.
+
+Output
+------
+
+The output file or FIFO must exist otherwise the capture fails to start.
+If it is a regular file, it must be empty. A pcapng file begins with a
+section header and an interface description block,
+which the library writes before the first packet;
+therefore it is not valid to append to an existing file.
+
+If the output is a FIFO the reader must have already opened it.
+This is to prevent capture thread blocking waiting for reader.
+
+A capture runs until ``/ethdev/capture/stop`` is called,
+or an error is detected such as when the reader of a FIFO goes away.
+
+Statistics
+----------
+
+``/ethdev/capture/stats`` reports the configuration of a capture and the
+following counters, summed over all queues:
+
+``accepted``
+   Packets copied for capture.
+
+``filtered``
+   Packets rejected by the filter.
+
+``nombuf``
+   Packets missed because no mbuf was available for the copy.
+
+``ringfull``
+   Packets missed because the capture thread was not keeping up.
+
+The same values are mapped onto the Interface Statistics Block written at the
+end of the capture: ``ifrecv`` is the number of packets seen, ``filteraccept``
+the subset that passed the filter, and ``ifdrop`` the packets that were lost.
+
+Example
+-------
+
+Normally, the capture API is intended to be used by the Wireshark
+external capture script, but it can be tested using ``dpdk-telemetry.py``
+against a running application.
+
+An example, capturing TCP traffic on port 0 into a file::
+
+   --> /ethdev/capture/start,0,out=/tmp/port0.pcapng,snaplen=128,filter=tcp
+   {"/ethdev/capture/start": {"id": 0, "status": "running"}}
+
+   --> /ethdev/capture/stats,0
+   {"/ethdev/capture/stats": {"port_id": 0, "filter": "tcp", "running": 1, \
+    "snaplen": 128, "rx_queues": 4, "tx_queues": 4, "accepted": 1200, \
+    "filtered": 87, "nombuf": 0, "ringfull": 0}}
+
+   --> /ethdev/capture/stop,0
+   {"/ethdev/capture/stop": {"status": "stopped"}}
+
+Using Wireshark
+---------------
+
+The ``dpdk-wireshark-extcap.py`` script presents the ports of a running DPDK
+application as Wireshark capture interfaces, using these telemetry commands
+with a FIFO created by Wireshark as the output.
+See :doc:`../tools/wireshark_extcap`.
+
+Limitations
+-----------
+
+* Only the primary process can capture; packets handled by a secondary process
+  are not seen.
+
+* Each capture covers a single port. Capturing several ports means starting
+  several captures.
+
+* Captured packets are those seen by the ethdev layer, so packets dropped by
+  the hardware or the driver do not appear.
diff --git a/doc/guides/prog_guide/index.rst b/doc/guides/prog_guide/index.rst
index b9c0d59728..7e869db132 100644
--- a/doc/guides/prog_guide/index.rst
+++ b/doc/guides/prog_guide/index.rst
@@ -129,6 +129,7 @@ Utility Libraries
     log_lib
     metrics_lib
     telemetry_lib
+    capture_lib
     pdump_lib
     pcapng_lib
     bpf_lib
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index cdc3c99f64..cd53fc6b9f 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -55,6 +55,11 @@ New Features
      Also, make sure to start the actual text at the margin.
      =======================================================
 
+* **Added wireshark capture support.**
+
+  * New ``capture`` library for packet capture accessed via telemetry API.
+
+
 
 Removed Items
 -------------
diff --git a/lib/capture/capture.c b/lib/capture/capture.c
new file mode 100644
index 0000000000..6ae8b3a5f6
--- /dev/null
+++ b/lib/capture/capture.c
@@ -0,0 +1,1202 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/queue.h>
+#include <sys/stat.h>
+#include <sys/utsname.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <rte_branch_prediction.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_debug.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_memory.h>
+#include <rte_mempool.h>
+#include <rte_mbuf.h>
+#include <rte_pcapng.h>
+#include <rte_pause.h>
+#include <rte_ring.h>
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+#include <rte_string_fns.h>
+#include <rte_telemetry.h>
+#include <rte_thread.h>
+#include <rte_version.h>
+
+#include "capture_impl.h"
+
+#define CAPTURE_EXTCAP_PREFIX	"dpdk"
+#define CAP_CMD_MAX		1024
+#define DEFAULT_SNAPLEN		262144u	/* from tcpdump et.al. */
+#define CAPTURE_BURST_SIZE	32u
+#define MBUF_POOL_CACHE_SIZE	32
+#define CAPTURE_RING_SIZE	256
+#define CAPTURE_POOL_SIZE	1024
+#define SLEEP_THRESHOLD		100
+#define SLEEP_US		100
+
+#define ALL_QUEUES		UINT16_MAX
+
+RTE_LOG_REGISTER_DEFAULT(rte_capture_logtype, NOTICE);
+
+/*
+ * List of active captures.
+ *
+ * This is a control-plane only structure: it is created, walked and torn down
+ * from the telemetry handler thread and from the per-capture drain threads,
+ * never from the dataplane. A plain spinlock is therefore enough; the EAL
+ * shared tailq (rte_tailq) is not used because captures are not visible to
+ * secondary processes in this design.
+ */
+TAILQ_HEAD(capture_list, capture);
+static struct capture_list capture_list = TAILQ_HEAD_INITIALIZER(capture_list);
+static rte_spinlock_t capture_lock = RTE_SPINLOCK_INITIALIZER;
+
+/* Parameter values: only used on stack inside parsing */
+struct capture_config {
+	uint16_t port_id;
+	uint16_t queue;
+	uint32_t snaplen;
+	const char *filter_str;
+	const char *output;
+};
+
+/* Per port/queue/direction counters. */
+struct capture_stats {
+	uint64_t accepted;	/**< Number of packets accepted by filter. */
+	uint64_t filtered;	/**< Number of packets rejected by filter. */
+	uint64_t nombuf;	/**< Number of mbuf allocation failures. */
+	uint64_t ringfull;	/**< Number of missed packets due to ring full. */
+};
+
+/* Aggregate per-queue counters of a capture instance. */
+struct capture_total {
+	uint64_t accepted;
+	uint64_t filtered;
+	uint64_t nombuf;
+	uint64_t ringfull;
+};
+
+/*
+ * Data used by callback, one per capture per port/queue/direction.
+ *
+ * These are allocated on demand and never freed, following the same reasoning
+ * as lib/bpf/bpf_pkt.c: rte_eth_remove_{rx,tx}_callback() only unlinks the
+ * callback, so a datapath thread that loaded the callback list just before the
+ * removal still enters capture_rx()/capture_tx() afterwards. Keeping this block
+ * alive for the life of the process gives that thread something valid to
+ * synchronize on. The struct capture it points at is freed at teardown, and
+ * that is what the handshake below protects.
+ *
+ * A detached block is recycled, but only by a later capture on the same
+ * port/queue/direction. That bounds the pool at the peak number of concurrent
+ * captures of a queue, and means a datapath thread arriving late can only ever
+ * be handed a capture that covers the packets it already holds.
+ */
+struct __rte_cache_aligned capture_rxtx_cb {
+	/* used by both data and control path */
+	RTE_ATOMIC(uint32_t) use_count;
+	RTE_ATOMIC(struct capture *) cap;	/* owner, NULL when idle */
+	struct capture_stats stats;
+
+	/* used by control path only */
+	TAILQ_ENTRY(capture_rxtx_cb) next;	/* links into capture_cb_freelist */
+	const struct rte_eth_rxtx_callback *cb;
+	const struct rte_eth_rxtx_callback *stale;
+	uint16_t port_id;
+	uint16_t queue;
+	bool is_rx;
+};
+
+/*
+ * Idle callback blocks, guarded by capture_lock.
+ *
+ * A block is on this list exactly when no capture owns it; an owned one is
+ * reachable only from its owner's cbs[] array. Blocks are moved between the
+ * two, never freed.
+ */
+TAILQ_HEAD(capture_cb_list, capture_rxtx_cb);
+static struct capture_cb_list capture_cb_freelist =
+	TAILQ_HEAD_INITIALIZER(capture_cb_freelist);
+
+/*
+ * Per-capture instance state.
+ */
+struct capture {
+	RTE_ATOMIC(bool) running;
+	struct rte_capture_filter *filter;
+	struct rte_ring *ring;		/* ring from dataplane to capture thread */
+	struct rte_mempool *mp;		/* mempool for capture mbufs */
+
+	uint32_t snaplen;		/* amount of data to copy */
+	uint16_t queue;
+	uint16_t tx_queues;
+	uint16_t rx_queues;
+	uint16_t port_id;
+	int socket_id;
+
+	uint32_t idx;
+	char *output;			/* filename of out */
+
+	TAILQ_ENTRY(capture) next;	/* links into capture_list */
+
+	/* counters of queues already detached; control path only */
+	struct capture_total total;
+
+	/* callback blocks attached by this capture; control path only */
+	uint16_t nb_cbs;
+	struct capture_rxtx_cb *cbs[];
+};
+
+/*
+ * Detach a callback block and wait for the datapath to let go of it.
+ *
+ * Clearing cap and then loading use_count is the mirror image of what
+ * capture_cb_hold() does (bump use_count, then load cap). With a sequentially
+ * consistent fence between the store and the load on both sides, at least one
+ * of the two observes the other: either this thread sees an odd use_count and
+ * waits for the burst in progress to finish, or that burst sees a NULL cap and
+ * returns without ever dereferencing it. Neither can slip through, so once
+ * this returns the capture is safe to free.
+ */
+static void
+capture_cb_detach(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_store_explicit(&cbs->cap, NULL, rte_memory_order_relaxed);
+
+	/* the store of cap must be visible before the use_count load below */
+	rte_atomic_thread_fence(rte_memory_order_seq_cst);
+
+	/* wait until use_count is even (not in use) */
+	RTE_WAIT_UNTIL_MASKED(&cbs->use_count, 1, ==, 0, rte_memory_order_acquire);
+}
+
+/* Mark datapath iteration in progress: count becomes odd. */
+static inline __rte_hot void
+capture_cb_hold(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_relaxed);
+
+	/* the store of use_count must be visible before the cap load */
+	rte_atomic_thread_fence(rte_memory_order_seq_cst);
+}
+
+/* Iteration finished: count becomes even again. */
+static inline __rte_hot void
+capture_cb_release(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_release);
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static inline __rte_hot void
+capture_copy_burst(uint16_t port_id, uint16_t queue_id,
+		   enum rte_pcapng_direction direction,
+		   struct rte_mbuf **pkts, unsigned int nb_pkts,
+		   const struct capture *cap,
+		   struct capture_stats *stats)
+{
+	unsigned int i, ring_enq, d_pkts = 0;
+	struct rte_mbuf *dup_bufs[CAPTURE_BURST_SIZE]; /* duplicated packets */
+	struct rte_ring *ring = cap->ring;
+	struct rte_mempool *mp = cap->mp;
+	uint32_t snaplen = cap->snaplen;
+	struct rte_mbuf *p;
+
+	RTE_ASSERT(nb_pkts <= CAPTURE_BURST_SIZE);
+
+	for (i = 0; i < nb_pkts; i++) {
+		/*
+		 * This uses same BPF return value convention as socket filter
+		 * and pcap_offline_filter. If program returns zero then packet
+		 * doesn't match the filter (will be ignored).
+		 */
+		if (cap->filter) {
+			if (__rte_capture_filter(cap->filter, pkts[i]) == 0) {
+				stats->filtered++;
+				continue;
+			}
+		}
+
+		p = rte_pcapng_copy(port_id, queue_id, pkts[i], mp, snaplen, direction, NULL);
+		if (unlikely(p == NULL))
+			stats->nombuf++;
+		else
+			dup_bufs[d_pkts++] = p;
+	}
+
+	if (d_pkts == 0)
+		return;
+
+	stats->accepted += d_pkts;
+
+	ring_enq = rte_ring_enqueue_burst(ring, (void *)&dup_bufs[0], d_pkts, NULL);
+	if (unlikely(ring_enq < d_pkts)) {
+		unsigned int drops = d_pkts - ring_enq;
+
+		stats->ringfull += drops;
+		rte_pktmbuf_free_bulk(&dup_bufs[ring_enq], drops);
+	}
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static __rte_hot inline void
+capture_copy(uint16_t port_id, uint16_t queue_id,
+	     enum rte_pcapng_direction direction,
+	     struct rte_mbuf **pkts, uint16_t nb_pkts,
+	     const struct capture *cap,
+	     struct capture_stats *stats)
+{
+	unsigned int offs = 0;
+
+	do {
+		unsigned int n = RTE_MIN(nb_pkts - offs, CAPTURE_BURST_SIZE);
+
+		capture_copy_burst(port_id, queue_id, direction, &pkts[offs], n, cap, stats);
+		offs += n;
+	} while (offs < nb_pkts);
+}
+
+static __rte_hot uint16_t
+capture_rx(uint16_t port, uint16_t queue,
+	struct rte_mbuf **pkts, uint16_t nb_pkts,
+	uint16_t max_pkts __rte_unused, void *user_params)
+{
+	struct capture_rxtx_cb *cbs = user_params;
+	const struct capture *cap;
+
+	capture_cb_hold(cbs);
+
+	/*
+	 * A NULL owner means the capture was torn down while this burst was on
+	 * its way in. See capture_cb_detach() for why this load cannot miss it.
+	 * Acquire pairs with the release store in capture_attach(), so a block
+	 * recycled by a new capture is fully prepared before it is used here.
+	 */
+	cap = rte_atomic_load_explicit(&cbs->cap, rte_memory_order_acquire);
+	if (likely(cap != NULL))
+		capture_copy(port, queue, RTE_PCAPNG_DIRECTION_IN,
+			     pkts, nb_pkts, cap, &cbs->stats);
+
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+static __rte_hot uint16_t
+capture_tx(uint16_t port, uint16_t queue,
+	   struct rte_mbuf **pkts, uint16_t nb_pkts, void *user_params)
+{
+	struct capture_rxtx_cb *cbs = user_params;
+	const struct capture *cap;
+
+	capture_cb_hold(cbs);
+
+	cap = rte_atomic_load_explicit(&cbs->cap, rte_memory_order_acquire);
+	if (likely(cap != NULL))
+		capture_copy(port, queue, RTE_PCAPNG_DIRECTION_OUT,
+			     pkts, nb_pkts, cap, &cbs->stats);
+
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+
+/*
+ * Take an idle callback block for a port/queue/direction off the free list,
+ * allocating another when every block for that queue is already in use.
+ *
+ * Several captures may cover the same queue, each with its own filter and its
+ * own ethdev callback, so this hands out one block per capture.
+ */
+static struct capture_rxtx_cb *
+capture_cb_get(struct capture *cap, uint16_t queue, bool is_rx)
+{
+	struct capture_rxtx_cb *cbs;
+
+	rte_spinlock_lock(&capture_lock);
+
+	TAILQ_FOREACH(cbs, &capture_cb_freelist, next) {
+		if (cbs->port_id == cap->port_id && cbs->queue == queue &&
+		    cbs->is_rx == is_rx) {
+			TAILQ_REMOVE(&capture_cb_freelist, cbs, next);
+			break;
+		}
+	}
+
+	rte_spinlock_unlock(&capture_lock);
+
+	if (cbs == NULL) {
+		cbs = rte_zmalloc_socket("capture_cb", sizeof(*cbs),
+					 RTE_CACHE_LINE_SIZE, cap->socket_id);
+		if (cbs == NULL)
+			return NULL;
+		cbs->port_id = cap->port_id;
+		cbs->queue = queue;
+		cbs->is_rx = is_rx;
+	}
+
+	return cbs;
+}
+
+/* Hand a detached block back for reuse. */
+static void
+capture_cb_put(struct capture_rxtx_cb *cbs)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_cb_freelist, cbs, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+/* Install one callback, on a block that is now ours. */
+static int
+capture_attach(struct capture *cap, uint16_t queue, bool is_rx)
+{
+	struct capture_rxtx_cb *cbs;
+
+	cbs = capture_cb_get(cap, queue, is_rx);
+	if (cbs == NULL) {
+		CAPTURE_LOG(ERR, "No callback block for %u:%u %s",
+			    cap->port_id, queue, is_rx ? "rx" : "tx");
+		return -1;
+	}
+
+	/* the block may have been used by an earlier capture of this queue */
+	memset(&cbs->stats, 0, sizeof(cbs->stats));
+
+	/*
+	 * Release the callback object left over by that earlier capture. It
+	 * cannot be freed at removal time because ethdev reads cb->next after
+	 * our callback returns, so there is no point at which the burst loop is
+	 * known to be done with it. Deferring it to the next attach puts a
+	 * whole capture session in between, which is ample.
+	 */
+	rte_free((void *)(uintptr_t)cbs->stale);
+	cbs->stale = NULL;
+
+	/* publish before the callback can be reached by the datapath */
+	rte_atomic_store_explicit(&cbs->cap, cap, rte_memory_order_release);
+
+	if (is_rx)
+		cbs->cb = rte_eth_add_rx_callback(cap->port_id, queue, capture_rx, cbs);
+	else
+		cbs->cb = rte_eth_add_tx_callback(cap->port_id, queue, capture_tx, cbs);
+
+	if (cbs->cb == NULL) {
+		rte_atomic_store_explicit(&cbs->cap, NULL, rte_memory_order_relaxed);
+		capture_cb_put(cbs);
+		CAPTURE_LOG(ERR, "Register %s callback for %u:%u failed",
+			    is_rx ? "rx" : "tx", cap->port_id, queue);
+		return -1;
+	}
+
+	cap->cbs[cap->nb_cbs++] = cbs;
+	return 0;
+}
+
+/* Install callbacks */
+static int
+capture_add_callbacks(struct capture *cap)
+{
+	for (uint16_t q = 0; q < cap->tx_queues; q++) {
+		if (cap->queue != ALL_QUEUES && cap->queue != q)
+			continue;
+
+		if (capture_attach(cap, q, false) < 0)
+			return -1;
+	}
+
+	for (uint16_t q = 0; q < cap->rx_queues; q++) {
+		if (cap->queue != ALL_QUEUES && cap->queue != q)
+			continue;
+
+		if (capture_attach(cap, q, true) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+static void
+capture_sum_one(struct capture_total *t, const struct capture_stats *s)
+{
+	t->accepted += s->accepted;
+	t->filtered += s->filtered;
+	t->nombuf   += s->nombuf;
+	t->ringfull += s->ringfull;
+}
+
+/*
+ * Sum the counters of the queues still attached plus those already detached.
+ * Caller holds capture_lock.
+ */
+static void
+capture_sum_stats(const struct capture *cap, struct capture_total *t)
+{
+	*t = cap->total;
+
+	for (uint16_t i = 0; i < cap->nb_cbs; i++)
+		capture_sum_one(t, &cap->cbs[i]->stats);
+}
+
+/*
+ * Cleanup call backs.
+ *
+ * Once this returns no datapath thread can reach the capture any more, so the
+ * caller is free to release it. The callback blocks themselves stay behind for
+ * the next capture on the same queue.
+ */
+static void
+capture_remove_callbacks(struct capture *cap)
+{
+	struct capture_total total;
+
+	for (uint16_t i = 0; i < cap->nb_cbs; i++) {
+		struct capture_rxtx_cb *cbs = cap->cbs[i];
+
+		if (cbs->is_rx)
+			rte_eth_remove_rx_callback(cap->port_id, cbs->queue, cbs->cb);
+		else
+			rte_eth_remove_tx_callback(cap->port_id, cbs->queue, cbs->cb);
+
+		/* freed by the next capture that attaches to this queue */
+		cbs->stale = cbs->cb;
+		cbs->cb = NULL;
+
+		capture_cb_detach(cbs);
+	}
+
+	/*
+	 * The blocks go back on the free list, so take the final counters
+	 * before letting go: whoever picks them up next zeroes the stats.
+	 */
+	rte_spinlock_lock(&capture_lock);
+	capture_sum_stats(cap, &total);
+	cap->total = total;
+	for (uint16_t i = 0; i < cap->nb_cbs; i++)
+		TAILQ_INSERT_TAIL(&capture_cb_freelist, cap->cbs[i], next);
+	cap->nb_cbs = 0;
+	rte_spinlock_unlock(&capture_lock);
+}
+
+/* Helper that returns error to telemetry and logs it */
+static void __rte_format_printf(2, 3)
+capture_err(struct rte_tel_data *d, const char *format, ...)
+{
+	va_list ap;
+	char msg[1024];
+
+	va_start(ap, format);
+	vsnprintf(msg, sizeof(msg), format, ap);
+	va_end(ap);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "error", msg);
+
+	CAPTURE_LOG(NOTICE, "%s", msg);
+}
+
+static int
+parse_uint32(const char *str, uint32_t *result, uint32_t limit)
+{
+	unsigned long val;
+	char *endp;
+
+	errno = 0;
+	val = strtoul(str, &endp, 10);
+	if (errno != 0 || *endp != '\0' || val >= limit)
+		return -1;
+
+	*result = val;
+	return 0;
+}
+
+/*
+ * Break the comma separated parameter string into tokens
+ * and fill in the capture config structure.
+ *
+ * Does not use rte_kvargs because that would mangle [] etc in filter expression.
+ */
+static int
+parse_params(char *str, struct capture_config *cfg, struct rte_tel_data *d)
+{
+	uint32_t snaplen = DEFAULT_SNAPLEN;
+	char *args[8];
+	int nargs;
+
+	/* Need at least the port id */
+	nargs = rte_strsplit(str, strlen(str), args, RTE_DIM(args), ',');
+	if (nargs < 1) {
+		capture_err(d, "missing parameters '%s'", str);
+		return -1;
+	}
+
+	/* Parse port id (required) */
+	uint32_t port_id;
+	if (parse_uint32(args[0], &port_id, RTE_MAX_ETHPORTS) < 0) {
+		capture_err(d, "invalid port_id='%s'", args[0]);
+		return -1;
+	}
+
+	/* parse remainder as name=value parameters */
+	for (int i = 1; i < nargs; i++) {
+		char *key = args[i];
+
+		/* split at the = */
+		char *eq = strchr(args[i], '=');
+
+		/* all current options require argument after = */
+		if (eq == NULL || eq[1] == '\0') {
+			capture_err(d,  "missing value for '%s'", key);
+			return -1;
+		}
+		*eq = '\0';
+		char *value = eq + 1;
+
+		if (strcmp(key, "out") == 0) {
+			cfg->output = value;
+		} else if (strcmp(key, "filter") == 0) {
+			cfg->filter_str = value;
+		} else if (strcmp(key, "queue") == 0) {
+			uint32_t q;
+
+			if (parse_uint32(value, &q, RTE_MAX_QUEUES_PER_PORT) < 0) {
+				capture_err(d, "invalid queue '%s'", value);
+				return -1;
+			}
+			cfg->queue = q;
+		} else if (strcmp(key, "snaplen") == 0) {
+			if (parse_uint32(value, &snaplen, UINT32_MAX) < 0) {
+				capture_err(d, "invalid snaplen '%s'", value);
+				return -1;
+			}
+		} else {
+			capture_err(d, "unknown parameter '%s'", key);
+			return -1;
+		}
+	}
+
+	if (cfg->output == NULL) {
+		capture_err(d, "missing output parameter");
+		return -1;
+	}
+
+	cfg->port_id = port_id;
+
+	/*
+	 * Default is 256K from tcpdump legacy
+	 * using snaplen=0 means everything.
+	 */
+	cfg->snaplen = snaplen > 0 ? snaplen : UINT32_MAX;
+	return 0;
+}
+
+static bool is_empty_or_fifo(const struct stat *stb)
+{
+	if (S_ISFIFO(stb->st_mode))
+		return true;
+	else if (S_ISREG(stb->st_mode))
+		return stb->st_size == 0;
+	else
+		return false; /* not a FIFO or regular file */
+}
+
+
+/*
+ * Create the file handle for pcapng output
+ * Note: can't really tell wireshark about errors since this in an
+ * independent thread.
+ */
+static __rte_cold rte_pcapng_t *
+capture_pcapng_open(const char *path, int *fd, uint16_t port_id, const char *filter)
+{
+	rte_pcapng_t *pcapng = NULL;
+	char port_name[RTE_ETH_NAME_MAX_LEN];
+	char appname[128];
+	char ifname[IFNAMSIZ];
+	char *ifdescr = NULL;
+	struct utsname uts;
+	char *osname = NULL;
+
+	/* OS name is optional, just keep going if not found */
+	if (uname(&uts) == 0 && asprintf(&osname, "%s %s", uts.sysname, uts.release) < 0)
+		osname = NULL;
+
+	/* add DPDK internal name */
+	if (rte_eth_dev_get_name_by_port(port_id, port_name) != 0) {
+		CAPTURE_LOG(NOTICE, "Could not find port name for %u", port_id);
+		goto cleanup;
+	}
+
+	/* match name convention used by dpdk-wireshark-extcap.py */
+	snprintf(ifname, sizeof(ifname), CAPTURE_EXTCAP_PREFIX ":%u", port_id);
+	if (asprintf(&ifdescr, "DPDK %s", port_name) < 0)
+		ifdescr = NULL;
+
+	/* mirror what other applications do for name */
+	snprintf(appname, sizeof(appname), CAPTURE_EXTCAP_PREFIX " (%s)", rte_version());
+
+	/*
+	 * Open the output in non-block mode in case it is a FIFO
+	 * without a reader. Wireshark must open the read end before
+	 * asking us to capture.
+	 */
+	*fd = open(path, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
+	if (*fd < 0) {
+		CAPTURE_LOG(ERR, "Could not open %s: %s", path, strerror(errno));
+		goto cleanup;
+	}
+
+	/* recheck that it is ok to use */
+	struct stat sb;
+	if (fstat(*fd, &sb) < 0 || !is_empty_or_fifo(&sb)) {
+		CAPTURE_LOG(ERR, "Not safe to use %s", path);
+		goto close_fd;
+	}
+
+	/* writes in the drain loop should block normally */
+	int flags = fcntl(*fd, F_GETFL, 0);
+	if (flags < 0 || fcntl(*fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
+		CAPTURE_LOG(ERR, "fcntl %s: %s",  path,  strerror(errno));
+		goto close_fd;
+	}
+
+	/* put pcapng header on and setup */
+	pcapng = rte_pcapng_fdopen(*fd, osname, NULL, appname, NULL);
+	if (pcapng == NULL) {
+		CAPTURE_LOG(ERR, "Add section block failed");
+		goto close_fd;
+	}
+
+	if (rte_pcapng_add_interface(pcapng, port_id, DLT_EN10MB, ifname, ifdescr, filter) < 0) {
+		CAPTURE_LOG(ERR, "Add interface for port %u:%s failed", port_id, ifname);
+		rte_pcapng_close(pcapng);  /* closes fd */
+		pcapng = NULL;
+	}
+	goto cleanup;
+
+close_fd:
+	close(*fd);
+cleanup:
+	free(osname);
+	free(ifdescr);
+	return pcapng;
+}
+
+static void
+capture_link(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+static void
+capture_unlink(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_REMOVE(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+static void
+capture_free(struct capture *cap)
+{
+	if (cap == NULL)
+		return;
+
+	free(cap->output);
+	__rte_capture_filter_free(cap->filter);
+	rte_ring_free(cap->ring);
+	rte_mempool_free(cap->mp);
+	rte_free(cap);
+}
+
+/* Generate unique id for naming and telemetry */
+static unsigned int
+get_unique_id(void)
+{
+	static RTE_ATOMIC(unsigned int) capture_instance;
+
+	return rte_atomic_fetch_add_explicit(&capture_instance, 1, rte_memory_order_relaxed);
+}
+
+/*
+ * Convert configuration into running state
+ */
+static struct capture *
+capture_alloc(const struct capture_config *cfg,
+	      const struct rte_eth_dev_info *dev_info,
+	      struct rte_tel_data *d)
+{
+	struct capture *cap;
+	char ring_name[RTE_RING_NAMESIZE];
+	uint16_t mbuf_size;
+
+	/* try and put capture data struct on same node as device. */
+	int socket_id = rte_eth_dev_socket_id(cfg->port_id);
+	if (socket_id < 0)
+		socket_id = SOCKET_ID_ANY;
+
+	uint16_t num_queues = dev_info->nb_tx_queues + dev_info->nb_rx_queues;
+	size_t cb_size = sizeof(*cap) + num_queues * sizeof(cap->cbs[0]);
+	cap = rte_zmalloc_socket("capture", cb_size, RTE_CACHE_LINE_SIZE, socket_id);
+	if (cap == NULL) {
+		capture_err(d, "Could not allocate capture struct");
+		goto error;
+	}
+
+	cap->idx = get_unique_id();
+	snprintf(ring_name, sizeof(ring_name), "capture-%u", cap->idx);
+	cap->ring = rte_ring_create(ring_name, CAPTURE_RING_SIZE, socket_id, 0);
+	if (cap->ring == NULL) {
+		capture_err(d, "Could not create ring");
+		goto error;
+	}
+
+	/*
+	 * If snapshot length is smaller than one mbuf segment then pool
+	 * element size can be reduced; otherwise can just use the default
+	 * and rte_pktmbuf_copy handle multiple segments.
+	 */
+	if (cfg->snaplen < RTE_MBUF_DEFAULT_BUF_SIZE)
+		mbuf_size = rte_pcapng_mbuf_size(cfg->snaplen);
+	else
+		mbuf_size = RTE_MBUF_DEFAULT_BUF_SIZE;
+
+	cap->mp = rte_pktmbuf_pool_create_by_ops(ring_name, CAPTURE_POOL_SIZE,
+						 MBUF_POOL_CACHE_SIZE, 0, mbuf_size,
+						 socket_id, "ring_mp_mc");
+	if (cap->mp == NULL) {
+		capture_err(d,  "Could not create mempool");
+		goto error;
+	}
+
+	if (cfg->filter_str) {
+		cap->filter = __rte_capture_filter_create(cfg->filter_str);
+		if (cap->filter == NULL) {
+			capture_err(d,  "Could not compile filter: %s", cfg->filter_str);
+			goto error;
+		}
+	}
+
+	cap->port_id = cfg->port_id;
+	cap->output = strdup(cfg->output);
+	if (cap->output == NULL) {
+		capture_err(d, "Could not strdup '%s'", cfg->output);
+		goto error;
+	}
+	rte_atomic_store_explicit(&cap->running, true, rte_memory_order_relaxed);
+	cap->snaplen = cfg->snaplen;
+	cap->queue = cfg->queue;
+	cap->socket_id = socket_id;
+	cap->tx_queues = dev_info->nb_tx_queues;
+	cap->rx_queues = dev_info->nb_rx_queues;
+
+	return cap;
+
+error:
+	capture_free(cap);
+	return NULL;
+}
+
+static void
+capture_write_stats(rte_pcapng_t *pcapng, const struct capture *cap)
+{
+	struct capture_total t;
+	struct rte_pcapng_interface_stats isb;
+
+	capture_sum_stats(cap, &t);
+
+	/* Unlike libpcap the ifrecv is the total number of packets
+	 * and filteraccept is the subset that passed.
+	 */
+	isb.ifrecv = t.accepted + t.filtered + t.nombuf;
+	isb.filteraccept = t.accepted + t.nombuf;
+	isb.ifdrop = t.nombuf + t.ringfull;
+
+	rte_pcapng_write_stats(pcapng, cap->port_id, &isb, sizeof(isb), NULL);
+}
+
+/* Check that output file is still ok to write (i.e FIFO not closed).
+ * Used to detect when wireshark has exited, and idle.
+ */
+static int
+check_fifo_status(int fd)
+{
+	struct pollfd pfd = { .fd = fd };
+
+	if (poll(&pfd, 1, 0) < 0) {
+		CAPTURE_LOG(ERR, "poll failed: %s", strerror(errno));
+		return -1;
+	}
+	if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
+		CAPTURE_LOG(NOTICE, "fifo reader closed");
+		return -1;
+	}
+	return 0;
+}
+
+static ssize_t
+capture_process_ring(struct rte_ring *ring, rte_pcapng_t *pcapng,
+		     unsigned int *available)
+{
+	struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+	unsigned int n;
+	ssize_t written;
+
+	n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+				      CAPTURE_BURST_SIZE, available);
+	if (n == 0)
+		return 0;
+
+	written = rte_pcapng_write_packets(pcapng, pkts, n);
+	rte_pktmbuf_free_bulk(pkts, n);
+
+	return written;
+}
+
+static void
+capture_flush_ring(struct rte_ring *ring)
+{
+	for (;;) {
+		struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+		unsigned int n;
+
+		n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+					      CAPTURE_BURST_SIZE, NULL);
+		if (n == 0)
+			break;
+
+		rte_pktmbuf_free_bulk(pkts, n);
+	}
+}
+
+/* The capture thread that moves packets from ring to the pcapng out */
+static uint32_t __rte_hot
+capture_thread(void *arg)
+{
+	struct capture *cap = arg;
+	unsigned int empty_count = 0;
+	bool reader_gone = false;
+	int fd = -1;
+
+	CAPTURE_LOG(INFO, "capture thread starting");
+
+	char name[RTE_THREAD_NAME_SIZE];
+	snprintf(name, sizeof(name), "cap-%u", cap->idx);
+	rte_thread_set_prefixed_name(rte_thread_self(), name);
+
+	/* This thread wants to detect when file gets closed (for FIFO) */
+	sigset_t set;
+	sigemptyset(&set);
+	sigaddset(&set, SIGPIPE);
+	pthread_sigmask(SIG_BLOCK, &set, NULL);
+
+	rte_pcapng_t *pcapng = capture_pcapng_open(cap->output, &fd, cap->port_id,
+						   __rte_capture_filter_string(cap->filter));
+	if (pcapng == NULL) {
+		capture_remove_callbacks(cap);
+		goto error;
+	}
+
+	while (rte_atomic_load_explicit(&cap->running, rte_memory_order_relaxed)) {
+		ssize_t written;
+		unsigned int avail;
+
+		written = capture_process_ring(cap->ring, pcapng, &avail);
+		if (written < 0) {
+			if (errno == EPIPE)
+				CAPTURE_LOG(DEBUG, "FIFO closed by reader");
+			else
+				CAPTURE_LOG(ERR, "write to file failed: %s", strerror(errno));
+
+			reader_gone = true;
+			break;
+		}
+
+		if (written > 0) {
+			/* are there more packets? */
+			empty_count = (avail == 0);
+		} else if (empty_count < SLEEP_THRESHOLD) {
+			/* spin a few times before checking */
+			++empty_count;
+			rte_pause();
+		} else if (check_fifo_status(fd) == 0) {
+			/* avoid consuming 100% CPU polling */
+			rte_delay_us_sleep(SLEEP_US);
+		} else {
+			/* output FIFO has closed */
+			reader_gone = true;
+			break;
+		}
+	}
+
+	/* Capture exiting */
+	CAPTURE_LOG(INFO, "capture thread stopping");
+	capture_remove_callbacks(cap);
+
+	/* Process residual */
+	if (reader_gone)
+		capture_flush_ring(cap->ring);
+	else {
+		while (capture_process_ring(cap->ring, pcapng, NULL) > 0)
+			continue;
+
+		capture_write_stats(pcapng, cap);
+	}
+
+	rte_pcapng_close(pcapng);
+
+error:
+	capture_unlink(cap);
+	capture_free(cap);
+
+	return 0;
+}
+
+/*
+ * Callback handler for telemetry library to start capture.
+ *
+ * Need to handle: <iface>,snaplen=<n>,filter=<str>
+ */
+static int
+capture_start_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	struct capture *cap = NULL;
+	struct capture_config cfg = { .queue = ALL_QUEUES };
+	struct rte_eth_dev_info dev_info;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry: %s %s", cmd, params);
+
+	if (rte_eal_process_type() != RTE_PROC_PRIMARY) {
+		CAPTURE_LOG(ERR, "capture can only be started from primary");
+		return -1;
+	}
+
+	/* Note: params is const so need non-const copy for parsing
+	 * Alternative would be to change telemetry to allow this.
+	 */
+	char tmp[CAP_CMD_MAX];
+	if (strlcpy(tmp, params, CAP_CMD_MAX) >= CAP_CMD_MAX) {
+		CAPTURE_LOG(ERR, "params too long");
+		return -1;
+	}
+
+	if (parse_params(tmp, &cfg, d) < 0)
+		return 0;
+
+	/* Lookup number of queues etc, also validates port_id */
+	if (rte_eth_dev_info_get(cfg.port_id, &dev_info) < 0) {
+		capture_err(d, "can not get info for port %u", cfg.port_id);
+		return 0;
+	}
+
+	if (cfg.queue != ALL_QUEUES &&
+	    cfg.queue >= RTE_MAX(dev_info.nb_rx_queues, dev_info.nb_tx_queues)) {
+		capture_err(d, "queue %d out of range", cfg.queue);
+		return 0;
+	}
+
+	struct stat sb;
+	if (stat(cfg.output, &sb) < 0) {
+		capture_err(d, "output %s:%s", cfg.output, strerror(errno));
+		return 0;
+	}
+
+	if (!is_empty_or_fifo(&sb)) {
+		capture_err(d, "output %s is not empty", cfg.output);
+		return 0;
+	}
+
+	cap = capture_alloc(&cfg, &dev_info, d);
+	if (cap == NULL)
+		return 0;
+
+	if (capture_add_callbacks(cap) < 0) {
+		capture_err(d, "can not register callbacks");
+		goto error_callback_remove;
+	}
+
+	/*
+	 * Publish into the active list before starting the drain thread so the
+	 * thread is guaranteed to find itself there when it removes itself on
+	 * exit (it may exit immediately, e.g. if the FIFO reader is already
+	 * gone). On thread-create failure we undo the insertion here.
+	 */
+	uint32_t idx = cap->idx;
+	capture_link(cap);
+
+	/*
+	 * Make a new thread to do the capture work
+	 * Thread will inherit affinity from the telemetry handler that calls us
+	 */
+	rte_thread_t thread_id;
+	int ret = rte_thread_create(&thread_id, NULL, capture_thread, cap);
+	if (ret != 0) {
+		capture_err(d, "thread start failed: %s", strerror(ret));
+		goto error_unlink;
+	}
+
+	rte_thread_detach(thread_id);
+
+	/* Return id back for later use. */
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "id", idx);
+	rte_tel_data_add_dict_string(d, "status", "running");
+	return 0;
+
+error_unlink:
+	capture_unlink(cap);
+error_callback_remove:
+	capture_remove_callbacks(cap);
+	capture_free(cap);
+
+	/* return 0 since error reported "error":"XXX" in response */
+	return 0;
+}
+
+/* Telemetry: stop active capture. */
+static int
+capture_stop_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	uint32_t idx;
+	if (parse_uint32(params, &idx, UINT32_MAX) < 0) {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	rte_spinlock_lock(&capture_lock);
+	struct capture *cap;
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		CAPTURE_LOG(DEBUG, "capture index %" PRIu32 " not found", idx);
+		return 0;
+	}
+
+	rte_atomic_store_explicit(&cap->running, false, rte_memory_order_relaxed);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "status", "stopped");
+	return 0;
+}
+
+/* Telemetry: list the ids of all active captures. */
+static int
+capture_list_req(const char *cmd __rte_unused, const char *params __rte_unused,
+		 struct rte_tel_data *d)
+{
+	struct capture *cap;
+
+	CAPTURE_LOG(DEBUG, "telemetry %s", cmd);
+	rte_tel_data_start_array(d, RTE_TEL_UINT_VAL);
+
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next)
+		rte_tel_data_add_array_uint(d, cap->idx);
+	rte_spinlock_unlock(&capture_lock);
+
+	return 0;
+}
+
+
+/* Telemetry: report configuration and counters for one capture. */
+static int
+capture_stats_req(const char *cmd, const char *params,
+		  struct rte_tel_data *d)
+{
+	struct capture *cap;
+	struct capture_total t;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	uint32_t idx;
+	if (parse_uint32(params, &idx, UINT32_MAX) < 0) {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	/* Find the instance and snapshot what we need while holding the lock. */
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		capture_err(d, "capture index %" PRIu32 " not found", idx);
+		return 0;
+	}
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "port_id", cap->port_id);
+	if (cap->filter)
+		rte_tel_data_add_dict_string(d, "filter",
+					     __rte_capture_filter_string(cap->filter));
+	rte_tel_data_add_dict_int(d, "running",
+				  rte_atomic_load_explicit(&cap->running,
+							   rte_memory_order_relaxed));
+	rte_tel_data_add_dict_uint(d, "snaplen", cap->snaplen);
+	rte_tel_data_add_dict_uint(d, "rx_queues", cap->rx_queues);
+	rte_tel_data_add_dict_uint(d, "tx_queues", cap->tx_queues);
+	capture_sum_stats(cap, &t);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_add_dict_uint(d, "accepted", t.accepted);
+	rte_tel_data_add_dict_uint(d, "filtered", t.filtered);
+	rte_tel_data_add_dict_uint(d, "nombuf", t.nombuf);
+	rte_tel_data_add_dict_uint(d, "ringfull", t.ringfull);
+
+	return 0;
+}
+
+RTE_INIT(capture_telemetry)
+{
+	rte_telemetry_register_cmd("/ethdev/capture/list", capture_list_req,
+		"List ids of active captures. Takes no parameters.");
+	rte_telemetry_register_cmd("/ethdev/capture/stats", capture_stats_req,
+		"Report configuration and counters for a capture. Parameters: id");
+	rte_telemetry_register_cmd("/ethdev/capture/start", capture_start_req,
+		"Start capture. Parameters: "
+		"port_id,out=<path>,snaplen=N(optional),queue=N(optional),filter=string(optional)");
+	rte_telemetry_register_cmd("/ethdev/capture/stop", capture_stop_req,
+				   "Stop an active capture. Parameters: id");
+}
diff --git a/lib/capture/capture_impl.h b/lib/capture/capture_impl.h
new file mode 100644
index 0000000000..adee734b6c
--- /dev/null
+++ b/lib/capture/capture_impl.h
@@ -0,0 +1,56 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+#ifndef CAPTURE_IMPL_H
+#define CAPTURE_IMPL_H
+
+#define RTE_LOGTYPE_CAPTURE rte_capture_logtype
+extern int rte_capture_logtype;
+#define CAPTURE_LOG(level, ...) \
+	RTE_LOG_LINE_PREFIX(level, CAPTURE, "%s(): ", __func__, __VA_ARGS__)
+
+struct rte_capture_filter;
+
+#ifdef RTE_HAS_LIBPCAP
+struct rte_capture_filter *__rte_capture_filter_create(const char *str);
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter);
+void __rte_capture_filter_free(struct rte_capture_filter *filter);
+uint64_t __rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb);
+
+#else /* !RTE_HAS_LIBPCAP */
+
+/* Stub version if pcap is not available */
+static inline struct rte_capture_filter *
+__rte_capture_filter_create(const char *str)
+{
+	RTE_SET_USED(str);
+	return NULL; /* not supported */
+}
+
+static inline const char *
+__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+	return NULL;
+}
+
+static inline void
+__rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+}
+
+/*
+ * This will  be zero if the packet doesn't match the filter and non-zero if
+ * the packet matches the filter.
+ */
+static inline uint64_t
+__rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb)
+{
+	RTE_SET_USED(filter);
+	RTE_SET_USED(mb);
+	return 1;
+}
+
+#endif  /* !RTE_HAS_LIBPCAP */
+#endif /* CAPTURE_IMPL_H */
diff --git a/lib/capture/filter.c b/lib/capture/filter.c
new file mode 100644
index 0000000000..28be22fe2e
--- /dev/null
+++ b/lib/capture/filter.c
@@ -0,0 +1,110 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#include <pcap/pcap.h>
+
+#include <rte_bpf.h>
+#include <rte_errno.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_mbuf.h>
+#include <rte_string_fns.h>
+
+#include "capture_impl.h"
+
+struct rte_capture_filter {
+	struct rte_bpf *bpf;
+	struct rte_bpf_jit jit;
+	char expr[];	/* original filter text */
+};
+
+/*
+ * Convert text string into an eBPF program
+ */
+struct rte_capture_filter *
+__rte_capture_filter_create(const char *filter)
+{
+	struct rte_bpf_prm *prm = NULL;
+
+	/* libpcap needs a handle */
+	pcap_t *pcap = pcap_open_dead(DLT_EN10MB, UINT16_MAX);
+	if (!pcap) {
+		CAPTURE_LOG(ERR, "pcap: can not open handle");
+		return NULL;
+	}
+
+	size_t filter_len = strlen(filter) + 1;
+	struct rte_capture_filter *flt;
+	flt = rte_zmalloc("capture_filter", sizeof(*flt) + filter_len, 0);
+	if (flt == NULL) {
+		CAPTURE_LOG(ERR, "could not allocate for filter");
+		goto error;
+	}
+
+	/* convert string to cBPF program */
+	struct bpf_program bf;
+	if (pcap_compile(pcap, &bf, filter, 1, PCAP_NETMASK_UNKNOWN) != 0) {
+		CAPTURE_LOG(ERR, "pcap: can not compile filter: %s",
+			    pcap_geterr(pcap));
+		goto error;
+	}
+	strlcpy(flt->expr, filter, filter_len);
+
+	/* convert cBPF to eBPF */
+	prm = rte_bpf_convert(&bf);
+	pcap_freecode(&bf); /* drop the cBPF program */
+
+	if (prm == NULL)  {
+		CAPTURE_LOG(ERR, "BPF convert interface %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	flt->bpf = rte_bpf_load(prm);
+	if (flt->bpf == NULL) {
+		CAPTURE_LOG(ERR, "BPF load failed: %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	rte_bpf_get_jit(flt->bpf, &flt->jit);
+	if (flt->jit.func == NULL)
+		CAPTURE_LOG(NOTICE, "No JIT available for filter");
+
+	pcap_close(pcap);
+	rte_free(prm);
+	return flt;
+
+error:
+	pcap_close(pcap);
+	rte_free(prm);
+	rte_free(flt);
+	return NULL;
+}
+
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	return filter ? filter->expr : NULL;
+}
+
+void __rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	if (filter == NULL)
+		return;
+
+	rte_bpf_destroy(filter->bpf);
+	rte_free(filter);
+}
+
+uint64_t __rte_capture_filter(const struct rte_capture_filter *filter, struct rte_mbuf *mb)
+{
+	if (filter->jit.func)
+		return filter->jit.func(mb);
+	else
+		return rte_bpf_exec(filter->bpf, mb);
+}
diff --git a/lib/capture/meson.build b/lib/capture/meson.build
new file mode 100644
index 0000000000..4dbe0d1a78
--- /dev/null
+++ b/lib/capture/meson.build
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Stephen Hemminger
+
+if is_windows
+    build = false
+    reason = 'not supported on Windows'
+    subdir_done()
+endif
+
+sources = files('capture.c')
+
+deps += ['ethdev', 'pcapng', 'bpf']
+
+if dpdk_conf.has('RTE_HAS_LIBPCAP')
+    sources += files('filter.c')
+    ext_deps += pcap_dep
+else
+    warning('libpcap is missing, capture filtering will be disabled')
+endif
diff --git a/lib/meson.build b/lib/meson.build
index af5c160cb8..6d9992f61f 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -49,6 +49,7 @@ libraries = [
         'lpm',
         'member',
         'pcapng',
+        'capture', # depends on pcapng and bpf
         'power',
         'rawdev',
         'regexdev',
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v3 3/4] test: add test for capture hooks
  2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 1/4] pcapng: extend interface statistics Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
@ 2026-07-30 18:22     ` Stephen Hemminger
  2026-07-30 18:22     ` [PATCH v3 4/4] usertools: add script for wireshark capture Stephen Hemminger
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-30 18:22 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Thomas Monjalon, Reshma Pattan

Provide tests to exercise telemetry based packet capture.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS             |   1 +
 app/test/meson.build    |   1 +
 app/test/test_capture.c | 660 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 662 insertions(+)
 create mode 100644 app/test/test_capture.c

diff --git a/MAINTAINERS b/MAINTAINERS
index fcd350ad94..608cb4b77d 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1721,6 +1721,7 @@ Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
 F: lib/capture/
+F: app/test/test_capture.c
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/app/test/meson.build b/app/test/meson.build
index 51abeeb732..b73ae8ca07 100644
--- a/app/test/meson.build
+++ b/app/test/meson.build
@@ -37,6 +37,7 @@ source_file_deps = {
     'test_bpf.c': ['bpf', 'net'],
     'test_bpf_validate.c': ['bpf'],
     'test_byteorder.c': [],
+    'test_capture.c': ['net_null', 'net', 'ethdev', 'bus_vdev', 'capture'],
     'test_cfgfile.c': ['cfgfile'],
     'test_cksum.c': ['net'],
     'test_cksum_perf.c': ['net'],
diff --git a/app/test/test_capture.c b/app/test/test_capture.c
new file mode 100644
index 0000000000..c5fdb1b784
--- /dev/null
+++ b/app/test/test_capture.c
@@ -0,0 +1,660 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Functional test for the capture library.
+ *
+ * The capture library has no public C API: it is driven entirely through the
+ * telemetry socket. The output is a path ('out=') that the primary opens
+ * itself and writes pcapng into; the caller never receives packet data over
+ * the control socket. This test therefore behaves like an external capture
+ * tool.
+ *
+ * The suite setup builds the fixture shared by every case:
+ *
+ *   - a virtual ethdev backed by net_null. Rx synthesizes packets and Tx is a
+ *     sink that frees whatever it is handed, which is all this test needs and
+ *     avoids the per-queue ring bookkeeping of net_ring. The port is given
+ *     NB_QUEUES queues so per-queue selection can be exercised;
+ *   - a FIFO for capture output;
+ *   - a connection to this process's own telemetry socket.
+ *
+ * Each case then runs with the FIFO read end open (the primary's non-blocking
+ * O_WRONLY open needs a reader present) and hands it back in the common
+ * teardown, which is also what tears down a capture left running by a failed
+ * assertion.
+ *
+ * The cases are:
+ *
+ *   lifecycle
+ *     start an all-queue capture, check a pcapng stream appears, the capture
+ *     is listed, stats account for traffic injected on every queue in both
+ *     directions, and it tears down when the reader disconnects;
+ *   bad_queue
+ *     an out-of-range queue index is rejected, not started;
+ *   queue_select
+ *     a queue=CAPTURE_QUEUE capture installs callbacks on that queue only, so
+ *     traffic on any other queue is not captured while traffic on that one is,
+ *     in both directions. The selected queue is neither the first nor the last
+ *     of NB_QUEUES, so an off-by-one in either install loop is caught. Both
+ *     directions are steerable here: the test names the queue in
+ *     rte_eth_rx_burst()/rte_eth_tx_burst() and net_null sources or sinks on
+ *     whichever queue it is handed.
+ *
+ * The suite is skipped (not failed) if telemetry is not enabled or the
+ * net_null driver is not available.
+ */
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/select.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/un.h>
+
+#include <rte_bus_vdev.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_eal.h>
+#include <rte_ethdev.h>
+#include <rte_lcore.h>
+#include <rte_log.h>
+#include <rte_mbuf.h>
+
+#include "test.h"
+
+#define TELEMETRY_VERSION	"v2"
+#define CAPTURE_START		"/ethdev/capture/start"
+#define CAPTURE_LIST		"/ethdev/capture/list"
+#define CAPTURE_STATS		"/ethdev/capture/stats"
+#define CAPTURE_STOP		"/ethdev/capture/stop"
+
+#define NULL_VDEV_NAME		"net_null_capture"
+#define NB_QUEUES		4
+#define CAPTURE_QUEUE		2	/* not first or last of NB_QUEUES */
+#define RING_SIZE		256
+#define NB_MBUFS		1024
+#define MBUF_CACHE		32
+#define NB_PKTS			32
+#define PKT_LEN			64
+#define REPLY_LEN		16384
+
+/* pcapng Section Header Block type, byte-order independent on disk. */
+static const uint8_t pcapng_shb_magic[4] = { 0x0a, 0x0d, 0x0d, 0x0a };
+
+static struct rte_mempool *test_mp;
+static uint16_t test_port = RTE_MAX_ETHPORTS;
+static char fifo_path[PATH_MAX];
+static int tel_sock = -1;
+static int fifo_rd = -1;
+
+/* --- telemetry client helpers ------------------------------------------ */
+
+/* Connect to this process's telemetry socket; -1 if unavailable. */
+static int
+tel_connect(void)
+{
+	struct sockaddr_un addr = { .sun_family = AF_UNIX };
+	char buf[REPLY_LEN];
+	int s;
+
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/dpdk_telemetry.%s",
+		 rte_eal_get_runtime_dir(), TELEMETRY_VERSION);
+
+	s = socket(AF_UNIX, SOCK_SEQPACKET, 0);
+	if (s < 0)
+		return -1;
+
+	if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+		close(s);
+		return -1;
+	}
+
+	/* Server greets with an info message; consume it. */
+	if (recv(s, buf, sizeof(buf), 0) <= 0) {
+		close(s);
+		return -1;
+	}
+	return s;
+}
+
+/* Send a command and read the reply. */
+static int
+tel_cmd(const char *cmd, char *reply, size_t reply_sz)
+{
+	ssize_t n;
+
+	if (send(tel_sock, cmd, strlen(cmd), 0) < 0)
+		return -1;
+	n = recv(tel_sock, reply, reply_sz - 1, 0);
+	if (n < 0)
+		return -1;
+	reply[n] = '\0';
+	return 0;
+}
+
+/* Minimal JSON scanning: find "key" and read the unsigned number after it. */
+static int
+json_uint(const char *s, const char *key, uint64_t *out)
+{
+	const char *p = strstr(s, key);
+
+	if (p == NULL)
+		return -1;
+	for (p += strlen(key); *p != '\0' && !isdigit((unsigned char)*p); p++)
+		;
+	if (*p == '\0')
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Read the first element of the array in a list reply; -1 if empty/absent. */
+static int
+json_first_array_uint(const char *s, uint64_t *out)
+{
+	const char *p = strchr(s, '[');
+
+	if (p == NULL)
+		return -1;
+	for (p++; *p == ' '; p++)
+		;
+	if (*p == ']' || !isdigit((unsigned char)*p))
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Query the accepted counter for a capture id via telemetry. */
+static int
+capture_accepted(uint64_t id, uint64_t *accepted)
+{
+	char cmd[64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STATS, id);
+	if (tel_cmd(cmd, reply, sizeof(reply)) < 0)
+		return -1;
+	return json_uint(reply, "\"accepted\"", accepted);
+}
+
+/*
+ * Poll the capture list until it is empty (the capture has torn down).
+ * Returns 0 once empty, -1 on timeout (~2s) or telemetry failure.
+ */
+static int
+wait_list_empty(void)
+{
+	char reply[REPLY_LEN];
+	uint64_t id;
+
+	for (int i = 0; i < 200; i++) {
+		if (tel_cmd(CAPTURE_LIST, reply, sizeof(reply)) < 0)
+			return -1;
+		if (json_first_array_uint(reply, &id) < 0)
+			return 0;
+		rte_delay_ms(10);
+	}
+	return -1;
+}
+
+/* --- packet injection --------------------------------------------------- */
+
+/*
+ * Pull count packets from a queue. net_null synthesizes them and pulling runs
+ * the capture Rx callback on each; the mbufs are ours to free.
+ */
+static int
+inject_rx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t got;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	got = rte_eth_rx_burst(test_port, queue, bufs, count);
+	rte_pktmbuf_free_bulk(bufs, got);
+	return got == count ? 0 : -1;	/* net_null fills the whole request */
+}
+
+/*
+ * Transmit count packets on a queue. The capture Tx callback runs first (it
+ * only copies) and net_null then frees the originals, so only mbufs it did not
+ * take are reclaimed here.
+ */
+static int
+inject_tx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t sent;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	for (unsigned int i = 0; i < count; i++) {
+		struct rte_mbuf *m = rte_pktmbuf_alloc(test_mp);
+
+		if (m == NULL) {
+			rte_pktmbuf_free_bulk(bufs, i);
+			return -1;
+		}
+		m->pkt_len = m->data_len = PKT_LEN;
+		memset(rte_pktmbuf_mtod(m, void *), 0, PKT_LEN);
+		bufs[i] = m;
+	}
+
+	/* net_null accepts the whole burst; free the tail if it ever does not. */
+	sent = rte_eth_tx_burst(test_port, queue, bufs, count);
+	if (sent < count) {
+		rte_pktmbuf_free_bulk(&bufs[sent], count - sent);
+		return -1;
+	}
+	return 0;
+}
+
+/*
+ * Inject on every queue except skip; pass NB_QUEUES to cover them all.
+ */
+static int
+inject_rx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_rx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+static int
+inject_tx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_tx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+/* --- fixture ------------------------------------------------------------ */
+
+static int
+build_port(void)
+{
+	struct rte_eth_conf conf = { 0 };
+
+	test_mp = rte_pktmbuf_pool_create("capture_test_mp", NB_MBUFS, MBUF_CACHE,
+					  0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
+	if (test_mp == NULL)
+		return -1;
+
+	if (rte_vdev_init(NULL_VDEV_NAME, NULL) < 0)
+		return -1;
+	if (rte_eth_dev_get_port_by_name(NULL_VDEV_NAME, &test_port) != 0)
+		return -1;
+
+	if (rte_eth_dev_configure(test_port, NB_QUEUES, NB_QUEUES, &conf) < 0)
+		return -1;
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (rte_eth_rx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL, test_mp) < 0)
+			return -1;
+		if (rte_eth_tx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL) < 0)
+			return -1;
+	}
+
+	return rte_eth_dev_start(test_port);
+}
+
+static void
+teardown_port(void)
+{
+	if (test_port != RTE_MAX_ETHPORTS) {
+		rte_eth_dev_stop(test_port);
+		/*
+		 * Uninit drives the driver's remove, which closes and releases
+		 * the ethdev port and removes the vdev in one path. No separate
+		 * rte_eth_dev_close() is needed and null's close is idempotent,
+		 * so this is safe even if a tree's close already released it.
+		 */
+		rte_vdev_uninit(NULL_VDEV_NAME);
+		test_port = RTE_MAX_ETHPORTS;
+	}
+	rte_mempool_free(test_mp);
+	test_mp = NULL;
+}
+
+/* Create the capture FIFO in the runtime dir; -1 on failure. */
+static int
+make_fifo(void)
+{
+	snprintf(fifo_path, sizeof(fifo_path), "%s/capture_test.%d",
+		 rte_eal_get_runtime_dir(), (int)getpid());
+	unlink(fifo_path);			/* clear any stale node */
+	if (mkfifo(fifo_path, 0600) < 0)
+		return -1;
+	return 0;
+}
+
+/*
+ * Release everything the suite setup built. Idempotent, because the runner
+ * does not call the suite teardown when the suite setup fails, so the setup
+ * has to unwind itself by calling this directly.
+ */
+static void
+testsuite_teardown(void)
+{
+	if (fifo_rd >= 0) {
+		close(fifo_rd);
+		fifo_rd = -1;
+	}
+	if (fifo_path[0] != '\0') {
+		unlink(fifo_path);
+		fifo_path[0] = '\0';
+	}
+	if (tel_sock >= 0) {
+		close(tel_sock);
+		tel_sock = -1;
+	}
+	teardown_port();
+}
+
+static int
+testsuite_setup(void)
+{
+	int ret = TEST_SKIPPED;
+
+	fifo_path[0] = '\0';
+
+	/*
+	 * The library writes to the FIFO; a closed reader must give the writer
+	 * EPIPE, not a fatal SIGPIPE. The library masks SIGPIPE on its drain
+	 * thread, but ignore it here too so the test process is safe regardless.
+	 */
+	signal(SIGPIPE, SIG_IGN);
+
+	tel_sock = tel_connect();
+	if (tel_sock < 0) {
+		printf("telemetry socket not available, skipping\n");
+		goto error;
+	}
+
+	if (build_port() < 0) {
+		printf("could not build net_null test port, skipping\n");
+		goto error;
+	}
+
+	if (make_fifo() < 0) {
+		printf("could not create fifo %s: %s\n", fifo_path, strerror(errno));
+		ret = TEST_FAILED;
+		goto error;
+	}
+
+	return TEST_SUCCESS;
+
+error:
+	testsuite_teardown();
+	return ret;
+}
+
+/*
+ * Open the read end before each case: the library opens the FIFO
+ * O_WRONLY|O_NONBLOCK and would get ENXIO with no reader present.
+ * O_RDONLY|O_NONBLOCK returns immediately even with no writer yet.
+ */
+static int
+ut_setup(void)
+{
+	fifo_rd = open(fifo_path, O_RDONLY | O_NONBLOCK);
+	if (fifo_rd < 0) {
+		printf("open %s: %s\n", fifo_path, strerror(errno));
+		return TEST_FAILED;
+	}
+	return TEST_SUCCESS;
+}
+
+/*
+ * Common cleanup. Closing the read end is the hangup that makes any capture
+ * still running -- including one that a failed assertion returned out from
+ * under -- tear itself down, so no case can leak a capture into the next.
+ */
+static void
+ut_teardown(void)
+{
+	if (fifo_rd >= 0) {
+		close(fifo_rd);
+		fifo_rd = -1;
+	}
+	if (wait_list_empty() < 0)
+		printf("warning: capture still active after test case\n");
+}
+
+/* --- test cases --------------------------------------------------------- */
+
+static int
+test_capture_lifecycle(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN], pcapng[REPLY_LEN];
+	uint64_t id, accepted;
+	struct timeval tv;
+	fd_set rfds;
+	ssize_t n;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "capture start returned an error: %s", reply);
+
+	/*
+	 * Inject traffic on every queue: with no queue= the callbacks must be
+	 * installed on all of them. Rx callbacks run synchronously inside
+	 * rx_burst, so the accepted counter is up to date as soon as this
+	 * returns.
+	 */
+	TEST_ASSERT_SUCCESS(inject_rx_except(NB_QUEUES, NB_PKTS),
+			    "rx injection failed");
+
+	/* A pcapng stream (at least the section header) must appear. */
+	FD_ZERO(&rfds);
+	FD_SET(fifo_rd, &rfds);
+	tv = (struct timeval){ .tv_sec = 2 };
+	TEST_ASSERT(select(fifo_rd + 1, &rfds, NULL, NULL, &tv) > 0,
+		    "no pcapng output within timeout");
+	n = read(fifo_rd, pcapng, sizeof(pcapng));
+	TEST_ASSERT(n >= 4, "short pcapng read (%zd)", n);
+	TEST_ASSERT(memcmp(pcapng, pcapng_shb_magic, sizeof(pcapng_shb_magic)) == 0,
+		    "output does not start with a pcapng section header block");
+
+	/* The capture must show up in the list. */
+	TEST_ASSERT_SUCCESS(tel_cmd(CAPTURE_LIST, reply, sizeof(reply)),
+			    "capture list command failed");
+	TEST_ASSERT_SUCCESS(json_first_array_uint(reply, &id),
+			    "no capture id in list reply: %s", reply);
+
+	/* Stats must report exactly the packets we injected, on every queue. */
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, NB_QUEUES * NB_PKTS);
+
+	/* Same for the Tx side of every queue. */
+	TEST_ASSERT_SUCCESS(inject_tx_except(NB_QUEUES, NB_PKTS),
+			    "tx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, 2 * NB_QUEUES * NB_PKTS);
+
+	/*
+	 * Close the reader: the capture should detect the hangup and tear down.
+	 * The drain thread's idle poll notices POLLERR on the write end on its
+	 * own; the extra injection just shortens the wait if it was mid-drain.
+	 * Done here rather than left to the teardown because the hangup is what
+	 * this case is asserting.
+	 */
+	close(fifo_rd);
+	fifo_rd = -1;
+	inject_rx(0, NB_PKTS);	/* any queue; all are captured here */
+	TEST_ASSERT_SUCCESS(wait_list_empty(),
+			    "capture did not tear down after reader closed");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * An out-of-range queue index must be rejected. The range check runs before
+ * the output is opened, so no capture should be left behind even though a
+ * reader is attached.
+ */
+static int
+test_capture_bad_queue(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, NB_QUEUES);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") != NULL,
+		    "out-of-range queue=%u was not rejected: %s", NB_QUEUES, reply);
+	TEST_ASSERT_SUCCESS(wait_list_empty(),
+			    "rejected start left a capture behind");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * With queue=CAPTURE_QUEUE the callbacks are installed on that queue only:
+ * traffic on any other queue must not be captured, traffic on that one must
+ * be, in both directions. The counters are bumped synchronously inside
+ * rx_burst/tx_burst, so every check here is race-free.
+ */
+static int
+test_capture_queue_select(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+	uint64_t id, accepted;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "queue=%u capture start failed", CAPTURE_QUEUE);
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "queue=%u capture start returned an error: %s",
+		    CAPTURE_QUEUE, reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id),
+			    "no id in start reply: %s", reply);
+
+	/* Unselected queues: callbacks never installed, nothing captured. */
+	TEST_ASSERT_SUCCESS(inject_rx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(inject_tx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)0,
+			  "other queues captured under queue=%u (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* Selected queue: captured, on Rx and on Tx. */
+	TEST_ASSERT_SUCCESS(inject_rx(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)NB_PKTS,
+			  "rx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	TEST_ASSERT_SUCCESS(inject_tx(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_PKTS),
+			  "tx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* The teardown closes the reader, which stops this capture. */
+	return TEST_SUCCESS;
+}
+
+/*
+ * Two captures may cover the same queue, each with its own filter and its own
+ * callback. Both must start, both must be listed separately, and traffic on a
+ * shared queue must be counted by both.
+ */
+static int
+test_capture_dual(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN], path2[PATH_MAX];
+	uint64_t id1, id2, a1, a2;
+	int fd2;
+
+	/* the second capture writes to an empty regular file, so it needs no reader */
+	snprintf(path2, sizeof(path2), "%s/capture_test2.%d",
+		 rte_eal_get_runtime_dir(), (int)getpid());
+	unlink(path2);
+	fd2 = open(path2, O_CREAT | O_WRONLY, 0600);
+	TEST_ASSERT(fd2 >= 0, "could not create %s: %s", path2, strerror(errno));
+	close(fd2);
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "first start failed");
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id1), "no id in reply: %s", reply);
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, path2);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "second start failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "second capture of the same queues was rejected: %s", reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id2), "no id in reply: %s", reply);
+	TEST_ASSERT_NOT_EQUAL(id1, id2, "both captures got id %" PRIu64, id1);
+
+	/* one burst on a queue both cover must be counted twice */
+	TEST_ASSERT_SUCCESS(inject_rx(0, NB_PKTS), "rx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id1, &a1), "stats query failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id2, &a2), "stats query failed");
+	TEST_ASSERT_EQUAL(a1, (uint64_t)NB_PKTS,
+			  "first capture accepted %" PRIu64 " != %d", a1, NB_PKTS);
+	TEST_ASSERT_EQUAL(a2, (uint64_t)NB_PKTS,
+			  "second capture accepted %" PRIu64 " != %d", a2, NB_PKTS);
+
+	/* the fifo one is stopped by the teardown closing the reader */
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STOP, id2);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "stop command failed");
+	unlink(path2);
+
+	return TEST_SUCCESS;
+}
+
+static struct unit_test_suite capture_testsuite = {
+	.suite_name = "capture autotest",
+	.setup = testsuite_setup,
+	.teardown = testsuite_teardown,
+	.unit_test_cases = {
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_lifecycle),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_bad_queue),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_queue_select),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_dual),
+		TEST_CASES_END()
+	}
+};
+
+static int
+test_capture(void)
+{
+	return unit_test_suite_runner(&capture_testsuite);
+}
+
+REGISTER_FAST_TEST(capture_autotest, NOHUGE_OK, ASAN_OK, test_capture);
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v3 4/4] usertools: add script for wireshark capture
  2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
                       ` (2 preceding siblings ...)
  2026-07-30 18:22     ` [PATCH v3 3/4] test: add test for capture hooks Stephen Hemminger
@ 2026-07-30 18:22     ` Stephen Hemminger
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-07-30 18:22 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger, Thomas Monjalon, Reshma Pattan, Robin Jarry

Provide glue script that wireshark can use to access
telemetry based packet capture. It is dual licensed because
it maybe desirable to put this in wireshark repository.

See https://www.wireshark.org/docs/man-pages/extcap.html

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |   2 +
 doc/guides/rel_notes/release_26_11.rst |   2 +-
 doc/guides/tools/index.rst             |   1 +
 doc/guides/tools/wireshark_extcap.rst  | 144 +++++++++
 usertools/dpdk-wireshark-extcap.py     | 416 +++++++++++++++++++++++++
 usertools/meson.build                  |   1 +
 6 files changed, 565 insertions(+), 1 deletion(-)
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100755 usertools/dpdk-wireshark-extcap.py

diff --git a/MAINTAINERS b/MAINTAINERS
index 608cb4b77d..7f220cc2c7 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1732,6 +1732,8 @@ F: app/pdump/
 F: doc/guides/tools/pdump.rst
 F: app/dumpcap/
 F: doc/guides/tools/dumpcap.rst
+F: usertools/dpdk-wireshark-extcap.py
+F: doc/guides/tools/wireshark_extcap.rst
 
 
 Packet Framework
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index cd53fc6b9f..8d4f57cef4 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -58,7 +58,7 @@ New Features
 * **Added wireshark capture support.**
 
   * New ``capture`` library for packet capture accessed via telemetry API.
-
+  * Added python script for wireshark external capture integration.
 
 
 Removed Items
diff --git a/doc/guides/tools/index.rst b/doc/guides/tools/index.rst
index 13f75a5bc6..01db27511a 100644
--- a/doc/guides/tools/index.rst
+++ b/doc/guides/tools/index.rst
@@ -14,6 +14,7 @@ DPDK Tools User Guides
     pmdinfo
     dumpcap
     pdump
+    wireshark_extcap
     telemetrywatcher
     dmaperf
     flow-perf
diff --git a/doc/guides/tools/wireshark_extcap.rst b/doc/guides/tools/wireshark_extcap.rst
new file mode 100644
index 0000000000..99fb94df72
--- /dev/null
+++ b/doc/guides/tools/wireshark_extcap.rst
@@ -0,0 +1,144 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Wireshark Extcap Plugin
+=======================
+
+The ``dpdk-wireshark-extcap.py`` script is an external capture (extcap)
+plugin that enables Wireshark capture of live traffic from
+the Ethernet ports of a DPDK application.
+Each DPDK port appears as a capture interface in the Wireshark interface list,
+alongside the host's own network interfaces.
+
+The plugin uses the DPDK telemetry API to query and start capture.
+It passes the path of the fifo file that Wireshark created to the DPDK application,
+which opens the file itself and writes pcapng packets straight into it;
+the plugin never touches packet data.
+Wireshark signals the plugin on exit and that results in closing
+the capture session.
+
+
+Requirements
+------------
+
+* A DPDK application built with the capture library and with telemetry enabled.
+  Telemetry is enabled by default.
+
+* Since the plugin is started by Wireshark, Wireshark and the DPDK application
+  must have the same permissions. See `Permissions`_.
+
+
+Installation
+------------
+
+For Wireshark to discover the plugin it must be present in an extcap
+directory. The configured locations are listed in Wireshark under
+*Help > About Wireshark > Folders*. Copy or symbolically link the script into
+the personal extcap directory, for example::
+
+    ln -s $RTE_SDK/usertools/dpdk-wireshark-extcap.py \
+        ~/.local/lib/wireshark/extcap/
+
+The DPDK ports then appear in the interface list the next time the capture
+options dialog is opened.
+
+
+Usage
+-----
+
+In normal use the plugin is not run by hand; Wireshark invokes it.
+The ports of a running DPDK application appear in the interface list as
+``DPDK <name>``, where ``<name>`` is the device name reported by
+DPDK ethdev, such as ``net_tap0``.
+
+The plugin can also be run directly, which is useful for confirming that a
+DPDK application is reachable::
+
+    $ usertools/dpdk-wireshark-extcap.py --extcap-interfaces
+    extcap {version=0.1}{display=DPDK telemetry capture}
+    interface {value=dpdk:0}{display=DPDK net_tap0}
+
+
+Capture options
+---------------
+
+The following options are offered in the Wireshark capture options dialog for
+a DPDK interface:
+
+Snapshot length
+    Number of bytes captured from each packet. ``0`` captures the whole
+    packet. The default is 262144.
+
+Queue
+    Capture a single hardware queue, or all queues (the default). The choices
+    are bounded to the port's configured queue count.
+
+Capture filter
+    A libpcap filter expression, applied by the DPDK application to the
+    captured traffic.
+
+
+Permissions
+-----------
+
+The DPDK runtime directory is created mode ``0700``, so only the user that
+started the DPDK application can reach its telemetry socket.
+Wireshark, and the plugin it launches, must run as that same user. If run as a
+different user, the interface list is simply empty; running the plugin directly
+with ``--extcap-interfaces`` prints a diagnostic to standard error explaining
+the permission failure.
+
+No privilege beyond access to the telemetry socket is required: if you can
+run ``dpdk-dumpcap`` against an application, you can capture from it with this
+plugin.
+
+
+Selecting a DPDK application
+----------------------------
+
+A host usually runs a single DPDK application, started with the default
+file-prefix (``rte``), and no configuration is needed: its ports appear
+automatically as ``DPDK <name>``.
+
+Running several DPDK applications on one host is also supported. In that case
+each application is started with a distinct ``--file-prefix`` so that its
+runtime state is kept separate. The plugin lists the ports of *all* running
+applications at once, so the application is chosen from the interface list
+rather than before Wireshark is launched. There is no environment variable to
+select one.
+
+Ports of the default prefix are shown plainly, as ``DPDK <name>`` with the
+interface value ``dpdk:<port>``. Ports of any other prefix are qualified with
+the prefix, shown as ``DPDK <name>@<prefix>`` with the interface value
+``dpdk:<prefix>:<port>``, so applications with colliding port numbers stay
+distinct in the list.
+
+
+Environment variables
+----------------------
+
+``DPDK_EXTCAP_PATH``
+    Overrides the base DPDK runtime directory that holds the per-prefix
+    subdirectories. Use it when the runtime directory is in a non-standard
+    location. It gives the base directory that holds the per-prefix
+    subdirectories, one of which is scanned for each running application.
+
+
+Troubleshooting
+---------------
+
+The DPDK ports do not appear in Wireshark
+    Confirm the application is running and was built with the capture library
+    and telemetry. Confirm Wireshark runs as the same user as the application;
+    see `Permissions`_. An application started with a non-default
+    ``--file-prefix`` is listed with its ports qualified as
+    ``DPDK <name>@<prefix>``; see `Selecting a DPDK application`_.
+
+    Running the plugin directly with ``--extcap-interfaces`` prints
+    diagnostics to standard error that the Wireshark GUI does not surface.
+
+A port is listed as ``portN`` instead of a device name
+    The port was reported by the application, but its details could not be
+    read, usually because the application stopped between listing and naming
+    its ports. A capture started against it will fail; restart the
+    application.
diff --git a/usertools/dpdk-wireshark-extcap.py b/usertools/dpdk-wireshark-extcap.py
new file mode 100755
index 0000000000..ba7323b857
--- /dev/null
+++ b/usertools/dpdk-wireshark-extcap.py
@@ -0,0 +1,416 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause OR GPL-2.0-or-later
+# Copyright(c) 2026 Stephen Hemminger
+
+"""
+Wireshark extcap plugin for live capture from DPDK ethdev ports.
+
+Capture path: Wireshark creates a FIFO and hands this plugin its path. The
+plugin opens the FIFO -- which rendezvous with Wireshark's read end -- and then
+passes the FIFO *path* to the DPDK primary process over the telemetry socket as
+the 'out=' parameter. DPDK opens the FIFO itself and writes pcapng straight into
+it; this plugin never touches packet data.
+
+Stopping: Wireshark stops a capture by closing the FIFO read end and/or sending
+SIGTERM. On either, this plugin asks DPDK to stop (/ethdev/capture/stop,<id>) so
+it can flush the trailing packets and the closing interface statistics block
+while a reader is still attached. If the read end is already gone, DPDK detects
+the same hangup and stops on its own; the explicit stop is then a harmless
+no-op. Note that when stopped from the Wireshark GUI the read-end close usually
+precedes the signal, so the statistics block is delivered reliably only for
+standalone (Ctrl+C) runs and for file output.
+
+Interface values: 'dpdk:<port>' for the default file-prefix ('rte'), and
+'dpdk:<prefix>:<port>' for any other primary. The default prefix is left
+implicit so the common single-instance case stays unadorned.
+"""
+
+import argparse
+import json
+import os
+import select
+import signal
+import socket
+import sys
+
+EXTCAP_VERSION = "0.1"
+TELEMETRY_SOCKET = "dpdk_telemetry.v2"
+CAPTURE_START_CMD = "/ethdev/capture/start"
+CAPTURE_STOP_CMD = "/ethdev/capture/stop"
+ETHDEV_LIST = "/ethdev/list"
+ETHDEV_INFO = "/ethdev/info"
+DEFAULT_SNAPLEN = 262144
+DEFAULT_PREFIX = "rte"  # EAL HUGEFILE_PREFIX_DEFAULT
+DLT_EN10MB = 1
+
+
+# --- DPDK runtime directory / socket discovery ---------------------------
+
+
+def dpdk_dir():
+    """Directory holding the per-file-prefix runtime subdirectories."""
+    override = os.environ.get("DPDK_EXTCAP_PATH")
+    if override:
+        return override
+    if os.geteuid() == 0:
+        base = "/var/run"
+    else:
+        base = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
+    return os.path.join(base, "dpdk")
+
+
+def socket_path(prefix):
+    return os.path.join(dpdk_dir(), prefix, TELEMETRY_SOCKET)
+
+
+def list_prefixes():
+    """Yield (prefix, path) for each DPDK telemetry socket found."""
+    root = dpdk_dir()
+    try:
+        entries = os.listdir(root)
+    except FileNotFoundError:
+        # No DPDK runtime dir is present.
+        sys.stderr.write(
+            f"no DPDK runtime directory {root}. Either no DPDK application "
+            "is running, or it runs as a different user\n"
+        )
+        return
+    except PermissionError:
+        # The runtime dir is mode 0700; a different user can see nothing.
+        sys.stderr.write(
+            f"cannot read {root}: permission denied. The DPDK runtime "
+            "directory is created mode 0700, so capture must run as the same "
+            "user as the DPDK application, or set DPDK_EXTCAP_PATH.\n"
+        )
+        return
+
+    found = False
+    for prefix in sorted(entries):
+        path = os.path.join(root, prefix, TELEMETRY_SOCKET)
+        if os.path.exists(path):
+            found = True
+            yield prefix, path
+
+    # Handle case where leftover unix domain socket
+    if not found:
+        sys.stderr.write(f"no DPDK telemetry socket under {root}\n")
+
+
+def iface_value(prefix, port):
+    """Interface value for a (prefix, port). The default prefix is left
+    implicit so a single default-prefix instance shows 'dpdk:<port>'; any
+    other primary is spelled out as 'dpdk:<prefix>:<port>'."""
+    if prefix == DEFAULT_PREFIX:
+        return f"dpdk:{port}"
+    return f"dpdk:{prefix}:{port}"
+
+
+# --- Telemetry transport -------------------------------------------------
+
+
+class Telemetry:
+    """Minimal client for the DPDK v2 telemetry socket (SOCK_SEQPACKET)."""
+
+    def __init__(self, path):
+        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
+        self.sock.connect(path)
+        info = json.loads(self.sock.recv(1024).decode())
+        self.max_output_len = info.get("max_output_len", 16384)
+        self.pid = info.get("pid")
+        self.version = info.get("version")
+
+    def command(self, cmd):
+        """Send a command, return the decoded JSON reply, or None if empty."""
+        self.sock.send(cmd.encode())
+        reply = self.sock.recv(self.max_output_len)
+        if not reply:
+            return None
+        return json.loads(reply.decode())
+
+    def close(self):
+        self.sock.close()
+
+
+def port_queue_count(tel, port):
+    """Max of the rx/tx queue counts for a port, 0 if unknown."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return max(info.get("nb_rx_queues", 0), info.get("nb_tx_queues", 0))
+
+
+# --- extcap query operations --------------------------------------------
+
+
+def port_name(tel, port):
+    """Device name for a port via /ethdev/info, or 'port<N>' if unreadable.
+    For a physical port this is the PCI address (0000:c1:00.0); for a vdev it
+    is the vdev name (net_tap0). Reuses the caller's open connection."""
+    try:
+        reply = tel.command(f"{ETHDEV_INFO},{port}")
+    except OSError:
+        reply = None
+    info = (reply or {}).get(ETHDEV_INFO) or {}
+    return info.get("name") or f"port{port}"
+
+
+def cmd_interfaces():
+    print(f"extcap {{version={EXTCAP_VERSION}}}{{display=DPDK telemetry capture}}")
+    for prefix, path in list_prefixes():
+        try:
+            tel = Telemetry(path)
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+            continue
+        # One connection per prefix for the whole enumeration: list the ports,
+        # then name each over the same socket (each connection costs the
+        # primary a handler thread).
+        try:
+            ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+            for port in ports:
+                name = port_name(tel, port)
+                # Name the prefix in the label only when it is not the default.
+                if prefix == DEFAULT_PREFIX:
+                    display = f"DPDK {name}"
+                else:
+                    display = f"DPDK {name}@{prefix}"
+                print(
+                    f"interface {{value={iface_value(prefix, port)}}}"
+                    f"{{display={display}}}"
+                )
+        except OSError as e:
+            sys.stderr.write(f"cannot query {path}: {e}\n")
+        finally:
+            tel.close()
+
+
+def cmd_dlts(_iface):
+    print(f"dlt {{number={DLT_EN10MB}}}{{name=EN10MB}}{{display=Ethernet}}")
+
+
+def cmd_config(iface):
+    print(
+        f"arg {{number=0}}{{call=--snaplen}}{{display=Snapshot length}}"
+        f"{{tooltip=Bytes captured per packet (0 = whole packet)}}"
+        f"{{type=integer}}{{range=0,{DEFAULT_SNAPLEN}}}"
+        f"{{default={DEFAULT_SNAPLEN}}}{{group=Capture}}"
+    )
+
+    # Bound the queue dropdown to the port's actual queue count. If the primary
+    # is unreachable, just omit the selector -- capture still defaults to all.
+    nqueues = 0
+    if iface:
+        prefix, port = parse_iface(iface)
+        try:
+            tel = Telemetry(socket_path(prefix))
+        except OSError:
+            tel = None
+        if tel is not None:
+            try:
+                nqueues = port_queue_count(tel, port)
+            finally:
+                tel.close()
+
+    if nqueues > 0:
+        print(
+            "arg {number=1}{call=--queue}{display=Queue}"
+            "{tooltip=Capture a single hardware queue}"
+            "{type=selector}{group=Capture}"
+        )
+        print("value {arg=1}{value=-1}{display=All queues}{default=true}")
+        for q in range(nqueues):
+            print(f"value {{arg=1}}{{value={q}}}{{display=Queue {q}}}")
+
+
+# --- capture -------------------------------------------------------------
+
+
+def comma_error(what):
+    """Telemetry command parameters are a comma separated list and there is no
+    escaping, so a comma inside a value is parsed as the start of the next
+    parameter. Reject it here rather than let the primary mis-parse it."""
+    return f"{what} must not contain a comma"
+
+
+def parse_iface(iface):
+    """Inverse of iface_value: accept 'dpdk:<port>' (default prefix) or
+    'dpdk:<prefix>:<port>'."""
+    parts = iface.split(":")
+    if parts[0] != "dpdk":
+        raise SystemExit(f"unsupported interface scheme in '{iface}'")
+    if len(parts) == 2:
+        prefix, port = DEFAULT_PREFIX, parts[1]
+    elif len(parts) == 3:
+        prefix, port = parts[1], parts[2]
+    else:
+        raise SystemExit(f"malformed interface '{iface}'")
+    try:
+        port = int(port)
+    except ValueError as e:
+        raise SystemExit(f"malformed interface '{iface}'") from e
+    return prefix, port
+
+
+def wait_for_stop(fifo_fd):
+    """Block until Wireshark stops us: either it closes the FIFO read end
+    (POLLERR on our write fd) or it sends SIGINT/SIGTERM. Watching the fd as
+    well as the signal matters because the signal may be missed -- Wireshark's
+    reliable stop is closing the pipe.
+
+    The handlers and the wakeup fd are restored before returning: the caller
+    still has telemetry work to do, and a second signal arriving during it
+    should terminate us the usual way rather than be swallowed."""
+    sigs = (signal.SIGINT, signal.SIGTERM)
+    old_handlers = {}
+    old_wakeup = None
+    rd, wr = os.pipe()
+    try:
+        os.set_blocking(wr, False)
+        old_wakeup = signal.set_wakeup_fd(wr)
+        for sig in sigs:
+            old_handlers[sig] = signal.signal(sig, lambda *_: None)
+
+        poller = select.poll()
+        poller.register(fifo_fd, select.POLLERR)
+        poller.register(rd, select.POLLIN)
+        poller.poll()
+    finally:
+        for sig, handler in old_handlers.items():
+            signal.signal(sig, handler)
+        if old_wakeup is not None:
+            signal.set_wakeup_fd(old_wakeup)
+        os.close(rd)
+        os.close(wr)
+
+
+def cmd_capture(iface, fifo, snaplen, queue, cfilter):
+    prefix, port = parse_iface(iface)
+    path = socket_path(prefix)
+
+    # Both are passed to the primary as telemetry parameters; check before
+    # opening anything so a bad one costs nothing.
+    if "," in fifo:
+        raise SystemExit(f"{comma_error('fifo path')}: {fifo}")
+    if cfilter and "," in cfilter:
+        raise SystemExit(comma_error("capture filter"))
+
+    # Blocking open of the FIFO Wireshark created. This rendezvous guarantees a
+    # reader is attached before we ask DPDK to open the write end (DPDK opens
+    # O_WRONLY|O_NONBLOCK and would fail with ENXIO if no reader were present).
+    # We then hold this fd for the whole session: it keeps the pipe from
+    # EOF-ing in the window before the DPDK capture thread opens its own write
+    # end, and it is our reliable stop signal -- POLLERR here means Wireshark
+    # closed the read end.
+    fifo_fd = os.open(fifo, os.O_WRONLY)
+
+    try:
+        tel = Telemetry(path)
+    except OSError as e:
+        os.close(fifo_fd)
+        raise SystemExit(f"cannot connect to DPDK telemetry at {path}: {e}") from e
+
+    params = [str(port), f"out={fifo}"]
+    if snaplen is not None:
+        params.append(f"snaplen={snaplen}")
+    if queue is not None and queue >= 0:
+        params.append(f"queue={queue}")
+    if cfilter:
+        params.append(f"filter={cfilter}")
+    cmd = CAPTURE_START_CMD + "," + ",".join(params)
+
+    try:
+        reply = tel.command(cmd)
+    except OSError as e:
+        os.close(fifo_fd)
+        tel.close()
+        raise SystemExit(f"capture start failed: {e}") from e
+
+    result = (reply or {}).get(CAPTURE_START_CMD) or {}
+    if "error" in result:
+        os.close(fifo_fd)
+        tel.close()
+        raise SystemExit(f"capture start failed: {result['error']}")
+
+    cap_id = result.get("id")
+
+    # Run until Wireshark stops us (signal or read-end close). DPDK now holds
+    # its own write end, so closing ours below won't EOF the reader prematurely.
+    wait_for_stop(fifo_fd)
+
+    # Ask DPDK to stop while we still hold the write end, so if the reader is
+    # still attached it drains the tail and writes the statistics block before
+    # the final EOF. If the read end is already gone DPDK has already stopped
+    # on the same hangup and this is a no-op.
+    if cap_id is not None:
+        try:
+            tel.command(f"{CAPTURE_STOP_CMD},{cap_id}")
+        except OSError:
+            pass
+
+    os.close(fifo_fd)
+    tel.close()
+
+
+# --- entry point ---------------------------------------------------------
+
+
+def main():
+    p = argparse.ArgumentParser(
+        prog="dpdk-wireshark-extcap.py",
+        allow_abbrev=False,
+        description="Wireshark extcap plugin for live packet capture from the "
+        "Ethernet ports of a running DPDK application. Normally "
+        "invoked by Wireshark; see the DPDK Wireshark extcap guide.",
+    )
+    p.add_argument("--version", action="version", version=f"%(prog)s {EXTCAP_VERSION}")
+
+    p.add_argument("--extcap-interfaces", action="store_true")
+    p.add_argument("--extcap-dlts", action="store_true")
+    p.add_argument("--extcap-config", action="store_true")
+    p.add_argument("--capture", action="store_true")
+    p.add_argument("--extcap-interface")
+    p.add_argument("--fifo")
+    p.add_argument("--extcap-capture-filter")
+    p.add_argument("--extcap-version", nargs="?")
+    p.add_argument("--snaplen", type=int)
+    p.add_argument("--queue", type=int)
+    args, _ = p.parse_known_args()
+
+    if args.extcap_interfaces:
+        cmd_interfaces()
+    elif args.extcap_dlts:
+        cmd_dlts(args.extcap_interface)
+    elif args.extcap_config:
+        cmd_config(args.extcap_interface)
+    elif args.capture:
+        if not args.extcap_interface or not args.fifo:
+            raise SystemExit("--capture requires --extcap-interface and --fifo")
+        cmd_capture(
+            args.extcap_interface,
+            args.fifo,
+            args.snaplen,
+            args.queue,
+            args.extcap_capture_filter,
+        )
+    elif args.extcap_capture_filter:
+        # Wireshark validates a capture filter by invoking the extcap with the
+        # filter but without --capture (see doc/extcap_example.py upstream).
+        # Wireshark already syntax-checks it with libpcap against our DLT
+        # (EN10MB) and DPDK compiles it again at capture start, so the only
+        # thing left to reject here is what the telemetry encoding cannot
+        # carry.
+        #
+        # No output means "accepted"; any output marks the filter invalid and
+        # its first line is shown to the user. The exit status must stay 0
+        # either way: Wireshark discards the output of a child that exited
+        # non-zero and reports the filter as unknown rather than invalid.
+        if "," in args.extcap_capture_filter:
+            print(comma_error("capture filter"))
+    else:
+        raise SystemExit("no extcap operation specified")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/usertools/meson.build b/usertools/meson.build
index 114d0a65b4..5debf73702 100644
--- a/usertools/meson.build
+++ b/usertools/meson.build
@@ -13,6 +13,7 @@ install_data([
             'dpdk-rss-flows.py',
             'dpdk-telemetry-exporter.py',
             'dpdk-telemetry-watcher.py',
+            'dpdk-wireshark-extcap.py',
         ],
         install_dir: 'bin')
 
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v4 0/3] wireshark capture implementation
  2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
                     ` (4 preceding siblings ...)
  2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
@ 2026-10-13  3:41   ` Stephen Hemminger
  2026-10-13  3:41     ` [PATCH v4 1/3] capture: infrastructure wireshark packet capture Stephen Hemminger
                       ` (3 more replies)
  5 siblings, 4 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-10-13  3:41 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This series adds live packet capture from a running DPDK application over the
telemetry socket. A new capture library installs Rx/Tx callbacks on an ethdev
port and writes pcapng to a FIFO, with an optional libpcap filter and
per-queue selection. A helper program presents those ports to Wireshark as
capture interfaces.

Advantages of this:
 - no secondary process needed to capture
 - installation is just putting the helper in the Wireshark extcap directory
 - the helper has no dependency on DPDK libraries; can be packaged with
   Wireshark
 - allows for live capture
 - supports BPF (with JIT) for filtering

How it works:
 - The capture library registers telemetry commands (/ethdev/capture/*)
 - Callbacks are installed on the specified port/queues to copy packets
 - Packets are formatted as pcapng and written to the specified output
 - No packet data passes through the telemetry socket itself

Packets handled by a secondary process are captured too, using EAL IPC as
pdump does.

The helper is a C program so that it can be installed with privilege, which
is what lets Wireshark capture from a root application without itself running
as root. See doc/guides/tools/wireshark_extcap.rst.

Linux and FreeBSD only. Windows needs the transport first: telemetry has no
socket server there and uses SOCK_SEQPACKET, which Windows AF_UNIX lacks.
A stream socket would need the replies framed.

Testing:
  - dpdk-wireshark-extcap --extcap-interfaces lists the ports. Reasons for an
    empty list go to stderr, which the Wireshark GUI hides.
  - Unit tests are in app/test/test_capture.c
  - Tested on Linux capturing from a root application as a non-root user.
    The unit test also passes on FreeBSD.

v4
 - helper rewritten in C, and can be installed with privilege so Wireshark
   need not run as the same user as the DPDK application
 - capture packets handled by secondary processes
 - split the library into several files by area
 - dropped the pcapng change adding the filter-accepted count to the
   interface statistics block. Still reported through telemetry.

v3 - fixed AI review feedback

Stephen Hemminger (3):
  capture: infrastructure wireshark packet capture
  test: add test for capture hooks
  usertools: add script for wireshark capture

 MAINTAINERS                            |    4 +
 app/test/meson.build                   |    1 +
 app/test/test_capture.c                |  688 ++++++++++++++
 doc/guides/prog_guide/capture_lib.rst  |  167 ++++
 doc/guides/prog_guide/index.rst        |    1 +
 doc/guides/rel_notes/release_26_11.rst |    5 +
 doc/guides/tools/index.rst             |    1 +
 doc/guides/tools/wireshark_extcap.rst  |  198 ++++
 lib/capture/capture.c                  |  560 ++++++++++++
 lib/capture/datapath.c                 |  420 +++++++++
 lib/capture/filter.c                   |  180 ++++
 lib/capture/meson.build                |   24 +
 lib/capture/multiproc.c                |  246 +++++
 lib/capture/output.c                   |  294 ++++++
 lib/capture/private.h                  |  214 +++++
 lib/meson.build                        |    1 +
 usertools/dpdk-wireshark-extcap.c      | 1145 ++++++++++++++++++++++++
 usertools/meson.build                  |   23 +
 18 files changed, 4172 insertions(+)
 create mode 100644 app/test/test_capture.c
 create mode 100644 doc/guides/prog_guide/capture_lib.rst
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/datapath.c
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build
 create mode 100644 lib/capture/multiproc.c
 create mode 100644 lib/capture/output.c
 create mode 100644 lib/capture/private.h
 create mode 100644 usertools/dpdk-wireshark-extcap.c

-- 
2.53.0


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [PATCH v4 1/3] capture: infrastructure wireshark packet capture
  2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
@ 2026-10-13  3:41     ` Stephen Hemminger
  2026-10-13  3:41     ` [PATCH v4 2/3] test: add test for capture hooks Stephen Hemminger
                       ` (2 subsequent siblings)
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-10-13  3:41 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

This provides a telemetry extension to provide packet capture.
It is intended to be used with a front end script to provide
external packet capture for wireshark.

Captures packets from both primary and secondary.
The notification mechanism is similar to what is already done in pdump.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |   1 +
 doc/guides/prog_guide/capture_lib.rst  | 159 +++++++
 doc/guides/prog_guide/index.rst        |   1 +
 doc/guides/rel_notes/release_26_11.rst |   4 +
 lib/capture/capture.c                  | 560 +++++++++++++++++++++++++
 lib/capture/datapath.c                 | 420 +++++++++++++++++++
 lib/capture/filter.c                   | 180 ++++++++
 lib/capture/meson.build                |  24 ++
 lib/capture/multiproc.c                | 246 +++++++++++
 lib/capture/output.c                   | 294 +++++++++++++
 lib/capture/private.h                  | 214 ++++++++++
 lib/meson.build                        |   1 +
 12 files changed, 2104 insertions(+)
 create mode 100644 doc/guides/prog_guide/capture_lib.rst
 create mode 100644 lib/capture/capture.c
 create mode 100644 lib/capture/datapath.c
 create mode 100644 lib/capture/filter.c
 create mode 100644 lib/capture/meson.build
 create mode 100644 lib/capture/multiproc.c
 create mode 100644 lib/capture/output.c
 create mode 100644 lib/capture/private.h

diff --git a/MAINTAINERS b/MAINTAINERS
index edc0cbc801..96adaebc1d 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1717,6 +1717,7 @@ F: doc/guides/sample_app_ug/qos_scheduler.rst
 Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
+F: lib/capture/
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/doc/guides/prog_guide/capture_lib.rst b/doc/guides/prog_guide/capture_lib.rst
new file mode 100644
index 0000000000..a9c9591bf2
--- /dev/null
+++ b/doc/guides/prog_guide/capture_lib.rst
@@ -0,0 +1,159 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Packet Capture Library
+======================
+
+The capture library records packets going in and out of the Ethernet ports of a
+running DPDK primary process, and writes them in pcapng format to a FIFO.
+
+Unlike the older pdump library, no secondary process is involved and the
+application needs no source changes and no additional EAL arguments. The
+library registers itself at load time and is driven entirely over the
+:doc:`telemetry <telemetry_lib>` socket, so capture can be started on an
+already running application, including a sealed vendor appliance.
+
+Any telemetry client can drive it; see :doc:`../howto/telemetry` for the
+``dpdk-telemetry.py`` script used in the examples below.
+
+Operation
+---------
+
+When a capture is started the library installs Rx and Tx callbacks on the
+requested queues of the port. Each callback copies matching packets into a
+new mbuf already formatted as a pcapng Enhanced Packet Block
+(``rte_pcapng_copy``) and enqueues it on a ring. A dedicated capture thread
+drains that ring and writes the blocks to the output.
+The receive and transmit callbacks do not block, but the capture
+thread may block. The capture file is set into blocking mode so that if an
+external reader cannot keep up, the capture thread will block writing to FIFO.
+
+The callbacks copy the packet; the original mbuf is not modified and is
+returned to the datapath unchanged. Packets are dropped, and counted,
+if an mbuf cannot be allocated or if the ring is full.
+
+Several captures can be active at once, including on the same port and queue.
+Each has its own id, filter, ring, output and callbacks, and stopping one does
+not disturb the others. Each capture runs its own filter
+and copies the packets that match.
+
+Requirements
+------------
+
+* Capture can only be started in a primary process.
+
+* Filtering requires that DPDK was built with ``libpcap`` available. Without
+  it, a capture that specifies a filter will fail to start.
+
+* The library can be excluded from a build with
+  ``-Ddisable_libs=capture``.
+
+Telemetry commands
+------------------
+
+.. csv-table:: Capture telemetry commands
+   :header: "Command", "Parameters"
+   :widths: 30, 50
+
+   "``/ethdev/capture/start``", "port id and options, see below"
+   "``/ethdev/capture/stop``", "capture id"
+   "``/ethdev/capture/list``", "none"
+   "``/ethdev/capture/stats``", "capture id"
+
+``/ethdev/capture/start`` takes the port id as its first parameter, followed by
+``name=value`` options separated by commas:
+
+``out=<path>``
+   Required. Path of the output, which must already exist and be a FIFO.
+
+``snaplen=<n>``
+   Number of bytes to capture from each packet. Default is 262144;
+   ``snaplen=0`` means capture the whole packet.
+
+``queue=<n>``
+   Capture only this hardware queue. Default is all queues of the port.
+
+``filter=<expression>``
+   A libpcap filter expression, compiled to eBPF and run over each packet.
+
+Because the parameter string is split on commas, neither the output path nor
+the filter expression may contain one.
+
+On success the reply contains the capture ``id``, which is used to stop the
+capture and to query its statistics. On failure the reply contains an
+``error`` string. The whole parameter string is limited to 1024 bytes.
+
+.. note::
+
+   The telemetry commands for capture are experimental
+   and may change without warning in future releases.
+
+Output
+------
+
+The output must be an existing FIFO, otherwise the capture fails to start.
+Only a FIFO is allowed because the application opens the path itself: writing
+to a regular file would let a capture request create or truncate a file with
+the privileges of the application.
+
+The reader must have the FIFO open already, so that the capture thread does
+not block waiting for one.
+
+A capture runs until ``/ethdev/capture/stop`` is called,
+or an error is detected such as when the reader of a FIFO goes away.
+
+Statistics
+----------
+
+``/ethdev/capture/stats`` reports the configuration of a capture and the
+following counters, summed over all queues:
+
+``accepted``
+   Packets copied for capture.
+
+``filtered``
+   Packets rejected by the filter.
+
+``nombuf``
+   Packets missed because no mbuf was available for the copy.
+
+``ringfull``
+   Packets missed because the capture thread was not keeping up.
+
+The same values are mapped onto the Interface Statistics Block written at the
+end of the capture: ``ifrecv`` is the number of packets seen, ``filteraccept``
+the subset that passed the filter, and ``ifdrop`` the packets that were lost.
+
+Example
+-------
+
+Normally the capture API is driven by an external capture program, but it can
+be tested using ``dpdk-telemetry.py`` against a running application.
+
+An example, capturing TCP traffic on port 0. Create the FIFO and start a
+reader on it first, for example ``tshark -r /tmp/port0.fifo``, since the
+capture fails to start if the FIFO has no reader::
+
+   --> /ethdev/capture/start,0,out=/tmp/port0.fifo,snaplen=128,filter=tcp
+   {"/ethdev/capture/start": {"id": 0, "status": "running"}}
+
+   --> /ethdev/capture/stats,0
+   {"/ethdev/capture/stats": {"port_id": 0, "filter": "tcp", "running": 1, \
+    "snaplen": 128, "rx_queues": 4, "tx_queues": 4, "accepted": 1200, \
+    "filtered": 87, "nombuf": 0, "ringfull": 0}}
+
+   --> /ethdev/capture/stop,0
+   {"/ethdev/capture/stop": {"status": "stopped"}}
+
+Limitations
+-----------
+
+* Only the primary process can start and stop a capture. Packets handled by a
+  secondary are included, and its counters appear in the statistics, but a
+  secondary started after the capture began is missed until it is restarted.
+
+* Each capture covers a single port. Capturing several ports means starting
+  several captures.
+
+* Captured packets are those seen by the ethdev layer, so packets dropped by
+  the hardware or the driver do not appear.
diff --git a/doc/guides/prog_guide/index.rst b/doc/guides/prog_guide/index.rst
index b9c0d59728..7e869db132 100644
--- a/doc/guides/prog_guide/index.rst
+++ b/doc/guides/prog_guide/index.rst
@@ -129,6 +129,7 @@ Utility Libraries
     log_lib
     metrics_lib
     telemetry_lib
+    capture_lib
     pdump_lib
     pcapng_lib
     bpf_lib
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index a6df347411..bc1d62d423 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -163,6 +163,10 @@ New Features
   Added ``rte_bbdev_queue_stats_get()`` function to retrieve statistics
   for a specific queue, complementing the existing device-level statistics API.
 
+* **Added wireshark capture support.**
+
+  * New ``capture`` library for packet capture accessed via telemetry API.
+
 
 Removed Items
 -------------
diff --git a/lib/capture/capture.c b/lib/capture/capture.c
new file mode 100644
index 0000000000..e8a9d4b485
--- /dev/null
+++ b/lib/capture/capture.c
@@ -0,0 +1,560 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/queue.h>
+#include <sys/stat.h>
+#include <sys/utsname.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <rte_branch_prediction.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_debug.h>
+#include <rte_eal.h>
+#include <rte_errno.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_memory.h>
+#include <rte_mempool.h>
+#include <rte_mbuf.h>
+#include <rte_pcapng.h>
+#include <rte_pause.h>
+#include <rte_ring.h>
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+#include <rte_string_fns.h>
+#include <rte_telemetry.h>
+#include <rte_thread.h>
+#include <rte_version.h>
+
+#include "private.h"
+
+#define CAPTURE_EXTCAP_PREFIX	"dpdk"
+#define CAP_CMD_MAX		1024
+#define DEFAULT_SNAPLEN		262144u	/* from tcpdump et.al. */
+#define MBUF_POOL_CACHE_SIZE	32
+#define CAPTURE_RING_SIZE	256
+#define CAPTURE_POOL_SIZE	1024
+#define SLEEP_THRESHOLD		100
+#define SLEEP_US		100
+
+RTE_LOG_REGISTER_DEFAULT(rte_capture_logtype, NOTICE);
+
+/*
+ * Active captures owned by this process. Control plane only, touched from the
+ * telemetry handler and the drain threads but never the dataplane, so a plain
+ * spinlock is enough.
+ *
+ * Only the creator has a capture on this list; the drain thread, output file
+ * and ring lifetime all belong to it. A secondary reaches a capture through
+ * the pointer in the IPC request, so no shared tailq is needed.
+ */
+static struct capture_list capture_list = TAILQ_HEAD_INITIALIZER(capture_list);
+rte_spinlock_t capture_lock = RTE_SPINLOCK_INITIALIZER;
+
+
+/* Parameter values: only used on stack inside parsing */
+struct capture_config {
+	uint16_t port_id;
+	uint16_t queue;
+	uint32_t snaplen;
+	const char *filter_str;
+	const char *output;
+};
+
+/* Helper that returns error to telemetry and logs it */
+static void __rte_format_printf(2, 3)
+capture_err(struct rte_tel_data *d, const char *format, ...)
+{
+	va_list ap;
+	char msg[1024];
+
+	va_start(ap, format);
+	vsnprintf(msg, sizeof(msg), format, ap);
+	va_end(ap);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "error", msg);
+
+	CAPTURE_LOG(NOTICE, "%s", msg);
+}
+
+static int
+parse_uint32(const char *str, uint32_t *result, uint32_t limit)
+{
+	unsigned long val;
+	char *endp;
+
+	errno = 0;
+	val = strtoul(str, &endp, 10);
+	if (errno != 0 || *endp != '\0' || val >= limit)
+		return -1;
+
+	*result = val;
+	return 0;
+}
+
+/*
+ * Break the comma separated parameter string into tokens
+ * and fill in the capture config structure.
+ *
+ * Does not use rte_kvargs because that would mangle [] etc in filter expression.
+ */
+static int
+parse_params(char *str, struct capture_config *cfg, struct rte_tel_data *d)
+{
+	uint32_t snaplen = DEFAULT_SNAPLEN;
+	char *args[8];
+	int nargs;
+
+	/* Need at least the port id */
+	nargs = rte_strsplit(str, strlen(str), args, RTE_DIM(args), ',');
+	if (nargs < 1) {
+		capture_err(d, "missing parameters '%s'", str);
+		return -1;
+	}
+
+	/* Parse port id (required) */
+	uint32_t port_id;
+	if (parse_uint32(args[0], &port_id, RTE_MAX_ETHPORTS) < 0) {
+		capture_err(d, "invalid port_id='%s'", args[0]);
+		return -1;
+	}
+
+	/* parse remainder as name=value parameters */
+	for (int i = 1; i < nargs; i++) {
+		char *key = args[i];
+
+		/* split at the = */
+		char *eq = strchr(args[i], '=');
+
+		/* all current options require argument after = */
+		if (eq == NULL || eq[1] == '\0') {
+			capture_err(d,  "missing value for '%s'", key);
+			return -1;
+		}
+		*eq = '\0';
+		char *value = eq + 1;
+
+		if (strcmp(key, "out") == 0) {
+			cfg->output = value;
+		} else if (strcmp(key, "filter") == 0) {
+			cfg->filter_str = value;
+		} else if (strcmp(key, "queue") == 0) {
+			uint32_t q;
+
+			if (parse_uint32(value, &q, RTE_MAX_QUEUES_PER_PORT) < 0) {
+				capture_err(d, "invalid queue '%s'", value);
+				return -1;
+			}
+			cfg->queue = q;
+		} else if (strcmp(key, "snaplen") == 0) {
+			if (parse_uint32(value, &snaplen, UINT32_MAX) < 0) {
+				capture_err(d, "invalid snaplen '%s'", value);
+				return -1;
+			}
+		} else {
+			capture_err(d, "unknown parameter '%s'", key);
+			return -1;
+		}
+	}
+
+	if (cfg->output == NULL) {
+		capture_err(d, "missing output parameter");
+		return -1;
+	}
+
+	cfg->port_id = port_id;
+
+	/*
+	 * Default is 256K from tcpdump legacy
+	 * using snaplen=0 means everything.
+	 */
+	cfg->snaplen = snaplen > 0 ? snaplen : UINT32_MAX;
+	return 0;
+}
+
+static void
+capture_link(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+void
+capture_unlink(struct capture *cap)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_REMOVE(&capture_list, cap, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+void
+capture_free(struct capture *cap)
+{
+	if (cap == NULL)
+		return;
+
+	free(cap->output);
+	__rte_capture_filter_free(cap->filter);
+	rte_ring_free(cap->ring);
+	rte_mempool_free(cap->mp);
+	rte_free(cap);
+}
+
+/* Generate unique id for naming and telemetry */
+static unsigned int
+get_unique_id(void)
+{
+	static RTE_ATOMIC(unsigned int) capture_instance;
+
+	return rte_atomic_fetch_add_explicit(&capture_instance, 1, rte_memory_order_relaxed);
+}
+
+/*
+ * Convert configuration into running state
+ */
+static struct capture *
+capture_alloc(const struct capture_config *cfg,
+	      const struct rte_eth_dev_info *dev_info,
+	      struct rte_tel_data *d)
+{
+	struct capture *cap;
+	char ring_name[RTE_RING_NAMESIZE];
+	uint16_t mbuf_size;
+
+	/* try and put capture data struct on same node as device. */
+	int socket_id = rte_eth_dev_socket_id(cfg->port_id);
+	if (socket_id < 0)
+		socket_id = SOCKET_ID_ANY;
+
+	uint16_t num_queues = dev_info->nb_tx_queues + dev_info->nb_rx_queues;
+	size_t cb_size = sizeof(*cap) + num_queues * sizeof(cap->cbs[0]);
+	cap = rte_zmalloc_socket("capture", cb_size, RTE_CACHE_LINE_SIZE, socket_id);
+	if (cap == NULL) {
+		capture_err(d, "Could not allocate capture struct");
+		goto error;
+	}
+
+	rte_spinlock_init(&cap->cbs_lock);
+	TAILQ_INIT(&cap->attached);
+
+	cap->idx = get_unique_id();
+	snprintf(ring_name, sizeof(ring_name), "capture-%u", cap->idx);
+	cap->ring = rte_ring_create(ring_name, CAPTURE_RING_SIZE, socket_id, 0);
+	if (cap->ring == NULL) {
+		capture_err(d, "Could not create ring");
+		goto error;
+	}
+
+	/*
+	 * If snapshot length is smaller than one mbuf segment then pool
+	 * element size can be reduced; otherwise can just use the default
+	 * and rte_pktmbuf_copy handle multiple segments.
+	 */
+	if (cfg->snaplen < RTE_MBUF_DEFAULT_BUF_SIZE)
+		mbuf_size = rte_pcapng_mbuf_size(cfg->snaplen);
+	else
+		mbuf_size = RTE_MBUF_DEFAULT_BUF_SIZE;
+
+	cap->mp = rte_pktmbuf_pool_create_by_ops(ring_name, CAPTURE_POOL_SIZE,
+						 MBUF_POOL_CACHE_SIZE, 0, mbuf_size,
+						 socket_id, "ring_mp_mc");
+	if (cap->mp == NULL) {
+		capture_err(d,  "Could not create mempool");
+		goto error;
+	}
+
+	if (cfg->filter_str) {
+		cap->filter = __rte_capture_filter_create(cfg->filter_str);
+		if (cap->filter == NULL) {
+			capture_err(d,  "Could not compile filter: %s", cfg->filter_str);
+			goto error;
+		}
+	}
+
+	cap->port_id = cfg->port_id;
+	cap->output = strdup(cfg->output);
+	if (cap->output == NULL) {
+		capture_err(d, "Could not strdup '%s'", cfg->output);
+		goto error;
+	}
+	rte_atomic_store_explicit(&cap->running, true, rte_memory_order_relaxed);
+	cap->snaplen = cfg->snaplen;
+	cap->queue = cfg->queue;
+	cap->socket_id = socket_id;
+	cap->tx_queues = dev_info->nb_tx_queues;
+	cap->rx_queues = dev_info->nb_rx_queues;
+
+	return cap;
+
+error:
+	capture_free(cap);
+	return NULL;
+}
+/*
+ * Callback handler for telemetry library to start capture.
+ *
+ * Need to handle: <iface>,snaplen=<n>,filter=<str>
+ */
+static int
+capture_start_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	struct capture *cap = NULL;
+	struct capture_config cfg = { .queue = ALL_QUEUES };
+	struct rte_eth_dev_info dev_info;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry: %s %s", cmd, params);
+
+	if (rte_eal_process_type() != RTE_PROC_PRIMARY) {
+		CAPTURE_LOG(ERR, "capture can only be started from primary");
+		return -1;
+	}
+
+	/* Note: params is const so need non-const copy for parsing
+	 * Alternative would be to change telemetry to allow this.
+	 */
+	char tmp[CAP_CMD_MAX];
+	if (strlcpy(tmp, params, CAP_CMD_MAX) >= CAP_CMD_MAX) {
+		CAPTURE_LOG(ERR, "params too long");
+		return -1;
+	}
+
+	if (parse_params(tmp, &cfg, d) < 0)
+		return 0;
+
+	/* Lookup number of queues etc, also validates port_id */
+	if (rte_eth_dev_info_get(cfg.port_id, &dev_info) < 0) {
+		capture_err(d, "can not get info for port %u", cfg.port_id);
+		return 0;
+	}
+
+	if (cfg.queue != ALL_QUEUES &&
+	    cfg.queue >= RTE_MAX(dev_info.nb_rx_queues, dev_info.nb_tx_queues)) {
+		capture_err(d, "queue %d out of range", cfg.queue);
+		return 0;
+	}
+
+	struct stat sb;
+	if (lstat(cfg.output, &sb) < 0) {
+		capture_err(d, "output %s:%s", cfg.output, strerror(errno));
+		return 0;
+	}
+
+	if (!is_fifo(&sb)) {
+		capture_err(d, "output %s is not empty", cfg.output);
+		return 0;
+	}
+
+	cap = capture_alloc(&cfg, &dev_info, d);
+	if (cap == NULL)
+		return 0;
+
+	if (capture_add_callbacks(cap) < 0) {
+		capture_err(d, "can not register callbacks");
+		goto error_callback_remove;
+	}
+
+	/*
+	 * Have the secondaries attach to their own queues. A secondary that
+	 * cannot is reported but does not fail the capture: what this process
+	 * sees is still captured.
+	 */
+	if (capture_mp_broadcast(cap, CAPTURE_MP_START) < 0)
+		CAPTURE_LOG(NOTICE, "capture %u: not all secondaries attached", cap->idx);
+
+	/*
+	 * Publish into the active list before starting the drain thread so the
+	 * thread is guaranteed to find itself there when it removes itself on
+	 * exit (it may exit immediately, e.g. if the FIFO reader is already
+	 * gone). On thread-create failure we undo the insertion here.
+	 */
+	uint32_t idx = cap->idx;
+	capture_link(cap);
+
+	/*
+	 * Make a new thread to do the capture work
+	 * Thread will inherit affinity from the telemetry handler that calls us
+	 */
+	rte_thread_t thread_id;
+	int ret = rte_thread_create(&thread_id, NULL, capture_thread, cap);
+	if (ret != 0) {
+		capture_err(d, "thread start failed: %s", strerror(ret));
+		goto error_unlink;
+	}
+
+	rte_thread_detach(thread_id);
+
+	/* Return id back for later use. */
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "id", idx);
+	rte_tel_data_add_dict_string(d, "status", "running");
+	return 0;
+
+error_unlink:
+	/* the drain thread never ran, so undo the attach here */
+	if (capture_mp_broadcast(cap, CAPTURE_MP_STOP) < 0) {
+		CAPTURE_LOG(ERR,
+			    "capture %u: secondary still attached, leaking ring and pool",
+			    cap->idx);
+		capture_unlink(cap);
+		capture_remove_callbacks(cap);
+		return 0;
+	}
+	capture_unlink(cap);
+error_callback_remove:
+	capture_remove_callbacks(cap);
+	capture_free(cap);
+
+	/* return 0 since error reported "error":"XXX" in response */
+	return 0;
+}
+
+/* Telemetry: stop active capture. */
+static int
+capture_stop_req(const char *cmd, const char *params, struct rte_tel_data *d)
+{
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	uint32_t idx;
+	if (parse_uint32(params, &idx, UINT32_MAX) < 0) {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	rte_spinlock_lock(&capture_lock);
+	struct capture *cap;
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		CAPTURE_LOG(DEBUG, "capture index %" PRIu32 " not found", idx);
+		return 0;
+	}
+
+	rte_atomic_store_explicit(&cap->running, false, rte_memory_order_relaxed);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_string(d, "status", "stopped");
+	return 0;
+}
+
+/* Telemetry: list the ids of all active captures. */
+static int
+capture_list_req(const char *cmd __rte_unused, const char *params __rte_unused,
+		 struct rte_tel_data *d)
+{
+	struct capture *cap;
+
+	CAPTURE_LOG(DEBUG, "telemetry %s", cmd);
+	rte_tel_data_start_array(d, RTE_TEL_UINT_VAL);
+
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next)
+		rte_tel_data_add_array_uint(d, cap->idx);
+	rte_spinlock_unlock(&capture_lock);
+
+	return 0;
+}
+
+
+/* Telemetry: report configuration and counters for one capture. */
+static int
+capture_stats_req(const char *cmd, const char *params,
+		  struct rte_tel_data *d)
+{
+	struct capture *cap;
+	struct capture_total t;
+
+	if (params == NULL || *params == '\0') {
+		CAPTURE_LOG(ERR, "missing parameters");
+		return -1;
+	}
+
+	CAPTURE_LOG(DEBUG, "telemetry %s %s", cmd, params);
+
+	uint32_t idx;
+	if (parse_uint32(params, &idx, UINT32_MAX) < 0) {
+		capture_err(d, "invalid capture index '%s'", params);
+		return 0;
+	}
+
+	/* Find the instance and snapshot what we need while holding the lock. */
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(cap, &capture_list, next) {
+		if (cap->idx == idx)
+			break;
+	}
+	if (cap == NULL) {
+		rte_spinlock_unlock(&capture_lock);
+		capture_err(d, "capture index %" PRIu32 " not found", idx);
+		return 0;
+	}
+
+	rte_tel_data_start_dict(d);
+	rte_tel_data_add_dict_uint(d, "port_id", cap->port_id);
+	if (cap->filter)
+		rte_tel_data_add_dict_string(d, "filter",
+					     __rte_capture_filter_string(cap->filter));
+	rte_tel_data_add_dict_int(d, "running",
+				  rte_atomic_load_explicit(&cap->running,
+							   rte_memory_order_relaxed));
+	rte_tel_data_add_dict_uint(d, "snaplen", cap->snaplen);
+	rte_tel_data_add_dict_uint(d, "rx_queues", cap->rx_queues);
+	rte_tel_data_add_dict_uint(d, "tx_queues", cap->tx_queues);
+	/* cbs_lock nests inside capture_lock, never the other way round */
+	rte_spinlock_lock(&cap->cbs_lock);
+	capture_sum_stats(cap, &t);
+	rte_spinlock_unlock(&cap->cbs_lock);
+	rte_spinlock_unlock(&capture_lock);
+
+	rte_tel_data_add_dict_uint(d, "accepted", t.accepted);
+	rte_tel_data_add_dict_uint(d, "filtered", t.filtered);
+	rte_tel_data_add_dict_uint(d, "nombuf", t.nombuf);
+	rte_tel_data_add_dict_uint(d, "ringfull", t.ringfull);
+
+	return 0;
+}
+
+RTE_INIT(capture_telemetry)
+{
+	capture_mp_register();
+
+	rte_telemetry_register_cmd("/ethdev/capture/list", capture_list_req,
+		"List ids of active captures. Takes no parameters.");
+	rte_telemetry_register_cmd("/ethdev/capture/stats", capture_stats_req,
+		"Report configuration and counters for a capture. Parameters: id");
+	rte_telemetry_register_cmd("/ethdev/capture/start", capture_start_req,
+		"Start capture. Parameters: "
+		"port_id,out=<fifo>,snaplen=N(optional),queue=N(optional),filter=string(optional)");
+	rte_telemetry_register_cmd("/ethdev/capture/stop", capture_stop_req,
+				   "Stop an active capture. Parameters: id");
+}
diff --git a/lib/capture/datapath.c b/lib/capture/datapath.c
new file mode 100644
index 0000000000..68d8c71f46
--- /dev/null
+++ b/lib/capture/datapath.c
@@ -0,0 +1,420 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Datapath side of capture: the ethdev callbacks that copy packets into the
+ * ring, and the callback blocks they run on.
+ */
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <string.h>
+#include <sys/queue.h>
+
+#include <rte_branch_prediction.h>
+#include <rte_common.h>
+#include <rte_debug.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_mbuf.h>
+#include <rte_mempool.h>
+#include <rte_pcapng.h>
+#include <rte_ring.h>
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+
+#include "private.h"
+
+/*
+ * Idle blocks, guarded by capture_lock. A block is here when no capture owns
+ * it; they are never freed. Per-process, since cb and stale only mean
+ * anything in the process that registered the ethdev callback.
+ */
+static struct capture_cb_list capture_cb_freelist =
+	TAILQ_HEAD_INITIALIZER(capture_cb_freelist);
+
+
+/*
+ * Detach a callback block and wait for the datapath to let go of it.
+ */
+static void
+capture_cb_detach(struct capture_rxtx_cb *cbs)
+{
+	/* detach the callback */
+	rte_atomic_store_explicit(&cbs->cap, NULL, rte_memory_order_relaxed);
+
+	/* the store of cap must be visible before the use_count load below */
+	rte_atomic_thread_fence(rte_memory_order_seq_cst);
+
+	/* wait until no reader is inside */
+	RTE_WAIT_UNTIL_MASKED(&cbs->use_count, UINT32_MAX, ==, 0, rte_memory_order_acquire);
+}
+
+/*
+ * Reader increment use count.
+ *
+ * Pairs with check in capture_cb_detach.
+ */
+static inline void
+capture_cb_hold(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_add_explicit(&cbs->use_count, 1, rte_memory_order_seq_cst);
+}
+
+/* Reader done */
+static inline void
+capture_cb_release(struct capture_rxtx_cb *cbs)
+{
+	rte_atomic_fetch_sub_explicit(&cbs->use_count, 1, rte_memory_order_release);
+}
+
+/*
+ * Create a clone of mbuf to be placed into ring.
+ * Note: for case of lockfree transmit statistics may not be correct due to races.
+ */
+static inline void
+capture_copy_burst(uint16_t port_id, uint16_t queue_id,
+		   enum rte_pcapng_direction direction,
+		   struct rte_mbuf **pkts, unsigned int nb_pkts,
+		   const struct capture *cap,
+		   const struct capture_filter_prog *prog,
+		   struct capture_stats *stats)
+{
+	unsigned int i, ring_enq, d_pkts = 0;
+	struct rte_mbuf *dup_bufs[CAPTURE_BURST_SIZE]; /* duplicated packets */
+	struct rte_ring *ring = cap->ring;
+	struct rte_mempool *mp = cap->mp;
+	uint32_t snaplen = cap->snaplen;
+	struct rte_mbuf *p;
+
+	RTE_ASSERT(nb_pkts <= CAPTURE_BURST_SIZE);
+
+	for (i = 0; i < nb_pkts; i++) {
+		/*
+		 * This uses same BPF return value convention as socket filter
+		 * and pcap_offline_filter. If program returns zero then packet
+		 * doesn't match the filter (will be ignored).
+		 */
+		if (prog != NULL) {
+			if (__rte_capture_filter(prog, pkts[i]) == 0) {
+				stats->filtered++;
+				continue;
+			}
+		}
+
+		p = rte_pcapng_copy(port_id, queue_id, pkts[i], mp, snaplen, direction, NULL);
+		if (unlikely(p == NULL))
+			stats->nombuf++;
+		else
+			dup_bufs[d_pkts++] = p;
+	}
+
+	if (d_pkts == 0)
+		return;
+
+	stats->accepted += d_pkts;
+
+	ring_enq = rte_ring_enqueue_burst(ring, (void *)&dup_bufs[0], d_pkts, NULL);
+	if (unlikely(ring_enq < d_pkts)) {
+		unsigned int drops = d_pkts - ring_enq;
+
+		stats->ringfull += drops;
+		rte_pktmbuf_free_bulk(&dup_bufs[ring_enq], drops);
+	}
+}
+
+/* Create a clone of mbuf to be placed into ring. */
+static inline void
+capture_copy(uint16_t port_id, uint16_t queue_id,
+	     enum rte_pcapng_direction direction,
+	     struct rte_mbuf **pkts, uint16_t nb_pkts,
+	     const struct capture *cap,
+	     const struct capture_filter_prog *prog,
+	     struct capture_stats *stats)
+{
+	unsigned int offs = 0;
+
+	do {
+		unsigned int n = RTE_MIN(nb_pkts - offs, CAPTURE_BURST_SIZE);
+
+		capture_copy_burst(port_id, queue_id, direction, &pkts[offs], n,
+				   cap, prog, stats);
+		offs += n;
+	} while (offs < nb_pkts);
+}
+
+static uint16_t
+capture_rx(uint16_t port, uint16_t queue,
+	struct rte_mbuf **pkts, uint16_t nb_pkts,
+	uint16_t max_pkts __rte_unused, void *user_params)
+{
+	struct capture_rxtx_cb *cbs = user_params;
+	const struct capture *cap;
+
+	capture_cb_hold(cbs);
+	cap = rte_atomic_load_explicit(&cbs->cap, rte_memory_order_seq_cst);
+	if (likely(cap != NULL))
+		capture_copy(port, queue, RTE_PCAPNG_DIRECTION_IN,
+			     pkts, nb_pkts, cap, cbs->prog, &cbs->stats);
+
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+static uint16_t
+capture_tx(uint16_t port, uint16_t queue,
+	   struct rte_mbuf **pkts, uint16_t nb_pkts, void *user_params)
+{
+	struct capture_rxtx_cb *cbs = user_params;
+	const struct capture *cap;
+
+	capture_cb_hold(cbs);
+	cap = rte_atomic_load_explicit(&cbs->cap, rte_memory_order_seq_cst);
+	if (likely(cap != NULL))
+		capture_copy(port, queue, RTE_PCAPNG_DIRECTION_OUT,
+			     pkts, nb_pkts, cap, cbs->prog, &cbs->stats);
+
+	capture_cb_release(cbs);
+
+	return nb_pkts;
+}
+
+
+/*
+ * Take an idle block for a port/queue/direction, allocating one if all are in
+ * use. Several captures may cover a queue, each with its own filter and
+ * callback, so this hands out one block per capture.
+ */
+static struct capture_rxtx_cb *
+capture_cb_get(struct capture *cap, uint16_t queue, bool is_rx)
+{
+	struct capture_rxtx_cb *cbs;
+
+	rte_spinlock_lock(&capture_lock);
+
+	TAILQ_FOREACH(cbs, &capture_cb_freelist, next) {
+		if (cbs->port_id == cap->port_id && cbs->queue == queue &&
+		    cbs->is_rx == is_rx) {
+			TAILQ_REMOVE(&capture_cb_freelist, cbs, next);
+			break;
+		}
+	}
+
+	rte_spinlock_unlock(&capture_lock);
+
+	if (cbs == NULL) {
+		cbs = rte_zmalloc_socket("capture_cb", sizeof(*cbs),
+					 RTE_CACHE_LINE_SIZE, cap->socket_id);
+		if (cbs == NULL)
+			return NULL;
+		cbs->port_id = cap->port_id;
+		cbs->queue = queue;
+		cbs->is_rx = is_rx;
+	}
+
+	return cbs;
+}
+
+/* Hand a detached block back for reuse. */
+static void
+capture_cb_put(struct capture_rxtx_cb *cbs)
+{
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_cb_freelist, cbs, next);
+	rte_spinlock_unlock(&capture_lock);
+}
+
+/*
+ * Install one callback, on a block that is now ours.
+ *
+ * Returns the block so the caller can record it for detach; the block is also
+ * linked into the capture's shared attached list for stats.
+ */
+static struct capture_rxtx_cb *
+capture_attach(struct capture *cap, uint16_t queue, bool is_rx,
+	       struct capture_filter_prog *prog)
+{
+	struct capture_rxtx_cb *cbs;
+
+	cbs = capture_cb_get(cap, queue, is_rx);
+	if (cbs == NULL) {
+		CAPTURE_LOG(ERR, "No callback block for %u:%u %s",
+			    cap->port_id, queue, is_rx ? "rx" : "tx");
+		return NULL;
+	}
+
+	/* the block may have been used by an earlier capture of this queue */
+	memset(&cbs->stats, 0, sizeof(cbs->stats));
+
+	/* one program per process per capture, shared by its queues */
+	cbs->prog = prog;
+
+	/*
+	 * Release the object left by an earlier capture. It cannot be freed at
+	 * removal time because ethdev reads cb->next after the callback
+	 * returns; deferring to the next attach puts a whole session between.
+	 */
+	rte_free((void *)(uintptr_t)cbs->stale);
+	cbs->stale = NULL;
+
+	/* publish before the callback can be reached by the datapath */
+	rte_atomic_store_explicit(&cbs->cap, cap, rte_memory_order_release);
+
+	if (is_rx)
+		cbs->cb = rte_eth_add_rx_callback(cap->port_id, queue, capture_rx, cbs);
+	else
+		cbs->cb = rte_eth_add_tx_callback(cap->port_id, queue, capture_tx, cbs);
+
+	if (cbs->cb == NULL) {
+		rte_atomic_store_explicit(&cbs->cap, NULL, rte_memory_order_relaxed);
+		capture_cb_put(cbs);
+		CAPTURE_LOG(ERR, "Register %s callback for %u:%u failed",
+			    is_rx ? "rx" : "tx", cap->port_id, queue);
+		return NULL;
+	}
+
+	rte_spinlock_lock(&cap->cbs_lock);
+	TAILQ_INSERT_TAIL(&cap->attached, cbs, attached);
+	rte_spinlock_unlock(&cap->cbs_lock);
+
+	return cbs;
+}
+
+/*
+ * Install callbacks on every queue this capture covers, recording them in
+ * cbs[] for detach. A secondary passes its own array.
+ *
+ * The filter program is per-process, so it is loaded here and returned in
+ * *progp; the caller passes it to capture_detach_queues() to release.
+ */
+int
+capture_attach_queues(struct capture *cap, struct capture_rxtx_cb **cbs, uint16_t *nb_cbs,
+		      struct capture_filter_prog **progp)
+{
+	struct capture_filter_prog *prog = NULL;
+
+	if (cap->filter != NULL) {
+		prog = __rte_capture_filter_load(cap->filter);
+		if (prog == NULL) {
+			/*
+			 * Attaching without the filter would put unwanted
+			 * packets in the capture file, so do not attach.
+			 */
+			CAPTURE_LOG(ERR, "Could not load filter for port %u",
+				    cap->port_id);
+			return -1;
+		}
+	}
+	*progp = prog;
+
+	for (uint16_t q = 0; q < cap->tx_queues; q++) {
+		if (cap->queue != ALL_QUEUES && cap->queue != q)
+			continue;
+
+		cbs[*nb_cbs] = capture_attach(cap, q, false, prog);
+		if (cbs[*nb_cbs] == NULL)
+			return -1;
+		++*nb_cbs;
+	}
+
+	for (uint16_t q = 0; q < cap->rx_queues; q++) {
+		if (cap->queue != ALL_QUEUES && cap->queue != q)
+			continue;
+
+		cbs[*nb_cbs] = capture_attach(cap, q, true, prog);
+		if (cbs[*nb_cbs] == NULL)
+			return -1;
+		++*nb_cbs;
+	}
+	return 0;
+}
+
+/* Install callbacks of the process that created the capture. */
+int
+capture_add_callbacks(struct capture *cap)
+{
+	return capture_attach_queues(cap, cap->cbs, &cap->nb_cbs, &cap->prog);
+}
+
+static void
+capture_sum_one(struct capture_total *t, const struct capture_stats *s)
+{
+	t->accepted += s->accepted;
+	t->filtered += s->filtered;
+	t->nombuf   += s->nombuf;
+	t->ringfull += s->ringfull;
+}
+
+/*
+ * Sum the counters of the queues still attached, in any process, plus those
+ * already detached. Caller holds cap->cbs_lock.
+ */
+void
+capture_sum_stats(const struct capture *cap, struct capture_total *t)
+{
+	const struct capture_rxtx_cb *cbs;
+
+	*t = cap->total;
+
+	TAILQ_FOREACH(cbs, &cap->attached, attached)
+		capture_sum_one(t, &cbs->stats);
+}
+
+/*
+ * Detach the blocks this process attached and hand them back for reuse.
+ *
+ * Once this returns no datapath thread in this process can reach the capture.
+ * The blocks themselves stay behind for the next capture on the same queue.
+ */
+void
+capture_detach_queues(struct capture *cap, struct capture_rxtx_cb **cbs, uint16_t *nb_cbs,
+		      struct capture_filter_prog *prog)
+{
+	for (uint16_t i = 0; i < *nb_cbs; i++) {
+		struct capture_rxtx_cb *cb = cbs[i];
+
+		if (cb->is_rx)
+			rte_eth_remove_rx_callback(cap->port_id, cb->queue, cb->cb);
+		else
+			rte_eth_remove_tx_callback(cap->port_id, cb->queue, cb->cb);
+
+		/* freed by the next capture that attaches to this queue */
+		cb->stale = cb->cb;
+		cb->cb = NULL;
+
+		capture_cb_detach(cb);
+		cb->prog = NULL;
+	}
+
+	/*
+	 * The blocks leave the capture, so fold their counters into the total
+	 * first: whoever picks them up next zeroes the stats.
+	 */
+	rte_spinlock_lock(&cap->cbs_lock);
+	for (uint16_t i = 0; i < *nb_cbs; i++) {
+		capture_sum_one(&cap->total, &cbs[i]->stats);
+		TAILQ_REMOVE(&cap->attached, cbs[i], attached);
+	}
+	rte_spinlock_unlock(&cap->cbs_lock);
+
+	rte_spinlock_lock(&capture_lock);
+	for (uint16_t i = 0; i < *nb_cbs; i++)
+		TAILQ_INSERT_TAIL(&capture_cb_freelist, cbs[i], next);
+	rte_spinlock_unlock(&capture_lock);
+
+	*nb_cbs = 0;
+
+	/* no datapath thread can reach the program now */
+	__rte_capture_filter_unload(prog);
+}
+
+/* Cleanup callbacks of the process that created the capture. */
+void
+capture_remove_callbacks(struct capture *cap)
+{
+	capture_detach_queues(cap, cap->cbs, &cap->nb_cbs, cap->prog);
+	cap->prog = NULL;
+}
diff --git a/lib/capture/filter.c b/lib/capture/filter.c
new file mode 100644
index 0000000000..9820845fc5
--- /dev/null
+++ b/lib/capture/filter.c
@@ -0,0 +1,180 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#include <pcap/pcap.h>
+
+#include <rte_bpf.h>
+#include <rte_errno.h>
+#include <rte_log.h>
+#include <rte_malloc.h>
+#include <rte_mbuf.h>
+#include <rte_string_fns.h>
+
+#include "private.h"
+
+/*
+ * Compiled filter, split in two because of where rte_bpf puts things.
+ * rte_bpf_convert() returns the eBPF instructions in rte_malloc memory, which
+ * is shared and position independent and references no external symbols.
+ * rte_bpf_load() puts the rte_bpf object in a MAP_PRIVATE mapping and the JIT
+ * output has to be privately executable, so neither can be shared.
+ *
+ * The instructions are therefore compiled once, by whoever starts the
+ * capture, and each process loads its own program from them. Only the process
+ * doing the compile needs libpcap.
+ */
+struct rte_capture_filter {
+	struct ebpf_insn *ins;	/* eBPF program, shared */
+	uint32_t nb_ins;
+	char expr[];		/* original filter text */
+};
+
+/* Per-process loaded form of the above, not shared. */
+struct capture_filter_prog {
+	struct rte_bpf *bpf;
+	struct rte_bpf_jit jit;
+};
+
+/*
+ * Convert text string into an eBPF program
+ */
+struct rte_capture_filter *
+__rte_capture_filter_create(const char *filter)
+{
+	struct rte_bpf_prm *prm = NULL;
+
+	/* libpcap needs a handle */
+	pcap_t *pcap = pcap_open_dead(DLT_EN10MB, UINT16_MAX);
+	if (!pcap) {
+		CAPTURE_LOG(ERR, "pcap: can not open handle");
+		return NULL;
+	}
+
+	size_t filter_len = strlen(filter) + 1;
+	struct rte_capture_filter *flt;
+	flt = rte_zmalloc("capture_filter", sizeof(*flt) + filter_len, 0);
+	if (flt == NULL) {
+		CAPTURE_LOG(ERR, "could not allocate for filter");
+		goto error;
+	}
+
+	/* convert string to cBPF program */
+	struct bpf_program bf;
+	if (pcap_compile(pcap, &bf, filter, 1, PCAP_NETMASK_UNKNOWN) != 0) {
+		CAPTURE_LOG(ERR, "pcap: can not compile filter: %s",
+			    pcap_geterr(pcap));
+		goto error;
+	}
+	strlcpy(flt->expr, filter, filter_len);
+
+	/* convert cBPF to eBPF */
+	prm = rte_bpf_convert(&bf);
+	pcap_freecode(&bf); /* drop the cBPF program */
+
+	if (prm == NULL)  {
+		CAPTURE_LOG(ERR, "BPF convert interface %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		goto error;
+	}
+
+	/*
+	 * Keep the instructions in shared memory. Only the primary has libpcap
+	 * do the compile; every process loads the result from here.
+	 */
+	flt->ins = rte_malloc("capture_filter_ins",
+			      prm->nb_ins * sizeof(*flt->ins), 0);
+	if (flt->ins == NULL) {
+		CAPTURE_LOG(ERR, "could not allocate for filter program");
+		goto error;
+	}
+	memcpy(flt->ins, prm->ins, prm->nb_ins * sizeof(*flt->ins));
+	flt->nb_ins = prm->nb_ins;
+
+	pcap_close(pcap);
+	rte_free(prm);
+	return flt;
+
+error:
+	pcap_close(pcap);
+	rte_free(prm);
+	if (flt != NULL)
+		rte_free(flt->ins);
+	rte_free(flt);
+	return NULL;
+}
+
+/*
+ * Load the shared program into this process. The JIT-ed code and the rte_bpf
+ * object are process private mappings, so each process that runs the filter
+ * needs its own.
+ */
+struct capture_filter_prog *
+__rte_capture_filter_load(const struct rte_capture_filter *filter)
+{
+	struct rte_bpf_prm prm = {
+		.ins = filter->ins,
+		.nb_ins = filter->nb_ins,
+		.prog_arg = {
+			.type = RTE_BPF_ARG_PTR_MBUF,
+			.size = sizeof(struct rte_mbuf),
+		},
+	};
+	struct capture_filter_prog *prog;
+
+	prog = calloc(1, sizeof(*prog));
+	if (prog == NULL) {
+		CAPTURE_LOG(ERR, "could not allocate filter program");
+		return NULL;
+	}
+
+	prog->bpf = rte_bpf_load(&prm);
+	if (prog->bpf == NULL) {
+		CAPTURE_LOG(ERR, "BPF load failed: %s(%d)",
+			    rte_strerror(rte_errno), rte_errno);
+		free(prog);
+		return NULL;
+	}
+
+	rte_bpf_get_jit(prog->bpf, &prog->jit);
+	if (prog->jit.func == NULL)
+		CAPTURE_LOG(NOTICE, "No JIT available for filter");
+
+	return prog;
+}
+
+void
+__rte_capture_filter_unload(struct capture_filter_prog *prog)
+{
+	if (prog == NULL)
+		return;
+
+	rte_bpf_destroy(prog->bpf);
+	free(prog);
+}
+
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	return filter ? filter->expr : NULL;
+}
+
+void __rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	if (filter == NULL)
+		return;
+
+	rte_free(filter->ins);
+	rte_free(filter);
+}
+
+uint64_t __rte_capture_filter(const struct capture_filter_prog *prog, struct rte_mbuf *mb)
+{
+	if (prog->jit.func)
+		return prog->jit.func(mb);
+	else
+		return rte_bpf_exec(prog->bpf, mb);
+}
diff --git a/lib/capture/meson.build b/lib/capture/meson.build
new file mode 100644
index 0000000000..f6a3b3a1f8
--- /dev/null
+++ b/lib/capture/meson.build
@@ -0,0 +1,24 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Stephen Hemminger
+
+if is_windows
+    build = false
+    reason = 'not supported on Windows'
+    subdir_done()
+endif
+
+sources = files(
+        'capture.c',
+        'datapath.c',
+        'multiproc.c',
+        'output.c',
+)
+
+deps += ['ethdev', 'pcapng', 'bpf']
+
+if dpdk_conf.has('RTE_HAS_LIBPCAP')
+    sources += files('filter.c')
+    ext_deps += pcap_dep
+else
+    warning('libpcap is missing, capture filtering will be disabled')
+endif
diff --git a/lib/capture/multiproc.c b/lib/capture/multiproc.c
new file mode 100644
index 0000000000..b37882bd94
--- /dev/null
+++ b/lib/capture/multiproc.c
@@ -0,0 +1,246 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Multi-process support.
+ *
+ * The ethdev callback lists are per-process, so the primary passes the capture
+ * to the other processes over EAL IPC and each attaches to its own queues.
+ */
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <sys/queue.h>
+
+#include <rte_eal.h>
+#include <rte_errno.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_spinlock.h>
+#include <rte_string_fns.h>
+
+#include "private.h"
+
+/* IPC endpoint used to tell secondaries to attach/detach their callbacks. */
+#define CAPTURE_MP		"mp_capture"
+#define CAPTURE_MP_VERSION	1
+#define CAPTURE_MP_TIMEOUT_S	1
+
+struct capture_mp_request {
+	uint16_t version;
+	uint16_t op;
+	/*
+	 * Capture in shared memory. Valid in every process because it comes
+	 * from rte_zmalloc; the secondary only reads the datapath fields and
+	 * never the creator's (output, drain thread state).
+	 */
+	struct capture *cap;
+};
+
+struct capture_mp_response {
+	uint16_t version;
+	uint16_t op;
+	int32_t err;
+};
+
+
+/*
+ * Callbacks this process attached to a capture created elsewhere.
+ *
+ * A secondary cannot record its blocks in cap->cbs[], which is sized for one
+ * process's queues, so it keeps them here. Guarded by capture_lock.
+ */
+struct capture_secondary {
+	TAILQ_ENTRY(capture_secondary) next;
+	struct capture *cap;
+	struct capture_filter_prog *prog;
+	uint16_t nb_cbs;
+	struct capture_rxtx_cb *cbs[];
+};
+
+static TAILQ_HEAD(, capture_secondary) capture_secondary_list =
+	TAILQ_HEAD_INITIALIZER(capture_secondary_list);
+
+/* Attach this process's callbacks to a capture owned by the primary. */
+static int
+capture_mp_attach(struct capture *cap)
+{
+	struct capture_secondary *sec;
+	struct rte_eth_dev_info dev_info;
+	uint16_t num_queues;
+
+	if (rte_eth_dev_info_get(cap->port_id, &dev_info) < 0)
+		return -EINVAL;
+
+	/*
+	 * Queue counts come from this process's view of the device: it is the
+	 * one registering the callbacks.
+	 */
+	num_queues = dev_info.nb_tx_queues + dev_info.nb_rx_queues;
+	sec = calloc(1, sizeof(*sec) + num_queues * sizeof(sec->cbs[0]));
+	if (sec == NULL)
+		return -ENOMEM;
+
+	sec->cap = cap;
+	if (capture_attach_queues(cap, sec->cbs, &sec->nb_cbs, &sec->prog) < 0) {
+		capture_detach_queues(cap, sec->cbs, &sec->nb_cbs, sec->prog);
+		free(sec);
+		return -ENOBUFS;
+	}
+
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_INSERT_TAIL(&capture_secondary_list, sec, next);
+	rte_spinlock_unlock(&capture_lock);
+
+	return 0;
+}
+
+/* Detach this process's callbacks from a capture owned by the primary. */
+static int
+capture_mp_detach(struct capture *cap)
+{
+	struct capture_secondary *sec;
+
+	rte_spinlock_lock(&capture_lock);
+	TAILQ_FOREACH(sec, &capture_secondary_list, next) {
+		if (sec->cap == cap) {
+			TAILQ_REMOVE(&capture_secondary_list, sec, next);
+			break;
+		}
+	}
+	rte_spinlock_unlock(&capture_lock);
+
+	if (sec == NULL)
+		return -ENOENT;
+
+	capture_detach_queues(cap, sec->cbs, &sec->nb_cbs, sec->prog);
+	free(sec);
+	return 0;
+}
+
+/*
+ * Runs on the IPC thread, but attaching sends nothing, so unlike pdump there
+ * is no need to defer to the interrupt thread.
+ */
+static int
+capture_mp_handler(const struct rte_mp_msg *msg, const void *peer)
+{
+	const struct capture_mp_request *req;
+	struct rte_mp_msg reply = { };
+	struct capture_mp_response *resp = (struct capture_mp_response *)reply.param;
+
+	resp->err = -EINVAL;
+
+	if (rte_eal_process_type() == RTE_PROC_PRIMARY) {
+		/* the primary sends these and attaches its own callbacks */
+		resp->err = -EPERM;
+	} else if (msg->len_param != sizeof(*req)) {
+		CAPTURE_LOG(ERR, "Invalid capture request length %d", msg->len_param);
+	} else {
+		req = (const struct capture_mp_request *)msg->param;
+
+		if (req->version != CAPTURE_MP_VERSION) {
+			CAPTURE_LOG(ERR, "Unsupported capture request version %u",
+				    req->version);
+		} else {
+			resp->version = req->version;
+			resp->op = req->op;
+
+			switch (req->op) {
+			case CAPTURE_MP_START:
+				resp->err = capture_mp_attach(req->cap);
+				break;
+			case CAPTURE_MP_STOP:
+				resp->err = capture_mp_detach(req->cap);
+				break;
+			default:
+				CAPTURE_LOG(ERR, "Unknown capture op %u", req->op);
+			}
+		}
+	}
+
+	rte_strscpy(reply.name, CAPTURE_MP, sizeof(reply.name));
+	reply.len_param = sizeof(*resp);
+
+	return rte_mp_reply(&reply, peer);
+}
+
+/*
+ * Registered from the constructor so no rte_pdump_init() equivalent is needed.
+ * Only inserts into a list, so it is safe this early.
+ *
+ * The process type is not yet known here (it defaults to primary until EAL
+ * init parses the arguments), so both ends register and the handler rejects
+ * a request that arrives in the sending process.
+ */
+void
+capture_mp_register(void)
+{
+	if (rte_mp_action_register(CAPTURE_MP, capture_mp_handler) < 0 &&
+	    rte_errno != ENOTSUP)
+		CAPTURE_LOG(NOTICE, "Could not register ipc action: %s",
+			    rte_strerror(rte_errno));
+}
+
+/*
+ * Must not be called holding a lock, the reply timeout is in seconds.
+ * On stop the result decides whether the ring can be freed.
+ */
+int
+capture_mp_broadcast(struct capture *cap, enum capture_mp_op op)
+{
+	struct rte_mp_msg msg = { };
+	struct capture_mp_request *req = (struct capture_mp_request *)msg.param;
+	struct rte_mp_reply mp_reply = { };
+	struct timespec ts = { .tv_sec = CAPTURE_MP_TIMEOUT_S };
+	int ret;
+
+	if (rte_eal_process_type() != RTE_PROC_PRIMARY)
+		return 0;
+
+	req->version = CAPTURE_MP_VERSION;
+	req->op = op;
+	req->cap = cap;
+
+	rte_strscpy(msg.name, CAPTURE_MP, sizeof(msg.name));
+	msg.len_param = sizeof(*req);
+
+	if (rte_mp_request_sync(&msg, &mp_reply, &ts) != 0) {
+		/* no secondary running at all is the common case */
+		if (rte_errno == ENOTSUP)
+			return 0;
+
+		CAPTURE_LOG(ERR, "Capture %s request failed: %s",
+			    op == CAPTURE_MP_START ? "start" : "stop",
+			    rte_strerror(rte_errno));
+		return -1;
+	}
+
+	ret = 0;
+	for (int i = 0; i < mp_reply.nb_received; i++) {
+		const struct capture_mp_response *resp
+			= (const struct capture_mp_response *)mp_reply.msgs[i].param;
+
+		/*
+		 * A secondary that has no callbacks to detach replies ENOENT,
+		 * which happens whenever it was not polling this port.
+		 */
+		if (resp->err != 0 && resp->err != -ENOENT) {
+			CAPTURE_LOG(ERR, "Secondary could not %s capture: %s",
+				    op == CAPTURE_MP_START ? "start" : "stop",
+				    rte_strerror(-resp->err));
+			ret = -1;
+		}
+	}
+
+	if (mp_reply.nb_received != mp_reply.nb_sent) {
+		CAPTURE_LOG(ERR, "Only %u of %u secondaries replied",
+			    mp_reply.nb_received, mp_reply.nb_sent);
+		ret = -1;
+	}
+
+	free(mp_reply.msgs);
+	return ret;
+}
diff --git a/lib/capture/output.c b/lib/capture/output.c
new file mode 100644
index 0000000000..9a52703d8e
--- /dev/null
+++ b/lib/capture/output.c
@@ -0,0 +1,294 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Output side of capture: opening the pcapng stream, and the per-capture
+ * thread that drains the ring into it.
+ */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/queue.h>
+#include <sys/stat.h>
+#include <sys/utsname.h>
+#include <unistd.h>
+
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_ethdev.h>
+#include <rte_log.h>
+#include <rte_mbuf.h>
+#include <rte_pcapng.h>
+#include <rte_pause.h>
+#include <rte_ring.h>
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+#include <rte_thread.h>
+#include <rte_version.h>
+
+#include "private.h"
+
+/*
+ * Create the file handle for pcapng output
+ * Note: can't really tell wireshark about errors since this in an
+ * independent thread.
+ */
+static rte_pcapng_t *
+capture_pcapng_open(const char *path, int *fd, uint16_t port_id, const char *filter)
+{
+	rte_pcapng_t *pcapng = NULL;
+	char port_name[RTE_ETH_NAME_MAX_LEN];
+	char appname[128];
+	char ifname[IFNAMSIZ];
+	char *ifdescr = NULL;
+	struct utsname uts;
+	char *osname = NULL;
+
+	/* OS name is optional, just keep going if not found */
+	if (uname(&uts) == 0 && asprintf(&osname, "%s %s", uts.sysname, uts.release) < 0)
+		osname = NULL;
+
+	/* add DPDK internal name */
+	if (rte_eth_dev_get_name_by_port(port_id, port_name) != 0) {
+		CAPTURE_LOG(NOTICE, "Could not find port name for %u", port_id);
+		goto cleanup;
+	}
+
+	/* match name convention used by dpdk-wireshark-extcap */
+	snprintf(ifname, sizeof(ifname), CAPTURE_EXTCAP_PREFIX ":%u", port_id);
+	if (asprintf(&ifdescr, "DPDK %s", port_name) < 0)
+		ifdescr = NULL;
+
+	/* mirror what other applications do for name */
+	snprintf(appname, sizeof(appname), CAPTURE_EXTCAP_PREFIX " (%s)", rte_version());
+
+	/*
+	 * Open the output in non-block mode in case it is a FIFO without a reader.
+	 * Wireshark must open the read end before asking us to capture.
+	 */
+	*fd = open(path, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
+	if (*fd < 0) {
+		CAPTURE_LOG(ERR, "Could not open %s: %s", path, strerror(errno));
+		goto cleanup;
+	}
+
+	/* recheck that it is ok to use */
+	struct stat sb;
+	if (fstat(*fd, &sb) < 0 || !is_fifo(&sb)) {
+		CAPTURE_LOG(ERR, "Not safe to use %s", path);
+		goto close_fd;
+	}
+
+	/* writes in the drain loop should block normally */
+	int flags = fcntl(*fd, F_GETFL, 0);
+	if (flags < 0 || fcntl(*fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
+		CAPTURE_LOG(ERR, "fcntl %s: %s",  path,  strerror(errno));
+		goto close_fd;
+	}
+
+	/* put pcapng header on and setup */
+	pcapng = rte_pcapng_fdopen(*fd, osname, NULL, appname, NULL);
+	if (pcapng == NULL) {
+		CAPTURE_LOG(ERR, "Add section block failed");
+		goto close_fd;
+	}
+
+	if (rte_pcapng_add_interface(pcapng, port_id, DLT_EN10MB, ifname, ifdescr, filter) < 0) {
+		CAPTURE_LOG(ERR, "Add interface for port %u:%s failed", port_id, ifname);
+		rte_pcapng_close(pcapng);  /* closes fd */
+		pcapng = NULL;
+	}
+	goto cleanup;
+
+close_fd:
+	close(*fd);
+cleanup:
+	free(osname);
+	free(ifdescr);
+	return pcapng;
+}
+
+
+
+static void
+capture_write_stats(rte_pcapng_t *pcapng, struct capture *cap)
+{
+	struct capture_total t;
+
+	rte_spinlock_lock(&cap->cbs_lock);
+	capture_sum_stats(cap, &t);
+	rte_spinlock_unlock(&cap->cbs_lock);
+
+	/* TODO: extend pcapng to have more stats */
+	rte_pcapng_write_stats(pcapng, cap->port_id,
+			       t.accepted + t.filtered + t.nombuf, /* ifrecv */
+			       t.nombuf + t.ringfull, /* ifdrop */
+			       NULL);
+}
+
+/* Check that output file is still ok to write (i.e FIFO not closed).
+ * Used to detect when wireshark has exited, and idle.
+ */
+static int
+check_fifo_status(int fd)
+{
+	struct pollfd pfd = { .fd = fd };
+
+	if (poll(&pfd, 1, 0) < 0) {
+		CAPTURE_LOG(ERR, "poll failed: %s", strerror(errno));
+		return -1;
+	}
+	if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
+		CAPTURE_LOG(NOTICE, "fifo reader closed");
+		return -1;
+	}
+	return 0;
+}
+
+static ssize_t
+capture_process_ring(struct rte_ring *ring, rte_pcapng_t *pcapng,
+		     unsigned int *available)
+{
+	struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+	unsigned int n;
+	ssize_t written;
+
+	n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+				      CAPTURE_BURST_SIZE, available);
+	if (n == 0)
+		return 0;
+
+	written = rte_pcapng_write_packets(pcapng, pkts, n);
+	rte_pktmbuf_free_bulk(pkts, n);
+
+	return written;
+}
+
+static void
+capture_flush_ring(struct rte_ring *ring)
+{
+	for (;;) {
+		struct rte_mbuf *pkts[CAPTURE_BURST_SIZE];
+		unsigned int n;
+
+		n = rte_ring_sc_dequeue_burst(ring, (void **) pkts,
+					      CAPTURE_BURST_SIZE, NULL);
+		if (n == 0)
+			break;
+
+		rte_pktmbuf_free_bulk(pkts, n);
+	}
+}
+
+/* The capture thread that moves packets from ring to the pcapng out */
+uint32_t __rte_hot
+capture_thread(void *arg)
+{
+	struct capture *cap = arg;
+	unsigned int empty_count = 0;
+	bool reader_gone = false;
+	bool orphaned = false;
+	int fd = -1;
+
+	CAPTURE_LOG(INFO, "capture thread starting");
+
+	char name[RTE_THREAD_NAME_SIZE];
+	snprintf(name, sizeof(name), "cap-%u", cap->idx);
+	rte_thread_set_prefixed_name(rte_thread_self(), name);
+
+	/* This thread wants to detect when file gets closed (for FIFO) */
+	sigset_t set;
+	sigemptyset(&set);
+	sigaddset(&set, SIGPIPE);
+	pthread_sigmask(SIG_BLOCK, &set, NULL);
+
+	rte_pcapng_t *pcapng = capture_pcapng_open(cap->output, &fd, cap->port_id,
+						   __rte_capture_filter_string(cap->filter));
+	if (pcapng == NULL) {
+		/* secondaries attached before this thread started */
+		if (capture_mp_broadcast(cap, CAPTURE_MP_STOP) < 0)
+			orphaned = true;
+		capture_remove_callbacks(cap);
+		goto error;
+	}
+
+	while (rte_atomic_load_explicit(&cap->running, rte_memory_order_relaxed)) {
+		ssize_t written;
+		unsigned int avail;
+
+		written = capture_process_ring(cap->ring, pcapng, &avail);
+		if (written < 0) {
+			if (errno == EPIPE)
+				CAPTURE_LOG(DEBUG, "FIFO closed by reader");
+			else
+				CAPTURE_LOG(ERR, "write to file failed: %s", strerror(errno));
+
+			reader_gone = true;
+			break;
+		}
+
+		if (written > 0) {
+			/* are there more packets? */
+			empty_count = (avail == 0);
+		} else if (empty_count < SLEEP_THRESHOLD) {
+			/* spin a few times before checking */
+			++empty_count;
+			rte_pause();
+		} else if (check_fifo_status(fd) == 0) {
+			/* avoid consuming 100% CPU polling */
+			rte_delay_us_sleep(SLEEP_US);
+		} else {
+			/* output FIFO has closed */
+			reader_gone = true;
+			break;
+		}
+	}
+
+	/*
+	 * Secondaries must stop first, they enqueue into the ring freed below.
+	 * Their counters are in shared blocks, so still counted in the stats.
+	 */
+	CAPTURE_LOG(INFO, "capture thread stopping");
+	if (capture_mp_broadcast(cap, CAPTURE_MP_STOP) < 0)
+		orphaned = true;
+	capture_remove_callbacks(cap);
+
+	/* Process residual */
+	if (reader_gone)
+		capture_flush_ring(cap->ring);
+	else {
+		while (capture_process_ring(cap->ring, pcapng, NULL) > 0)
+			continue;
+
+		capture_write_stats(pcapng, cap);
+	}
+
+	rte_pcapng_close(pcapng);
+
+error:
+	capture_unlink(cap);
+	if (orphaned) {
+		/*
+		 * A secondary did not confirm that it detached, so it may still
+		 * be inside a burst holding this capture. Freeing the ring it
+		 * writes into would corrupt that process, so leak instead.
+		 */
+		CAPTURE_LOG(ERR,
+			    "capture %u: secondary still attached, leaking ring and pool",
+			    cap->idx);
+	} else {
+		capture_free(cap);
+	}
+
+	return 0;
+}
diff --git a/lib/capture/private.h b/lib/capture/private.h
new file mode 100644
index 0000000000..97ecfa5bba
--- /dev/null
+++ b/lib/capture/private.h
@@ -0,0 +1,214 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+#ifndef CAPTURE_PRIVATE_H
+#define CAPTURE_PRIVATE_H
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <sys/queue.h>
+#include <sys/stat.h>
+
+#include <rte_spinlock.h>
+#include <rte_stdatomic.h>
+
+#define RTE_LOGTYPE_CAPTURE rte_capture_logtype
+extern int rte_capture_logtype;
+#define CAPTURE_LOG(level, ...) \
+	RTE_LOG_LINE_PREFIX(level, CAPTURE, "%s(): ", __func__, __VA_ARGS__)
+
+/* Shared between the control, datapath, output and multi-process parts. */
+#define ALL_QUEUES		UINT16_MAX
+#define CAPTURE_BURST_SIZE	32u
+#define CAPTURE_EXTCAP_PREFIX	"dpdk"
+#define SLEEP_THRESHOLD		100
+#define SLEEP_US		100
+
+/* Per port/queue/direction counters. */
+struct capture_stats {
+	uint64_t accepted;	/**< Number of packets accepted by filter. */
+	uint64_t filtered;	/**< Number of packets rejected by filter. */
+	uint64_t nombuf;	/**< Number of mbuf allocation failures. */
+	uint64_t ringfull;	/**< Number of missed packets due to ring full. */
+};
+
+/* Aggregate per-queue counters of a capture instance. */
+struct capture_total {
+	uint64_t accepted;
+	uint64_t filtered;
+	uint64_t nombuf;
+	uint64_t ringfull;
+};
+
+/*
+ * Callback data, one per capture per port/queue/direction.
+ *
+ * Never freed, as in lib/bpf/bpf_pkt.c: rte_eth_remove_{rx,tx}_callback()
+ * only unlinks, so a datapath thread can still enter the callback afterwards
+ * and needs something valid to synchronize on. Recycled only by a later
+ * capture of the same port/queue/direction.
+ */
+struct __rte_cache_aligned capture_rxtx_cb {
+	/* used by both data and control path */
+	RTE_ATOMIC(uint32_t) use_count;
+	RTE_ATOMIC(struct capture *) cap;	/* owner, NULL when idle */
+	struct capture_stats stats;
+
+	/* owner's attached list, guarded by cap->cbs_lock; this is how the
+	 * primary sees a secondary's counters. Separate from 'next' because an
+	 * idle block is on the free list at the same time.
+	 */
+	TAILQ_ENTRY(capture_rxtx_cb) attached;
+
+	/* filter loaded by this process, NULL if the capture has none */
+	struct capture_filter_prog *prog;
+
+	/* used by control path only, in the process that attached this block */
+	TAILQ_ENTRY(capture_rxtx_cb) next;	/* links into capture_cb_freelist */
+	const struct rte_eth_rxtx_callback *cb;
+	const struct rte_eth_rxtx_callback *stale;
+	uint16_t port_id;
+	uint16_t queue;
+	bool is_rx;
+};
+
+/*
+ * Idle callback blocks, guarded by capture_lock. Per-process: cb and stale
+ * are only meaningful in the process that registered the ethdev callback.
+ */
+TAILQ_HEAD(capture_cb_list, capture_rxtx_cb);
+
+/*
+ * Per-capture state, in shared memory. A secondary gets this pointer in the
+ * IPC request. It must not follow filter (process-private eBPF) or touch the
+ * creator-only fields marked below.
+ */
+struct capture {
+	RTE_ATOMIC(bool) running;
+	struct rte_capture_filter *filter;
+	struct rte_ring *ring;		/* ring from dataplane to capture thread */
+	struct rte_mempool *mp;		/* mempool for capture mbufs */
+
+	uint32_t snaplen;		/* amount of data to copy */
+	uint16_t queue;
+	uint16_t tx_queues;
+	uint16_t rx_queues;
+	uint16_t port_id;
+	int socket_id;
+
+	uint32_t idx;
+	char *output;			/* filename of out, creator only */
+
+	TAILQ_ENTRY(capture) next;	/* links into capture_list, creator only */
+
+	/* blocks attached by any process, so the lock cannot be capture_lock */
+	rte_spinlock_t cbs_lock;
+	struct capture_cb_list attached;
+
+	/* counters of queues already detached; guarded by cbs_lock */
+	struct capture_total total;
+
+	/* what this process attached; sized for one process's queues, so a
+	 * secondary keeps its own record. Creator only.
+	 */
+	struct capture_filter_prog *prog;
+	uint16_t nb_cbs;
+	struct capture_rxtx_cb *cbs[];
+};
+
+/* Captures owned by this process. The lock also guards the cb free list. */
+TAILQ_HEAD(capture_list, capture);
+extern rte_spinlock_t capture_lock;
+
+/* The application may be privileged and extcap not, so only write to a FIFO. */
+static inline bool
+is_fifo(const struct stat *stb)
+{
+	return S_ISFIFO(stb->st_mode);
+}
+
+/* datapath.c */
+int capture_attach_queues(struct capture *cap, struct capture_rxtx_cb **cbs,
+			  uint16_t *nb_cbs, struct capture_filter_prog **progp);
+void capture_detach_queues(struct capture *cap, struct capture_rxtx_cb **cbs,
+			   uint16_t *nb_cbs, struct capture_filter_prog *prog);
+int capture_add_callbacks(struct capture *cap);
+void capture_remove_callbacks(struct capture *cap);
+void capture_sum_stats(const struct capture *cap, struct capture_total *t);
+
+/* capture.c */
+void capture_unlink(struct capture *cap);
+void capture_free(struct capture *cap);
+
+/* output.c */
+uint32_t capture_thread(void *arg);
+
+/* multiproc.c */
+enum capture_mp_op {
+	CAPTURE_MP_START,
+	CAPTURE_MP_STOP,
+};
+
+void capture_mp_register(void);
+int capture_mp_broadcast(struct capture *cap, enum capture_mp_op op);
+
+/* Shared eBPF instructions, plus the per-process loaded program. */
+struct rte_capture_filter;
+struct capture_filter_prog;
+
+#ifdef RTE_HAS_LIBPCAP
+struct rte_capture_filter *__rte_capture_filter_create(const char *str);
+const char *__rte_capture_filter_string(struct rte_capture_filter *filter);
+void __rte_capture_filter_free(struct rte_capture_filter *filter);
+
+struct capture_filter_prog *__rte_capture_filter_load(const struct rte_capture_filter *filter);
+void __rte_capture_filter_unload(struct capture_filter_prog *prog);
+uint64_t __rte_capture_filter(const struct capture_filter_prog *prog, struct rte_mbuf *mb);
+
+#else /* !RTE_HAS_LIBPCAP */
+
+/* Stub version if pcap is not available */
+static inline struct rte_capture_filter *
+__rte_capture_filter_create(const char *str)
+{
+	RTE_SET_USED(str);
+	return NULL; /* not supported */
+}
+
+static inline const char *
+__rte_capture_filter_string(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+	return NULL;
+}
+
+static inline void
+__rte_capture_filter_free(struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+}
+
+static inline struct capture_filter_prog *
+__rte_capture_filter_load(const struct rte_capture_filter *filter)
+{
+	RTE_SET_USED(filter);
+	return NULL;
+}
+
+static inline void
+__rte_capture_filter_unload(struct capture_filter_prog *prog)
+{
+	RTE_SET_USED(prog);
+}
+
+/* Zero if the packet does not match the filter. */
+static inline uint64_t
+__rte_capture_filter(const struct capture_filter_prog *prog, struct rte_mbuf *mb)
+{
+	RTE_SET_USED(prog);
+	RTE_SET_USED(mb);
+	return 1;
+}
+
+#endif  /* !RTE_HAS_LIBPCAP */
+#endif /* CAPTURE_PRIVATE_H */
diff --git a/lib/meson.build b/lib/meson.build
index af5c160cb8..6d9992f61f 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -49,6 +49,7 @@ libraries = [
         'lpm',
         'member',
         'pcapng',
+        'capture', # depends on pcapng and bpf
         'power',
         'rawdev',
         'regexdev',
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v4 2/3] test: add test for capture hooks
  2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
  2026-10-13  3:41     ` [PATCH v4 1/3] capture: infrastructure wireshark packet capture Stephen Hemminger
@ 2026-10-13  3:41     ` Stephen Hemminger
  2026-10-13  3:41     ` [PATCH v4 3/3] usertools: add script for wireshark capture Stephen Hemminger
  2026-10-13  8:28     ` [PATCH v4 0/3] wireshark capture implementation fengchengwen
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-10-13  3:41 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

Provide tests to exercise telemetry based packet capture.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS             |   1 +
 app/test/meson.build    |   1 +
 app/test/test_capture.c | 688 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 690 insertions(+)
 create mode 100644 app/test/test_capture.c

diff --git a/MAINTAINERS b/MAINTAINERS
index 96adaebc1d..2192de83e3 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1718,6 +1718,7 @@ Packet capture
 M: Reshma Pattan <reshma.pattan@intel.com>
 M: Stephen Hemminger <stephen@networkplumber.org>
 F: lib/capture/
+F: app/test/test_capture.c
 F: lib/pdump/
 F: doc/guides/prog_guide/pdump_lib.rst
 F: app/test/test_pdump.*
diff --git a/app/test/meson.build b/app/test/meson.build
index 881b919b63..872d1ecfbe 100644
--- a/app/test/meson.build
+++ b/app/test/meson.build
@@ -41,6 +41,7 @@ source_file_deps = {
     'test_bpf.c': ['bpf', 'net'],
     'test_bpf_validate.c': ['bpf'],
     'test_byteorder.c': [],
+    'test_capture.c': ['net_null', 'net', 'ethdev', 'bus_vdev', 'capture'],
     'test_cfgfile.c': ['cfgfile'],
     'test_cksum.c': ['net'],
     'test_cksum_perf.c': ['net'],
diff --git a/app/test/test_capture.c b/app/test/test_capture.c
new file mode 100644
index 0000000000..7c8bd49653
--- /dev/null
+++ b/app/test/test_capture.c
@@ -0,0 +1,688 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Stephen Hemminger
+ */
+
+/*
+ * Functional test for the capture library.
+ *
+ * The capture library has no public C API: it is driven entirely through the
+ * telemetry socket. The output is a path ('out=') that the primary opens
+ * itself and writes pcapng into; the caller never receives packet data over
+ * the control socket. This test therefore behaves like an external capture
+ * tool.
+ *
+ * The suite setup builds the fixture shared by every case:
+ *
+ *   - a virtual ethdev backed by net_null. Rx synthesizes packets and Tx is a
+ *     sink that frees whatever it is handed, which is all this test needs and
+ *     avoids the per-queue ring bookkeeping of net_ring. The port is given
+ *     NB_QUEUES queues so per-queue selection can be exercised;
+ *   - a FIFO for capture output;
+ *   - a connection to this process's own telemetry socket.
+ *
+ * Each case then runs with the FIFO read end open (the primary's non-blocking
+ * O_WRONLY open needs a reader present) and hands it back in the common
+ * teardown, which is also what tears down a capture left running by a failed
+ * assertion.
+ *
+ * The cases are:
+ *
+ *   lifecycle
+ *     start an all-queue capture, check a pcapng stream appears, the capture
+ *     is listed, stats account for traffic injected on every queue in both
+ *     directions, and it tears down when the reader disconnects;
+ *   bad_queue
+ *     an out-of-range queue index is rejected, not started;
+ *   queue_select
+ *     a queue=CAPTURE_QUEUE capture installs callbacks on that queue only, so
+ *     traffic on any other queue is not captured while traffic on that one is,
+ *     in both directions. The selected queue is neither the first nor the last
+ *     of NB_QUEUES, so an off-by-one in either install loop is caught. Both
+ *     directions are steerable here: the test names the queue in
+ *     rte_eth_rx_burst()/rte_eth_tx_burst() and net_null sources or sinks on
+ *     whichever queue it is handed.
+ *   dual
+ *     two captures of the same queues coexist, each with its own output and
+ *     its own counters. This case makes a second FIFO, with its own reader,
+ *     since the library only writes to a FIFO.
+ *
+ * The suite is skipped (not failed) if telemetry is not enabled or the
+ * net_null driver is not available.
+ */
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/select.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/un.h>
+
+#include <rte_bus_vdev.h>
+#include <rte_common.h>
+#include <rte_cycles.h>
+#include <rte_eal.h>
+#include <rte_ethdev.h>
+#include <rte_lcore.h>
+#include <rte_log.h>
+#include <rte_mbuf.h>
+
+#include "test.h"
+
+#define TELEMETRY_VERSION	"v2"
+#define CAPTURE_START		"/ethdev/capture/start"
+#define CAPTURE_LIST		"/ethdev/capture/list"
+#define CAPTURE_STATS		"/ethdev/capture/stats"
+#define CAPTURE_STOP		"/ethdev/capture/stop"
+
+#define NULL_VDEV_NAME		"net_null_capture"
+#define NB_QUEUES		4
+#define CAPTURE_QUEUE		2	/* not first or last of NB_QUEUES */
+#define RING_SIZE		256
+#define NB_MBUFS		1024
+#define MBUF_CACHE		32
+#define NB_PKTS			32
+#define PKT_LEN			64
+#define REPLY_LEN		16384
+
+/* pcapng Section Header Block type, byte-order independent on disk. */
+static const uint8_t pcapng_shb_magic[4] = { 0x0a, 0x0d, 0x0d, 0x0a };
+
+static struct rte_mempool *test_mp;
+static uint16_t test_port = RTE_MAX_ETHPORTS;
+static char fifo_path[PATH_MAX];
+static int tel_sock = -1;
+static int fifo_rd = -1;
+
+/* --- telemetry client helpers ------------------------------------------ */
+
+/* Connect to this process's telemetry socket; -1 if unavailable. */
+static int
+tel_connect(void)
+{
+	struct sockaddr_un addr = { .sun_family = AF_UNIX };
+	char buf[REPLY_LEN];
+	int s;
+
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/dpdk_telemetry.%s",
+		 rte_eal_get_runtime_dir(), TELEMETRY_VERSION);
+
+	s = socket(AF_UNIX, SOCK_SEQPACKET, 0);
+	if (s < 0)
+		return -1;
+
+	if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+		close(s);
+		return -1;
+	}
+
+	/* Server greets with an info message; consume it. */
+	if (recv(s, buf, sizeof(buf), 0) <= 0) {
+		close(s);
+		return -1;
+	}
+	return s;
+}
+
+/* Send a command and read the reply. */
+static int
+tel_cmd(const char *cmd, char *reply, size_t reply_sz)
+{
+	ssize_t n;
+
+	if (send(tel_sock, cmd, strlen(cmd), 0) < 0)
+		return -1;
+	n = recv(tel_sock, reply, reply_sz - 1, 0);
+	if (n < 0)
+		return -1;
+	reply[n] = '\0';
+	return 0;
+}
+
+/* Minimal JSON scanning: find "key" and read the unsigned number after it. */
+static int
+json_uint(const char *s, const char *key, uint64_t *out)
+{
+	const char *p = strstr(s, key);
+
+	if (p == NULL)
+		return -1;
+	for (p += strlen(key); *p != '\0' && !isdigit((unsigned char)*p); p++)
+		;
+	if (*p == '\0')
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Read the first element of the array in a list reply; -1 if empty/absent. */
+static int
+json_first_array_uint(const char *s, uint64_t *out)
+{
+	const char *p = strchr(s, '[');
+
+	if (p == NULL)
+		return -1;
+	for (p++; *p == ' '; p++)
+		;
+	if (*p == ']' || !isdigit((unsigned char)*p))
+		return -1;
+	*out = strtoull(p, NULL, 10);
+	return 0;
+}
+
+/* Query the accepted counter for a capture id via telemetry. */
+static int
+capture_accepted(uint64_t id, uint64_t *accepted)
+{
+	char cmd[64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STATS, id);
+	if (tel_cmd(cmd, reply, sizeof(reply)) < 0)
+		return -1;
+	return json_uint(reply, "\"accepted\"", accepted);
+}
+
+/*
+ * Poll the capture list until it is empty (the capture has torn down).
+ * Returns 0 once empty, -1 on timeout (~2s) or telemetry failure.
+ */
+static int
+wait_list_empty(void)
+{
+	char reply[REPLY_LEN];
+	uint64_t id;
+
+	for (int i = 0; i < 200; i++) {
+		if (tel_cmd(CAPTURE_LIST, reply, sizeof(reply)) < 0)
+			return -1;
+		if (json_first_array_uint(reply, &id) < 0)
+			return 0;
+		rte_delay_ms(10);
+	}
+	return -1;
+}
+
+/* --- packet injection --------------------------------------------------- */
+
+/*
+ * Pull count packets from a queue. net_null synthesizes them and pulling runs
+ * the capture Rx callback on each; the mbufs are ours to free.
+ */
+static int
+inject_rx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t got;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	got = rte_eth_rx_burst(test_port, queue, bufs, count);
+	rte_pktmbuf_free_bulk(bufs, got);
+	return got == count ? 0 : -1;	/* net_null fills the whole request */
+}
+
+/*
+ * Transmit count packets on a queue. The capture Tx callback runs first (it
+ * only copies) and net_null then frees the originals, so only mbufs it did not
+ * take are reclaimed here.
+ */
+static int
+inject_tx(uint16_t queue, unsigned int count)
+{
+	struct rte_mbuf *bufs[NB_PKTS];
+	uint16_t sent;
+
+	if (count > NB_PKTS)
+		count = NB_PKTS;
+
+	for (unsigned int i = 0; i < count; i++) {
+		struct rte_mbuf *m = rte_pktmbuf_alloc(test_mp);
+
+		if (m == NULL) {
+			rte_pktmbuf_free_bulk(bufs, i);
+			return -1;
+		}
+		m->pkt_len = m->data_len = PKT_LEN;
+		memset(rte_pktmbuf_mtod(m, void *), 0, PKT_LEN);
+		bufs[i] = m;
+	}
+
+	/* net_null accepts the whole burst; free the tail if it ever does not. */
+	sent = rte_eth_tx_burst(test_port, queue, bufs, count);
+	if (sent < count) {
+		rte_pktmbuf_free_bulk(&bufs[sent], count - sent);
+		return -1;
+	}
+	return 0;
+}
+
+/*
+ * Inject on every queue except skip; pass NB_QUEUES to cover them all.
+ */
+static int
+inject_rx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_rx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+static int
+inject_tx_except(uint16_t skip, unsigned int count)
+{
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (q != skip && inject_tx(q, count) < 0)
+			return -1;
+	}
+	return 0;
+}
+
+/* --- fixture ------------------------------------------------------------ */
+
+static int
+build_port(void)
+{
+	struct rte_eth_conf conf = { 0 };
+
+	test_mp = rte_pktmbuf_pool_create("capture_test_mp", NB_MBUFS, MBUF_CACHE,
+					  0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
+	if (test_mp == NULL)
+		return -1;
+
+	if (rte_vdev_init(NULL_VDEV_NAME, NULL) < 0)
+		return -1;
+	if (rte_eth_dev_get_port_by_name(NULL_VDEV_NAME, &test_port) != 0)
+		return -1;
+
+	if (rte_eth_dev_configure(test_port, NB_QUEUES, NB_QUEUES, &conf) < 0)
+		return -1;
+	for (uint16_t q = 0; q < NB_QUEUES; q++) {
+		if (rte_eth_rx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL, test_mp) < 0)
+			return -1;
+		if (rte_eth_tx_queue_setup(test_port, q, RING_SIZE, rte_socket_id(),
+					   NULL) < 0)
+			return -1;
+	}
+
+	return rte_eth_dev_start(test_port);
+}
+
+static void
+teardown_port(void)
+{
+	if (test_port != RTE_MAX_ETHPORTS) {
+		rte_eth_dev_stop(test_port);
+		/*
+		 * Uninit drives the driver's remove, which closes and releases
+		 * the ethdev port and removes the vdev in one path. No separate
+		 * rte_eth_dev_close() is needed and null's close is idempotent,
+		 * so this is safe even if a tree's close already released it.
+		 */
+		rte_vdev_uninit(NULL_VDEV_NAME);
+		test_port = RTE_MAX_ETHPORTS;
+	}
+	rte_mempool_free(test_mp);
+	test_mp = NULL;
+}
+
+/*
+ * Create a capture FIFO in the runtime dir; -1 on failure.
+ *
+ * The capture library only writes to a FIFO, so every output in this test is
+ * one. 'tag' keeps the names of concurrent captures apart.
+ */
+static int
+make_fifo(char *path, size_t pathlen, const char *tag)
+{
+	snprintf(path, pathlen, "%s/capture_test%s.%d",
+		 rte_eal_get_runtime_dir(), tag, (int)getpid());
+	unlink(path);				/* clear any stale node */
+	if (mkfifo(path, 0600) < 0)
+		return -1;
+	return 0;
+}
+
+/*
+ * Release everything the suite setup built. Idempotent, because the runner
+ * does not call the suite teardown when the suite setup fails, so the setup
+ * has to unwind itself by calling this directly.
+ */
+static void
+testsuite_teardown(void)
+{
+	if (fifo_rd >= 0) {
+		close(fifo_rd);
+		fifo_rd = -1;
+	}
+	if (fifo_path[0] != '\0') {
+		unlink(fifo_path);
+		fifo_path[0] = '\0';
+	}
+	if (tel_sock >= 0) {
+		close(tel_sock);
+		tel_sock = -1;
+	}
+	teardown_port();
+}
+
+static int
+testsuite_setup(void)
+{
+	int ret = TEST_SKIPPED;
+
+	fifo_path[0] = '\0';
+
+	/*
+	 * The library writes to the FIFO; a closed reader must give the writer
+	 * EPIPE, not a fatal SIGPIPE. The library masks SIGPIPE on its drain
+	 * thread, but ignore it here too so the test process is safe regardless.
+	 */
+	signal(SIGPIPE, SIG_IGN);
+
+	tel_sock = tel_connect();
+	if (tel_sock < 0) {
+		printf("telemetry socket not available, skipping\n");
+		goto error;
+	}
+
+	if (build_port() < 0) {
+		printf("could not build net_null test port, skipping\n");
+		goto error;
+	}
+
+	if (make_fifo(fifo_path, sizeof(fifo_path), "") < 0) {
+		printf("could not create fifo %s: %s\n", fifo_path, strerror(errno));
+		ret = TEST_FAILED;
+		goto error;
+	}
+
+	return TEST_SUCCESS;
+
+error:
+	testsuite_teardown();
+	return ret;
+}
+
+/*
+ * Open the read end before each case: the library opens the FIFO
+ * O_WRONLY|O_NONBLOCK and would get ENXIO with no reader present.
+ * O_RDONLY|O_NONBLOCK returns immediately even with no writer yet.
+ */
+static int
+ut_setup(void)
+{
+	fifo_rd = open(fifo_path, O_RDONLY | O_NONBLOCK);
+	if (fifo_rd < 0) {
+		printf("open %s: %s\n", fifo_path, strerror(errno));
+		return TEST_FAILED;
+	}
+	return TEST_SUCCESS;
+}
+
+/*
+ * Common cleanup. Closing the read end is the hangup that makes any capture
+ * still running -- including one that a failed assertion returned out from
+ * under -- tear itself down, so no case can leak a capture into the next.
+ */
+static void
+ut_teardown(void)
+{
+	if (fifo_rd >= 0) {
+		close(fifo_rd);
+		fifo_rd = -1;
+	}
+	if (wait_list_empty() < 0)
+		printf("warning: capture still active after test case\n");
+}
+
+/* --- test cases --------------------------------------------------------- */
+
+static int
+test_capture_lifecycle(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN], pcapng[REPLY_LEN];
+	uint64_t id, accepted;
+	struct timeval tv;
+	fd_set rfds;
+	ssize_t n;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "capture start returned an error: %s", reply);
+
+	/*
+	 * Inject traffic on every queue: with no queue= the callbacks must be
+	 * installed on all of them. Rx callbacks run synchronously inside
+	 * rx_burst, so the accepted counter is up to date as soon as this
+	 * returns.
+	 */
+	TEST_ASSERT_SUCCESS(inject_rx_except(NB_QUEUES, NB_PKTS),
+			    "rx injection failed");
+
+	/* A pcapng stream (at least the section header) must appear. */
+	FD_ZERO(&rfds);
+	FD_SET(fifo_rd, &rfds);
+	tv = (struct timeval){ .tv_sec = 2 };
+	TEST_ASSERT(select(fifo_rd + 1, &rfds, NULL, NULL, &tv) > 0,
+		    "no pcapng output within timeout");
+	n = read(fifo_rd, pcapng, sizeof(pcapng));
+	TEST_ASSERT(n >= 4, "short pcapng read (%zd)", n);
+	TEST_ASSERT(memcmp(pcapng, pcapng_shb_magic, sizeof(pcapng_shb_magic)) == 0,
+		    "output does not start with a pcapng section header block");
+
+	/* The capture must show up in the list. */
+	TEST_ASSERT_SUCCESS(tel_cmd(CAPTURE_LIST, reply, sizeof(reply)),
+			    "capture list command failed");
+	TEST_ASSERT_SUCCESS(json_first_array_uint(reply, &id),
+			    "no capture id in list reply: %s", reply);
+
+	/* Stats must report exactly the packets we injected, on every queue. */
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, NB_QUEUES * NB_PKTS);
+
+	/* Same for the Tx side of every queue. */
+	TEST_ASSERT_SUCCESS(inject_tx_except(NB_QUEUES, NB_PKTS),
+			    "tx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_QUEUES * NB_PKTS),
+			  "accepted %" PRIu64 " != %d", accepted, 2 * NB_QUEUES * NB_PKTS);
+
+	/*
+	 * Close the reader: the capture should detect the hangup and tear down.
+	 * The drain thread's idle poll notices POLLERR on the write end on its
+	 * own; the extra injection just shortens the wait if it was mid-drain.
+	 * Done here rather than left to the teardown because the hangup is what
+	 * this case is asserting.
+	 */
+	close(fifo_rd);
+	fifo_rd = -1;
+	inject_rx(0, NB_PKTS);	/* any queue; all are captured here */
+	TEST_ASSERT_SUCCESS(wait_list_empty(),
+			    "capture did not tear down after reader closed");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * An out-of-range queue index must be rejected. The range check runs before
+ * the output is opened, so no capture should be left behind even though a
+ * reader is attached.
+ */
+static int
+test_capture_bad_queue(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, NB_QUEUES);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "capture start command failed");
+	TEST_ASSERT(strstr(reply, "error") != NULL,
+		    "out-of-range queue=%u was not rejected: %s", NB_QUEUES, reply);
+	TEST_ASSERT_SUCCESS(wait_list_empty(),
+			    "rejected start left a capture behind");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * With queue=CAPTURE_QUEUE the callbacks are installed on that queue only:
+ * traffic on any other queue must not be captured, traffic on that one must
+ * be, in both directions. The counters are bumped synchronously inside
+ * rx_burst/tx_burst, so every check here is race-free.
+ */
+static int
+test_capture_queue_select(void)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+	uint64_t id, accepted;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s,queue=%u",
+		 CAPTURE_START, test_port, fifo_path, CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)),
+			    "queue=%u capture start failed", CAPTURE_QUEUE);
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "queue=%u capture start returned an error: %s",
+		    CAPTURE_QUEUE, reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id),
+			    "no id in start reply: %s", reply);
+
+	/* Unselected queues: callbacks never installed, nothing captured. */
+	TEST_ASSERT_SUCCESS(inject_rx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(inject_tx_except(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on unselected queues failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)0,
+			  "other queues captured under queue=%u (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* Selected queue: captured, on Rx and on Tx. */
+	TEST_ASSERT_SUCCESS(inject_rx(CAPTURE_QUEUE, NB_PKTS),
+			    "rx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)NB_PKTS,
+			  "rx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	TEST_ASSERT_SUCCESS(inject_tx(CAPTURE_QUEUE, NB_PKTS),
+			    "tx inject on queue %u failed", CAPTURE_QUEUE);
+	TEST_ASSERT_SUCCESS(capture_accepted(id, &accepted),
+			    "capture stats query failed");
+	TEST_ASSERT_EQUAL(accepted, (uint64_t)(2 * NB_PKTS),
+			  "tx queue %u not captured (accepted %" PRIu64 ")",
+			  CAPTURE_QUEUE, accepted);
+
+	/* The teardown closes the reader, which stops this capture. */
+	return TEST_SUCCESS;
+}
+
+/* Body of test_capture_dual(), which owns the second fifo. */
+static int
+dual_capture(const char *path2)
+{
+	char cmd[PATH_MAX + 64], reply[REPLY_LEN];
+	uint64_t id1, id2, a1, a2;
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, fifo_path);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "first start failed");
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id1), "no id in reply: %s", reply);
+
+	snprintf(cmd, sizeof(cmd), "%s,%u,out=%s", CAPTURE_START, test_port, path2);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "second start failed");
+	TEST_ASSERT(strstr(reply, "error") == NULL,
+		    "second capture of the same queues was rejected: %s", reply);
+	TEST_ASSERT_SUCCESS(json_uint(reply, "\"id\"", &id2), "no id in reply: %s", reply);
+	TEST_ASSERT_NOT_EQUAL(id1, id2, "both captures got id %" PRIu64, id1);
+
+	/* one burst on a queue both cover must be counted twice */
+	TEST_ASSERT_SUCCESS(inject_rx(0, NB_PKTS), "rx injection failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id1, &a1), "stats query failed");
+	TEST_ASSERT_SUCCESS(capture_accepted(id2, &a2), "stats query failed");
+	TEST_ASSERT_EQUAL(a1, (uint64_t)NB_PKTS,
+			  "first capture accepted %" PRIu64 " != %d", a1, NB_PKTS);
+	TEST_ASSERT_EQUAL(a2, (uint64_t)NB_PKTS,
+			  "second capture accepted %" PRIu64 " != %d", a2, NB_PKTS);
+
+	/* the fifo one is stopped by the teardown closing the reader */
+	snprintf(cmd, sizeof(cmd), "%s,%" PRIu64, CAPTURE_STOP, id2);
+	TEST_ASSERT_SUCCESS(tel_cmd(cmd, reply, sizeof(reply)), "stop command failed");
+
+	return TEST_SUCCESS;
+}
+
+/*
+ * Two captures may cover the same queue, each with its own filter and its own
+ * callback. Both must start, both must be listed separately, and traffic on a
+ * shared queue must be counted by both.
+ */
+static int
+test_capture_dual(void)
+{
+	char path2[PATH_MAX];
+	int rd2, ret;
+
+	/* the second capture needs an output of its own, with its own reader */
+	if (make_fifo(path2, sizeof(path2), "2") < 0) {
+		printf("could not create %s: %s\n", path2, strerror(errno));
+		return TEST_FAILED;
+	}
+
+	rd2 = open(path2, O_RDONLY | O_NONBLOCK);
+	if (rd2 < 0) {
+		printf("open %s: %s\n", path2, strerror(errno));
+		unlink(path2);
+		return TEST_FAILED;
+	}
+
+	ret = dual_capture(path2);
+
+	/* closing the reader stops the second capture if an assertion bailed out */
+	close(rd2);
+	unlink(path2);
+
+	return ret;
+}
+
+static struct unit_test_suite capture_testsuite = {
+	.suite_name = "capture autotest",
+	.setup = testsuite_setup,
+	.teardown = testsuite_teardown,
+	.unit_test_cases = {
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_lifecycle),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_bad_queue),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_queue_select),
+		TEST_CASE_ST(ut_setup, ut_teardown, test_capture_dual),
+		TEST_CASES_END()
+	}
+};
+
+static int
+test_capture(void)
+{
+	return unit_test_suite_runner(&capture_testsuite);
+}
+
+REGISTER_FAST_TEST(capture_autotest, NOHUGE_OK, ASAN_OK, test_capture);
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [PATCH v4 3/3] usertools: add script for wireshark capture
  2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
  2026-10-13  3:41     ` [PATCH v4 1/3] capture: infrastructure wireshark packet capture Stephen Hemminger
  2026-10-13  3:41     ` [PATCH v4 2/3] test: add test for capture hooks Stephen Hemminger
@ 2026-10-13  3:41     ` Stephen Hemminger
  2026-10-13  8:28     ` [PATCH v4 0/3] wireshark capture implementation fengchengwen
  3 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-10-13  3:41 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

Provide glue executable that wireshark can use to access
telemetry based packet capture. It is a C program so that it
can be installed setuid if desired and it is standalone and
does not depend on any DPDK libraries or infrastructure.

It is dual licensed because it maybe desirable to put this
in wireshark repository later.

See https://www.wireshark.org/docs/man-pages/extcap.html

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                            |    2 +
 doc/guides/prog_guide/capture_lib.rst  |    8 +
 doc/guides/rel_notes/release_26_11.rst |    1 +
 doc/guides/tools/index.rst             |    1 +
 doc/guides/tools/wireshark_extcap.rst  |  198 ++++
 usertools/dpdk-wireshark-extcap.c      | 1145 ++++++++++++++++++++++++
 usertools/meson.build                  |   23 +
 7 files changed, 1378 insertions(+)
 create mode 100644 doc/guides/tools/wireshark_extcap.rst
 create mode 100644 usertools/dpdk-wireshark-extcap.c

diff --git a/MAINTAINERS b/MAINTAINERS
index 2192de83e3..5679d67f34 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1729,6 +1729,8 @@ F: app/pdump/
 F: doc/guides/tools/pdump.rst
 F: app/dumpcap/
 F: doc/guides/tools/dumpcap.rst
+F: usertools/dpdk-wireshark-extcap.c
+F: doc/guides/tools/wireshark_extcap.rst
 
 
 Packet Framework
diff --git a/doc/guides/prog_guide/capture_lib.rst b/doc/guides/prog_guide/capture_lib.rst
index a9c9591bf2..d195b45e04 100644
--- a/doc/guides/prog_guide/capture_lib.rst
+++ b/doc/guides/prog_guide/capture_lib.rst
@@ -145,6 +145,14 @@ capture fails to start if the FIFO has no reader::
    --> /ethdev/capture/stop,0
    {"/ethdev/capture/stop": {"status": "stopped"}}
 
+Using Wireshark
+---------------
+
+The ``dpdk-wireshark-extcap`` program presents the ports of a running DPDK
+application as Wireshark capture interfaces, using these telemetry commands
+with a FIFO created by Wireshark as the output.
+See :doc:`../tools/wireshark_extcap`.
+
 Limitations
 -----------
 
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index bc1d62d423..7460cd0672 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -166,6 +166,7 @@ New Features
 * **Added wireshark capture support.**
 
   * New ``capture`` library for packet capture accessed via telemetry API.
+  * Added ``dpdk-wireshark-extcap`` for wireshark external capture integration.
 
 
 Removed Items
diff --git a/doc/guides/tools/index.rst b/doc/guides/tools/index.rst
index 13f75a5bc6..01db27511a 100644
--- a/doc/guides/tools/index.rst
+++ b/doc/guides/tools/index.rst
@@ -14,6 +14,7 @@ DPDK Tools User Guides
     pmdinfo
     dumpcap
     pdump
+    wireshark_extcap
     telemetrywatcher
     dmaperf
     flow-perf
diff --git a/doc/guides/tools/wireshark_extcap.rst b/doc/guides/tools/wireshark_extcap.rst
new file mode 100644
index 0000000000..b1a8fd7bf3
--- /dev/null
+++ b/doc/guides/tools/wireshark_extcap.rst
@@ -0,0 +1,198 @@
+..  SPDX-License-Identifier: BSD-3-Clause
+    Copyright(c) 2026 Stephen Hemminger
+
+Wireshark Extcap Plugin
+=======================
+
+The ``dpdk-wireshark-extcap`` program is an external capture (extcap)
+plugin that enables Wireshark capture of live traffic from
+the Ethernet ports of a DPDK application.
+Each DPDK port appears as a capture interface in the Wireshark interface list,
+alongside the host's own network interfaces.
+
+The plugin uses the DPDK telemetry API to query and start capture.
+It passes the path of the fifo file that Wireshark created to the DPDK application,
+which opens the file itself and writes pcapng packets straight into it;
+the plugin never touches packet data.
+Wireshark signals the plugin on exit and that results in closing
+the capture session.
+
+
+Requirements
+------------
+
+* A DPDK application built with the capture library and with telemetry enabled.
+  Telemetry is enabled by default.
+
+* Since the plugin is started by Wireshark, Wireshark must be able to reach
+  the telemetry socket of the DPDK application. See `Permissions`_.
+
+The plugin is a standalone C program that speaks the telemetry protocol
+directly and links against nothing from DPDK. It is built and installed with
+the other DPDK tools, except on Windows. Building with ``libcap`` allows the
+capability install described under `Permissions`_.
+
+
+Installation
+------------
+
+For Wireshark to discover the plugin it must be present in an extcap
+directory. The configured locations are listed in Wireshark under
+*Help > About Wireshark > Folders*. Symbolically link the program into the
+personal extcap directory, naming it ``dpdkdump`` to match the convention of
+the other programs there::
+
+    ln -s /usr/local/bin/dpdk-wireshark-extcap \
+        ~/.local/lib/wireshark/extcap/dpdkdump
+
+Or from a build tree::
+
+    ln -s $(pwd)/build/usertools/dpdk-wireshark-extcap \
+        ~/.local/lib/wireshark/extcap/dpdkdump
+
+The DPDK ports then appear in the interface list the next time the capture
+options dialog is opened.
+
+
+Permissions
+-----------
+
+The DPDK runtime directory is created mode ``0700``, so only the user that
+started the DPDK application can reach its telemetry socket. No privilege
+beyond access to that socket is required: if you can run ``dpdk-dumpcap``
+against an application, you can capture from it with this plugin.
+
+Running Wireshark as that same user needs nothing more, and a DPDK
+application need not run as root. A containerized one has its own runtime
+directory, which can be made readable by the Wireshark user. Run as a
+different user the interface list is empty, and ``--extcap-interfaces``
+prints the permission failure to standard error.
+
+The rest of this section applies only to capturing from a root application as
+an ordinary user. Install set-user-ID to the user running the application,
+root here, and restrict who may execute it::
+
+    chown root:wireshark dpdk-wireshark-extcap
+    chmod 4750 dpdk-wireshark-extcap
+
+Set-group-ID is not enough: the runtime directory is mode ``0700``, so the
+group bits grant nothing. This is unlike ``dumpcap``, which is set-group-ID
+because the ``/dev/bpf`` devices it needs are group accessible.
+
+On Linux a capability grants less and is preferred where available::
+
+    chown root:wireshark dpdk-wireshark-extcap
+    chmod 0750 dpdk-wireshark-extcap
+    setcap cap_dac_override+ep dpdk-wireshark-extcap
+
+This needs a build with ``libcap``; without it a file capability cannot be
+dropped and the plugin refuses to run. ``cap_dac_override`` rather than
+``cap_dac_read_search`` because connecting to the socket needs write
+permission. FreeBSD has no file capabilities, so set-user-ID is the only
+option there.
+
+Whoever runs Wireshark must be in the group named above, or the plugin cannot
+be executed and no DPDK interfaces appear. Link the extcap entry to the
+installed program; copying does not preserve set-user-ID or capabilities.
+
+Privilege is used only for the connect, then dropped permanently before the
+banner is read, before any reply is parsed, and before the FIFO is opened.
+The drop is verified and privilege is never raised again.
+
+After a privileged start the plugin is more restrictive: the environment is
+ignored, only ``/var/run/dpdk`` is searched, and the output must be a FIFO.
+
+
+Usage
+-----
+
+In normal use the plugin is not run by hand; Wireshark invokes it.
+The ports of a running DPDK application appear in the interface list as
+``DPDK <name>``, where ``<name>`` is the device name reported by
+DPDK ethdev, such as ``net_tap0``.
+
+The plugin can also be run directly, which is useful for confirming that a
+DPDK application is reachable::
+
+    $ dpdk-wireshark-extcap --extcap-interfaces
+    extcap {version=0.1}{display=DPDK telemetry capture}
+    interface {value=dpdk:0}{display=DPDK net_tap0}
+
+When no interfaces are listed the reason is printed to standard error, which
+the Wireshark GUI does not show. ``--help`` lists the options.
+
+
+Capture options
+---------------
+
+The following options are offered in the Wireshark capture options dialog for
+a DPDK interface:
+
+Snapshot length
+    Number of bytes captured from each packet. ``0`` captures the whole
+    packet. The default is 262144.
+
+Queue
+    Capture a single hardware queue, or all queues (the default). The choices
+    are bounded to the port's configured queue count. If the queue count
+    cannot be read the option is omitted and capture uses all queues.
+
+Capture filter
+    A libpcap filter expression, applied by the DPDK application to the
+    captured traffic.
+
+
+Selecting a DPDK application
+----------------------------
+
+A host usually runs a single DPDK application, started with the default
+file-prefix (``rte``), and no configuration is needed: its ports appear
+automatically as ``DPDK <name>``.
+
+Running several DPDK applications on one host is also supported. In that case
+each application is started with a distinct ``--file-prefix`` so that its
+runtime state is kept separate. The plugin lists the ports of *all* running
+applications at once, so the application is chosen from the interface list
+rather than before Wireshark is launched. There is no environment variable to
+select one.
+
+Ports of the default prefix are shown plainly, as ``DPDK <name>`` with the
+interface value ``dpdk:<port>``. Ports of any other prefix are qualified with
+the prefix, shown as ``DPDK <name>@<prefix>`` with the interface value
+``dpdk:<prefix>:<port>``, so applications with colliding port numbers stay
+distinct in the list.
+
+
+Environment variables
+----------------------
+
+The runtime directory location depends on the user the DPDK application runs
+as, not on who runs the plugin, so both are searched: ``/var/run/dpdk`` for an
+application started by root, and ``$XDG_RUNTIME_DIR/dpdk``, or ``/tmp/dpdk``
+if that is unset, for one started by an ordinary user. Ports found in either
+are listed together.
+
+``DPDK_EXTCAP_PATH``
+    Search this base directory instead, for a runtime directory in a
+    non-standard location. Ignored, along with ``XDG_RUNTIME_DIR``, after a
+    privileged start; see `Permissions`_.
+
+
+Troubleshooting
+---------------
+
+The DPDK ports do not appear in Wireshark
+    Confirm the application is running and was built with the capture library
+    and telemetry. Confirm Wireshark runs as the same user as the application,
+    or that the plugin is installed with privilege; see `Permissions`_. An
+    application started with a non-default ``--file-prefix`` is listed with
+    its ports qualified as ``DPDK <name>@<prefix>``; see
+    `Selecting a DPDK application`_.
+
+    Running ``--extcap-interfaces`` by hand prints diagnostics to standard
+    error that the Wireshark GUI hides.
+
+A port is listed as ``portN`` instead of a device name
+    The port was reported but its details could not be read, usually because
+    the application stopped in between. A capture against it will fail;
+    restart the application.
diff --git a/usertools/dpdk-wireshark-extcap.c b/usertools/dpdk-wireshark-extcap.c
new file mode 100644
index 0000000000..377ae57ef4
--- /dev/null
+++ b/usertools/dpdk-wireshark-extcap.c
@@ -0,0 +1,1145 @@
+// SPDX-License-Identifier: BSD-3-Clause OR GPL-2.0-or-later
+/* Copyright(c) 2026 Stephen Hemminger */
+
+/*
+ * Wireshark extcap plugin for live capture from DPDK ethdev ports.
+ *
+ * Wireshark creates a FIFO, this program opens it and passes the path to the
+ * DPDK application as out= in /ethdev/capture/start. The application writes
+ * pcapng straight into the FIFO; packet data never passes through here.
+ * Capture stops when the read end closes or on SIGINT/SIGTERM, then
+ * /ethdev/capture/stop is sent so the application can flush.
+ *
+ * Interface values are 'dpdk:<port>' for the default file-prefix and
+ * 'dpdk:<prefix>:<port>' otherwise.
+ *
+ * Privilege
+ * ---------
+ * The runtime directory is mode 0700 and owned by whoever runs the DPDK
+ * application, so capturing from a root application as a desktop user needs
+ * help. As in dumpcap, privilege is dropped monotonically and never regained:
+ * argv is parsed and every telemetry socket opened while still privileged,
+ * then everything is dropped and verified, and the banner, replies and FIFO
+ * are handled unprivileged. Nothing after the connect needs privilege, so
+ * there is no equivalent of dumpcap's retained CAP_NET_RAW.
+ *
+ * Install setuid to the application's user, or on Linux root:wireshark 0750
+ * with cap_dac_override+ep (needs -DHAVE_LIBCAP -lcap; without libcap a file
+ * capability cannot be dropped, so do not use that install). Connecting to
+ * the socket needs write permission, which cap_dac_read_search does not give.
+ *
+ * After a privileged exec the environment is ignored (DPDK_EXTCAP_PATH,
+ * XDG_RUNTIME_DIR) and the output must be a FIFO.
+ *
+ * The runtime directory location depends on the user the application runs as,
+ * not on who runs this, so every candidate is searched; see dpdk_dirs().
+ *
+ * Build:
+ *   cc -O2 -Wall -Wextra -D_FORTIFY_SOURCE=2 -fstack-protector-strong \
+ *      -fPIE -pie -Wl,-z,relro,-z,now -o dpdk-wireshark-extcap \
+ *      dpdk-wireshark-extcap.c
+ */
+
+/* The DPDK build passes this; keep the standalone build above working too. */
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+#include <dirent.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/un.h>
+#include <unistd.h>
+
+#ifdef __linux__
+#include <sys/auxv.h>
+#endif
+#ifdef HAVE_LIBCAP
+#include <sys/capability.h>
+#endif
+
+#define PROG_NAME		"dpdk-wireshark-extcap"
+#define EXTCAP_VERSION		"0.1"
+#define TELEMETRY_SOCKET	"dpdk_telemetry.v2"
+#define CAPTURE_START_CMD	"/ethdev/capture/start"
+#define CAPTURE_STOP_CMD	"/ethdev/capture/stop"
+#define ETHDEV_LIST		"/ethdev/list"
+#define ETHDEV_INFO		"/ethdev/info"
+#define DEFAULT_SNAPLEN		262144
+#define DEFAULT_PREFIX		"rte"
+#define DLT_EN10MB		1
+
+#define SECURE_DIR		"/var/run/dpdk"
+#define ARRAY_SIZE(a)		((int)(sizeof(a) / sizeof((a)[0])))
+#define MAX_PREFIXES		64
+#define PREFIX_MAX		64
+#define MAX_PORTS		256
+#define MAX_QUEUES		1024
+#define PARAMS_MAX		1024	/* primary limit (CAP_CMD_MAX) */
+#define CMD_BUF			(PARAMS_MAX + 128)
+#define REPLY_TIMEOUT_SEC	30
+#define MIN_REPLY_LEN		1024
+#define MAX_REPLY_LEN		(1 << 20)
+
+/* One connected primary. */
+struct source {
+	char prefix[PREFIX_MAX];
+	int fd;
+};
+
+static uid_t ruid, euid;
+static gid_t rgid, egid;
+static bool privileged_exec;
+
+/* --- privilege handling -------------------------------------------- */
+
+/*
+ * True when the kernel raised our privilege at exec: setuid, setgid or file
+ * capabilities. This is the same test the C library uses to decide whether to
+ * trust the environment. dumpcap compares uids instead, which misses the
+ * capability-only install where ruid == euid throughout, and counts a plain
+ * root invocation as privileged, which this does not.
+ */
+static bool
+exec_was_privileged(void)
+{
+#if defined(__linux__)
+	return getauxval(AT_SECURE) != 0;
+#elif defined(__FreeBSD__)
+	return issetugid();
+#else
+	return getuid() != geteuid() || getgid() != getegid();
+#endif
+}
+
+static void
+priv_init(void)
+{
+	ruid = getuid();
+	rgid = getgid();
+	euid = geteuid();
+	egid = getegid();
+	privileged_exec = exec_was_privileged();
+}
+
+/*
+ * Give up privilege permanently, then prove it cannot be taken back.
+ *
+ * Effective uid goes last: changing it is what takes away the right to change
+ * anything else. Capabilities are cleared whether or not this process has any,
+ * as in dumpcap's relinquish_all_capabilities().
+ */
+static void
+priv_drop(void)
+{
+	if (setresgid(rgid, rgid, rgid) != 0)
+		err(1, "setresgid");
+	if (setresuid(ruid, ruid, ruid) != 0)
+		err(1, "setresuid");
+
+	if (getuid() != ruid || geteuid() != ruid ||
+	    getgid() != rgid || getegid() != rgid)
+		errx(1, "privilege drop failed");
+	if (euid != ruid && seteuid(euid) == 0)
+		errx(1, "privilege drop failed");
+	if (egid != rgid && setegid(egid) == 0)
+		errx(1, "privilege drop failed");
+
+#ifdef HAVE_LIBCAP
+	/* A file-capability install changes no id above, so this is the drop. */
+	cap_t caps = cap_init();	/* all capabilities off */
+
+	if (caps == NULL)
+		err(1, "cap_init");
+	if (cap_set_proc(caps) != 0)
+		err(1, "cap_set_proc");
+	cap_free(caps);
+#else
+	if (privileged_exec && ruid == euid && rgid == egid)
+		errx(1, "built without libcap, cannot drop a file capability");
+#endif
+}
+
+/* Make sure fds 0-2 are open so later opens cannot land on them. */
+static void
+std_fds(void)
+{
+	for (int fd = 0; fd <= 2; fd++) {
+		if (fcntl(fd, F_GETFD) < 0 && errno == EBADF) {
+			int n = open("/dev/null", O_RDWR);
+
+			if (n != fd)
+				_exit(1);
+		}
+	}
+}
+
+/* --- minimal JSON scanner ------------------------------------------ */
+
+static const char *
+skip_ws(const char *p)
+{
+	while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')
+		p++;
+	return p;
+}
+
+/* Return pointer to the value following "key": or NULL. */
+static const char *
+json_value(const char *json, const char *key)
+{
+	char pat[96];
+	const char *p = json;
+	int n = snprintf(pat, sizeof(pat), "\"%s\"", key);
+
+	if (n < 0 || (size_t)n >= sizeof(pat))
+		return NULL;
+
+	while ((p = strstr(p, pat)) != NULL) {
+		const char *v = skip_ws(p + n);
+
+		if (*v == ':')
+			return skip_ws(v + 1);
+		p += n;
+	}
+	return NULL;
+}
+
+static bool
+json_int(const char *json, const char *key, long long *val)
+{
+	const char *v = json_value(json, key);
+	char *end;
+	long long x;
+
+	if (v == NULL || !((*v >= '0' && *v <= '9') || *v == '-'))
+		return false;
+	errno = 0;
+	x = strtoll(v, &end, 10);
+	if (errno != 0 || end == v)
+		return false;
+	*val = x;
+	return true;
+}
+
+/* Copy a string value, replacing non printable characters with '_'. */
+static bool
+json_str(const char *json, const char *key, char *out, size_t outsz)
+{
+	const char *v = json_value(json, key);
+	size_t n = 0;
+
+	if (v == NULL || *v != '"')
+		return false;
+	for (v++; *v != '"'; v++) {
+		unsigned char c = *v;
+
+		if (c == '\0')
+			return false;
+		if (c == '\\') {
+			c = '_';
+			if (v[1] == '\0')
+				return false;
+			v++;
+		}
+		if (c < 0x20 || c > 0x7e)
+			c = '_';
+		if (n + 1 < outsz)
+			out[n++] = c;
+	}
+	out[n] = '\0';
+	return true;
+}
+
+static int
+json_int_array(const char *json, const char *key, long long *out, int max)
+{
+	const char *v = json_value(json, key);
+	int n = 0;
+
+	if (v == NULL || *v != '[')
+		return 0;
+	v = skip_ws(v + 1);
+	while (*v != '\0' && *v != ']') {
+		char *end;
+		long long x;
+
+		errno = 0;
+		x = strtoll(v, &end, 10);
+		if (end == v || errno != 0)
+			break;
+		if (n < max)
+			out[n++] = x;
+		v = skip_ws(end);
+		if (*v != ',')
+			break;
+		v = skip_ws(v + 1);
+	}
+	return n;
+}
+
+/* Names go into extcap sentences, keep them to a safe alphabet. */
+static void
+sanitize_label(char *s)
+{
+	for (; *s != '\0'; s++) {
+		char c = *s;
+
+		if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+		      (c >= '0' && c <= '9') || c == '.' || c == '_' ||
+		      c == ':' || c == '-'))
+			*s = '_';
+	}
+}
+
+/*
+ * Why this string cannot be handed to the primary, or NULL if it can.
+ * Telemetry parameters are a comma separated list with no escaping.
+ */
+static const char *
+param_problem(const char *s)
+{
+	if (strchr(s, ',') != NULL)
+		return "must not contain a comma";
+
+	for (; *s != '\0'; s++) {
+		if ((unsigned char)*s < 0x20 || *s == 0x7f)
+			return "must not contain control characters";
+	}
+	return NULL;
+}
+
+/* --- DPDK runtime directory / socket discovery ---------------------- */
+
+static char dirbuf[PATH_MAX];
+
+/*
+ * Directories that may hold the per-prefix runtime subdirectories.
+ *
+ * Where the primary put them depends on the user it runs as, not on the user
+ * running this program: a root primary uses /var/run/dpdk whether or not the
+ * capture tool is root. So rather than guess from our own uid, return every
+ * candidate and let the caller look in each.
+ *
+ * DPDK_EXTCAP_PATH overrides the search. It is ignored after a privileged
+ * exec, where the environment is not trusted.
+ */
+static int
+dpdk_dirs(const char *dirs[], int max)
+{
+	const char *env, *xdg;
+	int n = 0;
+
+	if (!privileged_exec) {
+		env = getenv("DPDK_EXTCAP_PATH");
+		if (env != NULL && *env != '\0') {
+			dirs[n++] = env;
+			return n;
+		}
+	}
+
+	/* where a root primary puts it */
+	if (n < max)
+		dirs[n++] = SECURE_DIR;
+
+	/* where a primary running as an ordinary user puts it */
+	if (!privileged_exec) {
+		xdg = getenv("XDG_RUNTIME_DIR");
+		if (xdg == NULL || *xdg == '\0')
+			xdg = "/tmp";
+		if ((size_t)snprintf(dirbuf, sizeof(dirbuf), "%s/dpdk", xdg) >= sizeof(dirbuf))
+			errx(1, "runtime directory path too long");
+		if (n < max)
+			dirs[n++] = dirbuf;
+	}
+
+	return n;
+}
+
+static bool
+valid_prefix(const char *s)
+{
+	size_t len = strlen(s);
+
+	if (len == 0 || len >= PREFIX_MAX)
+		return false;
+	if (strcmp(s, ".") == 0 || strcmp(s, "..") == 0)
+		return false;
+	for (; *s != '\0'; s++) {
+		char c = *s;
+
+		if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+		      (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'))
+			return false;
+	}
+	return true;
+}
+
+static void
+socket_path(const char *dir, const char *prefix, char *out, size_t sz)
+{
+	if ((size_t)snprintf(out, sz, "%s/%s/%s", dir, prefix,
+			     TELEMETRY_SOCKET) >= sz)
+		errx(1, "socket path too long");
+}
+
+/* --- telemetry transport -------------------------------------------- */
+
+struct telemetry {
+	int fd;
+	size_t max_len;
+	char *buf;	/* last reply, always NUL terminated */
+};
+
+/*
+ * Connect to a telemetry socket. This is the only call that needs privilege,
+ * so it is also the only thing done before priv_drop().
+ */
+static int
+tel_connect(const char *path)
+{
+	struct sockaddr_un sa;
+	size_t len = strlen(path);
+	int fd, save;
+
+	memset(&sa, 0, sizeof(sa));
+	sa.sun_family = AF_UNIX;
+	if (len >= sizeof(sa.sun_path)) {
+		errno = ENAMETOOLONG;
+		return -1;
+	}
+	memcpy(sa.sun_path, path, len);
+
+	fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
+	if (fd < 0)
+		return -1;
+
+	if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
+		save = errno;
+		close(fd);
+		errno = save;
+		return -1;
+	}
+	return fd;
+}
+
+/*
+ * Read the banner and size the reply buffer. Runs unprivileged: this is the
+ * first point at which the primary gets to send us anything. Takes ownership
+ * of fd and closes it on failure.
+ */
+static int
+tel_handshake(struct telemetry *t, int fd)
+{
+	struct timeval tv = { .tv_sec = REPLY_TIMEOUT_SEC };
+	char banner[1025];
+	long long maxlen;
+	ssize_t n;
+	int save;
+
+	setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+
+	do {
+		n = recv(fd, banner, sizeof(banner) - 1, 0);
+	} while (n < 0 && errno == EINTR);
+	if (n <= 0) {
+		save = n < 0 ? errno : ECONNRESET;
+		close(fd);
+		errno = save;
+		return -1;
+	}
+	banner[n] = '\0';
+
+	if (!json_int(banner, "max_output_len", &maxlen))
+		maxlen = 16384;
+	if (maxlen < MIN_REPLY_LEN)
+		maxlen = MIN_REPLY_LEN;
+	if (maxlen > MAX_REPLY_LEN)
+		maxlen = MAX_REPLY_LEN;
+
+	t->buf = malloc(maxlen + 1);
+	if (t->buf == NULL) {
+		close(fd);
+		errno = ENOMEM;
+		return -1;
+	}
+	t->fd = fd;
+	t->max_len = maxlen;
+	return 0;
+}
+
+/* Send a command; reply lands in t->buf (empty string if no reply). */
+static int
+tel_command(struct telemetry *t, const char *cmd)
+{
+	size_t len = strlen(cmd);
+	ssize_t n;
+
+	do {
+		n = send(t->fd, cmd, len, 0);
+	} while (n < 0 && errno == EINTR);
+	if (n < 0)
+		return -1;
+
+	do {
+		n = recv(t->fd, t->buf, t->max_len, 0);
+	} while (n < 0 && errno == EINTR);
+	if (n < 0)
+		return -1;
+	t->buf[n] = '\0';
+	return 0;
+}
+
+static void
+tel_close(struct telemetry *t)
+{
+	close(t->fd);
+	free(t->buf);
+	t->buf = NULL;
+}
+
+static long
+port_queue_count(struct telemetry *t, long port)
+{
+	char cmd[64];
+	long long rx = 0, tx = 0;
+
+	snprintf(cmd, sizeof(cmd), ETHDEV_INFO ",%ld", port);
+	if (tel_command(t, cmd) < 0)
+		return 0;
+	json_int(t->buf, "nb_rx_queues", &rx);
+	json_int(t->buf, "nb_tx_queues", &tx);
+	return rx > tx ? (long)rx : (long)tx;
+}
+
+/* Device name via /ethdev/info, or "port<N>" if unreadable. */
+static void
+port_name(struct telemetry *t, long port, char *out, size_t sz)
+{
+	char cmd[64];
+
+	snprintf(cmd, sizeof(cmd), ETHDEV_INFO ",%ld", port);
+	if (tel_command(t, cmd) < 0 || !json_str(t->buf, "name", out, sz) ||
+	    out[0] == '\0')
+		snprintf(out, sz, "port%ld", port);
+	sanitize_label(out);
+}
+
+/* --- interface naming ---------------------------------------------- */
+
+static void
+iface_value(const char *prefix, long port, char *out, size_t sz)
+{
+	if (strcmp(prefix, DEFAULT_PREFIX) == 0)
+		snprintf(out, sz, "dpdk:%ld", port);
+	else
+		snprintf(out, sz, "dpdk:%.*s:%ld", PREFIX_MAX - 1, prefix, port);
+}
+
+/* Inverse of iface_value. */
+static void
+parse_iface(const char *iface, char *prefix, size_t prefix_sz, long *port)
+{
+	const char *p, *c1, *c2, *num;
+	char *end;
+	unsigned long v;
+
+	c1 = strchr(iface, ':');
+	if (c1 == NULL || (size_t)(c1 - iface) != 4 || strncmp(iface, "dpdk", 4) != 0)
+		errx(1, "unsupported interface scheme in '%s'", iface);
+
+	p = c1 + 1;
+	c2 = strchr(p, ':');
+	if (c2 == NULL) {
+		snprintf(prefix, prefix_sz, "%s", DEFAULT_PREFIX);
+		num = p;
+	} else {
+		size_t len = c2 - p;
+
+		if (strchr(c2 + 1, ':') != NULL)
+			errx(1, "malformed interface '%s'", iface);
+		if (len == 0 || len >= prefix_sz)
+			errx(1, "malformed interface '%s'", iface);
+		memcpy(prefix, p, len);
+		prefix[len] = '\0';
+		num = c2 + 1;
+	}
+
+	if (!valid_prefix(prefix))
+		errx(1, "malformed interface '%s'", iface);
+	if (!(*num >= '0' && *num <= '9'))
+		errx(1, "malformed interface '%s'", iface);
+	errno = 0;
+	v = strtoul(num, &end, 10);
+	if (errno != 0 || *end != '\0' || v > UINT16_MAX)
+		errx(1, "malformed interface '%s'", iface);
+	*port = v;
+}
+
+/* --- privileged phase ----------------------------------------------- */
+
+static int
+cmp_source(const void *a, const void *b)
+{
+	const struct source *x = a, *y = b;
+
+	return strcmp(x->prefix, y->prefix);
+}
+
+/*
+ * Connect to every primary under the runtime directory.
+ *
+ * All of it happens before the drop, as dumpcap opens all of its capture
+ * devices before relinquishing. Nothing is read from these sockets here.
+ */
+static int
+connect_dir(const char *root, struct source *src, int n, int max, bool *looked,
+	    bool *complained)
+{
+	struct dirent *de;
+	DIR *d;
+
+	d = opendir(root);
+	if (d == NULL) {
+		/*
+		 * A directory that is simply not there is not worth a word: the
+		 * other candidate is probably the live one. Anything else means
+		 * a primary is likely there but out of reach, which is worth
+		 * saying even if another directory works out.
+		 */
+		if (errno == EACCES) {
+			*looked = true;
+			*complained = true;
+			warnx("cannot read %s: permission denied. The DPDK runtime "
+			      "directory is created mode 0700, so capture must run "
+			      "as the same user as the DPDK application, or be "
+			      "installed with privilege", root);
+		} else if (errno != ENOENT) {
+			*looked = true;
+			*complained = true;
+			warn("cannot read %s", root);
+		}
+		return n;
+	}
+	*looked = true;
+
+	while (n < max && (de = readdir(d)) != NULL) {
+		char path[PATH_MAX];
+		struct stat st;
+		int fd;
+
+		if (!valid_prefix(de->d_name))
+			continue;
+		socket_path(root, de->d_name, path, sizeof(path));
+		if (lstat(path, &st) != 0 || !S_ISSOCK(st.st_mode))
+			continue;
+
+		fd = tel_connect(path);
+		if (fd < 0) {
+			warn("cannot connect to %s", path);
+			continue;
+		}
+		/* valid_prefix() bounded the length */
+		memcpy(src[n].prefix, de->d_name, strlen(de->d_name) + 1);
+		src[n].fd = fd;
+		n++;
+	}
+	closedir(d);
+
+	return n;
+}
+
+/*
+ * Connect to one named prefix, trying each candidate directory. The path tried
+ * last is left in out[] for the caller's error message.
+ */
+static int
+connect_prefix(const char *prefix, char *out, size_t outsz)
+{
+	const char *dirs[4];
+	int ndirs = dpdk_dirs(dirs, ARRAY_SIZE(dirs));
+
+	for (int i = 0; i < ndirs; i++) {
+		int fd;
+
+		socket_path(dirs[i], prefix, out, outsz);
+		fd = tel_connect(out);
+		if (fd >= 0)
+			return fd;
+	}
+	return -1;
+}
+
+static int
+connect_all(struct source *src, int max)
+{
+	const char *dirs[4];
+	bool looked = false, complained = false;
+	int ndirs, n = 0;
+
+	ndirs = dpdk_dirs(dirs, ARRAY_SIZE(dirs));
+	for (int i = 0; i < ndirs && n < max; i++)
+		n = connect_dir(dirs[i], src, n, max, &looked, &complained);
+
+	/* a reason was already given for an unreadable directory */
+	if (n == 0 && !complained) {
+		if (looked)
+			warnx("no DPDK telemetry socket found. Is a DPDK "
+			      "application running with telemetry enabled?");
+		else
+			warnx("no DPDK runtime directory. Either no DPDK "
+			      "application is running, or it runs as a different "
+			      "user and this program was installed without privilege");
+	}
+
+	qsort(src, n, sizeof(*src), cmp_source);
+	return n;
+}
+
+/* --- extcap query operations --------------------------------------- */
+
+static void
+cmd_interfaces(struct source *src, int nsrc)
+{
+	printf("extcap {version=%s}{display=DPDK telemetry capture}\n", EXTCAP_VERSION);
+
+	for (int i = 0; i < nsrc; i++) {
+		long long ports[MAX_PORTS];
+		struct telemetry t;
+		int nports = 0;
+
+		if (tel_handshake(&t, src[i].fd) < 0) {
+			warn("no banner from %s", src[i].prefix);
+			continue;
+		}
+
+		if (tel_command(&t, ETHDEV_LIST) == 0)
+			nports = json_int_array(t.buf, ETHDEV_LIST, ports, MAX_PORTS);
+		else
+			warn("cannot query %s", src[i].prefix);
+
+		for (int j = 0; j < nports; j++) {
+			char name[128], value[160], display[256];
+
+			if (ports[j] < 0 || ports[j] > UINT16_MAX)
+				continue;
+			port_name(&t, ports[j], name, sizeof(name));
+			iface_value(src[i].prefix, ports[j], value, sizeof(value));
+			if (strcmp(src[i].prefix, DEFAULT_PREFIX) == 0)
+				snprintf(display, sizeof(display), "DPDK %.127s", name);
+			else
+				snprintf(display, sizeof(display), "DPDK %.127s@%.63s",
+					 name, src[i].prefix);
+			printf("interface {value=%s}{display=%s}\n", value, display);
+		}
+		tel_close(&t);
+	}
+}
+
+static void
+cmd_dlts(void)
+{
+	printf("dlt {number=%d}{name=EN10MB}{display=Ethernet}\n", DLT_EN10MB);
+}
+
+static void
+cmd_config(int fd, long port)
+{
+	long nqueues = 0;
+
+	printf("arg {number=0}{call=--snaplen}{display=Snapshot length}"
+	       "{tooltip=Bytes captured per packet (0 = whole packet)}"
+	       "{type=integer}{range=0,%d}{default=%d}{group=Capture}\n",
+	       DEFAULT_SNAPLEN, DEFAULT_SNAPLEN);
+
+	/*
+	 * Bound the queue dropdown to the port's queue count. If the primary
+	 * is unreachable just omit it, capture defaults to all queues.
+	 */
+	if (fd >= 0) {
+		struct telemetry t;
+
+		if (tel_handshake(&t, fd) == 0) {
+			nqueues = port_queue_count(&t, port);
+			tel_close(&t);
+		}
+	}
+
+	if (nqueues > MAX_QUEUES)
+		nqueues = MAX_QUEUES;
+	if (nqueues > 0) {
+		printf("arg {number=1}{call=--queue}{display=Queue}"
+		       "{tooltip=Capture a single hardware queue}"
+		       "{type=selector}{group=Capture}\n");
+		printf("value {arg=1}{value=-1}{display=All queues}{default=true}\n");
+		for (long q = 0; q < nqueues; q++)
+			printf("value {arg=1}{value=%ld}{display=Queue %ld}\n", q, q);
+	}
+}
+
+/* --- capture -------------------------------------------------------- */
+
+static void
+cmd_append(char *cmd, const char *fmt, ...)
+	__attribute__((format(printf, 2, 3)));
+
+static void
+cmd_append(char *cmd, const char *fmt, ...)
+{
+	size_t len = strlen(cmd);
+	va_list ap;
+	int n;
+
+	va_start(ap, fmt);
+	n = vsnprintf(cmd + len, CMD_BUF - len, fmt, ap);
+	va_end(ap);
+	if (n < 0 || (size_t)n >= CMD_BUF - len)
+		errx(1, "capture parameters too long");
+}
+
+static int stop_pipe[2] = { -1, -1 };
+
+static void
+on_stop_signal(int sig)
+{
+	int saved = errno;
+	ssize_t r;
+
+	(void)sig;
+	r = write(stop_pipe[1], "x", 1);
+	(void)r;
+	errno = saved;
+}
+
+/*
+ * Block until Wireshark stops us: it closes the FIFO read end (error/hangup
+ * on our write fd) or sends SIGINT/SIGTERM. Handlers are restored before
+ * returning so a second signal terminates us the usual way.
+ */
+static void
+wait_for_stop(int fifo_fd)
+{
+	struct sigaction sa, old_int, old_term;
+	struct pollfd pfd[2];
+
+	if (pipe2(stop_pipe, O_CLOEXEC | O_NONBLOCK) != 0)
+		err(1, "pipe2");
+
+	memset(&sa, 0, sizeof(sa));
+	sa.sa_handler = on_stop_signal;
+	sigemptyset(&sa.sa_mask);
+	sigaction(SIGINT, &sa, &old_int);
+	sigaction(SIGTERM, &sa, &old_term);
+
+	pfd[0].fd = fifo_fd;
+	pfd[0].events = POLLERR;
+	pfd[1].fd = stop_pipe[0];
+	pfd[1].events = POLLIN;
+	for (;;) {
+		int r = poll(pfd, 2, -1);
+
+		if (r > 0 || (r < 0 && errno != EINTR))
+			break;
+	}
+
+	sigaction(SIGINT, &old_int, NULL);
+	sigaction(SIGTERM, &old_term, NULL);
+	close(stop_pipe[0]);
+	close(stop_pipe[1]);
+}
+
+static void
+cmd_capture(int fd, long port, const char *fifo, long snaplen, long queue,
+	    const char *cfilter)
+{
+	char cmd[CMD_BUF];
+	char errmsg[256];
+	struct telemetry t;
+	struct stat sb;
+	long long cap_id;
+	int fifo_fd;
+
+	snprintf(cmd, sizeof(cmd), CAPTURE_START_CMD ",%ld,out=%s", port, fifo);
+	if (snaplen >= 0)
+		cmd_append(cmd, ",snaplen=%ld", snaplen);
+	if (queue >= 0)
+		cmd_append(cmd, ",queue=%ld", queue);
+	if (cfilter != NULL && *cfilter != '\0')
+		cmd_append(cmd, ",filter=%s", cfilter);
+	if (strlen(cmd) - sizeof(CAPTURE_START_CMD) >= PARAMS_MAX)
+		errx(1, "capture parameters too long");
+
+	/*
+	 * Blocking open of the FIFO Wireshark created, unprivileged, which is
+	 * where dumpcap opens its output too.
+	 *
+	 * Rendezvous: a reader is attached before the primary opens the write
+	 * end (it uses O_NONBLOCK and fails with ENXIO without a reader). We
+	 * hold this fd for the whole session: it keeps the pipe from EOF-ing
+	 * before the primary has its own write end, and it is the reliable stop
+	 * signal (error here means Wireshark closed the read end).
+	 */
+	fifo_fd = open(fifo, O_WRONLY | O_CLOEXEC | O_NOFOLLOW);
+	if (fifo_fd < 0)
+		err(1, "cannot open fifo %s", fifo);
+	if (fstat(fifo_fd, &sb) != 0 || !S_ISFIFO(sb.st_mode))
+		errx(1, "%s is not a fifo", fifo);
+
+	if (tel_handshake(&t, fd) < 0)
+		err(1, "no banner from DPDK telemetry");
+
+	if (tel_command(&t, cmd) < 0)
+		err(1, "capture start failed");
+
+	if (json_str(t.buf, "error", errmsg, sizeof(errmsg)))
+		errx(1, "capture start failed: %s", errmsg);
+	if (!json_int(t.buf, "id", &cap_id) || cap_id < 0)
+		errx(1, "capture start failed: unexpected reply from primary");
+
+	/*
+	 * Run until Wireshark stops us. The primary holds its own write end,
+	 * so closing ours below does not EOF the reader prematurely.
+	 */
+	wait_for_stop(fifo_fd);
+
+	/*
+	 * Ask the primary to stop while we still hold the write end, so a
+	 * still attached reader gets the tail and the statistics block before
+	 * the final EOF. If the read end is gone the primary already stopped
+	 * on the same hangup and this is a no-op.
+	 */
+	snprintf(cmd, sizeof(cmd), CAPTURE_STOP_CMD ",%lld", cap_id);
+	tel_command(&t, cmd);
+
+	close(fifo_fd);
+	tel_close(&t);
+}
+
+/* --- entry point ---------------------------------------------------- */
+
+struct options {
+	bool interfaces, dlts, config, capture, version, help;
+	const char *iface, *fifo, *cfilter;
+	long snaplen, queue;
+};
+
+static void
+usage(FILE *f)
+{
+	fprintf(f,
+		"Usage: %s [options]\n"
+		"\n"
+		"Wireshark external capture (extcap) plugin for DPDK ports.\n"
+		"Normally run by Wireshark, not by hand.\n"
+		"\n"
+		"Extcap operations:\n"
+		"  --extcap-interfaces          list the ports of every running DPDK application\n"
+		"  --extcap-dlts                list link types of an interface\n"
+		"  --extcap-config              list capture options of an interface\n"
+		"  --capture                    start a capture, needs --fifo\n"
+		"  --extcap-version[=<ver>]     version of the calling Wireshark\n"
+		"  --extcap-interface <iface>   interface to act on, as listed above\n"
+		"\n"
+		"Capture options:\n"
+		"  --fifo <path>                where to write pcapng, created by Wireshark\n"
+		"  --extcap-capture-filter <f>  pcap filter expression\n"
+		"  --snaplen <n>                bytes to capture per packet, 0 for all\n"
+		"  --queue <n>                  capture one queue, -1 for all\n"
+		"\n"
+		"  -h, --help                   this text\n"
+		"  --version                    program version\n"
+		"\n"
+		"Environment:\n"
+		"  DPDK_EXTCAP_PATH             base DPDK runtime directory to search\n"
+		"                               (ignored if installed with privilege)\n",
+		PROG_NAME);
+}
+
+/* Match --name, --name=value or --name value. Returns 1 on match. */
+static int
+opt_value(int argc, char **argv, int *i, const char *name, const char **val,
+	  bool optional)
+{
+	const char *a = argv[*i];
+	size_t len = strlen(name);
+
+	if (strncmp(a, name, len) != 0)
+		return 0;
+	if (a[len] == '=') {
+		*val = a + len + 1;
+		return 1;
+	}
+	if (a[len] != '\0')
+		return 0;
+	if (*i + 1 < argc && (!optional || argv[*i + 1][0] != '-')) {
+		*val = argv[++*i];
+		return 1;
+	}
+	if (optional) {
+		*val = NULL;
+		return 1;
+	}
+	errx(1, "%s requires a value", name);
+}
+
+static long
+parse_long(const char *name, const char *s, long lo, long hi)
+{
+	char *end;
+	long v;
+
+	errno = 0;
+	v = strtol(s, &end, 10);
+	if (errno != 0 || end == s || *end != '\0' || v < lo || v > hi)
+		errx(1, "invalid %s '%s'", name, s);
+	return v;
+}
+
+static void
+parse_args(int argc, char **argv, struct options *o)
+{
+	const char *val;
+
+	memset(o, 0, sizeof(*o));
+	o->snaplen = -1;
+	o->queue = -1;
+
+	/* Unknown options are ignored, Wireshark passes several extras. */
+	for (int i = 1; i < argc; i++) {
+		if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0)
+			o->help = true;
+		else if (strcmp(argv[i], "--version") == 0)
+			o->version = true;
+		else if (strcmp(argv[i], "--extcap-interfaces") == 0)
+			o->interfaces = true;
+		else if (strcmp(argv[i], "--extcap-dlts") == 0)
+			o->dlts = true;
+		else if (strcmp(argv[i], "--extcap-config") == 0)
+			o->config = true;
+		else if (strcmp(argv[i], "--capture") == 0)
+			o->capture = true;
+		else if (opt_value(argc, argv, &i, "--extcap-interface", &val, false))
+			o->iface = val;
+		else if (opt_value(argc, argv, &i, "--fifo", &val, false))
+			o->fifo = val;
+		else if (opt_value(argc, argv, &i, "--extcap-capture-filter", &val, false))
+			o->cfilter = val;
+		else if (opt_value(argc, argv, &i, "--extcap-version", &val, true))
+			continue;
+		else if (opt_value(argc, argv, &i, "--snaplen", &val, false))
+			o->snaplen = parse_long("--snaplen", val, 0, INT32_MAX);
+		else if (opt_value(argc, argv, &i, "--queue", &val, false))
+			o->queue = parse_long("--queue", val, -1, UINT16_MAX);
+	}
+}
+
+int
+main(int argc, char **argv)
+{
+	struct source src[MAX_PREFIXES];
+	struct options o;
+	const char *why;
+	long port = -1;
+	int nsrc = 0;
+
+	std_fds();
+	priv_init();
+	signal(SIGPIPE, SIG_IGN);
+	parse_args(argc, argv, &o);
+
+	/* Needs no privilege and no primary, so answer before anything else. */
+	if (o.help) {
+		usage(stdout);
+		return 0;
+	}
+
+	/*
+	 * Privileged phase. Only the connect needs privilege, so it is the
+	 * only thing here, and everything it needs from argv is checked first.
+	 */
+	if (o.interfaces) {
+		nsrc = connect_all(src, MAX_PREFIXES);
+	} else if (o.config || o.capture) {
+		char path[PATH_MAX];
+
+		if (o.iface == NULL)
+			errx(1, "missing --extcap-interface");
+		if (o.capture) {
+			struct stat sb;
+
+			if (o.fifo == NULL)
+				errx(1, "--capture requires --fifo");
+			why = param_problem(o.fifo);
+			if (why != NULL)
+				errx(1, "fifo path %s", why);
+			if (o.cfilter != NULL) {
+				why = param_problem(o.cfilter);
+				if (why != NULL)
+					errx(1, "capture filter %s", why);
+			}
+
+			/*
+			 * Advisory: fail before costing the primary a connection
+			 * and a handler thread. lstat() to match the O_NOFOLLOW
+			 * open, which is the check that counts.
+			 */
+			if (lstat(o.fifo, &sb) != 0)
+				err(1, "%s", o.fifo);
+			if (!S_ISFIFO(sb.st_mode))
+				errx(1, "%s is not a fifo", o.fifo);
+		}
+
+		parse_iface(o.iface, src[0].prefix, PREFIX_MAX, &port);
+		src[0].fd = connect_prefix(src[0].prefix, path, sizeof(path));
+		if (src[0].fd < 0 && o.capture)
+			err(1, "cannot connect to DPDK telemetry at %s", path);
+		nsrc = 1;
+	}
+
+	/* Nothing below this point runs with privilege. */
+	priv_drop();
+
+	if (o.version)
+		printf("%s %s\n", PROG_NAME, EXTCAP_VERSION);
+	else if (o.interfaces)
+		cmd_interfaces(src, nsrc);
+	else if (o.dlts)
+		cmd_dlts();
+	else if (o.config)
+		cmd_config(src[0].fd, port);
+	else if (o.capture)
+		cmd_capture(src[0].fd, port, o.fifo, o.snaplen, o.queue, o.cfilter);
+	else if (o.cfilter != NULL) {
+		/*
+		 * Wireshark validates a capture filter by running the extcap
+		 * with the filter but without --capture. No output means
+		 * accepted, any output marks it invalid. Exit status must stay
+		 * 0 or Wireshark reports the filter as unknown. Only reject
+		 * what the telemetry encoding cannot carry.
+		 */
+		why = param_problem(o.cfilter);
+		if (why != NULL)
+			printf("capture filter %s\n", why);
+	} else {
+		warnx("no extcap operation specified");
+		usage(stderr);
+		return 1;
+	}
+
+	if (fflush(stdout) != 0)
+		err(1, "stdout");
+	return 0;
+}
diff --git a/usertools/meson.build b/usertools/meson.build
index 114d0a65b4..c2bc8715a3 100644
--- a/usertools/meson.build
+++ b/usertools/meson.build
@@ -20,3 +20,26 @@ install_subdir(
         'telemetry-endpoints',
         install_dir: 'share/dpdk',
         strip_directory: false)
+
+# Wireshark extcap helper does not depend on anything from DPDK
+# and speaks the telemetry socket protocol directly.
+if not is_windows
+    extcap_cflags = []
+    extcap_deps = []
+
+    cap_dep = dependency('libcap', required: false)
+    if cap_dep.found()
+        extcap_cflags += '-DHAVE_LIBCAP'
+        extcap_deps += cap_dep
+    endif
+
+    executable('dpdk-wireshark-extcap',
+            files('dpdk-wireshark-extcap.c'),
+            # only for the project-wide '-include rte_config.h'
+            include_directories: config_inc,
+            c_args: extcap_cflags,
+            dependencies: extcap_deps,
+            pie: true,
+            install: true,
+            install_dir: 'bin')
+endif
-- 
2.53.0


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* Re: [PATCH v4 0/3] wireshark capture implementation
  2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
                       ` (2 preceding siblings ...)
  2026-10-13  3:41     ` [PATCH v4 3/3] usertools: add script for wireshark capture Stephen Hemminger
@ 2026-10-13  8:28     ` fengchengwen
  2026-10-13 14:19       ` Stephen Hemminger
  3 siblings, 1 reply; 23+ messages in thread
From: fengchengwen @ 2026-10-13  8:28 UTC (permalink / raw)
  To: Stephen Hemminger, dev

Hi Stephen,

A live Wireshark capture capability via extcap and telemetry is a highly anticipated
feature for runtime debugging. In our previous high-throughput testing with file-based
captures, we frequently encountered severe bottlenecks where packets could not be written
to disk fast enough, leading to massive ring buffer overflows and packet drops.
Therefore, the implementation details of this feature are of great interest to us.

Reviewing the current v4 architecture, I have two major concerns:

1) Security Aspect & Telemetry Attack Surface
The introduction of control operations (such as dynamically registering rx/tx
callbacks and passing arbitrary FIFO paths) drastically expands the attack
surface of the otherwise read-only telemetry interface. Could we introduce
a dedicated "debug mode" configuration to strictly guard these write paths?
Furthermore, it would be beneficial to know if this mode plans to support
more control interfaces down the road, such as a "stats_reset" command.

2) Performance Impact & Thread Isolation
The current implementation enforces a deep copy for captured packets. Have
you considered an adaptive approach? For instance, allowing a lightweight
shallow copy (mbuf clone) under certain conditions to save CPU cycles, while
reverting to a deep copy only when conflicting offloads (like FAST_FREE)
are active on the TX path.

Additionally, how is the actual packet writing thread managed and controlled?
To prevent I/O blocking from introducing jitter into the fast path, should
we consider isolating this writing worker thread onto a dedicated, isolated
core (ecore/lcore)?

Thanks

On 10/13/2026 11:41 AM, Stephen Hemminger wrote:
> This series adds live packet capture from a running DPDK application over the
> telemetry socket. A new capture library installs Rx/Tx callbacks on an ethdev
> port and writes pcapng to a FIFO, with an optional libpcap filter and
> per-queue selection. A helper program presents those ports to Wireshark as
> capture interfaces.
> 
> Advantages of this:
>  - no secondary process needed to capture
>  - installation is just putting the helper in the Wireshark extcap directory
>  - the helper has no dependency on DPDK libraries; can be packaged with
>    Wireshark
>  - allows for live capture
>  - supports BPF (with JIT) for filtering
> 
> How it works:
>  - The capture library registers telemetry commands (/ethdev/capture/*)
>  - Callbacks are installed on the specified port/queues to copy packets
>  - Packets are formatted as pcapng and written to the specified output
>  - No packet data passes through the telemetry socket itself
> 
> Packets handled by a secondary process are captured too, using EAL IPC as
> pdump does.
> 
> The helper is a C program so that it can be installed with privilege, which
> is what lets Wireshark capture from a root application without itself running
> as root. See doc/guides/tools/wireshark_extcap.rst.
> 
> Linux and FreeBSD only. Windows needs the transport first: telemetry has no
> socket server there and uses SOCK_SEQPACKET, which Windows AF_UNIX lacks.
> A stream socket would need the replies framed.
> 
> Testing:
>   - dpdk-wireshark-extcap --extcap-interfaces lists the ports. Reasons for an
>     empty list go to stderr, which the Wireshark GUI hides.
>   - Unit tests are in app/test/test_capture.c
>   - Tested on Linux capturing from a root application as a non-root user.
>     The unit test also passes on FreeBSD.
> 
> v4
>  - helper rewritten in C, and can be installed with privilege so Wireshark
>    need not run as the same user as the DPDK application
>  - capture packets handled by secondary processes
>  - split the library into several files by area
>  - dropped the pcapng change adding the filter-accepted count to the
>    interface statistics block. Still reported through telemetry.
> 
> v3 - fixed AI review feedback
> 
> Stephen Hemminger (3):
>   capture: infrastructure wireshark packet capture
>   test: add test for capture hooks
>   usertools: add script for wireshark capture
> 
>  MAINTAINERS                            |    4 +
>  app/test/meson.build                   |    1 +
>  app/test/test_capture.c                |  688 ++++++++++++++
>  doc/guides/prog_guide/capture_lib.rst  |  167 ++++
>  doc/guides/prog_guide/index.rst        |    1 +
>  doc/guides/rel_notes/release_26_11.rst |    5 +
>  doc/guides/tools/index.rst             |    1 +
>  doc/guides/tools/wireshark_extcap.rst  |  198 ++++
>  lib/capture/capture.c                  |  560 ++++++++++++
>  lib/capture/datapath.c                 |  420 +++++++++
>  lib/capture/filter.c                   |  180 ++++
>  lib/capture/meson.build                |   24 +
>  lib/capture/multiproc.c                |  246 +++++
>  lib/capture/output.c                   |  294 ++++++
>  lib/capture/private.h                  |  214 +++++
>  lib/meson.build                        |    1 +
>  usertools/dpdk-wireshark-extcap.c      | 1145 ++++++++++++++++++++++++
>  usertools/meson.build                  |   23 +
>  18 files changed, 4172 insertions(+)
>  create mode 100644 app/test/test_capture.c
>  create mode 100644 doc/guides/prog_guide/capture_lib.rst
>  create mode 100644 doc/guides/tools/wireshark_extcap.rst
>  create mode 100644 lib/capture/capture.c
>  create mode 100644 lib/capture/datapath.c
>  create mode 100644 lib/capture/filter.c
>  create mode 100644 lib/capture/meson.build
>  create mode 100644 lib/capture/multiproc.c
>  create mode 100644 lib/capture/output.c
>  create mode 100644 lib/capture/private.h
>  create mode 100644 usertools/dpdk-wireshark-extcap.c
> 


^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [PATCH v4 0/3] wireshark capture implementation
  2026-10-13  8:28     ` [PATCH v4 0/3] wireshark capture implementation fengchengwen
@ 2026-10-13 14:19       ` Stephen Hemminger
  0 siblings, 0 replies; 23+ messages in thread
From: Stephen Hemminger @ 2026-10-13 14:19 UTC (permalink / raw)
  To: fengchengwen; +Cc: dev

On Tue, 13 Oct 2026 16:28:41 +0800
fengchengwen <fengchengwen@huawei.com> wrote:

> Hi Stephen,
> 
> A live Wireshark capture capability via extcap and telemetry is a highly anticipated
> feature for runtime debugging. In our previous high-throughput testing with file-based
> captures, we frequently encountered severe bottlenecks where packets could not be written
> to disk fast enough, leading to massive ring buffer overflows and packet drops.
> Therefore, the implementation details of this feature are of great interest to us.


Live capture into Wireshark will not help if disk can't keep up;
Wireshark is slower than disk. For high rate, use a capture filter
and a short snaplen. Existing dpdk-dumpcap can already feed
Wireshark through a pipe.

> Reviewing the current v4 architecture, I have two major concerns:
> 
> 1) Security Aspect & Telemetry Attack Surface
> The introduction of control operations (such as dynamically registering rx/tx
> callbacks and passing arbitrary FIFO paths) drastically expands the attack
> surface of the otherwise read-only telemetry interface. Could we introduce
> a dedicated "debug mode" configuration to strictly guard these write paths?
> Furthermore, it would be beneficial to know if this mode plans to support
> more control interfaces down the road, such as a "stats_reset" command.

I am going to add a "--no-capture" option to command line like "--no-telemetry".
The problem is that if the feature is "opt-in" then users have trouble using
especially if command line is buried in application.

The launchpad script already has lots of hardening to let it run with
privilege escalation. It resets itself after opening the socket.
This is to allow setups (which is normal on most Linux distros) where any
user in group wireshark can run capture.

The capture setup does checks to make sure that it can only be passed a FIFO
and rejects symlinks etc. 

The telemetry socket already has same permissions as the DPDK primary process.
I.e if you can get to the telemetry you can read/modify shared memory etc.

Not sure where Bruce intends to go with telemetry

The other option is making a clone of telemetry (different socket and
startup) but that duplicates effort.


> 2) Performance Impact & Thread Isolation
> The current implementation enforces a deep copy for captured packets. Have
> you considered an adaptive approach? For instance, allowing a lightweight
> shallow copy (mbuf clone) under certain conditions to save CPU cycles, while
> reverting to a deep copy only when conflicting offloads (like FAST_FREE)
> are active on the TX path.

Looked at this before but:

 - Rx: most applications assume the mbuf is writeable and
   having a capture break application is non starter.
 - Tx: many drivers assume the mbuf is writeable (arguably a bug).
 - FAST_FREE requires refcnt == 1.
 - A clone pins the original buffer. If the reader falls behind,
   the capture ring holds Rx mempool buffers and can starve the NIC.

The most efficient way to handle this is to set snapshot length and get
the relevant headers.

> Additionally, how is the actual packet writing thread managed and controlled?
> To prevent I/O blocking from introducing jitter into the fast path, should
> we consider isolating this writing worker thread onto a dedicated, isolated
> core (ecore/lcore)?

The writer is just a control thread, it is going to block on the FIFO
write system call if reader does not keep up. Then the packets back pressure
is in the ring. The callback increments a statistic if ring is full.

The capture writers inherit the control cpuset so already isolated.

^ permalink raw reply	[flat|nested] 23+ messages in thread

end of thread, other threads:[~2026-10-13 14:19 UTC | newest]

Thread overview: 23+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
2026-07-24 21:11 ` [PATCH 1/5] pcapng: extend interface statistics Stephen Hemminger
2026-07-24 21:11 ` [PATCH 2/5] capture: infrastructure wireshark packet capture Stephen Hemminger
2026-07-24 21:11 ` [PATCH 3/5] test: add test for capture hooks Stephen Hemminger
2026-07-24 21:11 ` [PATCH 4/5] usertools/dpdk-wireshark-extcap.py: script for external capture Stephen Hemminger
2026-07-24 21:11 ` [PATCH 5/5] usertools/dpdk-dumpcap: add script for file capture Stephen Hemminger
2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
2026-07-27 22:42   ` [PATCH v2 1/4] pcapng: extend interface statistics Stephen Hemminger
2026-07-27 22:42   ` [PATCH v2 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
2026-07-27 22:42   ` [PATCH v2 3/4] test: add test for capture hooks Stephen Hemminger
2026-07-27 22:42   ` [PATCH v2 4/4] usertools: add script for wireshark capture Stephen Hemminger
2026-07-29 23:28     ` Stephen Hemminger
2026-07-30 18:22   ` [PATCH v3 0/4] Wireshark external capture Stephen Hemminger
2026-07-30 18:22     ` [PATCH v3 1/4] pcapng: extend interface statistics Stephen Hemminger
2026-07-30 18:22     ` [PATCH v3 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
2026-07-30 18:22     ` [PATCH v3 3/4] test: add test for capture hooks Stephen Hemminger
2026-07-30 18:22     ` [PATCH v3 4/4] usertools: add script for wireshark capture Stephen Hemminger
2026-10-13  3:41   ` [PATCH v4 0/3] wireshark capture implementation Stephen Hemminger
2026-10-13  3:41     ` [PATCH v4 1/3] capture: infrastructure wireshark packet capture Stephen Hemminger
2026-10-13  3:41     ` [PATCH v4 2/3] test: add test for capture hooks Stephen Hemminger
2026-10-13  3:41     ` [PATCH v4 3/3] usertools: add script for wireshark capture Stephen Hemminger
2026-10-13  8:28     ` [PATCH v4 0/3] wireshark capture implementation fengchengwen
2026-10-13 14:19       ` Stephen Hemminger

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox