1# Copyright (C) 2017 Canonical Ltd. 2# 3# Author: Chad Smith <chad.smith@canonical.com> 4# 5# This file is part of cloud-init. See LICENSE file for license information. 6 7from typing import Dict, Any 8import configobj 9import logging 10import os 11import re 12import signal 13import time 14from io import StringIO 15 16from cloudinit.net import ( 17 EphemeralIPv4Network, find_fallback_nic, get_devicelist, 18 has_url_connectivity) 19from cloudinit.net.network_state import mask_and_ipv4_to_bcast_addr as bcip 20from cloudinit import temp_utils 21from cloudinit import subp 22from cloudinit import util 23 24LOG = logging.getLogger(__name__) 25 26NETWORKD_LEASES_DIR = '/run/systemd/netif/leases' 27 28 29class InvalidDHCPLeaseFileError(Exception): 30 """Raised when parsing an empty or invalid dhcp.leases file. 31 32 Current uses are DataSourceAzure and DataSourceEc2 during ephemeral 33 boot to scrape metadata. 34 """ 35 36 37class NoDHCPLeaseError(Exception): 38 """Raised when unable to get a DHCP lease.""" 39 40 41class EphemeralDHCPv4(object): 42 def __init__( 43 self, 44 iface=None, 45 connectivity_url_data: Dict[str, Any] = None, 46 dhcp_log_func=None 47 ): 48 self.iface = iface 49 self._ephipv4 = None 50 self.lease = None 51 self.dhcp_log_func = dhcp_log_func 52 self.connectivity_url_data = connectivity_url_data 53 54 def __enter__(self): 55 """Setup sandboxed dhcp context, unless connectivity_url can already be 56 reached.""" 57 if self.connectivity_url_data: 58 if has_url_connectivity(self.connectivity_url_data): 59 LOG.debug( 60 'Skip ephemeral DHCP setup, instance has connectivity' 61 ' to %s', self.connectivity_url_data) 62 return 63 return self.obtain_lease() 64 65 def __exit__(self, excp_type, excp_value, excp_traceback): 66 """Teardown sandboxed dhcp context.""" 67 self.clean_network() 68 69 def clean_network(self): 70 """Exit _ephipv4 context to teardown of ip configuration performed.""" 71 if self.lease: 72 self.lease = None 73 if not self._ephipv4: 74 return 75 self._ephipv4.__exit__(None, None, None) 76 77 def obtain_lease(self): 78 """Perform dhcp discovery in a sandboxed environment if possible. 79 80 @return: A dict representing dhcp options on the most recent lease 81 obtained from the dhclient discovery if run, otherwise an error 82 is raised. 83 84 @raises: NoDHCPLeaseError if no leases could be obtained. 85 """ 86 if self.lease: 87 return self.lease 88 try: 89 leases = maybe_perform_dhcp_discovery( 90 self.iface, self.dhcp_log_func) 91 except InvalidDHCPLeaseFileError as e: 92 raise NoDHCPLeaseError() from e 93 if not leases: 94 raise NoDHCPLeaseError() 95 self.lease = leases[-1] 96 LOG.debug("Received dhcp lease on %s for %s/%s", 97 self.lease['interface'], self.lease['fixed-address'], 98 self.lease['subnet-mask']) 99 nmap = {'interface': 'interface', 'ip': 'fixed-address', 100 'prefix_or_mask': 'subnet-mask', 101 'broadcast': 'broadcast-address', 102 'static_routes': [ 103 'rfc3442-classless-static-routes', 104 'classless-static-routes' 105 ], 106 'router': 'routers'} 107 kwargs = self.extract_dhcp_options_mapping(nmap) 108 if not kwargs['broadcast']: 109 kwargs['broadcast'] = bcip(kwargs['prefix_or_mask'], kwargs['ip']) 110 if kwargs['static_routes']: 111 kwargs['static_routes'] = ( 112 parse_static_routes(kwargs['static_routes'])) 113 if self.connectivity_url_data: 114 kwargs['connectivity_url_data'] = self.connectivity_url_data 115 ephipv4 = EphemeralIPv4Network(**kwargs) 116 ephipv4.__enter__() 117 self._ephipv4 = ephipv4 118 return self.lease 119 120 def extract_dhcp_options_mapping(self, nmap): 121 result = {} 122 for internal_reference, lease_option_names in nmap.items(): 123 if isinstance(lease_option_names, list): 124 self.get_first_option_value( 125 internal_reference, 126 lease_option_names, 127 result 128 ) 129 else: 130 result[internal_reference] = self.lease.get(lease_option_names) 131 return result 132 133 def get_first_option_value(self, internal_mapping, 134 lease_option_names, result): 135 for different_names in lease_option_names: 136 if not result.get(internal_mapping): 137 result[internal_mapping] = self.lease.get(different_names) 138 139 140def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None): 141 """Perform dhcp discovery if nic valid and dhclient command exists. 142 143 If the nic is invalid or undiscoverable or dhclient command is not found, 144 skip dhcp_discovery and return an empty dict. 145 146 @param nic: Name of the network interface we want to run dhclient on. 147 @param dhcp_log_func: A callable accepting the dhclient output and error 148 streams. 149 @return: A list of dicts representing dhcp options for each lease obtained 150 from the dhclient discovery if run, otherwise an empty list is 151 returned. 152 """ 153 if nic is None: 154 nic = find_fallback_nic() 155 if nic is None: 156 LOG.debug('Skip dhcp_discovery: Unable to find fallback nic.') 157 return [] 158 elif nic not in get_devicelist(): 159 LOG.debug( 160 'Skip dhcp_discovery: nic %s not found in get_devicelist.', nic) 161 return [] 162 dhclient_path = subp.which('dhclient') 163 if not dhclient_path: 164 LOG.debug('Skip dhclient configuration: No dhclient command found.') 165 return [] 166 with temp_utils.tempdir(rmtree_ignore_errors=True, 167 prefix='cloud-init-dhcp-', 168 needs_exe=True) as tdir: 169 # Use /var/tmp because /run/cloud-init/tmp is mounted noexec 170 return dhcp_discovery(dhclient_path, nic, tdir, dhcp_log_func) 171 172 173def parse_dhcp_lease_file(lease_file): 174 """Parse the given dhcp lease file for the most recent lease. 175 176 Return a list of dicts of dhcp options. Each dict contains key value pairs 177 a specific lease in order from oldest to newest. 178 179 @raises: InvalidDHCPLeaseFileError on empty of unparseable leasefile 180 content. 181 """ 182 lease_regex = re.compile(r"lease {(?P<lease>.*?)}\n", re.DOTALL) 183 dhcp_leases = [] 184 lease_content = util.load_file(lease_file) 185 if len(lease_content) == 0: 186 raise InvalidDHCPLeaseFileError( 187 'Cannot parse empty dhcp lease file {0}'.format(lease_file)) 188 for lease in lease_regex.findall(lease_content): 189 lease_options = [] 190 for line in lease.split(';'): 191 # Strip newlines, double-quotes and option prefix 192 line = line.strip().replace('"', '').replace('option ', '') 193 if not line: 194 continue 195 lease_options.append(line.split(' ', 1)) 196 dhcp_leases.append(dict(lease_options)) 197 if not dhcp_leases: 198 raise InvalidDHCPLeaseFileError( 199 'Cannot parse dhcp lease file {0}. No leases found'.format( 200 lease_file)) 201 return dhcp_leases 202 203 204def dhcp_discovery(dhclient_cmd_path, interface, cleandir, dhcp_log_func=None): 205 """Run dhclient on the interface without scripts or filesystem artifacts. 206 207 @param dhclient_cmd_path: Full path to the dhclient used. 208 @param interface: Name of the network inteface on which to dhclient. 209 @param cleandir: The directory from which to run dhclient as well as store 210 dhcp leases. 211 @param dhcp_log_func: A callable accepting the dhclient output and error 212 streams. 213 214 @return: A list of dicts of representing the dhcp leases parsed from the 215 dhcp.leases file or empty list. 216 """ 217 LOG.debug('Performing a dhcp discovery on %s', interface) 218 219 # XXX We copy dhclient out of /sbin/dhclient to avoid dealing with strict 220 # app armor profiles which disallow running dhclient -sf <our-script-file>. 221 # We want to avoid running /sbin/dhclient-script because of side-effects in 222 # /etc/resolv.conf any any other vendor specific scripts in 223 # /etc/dhcp/dhclient*hooks.d. 224 sandbox_dhclient_cmd = os.path.join(cleandir, 'dhclient') 225 util.copy(dhclient_cmd_path, sandbox_dhclient_cmd) 226 pid_file = os.path.join(cleandir, 'dhclient.pid') 227 lease_file = os.path.join(cleandir, 'dhcp.leases') 228 229 # In some cases files in /var/tmp may not be executable, launching dhclient 230 # from there will certainly raise 'Permission denied' error. Try launching 231 # the original dhclient instead. 232 if not os.access(sandbox_dhclient_cmd, os.X_OK): 233 sandbox_dhclient_cmd = dhclient_cmd_path 234 235 # ISC dhclient needs the interface up to send initial discovery packets. 236 # Generally dhclient relies on dhclient-script PREINIT action to bring the 237 # link up before attempting discovery. Since we are using -sf /bin/true, 238 # we need to do that "link up" ourselves first. 239 subp.subp(['ip', 'link', 'set', 'dev', interface, 'up'], capture=True) 240 cmd = [sandbox_dhclient_cmd, '-1', '-v', '-lf', lease_file, 241 '-pf', pid_file, interface, '-sf', '/bin/true'] 242 out, err = subp.subp(cmd, capture=True) 243 244 # Wait for pid file and lease file to appear, and for the process 245 # named by the pid file to daemonize (have pid 1 as its parent). If we 246 # try to read the lease file before daemonization happens, we might try 247 # to read it before the dhclient has actually written it. We also have 248 # to wait until the dhclient has become a daemon so we can be sure to 249 # kill the correct process, thus freeing cleandir to be deleted back 250 # up the callstack. 251 missing = util.wait_for_files( 252 [pid_file, lease_file], maxwait=5, naplen=0.01) 253 if missing: 254 LOG.warning("dhclient did not produce expected files: %s", 255 ', '.join(os.path.basename(f) for f in missing)) 256 return [] 257 258 ppid = 'unknown' 259 daemonized = False 260 for _ in range(0, 1000): 261 pid_content = util.load_file(pid_file).strip() 262 try: 263 pid = int(pid_content) 264 except ValueError: 265 pass 266 else: 267 ppid = util.get_proc_ppid(pid) 268 if ppid == 1: 269 LOG.debug('killing dhclient with pid=%s', pid) 270 os.kill(pid, signal.SIGKILL) 271 daemonized = True 272 break 273 time.sleep(0.01) 274 275 if not daemonized: 276 LOG.error( 277 'dhclient(pid=%s, parentpid=%s) failed to daemonize after %s ' 278 'seconds', pid_content, ppid, 0.01 * 1000 279 ) 280 if dhcp_log_func is not None: 281 dhcp_log_func(out, err) 282 return parse_dhcp_lease_file(lease_file) 283 284 285def networkd_parse_lease(content): 286 """Parse a systemd lease file content as in /run/systemd/netif/leases/ 287 288 Parse this (almost) ini style file even though it says: 289 # This is private data. Do not parse. 290 291 Simply return a dictionary of key/values.""" 292 293 return dict(configobj.ConfigObj(StringIO(content), list_values=False)) 294 295 296def networkd_load_leases(leases_d=None): 297 """Return a dictionary of dictionaries representing each lease 298 found in lease_d.i 299 300 The top level key will be the filename, which is typically the ifindex.""" 301 302 if leases_d is None: 303 leases_d = NETWORKD_LEASES_DIR 304 305 ret = {} 306 if not os.path.isdir(leases_d): 307 return ret 308 for lfile in os.listdir(leases_d): 309 ret[lfile] = networkd_parse_lease( 310 util.load_file(os.path.join(leases_d, lfile))) 311 return ret 312 313 314def networkd_get_option_from_leases(keyname, leases_d=None): 315 if leases_d is None: 316 leases_d = NETWORKD_LEASES_DIR 317 leases = networkd_load_leases(leases_d=leases_d) 318 for _ifindex, data in sorted(leases.items()): 319 if data.get(keyname): 320 return data[keyname] 321 return None 322 323 324def parse_static_routes(rfc3442): 325 """ parse rfc3442 format and return a list containing tuple of strings. 326 327 The tuple is composed of the network_address (including net length) and 328 gateway for a parsed static route. It can parse two formats of rfc3442, 329 one from dhcpcd and one from dhclient (isc). 330 331 @param rfc3442: string in rfc3442 format (isc or dhcpd) 332 @returns: list of tuple(str, str) for all valid parsed routes until the 333 first parsing error. 334 335 E.g. 336 sr=parse_static_routes("32,169,254,169,254,130,56,248,255,0,130,56,240,1") 337 sr=[ 338 ("169.254.169.254/32", "130.56.248.255"), ("0.0.0.0/0", "130.56.240.1") 339 ] 340 341 sr2 = parse_static_routes("24.191.168.128 192.168.128.1,0 192.168.128.1") 342 sr2 = [ 343 ("191.168.128.0/24", "192.168.128.1"), ("0.0.0.0/0", "192.168.128.1") 344 ] 345 346 Python version of isc-dhclient's hooks: 347 /etc/dhcp/dhclient-exit-hooks.d/rfc3442-classless-routes 348 """ 349 # raw strings from dhcp lease may end in semi-colon 350 rfc3442 = rfc3442.rstrip(";") 351 tokens = [tok for tok in re.split(r"[, .]", rfc3442) if tok] 352 static_routes = [] 353 354 def _trunc_error(cidr, required, remain): 355 msg = ("RFC3442 string malformed. Current route has CIDR of %s " 356 "and requires %s significant octets, but only %s remain. " 357 "Verify DHCP rfc3442-classless-static-routes value: %s" 358 % (cidr, required, remain, rfc3442)) 359 LOG.error(msg) 360 361 current_idx = 0 362 for idx, tok in enumerate(tokens): 363 if idx < current_idx: 364 continue 365 net_length = int(tok) 366 if net_length in range(25, 33): 367 req_toks = 9 368 if len(tokens[idx:]) < req_toks: 369 _trunc_error(net_length, req_toks, len(tokens[idx:])) 370 return static_routes 371 net_address = ".".join(tokens[idx+1:idx+5]) 372 gateway = ".".join(tokens[idx+5:idx+req_toks]) 373 current_idx = idx + req_toks 374 elif net_length in range(17, 25): 375 req_toks = 8 376 if len(tokens[idx:]) < req_toks: 377 _trunc_error(net_length, req_toks, len(tokens[idx:])) 378 return static_routes 379 net_address = ".".join(tokens[idx+1:idx+4] + ["0"]) 380 gateway = ".".join(tokens[idx+4:idx+req_toks]) 381 current_idx = idx + req_toks 382 elif net_length in range(9, 17): 383 req_toks = 7 384 if len(tokens[idx:]) < req_toks: 385 _trunc_error(net_length, req_toks, len(tokens[idx:])) 386 return static_routes 387 net_address = ".".join(tokens[idx+1:idx+3] + ["0", "0"]) 388 gateway = ".".join(tokens[idx+3:idx+req_toks]) 389 current_idx = idx + req_toks 390 elif net_length in range(1, 9): 391 req_toks = 6 392 if len(tokens[idx:]) < req_toks: 393 _trunc_error(net_length, req_toks, len(tokens[idx:])) 394 return static_routes 395 net_address = ".".join(tokens[idx+1:idx+2] + ["0", "0", "0"]) 396 gateway = ".".join(tokens[idx+2:idx+req_toks]) 397 current_idx = idx + req_toks 398 elif net_length == 0: 399 req_toks = 5 400 if len(tokens[idx:]) < req_toks: 401 _trunc_error(net_length, req_toks, len(tokens[idx:])) 402 return static_routes 403 net_address = "0.0.0.0" 404 gateway = ".".join(tokens[idx+1:idx+req_toks]) 405 current_idx = idx + req_toks 406 else: 407 LOG.error('Parsed invalid net length "%s". Verify DHCP ' 408 'rfc3442-classless-static-routes value.', net_length) 409 return static_routes 410 411 static_routes.append(("%s/%s" % (net_address, net_length), gateway)) 412 413 return static_routes 414 415# vi: ts=4 expandtab 416