# Copyright 2019, David Wilson
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

# !mitogen: minify_safe

"""
Construct new children via the OpenSSH client.
"""

import logging
import re

try:
    from shlex import quote as shlex_quote
except ImportError:
    from pipes import quote as shlex_quote

import mitogen.parent
from mitogen.core import b

try:
    any
except NameError:
    from mitogen.core import any


LOG = logging.getLogger(__name__)

auth_incorrect_msg = 'SSH authentication is incorrect'
password_incorrect_msg = 'SSH password is incorrect'
password_required_msg = 'SSH password was requested, but none specified'
hostkey_config_msg = (
    'SSH requested permission to accept unknown host key, but '
    'check_host_keys=ignore. This is likely due to ssh_args=  '
    'conflicting with check_host_keys=. Please correct your '
    'configuration.'
)
hostkey_failed_msg = (
    'Host key checking is enabled, and SSH reported an unrecognized or '
    'mismatching host key.'
)

# sshpass uses 'assword' because it doesn't lowercase the input.
PASSWORD_PROMPT_PATTERN = re.compile(
    b('password'),
    re.I
)

HOSTKEY_REQ_PATTERN = re.compile(
    b(r'are you sure you want to continue connecting \(yes/no\)\?'),
    re.I
)

HOSTKEY_FAIL_PATTERN = re.compile(
    b(r'host key verification failed\.'),
    re.I
)

# [user@host: ] permission denied
# issue #271: work around conflict with user shell reporting 'permission
# denied' e.g. during chdir($HOME) by only matching it at the start of the
# line.
PERMDENIED_PATTERN = re.compile(
    b('^(?:[^@]+@[^:]+: )?'  # Absent in OpenSSH <7.5
      'Permission denied'),
    re.I
)

DEBUG_PATTERN = re.compile(b('^debug[123]:'))


class PasswordError(mitogen.core.StreamError):
    pass


class HostKeyError(mitogen.core.StreamError):
    pass


class SetupProtocol(mitogen.parent.RegexProtocol):
    """
    This protocol is attached to stderr of the SSH client. It responds to
    various interactive prompts as required.
    """
    password_sent = False

    def _on_host_key_request(self, line, match):
        if self.stream.conn.options.check_host_keys == 'accept':
            LOG.debug('%s: accepting host key', self.stream.name)
            self.stream.transmit_side.write(b('yes\n'))
            return

        # _host_key_prompt() should never be reached with ignore or enforce
        # mode, SSH should have handled that. User's ssh_args= is conflicting
        # with ours.
        self.stream.conn._fail_connection(HostKeyError(hostkey_config_msg))

    def _on_host_key_failed(self, line, match):
        self.stream.conn._fail_connection(HostKeyError(hostkey_failed_msg))

    def _on_permission_denied(self, line, match):
        if self.stream.conn.options.password is not None and \
                self.password_sent:
            self.stream.conn._fail_connection(
                PasswordError(password_incorrect_msg)
            )
        elif PASSWORD_PROMPT_PATTERN.search(line) and \
                self.stream.conn.options.password is None:
            # Permission denied (password,pubkey)
            self.stream.conn._fail_connection(
                PasswordError(password_required_msg)
            )
        else:
            self.stream.conn._fail_connection(
                PasswordError(auth_incorrect_msg)
            )

    def _on_password_prompt(self, line, match):
        LOG.debug('%s: (password prompt): %s', self.stream.name, line)
        if self.stream.conn.options.password is None:
            self.stream.conn._fail(PasswordError(password_required_msg))

        self.stream.transmit_side.write(
            (self.stream.conn.options.password + '\n').encode('utf-8')
        )
        self.password_sent = True

    def _on_debug_line(self, line, match):
        text = mitogen.core.to_text(line.rstrip())
        LOG.debug('%s: %s', self.stream.name, text)

    PATTERNS = [
        (DEBUG_PATTERN, _on_debug_line),
        (HOSTKEY_FAIL_PATTERN, _on_host_key_failed),
        (PERMDENIED_PATTERN, _on_permission_denied),
    ]

    PARTIAL_PATTERNS = [
        (PASSWORD_PROMPT_PATTERN, _on_password_prompt),
        (HOSTKEY_REQ_PATTERN, _on_host_key_request),
    ]


class Options(mitogen.parent.Options):
    #: Default to whatever is available as 'python' on the remote machine,
    #: overriding sys.executable use.
    python_path = 'python'

    #: Number of -v invocations to pass on command line.
    ssh_debug_level = 0

    #: The path to the SSH binary.
    ssh_path = 'ssh'

    hostname = None
    username = None
    port = None
    identity_file = None
    password = None
    ssh_args = None

    check_host_keys_msg = 'check_host_keys= must be set to accept, enforce or ignore'

    def __init__(self, hostname, username=None, ssh_path=None, port=None,
                 check_host_keys='enforce', password=None, identity_file=None,
                 compression=True, ssh_args=None, keepalive_enabled=True,
                 keepalive_count=3, keepalive_interval=15,
                 identities_only=True, ssh_debug_level=None, **kwargs):
        super(Options, self).__init__(**kwargs)

        if check_host_keys not in ('accept', 'enforce', 'ignore'):
            raise ValueError(self.check_host_keys_msg)

        self.hostname = hostname
        self.username = username
        self.port = port
        self.check_host_keys = check_host_keys
        self.password = password
        self.identity_file = identity_file
        self.identities_only = identities_only
        self.compression = compression
        self.keepalive_enabled = keepalive_enabled
        self.keepalive_count = keepalive_count
        self.keepalive_interval = keepalive_interval
        if ssh_path:
            self.ssh_path = ssh_path
        if ssh_args:
            self.ssh_args = ssh_args
        if ssh_debug_level:
            self.ssh_debug_level = ssh_debug_level


class Connection(mitogen.parent.Connection):
    options_class = Options
    diag_protocol_class = SetupProtocol

    child_is_immediate_subprocess = False

    def _get_name(self):
        s = u'ssh.' + mitogen.core.to_text(self.options.hostname)
        if self.options.port and self.options.port != 22:
            s += u':%s' % (self.options.port,)
        return s

    def _requires_pty(self):
        """
        Return :data:`True` if a PTY to is required for this configuration,
        because it must interactively accept host keys or type a password.
        """
        return (
            self.options.check_host_keys == 'accept' or
            self.options.password is not None
        )

    def create_child(self, **kwargs):
        """
        Avoid PTY use when possible to avoid a scaling limitation.
        """
        if self._requires_pty():
            return mitogen.parent.hybrid_tty_create_child(**kwargs)
        else:
            return mitogen.parent.create_child(stderr_pipe=True, **kwargs)

    def get_boot_command(self):
        bits = [self.options.ssh_path]
        if self.options.ssh_debug_level:
            bits += ['-' + ('v' * min(3, self.options.ssh_debug_level))]
        else:
            # issue #307: suppress any login banner, as it may contain the
            # password prompt, and there is no robust way to tell the
            # difference.
            bits += ['-o', 'LogLevel ERROR']
        if self.options.username:
            bits += ['-l', self.options.username]
        if self.options.port is not None:
            bits += ['-p', str(self.options.port)]
        if self.options.identities_only and (self.options.identity_file or
                                             self.options.password):
            bits += ['-o', 'IdentitiesOnly yes']
        if self.options.identity_file:
            bits += ['-i', self.options.identity_file]
        if self.options.compression:
            bits += ['-o', 'Compression yes']
        if self.options.keepalive_enabled:
            bits += [
                '-o', 'ServerAliveInterval %s' % (
                    self.options.keepalive_interval,
                ),
                '-o', 'ServerAliveCountMax %s' % (
                    self.options.keepalive_count,
                ),
            ]
        if not self._requires_pty():
            bits += ['-o', 'BatchMode yes']
        if self.options.check_host_keys == 'enforce':
            bits += ['-o', 'StrictHostKeyChecking yes']
        if self.options.check_host_keys == 'accept':
            bits += ['-o', 'StrictHostKeyChecking ask']
        elif self.options.check_host_keys == 'ignore':
            bits += [
                '-o', 'StrictHostKeyChecking no',
                '-o', 'UserKnownHostsFile /dev/null',
                '-o', 'GlobalKnownHostsFile /dev/null',
            ]
        if self.options.ssh_args:
            bits += self.options.ssh_args
        bits.append(self.options.hostname)
        base = super(Connection, self).get_boot_command()
        return bits + [shlex_quote(s).strip() for s in base]
