Carry IPv4 routes over IPv6 sessions with IPv6 next hops (RFC 8950)

For a link that has only IPv6 addresses.

- The Extended Next Hop capability is offered on every session over IPv6
  whose families include IPv4 unicast, and used if the peer offers it too.
  Nothing to configure.
- On such a session our IPv4 routes go out in MP_REACH_NLRI with the
  session's IPv6 address as next hop, and are withdrawn in
  MP_UNREACH_NLRI. Without the agreement they are not sent, as before.
- IPv4 routes are read with an IPv6 next hop, global or global with
  link-local.
- The kernel installer resolves an IPv4 route's IPv6 next hop like any
  other and installs it: by way of an IPv6 nexthop object, or with RTA_VIA
  where routes carry their own gateway.

Tested against a real kernel in both modes, with a link-local next hop
too, and in the lab against FRR and BIRD: the new `v6only` phase takes
the IPv4 addresses off the exchange LAN, runs one IPv6 session with each
router for both families, and pings IPv4 across.

Sessions between link-local addresses are not supported yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 17:58:03 +02:00
1 parent 321af51e4b
commit 92e013ca01
14 files changed
+511 -23

No files matched your search

+141 -3
View File
@@ -29,7 +29,7 @@ cd "$(dirname "$0")/../.."
bgp="${CARGO_TARGET_DIR:-target}/release/bgp"
work=/tmp/ferrix-lab
pathspace=ferrixlab
all_phases="basics roles rpki refresh flaps restart scale limits"
all_phases="basics roles rpki refresh flaps restart scale limits v6only"
phases="${PHASES:-$all_phases}"
scale_routes="${SCALE_ROUTES:-200000}"
@@ -170,6 +170,29 @@ http_endpoint { address: "127.0.0.1" port: 8080 }
END
}
dut_config_v6only() {
cat > "$work/dut.textpb" <<END
router_id: "10.9.0.1"
local_asn: 64601
listen_endpoints { address: "2001:db8:9::1" port: 179 }
connect_retry_seconds: 1
peers {
name: "frr" endpoint { address: "2001:db8:9::2" } remote_asn: 64602
role: PEER
import { accept_bogons: true max_prefixes: 1000 }
}
peers {
name: "bird" endpoint { address: "2001:db8:9::3" } remote_asn: 4200000003
role: PROVIDER
import { accept_bogons: true max_prefixes: 1000 }
}
originate { prefix: "10.1.0.0/24" }
originate { prefix: "2001:db8:1::/48" }
kernel { stale_seconds: 3 }
http_endpoint { address: "127.0.0.1" port: 8080 }
END
}
dut_up() {
sudo ip netns exec lab-dut \
setpriv --reuid "$(id -u)" --regid "$(id -g)" --init-groups \
@@ -197,7 +220,7 @@ dut_down() {
# --- FRR ------------------------------------------------------------------------
frr_up() {
frr_config() {
sudo mkdir -p "/etc/frr/$pathspace" "/var/run/frr/$pathspace"
sudo tee "/etc/frr/$pathspace/frr.conf" > /dev/null <<END
frr defaults traditional
@@ -229,6 +252,41 @@ router bgp 64602
exit-address-family
!
END
}
# One session, over IPv6, for both families: IPv4 routes go with IPv6 next
# hops (RFC 8950).
frr_config_v6only() {
sudo tee "/etc/frr/$pathspace/frr.conf" > /dev/null <<END
frr defaults traditional
hostname frr
log file $work/frr.log
!
router bgp 64602
bgp router-id 10.9.0.2
no bgp ebgp-requires-policy
no bgp network import-check
timers bgp 3 9
neighbor 2001:db8:9::1 remote-as 64601
neighbor 2001:db8:9::1 local-role peer
neighbor 2001:db8:9::1 timers connect 1
neighbor 2001:db8:9::1 capability extended-nexthop
!
address-family ipv4 unicast
network 10.2.0.0/24
network 10.2.5.0/24
neighbor 2001:db8:9::1 activate
exit-address-family
!
address-family ipv6 unicast
network 2001:db8:2::/48
neighbor 2001:db8:9::1 activate
exit-address-family
!
END
}
frr_up() {
sudo touch "/etc/frr/$pathspace/vtysh.conf" "$work/frr.log"
sudo chown -R frr:frr "/etc/frr/$pathspace" "/var/run/frr/$pathspace" "$work/frr.log"
for daemon in zebra bgpd; do
@@ -294,6 +352,29 @@ protocol bgp dut6 from dut {
END
}
bird_config_v6only() {
cat > "$work/bird.conf" <<END
log "$work/bird.log" all;
router id 10.9.0.3;
protocol device {}
protocol kernel kernel4 { ipv4 { export where source = RTS_BGP; }; }
protocol kernel kernel6 { ipv6 { export where source = RTS_BGP; }; }
protocol static static4 { ipv4; route 10.3.0.0/24 blackhole; }
protocol static static6 { ipv6; route 2001:db8:3::/48 blackhole; }
protocol bgp dut {
local as 4200000003;
neighbor 2001:db8:9::1 as 64601;
source address 2001:db8:9::3;
local role provider;
connect retry time 1;
hold time 9;
graceful restart off;
ipv4 { import all; export where source = RTS_STATIC; extended next hop on; };
ipv6 { import all; export where source = RTS_STATIC; };
}
END
}
bird_up() {
sudo ip netns exec lab-bird bird -c "$work/bird.conf" -s "$work/bird.ctl" -P "$work/bird.pid"
}
@@ -325,7 +406,7 @@ lab_up() {
[ -x "$bgp" ] || fail "$bgp not found: cargo build --release -p bgp"
network_up
routinator_up
frr_up
frr_config; frr_up
bird_config; bird_up
dut_config; dut_up
}
@@ -644,6 +725,63 @@ phase_limits() {
ok "back to normal"
}
# The exchange LAN loses its IPv4 addresses. One session over IPv6 with
# each router carries both families, and IPv4 routes have IPv6 next hops
# (RFC 8950), in BGP and in the kernel. Run last: the lab stays that way.
phase_v6only() {
dut_down TERM
frr_down TERM
sudo kill "$(sudo cat "$work/bird.pid")"; sleep 0.5; sudo rm -f "$work/bird.ctl"
local n=0 name
for name in dut frr bird; do
n=$((n + 1))
in_ns "$name" ip addr del "10.9.0.$n/24" dev ix0
done
frr_config_v6only; frr_up
bird_config_v6only; bird_up
dut_config_v6only; dut_up
wait_for "two sessions over IPv6" established 2
api /api/peers | jq -e 'all(.[]; .extended_next_hop and (.address_families | length == 2))' >/dev/null \
|| fail "the sessions did not agree on IPv4 with IPv6 next hops: $(api /api/peers | jq -c '[.[] | {name, address_families, extended_next_hop}]')"
ok "both sessions carry IPv4 and IPv6, with IPv6 next hops for IPv4 (RFC 8950)"
# What they send us: IPv4 routes whose next hop is an IPv6 address,
# and the same in the kernel.
wait_for "FRR's IPv4 route" dut_has 10.2.0.0/24 frr
wait_for "BIRD's IPv4 route" dut_has 10.3.0.0/24 bird
api "/api/routes/received?prefix=10.2.0.0/24" | jq -e '.routes[0].summary.next_hop | startswith("2001:db8:9::2")' >/dev/null \
|| fail "FRR's IPv4 route has the next hop $(api "/api/routes/received?prefix=10.2.0.0/24" | jq '.routes[0].summary.next_hop')"
wait_for "FRR's IPv4 route in the kernel" dut_kernel -4 "10.2.0.0/24 via inet6 2001:db8:9::2 dev ix0 metric 20"
wait_for "BIRD's IPv4 route in the kernel" dut_kernel -4 "10.3.0.0/24 via inet6 2001:db8:9::3 dev ix0 metric 20"
wait_for "FRR's IPv6 route in the kernel" dut_kernel -6 "2001:db8:2::/48 via 2001:db8:9::2 dev ix0 metric 20"
ok "their IPv4 routes arrive with IPv6 next hops and are in the kernel that way"
# What we send them.
wait_for "our IPv4 route at FRR" frr_has ipv4 10.1.0.0/24
vty -c "show bgp ipv4 unicast 10.1.0.0/24 json" | jq -e '.paths[0].nexthops | any(.ip == "2001:db8:9::1" and .afi == "ipv6")' >/dev/null \
|| fail "FRR's copy of our IPv4 route has the next hops $(vty -c 'show bgp ipv4 unicast 10.1.0.0/24 json' | jq -c '.paths[0].nexthops')"
wait_for "our IPv4 route at BIRD" bird_has 10.1.0.0/24
bird_ctl show route for 10.1.0.0/24 all | grep -q "BGP.next_hop: 2001:db8:9::1" \
|| fail "BIRD's copy of our IPv4 route: $(bird_ctl show route for 10.1.0.0/24 all)"
wait_for "our IPv6 route at FRR" frr_has ipv6 2001:db8:1::/48
ok "FRR and BIRD have our IPv4 route with our IPv6 address as next hop"
# IPv4 packets across a link that has no IPv4 addresses.
in_ns dut ping -q -c 2 -W 2 -I 10.1.0.1 10.2.0.1 >/dev/null || fail "no IPv4 ping to FRR's lan"
in_ns dut ping -q -c 2 -W 2 -I 10.1.0.1 10.3.0.1 >/dev/null || fail "no IPv4 ping to BIRD's lan"
in_ns frr ping -q -c 2 -W 2 -I 10.2.0.1 10.1.0.1 >/dev/null || fail "no IPv4 ping from FRR's lan"
in_ns dut ping -q -c 2 -W 2 -I 2001:db8:1::1 2001:db8:2::1 >/dev/null || fail "no IPv6 ping to FRR's lan"
ok "IPv4 packets cross the IPv6-only link, both ways"
# A withdrawal by FRR (in MP_UNREACH_NLRI) takes the route out.
frr_configure "router bgp 64602" "address-family ipv4 unicast" "no network 10.2.5.0/24"
wait_for "FRR's withdrawal" dut_lacks 10.2.5.0/24
wait_for "it out of the kernel" dut_kernel_lacks -4 10.2.5.0/24
dut_has 10.2.0.0/24 frr >/dev/null || fail "the withdrawal took the other route along"
ok "an IPv4 route withdrawn over the IPv6 session goes"
}
# --- main -----------------------------------------------------------------------
case "${1:-test}" in