VPP and eVPN/VxLAN - Part 2

Introduction

VPP

You know what would be really cool? If VPP could be an eVPN/VxLAN speaker! Sometimes I feel like I’m the very last on the planet to learn about something cool. My latest “A-Ha!"-moment was when I was configuring the eVPN fabric for [Frys-IX], and I wrote up an article about it [here] back in April.

I can build the equivalent of Virtual Private Wires (VPWS), also called L2VPN or Virtual Leased Lines, and these are straightforward because they typically only have two endpoints. A “regular” VxLAN tunnel which is L2 cross connected with another interface already does that just fine. Take a look at an article on [L2 Gymnastics] for that. But the real kicker is that I can also create multi-site L2 domains like Virtual Private LAN Services (VPLS) or also called Virtual Private Ethernet, L2VPN or Ethernet LAN Service (E-LAN). And that is a whole other level of awesome.

In a [previous article], which was almost a year ago (!), I did a lay-of-the-land on the moving parts in VPP that would be needed to create an L2 eVPN. In this article, I’ll do a deep dive on the controlplane parts. It’s timely, because Bird 2.19.0 [released], which includes eth route table and synchronization of those tables over BGP. I’ve been working on an integration of Bird2 with VPP - this article tracks my journey. It’s very much about the code, and serves as well for my own benefit, as Future-Me may completely forget how I figured all of this plumbing out.

Bird2: eVPN implementation

Bird logo

I have been following the eVPN journey for quite a while, starting with the evpn branch about two years ago, but it was on a very old Bird branch. Eventually, I rebased it to 2.18 with the help of Claude, and started tinkering with a vppevpn protocol in March of 2026. After discussion on the mailing list, I was recommended to switch to the oz-evpn branch, which was the chosen direction of the Bird team. Then, in May of 2026, the Bird team merged that branch into master and published eVPN support starting from Bird 2.19 and Bird 3.2. That’s a perfect time for me to share my work, after rebasing it on the latest release Bird 2.19.0.

Bird2: VPP eVPN protocol

First, let me spend some time understanding the eVPN implementation itself. There are two new tables, one is the ethernet table or etab for short, and the other is an eVPN table or evpntab for short.

Bird2: eth table

Unsurprisingly, the keys on this table are MAC addresses with optional 802.1Q tag (defaulting to 0, meaning untagged), and the values are metadata belonging to such entries such as the default Route Table Attributes (source, scope, dest, pref, interface/gateway/labels). For a local bridge entry the main attribute is just an interface; for a tunnel entry it’s the VTEP IP + VNI, in other words the information that allows us to know how to reach that {MAC,VID} entry.

Bird2: evpn table

When speaking BGP to learn eVPN routes, a new table is introduced, the aptly named eVPN Table. This is the RIB that represents everything we know about the eVPN deployment. It holds routes of type NET_EVPN which is a union over the route types defined in [RFC 7432]. The key in this table is a (subtype, length, etag, route-discriminator) tuple. The Route Discriminator allows the entries to be namespaced, say “eVPN Test” gets RD (65500,1000), while “eVPN Prod” gets RD (65500,1001).

Quick inspection of the codebase shows that EAD (Type 1: ethernet autodiscovery), MAC (Type-2), IMET (Type-3) and ES (Type 4: Ethernet Segment) are defined, but not the common Type 5 (L3VPN prefix). That’s perfectly fine for me, I’m interested in L2VPNs only, so the two relevant subtypes are 2 and 3 only. The values here are things like the BGP Next Hop (which will be the VTEP endpoint), the MPLS Label Stack (which, in VxLAN/eVPN is used to relay the VNI), and two pertinent BGP Extended Communities, one for the route-targets and one for the encapsulation type.

I puzzled on this for a little bit, but finally took away that ‘RD’ is the namespace for an eVPN, to make everything in it globally unique. The ‘RT’ is the selector in such a namespace which entries belong to which layer-2 domains. The IMET (Type 3) routes also have one specific extra field which is the so-called Provider Multicast Service Interface or PMSI, which tells the RIB for a given participating router, where to send the Broadcast, Unknown Unicast, and Multicast or BUM traffic. It’s a tuple of (IP,VNI) for VxLAN tunnels.

Bird2: eVPN config

There is a new protocol in Bird2, that participates in the iBGP or eBGP synchronization of this eVPN RIB. The relevant configuration is:

protocol evpn [name] {
  ethernet { ... };                # channel, net_type NET_ETH
  evpn     { ... };                # channel, net_type NET_EVPN

  rd <vpn_rd>;                     # route distinguisher: 'IP:n' or 'ASN:n' format
  route target <ec>;               # sets BOTH import + export
  import target <ec | [ec,...]>;   # - more specific import
  export target <ec | [ec,...]>;   # - more specific export
  vni <expr>;
  vid <expr>;                      # Optional VLAN tag 0–4095
  tag <expr>;                      # Globally unique bird 'tag'
  encapsulation vxlan { ... };     # The (Linux) Tunnel to use, at least one, can be multiple
  vlan <id> { ... };               # Definitions of VLAN-aware bundles
}

The encapsulation takes a tunnel device like vxlan0, and a router address like 2001:db8::1 or 192.0.2.1 and will model this router’s originating PMSI endpoint(s). The vlan stanzas allow for VLAN-aware bundles, which define ranges of VLANs that map to their own VNI and VID in the table, it takes either a simple or a range form:

  vlan 1000;
  vlan 2000 {
    range 100;
    vni 8298;
    vid 10;
  };

The second format maps VLANs 2000-2099 to VNI 8298 and puts them in the RIB as vid (dot1q-tag) 10.

Bird2: eVPN action

I decide to wire this up to the three eVPN iBGP route-reflectors. Here’s where it becomes obvious for me. The route-reflectors are taking information from a fleet of Centec, Arista and Nokia routers. Attaching to them works intuitively:

pim@dpu0-ddln0:~$ cat /etc/bird/core/evpn.conf
evpn table evpntab;

template bgp T_EVPN {
  local as 65500; neighbor as 65500; source address 198.19.0.64;
  evpn { table evpntab; import all; export all; };
};

protocol bgp evpn0_nlams0 from T_EVPN { neighbor 198.19.4.101; }
protocol bgp evpn0_chplo0 from T_EVPN { neighbor 198.19.4.174; }
protocol bgp evpn0_chbtl0 from T_EVPN { neighbor 198.19.6.232; }

And let’s say that I want to see what’s in an eVPN with route discriminator 65500:1000 and route-target 65500:1000 using VNI 1000, and put all of those into an ethernet table called etab_test, I would configure it as such:

pim@dpu0-ddln0:~$ cat /etc/bird/evpn/test.conf
eth table etab_test;

protocol evpn evpn_test {
  eth  { table etab_test; };
  evpn { table evpntab; import all; export all; };
  rd 65500:1000;
  route target (rt, 65500, 1000);
  encapsulation vxlan { tunnel device "vxlan0"; router address 198.19.0.64; };
  vni 1000;
};

There is one annoying problem. I don’t have an interface called vxlan0 at all. I can create a dummy tunnel but I’m not intending on using Linux at all for the encap/decap, yet Bird demands the tunnel device and router address. So I’ll make a tiny patch that allows that interface to not exist, by adding a evpn_no_iface_startup() to proto/evpn/evpn.c in commit #47848f92. This way, the device is not required, no VLANs and Bridges are created in Linux, and no IMET is announced from this node. It’s a small but vital change in my journey to use Bird with VPP’s dataplane, and it’s the only change I have to make to Bird itself. All the rest, as I’ll show in the rest of this document, is completely isolated in its own protocol, and makes no further changes to Bird.

With that, bird starts, and slurps in the eVPN RIB from the route reflectors:

BIRD 2.19.0+branch.vppevpn.40c59b2f4812 ready.
Name       Proto      Table      State  Since         Info
evpn0_nlams0 BGP        ---        up     2026-06-05 18:59:17  Established   
  BGP state:          Established
    Neighbor address: 198.19.4.101
    Neighbor AS:      65500
    Local AS:         65500
    Neighbor ID:      198.19.4.101
    Local capabilities
      Multiprotocol
        AF announced: evpn
      Route refresh
      Graceful restart
      4-octet AS numbers
      Enhanced refresh
      Long-lived graceful restart
    Neighbor capabilities
      Multiprotocol
        AF announced: evpn
      Route refresh
      Extended message
      Graceful restart
      4-octet AS numbers
      ADD-PATH
        RX: evpn
        TX:
      Enhanced refresh
      Long-lived graceful restart
        LL stale time: 0
        AF supported:
        AF preserved: evpn
      Hostname: evpn0-nlams0
    Session:          internal multihop AS4
    Source address:   198.19.0.64
    Hold timer:       5.895/9
    Keepalive timer:  1.999/3
    Send hold timer:  13.992/18
  Channel evpn
    State:          UP
    Table:          evpntab
    Preference:     100
    Input filter:   ACCEPT
    Output filter:  ACCEPT
    Routes:         163 imported, 0 exported, 163 preferred
    Route change stats:     received   rejected   filtered    ignored   accepted
      Import updates:           4422          0          0        703       3719
      Import withdraws:         2725          0        ---         30       3027
      Export updates:          12119      12099          0        ---         20
      Export withdraws:         2480        ---        ---        ---          8
    BGP Next hop:   198.19.0.64
    IGP IPv4 table: master4
    IGP IPv6 table: master6

I can see we learned 163 routes into evpntab. Notably these are the IMET and MAC addresses for the route-target 65500:1000 which is a test eVPN I have running around at IPng.

Bird2: Adding a new ‘protocol’

Bird lays out its code in protocols, like the BGP or OSPF protocol, or the EVPN protocol I just wrote about above. Adding a new one needs three things:

  1. An update to configure.ac to add it to all_protocols so that it is compiled and linked in.
  2. Addition to nest/protocol.h in enum protocol_class, and the struct protocol list, so that Bird itself becomes aware of its existence. In nest/route.h I need to add a new route source constant, which I’ll do before #define RTS_MAX, and I’ll give this thing a name in nets/rt-attr.c in the constant rta_src_names.
  3. The protocol itself, which I’ll put in proto/vppevpn/ starting off with its Makefile.

While I’m here, I add a flag --with-vpp to the configure script, because I’m going to need the vppinfra library, which turns on/off the protocol at build time. This way, my change is completely non-intrusive for non VPP users. I have to say I love what I see, the plumbing is only 25 lines of code. It means I can track bird2 upstream changes safely - and that comes in handy as during the last few months I’ve seen bird2 rebase three times before it got released as 2.19.0 in May. Each time, pulling back my code to sit on top of HEAD, or the oz-evpn versus master branch was a breeze.

Bird: Anatomy of vppevpn

Config Parser

In Bird2, each protocol comes with its own configuration representation. There’s a common parser which declares its symbols and grammar and ends up building a struct in C, in my case called struct vppevpn_config. The main language is:

CF_KEYWORDS(VPPEVPN, VXLAN, SRC, DST, BRIDGE, DOMAIN, SCAN, TIME)

vppevpn_proto:
  vppevpn_proto_start proto_name '{' vppevpn_proto_opts '}';

vppevpn_proto_start: proto_start VPPEVPN
{
  this_proto = proto_config_new(&proto_vppevpn, $1);
  this_proto->net_type = NET_ETH;

  VPPEVPN_CFG->vxlan_src_port = 4789;
  VPPEVPN_CFG->vxlan_dst_port = 4789;
  VPPEVPN_CFG->scan_time = 10 S_;
};

vppevpn_proto_opts:
 | vppevpn_proto_opts vppevpn_proto_item ';'
 ;

vppevpn_proto_item:
   proto_item
 | proto_channel { $1->ra_mode = RA_ANY; }
 | VXLAN IPV4 SRC ipa { VPPEVPN_CFG->vxlan_src4 = $4; }
 | VXLAN IPV6 SRC ipa { VPPEVPN_CFG->vxlan_src6 = $4; }
 | VXLAN SRC PORT expr { VPPEVPN_CFG->vxlan_src_port = $4; }
 | VXLAN DST PORT expr { VPPEVPN_CFG->vxlan_dst_port = $4; }
 | BRIDGE DOMAIN expr { VPPEVPN_CFG->bridge_domain = $3; }
 | SCAN TIME expr { VPPEVPN_CFG->scan_time = $3 S_; }
 ;

The protocol is defined by the first stanza, it gets a name, and then a bunch of options between curly braces. Those opts themselves are zero or more item which use the keywords I defined at the very top, and each item ends with a semicolon. The one thing I had to figure out, is the proto_channel, which allows me to reference other protocols and tables. The concept of a channel in Bird is the exchange of information from the RIB to and from a protocol with the use of Bird’s filtering framework. In my case, I’m interested in these new eth tables which carry MAC addresses and their endpoints learned from BGP.

This configuration parser allows me to construct a new protocol with a bunch of options that will be relevant for Bird to be able to program the VPP dataplane.

Putting it all together, the following protocol becomes valid in bird.conf:

eth table etab_test;

protocol vppevpn vppevpn_test {
  eth { table etab_test; import all; export all; };
  vxlan ipv4 src 198.19.0.64;
  bridge domain 1000;
  scan time 300;
};

Bird: Protocol lifecycle

Statically configured in proto/vppevpn/vppevpn.c I’ll first add the function table for my new protocol type. This table tells bird which code to execute on Bird protocol lifecycle concepts:

  • init: A new protocol instance is created
  • channel_mask: Tells Bird what types of channel (eg ipv4 or ipv6 or eth) are permissible in this protocol. I’ll set it to NB_ETH.
  • start / shutdown: The protocol is started or stopped
  • reconfigure: The bird.conf protocol changed, and I need to reconcile it with what’s running
  • show_proto_info: The user calls birdc show proto X and I can display operational information on the protocol and its runtime state

When Bird creates a new protocol, it’ll execute its initializer, and when the protocol is initialized, it’ll issue the start method. When the protocol needs to be torn down, it’ll issue the shutdown method. If the operator disables a protocol, it will also be issued a shutdown, and when the operator enables it again, it’ll be issued a start. These are all the hooks I need.

vppevpn: Handling Bridge Domain

eVPN needs a bridge-domain for two purposes. Firstly, it needs to be able to construct an L2FIB in which remote VTEP endpoints can be forwarded ethernet frames for MAC addresses that belong to them. Secondly, VPP needs to learn local MAC addresses, those that are connected to the bridge-domain locally, so that it can announce them in BGP, and start having other speakers forward ethernet frames for our MAC addresses to VPP.

Before I get ahead of myself let me create a bridge-domain when the protocol starts, and tear it down when the protocol stops. I implement that in vppevpn_add_bridge_domain() and vppevpn_del_bridge_domain() respectively.

But how do I avoid cycles? I pause to think about it for a minute, and realize that those MAC addresses that I learn from eVPN will always be behind a VxLAN tunnel, and those that I want to announce will always be behind either a regular L2 interface on my side, or a Bridge Virtual Interface (BVI) also sometimes called an Integrated Routing Bridging (IRB) device. VPP uses the former, so I’ll call them BVIs. The BVI is an L3 device with a MAC address that I can add to the bridge - and it’s clear that its MAC address should be announced in BGP.

VPP supports a few key concepts worth explaining in detail, as it took me a while to wrap my head around it all. Firstly, I must tell VPP to never flood ARP requests into VxLAN tunnels. The way other eVPN speakers learn our MACs is via BGP, not via BUM flooding. In the same vein, I must configure VPP never to learn MAC addresses from VxLAN tunnels, because we ourselves will be learning remote MACs via BGP too. As it turns out, VPP supports configuration options for both directions, but they are subtly different:

MAC addresses: Local Interfaces

I discover a concept called Split Horizon Groups, a packet received on an interface whose split-horizon group is N will not be flooded back out of any other interface that is also in group N. Flooding for broadcast, unknown-unicast, and multicast (BUM traffic) only goes to interfaces in different groups. In VPP, SHG of 0 is special: interfaces in group 0 can flood to each other freely. The split-horizon suppression only applies among interfaces sharing the same non-zero group ID.

The short-and-sweet of this is that I can add my own interfaces to the bridge-domain in SHG 0, and their ARPs and Neighbor Discovery etc will see each other freely. But I will add the VxLAN tunnels to the bridge-domain in SHG 1, and those ARPs will not be flooded to them. All I need to do now is periodically scan the L2FIB for MAC addresses learned on my own interfaces, and tell Bird about them, so it can announce them in BGP. tadah.wav

MAC addresses: Remote Interfaces

Even though I put VxLAN tunnels in a different Split Horizon Group, VPP will still try to learn addresses from them. Learning in this context means, once an ethernet frame enters a port, I can know that this MAC address lives ‘behind that port’ - this is perfectly normal switch behavior - but in this case, I neither want to learn nor flood to VxLAN ports. They need to be considered as dumb wires between VxLAN speakers. I can only use them to encapsulate an ethernet frame going to a known unicast MAC address, encap the frame into a VxLAN tunnel destined for the remote VTEP.

Tying both concepts together, something like:

create bridge-domain 1000 learn 1 uu-flood 0
set interface l2 bridge HundredGigabitEthernet3/0/0 1000
set interface l2 bridge HundredGigabitEthernet3/0/1 1000

create loopback instance 1000 mac 02:82:98:00:00:01
set interface l2 bridge loop1000 1000 bvi

create vxlan tunnel instance 0 src 198.19.0.64 dst 198.19.0.9 vni 1000 decap-next l2
set interface l2 learn vxlan_tunnel0 disable
set interface l2 flood vxlan_tunnel0 disable
set interface l2 bridge vxlan_tunnel0 1000 1

create vxlan tunnel instance 1 src 198.19.0.64 dst 198.19.1.16 vni 1000 decap-next l2
set interface l2 learn vxlan_tunnel1 disable
set interface l2 flood vxlan_tunnel1 disable
set interface l2 bridge vxlan_tunnel1 1000 1

To recap, I first create the bridge and turn off unknown unicast flooding. All MACs will be resolved either locally, learned on the bridge to physical network devices connected to it and stored in the L2FIB as dynamic entries, or remotely, learned via Bird’s vppevpn protocol and installed as static L2FIB entries.

The second stanza adds a BVI, an L2 loopback interface with a pre-configured MAC address. I could put an IPv4/IPv6 address on that, and it would become visible in the bridge-domain for the machines connected behind Hu3/0/0 and Hu3/0/1.

Then, I create two VxLAN tunnels to remote VTEPs using VNI 1000, and because MAC addresses are statically configured by the Bird vppevpn protocol, I will turn off learning and flooding on these tunnel interfaces, finally adding them to the bridge-domain in a common Split Horizon Group, which means that BUM traffic will not be propagated between the VxLAN tunnels, creating solid isolation.

The main takeaways after all of this:

  1. set interface l2 flood ... disable on each tunnel: stops BUM traffic from being flooded out the tunnels (toward the remote VTEPs). This is what prevents the physicals’ broadcasts from reaching the VXLAN tunnels.
  2. Common SHG on both tunnels: stops BUM traffic arriving from one tunnel from being re-flooded out the other tunnel — the EVPN/VPLS full-mesh split-horizon rule.

vppevpn: Learning Type-3 (IMET)

With that out of the way, I am ready to automate the creation of these VxLAN tunnels. In eVPN, those tunnels are represented by Inclusive Multicast Ethernet Tag or IMET routes, and they’re defined in [RFC 7432], which I read up on. One noteworthy find is the Provider Multicast Service Interface or PMSI attribute, which governs how BUM traffic is delivered to the router. Looking at Bird’s evpntab table, here’s one such entry:

Table evpntab:
evpn imet 65500:1000 0 198.19.0.9  [evpn0_nlams0 2026-06-05 18:59:19 from 198.19.4.101] * (100) [i]
        Type: BGP univ
        BGP.origin: IGP
        BGP.as_path: 
        BGP.next_hop: 198.19.0.9
        BGP.local_pref: 100
        BGP.originator_id: 198.19.0.9
        BGP.cluster_list: 198.19.4.101
        BGP.ext_community: (rt, 65500, 1000) (generic, 0x30c0000, 0x8)
        BGP.pmsi_tunnel: ingress-replication 198.19.0.9 mpls 1000

The evpntab entry has everything I need to construct the tunnel, the ingress-replication field shows the VTEP endpoint and the mpls field shows either an MPLS label, or a VxLAN VNI, and I can tell the difference because of the presence of the BGP community (generic, 0x30c0000, 0x8), which gives me pause. Decoding it 0x03 0x0c 0x00 0x00 0x00 0x00 0x00 0x08, its meaning is:

  • 0x03 is type 3, the Transitive Opaque extended community class.
  • 0x0c is sub-type 12, which is the Encapsulation sub-type coming from the BGP Encapsulation Extended Community, originally from [RFC 5512], reused by EVPN as per [RFC 8365].
  • 0x0008 in the second community member, means tunnel type 8: VxLAN Encapsulation

Bird’s just launched evpn protocol keeps one global evpntab but selects entries from it using the route-target BGP extended community, in this case all the entries with route-target 65500:1000 will be copied into the per-eVPN ethtab table. While it contains less information, it does contain by design precisely that information that I need to program the dataplane.

Table etab_test:
00:00:00:00:00:00 mpls 1000 unicast [evpn_test 2026-06-05 18:59:16] * (80)
        via 198.19.1.16 on vxlan0 mpls 1000
        Type: EVPN univ
        mpls_label: 1000

This condensed view allows me to implement vppevpn_add_imet() which calls vppevpn_add_vxlan_tunnel() and sets the newly created tunnel into the bridge with the right flooding / split-horizon-group options I described above. Retracting the BGP IMET route will cause the protocol to issue vppevpn_del_imet() which calls vppevpn_del_vxlan_tunnel() to remove the tunnel from the bridge-domain and clean it up.

Warning
Here’s where an issue arises with the Bird implementation. Bird uses the RD as the route source, and it makes an implicit assumption that this RD is unique for each eVPN speaker. In most modern cases it is, as the RD is typically of the form $LOOP_ID:$NUMBER (eg 198.19.1.16:1000) but older equipment, like the Arista 7050X that IPng uses, do not support IP:N notation, only ASN:N so it’s the same 65500:1000 for all speakers. This makes Bird collapse all of the IMETs into a single etab entry. RFC7432 7.3 indicates prefix key of (ETag, IP Len, Originating Router IP) which supports Type 0, Type 1 and Type 2 Route Discriminators, and makes them unique.

In proto/evpn/evpn.c I change the function evpn_receive_imet() to calculate an IMET key that supports all RD types:

 evpn_receive_imet(struct evpn_proto *p, const net_addr_evpn_imet *n0, rte *new)
 {
   struct channel *c = p->eth_channel;
-  struct rte_src *s = rt_get_source(&p->p, rd_to_u64(n0->rd));
+  /* RFC7432 7.3 indicates prefix key (ETag, IP Len, Originating Router IP)
+   * which supports Type 0, Type 1 and Type 2 RDs */
+  u64 imet_key = u32_hash0(n0->tag, HASH_PARAM,
+                 ip6_hash0(n0->rtr, HASH_PARAM, 0));
+  /* Or (RD, ETag, IP)
+  u64 imet_key = u64_hash0(rd_to_u64(n0->rd), HASH_PARAM,
+                 u32_hash0(n0->tag, HASH_PARAM,
+                 ip6_hash0(n0->rtr, HASH_PARAM, 0)));
+  */
+  struct rte_src *s = rt_get_source(&p->p, imet_key);

And with this small change, older Arista EOS can keep on using its ASN:N notation on all switches, and all of my IMET routes are accounted for in the etab table.

vppevpn: Learning Type-2 (mac-ip)

The etab above contains this special route for 00:00:00:00:00:00 which is the IMET, but it also contains regular entries like these:

Table etab_test:
00:0d:b9:34:21:4a mpls 1000 unicast [evpn_test 2026-06-06 08:02:34] * (80)
        via 198.19.0.9 on vxlan0 mpls 1000
        Type: EVPN univ
        mpls_label: 1000
3c:ec:ef:9a:bb:1d mpls 1000 unicast [evpn_test 2026-06-05 18:59:16] * (80)
        via 198.19.0.9 on vxlan0 mpls 1000
        Type: EVPN univ
        mpls_label: 1000

These are simple mappings between MACs and VTEPs. I just created the VTEPs, so it’s now straightforward to program the L2FIB with the MAC address pointing at those VxLAN tunnels. This becomes vppevpn_add_mac_remote(), and for when the routes are removed from the table, the corresponding vppevpn_del_mac_remote() implementation. All they do internally is call VPP’s binary API for l2fib_add_del() and place the MAC behind the correct bridge (VxLAN) interface.

vppevpn: Advertising Type-2 (mac-ip)

My own bridge is learning MAC addresses as well, for example those machines connected behind Hu3/0/0 and Hu3/0/1. When they send traffic into the bridge, it will learn the MAC address automatically. When they stop sending traffic for an amount of time, say five minutes, the bridge will age out the entry and unlearn the MAC address again.

A special case is the Bridge Virtual Interface or BVI. That one is added to the bridge and becomes a permanent and static MAC address behind the BVI interface. It doesn’t need to send traffic to be learned, it’ll immediately be learned when it is attached to the bridge-domain, after which its MAC address will show up in the L2FIB. When it’s removed from the bridge-domain, its MAC gets cleaned up from the L2FIB for that bridge.

Both concepts turn up as events in VPP, which I can subscribe to. When the vppevpn protocol detects such a newly added or removed MAC address, I need to tell Bird about it. To do this, I’ll create a new route entry with my RTS_VPPEVPN as source, and point it at a dummy interface, say the loopback interface lo which always exists. Then I can send an rte_update2() with this information, which will make Bird add it to the etab table, which will make the evpn protocol learn it, and advertise such MACs into BGP. Unlearning MAC addresses is also an rte_update2() call but with a NULL entry, which will remove it from etab, in turn making the evpn protocol retract the advertisement from BGP.

And thusly, the last moving part is implemented as vppevpn_learn_mac() and vppevpn_unlearn_mac()

vppevpn: Backstop - scanning

I used to have a cat called Murphy. He was called this because if it could go wrong, he would see to it that it did. It makes sense - for all sorts of reasons - to check up on the state of the protocol by scanning the L2FIB, the VxLAN tunnels, and the etab, periodically, to reconcile any unexpected findings. One such finding is restarting VPP: the Bird protocol will be up, and think that there’s a bridge-domain and a few VxLAN tunnels, but actually when VPP restarts, they will not be there.

So I do one final thing, and I try to get it right (who knows if I succeeded, only time will tell), and implement a vppevpn_scan_bridge() on a timer. It works in a few phases:

Phase 1: IMET / VXLAN tunnel reconciliation

It gets the BD members and all VXLAN tunnels from VPP, cross-referencing against the tunnel hash stored in Bird, in order to find mismatches. First, I walk Bird’s tunnel hash and verify each tunnel is in the dataplane correctly. If the protocol in bird.conf changed the VNI or local source, I will detect that, and recreate the VxLAN tunnel with the correct configuration before putting it back. Then, I walk the VPP bridge-domain members, and remove any VxLAN tunnels that are in the bridge-domain but not in Bird’s tunnel hash.

Phase 2: MAC-IP / L2 FIB reconciliation

Here, I first make a note of any BVI that may be in the bridge-domain. Its MAC address is special, and needs to always be present in the L2FIB and never behind a VxLAN tunnel. In this phase, I will walk VPP’s L2FIB for the bridge-domain and compare it to Bird’s etab table for the protocol.

First I’ll walk Bird’s etab, asserting that any remote MACs are in VPP’s L2FIB. It is here that the BVI’s MAC address will refuse to install on a remote VTEP. Second, I’ll walk VPP’s L2FIB and find entries not in the etab - those need to be put back. Third, a spot check that the BVI MAC is in the etab and that it is still associated with the loopback device in L2FIB. The local MACs are next, which I’ll do in two directions as well: assert that any MACs are unlearned from the etab if VPP no longer has them (eg a machine behind Hu3/0/0 stopped sending ethernet frames).

I install this vppevpn_scan_bridge() on a timer based on the config snippet scan time 300, by default every five minutes. For good measure, I also add a CLI command birdc vppevpn rescan [name] which will trigger the function on command. This can be handy if I make specific changes to the bridge-domain, like adding/removing a BVI, as the scan will force the corrective action in L2FIB and etab immediately rather than waiting an eternity for the next natural scan.

vppevpn: show protocol

It’s customary for new protocols to show their pertinent information in a show protocol X, which is encoded in vppevpn_show_proto_info(). I’ll print things like the VPP Bridge Domain this protocol instance is governing, its VxLAN information, the VxLAN tunnels it has associated with the eVPN (based on learned IMET) and the VPP version information.

On my test instance, it looks like this:

pim@dpu0-ddln0:~$ birdc show proto all vppevpn_test
BIRD 2.19.0+branch.vppevpn.40c59b2f4812 ready.
Name       Proto      Table      State  Since         Info
vppevpn_test VPPEVPN    etab_test  up     2026-06-04 17:28:46  
  Channel eth
    State:          UP
    Table:          etab_test
    Preference:     0
    Input filter:   ACCEPT
    Output filter:  ACCEPT
    Routes:         1 imported, 35 exported, 1 preferred
    Route change stats:     received   rejected   filtered    ignored   accepted
      Import updates:              7          0          0          0          7
      Import withdraws:            6          0        ---          0          6
      Export updates:            611          7          0        ---        604
      Export withdraws:          575        ---        ---        ---        569
  Bridge domain:    1000
  VxLAN:
    IPv4 src:       [198.19.0.64]:4789
    Tunnel:         [198.19.0.10]:4789 vni=1000 sw_if_index=22 imet=1 refcount=1
    Tunnel:         [198.19.0.20]:4789 vni=1000 sw_if_index=23 imet=1 refcount=15
    Tunnel:         [198.19.0.9]:4789 vni=1000 sw_if_index=34 imet=1 refcount=2
    Tunnel:         ....
  VPP version:      vpe 26.10-rc0~73-gb6112ca27 built 2026-06-04T19:36:58

What we see here is this test eVPN with RD (65500:1000) and route-target (65500:1000) operating in VPP’s Bridge domain number 1000, with a bunch of tunnels, the refcount of which shows how many etab routes were associated with them. The minimum is one route, the IMET, as is shown by the Arista with VTEP 198.19.0.10. One of these VTEPs (a Nokia IXR7220 at 198.19.0.20) has 14 additional MAC addresses associated with it. The third VTEP (a Centec S5612X at 198.19.0.10) has one additional MAC address associated with it, and we’ll find out which one that is shortly.

Results

The other pertinent datapoint is that from the vantagepoint of vppevpn_test, it has imported one route, let’s take a look at that one:

pim@dpu0-ddln0:~$ birdc show route all protocol vppevpn_test
BIRD 2.19.0+branch.vppevpn.40c59b2f4812 ready.
Table etab_test:
02:b1:fb:f2:c9:0d    unicast [vppevpn_test 2026-06-05 14:30:10] * (0)
        dev lo
        Type: VPPEVPN univ

This corresponds with the following configuration that I added after the Bridge Domain came up:

pim@dpu0-ddln0:~$ vppctl create loopback instance 1000 mac 02:b1:fb:f2:c9:0d
pim@dpu0-ddln0:~$ vppctl lcp create loop1000 host-if bvi-test
pim@dpu0-ddln0:~$ vppctl set interface state loop1000 up
pim@dpu0-ddln0:~$ vppctl set interface l2 bridge loop1000 1000 bvi

That sees to it that the BVI appears attached to the bridge and injects its MAC address into VPP, which gets picked up by the vppevpn protocol and announced to the evpn protocol, which sends it across the network. All VxLAN/eVPN speakers in this domain now know where to send ethernet frames to the MAC address, namely encapsulated in VxLAN VNI 1000 to VTEP 198.19.0.64. Slick!

pim@dpu0-ddln0:~$ vppctl set interface ip address loop1000 172.16.0.32/24
pim@dpu0-ddln0:~$ vppctl set interface ip address loop1000 fec0::32/64
pim@dpu0-ddln0:~$ ip -br a show bvi-test
bvi-test         UNKNOWN        172.16.0.32/24 fec0::32/64 fe80::406c:faff:fed6:8298/64 
pim@dpu0-ddln0:~$ ping -c5 172.16.0.1
PING 172.16.0.1 (172.16.0.1) 56(84) bytes of data.
64 bytes from 172.16.0.1: icmp_seq=1 ttl=64 time=0.59 ms
64 bytes from 172.16.0.1: icmp_seq=2 ttl=64 time=0.44 ms
64 bytes from 172.16.0.1: icmp_seq=3 ttl=64 time=0.46 ms
64 bytes from 172.16.0.1: icmp_seq=4 ttl=64 time=0.45 ms
64 bytes from 172.16.0.1: icmp_seq=5 ttl=64 time=0.45 ms

--- 172.16.0.1 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4003ms
rtt min/avg/max/mdev = 0.444/0.477/0.590/0.056 ms

pim@dpu0-ddln0:~$ ping -c5 fec0::193
PING fec0::193 (fec0::193) 56 data bytes
64 bytes from fec0::193: icmp_seq=1 ttl=64 time=5.58 ms
64 bytes from fec0::193: icmp_seq=2 ttl=64 time=5.45 ms
64 bytes from fec0::193: icmp_seq=3 ttl=64 time=5.46 ms
64 bytes from fec0::193: icmp_seq=4 ttl=64 time=5.46 ms
64 bytes from fec0::193: icmp_seq=5 ttl=64 time=5.41 ms

--- fec0::193 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4005ms
rtt min/avg/max/mdev = 5.405/5.470/5.584/0.060 ms

The neighbor 172.16.0.1 is a machine in the Daedalean colo, just next to the VPP eVPN speaking machine called dpu0-ddln0. In case the magic escaped you - these two machines are in the same building but they are not connected to the same switch. The 172.16.0.1 endpoint is in the test VLAN on an eVPN capable switch which you may have seen before, its VTEP endpoint is 198.19.0.9. VPP is reaching the target by wrapping the ICMP echo into a VxLAN packet destined towards 198.19.0.9, a Centec S5612X, which decapsulates it and sends it out on its switchport where the DDLN-target is connected.

VPP itself can be closer or further away, as is shown in the second ping, this fec0::193 address belongs to a machine in Geneva, a full 5.5ms away, and just the same as before, VPP is encapsulating it to the VTEP endpoint of 198.19.0.20 (the eVPN speaking Nokia IXR7220 in Geneva), which decapsulates it and sends it out to its local port where the Geneva-target is physically connected.

All three of these machines see each other on a shared L2 domain, even though they are in different cities, connected via a routed IPng Site Local underlay carrying VxLAN traffic. It’s magic.

What’s next

Configuring these Bird config snippets and manually adding loopback addresses is fun, but what would be even more fun is a controlplane that does it for me. I am envisioning a system that can:

  1. Have a bunch of evpn daemons running alongside VPP, interacting with Bird to add/remove the VPP router to and from the eVPN broadcast domains
  2. Configure ‘passive’ endpoints, which have only linklocal on a VPP-specific MAC address
  3. Configure ‘active’ endpoints, which have a specific vMAC and IPv4/IPv6 addresses
    DPU
  4. Swap in/out the ‘active’ and ‘passive’ endpoints, moving the vMAC+L3 to a different router
  5. Perform health checks a-la CARP, HSRP or VRRP and detect when a peer fails
  6. Advise automatic failover of a VPP router that became unfit to serve

This is a super interesting avenue for IPng Networks as well, as it would allow me to build eVPN networks within a metro, say between NTT and Interxion and Daedalean, and have two or three VPP routers be each others’ backup in case of maintenance or failures. And in case you’ve seen the hostname above, DPUs are the future design element that this will play out on. If you’re curious, stay tuned, in a few weeks an upcoming article will reveal a very cool project I’m heads down on right now.