Peer at link-local addresses: fe80::1%eth0

A peer's endpoint may be a link-local IPv6 address with the interface it
is on. Such a peer is the address and the interface together, wherever
peers are told apart: admission, collisions, hold-downs, the refusal log,
the status page. The interface is looked up by name each time it is
needed, so one that appears later or is made anew is found.

Our routes go out with our link-local address in both places of
MP_REACH_NLRI's next hop, as FRR sends its own. A peer's link-local next
hop is taken alone, twice, or after "::" (BIRD), and resolved in the
kernel on the interface of the session rather than by looking fe80::/64
up, which every interface has.

The lab has a new phase, linklocal: sessions with FRR (by interface, with
radvd beside the daemon) and BIRD over a LAN with link-local addresses
only, both families, pings across, the interface down and up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 18:24:13 +02:00
1 parent 92e013ca01
commit fd2248feb5
38 files changed
+975 -151

No files matched your search

+3 -3
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# Installs what scripts/interop/lab.sh needs, on Ubuntu or Debian: FRR and
# BIRD from the distribution, Routinator from crates.io (a few minutes to
# build).
# build), and radvd, which tells FRR who is on a link.
set -eu
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y frr bird2 jq curl
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y frr bird2 jq curl radvd
# They are run by the lab, in namespaces, not as services of this machine.
sudo systemctl disable --now frr bird 2>/dev/null || true
sudo systemctl disable --now frr bird radvd 2>/dev/null || true
command -v routinator >/dev/null || cargo install --locked routinator
+171 -3
View File
@@ -20,7 +20,7 @@
# PHASES="basics scale" scripts/interop/lab.sh only these phases
# SCALE_ROUTES=1000000 ... the size of the table BIRD sends
#
# Needs Linux, sudo, and: frr, bird2, jq, routinator, a built `bgp`
# Needs Linux, sudo, and: frr, bird2, radvd, jq, routinator, a built `bgp`
# (cargo build --release -p bgp). scripts/linux-vm.sh runs it from macOS;
# scripts/interop/install.sh installs what is missing on Ubuntu.
set -euo pipefail
@@ -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 v6only"
all_phases="basics roles rpki refresh flaps restart scale limits v6only linklocal"
phases="${PHASES:-$all_phases}"
scale_routes="${SCALE_ROUTES:-200000}"
@@ -59,6 +59,7 @@ down() {
for pidfile in "$work"/dut.pid "$work"/routinator.pid; do
[ -f "$pidfile" ] && sudo kill "$(cat "$pidfile")" 2>/dev/null || true
done
[ -f "$work/radvd.pid" ] && sudo kill "$(sudo cat "$work/radvd.pid")" 2>/dev/null || true
[ -f "$work/bird.pid" ] && sudo kill "$(sudo cat "$work/bird.pid")" 2>/dev/null || true
for daemon in bgpd zebra; do
local pidfile="/var/run/frr/$pathspace/$daemon.pid"
@@ -727,7 +728,8 @@ phase_limits() {
# 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.
# (RFC 8950), in BGP and in the kernel. Run after the rest: the lab stays
# that way.
phase_v6only() {
dut_down TERM
frr_down TERM
@@ -782,6 +784,172 @@ phase_v6only() {
ok "an IPv4 route withdrawn over the IPv6 session goes"
}
# The exchange LAN loses its global IPv6 addresses too: all that is left on
# it are link-local ones, the same on every link a router has, which mean
# something only together with the interface. The sessions are between
# those, as are the next hops, for both families. Run after v6only.
dut_config_linklocal() {
cat > "$work/dut.textpb" <<END
router_id: "10.9.0.1"
local_asn: 64601
listen_endpoints { address: "::" port: 179 }
connect_retry_seconds: 1
peers {
name: "frr" endpoint { address: "fe80::2%ix0" } remote_asn: 64602
role: PEER
import { accept_bogons: true max_prefixes: 1000 }
}
peers {
name: "bird" endpoint { address: "fe80::3%ix0" } 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
}
# FRR is told the interface and not the address ("BGP unnumbered"), which
# is how it is mostly set up for this. It learns who is on the link from
# router advertisements, which the daemon does not send: radvd does, as it
# would have to beside the daemon on a real router with such a neighbor.
radvd_up() {
cat > "$work/radvd.conf" <<END
interface ix0 {
AdvSendAdvert on;
MinRtrAdvInterval 3;
MaxRtrAdvInterval 4;
AdvDefaultLifetime 0;
};
END
in_ns dut "$(command -v radvd || echo /usr/sbin/radvd)" -C "$work/radvd.conf" -p "$work/radvd.pid" -m logfile -l "$work/radvd.log"
}
frr_config_linklocal() {
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 ix0 interface remote-as 64601
neighbor ix0 local-role peer
neighbor ix0 timers connect 1
neighbor ix0 capability extended-nexthop
!
address-family ipv4 unicast
network 10.2.0.0/24
network 10.2.5.0/24
neighbor ix0 activate
exit-address-family
!
address-family ipv6 unicast
network 2001:db8:2::/48
neighbor ix0 activate
exit-address-family
!
END
}
bird_config_linklocal() {
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 fe80::1%ix0 as 64601;
local role provider;
connect retry time 1;
# Back soon after the hold timer ran out, which the phase makes it do.
error wait time 1, 2;
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
}
phase_linklocal() {
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 2>/dev/null || true
in_ns "$name" ip -6 addr flush dev ix0
in_ns "$name" ip -6 addr add "fe80::$n/64" dev ix0 nodad
# The same address on another link: it must not be mistaken for
# the one the session is on.
in_ns "$name" ip -6 addr flush dev lan scope link
in_ns "$name" ip -6 addr add "fe80::$n/64" dev lan nodad
done
in_ns dut sysctl -qw net.ipv6.conf.ix0.forwarding=1
radvd_up
frr_config_linklocal; frr_up
bird_config_linklocal; bird_up
dut_config_linklocal; dut_up
wait_for "two sessions between link-local addresses" established 2
peer_is frr '.peer_addr | startswith("[fe80::2%ix0]:")' >/dev/null \
&& peer_is bird '.peer_addr | startswith("[fe80::3%ix0]:")' >/dev/null \
&& peer_is frr '.local_addr | startswith("[fe80::1%ix0]:")' >/dev/null \
|| fail "the peers are at $(api /api/peers | jq -c '[.[] | {name, peer_addr, local_addr}]')"
api /api/peers | jq -e 'all(.[]; .extended_next_hop and (.address_families | length == 2))' >/dev/null \
|| fail "the sessions did not agree on both families: $(api /api/peers | jq -c '[.[] | {name, address_families, extended_next_hop}]')"
ok "sessions with FRR and BIRD at fe80::2%ix0 and fe80::3%ix0, for both families"
# Their routes: link-local next hops, on the session's interface.
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
wait_for "FRR's IPv4 route in the kernel" dut_kernel -4 "10.2.0.0/24 via inet6 fe80::2 dev ix0 metric 20"
wait_for "BIRD's IPv4 route in the kernel" dut_kernel -4 "10.3.0.0/24 via inet6 fe80::3 dev ix0 metric 20"
wait_for "FRR's IPv6 route in the kernel" dut_kernel -6 "2001:db8:2::/48 via fe80::2 dev ix0 metric 20"
wait_for "BIRD's IPv6 route in the kernel" dut_kernel -6 "2001:db8:3::/48 via fe80::3 dev ix0 metric 20"
api /api/kernel | jq -e '.unresolved == {}' >/dev/null || fail "some next hops did not resolve: $(api /api/kernel)"
ok "their routes of both families are in the kernel by way of fe80::… on ix0"
# Ours, at them.
wait_for "our IPv4 route at FRR" frr_has ipv4 10.1.0.0/24
wait_for "our IPv6 route at FRR" frr_has ipv6 2001:db8:1::/48
wait_for "our IPv4 route at BIRD" bird_has 10.1.0.0/24
wait_for "our IPv6 route at BIRD" bird_has 2001:db8:1::/48
wait_for "our IPv4 route in FRR's kernel" in_ns frr ip -4 route get 10.1.0.1
wait_for "our IPv6 route in BIRD's kernel" in_ns bird ip -6 route get 2001:db8:1::1 from 2001:db8:3::1
ok "FRR and BIRD have our routes of both families"
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 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"
in_ns dut ping -q -c 2 -W 2 -I 2001:db8:1::1 2001:db8:3::1 >/dev/null || fail "no IPv6 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 bird ping -q -c 2 -W 2 -I 2001:db8:3::1 2001:db8:1::1 >/dev/null || fail "no IPv6 ping from BIRD's lan"
ok "IPv4 and IPv6 packets cross a link with only link-local addresses, both ways"
# The interface goes and comes back, as a tunnel does when it is
# restarted: the sessions come back by themselves.
in_ns dut ip link set ix0 down
wait_for "the sessions to go" established 0
in_ns dut ip link set ix0 up
in_ns dut ip -6 addr flush dev ix0
in_ns dut ip -6 addr add fe80::1/64 dev ix0 nodad
wait_for "the sessions to come back" established 2
wait_for "FRR's IPv4 route in the kernel again" dut_kernel -4 "10.2.0.0/24 via inet6 fe80::2 dev ix0 metric 20"
ok "the sessions and the routes come back after the interface was down"
}
# --- main -----------------------------------------------------------------------
case "${1:-test}" in