about summary refs log tree commit diff
path: root/src/core/dhcp
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
parent1a62dcfdc0470be37743914da69fe0b0677c6bfb (diff)
parent4741f1a52215c7ba466140912084d6906185bef3 (diff)
Merge branch 'debian/master' into ubuntu/master
Diffstat (limited to 'src/core/dhcp')
-rw-r--r--src/core/dhcp/nm-dhcp-client.c1445
-rw-r--r--src/core/dhcp/nm-dhcp-client.h79
-rw-r--r--src/core/dhcp/nm-dhcp-helper.c144
-rw-r--r--src/core/dhcp/nm-dhcp-listener.c22
-rw-r--r--src/core/dhcp/nm-dhcp-manager.c70
-rw-r--r--src/core/dhcp/nm-dhcp-nettools.c486
-rw-r--r--src/core/dhcp/nm-dhcp-systemd.c734
-rw-r--r--src/core/dhcp/nm-dhcp-utils.c200
-rw-r--r--src/core/dhcp/nm-dhcp-utils.h51
-rw-r--r--src/core/dhcp/tests/test-dhcp-utils.c48
10 files changed, 1800 insertions, 1479 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;
diff --git a/src/core/dhcp/nm-dhcp-client.h b/src/core/dhcp/nm-dhcp-client.h
index 249bd013..51c6bc04 100644
--- a/src/core/dhcp/nm-dhcp-client.h
+++ b/src/core/dhcp/nm-dhcp-client.h
@@ -27,16 +27,16 @@
 #define NM_DHCP_CLIENT_NOTIFY "dhcp-notify"
 
 typedef enum {
-    NM_DHCP_STATE_UNKNOWN = 0,
-    NM_DHCP_STATE_BOUND,      /* new lease */
-    NM_DHCP_STATE_EXTENDED,   /* lease extended */
-    NM_DHCP_STATE_TIMEOUT,    /* timed out contacting server */
-    NM_DHCP_STATE_DONE,       /* client reported it's stopping */
-    NM_DHCP_STATE_EXPIRE,     /* lease expired or NAKed */
-    NM_DHCP_STATE_FAIL,       /* failed for some reason */
-    NM_DHCP_STATE_TERMINATED, /* client is no longer running */
-    NM_DHCP_STATE_NOOP,       /* state is a non operation for NetworkManager */
-} NMDhcpState;
+    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,
+} NMDhcpClientEventType;
 
 typedef enum _nm_packed {
     NM_DHCP_CLIENT_NOTIFY_TYPE_LEASE_UPDATE,
@@ -82,17 +82,8 @@ typedef struct {
     };
 } NMDhcpClientNotifyData;
 
-const char *nm_dhcp_state_to_string(NMDhcpState state);
-
-/* FIXME(l3cfg:dhcp:config): nm_dhcp_manager_start_ip[46]() has a gazillion of parameters,
- * those get passed on as CONSTRUCT_ONLY properties to the NMDhcpClient. Drop
- * all these parameters, and let the caller provide one NMDhcpClientConfig
- * instance. There will be only one GObject property (NM_DHCP_CLIENT_CONFIG),
- * which is CONSTRUCT_ONLY and takes a (mandatory) G_TYPE_POINTER for the
- * configuration.
- *
- * Since NMDhcpClientConfig has an addr_family, we also don't need separate
- * nm_dhcp_manager_start_ip[46]() methods. */
+const char *nm_dhcp_client_event_type_to_string(NMDhcpClientEventType client_event_type);
+
 typedef struct {
     int addr_family;
 
@@ -156,12 +147,17 @@ typedef struct {
 
     union {
         struct {
+            /* The address from the previous lease */
+            const char *last_address;
+
+            /* Whether to do ACD for the DHCPv4 address. With timeout zero, ACD
+             * is disabled. */
+            guint acd_timeout_msec;
+
             /* Set BOOTP broadcast flag in request packets, so that servers
              * will always broadcast replies. */
             bool request_broadcast : 1;
 
-            /* The address from the previous lease */
-            const char *last_address;
         } v4;
         struct {
             /* If set, the DUID from the connection is used; otherwise
@@ -208,9 +204,12 @@ typedef struct {
 
     gboolean (*ip4_start)(NMDhcpClient *self, GError **error);
 
-    gboolean (*accept)(NMDhcpClient *self, GError **error);
+    gboolean (*accept)(NMDhcpClient *self, const NML3ConfigData *l3cd, GError **error);
 
-    gboolean (*decline)(NMDhcpClient *self, const char *error_message, GError **error);
+    gboolean (*decline)(NMDhcpClient         *self,
+                        const NML3ConfigData *l3cd,
+                        const char           *error_message,
+                        GError              **error);
 
     gboolean (*ip6_start)(NMDhcpClient *self, const struct in6_addr *ll_addr, GError **error);
 
@@ -230,8 +229,7 @@ typedef struct {
 
 GType nm_dhcp_client_get_type(void);
 
-gboolean nm_dhcp_client_start_ip4(NMDhcpClient *self, GError **error);
-gboolean nm_dhcp_client_start_ip6(NMDhcpClient *self, GError **error);
+gboolean nm_dhcp_client_start(NMDhcpClient *self, GError **error);
 
 const NMDhcpClientConfig *nm_dhcp_client_get_config(NMDhcpClient *self);
 
@@ -250,11 +248,6 @@ nm_dhcp_client_get_lease(NMDhcpClient *self)
     return NULL;
 }
 
-gboolean nm_dhcp_client_accept(NMDhcpClient *self, GError **error);
-gboolean nm_dhcp_client_can_accept(NMDhcpClient *self);
-
-gboolean nm_dhcp_client_decline(NMDhcpClient *self, const char *error_message, GError **error);
-
 void nm_dhcp_client_stop(NMDhcpClient *self, gboolean release);
 
 /* Backend helpers for subclasses */
@@ -268,15 +261,19 @@ void nm_dhcp_client_watch_child(NMDhcpClient *self, pid_t pid);
 
 void nm_dhcp_client_stop_watch_child(NMDhcpClient *self, pid_t pid);
 
-void
-nm_dhcp_client_set_state(NMDhcpClient *self, NMDhcpState new_state, const NML3ConfigData *l3cd);
+void _nm_dhcp_client_notify(NMDhcpClient         *self,
+                            NMDhcpClientEventType client_event_type,
+                            const NML3ConfigData *l3cd);
+
+gboolean _nm_dhcp_client_accept_offer(NMDhcpClient *self, gconstpointer p_yiaddr);
 
-gboolean nm_dhcp_client_handle_event(gpointer      unused,
-                                     const char   *iface,
-                                     int           pid,
-                                     GVariant     *options,
-                                     const char   *reason,
-                                     NMDhcpClient *self);
+gboolean nm_dhcp_client_handle_event(gpointer               unused,
+                                     const char            *iface,
+                                     int                    pid,
+                                     GVariant              *options,
+                                     const char            *reason,
+                                     GDBusMethodInvocation *invocation,
+                                     NMDhcpClient          *self);
 
 void nm_dhcp_client_emit_ipv6_prefix_delegated(NMDhcpClient               *self,
                                                const NMPlatformIP6Address *prefix);
@@ -291,6 +288,8 @@ int                nm_dhcp_client_get_ifindex(NMDhcpClient *self);
 void    nm_dhcp_client_set_effective_client_id(NMDhcpClient *self, GBytes *client_id);
 GBytes *nm_dhcp_client_get_effective_client_id(NMDhcpClient *self);
 
+NML3ConfigData *nm_dhcp_client_create_l3cd(NMDhcpClient *self);
+
 /*****************************************************************************
  * Client data
  *****************************************************************************/
diff --git a/src/core/dhcp/nm-dhcp-helper.c b/src/core/dhcp/nm-dhcp-helper.c
index 41862f2b..5a17f4e8 100644
--- a/src/core/dhcp/nm-dhcp-helper.c
+++ b/src/core/dhcp/nm-dhcp-helper.c
@@ -100,32 +100,20 @@ next:;
     return g_variant_ref_sink(g_variant_new("(a{sv})", &builder));
 }
 
-static void
-kill_pid(void)
-{
-    const char *pid_str;
-    pid_t       pid = 0;
-
-    pid_str = getenv("pid");
-    if (pid_str)
-        pid = strtol(pid_str, NULL, 10);
-    if (pid) {
-        _LOGI("a fatal error occurred, kill dhclient instance with pid %d", pid);
-        kill(pid, SIGTERM);
-    }
-}
-
 int
 main(int argc, char *argv[])
 {
-    gs_unref_object GDBusConnection *connection = NULL;
-    gs_free_error GError            *error      = NULL;
-    gs_unref_variant GVariant       *parameters = NULL;
-    gs_unref_variant GVariant       *result     = NULL;
-    gboolean                         success    = FALSE;
+    gs_unref_object GDBusConnection *connection  = NULL;
+    gs_free_error GError            *error       = NULL;
+    gs_free_error GError            *error_flush = NULL;
+    gs_unref_variant GVariant       *parameters  = NULL;
+    gs_unref_variant GVariant       *result      = NULL;
+    gs_free char                    *s_err       = NULL;
+    gboolean                         success;
     guint                            try_count;
     gint64                           time_start;
     gint64                           time_end;
+    gint64                           remaining_time;
 
     /* Connecting to the unix socket can fail with EAGAIN if there are too
      * many pending connections and the server can't accept them in time
@@ -136,6 +124,8 @@ main(int argc, char *argv[])
     time_end   = time_start + (5000 * 1000L);
     try_count  = 0;
 
+    _LOGi("nm-dhcp-helper: event called");
+
 do_connect:
     try_count++;
     connection =
@@ -146,16 +136,16 @@ do_connect:
                                                &error);
     if (!connection) {
         if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_WOULD_BLOCK)) {
-            gint64 time_remaining = time_end - g_get_monotonic_time();
-            gint64 interval;
+            remaining_time = time_end - g_get_monotonic_time();
+            if (remaining_time > 0) {
+                gint64 interval;
 
-            if (time_remaining > 0) {
                 _LOGi("failure to connect: %s (retry %u, waited %lld ms)",
                       error->message,
                       try_count,
-                      (long long) (time_end - time_remaining - time_start) / 1000);
+                      (long long) (time_end - remaining_time - time_start) / 1000);
                 interval = NM_CLAMP((gint64) (100L * (1L << NM_MIN(try_count, 31))), 5000, 100000);
-                g_usleep(NM_MIN(interval, time_remaining));
+                g_usleep(NM_MIN(interval, remaining_time));
                 g_clear_error(&error);
                 goto do_connect;
             }
@@ -163,6 +153,7 @@ do_connect:
 
         g_dbus_error_strip_remote_error(error);
         _LOGE("could not connect to NetworkManager D-Bus socket: %s", error->message);
+        success = FALSE;
         goto out;
     }
 
@@ -180,63 +171,78 @@ do_notify:
                                          parameters,
                                          NULL,
                                          G_DBUS_CALL_FLAGS_NONE,
-                                         1000,
+                                         60000,
                                          NULL,
                                          &error);
 
-    if (!result) {
-        gs_free char *s_err = NULL;
+    if (result) {
+        success = TRUE;
+        goto out;
+    }
 
-        s_err = g_dbus_error_get_remote_error(error);
-        if (NM_IN_STRSET(s_err, "org.freedesktop.DBus.Error.UnknownMethod")) {
-            gint64 remaining_time = time_end - g_get_monotonic_time();
-            gint64 interval;
+    s_err = g_dbus_error_get_remote_error(error);
 
-            /* I am not sure that a race can actually happen, as we register the object
-             * on the server side during GDBusServer:new-connection signal.
-             *
-             * However, there was also a race for subscribing to an event, so let's just
-             * do some retry. */
-            if (remaining_time > 0) {
-                _LOGi("failure to call notify: %s (retry %u)", error->message, try_count);
-                interval = NM_CLAMP((gint64) (100L * (1L << NM_MIN(try_count, 31))), 5000, 25000);
-                g_usleep(NM_MIN(interval, remaining_time));
-                g_clear_error(&error);
-                goto do_notify;
-            }
-        }
+    if (NM_IN_STRSET(s_err, "org.freedesktop.NetworkManager.Device.Failed")) {
+        _LOGi("notify failed with reason: %s", error->message);
+        success = FALSE;
+        goto out;
+    }
+
+    if (!NM_IN_STRSET(s_err, "org.freedesktop.DBus.Error.UnknownMethod")) {
+        /* Some unexpected error. We treat that as a failure. In particular,
+         * the daemon will fail the request if ACD fails. This causes nm-dhcp-helper
+         * to fail, which in turn causes dhclient to send a DECLINE. */
         _LOGW("failure to call notify: %s (try signal via Event)", error->message);
+        success = FALSE;
+        goto out;
+    }
+
+    /* I am not sure that a race can actually happen, as we register the object
+     * on the server side during GDBusServer:new-connection signal.
+     *
+     * However, there was also a race for subscribing to an event, so let's just
+     * do some retry. */
+    remaining_time = time_end - g_get_monotonic_time();
+    if (remaining_time > 0) {
+        gint64 interval;
+
+        _LOGi("failure to call notify: %s (retry %u)", error->message, try_count);
+        interval = NM_CLAMP((gint64) (100L * (1L << NM_MIN(try_count, 31))), 5000, 25000);
+        g_usleep(NM_MIN(interval, remaining_time));
         g_clear_error(&error);
+        goto do_notify;
+    }
 
-        /* for backward compatibility, try to emit the signal. There is no stable
-         * API between the dhcp-helper and NetworkManager. However, while upgrading
-         * the NetworkManager package, a newer helper might want to notify an
-         * older server, which still uses the "Event". */
-        if (!g_dbus_connection_emit_signal(connection,
-                                           NULL,
-                                           "/",
-                                           NM_DHCP_CLIENT_DBUS_IFACE,
-                                           "Event",
-                                           parameters,
-                                           &error)) {
-            g_dbus_error_strip_remote_error(error);
-            _LOGE("could not send DHCP Event signal: %s", error->message);
-            goto out;
-        }
+    /* for backward compatibility, try to emit the signal. There is no stable
+     * API between the dhcp-helper and NetworkManager. However, while upgrading
+     * the NetworkManager package, a newer helper might want to notify an
+     * older server, which still uses the "Event". */
+
+    _LOGW("failure to call notify: %s (try signal via Event)", error->message);
+    g_clear_error(&error);
+
+    if (g_dbus_connection_emit_signal(connection,
+                                      NULL,
+                                      "/",
+                                      NM_DHCP_CLIENT_DBUS_IFACE,
+                                      "Event",
+                                      parameters,
+                                      &error)) {
         /* We were able to send the asynchronous Event. Consider that a success. */
         success = TRUE;
-    } else
-        success = TRUE;
-
-    if (!g_dbus_connection_flush_sync(connection, NULL, &error)) {
-        g_dbus_error_strip_remote_error(error);
-        _LOGE("could not flush D-Bus connection: %s", error->message);
-        success = FALSE;
         goto out;
     }
 
+    g_dbus_error_strip_remote_error(error);
+    _LOGE("could not send DHCP Event signal: %s", error->message);
+    success = FALSE;
+
 out:
-    if (!success)
-        kill_pid();
+    if (!g_dbus_connection_flush_sync(connection, NULL, &error_flush)) {
+        _LOGE("could not flush D-Bus connection: %s", error_flush->message);
+        /* if we considered this a success so far, don't fail because of this. */
+    }
+
+    _LOGi("success: %s", success ? "YES" : "NO");
     return success ? EXIT_SUCCESS : EXIT_FAILURE;
 }
diff --git a/src/core/dhcp/nm-dhcp-listener.c b/src/core/dhcp/nm-dhcp-listener.c
index 2c567593..0854c1dc 100644
--- a/src/core/dhcp/nm-dhcp-listener.c
+++ b/src/core/dhcp/nm-dhcp-listener.c
@@ -128,7 +128,7 @@ get_option(GVariant *options, const char *key)
 }
 
 static void
-_method_call_handle(NMDhcpListener *self, GVariant *parameters)
+_method_call_handle(NMDhcpListener *self, GDBusMethodInvocation *invocation, GVariant *parameters)
 {
     gs_free char              *iface   = NULL;
     gs_free char              *pid_str = NULL;
@@ -142,23 +142,23 @@ _method_call_handle(NMDhcpListener *self, GVariant *parameters)
     iface = get_option(options, "interface");
     if (iface == NULL) {
         _LOGW("dhcp-event: didn't have associated interface.");
-        return;
+        goto out;
     }
 
     pid_str = get_option(options, "pid");
     pid     = _nm_utils_ascii_str_to_int64(pid_str, 10, 0, G_MAXINT32, -1);
     if (pid == -1) {
         _LOGW("dhcp-event: couldn't convert PID '%s' to an integer", pid_str ?: "(null)");
-        return;
+        goto out;
     }
 
     reason = get_option(options, "reason");
     if (reason == NULL) {
         _LOGW("dhcp-event: (pid %d) DHCP event didn't have a reason", pid);
-        return;
+        goto out;
     }
 
-    g_signal_emit(self, signals[EVENT], 0, iface, pid, options, reason, &handled);
+    g_signal_emit(self, signals[EVENT], 0, iface, pid, options, reason, invocation, &handled);
     if (!handled) {
         if (g_ascii_strcasecmp(reason, "RELEASE") == 0) {
             /* Ignore event when the dhcp client gets killed and we receive its last message */
@@ -166,6 +166,10 @@ _method_call_handle(NMDhcpListener *self, GVariant *parameters)
         } else
             _LOGW("dhcp-event: (pid %d) unhandled DHCP event for interface %s", pid, iface);
     }
+
+out:
+    if (!handled)
+        g_dbus_method_invocation_return_value(invocation, NULL);
 }
 
 static void
@@ -190,8 +194,7 @@ _method_call(GDBusConnection       *connection,
         return;
     }
 
-    _method_call_handle(self, parameters);
-    g_dbus_method_invocation_return_value(invocation, NULL);
+    _method_call_handle(self, invocation, parameters);
 }
 
 static GDBusInterfaceInfo *const interface_info = NM_DEFINE_GDBUS_INTERFACE_INFO(
@@ -311,9 +314,10 @@ nm_dhcp_listener_class_init(NMDhcpListenerClass *listener_class)
                                   NULL,
                                   NULL,
                                   G_TYPE_BOOLEAN, /* listeners return TRUE if handled */
-                                  4,
+                                  5,
                                   G_TYPE_STRING,  /* iface */
                                   G_TYPE_INT,     /* pid */
                                   G_TYPE_VARIANT, /* options */
-                                  G_TYPE_STRING); /* reason */
+                                  G_TYPE_STRING,  /* reason */
+                                  G_TYPE_DBUS_METHOD_INVOCATION /* invocation*/);
 }
diff --git a/src/core/dhcp/nm-dhcp-manager.c b/src/core/dhcp/nm-dhcp-manager.c
index f353e637..cfff23f8 100644
--- a/src/core/dhcp/nm-dhcp-manager.c
+++ b/src/core/dhcp/nm-dhcp-manager.c
@@ -42,6 +42,30 @@ G_DEFINE_TYPE(NMDhcpManager, nm_dhcp_manager, G_TYPE_OBJECT)
 
 /*****************************************************************************/
 
+#undef _NMLOG_ENABLED
+#define _NMLOG_ENABLED(level, addr_family) nm_logging_enabled((level), _LOGD_DHCP(addr_family))
+
+#define _NMLOG(level, addr_family, ...)                                                           \
+    G_STMT_START                                                                                  \
+    {                                                                                             \
+        const int         _addr_family = (addr_family);                                           \
+        const NMLogLevel  _log_level   = (level);                                                 \
+        const NMLogDomain _log_domain  = LOGD_DHCP_af(_addr_family);                              \
+                                                                                                  \
+        if (nm_logging_enabled(_log_level, _log_domain)) {                                        \
+            _nm_log(_log_level,                                                                   \
+                    _log_domain,                                                                  \
+                    0,                                                                            \
+                    NULL,                                                                         \
+                    NULL,                                                                         \
+                    "dhcp%s: " _NM_UTILS_MACRO_FIRST(__VA_ARGS__),                                \
+                    nm_utils_addr_family_to_str(_addr_family) _NM_UTILS_MACRO_REST(__VA_ARGS__)); \
+        }                                                                                         \
+    }                                                                                             \
+    G_STMT_END
+
+/*****************************************************************************/
+
 /* default to installed helper, but can be modified for testing */
 const char *nm_dhcp_helper_path = LIBEXECDIR "/nm-dhcp-helper";
 
@@ -131,8 +155,7 @@ NMDhcpClient *
 nm_dhcp_manager_start_client(NMDhcpManager *self, NMDhcpClientConfig *config, GError **error)
 {
     NMDhcpManagerPrivate         *priv;
-    gs_unref_object NMDhcpClient *client  = NULL;
-    gboolean                      success = FALSE;
+    gs_unref_object NMDhcpClient *client = NULL;
     gsize                         hwaddr_len;
     GType                         gtype;
 
@@ -168,11 +191,10 @@ nm_dhcp_manager_start_client(NMDhcpManager *self, NMDhcpClientConfig *config, GE
 
     gtype = _client_factory_get_gtype(priv->client_factory, config->addr_family);
 
-    nm_log_trace(LOGD_DHCP,
-                 "dhcp%c: creating IPv%c DHCP client of type %s",
-                 nm_utils_addr_family_to_char(config->addr_family),
-                 nm_utils_addr_family_to_char(config->addr_family),
-                 g_type_name(gtype));
+    _LOGT(config->addr_family,
+          "creating IPv%c DHCP client of type %s",
+          nm_utils_addr_family_to_char(config->addr_family),
+          g_type_name(gtype));
 
     client = g_object_new(gtype, NM_DHCP_CLIENT_CONFIG, config, NULL);
 
@@ -202,13 +224,7 @@ nm_dhcp_manager_start_client(NMDhcpManager *self, NMDhcpClientConfig *config, GE
      * default outside of NetworkManager API.
      */
 
-    if (config->addr_family == AF_INET) {
-        success = nm_dhcp_client_start_ip4(client, error);
-    } else {
-        success = nm_dhcp_client_start_ip6(client, error);
-    }
-
-    if (!success)
+    if (!nm_dhcp_client_start(client, error))
         return NULL;
 
     return g_steal_pointer(&client);
@@ -251,11 +267,11 @@ nm_dhcp_manager_init(NMDhcpManager *self)
         if (!f)
             continue;
 
-        nm_log_dbg(LOGD_DHCP,
-                   "dhcp-init: enabled DHCP client '%s'%s%s",
-                   f->name,
-                   _client_factory_available(f) ? "" : " (not available)",
-                   f->undocumented ? " (undocumented internal plugin)" : "");
+        _LOGD(AF_UNSPEC,
+              "init: enabled DHCP client '%s'%s%s",
+              f->name,
+              _client_factory_available(f) ? "" : " (not available)",
+              f->undocumented ? " (undocumented internal plugin)" : "");
     }
 
     /* Client-specific setup */
@@ -268,20 +284,20 @@ nm_dhcp_manager_init(NMDhcpManager *self)
     if (client) {
         client_factory = _client_factory_available(_client_factory_find_by_name(client));
         if (!client_factory)
-            nm_log_warn(LOGD_DHCP, "dhcp-init: DHCP client '%s' not available", client);
+            _LOGW(AF_UNSPEC, "init: DHCP client '%s' not available", client);
     }
     if (!client_factory) {
         client_factory = _client_factory_find_by_name("" NM_CONFIG_DEFAULT_MAIN_DHCP);
         if (!client_factory)
-            nm_log_err(LOGD_DHCP,
-                       "dhcp-init: default DHCP client '%s' is not installed",
-                       NM_CONFIG_DEFAULT_MAIN_DHCP);
+            _LOGE(AF_UNSPEC,
+                  "init: default DHCP client '%s' is not installed",
+                  NM_CONFIG_DEFAULT_MAIN_DHCP);
         else {
             client_factory = _client_factory_available(client_factory);
             if (!client_factory)
-                nm_log_info(LOGD_DHCP,
-                            "dhcp-init: default DHCP client '%s' is not available",
-                            NM_CONFIG_DEFAULT_MAIN_DHCP);
+                _LOGI(AF_UNSPEC,
+                      "init: default DHCP client '%s' is not available",
+                      NM_CONFIG_DEFAULT_MAIN_DHCP);
         }
     }
     if (!client_factory) {
@@ -294,7 +310,7 @@ nm_dhcp_manager_init(NMDhcpManager *self)
 
     g_return_if_fail(client_factory);
 
-    nm_log_info(LOGD_DHCP, "dhcp-init: Using DHCP client '%s'", client_factory->name);
+    _LOGI(AF_UNSPEC, "init: Using DHCP client '%s'", client_factory->name);
 
     /* NOTE: currently the DHCP plugin is chosen once at start. It's not
      * possible to reload that configuration. If that ever becomes possible,
diff --git a/src/core/dhcp/nm-dhcp-nettools.c b/src/core/dhcp/nm-dhcp-nettools.c
index aac18967..05b7b52e 100644
--- a/src/core/dhcp/nm-dhcp-nettools.c
+++ b/src/core/dhcp/nm-dhcp-nettools.c
@@ -13,22 +13,24 @@
 #include <ctype.h>
 #include <net/if_arp.h>
 
+#include "n-dhcp4/src/n-dhcp4.h"
+
 #include "libnm-glib-aux/nm-dedup-multi.h"
-#include "libnm-std-aux/unaligned.h"
+#include "libnm-glib-aux/nm-io-utils.h"
 #include "libnm-glib-aux/nm-str-buf.h"
+#include "libnm-std-aux/unaligned.h"
 
-#include "nm-l3-config-data.h"
-#include "nm-utils.h"
-#include "nm-config.h"
-#include "nm-dhcp-utils.h"
-#include "nm-dhcp-options.h"
-#include "nm-core-utils.h"
 #include "NetworkManagerUtils.h"
 #include "libnm-platform/nm-platform.h"
+#include "nm-config.h"
+#include "nm-core-utils.h"
 #include "nm-dhcp-client-logging.h"
-#include "n-dhcp4/src/n-dhcp4.h"
+#include "nm-dhcp-options.h"
+#include "nm-dhcp-utils.h"
+#include "nm-l3-config-data.h"
+#include "nm-utils.h"
+
 #include "libnm-systemd-shared/nm-sd-utils-shared.h"
-#include "libnm-systemd-core/nm-sd-utils-dhcp.h"
 
 /*****************************************************************************/
 
@@ -50,9 +52,16 @@ typedef struct _NMDhcpNettoolsClass NMDhcpNettoolsClass;
 typedef struct {
     NDhcp4Client      *client;
     NDhcp4ClientProbe *probe;
-    NDhcp4ClientLease *lease;
-    GSource           *event_source;
-    char              *lease_file;
+
+    struct {
+        NDhcp4ClientLease    *lease;
+        const NML3ConfigData *lease_l3cd;
+    } granted;
+
+    GSource *pop_all_events_on_idle_source;
+
+    GSource *event_source;
+    char    *lease_file;
 } NMDhcpNettoolsPrivate;
 
 struct _NMDhcpNettools {
@@ -71,6 +80,10 @@ G_DEFINE_TYPE(NMDhcpNettools, nm_dhcp_nettools, NM_TYPE_DHCP_CLIENT)
 
 /*****************************************************************************/
 
+static void dhcp4_event_pop_all_events_on_idle(NMDhcpNettools *self);
+
+/*****************************************************************************/
+
 static void
 set_error_nettools(GError **error, int r, const char *message)
 {
@@ -151,8 +164,10 @@ lease_option_consume_route(const uint8_t **datap,
 /*****************************************************************************/
 
 static gboolean
-lease_parse_address(NDhcp4ClientLease *lease,
+lease_parse_address(NMDhcpNettools    *self /* for logging context only */,
+                    NDhcp4ClientLease *lease,
                     NML3ConfigData    *l3cd,
+                    const char        *iface,
                     GHashTable        *options,
                     in_addr_t         *out_address,
                     GError           **error)
@@ -223,15 +238,33 @@ lease_parse_address(NDhcp4ClientLease *lease,
     }
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_SUBNET_MASK, &l_data, &l_data_len);
-    if (r != 0 || !nm_dhcp_lease_data_parse_in_addr(l_data, l_data_len, &a_netmask)) {
-        nm_utils_error_set_literal(error,
-                                   NM_UTILS_ERROR_UNKNOWN,
-                                   "could not get netmask from lease");
-        return FALSE;
+    if (r == N_DHCP4_E_UNSET) {
+        char str1[NM_UTILS_INET_ADDRSTRLEN];
+        char str2[NM_UTILS_INET_ADDRSTRLEN];
+
+        /* Some DHCP servers may not set the subnet-mask (issue#1037).
+         * Do the same as the dhclient plugin and use a default. */
+        a_plen    = _nm_utils_ip4_get_default_prefix(a_address.s_addr);
+        a_netmask = _nm_utils_ip4_prefix_to_netmask(a_plen);
+        _LOGT("missing subnet mask (option 1). Guess %s based on IP address %s",
+              _nm_utils_inet4_ntop(a_netmask, str1),
+              _nm_utils_inet4_ntop(a_address.s_addr, str2));
+    } else {
+        if (r != 0
+            || !nm_dhcp_lease_data_parse_in_addr(l_data,
+                                                 l_data_len,
+                                                 &a_netmask,
+                                                 iface,
+                                                 NM_DHCP_OPTION_DHCP4_SUBNET_MASK)) {
+            nm_utils_error_set_literal(error,
+                                       NM_UTILS_ERROR_UNKNOWN,
+                                       "could not get netmask from lease");
+            return FALSE;
+        }
+        a_plen    = _nm_utils_ip4_netmask_to_prefix(a_netmask);
+        a_netmask = _nm_utils_ip4_prefix_to_netmask(a_plen);
     }
 
-    a_plen = nm_utils_ip4_netmask_to_prefix(a_netmask);
-
     nm_dhcp_option_add_option_in_addr(options,
                                       AF_INET,
                                       NM_DHCP_OPTION_DHCP4_NM_IP_ADDRESS,
@@ -277,6 +310,7 @@ lease_parse_address(NDhcp4ClientLease *lease,
 static void
 lease_parse_address_list(NDhcp4ClientLease       *lease,
                          NML3ConfigData          *l3cd,
+                         const char              *iface,
                          NMDhcpOptionDhcp4Options option,
                          GHashTable              *options,
                          NMStrBuf                *sbuf)
@@ -289,8 +323,14 @@ lease_parse_address_list(NDhcp4ClientLease       *lease,
     if (r != 0)
         return;
 
-    if (l_data_len == 0 || l_data_len % 4 != 0)
+    if (l_data_len == 0 || l_data_len % 4 != 0) {
+        nm_dhcp_lease_log_invalid_option(iface,
+                                         AF_INET,
+                                         option,
+                                         "wrong option length %lu",
+                                         (unsigned long) l_data_len);
         return;
+    }
 
     nm_str_buf_reset(sbuf);
 
@@ -303,9 +343,14 @@ lease_parse_address_list(NDhcp4ClientLease       *lease,
 
         switch (option) {
         case NM_DHCP_OPTION_DHCP4_DOMAIN_NAME_SERVER:
-            if (addr == 0 || nm_ip4_addr_is_localhost(addr)) {
+            if (addr == 0 || nm_utils_ip4_address_is_loopback(addr)) {
                 /* Skip localhost addresses, like also networkd does.
                  * See https://github.com/systemd/systemd/issues/4524. */
+                nm_dhcp_lease_log_invalid_option(iface,
+                                                 AF_INET,
+                                                 option,
+                                                 "address %s is ignored",
+                                                 _nm_utils_inet4_ntop(addr, addr_str));
                 continue;
             }
             nm_l3_config_data_add_nameserver(l3cd, AF_INET, &addr);
@@ -497,7 +542,10 @@ lease_parse_routes(NDhcp4ClientLease *lease,
 }
 
 static void
-lease_parse_search_domains(NDhcp4ClientLease *lease, NML3ConfigData *l3cd, GHashTable *options)
+lease_parse_search_domains(NDhcp4ClientLease *lease,
+                           NML3ConfigData    *l3cd,
+                           const char        *iface,
+                           GHashTable        *options)
 {
     gs_strfreev char **domains = NULL;
     const guint8      *l_data;
@@ -509,7 +557,11 @@ lease_parse_search_domains(NDhcp4ClientLease *lease, NML3ConfigData *l3cd, GHash
     if (r != 0)
         return;
 
-    domains = nm_dhcp_lease_data_parse_search_list(l_data, l_data_len);
+    domains = nm_dhcp_lease_data_parse_search_list(l_data,
+                                                   l_data_len,
+                                                   iface,
+                                                   AF_INET,
+                                                   NM_DHCP_OPTION_DHCP4_DOMAIN_SEARCH_LIST);
 
     if (!domains || !domains[0])
         return;
@@ -551,12 +603,9 @@ lease_parse_private_options(NDhcp4ClientLease *lease, GHashTable *options)
 }
 
 static NML3ConfigData *
-lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
-                    const char        *iface,
-                    int                ifindex,
-                    NDhcp4ClientLease *lease,
-                    GError           **error)
+lease_to_ip4_config(NMDhcpNettools *self, NDhcp4ClientLease *lease, GError **error)
 {
+    const char                             *iface;
     nm_auto_str_buf NMStrBuf                sbuf    = NM_STR_BUF_INIT(0, FALSE);
     nm_auto_unref_l3cd_init NML3ConfigData *l3cd    = NULL;
     gs_unref_hashtable GHashTable          *options = NULL;
@@ -569,13 +618,15 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
     struct in_addr                          v_inaddr_s;
     int                                     r;
 
-    g_return_val_if_fail(lease != NULL, NULL);
+    nm_assert(lease);
+
+    iface = nm_dhcp_client_get_iface(NM_DHCP_CLIENT(self));
 
-    l3cd = nm_l3_config_data_new(multi_idx, ifindex, NM_IP_CONFIG_SOURCE_DHCP);
+    l3cd = nm_dhcp_client_create_l3cd(NM_DHCP_CLIENT(self));
 
     options = nm_dhcp_option_create_options_dict();
 
-    if (!lease_parse_address(lease, l3cd, options, &lease_address, error))
+    if (!lease_parse_address(self, lease, l3cd, iface, options, &lease_address, error))
         return NULL;
 
     r = n_dhcp4_client_lease_get_server_identifier(lease, &v_inaddr_s);
@@ -587,7 +638,12 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
     }
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_BROADCAST, &l_data, &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_in_addr(l_data, l_data_len, &v_inaddr)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_in_addr(l_data,
+                                            l_data_len,
+                                            &v_inaddr,
+                                            iface,
+                                            NM_DHCP_OPTION_DHCP4_BROADCAST)) {
         nm_dhcp_option_add_option_in_addr(options,
                                           AF_INET,
                                           NM_DHCP_OPTION_DHCP4_BROADCAST,
@@ -596,10 +652,21 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
 
     lease_parse_routes(lease, l3cd, lease_address, options, &sbuf);
 
-    lease_parse_address_list(lease, l3cd, NM_DHCP_OPTION_DHCP4_DOMAIN_NAME_SERVER, options, &sbuf);
+    lease_parse_address_list(lease,
+                             l3cd,
+                             iface,
+                             NM_DHCP_OPTION_DHCP4_DOMAIN_NAME_SERVER,
+                             options,
+                             &sbuf);
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_DOMAIN_NAME, &l_data, &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_cstr(l_data, l_data_len, &l_data_len)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_cstr(l_data,
+                                         l_data_len,
+                                         &l_data_len,
+                                         iface,
+                                         AF_INET,
+                                         NM_DHCP_OPTION_DHCP4_DOMAIN_NAME)) {
         gs_free const char **domains = NULL;
 
         nm_str_buf_reset(&sbuf);
@@ -615,7 +682,10 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
             for (i = 0; domains[i]; i++) {
                 gs_free char *s = NULL;
 
-                s = nm_dhcp_lease_data_parse_domain_validate(domains[i]);
+                s = nm_dhcp_lease_data_parse_domain_validate(domains[i],
+                                                             iface,
+                                                             AF_INET,
+                                                             NM_DHCP_OPTION_DHCP4_DOMAIN_NAME);
                 if (!s)
                     continue;
 
@@ -633,10 +703,16 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
         }
     }
 
-    lease_parse_search_domains(lease, l3cd, options);
+    lease_parse_search_domains(lease, l3cd, iface, options);
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_INTERFACE_MTU, &l_data, &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_mtu(l_data, l_data_len, &v_u16)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_mtu(l_data,
+                                        l_data_len,
+                                        &v_u16,
+                                        iface,
+                                        AF_INET,
+                                        NM_DHCP_OPTION_DHCP4_INTERFACE_MTU)) {
         nm_dhcp_option_add_option_u64(options, AF_INET, NM_DHCP_OPTION_DHCP4_INTERFACE_MTU, v_u16);
         nm_l3_config_data_set_mtu(l3cd, v_u16);
     }
@@ -649,15 +725,26 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
     if (r == 0) {
         gs_free char *s = NULL;
 
-        if (nm_dhcp_lease_data_parse_domain(l_data, l_data_len, &s)) {
+        if (nm_dhcp_lease_data_parse_domain(l_data,
+                                            l_data_len,
+                                            &s,
+                                            iface,
+                                            AF_INET,
+                                            NM_DHCP_OPTION_DHCP4_HOST_NAME)) {
             nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_HOST_NAME, s);
         }
     }
 
-    lease_parse_address_list(lease, l3cd, NM_DHCP_OPTION_DHCP4_NTP_SERVER, options, &sbuf);
+    lease_parse_address_list(lease, l3cd, iface, NM_DHCP_OPTION_DHCP4_NTP_SERVER, options, &sbuf);
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_ROOT_PATH, &l_data, &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_cstr(l_data, l_data_len, &l_data_len)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_cstr(l_data,
+                                         l_data_len,
+                                         &l_data_len,
+                                         iface,
+                                         AF_INET,
+                                         NM_DHCP_OPTION_DHCP4_ROOT_PATH)) {
         /* https://tools.ietf.org/html/rfc2132#section-3.19
          *
          *   The path is formatted as a character string consisting of
@@ -679,7 +766,13 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
                             NM_DHCP_OPTION_DHCP4_PRIVATE_PROXY_AUTODISCOVERY,
                             &l_data,
                             &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_cstr(l_data, l_data_len, &l_data_len)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_cstr(l_data,
+                                         l_data_len,
+                                         &l_data_len,
+                                         iface,
+                                         AF_INET,
+                                         NM_DHCP_OPTION_DHCP4_PRIVATE_PROXY_AUTODISCOVERY)) {
         /* https://tools.ietf.org/html/draft-ietf-wrec-wpad-01#section-4.4.1
          *
          * We reject NUL characters inside the string (except trailing NULs).
@@ -699,7 +792,13 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
     }
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_NIS_DOMAIN, &l_data, &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_cstr(l_data, l_data_len, &l_data_len)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_cstr(l_data,
+                                         l_data_len,
+                                         &l_data_len,
+                                         iface,
+                                         AF_INET,
+                                         NM_DHCP_OPTION_DHCP4_NIS_DOMAIN)) {
         gs_free char *to_free = NULL;
 
         /* https://tools.ietf.org/html/rfc2132#section-8.1 */
@@ -725,7 +824,13 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
     }
 
     r = _client_lease_query(lease, NM_DHCP_OPTION_DHCP4_BOOTFILE_NAME, &l_data, &l_data_len);
-    if (r == 0 && nm_dhcp_lease_data_parse_cstr(l_data, l_data_len, &l_data_len)) {
+    if (r == 0
+        && nm_dhcp_lease_data_parse_cstr(l_data,
+                                         l_data_len,
+                                         &l_data_len,
+                                         iface,
+                                         AF_INET,
+                                         NM_DHCP_OPTION_DHCP4_BOOTFILE_NAME)) {
         gs_free char *to_free = NULL;
 
         v_str = nm_utils_buf_utf8safe_escape((char *) l_data,
@@ -738,9 +843,14 @@ lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
                                   v_str ?: "");
     }
 
-    lease_parse_address_list(lease, l3cd, NM_DHCP_OPTION_DHCP4_NIS_SERVERS, options, &sbuf);
+    lease_parse_address_list(lease, l3cd, iface, NM_DHCP_OPTION_DHCP4_NIS_SERVERS, options, &sbuf);
 
-    lease_parse_address_list(lease, l3cd, NM_DHCP_OPTION_DHCP4_NETBIOS_NAMESERVER, options, &sbuf);
+    lease_parse_address_list(lease,
+                             l3cd,
+                             iface,
+                             NM_DHCP_OPTION_DHCP4_NETBIOS_NAMESERVER,
+                             options,
+                             &sbuf);
 
     lease_parse_private_options(lease, options);
 
@@ -778,52 +888,79 @@ lease_save(NMDhcpNettools *self, NDhcp4ClientLease *lease, const char *lease_fil
 }
 
 static void
-bound4_handle(NMDhcpNettools *self, NDhcp4ClientLease *lease, gboolean extended)
+bound4_handle(NMDhcpNettools *self, guint event, NDhcp4ClientLease *lease)
 {
-    NMDhcpNettoolsPrivate                  *priv   = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
-    NMDhcpClient                           *client = NM_DHCP_CLIENT(self);
-    const NMDhcpClientConfig               *client_config;
+    NMDhcpNettoolsPrivate                  *priv  = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
     nm_auto_unref_l3cd_init NML3ConfigData *l3cd  = NULL;
-    GError                                 *error = NULL;
+    gs_free_error GError                   *error = NULL;
 
-    _LOGT("lease available (%s)", extended ? "extended" : "new");
-    client_config = nm_dhcp_client_get_config(client);
-    l3cd          = lease_to_ip4_config(nm_dhcp_client_get_multi_idx(client),
-                               client_config->iface,
-                               nm_dhcp_client_get_ifindex(client),
-                               lease,
-                               &error);
+    nm_assert(NM_IN_SET(event, N_DHCP4_CLIENT_EVENT_GRANTED, N_DHCP4_CLIENT_EVENT_EXTENDED));
+    nm_assert(lease);
+
+    _LOGT("lease available (%s)", (event == N_DHCP4_CLIENT_EVENT_GRANTED) ? "granted" : "extended");
+
+    l3cd = lease_to_ip4_config(self, lease, &error);
     if (!l3cd) {
         _LOGW("failure to parse lease: %s", error->message);
-        g_clear_error(&error);
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
+
+        if (event == N_DHCP4_CLIENT_EVENT_GRANTED) {
+            n_dhcp4_client_lease_decline(lease, "invalid lease");
+            dhcp4_event_pop_all_events_on_idle(self);
+        }
+
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_FAIL, NULL);
         return;
     }
 
-    lease_save(self, lease, priv->lease_file);
-
-    nm_dhcp_client_set_state(NM_DHCP_CLIENT(self),
-                             extended ? NM_DHCP_STATE_EXTENDED : NM_DHCP_STATE_BOUND,
-                             l3cd);
+    if (event == N_DHCP4_CLIENT_EVENT_GRANTED) {
+        priv->granted.lease      = n_dhcp4_client_lease_ref(lease);
+        priv->granted.lease_l3cd = nm_l3_config_data_ref(l3cd);
+    } else
+        lease_save(self, lease, priv->lease_file);
+
+    _nm_dhcp_client_notify(NM_DHCP_CLIENT(self),
+                           event == N_DHCP4_CLIENT_EVENT_GRANTED
+                               ? NM_DHCP_CLIENT_EVENT_TYPE_BOUND
+                               : NM_DHCP_CLIENT_EVENT_TYPE_EXTENDED,
+                           l3cd);
 }
 
 static void
 dhcp4_event_handle(NMDhcpNettools *self, NDhcp4ClientEvent *event)
 {
-    NMDhcpNettoolsPrivate    *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
-    const NMDhcpClientConfig *client_config;
-    struct in_addr            server_id;
-    char                      addr_str[INET_ADDRSTRLEN];
-    int                       r;
+    NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
+    struct in_addr         server_id;
+    struct in_addr         yiaddr;
+    char                   addr_str[INET_ADDRSTRLEN];
+    char                   addr_str2[INET_ADDRSTRLEN];
+    int                    r;
 
-    _LOGT("client event %d", event->event);
-    client_config = nm_dhcp_client_get_config(NM_DHCP_CLIENT(self));
+    if (event->event == N_DHCP4_CLIENT_EVENT_LOG) {
+        _NMLOG(nm_log_level_from_syslog(event->log.level), "event: %s", event->log.message);
+        return;
+    }
+
+    if (!NM_IN_SET(event->event, N_DHCP4_CLIENT_EVENT_LOG)) {
+        /* In almost all events (even those that we don't expect below), we clear
+         * the currently granted lease. That is, because in GRANTED state we
+         * expect to follow up with accept/decline, and that only works while
+         * we are still in the same state. Transitioning away to another state
+         * (on most events) will invalidate that. */
+        nm_clear_pointer(&priv->granted.lease, n_dhcp4_client_lease_unref);
+        nm_clear_l3cd(&priv->granted.lease_l3cd);
+    }
 
     switch (event->event) {
     case N_DHCP4_CLIENT_EVENT_OFFER:
         r = n_dhcp4_client_lease_get_server_identifier(event->offer.lease, &server_id);
         if (r) {
-            _LOGW("selecting lease failed: %d", r);
+            _LOGW("selecting lease failed: could not get DHCP server identifier (%d)", r);
+            return;
+        }
+
+        n_dhcp4_client_lease_get_yiaddr(event->offer.lease, &yiaddr);
+        if (yiaddr.s_addr == INADDR_ANY) {
+            _LOGD("selecting lease failed: no yiaddr address");
             return;
         }
 
@@ -833,47 +970,102 @@ dhcp4_event_handle(NMDhcpNettools *self, NDhcp4ClientEvent *event)
             return;
         }
 
+        if (!_nm_dhcp_client_accept_offer(NM_DHCP_CLIENT(self), &yiaddr.s_addr)) {
+            /* We don't log about this, the parent class is expected to notify about the reasons. */
+            return;
+        }
+
+        _LOGT("selecting offered lease from %s for %s",
+              _nm_utils_inet4_ntop(server_id.s_addr, addr_str),
+              _nm_utils_inet4_ntop(yiaddr.s_addr, addr_str2));
+
         r = n_dhcp4_client_lease_select(event->offer.lease);
+
+        dhcp4_event_pop_all_events_on_idle(self);
+
         if (r) {
             _LOGW("selecting lease failed: %d", r);
             return;
         }
-        break;
+
+        return;
     case N_DHCP4_CLIENT_EVENT_RETRACTED:
     case N_DHCP4_CLIENT_EVENT_EXPIRED:
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_EXPIRE, NULL);
-        break;
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_EXPIRE, NULL);
+        return;
     case N_DHCP4_CLIENT_EVENT_CANCELLED:
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
-        break;
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_FAIL, NULL);
+        return;
     case N_DHCP4_CLIENT_EVENT_GRANTED:
-        priv->lease = n_dhcp4_client_lease_ref(event->granted.lease);
-        bound4_handle(self, event->granted.lease, FALSE);
-        break;
+        bound4_handle(self, event->event, event->granted.lease);
+        return;
     case N_DHCP4_CLIENT_EVENT_EXTENDED:
-        bound4_handle(self, event->extended.lease, TRUE);
-        break;
+        bound4_handle(self, event->event, event->extended.lease);
+        return;
     case N_DHCP4_CLIENT_EVENT_DOWN:
         /* ignore down events, they are purely informational */
-        break;
-    case N_DHCP4_CLIENT_EVENT_LOG:
-    {
-        NMLogLevel nm_level;
-
-        nm_level = nm_log_level_from_syslog(event->log.level);
-        if (nm_logging_enabled(nm_level, LOGD_DHCP4)) {
-            nm_log(nm_level,
-                   LOGD_DHCP4,
-                   NULL,
-                   NULL,
-                   "dhcp4 (%s): %s",
-                   client_config->iface,
-                   event->log.message);
-        }
-    } break;
+        _LOGT("event: down (ignore)");
+        return;
     default:
-        _LOGW("unhandled DHCP event %d", event->event);
-        break;
+        _LOGE("unhandled DHCP event %d", event->event);
+        nm_assert(event->event != N_DHCP4_CLIENT_EVENT_LOG);
+        nm_assert_not_reached();
+        return;
+    }
+}
+
+static void
+dhcp4_event_pop_all_events(NMDhcpNettools *self)
+{
+    NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
+    NDhcp4ClientEvent     *event;
+
+    while (!n_dhcp4_client_pop_event(priv->client, &event) && event)
+        dhcp4_event_handle(self, event);
+
+    nm_clear_g_source_inst(&priv->pop_all_events_on_idle_source);
+}
+
+static gboolean
+dhcp4_event_pop_all_events_on_idle_cb(gpointer user_data)
+{
+    NMDhcpNettools        *self = user_data;
+    NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
+
+    nm_clear_g_source_inst(&priv->pop_all_events_on_idle_source);
+    dhcp4_event_pop_all_events(self);
+    return G_SOURCE_CONTINUE;
+}
+
+static void
+dhcp4_event_pop_all_events_on_idle(NMDhcpNettools *self)
+{
+    NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
+
+    /* For the most part, NDhcp4Client gets driven from internal, that is
+     * by having events ready on the socket or the timerfd. For those
+     * events, we will poll on the (epoll) FD, then let it be processed
+     * by n_dhcp4_client_dispatch(), and pop the queued events.
+     *
+     * But certain commands (n_dhcp4_client_lease_select(), n_dhcp4_client_lease_accept(),
+     * n_dhcp4_client_lease_decline()) are initiated by the user. And they tend
+     * to log events. Logging is done by queuing a message, but that won't be processed,
+     * unless we pop the event.
+     *
+     * To ensure that those logging events get popped, schedule an idle handler to do that.
+     *
+     * Yes, this means, that the messages only get logged later, when the idle handler
+     * runs. The alternative seems even more problematic, because we don't know
+     * the current call-state, and it seems dangerous to pop unexpected events.
+     * E.g. we call n_dhcp4_client_lease_select() from inside the event-handler,
+     * it seems wrong to call dhcp4_event_pop_all_events() in that context again.
+     *
+     * See-also: https://github.com/nettools/n-dhcp4/issues/34
+     */
+
+    if (!priv->pop_all_events_on_idle_source) {
+        priv->pop_all_events_on_idle_source =
+            nm_g_idle_add_source(dhcp4_event_pop_all_events_on_idle_cb, self);
     }
 }
 
@@ -882,26 +1074,27 @@ dhcp4_event_cb(int fd, GIOCondition condition, gpointer user_data)
 {
     NMDhcpNettools        *self = user_data;
     NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
-    NDhcp4ClientEvent     *event;
     int                    r;
 
     r = n_dhcp4_client_dispatch(priv->client);
     if (r < 0) {
-        /* FIXME: if any operation (e.g. send()) fails during the
+        /* If any operation (e.g. send()) fails during the
          * dispatch, n-dhcp4 returns an error without arming timers
          * or progressing state, so the only reasonable thing to do
          * is to move to failed state so that the client will be
-         * restarted. Ideally n-dhcp4 should retry failed operations
-         * a predefined number of times (possibly infinite).
+         * restarted.
+         *
+         * That means, n_dhcp4_client_dispatch() must not fail if it can
+         * somehow workaround the problem. A failure is really fatal
+         * and the client needs to be restarted.
          */
         _LOGE("error %d dispatching events", r);
         nm_clear_g_source_inst(&priv->event_source);
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_FAIL, NULL);
         return G_SOURCE_REMOVE;
     }
 
-    while (!n_dhcp4_client_pop_event(priv->client, &event) && event)
-        dhcp4_event_handle(self, event);
+    dhcp4_event_pop_all_events(self);
 
     return G_SOURCE_CONTINUE;
 }
@@ -1008,46 +1201,69 @@ nettools_create(NMDhcpNettools *self, GError **error)
 }
 
 static gboolean
-_accept(NMDhcpClient *client, GError **error)
+_accept(NMDhcpClient *client, const NML3ConfigData *l3cd, GError **error)
 {
     NMDhcpNettools        *self = NM_DHCP_NETTOOLS(client);
     NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
     int                    r;
 
-    g_return_val_if_fail(priv->lease, FALSE);
-
     _LOGT("accept");
 
-    r = n_dhcp4_client_lease_accept(priv->lease);
+    g_return_val_if_fail(l3cd, FALSE);
+
+    if (priv->granted.lease_l3cd != l3cd)
+        return TRUE;
+
+    nm_assert(priv->granted.lease);
+
+    r = n_dhcp4_client_lease_accept(priv->granted.lease);
+    if (!r)
+        lease_save(self, priv->granted.lease, priv->lease_file);
+
+    dhcp4_event_pop_all_events_on_idle(self);
+
+    nm_clear_pointer(&priv->granted.lease, n_dhcp4_client_lease_unref);
+    nm_clear_l3cd(&priv->granted.lease_l3cd);
+
     if (r) {
         set_error_nettools(error, r, "failed to accept lease");
         return FALSE;
     }
 
-    priv->lease = n_dhcp4_client_lease_unref(priv->lease);
-
     return TRUE;
 }
 
 static gboolean
-decline(NMDhcpClient *client, const char *error_message, GError **error)
+decline(NMDhcpClient *client, const NML3ConfigData *l3cd, const char *error_message, GError **error)
 {
     NMDhcpNettools        *self = NM_DHCP_NETTOOLS(client);
     NMDhcpNettoolsPrivate *priv = NM_DHCP_NETTOOLS_GET_PRIVATE(self);
     int                    r;
+    nm_auto(n_dhcp4_client_lease_unrefp) NDhcp4ClientLease *lease = NULL;
 
-    g_return_val_if_fail(priv->lease, FALSE);
+    _LOGT("decline (%s)", error_message);
 
-    _LOGT("dhcp4-client: decline (%s)", error_message);
+    g_return_val_if_fail(l3cd, FALSE);
+
+    if (priv->granted.lease_l3cd != l3cd) {
+        nm_utils_error_set(error, NM_UTILS_ERROR_UNKNOWN, "calling decline in unexpected state");
+        return FALSE;
+    }
+
+    nm_assert(priv->granted.lease);
+
+    lease = g_steal_pointer(&priv->granted.lease);
+    nm_clear_l3cd(&priv->granted.lease_l3cd);
+
+    r = n_dhcp4_client_lease_decline(lease, error_message);
+
+    dhcp4_event_pop_all_events_on_idle(self);
 
-    r = n_dhcp4_client_lease_decline(priv->lease, error_message);
     if (r) {
         set_error_nettools(error, r, "failed to decline lease");
         return FALSE;
     }
 
-    priv->lease = n_dhcp4_client_lease_unref(priv->lease);
-
     return TRUE;
 }
 
@@ -1107,18 +1323,20 @@ ip4_start(NMDhcpClient *client, GError **error)
     if (client_config->v4.last_address)
         inet_pton(AF_INET, client_config->v4.last_address, &last_addr);
     else {
-        /*
-         * TODO: we stick to the systemd-networkd lease file format. Quite easy for now to
-         * just use the functions in systemd code. Anyway, as in the end we just use the
-         * ip address from all the options found in the lease, write a function that parses
-         * the lease file just for the assigned address and returns it in &last_address.
-         * Then drop reference to systemd-networkd structures and functions.
-         */
-        nm_auto(sd_dhcp_lease_unrefp) sd_dhcp_lease *lease = NULL;
-
-        dhcp_lease_load(&lease, lease_file);
-        if (lease)
-            sd_dhcp_lease_get_address(lease, &last_addr);
+        gs_free char *contents = NULL;
+        gs_free char *s_addr   = NULL;
+
+        nm_utils_file_get_contents(-1,
+                                   lease_file,
+                                   64 * 1024,
+                                   NM_UTILS_FILE_GET_CONTENTS_FLAG_NONE,
+                                   &contents,
+                                   NULL,
+                                   NULL,
+                                   NULL);
+        nm_parse_env_file(contents, "ADDRESS", &s_addr);
+        if (s_addr)
+            nm_utils_parse_inaddr_bin(AF_INET, s_addr, NULL, &last_addr);
     }
 
     if (last_addr.s_addr) {
@@ -1256,7 +1474,9 @@ dispose(GObject *object)
 
     nm_clear_g_free(&priv->lease_file);
     nm_clear_g_source_inst(&priv->event_source);
-    nm_clear_pointer(&priv->lease, n_dhcp4_client_lease_unref);
+    nm_clear_g_source_inst(&priv->pop_all_events_on_idle_source);
+    nm_clear_pointer(&priv->granted.lease, n_dhcp4_client_lease_unref);
+    nm_clear_l3cd(&priv->granted.lease_l3cd);
     nm_clear_pointer(&priv->probe, n_dhcp4_client_probe_free);
     nm_clear_pointer(&priv->client, n_dhcp4_client_unref);
 
diff --git a/src/core/dhcp/nm-dhcp-systemd.c b/src/core/dhcp/nm-dhcp-systemd.c
index 4a718de9..49e21d97 100644
--- a/src/core/dhcp/nm-dhcp-systemd.c
+++ b/src/core/dhcp/nm-dhcp-systemd.c
@@ -25,7 +25,6 @@
 #include "libnm-platform/nm-platform.h"
 #include "nm-dhcp-client-logging.h"
 #include "libnm-systemd-core/nm-sd.h"
-#include "libnm-systemd-core/nm-sd-utils-dhcp.h"
 
 /*****************************************************************************/
 
@@ -47,7 +46,6 @@ static GType nm_dhcp_systemd_get_type(void);
 /*****************************************************************************/
 
 typedef struct {
-    sd_dhcp_client  *client4;
     sd_dhcp6_client *client6;
     char            *lease_file;
 
@@ -70,682 +68,7 @@ G_DEFINE_TYPE(NMDhcpSystemd, nm_dhcp_systemd, NM_TYPE_DHCP_CLIENT)
 /*****************************************************************************/
 
 static NML3ConfigData *
-lease_to_ip4_config(NMDedupMultiIndex *multi_idx,
-                    const char        *iface,
-                    int                ifindex,
-                    sd_dhcp_lease     *lease,
-                    GError           **error)
-{
-    nm_auto_unref_l3cd_init NML3ConfigData *l3cd    = NULL;
-    gs_unref_hashtable GHashTable          *options = NULL;
-    const struct in_addr                   *addr_list;
-    char                                    addr_str[NM_UTILS_INET_ADDRSTRLEN];
-    const char                             *s;
-    nm_auto_free_gstring GString           *str              = NULL;
-    nm_auto_free sd_dhcp_route            **routes_static    = NULL;
-    nm_auto_free sd_dhcp_route            **routes_classless = NULL;
-    const char *const                      *search_domains   = NULL;
-    guint32                                 default_route_metric_offset;
-    guint16                                 mtu;
-    int                                     i;
-    int                                     num;
-    int                                     is_classless;
-    int                                     n_routes_static;
-    int                                     n_routes_classless;
-    const void                             *data;
-    gsize                                   data_len;
-    gboolean                                has_router_from_classless = FALSE;
-    const gint32                            ts      = nm_utils_get_monotonic_timestamp_sec();
-    gint64                                  ts_time = time(NULL);
-    struct in_addr                          a_address;
-    struct in_addr                          a_netmask;
-    struct in_addr                          a_next_server;
-    struct in_addr                          server_id;
-    struct in_addr                          broadcast;
-    const struct in_addr                   *a_router;
-    guint32                                 a_plen;
-    guint32                                 a_lifetime;
-    guint32                                 renewal;
-    guint32                                 rebinding;
-    gs_free nm_sd_dhcp_option              *private_options = NULL;
-
-    nm_assert(lease != NULL);
-
-    if (sd_dhcp_lease_get_address(lease, &a_address) < 0) {
-        nm_utils_error_set_literal(error,
-                                   NM_UTILS_ERROR_UNKNOWN,
-                                   "could not get address from lease");
-        return NULL;
-    }
-
-    if (sd_dhcp_lease_get_netmask(lease, &a_netmask) < 0) {
-        nm_utils_error_set_literal(error,
-                                   NM_UTILS_ERROR_UNKNOWN,
-                                   "could not get netmask from lease");
-        return NULL;
-    }
-
-    if (sd_dhcp_lease_get_lifetime(lease, &a_lifetime) < 0) {
-        nm_utils_error_set_literal(error,
-                                   NM_UTILS_ERROR_UNKNOWN,
-                                   "could not get lifetime from lease");
-        return NULL;
-    }
-
-    l3cd = nm_l3_config_data_new(multi_idx, ifindex, NM_IP_CONFIG_SOURCE_DHCP);
-
-    options = nm_dhcp_option_create_options_dict();
-
-    _nm_utils_inet4_ntop(a_address.s_addr, addr_str);
-    nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_NM_IP_ADDRESS, addr_str);
-
-    a_plen = nm_utils_ip4_netmask_to_prefix(a_netmask.s_addr);
-    nm_dhcp_option_add_option(options,
-                              AF_INET,
-                              NM_DHCP_OPTION_DHCP4_SUBNET_MASK,
-                              _nm_utils_inet4_ntop(a_netmask.s_addr, addr_str));
-
-    nm_dhcp_option_add_option_u64(options,
-                                  AF_INET,
-                                  NM_DHCP_OPTION_DHCP4_IP_ADDRESS_LEASE_TIME,
-                                  a_lifetime);
-    nm_dhcp_option_add_option_u64(options,
-                                  AF_INET,
-                                  NM_DHCP_OPTION_DHCP4_NM_EXPIRY,
-                                  (guint64) (ts_time + a_lifetime));
-
-    if (sd_dhcp_lease_get_next_server(lease, &a_next_server) == 0) {
-        _nm_utils_inet4_ntop(a_next_server.s_addr, addr_str);
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_NM_NEXT_SERVER, addr_str);
-    }
-
-    nm_l3_config_data_add_address_4(l3cd,
-                                    &((const NMPlatformIP4Address){
-                                        .address      = a_address.s_addr,
-                                        .peer_address = a_address.s_addr,
-                                        .plen         = a_plen,
-                                        .addr_source  = NM_IP_CONFIG_SOURCE_DHCP,
-                                        .timestamp    = ts,
-                                        .lifetime     = a_lifetime,
-                                        .preferred    = a_lifetime,
-                                    }));
-
-    if (sd_dhcp_lease_get_server_identifier(lease, &server_id) >= 0) {
-        _nm_utils_inet4_ntop(server_id.s_addr, addr_str);
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_SERVER_ID, addr_str);
-    }
-
-    if (sd_dhcp_lease_get_broadcast(lease, &broadcast) >= 0) {
-        _nm_utils_inet4_ntop(broadcast.s_addr, addr_str);
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_BROADCAST, addr_str);
-    }
-
-    num = sd_dhcp_lease_get_dns(lease, &addr_list);
-    if (num > 0) {
-        nm_gstring_prepare(&str);
-        for (i = 0; i < num; i++) {
-            _nm_utils_inet4_ntop(addr_list[i].s_addr, addr_str);
-            g_string_append(nm_gstring_add_space_delimiter(str), addr_str);
-
-            if (addr_list[i].s_addr == 0 || nm_ip4_addr_is_localhost(addr_list[i].s_addr)) {
-                /* Skip localhost addresses, like also networkd does.
-                 * See https://github.com/systemd/systemd/issues/4524. */
-                continue;
-            }
-            nm_l3_config_data_add_nameserver(l3cd, AF_INET, &addr_list[i].s_addr);
-        }
-        nm_dhcp_option_add_option(options,
-                                  AF_INET,
-                                  NM_DHCP_OPTION_DHCP4_DOMAIN_NAME_SERVER,
-                                  str->str);
-    }
-
-    num = sd_dhcp_lease_get_search_domains(lease, (char ***) &search_domains);
-    if (num > 0) {
-        nm_gstring_prepare(&str);
-        for (i = 0; i < num; i++) {
-            g_string_append(nm_gstring_add_space_delimiter(str), search_domains[i]);
-            nm_l3_config_data_add_search(l3cd, AF_INET, search_domains[i]);
-        }
-        nm_dhcp_option_add_option(options,
-                                  AF_INET,
-                                  NM_DHCP_OPTION_DHCP4_DOMAIN_SEARCH_LIST,
-                                  str->str);
-    }
-
-    if (sd_dhcp_lease_get_domainname(lease, &s) >= 0) {
-        gs_strfreev char **domains = NULL;
-        char             **d;
-
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_DOMAIN_NAME, s);
-
-        /* Multiple domains sometimes stuffed into option 15 "Domain Name".
-         * As systemd escapes such characters, split them at \\032. */
-        domains = g_strsplit(s, "\\032", 0);
-        for (d = domains; *d; d++)
-            nm_l3_config_data_add_domain(l3cd, AF_INET, *d);
-    }
-
-    if (sd_dhcp_lease_get_hostname(lease, &s) >= 0) {
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_HOST_NAME, s);
-    }
-
-    default_route_metric_offset = 0;
-    n_routes_static             = sd_dhcp_lease_get_static_routes(lease, &routes_static);
-    n_routes_classless          = sd_dhcp_lease_get_classless_routes(lease, &routes_classless);
-    for (is_classless = 1; is_classless >= 0; is_classless--) {
-        int                   n_routes = (is_classless ? n_routes_classless : n_routes_static);
-        sd_dhcp_route *const *routes   = (is_classless ? routes_classless : routes_static);
-
-        if (n_routes <= 0)
-            continue;
-
-        nm_gstring_prepare(&str);
-
-        for (i = 0; i < n_routes; i++) {
-            char           network_net_str[NM_UTILS_INET_ADDRSTRLEN];
-            char           gateway_str[NM_UTILS_INET_ADDRSTRLEN];
-            guint8         r_plen;
-            struct in_addr r_network;
-            struct in_addr r_gateway;
-            in_addr_t      network_net;
-            guint32        m;
-
-            if (sd_dhcp_route_get_destination(routes[i], &r_network) < 0)
-                continue;
-            if (sd_dhcp_route_get_destination_prefix_length(routes[i], &r_plen) < 0 || r_plen > 32)
-                continue;
-            if (sd_dhcp_route_get_gateway(routes[i], &r_gateway) < 0)
-                continue;
-
-            network_net = nm_utils_ip4_address_clear_host_address(r_network.s_addr, r_plen);
-            _nm_utils_inet4_ntop(network_net, network_net_str);
-            _nm_utils_inet4_ntop(r_gateway.s_addr, gateway_str);
-
-            g_string_append_printf(nm_gstring_add_space_delimiter(str),
-                                   "%s/%d %s",
-                                   network_net_str,
-                                   (int) r_plen,
-                                   gateway_str);
-
-            if (!is_classless && n_routes_classless > 0) {
-                /* RFC 3443: if the DHCP server returns both a Classless Static Routes
-                 * option and a Static Routes option, the DHCP client MUST ignore the
-                 * Static Routes option. */
-                continue;
-            }
-
-            if (r_plen == 0) {
-                if (!is_classless) {
-                    /* for option 33 (static route), RFC 2132 says:
-                     *
-                     * The default route (0.0.0.0) is an illegal destination for a static
-                     * route. */
-                    continue;
-                }
-
-                /* if there are multiple default routes, we add them with differing
-                 * metrics. */
-                m                         = default_route_metric_offset++;
-                has_router_from_classless = TRUE;
-            } else
-                m = 0;
-
-            nm_l3_config_data_add_route_4(l3cd,
-                                          &((const NMPlatformIP4Route){
-                                              .rt_source     = NM_IP_CONFIG_SOURCE_DHCP,
-                                              .network       = network_net,
-                                              .plen          = r_plen,
-                                              .gateway       = r_gateway.s_addr,
-                                              .pref_src      = a_address.s_addr,
-                                              .metric_any    = TRUE,
-                                              .metric        = m,
-                                              .table_any     = TRUE,
-                                              .table_coerced = 0,
-                                          }));
-        }
-
-        if (str->len > 0) {
-            nm_dhcp_option_add_option(options,
-                                      AF_INET,
-                                      is_classless ? NM_DHCP_OPTION_DHCP4_CLASSLESS_STATIC_ROUTE
-                                                   : NM_DHCP_OPTION_DHCP4_STATIC_ROUTE,
-                                      str->str);
-        }
-    }
-
-    num = sd_dhcp_lease_get_router(lease, &a_router);
-    if (num > 0) {
-        default_route_metric_offset = 0;
-
-        nm_gstring_prepare(&str);
-        for (i = 0; i < num; i++) {
-            guint32 m;
-
-            s = _nm_utils_inet4_ntop(a_router[i].s_addr, addr_str);
-            g_string_append(nm_gstring_add_space_delimiter(str), s);
-
-            if (a_router[i].s_addr == 0) {
-                /* silently skip 0.0.0.0 */
-                continue;
-            }
-
-            if (has_router_from_classless) {
-                /* If the DHCP server returns both a Classless Static Routes option and a
-                 * Router option, the DHCP client MUST ignore the Router option [RFC 3442].
-                 *
-                 * Be more lenient and ignore the Router option only if Classless Static
-                 * Routes contain a default gateway (as other DHCP backends do).
-                 */
-                continue;
-            }
-
-            /* if there are multiple default routes, we add them with differing
-             * metrics. */
-            m = default_route_metric_offset++;
-
-            nm_l3_config_data_add_route_4(l3cd,
-                                          &((const NMPlatformIP4Route){
-                                              .rt_source     = NM_IP_CONFIG_SOURCE_DHCP,
-                                              .gateway       = a_router[i].s_addr,
-                                              .pref_src      = a_address.s_addr,
-                                              .table_any     = TRUE,
-                                              .table_coerced = 0,
-                                              .metric_any    = TRUE,
-                                              .metric        = m,
-                                          }));
-        }
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_ROUTER, str->str);
-    }
-
-    if (sd_dhcp_lease_get_mtu(lease, &mtu) >= 0 && mtu) {
-        nm_dhcp_option_add_option_u64(options, AF_INET, NM_DHCP_OPTION_DHCP4_INTERFACE_MTU, mtu);
-        nm_l3_config_data_set_mtu(l3cd, mtu);
-    }
-
-    num = sd_dhcp_lease_get_ntp(lease, &addr_list);
-    if (num > 0) {
-        nm_gstring_prepare(&str);
-        for (i = 0; i < num; i++) {
-            _nm_utils_inet4_ntop(addr_list[i].s_addr, addr_str);
-            g_string_append(nm_gstring_add_space_delimiter(str), addr_str);
-        }
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_NTP_SERVER, str->str);
-    }
-
-    if (sd_dhcp_lease_get_root_path(lease, &s) >= 0) {
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_ROOT_PATH, s);
-    }
-
-    if (sd_dhcp_lease_get_t1(lease, &renewal) >= 0) {
-        nm_dhcp_option_add_option_u64(options,
-                                      AF_INET,
-                                      NM_DHCP_OPTION_DHCP4_RENEWAL_T1_TIME,
-                                      renewal);
-    }
-
-    if (sd_dhcp_lease_get_t2(lease, &rebinding) >= 0) {
-        nm_dhcp_option_add_option_u64(options,
-                                      AF_INET,
-                                      NM_DHCP_OPTION_DHCP4_REBINDING_T2_TIME,
-                                      rebinding);
-    }
-
-    if (sd_dhcp_lease_get_timezone(lease, &s) >= 0) {
-        nm_dhcp_option_add_option(options, AF_INET, NM_DHCP_OPTION_DHCP4_NEW_TZDB_TIMEZONE, s);
-    }
-
-    if (sd_dhcp_lease_get_vendor_specific(lease, &data, &data_len) >= 0) {
-        if (!!memmem(data, data_len, "ANDROID_METERED", NM_STRLEN("ANDROID_METERED")))
-            nm_l3_config_data_set_metered(l3cd, TRUE);
-    }
-
-    num = nm_sd_dhcp_lease_get_private_options(lease, &private_options);
-    if (num > 0) {
-        for (i = 0; i < num; i++) {
-            guint8        code       = private_options[i].code;
-            const guint8 *l_data     = private_options[i].data;
-            gsize         l_data_len = private_options[i].data_len;
-            char         *option_string;
-
-            if (code == NM_DHCP_OPTION_DHCP4_PRIVATE_PROXY_AUTODISCOVERY) {
-                if (nm_dhcp_lease_data_parse_cstr(l_data, l_data_len, &l_data_len)) {
-                    gs_free char *to_free = NULL;
-                    const char   *escaped;
-
-                    escaped =
-                        nm_utils_buf_utf8safe_escape((char *) l_data, l_data_len, 0, &to_free);
-                    nm_dhcp_option_add_option(options,
-                                              AF_INET,
-                                              NM_DHCP_OPTION_DHCP4_PRIVATE_PROXY_AUTODISCOVERY,
-                                              escaped ?: "");
-
-                    nm_l3_config_data_set_proxy_method(l3cd, NM_PROXY_CONFIG_METHOD_AUTO);
-                    nm_l3_config_data_set_proxy_pac_url(l3cd, escaped ?: "");
-                }
-                continue;
-            }
-            if (code == NM_DHCP_OPTION_DHCP4_PRIVATE_CLASSLESS_STATIC_ROUTE) {
-                /* nettools and dhclient parse option 249 (Microsoft Classless Static Route)
-                 * as fallback for routes and ignores them from private options.
-                 *
-                 * The systemd plugin does not, and for consistency with nettools we
-                 * also don't expose it as private option either. */
-                continue;
-            }
-
-            option_string = nm_utils_bin2hexstr_full(l_data, l_data_len, ':', FALSE, NULL);
-            nm_dhcp_option_take_option(options, AF_INET, code, option_string);
-        }
-    }
-
-    nm_dhcp_option_add_requests_to_options(options, AF_INET);
-
-    nm_l3_config_data_set_dhcp_lease_from_options(l3cd, AF_INET, g_steal_pointer(&options));
-
-    return g_steal_pointer(&l3cd);
-}
-
-/*****************************************************************************/
-
-static void
-bound4_handle(NMDhcpSystemd *self, gboolean extended)
-{
-    NMDhcpSystemdPrivate                   *priv  = NM_DHCP_SYSTEMD_GET_PRIVATE(self);
-    const char                             *iface = nm_dhcp_client_get_iface(NM_DHCP_CLIENT(self));
-    nm_auto_unref_l3cd_init NML3ConfigData *l3cd  = NULL;
-    sd_dhcp_lease                          *lease = NULL;
-    GError                                 *error = NULL;
-
-    if (sd_dhcp_client_get_lease(priv->client4, &lease) < 0 || !lease) {
-        _LOGW("no lease!");
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
-        return;
-    }
-
-    _LOGD("lease available");
-
-    l3cd = lease_to_ip4_config(nm_dhcp_client_get_multi_idx(NM_DHCP_CLIENT(self)),
-                               iface,
-                               nm_dhcp_client_get_ifindex(NM_DHCP_CLIENT(self)),
-                               lease,
-                               &error);
-    if (!l3cd) {
-        _LOGW("%s", error->message);
-        g_clear_error(&error);
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
-        return;
-    }
-
-    dhcp_lease_save(lease, priv->lease_file);
-
-    nm_dhcp_client_set_state(NM_DHCP_CLIENT(self),
-                             extended ? NM_DHCP_STATE_EXTENDED : NM_DHCP_STATE_BOUND,
-                             l3cd);
-}
-
-static int
-dhcp_event_cb(sd_dhcp_client *client, int event, gpointer user_data)
-{
-    NMDhcpSystemd        *self = NM_DHCP_SYSTEMD(user_data);
-    NMDhcpSystemdPrivate *priv = NM_DHCP_SYSTEMD_GET_PRIVATE(self);
-    char                  addr_str[INET_ADDRSTRLEN];
-    sd_dhcp_lease        *lease = NULL;
-    struct in_addr        addr;
-    int                   r;
-
-    nm_assert(priv->client4 == client);
-
-    _LOGD("client event %d", event);
-
-    switch (event) {
-    case SD_DHCP_CLIENT_EVENT_EXPIRED:
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(user_data), NM_DHCP_STATE_EXPIRE, NULL);
-        break;
-    case SD_DHCP_CLIENT_EVENT_STOP:
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(user_data), NM_DHCP_STATE_FAIL, NULL);
-        break;
-    case SD_DHCP_CLIENT_EVENT_RENEW:
-    case SD_DHCP_CLIENT_EVENT_IP_CHANGE:
-        bound4_handle(self, TRUE);
-        break;
-    case SD_DHCP_CLIENT_EVENT_IP_ACQUIRE:
-        bound4_handle(self, FALSE);
-        break;
-    case SD_DHCP_CLIENT_EVENT_SELECTING:
-        r = sd_dhcp_client_get_lease(priv->client4, &lease);
-        if (r < 0)
-            return r;
-        r = sd_dhcp_lease_get_server_identifier(lease, &addr);
-        if (r < 0)
-            return r;
-        if (nm_dhcp_client_server_id_is_rejected(NM_DHCP_CLIENT(user_data), &addr)) {
-            _LOGD("server-id %s is in the reject-list, ignoring",
-                  nm_utils_inet_ntop(AF_INET, &addr, addr_str));
-            return -ENOMSG;
-        }
-        break;
-    case SD_DHCP_CLIENT_EVENT_TRANSIENT_FAILURE:
-        break;
-    default:
-        _LOGW("unhandled DHCP event %d", event);
-        break;
-    }
-
-    return 0;
-}
-
-static gboolean
-ip4_start(NMDhcpClient *client, GError **error)
-{
-    nm_auto(sd_dhcp_client_unrefp) sd_dhcp_client *sd_client = NULL;
-    NMDhcpSystemd                                 *self      = NM_DHCP_SYSTEMD(client);
-    NMDhcpSystemdPrivate                          *priv      = NM_DHCP_SYSTEMD_GET_PRIVATE(self);
-    const NMDhcpClientConfig                      *client_config;
-    gs_free char                                  *lease_file = NULL;
-    GBytes                                        *hwaddr;
-    const uint8_t                                 *hwaddr_arr;
-    gsize                                          hwaddr_len;
-    int                                            arp_type;
-    GBytes                                        *client_id;
-    gs_unref_bytes GBytes                         *client_id_new = NULL;
-    GBytes                                        *vendor_class_identifier;
-    const uint8_t                                 *client_id_arr;
-    size_t                                         client_id_len;
-    struct in_addr                                 last_addr = {0};
-    const char                                    *hostname;
-    const char                                    *mud_url;
-    int                                            r, i;
-    GBytes                                        *bcast_hwaddr;
-    const uint8_t                                 *bcast_hwaddr_arr;
-    gsize                                          bcast_hwaddr_len;
-
-    g_return_val_if_fail(!priv->client4, FALSE);
-    g_return_val_if_fail(!priv->client6, FALSE);
-
-    client_config = nm_dhcp_client_get_config(client);
-
-    /* TODO: honor nm_dhcp_client_get_anycast_address() */
-
-    r = sd_dhcp_client_new(&sd_client, FALSE);
-    if (r < 0) {
-        nm_utils_error_set_errno(error, r, "failed to create dhcp-client: %s");
-        return FALSE;
-    }
-
-    _LOGT("dhcp-client4: set " NM_HASH_OBFUSCATE_PTR_FMT, NM_HASH_OBFUSCATE_PTR(sd_client));
-
-    r = sd_dhcp_client_attach_event(sd_client, NULL, 0);
-    if (r < 0) {
-        nm_utils_error_set_errno(error, r, "failed to attach event: %s");
-        return FALSE;
-    }
-
-    hwaddr = client_config->hwaddr;
-    if (!hwaddr || !(hwaddr_arr = g_bytes_get_data(hwaddr, &hwaddr_len))
-        || (arp_type = nm_utils_arp_type_detect_from_hwaddrlen(hwaddr_len)) < 0) {
-        nm_utils_error_set_literal(error, NM_UTILS_ERROR_UNKNOWN, "invalid MAC address");
-        return FALSE;
-    }
-
-    bcast_hwaddr_arr = NULL;
-    bcast_hwaddr     = client_config->bcast_hwaddr;
-    if (bcast_hwaddr) {
-        bcast_hwaddr_arr = g_bytes_get_data(bcast_hwaddr, &bcast_hwaddr_len);
-        if (bcast_hwaddr_len != hwaddr_len)
-            bcast_hwaddr_arr = NULL;
-    }
-
-    r = sd_dhcp_client_set_mac(sd_client,
-                               hwaddr_arr,
-                               bcast_hwaddr_arr,
-                               hwaddr_len,
-                               (guint16) arp_type);
-    if (r < 0) {
-        nm_utils_error_set_errno(error, r, "failed to set MAC address: %s");
-        return FALSE;
-    }
-
-    r = sd_dhcp_client_set_ifindex(sd_client, nm_dhcp_client_get_ifindex(client));
-    if (r < 0) {
-        nm_utils_error_set_errno(error, r, "failed to set ifindex: %s");
-        return FALSE;
-    }
-
-    nm_dhcp_utils_get_leasefile_path(AF_INET,
-                                     "internal",
-                                     client_config->iface,
-                                     client_config->uuid,
-                                     &lease_file);
-
-    if (client_config->v4.last_address)
-        inet_pton(AF_INET, client_config->v4.last_address, &last_addr);
-    else {
-        nm_auto(sd_dhcp_lease_unrefp) sd_dhcp_lease *lease = NULL;
-
-        dhcp_lease_load(&lease, lease_file);
-        if (lease)
-            sd_dhcp_lease_get_address(lease, &last_addr);
-    }
-
-    r = sd_dhcp_client_set_request_broadcast(sd_client, client_config->v4.request_broadcast);
-    nm_assert(r >= 0);
-
-    if (last_addr.s_addr) {
-        r = sd_dhcp_client_set_request_address(sd_client, &last_addr);
-        if (r < 0) {
-            nm_utils_error_set_errno(error, r, "failed to set last IPv4 address: %s");
-            return FALSE;
-        }
-    }
-
-    client_id = client_config->client_id;
-    if (!client_id) {
-        client_id_new = nm_utils_dhcp_client_id_mac(arp_type, hwaddr_arr, hwaddr_len);
-        client_id     = client_id_new;
-    }
-
-    if (!(client_id_arr = g_bytes_get_data(client_id, &client_id_len)) || client_id_len < 2) {
-        /* invalid client-ids are not expected. */
-        nm_assert_not_reached();
-
-        nm_utils_error_set_literal(error, NM_UTILS_ERROR_UNKNOWN, "no valid IPv4 client-id");
-        return FALSE;
-    }
-
-    /* Note that we always set a client-id. In particular for infiniband that is necessary,
-     * see https://tools.ietf.org/html/rfc4390#section-2.1 . */
-    r = sd_dhcp_client_set_client_id(sd_client,
-                                     client_id_arr[0],
-                                     client_id_arr + 1,
-                                     NM_MIN(client_id_len - 1, _NM_MAX_CLIENT_ID_LEN));
-    if (r < 0) {
-        nm_utils_error_set_errno(error, r, "failed to set IPv4 client-id: %s");
-        return FALSE;
-    }
-
-    /* Add requested options */
-    for (i = 0; i < (int) G_N_ELEMENTS(_nm_dhcp_option_dhcp4_options); i++) {
-        if (_nm_dhcp_option_dhcp4_options[i].include) {
-            nm_assert(_nm_dhcp_option_dhcp4_options[i].option_num <= 255);
-            r = sd_dhcp_client_set_request_option(sd_client,
-                                                  _nm_dhcp_option_dhcp4_options[i].option_num);
-            nm_assert(r >= 0 || r == -EEXIST);
-        }
-    }
-
-    hostname = client_config->hostname;
-    if (hostname) {
-        /* FIXME: sd-dhcp decides which hostname/FQDN option to send (12 or 81)
-         * only based on whether the hostname has a domain part or not. At the
-         * moment there is no way to force one or another.
-         */
-        r = sd_dhcp_client_set_hostname(sd_client, hostname);
-        if (r < 0) {
-            nm_utils_error_set_errno(error, r, "failed to set DHCP hostname: %s");
-            return FALSE;
-        }
-    }
-
-    mud_url = client_config->mud_url;
-    if (mud_url) {
-        r = sd_dhcp_client_set_mud_url(sd_client, mud_url);
-        if (r < 0) {
-            nm_utils_error_set_errno(error, r, "failed to set DHCP MUDURL: %s");
-            return FALSE;
-        }
-    }
-
-    vendor_class_identifier = client_config->vendor_class_identifier;
-    if (vendor_class_identifier) {
-        const char *option_data;
-        gsize       len;
-
-        option_data = g_bytes_get_data(vendor_class_identifier, &len);
-        nm_assert(option_data);
-        nm_assert(len <= 255);
-
-        option_data = nm_strndup_a(300, option_data, len, NULL);
-
-        r = sd_dhcp_client_set_vendor_class_identifier(sd_client, option_data);
-        if (r < 0) {
-            nm_utils_error_set_errno(error, r, "failed to set DHCP vendor class identifier: %s");
-            return FALSE;
-        }
-    }
-
-    r = sd_dhcp_client_set_callback(sd_client, dhcp_event_cb, client);
-    if (r < 0) {
-        nm_utils_error_set_errno(error, r, "failed to set callback: %s");
-        return FALSE;
-    }
-
-    priv->client4 = g_steal_pointer(&sd_client);
-
-    g_free(priv->lease_file);
-    priv->lease_file = g_steal_pointer(&lease_file);
-
-    nm_dhcp_client_set_effective_client_id(client, client_id);
-
-    r = sd_dhcp_client_start(priv->client4);
-    if (r < 0) {
-        sd_dhcp_client_set_callback(priv->client4, NULL, NULL);
-        nm_clear_pointer(&priv->client4, sd_dhcp_client_unref);
-        nm_utils_error_set_errno(error, r, "failed to start DHCP client: %s");
-        return FALSE;
-    }
-
-    return TRUE;
-}
-
-static NML3ConfigData *
-lease_to_ip6_config(NMDedupMultiIndex *multi_idx,
-                    const char        *iface,
-                    int                ifindex,
-                    sd_dhcp6_lease    *lease,
-                    gboolean           info_only,
-                    gint32             ts,
-                    GError           **error)
+lease_to_ip6_config(NMDhcpSystemd *self, sd_dhcp6_lease *lease, gint32 ts, GError **error)
 {
     nm_auto_unref_l3cd_init NML3ConfigData *l3cd    = NULL;
     gs_unref_hashtable GHashTable          *options = NULL;
@@ -761,11 +84,11 @@ lease_to_ip6_config(NMDedupMultiIndex *multi_idx,
 
     nm_assert(lease);
 
-    l3cd = nm_l3_config_data_new(multi_idx, ifindex, NM_IP_CONFIG_SOURCE_DHCP);
+    l3cd = nm_dhcp_client_create_l3cd(NM_DHCP_CLIENT(self));
 
     options = nm_dhcp_option_create_options_dict();
 
-    if (!info_only) {
+    if (!nm_dhcp_client_get_config(NM_DHCP_CLIENT(self))->v6.info_only) {
         gboolean has_any_addresses = FALSE;
         uint32_t lft_pref;
         uint32_t lft_valid;
@@ -863,40 +186,30 @@ lease_to_ip6_config(NMDedupMultiIndex *multi_idx,
 static void
 bound6_handle(NMDhcpSystemd *self)
 {
-    NMDhcpSystemdPrivate                   *priv  = NM_DHCP_SYSTEMD_GET_PRIVATE(self);
-    const gint32                            ts    = nm_utils_get_monotonic_timestamp_sec();
-    const char                             *iface = nm_dhcp_client_get_iface(NM_DHCP_CLIENT(self));
-    const NMDhcpClientConfig               *client_config;
+    NMDhcpSystemdPrivate                   *priv   = NM_DHCP_SYSTEMD_GET_PRIVATE(self);
+    const gint32                            ts     = nm_utils_get_monotonic_timestamp_sec();
     nm_auto_unref_l3cd_init NML3ConfigData *l3cd   = NULL;
     gs_free_error GError                   *error  = NULL;
     NMPlatformIP6Address                    prefix = {0};
     sd_dhcp6_lease                         *lease  = NULL;
 
-    client_config = nm_dhcp_client_get_config(NM_DHCP_CLIENT(self));
-
     if (sd_dhcp6_client_get_lease(priv->client6, &lease) < 0 || !lease) {
         _LOGW(" no lease!");
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_FAIL, NULL);
         return;
     }
 
     _LOGD("lease available");
 
-    l3cd = lease_to_ip6_config(nm_dhcp_client_get_multi_idx(NM_DHCP_CLIENT(self)),
-                               iface,
-                               nm_dhcp_client_get_ifindex(NM_DHCP_CLIENT(self)),
-                               lease,
-                               client_config->v6.info_only,
-                               ts,
-                               &error);
+    l3cd = lease_to_ip6_config(self, lease, ts, &error);
 
     if (!l3cd) {
         _LOGW("%s", error->message);
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_FAIL, NULL);
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_FAIL, NULL);
         return;
     }
 
-    nm_dhcp_client_set_state(NM_DHCP_CLIENT(self), NM_DHCP_STATE_BOUND, l3cd);
+    _nm_dhcp_client_notify(NM_DHCP_CLIENT(self), NM_DHCP_CLIENT_EVENT_TYPE_BOUND, l3cd);
 
     sd_dhcp6_lease_reset_pd_prefix_iter(lease);
     while (!sd_dhcp6_lease_get_pd(lease,
@@ -921,11 +234,11 @@ dhcp6_event_cb(sd_dhcp6_client *client, int event, gpointer user_data)
 
     switch (event) {
     case SD_DHCP6_CLIENT_EVENT_RETRANS_MAX:
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(user_data), NM_DHCP_STATE_TIMEOUT, NULL);
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(user_data), NM_DHCP_CLIENT_EVENT_TYPE_TIMEOUT, NULL);
         break;
     case SD_DHCP6_CLIENT_EVENT_RESEND_EXPIRE:
     case SD_DHCP6_CLIENT_EVENT_STOP:
-        nm_dhcp_client_set_state(NM_DHCP_CLIENT(user_data), NM_DHCP_STATE_FAIL, NULL);
+        _nm_dhcp_client_notify(NM_DHCP_CLIENT(user_data), NM_DHCP_CLIENT_EVENT_TYPE_FAIL, NULL);
         break;
     case SD_DHCP6_CLIENT_EVENT_IP_ACQUIRE:
     case SD_DHCP6_CLIENT_EVENT_INFORMATION_REQUEST:
@@ -952,7 +265,6 @@ ip6_start(NMDhcpClient *client, const struct in6_addr *ll_addr, GError **error)
     GBytes                                          *duid;
     gboolean                                         prefix_delegation;
 
-    g_return_val_if_fail(!priv->client4, FALSE);
     g_return_val_if_fail(!priv->client6, FALSE);
 
     client_config = nm_dhcp_client_get_config(client);
@@ -1079,18 +391,13 @@ stop(NMDhcpClient *client, gboolean release)
 
     NM_DHCP_CLIENT_CLASS(nm_dhcp_systemd_parent_class)->stop(client, release);
 
-    _LOGT("dhcp-client%d: stop %p",
-          priv->client4 ? '4' : '6',
-          priv->client4 ? (gpointer) priv->client4 : (gpointer) priv->client6);
+    _LOGT("dhcp-client6: stop");
 
-    if (priv->client4) {
-        sd_dhcp_client_set_callback(priv->client4, NULL, NULL);
-        r = sd_dhcp_client_stop(priv->client4);
-    } else if (priv->client6) {
-        sd_dhcp6_client_set_callback(priv->client6, NULL, NULL);
-        r = sd_dhcp6_client_stop(priv->client6);
-    }
+    if (!priv->client6)
+        return;
 
+    sd_dhcp6_client_set_callback(priv->client6, NULL, NULL);
+    r = sd_dhcp6_client_stop(priv->client6);
     if (r)
         _LOGW("failed to stop client (%d)", r);
 }
@@ -1108,12 +415,6 @@ dispose(GObject *object)
 
     nm_clear_g_free(&priv->lease_file);
 
-    if (priv->client4) {
-        sd_dhcp_client_stop(priv->client4);
-        sd_dhcp_client_unref(priv->client4);
-        priv->client4 = NULL;
-    }
-
     if (priv->client6) {
         sd_dhcp6_client_stop(priv->client6);
         sd_dhcp6_client_unref(priv->client6);
@@ -1131,14 +432,13 @@ nm_dhcp_systemd_class_init(NMDhcpSystemdClass *sdhcp_class)
 
     object_class->dispose = dispose;
 
-    client_class->ip4_start = ip4_start;
     client_class->ip6_start = ip6_start;
     client_class->stop      = stop;
 }
 
 const NMDhcpClientFactory _nm_dhcp_client_factory_systemd = {
     .name         = "systemd",
-    .get_type_4   = nm_dhcp_systemd_get_type,
+    .get_type_4   = nm_dhcp_nettools_get_type,
     .get_type_6   = nm_dhcp_systemd_get_type,
     .undocumented = TRUE,
 };
diff --git a/src/core/dhcp/nm-dhcp-utils.c b/src/core/dhcp/nm-dhcp-utils.c
index 081e2841..1bffb3c4 100644
--- a/src/core/dhcp/nm-dhcp-utils.c
+++ b/src/core/dhcp/nm-dhcp-utils.c
@@ -14,6 +14,7 @@
 #include "libnm-systemd-shared/nm-sd-utils-shared.h"
 
 #include "nm-dhcp-utils.h"
+#include "nm-dhcp-options.h"
 #include "nm-l3-config-data.h"
 #include "nm-utils.h"
 #include "nm-config.h"
@@ -295,7 +296,7 @@ process_classful_routes(const char     *iface,
         return;
 
     if ((NM_PTRARRAY_LEN(searches) % 2) != 0) {
-        _LOG2I(LOGD_DHCP, iface, "  static routes provided, but invalid");
+        _LOG2I(LOGD_DHCP4, iface, "  static routes provided, but invalid");
         return;
     }
 
@@ -305,11 +306,11 @@ process_classful_routes(const char     *iface,
         guint32            rt_addr, rt_route;
 
         if (inet_pton(AF_INET, *s, &rt_addr) <= 0) {
-            _LOG2W(LOGD_DHCP, iface, "DHCP provided invalid static route address: '%s'", *s);
+            _LOG2W(LOGD_DHCP4, iface, "DHCP provided invalid static route address: '%s'", *s);
             continue;
         }
         if (inet_pton(AF_INET, *(s + 1), &rt_route) <= 0) {
-            _LOG2W(LOGD_DHCP, iface, "DHCP provided invalid static route gateway: '%s'", *(s + 1));
+            _LOG2W(LOGD_DHCP4, iface, "DHCP provided invalid static route gateway: '%s'", *(s + 1));
             continue;
         }
 
@@ -340,7 +341,7 @@ process_classful_routes(const char     *iface,
 
         nm_l3_config_data_add_route_4(l3cd, &route);
 
-        _LOG2I(LOGD_DHCP,
+        _LOG2I(LOGD_DHCP4,
                iface,
                "  static route %s",
                nm_platform_ip4_route_to_string(&route, sbuf, sizeof(sbuf)));
@@ -352,6 +353,7 @@ process_domain_search(int addr_family, const char *iface, const char *str, NML3C
 {
     gs_free const char **searches  = NULL;
     gs_free char        *unescaped = NULL;
+    NMLogDomain          logd      = NM_IS_IPv4(addr_family) ? LOGD_DHCP4 : LOGD_DHCP6;
     const char         **s;
     char                *p;
     int                  i;
@@ -373,13 +375,13 @@ process_domain_search(int addr_family, const char *iface, const char *str, NML3C
     } while (*p++);
 
     if (strchr(unescaped, '\\')) {
-        _LOG2W(LOGD_DHCP, iface, "  invalid domain search: '%s'", unescaped);
+        _LOG2W(logd, iface, "  invalid domain search: '%s'", unescaped);
         return;
     }
 
     searches = nm_strsplit_set(unescaped, " ");
     for (s = searches; searches && *s; s++) {
-        _LOG2I(LOGD_DHCP, iface, "  domain search '%s'", *s);
+        _LOG2I(logd, iface, "  domain search '%s'", *s);
         nm_l3_config_data_add_search(l3cd, addr_family, *s);
     }
 }
@@ -414,18 +416,21 @@ nm_dhcp_utils_ip4_config_from_options(NMDedupMultiIndex *multi_idx,
     str = g_hash_table_lookup(options, "ip_address");
     if (!str || !nm_utils_parse_inaddr_bin(AF_INET, str, NULL, &addr))
         return NULL;
+    if (addr == INADDR_ANY)
+        return NULL;
 
     _LOG2I(LOGD_DHCP4, iface, "  address %s", str);
 
     str = g_hash_table_lookup(options, "subnet_mask");
     if (str && (inet_pton(AF_INET, str, &tmp_addr) > 0)) {
-        plen = nm_utils_ip4_netmask_to_prefix(tmp_addr);
+        plen = _nm_utils_ip4_netmask_to_prefix(tmp_addr);
         _LOG2I(LOGD_DHCP4, iface, "  plen %d (%s)", plen, str);
     } else {
         /* Get default netmask for the IP according to appropriate class. */
         plen = _nm_utils_ip4_get_default_prefix(addr);
         _LOG2I(LOGD_DHCP4, iface, "  plen %d (default)", plen);
     }
+
     nm_platform_ip4_address_set_addr(&address, addr, plen);
 
     /* Routes: if the server returns classless static routes, we MUST ignore
@@ -588,7 +593,7 @@ nm_dhcp_utils_ip6_prefix_from_options(GHashTable *options)
 {
     gs_strfreev char   **split_addr = NULL;
     NMPlatformIP6Address address    = {
-        0,
+           0,
     };
     struct in6_addr tmp_addr;
     char           *str = NULL;
@@ -822,26 +827,6 @@ nm_dhcp_utils_get_leasefile_path(int         addr_family,
     return FALSE;
 }
 
-char *
-nm_dhcp_utils_get_dhcp6_event_id(GHashTable *lease)
-{
-    const char *start;
-    const char *iaid;
-
-    if (!lease)
-        return NULL;
-
-    iaid = g_hash_table_lookup(lease, "iaid");
-    if (!iaid)
-        return NULL;
-
-    start = g_hash_table_lookup(lease, "life_starts");
-    if (!start)
-        return NULL;
-
-    return g_strdup_printf("%s|%s", iaid, start);
-}
-
 gboolean
 nm_dhcp_utils_merge_new_dhcp6_lease(const NML3ConfigData  *l3cd_old,
                                     const NML3ConfigData  *l3cd_new,
@@ -855,8 +840,7 @@ nm_dhcp_utils_merge_new_dhcp6_lease(const NML3ConfigData  *l3cd_old,
     const char                             *start;
     const char                             *iaid;
 
-    nm_assert(out_l3cd_merged);
-    nm_assert(!*out_l3cd_merged);
+    nm_assert(out_l3cd_merged && !*out_l3cd_merged);
 
     if (!l3cd_old)
         return FALSE;
@@ -903,28 +887,75 @@ nm_dhcp_utils_merge_new_dhcp6_lease(const NML3ConfigData  *l3cd_old,
 
 /*****************************************************************************/
 
+void
+nm_dhcp_lease_log_invalid_option(const char *iface,
+                                 int         addr_family,
+                                 guint       option,
+                                 const char *fmt,
+                                 ...)
+{
+    const char   *option_name;
+    gs_free char *msg = NULL;
+    va_list       ap;
+
+    option_name = nm_dhcp_option_request_string(addr_family, option);
+
+    va_start(ap, fmt);
+    msg = g_strdup_vprintf(fmt, ap);
+    va_end(ap);
+
+    _LOG2I(NM_IS_IPv4(addr_family) ? LOGD_DHCP4 : LOGD_DHCP6,
+           iface,
+           "error parsing DHCP option %d (%s)%s%s",
+           option,
+           option_name,
+           msg ? ": " : "",
+           msg ?: "");
+}
+
 gboolean
-nm_dhcp_lease_data_parse_u16(const guint8 *data, gsize n_data, uint16_t *out_val)
+nm_dhcp_lease_data_parse_u16(const guint8 *data,
+                             gsize         n_data,
+                             uint16_t     *out_val,
+                             const char   *iface,
+                             int           addr_family,
+                             guint         option)
 {
-    if (n_data != 2)
+    if (n_data != 2) {
+        nm_dhcp_lease_log_invalid_option(iface,
+                                         addr_family,
+                                         option,
+                                         "invalid option length %lu",
+                                         (unsigned long) n_data);
         return FALSE;
+    }
 
     *out_val = unaligned_read_be16(data);
     return TRUE;
 }
 
 gboolean
-nm_dhcp_lease_data_parse_mtu(const guint8 *data, gsize n_data, uint16_t *out_val)
+nm_dhcp_lease_data_parse_mtu(const guint8 *data,
+                             gsize         n_data,
+                             uint16_t     *out_val,
+                             const char   *iface,
+                             int           addr_family,
+                             guint         option)
 {
     uint16_t mtu;
 
-    if (!nm_dhcp_lease_data_parse_u16(data, n_data, &mtu))
+    if (!nm_dhcp_lease_data_parse_u16(data, n_data, &mtu, iface, addr_family, option))
         return FALSE;
 
     if (mtu < 68) {
         /* https://tools.ietf.org/html/rfc2132#section-5.1:
          *
          * The minimum legal value for the MTU is 68. */
+        nm_dhcp_lease_log_invalid_option(iface,
+                                         addr_family,
+                                         option,
+                                         "value %u is smaller than 68",
+                                         mtu);
         return FALSE;
     }
 
@@ -933,7 +964,12 @@ nm_dhcp_lease_data_parse_mtu(const guint8 *data, gsize n_data, uint16_t *out_val
 }
 
 gboolean
-nm_dhcp_lease_data_parse_cstr(const guint8 *data, gsize n_data, gsize *out_new_len)
+nm_dhcp_lease_data_parse_cstr(const guint8 *data,
+                              gsize         n_data,
+                              gsize        *out_new_len,
+                              const char   *iface,
+                              int           addr_family,
+                              guint         option)
 {
     /* WARNING: this function only validates that the string does not contain
      * NUL characters (and ignores trailing NULs). It does not check character
@@ -948,6 +984,10 @@ nm_dhcp_lease_data_parse_cstr(const guint8 *data, gsize n_data, gsize *out_new_l
              *
              * https://tools.ietf.org/html/rfc2132#section-2
              * https://github.com/systemd/systemd/issues/1337 */
+            nm_dhcp_lease_log_invalid_option(iface,
+                                             addr_family,
+                                             option,
+                                             "string contains embedded NUL");
             return FALSE;
         }
     }
@@ -957,32 +997,47 @@ nm_dhcp_lease_data_parse_cstr(const guint8 *data, gsize n_data, gsize *out_new_l
 }
 
 char *
-nm_dhcp_lease_data_parse_domain_validate(const char *str)
+nm_dhcp_lease_data_parse_domain_validate(const char *str,
+                                         const char *iface,
+                                         int         addr_family,
+                                         guint       option)
 {
     gs_free char *s = NULL;
 
     s = nm_sd_dns_name_normalize(str);
     if (!s)
-        return NULL;
+        goto err;
 
     if (nm_str_is_empty(s) || (s[0] == '.' && s[1] == '\0')) {
         /* root domains are not allowed. */
-        return NULL;
+        goto err;
     }
 
     if (nm_utils_is_localhost(s))
-        return NULL;
+        goto err;
 
     if (!g_utf8_validate(s, -1, NULL)) {
         /* the result must be valid UTF-8. */
-        return NULL;
+        goto err;
     }
 
     return g_steal_pointer(&s);
+err:
+    nm_dhcp_lease_log_invalid_option(iface,
+                                     addr_family,
+                                     option,
+                                     "'%s' is not a valid DNS domain",
+                                     str);
+    return NULL;
 }
 
 gboolean
-nm_dhcp_lease_data_parse_domain(const guint8 *data, gsize n_data, char **out_val)
+nm_dhcp_lease_data_parse_domain(const guint8 *data,
+                                gsize         n_data,
+                                char        **out_val,
+                                const char   *iface,
+                                int           addr_family,
+                                guint         option)
 {
     gs_free char *str1_free = NULL;
     const char   *str1;
@@ -990,7 +1045,7 @@ nm_dhcp_lease_data_parse_domain(const guint8 *data, gsize n_data, char **out_val
 
     /* this is mostly the same as systemd's lease_parse_domain(). */
 
-    if (!nm_dhcp_lease_data_parse_cstr(data, n_data, &n_data))
+    if (!nm_dhcp_lease_data_parse_cstr(data, n_data, &n_data, iface, addr_family, option))
         return FALSE;
 
     if (n_data == 0) {
@@ -1002,12 +1057,13 @@ nm_dhcp_lease_data_parse_domain(const guint8 *data, gsize n_data, char **out_val
          *
          * Note that this is *after* we potentially stripped trailing NULs.
          */
+        nm_dhcp_lease_log_invalid_option(iface, addr_family, option, "empty value");
         return FALSE;
     }
 
     str1 = nm_strndup_a(300, (char *) data, n_data, &str1_free);
 
-    s = nm_dhcp_lease_data_parse_domain_validate(str1);
+    s = nm_dhcp_lease_data_parse_domain_validate(str1, iface, addr_family, option);
     if (!s)
         return FALSE;
 
@@ -1016,7 +1072,11 @@ nm_dhcp_lease_data_parse_domain(const guint8 *data, gsize n_data, char **out_val
 }
 
 gboolean
-nm_dhcp_lease_data_parse_in_addr(const guint8 *data, gsize n_data, in_addr_t *out_val)
+nm_dhcp_lease_data_parse_in_addr(const guint8 *data,
+                                 gsize         n_data,
+                                 in_addr_t    *out_val,
+                                 const char   *iface,
+                                 guint         option)
 {
     /* - option 1, https://tools.ietf.org/html/rfc2132#section-3.3
      * - option 28, https://tools.ietf.org/html/rfc2132#section-5.3
@@ -1026,8 +1086,14 @@ nm_dhcp_lease_data_parse_in_addr(const guint8 *data, gsize n_data, in_addr_t *ou
      * according to RFC 3396 section 7. Therefore, it's possible that a
      * option carrying a IPv4 address has a length > 4.
      */
-    if (n_data < 4)
+    if (n_data < 4) {
+        nm_dhcp_lease_log_invalid_option(iface,
+                                         AF_INET,
+                                         option,
+                                         "invalid address length %lu",
+                                         (unsigned long) n_data);
         return FALSE;
+    }
 
     *out_val = unaligned_read_ne32(data);
     return TRUE;
@@ -1070,7 +1136,8 @@ static char *
 lease_option_print_domain_name(const uint8_t  *cache,
                                size_t         *n_cachep,
                                const uint8_t **datap,
-                               size_t         *n_datap)
+                               size_t         *n_datap,
+                               gboolean       *invalid)
 {
     nm_auto_str_buf NMStrBuf sbuf = NM_STR_BUF_INIT(NM_UTILS_GET_NEXT_REALLOC_SIZE_40, FALSE);
     const uint8_t           *domain;
@@ -1081,6 +1148,8 @@ lease_option_print_domain_name(const uint8_t  *cache,
     gboolean                 first     = TRUE;
     uint8_t                  c;
 
+    NM_SET_OUT(invalid, FALSE);
+
     /*
      * We are given two adjacent memory regions. The @cache contains alreday parsed
      * domain names, and the @datap contains the remaining data to parse.
@@ -1097,8 +1166,10 @@ lease_option_print_domain_name(const uint8_t  *cache,
      * Note, that each time a jump to an offset is performed, the size of the
      * cache shrinks, so this is guaranteed to terminate.
      */
-    if (cache + n_cache != *datap)
+    if (cache + n_cache != *datap) {
+        NM_SET_OUT(invalid, TRUE);
         return NULL;
+    }
 
     for (;;) {
         if (!nm_dhcp_lease_data_consume(domainp, n_domainp, &c, sizeof(c)))
@@ -1123,8 +1194,10 @@ lease_option_print_domain_name(const uint8_t  *cache,
             else
                 first = FALSE;
 
-            if (!lease_option_print_label(&sbuf, n_label, domainp, n_domainp))
+            if (!lease_option_print_label(&sbuf, n_label, domainp, n_domainp)) {
+                NM_SET_OUT(invalid, TRUE);
                 return NULL;
+            }
 
             break;
         }
@@ -1137,13 +1210,17 @@ lease_option_print_domain_name(const uint8_t  *cache,
              * two high bits are masked out.
              */
 
-            if (!nm_dhcp_lease_data_consume(domainp, n_domainp, &c, sizeof(c)))
+            if (!nm_dhcp_lease_data_consume(domainp, n_domainp, &c, sizeof(c))) {
+                NM_SET_OUT(invalid, TRUE);
                 return NULL;
+            }
 
             offset += c;
 
-            if (offset >= n_cache)
+            if (offset >= n_cache) {
+                NM_SET_OUT(invalid, TRUE);
                 return NULL;
+            }
 
             domain   = cache + offset;
             n_domain = n_cache - offset;
@@ -1155,29 +1232,44 @@ lease_option_print_domain_name(const uint8_t  *cache,
             break;
         }
         default:
+            NM_SET_OUT(invalid, TRUE);
             return NULL;
         }
     }
 }
 
 char **
-nm_dhcp_lease_data_parse_search_list(const guint8 *data, gsize n_data)
+nm_dhcp_lease_data_parse_search_list(const guint8 *data,
+                                     gsize         n_data,
+                                     const char   *iface,
+                                     int           addr_family,
+                                     guint         option)
 {
     GPtrArray    *array   = NULL;
     const guint8 *cache   = data;
     gsize         n_cache = 0;
+    guint         i       = 0;
 
     for (;;) {
         gs_free char *s = NULL;
-
-        s = lease_option_print_domain_name(cache, &n_cache, &data, &n_data);
-        if (!s)
+        gboolean      invalid;
+
+        s = lease_option_print_domain_name(cache, &n_cache, &data, &n_data, &invalid);
+        if (!s) {
+            if (iface && invalid)
+                nm_dhcp_lease_log_invalid_option(iface,
+                                                 addr_family,
+                                                 option,
+                                                 "search domain #%u is invalid",
+                                                 i);
             break;
+        }
 
         if (!array)
             array = g_ptr_array_new();
 
         g_ptr_array_add(array, g_steal_pointer(&s));
+        i++;
     }
 
     if (!array)
diff --git a/src/core/dhcp/nm-dhcp-utils.h b/src/core/dhcp/nm-dhcp-utils.h
index bef50c52..00199b02 100644
--- a/src/core/dhcp/nm-dhcp-utils.h
+++ b/src/core/dhcp/nm-dhcp-utils.h
@@ -59,13 +59,48 @@ nm_dhcp_lease_data_consume_in_addr(const uint8_t **datap, size_t *n_datap, in_ad
     return nm_dhcp_lease_data_consume(datap, n_datap, addrp, sizeof(struct in_addr));
 }
 
-char *nm_dhcp_lease_data_parse_domain_validate(const char *str);
-
-gboolean nm_dhcp_lease_data_parse_u16(const guint8 *data, gsize n_data, guint16 *out_val);
-gboolean nm_dhcp_lease_data_parse_mtu(const guint8 *data, gsize n_data, guint16 *out_val);
-gboolean nm_dhcp_lease_data_parse_cstr(const guint8 *data, gsize n_data, gsize *out_new_len);
-gboolean nm_dhcp_lease_data_parse_domain(const guint8 *data, gsize n_data, char **out_val);
-gboolean nm_dhcp_lease_data_parse_in_addr(const guint8 *data, gsize n_data, in_addr_t *out_val);
-char   **nm_dhcp_lease_data_parse_search_list(const guint8 *data, gsize n_data);
+void     nm_dhcp_lease_log_invalid_option(const char *iface,
+                                          int         addr_family,
+                                          guint       option,
+                                          const char *fmt,
+                                          ...) G_GNUC_PRINTF(4, 5);
+char    *nm_dhcp_lease_data_parse_domain_validate(const char *str,
+                                                  const char *iface,
+                                                  int         addr_family,
+                                                  guint       option);
+gboolean nm_dhcp_lease_data_parse_u16(const guint8 *data,
+                                      gsize         n_data,
+                                      guint16      *out_val,
+                                      const char   *iface,
+                                      int           addr_family,
+                                      guint         option);
+gboolean nm_dhcp_lease_data_parse_mtu(const guint8 *data,
+                                      gsize         n_data,
+                                      guint16      *out_val,
+                                      const char   *iface,
+                                      int           addr_family,
+                                      guint         option);
+gboolean nm_dhcp_lease_data_parse_cstr(const guint8 *data,
+                                       gsize         n_data,
+                                       gsize        *out_new_len,
+                                       const char   *iface,
+                                       int           addr_family,
+                                       guint         option);
+gboolean nm_dhcp_lease_data_parse_domain(const guint8 *data,
+                                         gsize         n_data,
+                                         char        **out_val,
+                                         const char   *iface,
+                                         int           addr_family,
+                                         guint         option);
+gboolean nm_dhcp_lease_data_parse_in_addr(const guint8 *data,
+                                          gsize         n_data,
+                                          in_addr_t    *out_val,
+                                          const char   *iface,
+                                          guint         option);
+char   **nm_dhcp_lease_data_parse_search_list(const guint8 *data,
+                                              gsize         n_data,
+                                              const char   *iface,
+                                              int           addr_family,
+                                              guint         option);
 
 #endif /* __NETWORKMANAGER_DHCP_UTILS_H__ */
diff --git a/src/core/dhcp/tests/test-dhcp-utils.c b/src/core/dhcp/tests/test-dhcp-utils.c
index f021b642..7597f516 100644
--- a/src/core/dhcp/tests/test-dhcp-utils.c
+++ b/src/core/dhcp/tests/test-dhcp-utils.c
@@ -196,16 +196,16 @@ test_parse_search_list(void)
     char  **domains;
 
     data    = (guint8[]){0x05, 'l', 'o', 'c', 'a', 'l', 0x00};
-    domains = nm_dhcp_lease_data_parse_search_list(data, 7);
+    domains = nm_dhcp_lease_data_parse_search_list(data, 7, NULL, 0, 0);
     g_assert(domains);
     g_assert_cmpint(g_strv_length(domains), ==, 1);
     g_assert_cmpstr(domains[0], ==, "local");
     g_strfreev(domains);
 
     data    = (guint8[]){0x04, 't',  'e',  's', 't', 0x07, 'e',  'x',  'a',  'm', 'p', 'l',
-                      'e',  0x03, 'c',  'o', 'm', 0x00, 0xc0, 0x05, 0x03, 'a', 'b', 'c',
-                      0xc0, 0x0d, 0x06, 'f', 'o', 'o',  'b',  'a',  'r',  0x00};
-    domains = nm_dhcp_lease_data_parse_search_list(data, 34);
+                         'e',  0x03, 'c',  'o', 'm', 0x00, 0xc0, 0x05, 0x03, 'a', 'b', 'c',
+                         0xc0, 0x0d, 0x06, 'f', 'o', 'o',  'b',  'a',  'r',  0x00};
+    domains = nm_dhcp_lease_data_parse_search_list(data, 34, NULL, 0, 0);
     g_assert(domains);
     g_assert_cmpint(g_strv_length(domains), ==, 4);
     g_assert_cmpstr(domains[0], ==, "test.example.com");
@@ -220,7 +220,7 @@ test_parse_search_list(void)
         'a',
         'd',
     };
-    domains = nm_dhcp_lease_data_parse_search_list(data, 4);
+    domains = nm_dhcp_lease_data_parse_search_list(data, 4, NULL, 0, 0);
     g_assert(!domains);
 
     data = (guint8[]){
@@ -235,7 +235,7 @@ test_parse_search_list(void)
         'a',
         'd',
     };
-    domains = nm_dhcp_lease_data_parse_search_list(data, 10);
+    domains = nm_dhcp_lease_data_parse_search_list(data, 10, NULL, 0, 0);
     g_assert(domains);
     g_assert_cmpint(g_strv_length(domains), ==, 1);
     g_assert_cmpstr(domains[0], ==, "okay");
@@ -285,7 +285,7 @@ test_classless_static_routes_1(void)
     const char                              *expected_route2_dest = "10.0.0.0";
     const char                              *expected_route2_gw   = "10.17.66.41";
     static const Option                      data[]               = {
-        /* dhclient custom format */
+                                           /* dhclient custom format */
         {"rfc3442_classless_static_routes", "24 192 168 10 192 168 1 1 8 10 10 17 66 41"},
         {NULL, NULL}};
 
@@ -310,7 +310,7 @@ test_classless_static_routes_2(void)
     const char                              *expected_route2_dest = "10.0.0.0";
     const char                              *expected_route2_gw   = "10.17.66.41";
     static const Option                      data[]               = {
-        /* dhcpcd format */
+                                           /* dhcpcd format */
         {"classless_static_routes", "192.168.10.0/24 192.168.1.1 10.0.0.0/8 10.17.66.41"},
         {NULL, NULL}};
 
@@ -336,9 +336,9 @@ test_fedora_dhclient_classless_static_routes(void)
     const char                              *expected_route2_gw   = "10.34.255.6";
     const char                              *expected_gateway     = "192.168.0.113";
     static const Option                      data[]               = {
-        /* Fedora dhclient format */
+                                           /* Fedora dhclient format */
         {"classless_static_routes",
-         "0 192.168.0.113 25.129.210.177.132 192.168.0.113 7.2 10.34.255.6"},
+                                            "0 192.168.0.113 25.129.210.177.132 192.168.0.113 7.2 10.34.255.6"},
         {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
@@ -362,7 +362,7 @@ test_dhclient_invalid_classless_routes_1(void)
     const char                              *expected_route1_dest = "192.168.10.0";
     const char                              *expected_route1_gw   = "192.168.1.1";
     static const Option                      data[]               = {
-        /* dhclient format */
+                                           /* dhclient format */
         {"rfc3442_classless_static_routes", "24 192 168 10 192 168 1 1 45 10 17 66 41"},
         {NULL, NULL}};
 
@@ -389,7 +389,7 @@ test_dhcpcd_invalid_classless_routes_1(void)
     const char                              *expected_route2_dest = "100.99.88.56";
     const char                              *expected_route2_gw   = "10.1.1.1";
     static const Option                      data[]               = {
-        /* dhcpcd format */
+                                           /* dhcpcd format */
         {"classless_static_routes", "192.168.10.0/24 192.168.1.1 10.0.adfadf/44 10.17.66.41"},
         {NULL, NULL}};
 
@@ -419,8 +419,8 @@ test_dhclient_invalid_classless_routes_2(void)
     const char                              *expected_route2_dest = "100.99.88.56";
     const char                              *expected_route2_gw   = "10.1.1.1";
     static const Option                      data[]               = {
-        {"rfc3442_classless_static_routes", "45 10 17 66 41 24 192 168 10 192 168 1 1"},
-        {NULL, NULL}};
+                                           {"rfc3442_classless_static_routes", "45 10 17 66 41 24 192 168 10 192 168 1 1"},
+                                           {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
     options = fill_table(data, options);
@@ -448,8 +448,8 @@ test_dhcpcd_invalid_classless_routes_2(void)
     const char                              *expected_route2_dest = "100.99.88.56";
     const char                              *expected_route2_gw   = "10.1.1.1";
     static const Option                      data[]               = {
-        {"classless_static_routes", "10.0.adfadf/44 10.17.66.41 192.168.10.0/24 192.168.1.1"},
-        {NULL, NULL}};
+                                           {"classless_static_routes", "10.0.adfadf/44 10.17.66.41 192.168.10.0/24 192.168.1.1"},
+                                           {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
     options = fill_table(data, options);
@@ -477,8 +477,8 @@ test_dhclient_invalid_classless_routes_3(void)
     const char                              *expected_route1_dest = "192.168.10.0";
     const char                              *expected_route1_gw   = "192.168.1.1";
     static const Option                      data[]               = {
-        {"rfc3442_classless_static_routes", "24 192 168 10 192 168 1 1 32 128 10 17 66 41"},
-        {NULL, NULL}};
+                                           {"rfc3442_classless_static_routes", "24 192 168 10 192 168 1 1 32 128 10 17 66 41"},
+                                           {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
     options = fill_table(data, options);
@@ -501,8 +501,8 @@ test_dhcpcd_invalid_classless_routes_3(void)
     const char                              *expected_route1_dest = "192.168.10.0";
     const char                              *expected_route1_gw   = "192.168.1.1";
     static Option                            data[]               = {
-        {"classless_static_routes", "192.168.10.0/24 192.168.1.1 128/32 10.17.66.41"},
-        {NULL, NULL}};
+                                                 {"classless_static_routes", "192.168.10.0/24 192.168.1.1 128/32 10.17.66.41"},
+                                                 {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
     options = fill_table(data, options);
@@ -526,8 +526,8 @@ test_dhclient_gw_in_classless_routes(void)
     const char                              *expected_route1_gw   = "192.168.1.1";
     const char                              *expected_gateway     = "192.2.3.4";
     static Option                            data[]               = {
-        {"rfc3442_classless_static_routes", "24 192 168 10 192 168 1 1 0 192 2 3 4"},
-        {NULL, NULL}};
+                                                 {"rfc3442_classless_static_routes", "24 192 168 10 192 168 1 1 0 192 2 3 4"},
+                                                 {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
     options = fill_table(data, options);
@@ -550,8 +550,8 @@ test_dhcpcd_gw_in_classless_routes(void)
     const char                              *expected_route1_gw   = "192.168.1.1";
     const char                              *expected_gateway     = "192.2.3.4";
     static Option                            data[]               = {
-        {"classless_static_routes", "192.168.10.0/24 192.168.1.1 0.0.0.0/0 192.2.3.4"},
-        {NULL, NULL}};
+                                                 {"classless_static_routes", "192.168.10.0/24 192.168.1.1 0.0.0.0/0 192.2.3.4"},
+                                                 {NULL, NULL}};
 
     options = fill_table(generic_options, NULL);
     options = fill_table(data, options);