]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
network/ndisc: dynamically configure nexthops when routes with gateway are requested 35119/head
authorYu Watanabe <watanabe.yu+github@gmail.com>
Mon, 11 Nov 2024 17:13:04 +0000 (02:13 +0900)
committerYu Watanabe <watanabe.yu+github@gmail.com>
Thu, 14 Nov 2024 02:59:59 +0000 (11:59 +0900)
Previously, when multiple routers send RAs with the same preference,
then the kernel merges routes with the same gateway address:
===
default proto ra metric 1024 expires 595sec pref medium
        nexthop via fe80::200:10ff:fe10:1060 dev enp0s9 weight 1
        nexthop via fe80::200:10ff:fe10:1061 dev enp0s9 weight 1
===
This causes IPv6 Conformance Test v6LC.2.2.11 failure, as reported in #33470.

To avoid the coalescing issue, we can use nexthop, as suggested by Ido Schimmel:
https://lore.kernel.org/netdev/ZytjEINNRmtpadr_@shredder/
> BTW, you can avoid the coalescing problem by using the nexthop API.
> # ip nexthop add id 1 via fe80::200:10ff:fe10:1060 dev enp0s9
> # ip -6 route add default nhid 1 expires 600 proto ra
> # ip nexthop add id 2 via fe80::200:10ff:fe10:1061 dev enp0s9
> # ip -6 route append default nhid 2 expires 600 proto ra
> # ip -6 route
> fe80::/64 dev enp0s9 proto kernel metric 256 pref medium
> default nhid 1 via fe80::200:10ff:fe10:1060 dev enp0s9 proto ra metric 1024 expires 563sec pref medium
> default nhid 2 via fe80::200:10ff:fe10:1061 dev enp0s9 proto ra metric 1024 expires 594sec pref medium

Fixes #33470.

Suggested-by: Ido Schimmel <idosch@idosch.org>
src/network/networkd-ndisc.c
test/test-network/systemd-networkd-tests.py

index 3871618e5f54eb72074696f9ad4a8b01628ce28f..d46fc4a4e544af25c3c3d8aaaef102ef568893b8 100644 (file)
@@ -18,6 +18,7 @@
 #include "networkd-dhcp6.h"
 #include "networkd-manager.h"
 #include "networkd-ndisc.h"
+#include "networkd-nexthop.h"
 #include "networkd-queue.h"
 #include "networkd-route.h"
 #include "networkd-state-file.h"
@@ -149,6 +150,290 @@ static int ndisc_check_ready(Link *link) {
         return 0;
 }
 
+static int ndisc_remove_unused_nexthop(Link *link, NextHop *nexthop) {
+        int r;
+
+        assert(link);
+        assert(link->manager);
+        assert(link->ifindex > 0);
+        assert(nexthop);
+
+        if (nexthop->source != NETWORK_CONFIG_SOURCE_NDISC)
+                return 0;
+
+        if (nexthop->ifindex != link->ifindex)
+                return 0;
+
+        Route *route;
+        SET_FOREACH(route, nexthop->routes)
+                if (route_exists(route) || route_is_requesting(route))
+                        return 0;
+
+        Request *req;
+        ORDERED_SET_FOREACH(req, link->manager->request_queue) {
+                if (req->type != REQUEST_TYPE_ROUTE)
+                        continue;
+
+                route = ASSERT_PTR(req->userdata);
+                if (route->nexthop_id == nexthop->id)
+                        return 0;
+        }
+
+        r = nexthop_remove_and_cancel(nexthop, link->manager);
+        if (r < 0)
+                return log_link_debug_errno(link, r, "Failed to remove unused nexthop: %m");
+
+        return 0;
+}
+
+static int ndisc_remove_unused_nexthop_by_id(Link *link, uint32_t id) {
+        assert(link);
+        assert(link->manager);
+
+        if (id == 0)
+                return 0;
+
+        NextHop *nexthop;
+        if (nexthop_get_by_id(link->manager, id, &nexthop) < 0)
+                return 0;
+
+        return ndisc_remove_unused_nexthop(link, nexthop);
+}
+
+static int ndisc_remove_unused_nexthops(Link *link) {
+        int ret = 0;
+
+        assert(link);
+        assert(link->manager);
+
+        NextHop *nexthop;
+        HASHMAP_FOREACH(nexthop, link->manager->nexthops_by_id)
+                RET_GATHER(ret, ndisc_remove_unused_nexthop(link, nexthop));
+
+        return ret;
+}
+
+#define NDISC_NEXTHOP_APP_ID SD_ID128_MAKE(76,d2,0f,1f,76,1e,44,d1,97,3a,52,5c,05,68,b5,0d)
+
+static uint32_t ndisc_generate_nexthop_id(NextHop *nexthop, Link *link, sd_id128_t app_id, uint64_t trial) {
+        assert(nexthop);
+        assert(link);
+
+        struct siphash state;
+        siphash24_init(&state, app_id.bytes);
+        siphash24_compress_typesafe(nexthop->protocol, &state);
+        siphash24_compress_string(link->ifname, &state);
+        siphash24_compress_typesafe(nexthop->gw.address.in6, &state);
+        siphash24_compress_typesafe(nexthop->provider.in6, &state);
+        uint64_t n = htole64(trial);
+        siphash24_compress_typesafe(n, &state);
+
+        uint64_t result = htole64(siphash24_finalize(&state));
+        return (uint32_t) ((result & 0xffffffff) ^ (result >> 32));
+}
+
+static bool ndisc_nexthop_equal(NextHop *a, NextHop *b) {
+        assert(a);
+        assert(b);
+
+        if (a->source != b->source)
+                return false;
+        if (a->protocol != b->protocol)
+                return false;
+        if (a->ifindex != b->ifindex)
+                return false;
+        if (!in6_addr_equal(&a->provider.in6, &b->provider.in6))
+                return false;
+        if (!in6_addr_equal(&a->gw.address.in6, &b->gw.address.in6))
+                return false;
+
+        return true;
+}
+
+static bool ndisc_take_nexthop_id(NextHop *nexthop, NextHop *existing, Manager *manager) {
+        assert(nexthop);
+        assert(existing);
+        assert(manager);
+
+        if (!ndisc_nexthop_equal(nexthop, existing))
+                return false;
+
+        log_nexthop_debug(existing, "Found matching", manager);
+        nexthop->id = existing->id;
+        return true;
+}
+
+static int ndisc_nexthop_find_id(NextHop *nexthop, Link *link) {
+        NextHop *n;
+        Request *req;
+        int r;
+
+        assert(nexthop);
+        assert(link);
+        assert(link->manager);
+
+        sd_id128_t app_id;
+        r = sd_id128_get_machine_app_specific(NDISC_NEXTHOP_APP_ID, &app_id);
+        if (r < 0)
+                return r;
+
+        uint32_t id = ndisc_generate_nexthop_id(nexthop, link, app_id, 0);
+        if (nexthop_get_by_id(link->manager, id, &n) >= 0 &&
+            ndisc_take_nexthop_id(nexthop, n, link->manager))
+                return true;
+        if (nexthop_get_request_by_id(link->manager, id, &req) >= 0 &&
+            ndisc_take_nexthop_id(nexthop, req->userdata, link->manager))
+                return true;
+
+        HASHMAP_FOREACH(n, link->manager->nexthops_by_id)
+                if (ndisc_take_nexthop_id(nexthop, n, link->manager))
+                        return true;
+
+        ORDERED_SET_FOREACH(req, link->manager->request_queue) {
+                if (req->type != REQUEST_TYPE_NEXTHOP)
+                        continue;
+
+                if (ndisc_take_nexthop_id(nexthop, req->userdata, link->manager))
+                        return true;
+        }
+
+        return false;
+}
+
+static int ndisc_nexthop_new(Route *route, Link *link, NextHop **ret) {
+        _cleanup_(nexthop_unrefp) NextHop *nexthop = NULL;
+        int r;
+
+        assert(route);
+        assert(link);
+        assert(ret);
+
+        r = nexthop_new(&nexthop);
+        if (r < 0)
+                return r;
+
+        nexthop->source = NETWORK_CONFIG_SOURCE_NDISC;
+        nexthop->provider = route->provider;
+        nexthop->protocol = route->protocol == RTPROT_REDIRECT ? RTPROT_REDIRECT : RTPROT_RA;
+        nexthop->family = AF_INET6;
+        nexthop->gw.address = route->nexthop.gw;
+        nexthop->ifindex = link->ifindex;
+
+        r = ndisc_nexthop_find_id(nexthop, link);
+        if (r < 0)
+                return r;
+
+        *ret = TAKE_PTR(nexthop);
+        return 0;
+}
+
+static int ndisc_nexthop_acquire_id(NextHop *nexthop, Link *link) {
+        int r;
+
+        assert(nexthop);
+        assert(nexthop->id == 0);
+        assert(link);
+        assert(link->manager);
+
+        sd_id128_t app_id;
+        r = sd_id128_get_machine_app_specific(NDISC_NEXTHOP_APP_ID, &app_id);
+        if (r < 0)
+                return r;
+
+        for (uint64_t trial = 0; trial < 100; trial++) {
+                uint32_t id = ndisc_generate_nexthop_id(nexthop, link, app_id, trial);
+                if (id == 0)
+                        continue;
+
+                if (set_contains(link->manager->nexthop_ids, UINT32_TO_PTR(id)))
+                        continue; /* The ID is already used in a .network file. */
+
+                if (nexthop_get_by_id(link->manager, id, NULL) >= 0)
+                        continue; /* The ID is already used by an existing nexthop. */
+
+                if (nexthop_get_request_by_id(link->manager, id, NULL) >= 0)
+                        continue; /* The ID is already used by a nexthop being requested. */
+
+                log_link_debug(link, "Generated new ndisc nexthop ID for %s with trial %"PRIu64": %"PRIu32,
+                               IN6_ADDR_TO_STRING(&nexthop->gw.address.in6), trial, id);
+                nexthop->id = id;
+                return 0;
+        }
+
+        return log_link_debug_errno(link, SYNTHETIC_ERRNO(EBUSY), "Cannot find free nexthop ID for %s.",
+                                    IN6_ADDR_TO_STRING(&nexthop->gw.address.in6));
+}
+
+static int ndisc_nexthop_handler(sd_netlink *rtnl, sd_netlink_message *m, Request *req, Link *link, NextHop *nexthop) {
+        int r;
+
+        assert(link);
+
+        r = nexthop_configure_handler_internal(m, link, "Could not set NDisc route");
+        if (r <= 0)
+                return r;
+
+        r = ndisc_check_ready(link);
+        if (r < 0)
+                link_enter_failed(link);
+
+        return 1;
+}
+
+static int ndisc_request_nexthop(NextHop *nexthop, Link *link) {
+        int r;
+
+        assert(nexthop);
+        assert(link);
+
+        if (nexthop->id > 0)
+                return 0;
+
+        r = ndisc_nexthop_acquire_id(nexthop, link);
+        if (r < 0)
+                return r;
+
+        r = link_request_nexthop(link, nexthop, &link->ndisc_messages, ndisc_nexthop_handler);
+        if (r < 0)
+                return r;
+        if (r > 0)
+                link->ndisc_configured = false;
+
+        return 0;
+}
+
+static int ndisc_set_route_nexthop(Route *route, Link *link, bool request) {
+        _cleanup_(nexthop_unrefp) NextHop *nexthop = NULL;
+        int r;
+
+        assert(route);
+        assert(link);
+        assert(link->manager);
+
+        if (!link->manager->manage_foreign_nexthops)
+                goto finalize;
+
+        if (route->nexthop.family != AF_INET6 || in6_addr_is_null(&route->nexthop.gw.in6))
+                goto finalize;
+
+        r = ndisc_nexthop_new(route, link, &nexthop);
+        if (r < 0)
+                return r;
+
+        if (nexthop->id == 0 && !request)
+                goto finalize;
+
+        r = ndisc_request_nexthop(nexthop, link);
+        if (r < 0)
+                return r;
+
+        route->nexthop = (RouteNextHop) {};
+        route->nexthop_id = nexthop->id;
+
+finalize:
+        return route_adjust_nexthops(route, link);
+}
+
 static int ndisc_route_handler(sd_netlink *rtnl, sd_netlink_message *m, Request *req, Link *link, Route *route) {
         int r;
 
@@ -200,7 +485,7 @@ static int ndisc_request_route(Route *route, Link *link) {
         if (r < 0)
                 return r;
 
-        r = route_adjust_nexthops(route, link);
+        r = ndisc_set_route_nexthop(route, link, /* request = */ true);
         if (r < 0)
                 return r;
 
@@ -322,7 +607,7 @@ static int ndisc_remove_route(Route *route, Link *link) {
         assert(link);
         assert(link->manager);
 
-        r = route_adjust_nexthops(route, link);
+        r = ndisc_set_route_nexthop(route, link, /* request = */ false);
         if (r < 0)
                 return r;
 
@@ -362,7 +647,7 @@ static int ndisc_remove_route(Route *route, Link *link) {
                 }
         }
 
-        return ret;
+        return RET_GATHER(ret, ndisc_remove_unused_nexthop_by_id(link, route->nexthop_id));
 }
 
 static int ndisc_remove_router_route(Route *route, Link *link, sd_ndisc_router *rt) {
@@ -2112,6 +2397,8 @@ static int ndisc_drop_outdated(Link *link, const struct in6_addr *router, usec_t
                         RET_GATHER(ret, log_link_warning_errno(link, r, "Failed to remove outdated SLAAC route, ignoring: %m"));
         }
 
+        RET_GATHER(ret, ndisc_remove_unused_nexthops(link));
+
         SET_FOREACH(address, link->addresses) {
                 if (address->source != NETWORK_CONFIG_SOURCE_NDISC)
                         continue;
@@ -2772,6 +3059,8 @@ int link_drop_ndisc_config(Link *link, Network *network) {
                         if (r < 0)
                                 RET_GATHER(ret, log_link_warning_errno(link, r, "Failed to remove SLAAC route, ignoring: %m"));
                 }
+
+                RET_GATHER(ret, ndisc_remove_unused_nexthops(link));
         }
 
         /* If SLAAC address is disabled, drop all addresses. */
index e603e6087a8934eae3bb493ce392d99aaa2f05c4..6f15aff1d946eddb0b9bc640e1184e2f48002218 100755 (executable)
@@ -6156,8 +6156,8 @@ class NetworkdRATests(unittest.TestCase, Utilities):
         check_output(f'{test_ndisc_send} --interface veth-peer --type redirect --target-address fe80::2 --redirect-destination 2002:da8:1:2:1a:2b:3c:4d')
         self.wait_route_dropped('veth99', '2002:da8:1:1:1a:2b:3c:4d proto redirect', ipv='-6', timeout_sec=10)
         self.wait_route_dropped('veth99', '2002:da8:1:2:1a:2b:3c:4d proto redirect', ipv='-6', timeout_sec=10)
-        self.wait_route('veth99', '2002:da8:1:1:1a:2b:3c:4d via fe80::1 proto redirect', ipv='-6', timeout_sec=10)
-        self.wait_route('veth99', '2002:da8:1:2:1a:2b:3c:4d via fe80::2 proto redirect', ipv='-6', timeout_sec=10)
+        self.wait_route('veth99', r'2002:da8:1:1:1a:2b:3c:4d nhid [0-9]* via fe80::1 proto redirect', ipv='-6', timeout_sec=10)
+        self.wait_route('veth99', r'2002:da8:1:2:1a:2b:3c:4d nhid [0-9]* via fe80::2 proto redirect', ipv='-6', timeout_sec=10)
 
         # Send Neighbor Advertisement without the router flag to announce the default router is not available anymore.
         # Then, verify that all redirect routes and the default route are dropped.
@@ -6309,14 +6309,14 @@ class NetworkdRATests(unittest.TestCase, Utilities):
 
         self.wait_address('client', '2002:da8:1:99:1034:56ff:fe78:9a00/64', ipv='-6', timeout_sec=10)
         self.wait_address('client', '2002:da8:1:98:1034:56ff:fe78:9a00/64', ipv='-6', timeout_sec=10)
-        self.wait_route('client', 'default via fe80::1034:56ff:fe78:9a99 proto ra metric 512', ipv='-6', timeout_sec=10)
-        self.wait_route('client', 'default via fe80::1034:56ff:fe78:9a98 proto ra metric 2048', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 512', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 2048', ipv='-6', timeout_sec=10)
 
         print('### ip -6 route show dev client default')
         output = check_output('ip -6 route show dev client default')
         print(output)
-        self.assertRegex(output, r'default via fe80::1034:56ff:fe78:9a99 proto ra metric 512 expires [0-9]*sec pref high')
-        self.assertRegex(output, r'default via fe80::1034:56ff:fe78:9a98 proto ra metric 2048 expires [0-9]*sec pref low')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 512 expires [0-9]*sec pref high')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 2048 expires [0-9]*sec pref low')
 
         with open(os.path.join(network_unit_dir, '25-veth-client.network'), mode='a', encoding='utf-8') as f:
             f.write('\n[Link]\nMACAddress=12:34:56:78:9a:01\n[IPv6AcceptRA]\nRouteMetric=100:200:300\n')
@@ -6326,14 +6326,14 @@ class NetworkdRATests(unittest.TestCase, Utilities):
 
         self.wait_address('client', '2002:da8:1:99:1034:56ff:fe78:9a01/64', ipv='-6', timeout_sec=10)
         self.wait_address('client', '2002:da8:1:98:1034:56ff:fe78:9a01/64', ipv='-6', timeout_sec=10)
-        self.wait_route('client', 'default via fe80::1034:56ff:fe78:9a99 proto ra metric 100', ipv='-6', timeout_sec=10)
-        self.wait_route('client', 'default via fe80::1034:56ff:fe78:9a98 proto ra metric 300', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 100', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 300', ipv='-6', timeout_sec=10)
 
         print('### ip -6 route show dev client default')
         output = check_output('ip -6 route show dev client default')
         print(output)
-        self.assertRegex(output, r'default via fe80::1034:56ff:fe78:9a99 proto ra metric 100 expires [0-9]*sec pref high')
-        self.assertRegex(output, r'default via fe80::1034:56ff:fe78:9a98 proto ra metric 300 expires [0-9]*sec pref low')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 100 expires [0-9]*sec pref high')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 300 expires [0-9]*sec pref low')
         self.assertNotIn('metric 512', output)
         self.assertNotIn('metric 2048', output)
 
@@ -6341,20 +6341,41 @@ class NetworkdRATests(unittest.TestCase, Utilities):
         remove_network_unit('25-veth-router-high.network', '25-veth-router-low.network')
         copy_network_unit('25-veth-router-high2.network', '25-veth-router-low2.network')
         networkctl_reload()
-        self.wait_route('client', 'default via fe80::1034:56ff:fe78:9a99 proto ra metric 300', ipv='-6', timeout_sec=10)
-        self.wait_route('client', 'default via fe80::1034:56ff:fe78:9a98 proto ra metric 100', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 300', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 100', ipv='-6', timeout_sec=10)
 
         print('### ip -6 route show dev client default')
         output = check_output('ip -6 route show dev client default')
         print(output)
-        self.assertRegex(output, r'default via fe80::1034:56ff:fe78:9a99 proto ra metric 300 expires [0-9]*sec pref low')
-        self.assertRegex(output, r'default via fe80::1034:56ff:fe78:9a98 proto ra metric 100 expires [0-9]*sec pref high')
-        self.assertNotRegex(output, 'default via fe80::1034:56ff:fe78:9a99 proto ra metric 100')
-        self.assertNotRegex(output, 'default via fe80::1034:56ff:fe78:9a98 proto ra metric 300')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 300 expires [0-9]*sec pref low')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 100 expires [0-9]*sec pref high')
+        self.assertNotRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 100')
+        self.assertNotRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 300')
         self.assertNotIn('metric 512', output)
         self.assertNotIn('metric 2048', output)
 
-    def test_ndisc_vs_static_route(self):
+        # Use the same preference, and check if the two routes are not coalesced. See issue #33470.
+        with open(os.path.join(network_unit_dir, '25-veth-router-high2.network'), mode='a', encoding='utf-8') as f:
+            f.write('\n[IPv6SendRA]\nRouterPreference=medium\n')
+        with open(os.path.join(network_unit_dir, '25-veth-router-low2.network'), mode='a', encoding='utf-8') as f:
+            f.write('\n[IPv6SendRA]\nRouterPreference=medium\n')
+        networkctl_reload()
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 200', ipv='-6', timeout_sec=10)
+        self.wait_route('client', r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 200', ipv='-6', timeout_sec=10)
+
+        print('### ip -6 route show dev client default')
+        output = check_output('ip -6 route show dev client default')
+        print(output)
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a99 proto ra metric 200 expires [0-9]*sec pref medium')
+        self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9a98 proto ra metric 200 expires [0-9]*sec pref medium')
+        self.assertNotIn('pref high', output)
+        self.assertNotIn('pref low', output)
+        self.assertNotIn('metric 512', output)
+        self.assertNotIn('metric 2048', output)
+
+    def _test_ndisc_vs_static_route(self, manage_foreign_nexthops):
+        if not manage_foreign_nexthops:
+            copy_networkd_conf_dropin('networkd-manage-foreign-nexthops-no.conf')
         copy_network_unit('25-veth.netdev', '25-ipv6-prefix.network', '25-ipv6-prefix-veth-static-route.network')
         start_networkd()
         self.wait_online('veth99:routable', 'veth-peer:degraded')
@@ -6364,13 +6385,24 @@ class NetworkdRATests(unittest.TestCase, Utilities):
         output = check_output('ip -6 route show dev veth99 default')
         print(output)
         self.assertIn('via fe80::1034:56ff:fe78:9abd proto static metric 256 pref medium', output)
-        self.assertNotIn('proto ra', output)
+        if manage_foreign_nexthops:
+            self.assertRegex(output, r'default nhid [0-9]* via fe80::1034:56ff:fe78:9abd proto ra metric 256 expires [0-9]*sec pref medium')
+        else:
+            self.assertNotIn('proto ra', output)
+
+        print('### ip -6 nexthop show dev veth99')
+        output = check_output('ip -6 nexthop show dev veth99')
+        print(output)
+        if manage_foreign_nexthops:
+            self.assertRegex(output, r'id [0-9]* via fe80::1034:56ff:fe78:9abd dev veth99 scope link proto ra')
+        else:
+            self.assertEqual(output, '')
 
         # Also check if the static route is protected from RA with zero lifetime
         with open(os.path.join(network_unit_dir, '25-ipv6-prefix.network'), mode='a', encoding='utf-8') as f:
             f.write('\n[Network]\nIPv6SendRA=no\n')
         networkctl_reload() # This makes veth-peer being reconfigured, and send RA with zero lifetime
-        self.wait_route_dropped('veth99', 'default via fe80::1034:56ff:fe78:9abd proto ra metric 256', ipv='-6', timeout_sec=10)
+        self.wait_route_dropped('veth99', r'default (nhid [0-9]* |)via fe80::1034:56ff:fe78:9abd proto ra metric 256', ipv='-6', timeout_sec=10)
 
         print('### ip -6 route show dev veth99 default')
         output = check_output('ip -6 route show dev veth99 default')
@@ -6378,6 +6410,24 @@ class NetworkdRATests(unittest.TestCase, Utilities):
         self.assertIn('via fe80::1034:56ff:fe78:9abd proto static metric 256 pref medium', output)
         self.assertNotIn('proto ra', output)
 
+        # Check if nexthop is removed.
+        print('### ip -6 nexthop show dev veth99')
+        output = check_output('ip -6 nexthop show dev veth99')
+        print(output)
+        self.assertEqual(output, '')
+
+    def test_ndisc_vs_static_route(self):
+        first = True
+        for manage_foreign_nexthops in [True, False]:
+            if first:
+                first = False
+            else:
+                self.tearDown()
+
+            print(f'### test_ndisc_vs_static_route(manage_foreign_nexthops={manage_foreign_nexthops})')
+            with self.subTest(manage_foreign_nexthops=manage_foreign_nexthops):
+                self._test_ndisc_vs_static_route(manage_foreign_nexthops)
+
     # radvd supports captive portal since v2.20.
     # https://github.com/radvd-project/radvd/commit/791179a7f730decbddb2290ef0e34aa85d71b1bc
     @unittest.skipUnless(radvd_check_config('captive-portal.conf'), "Installed radvd doesn't support captive portals")
@@ -8279,10 +8329,15 @@ class NetworkdIPv6PrefixTests(unittest.TestCase, Utilities):
         self.assertIn('2001:db8:0:1::/64 proto ra', output)
         self.assertNotIn('2001:db8:0:2::/64 proto ra', output)
         self.assertNotIn('2001:db8:0:3::/64 proto ra', output)
-        self.assertRegex(output, '2001:db0:fff::/64 via fe80::1034:56ff:fe78:9abc')
+        self.assertRegex(output, r'2001:db0:fff::/64 nhid [0-9]* via fe80::1034:56ff:fe78:9abc')
         self.assertNotIn('2001:db1:fff::/64', output)
         self.assertNotIn('2001:db2:fff::/64', output)
 
+        print('### ip -6 nexthop show dev veth-peer')
+        output = check_output('ip -6 nexthop show dev veth-peer')
+        print(output)
+        self.assertRegex(output, r'id [0-9]* via fe80::1034:56ff:fe78:9abc dev veth-peer scope link proto ra')
+
         print('### ip -6 address show dev veth99')
         output = check_output('ip -6 address show dev veth99')
         print(output)
@@ -8331,9 +8386,14 @@ class NetworkdIPv6PrefixTests(unittest.TestCase, Utilities):
         print(output)
         self.assertIn('2001:db8:0:1::/64 proto ra', output)
         self.assertNotIn('2001:db8:0:2::/64 proto ra', output)
-        self.assertRegex(output, '2001:db0:fff::/64 via fe80::1034:56ff:fe78:9abc')
+        self.assertRegex(output, r'2001:db0:fff::/64 nhid [0-9]* via fe80::1034:56ff:fe78:9abc')
         self.assertNotIn('2001:db1:fff::/64', output)
 
+        print('### ip -6 nexthop show dev veth-peer')
+        output = check_output('ip -6 nexthop show dev veth-peer')
+        print(output)
+        self.assertRegex(output, r'id [0-9]* via fe80::1034:56ff:fe78:9abc dev veth-peer scope link proto ra')
+
         print('### ip -6 address show dev veth99')
         output = check_output('ip -6 address show dev veth99')
         print(output)