Commit 78b6836fdb for frr

commit 78b6836fdb4226d22fadf804285ea3d26a65e426
Author: Abdul Wasey <w453y.me@gmail.com>
Date:   Thu Sep 24 22:41:27 2026 +0000

    tests: check that bfdd leaves the echo port to the data plane

    bfdd uses the listener as its data plane, with an IPv4 and an IPv6 peer
    in echo-mode. It must hold no socket on UDP 3785, with echo-mode from the
    startup configuration or set again from vtysh.

    Signed-off-by: Abdul Wasey <w453y.me@gmail.com>

diff --git a/tests/topotests/bfd_dplane_echo_socket_topo1/__init__.py b/tests/topotests/bfd_dplane_echo_socket_topo1/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/bfd_dplane_echo_socket_topo1/r1/frr.conf b/tests/topotests/bfd_dplane_echo_socket_topo1/r1/frr.conf
new file mode 100644
index 0000000000..6abb9ce7eb
--- /dev/null
+++ b/tests/topotests/bfd_dplane_echo_socket_topo1/r1/frr.conf
@@ -0,0 +1,14 @@
+!
+interface r1-eth0
+ ip address 10.0.1.1/24
+ ipv6 address fd00:1::1/64
+!
+bfd
+ peer 10.0.1.2 local-address 10.0.1.1 interface r1-eth0
+  echo-mode
+ exit
+ peer fd00:1::2 local-address fd00:1::1 interface r1-eth0
+  echo-mode
+ exit
+exit
+!
diff --git a/tests/topotests/bfd_dplane_echo_socket_topo1/test_bfd_dplane_echo_socket_topo1.py b/tests/topotests/bfd_dplane_echo_socket_topo1/test_bfd_dplane_echo_socket_topo1.py
new file mode 100644
index 0000000000..1294a1fc87
--- /dev/null
+++ b/tests/topotests/bfd_dplane_echo_socket_topo1/test_bfd_dplane_echo_socket_topo1.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+#
+# test_bfd_dplane_echo_socket_topo1.py
+#
+# Copyright (c) 2026 by Abdul Wasey
+#
+
+"""
+With a data plane, bfdd must not open the BFD echo port.
+
+bfdd opens no BFD sockets when it uses a data plane: the data plane sends
+and receives control and echo packets. The echo sockets were an exception,
+opened whenever echo-mode was set on a session whose VRF was already known.
+Whether bfdd held the port then depended on the order in which the
+configuration and the interfaces arrived, and so did whether a peer's IPv6
+echo was returned by bfdd or refused with port unreachable.
+
+The listener stands in for a data plane.
+"""
+
+import os
+import sys
+from functools import partial
+
+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, TopoRouter, get_topogen
+
+pytestmark = [pytest.mark.bfdd]
+
+DPLANE_PORT = 50700
+PEERS = {
+    "10.0.1.2": "peer 10.0.1.2 local-address 10.0.1.1 interface r1-eth0",
+    "fd00:1::2": "peer fd00:1::2 local-address fd00:1::1 interface r1-eth0",
+}
+
+
+def build_topo(tgen):
+    "Build function"
+    tgen.add_router("r1")
+    switch = tgen.add_switch("s1")
+    switch.add_link(tgen.gears["r1"])
+
+
+def setup_module(mod):
+    tgen = Topogen(build_topo, mod.__name__)
+    tgen.start_topology()
+
+    router = tgen.gears["r1"]
+
+    # bfdd connects to the listener as a client, so it starts first.
+    router.load_frr_config(
+        daemons=[
+            (TopoRouter.RD_BFD_DPLANE_LISTENER, "-p {}".format(DPLANE_PORT)),
+            (TopoRouter.RD_ZEBRA, None),
+            (
+                TopoRouter.RD_BFD,
+                "--dplaneaddr ipv4c:127.0.0.1:{}".format(DPLANE_PORT),
+            ),
+        ],
+    )
+
+    tgen.start_router()
+
+
+def teardown_module(_mod):
+    tgen = get_topogen()
+    tgen.stop_topology()
+
+
+def _echo_sockets(router):
+    "bfdd's UDP sockets on the echo port, as ss prints them."
+    out = router.cmd("ss -Hulpn sport = :3785")
+    return [line for line in out.splitlines() if "bfdd" in line]
+
+
+def _echo_tx(router, peer):
+    "The peer's echo transmit interval: 0 when echo-mode is off."
+    for entry in router.vtysh_cmd("show bfd peers json", isjson=True):
+        if entry.get("peer") == peer:
+            return entry.get("echo-transmit-interval")
+    return None
+
+
+def _all_up(router):
+    peers = router.vtysh_cmd("show bfd peers json", isjson=True)
+    down = [p["peer"] for p in peers if p.get("status") != "up"]
+    if len(peers) != len(PEERS) or down:
+        return "not up: {}".format(down or peers)
+    return None
+
+
+def test_sessions_up():
+    "The listener takes both sessions."
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    router = tgen.gears["r1"]
+    _, result = topotest.run_and_expect(
+        partial(_all_up, router), None, count=30, wait=1
+    )
+    assert result is None, result
+
+
+def test_no_echo_socket_from_config():
+    "echo-mode from the startup configuration opens no socket."
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    router = tgen.gears["r1"]
+    found = _echo_sockets(router)
+    assert not found, "bfdd holds the echo port: {}".format(found)
+
+
+def test_no_echo_socket_when_set_at_runtime():
+    "echo-mode set from vtysh opens no socket either."
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    router = tgen.gears["r1"]
+    for peer, config in PEERS.items():
+        router.vtysh_cmd(
+            "configure terminal\nbfd\n{}\nno echo-mode\nend".format(config)
+        )
+        assert _echo_tx(router, peer) == 0, "echo-mode is still on for {}".format(peer)
+        router.vtysh_cmd("configure terminal\nbfd\n{}\necho-mode\nend".format(config))
+        assert _echo_tx(router, peer), "echo-mode did not come back for {}".format(peer)
+
+    found = _echo_sockets(router)
+    assert not found, "bfdd holds the echo port: {}".format(found)
+
+    _, result = topotest.run_and_expect(
+        partial(_all_up, router), None, count=10, wait=1
+    )
+    assert result is None, result
+
+
+def test_memory_leak():
+    "Run the memory leak test and report results."
+    tgen = get_topogen()
+    if not tgen.is_memleak_enabled():
+        pytest.skip("Memory leak test/report is disabled")
+    tgen.report_memory_leaks()
+
+
+if __name__ == "__main__":
+    args = ["-s"] + sys.argv[1:]
+    sys.exit(pytest.main(args))