Skip to content

Commit 1be6af7

Browse files
thomas-manginclaude
andcommitted
feat: Implement Link-Local Next Hop Capability procedures
Implements the wire-encoding and configuration for LLNH (code 77) per draft-ietf-idr-linklocal-capability: - Add _encode_nexthop() for LLNH-aware next-hop encoding: - 16-byte: link-local only or global only - 32-byte: global + link-local (always this order per RFC) - Add local-link-local config for explicit LLA in next-hop - Add link-local-prefer config for receiver forwarding preference - Add is_multihop() to exclude LLA when TTL > 1 (not directly connected) - Add link_local_address() and link_local_prefer() to Negotiated - Validate local-link-local is fe80::/10 Configuration: neighbor X { local-link-local fe80::1; capability { link-local-nexthop enable; link-local-prefer enable; } } Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7fa6538 commit 1be6af7

File tree

9 files changed

+453
-17
lines changed

9 files changed

+453
-17
lines changed

src/exabgp/bgp/message/open/capability/negotiated.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,23 @@ def validate(self, neighbor: Any) -> tuple[int, int, str] | None:
241241
def nexthopself(self, afi: AFI) -> 'IP':
242242
return self.neighbor.ip_self(afi)
243243

244+
def link_local_address(self) -> 'IP | None':
245+
"""Get the local link-local IPv6 address if available."""
246+
return self.neighbor.session.ip_link_local()
247+
248+
def link_local_prefer(self) -> bool:
249+
"""Check if link-local nexthop is preferred over global."""
250+
return self.neighbor.capability.link_local_prefer
251+
252+
def is_multihop(self) -> bool:
253+
"""Check if session is multihop (TTL > 1).
254+
255+
Used to determine if link-local addresses should be excluded from
256+
next-hop (link-local only valid for directly connected peers).
257+
"""
258+
ttl = self.neighbor.session.outgoing_ttl
259+
return ttl is not None and ttl > 1
260+
244261
@property
245262
def is_ibgp(self) -> bool:
246263
"""Return True if this is an IBGP session (local_as == peer_as)."""

src/exabgp/bgp/message/update/attribute/mprnlri.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,10 @@ def unpack_attribute(cls, data: Buffer, negotiated: Negotiated) -> Attribute:
191191

192192
length, rd = Family.size[(afi, safi)]
193193

194-
# Note: link-local nexthop capability (code 77) doesn't change validation -
195-
# 16-byte nexthops are already valid for IPv6 families in Family.size
194+
# Link-Local Next Hop Capability (code 77) validation:
195+
# - 16-byte IPv6 nexthops are valid (could be global or link-local)
196+
# - With LLNH negotiated, 16-byte link-local (fe80::/10) is explicitly allowed
197+
# - Semantic interpretation of 16-byte NH depends on LLNH negotiation
196198
if negotiated.nexthop:
197199
if len_nh in (16, 32, 24):
198200
nh_afi = AFI.ipv6

src/exabgp/bgp/message/update/nlri/collection.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,72 @@ def _attr_len(self, payload_len: int) -> int:
272272
"""Calculate total attribute length including header."""
273273
return payload_len + (4 if payload_len > 255 else 3)
274274

275+
def _encode_nexthop(
276+
self,
277+
nlri_nexthop: IP,
278+
family_key: tuple[AFI, SAFI],
279+
negotiated: 'Negotiated',
280+
) -> bytes:
281+
"""Encode nexthop bytes for MP_REACH_NLRI.
282+
283+
Handles link-local nexthop capability (RFC draft-ietf-idr-linklocal-capability):
284+
- 16-byte: link-local only (when LLNH negotiated) or global only
285+
- 32-byte: global + link-local (for IPv6 unicast/labeled)
286+
287+
Args:
288+
nlri_nexthop: The route's next-hop IP address.
289+
family_key: (AFI, SAFI) tuple for the family.
290+
negotiated: BGP session parameters.
291+
292+
Returns:
293+
Packed nexthop bytes (including RD if applicable).
294+
"""
295+
from exabgp.protocol.family import Family
296+
297+
if nlri_nexthop is IP.NoNextHop:
298+
return b''
299+
300+
_, rd_size = Family.size.get(family_key, (0, 0))
301+
nh_rd = bytes([0]) * rd_size if rd_size else b''
302+
303+
try:
304+
nh_packed = nlri_nexthop.pack_ip()
305+
except TypeError:
306+
# Fallback for invalid nexthop
307+
return bytes([0]) * 4
308+
309+
# Only apply LLNH logic for IPv6 families
310+
if family_key[0] != AFI.ipv6:
311+
return nh_rd + nh_packed
312+
313+
# Check if LLNH capability is negotiated
314+
if not negotiated.linklocal_nexthop:
315+
# Without LLNH, just send the nexthop as-is
316+
return nh_rd + nh_packed
317+
318+
# Check if session is multihop - link-local not usable beyond 1 hop
319+
# RFC draft-ietf-idr-linklocal-capability: exclude LLA for non-directly-connected peers
320+
if negotiated.is_multihop():
321+
return nh_rd + nh_packed
322+
323+
# Get link-local address if available
324+
link_local = negotiated.link_local_address()
325+
is_nh_link_local = nlri_nexthop.is_link_local()
326+
327+
# Case 1: Nexthop is already link-local - send as 16-byte (with LLNH negotiated)
328+
if is_nh_link_local:
329+
return nh_rd + nh_packed
330+
331+
# Case 2: Nexthop is global, and we have a link-local to include
332+
# Wire format is ALWAYS: Global (16 bytes) + Link-local (16 bytes)
333+
# The link-local-prefer config affects receiver's forwarding decision, not wire order
334+
if link_local is not None:
335+
lla_packed = link_local.pack_ip()
336+
return nh_rd + nh_packed + lla_packed
337+
338+
# Case 3: Global nexthop, no link-local available - send as 16-byte
339+
return nh_rd + nh_packed
340+
275341
def packed_reach_attributes(
276342
self,
277343
negotiated: 'Negotiated',
@@ -288,8 +354,6 @@ def packed_reach_attributes(
288354
Yields:
289355
Wire-format attribute bytes (with flags/type/length header).
290356
"""
291-
from exabgp.protocol.family import Family
292-
293357
# Filter NLRIs for this family and group by nexthop
294358
mpnlri: dict[bytes, list[bytes]] = {}
295359
family_key = (self._afi, self._safi)
@@ -301,19 +365,8 @@ def packed_reach_attributes(
301365
if nlri.family().afi_safi() != family_key:
302366
continue
303367

304-
# Encode nexthop
305-
# Note: link-local capability doesn't change encoding - existing code
306-
# already produces correct lengths (16 for IPv6, 24 for VPNv6)
307-
if nlri_nexthop is IP.NoNextHop:
308-
nexthop = b''
309-
else:
310-
_, rd_size = Family.size.get(family_key, (0, 0))
311-
nh_rd = bytes([0]) * rd_size if rd_size else b''
312-
try:
313-
nexthop = nh_rd + nlri_nexthop.pack_ip()
314-
except TypeError:
315-
# Fallback for invalid nexthop
316-
nexthop = bytes([0]) * 4
368+
# Encode nexthop with LLNH support
369+
nexthop = self._encode_nexthop(nlri_nexthop, family_key, negotiated)
317370

318371
mpnlri.setdefault(nexthop, []).append(bytes(nlri.pack_nlri(negotiated)))
319372

src/exabgp/bgp/neighbor/capability.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class NeighborCapability:
8181
nexthop: TriState = TriState.UNSET
8282
aigp: TriState = TriState.UNSET
8383
link_local_nexthop: TriState = TriState.UNSET
84+
link_local_prefer: bool = False # Prefer link-local over global when both present
8485
software_version: str | None = None
8586

8687
def copy(self) -> 'NeighborCapability':
@@ -99,6 +100,7 @@ def copy(self) -> 'NeighborCapability':
99100
nexthop=self.nexthop,
100101
aigp=self.aigp,
101102
link_local_nexthop=self.link_local_nexthop,
103+
link_local_prefer=self.link_local_prefer,
102104
software_version=self.software_version,
103105
)
104106

@@ -117,5 +119,6 @@ def __eq__(self, other: object) -> bool:
117119
and self.nexthop == other.nexthop
118120
and self.aigp == other.aigp
119121
and self.link_local_nexthop == other.link_local_nexthop
122+
and self.link_local_prefer == other.link_local_prefer
120123
and self.software_version == other.software_version
121124
)

src/exabgp/bgp/neighbor/session.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class Session:
3636

3737
# With defaults (non-None where possible)
3838
local_address: IP = field(default_factory=lambda: IP.NoNextHop) # NoNextHop = auto-discovery
39+
local_link_local: IP | None = None # Link-local address for IPv6 (fe80::/10)
3940
local_as: ASN = field(default_factory=lambda: ASN(0)) # 0 = auto (mirror peer)
4041
peer_as: ASN = field(default_factory=lambda: ASN(0)) # 0 = auto
4142
router_id: 'RouterID | None' = None # Derived from local_address if None and IPv4
@@ -127,6 +128,14 @@ def ip_self(self, afi: AFI) -> IP:
127128
f'use of "next-hop self": the route ({afi}) does not have the same family as the BGP tcp session ({local_afi})',
128129
)
129130

131+
def ip_link_local(self) -> IP | None:
132+
"""Get the local link-local IPv6 address if available.
133+
134+
Returns:
135+
Link-local IP or None if not set.
136+
"""
137+
return self.local_link_local
138+
130139
def connection_established(self, local: str) -> None:
131140
"""Called after TCP connection to set auto-discovered values.
132141

src/exabgp/configuration/capability.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ class ParseCapability(Section):
140140
operation=ActionOperation.SET,
141141
key=ActionKey.COMMAND,
142142
),
143+
'link-local-prefer': Leaf(
144+
type=ValueType.BOOLEAN,
145+
description='Prefer link-local over global IPv6 next-hop when both present',
146+
default=False,
147+
target=ActionTarget.SCOPE,
148+
operation=ActionOperation.SET,
149+
key=ActionKey.COMMAND,
150+
),
143151
},
144152
)
145153

@@ -154,6 +162,7 @@ class ParseCapability(Section):
154162
' extended-message enable|disable;\n'
155163
' software-version enable|disable;\n'
156164
' link-local-nexthop enable|disable;\n'
165+
' link-local-prefer enable|disable;\n'
157166
'}\n'
158167
)
159168

@@ -176,6 +185,7 @@ class ParseCapability(Section):
176185
'extended-message': True,
177186
'software-version': False,
178187
'link-local-nexthop': None,
188+
'link-local-prefer': False,
179189
}
180190

181191
name = 'capability'

src/exabgp/configuration/neighbor/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ class ParseNeighbor(Section):
6666
operation=ActionOperation.SET,
6767
key=ActionKey.COMMAND,
6868
),
69+
'local-link-local': Leaf(
70+
type=ValueType.IP_ADDRESS,
71+
description='Local IPv6 link-local address for LLNH capability (fe80::/10)',
72+
target=ActionTarget.SCOPE,
73+
operation=ActionOperation.SET,
74+
key=ActionKey.COMMAND,
75+
),
6976
'local-as': Leaf(
7077
type=ValueType.ASN,
7178
description='Local AS number (or "auto" to copy peer-as)',
@@ -332,6 +339,7 @@ def _post_get_scope(self) -> dict[str, Any]:
332339
_CONFIG_TO_SESSION: dict[str, str] = {
333340
'router-id': 'router_id',
334341
'local-address': 'local_address',
342+
'local-link-local': 'local_link_local',
335343
'source-interface': 'source_interface',
336344
'peer-address': 'peer_address',
337345
'local-as': 'local_as',
@@ -413,6 +421,8 @@ def _post_capa_default(self, neighbor: Neighbor, local: dict[str, Any]) -> None:
413421
if 'link-local-nexthop' in capability:
414422
if capability['link-local-nexthop'] is not None:
415423
cap.link_local_nexthop = TriState.from_bool(capability['link-local-nexthop'])
424+
if 'link-local-prefer' in capability:
425+
cap.link_local_prefer = capability['link-local-prefer']
416426
if 'graceful-restart' in capability:
417427
gr = capability['graceful-restart']
418428
if gr is False:
@@ -540,6 +550,11 @@ def post(self) -> bool:
540550
if neighbor.session.peer_address is IP.NoNextHop:
541551
return self.error.set('peer-address must be set')
542552

553+
# Validate local-link-local is actually a link-local address if set
554+
if neighbor.session.local_link_local is not None:
555+
if not neighbor.session.local_link_local.is_link_local():
556+
return self.error.set('local-link-local must be an IPv6 link-local address (fe80::/10)')
557+
543558
# peer_address is always IPRange when parsed from configuration (see parser.peer_ip)
544559
assert isinstance(neighbor.session.peer_address, IPRange)
545560
peer_range = neighbor.session.peer_address

tests/fuzz/test_update_message_integration.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ def create_negotiated_mock(families: Any = None, asn4: Any = False, msg_size: An
8888
negotiated.local_as = ASN(65000)
8989
negotiated.peer_as = ASN(65001)
9090

91+
# Link-local nexthop capability (default: disabled)
92+
negotiated.linklocal_nexthop = False
93+
negotiated.link_local_address = Mock(return_value=None)
94+
negotiated.link_local_prefer = Mock(return_value=False)
95+
negotiated.is_multihop = Mock(return_value=False)
96+
9197
return negotiated
9298

9399

0 commit comments

Comments
 (0)