about summary refs log tree commit diff
path: root/src/core/nm-netns.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/nm-netns.c')
-rw-r--r--src/core/nm-netns.c595
1 files changed, 536 insertions, 59 deletions
diff --git a/src/core/nm-netns.c b/src/core/nm-netns.c
index 859dbb21..12ca8508 100644
--- a/src/core/nm-netns.c
+++ b/src/core/nm-netns.c
@@ -7,6 +7,8 @@
 
 #include "nm-netns.h"
 
+#include <linux/rtnetlink.h>
+
 #include "libnm-glib-aux/nm-dedup-multi.h"
 #include "libnm-glib-aux/nm-c-list.h"
 
@@ -16,6 +18,7 @@
 #include "libnm-platform/nm-platform.h"
 #include "libnm-platform/nmp-netns.h"
 #include "libnm-platform/nmp-global-tracker.h"
+#include "libnm-std-aux/c-list-util.h"
 
 /*****************************************************************************/
 
@@ -28,6 +31,8 @@ typedef struct {
     NMPGlobalTracker *global_tracker;
     GHashTable       *l3cfgs;
     GHashTable       *shared_ips;
+    GHashTable       *ecmp_track_by_obj;
+    GHashTable       *ecmp_track_by_ecmpid;
     CList             l3cfg_signal_pending_lst_head;
     GSource          *signal_pending_idle_source;
 } NMNetnsPrivate;
@@ -67,6 +72,165 @@ NM_DEFINE_SINGLETON_GETTER(NMNetns, nm_netns_get, NM_TYPE_NETNS);
 
 /*****************************************************************************/
 
+#define nm_assert_l3cfg(self, l3cfg)                                                      \
+    G_STMT_START                                                                          \
+    {                                                                                     \
+        NMNetns *_self  = (self);                                                         \
+        NML3Cfg *_l3cfg = (l3cfg);                                                        \
+                                                                                          \
+        nm_assert(NM_IS_NETNS(self));                                                     \
+        nm_assert(NM_IS_L3CFG(_l3cfg));                                                   \
+        if (NM_MORE_ASSERTS > 5)                                                          \
+            nm_assert(_l3cfg == nm_netns_l3cfg_get(_self, nm_l3cfg_get_ifindex(_l3cfg))); \
+    }                                                                                     \
+    G_STMT_END
+
+/*****************************************************************************/
+
+typedef struct {
+    const NMPObject *representative_obj;
+    const NMPObject *merged_obj;
+    CList            ecmpid_lst_head;
+    bool             needs_update : 1;
+    bool             already_visited : 1;
+} EcmpTrackEcmpid;
+
+typedef struct {
+    const NMPObject *obj;
+
+    NML3Cfg         *l3cfg;
+    EcmpTrackEcmpid *parent_track_ecmpid;
+
+    CList ifindex_lst;
+    CList ecmpid_lst;
+
+    /* Calling nm_netns_ip_route_ecmp_register() will ensure that the tracked
+     * entry is non-dirty. This can be used to remove stale entries. */
+    bool dirty : 1;
+
+    /* This flag is set during nm_netns_ip_route_ecmp_register(), when first tracking the
+     * route. It is cleared on the next nm_netns_ip_route_ecmp_commit(). It thus only
+     * exists for a short time, to know during a commit that the route is new and
+     * we need to do something special. */
+    bool is_new : 1;
+
+    /* The entry is ready to be configured. This exists, because the nexthop of
+     * a route must be reachable directly (being onlink). That is, we may need
+     * to add a direct, single-hop route to the gateway, which is done by
+     * the NML3Cfg of that interface. Since the NML3Cfg calls nm_netns_ip_route_ecmp_commit()
+     * and only adds the direct route afterwards, the ECMP route may not be ready
+     * right away, but only upon seeing the entry a second time. */
+    bool is_ready : 1;
+} EcmpTrackObj;
+
+static int
+_ecmp_track_sort_lst_cmp(const CList *a, const CList *b, const void *user_data)
+{
+    EcmpTrackObj             *track_obj_a = c_list_entry(a, EcmpTrackObj, ecmpid_lst);
+    EcmpTrackObj             *track_obj_b = c_list_entry(b, EcmpTrackObj, ecmpid_lst);
+    const NMPlatformIP4Route *route_a     = NMP_OBJECT_CAST_IP4_ROUTE(track_obj_a->obj);
+    const NMPlatformIP4Route *route_b     = NMP_OBJECT_CAST_IP4_ROUTE(track_obj_b->obj);
+
+    nm_assert(route_a->ifindex > 0);
+    nm_assert(route_a->n_nexthops <= 1);
+    nm_assert(route_b->ifindex > 0);
+    nm_assert(route_b->n_nexthops <= 1);
+
+    NM_CMP_FIELD(route_a, route_b, ifindex);
+    NM_CMP_FIELD(route_b, route_a, weight);
+    NM_CMP_DIRECT(htonl(route_a->gateway), htonl(route_b->gateway));
+
+    return nm_assert_unreachable_val(
+        nm_platform_ip4_route_cmp(route_a, route_b, NM_PLATFORM_IP_ROUTE_CMP_TYPE_ID));
+}
+
+static gboolean
+_ecmp_track_init_merged_obj(EcmpTrackEcmpid *track_ecmpid, const NMPObject **out_obj_del)
+{
+    EcmpTrackObj                   *track_obj;
+    nm_auto_nmpobj const NMPObject *obj_new = NULL;
+    gsize                           n_nexthops;
+    gsize                           i;
+
+    nm_assert(track_ecmpid);
+    nm_assert(!c_list_is_empty(&track_ecmpid->ecmpid_lst_head));
+    nm_assert(track_ecmpid->representative_obj
+              == c_list_first_entry(&track_ecmpid->ecmpid_lst_head, EcmpTrackObj, ecmpid_lst)->obj);
+    nm_assert(out_obj_del && !*out_obj_del);
+
+    if (!track_ecmpid->needs_update) {
+        /* Already up to date. Nothing to do. */
+        return FALSE;
+    }
+
+    track_ecmpid->needs_update = FALSE;
+
+    n_nexthops = c_list_length(&track_ecmpid->ecmpid_lst_head);
+
+    if (n_nexthops == 1) {
+        /* There is only a single entry. There is nothing to merge, just set
+         * the first entry. */
+        obj_new = nmp_object_ref(track_ecmpid->representative_obj);
+        goto out;
+    }
+
+    /* We want that the nexthop list is deterministic. We thus sort the list and update
+     * the representative_obj. */
+    c_list_sort(&track_ecmpid->ecmpid_lst_head, _ecmp_track_sort_lst_cmp, NULL);
+    nmp_object_ref_set(
+        &track_ecmpid->representative_obj,
+        c_list_first_entry(&track_ecmpid->ecmpid_lst_head, EcmpTrackObj, ecmpid_lst)->obj);
+
+    obj_new = nmp_object_clone(track_ecmpid->representative_obj, FALSE);
+
+    nm_assert(obj_new->ip4_route.n_nexthops <= 1);
+    nm_assert(!obj_new->_ip4_route.extra_nexthops);
+
+    /* Note that there actually cannot be duplicate (ifindex,gateway,weight) tuples, because
+     * NML3Cfg uses NM_PLATFORM_IP_ROUTE_CMP_TYPE_ID to track the routes, and track_ecmpid
+     * groups them further by NM_PLATFORM_IP_ROUTE_CMP_TYPE_ECMP_ID. The comparison for
+     * ECMP_ID is a strict superset of ID, hence there are no dupliated.
+     *
+     * Also, kernel wouldn't care if there were duplicate nexthops anyway.
+     *
+     * This means, it's gonna be simple. We sorted the single-hop routes by next-hop,
+     * now just create a plain list of the nexthops (no check for duplciates, etc). */
+
+    ((NMPObject *) obj_new)->ip4_route.n_nexthops = n_nexthops;
+    ((NMPObject *) obj_new)->_ip4_route.extra_nexthops =
+        g_new(NMPlatformIP4RtNextHop, n_nexthops - 1u);
+
+    i = 0;
+    c_list_for_each_entry (track_obj, &track_ecmpid->ecmpid_lst_head, ecmpid_lst) {
+        if (i > 0) {
+            const NMPlatformIP4Route *r  = NMP_OBJECT_CAST_IP4_ROUTE(track_obj->obj);
+            NMPlatformIP4RtNextHop   *nh = (gpointer) &obj_new->_ip4_route.extra_nexthops[i - 1];
+
+            *nh = (NMPlatformIP4RtNextHop){
+                .ifindex = r->ifindex,
+                .gateway = r->gateway,
+                .weight  = r->weight,
+            };
+        }
+        i++;
+    }
+
+out:
+    nm_assert(obj_new);
+    if (nmp_object_equal(track_ecmpid->merged_obj, obj_new))
+        /* the objects are equal but the update was needed, for example if the
+         * routes were removed from kernel but not from our tracking
+         * dictionaries and therefore we tried to register them again. */
+        return TRUE;
+
+    if (track_ecmpid->merged_obj)
+        *out_obj_del = g_steal_pointer(&track_ecmpid->merged_obj);
+    track_ecmpid->merged_obj = g_steal_pointer(&obj_new);
+    return TRUE;
+}
+
+/*****************************************************************************/
+
 NMPNetns *
 nm_netns_get_platform_netns(NMNetns *self)
 {
@@ -93,21 +257,67 @@ nm_netns_get_multi_idx(NMNetns *self)
 
 /*****************************************************************************/
 
-typedef struct {
-    int      ifindex;
-    guint32  signal_pending_obj_type_flags;
-    NML3Cfg *l3cfg;
-    CList    signal_pending_lst;
-} L3CfgData;
+static guint
+_ecmp_routes_by_ecmpid_hash(gconstpointer ptr)
+{
+    const NMPObject *const *p_obj = ptr;
+
+    return nm_platform_ip4_route_hash(NMP_OBJECT_CAST_IP4_ROUTE(*p_obj),
+                                      NM_PLATFORM_IP_ROUTE_CMP_TYPE_ECMP_ID);
+}
+
+static int
+_ecmp_routes_by_ecmpid_equal(gconstpointer ptr_a, gconstpointer ptr_b)
+{
+    const NMPObject *const *p_obj_a = ptr_a;
+    const NMPObject *const *p_obj_b = ptr_b;
+
+    return nm_platform_ip4_route_cmp(NMP_OBJECT_CAST_IP4_ROUTE(*p_obj_a),
+                                     NMP_OBJECT_CAST_IP4_ROUTE(*p_obj_b),
+                                     NM_PLATFORM_IP_ROUTE_CMP_TYPE_ECMP_ID)
+           == 0;
+}
 
 static void
-_l3cfg_data_free(gpointer ptr)
+_ecmp_routes_by_ecmpid_free(gpointer ptr)
 {
-    L3CfgData *l3cfg_data = ptr;
+    EcmpTrackEcmpid *track_ecmpid = ptr;
+
+    c_list_unlink_stale(&track_ecmpid->ecmpid_lst_head);
+    nmp_object_unref(track_ecmpid->representative_obj);
+    nmp_object_unref(track_ecmpid->merged_obj);
+    nm_g_slice_free(track_ecmpid);
+}
 
-    c_list_unlink_stale(&l3cfg_data->signal_pending_lst);
+static void
+_ecmp_routes_by_obj_free(gpointer ptr)
+{
+    EcmpTrackObj *track_obj = ptr;
 
-    nm_g_slice_free(l3cfg_data);
+    c_list_unlink_stale(&track_obj->ifindex_lst);
+    c_list_unlink_stale(&track_obj->ecmpid_lst);
+    nmp_object_unref(track_obj->obj);
+    nm_g_slice_free(track_obj);
+}
+
+/*****************************************************************************/
+
+static NML3Cfg *
+_l3cfg_hashed_to_l3cfg(gpointer ptr)
+{
+    gpointer l3cfg;
+
+    l3cfg = &(((char *) ptr)[-G_STRUCT_OFFSET(NML3Cfg, priv.ifindex)]);
+    nm_assert(NM_IS_L3CFG(l3cfg));
+    return l3cfg;
+}
+
+static void
+_l3cfg_hashed_free(gpointer ptr)
+{
+    NML3Cfg *l3cfg = _l3cfg_hashed_to_l3cfg(ptr);
+
+    c_list_unlink(&l3cfg->internal_netns.signal_pending_lst);
 }
 
 static void
@@ -128,58 +338,48 @@ _l3cfg_weak_notify(gpointer data, GObject *where_the_object_was)
 NML3Cfg *
 nm_netns_l3cfg_get(NMNetns *self, int ifindex)
 {
-    NMNetnsPrivate *priv;
-    L3CfgData      *l3cfg_data;
-
-    g_return_val_if_fail(NM_IS_NETNS(self), NULL);
-    g_return_val_if_fail(ifindex > 0, NULL);
-
-    priv = NM_NETNS_GET_PRIVATE(self);
+    NMNetnsPrivate *priv = NM_NETNS_GET_PRIVATE(self);
+    gpointer        ptr;
 
-    l3cfg_data = g_hash_table_lookup(priv->l3cfgs, &ifindex);
+    nm_assert(ifindex > 0);
 
-    return l3cfg_data ? l3cfg_data->l3cfg : NULL;
+    ptr = g_hash_table_lookup(priv->l3cfgs, &ifindex);
+    return ptr ? _l3cfg_hashed_to_l3cfg(ptr) : NULL;
 }
 
 NML3Cfg *
 nm_netns_l3cfg_acquire(NMNetns *self, int ifindex)
 {
     NMNetnsPrivate *priv;
-    L3CfgData      *l3cfg_data;
+    NML3Cfg        *l3cfg;
 
     g_return_val_if_fail(NM_IS_NETNS(self), NULL);
     g_return_val_if_fail(ifindex > 0, NULL);
 
     priv = NM_NETNS_GET_PRIVATE(self);
 
-    l3cfg_data = g_hash_table_lookup(priv->l3cfgs, &ifindex);
-
-    if (l3cfg_data) {
+    l3cfg = nm_netns_l3cfg_get(self, ifindex);
+    if (l3cfg) {
         nm_log_trace(LOGD_CORE,
                      "l3cfg[" NM_HASH_OBFUSCATE_PTR_FMT ",ifindex=%d] %s",
-                     NM_HASH_OBFUSCATE_PTR(l3cfg_data->l3cfg),
+                     NM_HASH_OBFUSCATE_PTR(l3cfg),
                      ifindex,
                      "referenced");
-        return g_object_ref(l3cfg_data->l3cfg);
+        return g_object_ref(l3cfg);
     }
 
-    l3cfg_data  = g_slice_new(L3CfgData);
-    *l3cfg_data = (L3CfgData){
-        .ifindex            = ifindex,
-        .l3cfg              = nm_l3cfg_new(self, ifindex),
-        .signal_pending_lst = C_LIST_INIT(l3cfg_data->signal_pending_lst),
-    };
+    l3cfg = nm_l3cfg_new(self, ifindex);
 
-    if (!g_hash_table_add(priv->l3cfgs, l3cfg_data))
+    if (!g_hash_table_add(priv->l3cfgs, &l3cfg->priv.ifindex))
         nm_assert_not_reached();
 
     if (NM_UNLIKELY(g_hash_table_size(priv->l3cfgs) == 1))
         g_object_ref(self);
 
-    g_object_weak_ref(G_OBJECT(l3cfg_data->l3cfg), _l3cfg_weak_notify, self);
+    g_object_weak_ref(G_OBJECT(l3cfg), _l3cfg_weak_notify, self);
 
     /* Transfer ownership! We keep only a weak ref. */
-    return l3cfg_data->l3cfg;
+    return l3cfg;
 }
 
 /*****************************************************************************/
@@ -189,7 +389,7 @@ _platform_signal_on_idle_cb(gpointer user_data)
 {
     gs_unref_object NMNetns *self = g_object_ref(NM_NETNS(user_data));
     NMNetnsPrivate          *priv = NM_NETNS_GET_PRIVATE(self);
-    L3CfgData               *l3cfg_data;
+    NML3Cfg                 *l3cfg;
     CList                    work_list;
 
     nm_clear_g_source_inst(&priv->signal_pending_idle_source);
@@ -205,12 +405,12 @@ _platform_signal_on_idle_cb(gpointer user_data)
     c_list_init(&work_list);
     c_list_splice(&work_list, &priv->l3cfg_signal_pending_lst_head);
 
-    while ((l3cfg_data = c_list_first_entry(&work_list, L3CfgData, signal_pending_lst))) {
-        nm_assert(NM_IS_L3CFG(l3cfg_data->l3cfg));
-        c_list_unlink(&l3cfg_data->signal_pending_lst);
+    while ((l3cfg = c_list_first_entry(&work_list, NML3Cfg, internal_netns.signal_pending_lst))) {
+        nm_assert(NM_IS_L3CFG(l3cfg));
+        c_list_unlink(&l3cfg->internal_netns.signal_pending_lst);
         _nm_l3cfg_notify_platform_change_on_idle(
-            l3cfg_data->l3cfg,
-            nm_steal_int(&l3cfg_data->signal_pending_obj_type_flags));
+            l3cfg,
+            nm_steal_int(&l3cfg->internal_netns.signal_pending_obj_type_flags));
     }
 
     return G_SOURCE_CONTINUE;
@@ -228,24 +428,28 @@ _platform_signal_cb(NMPlatform   *platform,
     NMNetnsPrivate                  *priv        = NM_NETNS_GET_PRIVATE(self);
     const NMPObjectType              obj_type    = obj_type_i;
     const NMPlatformSignalChangeType change_type = change_type_i;
-    L3CfgData                       *l3cfg_data;
+    NML3Cfg                         *l3cfg;
 
-    l3cfg_data = g_hash_table_lookup(priv->l3cfgs, &ifindex);
-    if (!l3cfg_data)
+    if (ifindex <= 0) {
+        /* platform signal callback could be triggered by nodev routes, skip them */
+        return;
+    }
+
+    l3cfg = nm_netns_l3cfg_get(self, ifindex);
+    if (!l3cfg)
         return;
 
-    l3cfg_data->signal_pending_obj_type_flags |= nmp_object_type_to_flags(obj_type);
+    l3cfg->internal_netns.signal_pending_obj_type_flags |= nmp_object_type_to_flags(obj_type);
 
-    if (c_list_is_empty(&l3cfg_data->signal_pending_lst)) {
-        c_list_link_tail(&priv->l3cfg_signal_pending_lst_head, &l3cfg_data->signal_pending_lst);
+    if (c_list_is_empty(&l3cfg->internal_netns.signal_pending_lst)) {
+        c_list_link_tail(&priv->l3cfg_signal_pending_lst_head,
+                         &l3cfg->internal_netns.signal_pending_lst);
         if (!priv->signal_pending_idle_source)
             priv->signal_pending_idle_source =
                 nm_g_idle_add_source(_platform_signal_on_idle_cb, self);
     }
 
-    _nm_l3cfg_notify_platform_change(l3cfg_data->l3cfg,
-                                     change_type,
-                                     NMP_OBJECT_UP_CAST(platform_object));
+    _nm_l3cfg_notify_platform_change(l3cfg, change_type, NMP_OBJECT_UP_CAST(platform_object));
 }
 
 /*****************************************************************************/
@@ -257,7 +461,7 @@ nm_netns_shared_ip_reserve(NMNetns *self)
     NMNetnsSharedIPHandle *handle;
     const in_addr_t        addr_start = ntohl(0x0a2a0001u); /* 10.42.0.1 */
     in_addr_t              addr;
-    char                   sbuf_addr[NM_UTILS_INET_ADDRSTRLEN];
+    char                   sbuf_addr[NM_INET_ADDRSTRLEN];
 
     /* Find an unused address in the 10.42.x.x range */
 
@@ -287,10 +491,10 @@ nm_netns_shared_ip_reserve(NMNetns *self)
             if (count > 0xFFu) {
                 if (handle->_ref_count == 1) {
                     _LOGE("shared-ip4: ran out of shared IP addresses. Reuse %s/24",
-                          _nm_utils_inet4_ntop(handle->addr, sbuf_addr));
+                          nm_inet4_ntop(handle->addr, sbuf_addr));
                 } else {
                     _LOGD("shared-ip4: reserved IP address range %s/24 (duplicate)",
-                          _nm_utils_inet4_ntop(handle->addr, sbuf_addr));
+                          nm_inet4_ntop(handle->addr, sbuf_addr));
                 }
                 handle->_ref_count++;
                 return handle;
@@ -307,8 +511,7 @@ nm_netns_shared_ip_reserve(NMNetns *self)
 
     g_hash_table_add(priv->shared_ips, handle);
 
-    _LOGD("shared-ip4: reserved IP address range %s/24",
-          _nm_utils_inet4_ntop(handle->addr, sbuf_addr));
+    _LOGD("shared-ip4: reserved IP address range %s/24", nm_inet4_ntop(handle->addr, sbuf_addr));
     return handle;
 }
 
@@ -317,7 +520,7 @@ nm_netns_shared_ip_release(NMNetnsSharedIPHandle *handle)
 {
     NMNetns        *self;
     NMNetnsPrivate *priv;
-    char            sbuf_addr[NM_UTILS_INET_ADDRSTRLEN];
+    char            sbuf_addr[NM_INET_ADDRSTRLEN];
 
     g_return_if_fail(handle);
 
@@ -334,7 +537,7 @@ nm_netns_shared_ip_release(NMNetnsSharedIPHandle *handle)
         nm_assert(handle->addr == ntohl(0x0A2AFF01u)); /* 10.42.255.1 */
         handle->_ref_count--;
         _LOGD("shared-ip4: release IP address range %s/24 (%d more references held)",
-              _nm_utils_inet4_ntop(handle->addr, sbuf_addr),
+              nm_inet4_ntop(handle->addr, sbuf_addr),
               handle->_ref_count);
         return;
     }
@@ -347,8 +550,7 @@ nm_netns_shared_ip_release(NMNetnsSharedIPHandle *handle)
         g_object_unref(self);
     }
 
-    _LOGD("shared-ip4: release IP address range %s/24",
-          _nm_utils_inet4_ntop(handle->addr, sbuf_addr));
+    _LOGD("shared-ip4: release IP address range %s/24", nm_inet4_ntop(handle->addr, sbuf_addr));
 
     handle->_self = NULL;
     nm_g_slice_free(handle);
@@ -356,6 +558,270 @@ nm_netns_shared_ip_release(NMNetnsSharedIPHandle *handle)
 
 /*****************************************************************************/
 
+void
+nm_netns_ip_route_ecmp_register(NMNetns *self, NML3Cfg *l3cfg, const NMPObject *obj)
+{
+    NMNetnsPrivate           *priv;
+    EcmpTrackObj             *track_obj;
+    const NMPlatformIP4Route *route;
+    char                      sbuf[NM_UTILS_TO_STRING_BUFFER_SIZE];
+
+    nm_assert_l3cfg(self, l3cfg);
+
+    route = NMP_OBJECT_CAST_IP4_ROUTE(obj);
+
+    nm_assert(route->ifindex > 0);
+    nm_assert(route->ifindex == nm_l3cfg_get_ifindex(l3cfg));
+    nm_assert(route->n_nexthops <= 1);
+
+    priv = NM_NETNS_GET_PRIVATE(self);
+
+    track_obj = g_hash_table_lookup(priv->ecmp_track_by_obj, &obj);
+
+    if (NM_MORE_ASSERTS > 10) {
+        EcmpTrackObj *track_obj2;
+        gboolean      found = FALSE;
+
+        c_list_for_each_entry (track_obj2,
+                               &l3cfg->internal_netns.ecmp_track_ifindex_lst_head,
+                               ifindex_lst) {
+            if (track_obj2->obj == obj) {
+                found = TRUE;
+                break;
+            }
+        }
+
+        nm_assert((!!track_obj) == found);
+    }
+
+    if (!track_obj) {
+        EcmpTrackEcmpid *track_ecmpid;
+
+        track_ecmpid = g_hash_table_lookup(priv->ecmp_track_by_ecmpid, &obj);
+        if (!track_ecmpid) {
+            track_ecmpid  = g_slice_new(EcmpTrackEcmpid);
+            *track_ecmpid = (EcmpTrackEcmpid){
+                .representative_obj = nmp_object_ref(obj),
+                .merged_obj         = NULL,
+                .ecmpid_lst_head    = C_LIST_INIT(track_ecmpid->ecmpid_lst_head),
+                .needs_update       = TRUE,
+            };
+            g_hash_table_add(priv->ecmp_track_by_ecmpid, track_ecmpid);
+        } else
+            track_ecmpid->needs_update = TRUE;
+
+        track_obj  = g_slice_new(EcmpTrackObj);
+        *track_obj = (EcmpTrackObj){
+            .obj                 = nmp_object_ref(obj),
+            .l3cfg               = l3cfg,
+            .parent_track_ecmpid = track_ecmpid,
+            .dirty               = FALSE,
+            .is_new              = TRUE,
+            .is_ready            = FALSE,
+        };
+
+        g_hash_table_add(priv->ecmp_track_by_obj, track_obj);
+        c_list_link_tail(&l3cfg->internal_netns.ecmp_track_ifindex_lst_head,
+                         &track_obj->ifindex_lst);
+        c_list_link_tail(&track_ecmpid->ecmpid_lst_head, &track_obj->ecmpid_lst);
+
+        _LOGT(
+            "ecmp-route: track %s",
+            nmp_object_to_string(track_obj->obj, NMP_OBJECT_TO_STRING_PUBLIC, sbuf, sizeof(sbuf)));
+    } else {
+        track_obj->dirty                             = FALSE;
+        track_obj->parent_track_ecmpid->needs_update = TRUE;
+    }
+}
+
+void
+nm_netns_ip_route_ecmp_commit(NMNetns    *self,
+                              NML3Cfg    *l3cfg,
+                              GPtrArray **out_singlehop_routes,
+                              gboolean    is_reapply)
+{
+    NMNetnsPrivate  *priv = NM_NETNS_GET_PRIVATE(self);
+    EcmpTrackObj    *track_obj;
+    EcmpTrackObj    *track_obj_safe;
+    EcmpTrackEcmpid *track_ecmpid;
+    const NMPObject *route_obj;
+    char             sbuf[NM_UTILS_TO_STRING_BUFFER_SIZE];
+    gboolean         already_notified = FALSE;
+
+    nm_assert_l3cfg(self, l3cfg);
+
+    _LOGT("ecmp-route: committing IPv4 ECMP routes");
+
+    /* First, delete all dirty entries, and mark the survivors as dirty, so that on the
+     * next update they must be touched again. */
+    c_list_for_each_entry_safe (track_obj,
+                                track_obj_safe,
+                                &l3cfg->internal_netns.ecmp_track_ifindex_lst_head,
+                                ifindex_lst) {
+        track_ecmpid                  = track_obj->parent_track_ecmpid;
+        track_ecmpid->already_visited = FALSE;
+
+        nm_assert(g_hash_table_lookup(priv->ecmp_track_by_ecmpid, track_ecmpid) == track_ecmpid);
+        nm_assert(g_hash_table_lookup(priv->ecmp_track_by_obj, track_obj) == track_obj);
+        nm_assert(c_list_contains(&track_ecmpid->ecmpid_lst_head, &track_obj->ecmpid_lst));
+        nm_assert(track_obj->l3cfg == l3cfg);
+
+        if (!track_obj->dirty) {
+            /* This one is still in used. Keep it, but mark dirty, so that on the
+             * next update cycle, it needs to be touched again or will be deleted. */
+            track_obj->dirty = TRUE;
+            if (is_reapply) {
+                track_obj->is_new   = TRUE;
+                track_obj->is_ready = FALSE;
+            }
+            if (track_obj->is_new) {
+                const NMPlatformIP4Route *route =
+                    NMP_OBJECT_CAST_IP4_ROUTE(track_ecmpid->merged_obj);
+
+                /* This is a new route entry that was just added. Upon first
+                 * addition, the route is not yet ready for configuration,
+                 * because we need to make sure that the gateway is reachable
+                 * via an onlink route. The calling l3cfg will configure that
+                 * route, but only after returning from this function.  So we
+                 * need to go through one more commit.
+                 *
+                 * We also need to make sure that we are called back right
+                 * after l3cfg configured that route. We achieve that by
+                 * scheduling another idle commit on "l3cfg". */
+                track_obj->is_new = FALSE;
+                if (route
+                    && (route->gateway == 0
+                        || NM_FLAGS_HAS(route->r_rtm_flags, (unsigned) RTNH_F_ONLINK))) {
+                    /* This route is onlink. We don't need to configure an onlink route
+                     * to the gateway, and the route is immediately ready for configuration. */
+                    track_obj->is_ready = TRUE;
+                } else if (c_list_length_is(&track_ecmpid->ecmpid_lst_head, 1)) {
+                    /* This route has no merge partner and ends up being a
+                     * single hop route. It will be returned and configured by
+                     * the calling "l3cfg".
+                     *
+                     * Unlike for multi-hop routes, we don't need to be called
+                     * again after the onlink route was added. We are done, and
+                     * don't need to schedule an idle commit. */
+                    track_obj->is_ready = TRUE;
+                } else {
+                    /* This is a new route which has a gateway. We need for the "l3cfg"
+                     * to first configure the onlink route. It's not yet ready for configuration.
+                     *
+                     * Instead, schedule an idle commit to make sure we get called back
+                     * again, and then (upon seeing the entry the second time) the onlink
+                     * route is already configured and we will be ready. */
+                    if (!already_notified) {
+                        /* Some micro optimization with already_notified to avoid calling
+                         * schedule unnecessarily. */
+                        already_notified = TRUE;
+                        nm_l3cfg_commit_on_idle_schedule(l3cfg, NM_L3_CFG_COMMIT_TYPE_AUTO);
+                    }
+                }
+            } else {
+                /* We see this entry the second time (or more) so it's ready. */
+                track_obj->is_ready = TRUE;
+            }
+            continue;
+        }
+
+        /* This entry can be dropped. */
+        if (!g_hash_table_remove(priv->ecmp_track_by_obj, track_obj))
+            nm_assert_not_reached();
+
+        if (c_list_is_empty(&track_ecmpid->ecmpid_lst_head)) {
+            if (track_ecmpid->merged_obj) {
+                if (NMP_OBJECT_CAST_IP4_ROUTE(track_ecmpid->merged_obj)->n_nexthops > 1)
+                    nm_platform_object_delete(priv->platform, track_ecmpid->merged_obj);
+            }
+            g_hash_table_remove(priv->ecmp_track_by_ecmpid, track_ecmpid);
+
+            continue;
+        }
+
+        /* We need to update the representative obj. */
+        nmp_object_ref_set(
+            &track_ecmpid->representative_obj,
+            c_list_first_entry(&track_ecmpid->ecmpid_lst_head, EcmpTrackObj, ecmpid_lst)->obj);
+        track_ecmpid->needs_update = TRUE;
+    }
+
+    /* Now, we need to iterate again over all objects, and regenerate the merged_obj. */
+    c_list_for_each_entry (track_obj,
+                           &l3cfg->internal_netns.ecmp_track_ifindex_lst_head,
+                           ifindex_lst) {
+        const NMPlatformIP4Route       *route;
+        EcmpTrackObj                   *track_obj2;
+        nm_auto_nmpobj const NMPObject *obj_del = NULL;
+        gboolean                        changed;
+        gboolean                        all_is_ready;
+
+        track_ecmpid = track_obj->parent_track_ecmpid;
+        if (track_ecmpid->already_visited) {
+            /* We already visited this ecmpid in the same loop. We can skip, otherwise
+             * we might add the same route twice. */
+            continue;
+        }
+        track_ecmpid->already_visited = TRUE;
+
+        all_is_ready = TRUE;
+        c_list_for_each_entry (track_obj2, &track_ecmpid->ecmpid_lst_head, ecmpid_lst) {
+            if (!track_obj2->is_ready) {
+                all_is_ready = FALSE;
+                break;
+            }
+        }
+        if (!all_is_ready) {
+            /* Here we might have a merged_obj already which can have the wrong
+             * setting e.g the wrong nexthops. We leave them for the moment and
+             * then we reconfigure it when this entry is ready. */
+            continue;
+        }
+
+        changed = _ecmp_track_init_merged_obj(track_obj->parent_track_ecmpid, &obj_del);
+
+        nm_assert(!obj_del || changed);
+
+        route_obj = track_ecmpid->merged_obj;
+        route     = NMP_OBJECT_CAST_IP4_ROUTE(route_obj);
+
+        if (obj_del) {
+            if (NMP_OBJECT_CAST_IP4_ROUTE(obj_del)->n_nexthops > 1)
+                nm_platform_object_delete(priv->platform, obj_del);
+            else if (track_obj->l3cfg != l3cfg)
+                nm_l3cfg_commit_on_idle_schedule(track_obj->l3cfg, NM_L3_CFG_COMMIT_TYPE_AUTO);
+        }
+
+        if (route->n_nexthops <= 1) {
+            /* This is a single hop route. Return it to the caller. */
+            if (!*out_singlehop_routes) {
+                /* Note that the returned array does not own a reference. This
+                 * function has only one caller, and for that caller, it's just
+                 * fine that the result is not additionally kept alive. */
+                *out_singlehop_routes =
+                    g_ptr_array_new_with_free_func((GDestroyNotify) nmp_object_unref);
+            }
+            g_ptr_array_add(*out_singlehop_routes, (gpointer) nmp_object_ref(route_obj));
+            if (changed) {
+                _LOGT("ecmp-route: single-hop %s",
+                      nmp_object_to_string(route_obj,
+                                           NMP_OBJECT_TO_STRING_PUBLIC,
+                                           sbuf,
+                                           sizeof(sbuf)));
+            }
+            continue;
+        }
+
+        if (changed || is_reapply) {
+            _LOGT("ecmp-route: multi-hop %s",
+                  nmp_object_to_string(route_obj, NMP_OBJECT_TO_STRING_PUBLIC, sbuf, sizeof(sbuf)));
+            nm_platform_ip_route_add(priv->platform, NMP_NLM_FLAG_APPEND, route_obj);
+        }
+    }
+}
+
+/*****************************************************************************/
+
 static void
 set_property(GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec)
 {
@@ -385,6 +851,14 @@ nm_netns_init(NMNetns *self)
 
     priv->_self_signal_user_data = self;
     c_list_init(&priv->l3cfg_signal_pending_lst_head);
+
+    G_STATIC_ASSERT_EXPR(G_STRUCT_OFFSET(EcmpTrackObj, obj) == 0);
+    priv->ecmp_track_by_obj =
+        g_hash_table_new_full(nm_pdirect_hash, nm_pdirect_equal, _ecmp_routes_by_obj_free, NULL);
+    priv->ecmp_track_by_ecmpid = g_hash_table_new_full(_ecmp_routes_by_ecmpid_hash,
+                                                       _ecmp_routes_by_ecmpid_equal,
+                                                       _ecmp_routes_by_ecmpid_free,
+                                                       NULL);
 }
 
 static void
@@ -396,7 +870,7 @@ constructed(GObject *object)
     if (!priv->platform)
         g_return_if_reached();
 
-    priv->l3cfgs = g_hash_table_new_full(nm_pint_hash, nm_pint_equal, _l3cfg_data_free, NULL);
+    priv->l3cfgs = g_hash_table_new_full(nm_pint_hash, nm_pint_equal, _l3cfg_hashed_free, NULL);
 
     priv->platform_netns = nm_platform_netns_get(priv->platform);
 
@@ -464,6 +938,9 @@ dispose(GObject *object)
     nm_assert(c_list_is_empty(&priv->l3cfg_signal_pending_lst_head));
     nm_assert(!priv->shared_ips);
 
+    nm_clear_pointer(&priv->ecmp_track_by_obj, g_hash_table_destroy);
+    nm_clear_pointer(&priv->ecmp_track_by_ecmpid, g_hash_table_destroy);
+
     nm_clear_g_source_inst(&priv->signal_pending_idle_source);
 
     if (priv->platform)