Re: [pve-devel] [PATCH ifupdown2 1/2] d/patches: add patch for supporting dhcpcd as alternative dhcp client

Thomas Lamprecht
2026-04-01T17:13:09Z

Am 12.12.25 um 12:32 schrieb Christoph Heiss:
> This adds support for dhcpcd(8) as a second dhcp client. dhclient has
> been deprecated by upstream.
>
> With this patch, ifupdown2 will prefer dhcpcd, falling back to dhclient
> if it cannot find the former for backwards compatibility.
>
> Upstream-PR: https://github.com/CumulusNetworks/ifupdown2/pull/347
> Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
> ---
> To test, first `dhcpcd` must be installed using `apt install dhcpcd`.
>
> Then, put
>
> auto nic1
> iface nic1 inet dhcp
> iface nic8 inet6 dhcp
>
> in /etc/network/interfaces (renaming `nic1` as needed), and commenting
> in/out the respective `inet` or `inet6` line and running
> `ifreload -avd; ip a s nic1` to check the results.
>

Looks alright from principle, some comments inline (not sure about every
question, need always a bit to get into python/ifupdown2...)

> debian/patches/series | 1 +
> ...-support-for-dhcpcd-8-as-dhcp-client.patch | 1009 +++++++++++++++++
> 2 files changed, 1010 insertions(+)
> create mode 100644 debian/patches/upstream/0003-addons-dhcp-add-support-for-dhcpcd-8-as-dhcp-client.patch
>
> diff --git a/debian/patches/series b/debian/patches/series
> index 2865533..ea7ec89 100644
> --- a/debian/patches/series
> +++ b/debian/patches/series
> @@ -16,3 +16,4 @@ upstream/0001-use-raw-strings-for-regex-to-fix-backslash-interpret.patch
> upstream/0002-vxlan-add-support-for-IPv6-vxlan-local-tunnelip.patch
> pve/0014-nlmanager-read-ipv6-devconf-disable_ipv6-attribute-t.patch
> pve/0015-revert-addons-bond-warn-if-sub-interface-is-detected-on-bond-slave.patch
> +upstream/0003-addons-dhcp-add-support-for-dhcpcd-8-as-dhcp-client.patch
> diff --git a/debian/patches/upstream/0003-addons-dhcp-add-support-for-dhcpcd-8-as-dhcp-client.patch b/debian/patches/upstream/0003-addons-dhcp-add-support-for-dhcpcd-8-as-dhcp-client.patch
> new file mode 100644
> index 0000000..fc9e9cd
> --- /dev/null
> +++ b/debian/patches/upstream/0003-addons-dhcp-add-support-for-dhcpcd-8-as-dhcp-client.patch
> @@ -0,0 +1,1009 @@
> +From c96ed60826714873ad06315bdfa9ec6028fd2f08 Mon Sep 17 00:00:00 2001
> +From: Christoph Heiss <c.heiss@proxmox.com>
> +Date: Fri, 12 Dec 2025 12:05:51 +0100
> +Subject: [PATCH] addons: dhcp: add support for dhcpcd(8) as dhcp client
> +
> +This adds support for dhcpcd(8) as a second dhcp client. dhclient has
> +been deprecated by upstream.
> +
> +With this patch, ifupdown2 will prefer dhcpcd, falling back to dhclient
> +if it cannot find the former for backwards compatibility.
> +
> +Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
> +---
> + docs/source/addonshelperapiref.rst | 18 +++
> + ifupdown2/addons/address.py | 41 ++++---
> + ifupdown2/addons/dhcp.py | 155 +++++++++++++++---------
> + ifupdown2/addons/vrf.py | 34 +++---
> + ifupdown2/ifupdown/exceptions.py | 4 +
> + ifupdown2/ifupdown/utils.py | 18 ++-
> + ifupdown2/ifupdownaddons/dhclient.py | 91 +++++++-------
> + ifupdown2/ifupdownaddons/dhcp_client.py | 49 ++++++++
> + ifupdown2/ifupdownaddons/dhcpcd.py | 147 ++++++++++++++++++++++
> + ifupdown2/lib/sysfs.py | 7 ++
> + 10 files changed, 425 insertions(+), 139 deletions(-)
> + create mode 100644 ifupdown2/ifupdownaddons/dhcp_client.py
> + create mode 100644 ifupdown2/ifupdownaddons/dhcpcd.py
> +
> +diff --git a/docs/source/addonshelperapiref.rst b/docs/source/addonshelperapiref.rst
> +index 6eaa0ef..1dd3396 100644
> +--- a/docs/source/addonshelperapiref.rst
> ++++ b/docs/source/addonshelperapiref.rst
> +@@ -22,3 +22,21 @@ Helper module to interact with dhclient tools.
> + .. automodule:: dhclient
> +
> + .. autoclass:: dhclient
> ++
> ++dhcpcd
> ++======
> ++
> ++Helper module to interact with the dhcpcd(8) DHCP client.
> ++
> ++.. automodule:: dhcpcd
> ++
> ++.. autoclass:: dhcpcd
> ++
> ++DhcpClient
> ++==========
> ++
> ++Helper module to interact with the dhcpcd(8) DHCP client.

As this is generic maybe rather word it:

"Helper module to interact with DHCP clients like dhcpcd(8) or legacy dhclient."

> ++
> ++.. automodule:: dhcp_client
> ++
> ++.. autoclass:: DhcpClient
> +diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py
> +index 46226a9..8ae69ee 100644
> +--- a/ifupdown2/addons/address.py
> ++++ b/ifupdown2/addons/address.py
> +@@ -17,8 +17,9 @@ try:
> +
> + from ifupdown2.ifupdown.iface import ifaceType, ifaceLinkKind, ifaceLinkPrivFlags, ifaceStatus, iface
> + from ifupdown2.ifupdown.utils import utils
> ++ from ifupdown2.ifupdown.exceptions import NoDhcpClientAvailable
> +
> +- from ifupdown2.ifupdownaddons.dhclient import dhclient
> ++ from ifupdown2.ifupdownaddons.dhcp_client import DhcpClient
> + from ifupdown2.ifupdownaddons.modulebase import moduleBase
> +
> + import ifupdown2.nlmanager.ipnetwork as ipnetwork
> +@@ -33,8 +34,9 @@ except (ImportError, ModuleNotFoundError):
> +
> + from ifupdown.iface import ifaceType, ifaceLinkKind, ifaceLinkPrivFlags, ifaceStatus, iface
> + from ifupdown.utils import utils
> ++ from ifupdown.exceptions import NoDhcpClientAvailable
> +
> +- from ifupdownaddons.dhclient import dhclient
> ++ from ifupdownaddons.dhcp_client import DhcpClient
> + from ifupdownaddons.modulebase import moduleBase
> +
> + import nlmanager.ipnetwork as ipnetwork
> +@@ -211,6 +213,8 @@ class address(AddonWithIpBlackList, moduleBase):
> +
> + DEFAULT_MTU_STRING = "1500"
> +
> ++ dhcpcmd: DhcpClient | None
> ++
> + def __init__(self, *args, **kargs):
> + AddonWithIpBlackList.__init__(self)
> + moduleBase.__init__(self, *args, **kargs)
> +@@ -291,6 +295,11 @@ class address(AddonWithIpBlackList, moduleBase):
> + except Exception:
> + self.default_autoconf = 1
> +
> ++ try:
> ++ self.dhcpcmd = DhcpClient()
> ++ except NoDhcpClientAvailable as e:
> ++ self.dhcpcmd = None
> ++
> + def __policy_get_default_mtu(self):
> + default_mtu = policymanager.policymanager_api.get_attr_default(
> + module_name=self.__class__.__name__,
> +@@ -1207,18 +1216,17 @@ class address(AddonWithIpBlackList, moduleBase):
> + if (addr_method not in ["dhcp", "ppp"] and not ifupdownflags.flags.PERFMODE and
> + not (ifaceobj.flags & iface.HAS_SIBLINGS)):
> + # if not running in perf mode and ifaceobj does not have
> +- # any sibling iface objects, kill any stale dhclient
> +- # processes
> +- dhclientcmd = dhclient()
> +- if dhclientcmd.is_running(ifaceobj.name):
> +- # release any dhcp leases
> +- dhclientcmd.release(ifaceobj.name)
> +- self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET)
> +- force_reapply = True
> +- elif dhclientcmd.is_running6(ifaceobj.name):
> +- dhclientcmd.release6(ifaceobj.name)
> +- self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6)
> +- force_reapply = True
> ++ # any sibling iface objects, kill any stale dhcp client processes
> ++ if self.dhcpcmd:
> ++ if self.dhcpcmd.is_running(ifaceobj.name):
> ++ # release any dhcp leases
> ++ self.dhcpcmd.release(ifaceobj.name)
> ++ self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET)
> ++ force_reapply = True
> ++ elif self.dhcpcmd.is_running6(ifaceobj.name):
> ++ self.dhcpcmd.release6(ifaceobj.name)
> ++ self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6)
> ++ force_reapply = True
> + except Exception:
> + pass
> +
> +@@ -1624,9 +1632,8 @@ class address(AddonWithIpBlackList, moduleBase):
> +
> + self.query_running_ipv6_addrgen(ifaceobjrunning)
> +
> +- dhclientcmd = dhclient()
> +- if (dhclientcmd.is_running(ifaceobjrunning.name) or
> +- dhclientcmd.is_running6(ifaceobjrunning.name)):
> ++ if self.dhcpcmd and (self.dhcpcmd.is_running(ifaceobjrunning.name) or
> ++ self.dhcpcmd.is_running6(ifaceobjrunning.name)):
> + # If dhcp is configured on the interface, we skip it
> + return
> +
> +diff --git a/ifupdown2/addons/dhcp.py b/ifupdown2/addons/dhcp.py
> +index 22bbdb4..cdad890 100644
> +--- a/ifupdown2/addons/dhcp.py
> ++++ b/ifupdown2/addons/dhcp.py
> +@@ -18,8 +18,9 @@ try:
> +
> + from ifupdown2.ifupdown.iface import ifaceLinkPrivFlags, ifaceStatus
> + from ifupdown2.ifupdown.utils import utils
> ++ from ifupdown2.ifupdown.exceptions import moduleNotSupported, NoDhcpClientAvailable
> +
> +- from ifupdown2.ifupdownaddons.dhclient import dhclient
> ++ from ifupdown2.ifupdownaddons.dhcp_client import DhcpClient
> + from ifupdown2.ifupdownaddons.modulebase import moduleBase
> + except (ImportError, ModuleNotFoundError):
> + from lib.addon import Addon
> +@@ -30,8 +31,9 @@ except (ImportError, ModuleNotFoundError):
> +
> + from ifupdown.iface import ifaceLinkPrivFlags, ifaceStatus
> + from ifupdown.utils import utils
> ++ from ifupdown.exceptions import moduleNotSupported, NoDhcpClientAvailable
> +
> +- from ifupdownaddons.dhclient import dhclient
> ++ from ifupdownaddons.dhcp_client import DhcpClient
> + from ifupdownaddons.modulebase import moduleBase
> +
> +
> +@@ -40,13 +42,14 @@ class dhcp(Addon, moduleBase):
> +
> + # by default we won't perform any dhcp retry
> + # this can be changed by setting the module global
> +- # policy: dhclient_retry_on_failure
> +- DHCLIENT_RETRY_ON_FAILURE = 0
> ++ # policy: dhcp_retry_on_failure
> ++ DHCP_RETRY_ON_FAILURE = 0
> ++
> ++ dhcpcmd: DhcpClient
> +
> + def __init__(self, *args, **kargs):
> + Addon.__init__(self)
> + moduleBase.__init__(self, *args, **kargs)
> +- self.dhclientcmd = dhclient(**kargs)
> + vrf_id = self._get_vrf_context()
> + if vrf_id and vrf_id == 'mgmt':
> + self.mgmt_vrf_context = True
> +@@ -55,24 +58,41 @@ class dhcp(Addon, moduleBase):
> + self.logger.info('mgmt vrf_context = %s' %self.mgmt_vrf_context)
> +
> + try:
> +- self.dhclient_retry_on_failure = int(
> +- policymanager.policymanager_api.get_module_globals(
> +- module_name=self.__class__.__name__,
> +- attr="dhclient_retry_on_failure"
> ++ try:
> ++ self.dhcp_retry_on_failure = int(
> ++ policymanager.policymanager_api.get_module_globals(
> ++ module_name=self.__class__.__name__,
> ++ attr="dhcp_retry_on_failure"
> ++ )
> ++ )
> ++ except:

Also catches SystemExit/KeyboardInterrupt, most code here uses the less
broad `except Exception:` AFAICT.

> ++ self.dhcp_retry_on_failure = int(
> ++ policymanager.policymanager_api.get_module_globals(
> ++ module_name=self.__class__.__name__,
> ++ attr="dhclient_retry_on_failure"
> ++ )
> + )
> +- )
> + except Exception:
> +- self.dhclient_retry_on_failure = self.DHCLIENT_RETRY_ON_FAILURE
> ++ self.dhcp_retry_on_failure = self.DHCP_RETRY_ON_FAILURE
> +
> +- if self.dhclient_retry_on_failure < 0:
> +- self.dhclient_retry_on_failure = 0
> ++ if self.dhcp_retry_on_failure < 0:
> ++ self.dhcp_retry_on_failure = 0
> +
> +- self.logger.debug("dhclient: dhclient_retry_on_failure set to %s" % self.dhclient_retry_on_failure)
> ++ try:
> ++ self.dhcpcmd = DhcpClient()
> ++ except NoDhcpClientAvailable as e:
> ++ self.logger.warn('no dhcp client available')
> ++ raise moduleNotSupported(e.message)

NoDhcpClientAvailable inherits from plain Exception, which has no .message
attr, should be probably `str(e)`

> ++
> ++ self.logger.debug("dhcp: dhcp_retry_on_failure set to %s" % self.dhcp_retry_on_failure)
> +
> + def syntax_check(self, ifaceobj, ifaceobj_getfunc):
> + return self.is_dhcp_allowed_on(ifaceobj, syntax_check=True)
> +
> + def is_dhcp_allowed_on(self, ifaceobj, syntax_check):
> ++ if not self.dhcpcmd:
> ++ return False
> ++
> + if ifaceobj.addr_method and 'dhcp' in ifaceobj.addr_method:
> + return utils.is_addr_ip_allowed_on(ifaceobj, syntax_check=True)
> + return True
> +@@ -97,9 +117,9 @@ class dhcp(Addon, moduleBase):
> + pass
> + return ips
> +
> +- def dhclient_start_and_check(self, ifname, family, handler, wait=True, **handler_kwargs):
> ++ def dhcp_client_start_and_check(self, ifname, family, handler, wait=True, **handler_kwargs):
> + ip_config_before = self.get_current_ip_configured(ifname, family)
> +- retry = self.dhclient_retry_on_failure
> ++ retry = self.dhcp_retry_on_failure
> +
> + while retry >= 0:
> + handler(ifname, wait=wait, **handler_kwargs)
> +@@ -107,47 +127,52 @@ class dhcp(Addon, moduleBase):
> + # In most case, the client won't have the time to find anything
> + # with the wait=False param.
> + return
> +- retry = self.dhclient_check(ifname, family, ip_config_before, retry, handler_kwargs.get("cmd_prefix"))
> ++ retry = self.dhcp_client_check(ifname, family, ip_config_before, retry, handler_kwargs.get("cmd_prefix"))
> +
> +- def dhclient_check(self, ifname, family, ip_config_before, retry, dhclient_cmd_prefix):
> ++ def dhcp_client_check(self, ifname, family, ip_config_before, retry, dhcp_cmd_prefix: list[str]):
> + diff = self.get_current_ip_configured(ifname, family).difference(ip_config_before)
> +
> + if diff:
> + self.logger.info(
> +- "%s: dhclient: new address%s detected: %s"
> ++ "%s: dhcp: new address%s detected: %s"
> + % (ifname, "es" if len(diff) > 1 else "", ", ".join(diff))
> + )
> + return -1
> + else:
> + if retry > 0:
> + self.logger.error(
> +- "%s: dhclient: couldn't detect new ip address, retrying %s more times..."
> ++ "%s: dhcp: couldn't detect new ip address, retrying %s more times..."
> + % (ifname, retry)
> + )
> +- self.dhclientcmd.stop(ifname)
> ++ self.dhcpcmd.stop(ifname)
> + else:
> +- self.logger.error("%s: dhclient: timeout failed to detect new ip addresses" % ifname)
> ++ self.logger.error("%s: dhcp: timeout failed to detect new ip addresses" % ifname)
> + return -1
> + retry -= 1
> + return retry
> +
> + def _up(self, ifaceobj):
> ++ real_ifname = self.cache.link_translate_altname(ifaceobj.name)
> ++
> + # if dhclient is already running do not stop and start it
> +- dhclient4_running = self.dhclientcmd.is_running(ifaceobj.name)
> +- dhclient6_running = self.dhclientcmd.is_running6(ifaceobj.name)
> ++ dhcp4_running = self.dhcpcmd.is_running(real_ifname)
> ++ dhcp6_running = self.dhcpcmd.is_running6(real_ifname)
> ++
> ++ self.logger.debug(f'dhcp v4 client running: {dhcp4_running}')
> ++ self.logger.debug(f'dhcp v6 client running: {dhcp6_running}')
> +
> + # today if we have an interface with both inet and inet6, if we
> + # remove the inet or inet6 or both then execute ifreload, we need
> + # to release/kill the appropriate dhclient(4/6) if they are running
> +- self._down_stale_dhcp_config(ifaceobj, 'inet', dhclient4_running)
> +- self._down_stale_dhcp_config(ifaceobj, 'inet6', dhclient6_running)
> ++ self._down_stale_dhcp_config(ifaceobj, 'inet', dhcp4_running)
> ++ self._down_stale_dhcp_config(ifaceobj, 'inet6', dhcp6_running)
> +
> + if ifaceobj.link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN:
> + self.logger.info("%s: skipping dhcp configuration: link-down yes" % ifaceobj.name)
> + return
> +
> + try:
> +- dhclient_cmd_prefix = None
> ++ dhcp_cmd_prefix = []
> + dhcp_wait = policymanager.policymanager_api.get_attr_default(
> + module_name=self.__class__.__name__, attr='dhcp-wait')
> + wait = str(dhcp_wait).lower() != "no"
> +@@ -163,38 +188,43 @@ class dhcp(Addon, moduleBase):
> + vrf = ifaceobj.get_attr_value_first('vrf')
> + if (vrf and self.vrf_exec_cmd_prefix and
> + self.cache.link_exists(vrf)):
> +- dhclient_cmd_prefix = '%s %s' %(self.vrf_exec_cmd_prefix, vrf)
> ++ dhcp_cmd_prefix = self.vrf_exec_cmd_prefix.split() + [vrf]
> + elif self.mgmt_vrf_context:
> +- dhclient_cmd_prefix = '%s %s' %(self.vrf_exec_cmd_prefix, 'default')
> ++ dhcp_cmd_prefix = self.vrf_exec_cmd_prefix.split() + ['default']
> + self.logger.info('detected mgmt vrf context starting dhclient in default vrf context')
> +
> + if 'inet' in ifaceobj.addr_family:
> +- if dhclient4_running:
> +- self.logger.info('dhclient4 already running on %s. '
> +- 'Not restarting.' % ifaceobj.name)
> ++ if dhcp4_running:
> ++ self.logger.info('dhcp4 client already running on %s. '
> ++ 'Not restarting.' % real_ifname)
> + else:
> +- # First release any existing dhclient processes
> ++ # First release any existing dhcp processes
> + try:
> + if not ifupdownflags.flags.PERFMODE:
> +- self.dhclientcmd.stop(ifaceobj.name)
> ++ self.dhcpcmd.stop(real_ifname)
> + except Exception:
> + pass
> +
> +- self.dhclient_start_and_check(
> +- ifaceobj.name,
> ++ self.dhcp_client_start_and_check(
> ++ real_ifname,
> + "inet",
> +- self.dhclientcmd.start,
> ++ self.dhcpcmd.start,
> + wait=wait,
> +- cmd_prefix=dhclient_cmd_prefix
> ++ cmd_prefix=dhcp_cmd_prefix
> + )
> ++ elif dhcp4_running:
> ++ # release and stop the running dhcp client if the ipv4 dhcp config vanished
> ++ self.logger.debug('dhcp4 running but config vanished, stopping')
> ++ self.dhcpcmd.release(real_ifname)
> ++ self.dhcpcmd.stop(real_ifname)

not 100% sure, isn't that already handled by the _down_stale_dhcp_config calls
above?

> +
> + if 'inet6' in ifaceobj.addr_family:
> +- if dhclient6_running:
> +- self.logger.info('dhclient6 already running on %s. '
> ++ if dhcp6_running:
> ++ self.logger.info('dhcp6 client already running on %s. '
> + 'Not restarting.' % ifaceobj.name)
> + else:
> + try:
> +- self.dhclientcmd.stop6(ifaceobj.name, duid=dhcp6_duid)
> ++ self.dhcpcmd.stop6(real_ifname, duid=dhcp6_duid)
> + except Exception:
> + pass
> + #add delay before starting IPv6 dhclient to
> +@@ -202,17 +232,21 @@ class dhcp(Addon, moduleBase):
> + if timeout > 1:
> + time.sleep(1)
> + while timeout:
> +- addr_output = utils.exec_command('%s -6 addr show %s'
> +- %(utils.ip_cmd, ifaceobj.name))
> +- r = re.search('inet6 .* scope link', addr_output)
> +- if r:
> +- self.dhclientcmd.start6(ifaceobj.name,
> +- wait=wait,
> +- cmd_prefix=dhclient_cmd_prefix, duid=dhcp6_duid)
> +- return
> ++ if self.cache.link_is_up(real_ifname):
> ++ break
> + timeout -= 1
> + if timeout:
> + time.sleep(1)
> ++
> ++ self.dhcpcmd.start6(real_ifname,
> ++ wait=wait,
> ++ cmd_prefix=dhcp_cmd_prefix, duid=dhcp6_duid)
> ++ elif dhcp6_running:
> ++ # release and stop the running dhcp client if the ipv6 dhcp config vanished
> ++ self.logger.debug('dhcp6 running but config vanished, stopping')
> ++ self.dhcpcmd.release6(real_ifname)
> ++ self.dhcpcmd.stop6(real_ifname)

similar here w.r.t. this potentially already being handled by _down_stale_dhcp_config

> ++
> + except Exception as e:
> + self.logger.error("%s: %s" % (ifaceobj.name, str(e)))
> + ifaceobj.set_status(ifaceStatus.ERROR)
> +@@ -229,18 +263,20 @@ class dhcp(Addon, moduleBase):
> + ifaceobj.addr_family = addr_family
> +
> + def _dhcp_down(self, ifaceobj):
> +- dhclient_cmd_prefix = None
> ++ dhcp_cmd_prefix = []
> ++ ifname = self.cache.link_translate_altname(ifaceobj.name)
> ++
> + vrf = ifaceobj.get_attr_value_first('vrf')
> + if (vrf and self.vrf_exec_cmd_prefix and
> + self.cache.link_exists(vrf)):
> +- dhclient_cmd_prefix = '%s %s' %(self.vrf_exec_cmd_prefix, vrf)
> ++ dhcp_cmd_prefix = self.vrf_exec_cmd_prefix.split() + [vrf]
> + dhcp6_duid = policymanager.policymanager_api.get_iface_default(module_name=self.__class__.__name__, \
> + ifname=ifaceobj.name, attr='dhcp6-duid')
> + if 'inet6' in ifaceobj.addr_family:
> +- self.dhclientcmd.release6(ifaceobj.name, dhclient_cmd_prefix, duid=dhcp6_duid)
> ++ self.dhcpcmd.release6(ifname, dhcp_cmd_prefix, duid=dhcp6_duid)
> + self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6)
> + if 'inet' in ifaceobj.addr_family:
> +- self.dhclientcmd.release(ifaceobj.name, dhclient_cmd_prefix)
> ++ self.dhcpcmd.release(ifname, dhcp_cmd_prefix)
> + self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET)
> +
> + def _down(self, ifaceobj):
> +@@ -250,9 +286,10 @@ class dhcp(Addon, moduleBase):
> + def _query_check(self, ifaceobj, ifaceobjcurr):
> + status = ifaceStatus.SUCCESS
> + dhcp_running = False
> ++ ifname = self.cache.link_translate_altname(ifaceobjcurr.name)
> +
> +- dhcp_v4 = self.dhclientcmd.is_running(ifaceobjcurr.name)
> +- dhcp_v6 = self.dhclientcmd.is_running6(ifaceobjcurr.name)
> ++ dhcp_v4 = self.dhcpcmd.is_running(ifname)
> ++ dhcp_v6 = self.dhcpcmd.is_running6(ifname)
> +
> + if dhcp_v4:
> + dhcp_running = True
> +@@ -271,12 +308,14 @@ class dhcp(Addon, moduleBase):
> + ifaceobjcurr.status = status
> +
> + def _query_running(self, ifaceobjrunning):
> +- if not self.cache.link_exists(ifaceobjrunning.name):
> ++ ifname = self.cache.link_translate_altname(ifaceobjrunning.name)
> ++
> ++ if not self.cache.link_exists(ifname):
> + return
> +- if self.dhclientcmd.is_running(ifaceobjrunning.name):
> ++ if self.dhcpcmd.is_running(ifname):
> + ifaceobjrunning.addr_family.append('inet')
> + ifaceobjrunning.addr_method = 'dhcp'
> +- if self.dhclientcmd.is_running6(ifaceobjrunning.name):
> ++ if self.dhcpcmd.is_running6(ifname):
> + ifaceobjrunning.addr_family.append('inet6')
> + ifaceobjrunning.addr_method = 'dhcp6'
> +
> +diff --git a/ifupdown2/addons/vrf.py b/ifupdown2/addons/vrf.py
> +index 2c92a12..04fec56 100644
> +--- a/ifupdown2/addons/vrf.py
> ++++ b/ifupdown2/addons/vrf.py
> +@@ -19,10 +19,11 @@ try:
> +
> + from ifupdown2.ifupdown.iface import ifaceRole, ifaceLinkKind, ifaceLinkPrivFlags, ifaceLinkType
> + from ifupdown2.ifupdown.utils import utils
> ++ from ifupdown2.ifupdown.exceptions import NoDhcpClientAvailable
> +
> + from ifupdown2.nlmanager.nlmanager import Link
> +
> +- from ifupdown2.ifupdownaddons.dhclient import dhclient
> ++ from ifupdown2.ifupdownaddons.dhcp_client import DhcpClient
> + from ifupdown2.ifupdownaddons.utilsbase import *
> + from ifupdown2.ifupdownaddons.modulebase import moduleBase
> + except (ImportError, ModuleNotFoundError):
> +@@ -34,14 +35,14 @@ except (ImportError, ModuleNotFoundError):
> +
> + from ifupdown.iface import ifaceRole, ifaceLinkKind, ifaceLinkPrivFlags, ifaceLinkType
> + from ifupdown.utils import utils
> ++ from ifupdown.exceptions import NoDhcpClientAvailable
> +
> + from nlmanager.nlmanager import Link
> +
> +- from ifupdownaddons.dhclient import dhclient
> ++ from ifupdownaddons.dhcp_client import DhcpClient
> + from ifupdownaddons.utilsbase import *
> + from ifupdownaddons.modulebase import moduleBase
> +
> +-
> + class vrfPrivFlags:
> + PROCESSED = 0x1
> +
> +@@ -80,10 +81,11 @@ class vrf(Addon, moduleBase):
> + "0": "unspec"
> + }
> +
> ++ dhcpcmd: DhcpClient | None
> ++
> + def __init__(self, *args, **kargs):
> + Addon.__init__(self)
> + moduleBase.__init__(self, *args, **kargs)
> +- self.dhclientcmd = None
> + self.name = self.__class__.__name__
> + self.vrf_mgmt_devname = policymanager.policymanager_api.get_module_globals(
> + module_name=self.__class__.__name__,
> +@@ -128,6 +130,11 @@ class vrf(Addon, moduleBase):
> + self.ip6_rule_cache = []
> + self.logger.warning('vrf: cache v6: %s' % str(e))
> +
> ++ try:
> ++ self.dhcpcmd = DhcpClient()
> ++ except NoDhcpClientAvailable as e:
> ++ self.dhcpcmd = None
> ++
> + #self.logger.debug("vrf: ip rule cache")
> + #self.logger.info(self.ip_rule_cache)
> +
> +@@ -403,7 +410,7 @@ class vrf(Addon, moduleBase):
> + def _up_vrf_slave_without_master(self, ifacename, vrfname, ifaceobj, vrf_master_objs, ifaceobj_getfunc=None):
> + """ If we have a vrf slave that has dhcp configured, bring up the
> + vrf master now. This is needed because vrf has special handling
> +- in dhclient hook which requires the vrf master to be present """
> ++ in dhcp hook which requires the vrf master to be present """
> + vrf_master = None
> + if len(ifaceobj.upperifaces) > 1 and ifaceobj_getfunc:
> + for upper_iface in ifaceobj.upperifaces:
> +@@ -460,14 +467,15 @@ class vrf(Addon, moduleBase):
> +
> + def _down_dhcp_slave(self, ifaceobj, vrfname):
> + try:
> +- dhclient_cmd_prefix = None
> ++ dhcp_cmd_prefix = None
> + if (vrfname and self.vrf_exec_cmd_prefix and
> + self.cache.link_exists(vrfname)):
> +- dhclient_cmd_prefix = '%s %s' %(self.vrf_exec_cmd_prefix,
> +- vrfname)
> +- self.dhclientcmd.release(ifaceobj.name, dhclient_cmd_prefix)
> ++ dhcp_cmd_prefix = self.vrf_exec_cmd_prefix.split() + [vrfname]
> ++
> ++ ifacename = self.cache.link_translate_altname(ifaceobj.name)
> ++ self.dhcpcmd.release(ifacename, dhcp_cmd_prefix)
> + except Exception:
> +- # ignore any dhclient release errors
> ++ # ignore any dhcp client release errors
> + pass
> +
> + def _handle_existing_connections(self, ifaceobj, vrfname):
> +@@ -1127,10 +1135,6 @@ class vrf(Addon, moduleBase):
> + """ returns list of ops supported by this module """
> + return list(self._run_ops.keys())
> +
> +- def _init_command_handlers(self):
> +- if not self.dhclientcmd:
> +- self.dhclientcmd = dhclient()
> +-
> + def run(self, ifaceobj, operation, query_ifaceobj=None,
> + ifaceobj_getfunc=None, **extra_args):
> + """ run bond configuration on the interface object passed as argument
> +@@ -1152,7 +1156,7 @@ class vrf(Addon, moduleBase):
> + op_handler = self._run_ops.get(operation)
> + if not op_handler:
> + return
> +- self._init_command_handlers()
> ++
> + if operation == 'query-checkcurr':
> + op_handler(self, ifaceobj, query_ifaceobj)
> + else:
> +diff --git a/ifupdown2/ifupdown/exceptions.py b/ifupdown2/ifupdown/exceptions.py
> +index 0dd16a6..54deacd 100644
> +--- a/ifupdown2/ifupdown/exceptions.py
> ++++ b/ifupdown2/ifupdown/exceptions.py
> +@@ -61,3 +61,7 @@ class moduleNotSupported(Error):
> +
> + class ReservedVlanException(Error):
> + pass
> ++
> ++
> ++class NoDhcpClientAvailable(Exception):
> ++ pass
> +diff --git a/ifupdown2/ifupdown/utils.py b/ifupdown2/ifupdown/utils.py
> +index 9b7ec9b..bc7617b 100644
> +--- a/ifupdown2/ifupdown/utils.py
> ++++ b/ifupdown2/ifupdown/utils.py
> +@@ -107,6 +107,7 @@ class utils():
> + ethtool_cmd = '/sbin/ethtool'
> + systemctl_cmd = '/bin/systemctl'
> + dpkg_cmd = '/usr/bin/dpkg'
> ++ dhcpcd_cmd = '/usr/sbin/dhcpcd'
> +
> + logger.info("utils init command paths")
> + for cmd in ['bridge',
> +@@ -123,7 +124,8 @@ class utils():
> + 'mstpctl',
> + 'ethtool',
> + 'systemctl',
> +- 'dpkg'
> ++ 'dpkg',
> ++ 'dhcpcd',
> + ]:
> + if os.path.exists(vars()[cmd + '_cmd']):
> + continue
> +@@ -575,4 +577,18 @@ class utils():
> + raise
> + return vnid
> +
> ++ @staticmethod
> ++ def pid_exists(pid: int) -> bool:
> ++ """
> ++ Check whether there is a process with the given PID.
> ++ """
> ++ try:
> ++ # > If sig is 0, then no signal is sent, but existence and permission checks are still
> ++ # > performed; this can be used to check for the existence of a process ID or process
> ++ # > group ID that the caller is permitted to signal.
> ++ os.kill(pid, 0)
> ++ except OSError:
> ++ return False
> ++ return True
> ++
> + fcntl.fcntl(utils.DEVNULL, fcntl.F_SETFD, fcntl.FD_CLOEXEC)
> +diff --git a/ifupdown2/ifupdownaddons/dhclient.py b/ifupdown2/ifupdownaddons/dhclient.py
> +index c10db65..bfb0208 100644
> +--- a/ifupdown2/ifupdownaddons/dhclient.py
> ++++ b/ifupdown2/ifupdownaddons/dhclient.py
> +@@ -10,49 +10,40 @@ import errno
> + try:
> + from ifupdown2.ifupdown.utils import utils
> + from ifupdown2.ifupdownaddons.utilsbase import *
> ++ from ifupdown2.lib.sysfs import Sysfs
> + except (ImportError, ModuleNotFoundError):
> + from ifupdown.utils import utils
> + from ifupdownaddons.utilsbase import *
> +-
> ++ from lib.sysfs import Sysfs
> +
> + class dhclient(utilsBase):
> + """ This class contains helper methods to interact with the dhclient
> + utility """
> +
> +- def _pid_exists(self, pidfilename):
> +- if os.path.exists(pidfilename):
> +- try:
> +- return os.readlink(
> +- "/proc/%s/exe" % self.read_file_oneline(pidfilename)
> +- ).endswith("dhclient")
> +- except OSError as e:
> +- try:
> +- if e.errno == errno.EACCES:
> +- return os.path.exists("/proc/%s" % self.read_file_oneline(pidfilename))
> +- except Exception:
> +- return False
> +- except Exception:
> +- return False
> +- return False
> ++ MAX_RETRIES = 5
> +
> +- def is_running(self, ifacename):
> +- return self._pid_exists('/run/dhclient.%s.pid' %ifacename)
> ++ def __init__(self, *args, **kwargs):
> ++ super().__init__(*args, **kwargs)
> ++ if not os.path.exists('/sbin/dhclient3') and not os.path.exists('/sbin/dhclient'):
> ++ raise RuntimeError(f'missing required executable: /sbin/dhclient3 or /sbin/dhclient')
> +
> +- def is_running6(self, ifacename):
> +- return self._pid_exists('/run/dhclient6.%s.pid' %ifacename)
> ++ def is_running(self, ifacename: str) -> bool:
> ++ pid = self.read_file_oneline(f'/run/dhclient.{ifacename}.pid')
> ++ try:
> ++ return utils.pid_exists(int(pid))
> ++ except (TypeError, ValueError):
> ++ return False
> +
> +- def _run_dhclient_cmd(self, cmd, cmd_prefix=None):
> +- if not cmd_prefix:
> +- cmd_aslist = []

nothing against dropping this and executing cmd_prefix + cmd directly, but
we do lose above fallback handling for when cmd_prefix=None, or?

> +- else:
> +- cmd_aslist = cmd_prefix.split()
> +- if cmd_aslist:
> +- cmd_aslist.extend(cmd)
> +- else:
> +- cmd_aslist = cmd
> +- utils.exec_commandl(cmd_aslist, stdout=None, stderr=None)
> ++ def is_running6(self, ifacename: str) -> bool:
> ++ pid = self.read_file_oneline(f'/run/dhclient6.{ifacename}.pid')
> ++ try:
> ++ return utils.pid_exists(int(pid))
> ++ except (TypeError, ValueError):
> ++ return False
> ++
> ++ def stop(self, ifacename: str, cmd_prefix: list[str] = []):
> ++ self.logger.debug(f'stopping dhclient on {ifacename}')
> +
> +- def stop(self, ifacename, cmd_prefix=None):
> + if os.path.exists('/sbin/dhclient3'):
> + cmd = ['/sbin/dhclient3', '-x', '-pf',
> + '/run/dhclient.%s.pid' %ifacename, '-lf',
> +@@ -63,18 +54,14 @@ class dhclient(utilsBase):
> + '/run/dhclient.%s.pid' %ifacename,
> + '-lf', '/var/lib/dhcp/dhclient.%s.leases' %ifacename,
> + '%s' %ifacename]
> +- self._run_dhclient_cmd(cmd, cmd_prefix)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None, stderr=None)
> +
> +- def start(self, ifacename, wait=True, cmd_prefix=None):
> ++ def start(self, ifacename: str, wait: bool = True, cmd_prefix: list[str] = []):
> ++ self.logger.debug(f'starting dhclient on {ifacename}')
> + retries = 0
> +- out = "0"
> +
> + # wait if interface isn't up yet
> +- while '1' not in out and retries < 5:
> +- path = '/sys/class/net/%s/carrier' %ifacename
> +- out = self.read_file_oneline(path)
> +- if out is None:
> +- break # No sysfs file found for this iface
> ++ while not Sysfs.link_has_carrier(ifacename) and retries < self.MAX_RETRIES:
> + retries += 1
> + time.sleep(1)
> +
> +@@ -90,9 +77,11 @@ class dhclient(utilsBase):
> + '%s' %ifacename]
> + if not wait:
> + cmd.append('-nw')
> +- self._run_dhclient_cmd(cmd, cmd_prefix)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None, stderr=None)
> ++
> ++ def release(self, ifacename: str, cmd_prefix: list[str] = []):
> ++ self.logger.debug(f'releasing lease on {ifacename}')
> +
> +- def release(self, ifacename, cmd_prefix=None):
> + if os.path.exists('/sbin/dhclient3'):
> + cmd = ['/sbin/dhclient3', '-r', '-pf',
> + '/run/dhclient.%s.pid' %ifacename, '-lf',
> +@@ -103,9 +92,11 @@ class dhclient(utilsBase):
> + '/run/dhclient.%s.pid' %ifacename,
> + '-lf', '/var/lib/dhcp/dhclient.%s.leases' %ifacename,
> + '%s' %ifacename]
> +- self._run_dhclient_cmd(cmd, cmd_prefix)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None, stderr=None)
> ++
> ++ def start6(self, ifacename: str, wait: bool = True, cmd_prefix: list[str] = [], duid: str | None = None):
> ++ self.logger.debug(f'starting v6 dhclient on {ifacename}')
> +
> +- def start6(self, ifacename, wait=True, cmd_prefix=None, duid=None):
> + cmd = ['/sbin/dhclient', '-6', '-pf',
> + '/run/dhclient6.%s.pid' %ifacename, '-lf',
> + '/var/lib/dhcp/dhclient6.%s.leases' % ifacename,
> +@@ -115,9 +106,11 @@ class dhclient(utilsBase):
> + if duid is not None:
> + cmd.append('-D')
> + cmd.append(duid)
> +- self._run_dhclient_cmd(cmd, cmd_prefix)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None, stderr=None)
> ++
> ++ def stop6(self, ifacename: str, cmd_prefix: list[str] = [], duid: str | None = None):
> ++ self.logger.debug(f'stopping v6 dhclient on {ifacename}')
> +
> +- def stop6(self, ifacename, cmd_prefix=None, duid=None):
> + cmd = ['/sbin/dhclient', '-6', '-x', '-pf',
> + '/run/dhclient6.%s.pid' % ifacename, '-lf',
> + '/var/lib/dhcp/dhclient6.%s.leases' % ifacename,
> +@@ -125,9 +118,11 @@ class dhclient(utilsBase):
> + if duid is not None:
> + cmd.append('-D')
> + cmd.append(duid)
> +- self._run_dhclient_cmd(cmd, cmd_prefix)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None, stderr=None)
> ++
> ++ def release6(self, ifacename: str, cmd_prefix: list[str] = [], duid: str | None = None):
> ++ self.logger.debug(f'releasing v6 lease on {ifacename}')
> +
> +- def release6(self, ifacename, cmd_prefix=None, duid=None):
> + cmd = ['/sbin/dhclient', '-6', '-r', '-pf',
> + '/run/dhclient6.%s.pid' %ifacename,
> + '-lf', '/var/lib/dhcp/dhclient6.%s.leases' % ifacename,
> +@@ -135,4 +130,4 @@ class dhclient(utilsBase):
> + if duid is not None:
> + cmd.append('-D')
> + cmd.append(duid)
> +- self._run_dhclient_cmd(cmd, cmd_prefix)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None, stderr=None)
> +diff --git a/ifupdown2/ifupdownaddons/dhcp_client.py b/ifupdown2/ifupdownaddons/dhcp_client.py
> +new file mode 100644
> +index 0000000..934119f
> +--- /dev/null
> ++++ b/ifupdown2/ifupdownaddons/dhcp_client.py
> +@@ -0,0 +1,49 @@
> ++import logging
> ++
> ++try:
> ++ from ifupdown2.ifupdownaddons.dhclient import dhclient
> ++ from ifupdown2.ifupdownaddons.dhcpcd import DhcpcdClient
> ++ from ifupdown2.ifupdown.exceptions import NoDhcpClientAvailable
> ++except (ImportError, ModuleNotFoundError):
> ++ from ifupdownaddons.dhclient import dhclient
> ++ from ifupdownaddons.dhcpcd import DhcpcdClient
> ++ from ifupdown.exceptions import NoDhcpClientAvailable
> ++
> ++
> ++class DhcpClient:
> ++ """
> ++ Automatically selects an available DHCP client (preferring dhcpcd over dhclient)
> ++ and forwards all method calls to the respective DHCP client.
> ++ """
> ++
> ++ impl: DhcpcdClient | dhclient
> ++
> ++ _PROXIED_METHODS = [
> ++ 'is_running',
> ++ 'is_running6',
> ++ 'start',
> ++ 'start6',
> ++ 'stop',
> ++ 'stop6',
> ++ 'release',
> ++ 'release6',
> ++ ]
> ++
> ++ def __init__(self, *args, **kwargs):
> ++ self.logger = logging.getLogger('ifupdown.dhcp_client')
> ++
> ++ try:
> ++ self.impl = DhcpcdClient(**kwargs)
> ++ self.logger.info('using dhcpcd client')
> ++ except RuntimeError:
> ++ self.logger.debug('dhcpcd client unavailable, trying deprecated dhclient')
> ++ try:
> ++ self.impl = dhclient(**kwargs)
> ++ self.logger.info('using deprecated dhclient client')
> ++ except RuntimeError:
> ++ raise NoDhcpClientAvailable('neither dhcpcd nor dhclient executable found')
> ++
> ++ def __getattr__(self, name):
> ++ if name in self._PROXIED_METHODS:
> ++ return getattr(self.impl, name)
> ++ raise AttributeError
> +diff --git a/ifupdown2/ifupdownaddons/dhcpcd.py b/ifupdown2/ifupdownaddons/dhcpcd.py
> +new file mode 100644
> +index 0000000..fa4eb87
> +--- /dev/null
> ++++ b/ifupdown2/ifupdownaddons/dhcpcd.py
> +@@ -0,0 +1,147 @@
> ++#!/usr/bin/env python3
> ++
> ++import os
> ++import time
> ++
> ++try:
> ++ from ifupdown2.ifupdown.utils import utils
> ++ from ifupdown2.ifupdownaddons.utilsbase import *
> ++ from ifupdown2.lib.sysfs import Sysfs
> ++except (ImportError, ModuleNotFoundError):
> ++ from ifupdown.utils import utils
> ++ from ifupdownaddons.utilsbase import *
> ++ from lib.sysfs import Sysfs
> ++
> ++class DhcpcdClient(utilsBase):
> ++ """
> ++ This class contains helper methods to interact with the dhcpcd(8) client
> ++ """
> ++ MAX_RETRIES = 5
> ++
> ++ def __init__(self, *args, **kwargs):
> ++ super().__init__(*args, **kwargs)
> ++ if not os.path.exists(utils.dhcpcd_cmd):
> ++ raise RuntimeError(f'missing required executable: {utils.dhcpcd_cmd}')
> ++
> ++ def _start(self, cmd_prefix: list[str], ifacename: str, wait: bool, ipv6: bool, duid: str | None = None):
> ++ """
> ++ Starts the dhcpcd(8) with the given arguments.
> ++ :param cmd_prefix: Optional list of strings to prefix the dhcpcd command with
> ++ :param ifacename: Interface name
> ++ :param wait: Whether to wait until a lease has been acquired before dhcpcd becomes a daemon.
> ++ :param ipv6: Whether to request a IPv6 address, otherwise IPv4.
> ++ :param duid: Optional DUID type for IPv6, must be 'LL' or 'LLT'
> ++ """
> ++ retries = 0
> ++
> ++ # wait if interface isn't up yet
> ++ while not Sysfs.link_has_carrier(ifacename) and retries < self.MAX_RETRIES:
> ++ retries += 1
> ++ time.sleep(1)
> ++
> ++ cmd = [utils.dhcpcd_cmd]
> ++
> ++ if ipv6:
> ++ cmd.append('--ipv6only')
> ++ else:
> ++ cmd.append('--ipv4only')
> ++
> ++ if wait:
> ++ cmd.append('--waitip')
> ++
> ++ if duid:
> ++ cmd.extend(['--duid', duid])
> ++
> ++ cmd.append(ifacename)
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None)

Should we also add stderr=None here and the other utils.exec_commandl
invoactions, as that uses subprocess.Popen underneath, and with None
it would inherit parent's stderr (while None for stdout actually does
cause stdout to be redirected to /dev/null, which is fine), ensuring
any error message is visible.

Maybe a small local wrapper would be nice here to avoid repetition.

> ++
> ++ def is_running(self, ifacename: str) -> bool:
> ++ """
> ++ Checks whether an IPv4 dhcpcd(8) daemon is running for the given interface.
> ++ :param ifacename: Interface name
> ++ """
> ++ pid = self.read_file_oneline(f'/run/dhcpcd/{ifacename}-4.pid')
> ++ try:
> ++ return utils.pid_exists(int(pid))
> ++ except (TypeError, ValueError):
> ++ return False
> ++
> ++ def is_running6(self, ifacename: str) -> bool:
> ++ """
> ++ Checks whether an IPv6 dhcpcd(8) daemon is running for the given interface.
> ++ :param ifacename: Interface name
> ++ """
> ++ pid = self.read_file_oneline(f'/run/dhcpcd/{ifacename}-6.pid')
> ++ try:
> ++ return utils.pid_exists(int(pid))
> ++ except (TypeError, ValueError):
> ++ return False
> ++
> ++ def start(self, ifacename: str, wait: bool = True, cmd_prefix: list[str] = []):
> ++ """
> ++ Starts the dhcpcd(8) for leasing an IPv4 address.
> ++ :param ifacename: Interface name
> ++ :param wait: Whether to wait until a lease has been acquired before dhcpcd becomes a daemon.
> ++ :param cmd_prefix: Optional list of strings to prefix the dhcpcd command with
> ++ """
> ++ self.logger.debug(f'starting dhcpcd client on {ifacename}')
> ++ self._start(cmd_prefix, ifacename, wait, ipv6=False)
> ++
> ++ def stop(self, ifacename: str, cmd_prefix: list[str] = []):
> ++ """
> ++ Stops the IPv4 dhcpcd daemon for the given interface.
> ++ Does not release the current lease.
> ++ :param ifacename: Interface name
> ++ :param cmd_prefix: Optional list of strings to prefix the dhcpcd command with
> ++ """
> ++ self.logger.debug(f'stopping dhcpcd client on {ifacename}')
> ++
> ++ cmd = [utils.dhcpcd_cmd, '--ipv4only', '--exit', ifacename]
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None)
> ++
> ++ def release(self, ifacename: str, cmd_prefix: list[str] = []):
> ++ """
> ++ Releases the current IPv4 lease for the given interface.
> ++ :param ifacename: Interface name
> ++ :param cmd_prefix: Optional list of strings to prefix the dhcpcd command with
> ++ """
> ++ self.logger.debug(f'releasing lease on {ifacename}')
> ++
> ++ cmd = [utils.dhcpcd_cmd, '--ipv4only', '--release', ifacename]
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None)
> ++
> ++ def start6(self, ifacename: str, wait: bool = True, cmd_prefix: list[str] = [], duid: str | None = None):
> ++ """
> ++ Starts the dhcpcd(8) for leasing an IPv6 address.
> ++ :param ifacename: Interface name
> ++ :param wait: Whether to wait until a lease has been acquired before dhcpcd becomes a daemon.
> ++ :param cmd_prefix: Optional list of strings to prefix the dhcpcd command with
> ++ :param duid: Optional DUID type for IPv6, must be 'LL' or 'LLT'
> ++ """
> ++ self.logger.debug(f'starting v6 dhcpcd client on {ifacename}')
> ++ self._start(cmd_prefix, ifacename, wait, True, duid)
> ++
> ++ def stop6(self, ifacename: str, cmd_prefix: list[str] = [], duid: str | None = None):
> ++ """
> ++ Stops the IPv6 dhcpcd daemon for the given interface.
> ++ Does not release the current lease.
> ++ :param ifacename: Interface name
> ++ :param cmd_prefix: Optional list of strings to prefix the dhcpcd command with
> ++ :param duid: Optional DUID type for IPv6, must be 'LL' or 'LLT'

No idea how relevant this is, but duid is not used in the method body here
and also not in release6 below, [dhcpcd man] lists it as generic option,
FWIW.

[dhcpcd man]: https://manpages.debian.org/trixie/dhcpcd-base/dhcpcd.8.en.html#D

> ++ """
> ++ self.logger.debug(f'stopping v6 dhcpcd client on {ifacename}')
> ++
> ++ cmd = [utils.dhcpcd_cmd, '--ipv6only', '--exit', ifacename]
> ++ utils.exec_commandl(cmd_prefix + cmd, stdout=None)
> ++
> ++ def release6(self, ifacename: str, cmd_prefix: list[str] = [], duid: str | None = None):
> ++ """
> ++ Releases the current IPv4 lease for the given interface.

^- s/IPv4/IPv6/ here