From d22f698e88a05332cee4b8dac2e7085c8b83b0ed Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 18:22:47 +0300 Subject: tests: cover the removal of stale ipoe session interfaces Three tests around the interfaces the ipoe module creates per session: - kill accel-pppd with SIGKILL while a dhcp session is up, start it again and check that the interfaces left behind by the killed instance are gone, - remove a session interface with 'ip link del', - check that 'ip link add ... type ipoe' is refused, since a device made through rtnetlink would have none of the private state that IPOE_CMD_CREATE sets up. They carry the ipoe_driver marker and sit next to the existing ipoe tests, so the workflows run them in the steps that follow the insmod of the module. No workflow change is needed. The [modules] section has to list connlimit and radius before ipoe: libipoe.so refers to symbols of both, and with a strict dynamic linker loading it on its own fails with a relocation error instead of a missing feature. The tests assert that accel-pppd came up, so that this kind of misconfiguration is not reported as an unrelated cli connection failure. Restarting accel-pppd needs no explicit synchronisation: triton_load_modules() runs every DEFINE_INIT() before triton_run() starts the threads that serve the cli, so by the time accel-cmd 'show version' is answered the flush registered at DEFINE_INIT(19) has already run. Interfaces are compared by ifindex and not by name, because dhclient may get a new session in the meantime and the fresh interface would reuse the ipoe0 name. The check that nothing removes the interfaces while no accel-pppd is running is only printed, not asserted, so that the module is free to start doing it on its own later on. --- tests/accel-pppd/ipoe/test_ipoe_link_ops.py | 82 ++++++++++++++++++++ tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py | 97 ++++++++++++++++++++++++ tests/common/ipoe_iface.py | 84 ++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 tests/accel-pppd/ipoe/test_ipoe_link_ops.py create mode 100644 tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py create mode 100644 tests/common/ipoe_iface.py (limited to 'tests') diff --git a/tests/accel-pppd/ipoe/test_ipoe_link_ops.py b/tests/accel-pppd/ipoe/test_ipoe_link_ops.py new file mode 100644 index 00000000..00d06af4 --- /dev/null +++ b/tests/accel-pppd/ipoe/test_ipoe_link_ops.py @@ -0,0 +1,82 @@ +import pytest +from common import process, ipoe_iface + + +@pytest.fixture() +def accel_pppd_config(veth_pair_netns): + return ( + """ + [modules] + connlimit + radius + ipoe + ippool + + [ip-pool] + gw-ip-address=192.0.2.1 + 192.0.2.2-255 + + [cli] + tcp=127.0.0.1:2001 + + [core] + log-error=/dev/stderr + + [log] + log-debug=/dev/stdout + log-file=/dev/stdout + log-emerg=/dev/stderr + level=5 + + [radius] + + [ipoe] + noauth=1 + shared=1 + gw-ip-address=192.0.2.1/24 + interface=re:.""" + + veth_pair_netns["veth_a"][1:] + ) + + +# sessions carry private state set up over generic netlink, a device made by +# rtnetlink would have none of it +@pytest.mark.dependency(depends=["ipoe_driver_loaded"], scope="session") +@pytest.mark.ipoe_driver +def test_ipoe_interface_cannot_be_created_by_iproute(): + (exit_code, out, err) = process.run( + ["ip", "link", "add", "ipoetest0", "type", "ipoe"] + ) + print("ip link add: exit=%d out=%s err=%s" % (exit_code, out, err)) + + # remove it again in case it got created anyway, so that the rest of the + # suite is not affected + process.run(["ip", "link", "del", "ipoetest0"]) + + assert exit_code != 0 + + +# stale interfaces have to be removable without restarting accel-pppd, which +# would drop every remaining session +@pytest.mark.dependency(depends=["ipoe_driver_loaded"], scope="session") +@pytest.mark.ipoe_driver +def test_ipoe_interface_can_be_deleted_by_iproute( + accel_pppd_instance, dhclient_instance, accel_cmd, veth_pair_netns +): + assert accel_pppd_instance, "accel-pppd did not start" + assert dhclient_instance["is_started"] + assert ipoe_iface.wait_for_session(accel_cmd, veth_pair_netns["veth_a"]) + + before = ipoe_iface.list_ipoe() + print("ipoe interfaces: " + str(before)) + assert len(before) > 0 + + (ifindex, name) = before[0] + + (exit_code, out, err) = process.run(["ip", "link", "del", name]) + print("ip link del %s: exit=%d out=%s err=%s" % (name, exit_code, out, err)) + assert exit_code == 0 + + after = ipoe_iface.list_ipoe() + print("ipoe interfaces after delete: " + str(after)) + assert (ifindex, name) not in after diff --git a/tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py b/tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py new file mode 100644 index 00000000..b7cfd533 --- /dev/null +++ b/tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py @@ -0,0 +1,97 @@ +import pytest +from common import accel_pppd_process, ipoe_iface + + +@pytest.fixture() +def accel_pppd_config(veth_pair_netns): + return ( + """ + [modules] + connlimit + radius + ipoe + ippool + + [ip-pool] + gw-ip-address=192.0.2.1 + 192.0.2.2-255 + + [cli] + tcp=127.0.0.1:2001 + + [core] + log-error=/dev/stderr + + [log] + log-debug=/dev/stdout + log-file=/dev/stdout + log-emerg=/dev/stderr + level=5 + + [radius] + + [ipoe] + noauth=1 + shared=1 + gw-ip-address=192.0.2.1/24 + interface=re:.""" + + veth_pair_netns["veth_a"][1:] + ) + + +# accel-pppd killed with SIGKILL has no chance to remove the session +# interfaces it created, so they are left in the kernel. The next instance is +# supposed to drop them while starting up. +@pytest.mark.dependency(depends=["ipoe_driver_loaded"], scope="session") +@pytest.mark.ipoe_driver +def test_ipoe_stale_interfaces_removed_after_sigkill( + accel_pppd_instance, + dhclient_instance, + accel_cmd, + accel_pppd, + accel_pppd_config_file, + veth_pair_netns, + pytestconfig, +): + assert accel_pppd_instance, "accel-pppd did not start" + assert dhclient_instance["is_started"] + assert ipoe_iface.wait_for_session(accel_cmd, veth_pair_netns["veth_a"]) + + # the session must have created an interface, otherwise there is nothing + # for this test to check + before = ipoe_iface.list_ipoe() + print("ipoe interfaces before the crash: " + str(before)) + assert len(before) > 0 + + assert ipoe_iface.kill_accel_pppd() > 0 + + # nothing removes them while no accel-pppd is running (not asserted, the + # kernel module is free to start doing it on its own one day) + print("ipoe interfaces after the crash: " + str(ipoe_iface.list_ipoe())) + + # start again, it replies to 'show version' only once the startup flush is + # done, so there is no need to wait for anything else + (is_started, thread, control) = accel_pppd_process.start( + accel_pppd, + ["-c" + accel_pppd_config_file], + accel_cmd, + pytestconfig.getoption("accel_pppd_max_wait_time"), + ) + + try: + assert is_started + + after = ipoe_iface.list_ipoe() + print("ipoe interfaces after the restart: " + str(after)) + + # compared by ifindex: dhclient may get a new session in the meantime, + # and the fresh interface would reuse the ipoe0 name + stale = set(before) & set(after) + assert not stale, "interfaces left over from the killed instance: " + str(stale) + finally: + accel_pppd_process.end( + thread, + control, + accel_cmd, + pytestconfig.getoption("accel_pppd_max_finish_time"), + ) diff --git a/tests/common/ipoe_iface.py b/tests/common/ipoe_iface.py new file mode 100644 index 00000000..62b26355 --- /dev/null +++ b/tests/common/ipoe_iface.py @@ -0,0 +1,84 @@ +from common import process +import os +import signal +import time + + +# (ifindex, name) of every ipoe session interface currently in the kernel. +# Matched by name rather than by 'ip link show type ipoe' so that the helper +# keeps working with a module that does not register rtnl_link_ops. +def list_ipoe(): + (exit_code, out, err) = process.run(["ip", "-o", "link", "show"]) + assert exit_code == 0, "ip link show failed: " + err + + ifaces = [] + for line in out.splitlines(): + fields = line.split(":") + if len(fields) < 2: + continue + try: + ifindex = int(fields[0].strip()) + except ValueError: + continue + name = fields[1].strip().split("@")[0] + if name.startswith("ipoe"): + ifaces.append((ifindex, name)) + + return ifaces + + +# pids of the running accel-pppd processes +def accel_pppd_pids(): + pids = [] + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + with open("/proc/" + entry + "/comm") as comm: + if comm.read().strip() == "accel-pppd": + pids.append(int(entry)) + except OSError: # process is gone, or not ours to look at + pass + + return pids + + +# SIGKILL every accel-pppd and wait until they are really gone. +# Returns the number of processes that were killed. +def kill_accel_pppd(max_wait_time=10.0): + pids = accel_pppd_pids() + for pid in pids: + print("kill_accel_pppd: SIGKILL to pid " + str(pid)) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + sleep_time = 0.0 + while sleep_time < max_wait_time: + if not accel_pppd_pids(): + print("kill_accel_pppd: gone in (sec): " + str(sleep_time)) + break + time.sleep(0.1) + sleep_time += 0.1 + + return len(pids) + + +# wait until accel-pppd reports an active ipoe session on the given interface +def wait_for_session(accel_cmd, called_sid, max_wait_time=10.0): + sleep_time = 0.0 + out = "" + while sleep_time < max_wait_time: + (exit_code, out, err) = process.run( + [accel_cmd, "show sessions called-sid,ip,state"] + ) + assert exit_code == 0, "accel-cmd failed: " + err + if called_sid in out and "192.0.2." in out and "active" in out: + print("wait_for_session: session found in (sec): " + str(sleep_time)) + return True + time.sleep(0.1) + sleep_time += 0.1 + + print("wait_for_session: last accel-cmd out: " + out) + return False -- cgit v1.2.3