From e0e0ded1eda04b83636d6b7a8817190205b45a69 Mon Sep 17 00:00:00 2001 From: Lubomir Rintel Date: Mon, 29 Jul 2024 12:30:55 +0200 Subject: [PATCH] dhcp: add option to use NetworkManager for DHCP discovery Many distros nowadays, especially that the ISC DHCP client got abandoned usptream, ship NetworkManager as their only DHCP client. Allow using it for the init stage networking. Signed-off-by: Lubomir Rintel --- cloudinit/net/dhcp.py | 227 +++++++++++++++++++++++++- systemd/cloud-init-local.service.tmpl | 2 +- tests/unittests/net/test_dhcp.py | 48 +++++- 3 files changed, 274 insertions(+), 3 deletions(-) diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index 6b3aabc8603a..7ebb803750ab 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -1,6 +1,7 @@ # Copyright (C) 2017 Canonical Ltd. # # Author: Chad Smith +# Author: Lubomir Rintel # # This file is part of cloud-init. See LICENSE file for license information. @@ -1011,4 +1012,228 @@ def parse_static_routes(routes: str) -> List[Tuple[str, str]]: return [] -ALL_DHCP_CLIENTS = [Dhcpcd, IscDhclient, Udhcpc] +class NetworkManagerDhcpClient(DhcpClient): + client_name = "nmcli" + + def __init__(self): + super().__init__() + self.lease_file = None + + try: + running = subp.subp( + [ + self.dhcp_client_path, + "--terse", + "--get-values", + "RUNNING", + "general", + "status", + ] + ).stdout.strip() + if not running == "running": + raise NoDHCPLeaseMissingDhclientError() + except subp.ProcessExecutionError as error: + LOG.debug( + "nmcli exited with code: %s stderr: %r stdout: %r", + error.exit_code, + error.stderr, + error.stdout, + ) + raise NoDHCPLeaseMissingDhclientError() from error + + def dhcp_discovery( + self, + interface: str, + dhcp_log_func: Optional[Callable] = None, + distro=None, + ) -> Dict[str, Any]: + """Configure an interface with DHCP using NetworkManager. + + @param interface: Name of the network interface which to configure + with NetworkManager + @param dhcp_log_func: A callable accepting the client output and + error streams. + @return: dict of lease options representing the most recent lease + NetworkManager obtained via DHCP + """ + LOG.debug("Connecting interface %s", interface) + + try: + ac = subp.subp( + [ + self.dhcp_client_path, + "--get-values", + "GENERAL.CON-PATH", + "device", + "show", + interface, + ] + ).stdout.strip() + + if not ac == "": + orig_uuid = subp.subp( + [ + self.dhcp_client_path, + "--get-values", + "GENERAL.UUID", + "connection", + "show", + ac, + ] + ).stdout.strip() + if orig_uuid == "": + orig_uuid = None + else: + LOG.debug("connection %s is already active", orig_uuid) + + conn_uuid = "cd4ac1c3-888b-433f-8cbd-2634df28c36d" + conn_type = ( + "infiniband" + if is_ib_interface(interface) + else "802-3-ethernet" + ) + + LOG.debug("adding connection %s", conn_uuid) + out, err = subp.subp( + [ + self.dhcp_client_path, + "connection", + "add", + "save", + "no", + "connection.uuid", + conn_uuid, + "connection.type", + conn_type, + "connection.interface-name", + interface, + "connection.autoconnect", + "no", + "ipv4.may-fail", + "no", + "ipv6.method", + "ignore", + ] + ) + if dhcp_log_func is not None: + dhcp_log_func(out, err) + + LOG.debug("activating connection %s", conn_uuid) + out, err = subp.subp( + [self.dhcp_client_path, "connection", "up", conn_uuid] + ) + if dhcp_log_func is not None: + dhcp_log_func(out, err) + + except subp.ProcessExecutionError as error: + LOG.debug( + "nmcli exited with code: %s stderr: %r stdout: %r", + error.exit_code, + error.stderr, + error.stdout, + ) + raise NoDHCPLeaseMissingDhclientError() from error + + lease = self.get_newest_lease(interface) + + if orig_uuid is not None: + LOG.debug("triggering reactivation of connection %s", orig_uuid) + out, err = subp.subp( + [ + self.dhcp_client_path, + "--wait", + "0", + "connection", + "up", + orig_uuid, + ], + rcs=[0, 1], + ) + if dhcp_log_func is not None: + dhcp_log_func(out, err) + + LOG.debug("removing connection %s", conn_uuid) + out, err = subp.subp( + [self.dhcp_client_path, "connection", "del", conn_uuid] + ) + if dhcp_log_func is not None: + dhcp_log_func(out, err) + + return lease + + @staticmethod + def parse_network_manager_lease(lease_dump: str, interface: str) -> Dict: + """parse the DHCP lease from nmcli + + map names to the datastructure we create from nmcli via + "nmcli --terse --fields DHCP4 device show eth0": + + example output: + + DHCP4.OPTION[1]:dhcp_client_identifier = 01:fa:16:3e:db:dc:bf + DHCP4.OPTION[2]:dhcp_lease_time = 43200 + DHCP4.OPTION[3]:dhcp_server_identifier = 10.0.215.254 + DHCP4.OPTION[4]:domain_name_servers = 10.11.5.160 10.2.70.215 + DHCP4.OPTION[5]:expiry = 1722039992 + DHCP4.OPTION[6]:interface_mtu = 1500 + DHCP4.OPTION[7]:ip_address = 10.0.215.164 + DHCP4.OPTION[8]:requested_broadcast_address = 1 + ... + """ + LOG.debug("Parsing lease for interface %s: %r", interface, lease_dump) + + lease = {"interface": interface} + for line in lease_dump.strip().splitlines(): + line = line.split(":", maxsplit=1)[1] + key, value = line.split(" = ", maxsplit=1) + if not key.startswith("requested_"): + key = key.replace("_", "-") + lease[key] = value + lease["fixed-address"] = lease.pop("ip-address") + return lease + + def get_newest_lease(self, interface: str) -> Dict[str, Any]: + """Get the most recent lease from the ephemeral phase as a dict. + + Return a dict of dhcp options. The dict contains key value + pairs from the most recent lease. + + @param interface: an interface name + @raises: InvalidDHCPLeaseFileError on empty or unparseable lease + file content. + """ + try: + return self.parse_network_manager_lease( + subp.subp( + [ + self.dhcp_client_path, + "--terse", + "--fields", + "DHCP4", + "device", + "show", + interface, + ] + ).stdout, + interface, + ) + + except subp.ProcessExecutionError as error: + LOG.debug( + "nmcli exited with code: %s stderr: %r stdout: %r", + error.exit_code, + error.stderr, + error.stdout, + ) + raise NoDHCPLeaseError from error + + @staticmethod + def parse_static_routes(routes: str) -> List[Tuple[str, str]]: + static_routes = routes.split() + if static_routes: + # format: dest1/mask gw1 ... destn/mask gwn + return [i for i in zip(static_routes[::2], static_routes[1::2])] + return [] + + +ALL_DHCP_CLIENTS = [Dhcpcd, IscDhclient, Udhcpc, NetworkManagerDhcpClient] diff --git a/systemd/cloud-init-local.service.tmpl b/systemd/cloud-init-local.service.tmpl index 0da2d8337e9c..2c28531e4a90 100644 --- a/systemd/cloud-init-local.service.tmpl +++ b/systemd/cloud-init-local.service.tmpl @@ -12,7 +12,7 @@ After=systemd-remount-fs.service Requires=dbus.socket After=dbus.socket {% endif %} -Before=NetworkManager.service +After=NetworkManager.service {% if variant in ["almalinux", "cloudlinux", "rhel"] %} Before=network.service {% endif %} diff --git a/tests/unittests/net/test_dhcp.py b/tests/unittests/net/test_dhcp.py index e7259d41d78a..f12825ebe4c7 100644 --- a/tests/unittests/net/test_dhcp.py +++ b/tests/unittests/net/test_dhcp.py @@ -16,6 +16,7 @@ Dhcpcd, InvalidDHCPLeaseFileError, IscDhclient, + NetworkManagerDhcpClient, NoDHCPLeaseError, NoDHCPLeaseInterfaceError, NoDHCPLeaseMissingDhclientError, @@ -423,7 +424,7 @@ def test_dhcp_client_failover( subp.ProcessExecutionError(exit_code=-5), ] - m_which.side_effect = [False, False, False, False] + m_which.side_effect = [False, False, False, False, False] with pytest.raises(NoDHCPLeaseError): maybe_perform_dhcp_discovery(Distro("somename", {}, None)) @@ -1414,6 +1415,51 @@ def test_dhcpcd_discovery_timeout( ) +class TestNetworkManagerDhcpClient: + def test_parse_lease_dump(self): + lease = dedent( + """ + DHCP4.OPTION[1]:dhcp_client_identifier = 01:fa:16:3e:db:dc:bf + DHCP4.OPTION[2]:dhcp_lease_time = 3600 + DHCP4.OPTION[3]:dhcp_server_identifier = 192.168.0.1 + DHCP4.OPTION[4]:domain_name_servers = 192.168.0.2 + DHCP4.OPTION[5]:expiry = 1722039992 + DHCP4.OPTION[6]:interface_mtu = 9001 + DHCP4.OPTION[7]:ip_address = 192.168.0.212 + DHCP4.OPTION[8]:requested_broadcast_address = 1 + DHCP4.OPTION[9]:requested_domain_name = 1 + DHCP4.OPTION[10]:requested_domain_name_servers = 1 + DHCP4.OPTION[11]:requested_domain_search = 1 + DHCP4.OPTION[12]:requested_host_name = 1 + DHCP4.OPTION[13]:requested_interface_mtu = 1 + DHCP4.OPTION[14]:requested_ms_classless_static_routes = 1 + DHCP4.OPTION[15]:requested_nis_domain = 1 + DHCP4.OPTION[16]:requested_nis_servers = 1 + DHCP4.OPTION[17]:requested_ntp_servers = 1 + DHCP4.OPTION[18]:requested_rfc3442_classless_static_routes = 1 + DHCP4.OPTION[19]:requested_root_path = 1 + DHCP4.OPTION[20]:requested_routers = 1 + DHCP4.OPTION[21]:requested_static_routes = 1 + DHCP4.OPTION[22]:requested_subnet_mask = 1 + DHCP4.OPTION[23]:requested_time_offset = 1 + DHCP4.OPTION[24]:requested_wpad = 1 + DHCP4.OPTION[25]:rfc3442_classless_static_routes = 169.254.169.254/32 10.0.212.1 0.0.0.0/0 10.0.215.254 + DHCP4.OPTION[26]:routers = 192.168.0.1 + DHCP4.OPTION[27]:subnet_mask = 255.255.240.0 + """ + ) + with mock.patch("cloudinit.net.dhcp.util.load_binary_file"): + parsed_lease = ( + NetworkManagerDhcpClient.parse_network_manager_lease( + lease, "eth0" + ) + ) + assert "eth0" == parsed_lease["interface"] + assert "192.168.0.212" == parsed_lease["fixed-address"] + assert "255.255.240.0" == parsed_lease["subnet-mask"] + assert "192.168.0.1" == parsed_lease["routers"] + + class TestMaybePerformDhcpDiscovery: def test_none_and_missing_fallback(self): with pytest.raises(NoDHCPLeaseInterfaceError):