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