From 53260ce3bd70a0852d3e0d5569474214cea0ec0c 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 | 175 ++++++++++++++++++++++++++++++- tools/.github-cla-signers | 1 + 4 files changed, 309 insertions(+), 4 deletions(-) diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index ec148939..0fab8945 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -110,14 +110,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, + ] def _unpickle(self, ci_pkl_version: int) -> None: """Perform deserialization fixes for Distro.""" diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index 6c8c2f54..f5586cea 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -21,6 +21,7 @@ from cloudinit import subp, temp_utils, util from cloudinit.net import ( find_fallback_nic, get_devicelist, + get_ib_interface_hwaddr, get_interface_mac, is_ib_interface, ) @@ -28,6 +29,37 @@ from cloudinit.net import ( 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() @@ -497,3 +536,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 + subp.subp(["ip", "link", "set", "dev", interface, "up"], capture=True) + + 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 55d4c6e9..9123cd15 100644 --- a/tests/unittests/net/test_dhcp.py +++ b/tests/unittests/net/test_dhcp.py @@ -13,6 +13,8 @@ from cloudinit.net.dhcp import ( NoDHCPLeaseError, NoDHCPLeaseInterfaceError, NoDHCPLeaseMissingDhclientError, + NoDHCPLeaseMissingUdhcpcError, + Udhcpc, maybe_perform_dhcp_discovery, networkd_load_leases, ) @@ -388,11 +390,13 @@ class TestDHCPDiscoveryClean(CiTestCase): self.logs.getvalue(), ) + @mock.patch("cloudinit.temp_utils.get_tmp_ancestor", return_value="/tmp") @mock.patch("cloudinit.net.dhcp.find_fallback_nic", return_value="eth9") @mock.patch("cloudinit.net.dhcp.os.remove") @mock.patch("cloudinit.net.dhcp.subp.subp") @mock.patch("cloudinit.net.dhcp.subp.which") - def test_dhcp_client_failover(self, m_which, m_subp, m_remove, m_fallback): + def test_dhcp_client_failover(self, m_which, m_subp, m_remove, m_fallback, + m_get_tmp_ancestor): """Log and do nothing when nic is absent and no fallback is found.""" m_subp.side_effect = [ ("", ""), @@ -928,3 +932,172 @@ class TestEphemeralDhcpLeaseErrors: pass assert len(m_dhcp.mock_calls) == 1 + + +class TestUDHCPCDiscoveryClean(CiTestCase): + with_logs = True + maxDiff = None + + @mock.patch("cloudinit.temp_utils.get_tmp_ancestor", return_value="/tmp") + @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, + m_get_tmp_ancestor): + """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.temp_utils.get_tmp_ancestor", return_value="/tmp") + @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, + m_get_tmp_ancestor, + ): + """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"], + capture=True, + ), + mock.call( + [ + "/sbin/udhcpc", + "-O", + "staticroutes", + "-i", + "eth9", + "-s", + "/tmp/udhcpc_script", + "-n", + "-q", + "-f", + "-v", + ], + update_env={"LEASE_FILE": "/tmp/eth9.lease.json"}, + capture=True, + ), + ] + ) + + @mock.patch("cloudinit.temp_utils.get_tmp_ancestor", return_value="/tmp") + @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, + m_get_tmp_ancestor, + ): + """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"], capture=True + ), + mock.call( + [ + "/sbin/udhcpc", + "-O", + "staticroutes", + "-i", + "ib0", + "-s", + "/tmp/udhcpc_script", + "-n", + "-q", + "-f", + "-v", + "-x", + "0x3d:0021280001cf4b01", + ], + update_env={"LEASE_FILE": "/tmp/ib0.lease.json"}, + capture=True, + ), + ] + ) diff --git a/tools/.github-cla-signers b/tools/.github-cla-signers index b4a9326e..4d82a055 100644 --- a/tools/.github-cla-signers +++ b/tools/.github-cla-signers @@ -65,6 +65,7 @@ jacobsalmela jamesottinger Jehops jf +jfroche Jille JohnKepplers johnsonshi -- 2.40.1