Count, for each security association, the packets and bytes sent or
received, and for inbound SAs the packets dropped as replays, because
they failed authentication, or because they were malformed.  The coverage
counters only gave totals for all tunnels.

The counters are relaxed atomics.  An outbound SA's counters share a
cache line with its sequence number, which is already incremented
atomically for each packet, and an inbound SA is usually only used by
the PMD thread that polls the queue its packets arrive on.

'ovs-appctl esp/show [port]' displays them with the SPI, cipher and
sequence number state of each SA:

  t1: remote 1.1.2.92
    outbound: spi 0x00002000, aes128-gcm16, esn off
      packets 2, bytes 184, last seq 2, errors 0
    inbound : spi 0x00001000, aes128-gcm16, esn off, replay window 64
      packets 2, bytes 80, highest seq 2
      replayed 1, auth failed 1, malformed 0

The tunnel-push-pop test checks this output after sending, replaying
and tampering with packets.

Assisted-by: claude-opus-5-5, Claude Code
Signed-off-by: Thilak Raj Surendra Babu <[email protected]>
---
 NEWS                            |  2 +
 lib/esp.c                       | 97 +++++++++++++++++++++++++++++++--
 lib/esp.h                       | 14 +++++
 lib/netdev-vport.c              | 87 +++++++++++++++++++++++++++++
 ofproto/ofproto-tnl-unixctl.man |  8 +++
 tests/test-esp.c                |  3 +
 tests/tunnel-push-pop.at        | 18 ++++++
 7 files changed, 225 insertions(+), 4 deletions(-)

diff --git a/NEWS b/NEWS
index e353d5204..a5fbd8a75 100644
--- a/NEWS
+++ b/NEWS
@@ -10,6 +10,8 @@ Post-v4.0.0
      * New 'esp' tunnel type, which carries IPv4 and IPv6 packets in IPsec
        ESP tunnel mode with AES-GCM, encrypted by the datapath threads.
        The keys are configured manually for now.
+     * New 'esp/show' unixctl command to display the security associations
+       of 'esp' tunnels and their statistics.
 
 
 v4.0.0 - 17 Aug 2026
diff --git a/lib/esp.c b/lib/esp.c
index a6a364356..b1e85da79 100644
--- a/lib/esp.c
+++ b/lib/esp.c
@@ -62,6 +62,17 @@ struct esp_sa {
     atomic_uint64_t seq;            /* Last sequence number sent. */
     uint64_t iv_salt;
 
+    /* Statistics, of sent packets for an outbound SA and of received ones
+     * for an inbound SA.  'n_bytes' counts the bytes of the payload, that is
+     * of the inner packets. */
+    atomic_uint64_t n_packets;
+    atomic_uint64_t n_bytes;
+    atomic_uint64_t n_replayed;     /* Inbound: dropped as replays. */
+    atomic_uint64_t n_auth_failed;  /* Inbound: failed authentication. */
+    atomic_uint64_t n_malformed;    /* Inbound: bad padding or length. */
+    atomic_uint64_t n_tx_errors;    /* Outbound: sequence exhausted or
+                                     * encryption failed. */
+
     /* Inbound anti-replay window (RFC 4303 section 3.4.3 and appendix A).
      * Bit (N % replay_size) of 'replay_bitmap' is set if sequence number N
      * has been received, for N in (replay_top - replay_size, replay_top]. */
@@ -177,6 +188,12 @@ esp_sa_create(const struct esp_sa_params *params 
OVS_UNUSED_WITHOUT_OPENSSL)
     atomic_add_relaxed(&next_id, 1, &sa->id);
     sa->cipher = cipher;
     atomic_init(&sa->seq, 0);
+    atomic_init(&sa->n_packets, 0);
+    atomic_init(&sa->n_bytes, 0);
+    atomic_init(&sa->n_replayed, 0);
+    atomic_init(&sa->n_auth_failed, 0);
+    atomic_init(&sa->n_malformed, 0);
+    atomic_init(&sa->n_tx_errors, 0);
     get_entropy_or_die(&sa->iv_salt, sizeof sa->iv_salt);
 
     ovs_mutex_init(&sa->replay_mutex);
@@ -219,6 +236,65 @@ esp_sa_get_params(const struct esp_sa *sa)
     return &sa->params;
 }
 
+#ifdef HAVE_OPENSSL
+static void
+esp_stat_inc(atomic_uint64_t *counter, uint64_t n)
+{
+    uint64_t orig;
+
+    atomic_add_relaxed(counter, n, &orig);
+}
+#endif
+
+static uint64_t
+esp_stat_read(const atomic_uint64_t *counter_)
+{
+    atomic_uint64_t *counter = CONST_CAST(atomic_uint64_t *, counter_);
+    uint64_t value;
+
+    atomic_read_relaxed(counter, &value);
+    return value;
+}
+
+/* Stores a snapshot of the statistics of 'sa' in 'stats'. */
+void
+esp_sa_get_stats(const struct esp_sa *sa_, struct esp_sa_stats *stats)
+{
+    struct esp_sa *sa = CONST_CAST(struct esp_sa *, sa_);
+
+    stats->n_packets = esp_stat_read(&sa->n_packets);
+    stats->n_bytes = esp_stat_read(&sa->n_bytes);
+    stats->n_replayed = esp_stat_read(&sa->n_replayed);
+    stats->n_auth_failed = esp_stat_read(&sa->n_auth_failed);
+    stats->n_malformed = esp_stat_read(&sa->n_malformed);
+    stats->n_tx_errors = esp_stat_read(&sa->n_tx_errors);
+    stats->tx_seq = esp_stat_read(&sa->seq);
+    if (!sa->params.esn && stats->tx_seq > UINT32_MAX) {
+        /* Exhausted: refused packets keep incrementing 'seq'. */
+        stats->tx_seq = UINT32_MAX;
+    }
+
+    ovs_mutex_lock(&sa->replay_mutex);
+    stats->rx_seq = sa->replay_top;
+    ovs_mutex_unlock(&sa->replay_mutex);
+}
+
+/* Returns the name of the cipher of 'sa', e.g. "aes128-gcm16". */
+const char *
+esp_sa_cipher_name(const struct esp_sa *sa)
+{
+    switch (sa->params.key_len - ESP_SALT_LEN) {
+    case 16:
+        return "aes128-gcm16";
+    case 24:
+        return "aes192-gcm16";
+    case 32:
+        return "aes256-gcm16";
+    default:
+        return "unknown";
+    }
+}
+
 /* Makes the next packet sent on outbound SA 'sa' use sequence number 'seq'.
  * For testing only. */
 void
@@ -557,6 +633,7 @@ esp_seal(struct esp_sa *sa OVS_UNUSED_WITHOUT_OPENSSL,
          * ESN, this only catches the wrap to zero, not later numbers, but
          * sending 2**64 packets is out of reach. */
         COVERAGE_INC(esp_tx_seq_exhausted);
+        esp_stat_inc(&sa->n_tx_errors, 1);
         return ERANGE;
     }
 
@@ -583,8 +660,12 @@ esp_seal(struct esp_sa *sa OVS_UNUSED_WITHOUT_OPENSSL,
                                              ESP_ICV_LEN,
                                              payload + ct_len))) {
         COVERAGE_INC(esp_tx_crypto_error);
+        esp_stat_inc(&sa->n_tx_errors, 1);
         return EIO;
     }
+
+    esp_stat_inc(&sa->n_packets, 1);
+    esp_stat_inc(&sa->n_bytes, payload_len);
     return 0;
 #else
     return EOPNOTSUPP;
@@ -637,6 +718,7 @@ esp_open(struct esp_header *esp OVS_UNUSED_WITHOUT_OPENSSL,
     seq_lo = ntohl(get_16aligned_be32(&esp->seq_no));
     if (OVS_UNLIKELY(!esp_replay_check(sa, seq_lo, &seq))) {
         COVERAGE_INC(esp_rx_replay);
+        esp_stat_inc(&sa->n_replayed, 1);
         return EALREADY;
     }
 
@@ -653,32 +735,39 @@ esp_open(struct esp_header *esp 
OVS_UNUSED_WITHOUT_OPENSSL,
                      || EVP_CipherFinal_ex(ctx, payload + out_len,
                                            &out_len) <= 0)) {
         COVERAGE_INC(esp_rx_auth_failed);
+        esp_stat_inc(&sa->n_auth_failed, 1);
         return EBADMSG;
     }
 
     if (OVS_UNLIKELY(!esp_replay_update(sa, seq))) {
         COVERAGE_INC(esp_rx_replay);
+        esp_stat_inc(&sa->n_replayed, 1);
         return EALREADY;
     }
 
     trailer = payload + ct_len - ESP_TRAILER_LEN;
     pad_len = trailer[0];
     if (OVS_UNLIKELY(pad_len + ESP_TRAILER_LEN > ct_len)) {
-        COVERAGE_INC(esp_rx_malformed);
-        return EINVAL;
+        goto malformed;
     }
     /* RFC 4303 section 2.4: the padding bytes are 1, 2, 3, ... */
     pad = trailer - pad_len;
     for (size_t i = 0; i < pad_len; i++) {
         if (OVS_UNLIKELY(pad[i] != i + 1)) {
-            COVERAGE_INC(esp_rx_malformed);
-            return EINVAL;
+            goto malformed;
         }
     }
 
     *next_hdr = trailer[1];
     *payload_len = ct_len - pad_len - ESP_TRAILER_LEN;
+    esp_stat_inc(&sa->n_packets, 1);
+    esp_stat_inc(&sa->n_bytes, *payload_len);
     return 0;
+
+malformed:
+    COVERAGE_INC(esp_rx_malformed);
+    esp_stat_inc(&sa->n_malformed, 1);
+    return EINVAL;
 #else
     return EOPNOTSUPP;
 #endif
diff --git a/lib/esp.h b/lib/esp.h
index be4558c30..5dfb362ab 100644
--- a/lib/esp.h
+++ b/lib/esp.h
@@ -79,6 +79,20 @@ struct esp_sa *esp_sa_create(const struct esp_sa_params *);
 void esp_sa_destroy(struct esp_sa *);
 void esp_sa_destroy_postponed(struct esp_sa *);
 const struct esp_sa_params *esp_sa_get_params(const struct esp_sa *);
+const char *esp_sa_cipher_name(const struct esp_sa *);
+
+struct esp_sa_stats {
+    uint64_t n_packets;         /* Sent (outbound) or received (inbound). */
+    uint64_t n_bytes;           /* Of the inner packets. */
+    uint64_t n_replayed;        /* Inbound only. */
+    uint64_t n_auth_failed;     /* Inbound only. */
+    uint64_t n_malformed;       /* Inbound only. */
+    uint64_t n_tx_errors;       /* Outbound only. */
+    uint64_t tx_seq;            /* Outbound: last sequence number sent. */
+    uint64_t rx_seq;            /* Inbound: highest sequence number received,
+                                 * if the anti-replay window is enabled. */
+};
+void esp_sa_get_stats(const struct esp_sa *, struct esp_sa_stats *);
 
 int esp_sad_insert(struct esp_sa *);
 void esp_sad_replace(struct esp_sa *old, struct esp_sa *new);
diff --git a/lib/netdev-vport.c b/lib/netdev-vport.c
index 1d77680c3..c5b03dab5 100644
--- a/lib/netdev-vport.c
+++ b/lib/netdev-vport.c
@@ -802,6 +802,91 @@ esp_tunnel_install_sa(struct esp_sa **cur, struct esp_sa 
*new)
     esp_sa_destroy_postponed(old);
 }
 
+static void
+esp_show_sa(struct ds *ds, const char *dir, const struct esp_sa *sa,
+            bool inbound)
+{
+    const struct esp_sa_params *p = esp_sa_get_params(sa);
+    struct esp_sa_stats stats;
+
+    esp_sa_get_stats(sa, &stats);
+    ds_put_format(ds, "  %s: spi 0x%08"PRIx32", %s, esn %s", dir,
+                  ntohl(p->spi), esp_sa_cipher_name(sa),
+                  p->esn ? "on" : "off");
+    if (inbound) {
+        ds_put_format(ds, ", replay window %"PRIu16"\n", p->replay_window);
+        ds_put_format(ds, "    packets %"PRIu64", bytes %"PRIu64,
+                      stats.n_packets, stats.n_bytes);
+        if (p->replay_window) {
+            ds_put_format(ds, ", highest seq %"PRIu64, stats.rx_seq);
+        }
+        ds_put_format(ds, "\n    replayed %"PRIu64", auth failed %"PRIu64
+                      ", malformed %"PRIu64"\n", stats.n_replayed,
+                      stats.n_auth_failed, stats.n_malformed);
+    } else {
+        ds_put_format(ds, "\n    packets %"PRIu64", bytes %"PRIu64
+                      ", last seq %"PRIu64", errors %"PRIu64"\n",
+                      stats.n_packets, stats.n_bytes, stats.tx_seq,
+                      stats.n_tx_errors);
+    }
+}
+
+static int
+compare_netdev_names(const void *a_, const void *b_)
+{
+    const struct netdev *const *a = a_;
+    const struct netdev *const *b = b_;
+
+    return strcmp(netdev_get_name(*a), netdev_get_name(*b));
+}
+
+static void
+netdev_vport_esp_show(struct unixctl_conn *conn, int argc,
+                      const char *argv[], void *aux OVS_UNUSED)
+{
+    struct ds ds = DS_EMPTY_INITIALIZER;
+    struct netdev **vports;
+    size_t n_vports;
+    bool found = false;
+
+    vports = netdev_get_vports(&n_vports);
+    qsort(vports, n_vports, sizeof *vports, compare_netdev_names);
+    for (size_t i = 0; i < n_vports; i++) {
+        struct netdev_vport *dev = netdev_vport_cast(vports[i]);
+        const char *name = netdev_get_name(vports[i]);
+
+        if (strcmp(netdev_get_type(vports[i]), "esp")
+            || (argc > 1 && strcmp(argv[1], name))) {
+            continue;
+        }
+
+        ovs_mutex_lock(&dev->mutex);
+        if (dev->esp_in_sa && dev->esp_out_sa) {
+            const struct netdev_tunnel_config *tnl_cfg;
+
+            tnl_cfg = vport_tunnel_config(dev);
+            ds_put_format(&ds, "%s: remote ", name);
+            ipv6_format_mapped(&tnl_cfg->ipv6_dst, &ds);
+            ds_put_char(&ds, '\n');
+            esp_show_sa(&ds, "outbound", dev->esp_out_sa, false);
+            esp_show_sa(&ds, "inbound ", dev->esp_in_sa, true);
+            found = true;
+        }
+        ovs_mutex_unlock(&dev->mutex);
+    }
+    for (size_t i = 0; i < n_vports; i++) {
+        netdev_close(vports[i]);
+    }
+    free(vports);
+
+    if (argc > 1 && !found) {
+        unixctl_command_reply_error(conn, "no such esp tunnel");
+    } else {
+        unixctl_command_reply(conn, ds_cstr(&ds));
+    }
+    ds_destroy(&ds);
+}
+
 static int
 set_tunnel_config(struct netdev *dev_, const struct smap *args, char **errp)
 {
@@ -1616,6 +1701,8 @@ netdev_vport_tunnel_register(void)
 
         unixctl_command_register("tnl/egress_port_range", "min max", 0, 2,
                                  netdev_tnl_egress_port_range, NULL);
+        unixctl_command_register("esp/show", "[port]", 0, 1,
+                                 netdev_vport_esp_show, NULL);
 
         ovsthread_once_done(&once);
     }
diff --git a/ofproto/ofproto-tnl-unixctl.man b/ofproto/ofproto-tnl-unixctl.man
index b2a401564..9c8d15018 100644
--- a/ofproto/ofproto-tnl-unixctl.man
+++ b/ofproto/ofproto-tnl-unixctl.man
@@ -77,3 +77,11 @@ If used without arguments, it prints the current aging value.
 Set range for UDP source port used for UDP based Tunnels. For
 example VxLAN. If case of zero arguments this command prints
 current range in use.
+.
+.IP "\fBesp/show\fR [\fIport\fR]"
+Shows the security associations of each \fBesp\fR tunnel port, or only
+of \fIport\fR, with their SPI, cipher and sequence number state, and
+the number of packets and bytes sent or received through each.  For
+inbound security associations, it also shows the number of packets
+dropped as replays, because they failed authentication, or because
+they were malformed.  The byte counts are of the inner packets.
diff --git a/tests/test-esp.c b/tests/test-esp.c
index e5fd9013d..92d5535d3 100644
--- a/tests/test-esp.c
+++ b/tests/test-esp.c
@@ -229,6 +229,7 @@ test_round_trip(void)
 static void
 test_replay_window(void)
 {
+    struct esp_sa_stats stats;
     struct esp_sa_params params = make_params(0x3000, TEST_KEY, false, 64);
     struct esp_sa *out = esp_sa_create(&params);
     struct esp_sa *in = esp_sa_create(&params);
@@ -251,6 +252,8 @@ test_replay_window(void)
     ovs_assert(!send_seq(out, UINT32_MAX));
     struct test_pkt pkt;
     ovs_assert(seal(out, &pkt, "x", 1) == ERANGE);
+    esp_sa_get_stats(out, &stats);
+    ovs_assert(stats.tx_seq == UINT32_MAX);
 
     esp_sad_remove(in);
     esp_sa_destroy(in);
diff --git a/tests/tunnel-push-pop.at b/tests/tunnel-push-pop.at
index 27278917e..3a9648851 100644
--- a/tests/tunnel-push-pop.at
+++ b/tests/tunnel-push-pop.at
@@ -1702,6 +1702,24 @@ AT_CHECK([ovs-appctl coverage/read-counter 
esp_rx_replay], [0], [1
 AT_CHECK([ovs-appctl coverage/read-counter esp_rx_auth_failed], [0], [1
 ])
 
+dnl Per-SA statistics.  The inner packets are 92 bytes long when sent and 40
+dnl bytes long when received.
+AT_CHECK([ovs-appctl esp/show], [0], [dnl
+t1: remote 1.1.2.92
+  outbound: spi 0x00002000, aes128-gcm16, esn off
+    packets 2, bytes 184, last seq 2, errors 0
+  inbound : spi 0x00001000, aes128-gcm16, esn off, replay window 64
+    packets 2, bytes 80, highest seq 2
+    replayed 1, auth failed 1, malformed 0
+])
+AT_CHECK([ovs-appctl esp/show t1 | head -1], [0], [dnl
+t1: remote 1.1.2.92
+])
+AT_CHECK([ovs-appctl esp/show nonexistent], [2], [], [dnl
+no such esp tunnel
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+
 dnl Datapath flows for both directions.  The tunnel ID of received packets
 dnl is the SPI.  The first packet of each flow was an upcall.
 AT_CHECK([ovs-appctl dpctl/dump-flows | strip_used | sort | dnl
-- 
2.43.0

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to