From 60ab6b317203933f4501cf8cc4b4307c5a531c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jun 2023 15:56:46 +0200 Subject: [PATCH] net/dhcp: add udhcpc support The currently used dhcp client, dhclient, is coming from the unmaintained package, isc-dhcp-client (refer https://www.isc.org/dhcp/) which ended support in 2022. This change introduce support for the dhcp client, udhcpc, from the busybox project. Busybox advantages are that it is available across many distributions and comes with lightweight executables. --- cloudinit/distros/__init__.py | 8 +- cloudinit/net/dhcp.py | 129 +++++++++++++++++++++++- tests/unittests/net/test_dhcp.py | 167 +++++++++++++++++++++++++++++++ tools/.github-cla-signers | 1 + 4 files changed, 302 insertions(+), 3 deletions(-) diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 2957908ef32..6c8d121af4d 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -112,14 +112,18 @@ class Distro(persistence.CloudInitPickleMixin, metaclass=abc.ABCMeta): resolve_conf_fn = "/etc/resolv.conf" osfamily: str - dhcp_client_priority = [dhcp.IscDhclient, dhcp.Dhcpcd] + dhcp_client_priority = [dhcp.IscDhclient, dhcp.Dhcpcd, dhcp.Udhcpc] def __init__(self, name, cfg, paths): self._paths = paths self._cfg = cfg self.name = name self.networking: Networking = self.networking_cls() - self.dhcp_client_priority = [dhcp.IscDhclient, dhcp.Dhcpcd] + self.dhcp_client_priority = [ + dhcp.IscDhclient, + dhcp.Dhcpcd, + dhcp.Udhcpc, + ] self.net_ops = iproute2.Iproute2 def _unpickle(self, ci_pkl_version: int) -> None: diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index 75a47efaa58..5cf961c51c3 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -21,6 +21,7 @@ from cloudinit.net import ( find_fallback_nic, get_devicelist, + get_ib_interface_hwaddr, get_interface_mac, is_ib_interface, ) @@ -28,6 +29,37 @@ LOG = logging.getLogger(__name__) NETWORKD_LEASES_DIR = "/run/systemd/netif/leases" +UDHCPC_SCRIPT = """#!/bin/sh +log() { + echo "udhcpc[$PPID]" "$interface: $2" +} +[ -z "$1" ] && echo "Error: should be called from udhcpc" && exit 1 +case $1 in + bound|renew) + cat < "$LEASE_FILE" +{ + "interface": "$interface", + "fixed-address": "$ip", + "subnet-mask": "$subnet", + "routers": "${router%% *}", + "static_routes" : "${staticroutes}" +} +JSON + ;; + deconfig) + log err "Not supported" + exit 1 + ;; + leasefail | nak) + log err "configuration failed: $1: $message" + exit 1 + ;; + *) + echo "$0: Unknown udhcpc command: $1" >&2 + exit 1 + ;; +esac +""" class NoDHCPLeaseError(Exception): @@ -50,6 +82,10 @@ class NoDHCPLeaseMissingDhclientError(NoDHCPLeaseError): """Raised when unable to find dhclient.""" +class NoDHCPLeaseMissingUdhcpcError(NoDHCPLeaseError): + """Raised when unable to find udhcpc client.""" + + def select_dhcp_client(distro): """distros set priority list, select based on this order which to use @@ -60,7 +96,10 @@ def select_dhcp_client(distro): dhcp_client = client() LOG.debug("DHCP client selected: %s", client.client_name) return dhcp_client - except NoDHCPLeaseMissingDhclientError: + except ( + NoDHCPLeaseMissingDhclientError, + NoDHCPLeaseMissingUdhcpcError, + ): LOG.warning("DHCP client not found: %s", client.client_name) raise NoDHCPLeaseMissingDhclientError() @@ -492,3 +531,91 @@ class Dhcpcd: def __init__(self): raise NoDHCPLeaseMissingDhclientError("Dhcpcd not yet implemented") + + +class Udhcpc(DhcpClient): + client_name = "udhcpc" + + def __init__(self): + self.udhcpc_path = subp.which("udhcpc") + if not self.udhcpc_path: + LOG.debug("Skip udhcpc configuration: No udhcpc command found.") + raise NoDHCPLeaseMissingUdhcpcError() + + def dhcp_discovery( + self, + interface, + dhcp_log_func=None, + distro=None, + ): + """Run udhcpc on the interface without scripts or filesystem artifacts. + + @param interface: Name of the network interface on which to run udhcpc. + @param dhcp_log_func: A callable accepting the udhcpc output and + error streams. + + @return: A list of dicts of representing the dhcp leases parsed from + the udhcpc lease file. + """ + LOG.debug("Performing a dhcp discovery on %s", interface) + + tmp_dir = temp_utils.get_tmp_ancestor(needs_exe=True) + lease_file = os.path.join(tmp_dir, interface + ".lease.json") + with contextlib.suppress(FileNotFoundError): + os.remove(lease_file) + + # udhcpc needs the interface up to send initial discovery packets + distro.net_ops.link_up(interface) + + udhcpc_script = os.path.join(tmp_dir, "udhcpc_script") + util.write_file(udhcpc_script, UDHCPC_SCRIPT, 0o755) + + cmd = [ + self.udhcpc_path, + "-O", + "staticroutes", + "-i", + interface, + "-s", + udhcpc_script, + "-n", # Exit if lease is not obtained + "-q", # Exit after obtaining lease + "-f", # Run in foreground + "-v", + ] + + # For INFINIBAND port the dhcpc must be running with + # client id option. So here we are checking if the interface is + # INFINIBAND or not. If yes, we are generating the the client-id to be + # used with the udhcpc + if is_ib_interface(interface): + dhcp_client_identifier = get_ib_interface_hwaddr( + interface, ethernet_format=True + ) + cmd.extend( + ["-x", "0x3d:%s" % dhcp_client_identifier.replace(":", "")] + ) + try: + out, err = subp.subp( + cmd, update_env={"LEASE_FILE": lease_file}, capture=True + ) + except subp.ProcessExecutionError as error: + LOG.debug( + "udhcpc exited with code: %s stderr: %r stdout: %r", + error.exit_code, + error.stderr, + error.stdout, + ) + raise NoDHCPLeaseError from error + + if dhcp_log_func is not None: + dhcp_log_func(out, err) + + lease_json = util.load_json(util.load_file(lease_file)) + static_routes = lease_json["static_routes"].split() + if static_routes: + # format: dest1/mask gw1 ... destn/mask gwn + lease_json["static_routes"] = [ + i for i in zip(static_routes[::2], static_routes[1::2]) + ] + return [lease_json] diff --git a/tests/unittests/net/test_dhcp.py b/tests/unittests/net/test_dhcp.py index 588f9bccdec..a7b62312757 100644 --- a/tests/unittests/net/test_dhcp.py +++ b/tests/unittests/net/test_dhcp.py @@ -13,6 +13,7 @@ NoDHCPLeaseError, NoDHCPLeaseInterfaceError, NoDHCPLeaseMissingDhclientError, + Udhcpc, maybe_perform_dhcp_discovery, networkd_load_leases, ) @@ -928,3 +929,169 @@ def test_ctx_mgr_umbrella_error(self, m_dhcp, error_class): pass assert len(m_dhcp.mock_calls) == 1 + + +class TestUDHCPCDiscoveryClean(CiTestCase): + with_logs = True + maxDiff = None + + @mock.patch("cloudinit.net.dhcp.subp.which") + @mock.patch("cloudinit.net.dhcp.find_fallback_nic") + def test_absent_udhcpc_command(self, m_fallback, m_which): + """When dhclient doesn't exist in the OS, log the issue and no-op.""" + m_fallback.return_value = "eth9" + m_which.return_value = None # udhcpc isn't found + + distro = MockDistro() + distro.dhcp_client_priority = [Udhcpc] + + with pytest.raises(NoDHCPLeaseMissingDhclientError): + maybe_perform_dhcp_discovery(distro) + + self.assertIn( + "Skip udhcpc configuration: No udhcpc command found.", + self.logs.getvalue(), + ) + + @mock.patch("cloudinit.net.dhcp.is_ib_interface", return_value=False) + @mock.patch("cloudinit.net.dhcp.subp.which", return_value="/sbin/udhcpc") + @mock.patch("cloudinit.net.dhcp.os.remove") + @mock.patch("cloudinit.net.dhcp.subp.subp") + @mock.patch("cloudinit.util.load_json") + @mock.patch("cloudinit.util.load_file") + @mock.patch("cloudinit.util.write_file") + def test_udhcpc_discovery( + self, + m_write_file, + m_load_file, + m_loadjson, + m_subp, + m_remove, + m_which, + mocked_is_ib_interface, + ): + """dhcp_discovery runs udcpc and parse the dhcp leases.""" + m_subp.return_value = ("", "") + m_loadjson.return_value = { + "interface": "eth9", + "fixed-address": "192.168.2.74", + "subnet-mask": "255.255.255.0", + "routers": "192.168.2.1", + "static_routes": "10.240.0.1/32 0.0.0.0 0.0.0.0/0 10.240.0.1", + } + self.assertEqual( + [ + { + "fixed-address": "192.168.2.74", + "interface": "eth9", + "routers": "192.168.2.1", + "static_routes": [ + ("10.240.0.1/32", "0.0.0.0"), + ("0.0.0.0/0", "10.240.0.1"), + ], + "subnet-mask": "255.255.255.0", + } + ], + Udhcpc().dhcp_discovery("eth9", distro=MockDistro()), + ) + # Interface was brought up before dhclient called + m_subp.assert_has_calls( + [ + mock.call( + ["ip", "link", "set", "dev", "eth9", "up"], + ), + mock.call( + [ + "/sbin/udhcpc", + "-O", + "staticroutes", + "-i", + "eth9", + "-s", + "/var/tmp/cloud-init/udhcpc_script", + "-n", + "-q", + "-f", + "-v", + ], + update_env={ + "LEASE_FILE": "/var/tmp/cloud-init/eth9.lease.json" + }, + capture=True, + ), + ] + ) + + @mock.patch("cloudinit.net.dhcp.is_ib_interface", return_value=True) + @mock.patch("cloudinit.net.dhcp.get_ib_interface_hwaddr") + @mock.patch("cloudinit.net.dhcp.subp.which", return_value="/sbin/udhcpc") + @mock.patch("cloudinit.net.dhcp.os.remove") + @mock.patch("cloudinit.net.dhcp.subp.subp") + @mock.patch("cloudinit.util.load_json") + @mock.patch("cloudinit.util.load_file") + @mock.patch("cloudinit.util.write_file") + def test_udhcpc_discovery_ib( + self, + m_write_file, + m_load_file, + m_loadjson, + m_subp, + m_remove, + m_which, + m_get_ib_interface_hwaddr, + m_is_ib_interface, + ): + """dhcp_discovery runs udcpc and parse the dhcp leases.""" + m_subp.return_value = ("", "") + m_loadjson.return_value = { + "interface": "ib0", + "fixed-address": "192.168.2.74", + "subnet-mask": "255.255.255.0", + "routers": "192.168.2.1", + "static_routes": "10.240.0.1/32 0.0.0.0 0.0.0.0/0 10.240.0.1", + } + m_get_ib_interface_hwaddr.return_value = "00:21:28:00:01:cf:4b:01" + self.assertEqual( + [ + { + "fixed-address": "192.168.2.74", + "interface": "ib0", + "routers": "192.168.2.1", + "static_routes": [ + ("10.240.0.1/32", "0.0.0.0"), + ("0.0.0.0/0", "10.240.0.1"), + ], + "subnet-mask": "255.255.255.0", + } + ], + Udhcpc().dhcp_discovery("ib0", distro=MockDistro()), + ) + # Interface was brought up before dhclient called + m_subp.assert_has_calls( + [ + mock.call( + ["ip", "link", "set", "dev", "ib0", "up"], + ), + mock.call( + [ + "/sbin/udhcpc", + "-O", + "staticroutes", + "-i", + "ib0", + "-s", + "/var/tmp/cloud-init/udhcpc_script", + "-n", + "-q", + "-f", + "-v", + "-x", + "0x3d:0021280001cf4b01", + ], + update_env={ + "LEASE_FILE": "/var/tmp/cloud-init/ib0.lease.json" + }, + capture=True, + ), + ] + ) diff --git a/tools/.github-cla-signers b/tools/.github-cla-signers index cae09f57589..d50f96ac959 100644 --- a/tools/.github-cla-signers +++ b/tools/.github-cla-signers @@ -67,6 +67,7 @@ jacobsalmela jamesottinger Jehops jf +jfroche Jille JohnKepplers johnsonshi