View Issue Details

IDProjectCategoryView StatusLast Update
0000661AlmaLinux-10Generalpublic2026-08-04 21:50
Reporterstefankoebeli Assigned To 
PrioritynormalSeverityfeatureReproducibilityalways
Status newResolutionopen 
Platformx86_64OSAlmaLinuxOS Version10
Summary0000661: systemd-networkd is not renewing IPv6 default route got from ISP
DescriptionOn my current AlmaLinux 10.2 I moved to systemd-networkd. I use it on a Protectli VP4650 as my Internet Firewall. Works perfect, except the issue with IPv6 default route. What happens:
enp1s0 is connected to WAN, getting the IPs via DHCP and DHCP6 (networkd), but also SLAAC. systemd-networkd is configuring everything correctly. The ISP router is giving a default route with a lifetime of 30 minutes. Now the issue begins. After this 30 minutes, the route is simply removed. Done. systemd-networkd is not renewing, not sending any router solicitation. On the other hand, ISP router is not sending router advertisement. Many ISPs behave like this. So, networkd should track this and manage to renew.
The result is just that IPv6 is not working (for ever, or the router is sending an RA for any other reason) from a clients perspective.

I have not found any hint on github that this will be solved, just a similar bug report, but different use case.
Steps To ReproduceConnect to a network where a router sends config by SLAAC (or e.g. radvd) is sending an RA with a lifetime of the default route, additional flags e.g. from my ISP: flags=0xc8(managed, other), preference=high, lifetime=30min

systemd-networkd will remove the route after 30min - done.
Additional InformationThere is just no code for this. After initial config, for networkd the jobs is done (except removing the route). I have made a patch (as a starting point) and tested successfully, applied on alma 10.2 src package and rebuilt. I have attached the patch.
Like:
Aug 03 23:37:08 ares systemd-networkd[1190]: enp1s0: Sent Router Solicitation to renew the default route before its Router Lifetime expires (attempt 1/3).
Aug 03 23:37:12 ares systemd-networkd[1190]: enp1s0: Sent Router Solicitation to renew the default route before its Router Lifetime expires (attempt 2/3).
Aug 03 23:37:12 ares systemd-networkd[1190]: enp1s0: NDISC: Received Router Advertisement from fe80::fe0a:xxxx:xxxx:ce15: flags=0xc8(managed, other), preference=high, lifetime=30min

With 3 tries, if no success an additional fallback comes in, trying every n seconds until it gets an RA.
To be configured with additional 3 directives in .network e.g.:
[IPv6AcceptRA]
RouterSolicitationRenew=true
RouterSolicitationRenewPercent=75%
RouterSolicitationRenewFallbackSec=30

Since on github they say that they will only care about things to latest 2 or so releases, and one should go to the distribution one uses... I am here.
No idea if this could be a way to go. What do you think? Is it ok reporting to you?
Tagssystemd
Attached Files
9999-systemd-networkd-router-solicitaion-route-renew.patch (25,382 bytes)   
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::</programlisting></para>
         </listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><varname>RouterSolicitationRenew=</varname></term>
+        <listitem>
+          <para>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 <varname>RouterSolicitationRenew=no</varname>, the reception
+          of unsolicited Router Advertisements is the only way an expiring default route can be renewed.
+          See <varname>RouterSolicitationRenewPercent=</varname> and
+          <varname>RouterSolicitationRenewFallbackSec=</varname> below for tuning when the renewal is
+          sent.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/>
+        </listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><varname>RouterSolicitationRenewPercent=</varname></term>
+        <listitem>
+          <para>Takes a percentage value between 1% and 99% (the trailing <literal>%</literal> character
+          is mandatory, e.g. <literal>RouterSolicitationRenewPercent=75%</literal>). Specifies how much of
+          the currently known Router Lifetime should elapse before a renewal Router Solicitation is sent, when
+          <varname>RouterSolicitationRenew=</varname> 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%.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/>
+        </listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><varname>RouterSolicitationRenewFallbackSec=</varname></term>
+        <listitem>
+          <para>Takes a timespan. When <varname>RouterSolicitationRenew=</varname> 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.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/>
+        </listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><varname>UsePREF64=</varname></term>
         <listitem>
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);

Activities

stefankoebeli

2026-08-04 21:49

reporter   ~0001237

0001-networkd-renewing-ipv6-default-route-patch.patch (27,770 bytes)   
From d0f4c8fcf96724e249f68922fc004272e0a96e7c Mon Sep 17 00:00:00 2001
From: Stefan Koebeli <stefan@koebeli.ch>
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::</programlisting></para>
         </listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><varname>RouterSolicitationRenew=</varname></term>
+        <listitem>
+          <para>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 <varname>RouterSolicitationRenew=no</varname>, the reception
+          of unsolicited Router Advertisements is the only way an expiring default route can be renewed.
+          See <varname>RouterSolicitationRenewPercent=</varname> and
+          <varname>RouterSolicitationRenewFallbackSec=</varname> below for tuning when the renewal is
+          sent.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/>
+        </listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><varname>RouterSolicitationRenewPercent=</varname></term>
+        <listitem>
+          <para>Takes a percentage value between 1% and 99% (the trailing <literal>%</literal> character
+          is mandatory, e.g. <literal>RouterSolicitationRenewPercent=75%</literal>). Specifies how much of
+          the currently known Router Lifetime should elapse before a renewal Router Solicitation is sent, when
+          <varname>RouterSolicitationRenew=</varname> 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%.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/>
+        </listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><varname>RouterSolicitationRenewFallbackSec=</varname></term>
+        <listitem>
+          <para>Takes a timespan. When <varname>RouterSolicitationRenew=</varname> 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.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/>
+        </listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><varname>UsePREF64=</varname></term>
         <listitem>
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

stefankoebeli

2026-08-04 21:50

reporter   ~0001238

Please look only at 0001-.. Patch, thats the right one. Sorry.

Issue History

Date Modified Username Field Change
2026-08-03 22:04 stefankoebeli New Issue
2026-08-03 22:04 stefankoebeli Tag Attached: systemd
2026-08-03 22:04 stefankoebeli File Added: 9999-systemd-networkd-router-solicitaion-route-renew.patch
2026-08-04 21:49 stefankoebeli Note Added: 0001237
2026-08-04 21:49 stefankoebeli File Added: 0001-networkd-renewing-ipv6-default-route-patch.patch
2026-08-04 21:50 stefankoebeli Note Added: 0001238