# Copyright (C) 2013-2014 Canonical Ltd. # # Author: Scott Moser # Author: Blake Rouse # # Curtin is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License as published by the # Free Software Foundation, either version 3 of the License, or (at your # option) any later version. # # Curtin is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for # more details. # # You should have received a copy of the GNU Affero General Public License # along with Curtin. If not, see . import errno import glob import os import re import string import textwrap from cloudinit import log as logging from cloudinit import util from .udev import generate_udev_rule from . import network_state LOG = logging.getLogger(__name__) SYS_CLASS_NET = "/sys/class/net/" NET_CONFIG_OPTIONS = [ "address", "netmask", "broadcast", "network", "metric", "gateway", "pointtopoint", "media", "mtu", "hostname", "leasehours", "leasetime", "vendor", "client", "bootfile", "server", "hwaddr", "provider", "frame", "netnum", "endpoint", "local", "ttl", ] NET_CONFIG_COMMANDS = [ "pre-up", "up", "post-up", "down", "pre-down", "post-down", ] NET_CONFIG_BRIDGE_OPTIONS = [ "bridge_ageing", "bridge_bridgeprio", "bridge_fd", "bridge_gcinit", "bridge_hello", "bridge_maxage", "bridge_maxwait", "bridge_stp", ] DEFAULT_PRIMARY_INTERFACE = 'eth0' def sys_dev_path(devname, path=""): return SYS_CLASS_NET + devname + "/" + path def read_sys_net(devname, path, translate=None, enoent=None, keyerror=None): try: contents = "" with open(sys_dev_path(devname, path), "r") as fp: contents = fp.read().strip() if translate is None: return contents try: return translate.get(contents) except KeyError: LOG.debug("found unexpected value '%s' in '%s/%s'", contents, devname, path) if keyerror is not None: return keyerror raise except OSError as e: if e.errno == errno.ENOENT and enoent is not None: return enoent raise def is_up(devname): # The linux kernel says to consider devices in 'unknown' # operstate as up for the purposes of network configuration. See # Documentation/networking/operstates.txt in the kernel source. translate = {'up': True, 'unknown': True, 'down': False} return read_sys_net(devname, "operstate", enoent=False, keyerror=False, translate=translate) def is_wireless(devname): return os.path.exists(sys_dev_path(devname, "wireless")) def is_connected(devname): # is_connected isn't really as simple as that. 2 is # 'physically connected'. 3 is 'not connected'. but a wlan interface will # always show 3. try: iflink = read_sys_net(devname, "iflink", enoent=False) if iflink == "2": return True if not is_wireless(devname): return False LOG.debug("'%s' is wireless, basing 'connected' on carrier", devname) return read_sys_net(devname, "carrier", enoent=False, keyerror=False, translate={'0': False, '1': True}) except IOError as e: if e.errno == errno.EINVAL: return False raise def is_physical(devname): return os.path.exists(sys_dev_path(devname, "device")) def is_present(devname): return os.path.exists(sys_dev_path(devname)) def get_devicelist(): return os.listdir(SYS_CLASS_NET) class ParserError(Exception): """Raised when parser has issue parsing the interfaces file.""" def parse_deb_config_data(ifaces, contents, src_dir, src_path): """Parses the file contents, placing result into ifaces. '_source_path' is added to every dictionary entry to define which file the configration information came from. :param ifaces: interface dictionary :param contents: contents of interfaces file :param src_dir: directory interfaces file was located :param src_path: file path the `contents` was read """ currif = None for line in contents.splitlines(): line = line.strip() if line.startswith('#'): continue split = line.split(' ') option = split[0] if option == "source-directory": parsed_src_dir = split[1] if not parsed_src_dir.startswith("/"): parsed_src_dir = os.path.join(src_dir, parsed_src_dir) for expanded_path in glob.glob(parsed_src_dir): dir_contents = os.listdir(expanded_path) dir_contents = [ os.path.join(expanded_path, path) for path in dir_contents if (os.path.isfile(os.path.join(expanded_path, path)) and re.match("^[a-zA-Z0-9_-]+$", path) is not None) ] for entry in dir_contents: with open(entry, "r") as fp: src_data = fp.read().strip() abs_entry = os.path.abspath(entry) parse_deb_config_data( ifaces, src_data, os.path.dirname(abs_entry), abs_entry) elif option == "source": new_src_path = split[1] if not new_src_path.startswith("/"): new_src_path = os.path.join(src_dir, new_src_path) for expanded_path in glob.glob(new_src_path): with open(expanded_path, "r") as fp: src_data = fp.read().strip() abs_path = os.path.abspath(expanded_path) parse_deb_config_data( ifaces, src_data, os.path.dirname(abs_path), abs_path) elif option == "auto": for iface in split[1:]: if iface not in ifaces: ifaces[iface] = { # Include the source path this interface was found in. "_source_path": src_path } ifaces[iface]['auto'] = True elif option == "iface": iface, family, method = split[1:4] if iface not in ifaces: ifaces[iface] = { # Include the source path this interface was found in. "_source_path": src_path } elif 'family' in ifaces[iface]: raise ParserError( "Interface %s can only be defined once. " "Re-defined in '%s'." % (iface, src_path)) ifaces[iface]['family'] = family ifaces[iface]['method'] = method currif = iface elif option == "hwaddress": ifaces[currif]['hwaddress'] = split[1] elif option in NET_CONFIG_OPTIONS: ifaces[currif][option] = split[1] elif option in NET_CONFIG_COMMANDS: if option not in ifaces[currif]: ifaces[currif][option] = [] ifaces[currif][option].append(' '.join(split[1:])) elif option.startswith('dns-'): if 'dns' not in ifaces[currif]: ifaces[currif]['dns'] = {} if option == 'dns-search': ifaces[currif]['dns']['search'] = [] for domain in split[1:]: ifaces[currif]['dns']['search'].append(domain) elif option == 'dns-nameservers': ifaces[currif]['dns']['nameservers'] = [] for server in split[1:]: ifaces[currif]['dns']['nameservers'].append(server) elif option.startswith('bridge_'): if 'bridge' not in ifaces[currif]: ifaces[currif]['bridge'] = {} if option in NET_CONFIG_BRIDGE_OPTIONS: bridge_option = option.replace('bridge_', '', 1) ifaces[currif]['bridge'][bridge_option] = split[1] elif option == "bridge_ports": ifaces[currif]['bridge']['ports'] = [] for iface in split[1:]: ifaces[currif]['bridge']['ports'].append(iface) elif option == "bridge_hw" and split[1].lower() == "mac": ifaces[currif]['bridge']['mac'] = split[2] elif option == "bridge_pathcost": if 'pathcost' not in ifaces[currif]['bridge']: ifaces[currif]['bridge']['pathcost'] = {} ifaces[currif]['bridge']['pathcost'][split[1]] = split[2] elif option == "bridge_portprio": if 'portprio' not in ifaces[currif]['bridge']: ifaces[currif]['bridge']['portprio'] = {} ifaces[currif]['bridge']['portprio'][split[1]] = split[2] elif option.startswith('bond-'): if 'bond' not in ifaces[currif]: ifaces[currif]['bond'] = {} bond_option = option.replace('bond-', '', 1) ifaces[currif]['bond'][bond_option] = split[1] for iface in ifaces.keys(): if 'auto' not in ifaces[iface]: ifaces[iface]['auto'] = False def parse_deb_config(path): """Parses a debian network configuration file.""" ifaces = {} with open(path, "r") as fp: contents = fp.read().strip() abs_path = os.path.abspath(path) parse_deb_config_data( ifaces, contents, os.path.dirname(abs_path), abs_path) return ifaces def parse_net_config_data(net_config): """Parses the config, returns NetworkState dictionary :param net_config: curtin network config dict """ state = None if 'version' in net_config and 'config' in net_config: ns = network_state.NetworkState(version=net_config.get('version'), config=net_config.get('config')) ns.parse_config() state = ns.network_state return state def parse_net_config(path): """Parses a curtin network configuration file and return network state""" ns = None net_config = util.read_conf(path) if 'network' in net_config: ns = parse_net_config_data(net_config.get('network')) return ns def find_fallback_network_device(rename_to_default=False): """Determine which attached net dev is most likely to have a connection and generate network state to run dhcp on that interface""" # by default use eth0 as primary interface ns = {'interfaces': {}, 'dns': {'search': [], 'nameservers': []}, 'routes': []} default_link_file = textwrap.dedent(""" #cloud-init [Match] MACAddress={mac} [Link] Name={name} """) # get list of interfaces that could have connections invalid_interfaces = set(['lo']) potential_interfaces = set(os.listdir(SYS_CLASS_NET)) potential_interfaces = potential_interfaces.difference(invalid_interfaces) # sort into interfaces with carrier, interfaces which could have carrier, # and ignore interfaces that are definitely disconnected connected = [] possibly_connected = [] for interface in potential_interfaces: try: sysfs_carrier = os.path.join(SYS_CLASS_NET, interface, 'carrier') carrier = int(util.load_file(sysfs_carrier).strip()) if carrier: connected.append(interface) continue except OSError: pass # check if nic is dormant or down, as this may make a nick appear to # not have a carrier even though it could acquire one when brought # online by dhclient try: sysfs_dormant = os.path.join(SYS_CLASS_NET, interface, 'dormant') dormant = int(util.load_file(sysfs_dormant).strip()) if dormant: possibly_connected.append(interface) continue except OSError: pass try: sysfs_operstate = os.path.join(SYS_CLASS_NET, interface, 'operstate') operstate = util.load_file(sysfs_operstate).strip() if operstate in ['dormant', 'down', 'lowerlayerdown', 'unknown']: possibly_connected.append(interface) continue except OSError: pass # don't bother with interfaces that might not be connected if there are # some that definitely are if connected: potential_interfaces = connected else: potential_interfaces = possibly_connected # if there are no interfaces, give up if not potential_interfaces: return # if eth0 exists use it above anything else, otherwise get the interface # that looks 'first' if DEFAULT_PRIMARY_INTERFACE in potential_interfaces: name = DEFAULT_PRIMARY_INTERFACE else: potential_interfaces.sort( key=lambda x: int(x.strip(string.ascii_letters))) name = potential_interfaces[0] sysfs_mac = os.path.join(SYS_CLASS_NET, name, 'address') mac = util.load_file(sysfs_mac).strip() target_name = name if rename_to_default: target_name = DEFAULT_PRIMARY_INTERFACE # generate net config for interface, rename interface to eth0 for backwards # compatibility, and attempt both dhcp4 and dhcp6 ns['interfaces'][target_name] = { 'mac_address': mac, 'name': target_name, 'type': 'physical', 'mode': 'manual', 'inet': 'inet', 'subnets': [{'type': 'dhcp4'}, {'type': 'dhcp6'}] } # insert params into link file link_file = default_link_file.format(name=target_name, mac=mac) syslink_name = "/etc/systemd/network/50-cloud-init-{}.link".format( target_name) return (ns, link_file, syslink_name) def render_persistent_net(network_state): ''' Given state, emit udev rules to map mac to ifname ''' content = "" interfaces = network_state.get('interfaces') for iface in interfaces.values(): # for physical interfaces write out a persist net udev rule if iface['type'] == 'physical' and \ 'name' in iface and 'mac_address' in iface: content += generate_udev_rule(iface['name'], iface['mac_address']) return content # TODO: switch valid_map based on mode inet/inet6 def iface_add_subnet(iface, subnet): content = "" valid_map = [ 'address', 'netmask', 'broadcast', 'metric', 'gateway', 'pointopoint', 'mtu', 'scope', 'dns_search', 'dns_nameservers', ] for key, value in subnet.items(): if value and key in valid_map: if type(value) == list: value = " ".join(value) if '_' in key: key = key.replace('_', '-') content += " {} {}\n".format(key, value) return content # TODO: switch to valid_map for attrs def iface_add_attrs(iface): content = "" ignore_map = [ 'type', 'name', 'inet', 'mode', 'index', 'subnets', ] if iface['type'] not in ['bond', 'bridge']: ignore_map.append('mac_address') for key, value in iface.items(): if value and key not in ignore_map: if type(value) == list: value = " ".join(value) content += " {} {}\n".format(key, value) return content def render_route(route): content = "up route add" mapping = { 'network': '-net', 'netmask': 'netmask', 'gateway': 'gw', 'metric': 'metric', } for k in ['network', 'netmask', 'gateway', 'metric']: if k in route: content += " %s %s" % (mapping[k], route[k]) content += '\n' return content def render_interfaces(network_state): ''' Given state, emit etc/network/interfaces content ''' content = "" interfaces = network_state.get('interfaces') ''' Apply a sort order to ensure that we write out the physical interfaces first; this is critical for bonding ''' order = { 'physical': 0, 'bond': 1, 'bridge': 2, 'vlan': 3, } content += "auto lo\niface lo inet loopback\n" for dnskey, value in network_state.get('dns', {}).items(): if len(value): content += " dns-{} {}\n".format(dnskey, " ".join(value)) for iface in sorted(interfaces.values(), key=lambda k: (order[k['type']], k['name'])): content += "auto {name}\n".format(**iface) subnets = iface.get('subnets', {}) if subnets: for index, subnet in zip(range(0, len(subnets)), subnets): iface['index'] = index iface['mode'] = subnet['type'] if iface['mode'].endswith('6'): iface['inet'] += '6' elif iface['mode'] == 'static' and ":" in subnet['address']: iface['inet'] += '6' if iface['mode'].startswith('dhcp'): iface['mode'] = 'dhcp' if index == 0: content += "iface {name} {inet} {mode}\n".format(**iface) else: content += "auto {name}:{index}\n".format(**iface) content += \ "iface {name}:{index} {inet} {mode}\n".format(**iface) content += iface_add_subnet(iface, subnet) content += iface_add_attrs(iface) content += "\n" else: content += "iface {name} {inet} {mode}\n".format(**iface) content += iface_add_attrs(iface) content += "\n" for route in network_state.get('routes'): content += render_route(route) # global replacements until v2 format content = content.replace('mac_address', 'hwaddress') return content def render_network_state(target, network_state): eni = 'etc/network/interfaces' netrules = 'etc/udev/rules.d/70-persistent-net.rules' eni = os.path.sep.join((target, eni,)) util.ensure_dir(os.path.dirname(eni)) with open(eni, 'w+') as f: f.write(render_interfaces(network_state)) netrules = os.path.sep.join((target, netrules,)) util.ensure_dir(os.path.dirname(netrules)) with open(netrules, 'w+') as f: f.write(render_persistent_net(network_state)) # vi: ts=4 expandtab syntax=python