[PATCH relayd] main: recover gateway routes after uplink interruptions
Lukas Bockel
ckbkr10 at ckprog.de
Mon Oct 19 10:33:38 PDT 2026
From: Lukas Bockel <56784875+ckbaker10 at users.noreply.github.com>
Retry unresolved configured gateways after startup and host expiry. Refresh the ARP source address before probing, and reassert known gateway routes after address reassignment flushes them from the kernel.
Add isolated namespace integration tests covering recovery and unchanged host expiry behavior. All nine pass against the complete CMake-built application; six fail against the unmodified upstream build.
Signed-off-by: Lukas Bockel <56784875+ckbaker10 at users.noreply.github.com>
---
This is the patch submitted as GitHub PR #3:
https://github.com/openwrt/relayd/pull/3
Since that repository is a mirror, I am submitting the inline patch here
for upstream review as well.
Related proposal: https://github.com/openwrt/relayd/pull/2 adds directed
ARP host-expiry probes. It addresses a complementary failure mode.
This patch is based directly on upstream 708a76f; it neither includes
nor requires PR #2. Both changes can be reviewed independently.
Validation: complete CMake builds (GCC 13.3.0, -Wall -Werror);
namespace integration suite: upstream 3/9, patched 9/9, also checked
with default expiry/retry settings.
Complete ARM hard-float build and test on ZTE MF289F / OpenWrt 24.10.4:
scoped ARP suppression expired both relay default routes; removing it
restored the routes without restarting relayd. Uplink ICMP, gateway DNS
and phone web access with mobile data disabled passed afterwards.
AP reboot was not injected; interface recreation is outside this patch.
main.c | 56 +++++--
tests/README.md | 31 ++++
tests/test_static_routes.py | 290 ++++++++++++++++++++++++++++++++++++
3 files changed, 367 insertions(+), 10 deletions(-)
create mode 100644 tests/README.md
create mode 100644 tests/test_static_routes.py
diff --git a/main.c b/main.c
index b3c13f7..3f60367 100644
--- a/main.c
+++ b/main.c
@@ -42,6 +42,11 @@ static int forward_bcast;
static int forward_dhcp;
static int parse_dhcp;
+static void ping_static_routes(struct uloop_timeout *timeout);
+static struct uloop_timeout static_routes_timeout = {
+ .cb = ping_static_routes,
+};
+
uint8_t local_addr[4];
int local_route_table;
@@ -124,6 +129,7 @@ void relayd_add_host_route(struct relayd_host *host, const uint8_t *dest, uint8_
static void del_host(struct relayd_host *host)
{
struct relayd_route *route, *tmp;
+ struct relayd_pending_route *rt;
DPRINTF(1, "%s: deleting host "IP_FMT" ("MAC_FMT")\n", host->rif->ifname,
IP_BUF(host->ipaddr), MAC_BUF(host->lladdr));
@@ -135,6 +141,10 @@ static void del_host(struct relayd_host *host)
}
if (host->rif->managed)
relayd_del_route(host, NULL);
+ list_for_each_entry(rt, &pending_routes, rt.list) {
+ if (!rt->timeout.cb && !memcmp(rt->gateway, host->ipaddr, 4))
+ uloop_timeout_set(&static_routes_timeout, 1000);
+ }
uloop_timeout_cancel(&host->timeout);
list_del(&host->list);
free(host);
@@ -158,10 +168,25 @@ static void fill_arp_packet(struct arp_packet *pkt, struct relayd_interface *rif
pkt->arp.arp_pln = 4;
}
+static void update_interface_ipaddr(struct relayd_interface *rif)
+{
+ struct sockaddr_in *sin;
+ struct ifreq ifr = {};
+
+ strcpy(ifr.ifr_name, rif->ifname);
+ if (ioctl(inet_sock, SIOCGIFADDR, &ifr) < 0) {
+ memcpy(rif->src_ip, DUMMY_IP, sizeof(rif->src_ip));
+ } else {
+ sin = (struct sockaddr_in *) &ifr.ifr_addr;
+ memcpy(rif->src_ip, &sin->sin_addr.s_addr, sizeof(rif->src_ip));
+ }
+}
+
static void send_arp_request(struct relayd_interface *rif, const uint8_t *ipaddr)
{
struct arp_packet pkt;
+ update_interface_ipaddr(rif);
fill_arp_packet(&pkt, rif, rif->src_ip, ipaddr);
pkt.arp.arp_op = htons(ARPOP_REQUEST);
@@ -326,6 +351,7 @@ static void send_gratuitous_arp(struct relayd_interface *rif, const uint8_t *spa
struct relayd_host *relayd_refresh_host(struct relayd_interface *rif, const uint8_t *lladdr, const uint8_t *ipaddr)
{
struct relayd_host *host;
+ struct relayd_route *route;
host = find_host_by_ipaddr(rif, ipaddr);
if (!host) {
@@ -347,6 +373,13 @@ struct relayd_host *relayd_refresh_host(struct relayd_interface *rif, const uint
} else {
host->cleanup_pending = false;
uloop_timeout_set(&host->timeout, host_timeout * 1000);
+ /* An address change may have flushed the gateway's kernel routes. */
+ if (!list_empty(&host->routes)) {
+ if (rif->managed)
+ relayd_add_route(host, NULL);
+ list_for_each_entry(route, &host->routes, list)
+ relayd_add_route(host, route);
+ }
send_gratuitous_arp(rif, ipaddr);
}
@@ -520,7 +553,6 @@ static void recv_bcast_packet(struct uloop_fd *fd, unsigned int events)
static int init_interface(struct relayd_interface *rif)
{
struct sockaddr_ll *sll = &rif->sll;
- struct sockaddr_in *sin;
struct ifreq ifr;
int fd = rif->fd.fd;
#ifdef PACKET_RECV_TYPE
@@ -555,12 +587,7 @@ static int init_interface(struct relayd_interface *rif)
sll->sll_ifindex = ifr.ifr_ifindex;
- if (ioctl(fd, SIOCGIFADDR, &ifr) < 0) {
- memcpy(rif->src_ip, DUMMY_IP, sizeof(rif->src_ip));
- } else {
- sin = (struct sockaddr_in *) &ifr.ifr_addr;
- memcpy(rif->src_ip, &sin->sin_addr.s_addr, sizeof(rif->src_ip));
- }
+ update_interface_ipaddr(rif);
if (bind(fd, (struct sockaddr *)sll, sizeof(struct sockaddr_ll)) < 0) {
perror("bind(ETH_P_ARP)");
@@ -599,14 +626,22 @@ static int init_interface(struct relayd_interface *rif)
return 0;
}
-static void ping_static_routes(void)
+static void ping_static_routes(struct uloop_timeout *timeout)
{
struct relayd_pending_route *rt;
struct relayd_interface *rif;
+ bool pending = false;
- list_for_each_entry(rt, &pending_routes, rt.list)
+ list_for_each_entry(rt, &pending_routes, rt.list) {
+ if (rt->timeout.cb || find_host_by_ipaddr(NULL, rt->gateway))
+ continue;
+
+ pending = true;
list_for_each_entry(rif, &interfaces, list)
send_arp_request(rif, rt->gateway);
+ }
+ if (pending)
+ uloop_timeout_set(timeout, 1000);
}
static int init_interfaces(void)
@@ -841,12 +876,13 @@ int main(int argc, char **argv)
if (init_interfaces() < 0)
return 1;
- ping_static_routes();
+ ping_static_routes(&static_routes_timeout);
uloop_run();
uloop_done();
cleanup_hosts();
+ uloop_timeout_cancel(&static_routes_timeout);
free_interfaces();
relayd_rtnl_done();
close(inet_sock);
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..f1d8fc6
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,31 @@
+# Static gateway regression tests
+
+Build relayd normally with CMake and libubox, then run:
+
+```sh
+python3 tests/test_static_routes.py --binary /absolute/path/to/relayd
+```
+
+The test needs Linux, Python 3, `ip` (iproute2), `unshare` (util-linux), and
+permission to create user/network namespaces. It creates its own namespace
+before changing links or sending packets. It requires neither root on the
+host nor third-party Python modules. No network access is needed during tests.
+
+A synthetic ARP responder on a veth peer answers gateway requests only when
+they use the currently assigned uplink IPv4 address. Tests cover a gateway
+returning after link loss and host expiry, an initially unavailable gateway,
+late/changed IPv4 addresses, address reassignment before gateway expiry,
+`-R` routes, healthy gateway probe traffic,
+ordinary host expiry, and operation without static routes.
+
+The default run uses `-t 2 -p 2` to shorten host expiry. To exercise link loss
+with relayd's unchanged default timers:
+
+```sh
+python3 tests/test_static_routes.py --binary /absolute/path/to/relayd \
+ --production-timers StaticRoutes.test_gateway_returns_after_expiry
+```
+
+The same tests can be run against an unpatched binary to demonstrate the
+regression. Recovery tests deliberately fail on the original implementation;
+they do not inject unsolicited ARP replies or restart relayd to restore routes.
diff --git a/tests/test_static_routes.py b/tests/test_static_routes.py
new file mode 100644
index 0000000..48175e7
--- /dev/null
+++ b/tests/test_static_routes.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+"""Exercise static gateway discovery using an isolated ARP responder."""
+
+import argparse
+import errno
+import json
+import os
+from pathlib import Path
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import unittest
+
+
+GATEWAY = "192.0.2.1"
+ADDRESS = "192.0.2.2"
+GATEWAY_MAC = bytes.fromhex("020000000001")
+BINARY = None
+PRODUCTION_TIMERS = False
+
+
+class StaticRoutes(unittest.TestCase):
+ def ip(self, *args):
+ return subprocess.check_output(["ip", *args], text=True)
+
+ def wait_for(self, predicate, timeout=4):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if self.process is not None:
+ self.assertIsNone(self.process.poll(), "relayd exited unexpectedly")
+ self.assertFalse(self.errors, self.errors)
+ if predicate():
+ return True
+ time.sleep(0.05)
+ return False
+
+ def setUp(self):
+ self.process = None
+ self.managed_local_address = True
+ self.stop = threading.Event()
+ self.enabled = True
+ self.expected_source = ADDRESS
+ self.probes = []
+ self.errors = []
+ self.log = tempfile.TemporaryFile(mode="w+")
+ self.addCleanup(self.log.close)
+ self.ip("link", "add", "rd-up", "type", "veth", "peer", "name", "rd-gw")
+ self.addCleanup(self.ip, "link", "del", "rd-up")
+ self.ip("link", "add", "rd-lan", "type", "dummy")
+ self.addCleanup(self.ip, "link", "del", "rd-lan")
+ for name in ["lo", "rd-up", "rd-gw", "rd-lan"]:
+ self.ip("link", "set", name, "up")
+ self.ip("address", "add", ADDRESS + "/24", "dev", "rd-up")
+ self.ip("address", "add", "198.51.100.1/24", "dev", "rd-lan")
+ address = json.loads(self.ip("-j", "link", "show", "rd-up"))[0]["address"]
+ self.uplink_mac = bytes.fromhex(address.replace(":", ""))
+ self.sock = socket.socket(
+ socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0806)
+ )
+ self.addCleanup(self.sock.close)
+ self.sock.bind(("rd-gw", 0))
+ self.sock.settimeout(0.1)
+ self.thread = threading.Thread(target=self.respond, daemon=True)
+ self.thread.start()
+ self.addCleanup(self.stop_responder)
+ self.addCleanup(self.stop_relay)
+
+ def stop_responder(self):
+ self.stop.set()
+ self.thread.join(timeout=2)
+
+ def stop_relay(self):
+ if self.process is not None:
+ self.process.terminate()
+ try:
+ self.process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ self.process.kill()
+ self.process.wait(timeout=5)
+ self.process = None
+
+ def reply(self, source=GATEWAY, source_mac=GATEWAY_MAC, target=None):
+ target = target or self.expected_source
+ packet = self.uplink_mac + source_mac + b"\x08\x06"
+ packet += struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
+ packet += source_mac + socket.inet_aton(source)
+ packet += self.uplink_mac + socket.inet_aton(target)
+ self.sock.send(packet)
+
+ def respond(self):
+ while not self.stop.is_set():
+ try:
+ packet = self.sock.recv(2048)
+ if len(packet) < 42 or packet[12:14] != b"\x08\x06":
+ continue
+ if packet[20:22] != b"\x00\x01":
+ continue
+ if packet[38:42] != socket.inet_aton(GATEWAY):
+ continue
+ source = socket.inet_ntoa(packet[28:32])
+ self.probes.append(source)
+ if self.enabled and source == self.expected_source:
+ self.reply(target=source)
+ except socket.timeout:
+ continue
+ except OSError as exc:
+ if exc.errno in (errno.ENETDOWN, errno.ENETUNREACH):
+ self.stop.wait(0.01)
+ continue
+ self.errors.append(str(exc))
+ return
+
+ def start_relay(self, routes=None, local_address=ADDRESS):
+ args = [str(BINARY), "-I", "rd-up", "-I", "rd-lan", "-B", "-D"]
+ self.managed_local_address = local_address is not None
+ if local_address is not None:
+ args += ["-L", local_address]
+ if not PRODUCTION_TIMERS:
+ args += ["-t", "2", "-p", "2"]
+ args += routes if routes is not None else ["-G", GATEWAY]
+ args += ["-d", "-d"]
+ self.process = subprocess.Popen(args, stdout=self.log, stderr=subprocess.STDOUT)
+
+ def routes(self, destination="default"):
+ return [
+ route
+ for route in json.loads(
+ self.ip("-j", "-4", "route", "show", "table", "all")
+ )
+ if route.get("dst") == destination
+ and route.get("gateway") == GATEWAY
+ and route.get("dev") == "rd-up"
+ ]
+
+ def assert_routes(self, destination="default"):
+ count = 2 if self.managed_local_address else 1
+ self.assertTrue(
+ self.wait_for(lambda: len(self.routes(destination)) == count),
+ "configured route was not discovered in all relay tables",
+ )
+
+ def test_gateway_returns_after_expiry(self):
+ self.start_relay()
+ self.assert_routes()
+ self.enabled = False
+ self.ip("link", "set", "rd-gw", "down")
+ self.assertTrue(
+ self.wait_for(
+ lambda: not self.routes(), timeout=40 if PRODUCTION_TIMERS else 7
+ ),
+ "unreachable gateway did not expire",
+ )
+ self.enabled = True
+ self.ip("link", "set", "rd-gw", "up")
+ self.assert_routes()
+
+ def test_gateway_missing_at_startup(self):
+ self.enabled = False
+ self.start_relay()
+ self.assertTrue(self.wait_for(lambda: bool(self.probes)))
+ self.assertEqual(self.routes(), [])
+ self.enabled = True
+ self.assert_routes()
+
+ def test_ipv4_address_assigned_after_startup(self):
+ self.ip("address", "del", ADDRESS + "/24", "dev", "rd-up")
+ self.start_relay()
+ self.assertTrue(self.wait_for(lambda: "1.1.1.1" in self.probes))
+ self.assertEqual(self.routes(), [])
+ self.ip("address", "add", ADDRESS + "/24", "dev", "rd-up")
+ self.assert_routes()
+ self.assertIn(ADDRESS, self.probes)
+
+ def test_ipv4_address_changes(self):
+ # -L pins a separate local route source; it must not reference an
+ # address that this test deliberately removes from the interface.
+ self.start_relay(local_address=None)
+ self.assert_routes()
+ self.expected_source = "192.0.2.3"
+ self.ip("address", "del", ADDRESS + "/24", "dev", "rd-up")
+ self.ip("address", "add", self.expected_source + "/24", "dev", "rd-up")
+ self.assertTrue(
+ self.wait_for(
+ lambda: self.expected_source in self.probes,
+ timeout=37 if PRODUCTION_TIMERS else 4,
+ ),
+ "ARP probes retained the old interface address",
+ )
+ self.assert_routes()
+
+ def test_explicit_static_route(self):
+ destination = "203.0.113.0/24"
+ self.enabled = False
+ self.start_relay(["-R", GATEWAY + ":" + destination])
+ self.assertTrue(self.wait_for(lambda: bool(self.probes)))
+ self.assertEqual(self.routes(destination), [])
+ self.enabled = True
+ self.assert_routes(destination)
+ self.assertEqual(self.routes(), [])
+
+ def test_address_reassigned_before_gateway_expires(self):
+ self.start_relay()
+ self.assert_routes()
+ self.ip("address", "del", ADDRESS + "/24", "dev", "rd-up")
+ self.ip("address", "add", ADDRESS + "/24", "dev", "rd-up")
+ self.assert_routes()
+
+ def test_healthy_gateway_has_no_extra_probes(self):
+ self.start_relay()
+ self.assert_routes()
+ time.sleep(0.2)
+ count = len(self.probes)
+ time.sleep(1.3)
+ self.assertEqual(
+ len(self.probes),
+ count,
+ "static discovery reprobed an already known gateway",
+ )
+ self.assertEqual(len(self.routes()), 2)
+
+ def test_ordinary_host_still_expires(self):
+ self.start_relay()
+ self.assert_routes()
+ host = "192.0.2.99"
+ self.reply(source=host, source_mac=bytes.fromhex("020000000063"))
+
+ def host_routes():
+ return [
+ r
+ for r in json.loads(
+ self.ip("-j", "-4", "route", "show", "table", "all")
+ )
+ if r.get("dst") == host
+ and r.get("dev") == "rd-up"
+ and r.get("table") in ("16800", "16801", 16800, 16801)
+ ]
+
+ self.assertTrue(self.wait_for(lambda: len(host_routes()) == 2))
+ self.assertTrue(
+ self.wait_for(
+ lambda: not host_routes(), timeout=40 if PRODUCTION_TIMERS else 7
+ )
+ )
+ self.assertEqual(len(self.routes()), 2)
+
+ def test_no_static_routes_has_no_discovery_timer(self):
+ self.start_relay([])
+ time.sleep(1.5)
+ self.assertFalse(self.probes)
+ self.assertEqual(self.routes(), [])
+
+
+def main():
+ global BINARY, PRODUCTION_TIMERS
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--binary", type=Path, required=True)
+ parser.add_argument("--production-timers", action="store_true")
+ parser.add_argument("--parent-netns", type=int, help=argparse.SUPPRESS)
+ args, tests = parser.parse_known_args()
+ BINARY = args.binary.resolve(strict=True)
+ PRODUCTION_TIMERS = args.production_timers
+ namespace = os.stat("/proc/self/ns/net").st_ino
+ if args.parent_netns is None:
+ # All link changes and synthetic traffic must stay off the host network.
+ command = [
+ "unshare",
+ "--user",
+ "--map-root-user",
+ "--net",
+ sys.executable,
+ str(Path(__file__).resolve()),
+ "--binary",
+ str(BINARY),
+ "--parent-netns",
+ str(namespace),
+ ]
+ if PRODUCTION_TIMERS:
+ command.append("--production-timers")
+ return subprocess.call(command + tests)
+ if namespace == args.parent_netns:
+ parser.error("refusing to run outside a new network namespace")
+ unittest.main(argv=[sys.argv[0], *tests], verbosity=2)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
--
---
Ich lese die E-Mails die auf diesen Postfach ankommen nicht
Grüße Lukas Bockel
More information about the openwrt-devel
mailing list