311 lines
11 KiB
Python
311 lines
11 KiB
Python
|
# 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 '
|
||
|
r'\(yes/no(?:/\[fingerprint\])?\)\?'
|
||
|
),
|
||
|
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
|
||
|
|
||
|
# strings that, if escaped, cause problems creating connections
|
||
|
# example: `source /opt/rh/rh-python36/enable && python`
|
||
|
# is an acceptable ansible_python_version but shlex would quote the &&
|
||
|
# and prevent python from executing
|
||
|
SHLEX_IGNORE = [
|
||
|
"&&"
|
||
|
]
|
||
|
|
||
|
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()
|
||
|
|
||
|
base_parts = []
|
||
|
for s in base:
|
||
|
val = s if s in self.SHLEX_IGNORE else shlex_quote(s).strip()
|
||
|
base_parts.append(val)
|
||
|
return bits + base_parts
|