Commit b0b7e4390a for frr
commit b0b7e4390a141010517abe79a66792b405299289
Author: Donatas Abraitis <donatas@opensourcerouting.org>
Date: Fri Sep 18 14:20:40 2026 +0300
tests: Check if we handle withdrawals first if the same prefix comes as NLRI
Signed-off-by: Donatas Abraitis <donatas@opensourcerouting.org>
diff --git a/tests/topotests/bgp_update_dup_prefix/__init__.py b/tests/topotests/bgp_update_dup_prefix/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/bgp_update_dup_prefix/r1/frr.conf b/tests/topotests/bgp_update_dup_prefix/r1/frr.conf
new file mode 100644
index 0000000000..05eeb488eb
--- /dev/null
+++ b/tests/topotests/bgp_update_dup_prefix/r1/frr.conf
@@ -0,0 +1,18 @@
+!
+interface r1-eth0
+ ip address 10.0.0.1/24
+!
+debug bgp updates in
+debug bgp neighbor-events
+!
+router bgp 65000
+ no bgp ebgp-requires-policy
+ bgp log-neighbor-changes
+ neighbor 10.0.0.2 remote-as external
+ ! The test peer drives the connection, so that the UPDATE sequence cannot
+ ! start before the script is ready.
+ neighbor 10.0.0.2 passive
+ address-family ipv4 unicast
+ neighbor 10.0.0.2 activate
+ exit-address-family
+!
diff --git a/tests/topotests/bgp_update_dup_prefix/rawpeer.py b/tests/topotests/bgp_update_dup_prefix/rawpeer.py
new file mode 100644
index 0000000000..cdb60dfdac
--- /dev/null
+++ b/tests/topotests/bgp_update_dup_prefix/rawpeer.py
@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: ISC
+#
+# Copyright (c) 2026 Donatas Abraitis <donatas@opensourcerouting.org>
+#
+"""Minimal BGP speaker that emits hand-crafted UPDATE messages.
+
+ExaBGP cannot drive these cases. It builds the UPDATE itself and will never
+place the same prefix in both WITHDRAWN ROUTES and NLRI, which is exactly the
+encoding RFC 4271 section 4.3 describes and asks a receiver to accept.
+
+Imported by the pytest side for CASES, so the announced case and the asserted
+case are one object; run as a script inside the peer namespace to speak.
+"""
+
+import socket
+import struct
+import sys
+import threading
+import time
+
+MARKER = b"\xff" * 16
+HEADER_LEN = 19
+
+OPEN = 1
+UPDATE = 2
+NOTIFICATION = 3
+KEEPALIVE = 4
+
+LOCAL_AS = 65002
+LOCAL_IP = "10.0.0.2"
+PEER_IP = "10.0.0.1"
+PEER_PORT = 179
+
+ORIGIN_IGP = 0
+# Undefined ORIGIN value. RFC 7606 section 7.3 makes the whole UPDATE
+# treat-as-withdraw, which is how the attr==NULL path is reached with both
+# NLRI fields populated.
+ORIGIN_INVALID = 5
+
+# Announced last. Its arrival proves every preceding UPDATE has been
+# processed, which removes the need for a fixed sleep on the pytest side.
+SENTINEL = "10.10.254.0/24"
+
+
+class Step:
+ """One UPDATE message put on the wire.
+
+ withdraw -- prefixes for the WITHDRAWN ROUTES field
+ announce -- prefixes for the NLRI field
+ mp_unreach -- prefixes for an IPv4-unicast MP_UNREACH_NLRI attribute,
+ which withdraws from the very same RIB as `withdraw`
+ """
+
+ def __init__(self, withdraw=(), announce=(), mp_unreach=(), origin=ORIGIN_IGP):
+ self.withdraw = tuple(withdraw)
+ self.announce = tuple(announce)
+ self.mp_unreach = tuple(mp_unreach)
+ self.origin = origin
+
+
+class Case:
+ """An UPDATE sequence and the RIB state it must leave behind.
+
+ Expectations are checked once, after the sentinel arrives, so every case
+ must use prefixes no other case touches.
+ """
+
+ def __init__(self, name, steps, present=(), absent=(), spec=""):
+ self.name = name
+ self.steps = steps
+ self.present = tuple(present)
+ self.absent = tuple(absent)
+ self.spec = spec
+
+
+CASES = [
+ Case(
+ name="duplicate-prefix-unknown",
+ steps=[Step(withdraw=["10.10.1.0/24"], announce=["10.10.1.0/24"])],
+ present=["10.10.1.0/24"],
+ spec="RFC 4271 section 4.3",
+ ),
+ Case(
+ name="duplicate-prefix-already-known",
+ # The prefix is in the Adj-RIB-In before the duplicate UPDATE lands,
+ # so the withdraw half would actually find something to remove. This
+ # is the case that distinguishes the orderings most sharply.
+ steps=[
+ Step(announce=["10.10.2.0/24"]),
+ Step(withdraw=["10.10.2.0/24"], announce=["10.10.2.0/24"]),
+ ],
+ present=["10.10.2.0/24"],
+ spec="RFC 4271 section 4.3",
+ ),
+ Case(
+ name="distinct-prefixes-one-update",
+ # Control: ordinary mixed UPDATEs must keep working. If withdrawals
+ # were simply moved ahead of NLRI without care, this is what breaks.
+ steps=[
+ Step(announce=["10.10.3.0/24"]),
+ Step(withdraw=["10.10.3.0/24"], announce=["10.10.13.0/24"]),
+ ],
+ present=["10.10.13.0/24"],
+ absent=["10.10.3.0/24"],
+ spec="RFC 4271 section 4.3",
+ ),
+ Case(
+ name="mp-unreach-and-nlri-same-prefix",
+ # An MP_UNREACH_NLRI for IPv4 unicast withdraws from the same RIB as
+ # the conventional NLRI field, so it has to be applied before the
+ # reachable NLRI for the same reason WITHDRAWN ROUTES does.
+ steps=[Step(announce=["10.10.5.0/24"], mp_unreach=["10.10.5.0/24"])],
+ present=["10.10.5.0/24"],
+ spec="RFC 4271 section 4.3 read together with RFC 4760",
+ ),
+ Case(
+ name="mp-unreach-distinct-prefix",
+ # Control: an MP_UNREACH that names a different prefix must still
+ # take it out, whichever side of the reachable NLRI it is applied on.
+ steps=[
+ Step(announce=["10.10.6.0/24"]),
+ Step(announce=["10.10.16.0/24"], mp_unreach=["10.10.6.0/24"]),
+ ],
+ present=["10.10.16.0/24"],
+ absent=["10.10.6.0/24"],
+ spec="RFC 4760 section 3",
+ ),
+ Case(
+ name="duplicate-prefix-treat-as-withdraw",
+ # A malformed ORIGIN turns the reachable NLRI into a withdrawal too,
+ # so here the prefix must end up gone. Guards the attr==NULL path
+ # against a fix that special-cases duplicates too eagerly.
+ steps=[
+ Step(announce=["10.10.4.0/24"]),
+ Step(
+ withdraw=["10.10.4.0/24"],
+ announce=["10.10.4.0/24"],
+ origin=ORIGIN_INVALID,
+ ),
+ ],
+ absent=["10.10.4.0/24"],
+ spec="RFC 7606 section 7.3",
+ ),
+]
+
+
+def log(fmt, *args):
+ sys.stderr.write("%s peer1: %s\n" % (time.strftime("%H:%M:%S"), fmt % args))
+ sys.stderr.flush()
+
+
+def message(kind, body):
+ return MARKER + struct.pack("!HB", HEADER_LEN + len(body), kind) + body
+
+
+def capability(code, value):
+ return struct.pack("!BB", code, len(value)) + value
+
+
+def open_message():
+ caps = capability(1, struct.pack("!HBB", 1, 0, 1)) # MP-BGP, IPv4 unicast
+ caps += capability(65, struct.pack("!I", LOCAL_AS)) # 4-octet AS
+ opt = struct.pack("!BB", 2, len(caps)) + caps
+ body = struct.pack("!BHH", 4, LOCAL_AS, 180)
+ body += socket.inet_aton(LOCAL_IP)
+ body += struct.pack("!B", len(opt)) + opt
+ return message(OPEN, body)
+
+
+def nlri(prefix):
+ addr, masklen = prefix.split("/")
+ masklen = int(masklen)
+ return struct.pack("!B", masklen) + socket.inet_aton(addr)[: (masklen + 7) // 8]
+
+
+def attribute(flags, code, value):
+ return struct.pack("!BBB", flags, code, len(value)) + value
+
+
+def path_attributes(origin):
+ attrs = attribute(0x40, 1, struct.pack("!B", origin))
+ attrs += attribute(0x40, 2, struct.pack("!BBI", 2, 1, LOCAL_AS)) # AS_SEQUENCE
+ attrs += attribute(0x40, 3, socket.inet_aton(LOCAL_IP)) # NEXT_HOP
+ return attrs
+
+
+def mp_unreach_attribute(prefixes):
+ # Optional non-transitive, AFI 1 / SAFI 1: the same RIB the conventional
+ # NLRI and WITHDRAWN ROUTES fields feed.
+ value = struct.pack("!HB", 1, 1) + b"".join(nlri(p) for p in prefixes)
+ return attribute(0x80, 15, value)
+
+
+def update_message(step):
+ withdrawn = b"".join(nlri(p) for p in step.withdraw)
+ reachable = b"".join(nlri(p) for p in step.announce)
+ attrs = path_attributes(step.origin) if step.announce else b""
+ if step.mp_unreach:
+ attrs += mp_unreach_attribute(step.mp_unreach)
+ body = struct.pack("!H", len(withdrawn)) + withdrawn
+ body += struct.pack("!H", len(attrs)) + attrs + reachable
+ return message(UPDATE, body)
+
+
+def recv_exactly(sock, count):
+ data = b""
+ while len(data) < count:
+ chunk = sock.recv(count - len(data))
+ if not chunk:
+ raise EOFError("peer closed the connection")
+ data += chunk
+ return data
+
+
+def recv_message(sock):
+ header = recv_exactly(sock, HEADER_LEN)
+ length, kind = struct.unpack("!HB", header[16:HEADER_LEN])
+ return kind, recv_exactly(sock, length - HEADER_LEN)
+
+
+class Session:
+ def __init__(self):
+ self.sock = None
+ self.lock = threading.Lock()
+ self.hold = 180
+
+ def send(self, data):
+ with self.lock:
+ self.sock.sendall(data)
+
+ def close(self):
+ if self.sock is not None:
+ self.sock.close()
+ self.sock = None
+
+ def establish(self):
+ self.sock = socket.create_connection((PEER_IP, PEER_PORT), timeout=60)
+ self.send(open_message())
+
+ while True:
+ kind, body = recv_message(self.sock)
+ if kind == OPEN:
+ self.hold = min(self.hold, struct.unpack("!H", body[3:5])[0])
+ log("received OPEN, negotiated hold time %d", self.hold)
+ self.send(message(KEEPALIVE, b""))
+ elif kind == KEEPALIVE:
+ log("session established")
+ return
+ elif kind == NOTIFICATION:
+ raise RuntimeError(
+ "peer sent NOTIFICATION %s during the handshake" % body[:2].hex()
+ )
+
+ def keepalive_loop(self):
+ # Hold time 0 means no keepalives are expected at all.
+ if not self.hold:
+ return
+ interval = max(self.hold / 3.0, 1.0)
+ while True:
+ time.sleep(interval)
+ try:
+ self.send(message(KEEPALIVE, b""))
+ except OSError as err:
+ log("keepalive failed: %s", err)
+ return
+
+ def reader_loop(self):
+ while True:
+ try:
+ kind, body = recv_message(self.sock)
+ except (OSError, EOFError) as err:
+ log("read failed: %s", err)
+ return
+ if kind == NOTIFICATION:
+ log("received NOTIFICATION %s", body[:2].hex())
+ return
+
+
+def establish_with_retry(deadline):
+ """Keep retrying the handshake until the deadline.
+
+ bgpd rejects connections while it is still reading its configuration, and
+ closes the one it just accepted. A speaker that gave up on the first
+ attempt would race the router's startup and fail the run intermittently.
+ """
+ while True:
+ session = Session()
+ try:
+ log("connecting to %s:%d", PEER_IP, PEER_PORT)
+ session.establish()
+ return session
+ except (OSError, EOFError, RuntimeError) as err:
+ session.close()
+ if time.time() >= deadline:
+ raise
+ log("handshake failed (%s), retrying", err)
+ time.sleep(1)
+
+
+def main():
+ session = establish_with_retry(time.time() + 120)
+
+ threading.Thread(target=session.keepalive_loop, daemon=True).start()
+ threading.Thread(target=session.reader_loop, daemon=True).start()
+
+ for case in CASES:
+ for step in case.steps:
+ log(
+ "%s: withdraw=%s announce=%s origin=%d",
+ case.name,
+ list(step.withdraw),
+ list(step.announce),
+ step.origin,
+ )
+ session.send(update_message(step))
+
+ log("announcing sentinel %s", SENTINEL)
+ session.send(update_message(Step(announce=[SENTINEL])))
+
+ while True:
+ time.sleep(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/topotests/bgp_update_dup_prefix/test_bgp_update_dup_prefix.py b/tests/topotests/bgp_update_dup_prefix/test_bgp_update_dup_prefix.py
new file mode 100644
index 0000000000..9b375f3bba
--- /dev/null
+++ b/tests/topotests/bgp_update_dup_prefix/test_bgp_update_dup_prefix.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+
+# Copyright (c) 2026 Donatas Abraitis <donatas@opensourcerouting.org>
+
+"""
+RFC 4271 conformance: a prefix repeated in WITHDRAWN ROUTES and NLRI.
+
+RFC 4271 section 4.3 allows an UPDATE to carry the same prefix in both the
+WITHDRAWN ROUTES and the Network Layer Reachability Information fields, and
+says a receiver SHOULD treat such an UPDATE as though WITHDRAWN ROUTES did not
+contain the prefix. The reachable NLRI wins; the duplicate withdrawal is
+ignored.
+
+bgp_update_receive() walks nlris[] in enum order, so the enum decides which
+half of the UPDATE is applied last. Withdrawals sort first, which leaves the
+route installed as the RFC asks. Announcing first and withdrawing second would
+leave the opposite state and is what this test exists to catch.
+
+The peer is a hand-written speaker rather than ExaBGP, which composes its own
+UPDATEs and cannot be made to emit this encoding. The case table lives in
+rawpeer.py and is imported by both sides, so the announced case and the
+asserted case are one object.
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+
+import pytest
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(CWD, "../"))
+
+# pylint: disable=C0413
+from lib import topotest
+from lib.topogen import Topogen, get_topogen
+from lib.topolog import logger
+
+# Imported through this directory's package rather than off a sys.path entry:
+# a bare `import rawpeer` would hand whichever module got imported first to
+# every directory that ships one, once pytest collects them together.
+from bgp_update_dup_prefix.rawpeer import CASES, LOCAL_IP, SENTINEL
+
+pytestmark = [pytest.mark.bgpd]
+
+PEER_NAME = "peer1"
+PEER_SCRIPT = os.path.join(CWD, "rawpeer.py")
+
+# The running speaker, so teardown can reap it.
+PEER_PROCESS = None
+
+
+def build_topo(tgen):
+ r1 = tgen.add_router("r1")
+
+ switch = tgen.add_switch("s1")
+ switch.add_link(r1)
+
+ peer = tgen.add_host(PEER_NAME, "%s/24" % LOCAL_IP, "via 10.0.0.1")
+ switch.add_link(peer)
+
+
+def setup_module(mod):
+ global PEER_PROCESS
+
+ tgen = Topogen(build_topo, mod.__name__)
+ tgen.start_topology()
+
+ router = tgen.gears["r1"]
+ router.load_frr_config(os.path.join(CWD, "r1/frr.conf"))
+ router.start()
+
+ peer = tgen.gears[PEER_NAME]
+ logfile = os.path.join(peer.logdir, PEER_NAME, "rawpeer.log")
+ os.makedirs(os.path.dirname(logfile), exist_ok=True)
+ # The speaker writes its progress to stderr; keeping it next to the
+ # router logs is what makes a failed run diagnosable.
+ PEER_PROCESS = peer.popen(
+ ["python3", PEER_SCRIPT],
+ stdout=open(logfile, "w"),
+ stderr=subprocess.STDOUT,
+ )
+
+
+def teardown_module(mod):
+ tgen = get_topogen()
+
+ if PEER_PROCESS is not None:
+ PEER_PROCESS.terminate()
+ PEER_PROCESS.wait()
+
+ tgen.stop_topology()
+
+ shutil.rmtree(os.path.join(CWD, "__pycache__"), ignore_errors=True)
+
+
+def _routes(r1):
+ output = json.loads(r1.vtysh_cmd("show bgp ipv4 unicast json"))
+ return output.get("routes", {})
+
+
+@pytest.fixture(scope="module")
+def sequence_done():
+ """Block until every UPDATE in the sequence has been parsed.
+
+ The sentinel is announced after the last case, so its arrival -- not a
+ fixed sleep -- is what says the sequence is complete. It says nothing
+ about the RIB having settled: bgp_rib_withdraw() only flags the path and
+ queues the dest, so a withdrawn prefix is still listed for a moment
+ afterwards. The per-case checks poll for that.
+ """
+ tgen = get_topogen()
+ if tgen.routers_have_failure():
+ pytest.skip(tgen.errors)
+
+ r1 = tgen.gears["r1"]
+
+ def _established():
+ output = json.loads(r1.vtysh_cmd("show bgp neighbors %s json" % LOCAL_IP))
+ state = output.get(LOCAL_IP, {}).get("bgpState")
+ return None if state == "Established" else state
+
+ _, result = topotest.run_and_expect(_established, None, count=60, wait=1)
+ assert result is None, (
+ "session with %s never came up (state %s), so no case was announced; "
+ "check the peer log in the %s log directory" % (LOCAL_IP, result, PEER_NAME)
+ )
+
+ def _sentinel():
+ return None if SENTINEL in _routes(r1) else "sentinel %s absent" % SENTINEL
+
+ _, result = topotest.run_and_expect(_sentinel, None, count=60, wait=1)
+ assert result is None, (
+ "sentinel %s never arrived (%s); the UPDATE sequence did not run to "
+ "completion, so the per-case results below would be meaningless"
+ % (SENTINEL, result)
+ )
+
+ logger.info("RIB once the sequence was parsed: %s", sorted(_routes(r1)))
+ return r1
+
+
+@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES])
+def test_update_dup_prefix(sequence_done, case):
+ """The UPDATE sequence must leave exactly the state the case declares."""
+ r1 = sequence_done
+
+ def _settled():
+ rib = _routes(r1)
+ missing = [prefix for prefix in case.present if prefix not in rib]
+ extra = [prefix for prefix in case.absent if prefix in rib]
+ if not missing and not extra:
+ return None
+ return {"missing": missing, "still present": extra, "rib": sorted(rib)}
+
+ _, result = topotest.run_and_expect(_settled, None, count=30, wait=1)
+ assert result is None, (
+ "case %s did not reach the state %s requires: %s. A prefix under "
+ "'missing' means the duplicate withdrawal won over the reachable "
+ "NLRI, which is the ordering bug; a prefix under 'still present' "
+ "means an ordinary withdrawal stopped taking effect."
+ % (case.name, case.spec, result)
+ )
+
+
+if __name__ == "__main__":
+ args = ["-s"] + sys.argv[1:]
+ sys.exit(pytest.main(args))