#!/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 glob
import grp
import json
import logging
import os
import pwd
import ssl
import subprocess
import sys
import textwrap
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',
    'journalreporter',
    'node',
    'subprocess',
    'salt-minion',
    'bonding-nftables',
    'bonding-openvpn',
    'bonding-openvpn-manager.path',
    'bonding-collectd',
    'bonding-collectd-manager.path',
    'bonding-bird',
    'bonding-troubleshooting-address',
    'bonding-nodessl-daily.timer',
    'bonding-nodeconfig-daily.timer',
]
DISABLED_SERVICES = [
    'bird',
    'pwan-router',
    'pwan-aggregator',
    'collectd',
    'bonding-bird-manager.path',
    'bonding-uacctd',
    'bonding-uacctd-manager.path',
    'firewalld',
]
START_SERVICES = [
    'bonding-nodessl-daily.timer',
    'bonding-nodeconfig-daily.timer',
]
OLD_FILES_TO_REMOVE = [
    '/etc/cron.daily/bonding_nodeconfig',
    '/etc/modules-load.d/bonding.conf',
]
NFTABLES_TROUBLESHOOTING_DEFAULT_FILE = '/usr/share/bonding/defaults/nftables/filter-input-90-troubleshooting.nft'
NFTABLES_TROUBLESHOOTING_FILE = '/etc/bonding/nftables/filter-input-90-troubleshooting.nft'
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'
HOSTS_FILE = '/etc/hosts'
INTERFACES_FILE = "/etc/network/interfaces"
INTERFACES_FILE_BACKUP = INTERFACES_FILE + '.backup'
TROUBLESHOOTING_INTERFACE_CONFIG_FILE = "/etc/bonding/troubleshooting-address.conf"
TROUBLESHOOTING_INTERFACE_IP = "10.207.35.254"
TROUBLESHOOTING_INTERFACE_PREFIX_LENGTH = "29"
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'
NODE_CONFIG_FILE = "/var/lib/bonding/configuration.json"
NODE_CONFIG_FILE_BACKUP = NODE_CONFIG_FILE + '.backup'
NODECONFIG_BAD_SSL = 2
NODECONFIG_INVALID_NODEKEY = 3
if os.path.exists("/etc/ssh/sshd_config.d"):
    SSHD_CONFIG_FILE = "/etc/ssh/sshd_config.d/bonding"
else:
    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)
PPP_UP_LOCAL_SCRIPT = "/etc/ppp/ip-up.local"
PPP_DOWN_LOCAL_SCRIPT = "/etc/ppp/ip-down.local"

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

NETWORKMANAGER_BONDING_CONFIG_PATH = "/etc/NetworkManager/conf.d/bonding.conf"
SELINUX_CONFIG_FILE = "/etc/sysconfig/selinux"

if os.path.isdir("/usr/lib/systemd/system"):
    # Non-Debian
    UNIT_DIR = "/usr/lib/systemd/system"
else:
    # Debian
    UNIT_DIR = "/lib/systemd/system"

if os.path.isdir("/etc/unbound/unbound.conf.d"):
    UNBOUND_CONF_D_PATH = "/etc/unbound/unbound.conf.d"
else:
    UNBOUND_CONF_D_PATH = "/etc/unbound/conf.d"

UNBOUND_BONDING_CONFIG_PATH = UNBOUND_CONF_D_PATH + '/bonding.conf'
UNBOUND_BONDING_CONFIG_CONTENT = 'include: "/var/run/bonding/unbound-*.conf"'
UNBOUND_CONTROL_CONFIG_PATH = UNBOUND_CONF_D_PATH + '/control.conf'
UNBOUND_CONTROL_CONFIG_CONTENT = """
remote-control:
\tcontrol-enable: yes
\tcontrol-interface: 127.0.0.1
\tcontrol-port: 8953
\tserver-key-file: "/etc/unbound/unbound_server.key"
\tserver-cert-file: "/etc/unbound/unbound_server.pem"
\tcontrol-key-file: "/etc/unbound/unbound_control.key"
\tcontrol-cert-file: "/etc/unbound/unbound_control.pem"
"""
UNBOUND_SERVER_CERTIFICATE_PATH = "/etc/unbound/unbound_server.pem"
UNBOUND_SERVER_KEY_PATH = "/etc/unbound/unbound_server.key"
UNBOUND_CONTROL_CERTIFICATE_PATH = "/etc/unbound/unbound_control.pem"
UNBOUND_CONTROL_KEY_PATH = "/etc/unbound/unbound_control.key"
UNBOUND_SYSTEMD_CONFIG_PATH = "/etc/systemd/system/unbound.service.d"
UNBOUND_ANCHOR_ROOT_KEY = "/var/lib/unbound/root.key"
UNBOUND_ANCHOR_BACKUP_ROOT_KEY = "/usr/share/dns/root.key"

APPARMOR_UNBOUND_CONF = "/etc/apparmor.d/local/usr.sbin.unbound"
APPARMOR_UNBOUND_BONDING_INCLUDE = "#include /etc/bonding/apparmor/usr.sbin.unbound"

APPARMOR_DNSMASQ_CONF = "/etc/apparmor.d/local/usr.sbin.dnsmasq"
APPARMOR_DNSMASQ_BONDING_INCLUDE = "#include /etc/bonding/apparmor/usr.sbin.dnsmasq"

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 InvalidLegDefinedConfig(BondingSetupException):
    pass

class RequiredCommandNotFound(BondingSetupException):
    pass


class Platform:
    @classmethod
    def get_bin_path(cls, binary):
        "Get the real path to the given binary. Returns None if it is not found"
        for directory in ("/bin", "/sbin", "/usr/bin", "/usr/sbin"):
            path = "%s/%s" % (directory, binary)
            if os.path.exists(path):
                return path
        return None

    @classmethod
    def get_grub2_conf_path(cls):
        "Get the GRUB2 configuration path. Returns None if it is not found"
        for path in ("/boot/grub/grub.cfg", "/boot/grub2/grub.cfg"):
            if os.path.exists(path):
                return path
        return None

    @classmethod
    def get_default_kernel_image(cls):
        """Return a path to the default kernel image that will be loaded on
        the next boot"""
        grubby_path = cls.get_bin_path("grubby")
        if grubby_path:
            # RHEL
            try:
                stdout = subprocess.check_output([grubby_path, "--default-kernel"])
            except subprocess.CalledProcessError:
                # Probably no kernel
                return None
            return stdout.decode("utf8").strip()

        # Attempt to parse grub.cfg and grab the first linux menu entry. This
        # will be the default kernel that will run on the next boot for Debian
        # and SUSE
        grub_conf_path = cls.get_grub2_conf_path()

        if not grub_conf_path:
            # Not a GRUB system or perhaps a container. Ignore
            return None
        with open(grub_conf_path, "r") as f:
            reading_menuentry = False
            for line in f.readlines():
                line = line.strip()
                if line.startswith("menuentry") and line.endswith("{"):
                    reading_menuentry = True
                    continue
                if reading_menuentry:
                    if line.startswith("linux"):
                        image = line.split()[1]
                        if not os.path.exists(image):
                            # /boot on separate partition?
                            image = "/boot/%s" % image
                            if not os.path.exists(image):
                                # Can't find it
                                continue
                        break
                    elif line.startswith("}"):
                        reading_menuentry = False
                        continue
            else:
                # Did not find an entry with a kernel
                return None

        if os.path.islink(image):
            target = os.readlink(image)
            if target.startswith("/"):
                image = target
            else:
                image = os.path.join(os.path.dirname(image), target)

        return image

    @classmethod
    def get_installed_kernel_release(cls):
        "Get the installed kernel release"
        default_kernel_image = cls.get_default_kernel_image()
        if not default_kernel_image:
            # Could not figure out the default kernel
            return None

        file_path = cls.get_bin_path("file")
        if not file_path:
            raise RequiredCommandNotFound('Could not find the "file" command. Is it installed?')

        try:
            stdout = subprocess.check_output([file_path, default_kernel_image])
        except subprocess.CalledProcessError:
            # Could not read. Give up
            return None
        stdout = stdout.decode("utf8")
        for version in ("version", "Version"):
            if ", %s " % version in stdout:
                return stdout.split(", %s " % version)[1].split()[0]
        return None

    @classmethod
    def get_running_kernel_release(cls):
        return os.uname().release


class UI:
    def __init__(self, interactive):
        self.interactive = interactive

    def get_wrap_text(self, text):
        try:
            width = os.get_terminal_size().columns
        except OSError:
            width = 80

        # Seems like a reasonable word wrap number
        if width <= 30:
            width = 80

        # we want to preserve defined newlines \n
        horizontal_padding = 8
        wrapped_body = ""
        lines = text.split("\n")

        for line in lines:
            if len(line) > width - horizontal_padding:
                line = "\n".join(textwrap.wrap(line, width - horizontal_padding))
            wrapped_body += line + "\n"

        return wrapped_body

    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,
            ).run()

    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=self.get_wrap_text(text),
                buttons=[
                    ('Retry', True),
                    ('Quit', False),
                ],
            ).run()

    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.
        """
        msg = "You didn't provide a key. This will configure the device as a bonder with a generic base configuration. You 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."

        # return
        if self.interactive:
            return button_dialog(
                title='No Node Key Provided',
                text=self.get_wrap_text(msg),
                buttons=[
                    ('Okay', DEFAULT_BONDER),
                    ('Enter Key', RETRY),
                    ('Quit', None),
                ],
            ).run()

    def get_retry(self, error):
        text = "There was a problem with the bonding setup: \n%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=self.get_wrap_text('Unable to verify management server SSL certificate.'),
                buttons=[
                    ('Ignore', IGNORE_SSL),
                    ('Retry', RETRY),
                    ('Quit', None),
                ],
            ).run()

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

    def get_reboot(self):
        if self.interactive:
            return yes_no_dialog(
                title="Reboot required",
                text=self.get_wrap_text("In order to complete the setup a reboot is required. Do you wish to reboot now?")
                ).run()

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

    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'

    def get_reboot(self):
        return self._get_value('bonding/reboot') == 'true'


class Interface:
    """
    Represents an interface
    """
    VALID_KINDS = (
        "veth",
    )

    def __init__(self, ifname, altnames, hwaddr, kind):
        self.ifname = ifname
        self.altnames = altnames
        self.hwaddr = hwaddr
        self.kind = kind


class BondingSetup(object):
    def __init__(self, ui, options):
        self.ui = ui
        self.options = options
        self.logger = None
        self.default_bonder_confirmed = False
        self.is_container = False
        self.reboot_required = 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.configure_logger()
        self.logger.info('Running bonding-setup')
        self.set_up_bonding_group_and_user()
        write_bonding_iproute_files()
        self.remove_old_files()
        self.configure_selinux()
        self.configure_hosts_file()
        self.check_for_firewalld()
        self.set_up_ppp()
        self.set_up_services()
        self.check_time_daemon()
        self.update_node_configuration()
        self.configure_services()
        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)

        node_config = read_node_configuration()

        troubleshooting_interface = self.get_legacy_troubleshooting_interface()
        troubleshooting_interface_config = dict()
        troubleshooting_interface_config["enable"] = "false" if troubleshooting_interface else "true"
        troubleshooting_interface_config["interface"] = troubleshooting_interface if troubleshooting_interface else self.get_troubleshooting_interface()
        troubleshooting_interface_config["ip_address"] = f'{TROUBLESHOOTING_INTERFACE_IP}/{TROUBLESHOOTING_INTERFACE_PREFIX_LENGTH}'

        if self.default_bonder_confirmed or (node_config is not None and node_config['type'] == 'bonder'):
            interface_config = configparser.ConfigParser()
            interface_config['troubleshooting_interface'] = troubleshooting_interface_config
            with open(TROUBLESHOOTING_INTERFACE_CONFIG_FILE, 'w') as configfile:
                interface_config.write(configfile)
        else:
            self.logger.warning("Unable to detect an interface to assign a troubleshooting IP to, skipping configuration of /etc/network/interfaces")

        if not os.path.isfile(NFTABLES_TROUBLESHOOTING_FILE) and (self.default_bonder_confirmed or (node_config is not None and node_config['type'] == 'bonder')):
            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.get_running_kernel_release().split('.')[:2])
            except ValueError:
                # Platform.get_running_kernel_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()

        self.configure_apparmor()
        self.configure_unbound()

        self.disable_iptables()

        # Ensure SystemD gets service updates in case there were changes
        systemctl('daemon-reload')

        reboot_scheduled = False

        if not self.is_container:
            # Check for running kernel
            desired_release = Platform.get_installed_kernel_release()
            if desired_release and desired_release != Platform.get_running_kernel_release():
                self.reboot_required = True

        if not self.reboot_required:
            # Ensure kernel modules are loaded
            try:
                systemctl('restart', 'systemd-modules-load')
            except BondingSetupException as e:
                print("Some kernel modules required for bonding failed to load: {}".format(e))
                self.reboot_required = True

        if self.reboot_required:
            if not self.options[SKIP_REBOOT] and self.ui.get_reboot():
                try:
                    reboot_scheduled = True
                    systemctl('start', 'bonding-setup-reboot.service', wait=False)
                except BondingSetupException as e:
                    print("Failed to schedule reboot: %s.\nYou must reboot the system to complete the setup." % e)

            if not reboot_scheduled:
                print("A reboot is required to complete the setup.")

        if not self.options[SKIP_RESTART] and not reboot_scheduled:
            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 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)
                    continue
                else:
                    self.logger.info("Downloaded new node configuration.")

                try:
                    validate_node_interfaces(config, self.options[DEBUG])
                except InvalidLegDefinedConfig as e:
                    self.get_retry(e)
                    continue
                else:
                    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 check_deprecated_dhcpc_services(self):
        """
        udhcpc has been deprecated in favor of dhclient as a DHCPv4 client for
        Bonding 6.7.

        Check if udhcpc is running and tell the user that the bond must be
        restarted.
        """
        DEPRECATED_DHCPC_SERVICES = (
            # Old DHCPv4 service pid file
            "/run/bonding/udhcpc*.pid",
            # Old DHCPv6 service pid file
            "/run/bonding/dhclient*.pid",
        )
        for service_glob in DEPRECATED_DHCPC_SERVICES:
            active_dhcpc_services = glob.glob(service_glob)
            if active_dhcpc_services:
                print("NOTE: deprecated DHCP clients are still running. A reboot is required.")
                break

    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 configure_selinux(self):
        "Set mode to permissive"
        # TODO: Make a proper policy:
        # https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/using_selinux/writing-a-custom-selinux-policy_using-selinux
        if os.path.exists(SELINUX_CONFIG_FILE):
            lines = []
            modify = False
            with open(SELINUX_CONFIG_FILE) as f:
                for line in f:
                    if line.startswith("SELINUX=enforcing"):
                        lines.append("SELINUX=permissive\n")
                        modify = True
                    else:
                        lines.append(line)
            if modify:
                with open(SELINUX_CONFIG_FILE, "w") as f:
                    f.writelines(lines)
                self.reboot_required = True

    def configure_services(self):
        """
        Disables services that might get in the way of bonding if they are running.
        """
        config = read_node_configuration()

        if not config:
            # Default bonder does not yet have a configuration
            return

        if config.get('type') == 'bonder':
            self.disable_service("NetworkManager")
            self.disable_service("wicked")
        # Tell it to ignore our interfaces on aggregators
        elif not os.path.exists(NETWORKMANAGER_BONDING_CONFIG_PATH):
            try:
                systemctl("is-enabled", "NetworkManager")
                if not os.path.exists("/etc/NetworkManager/conf.d"):
                    os.makedirs("/etc/NetworkManager/conf.d")
                with open(NETWORKMANAGER_BONDING_CONFIG_PATH, "w") as f:
                    f.write("[main]\n")
                    f.write("dns=none\n")
                    f.write("[keyfile]\n")
                    f.write("unmanaged-devices=interface-name:tun*;interface-name:msh*;interface-name:vrf*;interface-name:vx*;interface-name:pwr*;interface-name:agg*;interface-name:ppp*;interface-name:mppp*\n")
                systemctl("restart", "NetworkManager")
            except BondingSetupException:
                pass

    def disable_service(self, service):
        enabled = True
        try:
            systemctl("is-enabled", service)
        except BondingSetupException:
            enabled = False
        if enabled:
            systemctl('disable', service)
            self.reboot_required = True
        active = True
        try:
            systemctl("is-active", service)
        except BondingSetupException:
            active = False
        if active:
            systemctl('stop', service)

    def check_for_firewalld(self):
        "If firewalld is running, a reboot is required."
        active = True
        try:
            systemctl('is-active', 'firewalld')
        except BondingSetupException:
            active = False

        if active:
            self.reboot_required = True

    def set_up_ppp(self):
        if OS_RELEASE["ID"] == "rhel":
            # RHEL does not support ip-{up,down}.d
            if not os.path.exists(PPP_UP_LOCAL_SCRIPT):
                os.symlink("/usr/lib/bonding/pppup", PPP_UP_LOCAL_SCRIPT)
            if not os.path.exists(PPP_DOWN_LOCAL_SCRIPT):
                os.symlink("/usr/lib/bonding/pppdown", PPP_DOWN_LOCAL_SCRIPT)

    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()
        self.check_deprecated_dhcpc_services()
        # Ensure certain services are started
        for service in START_SERVICES:
            systemctl('start', service)

    def remove_old_files(self):
        for old_file in OLD_FILES_TO_REMOVE:
            if os.path.exists(old_file):
                os.remove(old_file)

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

    def get_legacy_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"

        interfaces = get_real_interfaces()

        lowest = "FF:FF:FF:FF:FF:FF"
        ifname = None

        for interface in interfaces.values():
            if (
                interface.hwaddr and self.mac_to_int(interface.hwaddr) != 0
                and self.mac_to_int(lowest) > self.mac_to_int(interface.hwaddr)
            ):
                lowest = interface.hwaddr
                ifname = interface.ifname

        return ifname

    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_node_firewall_script(self, interface):
        try:
            # nftables troubleshooting interface file
            with open(NFTABLES_TROUBLESHOOTING_DEFAULT_FILE, 'r', encoding='UTF-8') as f:
                text = f.read()
            with open(NFTABLES_TROUBLESHOOTING_FILE, 'w', encoding='UTF-8') as f:
                f.write(text.replace("eth0", interface))
        except EnvironmentError as e:
            self.logger.warning("Failed to update nftables troubleshooting file %s: %s." % (NFTABLES_TROUBLESHOOTING_FILE, e.strerror))

    def configure_hosts_file(self):
        """Ensure that the hosts file exists and has the proper permissions"""
        try:
            if not os.path.isfile(HOSTS_FILE):
                with open(HOSTS_FILE, 'w'):
                    pass
        except EnvironmentError as e:
            self.logger.warning(
                "Could not create hosts file %s: %s" % (
                    HOSTS_FILE,
                    e
                )
            )

        try:
            os.chmod(HOSTS_FILE, 0o644)
        except EnvironmentError as e:
            self.logger.warning(
                "Failed to set permissions of hosts file %s: %s" % (
                    HOSTS_FILE,
                    e
                )
            )

    def configure_unbound(self):
        "Set up the unbound resolver"

        # Handle invalid certificates or keys by removing the files so they can be re-initialized
        try:
            control_socket_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
            if os.path.exists(UNBOUND_SERVER_CERTIFICATE_PATH) and os.path.exists(UNBOUND_SERVER_KEY_PATH):
                control_socket_context.load_cert_chain(UNBOUND_SERVER_CERTIFICATE_PATH, UNBOUND_SERVER_KEY_PATH)
            if os.path.exists(UNBOUND_CONTROL_CERTIFICATE_PATH) and os.path.exists(UNBOUND_CONTROL_KEY_PATH):
                control_socket_context.load_cert_chain(UNBOUND_CONTROL_CERTIFICATE_PATH, UNBOUND_CONTROL_KEY_PATH)
        except ssl.SSLError as e:
            self.logger.error('Failed to load unbound certificate chain: %s\nRe-initializing certificates and keys...' % e)
            for path in (UNBOUND_SERVER_CERTIFICATE_PATH, UNBOUND_SERVER_KEY_PATH, UNBOUND_CONTROL_CERTIFICATE_PATH, UNBOUND_CONTROL_KEY_PATH):
                if os.path.exists(path):
                    os.remove(path)

        try:
            with open(UNBOUND_BONDING_CONFIG_PATH, "w") as f:
                f.write(UNBOUND_BONDING_CONFIG_CONTENT)
        except EnvironmentError as e:
            self.logger.warning(
                "Could not create unbound config %s: %s" % (
                    UNBOUND_BONDING_CONFIG_PATH,
                    e
                )
            )

        try:
            with open(UNBOUND_CONTROL_CONFIG_PATH, "w") as f:
                f.write(UNBOUND_CONTROL_CONFIG_CONTENT)
        except EnvironmentError as e:
            self.logger.warning(
                "Could not create unbound control config %s: %s" % (
                    UNBOUND_CONTROL_CONFIG_PATH,
                    e
                )
            )

        try:
            run_subprocess("unbound-control-setup")
        except BondingSetupException as e:
            self.logger.warning("Could not set up unbound control: %s", e)

        gid = get_bonding_group_id()
        for path in (UNBOUND_CONTROL_CERTIFICATE_PATH, UNBOUND_CONTROL_KEY_PATH):
            try:
                os.chown(path, -1, gid)
            except EnvironmentError as e:
                self.logger.warning("Failed to set ownership of unbound control certificate and key: %s", e)
            try:
                os.chmod(path, 0o640)
            except EnvironmentError as e:
                self.logger.warning("Failed to set permission of unbound control certificate and key: %s", e)

        unbound_needs_restart = False

        # Add unbound to bonding group so that unbound config works properly
        bonding_group = grp.getgrnam('bonding')
        if 'unbound' not in bonding_group.gr_mem:
            run_subprocess(['usermod', '--append', '--groups', 'bonding', 'unbound'])
            unbound_needs_restart = True

        # Override unbound unit file to skip unbound anchor check
        if not os.path.isfile(UNBOUND_SYSTEMD_CONFIG_PATH + '/bonding.conf'):
            with open('%s/unbound.service' % UNIT_DIR, 'r') as f:
                exec_start_pre = []
                for line in f:
                    if line.startswith('ExecStartPre') and 'anchor' not in line:
                        exec_start_pre.append(line)
            if not os.path.isdir(UNBOUND_SYSTEMD_CONFIG_PATH):
                os.mkdir(UNBOUND_SYSTEMD_CONFIG_PATH)
            with open(UNBOUND_SYSTEMD_CONFIG_PATH + '/bonding.conf', 'w') as f:
                f.write('[Service]\nExecStartPre=\n')
                for line in exec_start_pre:
                    f.write(line)
            unbound_needs_restart = True

        if not os.path.isfile(UNBOUND_ANCHOR_ROOT_KEY) and os.path.isfile(UNBOUND_ANCHOR_BACKUP_ROOT_KEY):
            run_subprocess(['/usr/lib/unbound/package-helper', 'root_trust_anchor_update'])

        if unbound_needs_restart:
            systemctl('restart', 'unbound')

    def configure_apparmor(self):
        restart_apparmor = False
        if os.path.isfile(APPARMOR_UNBOUND_CONF):
            with open(APPARMOR_UNBOUND_CONF, "r+") as apparmor_file:
                line_included = False
                for line in apparmor_file:
                    if line == APPARMOR_UNBOUND_BONDING_INCLUDE:
                        line_included = True
                if not line_included:
                    apparmor_file.write("\n%s\n" % APPARMOR_UNBOUND_BONDING_INCLUDE)
                    restart_apparmor = True

        if os.path.isfile(APPARMOR_DNSMASQ_CONF):
            with open(APPARMOR_DNSMASQ_CONF, "r+") as apparmor_file:
                line_included = False
                for line in apparmor_file:
                    if line == APPARMOR_DNSMASQ_BONDING_INCLUDE:
                        line_included = True
                if not line_included:
                    apparmor_file.write("\n%s\n" % APPARMOR_DNSMASQ_BONDING_INCLUDE)
                    restart_apparmor = True

        if restart_apparmor:
            try:
                systemctl('is-enabled', 'apparmor')
            except BondingSetupException:
                return
            systemctl('restart', 'apparmor')

    def disable_iptables(self):
        if OS_RELEASE["ID"] != "debian":
            # We only need to do this on Debian
            return
        try:
            results = subprocess.check_output(["dpkg", "-L", "iptables"]).decode("utf-8").split('\n')
        except subprocess.CalledProcessError:
            # iptables package isn't installed
            return
        for line in results:
            if 'sbin/' in line and os.path.exists(line):
                cmd = line.split('/')[-1]
                run_subprocess(["update-alternatives", "--quiet", "--install", line, cmd, "/usr/sbin/iptables-warning", "1000"])
                run_subprocess(["update-alternatives", "--quiet", "--auto", cmd])

    def check_time_daemon(self):
        if self.is_container:
            return
        if systemctl('is-enabled', 'chrony', ignore_errors=True) == 0:
            return
        if systemctl('is-enabled', 'ntp', ignore_errors=True) == 0:
            return
        if systemctl('enable', 'chrony', ignore_errors=True) == 0:
            return
        if systemctl('enable', 'ntp', ignore_errors=True) == 0:
            return
        raise BondingSetupException('No time daemon is installed')


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)
    parser.add_argument('--' + SKIP_REBOOT.replace("_", "-"), dest=SKIP_REBOOT, action="store_true", default=False)
    parser.add_argument('--' + NON_INTERACTIVE.replace("_", "-"), dest=NON_INTERACTIVE, 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)
    options[SKIP_REBOOT] = os.environ.get('BONDING_' + SKIP_REBOOT.upper(), None)
    options[NON_INTERACTIVE] = os.environ.get('BONDING_' + NON_INTERACTIVE.upper(), None)

    # Account for legacy Debian behaviour
    #
    if options[NON_INTERACTIVE] is None and os.environ.get("DEBIAN_FRONTEND", None) == "noninteractive":
        options[NON_INTERACTIVE] = True

    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=None, wait=True, ignore_errors=False):
    args = [Platform.get_bin_path("systemctl")]
    if not wait:
        args.append("--no-block")
    args.append(action)
    if service:
        args.append(service)
    try:
        subprocess.check_output(args, stderr=subprocess.STDOUT)
        return 0
    except subprocess.CalledProcessError as e:
        if not ignore_errors:
            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 get_real_interfaces():
    """
    Return a list of interfaces present on the system that are usable for
    external communication.

    Returns a dict of Interface objects indexed by ifnmae.
    """
    interfaces = {}

    ip = IPRoute()
    for link in ip.get_links():
        if link["ifi_type"] == ARPHRD_ETHER:
            ifname = link.get_attr("IFLA_IFNAME")
            info = link.get_attr("IFLA_LINKINFO")
            if info:
                kind = info.get_attr("IFLA_INFO_KIND")
                if kind not in Interface.VALID_KINDS:
                    continue
            else:
                kind = None
            altnames = []
            prop_list = link.get_attr("IFLA_PROP_LIST")
            if prop_list:
                for name, value in prop_list["attrs"]:
                    if name == "IFLA_ALT_IFNAME":
                        altnames.append(value)
            hwaddr = link.get_attr("IFLA_ADDRESS")

            interfaces[ifname] = Interface(ifname, altnames, hwaddr, kind)

    return interfaces


def validate_node_interfaces(config, mode):
    logging_level = logging.DEBUG if mode else logging.INFO
    logger = logging.getLogger('bonding-setup')
    logger.setLevel(logging_level)
    if config is not None and config.get('type') == 'bonder':
        # Get all real interfaces available on the host
        host_interfaces = set()
        for interface in get_real_interfaces().values():
            host_interfaces.add(interface.ifname)
            for altname in interface.altnames:
                host_interfaces.add(altname)
        # Figure out which defined interfaces are present and which legs can
        # presumably come up
        present_interfaces = set()

        for interface in config["bonder"]["ethernet_interfaces"]:
            if interface["bridge"]:
                # If the interface is attached to a bridge it cannot be used
                # on its own, but the bridge interface may be used
                present_interfaces.add(interface["bridge"])
            elif interface["ifname"] in host_interfaces:
                present_interfaces.add(interface["id"])

        available_legs = set()

        for leg in config["bond"]["interface_legs"]:
            if leg["interface"] in present_interfaces:
                available_legs.add(leg["id"])

        # Mobile broadband legs may not be immediately present. Assume one
        # will work once plugged in
        for leg in config["bond"]["mobilebroadband_legs"]:
            available_legs.add(leg["id"])

        if not available_legs:
            msg = (
                "No interfaces assigned to legs are present on this device. Interfaces available are %s."
                % (", ".join(host_interfaces))
            )
            raise InvalidLegDefinedConfig(msg)


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


def setup_systemd_tmpfiles():
    run_subprocess([Platform.get_bin_path('systemd-tmpfiles'), '--create'])


def configure_sshd():
    """Set options in /etc/ssh/sshd_config"""
    try:
        if not os.path.exists(SSHD_CONFIG_FILE):
            open(SSHD_CONFIG_FILE, "a+")

        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():
    # Create journal directory and set permissions
    if not os.path.isdir(JOURNAL_DIR):
        os.mkdir(JOURNAL_DIR)
        run_subprocess(['journalctl', '--flush'])
    journal_gid = grp.getgrnam(JOURNAL_GROUP).gr_gid
    os.chown(JOURNAL_DIR, -1, journal_gid)
    os.chmod(JOURNAL_DIR, 0o2755)

    # Set configuration
    if OS_RELEASE["ID"] != "debian":
        # Only Debian overrides the journal with syslog
        return

    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
    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)


if __name__ == "__main__":
    options = {}

    cli_args = get_setup_option_arguments()
    env_vars = get_setup_option_environment_variables()

    # Arguments take precedence over environment variables
    for opt in SETUP_OPTIONS:
        options[opt] = cli_args.get(opt, None) if cli_args.get(opt, None) else env_vars.get(opt, None)

    # Operate non-interactively when not on a terminal
    if (
            # Don't set if we execv'ed to the Debconf frontend
            not os.environ.get("BONDING_DEBCONF_FRONTEND", False)
            and options[NON_INTERACTIVE] is None
            and not sys.stdout.isatty()
    ):
        options[NON_INTERACTIVE] = True

    interactive = not options[NON_INTERACTIVE]

    if have_debconf:
        ui = DebconfUI(interactive)
    else:
        ui = UI(interactive)
    bs = BondingSetup(ui, options)
    bs.run()
