From 9ffa9c8e61594b4d2de893a3673ba9161cc69e3e Mon Sep 17 00:00:00 2001 From: Alex Kiernan Date: Sun, 13 Sep 2026 09:49:50 +0100 Subject: [PATCH 1/2] ipv6nd: rebuild routes when a Route Information route expires ipv6nd_expirera() frees an expired Route Information entry but does not set the expired flag, so rt_build() is not called and the route it installed is not removed. On Linux the kernel drops the route itself because it was added with a lifetime, but dhcpcd's own route tree is left stale, and on platforms without route lifetimes the route stays in the kernel until some unrelated rebuild. The ROUTERADVERT script is not run either. Set the expired flag so that the route is removed as soon as its Route Lifetime runs out. AI-Assisted: Claude Fable 5 (Claude Code) Signed-off-by: Alex Kiernan --- src/ipv6nd.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ipv6nd.c b/src/ipv6nd.c index 0004b1b5..7b8a7ec2 100644 --- a/src/ipv6nd.c +++ b/src/ipv6nd.c @@ -1851,6 +1851,7 @@ ipv6nd_expirera(void *arg) rap->iface->name, rinfo->sprefix); TAILQ_REMOVE(&rap->rinfos, rinfo, next); free(rinfo); + expired = true; } } From 0d7fcc16b78e61ab28649bdb257b073feff99ace Mon Sep 17 00:00:00 2001 From: Alex Kiernan Date: Tue, 8 Sep 2026 11:03:51 +0100 Subject: [PATCH 2/2] ipv6nd: a zero Router Lifetime must not expire Route Information routes RFC 4191 section 3.1 separates two lifetimes that a Router Advertisement carries. The Router Lifetime in the message header updates the ::/0 route and nothing else - "a type C host first updates a ::/0 route based on the Router Lifetime and Default Router Preference in the Router Advertisement message header". Each Route Information option is then processed separately and carries its own Route Lifetime, which is what the section's "if the received route's lifetime is zero, the route is removed" refers to. RFC 4861 section 4.2 defines a Router Lifetime of zero as meaning the sender is not a default router, and RFC 4191 section 2.2 specifies the preference handling for exactly that case, so such advertisements are expected rather than malformed. ipv6nd_expirera() conflates the two. A router advertising only Route Information with a zero Router Lifetime passes no test in the expiry loop: the Router Lifetime branch is skipped because the lifetime is zero, there are no addresses or prefixes to validate, and the ND option loop handles only DNSSL and RDNSS, so a Route Information option reaches the default case. `valid` stays false, the router is marked expired, and rt_build() removes its routes - although each of those routes has its own unexpired Route Lifetime, which is the only lifetime section 3.1 gives authority over them. The next Router Advertisement clears the expired flag and reinstates the routes, so the fault presents as perpetual churn rather than as lost connectivity. A Thread border router is the common case: it advertises the Thread mesh's off-mesh-routable prefix in a Route Information option, with a zero Router Lifetime because it is not a default router, and no Prefix Information. Measured against an Amazon Echo advertising one /64 with an 1800s Route Lifetime and re-advertising every 130-190s: 545 route add/delete events in 23 hours, 91% of all dhcpcd log output on that host. Keep the router while any of its route information is unexpired, and let that route's remaining lifetime schedule the next expiry run. Note that a Route Information option with a prefix length of zero is already unaffected, because it assigns rap->lifetime directly - consistent with section 3.1, where a ::/0 Route Information option overrides the header's lifetime. Only a specific-prefix route is affected. AI-Assisted: Claude Fable 5 (Claude Code) Signed-off-by: Alex Kiernan --- src/ipv6nd.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ipv6nd.c b/src/ipv6nd.c index 7b8a7ec2..8c253bf9 100644 --- a/src/ipv6nd.c +++ b/src/ipv6nd.c @@ -1852,7 +1852,14 @@ ipv6nd_expirera(void *arg) TAILQ_REMOVE(&rap->rinfos, rinfo, next); free(rinfo); expired = true; + continue; } + + /* A non expired route information option keeps the RA valid. */ + valid = true; + if (ltime != ND6_INFINITE_LIFETIME && + (next == 0 || ltime < next)) + next = ltime; } /* Work out expiry for ND options */