#!/usr/bin/python3
# -*- coding: UTF-8 -*-
"""
Perform set up for the installed bonding package.
Always download a new copy of the node configuration from the management server.
"""
# © 2019, Multapplied Networks, Inc.
import argparse
import configparser
import fileinput
import grp
import json
import logging
import os
import platform
import pwd
import stat
import subprocess
import sys
from pyroute2 import IPRoute
from pyroute2.arp import ARPHRD_ETHER

try:
    import debconf
    have_debconf = True
except ImportError:
    # Use prompt_toolkit on non-Debian
    from prompt_toolkit.shortcuts import input_dialog, button_dialog, message_dialog, yes_no_dialog
    have_debconf = False

try:
    # This will fail on Debian which doesn't have python-systemd installed
    # However, we use StreamHandler if running in a TTY and that output
    # ends up in /var/log/apt/term.log
    from systemd.journal import JournalHandler as LogHandler
except ImportError:
    from logging.handlers import SysLogHandler as LogHandler

# SETUP CONSTANTS
BONDING_CONF_FILE = "/etc/bonding/bonding.conf"
ENABLED_SERVICES = [
    'bonding',
    'config',
    'firewall',
    'journalreporter',
    'node',
    'subprocess',
    'salt-minion',
    'bonding-openvpn',
    'bonding-openvpn-manager.path',
    'bonding-uacctd',
    'bonding-uacctd-manager.path',
    'bonding-collectd',
    'bonding-collectd-manager.path',
    'bonding-bird',
]
DISABLED_SERVICES = [
    'bird',
    'pwan-router',
    'pwan-aggregator',
    'collectd',
    'bonding-bird-manager.path',
]
FIREWALL_DEFAULT_FILE = '/usr/share/bonding/defaults/firewall.d/50_wan_in'
FIREWALL_FILE = '/etc/firewall.d/50_wan_in'
GLOBAL_ENVIRONMENT_FILE = '/etc/environment'
IPROUTE2_TABLE_FILE = '/etc/iproute2/rt_tables.d/bonding.conf'
IPROUTE2_BONDING_TABLES = '''\
2000 bonding
2001 bonding-tunnel-routes
2002 wireguard-routes
5000 tunnel-bypass
8192 bonding-pwan
'''
IPROUTE2_PROTO_FILE = '/etc/iproute2/rt_protos.d/bonding.conf'
IPROUTE2_BONDING_PROTO = '80 bonding'
INTERFACES_DEFAULT_FILE = "/usr/share/bonding/defaults/network/interfaces"
INTERFACES_FILE = "/etc/network/interfaces"
INTERFACES_FILE_BACKUP = INTERFACES_FILE + '.backup'
TROUBLESHOOTING_INTERFACE_IP = "10.207.35.254"
JOURNAL_DIR = '/var/log/journal'
JOURNAL_GROUP = 'systemd-journal'
JOURNAL_ACL = 'g:adm:rx,d:g:adm:rx'
JOURNALD_CONF_FILE = '/etc/systemd/journald.conf'
NETWORK_CONFIGURED_FILE = '/var/lib/bonding/network-configured'
NODE_CONFIG_FILE = "/var/lib/bonding/configuration.json"
NODE_CONFIG_FILE_BACKUP = NODE_CONFIG_FILE + '.backup'
NODECONFIG_BAD_SSL = 2
NODECONFIG_INVALID_NODEKEY = 3
SSHD_CONFIG_FILE = "/etc/ssh/sshd_config"
if os.path.exists("/etc/frr/daemons"):
    QUAGGA_ZEBRA_CONF_FILE = '/etc/frr/zebra.conf'
    QUAGGA_DAEMONS_CONF_FILE = '/etc/frr/daemons'
    QUAGGA_USER = "frr"
    QUAGGA_GROUP = "frr"
else:
    QUAGGA_ZEBRA_CONF_FILE = '/etc/quagga/zebra.conf'
    QUAGGA_DAEMONS_CONF_FILE = '/etc/quagga/daemons'
    QUAGGA_USER = "quagga"
    QUAGGA_GROUP = "quagga"
# 5.4 required on aggregators to get around bugs with NAT + VRF and sockets
# bound to all addresses and also bound to VRF devices
MIN_SUPPORTED_AGGREGATOR_KERNEL = (5, 4)

# SETUP OPTIONS
DEBUG = 'debug'
IGNORE_SSL = 'ignore_ssl'
DEFAULT_BONDER = 'default_bonder'
MANAGEMENT_SERVER = 'management_server'
NODE_KEY = 'node_key'
SKIP_RESTART = 'skip_restart'
SETUP_OPTIONS = [
    DEBUG,
    DEFAULT_BONDER,
    IGNORE_SSL,
    MANAGEMENT_SERVER,
    NODE_KEY,
    SKIP_RESTART
]
# INTERACTIVE KEY WORDS
RETRY = 'retry'

KERNEL_PARAM_FILE = "/proc/cmdline"

def get_os_release():
    "Return a dict of values from /etc/os-release"
    d = {}
    with open("/etc/os-release") as f:
        for line in f.readlines():
            if "=" in line:
                key, val = line.split("=", 1)
                d[key] = val.replace('"', '').rstrip('\n')
    return d


OS_RELEASE = get_os_release()


class BondingSetupException(Exception):
    pass


class SSLException(BondingSetupException):
    pass


class InvalidNodeKeyException(BondingSetupException):
    pass


class InvalidBonderConfig(BondingSetupException):
    pass


class UI:
    def __init__(self):
        self.interactive = sys.stdout.isatty()

    def _prompt_for_option(self, option_name):
        """If possible, ask the user to provide the given option"""
        if self.interactive:
            pretty_option_name = option_name.replace('_', ' ')
            text = 'Enter %s:' % pretty_option_name
            return input_dialog(
                title='Input Needed',
                text=text,
            )

    def _prompt_to_retry(self, text):
        """
        If possible, prompt the user to retry or quit.
        """
        if self.interactive:
            return button_dialog(
                title='Action Needed',
                text=text,
                buttons=[
                    ('Retry', True),
                    ('Quit', False),
                ],
            )

    def get_server(self):
        return self._prompt_for_option(MANAGEMENT_SERVER)

    def get_key(self):
        return self._prompt_for_option(NODE_KEY)

    def get_skipkey(self):
        return False

    def get_default_bonder_confirmation(self):
        """
        If possible, ask the user to confirm they want to configure the node as a default bonder.
        """
        if self.interactive:
            return button_dialog(
                title='No Node Key Provided',
                text="""You didn't provide a key. This will configure the device as a bonder with a generic base configuration.\
\nYou can access a web server to submit the key via a DHCP address assigned to a \
computer from the bonder's eth0 interface, or via a DHCP address assigned to \
the bonder on any other interface.""",
                buttons=[
                    ('Okay', DEFAULT_BONDER),
                    ('Enter Key', RETRY),
                    ('Quit', None),
                ],
            )

    def get_retry(self, error):
        text = """There was a problem with the bonding setup: %s\
            \nTake action to resolve the error and then select retry, or select quit to cancel the installation.\
            \nQUITTING THE INSTALL NOW WILL LEAVE THE NODE IN AN INCONSISTENT STATE. IT MAY FAIL TO START OR RESTART PROPERLY.""" % error
        return self._prompt_to_retry(text)

    def get_invalid_key_retry(self, server, key):
        text = """Invalid node key provided: %s\
            \nSelect retry to enter a new node key, or select quit to cancel the installation.\
            \nQUITTING THE INSTALL NOW WILL LEAVE THE NODE IN AN INCONSISTENT STATE. IT MAY FAIL TO START OR RESTART PROPERLY.""" % key
        return self._prompt_to_retry(text)

    def get_ssl_error_action(self):
        """
        If possible, ask the user if SSL errors may be ignored.
        """
        if self.interactive:
            return button_dialog(
                title='Error Validating SSL Certificate',
                text='Unable to verify management server SSL certificate.',
                buttons=[
                    ('Ignore', IGNORE_SSL),
                    ('Retry', RETRY),
                    ('Quit', None),
                ],
            )

    def get_restart(self):
        if self.interactive:
            return yes_no_dialog(
                title="Bonding Setup Successful",
                text="Do you want to restart bonding now?"
            )


class DebconfUI:
    def __init__(self):
        if 'DEBIAN_HAS_FRONTEND' not in os.environ:
            os.execv(debconf._frontEndProgram, [debconf._frontEndProgram] + sys.argv)
        self.db = debconf.Debconf()
        self.interactive = os.environ.get('DEBIAN_FRONTEND', None) != 'noninteractive'

    def _get_value(self, template):
        "Get value for template from debconf"
        if self.interactive:
            self.db.reset(template)
            self.db.input(debconf.CRITICAL, template)
            self.db.go()
            return self.db.get(template)

    def get_server(self):
        return self._get_value('bonding/server')

    def get_key(self):
        return self._get_value('bonding/key')

    def get_skipkey(self):
        return self.db.get('bonding/skipkey') == 'true'

    def get_default_bonder_confirmation(self):
        value = self._get_value('bonding/emptykey')
        if value == 'Return to key dialog':
            return RETRY
        elif value == 'Continue':
            return DEFAULT_BONDER

    def get_retry(self, error):
        self.db.subst("bonding/retry", "error", error)
        value = self._get_value('bonding/retry')
        if value == 'Retry':
            return True
        elif value == 'Quit':
            return False

    def get_invalid_key_retry(self, mgmt_server, node_key):
        error_message = "The node key %s is not valid on %s." % (node_key, mgmt_server)
        return self.get_retry(error_message)

    def get_ssl_error_action(self):
        value = self._get_value('bonding/sslerroraction')
        if value == 'Ignore error':
            return IGNORE_SSL
        elif value == 'Retry':
            return RETRY
        else:
            return None

    def get_restart(self):
        return self._get_value('bonding/restart') == 'true'


class BondingSetup(object):
    def __init__(self, ui):
        self.ui = ui
        self.logger = None
        self.options = {}
        self.default_bonder_confirmed = False
        self.is_container = False

    def run(self):
        """Do system setup for bonding"""
        try:
            subprocess.check_call(["/usr/bin/systemd-detect-virt", "--container"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
            # Not running as a container
            self.is_container = False
        else:
            # Running as a container
            self.is_container = True

        self.evaluate_options()

        self.configure_logger()
        self.logger.info('Running bonding-setup')
        self.set_up_bonding_group_and_user()
        write_bonding_iproute_files()
        self.set_up_services()
        self.update_node_configuration()
        self.set_ownership_and_permissions()

        # Configure SSH and Journal
        try:
            configure_sshd()
        except BondingSetupException as e:
            self.logger.warning("Failed to configure sshd: %s", e)
        try:
            configure_journal()
        except BondingSetupException as e:
            self.logger.warning("Failed to configure journal: %s", e)

        if OS_RELEASE["ID"] == "debian" and OS_RELEASE["VERSION_ID"] == "10":
            try:
                downgrade_default_netfilter_suite()
            except OSError:
                pass

        node_config = read_node_configuration()

        # Troubleshooting IP is only on Debian at the moment
        if OS_RELEASE["ID"] == "debian":
            troubleshooting_interface = self.get_existing_troubleshooting_interface()
            # Only rewrite interfaces file if networking has not been configured
            if not os.path.isfile(NETWORK_CONFIGURED_FILE):
                if not troubleshooting_interface:
                    troubleshooting_interface = self.get_troubleshooting_interface()

                    if troubleshooting_interface:
                        if self.default_bonder_confirmed or (node_config is not None and node_config['type'] == 'bonder'):
                            self.write_bonder_etc_network_interfaces(troubleshooting_interface)
                    else:
                        self.logger.warning("Unable to detect an interface to assign a troubleshooting IP to, skipping configuration of /etc/network/interfaces")
                try:
                    with open(NETWORK_CONFIGURED_FILE, 'w'):
                        pass
                except EnvironmentError as e:
                    self.logger.error('Failed to touch file %s: %s', NETWORK_CONFIGURED_FILE, e)

            if not os.path.isfile(FIREWALL_FILE):
                if not troubleshooting_interface:
                    troubleshooting_interface = self.get_troubleshooting_interface()
                if troubleshooting_interface:
                    # If node is not a bonder, the interface that would be chosen
                    # as a troubleshooting interface will be assumed to be the main one
                    self.write_node_firewall_script(troubleshooting_interface)
                else:
                    self.logger.warning("Unable to detect an interface to assign a troubleshooting IP to, skipping configuration of firewall")

        self.update_grub()

        if node_config is not None and node_config.get('type') == 'aggregator':
            try:
                write_aggregator_modules_load_file()
            except OSError as e:
                self.logger.error("Failed to configure required kernel modules, fix the following error and re-run bonding-setup: %s", e)
            try:
                major, minor = map(int, platform.release().split('.')[:2])
            except ValueError:
                # platform.release probably couldn't detect the kernel release
                print(
                    "The current running kernel may not support features "
                    "required by this version of bonding. Ensure this node is "
                    "running at least kernel version %(major)s.%(minor)s to "
                    "avoid running into issues." % {
                        "major": MIN_SUPPORTED_AGGREGATOR_KERNEL[0],
                        "minor": MIN_SUPPORTED_AGGREGATOR_KERNEL[1],
                    }
                )
            else:
                if (major, minor) < MIN_SUPPORTED_AGGREGATOR_KERNEL:
                    print(
                        "The current running kernel does not support features "
                        "required by this version of bonding. Ensure this node is "
                        "running at least kernel version %(major)s.%(minor)s to "
                        "avoid running into issues." % {
                            "major": MIN_SUPPORTED_AGGREGATOR_KERNEL[0],
                            "minor": MIN_SUPPORTED_AGGREGATOR_KERNEL[1],
                        }
                    )
        else:
            remove_aggregator_modules_load_file()

        if not self.options[SKIP_RESTART]:
            # Ensure kernel modules are loaded
            try:
                if not self.is_container:
                    systemctl('restart', 'systemd-modules-load')
            except BondingSetupException as e:
                print(\
                    "Some kernel modules required for bonding failed to load: {}\n"\
                    "A reboot is required.".format(e)
                )
            else:
                if self.ui.get_restart():
                    try:
                        systemctl('restart', 'bonding')
                    except BondingSetupException as e:
                        self.logger.warning("Failed to restart bonding service: %s", e)
                else:
                    print("Remember to restart bonding with this command:\tsystemctl restart bonding")

    def evaluate_options(self):
        """
        Consolidate script arguments and environment variables into one set of setup options.
        Arguments take precedence over environment variables.
        """
        cli_args = get_setup_option_arguments()
        env_vars = get_setup_option_environment_variables()
        for opt in SETUP_OPTIONS:
            self.options[opt] = cli_args.get(opt, None) if cli_args.get(opt, None) else env_vars.get(opt, None)

    def configure_logger(self):
        logging_level = logging.DEBUG if self.options[DEBUG] else logging.INFO
        self.logger = logging.getLogger('bonding-setup')
        if sys.stdout.isatty():
            self.logger.addHandler(logging.StreamHandler())
        self.logger.addHandler(LogHandler())
        self.logger.setLevel(logging_level)

    def quit(self):
        sys.exit(1)

    def set_up_bonding_group_and_user(self):
        try:
            set_up_bonding_group()
        except BondingSetupException as e:
            self.logger.error("Something went wrong creating the bonding group: %s", e)
            self.quit()
        try:
            set_up_bonding_user()
        except BondingSetupException as e:
            self.logger.error("Something went wrong creating the bonding user: %s", e)
            self.quit()

    def get_server(self):
        # Get a server from the user (which may not be none) or quit
        server = self.ui.get_server()
        return server

    def get_key(self):
        # Get a node key from the user (which may be none) or quit
        self.default_bonder_confirmed = self.options[DEFAULT_BONDER]
        key = None
        while not key and not self.default_bonder_confirmed and not self.ui.get_skipkey():
            key = self.ui.get_key()

            if key is not None:
                key = key.strip()

            if not key:
                reply = self.ui.get_default_bonder_confirmation()
                if reply is DEFAULT_BONDER:
                    self.logger.warning("No node key was found configured in %s and none was provided; node will be a default bonder.", BONDING_CONF_FILE)
                    self.default_bonder_confirmed = True
                elif reply == RETRY:
                    # Loop to enter a new key
                    pass
                else:
                    self.logger.error("No management server was found configured in %s and none was provided; quitting.", BONDING_CONF_FILE)
                    self.quit()
        return key

    def get_ssl_ignore(self):
        # Return a new ssl ignore value or quit
        self.logger.info("Unable to verify management server SSL.")
        ignore_ssl = self.options[IGNORE_SSL]
        if not ignore_ssl:
            reply = self.ui.get_ssl_error_action()
            if reply == IGNORE_SSL:
                return True
            elif reply == RETRY:
                return False
            else:
                self.logger.error("Error downloading node configuration: certificate verify failed.")
                self.quit()
        else:
            return True

    def get_retry(self, error):
        # Get confirmation to continue (retry) or quit
        retry = self.ui.get_retry(error)
        if not retry:
            self.logger.error("Error with node setup: %s", error)
            self.quit()

    def get_invalid_key_retry(self, server, key):
        # Get confirmation to continue (retry) or quit
        retry = self.ui.get_invalid_key_retry(server, key)
        if not retry:
            self.logger.error("Error downloading node configuration: Invalid node key provided: %s" % key)
            self.quit()

    def set_up_bonding_configuration(self):
        # First, get any existing configuration
        server, key = read_bonding_configuration()
        self.logger.debug("Read server '%s' and key '%s' from bonding.conf", server, key)
        # Second, check for different values provided via options
        if self.options[MANAGEMENT_SERVER] and self.options[MANAGEMENT_SERVER] != server:
            server = self.options[MANAGEMENT_SERVER]
            self.logger.debug("Got server '%s' from setup options.", server)
        if self.options[NODE_KEY] and self.options[NODE_KEY] != key:
            key = self.options[NODE_KEY]
            self.logger.debug("Got server '%s' from setup options.", server)
        # Third, attempt to ask for any missing configuration
        if not server:
            server = self.get_server()
        if not key:
            key = self.get_key()
        # Write the new bonding configuration
        write_bonding_configuration(server, key)
        self.logger.debug("Wrote server '%s' and key '%s' to bonding.conf", server, key)
        return (server, key)

    def update_node_configuration(self):
        # Back up any existing node configuration
        backup_node_configuration_created = False
        if os.path.isfile(NODE_CONFIG_FILE):
            os.rename(NODE_CONFIG_FILE, NODE_CONFIG_FILE_BACKUP)
            backup_node_configuration_created = True
        valid_config_downloaded = False
        ignore_ssl = False
        # Get an initial configuration
        server, key = self.set_up_bonding_configuration()
        new_bonding_configuration_required = False
        # Loop until a valid node configuration is downloaded
        while not valid_config_downloaded:
            if new_bonding_configuration_required:
                server = self.get_server()
                key = self.get_key()
                write_bonding_configuration(server, key)
                self.logger.debug("Wrote server '%s' and key '%s' to bonding.conf", server, key)
                new_bonding_configuration_required = False
            # We have a key, so run nodeconfig
            if key:
                try:
                    run_nodeconfig(ignore=ignore_ssl)
                except SSLException:
                    ignore_ssl = self.get_ssl_ignore()
                    continue
                except InvalidNodeKeyException as e:
                    self.get_invalid_key_retry(server, key)
                    new_bonding_configuration_required = True
                    continue
                except BondingSetupException as e:
                    self.get_retry(e)
                    continue
                else:
                    config = read_node_configuration()
                # Node configuration was downloaded, so validate it
                try:
                    validate_node_config(config)
                except InvalidBonderConfig as e:
                    # The configuration is poison, so make sure to clean it up
                    if backup_node_configuration_created:
                        os.rename(NODE_CONFIG_FILE_BACKUP, NODE_CONFIG_FILE)
                    else:
                        os.remove(NODE_CONFIG_FILE)
                    self.get_retry(e)
                else:
                    self.logger.info("Downloaded new node configuration.")
                    valid_config_downloaded = True
            # We don't have a key, so run the default bonder salt minion
            else:
                try:
                    download_default_bonder_configuration()
                except BondingSetupException as e:
                    self.logger.warning("Error downloading default bonder configuration: %s", e)
                else:
                    self.logger.info("Downloaded new node configuration.")
                    valid_config_downloaded = True

    def disable_deprecated_supervise_services(self):
        for service in ('config', 'node', 'subprocess'):
            service_link = '%s/bonding.%s' % ('/etc/service', service)
            if os.path.exists(service_link):
                try:
                    run_subprocess(['/usr/sbin/update-service', service, 'stop'])
                except (BondingSetupException, FileNotFoundError) as e:
                    self.logger.warning("Failed to stop deprecated service %s: %s" % (service, e))
                try:
                    run_subprocess(['/usr/sbin/update-service', '--remove', os.path.join('/var/lib/bonding/services', service), 'bonding.' + service])
                except (BondingSetupException, FileNotFoundError) as e:
                    self.logger.warning("Failed to remove deprecated service %s: %s" % (service, e))
                try:
                    os.unlink(service_link)
                except EnvironmentError as e:
                    self.logger.warning("Failed to remove deprecated service link %s: %s" % (service_link, e))

    def set_ownership_and_permissions(self):
        try:
            uid = get_bonding_user_id()
            gid = get_bonding_group_id()
        except BondingSetupException as e:
            # This really shouldn't happen at this point since we just created this user/group pair, so raise the exception
            self.logger.warning("Failed to get bonding user or group id: %s", e)
            raise e
        paths = [
            '/var/run/bonding',
            '/var/lib/bonding',
            '/var/lib/bonding/qos',
        ]
        for p in paths:
            try:
                os.chown(p, uid, gid)
            except EnvironmentError as e:
                self.logger.warning("Failed to set ownerships of %s: %s", p, e)
            try:
                os.chmod(p, 0o0755)
            except EnvironmentError as e:
                self.logger.warning("Failed to set permissions of %s: %s", p, e)
        try:
            os.chown(BONDING_CONF_FILE, -1, gid) # owner should remain root
        except EnvironmentError as e:
            self.logger.warning("Failed to set group of %s: %s", BONDING_CONF_FILE, e)


    def enable_zebra_in_quagga(self):
        """Enable zebra in Quagga."""
        enabling_zebra = False
        # Read each line of the daemons file, stdout writes the line to the file
        # because of the inplace flag.
        for line in fileinput.input(QUAGGA_DAEMONS_CONF_FILE, inplace=1):
            # Check for the line we are interested in
            if line == 'zebra=no\n':
                sys.stdout.write('zebra=yes\n')
                enabling_zebra = True
            else:
                sys.stdout.write(line)

        if enabling_zebra and not os.path.exists(QUAGGA_ZEBRA_CONF_FILE):
            open(QUAGGA_ZEBRA_CONF_FILE, 'w').close()  # Touch the zebra.conf file
            # Quagga likes to own it too...
            try:
                quagga_uid = pwd.getpwnam(QUAGGA_USER).pw_uid
                quagga_gid = grp.getgrnam(QUAGGA_GROUP).gr_gid
                os.chown(QUAGGA_ZEBRA_CONF_FILE, quagga_uid, quagga_gid)
            except KeyError:
                self.logger.warning("Failed to chown zebra config file: unable to retrieve quagga user or group ID.")
            except EnvironmentError as e:
                self.logger.warning("Failed to chown zebra config file: %s" % e)

        # Set the VTYSH_PAGER to more, even though it should default to it.
        try:
            with open(GLOBAL_ENVIRONMENT_FILE, 'a+') as env_file:
                if 'VTYSH_PAGER' not in env_file.read():
                    env_file.write('VTYSH_PAGER=more\n')
        except EnvironmentError as e:
            self.logger.warning("Failed to add VTYSH_PAGER to global environment file: %s" % e)

    def set_up_services(self):
        """
        Configure and enable services for bonding.
        """
        # Ensure systemd temp files exist
        try:
            setup_systemd_tmpfiles()
        except BondingSetupException as e:
            self.logger.warning("Failed to create systemd tmpfiles: %s", e)
        # Ensure desired services are enabled
        if os.path.isfile(QUAGGA_DAEMONS_CONF_FILE):
            self.enable_zebra_in_quagga()
        for service in ENABLED_SERVICES:
            try:
                systemctl('enable', service)
            except BondingSetupException as e:
                self.logger.warning('Failed to enable %s: %s', service, e)
        # Ensure undesired services are disabled
        for service in DISABLED_SERVICES:
            try:
                systemctl('disable', service)
            except BondingSetupException as e:
                # Only log expected failures in debug
                self.logger.debug('Failed to disable %s: %s', service, e)
        self.disable_deprecated_supervise_services()

    def mac_to_int(self, mac):
        return int(''.join(mac.split(':')), 16)

    def get_existing_troubleshooting_interface(self):
        if os.path.isfile(INTERFACES_FILE):
            with open(INTERFACES_FILE, "r") as f:
                lines = f.readlines()
            for idx, line in enumerate(lines):
                if "address %s" % TROUBLESHOOTING_INTERFACE_IP in line:
                    for line_idx in range(idx-1, -1, -1):
                        if lines[line_idx].startswith("iface"):
                            interface = lines[line_idx].split()[1]
                            if os.path.exists("/sys/class/net/%s" % interface):
                                # The troubleshooting IP is configured and the interface exists
                                return interface
                            break
                    break
        return None

    def get_troubleshooting_interface(self):
        """ Get the interface with the lowest MAC address
            unless legacy option has been set.
        """
        # Debian jessie uses legacy interface naming schema
        if OS_RELEASE["VERSION_ID"] == "8" and OS_RELEASE["ID"] == "debian":
            with open(KERNEL_PARAM_FILE, "r") as f:
                if "net.ifnames=1" not in f.read():
                    return "eth0" # Highly unlikely but Debian 8 can have predictable naming schema using net.ifnames=1

        # Check for legacy interface naming in Debian 9 or newer
        with open(KERNEL_PARAM_FILE, "r") as f:
            if "net.ifnames=0" in f.read():
                return "eth0"

        ip = IPRoute()
        links = ip.get_links()

        lowest = "FF:FF:FF:FF:FF:FF"
        interface = None

        accepted_types = [
            None, # Ethernet interfaces default to None
            'veth',
        ]

        for link in links:
            if link['ifi_type'] == ARPHRD_ETHER:
                info = link.get_attr('IFLA_LINKINFO')
                if info:
                    kind = info.get_attr('IFLA_INFO_KIND')
                    if kind not in accepted_types:
                        continue
                mac = link.get_attr('IFLA_ADDRESS')
                if mac and self.mac_to_int(mac) != 0 and self.mac_to_int(lowest) > self.mac_to_int(mac):
                    lowest = mac
                    interface = link.get_attr('IFLA_IFNAME')

        return interface

    def update_grub(self):
        """Ensure any specific kernel command line entries we want configured are there"""
        if self.is_container:
            return
        updated = False
        grub_cfg = ""
        try:
            with open("/etc/default/grub", "r") as f:
                for line in f.readlines():
                    if line.startswith("GRUB_CMDLINE_LINUX_DEFAULT="):
                        args = line.split("=", 1)[1].replace('"', '').strip()
                        if "mitigations" not in args:
                            args += " mitigations=off"
                            updated = True
                        grub_cfg += 'GRUB_CMDLINE_LINUX_DEFAULT="%s"\n' % args.strip()
                    else:
                        grub_cfg += line
        except OSError as e:
            self.logger.warning("Failed to parse /etc/default/grub: %s", e)
            return

        if updated:
            try:
                with open("/etc/default/grub", "w") as f:
                    f.write(grub_cfg)
            except OSError as e:
                self.logger.warning("Failed to update /etc/default/grub: %s", e)
                return
            try:
                run_subprocess(["/usr/sbin/update-grub"])
            except (BondingSetupException, FileNotFoundError) as e:
                self.logger.warning("Failed to update grub: %s", e)
                return
            self.logger.info("boot options have been adjusted, a reboot is required.")

    def write_bonder_etc_network_interfaces(self, interface):
        """Use default setup, replacing eth0 with given interface"""
        try:
            # Interfaces file
            with open(INTERFACES_DEFAULT_FILE, 'r') as f:
                text = f.read()
            with open(INTERFACES_FILE, 'w') as f:
                f.write(text.replace("eth0", interface))
        except EnvironmentError as e:
            self.logger.warning("Failed to update troubleshoot interface in %s: %s." % (INTERFACES_FILE, e.strerror))

    def write_node_firewall_script(self, interface):
        try:
            # Firewall file
            with open(FIREWALL_DEFAULT_FILE, 'r', encoding='UTF-8') as f:
                text = f.read()
            with open(FIREWALL_FILE, 'w', encoding='UTF-8') as f:
                f.write(text.replace("eth0", interface))
            st = os.stat(FIREWALL_FILE)
            os.chmod(FIREWALL_FILE, st.st_mode | stat.S_IEXEC | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
        except EnvironmentError as e:
            self.logger.warning("Failed to update troubleshoot interface in %s: %s." % (FIREWALL_FILE, e.strerror))


def get_setup_option_arguments():
    """
    Arguments are expected to be the option keyword, lower case, with double-dash prefixes,
    e.g. the option 'setup_option' can be set via the argument '--setup_option'.
    """
    parser = argparse.ArgumentParser()
    parser.add_argument('--' + DEBUG.replace("_", "-"), dest=DEBUG, action='store_true', default=False)
    parser.add_argument('--' + DEFAULT_BONDER.replace("_", "-"), dest=DEFAULT_BONDER, action='store_true', default=False)
    parser.add_argument('--' + IGNORE_SSL.replace("_", "-"), dest=IGNORE_SSL, action='store_true', default=False)
    parser.add_argument('--' + MANAGEMENT_SERVER.replace("_", "-"), dest=MANAGEMENT_SERVER, type=str, default=None)
    parser.add_argument('--' + NODE_KEY.replace("_", "-"), dest=NODE_KEY, type=str, default=None)
    parser.add_argument('--' + SKIP_RESTART.replace("_", "-"), dest=SKIP_RESTART, action="store_true", default=False)
    args, unknown = parser.parse_known_args()

    setup_options = vars(args)

    return setup_options


def get_setup_option_environment_variables():
    """
    Environment variables are expected to be the option keyword, upper case, with 'BONDING_' prefixes,
    e.g. the option 'setup_option' can be set via the environment variable 'BONDING_SETUP_OPTION'.
    """
    options = {}
    options[DEBUG] = os.environ.get('BONDING_' + DEBUG.upper(), False)
    options[DEFAULT_BONDER] = os.environ.get('BONDING_' + DEFAULT_BONDER.upper(), False)
    options[IGNORE_SSL] = os.environ.get('BONDING_' + IGNORE_SSL.upper(), False)
    options[MANAGEMENT_SERVER] = os.environ.get('BONDING_' + MANAGEMENT_SERVER.upper(), None)
    options[NODE_KEY] = os.environ.get('BONDING_' + NODE_KEY.upper(), None)
    options[SKIP_RESTART] = os.environ.get('BONDING_' + SKIP_RESTART.upper(), None)
    return options


def run_subprocess(args):
    try:
        subprocess.check_output(
            args=args,
            stderr=subprocess.STDOUT,
        )
    except subprocess.CalledProcessError as e:
        raise BondingSetupException(e.output.decode().replace('\n', ' '))


def systemctl(action, service):
    try:
        subprocess.check_output(
            ['/bin/systemctl', action, service],
            stderr=subprocess.STDOUT,
        )
    except subprocess.CalledProcessError as e:
        raise BondingSetupException(e.output.decode().replace('\n', ' '))


def get_bonding_group_id():
    try:
        return grp.getgrnam('bonding').gr_gid
    except KeyError:
        raise BondingSetupException("No group named 'bonding' found.")


def get_bonding_user_id():
    try:
        return pwd.getpwnam('bonding').pw_uid
    except KeyError:
        raise BondingSetupException("No user named 'bonding' found.")


def set_up_bonding_group():
    """Check if the bonding group exists, and if not then create it."""
    try:
        get_bonding_group_id()
    except BondingSetupException:
        run_subprocess(['/usr/sbin/groupadd', '--system', 'bonding'])


def set_up_bonding_user():
    """Check if the bonding user exists, and if not then create it."""
    try:
        get_bonding_user_id()
    except BondingSetupException:
        args = [
                "/usr/sbin/useradd",
                "--gid",
                "bonding",
                "--shell",
                "/bin/bash",
                "--system",
                "--base-dir",
                "/var/lib/",
                "--create-home",
                "bonding",
        ]
        run_subprocess(args)


def read_bonding_configuration():
    """
    Returns a 2-tuple containing the server and node key values
    found in bonding.conf -- each value may be none.
    """
    server = None
    key = None
    config = configparser.ConfigParser()
    if os.path.isfile(BONDING_CONF_FILE):
        config.read(BONDING_CONF_FILE)
        if config.has_section("bonding"):
            if config.has_option("bonding", "server"):
                server = config.get("bonding", "server")
            if config.has_option("bonding", "key"):
                key = config.get("bonding", "key")
    return (server, key)


def write_bonding_configuration(server, key):
    """Write the given server and key to bonding.conf"""
    if not server:
        server = ''
    if not key:
        key = ''
    config = configparser.ConfigParser()
    config.add_section("bonding")
    config.set("bonding", "server", server)
    config.set("bonding", "key", key)
    with open(BONDING_CONF_FILE, 'w') as f:
        config.write(f)


def read_node_configuration():
    """Parse any found node configuration as JSON and return it as a dictionary"""
    node_config = None
    if os.path.isfile(NODE_CONFIG_FILE):
        try:
            with open(NODE_CONFIG_FILE) as conf:
                node_config = json.loads(conf.read())
        except (ValueError, OSError):
            pass
    return node_config


def run_nodeconfig(server=None, key=None, ignore=False):
    """Download node configuration"""
    args = [
        '/usr/sbin/nodeconfig',
        '--no-suggestions',
    ]
    if server:
        args.append('--server')
        args.append(server)
    if key:
        args.append('--key')
        args.append(key)
    if ignore:
        args.append('--no-check-certificate')
    try:
        subprocess.check_output(args, stderr=subprocess.STDOUT)
    except subprocess.CalledProcessError as e:
        if e.returncode == NODECONFIG_BAD_SSL:
            raise SSLException()
        elif e.returncode == NODECONFIG_INVALID_NODEKEY:
            raise InvalidNodeKeyException()
        else:
            error_message = e.output.decode().replace('\n', ' ')
            error_message = error_message if error_message else 'An unexpected error occurred, check your network connectivity.'
            raise BondingSetupException(error_message)


def validate_node_config(config):
    """
    Bonders must have at least one address scheme or one mobile broadband leg.
    """
    try:
        node_type = config["type"]
    except (TypeError, KeyError):
        raise InvalidBonderConfig("Node configuration is incomplete (missing type).")
    if node_type == "bonder":
        has_addressings = (
            len(config['bond']['static_addressings']) > 0
            or len(config['bond']['auto_ipv6_addressings']) > 0
            or len(config['bond']['pppoe_addressings']) > 0
            or len(config['bond']['dhcp_addressings']) > 0
        )
        if not has_addressings and len(config['bond']['mobilebroadband_legs']) == 0:
            raise InvalidBonderConfig("Bonders must have at least one interface leg with an address scheme, or one mobile broadband leg.")


def download_default_bonder_configuration():
    run_subprocess(['/usr/sbin/default-salt-minion', '-n', '-q'])


def setup_systemd_tmpfiles():
    run_subprocess(['/bin/systemd-tmpfiles', '--create'])


def configure_sshd():
    """Set options in /etc/ssh/sshd_config"""
    try:
        before_size = os.path.getsize(SSHD_CONFIG_FILE)
        with open(SSHD_CONFIG_FILE, "r") as f:
            lines = f.readlines()

        write_lines = []
        use_dns_disabled = False
        permit_root_login_enabled = False

        for line in lines:
            if line.startswith("UseDNS "):
                write_lines.append("UseDNS no\n")
                use_dns_disabled = True
                continue
            elif line.startswith("PermitRootLogin "):
                write_lines.append("PermitRootLogin yes\n")
                permit_root_login_enabled = True
                continue

            write_lines.append(line)

        if not use_dns_disabled:
            write_lines.append("UseDNS no\n")
        if not permit_root_login_enabled:
            write_lines.append("PermitRootLogin yes\n")

        with open(SSHD_CONFIG_FILE, "w") as f:
            f.writelines(write_lines)

        after_size = os.path.getsize(SSHD_CONFIG_FILE)
        if before_size != after_size:
            systemctl("restart", "sshd")
    except EnvironmentError as e:
        raise BondingSetupException(e)


def configure_journal():
    # Set configuration
    conf_updated = False
    with open(JOURNALD_CONF_FILE, 'r') as f:
        conf = f.read()
        if '\nForwardToSyslog=yes' in conf:
            conf.replace('\nForwardToSyslog=yes', '\nForwardToSyslog=no')
            conf_updated = True
        elif '\nForwardToSyslog=no' not in conf:
            conf += '\nForwardToSyslog=no\n'
            conf_updated = True
    if conf_updated:
        with open(JOURNALD_CONF_FILE, 'w') as f:
            f.write(conf)
            systemctl('restart', 'systemd-journald.service')
    # Set ACL
    if not os.path.isdir(JOURNAL_DIR):
        os.mkdir(JOURNAL_DIR)
    journal_gid = grp.getgrnam(JOURNAL_GROUP).gr_gid
    os.chown(JOURNAL_DIR, -1, journal_gid)
    os.chmod(JOURNAL_DIR, 0o2755)
    run_subprocess(['/usr/bin/setfacl', '-R', '-nm', JOURNAL_ACL, JOURNAL_DIR])


def write_aggregator_modules_load_file():
    with open('/etc/modules-load.d/bonding-aggregator.conf', 'w') as f:
        f.write('wireguard\n')


def remove_aggregator_modules_load_file():
    try:
        os.remove('/etc/modules-load.d/bonding-aggregator.conf')
    except OSError:
        pass


def write_bonding_iproute_files():
    """Add protocol and table definitions for bonding.
    """
    if not os.path.isdir(os.path.dirname(IPROUTE2_TABLE_FILE)):
        os.makedirs(os.path.dirname(IPROUTE2_TABLE_FILE))
    with open(IPROUTE2_TABLE_FILE, 'w') as f:
        f.write(IPROUTE2_BONDING_TABLES)
    if not os.path.isdir(os.path.dirname(IPROUTE2_PROTO_FILE)):
        os.makedirs(os.path.dirname(IPROUTE2_PROTO_FILE))
    with open(IPROUTE2_PROTO_FILE, 'w') as f:
        f.write(IPROUTE2_BONDING_PROTO)


def downgrade_default_netfilter_suite():
    for command in ("iptables", "ip6tables", "arptables", "ebtables"):
        try:
            subprocess.check_output(
                args=["/usr/bin/update-alternatives", "--set", command, "/usr/sbin/%s-legacy" % command],
                stderr=subprocess.STDOUT,
            )
        except subprocess.CalledProcessError as e:
            raise BondingSetupException(e.output.decode().replace('\n', ' '))


if __name__ == "__main__":
    if have_debconf:
        ui = DebconfUI()
    else:
        ui = UI()
    bs = BondingSetup(ui)
    bs.run()
