Implement ssh connection handling as a state machine

The event loop (even after it was brought into one place in _run in the
previous commit) was hard to follow. The states and transitions weren't
clear or documented, and the privilege escalation code was non-blocking
while the rest was blocking.

Now we have a state machine with four states: awaiting_prompt,
awaiting_escalation, ready_to_send (initial data), and awaiting_exit.
The actions in each state and the transitions between then are clearly
documented.

The check_incorrect_password() method no longer checks for empty strings
(since they will always match), and check_become_success() uses equality
rather than a substring match to avoid thinking an echoed command is an
indication of successful escalation. Also adds a check_missing_password
connection method to detect the error from sudo -n/doas -n.
pull/12276/merge
Abhijit Menon-Sen 9 years ago committed by James Cammarata
parent 840a32bc08
commit ac98fe9e89

@ -172,6 +172,7 @@ DEFAULT_ASK_SUDO_PASS = get_config(p, DEFAULTS, 'ask_sudo_pass', 'ANSIBLE
# Become # Become
BECOME_ERROR_STRINGS = {'sudo': 'Sorry, try again.', 'su': 'Authentication failure', 'pbrun': '', 'pfexec': '', 'runas': '', 'doas': 'Permission denied'} #FIXME: deal with i18n BECOME_ERROR_STRINGS = {'sudo': 'Sorry, try again.', 'su': 'Authentication failure', 'pbrun': '', 'pfexec': '', 'runas': '', 'doas': 'Permission denied'} #FIXME: deal with i18n
BECOME_MISSING_STRINGS = {'sudo': 'sorry, a password is required to run sudo', 'su': '', 'pbrun': '', 'pfexec': '', 'runas': '', 'doas': 'Authorization required'} #FIXME: deal with i18n
BECOME_METHODS = ['sudo','su','pbrun','pfexec','runas','doas'] BECOME_METHODS = ['sudo','su','pbrun','pfexec','runas','doas']
BECOME_ALLOW_SAME_USER = get_config(p, 'privilege_escalation', 'become_allow_same_user', 'ANSIBLE_BECOME_ALLOW_SAME_USER', False, boolean=True) BECOME_ALLOW_SAME_USER = get_config(p, 'privilege_escalation', 'become_allow_same_user', 'ANSIBLE_BECOME_ALLOW_SAME_USER', False, boolean=True)
DEFAULT_BECOME_METHOD = get_config(p, 'privilege_escalation', 'become_method', 'ANSIBLE_BECOME_METHOD','sudo' if DEFAULT_SUDO else 'su' if DEFAULT_SU else 'sudo' ).lower() DEFAULT_BECOME_METHOD = get_config(p, 'privilege_escalation', 'become_method', 'ANSIBLE_BECOME_METHOD','sudo' if DEFAULT_SUDO else 'su' if DEFAULT_SU else 'sudo' ).lower()

@ -171,6 +171,9 @@ class PlayContext(Base):
self.password = passwords.get('conn_pass','') self.password = passwords.get('conn_pass','')
self.become_pass = passwords.get('become_pass','') self.become_pass = passwords.get('become_pass','')
self.prompt = ''
self.success_key = ''
# a file descriptor to be used during locking operations # a file descriptor to be used during locking operations
self.connection_lockfd = connection_lockfd self.connection_lockfd = connection_lockfd

@ -144,20 +144,23 @@ class ConnectionBase(with_metaclass(ABCMeta, object)):
pass pass
def check_become_success(self, output): def check_become_success(self, output):
return self._play_context.success_key in output return self._play_context.success_key == output.rstrip()
def check_password_prompt(self, output): def check_password_prompt(self, output):
if self._play_context.prompt is None: if self._play_context.prompt is None:
return False return False
elif isinstance(self._play_context.prompt, basestring): elif isinstance(self._play_context.prompt, basestring):
return output.endswith(self._play_context.prompt) return output.startswith(self._play_context.prompt)
else: else:
return self._play_context.prompt(output) return self._play_context.prompt(output)
def check_incorrect_password(self, output): def check_incorrect_password(self, output):
incorrect_password = gettext.dgettext(self._play_context.become_method, C.BECOME_ERROR_STRINGS[self._play_context.become_method]) incorrect_password = gettext.dgettext(self._play_context.become_method, C.BECOME_ERROR_STRINGS[self._play_context.become_method])
if incorrect_password in output: return incorrect_password and incorrect_password in output
raise AnsibleError('Incorrect %s password' % self._play_context.become_method)
def check_missing_password(self, output):
missing_password = gettext.dgettext(self._play_context.become_method, C.BECOME_MISSING_STRINGS[self._play_context.become_method])
return missing_password and missing_password in output
def connection_lock(self): def connection_lock(self):
f = self._play_context.connection_lockfd f = self._play_context.connection_lockfd

@ -1,4 +1,5 @@
# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com> # (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
# Copyright 2015 Abhijit Menon-Sen <ams@2ndQuadrant.com>
# #
# This file is part of Ansible # This file is part of Ansible
# #
@ -80,8 +81,7 @@ class Connection(ConnectionBase):
def _build_command(self, binary, *other_args): def _build_command(self, binary, *other_args):
''' '''
Takes a binary (ssh, scp, sftp) and optional extra arguments and returns Takes a binary (ssh, scp, sftp) and optional extra arguments and returns
a command line as an array that can be passed to subprocess.Popen after a command line as an array that can be passed to subprocess.Popen.
appending any extra commands to it.
''' '''
self._command = [] self._command = []
@ -126,7 +126,10 @@ class Connection(ConnectionBase):
elif binary == 'ssh': elif binary == 'ssh':
self._command += ['-C'] self._command += ['-C']
self._command += ['-vvv'] if self._play_context.verbosity > 3:
self._command += ['-vvv']
else:
self._command += ['-q']
# Next, we add ansible_ssh_args from the inventory if it's set, or # Next, we add ansible_ssh_args from the inventory if it's set, or
# [ssh_connection]ssh_args from ansible.cfg, or the default Control* # [ssh_connection]ssh_args from ansible.cfg, or the default Control*
@ -360,8 +363,9 @@ class Connection(ConnectionBase):
self._display.vvv('SSH: EXEC {0}'.format(' '.join(display_cmd)), host=self.host) self._display.vvv('SSH: EXEC {0}'.format(' '.join(display_cmd)), host=self.host)
# Start the given command. If we don't need to pipeline data, we can try # Start the given command. If we don't need to pipeline data, we can try
# to use a pseudo-tty. If we are pipelining data, or can't create a pty, # to use a pseudo-tty (ssh will have been invoked with -tt). If we are
# we fall back to using plain old pipes. # pipelining data, or can't create a pty, we fall back to using plain
# old pipes.
p = None p = None
if not in_data: if not in_data:
@ -371,131 +375,176 @@ class Connection(ConnectionBase):
p = subprocess.Popen(cmd, stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE) p = subprocess.Popen(cmd, stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdin = os.fdopen(master, 'w', 0) stdin = os.fdopen(master, 'w', 0)
os.close(slave) os.close(slave)
except: except (OSError, IOError):
p = None p = None
if not p: if not p:
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdin = p.stdin stdin = p.stdin
# If we are using SSH password authentication, write the password to the # If we are using SSH password authentication, write the password into
# pipe we opened in _build_command. # the pipe we opened in _build_command.
if self._play_context.password: if self._play_context.password:
os.close(self.sshpass_pipe[0]) os.close(self.sshpass_pipe[0])
os.write(self.sshpass_pipe[1], "{0}\n".format(self._play_context.password)) os.write(self.sshpass_pipe[1], "{0}\n".format(self._play_context.password))
os.close(self.sshpass_pipe[1]) os.close(self.sshpass_pipe[1])
# This section is specific to ssh: ## SSH state machine
# #
# If we have a privilege escalation prompt, we need to look for the # Now we read and accumulate output from the running process until it
# prompt and send the password (but we won't be prompted if sudo has # exits. Depending on the circumstances, we may also need to write an
# NOPASSWD configured), then detect successful escalation (or handle # escalation password and/or pipelined input to the process.
# errors and timeouts).
states = [
no_prompt_out = '' 'awaiting_prompt', 'awaiting_escalation', 'ready_to_send', 'awaiting_exit'
no_prompt_err = '' ]
if self._play_context.prompt: # Are we requesting privilege escalation? Right now, we may be invoked
self._display.debug("Handling privilege escalation password prompt.") # to execute sftp/scp with sudoable=True, but we can request escalation
# only when using ssh. Otherwise we can send initial data straightaway.
fcntl.fcntl(p.stdout, fcntl.F_SETFL, fcntl.fcntl(p.stdout, fcntl.F_GETFL) | os.O_NONBLOCK)
fcntl.fcntl(p.stderr, fcntl.F_SETFL, fcntl.fcntl(p.stderr, fcntl.F_GETFL) | os.O_NONBLOCK) state = states.index('ready_to_send')
if 'ssh' in cmd:
become_output = '' if self._play_context.prompt:
become_errput = '' # We're requesting escalation with a password, so we have to
passprompt = False # wait for a password prompt.
state = states.index('awaiting_prompt')
self._display.debug('Initial state: %s: %s' % (states[state], self._play_context.prompt))
elif self._play_context.become and self._play_context.success_key:
# We're requesting escalation without a password, so we have to
# detect success/failure before sending any initial data.
state = states.index('awaiting_escalation')
self._display.debug('Initial state: %s: %s' % (states[state], self._play_context.success_key))
# We store accumulated stdout and stderr output from the process here,
# but strip any privilege escalation prompt/confirmation lines first.
# Output is accumulated into tmp_*, complete lines are extracted into
# an array, then checked and removed or copied to stdout or stderr. We
# set any flags based on examining the output in self._flags.
while True: stdout = stderr = ''
self._display.debug('Waiting for Privilege Escalation input') tmp_stdout = tmp_stderr = ''
if self.check_become_success(become_output + become_errput): self._flags = dict(
self._display.debug('Succeded!') become_prompt=False, become_success=False,
break become_error=False, become_nopasswd_error=False
elif self.check_password_prompt(become_output) or self.check_password_prompt(become_errput): )
self._display.debug('Password prompt!')
passprompt = True
break
self._display.debug('Read next chunks') timeout = self._play_context.timeout
rfd, wfd, efd = select.select([p.stdout, p.stderr], [], [p.stdout], self._play_context.timeout) rpipes = [p.stdout, p.stderr]
if not rfd: for fd in rpipes:
# timeout. wrap up process communication fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK)
stdout, stderr = p.communicate()
raise AnsibleError('Connection error waiting for privilege escalation password prompt: %s' % become_output)
elif p.stderr in rfd:
chunk = p.stderr.read()
become_errput += chunk
self._display.debug('stderr chunk is: %s' % chunk)
self.check_incorrect_password(become_errput)
elif p.stdout in rfd:
chunk = p.stdout.read()
become_output += chunk
self._display.debug('stdout chunk is: %s' % chunk)
if not chunk:
break
#raise AnsibleError('Connection closed waiting for privilege escalation password prompt: %s ' % become_output)
if passprompt: while True:
self._display.debug("Sending privilege escalation password.") rfd, wfd, efd = select.select(rpipes, [], rpipes, timeout)
stdin.write(self._play_context.become_pass + '\n')
else:
no_prompt_out = become_output
no_prompt_err = become_errput
# Now we're back to common handling for ssh/scp/sftp. If we have any # We pay attention to timeouts only while negotiating a prompt.
# data to write into the connection, we do it now. (But we can't use
# p.communicate because the ControlMaster may have stdout open too.)
fcntl.fcntl(p.stdout, fcntl.F_SETFL, fcntl.fcntl(p.stdout, fcntl.F_GETFL) & ~os.O_NONBLOCK) if not rfd:
fcntl.fcntl(p.stderr, fcntl.F_SETFL, fcntl.fcntl(p.stderr, fcntl.F_GETFL) & ~os.O_NONBLOCK) if state <= states.index('awaiting_escalation'):
self._terminate_process(p)
raise AnsibleError('Timeout (%ds) waiting for privilege escalation prompt: %s' % (timeout, stdout))
if in_data: # Read whatever output is available on stdout and stderr, and stop
try: # listening to the pipe if it's been closed.
stdin.write(in_data)
stdin.close()
except:
raise AnsibleConnectionFailure('SSH Error: data could not be sent to the remote host. Make sure this host can be reached over ssh')
# Now we just loop reading stdout/stderr from the process until it elif p.stdout in rfd:
# terminates. chunk = p.stdout.read()
if chunk == '':
rpipes.remove(p.stdout)
tmp_stdout += chunk
#self._display.debug("stdout chunk (state=%s):\n>>>%s<<<\n" % (state, chunk))
stdout = stderr = '' elif p.stderr in rfd:
rpipes = [p.stdout, p.stderr] chunk = p.stderr.read()
if chunk == '':
rpipes.remove(p.stderr)
tmp_stderr += chunk
#self._display.debug("stderr chunk (state=%s):\n>>>%s<<<\n" % (state, chunk))
# We examine the output line-by-line until we have negotiated any
# privilege escalation prompt and subsequent success/error message.
# Afterwards, we can accumulate output without looking at it.
if state < states.index('ready_to_send'):
if tmp_stdout:
output, unprocessed = self._examine_output('stdout', states[state], tmp_stdout, sudoable)
stdout += output
tmp_stdout = unprocessed
if tmp_stderr:
output, unprocessed = self._examine_output('stderr', states[state], tmp_stderr, sudoable)
stderr += output
tmp_stderr = unprocessed
else:
stdout += tmp_stdout
stderr += tmp_stderr
tmp_stdout = tmp_stderr = ''
while True: # If we see a privilege escalation prompt, we send the password.
rfd, wfd, efd = select.select(rpipes, [], rpipes, 1)
# fail early if the become password is wrong if states[state] == 'awaiting_prompt' and self._flags['become_prompt']:
if self._play_context.become and sudoable: self._display.debug('Sending become_pass in response to prompt')
if self._play_context.become_pass: stdin.write(self._play_context.become_pass + '\n')
self.check_incorrect_password(stdout) self._flags['become_prompt'] = False
elif self.check_password_prompt(stdout): state += 1
# We've requested escalation (with or without a password), now we
# wait for an error message or a successful escalation.
if states[state] == 'awaiting_escalation':
if self._flags['become_success']:
self._display.debug('Escalation succeeded')
self._flags['become_success'] = False
state += 1
elif self._flags['become_error']:
self._display.debug('Escalation failed')
self._terminate_process(p)
self._flags['become_error'] = False
raise AnsibleError('Incorrect %s password' % self._play_context.become_method)
elif self._flags['become_nopasswd_error']:
self._display.debug('Escalation requires password')
self._terminate_process(p)
self._flags['become_nopasswd_error'] = False
raise AnsibleError('Missing %s password' % self._play_context.become_method) raise AnsibleError('Missing %s password' % self._play_context.become_method)
elif self._flags['become_prompt']:
if p.stderr in rfd: # This shouldn't happen, because we should see the "Sorry,
dat = os.read(p.stderr.fileno(), 9000) # try again" message first.
stderr += dat self._display.debug('Escalation prompt repeated')
if dat == '': self._terminate_process(p)
rpipes.remove(p.stderr) self._flags['become_prompt'] = False
elif p.stdout in rfd: raise AnsibleError('Incorrect %s password' % self._play_context.become_method)
dat = os.read(p.stdout.fileno(), 9000)
stdout += dat # Once we're sure that the privilege escalation prompt, if any, has
if dat == '': # been dealt with, we can send any initial data and start waiting
rpipes.remove(p.stdout) # for output. (Note that we have to close the process's stdin here,
# otherwise, for example, "sftp -b -" will just hang forever waiting
# for more commands.)
if states[state] == 'ready_to_send':
if in_data:
self._display.debug('Sending initial data (%d bytes)' % len(in_data))
try:
stdin.write(in_data)
stdin.close()
except (OSError, IOError):
raise AnsibleConnectionFailure('SSH Error: data could not be sent to the remote host. Make sure this host can be reached over ssh')
state += 1
# Now we just wait for the process to exit. Output is already being
# accumulated above, so we don't need to do anything special here.
status = p.poll()
# only break out if no pipes are left to read or # only break out if no pipes are left to read or
# the pipes are completely read and # the pipes are completely read and
# the process is terminated # the process is terminated
if (not rpipes or not rfd) and p.poll() is not None: if (not rpipes or not rfd) and status is not None:
break break
# No pipes are left to read but process is not yet terminated # No pipes are left to read but process is not yet terminated
# Only then it is safe to wait for the process to be finished # Only then it is safe to wait for the process to be finished
# NOTE: Actually p.poll() is always None here if rpipes is empty # NOTE: Actually p.poll() is always None here if rpipes is empty
elif not rpipes and p.poll() == None: elif not rpipes and status == None:
p.wait() p.wait()
# The process is terminated. Since no pipes to read from are # The process is terminated. Since no pipes to read from are
# left, there is no need to call select() again. # left, there is no need to call select() again.
@ -505,23 +554,75 @@ class Connection(ConnectionBase):
# completely (see also issue #848) # completely (see also issue #848)
stdin.close() stdin.close()
controlpersisterror = 'Bad configuration option: ControlPersist' in stderr or 'unknown configuration option: ControlPersist' in stderr
if C.HOST_KEY_CHECKING: if C.HOST_KEY_CHECKING:
if cmd[0] == "sshpass" and p.returncode == 6: if cmd[0] == "sshpass" and p.returncode == 6:
raise AnsibleError('Using a SSH password instead of a key is not possible because Host Key checking is enabled and sshpass does not support this. Please add this host\'s fingerprint to your known_hosts file to manage this host.') raise AnsibleError('Using a SSH password instead of a key is not possible because Host Key checking is enabled and sshpass does not support this. Please add this host\'s fingerprint to your known_hosts file to manage this host.')
controlpersisterror = 'Bad configuration option: ControlPersist' in stderr or 'unknown configuration option: ControlPersist' in stderr
if p.returncode != 0 and controlpersisterror: if p.returncode != 0 and controlpersisterror:
raise AnsibleError('using -c ssh on certain older ssh versions may not support ControlPersist, set ANSIBLE_SSH_ARGS="" (or ssh_args in [ssh_connection] section of the config file) before running again') raise AnsibleError('using -c ssh on certain older ssh versions may not support ControlPersist, set ANSIBLE_SSH_ARGS="" (or ssh_args in [ssh_connection] section of the config file) before running again')
# FIXME: module name isn't in runner
#if p.returncode == 255 and (in_data or self.runner.module_name == 'raw'):
if p.returncode == 255 and in_data: if p.returncode == 255 and in_data:
raise AnsibleConnectionFailure('SSH Error: data could not be sent to the remote host. Make sure this host can be reached over ssh') raise AnsibleConnectionFailure('SSH Error: data could not be sent to the remote host. Make sure this host can be reached over ssh')
return (p.returncode, no_prompt_out+stdout, no_prompt_err+stderr) return (p.returncode, stdout, stderr)
# This is a separate method because we need to do the same thing for stdout
# and stderr.
def _examine_output(self, source, state, chunk, sudoable):
'''
Takes a string, extracts complete lines from it, tests to see if they
are a prompt, error message, etc., and sets appropriate flags in self.
Prompt and success lines are removed.
Returns the processed (i.e. possibly-edited) output and the unprocessed
remainder (to be processed with the next chunk) as strings.
'''
output = []
for l in chunk.splitlines(True):
suppress_output = False
# self._display.debug("Examining line (source=%s, state=%s): '%s'" % (source, state, l.rstrip('\r\n')))
if self._play_context.prompt and self.check_password_prompt(l):
self._display.debug("become_prompt: (source=%s, state=%s): '%s'" % (source, state, l.rstrip('\r\n')))
self._flags['become_prompt'] = True
suppress_output = True
elif self._play_context.success_key and self.check_become_success(l):
self._display.debug("become_success: (source=%s, state=%s): '%s'" % (source, state, l.rstrip('\r\n')))
self._flags['become_success'] = True
suppress_output = True
elif sudoable and self.check_incorrect_password(l):
self._display.debug("become_error: (source=%s, state=%s): '%s'" % (source, state, l.rstrip('\r\n')))
self._flags['become_error'] = True
elif sudoable and self.check_missing_password(l):
self._display.debug("become_nopasswd_error: (source=%s, state=%s): '%s'" % (source, state, l.rstrip('\r\n')))
self._flags['become_nopasswd_error'] = True
if not suppress_output:
output.append(l)
# The chunk we read was most likely a series of complete lines, but just
# in case the last line was incomplete (and not a prompt, which we would
# have removed from the output), we retain it to be processed with the
# next chunk.
remainder = ''
if output and not output[-1].endswith('\n'):
remainder = output[-1]
output = output[:-1]
return ''.join(output), remainder
# Utility functions # Utility functions
def _terminate_process(self, p):
try:
p.terminate()
except (OSError, IOError):
pass
def _split_args(self, argstring): def _split_args(self, argstring):
""" """
Takes a string like '-o Foo=1 -o Bar="foo bar"' and returns a Takes a string like '-o Foo=1 -o Bar="foo bar"' and returns a

Loading…
Cancel
Save