diff options
Diffstat (limited to 'cloudinit/net/dhcp.py')
-rw-r--r-- | cloudinit/net/dhcp.py | 616 |
1 files changed, 374 insertions, 242 deletions
diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index c934ee16..6c8c2f54 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -4,13 +4,16 @@ # # This file is part of cloud-init. See LICENSE file for license information. +import abc import contextlib +import glob import logging import os import re import signal import time from io import StringIO +from typing import Any, Dict, List import configobj @@ -47,7 +50,22 @@ class NoDHCPLeaseMissingDhclientError(NoDHCPLeaseError): """Raised when unable to find dhclient.""" -def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None): +def select_dhcp_client(distro): + """distros set priority list, select based on this order which to use + + If the priority dhcp client isn't found, fall back to lower in list. + """ + for client in distro.dhcp_client_priority: + try: + dhcp_client = client() + LOG.debug("DHCP client selected: %s", client.client_name) + return dhcp_client + except NoDHCPLeaseMissingDhclientError: + LOG.warning("DHCP client not found: %s", client.client_name) + raise NoDHCPLeaseMissingDhclientError() + + +def maybe_perform_dhcp_discovery(distro, nic=None, dhcp_log_func=None): """Perform dhcp discovery if nic valid and dhclient command exists. If the nic is invalid or undiscoverable or dhclient command is not found, @@ -70,161 +88,8 @@ def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None): "Skip dhcp_discovery: nic %s not found in get_devicelist.", nic ) raise NoDHCPLeaseInterfaceError() - dhclient_path = subp.which("dhclient") - if not dhclient_path: - LOG.debug("Skip dhclient configuration: No dhclient command found.") - raise NoDHCPLeaseMissingDhclientError() - return dhcp_discovery(dhclient_path, nic, dhcp_log_func) - - -def parse_dhcp_lease_file(lease_file): - """Parse the given dhcp lease file for the most recent lease. - - Return a list of dicts of dhcp options. Each dict contains key value pairs - a specific lease in order from oldest to newest. - - @raises: InvalidDHCPLeaseFileError on empty of unparseable leasefile - content. - """ - lease_regex = re.compile(r"lease {(?P<lease>.*?)}\n", re.DOTALL) - dhcp_leases = [] - lease_content = util.load_file(lease_file) - if len(lease_content) == 0: - raise InvalidDHCPLeaseFileError( - "Cannot parse empty dhcp lease file {0}".format(lease_file) - ) - for lease in lease_regex.findall(lease_content): - lease_options = [] - for line in lease.split(";"): - # Strip newlines, double-quotes and option prefix - line = line.strip().replace('"', "").replace("option ", "") - if not line: - continue - lease_options.append(line.split(" ", 1)) - dhcp_leases.append(dict(lease_options)) - if not dhcp_leases: - raise InvalidDHCPLeaseFileError( - "Cannot parse dhcp lease file {0}. No leases found".format( - lease_file - ) - ) - return dhcp_leases - - -def dhcp_discovery(dhclient_cmd_path, interface, dhcp_log_func=None): - """Run dhclient on the interface without scripts or filesystem artifacts. - - @param dhclient_cmd_path: Full path to the dhclient used. - @param interface: Name of the network interface on which to dhclient. - @param dhcp_log_func: A callable accepting the dhclient output and error - streams. - - @return: A list of dicts of representing the dhcp leases parsed from the - dhclient.lease file or empty list. - """ - LOG.debug("Performing a dhcp discovery on %s", interface) - - # We want to avoid running /sbin/dhclient-script because of side-effects in - # /etc/resolv.conf any any other vendor specific scripts in - # /etc/dhcp/dhclient*hooks.d. - pid_file = "/run/dhclient.pid" - lease_file = "/run/dhclient.lease" - - # this function waits for these files to exist, clean previous runs - # to avoid false positive in wait_for_files - with contextlib.suppress(FileNotFoundError): - os.remove(pid_file) - os.remove(lease_file) - - # ISC dhclient needs the interface up to send initial discovery packets. - # Generally dhclient relies on dhclient-script PREINIT action to bring the - # link up before attempting discovery. Since we are using -sf /bin/true, - # we need to do that "link up" ourselves first. - subp.subp(["ip", "link", "set", "dev", interface, "up"], capture=True) - # For INFINIBAND port the dhlient must be sent with dhcp-client-identifier. - # 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 dhclient - cmd = [ - dhclient_cmd_path, - "-1", - "-v", - "-lf", - lease_file, - "-pf", - pid_file, - interface, - "-sf", - "/bin/true", - ] - if is_ib_interface(interface): - dhcp_client_identifier = "20:%s" % get_interface_mac(interface)[36:] - interface_dhclient_content = ( - 'interface "%s" ' - "{send dhcp-client-identifier %s;}" - % (interface, dhcp_client_identifier) - ) - tmp_dir = temp_utils.get_tmp_ancestor(needs_exe=True) - file_name = os.path.join(tmp_dir, interface + "-dhclient.conf") - util.write_file(file_name, interface_dhclient_content) - cmd.append("-cf") - cmd.append(file_name) - - try: - out, err = subp.subp(cmd, capture=True) - except subp.ProcessExecutionError as error: - LOG.debug( - "dhclient exited with code: %s stderr: %r stdout: %r", - error.exit_code, - error.stderr, - error.stdout, - ) - raise NoDHCPLeaseError from error - - # Wait for pid file and lease file to appear, and for the process - # named by the pid file to daemonize (have pid 1 as its parent). If we - # try to read the lease file before daemonization happens, we might try - # to read it before the dhclient has actually written it. We also have - # to wait until the dhclient has become a daemon so we can be sure to - # kill the correct process, thus freeing cleandir to be deleted back - # up the callstack. - missing = util.wait_for_files( - [pid_file, lease_file], maxwait=5, naplen=0.01 - ) - if missing: - LOG.warning( - "dhclient did not produce expected files: %s", - ", ".join(os.path.basename(f) for f in missing), - ) - return [] - - ppid = "unknown" - daemonized = False - for _ in range(0, 1000): - pid_content = util.load_file(pid_file).strip() - try: - pid = int(pid_content) - except ValueError: - pass - else: - ppid = util.get_proc_ppid(pid) - if ppid == 1: - LOG.debug("killing dhclient with pid=%s", pid) - os.kill(pid, signal.SIGKILL) - daemonized = True - break - time.sleep(0.01) - - if not daemonized: - LOG.error( - "dhclient(pid=%s, parentpid=%s) failed to daemonize after %s " - "seconds", - pid_content, - ppid, - 0.01 * 1000, - ) - if dhcp_log_func is not None: - dhcp_log_func(out, err) - return parse_dhcp_lease_file(lease_file) + client = select_dhcp_client(distro) + return client.dhcp_discovery(nic, dhcp_log_func, distro) def networkd_parse_lease(content): @@ -267,101 +132,368 @@ def networkd_get_option_from_leases(keyname, leases_d=None): return None -def parse_static_routes(rfc3442): - """parse rfc3442 format and return a list containing tuple of strings. +class DhcpClient(abc.ABC): + client_name = "" + + @classmethod + def kill_dhcp_client(cls): + subp.subp(["pkill", cls.client_name], rcs=[0, 1]) - The tuple is composed of the network_address (including net length) and - gateway for a parsed static route. It can parse two formats of rfc3442, - one from dhcpcd and one from dhclient (isc). + @classmethod + def clear_leases(cls): + cls.kill_dhcp_client() + files = glob.glob("/var/lib/dhcp/*") + for file in files: + os.remove(file) - @param rfc3442: string in rfc3442 format (isc or dhcpd) - @returns: list of tuple(str, str) for all valid parsed routes until the - first parsing error. + @classmethod + def start_service(cls, dhcp_interface: str, distro): + distro.manage_service( + "start", cls.client_name, dhcp_interface, rcs=[0, 1] + ) - E.g. - sr=parse_static_routes("32,169,254,169,254,130,56,248,255,0,130,56,240,1") - sr=[ - ("169.254.169.254/32", "130.56.248.255"), ("0.0.0.0/0", "130.56.240.1") - ] + @classmethod + def stop_service(cls, dhcp_interface: str, distro): + distro.manage_service("stop", cls.client_name, rcs=[0, 1]) - sr2 = parse_static_routes("24.191.168.128 192.168.128.1,0 192.168.128.1") - sr2 = [ - ("191.168.128.0/24", "192.168.128.1"), ("0.0.0.0/0", "192.168.128.1") - ] - Python version of isc-dhclient's hooks: - /etc/dhcp/dhclient-exit-hooks.d/rfc3442-classless-routes - """ - # raw strings from dhcp lease may end in semi-colon - rfc3442 = rfc3442.rstrip(";") - tokens = [tok for tok in re.split(r"[, .]", rfc3442) if tok] - static_routes = [] - - def _trunc_error(cidr, required, remain): - msg = ( - "RFC3442 string malformed. Current route has CIDR of %s " - "and requires %s significant octets, but only %s remain. " - "Verify DHCP rfc3442-classless-static-routes value: %s" - % (cidr, required, remain, rfc3442) +class IscDhclient(DhcpClient): + client_name = "dhclient" + + def __init__(self): + self.dhclient_path = subp.which("dhclient") + if not self.dhclient_path: + LOG.debug( + "Skip dhclient configuration: No dhclient command found." + ) + raise NoDHCPLeaseMissingDhclientError() + + @staticmethod + def parse_dhcp_lease_file(lease_file: str) -> List[Dict[str, Any]]: + """Parse the given dhcp lease file returning all leases as dicts. + + Return a list of dicts of dhcp options. Each dict contains key value + pairs a specific lease in order from oldest to newest. + + @raises: InvalidDHCPLeaseFileError on empty of unparseable leasefile + content. + """ + lease_regex = re.compile(r"lease {(?P<lease>.*?)}\n", re.DOTALL) + dhcp_leases = [] + lease_content = util.load_file(lease_file) + if len(lease_content) == 0: + raise InvalidDHCPLeaseFileError( + "Cannot parse empty dhcp lease file {0}".format(lease_file) + ) + for lease in lease_regex.findall(lease_content): + lease_options = [] + for line in lease.split(";"): + # Strip newlines, double-quotes and option prefix + line = line.strip().replace('"', "").replace("option ", "") + if not line: + continue + lease_options.append(line.split(" ", 1)) + dhcp_leases.append(dict(lease_options)) + if not dhcp_leases: + raise InvalidDHCPLeaseFileError( + "Cannot parse dhcp lease file {0}. No leases found".format( + lease_file + ) + ) + return dhcp_leases + + def dhcp_discovery( + self, + interface, + dhcp_log_func=None, + distro=None, + ): + """Run dhclient on the interface without scripts/filesystem artifacts. + + @param dhclient_cmd_path: Full path to the dhclient used. + @param interface: Name of the network interface on which to dhclient. + @param dhcp_log_func: A callable accepting the dhclient output and + error streams. + + @return: A list of dicts of representing the dhcp leases parsed from + the dhclient.lease file or empty list. + """ + LOG.debug("Performing a dhcp discovery on %s", interface) + + # We want to avoid running /sbin/dhclient-script because of + # side-effects in # /etc/resolv.conf any any other vendor specific + # scripts in /etc/dhcp/dhclient*hooks.d. + pid_file = "/run/dhclient.pid" + lease_file = "/run/dhclient.lease" + + # this function waits for these files to exist, clean previous runs + # to avoid false positive in wait_for_files + with contextlib.suppress(FileNotFoundError): + os.remove(pid_file) + os.remove(lease_file) + + # ISC dhclient needs the interface up to send initial discovery packets + # Generally dhclient relies on dhclient-script PREINIT action to bring + # the link up before attempting discovery. Since we are using + # -sf /bin/true, we need to do that "link up" ourselves first. + subp.subp(["ip", "link", "set", "dev", interface, "up"], capture=True) + # For INFINIBAND port the dhlient must be sent with + # dhcp-client-identifier. 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 dhclient + cmd = [ + self.dhclient_path, + "-1", + "-v", + "-lf", + lease_file, + "-pf", + pid_file, + interface, + "-sf", + "/bin/true", + ] + if is_ib_interface(interface): + dhcp_client_identifier = ( + "20:%s" % get_interface_mac(interface)[36:] + ) + interface_dhclient_content = ( + 'interface "%s" ' + "{send dhcp-client-identifier %s;}" + % (interface, dhcp_client_identifier) + ) + tmp_dir = temp_utils.get_tmp_ancestor(needs_exe=True) + file_name = os.path.join(tmp_dir, interface + "-dhclient.conf") + util.write_file(file_name, interface_dhclient_content) + cmd.append("-cf") + cmd.append(file_name) + + try: + out, err = subp.subp(cmd, capture=True) + except subp.ProcessExecutionError as error: + LOG.debug( + "dhclient exited with code: %s stderr: %r stdout: %r", + error.exit_code, + error.stderr, + error.stdout, + ) + raise NoDHCPLeaseError from error + + # Wait for pid file and lease file to appear, and for the process + # named by the pid file to daemonize (have pid 1 as its parent). If we + # try to read the lease file before daemonization happens, we might try + # to read it before the dhclient has actually written it. We also have + # to wait until the dhclient has become a daemon so we can be sure to + # kill the correct process, thus freeing cleandir to be deleted back + # up the callstack. + missing = util.wait_for_files( + [pid_file, lease_file], maxwait=5, naplen=0.01 ) - LOG.error(msg) - - current_idx = 0 - for idx, tok in enumerate(tokens): - if idx < current_idx: - continue - net_length = int(tok) - if net_length in range(25, 33): - req_toks = 9 - if len(tokens[idx:]) < req_toks: - _trunc_error(net_length, req_toks, len(tokens[idx:])) - return static_routes - net_address = ".".join(tokens[idx + 1 : idx + 5]) - gateway = ".".join(tokens[idx + 5 : idx + req_toks]) - current_idx = idx + req_toks - elif net_length in range(17, 25): - req_toks = 8 - if len(tokens[idx:]) < req_toks: - _trunc_error(net_length, req_toks, len(tokens[idx:])) - return static_routes - net_address = ".".join(tokens[idx + 1 : idx + 4] + ["0"]) - gateway = ".".join(tokens[idx + 4 : idx + req_toks]) - current_idx = idx + req_toks - elif net_length in range(9, 17): - req_toks = 7 - if len(tokens[idx:]) < req_toks: - _trunc_error(net_length, req_toks, len(tokens[idx:])) - return static_routes - net_address = ".".join(tokens[idx + 1 : idx + 3] + ["0", "0"]) - gateway = ".".join(tokens[idx + 3 : idx + req_toks]) - current_idx = idx + req_toks - elif net_length in range(1, 9): - req_toks = 6 - if len(tokens[idx:]) < req_toks: - _trunc_error(net_length, req_toks, len(tokens[idx:])) - return static_routes - net_address = ".".join(tokens[idx + 1 : idx + 2] + ["0", "0", "0"]) - gateway = ".".join(tokens[idx + 2 : idx + req_toks]) - current_idx = idx + req_toks - elif net_length == 0: - req_toks = 5 - if len(tokens[idx:]) < req_toks: - _trunc_error(net_length, req_toks, len(tokens[idx:])) - return static_routes - net_address = "0.0.0.0" - gateway = ".".join(tokens[idx + 1 : idx + req_toks]) - current_idx = idx + req_toks - else: + if missing: + LOG.warning( + "dhclient did not produce expected files: %s", + ", ".join(os.path.basename(f) for f in missing), + ) + return [] + + ppid = "unknown" + daemonized = False + for _ in range(0, 1000): + pid_content = util.load_file(pid_file).strip() + try: + pid = int(pid_content) + except ValueError: + pass + else: + ppid = util.get_proc_ppid(pid) + if ppid == 1: + LOG.debug("killing dhclient with pid=%s", pid) + os.kill(pid, signal.SIGKILL) + daemonized = True + break + time.sleep(0.01) + + if not daemonized: LOG.error( - 'Parsed invalid net length "%s". Verify DHCP ' - "rfc3442-classless-static-routes value.", - net_length, + "dhclient(pid=%s, parentpid=%s) failed to daemonize after %s " + "seconds", + pid_content, + ppid, + 0.01 * 1000, ) - return static_routes + if dhcp_log_func is not None: + dhcp_log_func(out, err) + return self.parse_dhcp_lease_file(lease_file) + + @staticmethod + def parse_static_routes(rfc3442): + """ + parse rfc3442 format and return a list containing tuple of strings. + + The tuple is composed of the network_address (including net length) and + gateway for a parsed static route. It can parse two formats of + rfc3442, one from dhcpcd and one from dhclient (isc). + + @param rfc3442: string in rfc3442 format (isc or dhcpd) + @returns: list of tuple(str, str) for all valid parsed routes until the + first parsing error. + + e.g.: + + sr=parse_static_routes(\ + "32,169,254,169,254,130,56,248,255,0,130,56,240,1") + sr=[ + ("169.254.169.254/32", "130.56.248.255"), \ + ("0.0.0.0/0", "130.56.240.1") + ] + + sr2 = parse_static_routes(\ + "24.191.168.128 192.168.128.1,0 192.168.128.1") + sr2 = [ + ("191.168.128.0/24", "192.168.128.1"),\ + ("0.0.0.0/0", "192.168.128.1") + ] + + Python version of isc-dhclient's hooks: + /etc/dhcp/dhclient-exit-hooks.d/rfc3442-classless-routes + """ + # raw strings from dhcp lease may end in semi-colon + rfc3442 = rfc3442.rstrip(";") + tokens = [tok for tok in re.split(r"[, .]", rfc3442) if tok] + static_routes = [] + + def _trunc_error(cidr, required, remain): + msg = ( + "RFC3442 string malformed. Current route has CIDR of %s " + "and requires %s significant octets, but only %s remain. " + "Verify DHCP rfc3442-classless-static-routes value: %s" + % (cidr, required, remain, rfc3442) + ) + LOG.error(msg) - static_routes.append(("%s/%s" % (net_address, net_length), gateway)) + current_idx = 0 + for idx, tok in enumerate(tokens): + if idx < current_idx: + continue + net_length = int(tok) + if net_length in range(25, 33): + req_toks = 9 + if len(tokens[idx:]) < req_toks: + _trunc_error(net_length, req_toks, len(tokens[idx:])) + return static_routes + net_address = ".".join(tokens[idx + 1 : idx + 5]) + gateway = ".".join(tokens[idx + 5 : idx + req_toks]) + current_idx = idx + req_toks + elif net_length in range(17, 25): + req_toks = 8 + if len(tokens[idx:]) < req_toks: + _trunc_error(net_length, req_toks, len(tokens[idx:])) + return static_routes + net_address = ".".join(tokens[idx + 1 : idx + 4] + ["0"]) + gateway = ".".join(tokens[idx + 4 : idx + req_toks]) + current_idx = idx + req_toks + elif net_length in range(9, 17): + req_toks = 7 + if len(tokens[idx:]) < req_toks: + _trunc_error(net_length, req_toks, len(tokens[idx:])) + return static_routes + net_address = ".".join(tokens[idx + 1 : idx + 3] + ["0", "0"]) + gateway = ".".join(tokens[idx + 3 : idx + req_toks]) + current_idx = idx + req_toks + elif net_length in range(1, 9): + req_toks = 6 + if len(tokens[idx:]) < req_toks: + _trunc_error(net_length, req_toks, len(tokens[idx:])) + return static_routes + net_address = ".".join( + tokens[idx + 1 : idx + 2] + ["0", "0", "0"] + ) + gateway = ".".join(tokens[idx + 2 : idx + req_toks]) + current_idx = idx + req_toks + elif net_length == 0: + req_toks = 5 + if len(tokens[idx:]) < req_toks: + _trunc_error(net_length, req_toks, len(tokens[idx:])) + return static_routes + net_address = "0.0.0.0" + gateway = ".".join(tokens[idx + 1 : idx + req_toks]) + current_idx = idx + req_toks + else: + LOG.error( + 'Parsed invalid net length "%s". Verify DHCP ' + "rfc3442-classless-static-routes value.", + net_length, + ) + return static_routes - return static_routes + static_routes.append( + ("%s/%s" % (net_address, net_length), gateway) + ) + return static_routes + + @staticmethod + def get_dhclient_d(): + # find lease files directory + supported_dirs = [ + "/var/lib/dhclient", + "/var/lib/dhcp", + "/var/lib/NetworkManager", + ] + for d in supported_dirs: + if os.path.exists(d) and len(os.listdir(d)) > 0: + LOG.debug("Using %s lease directory", d) + return d + return None + + @staticmethod + def get_latest_lease(lease_d=None): + # find latest lease file + if lease_d is None: + lease_d = IscDhclient.get_dhclient_d() + if not lease_d: + return None + lease_files = os.listdir(lease_d) + latest_mtime = -1 + latest_file = None + + # lease files are named inconsistently across distros. + # We assume that 'dhclient6' indicates ipv6 and ignore it. + # ubuntu: + # dhclient.<iface>.leases, dhclient.leases, dhclient6.leases + # centos6: + # dhclient-<iface>.leases, dhclient6.leases + # centos7: ('--' is not a typo) + # dhclient--<iface>.lease, dhclient6.leases + for fname in lease_files: + if fname.startswith("dhclient6"): + # avoid files that start with dhclient6 assuming dhcpv6. + continue + if not (fname.endswith(".lease") or fname.endswith(".leases")): + continue -# vi: ts=4 expandtab + abs_path = os.path.join(lease_d, fname) + mtime = os.path.getmtime(abs_path) + if mtime > latest_mtime: + latest_mtime = mtime + latest_file = abs_path + return latest_file + + @staticmethod + def parse_dhcp_server_from_lease_file(lease_file): + with open(lease_file, "r") as fd: + for line in fd: + if "dhcp-server-identifier" in line: + words = line.strip(" ;\r\n").split(" ") + if len(words) > 2: + dhcptok = words[2] + LOG.debug("Found DHCP identifier %s", dhcptok) + latest_address = dhcptok + return latest_address + + +class Dhcpcd: + client_name = "dhcpcd" + + def __init__(self): + raise NoDHCPLeaseMissingDhclientError("Dhcpcd not yet implemented") |