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..b315293 100644
--- a/src/network/networkd-ndisc.c
+++ b/src/network/networkd-ndisc.c
@@ -2499,6 +2499,104 @@ 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;
+
+ if (!link->ndisc)
+ return 0;
+
+ 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 (r < 0)
+ log_link_warning_errno(link, 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;
+ }
+
+ if (r < 0)
+ log_link_warning_errno(link, r,
+ "Failed to send NDisc renewal Router Solicitation "
+ "(attempt %u/%u, anchored to a known Router Lifetime), ignoring: %m",
+ link->ndisc_rs_renew_attempt + 1, 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 + 1, NDISC_RS_RENEW_MAX_RETRIES);
+
+ /* Otherwise we are within a bounded retry burst anchored to a real, finite router
+ * lifetime: retry a few more times in quick succession, since Router Solicitation is
+ * sent to a multicast address with no transport-level reliability and a single lost
+ * packet must not mean giving up on renewal entirely. */
+ link->ndisc_rs_renew_attempt++;
+
+ if (link->ndisc_rs_renew_attempt >= NDISC_RS_RENEW_MAX_RETRIES)
+ return 0;
+
+ r = sd_event_now(link->manager->event, CLOCK_BOOTTIME, &now_usec);
+ if (r < 0)
+ return 0;
+
+ /* Only retry if there is still meaningful headroom before the router lifetime this
+ * renewal cycle is anchored to actually runs out; otherwise just let
+ * ndisc_expire_handler() take over as before, retrying now would be pointless noise. */
+ if (now_usec + NDISC_RS_RENEW_RETRY_INTERVAL_USEC < link->ndisc_router_lifetime_usec) {
+ r = event_reset_time_relative(link->manager->event, &link->ndisc_rs_renew, CLOCK_BOOTTIME,
+ NDISC_RS_RENEW_RETRY_INTERVAL_USEC, 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 +2605,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 +2614,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 +2626,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 +2636,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 +2677,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 +3308,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);