about summary refs log tree commit diff
path: root/src/core/dhcp/nm-dhcp-client.c
diff options
context:
space:
mode:
authorJeremy Bicha <jeremy.bicha@canonical.com>2022-08-18 08:31:29 -0400
committerJeremy Bicha <jeremy.bicha@canonical.com>2022-08-18 08:31:29 -0400
commitb0887dd4d035acc0d84aa859748d36891548eaaf (patch)
treec4dc0dae50954f3c8fb600aa9e7cfaabeb80deb0 /src/core/dhcp/nm-dhcp-client.c
parent1a62dcfdc0470be37743914da69fe0b0677c6bfb (diff)
parent4741f1a52215c7ba466140912084d6906185bef3 (diff)
Merge branch 'debian/master' into ubuntu/master
Diffstat (limited to 'src/core/dhcp/nm-dhcp-client.c')
-rw-r--r--src/core/dhcp/nm-dhcp-client.c1445
1 files changed, 1047 insertions, 398 deletions
diff --git a/src/core/dhcp/nm-dhcp-client.c b/src/core/dhcp/nm-dhcp-client.c
index 2bfd7e01..77cfeecf 100644
--- a/src/core/dhcp/nm-dhcp-client.c
+++ b/src/core/dhcp/nm-dhcp-client.c
@@ -32,28 +32,108 @@
 
 /*****************************************************************************/
 
-enum { SIGNAL_NOTIFY, LAST_SIGNAL };
+/* This is how long we do ACD for each entry and reject new offers for
+ * the same address. Note that the maximum ACD timeout is limited to 30 seconds
+ * (NM_ACD_TIMEOUT_MAX_MSEC).
+ **/
+#define ACD_REGLIST_GRACE_PERIOD_MSEC 300000u
+
+G_STATIC_ASSERT(ACD_REGLIST_GRACE_PERIOD_MSEC > (NM_ACD_TIMEOUT_MAX_MSEC + 1000));
+
+#define ACD_REGLIST_MAX_ENTRIES 30
+
+/* To do ACD for an address (new lease), we will register a NML3ConfigData
+ * with l3cfg. After ACD completes, we still continue having NML3Cfg
+ * watch that address, for ACD_REGLIST_GRACE_PERIOD_MSEC. The reasons are:
+ *
+ * - the caller is supposed to actually configure the address right after
+ *   ACD passed. We would not want to drop the ACD state before the caller
+ *   got a chance to do that.
+ * - when ACD fails, we decline the address and expect the DHCP client
+ *   to present a new lease. We may want to outright reject the address,
+ *   if ACD is bad. Thus, we want to keep running ACD for the address a bit
+ *   longer, so that future requests for the same address can be rejected.
+ *
+ * This data structure is used for tracking the registered ACD address.
+ */
+typedef struct {
+    const NML3ConfigData *l3cd;
+    gint64                expiry_msec;
+    in_addr_t             addr;
+} AcdRegListData;
+
+/*****************************************************************************/
+
+enum {
+    SIGNAL_NOTIFY,
+    LAST_SIGNAL,
+};
 
 static guint signals[LAST_SIGNAL] = {0};
 
 NM_GOBJECT_PROPERTIES_DEFINE(NMDhcpClient, PROP_CONFIG, );
 
 typedef struct _NMDhcpClientPrivate {
-    NMDhcpClientConfig    config;
-    const NML3ConfigData *l3cd;
-    GSource              *no_lease_timeout_source;
-    GSource              *ipv6_lladdr_timeout_source;
-    GBytes               *effective_client_id;
-    pid_t                 pid;
-    guint                 watch_id;
-    NMDhcpState           state;
-    bool                  iaid_explicit : 1;
-    bool                  is_stopped : 1;
+    NMDhcpClientConfig config;
+
+    /* This is the "next" data. That is, the one what was received last via
+     * _nm_dhcp_client_notify(), but which is currently pending on ACD. */
+    const NML3ConfigData *l3cd_next;
+
+    /* This is the currently exposed data. It passed ACD (or no ACD was performed),
+     * and is set from l3cd_next. */
+    const NML3ConfigData *l3cd_curr;
+
+    GSource *no_lease_timeout_source;
+    GSource *watch_source;
+    GBytes  *effective_client_id;
+
+    union {
+        struct {
+            struct {
+                NML3CfgCommitTypeHandle *l3cfg_commit_handle;
+                GSource                 *done_source;
+
+                /* When we do ACD for a l3cd lease, we will keep running ACD for
+                 * the grace period ACD_REGLIST_GRACE_PERIOD_MSEC, even if we already
+                 * determined the state. There are two reasons for that:
+                 *
+                 * - after ACD completes we notify the lease to the user, who is supposed
+                 *   to configure the address in NML3Cfg. If we were already removing the
+                 *   ACD state from NML3Cfg, ACD might need to start over. Instead, when
+                 *   the caller tries to configure the address, ACD state is already good.
+                 *
+                 * - if we decline on ACD offer, we may want to keep running and
+                 *   select other offers. Offers for which we just failed ACD (within
+                 *   ACD_REGLIST_GRACE_PERIOD_MSEC) are rejected. See _nm_dhcp_client_accept_offer().
+                 *   For that, we keep monitoring the ACD state for up to ACD_REGLIST_MAX_ENTRIES
+                 *   addresses, to not restart and select the same lease twice in a row.
+                 */
+                GArray  *reglist;
+                GSource *reglist_timeout_source;
+
+                in_addr_t    addr;
+                NMOptionBool state;
+            } acd;
+            struct {
+                GDBusMethodInvocation *invocation;
+            } bound;
+        } v4;
+        struct {
+            GSource *lladdr_timeout_source;
+            GSource *dad_timeout_source;
+        } v6;
+    };
+
     struct {
         gulong id;
         bool   wait_dhcp_commit : 1;
+        bool   wait_ipv6_dad : 1;
         bool   wait_ll_address : 1;
     } l3cfg_notify;
+
+    pid_t pid;
+    bool  is_stopped : 1;
 } NMDhcpClientPrivate;
 
 G_DEFINE_ABSTRACT_TYPE(NMDhcpClient, nm_dhcp_client, G_TYPE_OBJECT)
@@ -62,9 +142,22 @@ G_DEFINE_ABSTRACT_TYPE(NMDhcpClient, nm_dhcp_client, G_TYPE_OBJECT)
 
 /*****************************************************************************/
 
+#define L3CD_ACD_TAG(priv) (&(priv)->v4.acd.addr)
+
+static gboolean _dhcp_client_accept(NMDhcpClient *self, const NML3ConfigData *l3cd, GError **error);
+
+static gboolean _dhcp_client_decline(NMDhcpClient         *self,
+                                     const NML3ConfigData *l3cd,
+                                     const char           *error_message,
+                                     GError              **error);
+
 static void
 l3_cfg_notify_cb(NML3Cfg *l3cfg, const NML3ConfigNotifyData *notify_data, NMDhcpClient *self);
 
+static void _acd_reglist_timeout_reschedule(NMDhcpClient *self, gint64 now_msec);
+
+static void _acd_reglist_data_remove(NMDhcpClient *self, guint idx, gboolean do_log);
+
 /*****************************************************************************/
 
 /* we use pid=-1 for invalid PIDs. Ensure that pid_t can hold negative values. */
@@ -72,47 +165,86 @@ G_STATIC_ASSERT(!(((pid_t) -1) > 0));
 
 /*****************************************************************************/
 
-static void
-_emit_notify(NMDhcpClient *self, const NMDhcpClientNotifyData *notify_data)
+NM_UTILS_LOOKUP_STR_DEFINE(nm_dhcp_client_event_type_to_string,
+                           NMDhcpClientEventType,
+                           NM_UTILS_LOOKUP_DEFAULT_NM_ASSERT(NULL),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_BOUND, "bound"),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_EXPIRE, "expire"),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED, "extended"),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_FAIL, "fail"),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_TERMINATED,
+                                                    "terminated"),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT, "timeout"),
+                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_CLIENT_EVENT_TYPE_UNSPECIFIED,
+                                                    "unspecified"), );
+
+/*****************************************************************************/
+
+int
+nm_dhcp_client_get_addr_family(NMDhcpClient *self)
 {
-    g_signal_emit(G_OBJECT(self), signals[SIGNAL_NOTIFY], 0, notify_data);
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+
+    return priv->config.addr_family;
 }
 
-/*****************************************************************************/
+const char *
+nm_dhcp_client_get_iface(NMDhcpClient *self)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-static void
-connect_l3cfg_notify(NMDhcpClient *self)
+    return priv->config.iface;
+}
+
+NMDedupMultiIndex *
+nm_dhcp_client_get_multi_idx(NMDhcpClient *self)
 {
     NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-    gboolean             do_connect;
 
-    do_connect = priv->l3cfg_notify.wait_dhcp_commit | priv->l3cfg_notify.wait_ll_address;
+    return nm_l3cfg_get_multi_idx(priv->config.l3cfg);
+}
 
-    if (!do_connect) {
-        nm_clear_g_signal_handler(priv->config.l3cfg, &priv->l3cfg_notify.id);
-        return;
-    }
+int
+nm_dhcp_client_get_ifindex(NMDhcpClient *self)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    if (priv->l3cfg_notify.id == 0) {
-        priv->l3cfg_notify.id = g_signal_connect(priv->config.l3cfg,
-                                                 NM_L3CFG_SIGNAL_NOTIFY,
-                                                 G_CALLBACK(l3_cfg_notify_cb),
-                                                 self);
-    }
+    return nm_l3cfg_get_ifindex(priv->config.l3cfg);
 }
 
-pid_t
-nm_dhcp_client_get_pid(NMDhcpClient *self)
+const NMDhcpClientConfig *
+nm_dhcp_client_get_config(NMDhcpClient *self)
 {
-    g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), -1);
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    return NM_DHCP_CLIENT_GET_PRIVATE(self)->pid;
+    return &priv->config;
 }
 
+GBytes *
+nm_dhcp_client_get_effective_client_id(NMDhcpClient *self)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+
+    return priv->effective_client_id;
+}
+
+NML3ConfigData *
+nm_dhcp_client_create_l3cd(NMDhcpClient *self)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+
+    return nm_l3_config_data_new(nm_l3cfg_get_multi_idx(priv->config.l3cfg),
+                                 nm_l3cfg_get_ifindex(priv->config.l3cfg),
+                                 NM_IP_CONFIG_SOURCE_DHCP);
+}
+
+/*****************************************************************************/
+
 void
 nm_dhcp_client_set_effective_client_id(NMDhcpClient *self, GBytes *client_id)
 {
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    NMDhcpClientPrivate *priv    = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    gs_free char        *tmp_str = NULL;
 
     g_return_if_fail(NM_IS_DHCP_CLIENT(self));
     g_return_if_fail(!client_id || g_bytes_get_size(client_id) >= 2);
@@ -123,72 +255,64 @@ nm_dhcp_client_set_effective_client_id(NMDhcpClient *self, GBytes *client_id)
         return;
 
     g_bytes_unref(priv->effective_client_id);
-    priv->effective_client_id = client_id;
-    if (client_id)
-        g_bytes_ref(client_id);
+    priv->effective_client_id = nm_g_bytes_ref(client_id);
 
-    {
-        gs_free char *s = NULL;
+    _LOGT("%s: set %s",
+          priv->config.addr_family == AF_INET6 ? "duid" : "client-id",
+          priv->effective_client_id
+              ? (tmp_str = nm_dhcp_utils_duid_to_string(priv->effective_client_id))
+              : "default");
+}
 
-        _LOGT("%s: set %s",
-              priv->config.addr_family == AF_INET6 ? "duid" : "client-id",
-              priv->effective_client_id
-                  ? (s = nm_dhcp_utils_duid_to_string(priv->effective_client_id))
-                  : "default");
-    }
+/*****************************************************************************/
+
+static void
+_emit_notify(NMDhcpClient *self, const NMDhcpClientNotifyData *notify_data)
+{
+    g_signal_emit(G_OBJECT(self), signals[SIGNAL_NOTIFY], 0, notify_data);
 }
 
 /*****************************************************************************/
 
-NM_UTILS_LOOKUP_STR_DEFINE(nm_dhcp_state_to_string,
-                           NMDhcpState,
-                           NM_UTILS_LOOKUP_DEFAULT(NULL),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_BOUND, "bound"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_DONE, "done"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_EXPIRE, "expire"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_EXTENDED, "extended"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_FAIL, "fail"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_NOOP, "noop"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_TERMINATED, "terminated"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_TIMEOUT, "timeout"),
-                           NM_UTILS_LOOKUP_STR_ITEM(NM_DHCP_STATE_UNKNOWN, "unknown"), );
-
-static NMDhcpState
-reason_to_state(NMDhcpClient *self, const char *iface, const char *reason)
+static void
+l3_cfg_notify_check_connected(NMDhcpClient *self)
 {
-    if (g_ascii_strcasecmp(reason, "bound") == 0 || g_ascii_strcasecmp(reason, "bound6") == 0
-        || g_ascii_strcasecmp(reason, "static") == 0)
-        return NM_DHCP_STATE_BOUND;
-    else if (g_ascii_strcasecmp(reason, "renew") == 0 || g_ascii_strcasecmp(reason, "renew6") == 0
-             || g_ascii_strcasecmp(reason, "reboot") == 0
-             || g_ascii_strcasecmp(reason, "rebind") == 0
-             || g_ascii_strcasecmp(reason, "rebind6") == 0)
-        return NM_DHCP_STATE_EXTENDED;
-    else if (g_ascii_strcasecmp(reason, "timeout") == 0)
-        return NM_DHCP_STATE_TIMEOUT;
-    else if (g_ascii_strcasecmp(reason, "nak") == 0 || g_ascii_strcasecmp(reason, "expire") == 0
-             || g_ascii_strcasecmp(reason, "expire6") == 0)
-        return NM_DHCP_STATE_EXPIRE;
-    else if (g_ascii_strcasecmp(reason, "end") == 0 || g_ascii_strcasecmp(reason, "stop") == 0
-             || g_ascii_strcasecmp(reason, "stopped") == 0)
-        return NM_DHCP_STATE_DONE;
-    else if (g_ascii_strcasecmp(reason, "fail") == 0 || g_ascii_strcasecmp(reason, "abend") == 0)
-        return NM_DHCP_STATE_FAIL;
-    else if (g_ascii_strcasecmp(reason, "preinit") == 0)
-        return NM_DHCP_STATE_NOOP;
-
-    _LOGD("unmapped DHCP state '%s'", reason);
-    return NM_DHCP_STATE_UNKNOWN;
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    gboolean             do_connect;
+
+    do_connect = priv->l3cfg_notify.wait_dhcp_commit | priv->l3cfg_notify.wait_ll_address
+                 | priv->l3cfg_notify.wait_ipv6_dad
+                 | (NM_IS_IPv4(priv->config.addr_family) && priv->v4.acd.l3cfg_commit_handle);
+
+    if (!do_connect) {
+        nm_clear_g_signal_handler(priv->config.l3cfg, &priv->l3cfg_notify.id);
+        return;
+    }
+
+    if (priv->l3cfg_notify.id == 0) {
+        priv->l3cfg_notify.id = g_signal_connect(priv->config.l3cfg,
+                                                 NM_L3CFG_SIGNAL_NOTIFY,
+                                                 G_CALLBACK(l3_cfg_notify_cb),
+                                                 self);
+    }
 }
 
 /*****************************************************************************/
 
+pid_t
+nm_dhcp_client_get_pid(NMDhcpClient *self)
+{
+    g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), -1);
+
+    return NM_DHCP_CLIENT_GET_PRIVATE(self)->pid;
+}
+
 static void
 watch_cleanup(NMDhcpClient *self)
 {
     NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    nm_clear_g_source(&priv->watch_id);
+    nm_clear_g_source_inst(&priv->watch_source);
 }
 
 void
@@ -225,6 +349,8 @@ stop(NMDhcpClient *self, gboolean release)
     priv->pid = -1;
 }
 
+/*****************************************************************************/
+
 static gboolean
 _no_lease_timeout(gpointer user_data)
 {
@@ -237,19 +363,12 @@ _no_lease_timeout(gpointer user_data)
                  &((NMDhcpClientNotifyData){
                      .notify_type = NM_DHCP_CLIENT_NOTIFY_TYPE_NO_LEASE_TIMEOUT,
                  }));
-    return G_SOURCE_CONTINUE;
-}
 
-const NMDhcpClientConfig *
-nm_dhcp_client_get_config(NMDhcpClient *self)
-{
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    return &priv->config;
+    return G_SOURCE_CONTINUE;
 }
 
 static void
-schedule_no_lease_timeout(NMDhcpClient *self)
+_no_lease_timeout_schedule(NMDhcpClient *self)
 {
     NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
@@ -267,56 +386,417 @@ schedule_no_lease_timeout(NMDhcpClient *self)
     }
 }
 
-void
-nm_dhcp_client_set_state(NMDhcpClient *self, NMDhcpState new_state, const NML3ConfigData *l3cd)
+/*****************************************************************************/
+
+static void
+_acd_state_reset(NMDhcpClient *self, gboolean forget_addr, gboolean forget_reglist)
 {
-    NMDhcpClientPrivate                     *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-    GHashTable                              *options;
-    const int                                IS_IPv4     = NM_IS_IPv4(priv->config.addr_family);
-    nm_auto_unref_l3cd const NML3ConfigData *l3cd_merged = NULL;
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    g_return_if_fail(NM_IS_DHCP_CLIENT(self));
+    if (!NM_IS_IPv4(priv->config.addr_family))
+        return;
 
-    if (NM_IN_SET(new_state, NM_DHCP_STATE_BOUND, NM_DHCP_STATE_EXTENDED)) {
-        g_return_if_fail(NM_IS_L3_CONFIG_DATA(l3cd));
-        g_return_if_fail(nm_l3_config_data_get_dhcp_lease(l3cd, priv->config.addr_family));
+    if (priv->v4.acd.addr != INADDR_ANY) {
+        nm_l3cfg_commit_type_clear(priv->config.l3cfg, &priv->v4.acd.l3cfg_commit_handle);
+        l3_cfg_notify_check_connected(self);
+        nm_clear_g_source_inst(&priv->v4.acd.done_source);
+        if (forget_addr) {
+            priv->v4.acd.addr  = INADDR_ANY;
+            priv->v4.acd.state = NM_OPTION_BOOL_DEFAULT;
+        }
     } else
-        g_return_if_fail(!l3cd);
+        nm_assert(priv->v4.acd.state == NM_OPTION_BOOL_DEFAULT);
+
+    if (forget_reglist) {
+        guint n;
+
+        while ((n = nm_g_array_len(priv->v4.acd.reglist)) > 0)
+            _acd_reglist_data_remove(self, n - 1, TRUE);
+    }
+
+    nm_assert(!priv->v4.acd.l3cfg_commit_handle);
+    nm_assert(!priv->v4.acd.done_source);
+    nm_assert(!forget_reglist
+              || !nm_l3cfg_remove_config_all(priv->config.l3cfg, L3CD_ACD_TAG(priv)));
+}
+
+static gboolean
+_acd_complete_on_idle_cb(gpointer user_data)
+{
+    NMDhcpClient        *self = user_data;
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+
+    nm_assert(NM_IS_IPv4(priv->config.addr_family));
+    nm_assert(priv->v4.acd.addr != INADDR_ANY);
+    nm_assert(!priv->v4.acd.l3cfg_commit_handle);
+    nm_assert(priv->l3cd_next);
+
+    _acd_state_reset(self, FALSE, FALSE);
+
+    _nm_dhcp_client_notify(self, NM_DHCP_CLIENT_EVENT_TYPE_BOUND, priv->l3cd_next);
+
+    return G_SOURCE_CONTINUE;
+}
+
+#define _acd_reglist_data_get(priv, idx) \
+    nm_g_array_index_p((priv)->v4.acd.reglist, AcdRegListData, (idx))
+
+static guint
+_acd_reglist_data_find(NMDhcpClientPrivate *priv, in_addr_t addr_needle)
+{
+    const guint n = nm_g_array_len(priv->v4.acd.reglist);
+    guint       i;
+
+    nm_assert(addr_needle != INADDR_ANY);
+
+    for (i = 0; i < n; i++) {
+        AcdRegListData *reglist_data = _acd_reglist_data_get(priv, i);
+
+        if (reglist_data->addr == addr_needle)
+            return i;
+    }
+    return G_MAXUINT;
+}
+
+static void
+_acd_reglist_data_remove(NMDhcpClient *self, guint idx, gboolean do_log)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    AcdRegListData      *reglist_data;
+
+    nm_assert(idx < nm_g_array_len(priv->v4.acd.reglist));
+
+    reglist_data = _acd_reglist_data_get(priv, idx);
+
+    if (do_log) {
+        char sbuf_addr[NM_UTILS_INET_ADDRSTRLEN];
+
+        _LOGD("acd: drop check for address %s (l3cd " NM_HASH_OBFUSCATE_PTR_FMT ")",
+              _nm_utils_inet4_ntop(reglist_data->addr, sbuf_addr),
+              NM_HASH_OBFUSCATE_PTR(reglist_data->l3cd));
+    }
+
+    if (!nm_l3cfg_remove_config(priv->config.l3cfg, L3CD_ACD_TAG(priv), reglist_data->l3cd))
+        nm_assert_not_reached();
+
+    nm_clear_l3cd(&reglist_data->l3cd);
+
+    nm_l3cfg_commit_on_idle_schedule(priv->config.l3cfg, NM_L3_CFG_COMMIT_TYPE_UPDATE);
+
+    g_array_remove_index(priv->v4.acd.reglist, idx);
+
+    if (priv->v4.acd.reglist->len == 0) {
+        nm_clear_pointer(&priv->v4.acd.reglist, g_array_unref);
+        nm_clear_g_source_inst(&priv->v4.acd.reglist_timeout_source);
+    }
+}
+
+static gboolean
+_acd_reglist_timeout_cb(gpointer user_data)
+{
+    NMDhcpClient        *self = user_data;
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    gint64               now_msec;
+
+    nm_clear_g_source_inst(&priv->v4.acd.reglist_timeout_source);
+
+    now_msec = nm_utils_get_monotonic_timestamp_msec();
+
+    while (nm_g_array_len(priv->v4.acd.reglist) > 0) {
+        AcdRegListData *reglist_data = _acd_reglist_data_get(priv, 0);
+
+        if (reglist_data->expiry_msec > now_msec)
+            break;
+
+        _acd_reglist_data_remove(self, 0, TRUE);
+    }
+
+    _acd_reglist_timeout_reschedule(self, now_msec);
+
+    return G_SOURCE_CONTINUE;
+}
+
+static void
+_acd_reglist_timeout_reschedule(NMDhcpClient *self, gint64 now_msec)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    AcdRegListData      *reglist_data;
+
+    if (nm_g_array_len(priv->v4.acd.reglist) == 0) {
+        nm_assert(!priv->v4.acd.reglist_timeout_source);
+        return;
+    }
+
+    if (priv->v4.acd.reglist_timeout_source) {
+        /* already pending. As we only add new elements with a *later*
+          * expiry, we don't need to ever cancel a pending timer. Worst
+          * case, the timer fires, and there is nothing to do and we
+          * reschedule. */
+        return;
+    }
+
+    now_msec = nm_utils_get_monotonic_timestamp_msec();
+
+    reglist_data = _acd_reglist_data_get(priv, 0);
+
+    nm_assert(reglist_data->expiry_msec > now_msec);
+
+    priv->v4.acd.reglist_timeout_source =
+        nm_g_timeout_add_source(reglist_data->expiry_msec - now_msec,
+                                _acd_reglist_timeout_cb,
+                                self);
+}
+
+static void
+_acd_check_lease(NMDhcpClient *self, NMOptionBool *out_acd_state)
+{
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    char                 sbuf_addr[NM_UTILS_INET_ADDRSTRLEN];
+    in_addr_t            addr;
+    gboolean             addr_changed = FALSE;
+    guint                idx;
+    gint64               now_msec;
+
+    if (!NM_IS_IPv4(priv->config.addr_family))
+        goto handle_no_acd;
+
+    if (!priv->l3cd_next)
+        goto handle_no_acd;
+
+    /* an IPv4 lease is always expected to have exactly one address. */
+    nm_assert(nm_l3_config_data_get_num_addresses(priv->l3cd_next, AF_INET) == 1);
+
+    if (priv->config.v4.acd_timeout_msec == 0)
+        goto handle_no_acd;
+
+    addr = NMP_OBJECT_CAST_IP4_ADDRESS(
+               nm_l3_config_data_get_first_obj(priv->l3cd_next, NMP_OBJECT_TYPE_IP4_ADDRESS, NULL))
+               ->address;
+    nm_assert(addr != INADDR_ANY);
+
+    nm_clear_g_source_inst(&priv->v4.acd.done_source);
+
+    if (priv->v4.acd.state != NM_OPTION_BOOL_DEFAULT && priv->v4.acd.addr == addr) {
+        /* the ACD state is already determined. Return right away. */
+        nm_assert(!priv->v4.acd.l3cfg_commit_handle);
+        *out_acd_state = !!priv->v4.acd.state;
+        return;
+    }
+
+    if (priv->v4.acd.addr != addr) {
+        addr_changed      = TRUE;
+        priv->v4.acd.addr = addr;
+    }
+
+    _LOGD("acd: %s check for address %s (timeout %u msec, l3cd " NM_HASH_OBFUSCATE_PTR_FMT ")",
+          addr_changed ? "add" : "update",
+          _nm_utils_inet4_ntop(addr, sbuf_addr),
+          priv->config.v4.acd_timeout_msec,
+          NM_HASH_OBFUSCATE_PTR(priv->l3cd_next));
+
+    priv->v4.acd.state = NM_OPTION_BOOL_DEFAULT;
+
+    if (nm_l3cfg_add_config(priv->config.l3cfg,
+                            L3CD_ACD_TAG(priv),
+                            FALSE,
+                            priv->l3cd_next,
+                            NM_L3CFG_CONFIG_PRIORITY_IPV4LL,
+                            0,
+                            0,
+                            NM_PLATFORM_ROUTE_METRIC_DEFAULT_IP4,
+                            NM_PLATFORM_ROUTE_METRIC_DEFAULT_IP6,
+                            0,
+                            0,
+                            NM_DNS_PRIORITY_DEFAULT_NORMAL,
+                            NM_DNS_PRIORITY_DEFAULT_NORMAL,
+                            NM_L3_ACD_DEFEND_TYPE_ONCE,
+                            NM_MIN(priv->config.v4.acd_timeout_msec, NM_ACD_TIMEOUT_MAX_MSEC),
+                            NM_L3CFG_CONFIG_FLAGS_ONLY_FOR_ACD,
+                            NM_L3_CONFIG_MERGE_FLAGS_NONE))
+        addr_changed = TRUE;
+
+    if (!priv->v4.acd.reglist)
+        priv->v4.acd.reglist = g_array_new(FALSE, FALSE, sizeof(AcdRegListData));
+
+    idx = _acd_reglist_data_find(priv, addr);
+
+    now_msec = nm_utils_get_monotonic_timestamp_msec();
+
+    g_array_append_val(priv->v4.acd.reglist,
+                       ((AcdRegListData){
+                           .l3cd        = nm_l3_config_data_ref(priv->l3cd_next),
+                           .addr        = addr,
+                           .expiry_msec = now_msec + ACD_REGLIST_GRACE_PERIOD_MSEC,
+                       }));
+
+    if (idx != G_MAXUINT) {
+        /* we already tracked this "addr". We don't need to track it twice,
+         * forget about this one. This also has the effect, that we will
+         * always append the new entry to the list (so the list
+         * stays sorted by the increasing timestamp). */
+        _acd_reglist_data_remove(self, idx, FALSE);
+    }
+
+    if (priv->v4.acd.reglist->len > ACD_REGLIST_MAX_ENTRIES) {
+        /* rate limit how many addresses we track for ACD. */
+        _acd_reglist_data_remove(self, 0, TRUE);
+    }
+
+    _acd_reglist_timeout_reschedule(self, now_msec);
+
+    if (!priv->v4.acd.l3cfg_commit_handle) {
+        priv->v4.acd.l3cfg_commit_handle =
+            nm_l3cfg_commit_type_register(priv->config.l3cfg,
+                                          NM_L3_CFG_COMMIT_TYPE_UPDATE,
+                                          NULL,
+                                          "dhcp4-acd");
+        l3_cfg_notify_check_connected(self);
+    }
+
+    if (addr_changed)
+        nm_l3cfg_commit_on_idle_schedule(priv->config.l3cfg, NM_L3_CFG_COMMIT_TYPE_AUTO);
+
+    /* ACD is started/pending... */
+    nm_assert(priv->v4.acd.addr != INADDR_ANY);
+    nm_assert(priv->v4.acd.state == NM_OPTION_BOOL_DEFAULT);
+    nm_assert(priv->v4.acd.l3cfg_commit_handle);
+    nm_assert(priv->l3cfg_notify.id);
+    *out_acd_state = NM_OPTION_BOOL_DEFAULT;
+    return;
+
+handle_no_acd:
+    /* Indicate that ACD is good (or disabled) by returning TRUE. */
+    _acd_state_reset(self, TRUE, FALSE);
+    *out_acd_state = NM_OPTION_BOOL_TRUE;
+    return;
+}
+
+/*****************************************************************************/
+
+gboolean
+_nm_dhcp_client_accept_offer(NMDhcpClient *self, gconstpointer p_yiaddr)
+{
+    NMDhcpClientPrivate   *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    char                   sbuf_addr[NM_UTILS_INET_ADDRSTRLEN];
+    NMIPAddr               yiaddr;
+    const NML3AcdAddrInfo *acd_info;
+
+    if (!NM_IS_IPv4(priv->config.addr_family))
+        return nm_assert_unreachable_val(FALSE);
+
+    if (priv->config.v4.acd_timeout_msec == 0) {
+        /* ACD is disabled. Note that we might track the address for other
+         * reasons and have information about the ACD state below. But
+         * with ACD disabled, we always ignore that information. */
+        return TRUE;
+    }
 
-    if (l3cd)
-        nm_l3_config_data_seal(l3cd);
+    nm_ip_addr_set(priv->config.addr_family, &yiaddr, p_yiaddr);
+
+    /* Note that once we do ACD for a certain address, even after completing
+     * it, we keep the l3cd registered in NML3Cfg for ACD_REGLIST_GRACE_PERIOD_MSEC
+     * The idea is, that we don't yet turn off ACD for a grace period, so that
+     * we can avoid selecting the same lease again.
+     *
+     * Note that we even check whether we have an ACD state if priv->v4.acd.reglist
+     * is empty. Maybe for odd reasons, we track ACD for the address already. */
+
+    acd_info = nm_l3cfg_get_acd_addr_info(priv->config.l3cfg, yiaddr.addr4);
+
+    if (!acd_info)
+        return TRUE;
+
+    if (!NM_IN_SET(acd_info->state, NM_L3_ACD_ADDR_STATE_USED, NM_L3_ACD_ADDR_STATE_CONFLICT))
+        return TRUE;
 
-    if (new_state >= NM_DHCP_STATE_TIMEOUT)
+    _LOGD("offered lease rejected: address %s failed ACD check",
+          _nm_utils_inet4_ntop(yiaddr.addr4, sbuf_addr));
+
+    return FALSE;
+}
+
+void
+_nm_dhcp_client_notify(NMDhcpClient         *self,
+                       NMDhcpClientEventType client_event_type,
+                       const NML3ConfigData *l3cd)
+{
+    NMDhcpClientPrivate                     *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    GHashTable                              *options;
+    gboolean                                 l3cd_changed;
+    NMOptionBool                             acd_state;
+    const int                                IS_IPv4     = NM_IS_IPv4(priv->config.addr_family);
+    nm_auto_unref_l3cd const NML3ConfigData *l3cd_merged = NULL;
+    char                                     sbuf1[NM_HASH_OBFUSCATE_PTR_STR_BUF_SIZE];
+
+    nm_assert(NM_IN_SET(client_event_type,
+                        NM_DHCP_CLIENT_EVENT_TYPE_UNSPECIFIED,
+                        NM_DHCP_CLIENT_EVENT_TYPE_BOUND,
+                        NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED,
+                        NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT,
+                        NM_DHCP_CLIENT_EVENT_TYPE_EXPIRE,
+                        NM_DHCP_CLIENT_EVENT_TYPE_FAIL,
+                        NM_DHCP_CLIENT_EVENT_TYPE_TERMINATED));
+    nm_assert((client_event_type >= NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT)
+              == NM_IN_SET(client_event_type,
+                           NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT,
+                           NM_DHCP_CLIENT_EVENT_TYPE_EXPIRE,
+                           NM_DHCP_CLIENT_EVENT_TYPE_FAIL,
+                           NM_DHCP_CLIENT_EVENT_TYPE_TERMINATED));
+    nm_assert((!!l3cd)
+              == NM_IN_SET(client_event_type,
+                           NM_DHCP_CLIENT_EVENT_TYPE_BOUND,
+                           NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED));
+
+    nm_assert(!l3cd || NM_IS_L3_CONFIG_DATA(l3cd));
+    nm_assert(!l3cd || nm_l3_config_data_get_dhcp_lease(l3cd, priv->config.addr_family));
+
+    _LOGT("notify: event=%s%s%s",
+          nm_dhcp_client_event_type_to_string(client_event_type),
+          NM_PRINT_FMT_QUOTED2(l3cd, ", l3cd=", NM_HASH_OBFUSCATE_PTR_STR(l3cd, sbuf1), ""));
+
+    nm_l3_config_data_seal(l3cd);
+
+    if (client_event_type >= NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT)
         watch_cleanup(self);
 
     if (!IS_IPv4 && l3cd) {
-        if (nm_dhcp_utils_merge_new_dhcp6_lease(priv->l3cd, l3cd, &l3cd_merged)) {
+        /* nm_dhcp_utils_merge_new_dhcp6_lease() relies on "life_starts" option
+         * for merging, which is only set by dhclient. Internal client never sets that,
+         * but it supports multiple IP addresses per lease. */
+        if (nm_dhcp_utils_merge_new_dhcp6_lease(priv->l3cd_next, l3cd, &l3cd_merged)) {
+            _LOGD("lease merged with existing one");
             l3cd = nm_l3_config_data_seal(l3cd_merged);
         }
     }
 
-    if (priv->l3cd == l3cd)
-        return;
-
     if (l3cd) {
         nm_clear_g_source_inst(&priv->no_lease_timeout_source);
-    } else {
-        if (priv->l3cd)
-            schedule_no_lease_timeout(self);
-    }
+    } else
+        _no_lease_timeout_schedule(self);
+
+    l3cd_changed = nm_l3_config_data_reset(&priv->l3cd_next, l3cd);
 
-    /* FIXME(l3cfg:dhcp): the API of NMDhcpClient is changing to expose a simpler API.
-     * The internals like NMDhcpState should not be exposed (or possibly dropped in large
-     * parts). */
+    _acd_check_lease(self, &acd_state);
 
-    nm_l3_config_data_reset(&priv->l3cd, l3cd);
+    options = priv->l3cd_next ? nm_dhcp_lease_get_options(
+                  nm_l3_config_data_get_dhcp_lease(priv->l3cd_next, priv->config.addr_family))
+                              : NULL;
 
-    options = l3cd ? nm_dhcp_lease_get_options(
-                  nm_l3_config_data_get_dhcp_lease(l3cd, priv->config.addr_family))
-                   : NULL;
+    if (_LOGI_ENABLED()) {
+        const char *req_str =
+            IS_IPv4 ? nm_dhcp_option_request_string(AF_INET, NM_DHCP_OPTION_DHCP4_NM_IP_ADDRESS)
+                    : nm_dhcp_option_request_string(AF_INET6, NM_DHCP_OPTION_DHCP6_NM_IP_ADDRESS);
+        const char *addr = nm_g_hash_table_lookup(options, req_str);
+
+        _LOGI("state changed %s%s%s%s",
+              priv->l3cd_next ? "new lease" : "no lease",
+              NM_PRINT_FMT_QUOTED2(addr, ", address=", addr, ""),
+              acd_state == NM_OPTION_BOOL_DEFAULT ? ", acd pending"
+                                                  : (acd_state ? "" : ", acd conflict"));
+    }
 
     if (_LOGD_ENABLED()) {
-        if (options) {
+        if (l3cd_changed && options) {
             gs_free const char **keys = NULL;
             guint                nkeys;
             guint                i;
@@ -327,60 +807,54 @@ nm_dhcp_client_set_state(NMDhcpClient *self, NMDhcpState new_state, const NML3Co
                       keys[i],
                       (char *) g_hash_table_lookup(options, keys[i]));
             }
-
-            if (priv->config.addr_family == AF_INET6) {
-                gs_free char *event_id = NULL;
-
-                event_id = nm_dhcp_utils_get_dhcp6_event_id(options);
-                if (event_id)
-                    _LOGT("event-id: \"%s\"", event_id);
-            }
         }
     }
 
-    if (_LOGI_ENABLED()) {
-        const char *req_str =
-            IS_IPv4 ? nm_dhcp_option_request_string(AF_INET, NM_DHCP_OPTION_DHCP4_NM_IP_ADDRESS)
-                    : nm_dhcp_option_request_string(AF_INET6, NM_DHCP_OPTION_DHCP6_NM_IP_ADDRESS);
-        const char *addr = nm_g_hash_table_lookup(options, req_str);
+    if (acd_state == NM_OPTION_BOOL_DEFAULT) {
+        /* ACD is in progress... */
+        return;
+    }
 
-        _LOGI("state changed %s%s%s%s",
-              priv->l3cd ? "new lease" : "no lease",
-              NM_PRINT_FMT_QUOTED(addr, ", address=", addr, "", ""));
+    if (!acd_state) {
+        gs_free_error GError *error = NULL;
+
+        /* We only decline. We don't actually emit to the caller that
+         * something is wrong (like NM_DHCP_CLIENT_NOTIFY_TYPE_IT_LOOKS_BAD).
+         * If we would, NMDevice might decide to tear down the device, when
+         * we actually should continue trying to get a better lease. There
+         * is already "ipv4.dhcp-timeout" which will handle the failure if
+         * we don't get a good lease. */
+        if (!_dhcp_client_decline(self, priv->l3cd_next, "acd failed", &error))
+            _LOGD("decline failed: %s", error->message);
+        return;
     }
 
-    /* FIXME(l3cfg:dhcp:acd): NMDhcpClient must also do ACD. It needs acd_timeout_msec
-     * as a configuration parameter (in NMDhcpClientConfig). When ACD is enabled,
-     * when a new lease gets announced, it must first use NML3Cfg to run ACD on the
-     * interface (the previous lease -- if any -- will still be used at that point).
-     * If ACD fails, we call nm_dhcp_client_decline() and try to get a different
-     * lease.
-     * If ACD passes, we need to notify the new lease, and the user (NMDevice) may
-     * then configure the address. We need to watch the configured addresses (in NML3Cfg),
-     * and if the address appears there, we need to accept the lease. That is complicated
-     * but necessary, because we can only accept the lease after we configured the
-     * address.
-     *
-     * As a whole, ACD is transparent for the user (NMDevice). It's entirely managed
-     * by NMDhcpClient. Note that we do ACD through NML3Cfg, which centralizes IP handling
-     * for one interface, so for example if the same address happens to be configured
-     * as a static address (bypassing ACD), then NML3Cfg is aware of that and signals
-     * immediate success. */
-
-    if (nm_dhcp_client_can_accept(self) && new_state == NM_DHCP_STATE_BOUND && priv->l3cd
-        && nm_l3_config_data_get_num_addresses(priv->l3cd, priv->config.addr_family) > 0) {
+    nm_l3_config_data_reset(&priv->l3cd_curr, priv->l3cd_next);
+
+    if (client_event_type == NM_DHCP_CLIENT_EVENT_TYPE_BOUND && priv->l3cd_curr
+        && nm_l3_config_data_get_num_addresses(priv->l3cd_curr, priv->config.addr_family) > 0)
         priv->l3cfg_notify.wait_dhcp_commit = TRUE;
-    } else {
+    else
         priv->l3cfg_notify.wait_dhcp_commit = FALSE;
+
+    if (!priv->l3cfg_notify.wait_dhcp_commit && priv->l3cd_curr) {
+        gs_free_error GError *error = NULL;
+
+        _LOGD("accept lease right away");
+        if (!_dhcp_client_accept(self, priv->l3cd_curr, &error)) {
+            _LOGD("accept failed: %s", error->message);
+            /* Unclear why this happened, or what to do about it. Just proceed. */
+        }
     }
-    connect_l3cfg_notify(self);
+
+    l3_cfg_notify_check_connected(self);
 
     {
         const NMDhcpClientNotifyData notify_data = {
             .notify_type = NM_DHCP_CLIENT_NOTIFY_TYPE_LEASE_UPDATE,
             .lease_update =
                 {
-                    .l3cd     = priv->l3cd,
+                    .l3cd     = priv->l3cd_curr,
                     .accepted = !priv->l3cfg_notify.wait_dhcp_commit,
                 },
         };
@@ -396,14 +870,15 @@ daemon_watch_cb(GPid pid, int status, gpointer user_data)
     NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
     gs_free char        *desc = NULL;
 
-    g_return_if_fail(priv->watch_id);
-    priv->watch_id = 0;
+    g_return_if_fail(priv->watch_source);
+
+    priv->watch_source = NULL;
 
     _LOGI("client pid %d %s", pid, (desc = nm_utils_get_process_exit_status_desc(status)));
 
     priv->pid = -1;
 
-    nm_dhcp_client_set_state(self, NM_DHCP_STATE_TERMINATED, NULL);
+    _nm_dhcp_client_notify(self, NM_DHCP_CLIENT_EVENT_TYPE_TERMINATED, NULL);
 }
 
 void
@@ -414,8 +889,8 @@ nm_dhcp_client_watch_child(NMDhcpClient *self, pid_t pid)
     g_return_if_fail(priv->pid == -1);
     priv->pid = pid;
 
-    g_return_if_fail(priv->watch_id == 0);
-    priv->watch_id = g_child_watch_add(pid, daemon_watch_cb, self);
+    g_return_if_fail(!priv->watch_source);
+    priv->watch_source = nm_g_child_watch_add_source(pid, daemon_watch_cb, self);
 }
 
 void
@@ -429,71 +904,74 @@ nm_dhcp_client_stop_watch_child(NMDhcpClient *self, pid_t pid)
     watch_cleanup(self);
 }
 
-gboolean
-nm_dhcp_client_start_ip4(NMDhcpClient *self, GError **error)
+static gboolean
+_accept(NMDhcpClient *self, const NML3ConfigData *l3cd, GError **error)
 {
-    NMDhcpClientPrivate *priv;
-
-    g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), FALSE);
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-    g_return_val_if_fail(priv->pid == -1, FALSE);
-    g_return_val_if_fail(priv->config.addr_family == AF_INET, FALSE);
-    g_return_val_if_fail(priv->config.uuid, FALSE);
+    if (!NM_IS_IPv4(priv->config.addr_family))
+        return TRUE;
 
-    schedule_no_lease_timeout(self);
+    if (!priv->v4.bound.invocation)
+        return TRUE;
 
-    return NM_DHCP_CLIENT_GET_CLASS(self)->ip4_start(self, error);
+    g_dbus_method_invocation_return_value(g_steal_pointer(&priv->v4.bound.invocation), NULL);
+    return TRUE;
 }
 
-gboolean
-nm_dhcp_client_accept(NMDhcpClient *self, GError **error)
+static gboolean
+_dhcp_client_accept(NMDhcpClient *self, const NML3ConfigData *l3cd, GError **error)
 {
-    NMDhcpClientPrivate *priv;
+    NMDhcpClientClass *klass;
 
     g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), FALSE);
+    nm_assert(l3cd);
 
-    priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    klass = NM_DHCP_CLIENT_GET_CLASS(self);
 
-    g_return_val_if_fail(priv->l3cd, FALSE);
+    g_return_val_if_fail(NM_DHCP_CLIENT_GET_PRIVATE(self)->l3cd_curr, FALSE);
 
-    if (NM_DHCP_CLIENT_GET_CLASS(self)->accept) {
-        return NM_DHCP_CLIENT_GET_CLASS(self)->accept(self, error);
-    }
-
-    return TRUE;
+    return klass->accept(self, l3cd, error);
 }
 
-gboolean
-nm_dhcp_client_can_accept(NMDhcpClient *self)
+static gboolean
+decline(NMDhcpClient *self, const NML3ConfigData *l3cd, const char *error_message, GError **error)
 {
-    gboolean can_accept;
-
-    g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), FALSE);
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    can_accept = !!(NM_DHCP_CLIENT_GET_CLASS(self)->accept);
+    if (!NM_IS_IPv4(priv->config.addr_family))
+        return TRUE;
 
-    nm_assert(can_accept == (!!(NM_DHCP_CLIENT_GET_CLASS(self)->decline)));
+    if (!priv->v4.bound.invocation) {
+        nm_utils_error_set(error,
+                           NM_UTILS_ERROR_UNKNOWN,
+                           "calling decline in unexpected script state");
+        return FALSE;
+    }
 
-    return can_accept;
+    g_dbus_method_invocation_return_error(g_steal_pointer(&priv->v4.bound.invocation),
+                                          NM_DEVICE_ERROR,
+                                          NM_DEVICE_ERROR_FAILED,
+                                          "acd failed");
+    return TRUE;
 }
 
-gboolean
-nm_dhcp_client_decline(NMDhcpClient *self, const char *error_message, GError **error)
+static gboolean
+_dhcp_client_decline(NMDhcpClient         *self,
+                     const NML3ConfigData *l3cd,
+                     const char           *error_message,
+                     GError              **error)
 {
-    NMDhcpClientPrivate *priv;
+    NMDhcpClientClass *klass;
 
     g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), FALSE);
+    nm_assert(l3cd);
 
-    priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    g_return_val_if_fail(priv->l3cd, FALSE);
+    klass = NM_DHCP_CLIENT_GET_CLASS(self);
 
-    if (NM_DHCP_CLIENT_GET_CLASS(self)->decline) {
-        return NM_DHCP_CLIENT_GET_CLASS(self)->decline(self, error_message, error);
-    }
+    g_return_val_if_fail(NM_DHCP_CLIENT_GET_PRIVATE(self)->l3cd_next, FALSE);
 
-    return TRUE;
+    return klass->decline(self, l3cd, error_message, error);
 }
 
 static GBytes *
@@ -508,7 +986,7 @@ ipv6_lladdr_timeout(gpointer user_data)
     NMDhcpClient        *self = user_data;
     NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
-    nm_clear_g_source_inst(&priv->ipv6_lladdr_timeout_source);
+    nm_clear_g_source_inst(&priv->v6.lladdr_timeout_source);
 
     _emit_notify(
         self,
@@ -519,6 +997,23 @@ ipv6_lladdr_timeout(gpointer user_data)
     return G_SOURCE_CONTINUE;
 }
 
+static gboolean
+ipv6_dad_timeout(gpointer user_data)
+{
+    NMDhcpClient        *self = user_data;
+    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+
+    nm_clear_g_source_inst(&priv->v6.dad_timeout_source);
+
+    _emit_notify(
+        self,
+        &((NMDhcpClientNotifyData){
+            .notify_type         = NM_DHCP_CLIENT_NOTIFY_TYPE_IT_LOOKS_BAD,
+            .it_looks_bad.reason = "timeout reached while waiting for IPv6 DAD to complete",
+        }));
+    return G_SOURCE_CONTINUE;
+}
+
 static const NMPlatformIP6Address *
 ipv6_lladdr_find(NMDhcpClient *self)
 {
@@ -528,8 +1023,12 @@ ipv6_lladdr_find(NMDhcpClient *self)
     NMDedupMultiIter     iter;
     const NMPObject     *obj;
 
+    nm_assert(!NM_IS_IPv4(priv->config.addr_family));
+
     l3cfg = priv->config.l3cfg;
-    nmp_lookup_init_object(&lookup, NMP_OBJECT_TYPE_IP6_ADDRESS, nm_l3cfg_get_ifindex(l3cfg));
+    nmp_lookup_init_object_by_ifindex(&lookup,
+                                      NMP_OBJECT_TYPE_IP6_ADDRESS,
+                                      nm_l3cfg_get_ifindex(l3cfg));
 
     nm_platform_iter_obj_for_each (&iter, nm_l3cfg_get_platform(l3cfg), &lookup, &obj) {
         const NMPlatformIP6Address *pladdr = NMP_OBJECT_CAST_IP6_ADDRESS(obj);
@@ -544,30 +1043,58 @@ ipv6_lladdr_find(NMDhcpClient *self)
     return NULL;
 }
 
+static const NMPlatformIP6Address *
+ipv6_tentative_addr_find(NMDhcpClient *self)
+{
+    NMDhcpClientPrivate        *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    NMDedupMultiIter            iter;
+    const NMPlatformIP6Address *addr;
+    NML3Cfg                    *l3cfg = priv->config.l3cfg;
+
+    /* For each address in the lease, check whether it's tentative
+     * in platform. */
+    nm_l3_config_data_iter_ip6_address_for_each (&iter, priv->l3cd_curr, &addr) {
+        const NMPlatformIP6Address *pladdr;
+        NMPObject                   needle;
+
+        nmp_object_stackinit_id_ip6_address(&needle, nm_l3cfg_get_ifindex(l3cfg), &addr->address);
+        pladdr = NMP_OBJECT_CAST_IP6_ADDRESS(nm_platform_lookup_obj(nm_l3cfg_get_platform(l3cfg),
+                                                                    NMP_CACHE_ID_TYPE_OBJECT_TYPE,
+                                                                    &needle));
+        if (!pladdr) {
+            /* Address was removed from platform */
+            continue;
+        }
+
+        if (NM_FLAGS_HAS(pladdr->n_ifa_flags, IFA_F_TENTATIVE)
+            && !NM_FLAGS_HAS(pladdr->n_ifa_flags, IFA_F_OPTIMISTIC))
+            return pladdr;
+    }
+
+    return NULL;
+}
+
 static void
 l3_cfg_notify_cb(NML3Cfg *l3cfg, const NML3ConfigNotifyData *notify_data, NMDhcpClient *self)
 {
     NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
+    char                 sbuf_addr[NM_UTILS_INET_ADDRSTRLEN];
 
     nm_assert(l3cfg == priv->config.l3cfg);
 
-    switch (notify_data->notify_type) {
-    case NM_L3_CONFIG_NOTIFY_TYPE_PLATFORM_CHANGE_ON_IDLE:
-    {
+    if (notify_data->notify_type == NM_L3_CONFIG_NOTIFY_TYPE_PLATFORM_CHANGE_ON_IDLE
+        && priv->l3cfg_notify.wait_ll_address) {
         const NMPlatformIP6Address *addr;
         gs_free_error GError       *error = NULL;
 
-        if (!priv->l3cfg_notify.wait_ll_address)
-            return;
-
         addr = ipv6_lladdr_find(self);
         if (addr) {
             _LOGD("got IPv6LL address, starting transaction");
             priv->l3cfg_notify.wait_ll_address = FALSE;
-            connect_l3cfg_notify(self);
-            nm_clear_g_source_inst(&priv->ipv6_lladdr_timeout_source);
+            l3_cfg_notify_check_connected(self);
+            nm_clear_g_source_inst(&priv->v6.lladdr_timeout_source);
 
-            schedule_no_lease_timeout(self);
+            _no_lease_timeout_schedule(self);
 
             if (!NM_DHCP_CLIENT_GET_CLASS(self)->ip6_start(self, &addr->address, &error)) {
                 _emit_notify(self,
@@ -577,11 +1104,30 @@ l3_cfg_notify_cb(NML3Cfg *l3cfg, const NML3ConfigNotifyData *notify_data, NMDhcp
                              }));
             }
         }
+    }
 
-        break;
+    if (notify_data->notify_type == NM_L3_CONFIG_NOTIFY_TYPE_PLATFORM_CHANGE_ON_IDLE
+        && priv->l3cfg_notify.wait_ipv6_dad) {
+        const NMPlatformIP6Address *tentative;
+
+        tentative = ipv6_tentative_addr_find(self);
+        if (!tentative) {
+            _LOGD("addresses in the lease completed DAD");
+            priv->l3cfg_notify.wait_ipv6_dad = FALSE;
+            nm_clear_g_source_inst(&priv->v6.dad_timeout_source);
+            l3_cfg_notify_check_connected(self);
+            _emit_notify(
+                self,
+                &((NMDhcpClientNotifyData){.notify_type  = NM_DHCP_CLIENT_NOTIFY_TYPE_LEASE_UPDATE,
+                                           .lease_update = {
+                                               .l3cd     = priv->l3cd_curr,
+                                               .accepted = TRUE,
+                                           }}));
+        }
     }
-    case NM_L3_CONFIG_NOTIFY_TYPE_POST_COMMIT:
-    {
+
+    if (notify_data->notify_type == NM_L3_CONFIG_NOTIFY_TYPE_POST_COMMIT
+        && priv->l3cfg_notify.wait_dhcp_commit) {
         const NML3ConfigData      *committed_l3cd;
         NMDedupMultiIter           ipconf_iter;
         const NMPlatformIPAddress *lease_address;
@@ -592,11 +1138,8 @@ l3_cfg_notify_cb(NML3Cfg *l3cfg, const NML3ConfigNotifyData *notify_data, NMDhcp
          * configured. If the address was added, we can proceed accepting the
          * lease and notifying NMDevice. */
 
-        if (!priv->l3cfg_notify.wait_dhcp_commit)
-            return;
-
         nm_l3_config_data_iter_ip_address_for_each (&ipconf_iter,
-                                                    priv->l3cd,
+                                                    priv->l3cd_curr,
                                                     priv->config.addr_family,
                                                     &lease_address)
             break;
@@ -610,79 +1153,147 @@ l3_cfg_notify_cb(NML3Cfg *l3cfg, const NML3ConfigNotifyData *notify_data, NMDhcp
                                                     address4->address,
                                                     address4->plen,
                                                     address4->peer_address))
-                return;
+                goto wait_dhcp_commit_done;
         } else {
             const NMPlatformIP6Address *address6 = (const NMPlatformIP6Address *) lease_address;
+            const NMPlatformIP6Address *tentative;
+            char                        str[NM_UTILS_TO_STRING_BUFFER_SIZE];
 
             if (!nm_l3_config_data_lookup_address_6(committed_l3cd, &address6->address))
-                return;
+                goto wait_dhcp_commit_done;
+
+            tentative = ipv6_tentative_addr_find(self);
+            if (tentative) {
+                priv->l3cfg_notify.wait_ipv6_dad = TRUE;
+                priv->v6.dad_timeout_source =
+                    nm_g_timeout_add_seconds_source(30, ipv6_dad_timeout, self);
+                _LOGD("wait DAD for address %s",
+                      nm_platform_ip6_address_to_string(tentative, str, sizeof(str)));
+            } else {
+                priv->l3cfg_notify.wait_ipv6_dad = FALSE;
+                nm_clear_g_source_inst(&priv->v6.dad_timeout_source);
+            }
         }
 
         priv->l3cfg_notify.wait_dhcp_commit = FALSE;
-        connect_l3cfg_notify(self);
 
-        _LOGD("accept address");
+        l3_cfg_notify_check_connected(self);
+
+        _LOGD("accept lease");
 
-        if (!nm_dhcp_client_accept(self, &error)) {
+        if (!_dhcp_client_accept(self, priv->l3cd_curr, &error)) {
             gs_free char *reason = g_strdup_printf("error accepting lease: %s", error->message);
 
+            _LOGD("accept failed: %s", error->message);
+
             _emit_notify(self,
                          &((NMDhcpClientNotifyData){
                              .notify_type         = NM_DHCP_CLIENT_NOTIFY_TYPE_IT_LOOKS_BAD,
                              .it_looks_bad.reason = reason,
                          }));
-            return;
+            goto wait_dhcp_commit_done;
         }
 
-        _emit_notify(
-            self,
-            &((NMDhcpClientNotifyData){.notify_type  = NM_DHCP_CLIENT_NOTIFY_TYPE_LEASE_UPDATE,
-                                       .lease_update = {
-                                           .l3cd     = priv->l3cd,
-                                           .accepted = TRUE,
-                                       }}));
-        break;
-    };
-    default:
-        /* ignore */;
+        if (priv->config.addr_family == AF_INET || !priv->l3cfg_notify.wait_ipv6_dad) {
+            _emit_notify(
+                self,
+                &((NMDhcpClientNotifyData){.notify_type  = NM_DHCP_CLIENT_NOTIFY_TYPE_LEASE_UPDATE,
+                                           .lease_update = {
+                                               .l3cd     = priv->l3cd_curr,
+                                               .accepted = TRUE,
+                                           }}));
+        }
+    }
+wait_dhcp_commit_done:
+
+    if (notify_data->notify_type == NM_L3_CONFIG_NOTIFY_TYPE_ACD_EVENT
+        && priv->v4.acd.l3cfg_commit_handle) {
+        nm_assert(priv->v4.acd.addr != INADDR_ANY);
+        nm_assert(priv->v4.acd.state == NM_OPTION_BOOL_DEFAULT);
+        nm_assert(!priv->v4.acd.done_source);
+
+        if (priv->v4.acd.addr == notify_data->acd_event.info.addr
+            && nm_l3_acd_addr_info_find_track_info(&notify_data->acd_event.info,
+                                                   L3CD_ACD_TAG(priv),
+                                                   NULL,
+                                                   NULL)) {
+            NMOptionBool acd_state;
+
+            switch (notify_data->acd_event.info.state) {
+            default:
+                nm_assert_not_reached();
+                /* fall-through */
+            case NM_L3_ACD_ADDR_STATE_INIT:
+            case NM_L3_ACD_ADDR_STATE_PROBING:
+                acd_state = NM_OPTION_BOOL_DEFAULT;
+                break;
+            case NM_L3_ACD_ADDR_STATE_USED:
+            case NM_L3_ACD_ADDR_STATE_CONFLICT:
+            case NM_L3_ACD_ADDR_STATE_EXTERNAL_REMOVED:
+                acd_state = NM_OPTION_BOOL_FALSE;
+                break;
+            case NM_L3_ACD_ADDR_STATE_READY:
+            case NM_L3_ACD_ADDR_STATE_DEFENDING:
+                acd_state = NM_OPTION_BOOL_TRUE;
+                break;
+            }
+            if (acd_state != NM_OPTION_BOOL_DEFAULT) {
+                _LOGD("acd: acd %s for %s",
+                      acd_state ? "ready" : "conflict",
+                      _nm_utils_inet4_ntop(priv->v4.acd.addr, sbuf_addr));
+                nm_l3cfg_commit_type_clear(priv->config.l3cfg, &priv->v4.acd.l3cfg_commit_handle);
+                priv->v4.acd.state       = acd_state;
+                priv->v4.acd.done_source = nm_g_idle_add_source(_acd_complete_on_idle_cb, self);
+            }
+        }
     }
 }
 
 gboolean
-nm_dhcp_client_start_ip6(NMDhcpClient *self, GError **error)
+nm_dhcp_client_start(NMDhcpClient *self, GError **error)
 {
     NMDhcpClientPrivate        *priv;
     gs_unref_bytes GBytes      *own_client_id = NULL;
-    const NMPlatformIP6Address *addr;
+    const NMPlatformIP6Address *addr          = NULL;
+    int                         IS_IPv4;
 
     g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), FALSE);
+
     priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
     g_return_val_if_fail(priv->pid == -1, FALSE);
-    g_return_val_if_fail(priv->config.addr_family == AF_INET6, FALSE);
     g_return_val_if_fail(priv->config.uuid, FALSE);
-    g_return_val_if_fail(!priv->effective_client_id, FALSE);
+    nm_assert(!priv->effective_client_id);
 
-    if (!priv->config.v6.enforce_duid)
-        own_client_id = NM_DHCP_CLIENT_GET_CLASS(self)->get_duid(self);
+    IS_IPv4 = NM_IS_IPv4(priv->config.addr_family);
 
-    nm_dhcp_client_set_effective_client_id(self, own_client_id ?: priv->config.client_id);
+    if (!IS_IPv4) {
+        if (!priv->config.v6.enforce_duid)
+            own_client_id = NM_DHCP_CLIENT_GET_CLASS(self)->get_duid(self);
 
-    addr = ipv6_lladdr_find(self);
-    if (!addr) {
-        _LOGD("waiting for IPv6LL address");
-        priv->l3cfg_notify.wait_ll_address = TRUE;
-        connect_l3cfg_notify(self);
-        priv->ipv6_lladdr_timeout_source =
-            nm_g_timeout_add_seconds_source(10, ipv6_lladdr_timeout, self);
-        return TRUE;
+        nm_dhcp_client_set_effective_client_id(self, own_client_id ?: priv->config.client_id);
+
+        addr = ipv6_lladdr_find(self);
+        if (!addr) {
+            _LOGD("waiting for IPv6LL address");
+            priv->l3cfg_notify.wait_ll_address = TRUE;
+            l3_cfg_notify_check_connected(self);
+            priv->v6.lladdr_timeout_source =
+                nm_g_timeout_add_seconds_source(10, ipv6_lladdr_timeout, self);
+            return TRUE;
+        }
     }
 
-    schedule_no_lease_timeout(self);
+    _no_lease_timeout_schedule(self);
+
+    if (IS_IPv4)
+        return NM_DHCP_CLIENT_GET_CLASS(self)->ip4_start(self, error);
 
     return NM_DHCP_CLIENT_GET_CLASS(self)->ip6_start(self, &addr->address, error);
 }
 
+/*****************************************************************************/
+
 void
 nm_dhcp_client_stop_existing(const char *pid_file, const char *binary_name)
 {
@@ -757,9 +1368,19 @@ nm_dhcp_client_stop(NMDhcpClient *self, gboolean release)
 
     priv->is_stopped = TRUE;
 
+    if (NM_IS_IPv4(priv->config.addr_family) && priv->v4.bound.invocation) {
+        g_dbus_method_invocation_return_error(g_steal_pointer(&priv->v4.bound.invocation),
+                                              NM_DEVICE_ERROR,
+                                              NM_DEVICE_ERROR_FAILED,
+                                              "dhcp stopping");
+    }
+
+    _acd_state_reset(self, TRUE, TRUE);
+
     priv->l3cfg_notify.wait_dhcp_commit = FALSE;
     priv->l3cfg_notify.wait_ll_address  = FALSE;
-    connect_l3cfg_notify(self);
+    priv->l3cfg_notify.wait_ipv6_dad    = FALSE;
+    l3_cfg_notify_check_connected(self);
 
     /* Kill the DHCP client */
     old_pid = priv->pid;
@@ -770,7 +1391,10 @@ nm_dhcp_client_stop(NMDhcpClient *self, gboolean release)
         _LOGI("canceled DHCP transaction");
     nm_assert(priv->pid == -1);
 
-    nm_dhcp_client_set_state(self, NM_DHCP_STATE_TERMINATED, NULL);
+    nm_clear_l3cd(&priv->l3cd_next);
+    nm_clear_l3cd(&priv->l3cd_curr);
+
+    _nm_dhcp_client_notify(self, NM_DHCP_CLIENT_EVENT_TYPE_TERMINATED, NULL);
 }
 
 /*****************************************************************************/
@@ -779,37 +1403,31 @@ static char *
 bytearray_variant_to_string(NMDhcpClient *self, GVariant *value, const char *key)
 {
     const guint8 *array;
+    char         *str;
     gsize         length;
-    GString      *str;
-    int           i;
-    unsigned char c;
-    char         *converted = NULL;
+    gsize         i;
 
-    g_return_val_if_fail(value != NULL, NULL);
+    nm_assert(value);
 
     array = g_variant_get_fixed_array(value, &length, 1);
 
-    /* Since the DHCP options come through environment variables, they should
-     * already be UTF-8 safe, but just make sure.
+    /* Since the DHCP options come originally came as environment variables, they
+     * have not guaranteed encoding. Let's only accept ASCII here.
      */
-    str = g_string_sized_new(length);
+    str = g_malloc(length + 1);
     for (i = 0; i < length; i++) {
-        c = array[i];
+        guint8 c = array[i];
 
-        /* Convert NULLs to spaces and non-ASCII characters to ? */
         if (c == '\0')
-            c = ' ';
+            str[i] = ' ';
         else if (c > 127)
-            c = '?';
-        str = g_string_append_c(str, c);
+            str[i] = '?';
+        else
+            str[i] = (char) c;
     }
-    str = g_string_append_c(str, '\0');
+    str[i] = '\0';
 
-    converted = str->str;
-    if (!g_utf8_validate(converted, -1, NULL))
-        _LOGW("option '%s' couldn't be converted to UTF-8", key);
-    g_string_free(str, FALSE);
-    return converted;
+    return str;
 }
 
 static int
@@ -832,11 +1450,13 @@ label_is_unknown_xyz(const char *label)
 static void
 maybe_add_option(NMDhcpClient *self, GHashTable *hash, const char *key, GVariant *value)
 {
-    char *str_value = NULL;
+    char *str_value;
+    int   priv_opt_num;
 
-    g_return_if_fail(g_variant_is_of_type(value, G_VARIANT_TYPE_BYTESTRING));
+    if (!g_variant_is_of_type(value, G_VARIANT_TYPE_BYTESTRING))
+        return;
 
-    if (g_str_has_prefix(key, OLD_TAG))
+    if (NM_STR_HAS_PREFIX(key, OLD_TAG))
         return;
 
     /* Filter out stuff that's not actually new DHCP options */
@@ -849,34 +1469,33 @@ maybe_add_option(NMDhcpClient *self, GHashTable *hash, const char *key, GVariant
         return;
 
     str_value = bytearray_variant_to_string(self, value, key);
-    if (str_value) {
-        int priv_opt_num;
+    if (!str_value)
+        return;
 
-        g_hash_table_insert(hash, g_strdup(key), str_value);
+    g_hash_table_insert(hash, g_strdup(key), str_value);
 
-        /* dhclient has no special labels for private dhcp options: it uses "unknown_xyz"
+    /* dhclient has no special labels for private dhcp options: it uses "unknown_xyz"
          * labels for that. We need to identify those to alias them to our "private_xyz"
          * format unused in the internal dchp plugins.
          */
-        if ((priv_opt_num = label_is_unknown_xyz(key)) > 0) {
-            gs_free guint8 *check_val = NULL;
-            char           *hex_str   = NULL;
-            gsize           len;
+    if ((priv_opt_num = label_is_unknown_xyz(key)) > 0) {
+        gs_free guint8 *check_val = NULL;
+        char           *hex_str   = NULL;
+        gsize           len;
 
-            /* dhclient passes values from dhcp private options in its own "string" format:
+        /* dhclient passes values from dhcp private options in its own "string" format:
              * if the raw values are printable as ascii strings, it will pass the string
              * representation; if the values are not printable as an ascii string, it will
              * pass a string displaying the hex values (hex string). Try to enforce passing
              * always an hex string, converting string representation if needed.
              */
-            check_val = nm_utils_hexstr2bin_alloc(str_value, FALSE, TRUE, ":", 0, &len);
-            hex_str   = nm_utils_bin2hexstr_full(check_val ?: (guint8 *) str_value,
-                                               check_val ? len : strlen(str_value),
-                                               ':',
-                                               FALSE,
-                                               NULL);
-            g_hash_table_insert(hash, g_strdup_printf("private_%d", priv_opt_num), hex_str);
-        }
+        check_val = nm_utils_hexstr2bin_alloc(str_value, FALSE, TRUE, ":", 0, &len);
+        hex_str   = nm_utils_bin2hexstr_full(check_val ?: (guint8 *) str_value,
+                                           check_val ? len : strlen(str_value),
+                                           ':',
+                                           FALSE,
+                                           NULL);
+        g_hash_table_insert(hash, g_strdup_printf("private_%d", priv_opt_num), hex_str);
     }
 }
 
@@ -895,44 +1514,61 @@ nm_dhcp_client_emit_ipv6_prefix_delegated(NMDhcpClient *self, const NMPlatformIP
 }
 
 gboolean
-nm_dhcp_client_handle_event(gpointer      unused,
-                            const char   *iface,
-                            int           pid,
-                            GVariant     *options,
-                            const char   *reason,
-                            NMDhcpClient *self)
+nm_dhcp_client_handle_event(gpointer               unused,
+                            const char            *iface,
+                            int                    pid,
+                            GVariant              *options,
+                            const char            *reason,
+                            GDBusMethodInvocation *invocation,
+                            NMDhcpClient          *self)
 {
     NMDhcpClientPrivate                    *priv;
-    guint32                                 new_state;
-    nm_auto_unref_l3cd_init NML3ConfigData *l3cd   = NULL;
+    nm_auto_unref_l3cd_init NML3ConfigData *l3cd = NULL;
+    NMDhcpClientEventType                   client_event_type;
     NMPlatformIP6Address                    prefix = {
-        0,
+                           0,
     };
+    int IS_IPv4;
 
     g_return_val_if_fail(NM_IS_DHCP_CLIENT(self), FALSE);
     g_return_val_if_fail(iface != NULL, FALSE);
     g_return_val_if_fail(pid > 0, FALSE);
     g_return_val_if_fail(g_variant_is_of_type(options, G_VARIANT_TYPE_VARDICT), FALSE);
     g_return_val_if_fail(reason != NULL, FALSE);
+    g_return_val_if_fail(G_IS_DBUS_METHOD_INVOCATION(invocation), FALSE);
 
     priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
 
+    g_return_val_if_fail(!priv->is_stopped, FALSE);
+
     if (!nm_streq0(priv->config.iface, iface))
         return FALSE;
     if (priv->pid != pid)
         return FALSE;
 
-    new_state = reason_to_state(self, priv->config.iface, reason);
-    if (new_state == NM_DHCP_STATE_NOOP)
-        return TRUE;
-
-    _LOGD("DHCP state '%s' -> '%s' (reason: '%s')",
-          nm_dhcp_state_to_string(priv->state),
-          nm_dhcp_state_to_string(new_state),
-          reason);
-    priv->state = new_state;
+    _LOGD("DHCP event (reason: '%s')", reason);
+
+    if (NM_IN_STRSET_ASCII_CASE(reason, "preinit"))
+        goto out_handled;
+
+    if (NM_IN_STRSET_ASCII_CASE(reason, "bound", "bound6", "static"))
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_BOUND;
+    else if (NM_IN_STRSET_ASCII_CASE(reason, "renew", "renew6", "reboot", "rebind", "rebind6"))
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED;
+    else if (NM_IN_STRSET_ASCII_CASE(reason, "timeout"))
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT;
+    else if (NM_IN_STRSET_ASCII_CASE(reason, "nak", "expire", "expire6"))
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_EXPIRE;
+    else if (NM_IN_STRSET_ASCII_CASE(reason, "end", "stop", "stopped"))
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_TERMINATED;
+    else if (NM_IN_STRSET_ASCII_CASE(reason, "fail", "abend"))
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_FAIL;
+    else
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_UNSPECIFIED;
 
-    if (NM_IN_SET(new_state, NM_DHCP_STATE_BOUND, NM_DHCP_STATE_EXTENDED)) {
+    if (NM_IN_SET(client_event_type,
+                  NM_DHCP_CLIENT_EVENT_TYPE_BOUND,
+                  NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED)) {
         gs_unref_hashtable GHashTable *str_options = NULL;
         GVariantIter                   iter;
         const char                    *name;
@@ -963,8 +1599,7 @@ nm_dhcp_client_handle_event(gpointer      unused,
                     str_options,
                     priv->config.v6.info_only);
             }
-        } else
-            g_warn_if_reached();
+        }
 
         if (l3cd) {
             nm_l3_config_data_set_dhcp_lease_from_options(l3cd,
@@ -978,16 +1613,34 @@ nm_dhcp_client_handle_event(gpointer      unused,
          * of the DHCP client instance. Instead, we just signal the prefix
          * to the device. */
         nm_dhcp_client_emit_ipv6_prefix_delegated(self, &prefix);
-        return TRUE;
+        goto out_handled;
     }
 
-    /* Fail if no valid IP config was received */
-    if (NM_IN_SET(new_state, NM_DHCP_STATE_BOUND, NM_DHCP_STATE_EXTENDED) && !l3cd) {
+    if (NM_IN_SET(client_event_type,
+                  NM_DHCP_CLIENT_EVENT_TYPE_BOUND,
+                  NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED)
+        && !l3cd) {
+        /* Fail if no valid IP config was received */
         _LOGW("client bound but IP config not received");
-        new_state = NM_DHCP_STATE_FAIL;
+        client_event_type = NM_DHCP_CLIENT_EVENT_TYPE_FAIL;
     }
 
-    nm_dhcp_client_set_state(self, new_state, l3cd);
+    IS_IPv4 = NM_IS_IPv4(priv->config.addr_family);
+
+    if (IS_IPv4 && priv->v4.bound.invocation)
+        g_dbus_method_invocation_return_value(g_steal_pointer(&priv->v4.bound.invocation), NULL);
+
+    if (IS_IPv4
+        && NM_IN_SET(client_event_type,
+                     NM_DHCP_CLIENT_EVENT_TYPE_BOUND,
+                     NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED))
+        priv->v4.bound.invocation = g_steal_pointer(&invocation);
+
+    _nm_dhcp_client_notify(self, client_event_type, l3cd);
+
+out_handled:
+    if (invocation)
+        g_dbus_method_invocation_return_value(invocation, NULL);
     return TRUE;
 }
 
@@ -1001,43 +1654,48 @@ nm_dhcp_client_server_id_is_rejected(NMDhcpClient *self, gconstpointer addr)
     /* IPv6 not implemented yet */
     nm_assert(priv->config.addr_family == AF_INET);
 
-    if (!priv->config.reject_servers || !priv->config.reject_servers[0])
-        return FALSE;
-
-    for (i = 0; priv->config.reject_servers[i]; i++) {
-        in_addr_t r_addr;
-        in_addr_t mask;
-        int       r_prefix;
-
-        if (!nm_utils_parse_inaddr_prefix_bin(AF_INET,
-                                              priv->config.reject_servers[i],
-                                              NULL,
-                                              &r_addr,
-                                              &r_prefix))
-            nm_assert_not_reached();
-        mask = _nm_utils_ip4_prefix_to_netmask(r_prefix < 0 ? 32 : r_prefix);
-        if ((addr4 & mask) == (r_addr & mask))
-            return TRUE;
+    if (priv->config.reject_servers) {
+        for (i = 0; priv->config.reject_servers[i]; i++) {
+            in_addr_t r_addr;
+            in_addr_t mask;
+            int       r_prefix;
+
+            if (!nm_utils_parse_inaddr_prefix_bin(AF_INET,
+                                                  priv->config.reject_servers[i],
+                                                  NULL,
+                                                  &r_addr,
+                                                  &r_prefix))
+                nm_assert_not_reached();
+
+            mask = _nm_utils_ip4_prefix_to_netmask(r_prefix < 0 ? 32 : r_prefix);
+            if ((addr4 & mask) == (r_addr & mask))
+                return TRUE;
+        }
     }
 
     return FALSE;
 }
 
+/*****************************************************************************/
+
 static void
 config_init(NMDhcpClientConfig *config, const NMDhcpClientConfig *src)
 {
+    nm_assert(config);
+    nm_assert(src);
+    nm_assert(config != src);
+    nm_assert_addr_family(src->addr_family);
+
     *config = *src;
 
+    /* We must not return before un-aliasing all pointers in @config! */
+
     g_object_ref(config->l3cfg);
 
-    if (config->hwaddr)
-        g_bytes_ref(config->hwaddr);
-    if (config->bcast_hwaddr)
-        g_bytes_ref(config->bcast_hwaddr);
-    if (config->vendor_class_identifier)
-        g_bytes_ref(config->vendor_class_identifier);
-    if (config->client_id)
-        g_bytes_ref(config->client_id);
+    nm_g_bytes_ref(config->hwaddr);
+    nm_g_bytes_ref(config->bcast_hwaddr);
+    nm_g_bytes_ref(config->vendor_class_identifier);
+    nm_g_bytes_ref(config->client_id);
 
     config->iface           = g_strdup(config->iface);
     config->uuid            = g_strdup(config->uuid);
@@ -1045,16 +1703,14 @@ config_init(NMDhcpClientConfig *config, const NMDhcpClientConfig *src)
     config->hostname        = g_strdup(config->hostname);
     config->mud_url         = g_strdup(config->mud_url);
 
-    config->reject_servers = (const char *const *) nm_strv_dup(config->reject_servers, -1, TRUE);
+    config->reject_servers = nm_strv_dup_packed(config->reject_servers, -1);
 
-    if (config->addr_family == AF_INET) {
+    if (NM_IS_IPv4(config->addr_family))
         config->v4.last_address = g_strdup(config->v4.last_address);
-    } else if (config->addr_family == AF_INET6) {
+    else {
         config->hwaddr       = NULL;
         config->bcast_hwaddr = NULL;
         config->use_fqdn     = TRUE;
-    } else {
-        nm_assert_not_reached();
     }
 
     if (!config->hostname && config->send_hostname) {
@@ -1080,7 +1736,7 @@ config_init(NMDhcpClientConfig *config, const NMDhcpClientConfig *src)
         if (!config->send_hostname) {
             nm_clear_g_free((gpointer *) &config->hostname);
         } else if ((config->use_fqdn && !nm_sd_dns_name_is_valid(config->hostname))
-                   || (!config->use_fqdn && !nm_sd_hostname_is_valid(config->hostname, FALSE))) {
+                   || (!config->use_fqdn && !nm_hostname_is_valid(config->hostname, FALSE))) {
             nm_log_warn(LOGD_DHCP,
                         "dhcp%c: %s '%s' is invalid, will be ignored",
                         nm_utils_addr_family_to_char(config->addr_family),
@@ -1106,54 +1762,13 @@ config_clear(NMDhcpClientConfig *config)
     nm_clear_g_free((gpointer *) &config->anycast_address);
     nm_clear_g_free((gpointer *) &config->hostname);
     nm_clear_g_free((gpointer *) &config->mud_url);
-
-    nm_clear_pointer((gpointer *) &config->reject_servers, g_strfreev);
+    nm_clear_g_free((gpointer *) &config->reject_servers);
 
     if (config->addr_family == AF_INET) {
         nm_clear_g_free((gpointer *) &config->v4.last_address);
     }
 }
 
-int
-nm_dhcp_client_get_addr_family(NMDhcpClient *self)
-{
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    return priv->config.addr_family;
-}
-
-const char *
-nm_dhcp_client_get_iface(NMDhcpClient *self)
-{
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    return priv->config.iface;
-}
-
-NMDedupMultiIndex *
-nm_dhcp_client_get_multi_idx(NMDhcpClient *self)
-{
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    return nm_l3cfg_get_multi_idx(priv->config.l3cfg);
-}
-
-int
-nm_dhcp_client_get_ifindex(NMDhcpClient *self)
-{
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    return nm_l3cfg_get_ifindex(priv->config.l3cfg);
-}
-
-GBytes *
-nm_dhcp_client_get_effective_client_id(NMDhcpClient *self)
-{
-    NMDhcpClientPrivate *priv = NM_DHCP_CLIENT_GET_PRIVATE(self);
-
-    return priv->effective_client_id;
-}
-
 /*****************************************************************************/
 
 static void
@@ -1165,6 +1780,28 @@ set_property(GObject *object, guint prop_id, const GValue *value, GParamSpec *ps
     case PROP_CONFIG:
         /* construct-only */
         config_init(&priv->config, g_value_get_pointer(value));
+
+        /* I know, this is technically not necessary. It just feels nicer to
+         * explicitly initialize the respective union member. */
+        if (NM_IS_IPv4(priv->config.addr_family)) {
+            priv->v4 = (typeof(priv->v4)){
+                .bound =
+                    {
+                        .invocation = NULL,
+                    },
+                .acd =
+                    {
+                        .addr                = INADDR_ANY,
+                        .state               = NM_OPTION_BOOL_DEFAULT,
+                        .l3cfg_commit_handle = NULL,
+                        .done_source         = NULL,
+                    },
+            };
+        } else {
+            priv->v6 = (typeof(priv->v6)){
+                .lladdr_timeout_source = NULL,
+            };
+        }
         break;
     default:
         G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
@@ -1196,9 +1833,19 @@ dispose(GObject *object)
     watch_cleanup(self);
 
     nm_clear_g_source_inst(&priv->no_lease_timeout_source);
-    nm_clear_g_source_inst(&priv->ipv6_lladdr_timeout_source);
+
+    if (!NM_IS_IPv4(priv->config.addr_family)) {
+        nm_clear_g_source_inst(&priv->v6.lladdr_timeout_source);
+        nm_clear_g_source_inst(&priv->v6.dad_timeout_source);
+    }
+
     nm_clear_pointer(&priv->effective_client_id, g_bytes_unref);
 
+    nm_assert(!priv->watch_source);
+    nm_assert(!priv->l3cd_next);
+    nm_assert(!priv->l3cd_curr);
+    nm_assert(priv->l3cfg_notify.id == 0);
+
     G_OBJECT_CLASS(nm_dhcp_client_parent_class)->dispose(object);
 }
 
@@ -1223,6 +1870,8 @@ nm_dhcp_client_class_init(NMDhcpClientClass *client_class)
     object_class->dispose      = dispose;
     object_class->finalize     = finalize;
     object_class->set_property = set_property;
+    client_class->accept       = _accept;
+    client_class->decline      = decline;
 
     client_class->stop     = stop;
     client_class->get_duid = get_duid;