Up-front disclosure, the words are from Claude Code, the analysis was its, with me reading the relevant RFCs and redirecting it where necessary; I believe the analysis is correct at this point.
SUMMARY
A Router Advertisement carries two different fields that are both called a lifetime, and dhcpcd applies one of them to routes it has no authority over.
RFC 4191 section 3.1 is explicit about which governs what: a host "first updates a ::/0 route based on the Router Lifetime and Default Router Preference in the Router Advertisement message header", and "then as the host processes Route Information Options in the Router Advertisement message body, it updates its routing table for each such option". That section's sentence "if the received route's lifetime is zero, the route is removed from the Routing Table if present" refers to THAT OPTION'S Route Lifetime, not the header's Router Lifetime.
So a zero Router Lifetime removes the sender's ::/0 route and has no authority over anything else. RFC 4861 section 4.2 defines a zero Router Lifetime as meaning the sender is not a default router, and RFC 4191 section 2.2 specifies the preference handling for 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, so it is marked expired on every pass 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 them, so the fault presents as perpetual churn rather than as lost connectivity.
This is not a corner case: it is what every Thread border router does. Amazon Echo, Apple HomePod/TV, Google Nest Hub and SmartThings hubs all advertise a route to their Thread mesh's off-mesh-routable prefix with Router Lifetime 0.
VERSIONS
dhcpcd 10.5.2.
Linux 6.1.187, ARM32 (Cortex-A35). dhcpcd manages IPv6, accept_ra=0.
THE ADVERTISEMENT
fe80::963a:91ff:fe54:86c9 > ff02::1: ICMP6, router advertisement, length 32
hop limit 0, Flags [managed, other stateful, ipv6 only], pref medium,
router lifetime 0s, reachable time 0ms, retrans timer 0ms
route info option (24), length 16 (2):
fdf2:e316:d992:1::/64, pref=medium, lifetime=1800s
No Prefix Information, no RDNSS, no DNSSL, no source link-layer address. Re-advertised every 130-190s, so a 1800s route lifetime is refreshed about ten times over before it could expire.
ANALYSIS (src/ipv6nd.c, ipv6nd_expirera(), line numbers from 10.5.2)
1795 if (rap->lifetime) { <- 0, so this branch is skipped entirely
1815 TAILQ_FOREACH(ia, &rap->addrs, ...) <- no PIO, nothing to validate
1846 TAILQ_FOREACH_SAFE(rinfo, &rap->rinfos, ...)
expires route information but never sets valid
1876 switch (ndo.nd_opt_type)
1884 case ND_OPT_DNSSL: ...
1890 case ND_OPT_RDNSS: ...
1896 default: continue; <- ND_OPT_RI (24) lands here
1923 if (valid) continue;
1926 rap->expired = true; <- then rt_build() drops its routes
valid cannot become true for this advertisement. Note that the Router Lifetime test at 1795 is the only thing that could have kept this router, and per section 3.1 that lifetime governs only the ::/0 route - it should not be deciding the fate of a /64 carried in a Route Information option. Line 1220 clears rap->expired on the next RA, which reinstates the routes.
Note that a Route Information option with a prefix length of ZERO is already immune, because line 1453 assigns rap->lifetime directly. Only a
specific-prefix route is affected, which is probably why this has gone unnoticed.
WHAT IT LOOKS LIKE IN THE LOG
Ninety minutes from the middle of an ordinary run. Nothing else is interleaved -
this is the whole of what dhcpcd had to say during that period:
05:44:54.16063 eth0: adding route to fdf2:e316:d992:1::/64 via fe80::963a:...
05:47:39.20483 eth0: deleting route to fdf2:e316:d992:1::/64 via fe80::963a:...
05:50:00.70554 eth0: adding route to fdf2:e316:d992:1::/64 via fe80::963a:...
05:51:26.63273 eth0: deleting route to fdf2:e316:d992:1::/64 via fe80::963a:...
05:51:58.66131 eth0: adding route to fdf2:e316:d992:1::/64 via fe80::963a:...
05:57:05.11965 eth0: deleting route to fdf2:e316:d992:1::/64 via fe80::963a:...
05:59:44.05279 eth0: adding route to fdf2:e316:d992:1::/64 via fe80::963a:...
06:03:52.37611 eth0: deleting route to fdf2:e316:d992:1::/64 via fe80::963a:...
06:03:53.11914 eth0: adding route to fdf2:e316:d992:1::/64 via fe80::963a:...
06:08:10.56834 eth0: deleting route to fdf2:e316:d992:1::/64 via fe80::963a:...
06:09:09.39525 eth0: adding route to fdf2:e316:d992:1::/64 via fe80::963a:...
06:10:09.21381 eth0: deleting route to fdf2:e316:d992:1::/64 via fe80::963a:...
It continues like that for the life of the boot. The 06:03:52 / 06:03:53 pair is 0.75 SECONDS apart - the route removed and reinstated inside a second, which is what an expiry pass firing just before an advertisement arrives looks like. That pair is also the clearest refutation of lifetime expiry as the cause: a route cannot meaningfully have expired 0.75s before being re-advertised with 1800s remaining.
EVIDENCE
Packet capture of every RA on the link, correlated with dhcpcd's log:
06:29:45 Echo RA, RIO lifetime 1800s -> route ADDED, same millisecond
06:31:57 Echo RA, RIO lifetime 1800s -> no log event (refresh)
06:32:39 no advertisement at all -> route DELETED (174s into 1800s)
06:34:02 gateway RA (other router) -> no event for this route
06:35:02 Echo RA, RIO lifetime 1800s -> route ADDED, same millisecond
Adds land in the same millisecond as an advertisement. Deletes correlate with nothing. The expiry timer is scheduled from any router's option lifetimes, so a delete can coincide with processing an unrelated router's RA - one landed 5.7ms after the gateway's RA (06:39:25.904 capture / 06:39:25.909 log).
Two explanations ruled out by measurement:
- Not lifetime expiry. RIO lifetime 1800s, re-advertised every 130-190s, observed add->delete median 210s.
- Not neighbour unreachability. Neighbour state sampled every 12s across a full delete/re-add cycle read STALE throughout, never changing.
MEASUREMENTS
Route add/delete events 545 in 23h (275 add / 270 delete)
Share of all dhcpcd log output 91% (545 of 600 lines)
Route present (add -> delete) median 210s, max 548s
Route absent (delete -> add) median 80s, max 189s
Advertised RIO lifetime 1800s
Observed RA interval 130-190s
Control: gateway's RA routes 3 events, all at boot, no churn
The control matters: on the same interface at the same time, routes from a router sending a non-zero Router Lifetime are installed once and left alone.
REPRODUCTION
- Put any Thread border router on the link (Amazon Echo, Apple TV, HomePod, Nest Hub). Any device sending an RA with Router Lifetime 0, at least one Route Information option with a non-zero prefix length, and no Prefix Information will reproduce it.
- Run dhcpcd with IPv6 enabled so it manages RAs itself.
- Watch the log: add/delete pairs for that prefix appear indefinitely at roughly the advertisement interval.
A host where the kernel does SLAAC shows nothing, because Linux ignores Route Information options unless CONFIG_IPV6_ROUTE_INFO is set.
AI-Assisted: Claude Fable 5 (Claude Code)
Up-front disclosure, the words are from Claude Code, the analysis was its, with me reading the relevant RFCs and redirecting it where necessary; I believe the analysis is correct at this point.
SUMMARY
A Router Advertisement carries two different fields that are both called a lifetime, and dhcpcd applies one of them to routes it has no authority over.
RFC 4191 section 3.1 is explicit about which governs what: a host "first updates a ::/0 route based on the Router Lifetime and Default Router Preference in the Router Advertisement message header", and "then as the host processes Route Information Options in the Router Advertisement message body, it updates its routing table for each such option". That section's sentence "if the received route's lifetime is zero, the route is removed from the Routing Table if present" refers to THAT OPTION'S Route Lifetime, not the header's Router Lifetime.
So a zero Router Lifetime removes the sender's ::/0 route and has no authority over anything else. RFC 4861 section 4.2 defines a zero Router Lifetime as meaning the sender is not a default router, and RFC 4191 section 2.2 specifies the preference handling for 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, so it is marked expired on every pass andrt_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 them, so the fault presents as perpetual churn rather than as lost connectivity.This is not a corner case: it is what every Thread border router does. Amazon Echo, Apple HomePod/TV, Google Nest Hub and SmartThings hubs all advertise a route to their Thread mesh's off-mesh-routable prefix with Router Lifetime 0.
VERSIONS
dhcpcd 10.5.2.
Linux 6.1.187, ARM32 (Cortex-A35). dhcpcd manages IPv6, accept_ra=0.
THE ADVERTISEMENT
No Prefix Information, no RDNSS, no DNSSL, no source link-layer address. Re-advertised every 130-190s, so a 1800s route lifetime is refreshed about ten times over before it could expire.
ANALYSIS (src/ipv6nd.c, ipv6nd_expirera(), line numbers from 10.5.2)
validcannot become true for this advertisement. Note that the Router Lifetime test at 1795 is the only thing that could have kept this router, and per section 3.1 that lifetime governs only the ::/0 route - it should not be deciding the fate of a /64 carried in a Route Information option. Line 1220 clears rap->expired on the next RA, which reinstates the routes.Note that a Route Information option with a prefix length of ZERO is already immune, because line 1453 assigns rap->lifetime directly. Only a
specific-prefix route is affected, which is probably why this has gone unnoticed.
WHAT IT LOOKS LIKE IN THE LOG
Ninety minutes from the middle of an ordinary run. Nothing else is interleaved -
this is the whole of what dhcpcd had to say during that period:
It continues like that for the life of the boot. The 06:03:52 / 06:03:53 pair is 0.75 SECONDS apart - the route removed and reinstated inside a second, which is what an expiry pass firing just before an advertisement arrives looks like. That pair is also the clearest refutation of lifetime expiry as the cause: a route cannot meaningfully have expired 0.75s before being re-advertised with 1800s remaining.
EVIDENCE
Packet capture of every RA on the link, correlated with dhcpcd's log:
Adds land in the same millisecond as an advertisement. Deletes correlate with nothing. The expiry timer is scheduled from any router's option lifetimes, so a delete can coincide with processing an unrelated router's RA - one landed 5.7ms after the gateway's RA (06:39:25.904 capture / 06:39:25.909 log).
Two explanations ruled out by measurement:
MEASUREMENTS
Route add/delete events 545 in 23h (275 add / 270 delete)
Share of all dhcpcd log output 91% (545 of 600 lines)
Route present (add -> delete) median 210s, max 548s
Route absent (delete -> add) median 80s, max 189s
Advertised RIO lifetime 1800s
Observed RA interval 130-190s
Control: gateway's RA routes 3 events, all at boot, no churn
The control matters: on the same interface at the same time, routes from a router sending a non-zero Router Lifetime are installed once and left alone.
REPRODUCTION
A host where the kernel does SLAAC shows nothing, because Linux ignores Route Information options unless
CONFIG_IPV6_ROUTE_INFOis set.AI-Assisted: Claude Fable 5 (Claude Code)