From d0f4c8fcf96724e249f68922fc004272e0a96e7c Mon Sep 17 00:00:00 2001 From: Stefan Koebeli Date: Tue, 4 Aug 2026 23:39:27 +0200 Subject: [PATCH] networkd renewing ipv6 default route patch --- man/systemd.network.xml | 51 ++++++ src/libsystemd-network/sd-ndisc.c | 12 ++ src/network/networkd-link.c | 1 + src/network/networkd-link.h | 4 + src/network/networkd-ndisc.c | 222 ++++++++++++++++++++++- src/network/networkd-network-gperf.gperf | 3 + src/network/networkd-network.c | 3 + src/network/networkd-network.h | 3 + src/systemd/sd-ndisc.h | 6 + 9 files changed, 301 insertions(+), 4 deletions(-) diff --git a/man/systemd.network.xml b/man/systemd.network.xml index 9d8dcba..a794ed1 100644 --- a/man/systemd.network.xml +++ b/man/systemd.network.xml @@ -3600,6 +3600,57 @@ Token=prefixstable:2002:da8:1:: + + RouterSolicitationRenew= + + Takes a boolean. When true (the default), and a Router Advertisement carried a finite + Router Lifetime (i.e. what backs the default route derived from it), a Router Solicitation is + sent proactively before that lifetime elapses, in order to renew it. This is useful because many + routers only send Router Advertisements in reply to a Router Solicitation and never send + unsolicited ones, in which case the default route would otherwise simply expire and not be + renewed once the initial discovery phase completed. If the first renewal attempt does not + receive a reply in time, it is retried a few more times in quick succession before falling back + to waiting for the hard expiry. When RouterSolicitationRenew=no, the reception + of unsolicited Router Advertisements is the only way an expiring default route can be renewed. + See RouterSolicitationRenewPercent= and + RouterSolicitationRenewFallbackSec= below for tuning when the renewal is + sent. + + + + + + + RouterSolicitationRenewPercent= + + Takes a percentage value between 1% and 99% (the trailing % character + is mandatory, e.g. RouterSolicitationRenewPercent=75%). Specifies how much of + the currently known Router Lifetime should elapse before a renewal Router Solicitation is sent, when + RouterSolicitationRenew= is enabled. For example, the default of 80% means the + solicitation is sent once 80% of the Router Lifetime has passed, i.e. when 20% of it remains. + Other NDisc-derived lifetimes (addresses, RDNSS, DNSSL, ...) are not taken into account for this + calculation. Defaults to 80%. + + + + + + + RouterSolicitationRenewFallbackSec= + + Takes a timespan. When RouterSolicitationRenew= is enabled but no + router with a known Router Lifetime is currently tracked for the link (for example because no + Router Advertisement has been received yet, or the previously known router's lifetime has fully + expired), a renewal Router Solicitation is instead sent periodically at this fixed interval, + indefinitely, until a router responds. The very first such solicitation after entering this + fallback state is sent quickly (after a few seconds) rather than waiting a full interval, since + this most commonly happens right after a brief, transient outage; every following one uses the + full configured interval. Defaults to 30 minutes. + + + + + UsePREF64= diff --git a/src/libsystemd-network/sd-ndisc.c b/src/libsystemd-network/sd-ndisc.c index ca15f94..fb885a2 100644 --- a/src/libsystemd-network/sd-ndisc.c +++ b/src/libsystemd-network/sd-ndisc.c @@ -404,6 +404,18 @@ static int ndisc_send_router_solicitation(sd_ndisc *nd) { return ndisc_send(nd->fd, &IN6_ADDR_ALL_ROUTERS_MULTICAST, &header.nd_rs_hdr, options, USEC_INFINITY); } +int sd_ndisc_send_router_solicitation(sd_ndisc *nd) { + assert_return(nd, -EINVAL); + assert_return(sd_ndisc_is_running(nd), -ESTALE); + + /* This deliberately does not touch nd->timeout_event_source, nd->timeout_no_ra, or + * nd->retransmit_time: it is meant for callers (e.g. networkd) that want to proactively + * solicit a Router Advertisement outside of the normal initial-discovery retransmission + * sequence, for example to refresh a Router Lifetime that is about to expire. Any reply + * is processed exactly like an unsolicited RA via the normal ndisc_recv() -> callback path. */ + return ndisc_send_router_solicitation(nd); +} + static usec_t ndisc_timeout_compute_random(usec_t val) { /* compute a time that is random within ±10% of the given value */ return val - val / 10 + diff --git a/src/network/networkd-link.c b/src/network/networkd-link.c index 3c042e6..80fdc69 100644 --- a/src/network/networkd-link.c +++ b/src/network/networkd-link.c @@ -244,6 +244,7 @@ static void link_free_engines(Link *link) { link->ndisc = sd_ndisc_unref(link->ndisc); link->ndisc_expire = sd_event_source_disable_unref(link->ndisc_expire); + link->ndisc_rs_renew = sd_event_source_disable_unref(link->ndisc_rs_renew); ndisc_flush(link); link->radv = sd_radv_unref(link->radv); diff --git a/src/network/networkd-link.h b/src/network/networkd-link.h index 113217c..88c7a53 100644 --- a/src/network/networkd-link.h +++ b/src/network/networkd-link.h @@ -170,6 +170,10 @@ typedef struct Link { sd_ndisc *ndisc; sd_event_source *ndisc_expire; + sd_event_source *ndisc_rs_renew; + unsigned ndisc_rs_renew_attempt; + bool ndisc_rs_renew_in_fallback; + usec_t ndisc_router_lifetime_usec; Hashmap *ndisc_routers_by_sender; Set *ndisc_rdnss; Set *ndisc_dnssl; diff --git a/src/network/networkd-ndisc.c b/src/network/networkd-ndisc.c index 33e86fb..6f6be5e 100644 --- a/src/network/networkd-ndisc.c +++ b/src/network/networkd-ndisc.c @@ -2499,6 +2499,124 @@ static int ndisc_drop_outdated(Link *link, const struct in6_addr *router, usec_t static int ndisc_setup_expire(Link *link); +/* Fires at ~RouterSolicitationRenewPercent= (80% by default) of the currently known Router + * Lifetime (i.e. what backs the default route), or periodically every + * RouterSolicitationRenewFallbackSec= if no router with a known lifetime is tracked at all. + * Many ISP/CPE routers only ever send Router Advertisements in reply to a Router Solicitation + * and never send unsolicited periodic RAs, even though RFC 4861 recommends it. Without this, + * sd-ndisc's own RS retransmission is limited to the initial link-confirmation burst (see + * NDISC_MAX_ROUTER_SOLICITATIONS in sd-ndisc.c) and permanently stops after the first RA is + * received, so the default route would simply expire and never be renewed. + * + * Deliberately anchored ONLY to the Router Lifetime, not the broader NDisc expiry sweep (which + * also covers RDNSS/DNSSL/addresses/...): those are frequently configured with different, + * shorter values than the Router Lifetime on real-world routers, and mixing them in would make + * the renewal point jump around depending on whatever happens to be shortest-lived at + * computation time. + * + * Router Solicitation is sent to a multicast address without any transport-level reliability, + * so a single lost packet (or a router that is briefly unable to answer) must not mean giving + * up on renewal entirely: retry a few times, spaced out like the initial discovery burst, as + * long as there is still time left before the hard expiry. Any successful reply is processed + * through the normal RA handling path, which re-arms this timer against a fresh, later + * deadline and implicitly ends the retry sequence for this cycle. */ +#define NDISC_RS_RENEW_RETRY_INTERVAL_USEC (4 * USEC_PER_SEC) +#define NDISC_RS_RENEW_MAX_RETRIES 3U + +static int ndisc_rs_renew_handler(sd_event_source *s, uint64_t usec, void *userdata) { + Link *link = ASSERT_PTR(userdata); + usec_t now_usec; + int r, send_r; + + if (!link->ndisc) + return 0; + + send_r = sd_ndisc_send_router_solicitation(link->ndisc); + + /* No router with a known lifetime is currently tracked: this is the fixed periodic + * fallback mode (RouterSolicitationRenewFallbackSec=), not the finite-deadline retry + * burst below. Just keep repeating at the configured interval, indefinitely, until + * either a reply arrives (ndisc_setup_expire() will then re-arm this timer against a + * real deadline instead) or the link goes away. */ + if (link->ndisc_router_lifetime_usec == USEC_INFINITY) { + if (send_r < 0) + log_link_warning_errno(link, send_r, + "Failed to send NDisc fallback renewal Router Solicitation " + "(no router with a known lifetime is currently tracked), ignoring: %m"); + else + log_link_debug(link, + "Sent periodic NDisc fallback Router Solicitation " + "(no router with a known lifetime is currently tracked; " + "next one in %s unless a reply arrives sooner).", + FORMAT_TIMESPAN(link->network->ndisc_rs_renew_fallback_usec, 0)); + + r = event_reset_time_relative(link->manager->event, &link->ndisc_rs_renew, CLOCK_BOOTTIME, + link->network->ndisc_rs_renew_fallback_usec, 0, + ndisc_rs_renew_handler, link, 0, "ndisc-rs-renew", true); + if (r < 0) + log_link_warning_errno(link, r, "Failed to re-arm NDisc fallback renewal timer, ignoring: %m"); + + return 0; + } + + link->ndisc_rs_renew_attempt++; + + r = sd_event_now(link->manager->event, CLOCK_BOOTTIME, &now_usec); + if (r < 0) + return 0; + + if (link->ndisc_rs_renew_attempt <= NDISC_RS_RENEW_MAX_RETRIES) { + if (send_r < 0) + log_link_warning_errno(link, send_r, + "Failed to send NDisc renewal Router Solicitation " + "(attempt %u/%u, anchored to a known Router Lifetime), ignoring: %m", + link->ndisc_rs_renew_attempt, NDISC_RS_RENEW_MAX_RETRIES); + else + log_link_debug(link, + "Sent Router Solicitation to renew the default route before its " + "Router Lifetime expires (attempt %u/%u).", + link->ndisc_rs_renew_attempt, NDISC_RS_RENEW_MAX_RETRIES); + } else { + if (send_r < 0) + log_link_warning_errno(link, send_r, + "Failed to send extended NDisc renewal retry " + "(still anchored to a known Router Lifetime), ignoring: %m"); + else + log_link_debug(link, + "Sent extended Router Solicitation retry (initial %u attempts " + "unanswered, still anchored to a known Router Lifetime; next one " + "in %s unless a reply arrives sooner).", + NDISC_RS_RENEW_MAX_RETRIES, + FORMAT_TIMESPAN(link->network->ndisc_rs_renew_fallback_usec, 0)); + } + + /* Router Solicitation is sent to a multicast address with no transport-level reliability, + * so a single lost packet (or a router briefly unable to answer) must not mean giving up + * on renewal entirely. The first NDISC_RS_RENEW_MAX_RETRIES attempts are spaced quickly + * (NDISC_RS_RENEW_RETRY_INTERVAL_USEC) to catch the common transient-loss case fast; if + * those all go unanswered too, retries continue at the same cadence as + * RouterSolicitationRenewFallbackSec= instead of falling silent -- there is no reason to + * stop trying just because a fixed attempt count was reached while a router that + * previously had a known, finite lifetime might still come back at any moment before that + * lifetime's hard deadline. Retries stop only once there is no longer meaningful headroom + * before that deadline; ndisc_expire_handler() then takes over as usual, and the + * transition into the "no router with a known lifetime" fallback mode picks up seamlessly + * from there (itself starting with its own quick first attempt, see above). */ + usec_t interval = link->ndisc_rs_renew_attempt < NDISC_RS_RENEW_MAX_RETRIES + ? NDISC_RS_RENEW_RETRY_INTERVAL_USEC + : link->network->ndisc_rs_renew_fallback_usec; + + if (now_usec + interval < link->ndisc_router_lifetime_usec) { + r = event_reset_time_relative(link->manager->event, &link->ndisc_rs_renew, CLOCK_BOOTTIME, + interval, 0, + ndisc_rs_renew_handler, link, 0, "ndisc-rs-renew", true); + if (r < 0) + log_link_warning_errno(link, r, "Failed to schedule NDisc renewal retry, ignoring: %m"); + } + + return 0; +} + static int ndisc_expire_handler(sd_event_source *s, uint64_t usec, void *userdata) { Link *link = ASSERT_PTR(userdata); usec_t now_usec; @@ -2507,6 +2625,8 @@ static int ndisc_expire_handler(sd_event_source *s, uint64_t usec, void *userdat assert_se(sd_event_now(link->manager->event, CLOCK_BOOTTIME, &now_usec) >= 0); + log_link_debug(link, "ndisc_expire_handler(): sweep timer fired."); + (void) ndisc_drop_outdated(link, /* router = */ NULL, now_usec); (void) ndisc_setup_expire(link); return 0; @@ -2514,6 +2634,7 @@ static int ndisc_expire_handler(sd_event_source *s, uint64_t usec, void *userdat static int ndisc_setup_expire(Link *link) { usec_t lifetime_usec = USEC_INFINITY; + usec_t router_lifetime_usec = USEC_INFINITY; NDiscCaptivePortal *cp; NDiscDNSSL *dnssl; NDiscRDNSS *rdnss; @@ -2525,6 +2646,7 @@ static int ndisc_setup_expire(Link *link) { assert(link); assert(link->manager); + assert(link->network); sd_ndisc_router *rt; HASHMAP_FOREACH(rt, link->ndisc_routers_by_sender) { @@ -2534,6 +2656,7 @@ static int ndisc_setup_expire(Link *link) { continue; lifetime_usec = MIN(lifetime_usec, t); + router_lifetime_usec = MIN(router_lifetime_usec, t); } SET_FOREACH(route, link->manager->routes) { @@ -2574,13 +2697,103 @@ static int ndisc_setup_expire(Link *link) { SET_FOREACH(dnr, link->ndisc_dnr) lifetime_usec = MIN(lifetime_usec, dnr->lifetime_usec); - if (lifetime_usec == USEC_INFINITY) + /* Sweep expiry timer: unchanged, still uses the global minimum lifetime across ALL + * NDisc-derived state (routers, routes, addresses, RDNSS, ...). This is purely a + * scheduling wakeup optimization; ndisc_drop_outdated() re-validates each object's own + * lifetime individually when the timer fires, so waking up "too early" because of some + * unrelated short-lived object is harmless -- nothing gets removed before its own + * lifetime has actually passed. */ + if (lifetime_usec != USEC_INFINITY) { + r = event_reset_time(link->manager->event, &link->ndisc_expire, CLOCK_BOOTTIME, + lifetime_usec, 0, ndisc_expire_handler, link, 0, "ndisc-expiration", true); + if (r < 0) + return log_link_warning_errno(link, r, "Failed to update expiration timer for ndisc: %m"); + } + + if (!link->network->ndisc_rs_renew) { + link->ndisc_rs_renew = sd_event_source_disable_unref(link->ndisc_rs_renew); + return 0; + } + + /* Renewal timer: deliberately anchored ONLY to the currently known Router Lifetime(s) + * (i.e. what actually backs the default route), not the broader sweep minimum above. + * Mixing in unrelated NDisc state (RDNSS/DNSSL/address lifetimes are frequently + * configured with different values than the Router Lifetime on real-world routers) + * would make the renewal point jump around depending on whatever happens to be + * shortest-lived at computation time, which is neither what + * RouterSolicitationRenewPercent= is meant to describe nor useful for the reader trying + * to reason about when the default route actually gets renewed. */ + if (router_lifetime_usec == USEC_INFINITY) { + /* No router with a known lifetime is currently tracked (e.g. no RA received yet, + * or the only known routers advertise a zero/non-default lifetime). Fall back to + * a fixed periodic solicitation instead of going silent. + * + * The very first solicitation after *entering* fallback mode is sent quickly + * (NDISC_RS_RENEW_RETRY_INTERVAL_USEC, same spacing as the retry burst above) + * rather than waiting a full RouterSolicitationRenewFallbackSec=: this is the + * common case where the previous router's finite lifetime has just expired + * without a successful renewal, and a brief, transient outage (a momentary + * failure to answer RS, a router reboot, ...) is more likely than a truly + * extended one. If that quick attempt also gets no reply, every subsequent + * fallback attempt uses the full configured interval as before. */ + usec_t interval = link->ndisc_rs_renew_in_fallback + ? link->network->ndisc_rs_renew_fallback_usec + : NDISC_RS_RENEW_RETRY_INTERVAL_USEC; + + link->ndisc_router_lifetime_usec = USEC_INFINITY; + link->ndisc_rs_renew_attempt = 0; + link->ndisc_rs_renew_in_fallback = true; + r = event_reset_time_relative(link->manager->event, &link->ndisc_rs_renew, CLOCK_BOOTTIME, + interval, 0, + ndisc_rs_renew_handler, link, 0, "ndisc-rs-renew", true); + if (r < 0) + log_link_warning_errno(link, r, "Failed to arm NDisc fallback renewal timer, ignoring: %m"); + return 0; + } - r = event_reset_time(link->manager->event, &link->ndisc_expire, CLOCK_BOOTTIME, - lifetime_usec, 0, ndisc_expire_handler, link, 0, "ndisc-expiration", true); + usec_t now_usec; + r = sd_event_now(link->manager->event, CLOCK_BOOTTIME, &now_usec); if (r < 0) - return log_link_warning_errno(link, r, "Failed to update expiration timer for ndisc: %m"); + return log_link_warning_errno(link, r, "Failed to get current time: %m"); + + if (router_lifetime_usec <= now_usec) { + /* Already expired (or expiring right now); ndisc_expire_handler() will deal with + * dropping the corresponding route once the sweep timer above catches up, no + * point in scheduling a renewal for it. */ + link->ndisc_rs_renew = sd_event_source_disable_unref(link->ndisc_rs_renew); + link->ndisc_router_lifetime_usec = USEC_INFINITY; + } else if (link->ndisc_rs_renew && link->ndisc_router_lifetime_usec == router_lifetime_usec) { + /* Nothing actually changed since we last armed the renewal timer for this router + * (this call is a "spurious" recompute -- e.g. the NDisc expiry sweep timer + * firing for some unrelated, shorter-lived piece of state, or simply + * ndisc_setup_expire() being called again without any new information). The + * renewal timer is deliberately left completely untouched here: recomputing + * "now + remaining * percent" on every such call would recalculate against a + * later "now" each time, silently pushing the actual renewal point later and + * later (towards the real deadline) without ever getting a fresh RA -- which + * defeats the point of RouterSolicitationRenewPercent= entirely. The timer set + * below is left to fire on its own; only a genuine change in + * router_lifetime_usec (a new/renewed RA) should ever cause it to be + * recalculated. */ + log_link_debug(link, + "ndisc_setup_expire(): Router Lifetime unchanged, leaving existing " + "renewal timer untouched."); + } else { + /* Percent is validated to 0..100 by config_parse_percent(), but 0% or 100% would + * make the renewal fire together with (or after) the actual expiry, so clamp to a + * sane usable range. */ + unsigned percent = CLAMP(link->network->ndisc_rs_renew_percent, 1, 99); + usec_t renew_usec = now_usec + (router_lifetime_usec - now_usec) * percent / 100; + + link->ndisc_router_lifetime_usec = router_lifetime_usec; + link->ndisc_rs_renew_attempt = 0; + link->ndisc_rs_renew_in_fallback = false; + r = event_reset_time(link->manager->event, &link->ndisc_rs_renew, CLOCK_BOOTTIME, + renew_usec, 0, ndisc_rs_renew_handler, link, 0, "ndisc-rs-renew", true); + if (r < 0) + log_link_warning_errno(link, r, "Failed to update NDisc renewal timer, ignoring: %m"); + } return 0; } @@ -3115,6 +3328,7 @@ int ndisc_stop(Link *link) { assert(link); link->ndisc_expire = sd_event_source_disable_unref(link->ndisc_expire); + link->ndisc_rs_renew = sd_event_source_disable_unref(link->ndisc_rs_renew); return sd_ndisc_stop(link->ndisc); } diff --git a/src/network/networkd-network-gperf.gperf b/src/network/networkd-network-gperf.gperf index ab7f255..3a703b8 100644 --- a/src/network/networkd-network-gperf.gperf +++ b/src/network/networkd-network-gperf.gperf @@ -321,6 +321,9 @@ IPv6AcceptRA.RouteTable, config_parse_dhcp_or_ra_route_table IPv6AcceptRA.RouteMetric, config_parse_ndisc_route_metric, 0, 0 IPv6AcceptRA.QuickAck, config_parse_bool, 0, offsetof(Network, ndisc_quickack) IPv6AcceptRA.UseCaptivePortal, config_parse_bool, 0, offsetof(Network, ndisc_use_captive_portal) +IPv6AcceptRA.RouterSolicitationRenew, config_parse_bool, 0, offsetof(Network, ndisc_rs_renew) +IPv6AcceptRA.RouterSolicitationRenewPercent, config_parse_percent, 0, offsetof(Network, ndisc_rs_renew_percent) +IPv6AcceptRA.RouterSolicitationRenewFallbackSec, config_parse_sec, 0, offsetof(Network, ndisc_rs_renew_fallback_usec) IPv6AcceptRA.RouterAllowList, config_parse_in_addr_prefixes, AF_INET6, offsetof(Network, ndisc_allow_listed_router) IPv6AcceptRA.RouterDenyList, config_parse_in_addr_prefixes, AF_INET6, offsetof(Network, ndisc_deny_listed_router) IPv6AcceptRA.PrefixAllowList, config_parse_in_addr_prefixes, AF_INET6, offsetof(Network, ndisc_allow_listed_prefix) diff --git a/src/network/networkd-network.c b/src/network/networkd-network.c index 89dcf4f..6c53d7a 100644 --- a/src/network/networkd-network.c +++ b/src/network/networkd-network.c @@ -489,6 +489,9 @@ int network_load_one(Manager *manager, OrderedHashmap **networks, const char *fi .ndisc_use_dnr = -1, .ndisc_use_gateway = true, .ndisc_use_captive_portal = true, + .ndisc_rs_renew = true, + .ndisc_rs_renew_percent = 80, + .ndisc_rs_renew_fallback_usec = 30 * USEC_PER_MINUTE, .ndisc_use_route_prefix = true, .ndisc_use_autonomous_prefix = true, .ndisc_use_onlink_prefix = true, diff --git a/src/network/networkd-network.h b/src/network/networkd-network.h index 03e3108..aea9403 100644 --- a/src/network/networkd-network.h +++ b/src/network/networkd-network.h @@ -360,6 +360,9 @@ struct Network { bool ndisc_quickack; bool ndisc_use_captive_portal; bool ndisc_use_pref64; + bool ndisc_rs_renew; + int ndisc_rs_renew_percent; + usec_t ndisc_rs_renew_fallback_usec; bool active_slave; bool primary_slave; UseDomains ndisc_use_domains; diff --git a/src/systemd/sd-ndisc.h b/src/systemd/sd-ndisc.h index 85fcf6b..ad9022c 100644 --- a/src/systemd/sd-ndisc.h +++ b/src/systemd/sd-ndisc.h @@ -58,6 +58,12 @@ int sd_ndisc_start(sd_ndisc *nd); int sd_ndisc_stop(sd_ndisc *nd); int sd_ndisc_is_running(sd_ndisc *nd); +/* Explicitly (re-)send a single Router Solicitation on an already running client, + * without touching the internal retransmission/timeout state machine. Intended for + * callers that want to proactively refresh router state (e.g. before a previously + * received Router Lifetime expires), independent of the initial-discovery timers. */ +int sd_ndisc_send_router_solicitation(sd_ndisc *nd); + int sd_ndisc_attach_event(sd_ndisc *nd, sd_event *event, int64_t priority); int sd_ndisc_detach_event(sd_ndisc *nd); sd_event *sd_ndisc_get_event(sd_ndisc *nd); -- 2.52.0