Hello,

While preparing a question about this symptom, I found the root cause and
put together a working, tested patch against sysdep/linux/netlink.c.
Posting the fix directly rather than just the question, since a patch is
more actionable than a symptom report. Happy to open this as a GitLab MR
if that's useful — wanted to get feedback from the list / maintainers first,
since I'm not fully sure this is the right layer to fix it at.

== Environment ==
  BIRD:   3.3.1 (bird3 3.3.1-cznic.1~trixie, Debian 13 test box)
  Kernel: 6.12.86 (Debian test), production runs Gentoo
  Role:   egress PE, seamless-MPLS BGP L3VPN (AS 197296), IPv4-only underlay
          (OSPFv2 + BGP-LU), adding IPv6 as 6VPE (RFC 4659) over the IPv4
          transport, extended next hop capability enabled on the vpn6
          channel, no IPv6 IGP / LDPv6 anywhere in the underlay.

== Symptom (unchanged from the original report) ==
Control plane resolution is complete and correct — BIRD resolves the VPNv6
route recursively via the IPv4 labeled table and builds the correct double
MPLS label stack:

  show route table vpntab6 all
  2a02:d280:201::/64 mpls 1207 unicast [proto_bgp_rr] * (100/?) [?]
        via 172.21.1.1 on lan mpls 24482/16
        hostentry: via 100.127.1.26 table master4_lu mpls 16

But kernel export fails for these routes:

  show route table table_vpn6_overlay
  2a02:d280:201::/64 unicast [l3_overlay] ! (80/?)
        via 172.21.1.1 on lan mpls 24482/16

  <bird>: Netlink: Invalid argument   (repeated, once per scan cycle)

== Root cause ==
Confirmed by manual reproduction outside BIRD:

  # ip -6 route add 2a02:d280:300::/40 encap mpls 24481/1033 via inet
172.21.1.1 dev lan table 20
  Error: IPv6 does not support RTA_VIA attribute.

net/ipv6/route.c in mainline Linux does not accept RTA_VIA (the netlink
attribute for a cross-family nexthop) on an IPv6 route, full stop —
regardless of whether the address is a raw IPv4 address or an IPv4-mapped
IPv6 address. Since our underlay is IPv4-only, every VPNv6 route we import
resolves (via the IPv4-labeled IGP table) to a physically IPv4-family
adjacency, so every such route hits this kernel limitation.

In sysdep/linux/netlink.c, nl_add_nexthop() is exactly where this happens:

  if (ipa_nonzero(nh->gw))
  {
    if (af == (ipa_is_ip4(nh->gw) ? AF_INET : AF_INET6))
      nl_add_attr_ipa(h, bufsize, RTA_GATEWAY, nh->gw);   /* same family,
OK */
    else
      nl_add_attr_via(h, bufsize, nh->gw);                /* cross-family
-> rejected for v6 */
  }

The MPLS encap (nl_add_attr_mpls_encap(), a few lines above) is completely
independent of this gateway-family branch — it's already correctly built
from the hostentry-resolved label stack before the gateway is even
considered. So fixing the gateway encoding does not touch the label stack
at all.

This looks like the mirror case of RFC 8950 (v4-via-v6, "IPv6 next hop for
IPv4 NLRI", which the kernel does support): what we need here is the
opposite direction, v6-via-v4, for which the kernel intentionally has no
FIB representation. For an MPLS-encapsulated route, though, the gateway is
only used to pick the L2 adjacency (the packet leaves as an MPLS frame, so
the address family of the nexthop is otherwise irrelevant to forwarding),
which means we don't actually need the kernel to support v6-via-v4 in
general — we just need *some* valid on-link IPv6 address for that neighbor.

== Two things I ruled out first (documenting so nobody re-treads this) ==

1. Overriding gw in a kernel export filter
   (`if ifname = "lan" then gw = fe80::1;`): the route becomes installable,
   but overriding gw forces BIRD to recompute the nexthop as
   non-recursive/explicit, which drops the label stack from the hostentry
   resolution. Installable but non-forwarding.

2. BGP-side "next hop address" / extended-nexthop config changes: these
   only affect what *we* advertise for routes *we* originate. They have no
   effect on how imported routes (from the RR, next-hop = remote PE
   loopback) get resolved — that resolution goes through our IPv4-only IGP
   table (master4_lu) regardless of how our own next-hop attribute is
   encoded. Also, disabling extended-nexthop entirely makes BIRD reject the
   config outright ("Mismatched IGP table type") since it then requires the
   next-hop family to match the IGP table family. Verified both variants
   live against production traffic; neither changes the kernel-export
   outcome.

== The patch ==
Confirmed the neighboring router already sends IPv6 Router Advertisements
on the segment (independent of any IPv6 IGP — RA is link-scoped), so the
kernel already keeps a working ND cache entry for it:

  ip -6 neigh show dev lan
  fe80::1 lladdr dc:68:0c:32:ed:b2 router STALE

The attached patch adds a small on-link-router cache to netlink.c (mirrors
the existing AF_BRIDGE FDB cache/parsing that's already in the file, just
generalized to AF_INET6 neighbors flagged NTF_ROUTER), refreshed once per
KRT scan cycle. In nl_add_nexthop(), when exporting an IPv6 route whose
resolved gateway is IPv4-family, it looks up this cache by egress interface
and substitutes the neighbor's real link-local address for RTA_GATEWAY
instead of building the (rejected) RTA_VIA — the MPLS encap is untouched.
No interface name or address is hardcoded anywhere; it's fully derived from
what the kernel already knows via ND. If the cache has no entry yet for
that interface, behavior is unchanged from today (route stays uninstalled,
self-heals on the next scan) — no regression.

Diff attached:
0001-netlink-ipv6-onlink-router-cache-for-6vpe-mpls-nexthop.diff
(against v3.3.1, 4 hunks, all confined to sysdep/linux/netlink.c).

== Test results (live, on a production-representative PE) ==

  show route table table_vpn6_overlay   -> routes now "*" (were "!")

  ip -6 route show table 20
  2a02:d280:201::/64  encap mpls 24482/16 via fe80::1 dev lan proto bird

  ip -6 route get 2a02:d280:200::1
  ... encap mpls 24481/1032 via fe80::1 dev lan ... (correct FIB lookup)

  ip -6 neigh show dev lan
  fe80::1 lladdr dc:68:0c:32:ed:b2 router REACHABLE

  journalctl -u bird   -> no more "Netlink: Invalid argument"

  End-to-end ping from a live CPE through the fixed path: 171/171 received,
  0% loss.

== Questions for the list / maintainers ==
  1. Is this an acceptable place/approach to fix this, or would you rather
     see it handled differently (e.g. in rt-table.c nexthop resolution
     itself, or as an explicit config knob rather than automatic ND-based
     discovery)?
  2. Known edge case: if a segment ever has more than one NTF_ROUTER
     neighbor on the same interface, the cache currently keeps whichever
     one was last seen in the dump (non-deterministic ordering). Not an
     issue in our topology (always exactly one router per segment), but
     worth flagging.
  3. Cache refresh is tied to the existing KRT scan interval rather than
     the async netlink notification path, to avoid touching the shared
     AF_BRIDGE dispatch code. Happy to change this if an event-driven
     refresh is preferred upstream.

Willing to open this as a merge request, test further, or provide more
captures/config context — just let me know the preferred path.

Thanks a lot for BIRD.

Aleš Kopecký
AS 197296
[email protected]

Attachments:
  0001-netlink-ipv6-onlink-router-cache-for-6vpe-mpls-nexthop.diff

Attachment: 0001-netlink-ipv6-onlink-router-cache-for-6vpe-mpls-nexthop.diff
Description: Binary data

Reply via email to