Blob Blame History Raw
# This file is part of cloud-init. See LICENSE file for license information.

import copy
import glob
import os
import re

from . import ParserError

from . import renderer
from .network_state import subnet_is_ipv6

from cloudinit import log as logging
from cloudinit import subp
from cloudinit import util


LOG = logging.getLogger(__name__)

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",
]

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",
]


# 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 key == 'netmask':
            continue
        if key == 'address':
            value = "%s/%s" % (subnet['address'], subnet['prefix'])
        if value and key in valid_map:
            if type(value) == list:
                value = " ".join(value)
            if '_' in key:
                key = key.replace('_', '-')
            content.append("    {0} {1}".format(key, value))

    return sorted(content)


# TODO: switch to valid_map for attrs
def _iface_add_attrs(iface, index, ipv4_subnet_mtu):
    # If the index is non-zero, this is an alias interface. Alias interfaces
    # represent additional interface addresses, and should not have additional
    # attributes. (extra attributes here are almost always either incorrect,
    # or are applied to the parent interface.) So if this is an alias, stop
    # right here.
    if index != 0:
        return []
    content = []
    ignore_map = [
        'control',
        'device_id',
        'driver',
        'index',
        'inet',
        'mode',
        'name',
        'subnets',
        'type',
    ]

    # The following parameters require repetitive entries of the key for
    # each of the values
    multiline_keys = [
        'bridge_pathcost',
        'bridge_portprio',
        'bridge_waitport',
    ]

    renames = {'mac_address': 'hwaddress'}
    if iface['type'] not in ['bond', 'bridge', 'infiniband', 'vlan']:
        ignore_map.append('mac_address')

    for key, value in iface.items():
        # convert bool to string for eni
        if type(value) == bool:
            value = 'on' if iface[key] else 'off'
        if not value or key in ignore_map:
            continue
        if key == 'mtu' and ipv4_subnet_mtu:
            if value != ipv4_subnet_mtu:
                LOG.warning(
                    "Network config: ignoring %s device-level mtu:%s because"
                    " ipv4 subnet-level mtu:%s provided.",
                    iface['name'], value, ipv4_subnet_mtu)
            continue
        if key in multiline_keys:
            for v in value:
                content.append("    {0} {1}".format(renames.get(key, key), v))
            continue
        if type(value) == list:
            value = " ".join(value)
        content.append("    {0} {1}".format(renames.get(key, key), value))

    return sorted(content)


def _iface_start_entry(iface, index, render_hwaddress=False):
    fullname = iface['name']

    control = iface['control']
    if control == "auto":
        cverb = "auto"
    elif control in ("hotplug",):
        cverb = "allow-" + control
    else:
        cverb = "# control-" + control

    subst = iface.copy()
    subst.update({'fullname': fullname, 'cverb': cverb})

    lines = [
        "{cverb} {fullname}".format(**subst),
        "iface {fullname} {inet} {mode}".format(**subst)]
    if render_hwaddress and iface.get('mac_address'):
        lines.append("    hwaddress {mac_address}".format(**subst))

    return lines


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":
            if split[1] == "ether":
                val = split[2]
            else:
                val = split[1]
            ifaces[currif]['hwaddress'] = val
        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":
                # doc is confusing and thus some may put literal 'MAC'
                #    bridge_hw MAC <address>
                # but correct is:
                #    bridge_hw <address>
                if split[1].lower() == "mac":
                    ifaces[currif]['bridge']['mac'] = split[2]
                else:
                    ifaces[currif]['bridge']['mac'] = split[1]
            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 convert_eni_data(eni_data):
    # return a network config representation of what is in eni_data
    ifaces = {}
    _parse_deb_config_data(ifaces, eni_data, src_dir=None, src_path=None)
    return _ifaces_to_net_config_data(ifaces)


def _ifaces_to_net_config_data(ifaces):
    """Return network config that represents the ifaces data provided.
    ifaces = parse_deb_config("/etc/network/interfaces")
    config = ifaces_to_net_config_data(ifaces)
    state = parse_net_config_data(config)."""
    devs = {}
    for name, data in ifaces.items():
        # devname is 'eth0' for name='eth0:1'
        devname = name.partition(":")[0]
        if devname not in devs:
            if devname == "lo":
                dtype = "loopback"
            else:
                dtype = "physical"
            devs[devname] = {'type': dtype, 'name': devname, 'subnets': []}
            # this isnt strictly correct, but some might specify
            # hwaddress on a nic for matching / declaring name.
            if 'hwaddress' in data:
                devs[devname]['mac_address'] = data['hwaddress']
        subnet = {'_orig_eni_name': name, 'type': data['method']}
        if data.get('auto'):
            subnet['control'] = 'auto'
        else:
            subnet['control'] = 'manual'

        if data.get('method') == 'static':
            subnet['address'] = data['address']

        for copy_key in ('netmask', 'gateway', 'broadcast'):
            if copy_key in data:
                subnet[copy_key] = data[copy_key]

        if 'dns' in data:
            for n in ('nameservers', 'search'):
                if n in data['dns'] and data['dns'][n]:
                    subnet['dns_' + n] = data['dns'][n]
        devs[devname]['subnets'].append(subnet)

    return {'version': 1,
            'config': [devs[d] for d in sorted(devs)]}


class Renderer(renderer.Renderer):
    """Renders network information in a /etc/network/interfaces format."""

    def __init__(self, config=None):
        if not config:
            config = {}
        self.eni_path = config.get('eni_path', 'etc/network/interfaces')
        self.eni_header = config.get('eni_header', None)
        self.netrules_path = config.get(
            'netrules_path', 'etc/udev/rules.d/70-persistent-net.rules')

    def _render_route(self, route, indent=""):
        """When rendering routes for an iface, in some cases applying a route
        may result in the route command returning non-zero which produces
        some confusing output for users manually using ifup/ifdown[1].  To
        that end, we will optionally include an '|| true' postfix to each
        route line allowing users to work with ifup/ifdown without using
        --force option.

        We may at somepoint not want to emit this additional postfix, and
        add a 'strict' flag to this function.  When called with strict=True,
        then we will not append the postfix.

        1. http://askubuntu.com/questions/168033/
                 how-to-set-static-routes-in-ubuntu-server
        """
        content = []
        up = indent + "post-up route add"
        down = indent + "pre-down route del"
        or_true = " || true"
        mapping = {
            'gateway': 'gw',
            'metric': 'metric',
        }

        default_gw = ''
        if route['network'] == '0.0.0.0' and route['netmask'] == '0.0.0.0':
            default_gw = ' default'
        elif route['network'] == '::' and route['prefix'] == 0:
            default_gw = ' -A inet6 default'

        route_line = ''
        for k in ['network', 'gateway', 'metric']:
            if default_gw and k == 'network':
                continue
            if k == 'gateway':
                route_line += '%s %s %s' % (default_gw, mapping[k], route[k])
            elif k in route:
                if k == 'network':
                    if ':' in route[k]:
                        route_line += ' -A inet6'
                    else:
                        route_line += ' -net'
                    if 'prefix' in route:
                        route_line += ' %s/%s' % (route[k], route['prefix'])
                else:
                    route_line += ' %s %s' % (mapping[k], route[k])
        content.append(up + route_line + or_true)
        content.append(down + route_line + or_true)
        return content

    def _render_iface(self, iface, render_hwaddress=False):
        sections = []
        subnets = iface.get('subnets', {})
        accept_ra = iface.pop('accept-ra', None)
        if subnets:
            for index, subnet in enumerate(subnets):
                ipv4_subnet_mtu = None
                iface['index'] = index
                iface['mode'] = subnet['type']
                iface['control'] = subnet.get('control', 'auto')
                subnet_inet = 'inet'
                if subnet_is_ipv6(subnet):
                    subnet_inet += '6'
                else:
                    ipv4_subnet_mtu = subnet.get('mtu')
                iface['inet'] = subnet_inet
                if (subnet['type'] == 'dhcp4' or subnet['type'] == 'dhcp6' or
                        subnet['type'] == 'ipv6_dhcpv6-stateful'):
                    # Configure network settings using DHCP or DHCPv6
                    iface['mode'] = 'dhcp'
                    if accept_ra is not None:
                        # Accept router advertisements (0=off, 1=on)
                        iface['accept_ra'] = '1' if accept_ra else '0'
                elif subnet['type'] == 'ipv6_dhcpv6-stateless':
                    # Configure network settings using SLAAC from RAs
                    iface['mode'] = 'auto'
                    # Use stateless DHCPv6 (0=off, 1=on)
                    iface['dhcp'] = '1'
                elif subnet['type'] == 'ipv6_slaac':
                    # Configure network settings using SLAAC from RAs
                    iface['mode'] = 'auto'
                    # Use stateless DHCPv6 (0=off, 1=on)
                    iface['dhcp'] = '0'
                elif subnet_is_ipv6(subnet):
                    # mode might be static6, eni uses 'static'
                    iface['mode'] = 'static'
                    if accept_ra is not None:
                        # Accept router advertisements (0=off, 1=on)
                        iface['accept_ra'] = '1' if accept_ra else '0'

                # do not emit multiple 'auto $IFACE' lines as older (precise)
                # ifupdown complains
                if True in ["auto %s" % (iface['name']) in line
                            for line in sections]:
                    iface['control'] = 'alias'

                lines = list(
                    _iface_start_entry(
                        iface, index, render_hwaddress=render_hwaddress) +
                    _iface_add_subnet(iface, subnet) +
                    _iface_add_attrs(iface, index, ipv4_subnet_mtu)
                )
                for route in subnet.get('routes', []):
                    lines.extend(self._render_route(route, indent="    "))

                sections.append(lines)
        else:
            # ifenslave docs say to auto the slave devices
            lines = []
            if 'bond-master' in iface or 'bond-slaves' in iface:
                lines.append("auto {name}".format(**iface))
            lines.append("iface {name} {inet} {mode}".format(**iface))
            lines.extend(
                _iface_add_attrs(iface, index=0, ipv4_subnet_mtu=None))
            sections.append(lines)
        return sections

    def _render_interfaces(self, network_state, render_hwaddress=False):
        '''Given state, emit etc/network/interfaces content.'''

        # handle 'lo' specifically as we need to insert the global dns entries
        # there (as that is the only interface that will be always up).
        lo = {'name': 'lo', 'type': 'physical', 'inet': 'inet',
              'subnets': [{'type': 'loopback', 'control': 'auto'}]}
        for iface in network_state.iter_interfaces():
            if iface.get('name') == "lo":
                lo = copy.deepcopy(iface)

        nameservers = network_state.dns_nameservers
        if nameservers:
            lo['subnets'][0]["dns_nameservers"] = (" ".join(nameservers))

        searchdomains = network_state.dns_searchdomains
        if searchdomains:
            lo['subnets'][0]["dns_search"] = (" ".join(searchdomains))

        # Apply a sort order to ensure that we write out the physical
        # interfaces first; this is critical for bonding
        order = {
            'loopback': 0,
            'physical': 1,
            'infiniband': 2,
            'bond': 3,
            'bridge': 4,
            'vlan': 5,
        }

        sections = []
        sections.extend(self._render_iface(lo))
        for iface in sorted(network_state.iter_interfaces(),
                            key=lambda k: (order[k['type']], k['name'])):

            if iface.get('name') == "lo":
                continue
            sections.extend(
                self._render_iface(iface, render_hwaddress=render_hwaddress))

        for route in network_state.iter_routes():
            sections.append(self._render_route(route))

        return '\n\n'.join(['\n'.join(s) for s in sections]) + "\n"

    def render_network_state(self, network_state, templates=None, target=None):
        fpeni = subp.target_path(target, self.eni_path)
        util.ensure_dir(os.path.dirname(fpeni))
        header = self.eni_header if self.eni_header else ""
        util.write_file(fpeni, header + self._render_interfaces(network_state))

        if self.netrules_path:
            netrules = subp.target_path(target, self.netrules_path)
            util.ensure_dir(os.path.dirname(netrules))
            util.write_file(netrules,
                            self._render_persistent_net(network_state))


def network_state_to_eni(network_state, header=None, render_hwaddress=False):
    # render the provided network state, return a string of equivalent eni
    eni_path = 'etc/network/interfaces'
    renderer = Renderer(config={
        'eni_path': eni_path,
        'eni_header': header,
        'netrules_path': None,
    })
    if not header:
        header = ""
    if not header.endswith("\n"):
        header += "\n"
    contents = renderer._render_interfaces(
        network_state, render_hwaddress=render_hwaddress)
    return header + contents


def available(target=None):
    expected = ['ifquery', 'ifup', 'ifdown']
    search = ['/sbin', '/usr/sbin']
    for p in expected:
        if not subp.which(p, search=search, target=target):
            return False
    eni = subp.target_path(target, 'etc/network/interfaces')
    if not os.path.isfile(eni):
        return False

    return True


# vi: ts=4 expandtab