Introduction
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
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:
- An update to
configure.acto add it toall_protocolsso that it is compiled and linked in. - Addition to
nest/protocol.hinenum protocol_class, and thestruct protocollist, so that Bird itself becomes aware of its existence. Innest/route.hI need to add a new route source constant, which I’ll do before#define RTS_MAX, and I’ll give this thing a name innets/rt-attr.cin the constantrta_src_names. - 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(egipv4oripv6oreth) are permissible in this protocol. I’ll set it toNB_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 Xand 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:
set interface l2 flood ... disableon 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.- 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:
0x03is type 3, the Transitive Opaque extended community class.0x0cis 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].0x0008in 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.

$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:
- 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
- Configure ‘passive’ endpoints, which have only linklocal on a VPP-specific MAC address
- Configure ‘active’ endpoints, which have a specific vMAC and IPv4/IPv6 addresses

- Swap in/out the ‘active’ and ‘passive’ endpoints, moving the vMAC+L3 to a different router
- Perform health checks a-la CARP, HSRP or VRRP and detect when a peer fails
- 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.