Manually configured keys are never changed, and break the tunnel when
either end restarts.  Let an IKE daemon negotiate and rekey them
instead.  A follow-up plugin for strongSwan, 'kernel-ovs', uses this.

An esp tunnel with 'options:esp_keying=ike' has no keys of its own.
The IKE daemon installs its security associations through new unixctl
commands, which are its kernel interface:

  esp/spi-alloc    an unused SPI for an inbound SA
  esp/sa-add       install an SA (its arguments are not logged)
  esp/sa-del       delete an SA
  esp/sa-query     packets and bytes processed by an SA, for lifetimes
  esp/sa-flush     delete all SAs

The SAs are bound to the tunnel by an interface ID, 'options:esp_if_id',
which is strongSwan's 'if_id_in' and 'if_id_out'.

Rekeying must not disturb traffic, so:

 - An SA now has a 'sad_id', by which it is found in the SA database,
   besides its SPI.  An outbound SA of an IKE-keyed tunnel is found by
   the interface ID, which is what the tunnel's datapath flows push as
   the SPI, and esp_seal() writes the SA's actual SPI.  A new outbound
   SA atomically replaces the previous one in the database, without
   changing datapath flows.  The previous one remains on standby until
   the daemon deletes it.  If the daemon deletes the active one instead,
   the newest one on standby takes over, which strongSwan 5.9.13 did not
   need in 100 rounds of rekeying from both ends at once.

 - An SA now has a 'tun_id', the tunnel ID of the packets it decrypts.
   It is the interface ID for IKE-keyed tunnels, so that the old and new
   inbound SAs coexist during rekeying and both map to the tunnel.  It
   remains the inbound SPI for manually keyed tunnels.

'esp/show' lists the SAs of IKE-keyed tunnels.

'esp/sa-add' refuses an SA whose addresses or UDP encapsulation do not
match a tunnel with its interface ID, since its packets could not flow.

The arguments of 'esp/sa-add' contain keys, so they are overwritten
after use with a new ovs_memwipe(), as far as this code can reach them.
The jsonrpc receive buffer is not covered.

Tests:
 - tunnel-push-pop: the commands, packets in both directions, rekeying
   without flow changes, deleting the active SA, invalid arguments, SAs
   that do not match their tunnel, and that keys are not logged.
 - By hand on E810 25G ports: strongSwan 5.9.13 with the plugin against
   a second strongSwan using the Linux kernel's IPsec, with rekeys
   under traffic, with and without ESN, several CHILD_SAs and
   ovs-vswitchd restarts.

Assisted-by: claude-opus-5-5, Claude Code
Signed-off-by: Thilak Raj Surendra Babu <[email protected]>
---
 NEWS                            |   4 +
 lib/automake.mk                 |   2 +
 lib/esp-ctl.c                   | 558 ++++++++++++++++++++++++++++++++
 lib/esp-ctl.h                   |  31 ++
 lib/esp.c                       |  70 +++-
 lib/esp.h                       |  21 +-
 lib/netdev-native-tnl.c         |  13 +-
 lib/netdev-vport.c              | 314 +++++++++++++-----
 lib/netdev-vport.h              |   7 +
 lib/netdev.h                    |   6 +-
 lib/unixctl.c                   |  15 +
 lib/util.c                      |  12 +
 lib/util.h                      |   1 +
 ofproto/ofproto-tnl-unixctl.man |  20 ++
 tests/test-esp.c                |  40 ++-
 tests/tunnel-push-pop.at        | 238 +++++++++++++-
 vswitchd/vswitch.xml            |  40 +++
 17 files changed, 1268 insertions(+), 124 deletions(-)
 create mode 100644 lib/esp-ctl.c
 create mode 100644 lib/esp-ctl.h

diff --git a/NEWS b/NEWS
index 0fdb75fe9..1c6861586 100644
--- a/NEWS
+++ b/NEWS
@@ -14,6 +14,10 @@ Post-v4.0.0
        of 'esp' tunnels and their statistics.
      * 'esp' tunnels support UDP encapsulation for NAT traversal, and TCP
        segmentation in software when userspace TSO is enabled.
+     * 'esp' tunnels with 'esp_keying=ike' get their security associations
+       from an IKE daemon, such as strongSwan with a 'kernel-ovs' plugin,
+       through new 'esp/sa-add', 'esp/sa-del', 'esp/sa-query',
+       'esp/sa-flush' and 'esp/spi-alloc' unixctl commands.
 
 
 v4.0.0 - 17 Aug 2026
diff --git a/lib/automake.mk b/lib/automake.mk
index 1e3a800fe..fd842e781 100644
--- a/lib/automake.mk
+++ b/lib/automake.mk
@@ -106,6 +106,8 @@ lib_libopenvswitch_la_SOURCES = \
        lib/entropy.h \
        lib/esp.c \
        lib/esp.h \
+       lib/esp-ctl.c \
+       lib/esp-ctl.h \
        lib/fat-rwlock.c \
        lib/fat-rwlock.h \
        lib/fatal-signal.c \
diff --git a/lib/esp-ctl.c b/lib/esp-ctl.c
new file mode 100644
index 000000000..7c2fce543
--- /dev/null
+++ b/lib/esp-ctl.c
@@ -0,0 +1,558 @@
+/*
+ * Copyright (c) 2026 Thilak Raj Surendra Babu
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <config.h>
+#include "esp-ctl.h"
+
+#include <sys/types.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <errno.h>
+#include <stdlib.h>
+
+#include "byte-order.h"
+#include "esp.h"
+#include "hash.h"
+#include "netdev-vport.h"
+#include "openvswitch/dynamic-string.h"
+#include "openvswitch/hmap.h"
+#include "openvswitch/vlog.h"
+#include "packets.h"
+#include "random.h"
+#include "smap.h"
+#include "unixctl.h"
+#include "util.h"
+
+VLOG_DEFINE_THIS_MODULE(esp_ctl);
+
+/* A security association installed by the IKE daemon.
+ *
+ * An inbound SA is in the SAD as long as it exists.  Several can exist for
+ * one tunnel while it is rekeyed.  Received packets get the interface ID as
+ * their tunnel ID, which binds them to the tunnel with that 'esp_if_id'.
+ *
+ * An outbound SA is in the SAD under the interface ID and the peer's
+ * address, which is what the tunnel's datapath flows look up.  A new
+ * outbound SA for the same tunnel, installed when the SAs are rekeyed,
+ * replaces the previous one there, so that the flows do not change.  The
+ * previous one then remains on standby until the IKE daemon deletes it.  If
+ * the IKE daemon deletes the newer one instead, the newest one on standby
+ * takes over again, so that a remaining SA does not go unused. */
+struct ike_sa {
+    struct hmap_node node;      /* In 'ike_sas'. */
+    uint64_t install_order;     /* Higher for SAs installed later. */
+    bool inbound;
+    ovs_be32 spi;
+    struct in6_addr dst;
+    uint32_t if_id;
+    struct esp_sa *sa;
+};
+
+static struct hmap ike_sas = HMAP_INITIALIZER(&ike_sas);
+static uint64_t next_install_order;
+
+/* SPIs returned by "esp/spi-alloc" and not yet used by an inbound SA. */
+#define MAX_RESERVED_SPIS 1024
+static struct hmap reserved_spis = HMAP_INITIALIZER(&reserved_spis);
+struct reserved_spi {
+    struct hmap_node node;
+    ovs_be32 spi;
+};
+
+static uint32_t
+ike_sa_hash(bool inbound, ovs_be32 spi, const struct in6_addr *dst)
+{
+    return hash_bytes(dst, sizeof *dst,
+                      hash_2words((OVS_FORCE uint32_t) spi, inbound));
+}
+
+static struct ike_sa *
+ike_sa_find(bool inbound, ovs_be32 spi, const struct in6_addr *dst)
+{
+    struct ike_sa *ike;
+
+    HMAP_FOR_EACH_WITH_HASH (ike, node, ike_sa_hash(inbound, spi, dst),
+                             &ike_sas) {
+        if (ike->inbound == inbound && ike->spi == spi
+            && ipv6_addr_equals(&ike->dst, dst)) {
+            return ike;
+        }
+    }
+    return NULL;
+}
+
+static struct ike_sa *
+ike_sa_find_by_esp_sa(const struct esp_sa *sa)
+{
+    struct ike_sa *ike;
+
+    HMAP_FOR_EACH (ike, node, &ike_sas) {
+        if (ike->sa == sa) {
+            return ike;
+        }
+    }
+    return NULL;
+}
+
+static struct reserved_spi *
+reserved_spi_find(ovs_be32 spi)
+{
+    struct reserved_spi *r;
+
+    HMAP_FOR_EACH_WITH_HASH (r, node, hash_int((OVS_FORCE uint32_t) spi, 0),
+                             &reserved_spis) {
+        if (r->spi == spi) {
+            return r;
+        }
+    }
+    return NULL;
+}
+
+static void
+reserved_spi_release(ovs_be32 spi)
+{
+    struct reserved_spi *r = reserved_spi_find(spi);
+
+    if (r) {
+        hmap_remove(&reserved_spis, &r->node);
+        free(r);
+    }
+}
+
+static bool
+parse_addr(const char *s, struct in6_addr *addr)
+{
+    ovs_be32 ip4;
+
+    if (s && inet_pton(AF_INET, s, &ip4) == 1) {
+        in6_addr_set_mapped_ipv4(addr, ip4);
+        return true;
+    }
+    return s && inet_pton(AF_INET6, s, addr) == 1;
+}
+
+static bool
+parse_u32(const char *s, uint32_t min, uint32_t *value)
+{
+    unsigned long long int x;
+    char *tail;
+
+    if (!s) {
+        return false;
+    }
+    errno = 0;
+    x = strtoull(s, &tail, 0);
+    if (errno || *tail || tail == s || x < min || x > UINT32_MAX) {
+        return false;
+    }
+    *value = x;
+    return true;
+}
+
+/* Stores in '*value' whether optional argument 'key' is "yes".  Returns false
+ * if it is neither "yes" nor "no". */
+static bool
+parse_yes_no(const struct smap *args, const char *key, bool *value)
+{
+    const char *s = smap_get_def(args, key, "no");
+
+    *value = !strcmp(s, "yes");
+    return *value || !strcmp(s, "no");
+}
+
+/* Parses "key=value" arguments into 'args'. */
+static char *
+parse_args(int argc, const char *argv[], struct smap *args)
+{
+    for (int i = 1; i < argc; i++) {
+        const char *eq = strchr(argv[i], '=');
+
+        if (!eq) {
+            return xasprintf("argument '%s' is not key=value", argv[i]);
+        }
+
+        char *key = xmemdup0(argv[i], eq - argv[i]);
+
+        smap_replace_nocopy(args, key, xstrdup(eq + 1));
+        free(key);
+    }
+    return NULL;
+}
+
+/* Parses the arguments that identify an SA: "dir", "spi" and "dst". */
+static char *
+parse_sa_id(const struct smap *args, bool *inbound, ovs_be32 *spi,
+            struct in6_addr *dst)
+{
+    const char *dir = smap_get(args, "dir");
+    uint32_t spi_;
+
+    if (!dir || (strcmp(dir, "in") && strcmp(dir, "out"))) {
+        return xstrdup("'dir' must be 'in' or 'out'");
+    }
+    *inbound = !strcmp(dir, "in");
+    if (!parse_u32(smap_get(args, "spi"), 256, &spi_)) {
+        return xstrdup("'spi' must be between 256 and 4294967295");
+    }
+    *spi = htonl(spi_);
+    if (!parse_addr(smap_get(args, "dst"), dst)) {
+        return xstrdup("'dst' must be an IP address");
+    }
+    return NULL;
+}
+
+/* "esp/spi-alloc": returns an SPI that no inbound SA uses. */
+static void
+esp_ctl_spi_alloc(struct unixctl_conn *conn, int argc OVS_UNUSED,
+                  const char *argv[] OVS_UNUSED, void *aux OVS_UNUSED)
+{
+    struct reserved_spi *r;
+    char reply[16];
+    ovs_be32 spi;
+
+    if (hmap_count(&reserved_spis) >= MAX_RESERVED_SPIS) {
+        /* Allocated, but never used.  Forget them. */
+        HMAP_FOR_EACH_POP (r, node, &reserved_spis) {
+            free(r);
+        }
+    }
+
+    /* Like the Linux kernel, use the range 0xc0000000 to 0xcfffffff. */
+    do {
+        spi = htonl(0xc0000000 | (random_uint32() & 0x0fffffff));
+    } while (esp_sad_lookup(spi, &in6addr_any) || reserved_spi_find(spi));
+
+    r = xmalloc(sizeof *r);
+    r->spi = spi;
+    hmap_insert(&reserved_spis, &r->node, hash_int((OVS_FORCE uint32_t) spi,
+                                                   0));
+
+    snprintf(reply, sizeof reply, "0x%08"PRIx32, ntohl(spi));
+    unixctl_command_reply(conn, reply);
+}
+
+/* "esp/sa-add dir=in|out spi=SPI src=IP dst=IP if_id=ID key=KEY [esn=yes]
+ *              [replay=N] [encap=yes]" */
+static void
+esp_ctl_sa_add(struct unixctl_conn *conn, int argc, const char *argv[],
+               void *aux OVS_UNUSED)
+{
+    struct smap args = SMAP_INITIALIZER(&args);
+    struct esp_sa_params params;
+    struct in6_addr src, dst;
+    struct esp_sa *sa, *old;
+    struct smap_node *key;
+    struct ike_sa *ike;
+    unsigned int replay;
+    uint32_t if_id;
+    bool inbound;
+    bool encap;
+    char *error;
+    ovs_be32 spi;
+
+    memset(&params, 0, sizeof params);
+
+    error = parse_args(argc, argv, &args);
+    if (!error) {
+        error = parse_sa_id(&args, &inbound, &spi, &dst);
+    }
+    if (error) {
+        goto out;
+    }
+    if (!parse_addr(smap_get(&args, "src"), &src)) {
+        error = xstrdup("'src' must be an IP address");
+        goto out;
+    }
+    if (!parse_u32(smap_get(&args, "if_id"), 1, &if_id)) {
+        error = xstrdup("'if_id' must be between 1 and 4294967295");
+        goto out;
+    }
+    error = esp_parse_key(smap_get_def(&args, "key", ""), &params);
+    if (error) {
+        goto out;
+    }
+    if (ike_sa_find(inbound, spi, &dst)) {
+        error = xstrdup("SA already exists");
+        goto out;
+    }
+    if (!parse_yes_no(&args, "esn", &params.esn)
+        || !parse_yes_no(&args, "encap", &encap)) {
+        error = xstrdup("'esn' and 'encap' must be 'yes' or 'no'");
+        goto out;
+    }
+    error = netdev_vport_esp_check_sa(if_id, inbound, &src, &dst, encap);
+    if (error) {
+        goto out;
+    }
+
+    params.spi = spi;
+    if (inbound) {
+        params.sad_id = spi;
+        params.tun_id = htonll(if_id);
+        replay = smap_get_uint(&args, "replay", ESP_DEFAULT_REPLAY_WINDOW);
+        if (replay > ESP_MAX_REPLAY_WINDOW) {
+            error = xasprintf("'replay' must be between 0 and %d",
+                              ESP_MAX_REPLAY_WINDOW);
+            goto out;
+        }
+        params.replay_window = replay;
+        if (esp_sad_lookup(spi, &in6addr_any)) {
+            error = xstrdup("SPI already in use");
+            goto out;
+        }
+    } else {
+        params.sad_id = htonl(if_id);
+        params.dst = dst;
+    }
+
+    sa = esp_sa_create(&params);
+    if (!sa) {
+        error = xstrdup("invalid parameters (replay window?)");
+        goto out;
+    }
+
+    old = esp_sad_lookup(params.sad_id, &params.dst);
+    if (!old) {
+        ovs_assert(!esp_sad_insert(sa));
+    } else if (!inbound && ike_sa_find_by_esp_sa(old)) {
+        /* Rekeying: the new outbound SA takes over. */
+        esp_sad_replace(old, sa);
+    } else {
+        /* A manually keyed tunnel uses this SPI or interface ID. */
+        esp_sa_destroy(sa);
+        error = xstrdup("conflicts with a manually keyed esp tunnel");
+        goto out;
+    }
+
+    ike = xzalloc(sizeof *ike);
+    ike->install_order = ++next_install_order;
+    ike->inbound = inbound;
+    ike->spi = spi;
+    ike->dst = dst;
+    ike->if_id = if_id;
+    ike->sa = sa;
+    hmap_insert(&ike_sas, &ike->node, ike_sa_hash(inbound, spi, &dst));
+    if (inbound) {
+        reserved_spi_release(spi);
+    }
+    VLOG_INFO("added %s SA spi 0x%08"PRIx32" for if_id %"PRIu32,
+              inbound ? "inbound" : "outbound", ntohl(spi), if_id);
+
+out:
+    if (error) {
+        unixctl_command_reply_error(conn, error);
+        free(error);
+    } else {
+        unixctl_command_reply(conn, NULL);
+    }
+    ovs_memwipe(&params, sizeof params);
+    key = smap_get_node(&args, "key");
+    if (key) {
+        ovs_memwipe(key->value, strlen(key->value));
+    }
+    smap_destroy(&args);
+}
+
+/* Returns the newest other SA on standby for the tunnel and peer of outbound
+ * SA 'active', or NULL if there is none. */
+static struct ike_sa *
+ike_sa_find_standby(const struct ike_sa *active)
+{
+    struct ike_sa *best = NULL;
+    struct ike_sa *ike;
+
+    HMAP_FOR_EACH (ike, node, &ike_sas) {
+        if (ike != active && !ike->inbound && ike->if_id == active->if_id
+            && ipv6_addr_equals(&ike->dst, &active->dst)
+            && (!best || ike->install_order > best->install_order)) {
+            best = ike;
+        }
+    }
+    return best;
+}
+
+static void
+ike_sa_delete(struct ike_sa *ike)
+{
+    const struct esp_sa_params *p = esp_sa_get_params(ike->sa);
+
+    /* An outbound SA that was replaced by a newer one is no longer in the
+     * SAD. */
+    if (esp_sad_lookup(p->sad_id, &p->dst) == ike->sa) {
+        struct ike_sa *standby;
+
+        standby = ike->inbound ? NULL : ike_sa_find_standby(ike);
+
+        if (standby) {
+            VLOG_INFO("outbound SA spi 0x%08"PRIx32" for if_id %"PRIu32" "
+                      "takes over from a deleted SA", ntohl(standby->spi),
+                      standby->if_id);
+            esp_sad_replace(ike->sa, standby->sa);
+        } else {
+            esp_sad_remove(ike->sa);
+        }
+    }
+    esp_sa_destroy_postponed(ike->sa);
+    hmap_remove(&ike_sas, &ike->node);
+    free(ike);
+}
+
+/* "esp/sa-del dir=in|out spi=SPI dst=IP" */
+static void
+esp_ctl_sa_del(struct unixctl_conn *conn, int argc, const char *argv[],
+               void *aux OVS_UNUSED)
+{
+    struct smap args = SMAP_INITIALIZER(&args);
+    struct in6_addr dst;
+    struct ike_sa *ike;
+    bool inbound;
+    char *error;
+    ovs_be32 spi;
+
+    error = parse_args(argc, argv, &args);
+    if (!error) {
+        error = parse_sa_id(&args, &inbound, &spi, &dst);
+    }
+    if (!error) {
+        ike = ike_sa_find(inbound, spi, &dst);
+        if (ike) {
+            VLOG_INFO("deleted %s SA spi 0x%08"PRIx32" for if_id %"PRIu32,
+                      inbound ? "inbound" : "outbound", ntohl(spi),
+                      ike->if_id);
+            ike_sa_delete(ike);
+        } else {
+            error = xstrdup("no such SA");
+        }
+    }
+
+    if (error) {
+        unixctl_command_reply_error(conn, error);
+        free(error);
+    } else {
+        unixctl_command_reply(conn, NULL);
+    }
+    smap_destroy(&args);
+}
+
+/* "esp/sa-query dir=in|out spi=SPI dst=IP": replies with the number of
+ * packets and bytes processed by the SA, "packets=N bytes=N". */
+static void
+esp_ctl_sa_query(struct unixctl_conn *conn, int argc, const char *argv[],
+                 void *aux OVS_UNUSED)
+{
+    struct smap args = SMAP_INITIALIZER(&args);
+    struct in6_addr dst;
+    struct ike_sa *ike;
+    bool inbound;
+    char *error;
+    ovs_be32 spi;
+
+    error = parse_args(argc, argv, &args);
+    if (!error) {
+        error = parse_sa_id(&args, &inbound, &spi, &dst);
+    }
+    if (!error) {
+        ike = ike_sa_find(inbound, spi, &dst);
+        if (ike) {
+            struct esp_sa_stats stats;
+            char *reply;
+
+            esp_sa_get_stats(ike->sa, &stats);
+            reply = xasprintf("packets=%"PRIu64" bytes=%"PRIu64,
+                              stats.n_packets, stats.n_bytes);
+            unixctl_command_reply(conn, reply);
+            free(reply);
+        } else {
+            error = xstrdup("no such SA");
+        }
+    }
+
+    if (error) {
+        unixctl_command_reply_error(conn, error);
+        free(error);
+    }
+    smap_destroy(&args);
+}
+
+/* "esp/sa-flush": deletes all the SAs installed through "esp/sa-add". */
+static void
+esp_ctl_sa_flush(struct unixctl_conn *conn, int argc OVS_UNUSED,
+                 const char *argv[] OVS_UNUSED, void *aux OVS_UNUSED)
+{
+    struct ike_sa *ike;
+
+    HMAP_FOR_EACH_SAFE (ike, node, &ike_sas) {
+        ike_sa_delete(ike);
+    }
+    unixctl_command_reply(conn, NULL);
+}
+
+static int
+compare_ike_sas(const void *a_, const void *b_)
+{
+    const struct ike_sa *const *a = a_;
+    const struct ike_sa *const *b = b_;
+
+    if ((*a)->inbound != (*b)->inbound) {
+        return (*a)->inbound ? 1 : -1;
+    }
+    return ntohl((*a)->spi) < ntohl((*b)->spi) ? -1
+           : ntohl((*a)->spi) > ntohl((*b)->spi);
+}
+
+/* Appends to 'ds' the SAs installed for interface ID 'if_id', outbound ones
+ * first, by SPI.  An outbound SA that was replaced by a newer one is marked
+ * "standby". */
+void
+esp_ctl_format(struct ds *ds, uint32_t if_id)
+{
+    struct ike_sa **sas = xmalloc(hmap_count(&ike_sas) * sizeof *sas);
+    struct ike_sa *ike;
+    size_t n = 0;
+
+    HMAP_FOR_EACH (ike, node, &ike_sas) {
+        if (ike->if_id == if_id) {
+            sas[n++] = ike;
+        }
+    }
+    qsort(sas, n, sizeof *sas, compare_ike_sas);
+
+    for (size_t i = 0; i < n; i++) {
+        const struct esp_sa_params *p = esp_sa_get_params(sas[i]->sa);
+        bool active = esp_sad_lookup(p->sad_id, &p->dst) == sas[i]->sa;
+
+        esp_sa_format(ds, (sas[i]->inbound ? "inbound "
+                           : active ? "outbound" : "outbound (standby)"),
+                      sas[i]->sa, sas[i]->inbound);
+    }
+    free(sas);
+}
+
+void
+esp_ctl_init(void)
+{
+    unixctl_command_register("esp/spi-alloc", "", 0, 0,
+                             esp_ctl_spi_alloc, NULL);
+    unixctl_command_register_sensitive(
+        "esp/sa-add", "dir=in|out spi=SPI src=IP dst=IP if_id=ID key=KEY "
+        "[esn=yes] [replay=N] [encap=yes]", 6, 9, esp_ctl_sa_add, NULL);
+    unixctl_command_register("esp/sa-del", "dir=in|out spi=SPI dst=IP",
+                             3, 3, esp_ctl_sa_del, NULL);
+    unixctl_command_register("esp/sa-query", "dir=in|out spi=SPI dst=IP",
+                             3, 3, esp_ctl_sa_query, NULL);
+    unixctl_command_register("esp/sa-flush", "", 0, 0,
+                             esp_ctl_sa_flush, NULL);
+}
diff --git a/lib/esp-ctl.h b/lib/esp-ctl.h
new file mode 100644
index 000000000..ff0afb9a7
--- /dev/null
+++ b/lib/esp-ctl.h
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2026 Thilak Raj Surendra Babu
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef ESP_CTL_H
+#define ESP_CTL_H 1
+
+/* Control interface for an IKE daemon, such as strongSwan with its
+ * "kernel-ovs" plugin, to install the security associations of 'esp'
+ * tunnels with "esp_keying=ike" through unixctl commands. */
+
+#include <stdint.h>
+
+struct ds;
+
+void esp_ctl_init(void);
+void esp_ctl_format(struct ds *, uint32_t if_id);
+
+#endif /* esp-ctl.h */
diff --git a/lib/esp.c b/lib/esp.c
index b1e85da79..62ff291b0 100644
--- a/lib/esp.c
+++ b/lib/esp.c
@@ -30,6 +30,7 @@
 #include "coverage.h"
 #include "entropy.h"
 #include "hash.h"
+#include "openvswitch/dynamic-string.h"
 #include "ovs-atomic.h"
 #include "ovs-rcu.h"
 #include "ovs-thread.h"
@@ -145,6 +146,8 @@ esp_sa_params_equal(const struct esp_sa_params *a,
 {
     return (a->spi == b->spi
             && ipv6_addr_equals(&a->dst, &b->dst)
+            && a->sad_id == b->sad_id
+            && a->tun_id == b->tun_id
             && a->key_len == b->key_len
             && !memcmp(a->key, b->key, a->key_len)
             && a->esn == b->esn
@@ -279,6 +282,37 @@ esp_sa_get_stats(const struct esp_sa *sa_, struct 
esp_sa_stats *stats)
     ovs_mutex_unlock(&sa->replay_mutex);
 }
 
+/* Appends to 'ds' a description of 'sa', labeled 'dir', and its
+ * statistics. */
+void
+esp_sa_format(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);
+    }
+}
+
 /* Returns the name of the cipher of 'sa', e.g. "aes128-gcm16". */
 const char *
 esp_sa_cipher_name(const struct esp_sa *sa)
@@ -318,10 +352,10 @@ esp_sad_insert(struct esp_sa *sa)
 {
     const struct esp_sa_params *p = &sa->params;
 
-    if (esp_sad_lookup(p->spi, &p->dst)) {
+    if (esp_sad_lookup(p->sad_id, &p->dst)) {
         return EEXIST;
     }
-    cmap_insert(&esp_sad, &sa->node, esp_sad_hash(p->spi, &p->dst));
+    cmap_insert(&esp_sad, &sa->node, esp_sad_hash(p->sad_id, &p->dst));
     return 0;
 }
 
@@ -333,10 +367,10 @@ esp_sad_replace(struct esp_sa *old, struct esp_sa *new)
 {
     const struct esp_sa_params *p = &new->params;
 
-    ovs_assert(old->params.spi == p->spi
+    ovs_assert(old->params.sad_id == p->sad_id
                && ipv6_addr_equals(&old->params.dst, &p->dst));
     cmap_replace(&esp_sad, &old->node, &new->node,
-                 esp_sad_hash(p->spi, &p->dst));
+                 esp_sad_hash(p->sad_id, &p->dst));
 }
 
 void
@@ -344,18 +378,20 @@ esp_sad_remove(struct esp_sa *sa)
 {
     const struct esp_sa_params *p = &sa->params;
 
-    cmap_remove(&esp_sad, &sa->node, esp_sad_hash(p->spi, &p->dst));
+    cmap_remove(&esp_sad, &sa->node, esp_sad_hash(p->sad_id, &p->dst));
 }
 
-/* Returns the SA with 'spi' and 'dst' from the SAD, or NULL.  'dst' is the
- * peer's address for an outbound SA, all-zeros for an inbound SA. */
+/* Returns the SA with 'sad_id' and 'dst' from the SAD, or NULL.  'dst' is
+ * the peer's address for an outbound SA, all-zeros for an inbound SA.  See
+ * struct esp_sa_params for the meaning of 'sad_id'. */
 struct esp_sa *
-esp_sad_lookup(ovs_be32 spi, const struct in6_addr *dst)
+esp_sad_lookup(ovs_be32 sad_id, const struct in6_addr *dst)
 {
     struct esp_sa *sa;
 
-    CMAP_FOR_EACH_WITH_HASH (sa, node, esp_sad_hash(spi, dst), &esp_sad) {
-        if (sa->params.spi == spi && ipv6_addr_equals(&sa->params.dst, dst)) {
+    CMAP_FOR_EACH_WITH_HASH (sa, node, esp_sad_hash(sad_id, dst), &esp_sad) {
+        if (sa->params.sad_id == sad_id
+            && ipv6_addr_equals(&sa->params.dst, dst)) {
             return sa;
         }
     }
@@ -676,14 +712,14 @@ esp_seal(struct esp_sa *sa OVS_UNUSED_WITHOUT_OPENSSL,
  * using the inbound SA for its SPI, and checks it against the SA's
  * anti-replay window.
  *
- * If successful, stores the SPI in '*spi', the protocol of the payload in
- * '*next_hdr' and its length in '*payload_len', and returns 0.  The payload
- * starts ESP_PREFIX_LEN bytes after 'esp'.  Otherwise, returns a positive
- * errno value. */
+ * If successful, stores the SA's tunnel ID in '*tun_id', the protocol of the
+ * payload in '*next_hdr' and its length in '*payload_len', and returns 0.  The
+ * payload starts ESP_PREFIX_LEN bytes after 'esp'.  Otherwise, returns a
+ * positive errno value. */
 int
 esp_open(struct esp_header *esp OVS_UNUSED_WITHOUT_OPENSSL,
          size_t len OVS_UNUSED_WITHOUT_OPENSSL,
-         ovs_be32 *spi OVS_UNUSED_WITHOUT_OPENSSL,
+         ovs_be64 *tun_id OVS_UNUSED_WITHOUT_OPENSSL,
          uint8_t *next_hdr OVS_UNUSED_WITHOUT_OPENSSL,
          size_t *payload_len OVS_UNUSED_WITHOUT_OPENSSL)
 {
@@ -708,8 +744,7 @@ esp_open(struct esp_header *esp OVS_UNUSED_WITHOUT_OPENSSL,
     }
     ct_len = len - ESP_PREFIX_LEN - ESP_ICV_LEN;
 
-    *spi = get_16aligned_be32(&esp->spi);
-    sa = esp_sad_lookup(*spi, &in6addr_any);
+    sa = esp_sad_lookup(get_16aligned_be32(&esp->spi), &in6addr_any);
     if (OVS_UNLIKELY(!sa)) {
         COVERAGE_INC(esp_rx_no_sa);
         return ENOENT;
@@ -758,6 +793,7 @@ esp_open(struct esp_header *esp OVS_UNUSED_WITHOUT_OPENSSL,
         }
     }
 
+    *tun_id = sa->params.tun_id;
     *next_hdr = trailer[1];
     *payload_len = ct_len - pad_len - ESP_TRAILER_LEN;
     esp_stat_inc(&sa->n_packets, 1);
diff --git a/lib/esp.h b/lib/esp.h
index 8a6362b7a..8e3658e6f 100644
--- a/lib/esp.h
+++ b/lib/esp.h
@@ -62,8 +62,19 @@
 #define ESP_MAX_REPLAY_WINDOW     4096
 
 struct esp_sa_params {
-    ovs_be32 spi;
+    ovs_be32 spi;                   /* SPI in the packets. */
     struct in6_addr dst;            /* Outbound: peer address.  Inbound: 0. */
+
+    /* An SA is found in the SAD by 'sad_id' and 'dst'.  For an inbound SA,
+     * 'sad_id' is the SPI.  For an outbound SA, it is the SPI in the tunnel
+     * header that the datapath pushes: the SPI itself for a manually keyed
+     * tunnel, or a stable ID for a tunnel whose SAs are rekeyed by IKE, so
+     * that a new SA can replace the old one without changing datapath flows.
+     *
+     * Received packets get 'tun_id' as their tunnel ID. */
+    ovs_be32 sad_id;
+    ovs_be64 tun_id;
+
     uint8_t key[ESP_MAX_KEY_LEN];   /* AES key followed by the salt. */
     uint8_t key_len;                /* Including the salt. */
     bool esn;                       /* Extended (64-bit) Sequence Numbers. */
@@ -97,15 +108,19 @@ struct esp_sa_stats {
 };
 void esp_sa_get_stats(const struct esp_sa *, struct esp_sa_stats *);
 
+struct ds;
+void esp_sa_format(struct ds *, const char *dir, const struct esp_sa *,
+                   bool inbound);
+
 int esp_sad_insert(struct esp_sa *);
 void esp_sad_replace(struct esp_sa *old, struct esp_sa *new);
 void esp_sad_remove(struct esp_sa *);
-struct esp_sa *esp_sad_lookup(ovs_be32 spi, const struct in6_addr *dst);
+struct esp_sa *esp_sad_lookup(ovs_be32 sad_id, const struct in6_addr *dst);
 
 size_t esp_trailer_len(size_t payload_len);
 int esp_seal(struct esp_sa *, struct esp_header *, size_t payload_len,
              uint8_t next_hdr);
-int esp_open(struct esp_header *, size_t len, ovs_be32 *spi,
+int esp_open(struct esp_header *, size_t len, ovs_be64 *tun_id,
              uint8_t *next_hdr, size_t *payload_len);
 
 /* For testing only. */
diff --git a/lib/netdev-native-tnl.c b/lib/netdev-native-tnl.c
index 0579a069f..756438823 100644
--- a/lib/netdev-native-tnl.c
+++ b/lib/netdev-native-tnl.c
@@ -1139,8 +1139,9 @@ netdev_esp_build_header(const struct netdev *netdev,
         esp = netdev_tnl_ip_build_header(data, params, IPPROTO_ESP, 0);
     }
 
-    /* netdev_esp_push_header() finds the outbound SA from this SPI and the
-     * destination address, and fills in the rest of the ESP header. */
+    /* netdev_esp_push_header() finds the outbound SA from this SPI, which is
+     * the SA's 'sad_id', and the destination address.  esp_seal() then
+     * writes the SA's actual SPI and the rest of the ESP header. */
     put_16aligned_be32(&esp->spi, tnl_cfg->esp_out_spi);
     put_16aligned_be32(&esp->seq_no, 0);
     memset(esp + 1, 0, ESP_IV_LEN);
@@ -1185,7 +1186,7 @@ netdev_esp_push_header(const struct netdev *netdev,
                                    &ip_tot_size, 0);
 
     /* 'netdev' is the datapath port shared by all esp tunnels, so find the
-     * outbound SA from the SPI and destination in the header. */
+     * outbound SA from the SPI ('sad_id') and destination in the header. */
     if (netdev_tnl_is_header_ipv6(data->header)) {
         const struct ovs_16aligned_ip6_hdr *ip6
             = netdev_tnl_ipv6_hdr(CONST_CAST(void *, data->header));
@@ -1235,7 +1236,7 @@ netdev_esp_pop_header(struct dp_packet *packet)
     unsigned int hlen;
     uint8_t next_hdr;
     uint8_t nw_proto;
-    ovs_be32 spi;
+    ovs_be64 tun_id;
     void *l4;
 
     pkt_metadata_init_tnl(md);
@@ -1284,7 +1285,7 @@ netdev_esp_pop_header(struct dp_packet *packet)
         esp = l4;
     }
 
-    if (esp_open(esp, esp_len, &spi, &next_hdr, &payload_len)) {
+    if (esp_open(esp, esp_len, &tun_id, &next_hdr, &payload_len)) {
         goto err;
     }
 
@@ -1298,7 +1299,7 @@ netdev_esp_pop_header(struct dp_packet *packet)
         goto err;
     }
 
-    tnl->tun_id = htonll(ntohl(spi));
+    tnl->tun_id = tun_id;
     tnl->flags |= FLOW_TNL_F_KEY;
 
     dp_packet_reset_packet(packet, hlen + ESP_PREFIX_LEN);
diff --git a/lib/netdev-vport.c b/lib/netdev-vport.c
index 6407b07e4..559b99c06 100644
--- a/lib/netdev-vport.c
+++ b/lib/netdev-vport.c
@@ -33,6 +33,7 @@
 #include "dirs.h"
 #include "dpif.h"
 #include "esp.h"
+#include "esp-ctl.h"
 #include "netdev.h"
 #include "netdev-native-tnl.h"
 #include "netdev-provider.h"
@@ -187,6 +188,8 @@ netdev_vport_route_changed(void)
     free(vports);
 }
 
+static void esp_tunnel_remove_sas(struct netdev_vport *);
+
 static struct netdev *
 netdev_vport_alloc(void)
 {
@@ -252,14 +255,7 @@ netdev_vport_destruct(struct netdev *netdev_)
         update_vxlan_global_cfg(netdev_, tnl_cfg, NULL);
     }
 
-    if (netdev->esp_in_sa) {
-        esp_sad_remove(netdev->esp_in_sa);
-        esp_sa_destroy_postponed(netdev->esp_in_sa);
-    }
-    if (netdev->esp_out_sa) {
-        esp_sad_remove(netdev->esp_out_sa);
-        esp_sa_destroy_postponed(netdev->esp_out_sa);
-    }
+    esp_tunnel_remove_sas(netdev);
 
     ovsrcu_set(&netdev->tnl_cfg, NULL);
     ovsrcu_postpone(free, CONST_CAST(struct netdev_tunnel_config *, tnl_cfg));
@@ -643,24 +639,64 @@ is_esp_option(const char *key)
     return (!strcmp(key, "esp_in_spi") || !strcmp(key, "esp_out_spi")
             || !strcmp(key, "esp_in_key") || !strcmp(key, "esp_out_key")
             || !strcmp(key, "esp_esn") || !strcmp(key, "esp_replay_window")
-            || !strcmp(key, "esp_udp_encap") || !strcmp(key, "esp_udp_port"));
+            || !strcmp(key, "esp_udp_encap") || !strcmp(key, "esp_udp_port")
+            || !strcmp(key, "esp_keying") || !strcmp(key, "esp_if_id"));
 }
 
-/* Parses the ESP options in 'args' for ESP tunnel 'dev', checks them against
- * 'tnl_cfg' and completes 'tnl_cfg'.  Creates in '*new_in' and '*new_out' the
- * security associations whose parameters changed, or sets them to NULL.
- *
- * Returns 0 if successful, otherwise appends an error to 'errors' and
- * returns a positive errno value. */
+/* Completes 'tnl_cfg' for an esp tunnel whose security associations are
+ * installed by an IKE daemon through the "esp/sa-add" command, which binds
+ * them to the tunnel through 'esp_if_id'. */
 static int
-esp_tunnel_config(struct netdev_vport *dev, const struct smap *args,
-                  struct netdev_tunnel_config *tnl_cfg,
-                  struct esp_sa **new_in, struct esp_sa **new_out,
-                  struct ds *errors)
+esp_tunnel_config_ike(struct netdev_vport *dev, const struct smap *args,
+                      struct netdev_tunnel_config *tnl_cfg, struct ds *errors)
+{
+    static const char *manual_options[] = {
+        "esp_in_spi", "esp_out_spi", "esp_in_key", "esp_out_key",
+        "esp_esn", "esp_replay_window",
+    };
+    const char *name = netdev_get_name(&dev->up);
+    unsigned long long int if_id;
+    const char *s;
+    char *tail;
+
+    for (size_t i = 0; i < ARRAY_SIZE(manual_options); i++) {
+        if (smap_get(args, manual_options[i])) {
+            ds_put_format(errors, "%s: '%s' is negotiated by IKE with "
+                          "'esp_keying=ike'\n", name, manual_options[i]);
+            return EINVAL;
+        }
+    }
+
+    s = smap_get(args, "esp_if_id");
+    errno = 0;
+    if_id = s ? strtoull(s, &tail, 0) : 0;
+    if (!s || errno || *tail || !if_id || if_id > UINT32_MAX) {
+        ds_put_format(errors, "%s: 'esp_keying=ike' requires 'esp_if_id' "
+                      "between 1 and 4294967295\n", name);
+        return EINVAL;
+    }
+
+    tnl_cfg->esp_ike = true;
+    tnl_cfg->esp_if_id = if_id;
+    tnl_cfg->esp_out_spi = htonl(if_id);
+
+    /* Received packets carry the interface ID as their tunnel ID, which
+     * stays the same when the SAs are rekeyed. */
+    tnl_cfg->in_key = tnl_cfg->out_key = htonll(if_id);
+    tnl_cfg->in_key_present = tnl_cfg->out_key_present = true;
+
+    return 0;
+}
+
+static int
+esp_tunnel_config__(struct netdev_vport *dev, const struct smap *args,
+                    struct netdev_tunnel_config *tnl_cfg,
+                    struct esp_sa_params *in, struct esp_sa_params *out,
+                    struct esp_sa **new_in, struct esp_sa **new_out,
+                    struct ds *errors)
 {
     const char *name = netdev_get_name(&dev->up);
     struct esp_sa *cur_in, *cur_out, *other;
-    struct esp_sa_params in, out;
     unsigned int replay_window;
     const char *s;
     char *error;
@@ -685,104 +721,113 @@ esp_tunnel_config(struct netdev_vport *dev, const 
struct smap *args,
         return EINVAL;
     }
 
-    memset(&in, 0, sizeof in);
-    memset(&out, 0, sizeof out);
+    if (smap_get_bool(args, "esp_udp_encap", false)) {
+        unsigned int port = smap_get_uint(args, "esp_udp_port",
+                                          ESP_UDP_ENCAP_PORT);
+
+        if (!port || port > UINT16_MAX) {
+            ds_put_format(errors, "%s: 'esp_udp_port' must be between 1 "
+                          "and 65535\n", name);
+            return EINVAL;
+        }
+        tnl_cfg->dst_port = htons(port);
+    }
+
+    s = smap_get_def(args, "esp_keying", "manual");
+    if (!strcmp(s, "ike")) {
+        return esp_tunnel_config_ike(dev, args, tnl_cfg, errors);
+    } else if (strcmp(s, "manual")) {
+        ds_put_format(errors, "%s: 'esp_keying' must be 'manual' or 'ike'\n",
+                      name);
+        return EINVAL;
+    }
 
     s = smap_get(args, "esp_in_spi");
-    if (!s || !parse_esp_spi(s, &in.spi)) {
+    if (!s || !parse_esp_spi(s, &in->spi)) {
         ds_put_format(errors, "%s: esp tunnels require 'esp_in_spi' "
                       "between 256 and 4294967295\n", name);
         return EINVAL;
     }
     s = smap_get(args, "esp_out_spi");
-    if (!s || !parse_esp_spi(s, &out.spi)) {
+    if (!s || !parse_esp_spi(s, &out->spi)) {
         ds_put_format(errors, "%s: esp tunnels require 'esp_out_spi' "
                       "between 256 and 4294967295\n", name);
         return EINVAL;
     }
 
     s = smap_get(args, "esp_in_key");
-    error = s ? esp_parse_key(s, &in) : xstrdup("missing");
+    error = s ? esp_parse_key(s, in) : xstrdup("missing");
     if (error) {
         ds_put_format(errors, "%s: bad 'esp_in_key': %s\n", name, error);
         free(error);
         return EINVAL;
     }
     s = smap_get(args, "esp_out_key");
-    error = s ? esp_parse_key(s, &out) : xstrdup("missing");
+    error = s ? esp_parse_key(s, out) : xstrdup("missing");
     if (error) {
         ds_put_format(errors, "%s: bad 'esp_out_key': %s\n", name, error);
         free(error);
         return EINVAL;
     }
 
-    if (smap_get_bool(args, "esp_udp_encap", false)) {
-        unsigned int port = smap_get_uint(args, "esp_udp_port",
-                                          ESP_UDP_ENCAP_PORT);
-
-        if (!port || port > UINT16_MAX) {
-            ds_put_format(errors, "%s: 'esp_udp_port' must be between 1 "
-                          "and 65535\n", name);
-            return EINVAL;
-        }
-        tnl_cfg->dst_port = htons(port);
-    }
-
-    in.esn = out.esn = smap_get_bool(args, "esp_esn", false);
+    in->esn = out->esn = smap_get_bool(args, "esp_esn", false);
     replay_window = smap_get_uint(args, "esp_replay_window",
                                   ESP_DEFAULT_REPLAY_WINDOW);
     if (replay_window > ESP_MAX_REPLAY_WINDOW
-        || (in.esn && !replay_window)) {
+        || (in->esn && !replay_window)) {
         ds_put_format(errors, "%s: 'esp_replay_window' must be between "
-                      "%d and %d\n", name, in.esn ? 1 : 0,
+                      "%d and %d\n", name, in->esn ? 1 : 0,
                       ESP_MAX_REPLAY_WINDOW);
         return EINVAL;
     }
-    in.replay_window = replay_window;
+    in->replay_window = replay_window;
 
     /* An SA is identified by its SPI and, for outbound SAs, the peer's
      * address.  Two tunnels cannot share one. */
-    out.dst = tnl_cfg->ipv6_dst;
+    out->dst = tnl_cfg->ipv6_dst;
+    in->sad_id = in->spi;
+    in->tun_id = htonll(ntohl(in->spi));
+    out->sad_id = out->spi;
     cur_in = dev->esp_in_sa;
-    other = esp_sad_lookup(in.spi, &in.dst);
+    other = esp_sad_lookup(in->sad_id, &in->dst);
     if (other && other != cur_in) {
         ds_put_format(errors, "%s: 'esp_in_spi' %"PRIu32" is already used "
-                      "by another esp tunnel\n", name, ntohl(in.spi));
+                      "by another esp tunnel\n", name, ntohl(in->spi));
         return EEXIST;
     }
     cur_out = dev->esp_out_sa;
-    other = esp_sad_lookup(out.spi, &out.dst);
+    other = esp_sad_lookup(out->sad_id, &out->dst);
     if (other && other != cur_out) {
         ds_put_format(errors, "%s: 'esp_out_spi' %"PRIu32" is already used "
                       "by another esp tunnel to the same 'remote_ip'\n",
-                      name, ntohl(out.spi));
+                      name, ntohl(out->spi));
         return EEXIST;
     }
 
     /* Keep the SAs whose parameters do not change, so that their sequence
      * numbers and anti-replay windows carry on. */
-    if (!cur_in || !esp_sa_params_equal(esp_sa_get_params(cur_in), &in)) {
-        *new_in = esp_sa_create(&in);
+    if (!cur_in || !esp_sa_params_equal(esp_sa_get_params(cur_in), in)) {
+        *new_in = esp_sa_create(in);
         if (!*new_in) {
             goto create_error;
         }
     }
-    if (!cur_out || !esp_sa_params_equal(esp_sa_get_params(cur_out), &out)) {
-        *new_out = esp_sa_create(&out);
+    if (!cur_out || !esp_sa_params_equal(esp_sa_get_params(cur_out), out)) {
+        *new_out = esp_sa_create(out);
         if (!*new_out) {
             goto create_error;
         }
     }
 
-    tnl_cfg->esp_in_spi = in.spi;
-    tnl_cfg->esp_out_spi = out.spi;
-    tnl_cfg->esp_esn = in.esn;
-    tnl_cfg->esp_replay_window = in.replay_window;
+    tnl_cfg->esp_in_spi = in->spi;
+    tnl_cfg->esp_out_spi = out->spi;
+    tnl_cfg->esp_esn = in->esn;
+    tnl_cfg->esp_replay_window = in->replay_window;
 
     /* Received packets carry the inbound SPI as their tunnel ID. */
-    tnl_cfg->in_key = htonll(ntohl(in.spi));
+    tnl_cfg->in_key = htonll(ntohl(in->spi));
     tnl_cfg->in_key_present = true;
-    tnl_cfg->out_key = htonll(ntohl(out.spi));
+    tnl_cfg->out_key = htonll(ntohl(out->spi));
     tnl_cfg->out_key_present = true;
 
     return 0;
@@ -795,6 +840,30 @@ create_error:
     return EINVAL;
 }
 
+/* Parses the ESP options in 'args' for ESP tunnel 'dev', checks them against
+ * 'tnl_cfg' and completes 'tnl_cfg'.  Creates in '*new_in' and '*new_out' the
+ * security associations whose parameters changed, or sets them to NULL.
+ *
+ * Returns 0 if successful, otherwise appends an error to 'errors' and
+ * returns a positive errno value. */
+static int
+esp_tunnel_config(struct netdev_vport *dev, const struct smap *args,
+                  struct netdev_tunnel_config *tnl_cfg,
+                  struct esp_sa **new_in, struct esp_sa **new_out,
+                  struct ds *errors)
+{
+    struct esp_sa_params in, out;
+    int error;
+
+    memset(&in, 0, sizeof in);
+    memset(&out, 0, sizeof out);
+    error = esp_tunnel_config__(dev, args, tnl_cfg, &in, &out, new_in, new_out,
+                                errors);
+    ovs_memwipe(&in, sizeof in);
+    ovs_memwipe(&out, sizeof out);
+    return error;
+}
+
 /* Replaces the SA in '*cur', if any, by 'new', if nonnull, in the SAD. */
 static void
 esp_tunnel_install_sa(struct esp_sa **cur, struct esp_sa *new)
@@ -807,7 +876,7 @@ esp_tunnel_install_sa(struct esp_sa **cur, struct esp_sa 
*new)
     }
 
     new_p = esp_sa_get_params(new);
-    if (old && esp_sa_get_params(old)->spi == new_p->spi
+    if (old && esp_sa_get_params(old)->sad_id == new_p->sad_id
         && ipv6_addr_equals(&esp_sa_get_params(old)->dst, &new_p->dst)) {
         esp_sad_replace(old, new);
     } else {
@@ -820,35 +889,85 @@ esp_tunnel_install_sa(struct esp_sa **cur, struct esp_sa 
*new)
     esp_sa_destroy_postponed(old);
 }
 
+/* Removes the manually keyed SAs of 'dev', if any. */
 static void
-esp_show_sa(struct ds *ds, const char *dir, const struct esp_sa *sa,
-            bool inbound)
+esp_tunnel_remove_sas(struct netdev_vport *dev)
 {
-    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);
+    struct esp_sa **sas[] = { &dev->esp_in_sa, &dev->esp_out_sa };
+
+    for (size_t i = 0; i < ARRAY_SIZE(sas); i++) {
+        if (*sas[i]) {
+            esp_sad_remove(*sas[i]);
+            esp_sa_destroy_postponed(*sas[i]);
+            *sas[i] = NULL;
         }
-        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);
     }
 }
 
+/* Checks a security association that an IKE daemon wants to install against
+ * the esp tunnels with interface ID 'if_id', since its packets could not flow
+ * unless it matches one of them.  'src' and 'dst' are the source and
+ * destination of its packets.  An SA for an interface ID that no tunnel has
+ * yet is fine.
+ *
+ * Returns NULL if successful, otherwise a malloc()'d error message. */
+char *
+netdev_vport_esp_check_sa(uint32_t if_id, bool inbound,
+                          const struct in6_addr *src,
+                          const struct in6_addr *dst, bool encap)
+{
+    const struct in6_addr *remote = inbound ? src : dst;
+    const struct in6_addr *local = inbound ? dst : src;
+    char *error = NULL;
+    bool match = false;
+    struct netdev **vports;
+    size_t n_vports;
+
+    vports = netdev_get_vports(&n_vports);
+    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]);
+        char *mismatch = NULL;
+
+        if (!match && !strcmp(netdev_get_type(vports[i]), "esp")) {
+            const struct netdev_tunnel_config *tnl_cfg;
+
+            ovs_mutex_lock(&dev->mutex);
+            tnl_cfg = vport_tunnel_config(dev);
+            if (tnl_cfg->esp_ike && tnl_cfg->esp_if_id == if_id) {
+                if (!ipv6_addr_equals(remote, &tnl_cfg->ipv6_dst)) {
+                    mismatch = xasprintf("%s: peer address does not match "
+                                         "'remote_ip'", name);
+                } else if (ipv6_addr_is_set(&tnl_cfg->ipv6_src)
+                           && !ipv6_addr_equals(local, &tnl_cfg->ipv6_src)) {
+                    mismatch = xasprintf("%s: local address does not match "
+                                         "'local_ip'", name);
+                } else if (encap != (tnl_cfg->dst_port != 0)) {
+                    mismatch = xasprintf("%s: 'encap' does not match "
+                                         "'esp_udp_encap'", name);
+                } else {
+                    match = true;
+                }
+            }
+            ovs_mutex_unlock(&dev->mutex);
+        }
+
+        if (mismatch && !error) {
+            error = mismatch;
+        } else {
+            free(mismatch);
+        }
+        netdev_close(vports[i]);
+    }
+    free(vports);
+
+    if (match) {
+        free(error);
+        error = NULL;
+    }
+    return error;
+}
+
 static int
 compare_netdev_names(const void *a_, const void *b_)
 {
@@ -879,15 +998,20 @@ netdev_vport_esp_show(struct unixctl_conn *conn, int argc,
         }
 
         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);
+        const struct netdev_tunnel_config *tnl_cfg = vport_tunnel_config(dev);
+        if (tnl_cfg->esp_ike) {
+            ds_put_format(&ds, "%s: remote ", name);
+            ipv6_format_mapped(&tnl_cfg->ipv6_dst, &ds);
+            ds_put_format(&ds, ", keyed by IKE, if_id %"PRIu32"\n",
+                          tnl_cfg->esp_if_id);
+            esp_ctl_format(&ds, tnl_cfg->esp_if_id);
+            found = true;
+        } else if (dev->esp_in_sa && dev->esp_out_sa) {
             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);
+            esp_sa_format(&ds, "outbound", dev->esp_out_sa, false);
+            esp_sa_format(&ds, "inbound ", dev->esp_in_sa, true);
             found = true;
         }
         ovs_mutex_unlock(&dev->mutex);
@@ -1242,6 +1366,9 @@ set_tunnel_config(struct netdev *dev_, const struct smap 
*args, char **errp)
 
     ovs_mutex_lock(&dev->mutex);
 
+    if (tnl_cfg.esp_ike) {
+        esp_tunnel_remove_sas(dev);
+    }
     esp_tunnel_install_sa(&dev->esp_in_sa, esp_in);
     esp_tunnel_install_sa(&dev->esp_out_sa, esp_out);
 
@@ -1299,7 +1426,17 @@ get_tunnel_config(const struct netdev *dev, struct smap 
*args)
         smap_add(args, "local_ip", "flow");
     }
 
-    if (!strcmp(type, "esp")) {
+    if (!strcmp(type, "esp") && tnl_cfg->esp_ike) {
+        smap_add(args, "esp_keying", "ike");
+        smap_add_format(args, "esp_if_id", "%"PRIu32, tnl_cfg->esp_if_id);
+        if (tnl_cfg->dst_port) {
+            smap_add(args, "esp_udp_encap", "true");
+            if (ntohs(tnl_cfg->dst_port) != ESP_UDP_ENCAP_PORT) {
+                smap_add_format(args, "esp_udp_port", "%"PRIu16,
+                                ntohs(tnl_cfg->dst_port));
+            }
+        }
+    } else if (!strcmp(type, "esp")) {
         /* The tunnel IDs are derived from the SPIs.  The ESP keys are secret,
          * so they are left out. */
         smap_add_format(args, "esp_in_spi", "%"PRIu32,
@@ -1729,6 +1866,7 @@ netdev_vport_tunnel_register(void)
                                  netdev_tnl_egress_port_range, NULL);
         unixctl_command_register("esp/show", "[port]", 0, 1,
                                  netdev_vport_esp_show, NULL);
+        esp_ctl_init();
 
         ovsthread_once_done(&once);
     }
diff --git a/lib/netdev-vport.h b/lib/netdev-vport.h
index 9441e7395..6266baf39 100644
--- a/lib/netdev-vport.h
+++ b/lib/netdev-vport.h
@@ -19,6 +19,7 @@
 
 #include <stdbool.h>
 #include <stddef.h>
+#include <stdint.h>
 #include "compiler.h"
 
 struct dpif_netlink_vport;
@@ -48,4 +49,10 @@ const char *netdev_vport_get_dpif_port(const struct netdev *,
                                        char namebuf[], size_t bufsize)
     OVS_WARN_UNUSED_RESULT;
 
+struct in6_addr;
+char *netdev_vport_esp_check_sa(uint32_t if_id, bool inbound,
+                                const struct in6_addr *src,
+                                const struct in6_addr *dst, bool encap)
+    OVS_WARN_UNUSED_RESULT;
+
 #endif /* netdev-vport.h */
diff --git a/lib/netdev.h b/lib/netdev.h
index 17e389027..97fc5901b 100644
--- a/lib/netdev.h
+++ b/lib/netdev.h
@@ -178,8 +178,12 @@ struct netdev_tunnel_config {
     enum netdev_srv6_flowlabel srv6_flowlabel;
 
     /* ESP.  The keys are only kept in the tunnel's security associations. */
+    bool esp_ike;               /* SAs are installed by an IKE daemon. */
+    uint32_t esp_if_id;         /* With 'esp_ike', binds SAs to the tunnel. */
     ovs_be32 esp_in_spi;
-    ovs_be32 esp_out_spi;
+    ovs_be32 esp_out_spi;       /* SPI, or 'esp_if_id' with 'esp_ike', in the
+                                 * pushed header: the outbound SA's
+                                 * 'sad_id'. */
     bool esp_esn;
     uint16_t esp_replay_window;
 };
diff --git a/lib/unixctl.c b/lib/unixctl.c
index d4bdbf097..482486763 100644
--- a/lib/unixctl.c
+++ b/lib/unixctl.c
@@ -437,6 +437,21 @@ process_command(struct unixctl_conn *conn, struct 
jsonrpc_msg *request)
                         command->aux);
         }
 
+        if (command->sensitive) {
+            /* Erase the copies of the secrets that this function can reach. */
+            for (i = 0; i < argv.n; i++) {
+                ovs_memwipe(argv.names[i], strlen(argv.names[i]));
+            }
+            for (i = 0; i < n; i++) {
+                const struct json *elem = json_array_at(params, i);
+
+                if (elem->type == JSON_STRING) {
+                    char *s = CONST_CAST(char *, json_string(elem));
+
+                    ovs_memwipe(s, strlen(s));
+                }
+            }
+        }
         svec_destroy(&argv);
     }
 
diff --git a/lib/util.c b/lib/util.c
index 06a692ea2..ddb348699 100644
--- a/lib/util.c
+++ b/lib/util.c
@@ -746,6 +746,18 @@ ovs_print_version(uint8_t min_ofp, uint8_t max_ofp)
     }
 }
 
+/* Overwrites the 'n' bytes at 'p' with zeros, in a way the compiler does not
+ * optimize away, to erase secrets such as keys. */
+void
+ovs_memwipe(void *p, size_t n)
+{
+    volatile uint8_t *v = p;
+
+    while (n--) {
+        *v++ = 0;
+    }
+}
+
 /* Writes the 'size' bytes in 'buf' to 'stream' as hex bytes arranged 16 per
  * line.  Numeric offsets are also included, starting at 'ofs' for the first
  * byte in 'buf'.  If 'ascii' is true then the corresponding ASCII characters
diff --git a/lib/util.h b/lib/util.h
index 03f0c151f..2f68e3dd2 100644
--- a/lib/util.h
+++ b/lib/util.h
@@ -221,6 +221,7 @@ void ovs_error_valist(int err_no, const char *format, 
va_list)
     OVS_PRINTF_FORMAT(2, 0);
 const char *ovs_retval_to_string(int);
 const char *ovs_strerror(int);
+void ovs_memwipe(void *, size_t);
 void ovs_hex_dump(FILE *, const void *, size_t, uintptr_t offset, bool ascii);
 
 bool str_to_int(const char *, int base, int *);
diff --git a/ofproto/ofproto-tnl-unixctl.man b/ofproto/ofproto-tnl-unixctl.man
index 9c8d15018..22f677d34 100644
--- a/ofproto/ofproto-tnl-unixctl.man
+++ b/ofproto/ofproto-tnl-unixctl.man
@@ -85,3 +85,23 @@ 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.
+.
+.IP "\fBesp/spi-alloc\fR"
+.IQ "\fBesp/sa-add dir=\fR{\fBin\fR|\fBout\fR} \fBspi=\fIspi\fR 
\fBsrc=\fIip\fR \fBdst=\fIip\fR \fBif_id=\fIid\fR \fBkey=\fIkey\fR 
[\fBesn=yes\fR] [\fBreplay=\fIn\fR] [\fBencap=yes\fR]"
+.IQ "\fBesp/sa-del dir=\fR{\fBin\fR|\fBout\fR} \fBspi=\fIspi\fR 
\fBdst=\fIip\fR"
+.IQ "\fBesp/sa-query dir=\fR{\fBin\fR|\fBout\fR} \fBspi=\fIspi\fR 
\fBdst=\fIip\fR"
+.IQ "\fBesp/sa-flush\fR"
+Interface for an IKE daemon, such as strongSwan with its
+\fBkernel-ovs\fR plugin, to manage the security associations of
+\fBesp\fR tunnels with \fBoptions:esp_keying=ike\fR.  \fBesp/spi-alloc\fR
+returns an unused SPI for an inbound security association.
+\fBesp/sa-add\fR installs a security association for the tunnel whose
+\fBoptions:esp_if_id\fR is \fIid\fR, with an AES-GCM \fIkey\fR followed
+by its 4-byte salt, in hexadecimal.  A new outbound security association
+for a tunnel replaces the previous one, which remains until it is
+deleted.  The command fails if \fIip\fR addresses or \fBencap\fR do not
+match the tunnel's \fBremote_ip\fR, \fBlocal_ip\fR and
+\fBesp_udp_encap\fR options, if it has an interface with that \fIid\fR.
+Its arguments are not logged.  \fBesp/sa-del\fR deletes a
+security association, \fBesp/sa-query\fR prints the numbers of packets
+and bytes it processed, and \fBesp/sa-flush\fR deletes all of them.
diff --git a/tests/test-esp.c b/tests/test-esp.c
index 92d5535d3..f7d2d0d82 100644
--- a/tests/test-esp.c
+++ b/tests/test-esp.c
@@ -52,6 +52,8 @@ make_params(uint32_t spi, const char *key, bool esn, uint16_t 
window)
 
     memset(&params, 0, sizeof params);
     params.spi = htonl(spi);
+    params.sad_id = params.spi;
+    params.tun_id = htonll(spi);
     params.esn = esn;
     params.replay_window = window;
     error = esp_parse_key(key, &params);
@@ -92,9 +94,9 @@ static int
 open_pkt(struct test_pkt *pkt, size_t *payload_len)
 {
     uint8_t next_hdr;
-    ovs_be32 spi;
+    ovs_be64 tun_id;
 
-    return esp_open(pkt_esp(pkt), pkt->len, &spi, &next_hdr, payload_len);
+    return esp_open(pkt_esp(pkt), pkt->len, &tun_id, &next_hdr, payload_len);
 }
 
 /* Seals a packet with sequence number 'seq' on 'out' and returns the result
@@ -141,8 +143,8 @@ test_known_answer(void)
     struct esp_sa *sa = esp_sa_create(&params);
     struct test_pkt pkt;
     uint8_t next_hdr;
+    ovs_be64 tun_id;
     size_t len;
-    ovs_be32 spi;
 
     ovs_assert(!esp_sad_insert(sa));
 
@@ -167,8 +169,8 @@ test_known_answer(void)
     ovs_assert(open_pkt(&pkt, &len) == EINVAL);
 
     from_hex(&pkt, kat_seq7);
-    ovs_assert(!esp_open(pkt_esp(&pkt), pkt.len, &spi, &next_hdr, &len));
-    ovs_assert(spi == htonl(0x1000));
+    ovs_assert(!esp_open(pkt_esp(&pkt), pkt.len, &tun_id, &next_hdr, &len));
+    ovs_assert(tun_id == htonll(0x1000));
     ovs_assert(next_hdr == 4);
     ovs_assert(len == strlen(TEST_PAYLOAD));
     ovs_assert(!memcmp(pkt.buf + ESP_PREFIX_LEN, TEST_PAYLOAD, len));
@@ -344,6 +346,34 @@ test_sad(void)
     esp_sad_remove(sa3);
     ovs_assert(!esp_sad_lookup(spi, &peer));
 
+    /* An SA rekeyed by IKE is looked up by a stable ID, but packets carry its
+     * SPI, and received packets get its tunnel ID. */
+    struct esp_sa_params p4 = make_params(0x5000, TEST_KEY, false, 64);
+    struct esp_sa_params p5 = make_params(0x5000, TEST_KEY, false, 64);
+    struct esp_sa *out, *in;
+    struct test_pkt pkt;
+    ovs_be64 tun_id;
+    uint8_t next_hdr;
+    size_t len;
+
+    p4.sad_id = htonl(7);
+    p4.dst = peer;
+    p5.tun_id = htonll(7);
+    out = esp_sa_create(&p4);
+    in = esp_sa_create(&p5);
+    ovs_assert(!esp_sad_insert(out));
+    ovs_assert(!esp_sad_insert(in));
+    ovs_assert(esp_sad_lookup(htonl(7), &peer) == out);
+    ovs_assert(!esp_sad_lookup(htonl(0x5000), &peer));
+    ovs_assert(!seal(out, &pkt, "x", 1));
+    ovs_assert(get_16aligned_be32(&pkt_esp(&pkt)->spi) == htonl(0x5000));
+    ovs_assert(!esp_open(pkt_esp(&pkt), pkt.len, &tun_id, &next_hdr, &len));
+    ovs_assert(tun_id == htonll(7));
+    esp_sad_remove(out);
+    esp_sad_remove(in);
+    esp_sa_destroy(out);
+    esp_sa_destroy(in);
+
     esp_sa_destroy(sa1);
     esp_sa_destroy(sa2);
     esp_sa_destroy(sa3);
diff --git a/tests/tunnel-push-pop.at b/tests/tunnel-push-pop.at
index a2cb54b4f..0568cc465 100644
--- a/tests/tunnel-push-pop.at
+++ b/tests/tunnel-push-pop.at
@@ -1840,8 +1840,8 @@ 
aa55aa550001aa55aa55000008004500009c000040004011339c010102580101025c119411940088
 
 dnl Decapsulation of a packet encrypted by an independent implementation.
 m4_define([ESP_IN_UDP], 
[aa55aa550000aa55aa55000108004500006800004000401133d00101025c01010258119411940054000000001000000000010102030405060708319779ecb7385859a48de241704608a2b76f467926ada1846103e4f0c011b294f83a80b9ba98e1cfbfd7b9421e33c5e1a7d8e84de7b0d2a39cfc4887])
-m4_define([IKE], 
[aa55aa550000aa55aa55000108004500003c00004000401133fc0101025c01010258119411940028000000000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b])
-m4_define([KEEPALIVE], 
[aa55aa550000aa55aa55000108004500001d000040004011341b0101025c010102581194119400090000ff])
+m4_define([NATT_IKE_PKT], 
[aa55aa550000aa55aa55000108004500003c00004000401133fc0101025c01010258119411940028000000000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b])
+m4_define([NATT_KEEPALIVE_PKT], 
[aa55aa550000aa55aa55000108004500001d000040004011341b0101025c010102581194119400090000ff])
 
 AT_CHECK([ovs-appctl netdev-dummy/receive p0 ESP_IN_UDP])
 OVS_WAIT_UNTIL([test $(ovs-pcap p1.pcap | wc -l) -ge 1])
@@ -1851,8 +1851,8 @@ AT_CHECK([ovs-pcap p1.pcap], [0], [dnl
 
 dnl IKE packets, which start with a zero non-ESP marker, and NAT keepalives
 dnl are not decrypted.
-AT_CHECK([ovs-appctl netdev-dummy/receive p0 IKE])
-AT_CHECK([ovs-appctl netdev-dummy/receive p0 KEEPALIVE])
+AT_CHECK([ovs-appctl netdev-dummy/receive p0 NATT_IKE_PKT])
+AT_CHECK([ovs-appctl netdev-dummy/receive p0 NATT_KEEPALIVE_PKT])
 OVS_WAIT_UNTIL([test $(ovs-appctl coverage/read-counter 
native_tnl_esp_natt_keepalive) = 1])
 AT_CHECK([ovs-appctl coverage/read-counter native_tnl_esp_natt_non_esp], [0], 
[1
 ])
@@ -1874,3 +1874,233 @@ esp_sys (X) ref_cnt=1
 dnl A datapath flow from the old port may be revalidated meanwhile.
 OVS_VSWITCHD_STOP(["/receive tunnel port not found/d"])
 AT_CLEANUP
+
+AT_SETUP([tunnel_push_pop - esp keyed by IKE])
+AT_KEYWORDS([ipsec])
+AT_SKIP_IF([test "$HAVE_OPENSSL" = no])
+
+m4_define([KEY_IN], [0x101112131415161718191a1b1c1d1e1fcafebabe])
+m4_define([KEY_OUT], [0x202122232425262728292a2b2c2d2e2fdeadbeef])
+
+OVS_VSWITCHD_START([add-port br0 p0 -- set Interface p0 type=dummy dnl
+                    ofport_request=1 other-config:hwaddr=aa:55:aa:55:00:00 dnl
+                    options:tx_pcap=p0.pcap])
+AT_CHECK([ovs-vsctl add-br int-br -- set bridge int-br datapath_type=dummy])
+AT_CHECK([ovs-vsctl add-port int-br t1 -- set Interface t1 type=esp dnl
+            ofport_request=2 options:remote_ip=1.1.2.92 dnl
+            options:esp_keying=ike options:esp_if_id=7 dnl
+            -- add-port int-br p1 -- set Interface p1 type=dummy dnl
+            ofport_request=3 options:tx_pcap=p1.pcap])
+AT_CHECK([ovs-appctl dpif/show | grep t1 | sed 's|2/[[0-9]]*|2/X|'], [0], [dnl
+    t1 2/X: (esp: esp_if_id=7, esp_keying=ike, remote_ip=1.1.2.92)
+])
+
+AT_CHECK([ovs-appctl netdev-dummy/ip4addr br0 1.1.2.88/24], [0], [OK
+])
+AT_CHECK([ovs-appctl tnl/neigh/set br0 1.1.2.92 aa:55:aa:55:00:01], [0], [OK
+])
+AT_CHECK([ovs-ofctl add-flow br0 action=normal])
+AT_CHECK([ovs-ofctl add-flow int-br in_port=t1,action=p1])
+AT_CHECK([ovs-ofctl add-flow int-br in_port=p1,action=t1])
+
+dnl What an IKE daemon does: allocate an SPI and install the SAs.
+AT_CHECK([ovs-appctl esp/spi-alloc | grep -c '^0xc[[0-9a-f]]\{7\}$'], [0], [1
+])
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1000 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x2000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT])
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1000 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN], [2], [], [dnl
+SA already exists
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+
+dnl The keys are not logged.
+AT_CHECK([grep -c 'esp/sa-add\[[<hidden>\]]' ovs-vswitchd.log], [0], [3
+])
+AT_CHECK([grep -c '101112131415\|202122232425' ovs-vswitchd.log], [1], [0
+])
+
+dnl Encapsulation.  The pushed header carries the interface ID, by which the
+dnl outbound SA is found, and the packets carry the SA's SPI.
+m4_define([INNER_FLOW], 
[eth(src=f8:bc:12:44:34:b6,dst=aa:55:aa:55:00:00),eth_type(0x0800),ipv4(src=10.0.0.1,dst=10.0.0.2,proto=17,tos=0,ttl=64,frag=no),udp(src=100,dst=200)])
+AT_CHECK([ovs-appctl ofproto/trace --names int-br dnl
+            in_port=p1,dl_src=f8:bc:12:44:34:b6,dl_dst=aa:55:aa:55:00:00,dnl
+udp,nw_src=10.0.0.1,nw_dst=10.0.0.2,nw_ttl=64,udp_src=100,udp_dst=200],
+         [0], [stdout])
+AT_CHECK([tail -1 stdout | grep -o 'esp(spi=[[^)]]*)'], [0], [dnl
+esp(spi=0x7)
+])
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 'INNER_FLOW'])
+OVS_WAIT_UNTIL([test $(ovs-pcap p0.pcap | wc -l) -ge 1])
+AT_CHECK([ovs-pcap p0.pcap | cut -c69-84], [0], [dnl
+0000200000000001
+])
+
+dnl Decapsulation of a packet encrypted by an independent implementation with
+dnl KEY_IN, SPI 0x1000 and sequence number 1.  Its tunnel ID is the interface
+dnl ID.
+m4_define([ESP_SEQ1], 
[aa55aa550000aa55aa55000108004500006000004000403233b70101025c0101025800001000000000010102030405060708319779ecb7385859a48de241704608a2b76f467926ada1846103e4f0c011b294f83a80b9ba98e1cfbfd7b9421e33c5e1a7d8e84de7b0d2a39cfc4887])
+AT_CHECK([ovs-appctl netdev-dummy/receive p0 ESP_SEQ1])
+OVS_WAIT_UNTIL([test $(ovs-pcap p1.pcap | wc -l) -ge 1])
+AT_CHECK([ovs-appctl dpctl/dump-flows | grep -c 'tun_id=0x7,'], [0], [1
+])
+
+AT_CHECK([ovs-appctl esp/sa-query dir=out spi=0x2000 dst=1.1.2.92], [0], [dnl
+packets=1 bytes=92
+])
+AT_CHECK([ovs-appctl esp/sa-query dir=in spi=0x1000 dst=1.1.2.88], [0], [dnl
+packets=1 bytes=40
+])
+
+dnl Rekeying.  The new outbound SA takes over without changing the datapath
+dnl flows, and the old one waits on standby until it is deleted.
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1001 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x3000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT])
+AT_CHECK([ovs-appctl esp/show t1 | grep spi], [0], [dnl
+  outbound (standby): spi 0x00002000, aes128-gcm16, esn off
+  outbound: spi 0x00003000, aes128-gcm16, esn off
+  inbound : spi 0x00001000, aes128-gcm16, esn off, replay window 64
+  inbound : spi 0x00001001, aes128-gcm16, esn off, replay window 64
+])
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 'INNER_FLOW'])
+OVS_WAIT_UNTIL([test $(ovs-pcap p0.pcap | wc -l) -ge 2])
+AT_CHECK([ovs-pcap p0.pcap | tail -1 | cut -c69-84], [0], [dnl
+0000300000000001
+])
+
+AT_CHECK([ovs-appctl esp/sa-del dir=out spi=0x2000 dst=1.1.2.92])
+AT_CHECK([ovs-appctl esp/sa-del dir=in spi=0x1000 dst=1.1.2.88])
+AT_CHECK([ovs-appctl esp/sa-del dir=in spi=0x1000 dst=1.1.2.88], [2], [], [dnl
+no such SA
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 'INNER_FLOW'])
+OVS_WAIT_UNTIL([test $(ovs-pcap p0.pcap | wc -l) -ge 3])
+AT_CHECK([ovs-pcap p0.pcap | tail -1 | cut -c69-84], [0], [dnl
+0000300000000002
+])
+
+dnl A packet for the deleted inbound SA is dropped.
+AT_CHECK([ovs-appctl netdev-dummy/receive p0 ESP_SEQ1])
+OVS_WAIT_UNTIL([test $(ovs-appctl coverage/read-counter esp_rx_no_sa) -ge 1])
+
+dnl Deleting the active outbound SA makes the newest SA on standby active, not
+dnl the first installed.
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x4000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x5000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT])
+AT_CHECK([ovs-appctl esp/sa-del dir=out spi=0x5000 dst=1.1.2.92])
+AT_CHECK([ovs-appctl esp/show t1 | grep outbound], [0], [dnl
+  outbound (standby): spi 0x00003000, aes128-gcm16, esn off
+  outbound: spi 0x00004000, aes128-gcm16, esn off
+])
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 'INNER_FLOW'])
+OVS_WAIT_UNTIL([test $(ovs-pcap p0.pcap | wc -l) -ge 4])
+AT_CHECK([ovs-pcap p0.pcap | tail -1 | cut -c69-84], [0], [dnl
+0000400000000001
+])
+AT_CHECK([ovs-appctl esp/sa-del dir=out spi=0x4000 dst=1.1.2.92])
+AT_CHECK([ovs-appctl esp/show t1 | grep outbound], [0], [dnl
+  outbound: spi 0x00003000, aes128-gcm16, esn off
+])
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 'INNER_FLOW'])
+OVS_WAIT_UNTIL([test $(ovs-pcap p0.pcap | wc -l) -ge 5])
+AT_CHECK([ovs-pcap p0.pcap | tail -1 | cut -c69-84], [0], [dnl
+0000300000000003
+])
+
+dnl A replay window that does not fit in the SA is an error, not truncated.
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1002 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN replay=65536], [2], [], [dnl
+'replay' must be between 0 and 4096
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+
+dnl An SA that does not match its tunnel is an error: its packets could not
+dnl flow.
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.88 dnl
+            dst=1.1.2.93 if_id=7 key=KEY_OUT], [2], [], [dnl
+t1: peer address does not match 'remote_ip'
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1003 src=1.1.2.93 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN], [2], [], [dnl
+t1: peer address does not match 'remote_ip'
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT encap=yes], [2], [], [dnl
+t1: 'encap' does not match 'esp_udp_encap'
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT encap=no])
+AT_CHECK([ovs-appctl esp/sa-del dir=out spi=0x7000 dst=1.1.2.92])
+AT_CHECK([ovs-vsctl set Interface t1 options:local_ip=1.1.2.88])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.89 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT], [2], [], [dnl
+t1: local address does not match 'local_ip'
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+AT_CHECK([ovs-vsctl remove Interface t1 options local_ip])
+dnl With a UDP-encapsulated tunnel, the SA must be encapsulated too.
+AT_CHECK([ovs-vsctl set Interface t1 options:esp_udp_encap=true])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT], [2], [], [dnl
+t1: 'encap' does not match 'esp_udp_encap'
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT encap=yes])
+AT_CHECK([ovs-appctl esp/sa-del dir=out spi=0x7000 dst=1.1.2.92])
+AT_CHECK([ovs-vsctl remove Interface t1 options esp_udp_encap])
+
+dnl 'esn' and 'encap' take "yes" or "no".
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1003 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN esn=yes])
+AT_CHECK([ovs-appctl esp/show t1 | grep -c '0x00001003.*esn on'], [0], [1
+])
+AT_CHECK([ovs-appctl esp/sa-del dir=in spi=0x1003 dst=1.1.2.88])
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1003 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN esn=no])
+AT_CHECK([ovs-appctl esp/show t1 | grep -c '0x00001003.*esn off'], [0], [1
+])
+AT_CHECK([ovs-appctl esp/sa-del dir=in spi=0x1003 dst=1.1.2.88])
+AT_CHECK([ovs-appctl esp/sa-add dir=in spi=0x1003 src=1.1.2.92 dnl
+            dst=1.1.2.88 if_id=7 key=KEY_IN esn=true], [2], [], [dnl
+'esn' and 'encap' must be 'yes' or 'no'
+ovs-appctl: ovs-vswitchd: server returned an error
+])
+
+dnl An SA for an interface ID that no tunnel has yet is accepted.
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x7000 src=1.1.2.88 dnl
+            dst=1.1.2.93 if_id=8 key=KEY_OUT])
+AT_CHECK([ovs-appctl esp/sa-del dir=out spi=0x7000 dst=1.1.2.93])
+
+dnl Flushing with an SA on standby.
+AT_CHECK([ovs-appctl esp/sa-add dir=out spi=0x6000 src=1.1.2.88 dnl
+            dst=1.1.2.92 if_id=7 key=KEY_OUT])
+AT_CHECK([ovs-appctl esp/sa-flush])
+AT_CHECK([ovs-appctl esp/show t1], [0], [dnl
+t1: remote 1.1.2.92, keyed by IKE, if_id 7
+])
+
+dnl Configuration errors.
+AT_CHECK([ovs-vsctl set Interface t1 options:esp_in_key=KEY_IN], [0], [],
+         [ignore])
+AT_CHECK([grep -q "t1: 'esp_in_key' is negotiated by IKE" ovs-vswitchd.log])
+AT_CHECK([ovs-vsctl remove Interface t1 options esp_in_key esp_if_id], [0], [],
+         [ignore])
+AT_CHECK([grep -q "t1: 'esp_keying=ike' requires 'esp_if_id'" dnl
+            ovs-vswitchd.log])
+
+OVS_VSWITCHD_STOP(["/is negotiated by IKE/d
+/requires 'esp_if_id'/d
+/could not set configuration/d
+/receive tunnel port not found/d"])
+AT_CLEANUP
diff --git a/vswitchd/vswitch.xml b/vswitchd/vswitch.xml
index 5dd30d690..f86fb7a6b 100644
--- a/vswitchd/vswitch.xml
+++ b/vswitchd/vswitch.xml
@@ -3390,6 +3390,46 @@
         that a restart does not reuse the GCM nonces.
       </p>
 
+      <column name="options" key="esp_keying"
+              type='{"type": "string", "enum": ["set", ["manual", "ike"]]}'>
+        <p>
+          Optional.  How the security associations of the tunnel are
+          configured:
+        </p>
+        <dl>
+          <dt><code>manual</code> (the default)</dt>
+          <dd>
+            With the <code>esp_in_*</code> and <code>esp_out_*</code> options
+            below.
+          </dd>
+          <dt><code>ike</code></dt>
+          <dd>
+            By an IKE daemon, such as strongSwan with its
+            <code>kernel-ovs</code> plugin, through the
+            <code>esp/sa-add</code> and related commands of
+            <code>ovs-vswitchd</code>(8).  The daemon negotiates the keys and
+            rekeys the security associations, which it binds to the tunnel
+            with <ref column="options" key="esp_if_id"/>.  The
+            <code>esp_in_*</code>, <code>esp_out_*</code>, <code>esp_esn</code>
+            and <code>esp_replay_window</code> options are not allowed.
+          </dd>
+        </dl>
+      </column>
+
+      <column name="options" key="esp_if_id"
+              type='{"type": "integer", "minInteger": 1,
+                     "maxInteger": 4294967295}'>
+        <p>
+          Required with <code>esp_keying=ike</code>.  The interface ID of the
+          IKE daemon's security associations for this tunnel, e.g.
+          <code>if_id_in</code> and <code>if_id_out</code> in strongSwan's
+          configuration.  Received packets have it in the
+          <code>tun_id</code> field, and datapath flows show it as the SPI
+          of the pushed ESP header, the SPI of the current security
+          association being written into each packet.
+        </p>
+      </column>
+
       <column name="options" key="esp_in_spi">
         <p>
           Required.  The Security Parameters Index (SPI) of received packets,
-- 
2.43.0

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

Reply via email to