On 8/19/26 3:40 PM, Rukomoinikova Aleksandra wrote:
> On 19.08.2026 14:34, Dumitru Ceara wrote:
>> On 7/20/26 6:59 PM, Alexandra Rukomoinikova via dev wrote:
>>> When a Logical Switch has dynamic-routing-vni and
>>> dynamic-routing-maintain-evpn=mvd set, ovn-controller automatically
>>> creates and manages the Linux kernel interfaces required for
>>> EVPN/BGP: a VRF (ovnvrf<vni>), a bridge (ovnbr<vni>), a VXLAN
>>> interface (ovnvxlan<vni>) and, when FDB redistribution is enabled,
>>> a dummy interface (ovnlo<vni>) used to advertise MACs.
>>>
>>> For L3 VNI support (IP redistribution), dynamic-routing-maintain-evpn-lrp
>>> must name a Logical Router Port; its MAC is applied to the bridge
>>> interface so BGP can use it as next-hop. Interfaces are recreated
>>> whenever a change (local IP, VXLAN port, has_addr or redistribute
>>> mode) can't be applied in place, and are torn down on shutdown or
>>> when the VNI is no longer configured.
>>>
>>> The low-level netlink calls to create/delete devices and set their
>>> master/MAC, previously duplicated between route-exchange and
>>> neighbor-exchange, are consolidated into a new
>>> controller/netlink-utils.c. evpn_local_ip_map also moves from
>>> controller/physical.c to lib/ovn-util.c so it can be shared with the
>>> new code.
>>>
>>> Add system-ovn.at coverage and document the new options in
>>> ovn-nb.xml
>>>
>>> Signed-off-by: Alexandra Rukomoinikova <[email protected]>
>>> ---
>> Hi Alexandra,
>>
>> Thanks for the new feature patch!  I didn't get a chance to review it
>> yet and unfortunately it will have to wait for 27.03 as it didn't get
>> any reviews before soft freeze.
> 
> HI!
> 

Hi Alexandra,

> I think it's great that you didn't get around to reviewing this yet; 
> I’ve reworked the patch a bit on my end, found some issues, and will 
> send a new version.
> 

OK.

>> On the other hand that gives us more time to think about the user facing
>> configuration.
>>
>> Some thoughts:
>> - dynamic-routing-maintain-evpn-lrp=LRP is actually used in
>> ovn-controller where we can only access SB data so Port_Bindings.  But,
>> at least theoretically, those can have multiple mac addresses.  Which
>> one should ovn-controller choose?  Is it maybe better to make the MAC
>> address a different EVPN LS configuration?
> Right now the logic picks the first MAC address, and yes, that does look 
> odd. But honestly, I like the idea of options for mac rather for router 
> port name even less. First, I consider the northbound database the 
> source of truth: if the port already has a MAC address, why would I have 
> to specify it a second time? Tying it to the port also seems more robust 
> — if I change the address there in NB, the controller picks it up on its 
> own, whereas with an option the CMS has to go and update it separately 
> as well. And "take the first MAC by default" seems to cover almost every 
> case. That said, I'm not sure where the right compromise is here — if 
> you think an explicit MAC option is better, that's fine, I can do that.

I think they're both equally ugly but the
dynamic-routing-maintain-evpn-lrp=LRP option is ambiguous.

I guess we can discuss some more on the new version of the patch, while
looking at the code.

>>
>> - what about SVD (single vxlan device) mode?  Can we find a reasonable
>> way of having ovn autoconfigure that too so we don't have this new
>> feature that only covers half of the deployment modes?  What would be
>> needed?  I guess we'd need VNI <-> VLAN mappings passed somehow.
>> Anything else?
> 
> It seems like that's all, and yes, I can finish the SVD part, I don't 
> see any problems with it.
> 
> I have two questions.
> 
>  1. Right now, anywhere in the controller code, any netlink error
>     triggers a recompute. That approach feels concerning to me: a
>     netlink error doesn't invalidate any engine input — the desired
>     state hasn't changed, so the recompute derives the same result and
>     re-issues the same calls. It's really a retry expressed as
>     invalidation. What do you think, is this worth doing something
>     about? What bothers me is how this looks in production: if the error
>     somehow isn't caused by OVN's own code but by an external factor, we
>     end up with a controller spinning at 100% CPU until someone fixes it
>     by hand.

That is a very good point.  We should fix that but the question is: how
do you differentiate between transient errors and other types of errors?
 For the transient ones we should probably try to recompute (maybe with
a backoff).

>  2. In the current patch, the VNI for the fake vxlan port is derived as
>     an offset: if the user specifies, say, 67, what's actually used is
>     60000 + 67. This limits the range of usable VNIs. What do you think
>     — leave it as is, or add a separate option after all? Though I don't
>     much like the idea of yet another option here either. or create an
>     option with some pool of free ports?
> 

This makes it so visible that we're relying on a (kind of) broken FRR
behavior, i.e., the fact that FRR ignores the vxlan interface configured
local port.  I vaguely remembered a discussion along the same lines
related to our system tests and VNI choice.  I don't think there's any
good way of doing this.

Probably the only reasonable way is to make FRR work with OVS vxlan
tunnels directly or make OVN capable of monitoring route/interface
tables from other network namespaces than the one it runs into.

Everything else will be less than ideal so I guess I'd just document how
it's derived as an offset for now.

Regards,
Dumitru

>>
>> Regards,
>> Dumitru
>>
>>
>>> v1 --> v2: minor cleanups, no logic changed, rebased
>>> ---
>>>   NEWS                                   |   7 +
>>>   controller/automake.mk                 |   2 +
>>>   controller/host-if-monitor.c           |  15 ++
>>>   controller/host-if-monitor.h           |   2 +-
>>>   controller/neighbor-exchange-netlink.c | 103 +++++++-
>>>   controller/neighbor-exchange-netlink.h |  11 +
>>>   controller/neighbor-exchange-stub.c    |  10 +
>>>   controller/neighbor-exchange.c         | 324 +++++++++++++++++++++++++
>>>   controller/neighbor-exchange.h         |   8 +
>>>   controller/neighbor.c                  | 182 +++++++++++---
>>>   controller/neighbor.h                  |  34 +++
>>>   controller/netlink-utils.c             | 148 +++++++++++
>>>   controller/netlink-utils.h             |  41 ++++
>>>   controller/ovn-controller.c            |  20 ++
>>>   controller/physical.c                  | 157 ------------
>>>   controller/route-exchange-netlink.c    |  45 +---
>>>   controller/route-exchange-netlink.h    |   5 -
>>>   controller/route-exchange.c            |   1 +
>>>   lib/ovn-util.c                         | 157 ++++++++++++
>>>   lib/ovn-util.h                         |  25 ++
>>>   northd/en-datapath-logical-switch.c    |  14 ++
>>>   ovn-nb.xml                             |  88 ++++++-
>>>   tests/automake.mk                      |   2 +
>>>   tests/system-ovn.at                    | 186 ++++++++++++++
>>>   24 files changed, 1337 insertions(+), 250 deletions(-)
>>>   create mode 100644 controller/netlink-utils.c
>>>   create mode 100644 controller/netlink-utils.h
>>>
>>> diff --git a/NEWS b/NEWS
>>> index 82bf1a272..43c3e5af1 100644
>>> --- a/NEWS
>>> +++ b/NEWS
>>> @@ -74,6 +74,13 @@ Post v26.03.0
>>>        (type 11) and Parameter Problem (type 12) - generated by an external
>>>        router are un-NATed correctly.  This makes Path MTU discovery and
>>>        traceroute work through stateless NAT.
>>> +   - Add other_config "dynamic-routing-maintain-evpn" option for Logical
>>> +     Switche. When set to "mvd", ovn-controller creates and maintains
>>> +     Linux kernel interfaces required for EVPN/VXLAN itself: VRF, bridge,
>>> +     VXLAN device per VNI, for FDB redistribution, a dummy interface.
>>> +     Other_config "dynamic-routing-maintain-evpn-lrp" option names a 
>>> Logical
>>> +     Router Port whose MAC applied to the bridge interface for router port
>>> +     MAC-IP redistribution support.
>>>   
>>>   OVN v26.03.0 - xxx xx xxxx
>>>   --------------------------
>>> diff --git a/controller/automake.mk b/controller/automake.mk
>>> index a779250cb..dfbb1d4ae 100644
>>> --- a/controller/automake.mk
>>> +++ b/controller/automake.mk
>>> @@ -73,6 +73,8 @@ controller_ovn_controller_SOURCES = \
>>>   
>>>   if HAVE_NETLINK
>>>   controller_ovn_controller_SOURCES += \
>>> +   controller/netlink-utils.h \
>>> +   controller/netlink-utils.c \
>>>     controller/host-if-monitor.c \
>>>     controller/ovn-netlink-notifier.c \
>>>     controller/neighbor-exchange-netlink.h \
>>> diff --git a/controller/host-if-monitor.c b/controller/host-if-monitor.c
>>> index 65110d2a3..bbd86aaa5 100644
>>> --- a/controller/host-if-monitor.c
>>> +++ b/controller/host-if-monitor.c
>>> @@ -65,6 +65,21 @@ host_if_monitor_run(void)
>>>       return monitor.changes_detected;
>>>   }
>>>   
>>> +void
>>> +host_if_monitor_invalidate(const char *if_name)
>>> +{
>>> +    if (!sset_contains(&monitor.watched_interfaces, if_name)) {
>>> +        return;
>>> +    }
>>> +
>>> +    int32_t ifindex = if_nametoindex(if_name);
>>> +    if (ifindex) {
>>> +        simap_put(&monitor.ifname_to_ifindex, if_name, ifindex);
>>> +    } else {
>>> +        simap_find_and_delete(&monitor.ifname_to_ifindex, if_name);
>>> +    }
>>> +}
>>> +
>>>   void
>>>   host_if_monitor_update_watches(const struct sset *if_names)
>>>   {
>>> diff --git a/controller/host-if-monitor.h b/controller/host-if-monitor.h
>>> index a41c5869c..f625d839c 100644
>>> --- a/controller/host-if-monitor.h
>>> +++ b/controller/host-if-monitor.h
>>> @@ -22,7 +22,7 @@
>>>   
>>>   void host_if_monitor_wait(void);
>>>   bool host_if_monitor_run(void);
>>> -
>>> +void host_if_monitor_invalidate(const char *if_name);
>>>   void host_if_monitor_update_watches(const struct sset *if_names);
>>>   
>>>   int32_t host_if_monitor_ifname_toindex(const char *if_name);
>>> diff --git a/controller/neighbor-exchange-netlink.c 
>>> b/controller/neighbor-exchange-netlink.c
>>> index f01bfda5b..8261f6cf6 100644
>>> --- a/controller/neighbor-exchange-netlink.c
>>> +++ b/controller/neighbor-exchange-netlink.c
>>> @@ -14,8 +14,11 @@
>>>    */
>>>   
>>>   #include <config.h>
>>> +#include <errno.h>
>>>   #include <stdbool.h>
>>> +
>>>   #include <linux/if_ether.h>
>>> +#include <linux/if_link.h>
>>>   #include <linux/rtnetlink.h>
>>>   
>>>   #include "hmapx.h"
>>> @@ -23,20 +26,13 @@
>>>   #include "lib/netlink-socket.h"
>>>   #include "lib/packets.h"
>>>   #include "openvswitch/vlog.h"
>>> +#include "netlink-utils.h"
>>>   
>>>   #include "neighbor-exchange-netlink.h"
>>>   #include "neighbor.h"
>>>   
>>>   VLOG_DEFINE_THIS_MODULE(neighbor_exchange_netlink);
>>>   
>>> -#define NETNL_REQ_BUFFER_SIZE 128
>>> -
>>> -/* NTF_EXT_LEARNED was introduced in Linux v3.19, define it if
>>> - * not available. */
>>> -#ifndef NTF_EXT_LEARNED
>>> -#define NTF_EXT_LEARNED (1 << 4)
>>> -#endif
>>> -
>>>   static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 20);
>>>   
>>>   /* Inspired from route_table_dump_one_table() in OVS. */
>>> @@ -535,3 +531,94 @@ ne_table_parse(struct ofpbuf *buf, void *change)
>>>       return ne_table_parse__(buf, NLMSG_HDRLEN + sizeof *nd,
>>>                               nlmsg, nd, change);
>>>   }
>>> +
>>> +int32_t
>>> +ne_nl_ifindex_get(const char *ifname)
>>> +{
>>> +    return nl_ifindex_get(ifname);
>>> +}
>>> +
>>> +int
>>> +ne_nl_create_bridge(const char *ifname)
>>> +{
>>> +    return nl_create_device(ifname, "bridge");
>>> +}
>>> +
>>> +int
>>> +ne_nl_create_lo(const char *ifname)
>>> +{
>>> +    return nl_create_device(ifname, "dummy");
>>> +}
>>> +
>>> +int
>>> +ne_nl_create_vxlan(const char *ifname, uint32_t vni,
>>> +                   const struct in6_addr *local_ip, uint16_t dst_port,
>>> +                   int32_t link_ifindex)
>>> +{
>>> +    int err = 0;
>>> +    struct ifinfomsg *ifinfo;
>>> +    struct ofpbuf request;
>>> +    ofpbuf_init(&request, 0);
>>> +
>>> +    nl_msg_put_nlmsghdr(&request, 0, RTM_NEWLINK,
>>> +                        NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | 
>>> NLM_F_EXCL);
>>> +    ifinfo = ofpbuf_put_zeros(&request, sizeof *ifinfo);
>>> +    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> +
>>> +    ifinfo->ifi_change = ifinfo->ifi_flags = IFF_UP;
>>> +    size_t linkinfo_off = nl_msg_start_nested(&request, IFLA_LINKINFO);
>>> +    nl_msg_put_string(&request, IFLA_INFO_KIND, "vxlan");
>>> +    size_t infodata_off = nl_msg_start_nested(&request, IFLA_INFO_DATA);
>>> +
>>> +    nl_msg_put_u32(&request, IFLA_VXLAN_ID, vni);
>>> +    nl_msg_put_u32(&request, IFLA_VXLAN_LINK, link_ifindex);
>>> +    nl_msg_put_u8(&request, IFLA_VXLAN_LEARNING, 0);
>>> +    nl_msg_put_be16(&request, IFLA_VXLAN_PORT, htons(dst_port));
>>> +
>>> +    if (IN6_IS_ADDR_V4MAPPED(local_ip)) {
>>> +        ovs_be32 ipv4 = in6_addr_get_mapped_ipv4(local_ip);
>>> +        nl_msg_put_be32(&request, IFLA_VXLAN_LOCAL, ipv4);
>>> +    } else {
>>> +        nl_msg_put_in6_addr(&request, IFLA_VXLAN_LOCAL6, local_ip);
>>> +    }
>>> +
>>> +    nl_msg_end_nested(&request, infodata_off);
>>> +    nl_msg_end_nested(&request, linkinfo_off);
>>> +
>>> +    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> +    ofpbuf_uninit(&request);
>>> +    return err;
>>> +}
>>> +
>>> +int
>>> +ne_nl_set_master(const char *slave, const char *master)
>>> +{
>>> +    return nl_set_master(slave, master);
>>> +}
>>> +
>>> +int
>>> +ne_nl_set_iface_mac_addr(const char *ifname, const struct eth_addr *mac)
>>> +{
>>> +    return nl_set_iface_mac(ifname, mac);
>>> +}
>>> +
>>> +int
>>> +ne_nl_delete_iface(const char *ifname)
>>> +{
>>> +    return nl_delete_device(ifname);
>>> +}
>>> +
>>> +int
>>> +ne_nl_create_vrf(const char *ifname, uint32_t table_id)
>>> +{
>>> +    if (TABLE_ID_VALID(table_id)) {
>>> +        return nl_create_vrf(ifname, table_id);
>>> +    }
>>> +
>>> +    VLOG_WARN_RL(&rl, "attempt to create VRF using invalid table id 
>>> %"PRIu32,
>>> +                 table_id);
>>> +
>>> +    return EINVAL;
>>> +}
>>> +
>>> +
>>> diff --git a/controller/neighbor-exchange-netlink.h 
>>> b/controller/neighbor-exchange-netlink.h
>>> index 659fe72d8..37badb226 100644
>>> --- a/controller/neighbor-exchange-netlink.h
>>> +++ b/controller/neighbor-exchange-netlink.h
>>> @@ -50,6 +50,17 @@ struct ne_table_msg {
>>>       struct ne_nl_received_neigh nd; /* Data parsed from this message. */
>>>   };
>>>   
>>> +int32_t ne_nl_ifindex_get(const char *ifname);
>>> +int ne_nl_create_vxlan(const char *ifname, uint32_t vni,
>>> +                       const struct in6_addr *local_ip, uint16_t dst_port,
>>> +                       int32_t link_ifindex);
>>> +int ne_nl_create_bridge(const char *ifname);
>>> +int ne_nl_create_lo(const char *ifname);
>>> +int ne_nl_create_vrf(const char *ifname, uint32_t table_id);
>>> +int ne_nl_set_master(const char *slave, const char *master);
>>> +int ne_nl_set_iface_mac_addr(const char *ifname, const struct eth_addr 
>>> *mac);
>>> +int ne_nl_delete_iface(const char *ifname);
>>> +
>>>   int ne_nl_sync_neigh(uint8_t family, int32_t if_index,
>>>                        const struct hmap *neighbors,
>>>                        struct vector *learned_neighbors);
>>> diff --git a/controller/neighbor-exchange-stub.c 
>>> b/controller/neighbor-exchange-stub.c
>>> index a1c89ed2b..5a0912b73 100644
>>> --- a/controller/neighbor-exchange-stub.c
>>> +++ b/controller/neighbor-exchange-stub.c
>>> @@ -45,3 +45,13 @@ void
>>>   evpn_static_entries_clear(struct hmap *static_entries OVS_UNUSED)
>>>   {
>>>   }
>>> +
>>> +void
>>> +neighbor_exchange_maintain_evpn_cleanup_all(void)
>>> +{
>>> +}
>>> +
>>> +void
>>> +neighbor_exchange_maintain_evpn_destroy(void)
>>> +{
>>> +}
>>> diff --git a/controller/neighbor-exchange.c b/controller/neighbor-exchange.c
>>> index 43626cc21..b1f6402a5 100644
>>> --- a/controller/neighbor-exchange.c
>>> +++ b/controller/neighbor-exchange.c
>>> @@ -15,12 +15,16 @@
>>>   
>>>   #include <config.h>
>>>   
>>> +#include <errno.h>
>>>   #include <linux/neighbour.h>
>>> +#include <net/if.h>
>>>   
>>>   #include "host-if-monitor.h"
>>> +#include "lib/sset.h"
>>>   #include "neighbor.h"
>>>   #include "neighbor-exchange.h"
>>>   #include "neighbor-exchange-netlink.h"
>>> +#include "route-exchange-netlink.h"
>>>   #include "openvswitch/poll-loop.h"
>>>   #include "openvswitch/vlog.h"
>>>   #include "ovn-util.h"
>>> @@ -47,6 +51,8 @@ static uint32_t evpn_static_entry_hash(const struct 
>>> eth_addr *mac,
>>>                                          const struct in6_addr *ip,
>>>                                          uint32_t vni, uint32_t nh_id);
>>>   
>>> +static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 20);
>>> +
>>>   /* Last neighbor_exchange netlink operation. */
>>>   static int neighbor_exchange_nl_status;
>>>   
>>> @@ -65,10 +71,328 @@ static int neighbor_exchange_nl_status;
>>>           }                                          \
>>>       } while (0)
>>>   
>>> +struct maintained_evpn_entry {
>>> +    struct hmap_node node;
>>> +    struct neighbor_ovn_maintain_entry entry;
>>> +};
>>> +
>>> +static struct hmap maintained_evpn_entries =
>>> +    HMAP_INITIALIZER(&maintained_evpn_entries);
>>> +
>>> +static struct maintained_evpn_entry *
>>> +maintained_evpn_entry_find(const struct hmap *entries, uint32_t vni)
>>> +{
>>> +    struct maintained_evpn_entry *me;
>>> +    HMAP_FOR_EACH_WITH_HASH (me, node, hash_int(vni, 0), entries) {
>>> +        if (me->entry.vni == vni) {
>>> +            return me;
>>> +        }
>>> +    }
>>> +
>>> +    return NULL;
>>> +}
>>> +
>>> +/* Deletes the interfaces for 'entry'. Returns true if all of them were
>>> + * deleted (or were already absent), false if any netlink call failed. */
>>> +static bool
>>> +evpn_delete_evpn_devices(struct neighbor_ovn_maintain_entry *entry)
>>> +{
>>> +    int error;
>>> +    bool ok = true;
>>> +
>>> +    error = ne_nl_delete_iface(entry->br_if_name);
>>> +    if (error && error != ENODEV) {
>>> +        VLOG_WARN_RL(&rl, "Unable to delete bridge interface %s: %s",
>>> +                     entry->br_if_name, ovs_strerror(error));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(error);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    error = ne_nl_delete_iface(entry->vxlan_if_name);
>>> +    if (error && error != ENODEV) {
>>> +        VLOG_WARN_RL(&rl, "Unable to delete VXLAN interface %s: %s",
>>> +                     entry->vxlan_if_name, ovs_strerror(error));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(error);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    error = ne_nl_delete_iface(entry->vrf_if_name);
>>> +    if (error && error != ENODEV) {
>>> +        VLOG_WARN_RL(&rl, "Unable to delete vrf interface %s: %s",
>>> +                     entry->vrf_if_name, ovs_strerror(error));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(error);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    error = ne_nl_delete_iface(entry->lo_if_name);
>>> +    if (error && error != ENODEV) {
>>> +        VLOG_WARN_RL(&rl, "Unable to delete dummy interface %s: %s",
>>> +                     entry->lo_if_name, ovs_strerror(error));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(error);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    /* Refresh the host-if-monitor ifindex cache for the interfaces we
>>> +     * monitor for neighbor sync, so a lookup later in this same engine
>>> +     * iteration doesn't use a stale ifindex. */
>>> +    host_if_monitor_invalidate(entry->br_if_name);
>>> +    host_if_monitor_invalidate(entry->vxlan_if_name);
>>> +    host_if_monitor_invalidate(entry->lo_if_name);
>>> +
>>> +    return ok;
>>> +}
>>> +
>>> +static bool
>>> +set_bridge_evpn_device_addr(struct neighbor_ovn_maintain_entry *entry)
>>> +{
>>> +    int err;
>>> +    if (entry->br_config.has_addr) {
>>> +        err = ne_nl_set_iface_mac_addr(entry->br_if_name,
>>> +                                       &entry->br_config.lladdr);
>>> +        if (err) {
>>> +            VLOG_WARN_RL(&rl, "Unable set mac address on interface %s: %s",
>>> +                         entry->br_if_name, ovs_strerror(err));
>>> +            SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +            return false;
>>> +        }
>>> +    }
>>> +
>>> +    return true;
>>> +}
>>> +
>>> +/* Creates the interfaces for 'entry'. Returns true if the whole stack was
>>> + * created successfully (or already existed), false if any netlink call
>>> + * failed. */
>>> +static bool
>>> +evpn_create_devices(struct neighbor_ovn_maintain_entry *entry)
>>> +{
>>> +    static char *link_dev = "vxlan_sys_4789";
>>> +    int32_t link_ifindex = ne_nl_ifindex_get(link_dev);
>>> +    int err;
>>> +    bool ok = true;
>>> +
>>> +    if (!link_ifindex) {
>>> +        err = ENODEV;
>>> +        VLOG_WARN_RL(&rl,
>>> +                     "Unable to find OVS system vxlan interface");
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    if (entry->br_config.has_addr
>>> +        && nrm_mode_IP_is_set(entry->redistribute_mode)) {
>>> +        err = ne_nl_create_vrf(entry->vrf_if_name, entry->vni);
>>> +        if (err && err != EEXIST) {
>>> +            VLOG_WARN_RL(&rl,
>>> +                         "Unable to create VRF %s for datapath %s",
>>> +                         entry->vrf_if_name, ovs_strerror(err));
>>> +            SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +            ok = false;
>>> +        }
>>> +    }
>>> +
>>> +    err = ne_nl_create_vxlan(entry->vxlan_if_name, entry->vni,
>>> +                             &entry->local_ip, entry->fake_vxlan_port,
>>> +                             link_ifindex);
>>> +    if (err && err != EEXIST) {
>>> +        VLOG_WARN_RL(&rl,
>>> +                     "Unable to create VXLAN interface %s for "
>>> +                     "VNI %"PRIu32": %s",
>>> +                     entry->vxlan_if_name, entry->vni, ovs_strerror(err));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    err = ne_nl_create_bridge(entry->br_if_name);
>>> +    if (err && err != EEXIST) {
>>> +        VLOG_WARN_RL(&rl,
>>> +                     "Unable to create bridge interface %s for "
>>> +                     "VNI %"PRIu32": %s",
>>> +                     entry->br_if_name, entry->vni, ovs_strerror(err));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    if (!set_bridge_evpn_device_addr(entry)) {
>>> +        ok = false;
>>> +    }
>>> +
>>> +    if (entry->br_config.has_addr
>>> +        && nrm_mode_IP_is_set(entry->redistribute_mode)) {
>>> +        err = ne_nl_set_master(entry->br_if_name, entry->vrf_if_name);
>>> +        if (err) {
>>> +            VLOG_WARN_RL(&rl, "Unable to enslave %s to bridge %s: %s",
>>> +                         entry->br_if_name, entry->vrf_if_name,
>>> +                         ovs_strerror(err));
>>> +            SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +            ok = false;
>>> +        }
>>> +    }
>>> +
>>> +    if (nrm_mode_FDB_is_set(entry->redistribute_mode)) {
>>> +        err = ne_nl_create_lo(entry->lo_if_name);
>>> +        if (err && err != EEXIST) {
>>> +            VLOG_WARN_RL(&rl,
>>> +                         "Unable to create dummy interface %s for "
>>> +                         "VNI %"PRIu32": %s",
>>> +                         entry->lo_if_name, entry->vni, ovs_strerror(err));
>>> +            SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +            ok = false;
>>> +        }
>>> +        err = ne_nl_set_master(entry->lo_if_name, entry->br_if_name);
>>> +        if (err) {
>>> +            VLOG_WARN_RL(&rl, "Unable to enslave %s to bridge %s: %s",
>>> +                         entry->lo_if_name, entry->br_if_name,
>>> +                         ovs_strerror(err));
>>> +            SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +            ok = false;
>>> +        }
>>> +    }
>>> +
>>> +    err = ne_nl_set_master(entry->vxlan_if_name, entry->br_if_name);
>>> +    if (err) {
>>> +        VLOG_WARN_RL(&rl, "Unable to enslave %s to bridge %s: %s",
>>> +                     entry->vxlan_if_name, entry->br_if_name,
>>> +                     ovs_strerror(err));
>>> +        SET_NEIGHBOR_EXCHANGE_NL_STATUS(err);
>>> +        ok = false;
>>> +    }
>>> +
>>> +    /* Refresh the host-if-monitor ifindex cache so a lookup later in this
>>> +     * same engine iteration picks up the newly (re)created interfaces
>>> +     * instead of a stale ifindex left over from before they were
>>> +     * deleted/recreated. */
>>> +    host_if_monitor_invalidate(entry->br_if_name);
>>> +    host_if_monitor_invalidate(entry->vxlan_if_name);
>>> +    host_if_monitor_invalidate(entry->lo_if_name);
>>> +
>>> +    return ok;
>>> +}
>>> +
>>> +/* Returns true if a change between 'old_entry' and 'entry' requires
>>> + * deleting and recreating the whole VRF/bridge/VXLAN/lo interface stack,
>>> + * rather than updating it in place.  This is the case when:
>>> + *
>>> + * - The VXLAN device's local IP or UDP destination port changed: Netlink
>>> + *   offers no way to change either on an existing VXLAN interface.
>>> + *
>>> + * - The bridge gained or lost its L3 address ('has_addr'): this changes
>>> + *   whether a VRF is needed at all.
>>> + *
>>> + * - The redistribution mode changed (FDB and/or IP bits): this changes
>>> + *   whether the loopback advertise interface is needed at all.
>>> + *
>>> + * Recreating the whole stack in these cases is simpler and less
>>> + * error-prone than trying to patch each interface individually. */
>>> +static bool
>>> +should_destroy_interfaces(const struct neighbor_ovn_maintain_entry *entry,
>>> +                          const struct neighbor_ovn_maintain_entry 
>>> *old_entry)
>>> +{
>>> +    return !ipv6_addr_equals(&old_entry->local_ip, &entry->local_ip) ||
>>> +           old_entry->fake_vxlan_port != entry->fake_vxlan_port ||
>>> +           old_entry->br_config.has_addr != entry->br_config.has_addr ||
>>> +           old_entry->redistribute_mode != entry->redistribute_mode ||
>>> +           (entry->br_config.has_addr
>>> +           && !eth_addr_equals(old_entry->br_config.lladdr,
>>> +                               entry->br_config.lladdr));
>>> +}
>>> +
>>> +static bool
>>> +maintain_evpn_devices(struct neighbor_ovn_maintain_entry *entry,
>>> +                        struct neighbor_ovn_maintain_entry *old_entry)
>>> +{
>>> +    if (!old_entry) {
>>> +        return evpn_create_devices(entry);
>>> +    }
>>> +
>>> +    if (should_destroy_interfaces(entry, old_entry)) {
>>> +        if (!evpn_delete_evpn_devices(entry)) {
>>> +            return false;
>>> +        }
>>> +        return evpn_create_devices(entry);
>>> +    }
>>> +
>>> +    return true;
>>> +}
>>> +
>>> +static void
>>> +neighbor_exchange_maintain_evpn_run(const struct vector *maintain_evpn)
>>> +{
>>> +    CLEAR_NEIGHBOR_EXCHANGE_NL_STATUS();
>>> +
>>> +    struct hmap old_maintained_evpn_entries =
>>> +        HMAP_INITIALIZER(&old_maintained_evpn_entries);
>>> +    hmap_swap(&maintained_evpn_entries, &old_maintained_evpn_entries);
>>> +
>>> +    struct neighbor_ovn_maintain_entry *entry;
>>> +    VECTOR_FOR_EACH_PTR (maintain_evpn, entry) {
>>> +        struct maintained_evpn_entry *old_me =
>>> +            maintained_evpn_entry_find(&old_maintained_evpn_entries,
>>> +                                        entry->vni);
>>> +
>>> +        bool ok = maintain_evpn_devices(entry, old_me ? &old_me->entry : 
>>> NULL);
>>> +
>>> +        if (!ok && !old_me) {
>>> +            /* First-time creation failed: don't start tracking this VNI as
>>> +             * maintained. The next run will find no old entry for it and
>>> +             * retry evpn_create_devices() from scratch. */
>>> +            continue;
>>> +        }
>>> +
>>> +        struct maintained_evpn_entry *me;
>>> +        if (old_me) {
>>> +            hmap_remove(&old_maintained_evpn_entries, &old_me->node);
>>> +            me = old_me;
>>> +        } else {
>>> +            me = xmalloc(sizeof *me);
>>> +        }
>>> +        if (ok) {
>>> +            me->entry = *entry;
>>> +        }
>>> +        hmap_insert(&maintained_evpn_entries, &me->node,
>>> +                    hash_int(entry->vni, 0));
>>> +    }
>>> +
>>> +    struct maintained_evpn_entry *stale_me;
>>> +    HMAP_FOR_EACH_POP (stale_me, node, &old_maintained_evpn_entries) {
>>> +        if (evpn_delete_evpn_devices(&stale_me->entry)) {
>>> +            free(stale_me);
>>> +        } else {
>>> +            /* Deletion failed: keep tracking it so it's retried as stale
>>> +             * again on the next run (it's no longer in 'maintain_evpn'). 
>>> */
>>> +            hmap_insert(&maintained_evpn_entries, &stale_me->node,
>>> +                        hash_int(stale_me->entry.vni, 0));
>>> +        }
>>> +    }
>>> +
>>> +    hmap_destroy(&old_maintained_evpn_entries);
>>> +}
>>> +
>>> +void
>>> +neighbor_exchange_maintain_evpn_cleanup_all(void)
>>> +{
>>> +    struct maintained_evpn_entry *me;
>>> +    HMAP_FOR_EACH (me, node, &maintained_evpn_entries) {
>>> +        evpn_delete_evpn_devices(&me->entry);
>>> +    }
>>> +}
>>> +
>>> +void
>>> +neighbor_exchange_maintain_evpn_destroy(void)
>>> +{
>>> +    struct maintained_evpn_entry *me;
>>> +    HMAP_FOR_EACH_POP (me, node, &maintained_evpn_entries) {
>>> +        free(me);
>>> +    }
>>> +    hmap_destroy(&maintained_evpn_entries);
>>> +}
>>> +
>>>   void
>>>   neighbor_exchange_run(const struct neighbor_exchange_ctx_in *n_ctx_in,
>>>                         struct neighbor_exchange_ctx_out *n_ctx_out)
>>>   {
>>> +    neighbor_exchange_maintain_evpn_run(n_ctx_in->maintain_evpn);
>>> +
>>>       struct neighbor_interface_monitor *nim;
>>>   
>>>       struct sset if_names = SSET_INITIALIZER(&if_names);
>>> diff --git a/controller/neighbor-exchange.h b/controller/neighbor-exchange.h
>>> index 456ff23df..509655788 100644
>>> --- a/controller/neighbor-exchange.h
>>> +++ b/controller/neighbor-exchange.h
>>> @@ -27,6 +27,9 @@ struct unixctl_conn;
>>>   struct neighbor_exchange_ctx_in {
>>>       /* Contains struct neighbor_interface_monitor pointers. */
>>>       const struct vector *monitored_interfaces;
>>> +    /* Contains struct neighbor_ovn_maintain_entry for VNIs whose kernel
>>> +     * interfaces OVN must create/maintain.  May be NULL. */
>>> +    const struct vector *maintain_evpn;
>>>   };
>>>   
>>>   struct neighbor_exchange_ctx_out {
>>> @@ -73,4 +76,9 @@ void evpn_remote_vtep_list(struct unixctl_conn *, int 
>>> argc,
>>>                              const char *argv[], void *data_);
>>>   void evpn_static_entries_clear(struct hmap *static_entries);
>>>   
>>> +/* EVPN kernel interfaces lifecycle. Call cleanup_all on graceful shutdown,
>>> + * destroy to release module-level state. */
>>> +void neighbor_exchange_maintain_evpn_cleanup_all(void);
>>> +void neighbor_exchange_maintain_evpn_destroy(void);
>>> +
>>>   #endif  /* NEIGHBOR_EXCHANGE_H */
>>> diff --git a/controller/neighbor.c b/controller/neighbor.c
>>> index ada9254e0..2c42ec541 100644
>>> --- a/controller/neighbor.c
>>> +++ b/controller/neighbor.c
>>> @@ -27,6 +27,7 @@
>>>   #include "neighbor.h"
>>>   
>>>   VLOG_DEFINE_THIS_MODULE(neighbor);
>>> +static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(1, 5);
>>>   
>>>   static const char *neighbor_opt_name[] = {
>>>       [NEIGH_IFACE_BRIDGE] = "dynamic-routing-bridge-ifname",
>>> @@ -88,13 +89,111 @@ neigh_parse_device_name(struct sset *device_names, 
>>> struct local_datapath *ld,
>>>                                        neighbor_opt_name[type], "");
>>>       sset_from_delimited_string(device_names, names, ",");
>>>       if (sset_is_empty(device_names)) {
>>> -        static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(1, 5);
>>>           VLOG_WARN_RL(&rl, "Datapath "UUID_FMT" misses %s",
>>>                        UUID_ARGS(&ld->datapath->header_.uuid),
>>>                        neighbor_opt_name[type]);
>>>       }
>>>   }
>>>   
>>> +static bool
>>> +create_maintain_evpn_entry(const struct neighbor_ctx_in *n_ctx_in,
>>> +                           const struct local_datapath *ld, uint32_t vni,
>>> +                           enum neigh_redistribute_mode redistribute_mode,
>>> +                           enum neighbor_mainatain_mode mainatain_mode,
>>> +                           struct neighbor_ovn_maintain_entry *entry)
>>> +{
>>> +    if (vni > UINT16_MAX - 60000) {
>>> +        VLOG_WARN_RL(&rl, "dynamic-routing-vni %"PRIu32" for datapath "
>>> +                     UUID_FMT" is too large for 
>>> dynamic-routing-maintain-evpn "
>>> +                     "(max supported VNI is %u)",
>>> +                     vni, UUID_ARGS(&ld->datapath->header_.uuid),
>>> +                     UINT16_MAX - 60000);
>>> +        return false;
>>> +    }
>>> +
>>> +    *entry = (struct neighbor_ovn_maintain_entry) {
>>> +        .redistribute_mode = redistribute_mode,
>>> +        .mainatain_mode = mainatain_mode,
>>> +        .vni = vni,
>>> +        .fake_vxlan_port = 60000 + vni,
>>> +    };
>>> +
>>> +    snprintf(entry->vxlan_if_name, sizeof entry->vxlan_if_name,
>>> +             "ovnvxlan%"PRIu32, vni);
>>> +    snprintf(entry->br_if_name, sizeof entry->br_if_name,
>>> +             "ovnbr%"PRIu32, vni);
>>> +    snprintf(entry->lo_if_name, sizeof entry->lo_if_name,
>>> +             "ovnlo%"PRIu32, vni);
>>> +    snprintf(entry->vrf_if_name, sizeof entry->vrf_if_name,
>>> +             "ovnvrf%"PRIu32, vni);
>>> +
>>> +    const struct in6_addr *local_ip = NULL;
>>> +    local_ip = evpn_local_ip_map_lookup(n_ctx_in->evpn_local_ip_map,
>>> +                                        vni, true);
>>> +    if (!local_ip) {
>>> +        local_ip = evpn_local_ip_map_lookup(n_ctx_in->evpn_local_ip_map,
>>> +                                            vni, false);
>>> +    }
>>> +
>>> +    if (local_ip) {
>>> +        entry->local_ip = *local_ip;
>>> +    } else {
>>> +        VLOG_WARN_RL(&rl, "dynamic-routing-maintain-evpn set for datapath "
>>> +                     UUID_FMT" VNI %"PRIu32" but no ovn-evpn-local-ip "
>>> +                     "configured",
>>> +                     UUID_ARGS(&ld->datapath->header_.uuid), vni);
>>> +        return false;
>>> +    }
>>> +
>>> +    if (nrm_mode_IP_is_set(redistribute_mode)) {
>>> +        const char *lrp_name = smap_get(&ld->datapath->external_ids,
>>> +                                        
>>> "dynamic-routing-maintain-evpn-lrp");
>>> +        if (!lrp_name) {
>>> +            VLOG_WARN_RL(&rl, "dynamic-routing-maintain-evpn=mvd is set 
>>> with "
>>> +                         "IP redistribution on datapath "UUID_FMT" but "
>>> +                         "dynamic-routing-maintain-evpn-lrp is not 
>>> configured",
>>> +                         UUID_ARGS(&ld->datapath->header_.uuid));
>>> +            return false;
>>> +        }
>>> +
>>> +        const struct sbrec_port_binding *pb =
>>> +            lport_lookup_by_name(n_ctx_in->sbrec_pb_by_name, lrp_name);
>>> +        if (!pb) {
>>> +            VLOG_WARN_RL(&rl, "dynamic-routing-evpn-lrp: port \"%s\" "
>>> +                              "not found", lrp_name);
>>> +            return false;
>>> +        }
>>> +
>>> +        entry->br_config.has_addr =
>>> +            extract_sbrec_binding_first_mac(pb, &entry->br_config.lladdr);
>>> +        if (!entry->br_config.has_addr) {
>>> +            VLOG_WARN_RL(&rl, "Unable to parse mac address on router "
>>> +                         "port %s for configuring EVPN devices", lrp_name);
>>> +            return false;
>>> +        }
>>> +    }
>>> +
>>> +    return true;
>>> +}
>>> +
>>> +static enum neighbor_mainatain_mode
>>> +parse_neigh_maintain_mode(const struct smap *external_ids)
>>> +{
>>> +    const char *val = smap_get(external_ids, 
>>> "dynamic-routing-maintain-evpn");
>>> +    if (!val) {
>>> +        return NEIGH_MAINTAIN_NONE;
>>> +    }
>>> +
>>> +    if (!strcmp(val, "mvd")) {
>>> +        return NEIGH_MAINTAIN_MVD;
>>> +    }
>>> +
>>> +    VLOG_WARN_RL(&rl, "dynamic-routing-maintain-evpn: unknown value 
>>> \"%s\", "
>>> +                 "expected \"mvd\"", val);
>>> +
>>> +    return NEIGH_MAINTAIN_NONE;
>>> +}
>>> +
>>>   void
>>>   neighbor_run(struct neighbor_ctx_in *n_ctx_in,
>>>                struct neighbor_ctx_out *n_ctx_out)
>>> @@ -116,24 +215,50 @@ neighbor_run(struct neighbor_ctx_in *n_ctx_in,
>>>               continue;
>>>           }
>>>   
>>> +        enum neigh_redistribute_mode redistribute_mode =
>>> +            parse_neigh_dynamic_redistribute(&ld->datapath->external_ids);
>>> +
>>> +        enum neighbor_mainatain_mode mainatain_mode =
>>> +            parse_neigh_maintain_mode(&ld->datapath->external_ids);
>>> +
>>> +        struct neighbor_ovn_maintain_entry maintain_entry;
>>> +        if (mainatain_mode != NEIGH_MAINTAIN_NONE) {
>>> +            if (create_maintain_evpn_entry(n_ctx_in, ld, (uint32_t) vni,
>>> +                                           redistribute_mode, 
>>> mainatain_mode,
>>> +                                           &maintain_entry)) {
>>> +                vector_push(n_ctx_out->maintain_evpn, &maintain_entry);
>>> +            } else {
>>> +                continue;
>>> +            }
>>> +        }
>>> +
>>>           struct sset device_names;
>>> -        neigh_parse_device_name(&device_names, ld, NEIGH_IFACE_VXLAN);
>>> +        if (mainatain_mode != NEIGH_MAINTAIN_NONE) {
>>> +            sset_init(&device_names);
>>> +            sset_add(&device_names, maintain_entry.vxlan_if_name);
>>> +        } else {
>>> +            neigh_parse_device_name(&device_names, ld, NEIGH_IFACE_VXLAN);
>>> +        }
>>>           const char *name;
>>>           SSET_FOR_EACH (name, &device_names) {
>>> -            struct neighbor_interface_monitor *vxlan =
>>> +            struct neighbor_interface_monitor *nim =
>>>                   neighbor_interface_monitor_alloc(NEIGH_AF_BRIDGE,
>>>                                                    NEIGH_IFACE_VXLAN, vni, 
>>> name);
>>> -            vector_push(n_ctx_out->monitored_interfaces, &vxlan);
>>> +            vector_push(n_ctx_out->monitored_interfaces, &nim);
>>>           }
>>>           sset_destroy(&device_names);
>>>   
>>>           struct neighbor_interface_monitor *lo = NULL;
>>> -        neigh_parse_device_name(&device_names, ld, NEIGH_IFACE_LOOPBACK);
>>> -        if (sset_count(&device_names) > 1) {
>>> -            static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(1, 5);
>>> -            VLOG_WARN_RL(&rl, "Datapath "UUID_FMT" too many names provided 
>>> "
>>> -                              "for loopback device",
>>> -                         UUID_ARGS(&ld->datapath->header_.uuid));
>>> +        if (mainatain_mode != NEIGH_MAINTAIN_NONE) {
>>> +            sset_init(&device_names);
>>> +            sset_add(&device_names, maintain_entry.lo_if_name);
>>> +        } else {
>>> +            neigh_parse_device_name(&device_names, ld, 
>>> NEIGH_IFACE_LOOPBACK);
>>> +            if (sset_count(&device_names) > 1) {
>>> +                VLOG_WARN_RL(&rl, "Datapath "UUID_FMT" too many names for "
>>> +                                  "loopback device",
>>> +                             UUID_ARGS(&ld->datapath->header_.uuid));
>>> +            }
>>>           }
>>>   
>>>           if (!sset_is_empty(&device_names)) {
>>> @@ -146,32 +271,31 @@ neighbor_run(struct neighbor_ctx_in *n_ctx_in,
>>>   
>>>           struct neighbor_interface_monitor *br_v4 = NULL;
>>>           struct neighbor_interface_monitor *br_v6 = NULL;
>>> -        neigh_parse_device_name(&device_names, ld, NEIGH_IFACE_BRIDGE);
>>> -        if (sset_count(&device_names) > 1) {
>>> -            static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(1, 5);
>>> -            VLOG_WARN_RL(&rl, "Datapath "UUID_FMT" too many names provided 
>>> "
>>> -                              "for bridge device",
>>> -                         UUID_ARGS(&ld->datapath->header_.uuid));
>>> +        if (mainatain_mode != NEIGH_MAINTAIN_NONE) {
>>> +            sset_init(&device_names);
>>> +            sset_add(&device_names, maintain_entry.br_if_name);
>>> +        } else {
>>> +            neigh_parse_device_name(&device_names, ld, NEIGH_IFACE_BRIDGE);
>>> +            if (sset_count(&device_names) > 1) {
>>> +                VLOG_WARN_RL(&rl, "Datapath "UUID_FMT" too many names for "
>>> +                                  "bridge device",
>>> +                             UUID_ARGS(&ld->datapath->header_.uuid));
>>> +            }
>>>           }
>>>   
>>>           if (!sset_is_empty(&device_names)) {
>>> -            br_v4 =
>>> -                neighbor_interface_monitor_alloc(NEIGH_AF_INET,
>>> -                                                 NEIGH_IFACE_BRIDGE, vni,
>>> -                                                 
>>> SSET_FIRST(&device_names));
>>> +            br_v4 = neighbor_interface_monitor_alloc(
>>> +                NEIGH_AF_INET, NEIGH_IFACE_BRIDGE, vni,
>>> +                SSET_FIRST(&device_names));
>>>               vector_push(n_ctx_out->monitored_interfaces, &br_v4);
>>> -
>>> -            br_v6 =
>>> -                neighbor_interface_monitor_alloc(NEIGH_AF_INET6,
>>> -                                                 NEIGH_IFACE_BRIDGE, vni,
>>> -                                                 
>>> SSET_FIRST(&device_names));
>>> +            br_v6 = neighbor_interface_monitor_alloc(
>>> +                NEIGH_AF_INET6, NEIGH_IFACE_BRIDGE, vni,
>>> +                SSET_FIRST(&device_names));
>>>               vector_push(n_ctx_out->monitored_interfaces, &br_v6);
>>>           }
>>>           sset_destroy(&device_names);
>>>   
>>> -        enum neigh_redistribute_mode mode =
>>> -            parse_neigh_dynamic_redistribute(&ld->datapath->external_ids);
>>> -        if (nrm_mode_FDB_is_set(mode) && lo) {
>>> +        if (nrm_mode_FDB_is_set(redistribute_mode) && lo) {
>>>               neighbor_collect_mac_to_advertise(n_ctx_in,
>>>                                                 &lo->announced_neighbors,
>>>                                                 n_ctx_out->advertised_pbs,
>>> @@ -182,7 +306,7 @@ neighbor_run(struct neighbor_ctx_in *n_ctx_in,
>>>            * meant to be advertised.  With 'fdb' set, also program them as 
>>> FDB
>>>            * entries. */
>>>           neighbor_collect_advertised_mac_bindings(
>>> -            n_ctx_in, mode, lo ? &lo->announced_neighbors : NULL,
>>> +            n_ctx_in, redistribute_mode, lo ? &lo->announced_neighbors : 
>>> NULL,
>>>               br_v4 ? &br_v4->announced_neighbors : NULL,
>>>               br_v6 ? &br_v6->announced_neighbors : NULL,
>>>               n_ctx_out->advertised_pbs, ld->datapath);
>>> diff --git a/controller/neighbor.h b/controller/neighbor.h
>>> index e3adc87d1..bb440535f 100644
>>> --- a/controller/neighbor.h
>>> +++ b/controller/neighbor.h
>>> @@ -23,6 +23,7 @@
>>>   
>>>   #include "lib/sset.h"
>>>   #include "openvswitch/hmap.h"
>>> +#include "lib/ovn-util.h"
>>>   
>>>   #include "vec.h"
>>>   
>>> @@ -49,6 +50,7 @@ struct neighbor_ctx_in {
>>>       /* Index for Port Binding by name. */
>>>       struct ovsdb_idl_index *sbrec_pb_by_name;
>>>       const struct sbrec_chassis *chassis;
>>> +    struct evpn_local_ip_map *evpn_local_ip_map;
>>>   };
>>>   
>>>   struct neighbor_ctx_out {
>>> @@ -56,6 +58,10 @@ struct neighbor_ctx_out {
>>>       struct vector *monitored_interfaces;
>>>       /* Contains set of port binding names that are currently advertised. 
>>> */
>>>       struct sset *advertised_pbs;
>>> +    /* Contains struct neighbor_ovn_maintain_entry for VNIs that need
>>> +     * maintained interfaces.  May be NULL if caller does not need this. */
>>> +    struct vector *maintain_evpn;
>>> +    struct evpn_local_ip_map *evpn_ip_map;
>>>   };
>>>   
>>>   enum neighbor_interface_type {
>>> @@ -64,6 +70,34 @@ enum neighbor_interface_type {
>>>       NEIGH_IFACE_LOOPBACK,
>>>   };
>>>   
>>> +enum neighbor_mainatain_mode {
>>> +    NEIGH_MAINTAIN_NONE,
>>> +    NEIGH_MAINTAIN_MVD,
>>> +};
>>> +
>>> +/* IP/MAC to configure on the bridge interface so BGP can use it as 
>>> next-hop.
>>> + * Filled from the LRP named by dynamic-routing-evpn-lrp. */
>>> +struct neighbor_bridge_iface_config {
>>> +    bool has_addr;
>>> +    struct eth_addr lladdr;
>>> +    struct in6_addr addr;    /* IPv4 stored as mapped IPv4-in-IPv6. */
>>> +    uint8_t prefix_len;
>>> +};
>>> +
>>> +struct neighbor_ovn_maintain_entry {
>>> +    enum neigh_redistribute_mode redistribute_mode;
>>> +    enum neighbor_mainatain_mode mainatain_mode;
>>> +    char vxlan_if_name[IFNAMSIZ + 1];
>>> +    char vrf_if_name[IFNAMSIZ + 1];
>>> +    char br_if_name[IFNAMSIZ + 1];
>>> +    char lo_if_name[IFNAMSIZ + 1];
>>> +    struct neighbor_bridge_iface_config br_config;
>>> +    struct in6_addr local_ip;
>>> +    uint32_t vni;
>>> +    /* Formula: 60000 + vni (valid for VNI <= 5535). */
>>> +    uint16_t fake_vxlan_port;
>>> +};
>>> +
>>>   struct neighbor_interface_monitor {
>>>       enum neighbor_family family;
>>>       char if_name[IFNAMSIZ + 1];
>>> diff --git a/controller/netlink-utils.c b/controller/netlink-utils.c
>>> new file mode 100644
>>> index 000000000..4055f8809
>>> --- /dev/null
>>> +++ b/controller/netlink-utils.c
>>> @@ -0,0 +1,148 @@
>>> +/* Copyright (c) 2025, Red Hat, Inc.
>>> + *
>>> + * 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 <stdbool.h>
>>> +
>>> +#include <errno.h>
>>> +#include <linux/if_ether.h>
>>> +#include <linux/if_link.h>
>>> +#include <linux/rtnetlink.h>
>>> +
>>> +#include "lib/netlink.h"
>>> +#include "lib/netlink-socket.h"
>>> +#include "lib/packets.h"
>>> +
>>> +#include "openvswitch/ofpbuf.h"
>>> +
>>> +#include "netlink-utils.h"
>>> +
>>> +int32_t
>>> +nl_ifindex_get(const char *ifname)
>>> +{
>>> +    return (int32_t) if_nametoindex(ifname);
>>> +}
>>> +
>>> +int
>>> +nl_create_device(const char *ifname, const char *kind)
>>> +{
>>> +    struct ifinfomsg *ifinfo;
>>> +    struct ofpbuf request;
>>> +    ofpbuf_init(&request, 0);
>>> +
>>> +    nl_msg_put_nlmsghdr(&request, 0, RTM_NEWLINK,
>>> +                        NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | 
>>> NLM_F_EXCL);
>>> +    ifinfo = ofpbuf_put_zeros(&request, sizeof *ifinfo);
>>> +    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> +    ifinfo->ifi_change = ifinfo->ifi_flags = IFF_UP;
>>> +
>>> +    size_t linkinfo_off = nl_msg_start_nested(&request, IFLA_LINKINFO);
>>> +    nl_msg_put_string(&request, IFLA_INFO_KIND, kind);
>>> +    nl_msg_end_nested(&request, linkinfo_off);
>>> +
>>> +    int err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> +    ofpbuf_uninit(&request);
>>> +    return err;
>>> +}
>>> +
>>> +int
>>> +nl_create_vrf(const char *ifname, uint32_t table_id)
>>> +{
>>> +    size_t linkinfo_off, infodata_off;
>>> +    struct ifinfomsg *ifinfo;
>>> +    int err;
>>> +
>>> +    struct ofpbuf request;
>>> +    uint8_t request_stub[NETNL_REQ_BUFFER_SIZE];
>>> +    ofpbuf_use_stub(&request, request_stub, sizeof(request_stub));
>>> +
>>> +    nl_msg_put_nlmsghdr(&request, 0, RTM_NEWLINK,
>>> +                        NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | 
>>> NLM_F_EXCL);
>>> +    ifinfo = ofpbuf_put_zeros(&request, sizeof *ifinfo);
>>> +    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> +
>>> +    ifinfo->ifi_change = ifinfo->ifi_flags = IFF_UP;
>>> +    linkinfo_off = nl_msg_start_nested(&request, IFLA_LINKINFO);
>>> +    nl_msg_put_string(&request, IFLA_INFO_KIND, "vrf");
>>> +    infodata_off = nl_msg_start_nested(&request, IFLA_INFO_DATA);
>>> +    nl_msg_put_u32(&request, IFLA_VRF_TABLE, table_id);
>>> +    nl_msg_end_nested(&request, infodata_off);
>>> +    nl_msg_end_nested(&request, linkinfo_off);
>>> +
>>> +    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> +
>>> +    ofpbuf_uninit(&request);
>>> +    return err;
>>> +}
>>> +
>>> +int
>>> +nl_delete_device(const char *ifname)
>>> +{
>>> +    struct ifinfomsg *ifinfo;
>>> +    int err;
>>> +
>>> +    struct ofpbuf request;
>>> +    uint8_t request_stub[NETNL_REQ_BUFFER_SIZE];
>>> +    ofpbuf_use_stub(&request, request_stub, sizeof(request_stub));
>>> +
>>> +    nl_msg_put_nlmsghdr(&request, 0, RTM_DELLINK, NLM_F_REQUEST | 
>>> NLM_F_ACK);
>>> +    ifinfo = ofpbuf_put_zeros(&request, sizeof *ifinfo);
>>> +    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> +    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> +
>>> +    ofpbuf_uninit(&request);
>>> +    return err;
>>> +}
>>> +
>>> +int
>>> +nl_set_master(const char *slave, const char *master)
>>> +{
>>> +    int err = 0;
>>> +    int32_t master_idx = nl_ifindex_get(master);
>>> +    if (!master_idx) {
>>> +        return ENODEV;
>>> +    }
>>> +
>>> +    struct ofpbuf request;
>>> +    ofpbuf_init(&request, 0);
>>> +
>>> +    nl_msg_put_nlmsghdr(&request, 0, RTM_NEWLINK,
>>> +                        NLM_F_REQUEST | NLM_F_ACK);
>>> +    ofpbuf_put_zeros(&request, sizeof(struct ifinfomsg));
>>> +    nl_msg_put_string(&request, IFLA_IFNAME, slave);
>>> +    nl_msg_put_u32(&request, IFLA_MASTER, master_idx);
>>> +
>>> +    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> +    ofpbuf_uninit(&request);
>>> +    return err;
>>> +}
>>> +
>>> +int
>>> +nl_set_iface_mac(const char *ifname, const struct eth_addr *mac)
>>> +{
>>> +    int err = 0;
>>> +    struct ofpbuf request;
>>> +    ofpbuf_init(&request, 0);
>>> +
>>> +    nl_msg_put_nlmsghdr(&request, 0, RTM_NEWLINK,
>>> +                        NLM_F_REQUEST | NLM_F_ACK);
>>> +    ofpbuf_put_zeros(&request, sizeof(struct ifinfomsg));
>>> +    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> +    nl_msg_put_unspec(&request, IFLA_ADDRESS, mac, sizeof *mac);
>>> +
>>> +    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> +    ofpbuf_uninit(&request);
>>> +    return err;
>>> +}
>>> diff --git a/controller/netlink-utils.h b/controller/netlink-utils.h
>>> new file mode 100644
>>> index 000000000..95c817e33
>>> --- /dev/null
>>> +++ b/controller/netlink-utils.h
>>> @@ -0,0 +1,41 @@
>>> +/*
>>> + * Copyright (c) 2025 Canonical, Ltd.
>>> + * Copyright (c) 2025, STACKIT GmbH & Co. KG
>>> + *
>>> + * 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 NETLINK_UTILS_H
>>> +#define NETLINK_UTILS_H 1
>>> +
>>> +#include <stdint.h>
>>> +#include <linux/rtnetlink.h>
>>> +#include <netinet/in.h>
>>> +#include <net/if.h>
>>> +
>>> +#define NETNL_REQ_BUFFER_SIZE 128
>>> +
>>> +#define TABLE_ID_VALID(table_id) (table_id != RT_TABLE_UNSPEC &&           
>>>    \
>>> +                                  table_id != RT_TABLE_COMPAT &&           
>>>    \
>>> +                                  table_id != RT_TABLE_LOCAL &&            
>>>    \
>>> +                                  table_id != RT_TABLE_MAX)
>>> +
>>> +int32_t nl_ifindex_get(const char *ifname);
>>> +int nl_create_device(const char *ifname, const char *kind);
>>> +int nl_create_vrf(const char *ifname, uint32_t table_id);
>>> +int nl_delete_device(const char *ifname);
>>> +int nl_set_iface_mac(const char *ifname,
>>> +                     const struct eth_addr *mac);
>>> +int nl_set_master(const char *slave, const char *master);
>>> +
>>> +#endif /* netlink-utils.h */
>>> diff --git a/controller/ovn-controller.c b/controller/ovn-controller.c
>>> index 5f63c5fec..dc6089388 100644
>>> --- a/controller/ovn-controller.c
>>> +++ b/controller/ovn-controller.c
>>> @@ -6081,6 +6081,9 @@ struct ed_type_neighbor {
>>>       struct vector monitored_interfaces;
>>>       /* Contains set of PB names that are currently advertised. */
>>>       struct sset advertised_pbs;
>>> +    /* Contains struct neighbor_ovn_maintain_entry, one per VNI
>>> +     * whose EVPN interfaces are auto-created and maintained. */
>>> +    struct vector maintain_evpn;
>>>   };
>>>   
>>>   static void *
>>> @@ -6093,6 +6096,8 @@ en_neighbor_init(struct engine_node *node OVS_UNUSED,
>>>           .monitored_interfaces =
>>>               VECTOR_EMPTY_INITIALIZER(struct neighbor_interface_monitor *),
>>>           .advertised_pbs = SSET_INITIALIZER(&data->advertised_pbs),
>>> +        .maintain_evpn =
>>> +            VECTOR_EMPTY_INITIALIZER(struct neighbor_ovn_maintain_entry),
>>>       };
>>>       return data;
>>>   }
>>> @@ -6104,6 +6109,7 @@ en_neighbor_cleanup(void *data)
>>>   
>>>       neighbor_cleanup(&ne_data->monitored_interfaces);
>>>       vector_destroy(&ne_data->monitored_interfaces);
>>> +    vector_destroy(&ne_data->maintain_evpn);
>>>       sset_destroy(&ne_data->advertised_pbs);
>>>   }
>>>   
>>> @@ -6138,22 +6144,32 @@ en_neighbor_run(struct engine_node *node 
>>> OVS_UNUSED, void *data)
>>>           chassis_lookup_by_name(sbrec_chassis_by_name, chassis_id);
>>>       ovs_assert(chassis);
>>>   
>>> +    struct evpn_local_ip_map evpn_ip_map = {
>>> +        .vni_ip4 = HMAP_INITIALIZER(&evpn_ip_map.vni_ip4),
>>> +        .vni_ip6 = HMAP_INITIALIZER(&evpn_ip_map.vni_ip6),
>>> +    };
>>> +    evpn_local_ip_map_init(&evpn_ip_map, &chassis->other_config);
>>> +
>>>       struct neighbor_ctx_in n_ctx_in = {
>>>           .local_datapaths = &rt_data->local_datapaths,
>>>           .sbrec_pb_by_dp = sbrec_port_binding_by_datapath,
>>>           .sbrec_amb_by_dp = sbrec_advertised_mac_binding_by_datapath,
>>>           .sbrec_pb_by_name = sbrec_port_binding_by_name,
>>>           .chassis = chassis,
>>> +        .evpn_local_ip_map = &evpn_ip_map,
>>>       };
>>>   
>>>       struct neighbor_ctx_out n_ctx_out = {
>>>           .monitored_interfaces = &ne_data->monitored_interfaces,
>>>           .advertised_pbs = &ne_data->advertised_pbs,
>>> +        .maintain_evpn = &ne_data->maintain_evpn,
>>>       };
>>>   
>>>       neighbor_cleanup(&ne_data->monitored_interfaces);
>>>       sset_clear(&ne_data->advertised_pbs);
>>> +    vector_clear(&ne_data->maintain_evpn);
>>>       neighbor_run(&n_ctx_in, &n_ctx_out);
>>> +    evpn_local_ip_map_destroy(&evpn_ip_map);
>>>   
>>>       return EN_UPDATED;
>>>   }
>>> @@ -6486,6 +6502,7 @@ en_neighbor_exchange_run(struct engine_node *node, 
>>> void *data_)
>>>   
>>>       struct neighbor_exchange_ctx_in n_ctx_in = {
>>>           .monitored_interfaces = &neighbor_data->monitored_interfaces,
>>> +        .maintain_evpn = &neighbor_data->maintain_evpn,
>>>       };
>>>       struct neighbor_exchange_ctx_out n_ctx_out = {
>>>           .neighbor_table_watches = &nt_notify->watches,
>>> @@ -8465,6 +8482,8 @@ loop_done:
>>>           route_exchange_cleanup_vrfs();
>>>       }
>>>   
>>> +    neighbor_exchange_maintain_evpn_cleanup_all();
>>> +
>>>       /* The engine cleanup should happen only after threads have been
>>>        * destroyed and joined in case they are accessing engine data. */
>>>       pinctrl_destroy();
>>> @@ -8501,6 +8520,7 @@ loop_done:
>>>       dns_resolve_destroy();
>>>       route_exchange_destroy();
>>>       ovn_netlink_notifiers_destroy();
>>> +    neighbor_exchange_maintain_evpn_destroy();
>>>   
>>>       exit(retval);
>>>   }
>>> diff --git a/controller/physical.c b/controller/physical.c
>>> index 89ff2785e..0277d472d 100644
>>> --- a/controller/physical.c
>>> +++ b/controller/physical.c
>>> @@ -3231,163 +3231,6 @@ physical_eval_remote_chassis_flows(const struct 
>>> physical_ctx *ctx,
>>>       ofpbuf_uninit(&ingress_ofpacts);
>>>   }
>>>   
>>> -struct vni_local_ip {
>>> -    struct hmap_node hmap_node;
>>> -    struct in6_addr ip;
>>> -    uint32_t vni;
>>> -};
>>> -
>>> -struct evpn_local_ip_map {
>>> -    struct hmap vni_ip4;         /* Per VNI local IPv4 vni_local_ips. */
>>> -    struct hmap vni_ip6;         /* Per VNI local IPv6 vni_local_ips. */
>>> -    struct in6_addr default_ip4; /* Default local IPv4. */
>>> -    struct in6_addr default_ip6; /* Default local IPv6. */
>>> -};
>>> -
>>> -static const struct in6_addr *
>>> -evpn_local_ip_lookup(const struct hmap *map, uint32_t vni)
>>> -{
>>> -    struct vni_local_ip *e;
>>> -    HMAP_FOR_EACH_WITH_HASH (e, hmap_node, hash_add(vni, 0), map) {
>>> -        if (e->vni == vni) {
>>> -            return &e->ip;
>>> -        }
>>> -    }
>>> -    return NULL;
>>> -}
>>> -
>>> -static ovs_be32
>>> -evpn_local_ip_find_v4(const struct evpn_local_ip_map *vni_ip_map,
>>> -                      uint32_t vni)
>>> -{
>>> -    const struct in6_addr *addr = 
>>> evpn_local_ip_lookup(&vni_ip_map->vni_ip4,
>>> -                                                       vni);
>>> -    if (addr) {
>>> -        return in6_addr_get_mapped_ipv4(addr);
>>> -    }
>>> -
>>> -    if (ipv6_addr_is_set(&vni_ip_map->default_ip4)) {
>>> -        return in6_addr_get_mapped_ipv4(&vni_ip_map->default_ip4);
>>> -    }
>>> -
>>> -    return 0;
>>> -}
>>> -
>>> -static const struct in6_addr *
>>> -evpn_local_ip_find_v6(const struct evpn_local_ip_map *vni_ip_map,
>>> -                      uint32_t vni)
>>> -{
>>> -    const struct in6_addr *addr = 
>>> evpn_local_ip_lookup(&vni_ip_map->vni_ip6,
>>> -                                                       vni);
>>> -    if (addr) {
>>> -        return addr;
>>> -    }
>>> -
>>> -    if (ipv6_addr_is_set(&vni_ip_map->default_ip6)) {
>>> -        return &vni_ip_map->default_ip6;
>>> -    }
>>> -
>>> -    return NULL;
>>> -}
>>> -
>>> -static void
>>> -evpn_local_ip_map_init(struct evpn_local_ip_map *vni_ip_map,
>>> -                       const struct smap *config)
>>> -{
>>> -    char *tokstr, *token, *ptr0 = NULL;
>>> -
>>> -    const char *local_ip_str = smap_get_def(config, "ovn-evpn-local-ip", 
>>> "");
>>> -    tokstr = xstrdup(local_ip_str);
>>> -    for (token = strtok_r(tokstr, ",", &ptr0); token;
>>> -         token = strtok_r(NULL, ",", &ptr0)) {
>>> -        char *ptr1 = NULL, *vni_str = strtok_r(token, "-", &ptr1);
>>> -        char *ip_str = strtok_r(NULL, "-", &ptr1);
>>> -        struct in6_addr ip;
>>> -        uint32_t vni;
>>> -
>>> -        if (ptr1 && *ptr1) {
>>> -            static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
>>> -            VLOG_WARN_RL(&rl, "Malformed 'ovn-evpn-local-ip': %s", tokstr);
>>> -            break;
>>> -        }
>>> -
>>> -        if (!ip_str) { /* default IP */
>>> -            if (!ip46_parse(vni_str, &ip)) {
>>> -                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> -                VLOG_WARN_RL(&rl, "Invalid IP %s in 'ovn-evpn-local-ip'",
>>> -                             vni_str);
>>> -                continue;
>>> -            }
>>> -            struct in6_addr *ip_ptr = IN6_IS_ADDR_V4MAPPED(&ip)
>>> -                                      ? &vni_ip_map->default_ip4
>>> -                                      : &vni_ip_map->default_ip6;
>>> -            if (ipv6_addr_is_set(ip_ptr)) {
>>> -                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> -                VLOG_WARN_RL(&rl, "Default ovn-evpn-local-ip IP%d "
>>> -                                  "already configured",
>>> -                             IN6_IS_ADDR_V4MAPPED(&ip) ? 4 : 6);
>>> -                continue;
>>> -            }
>>> -            *ip_ptr = ip;
>>> -        } else {
>>> -            if (!ip46_parse(ip_str, &ip)) {
>>> -                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> -                VLOG_WARN_RL(&rl,
>>> -                             "EVPN enabled, but required 
>>> 'ovn-evpn-local-ip' "
>>> -                             "is missing or invalid for vni %s", vni_str);
>>> -                continue;
>>> -            }
>>> -
>>> -            if (!vni_str) {
>>> -                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> -                VLOG_WARN_RL(&rl, "Required 'ovn-evpn-local-ip' VNI not "
>>> -                                  "configured for IP %s",
>>> -                             ip_str);
>>> -                continue;
>>> -            }
>>> -
>>> -            if (!ovs_scan(vni_str, "%u", &vni) || !ovn_is_valid_vni(vni)) {
>>> -                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> -                VLOG_WARN_RL(&rl, "Invalid VNI %s in 'ovn-evpn-local-ip'",
>>> -                             vni_str);
>>> -                continue;
>>> -            }
>>> -
>>> -            struct hmap *map = IN6_IS_ADDR_V4MAPPED(&ip)
>>> -                               ? &vni_ip_map->vni_ip4
>>> -                               : &vni_ip_map->vni_ip6;
>>> -            if (evpn_local_ip_lookup(map, vni)) {
>>> -                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> -                VLOG_WARN_RL(&rl, "Duplicated VNI entry: %s", vni_str);
>>> -                continue;
>>> -            }
>>> -
>>> -            struct vni_local_ip *e = xmalloc(sizeof *e);
>>> -            *e = (struct vni_local_ip) {
>>> -                .vni = vni,
>>> -                .ip = ip,
>>> -            };
>>> -            hmap_insert(map, &e->hmap_node, hash_add(vni, 0));
>>> -        }
>>> -    }
>>> -    free(tokstr);
>>> -}
>>> -
>>> -static void
>>> -evpn_local_ip_map_destroy(struct evpn_local_ip_map *map)
>>> -{
>>> -    struct vni_local_ip *e;
>>> -    HMAP_FOR_EACH_POP (e, hmap_node, &map->vni_ip4) {
>>> -        free(e);
>>> -    }
>>> -    hmap_destroy(&map->vni_ip4);
>>> -
>>> -    HMAP_FOR_EACH_POP (e, hmap_node, &map->vni_ip6) {
>>> -        free(e);
>>> -    }
>>> -    hmap_destroy(&map->vni_ip6);
>>> -}
>>> -
>>>   static void
>>>   physical_consider_evpn_binding(const struct evpn_binding *binding,
>>>                                  const struct evpn_local_ip_map *vni_ip_map,
>>> diff --git a/controller/route-exchange-netlink.c 
>>> b/controller/route-exchange-netlink.c
>>> index 8f1615c4e..e280fae1d 100644
>>> --- a/controller/route-exchange-netlink.c
>>> +++ b/controller/route-exchange-netlink.c
>>> @@ -31,13 +31,12 @@
>>>   #include "route-table.h"
>>>   #include "route.h"
>>>   #include "vec.h"
>>> +#include "netlink-utils.h"
>>>   
>>>   #include "route-exchange-netlink.h"
>>>   
>>>   VLOG_DEFINE_THIS_MODULE(route_exchange_netlink);
>>>   
>>> -#define NETNL_REQ_BUFFER_SIZE 128
>>> -
>>>   static void re_nl_encode_nexthop(struct ofpbuf *, bool dst_is_ipv4,
>>>                                    const struct in6_addr *);
>>>   
>>> @@ -51,51 +50,13 @@ re_nl_create_vrf(const char *ifname, uint32_t table_id)
>>>                        table_id);
>>>           return EINVAL;
>>>       }
>>> -
>>> -    size_t linkinfo_off, infodata_off;
>>> -    struct ifinfomsg *ifinfo;
>>> -    int err;
>>> -
>>> -    struct ofpbuf request;
>>> -    uint8_t request_stub[NETNL_REQ_BUFFER_SIZE];
>>> -    ofpbuf_use_stub(&request, request_stub, sizeof(request_stub));
>>> -
>>> -    nl_msg_put_nlmsghdr(&request, 0, RTM_NEWLINK,
>>> -                        NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | 
>>> NLM_F_EXCL);
>>> -    ifinfo = ofpbuf_put_zeros(&request, sizeof *ifinfo);
>>> -    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> -
>>> -    ifinfo->ifi_change = ifinfo->ifi_flags = IFF_UP;
>>> -    linkinfo_off = nl_msg_start_nested(&request, IFLA_LINKINFO);
>>> -    nl_msg_put_string(&request, IFLA_INFO_KIND, "vrf");
>>> -    infodata_off = nl_msg_start_nested(&request, IFLA_INFO_DATA);
>>> -    nl_msg_put_u32(&request, IFLA_VRF_TABLE, table_id);
>>> -    nl_msg_end_nested(&request, infodata_off);
>>> -    nl_msg_end_nested(&request, linkinfo_off);
>>> -
>>> -    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> -
>>> -    ofpbuf_uninit(&request);
>>> -    return err;
>>> +    return nl_create_vrf(ifname, table_id);
>>>   }
>>>   
>>>   int
>>>   re_nl_delete_vrf(const char *ifname)
>>>   {
>>> -    struct ifinfomsg *ifinfo;
>>> -    int err;
>>> -
>>> -    struct ofpbuf request;
>>> -    uint8_t request_stub[NETNL_REQ_BUFFER_SIZE];
>>> -    ofpbuf_use_stub(&request, request_stub, sizeof(request_stub));
>>> -
>>> -    nl_msg_put_nlmsghdr(&request, 0, RTM_DELLINK, NLM_F_REQUEST | 
>>> NLM_F_ACK);
>>> -    ifinfo = ofpbuf_put_zeros(&request, sizeof *ifinfo);
>>> -    nl_msg_put_string(&request, IFLA_IFNAME, ifname);
>>> -    err = nl_transact(NETLINK_ROUTE, &request, NULL);
>>> -
>>> -    ofpbuf_uninit(&request);
>>> -    return err;
>>> +    return nl_delete_device(ifname);
>>>   }
>>>   
>>>   void
>>> diff --git a/controller/route-exchange-netlink.h 
>>> b/controller/route-exchange-netlink.h
>>> index c137b5119..b6718f3c7 100644
>>> --- a/controller/route-exchange-netlink.h
>>> +++ b/controller/route-exchange-netlink.h
>>> @@ -29,11 +29,6 @@
>>>   #define RTPROT_OVN 84
>>>   #endif
>>>   
>>> -#define TABLE_ID_VALID(table_id) (table_id != RT_TABLE_UNSPEC &&           
>>>    \
>>> -                                  table_id != RT_TABLE_COMPAT &&           
>>>    \
>>> -                                  table_id != RT_TABLE_LOCAL &&            
>>>    \
>>> -                                  table_id != RT_TABLE_MAX)
>>> -
>>>   struct in6_addr;
>>>   struct hmap;
>>>   struct vector;
>>> diff --git a/controller/route-exchange.c b/controller/route-exchange.c
>>> index b86eb43bf..879be9940 100644
>>> --- a/controller/route-exchange.c
>>> +++ b/controller/route-exchange.c
>>> @@ -34,6 +34,7 @@
>>>   #include "route.h"
>>>   #include "route-exchange.h"
>>>   #include "route-exchange-netlink.h"
>>> +#include "netlink-utils.h"
>>>   
>>>   VLOG_DEFINE_THIS_MODULE(route_exchange);
>>>   static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 20);
>>> diff --git a/lib/ovn-util.c b/lib/ovn-util.c
>>> index d9b78fbb6..314575d00 100644
>>> --- a/lib/ovn-util.c
>>> +++ b/lib/ovn-util.c
>>> @@ -1897,3 +1897,160 @@ port_contains_duplicate_ip(struct lport_addresses 
>>> *laddrs1,
>>>   
>>>       return false;
>>>   }
>>> +
>>> +const struct in6_addr *
>>> +evpn_local_ip_lookup(const struct hmap *map, uint32_t vni)
>>> +{
>>> +    struct vni_local_ip *e;
>>> +    HMAP_FOR_EACH_WITH_HASH (e, hmap_node, hash_add(vni, 0), map) {
>>> +        if (e->vni == vni) {
>>> +            return &e->ip;
>>> +        }
>>> +    }
>>> +    return NULL;
>>> +}
>>> +
>>> +ovs_be32
>>> +evpn_local_ip_find_v4(const struct evpn_local_ip_map *vni_ip_map,
>>> +                      uint32_t vni)
>>> +{
>>> +    const struct in6_addr *addr = 
>>> evpn_local_ip_lookup(&vni_ip_map->vni_ip4,
>>> +                                                       vni);
>>> +    if (addr) {
>>> +        return in6_addr_get_mapped_ipv4(addr);
>>> +    }
>>> +
>>> +    if (ipv6_addr_is_set(&vni_ip_map->default_ip4)) {
>>> +        return in6_addr_get_mapped_ipv4(&vni_ip_map->default_ip4);
>>> +    }
>>> +
>>> +    return 0;
>>> +}
>>> +
>>> +const struct in6_addr *
>>> +evpn_local_ip_find_v6(const struct evpn_local_ip_map *vni_ip_map,
>>> +                      uint32_t vni)
>>> +{
>>> +    const struct in6_addr *addr = 
>>> evpn_local_ip_lookup(&vni_ip_map->vni_ip6,
>>> +                                                       vni);
>>> +    if (addr) {
>>> +        return addr;
>>> +    }
>>> +
>>> +    if (ipv6_addr_is_set(&vni_ip_map->default_ip6)) {
>>> +        return &vni_ip_map->default_ip6;
>>> +    }
>>> +
>>> +    return NULL;
>>> +}
>>> +
>>> +const struct in6_addr *
>>> +evpn_local_ip_map_lookup(const struct evpn_local_ip_map *map,
>>> +                         uint32_t vni, bool ipv4)
>>> +{
>>> +    const struct hmap *hmap = ipv4 ? &map->vni_ip4 : &map->vni_ip6;
>>> +    const struct in6_addr *addr = evpn_local_ip_lookup(hmap, vni);
>>> +    if (addr) {
>>> +        return addr;
>>> +    }
>>> +    const struct in6_addr *def = ipv4 ? &map->default_ip4 : 
>>> &map->default_ip6;
>>> +    return ipv6_addr_is_set(def) ? def : NULL;
>>> +}
>>> +
>>> +void
>>> +evpn_local_ip_map_init(struct evpn_local_ip_map *vni_ip_map,
>>> +                       const struct smap *config)
>>> +{
>>> +    char *tokstr, *token, *ptr0 = NULL;
>>> +
>>> +    const char *local_ip_str = smap_get_def(config, "ovn-evpn-local-ip", 
>>> "");
>>> +    tokstr = xstrdup(local_ip_str);
>>> +    for (token = strtok_r(tokstr, ",", &ptr0); token;
>>> +         token = strtok_r(NULL, ",", &ptr0)) {
>>> +        char *ptr1 = NULL, *vni_str = strtok_r(token, "-", &ptr1);
>>> +        char *ip_str = strtok_r(NULL, "-", &ptr1);
>>> +        struct in6_addr ip;
>>> +        uint32_t vni;
>>> +
>>> +        if (ptr1 && *ptr1) {
>>> +            static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
>>> +            VLOG_WARN_RL(&rl, "Malformed 'ovn-evpn-local-ip': %s", tokstr);
>>> +            break;
>>> +        }
>>> +
>>> +        if (!ip_str) { /* default IP */
>>> +            if (!ip46_parse(vni_str, &ip)) {
>>> +                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> +                VLOG_WARN_RL(&rl, "Invalid IP %s in 'ovn-evpn-local-ip'",
>>> +                             vni_str);
>>> +                continue;
>>> +            }
>>> +            struct in6_addr *ip_ptr = IN6_IS_ADDR_V4MAPPED(&ip)
>>> +                                      ? &vni_ip_map->default_ip4
>>> +                                      : &vni_ip_map->default_ip6;
>>> +            if (ipv6_addr_is_set(ip_ptr)) {
>>> +                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> +                VLOG_WARN_RL(&rl, "Default ovn-evpn-local-ip IP%d "
>>> +                                  "already configured",
>>> +                             IN6_IS_ADDR_V4MAPPED(&ip) ? 4 : 6);
>>> +                continue;
>>> +            }
>>> +            *ip_ptr = ip;
>>> +        } else {
>>> +            if (!ip46_parse(ip_str, &ip)) {
>>> +                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> +                VLOG_WARN_RL(&rl,
>>> +                             "EVPN enabled, but required 
>>> 'ovn-evpn-local-ip' "
>>> +                             "is missing or invalid for vni %s", vni_str);
>>> +                continue;
>>> +            }
>>> +
>>> +            if (!vni_str) {
>>> +                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> +                VLOG_WARN_RL(&rl, "Required 'ovn-evpn-local-ip' VNI not "
>>> +                                  "configured for IP %s",
>>> +                             ip_str);
>>> +                continue;
>>> +            }
>>> +
>>> +            if (!ovs_scan(vni_str, "%u", &vni) || !ovn_is_valid_vni(vni)) {
>>> +                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> +                VLOG_WARN_RL(&rl, "Invalid VNI %s in 'ovn-evpn-local-ip'",
>>> +                             vni_str);
>>> +                continue;
>>> +            }
>>> +
>>> +            struct hmap *map = IN6_IS_ADDR_V4MAPPED(&ip)
>>> +                               ? &vni_ip_map->vni_ip4
>>> +                               : &vni_ip_map->vni_ip6;
>>> +            if (evpn_local_ip_lookup(map, vni)) {
>>> +                static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 
>>> 1);
>>> +                VLOG_WARN_RL(&rl, "Duplicated VNI entry: %s", vni_str);
>>> +                continue;
>>> +            }
>>> +
>>> +            struct vni_local_ip *e = xmalloc(sizeof *e);
>>> +            *e = (struct vni_local_ip) {
>>> +                .vni = vni,
>>> +                .ip = ip,
>>> +            };
>>> +            hmap_insert(map, &e->hmap_node, hash_add(vni, 0));
>>> +        }
>>> +    }
>>> +    free(tokstr);
>>> +}
>>> +
>>> +void
>>> +evpn_local_ip_map_destroy(struct evpn_local_ip_map *map)
>>> +{
>>> +    struct vni_local_ip *e;
>>> +    HMAP_FOR_EACH_POP (e, hmap_node, &map->vni_ip4) {
>>> +        free(e);
>>> +    }
>>> +    hmap_destroy(&map->vni_ip4);
>>> +
>>> +    HMAP_FOR_EACH_POP (e, hmap_node, &map->vni_ip6) {
>>> +        free(e);
>>> +    }
>>> +    hmap_destroy(&map->vni_ip6);
>>> +}
>>> diff --git a/lib/ovn-util.h b/lib/ovn-util.h
>>> index 29f6bb739..65aa53e75 100644
>>> --- a/lib/ovn-util.h
>>> +++ b/lib/ovn-util.h
>>> @@ -831,4 +831,29 @@ parse_neigh_dynamic_redistribute(const struct smap 
>>> *options);
>>>   
>>>   size_t *shuffled_range(size_t n);
>>>   
>>> +struct vni_local_ip {
>>> +    struct hmap_node hmap_node;
>>> +    struct in6_addr ip;
>>> +    uint32_t vni;
>>> +};
>>> +
>>> +struct evpn_local_ip_map {
>>> +    struct hmap vni_ip4;         /* Per VNI local IPv4 vni_local_ips. */
>>> +    struct hmap vni_ip6;         /* Per VNI local IPv6 vni_local_ips. */
>>> +    struct in6_addr default_ip4; /* Default local IPv4. */
>>> +    struct in6_addr default_ip6; /* Default local IPv6. */
>>> +};
>>> +
>>> +const struct in6_addr *evpn_local_ip_lookup(const struct hmap *map,
>>> +                                            uint32_t vni);
>>> +ovs_be32 evpn_local_ip_find_v4(const struct evpn_local_ip_map *vni_ip_map,
>>> +                               uint32_t vni);
>>> +const struct in6_addr *evpn_local_ip_find_v6(
>>> +    const struct evpn_local_ip_map *vni_ip_map, uint32_t vni);
>>> +void evpn_local_ip_map_init(struct evpn_local_ip_map *vni_ip_map,
>>> +                            const struct smap *config);
>>> +const struct in6_addr *
>>> +evpn_local_ip_map_lookup(const struct evpn_local_ip_map *map,
>>> +                         uint32_t vni, bool ipv4);
>>> +void evpn_local_ip_map_destroy(struct evpn_local_ip_map *map);
>>>   #endif /* OVN_UTIL_H */
>>> diff --git a/northd/en-datapath-logical-switch.c 
>>> b/northd/en-datapath-logical-switch.c
>>> index ad6d042bc..a1ba217ba 100644
>>> --- a/northd/en-datapath-logical-switch.c
>>> +++ b/northd/en-datapath-logical-switch.c
>>> @@ -180,6 +180,20 @@ gather_external_ids(const struct nbrec_logical_switch 
>>> *nbs,
>>>               smap_add(external_ids, "dynamic-routing-arp-prefer-local",
>>>                        prefer_evpn_arp_local);
>>>           }
>>> +
>>> +        const char *ovn_maintain_evpn_mode =
>>> +            smap_get(&nbs->other_config, "dynamic-routing-maintain-evpn");
>>> +        if (ovn_maintain_evpn_mode) {
>>> +            smap_add(external_ids, "dynamic-routing-maintain-evpn",
>>> +                     ovn_maintain_evpn_mode);
>>> +        }
>>> +
>>> +        const char *ovn_maintain_evpn_lrp =
>>> +            smap_get(&nbs->other_config, 
>>> "dynamic-routing-maintain-evpn-lrp");
>>> +        if (ovn_maintain_evpn_lrp) {
>>> +            smap_add(external_ids, "dynamic-routing-maintain-evpn-lrp",
>>> +                     ovn_maintain_evpn_lrp);
>>> +        }
>>>       }
>>>   
>>>       /* For backwards-compatibility, also store the NB UUID in
>>> diff --git a/ovn-nb.xml b/ovn-nb.xml
>>> index b0bb8c264..03d6536bb 100644
>>> --- a/ovn-nb.xml
>>> +++ b/ovn-nb.xml
>>> @@ -948,15 +948,30 @@
>>>           </p>
>>>   
>>>           <p>
>>> -          All of the following configuration options must also be provided
>>> -          in order for the configuration to be valid:
>>> -          <ref column="other_config" key="dynamic-routing-bridge-ifname"
>>> -               table="Logical_switch"/>,
>>> -          <ref column="other_config" key="dynamic-routing-vxlan-ifname"
>>> -               table="Logical_switch"/>,
>>> -          <ref column="other_config" key="dynamic-routing-advertise-ifname"
>>> -               table="Logical_switch"/>.
>>> +          The EVPN interfaces (VXLAN, bridge and advertise devices) can
>>> +          either be named explicitly by the CMS, or auto-created and
>>> +          maintained by <code>ovn-controller</code>:
>>>           </p>
>>> +
>>> +        <ul>
>>> +          <li>
>>> +            To name the interfaces explicitly, all of the following
>>> +            configuration options must also be provided in order for the
>>> +            configuration to be valid:
>>> +            <ref column="other_config" key="dynamic-routing-bridge-ifname"
>>> +                 table="Logical_switch"/>,
>>> +            <ref column="other_config" key="dynamic-routing-vxlan-ifname"
>>> +                 table="Logical_switch"/>,
>>> +            <ref column="other_config" 
>>> key="dynamic-routing-advertise-ifname"
>>> +                 table="Logical_switch"/>.
>>> +          </li>
>>> +          <li>
>>> +            To have <code>ovn-controller</code> create and maintain the
>>> +            interfaces automatically instead, set
>>> +            <ref column="other_config" key="dynamic-routing-maintain-evpn"
>>> +                 table="Logical_switch"/>.
>>> +          </li>
>>> +        </ul>
>>>         </column>
>>>   
>>>         <column name="other_config" key="dynamic-routing-bridge-ifname">
>>> @@ -979,6 +994,63 @@
>>>                                table="Logical_switch"/> is set to valid VNI.
>>>         </column>
>>>   
>>> +     <column name="other_config" key="dynamic-routing-maintain-evpn"
>>> +                   type='{"type": "string", "enum": ["set", ["mvd"]]}'>
>>> +        <p>
>>> +          Enables automatic management of the EVPN interface stack by
>>> +          <code>ovn-controller</code> for the VNI specified by
>>> +          <ref column="other_config" key="dynamic-routing-vni"
>>> +          table="Logical_switch"/>.
>>> +          Instead of requiring the CMS to create and configure the 
>>> interfaces,
>>> +          <code>ovn-controller</code> creates and maintains them on every
>>> +          chassis where the logical switch is local.
>>> +        </p>
>>> +
>>> +        <p>
>>> +        The only supported value is <code>mvd</code> (VXLAN device per 
>>> VNI).
>>> +        </p>
>>> +
>>> +        <p>
>>> +          In this mode, <code>ovn-controller</code> creates the following
>>> +          interfaces, each suffixed with the VNI:
>>> +          <code>ovnbr&lt;vni&gt;</code>,
>>> +          <code>ovnvxlan&lt;vni&gt;</code>, and
>>> +          <code>ovnlo&lt;vni&gt;</code>
>>> +          (for example, <code>ovnvxlan100</code>).
>>> +        </p>
>>> +
>>> +        <p>
>>> +          If <ref column="other_config" key="dynamic-routing-redistribute"
>>> +          table="Logical_switch"/> includes <code>ip</code> and
>>> +          <ref column="other_config" 
>>> key="dynamic-routing-maintain-evpn-lrp"
>>> +          table="Logical_switch"/> is configured,
>>> +          <code>ovn-controller</code> also creates a VRF named
>>> +          <code>ovnvrf&lt;vni&gt;</code> and enslaves the bridge interface
>>> +          to it. This allows the logical router IP addresses to be 
>>> advertised
>>> +          through EVPN.
>>> +        </p>
>>> +      </column>
>>> +
>>> +      <column name="other_config" key="dynamic-routing-maintain-evpn-lrp">
>>> +        <p>
>>> +          Specifies the
>>> +          <ref table="Logical_Router_Port"/> whose MAC address is assigned
>>> +          to the auto-created bridge interface managed by
>>> +          <ref column="other_config" key="dynamic-routing-maintain-evpn"
>>> +          table="Logical_switch"/>.
>>> +          This ensures that L3/IRB traffic terminating on the EVPN VNI uses
>>> +          the logical router's MAC address.
>>> +        </p>
>>> +
>>> +        <p>
>>> +          This option is required only when
>>> +          <ref column="other_config" key="dynamic-routing-maintain-evpn"
>>> +          table="Logical_switch"/> is enabled and
>>> +          <ref column="other_config" key="dynamic-routing-redistribute"
>>> +          table="Logical_switch"/> includes <code>ip</code>.
>>> +        </p>
>>> +      </column>
>>> +
>>>         <column name="other_config" key="dynamic-routing-fdb-prefer-local"
>>>                 type='{"type": "boolean"}'>
>>>           <p>
>>> diff --git a/tests/automake.mk b/tests/automake.mk
>>> index 8084357b5..1aa9f45c2 100644
>>> --- a/tests/automake.mk
>>> +++ b/tests/automake.mk
>>> @@ -291,6 +291,8 @@ tests_ovstest_SOURCES = \
>>>   
>>>   if HAVE_NETLINK
>>>   tests_ovstest_SOURCES += \
>>> +   controller/netlink-utils.h \
>>> +   controller/netlink-utils.c \
>>>     controller/host-if-monitor.c \
>>>     controller/host-if-monitor.h \
>>>     controller/neighbor-exchange-netlink.c \
>>> diff --git a/tests/system-ovn.at b/tests/system-ovn.at
>>> index 17b4dcb3d..87e0e58a2 100644
>>> --- a/tests/system-ovn.at
>>> +++ b/tests/system-ovn.at
>>> @@ -21998,3 +21998,189 @@ OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query port 
>>> patch-.*/d
>>>   
>>>   AT_CLEANUP
>>>   ])
>>> +
>>> +OVN_FOR_EACH_NORTHD([
>>> +AT_SETUP([dynamic-routing - EVPN maintain-evpn mvd])
>>> +AT_KEYWORDS([dynamic-routing evpn maintain-evpn])
>>> +
>>> +CHECK_VRF()
>>> +
>>> +vni=100
>>> +VRF_RESERVE([$vni])
>>> +
>>> +ovn_start
>>> +OVS_TRAFFIC_VSWITCHD_START()
>>> +ADD_BR([br-int])
>>> +
>>> +# Create a fake VXLAN interface, since there's no real peer chassis in
>>> +# this test to make OVS create vxlan_sys_<port> automatically.
>>> +ip link add vxlan_sys_4789 type vxlan dstport 4789 external
>>> +ip link set vxlan_sys_4789 up
>>> +
>>> +check ovs-vsctl \
>>> +    -- set Open_vSwitch . external-ids:system-id=hv1 \
>>> +    -- set Open_vSwitch . 
>>> external-ids:ovn-remote=unix:$ovs_base/ovn-sb/ovn-sb.sock \
>>> +    -- set Open_vSwitch . external-ids:ovn-encap-type=geneve \
>>> +    -- set Open_vSwitch . external-ids:ovn-encap-ip=169.0.0.1 \
>>> +    -- set Open_vSwitch . external-ids:ovn-evpn-local-ip=169.0.0.1 \
>>> +    -- set bridge br-int fail-mode=secure other-config:disable-in-band=true
>>> +
>>> +start_daemon ovn-controller
>>> +ovs-vsctl add-port br-int p1 -- \
>>> +    set Interface p1 external_ids:iface-id=ls-p1 -- \
>>> +    set Interface p1 type=internal
>>> +check ovn-nbctl ls-add ls-evpn
>>> +check ovn-nbctl lsp-add ls-evpn ls-p1
>>> +check ovn-nbctl lsp-set-addresses ls-p1 "00:00:00:00:00:02 20.20.20.2"
>>> +
>>> +AS_BOX([Create EVPN interface stack - L2 VNI])
>>> +check ovn-nbctl \
>>> +    -- set Logical_Switch ls-evpn \
>>> +            other_config:dynamic-routing-vni=$vni \
>>> +            other_config:dynamic-routing-maintain-evpn=mvd \
>>> +            other_config:dynamic-routing-redistribute=fdb
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +# check no vrf exists since it's only fdb mode
>>> +AT_CHECK([ip a | grep -q ovnvrf100], [1])
>>> +AT_CHECK([ip a | grep -q ovnbr100], [0])
>>> +AT_CHECK([ip a | grep -q ovnvxlan100], [0])
>>> +AT_CHECK([ip a | grep -q ovnlo100], [0])
>>> +
>>> +dstport=$((60000 + $vni))
>>> +AT_CHECK([ip -d link show ovnvxlan$vni | grep -oP ' id \K[[0-9]]+'], [0], 
>>> [dnl
>>> +100
>>> +], [ignore])
>>> +
>>> +AT_CHECK([ip -d link show ovnvxlan$vni | grep -oP 'dstport 
>>> \K[[0-9]]+'],[0], [dnl
>>> +60100
>>> +], ignore)
>>> +
>>> +AT_CHECK([ip -d link show ovnvxlan$vni | grep -q 'local 169.0.0.1'], [0], 
>>> [ignore], [ignore])
>>> +
>>> +AT_CHECK([ip -d link show ovnvxlan$vni | grep -q 'nolearning'], [0], 
>>> [ignore], [ignore])
>>> +
>>> +AT_CHECK([ip link show ovnvxlan$vni | grep -oP 'master \K\S+'],[0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnlo$vni | grep -oP 'master \K\S+'],[0], [dnl
>>> +ovnbr100
>>> +])
>>> +
>>> +AS_BOX([Bridge MAC via dynamic-routing-evpn-lrp])
>>> +# Add LRP connected to ls-evpn; its MAC/IP will be applied to the bridge.
>>> +check ovn-nbctl \
>>> +    -- lr-add R-evpn \
>>> +    -- lrp-add R-evpn rp-evpn 00:00:00:aa:bb:01 192.168.100.1/24 \
>>> +    -- lsp-add ls-evpn ls-evpn-rp \
>>> +    -- set Logical_Switch_Port ls-evpn-rp type=router \
>>> +            options:router-port=rp-evpn \
>>> +    -- lsp-set-addresses ls-evpn-rp router \
>>> +    -- set Logical_Switch ls-evpn \
>>> +            other_config:dynamic-routing-maintain-evpn-lrp=rp-evpn \
>>> +            other_config:dynamic-routing-redistribute=fdb,ip
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +# check vrf exists since it's fdb + ip mode
>>> +AT_CHECK([ip a | grep -q ovnvrf100], [0])
>>> +AT_CHECK([ip a | grep -q ovnbr100], [0])
>>> +AT_CHECK([ip a | grep -q ovnvxlan100], [0])
>>> +AT_CHECK([ip a | grep -q ovnlo100], [0])
>>> +
>>> +AT_CHECK([ip link show ovnlo$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnvxlan$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnbr$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnvrf100
>>> +])
>>> +
>>> +AT_CHECK([ip link show ovnbr$vni | grep -oP 'link/ether \K\S+'], [0], [dnl
>>> +00:00:00:aa:bb:01
>>> +])
>>> +
>>> +AS_BOX([Bridge MAC address changes])
>>> +check ovn-nbctl set Logical_Router_Port rp-evpn \
>>> +    mac='"00:00:00:00:00:08"'
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +AT_CHECK([ip link show ovnbr$vni | grep -oP 'link/ether \K\S+'],[0], [dnl
>>> +00:00:00:00:00:08
>>> +])
>>> +
>>> +AS_BOX([Redistribute mode change fdb,ip -> fdb: VRF destroyed, lo kept])
>>> +check ovn-nbctl set Logical_Switch ls-evpn \
>>> +    other_config:dynamic-routing-redistribute=fdb
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +# check vrf exists since it's fdb + ip mode
>>> +AT_CHECK([ip a | grep -q ovnvrf100], [1])
>>> +AT_CHECK([ip a | grep -q ovnbr100], [0])
>>> +AT_CHECK([ip a | grep -q ovnvxlan100], [0])
>>> +AT_CHECK([ip a | grep -q ovnlo100], [0])
>>> +
>>> +AT_CHECK([ip link show ovnlo$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnvxlan$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnbr100
>>> +])
>>> +
>>> +AS_BOX([Redistribute mode change fdb -> ip: lo destroyed, VRF recreated])
>>> +check ovn-nbctl set Logical_Switch ls-evpn \
>>> +    other_config:dynamic-routing-redistribute=ip
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +AT_CHECK([ip a | grep -q ovnvrf100], [0])
>>> +AT_CHECK([ip a | grep -q ovnbr100], [0])
>>> +AT_CHECK([ip a | grep -q ovnvxlan100], [0])
>>> +AT_CHECK([ip a | grep -q ovnlo100], [1])
>>> +AT_CHECK([ip link show ovnvxlan$vni | grep -oP 'master \K\S+'],[0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnbr$vni | grep -oP 'master \K\S+'],[0], [dnl
>>> +ovnvrf100
>>> +])
>>> +
>>> +AS_BOX([Redistribute mode change ip -> fdb,ip: lo and VRF both present 
>>> again])
>>> +check ovn-nbctl set Logical_Switch ls-evpn \
>>> +    other_config:dynamic-routing-redistribute=fdb,ip
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +AT_CHECK([ip a | grep -q ovnvrf100], [0])
>>> +AT_CHECK([ip a | grep -q ovnbr100], [0])
>>> +AT_CHECK([ip a | grep -q ovnvxlan100], [0])
>>> +AT_CHECK([ip a | grep -q ovnlo100], [0])
>>> +
>>> +AT_CHECK([ip link show ovnlo$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnvxlan$vni | grep -oP 'master \K\S+'], [0], [dnl
>>> +ovnbr100
>>> +])
>>> +AT_CHECK([ip link show ovnbr$vni | grep -oP 'master \K\S+'],[0], [dnl
>>> +ovnvrf100
>>> +])
>>> +
>>> +AS_BOX([Remove maintain-evpn - full stack cleanup])
>>> +check ovn-nbctl \
>>> +    -- remove Logical_Switch ls-evpn other_config 
>>> dynamic-routing-maintain-evpn
>>> +check ovn-nbctl --wait=hv sync
>>> +
>>> +AT_CHECK([ip a | grep -q ovnvrf100], [1])
>>> +AT_CHECK([ip a | grep -q ovnbr100], [1])
>>> +AT_CHECK([ip a | grep -q ovnvxlan100], [1])
>>> +AT_CHECK([ip a | grep -q ovnlo100], [1])
>>> +
>>> +ip link del vxlan_sys_4789
>>> +
>>> +OVN_CLEANUP_CONTROLLER([hv1])
>>> +OVN_CLEANUP_NORTHD
>>> +as
>>> +OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query port patch-.*/d
>>> +/connection dropped.*/d
>>> +/misses dynamic-routing.*/d"])
>>> +AT_CLEANUP
>>> +])
> 
> 

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

Reply via email to