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

Christoph Heiss
2025-12-12T11:31:56Z

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.

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.
++
++.. 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:
++ 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)
++
++ 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)
+
+ 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)
++
+ 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 = []
+- 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)
++
++ 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'
++ """
++ 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.
++ :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'
++ """
++ self.logger.debug(f'releasing v6 lease on {ifacename}')
++
++ cmd = [utils.dhcpcd_cmd, '--ipv6only', '--release', ifacename]
++ utils.exec_commandl(cmd_prefix + cmd, stdout=None)
+diff --git a/ifupdown2/lib/sysfs.py b/ifupdown2/lib/sysfs.py
+index 6aa4284..d3b998b 100644
+--- a/ifupdown2/lib/sysfs.py
++++ b/ifupdown2/lib/sysfs.py
+@@ -100,6 +100,13 @@ class __Sysfs(IO, Requirements):
+ """
+ return "up" == self.read_file_oneline("/sys/class/net/%s/operstate" % ifname)
+
++ def link_has_carrier(self, name: str) -> bool:
++ """
++ Checks whether the given interface has CARRIER set
++ """
++ out = self.read_file_oneline(f'/sys/class/net/{name}/carrier')
++ return out is not None and '1' in out
++
+ def get_link_address(self, ifname):
+ """
+ Read MAC hardware address from sysfs
+--
+2.51.2
+
--
2.51.2

_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com