Merge pull request #242 from dw/dmw

su method, CTRL+C exception handler, real host key checking modes
pull/246/head
dw 7 years ago committed by GitHub
commit 4f46d8a1e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -74,6 +74,9 @@ echo --- ansible/inventory/hosts: ----
cat ansible/inventory/hosts
echo ---
# Now we have real host key checking, we need to turn it off. :)
export ANSIBLE_HOST_KEY_CHECKING=False
echo travis_fold:end:job_setup

@ -60,10 +60,15 @@ def _connect_local(spec):
def _connect_ssh(spec):
if C.HOST_KEY_CHECKING:
check_host_keys = 'enforce'
else:
check_host_keys = 'ignore'
return {
'method': 'ssh',
'kwargs': {
'check_host_keys': False, # TODO
'check_host_keys': check_host_keys,
'hostname': spec['remote_addr'],
'username': spec['remote_user'],
'password': spec['password'],
@ -131,6 +136,20 @@ def _connect_setns(spec):
}
def _connect_su(spec):
return {
'method': 'su',
'enable_lru': True,
'kwargs': {
'username': spec['become_user'],
'password': spec['become_pass'],
'python_path': spec['python_path'],
'su_path': spec['become_exe'],
'connect_timeout': spec['timeout'],
}
}
def _connect_sudo(spec):
return {
'method': 'sudo',
@ -146,6 +165,20 @@ def _connect_sudo(spec):
}
def _connect_mitogen_su(spec):
# su as a first-class proxied connection, not a become method.
return {
'method': 'su',
'kwargs': {
'username': spec['remote_user'],
'password': spec['password'],
'python_path': spec['python_path'],
'su_path': spec['become_exe'],
'connect_timeout': spec['timeout'],
}
}
def _connect_mitogen_sudo(spec):
# sudo as a first-class proxied connection, not a become method.
return {
@ -170,7 +203,9 @@ CONNECTION_METHOD = {
'machinectl': _connect_machinectl,
'setns': _connect_setns,
'ssh': _connect_ssh,
'su': _connect_su,
'sudo': _connect_sudo,
'mitogen_su': _connect_mitogen_su,
'mitogen_sudo': _connect_mitogen_sudo,
}
@ -266,8 +301,8 @@ class Connection(ansible.plugins.connection.ConnectionBase):
#: target machine (i.e. via sudo).
context = None
#: Only sudo is supported for now.
become_methods = ['sudo']
#: Only sudo and su are supported for now.
become_methods = ['sudo', 'su']
#: Set to 'ansible_python_interpreter' by on_action_run().
python_path = None

@ -0,0 +1,43 @@
# Copyright 2017, 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.
import os.path
import sys
try:
import ansible_mitogen.connection
except ImportError:
base_dir = os.path.dirname(__file__)
sys.path.insert(0, os.path.abspath(os.path.join(base_dir, '../../..')))
del base_dir
import ansible_mitogen.connection
class Connection(ansible_mitogen.connection.Connection):
transport = 'mitogen_su'

@ -110,11 +110,10 @@ Installation
Noteworthy Differences
----------------------
* Ansible 2.3, 2.4 and 2.5 are supported. File bugs to register interest in
older releases.
* Ansible 2.3, 2.4 and 2.5 are supported on Python 2.7.
* The ``sudo`` become method is available and ``su`` is planned. File bugs to
register interest in additional methods.
* The ``su`` and ``sudo`` become methods are available. File bugs to register
interest in more.
* The `docker <https://docs.ansible.com/ansible/2.5/plugins/connection/docker.html>`_,
`jail <https://docs.ansible.com/ansible/2.5/plugins/connection/jail.html>`_,
@ -123,8 +122,9 @@ Noteworthy Differences
`lxd <https://docs.ansible.com/ansible/2.5/plugins/connection/lxd.html>`_,
and `ssh <https://docs.ansible.com/ansible/2.5/plugins/connection/ssh.html>`_
built-in connection types are supported, along with Mitogen-specific
:ref:`machinectl <machinectl>`, :ref:`mitogen_sudo <sudo>`, and
:ref:`setns <setns>` types. File bugs to register interest in others.
:ref:`machinectl <machinectl>`, :ref:`mitogen_su <su>`, :ref:`mitogen_sudo
<sudo>`, and :ref:`setns <setns>` types. File bugs to register interest in
others.
* Local commands execute in a reuseable interpreter created identically to
interpreters on targets. Presently one interpreter per ``become_user``
@ -558,6 +558,31 @@ process.
as ``/bin/machinectl``.
.. _su:
Su
~~
Su can be used as a connection method that supports connection delegation, or
as a become method.
When used as a become method:
* ``ansible_python_interpreter``
* ``ansible_su_exe``, ``ansible_become_exe``
* ``ansible_su_user``, ``ansible_become_user`` (default: ``root``)
* ``ansible_su_pass``, ``ansible_become_pass`` (default: assume passwordless)
* ``su_flags``, ``become_flags``
* ansible.cfg: ``timeout``
When used as the ``mitogen_su`` connection method:
* The inventory hostname has no special meaning.
* ``ansible_user``: username to su as.
* ``ansible_password``: password to su as.
* ``ansible_python_interpreter``
.. _sudo:
Sudo

@ -780,6 +780,36 @@ Router Class
will be searched if given as a filename. Defaults to
``machinectl``.
.. method:: su (username=None, password=None, su_path=None, password_prompt=None, incorrect_prompts=None, \**kwargs)
Construct a context on the local machine over a ``su`` invocation. The
``su`` process is started in a newly allocated pseudo-terminal, and
supports typing interactive passwords.
Accepts all parameters accepted by :py:meth:`local`, in addition to:
:param str username:
Username to pass to ``su``, defaults to ``root``.
:param str password:
The account password to use if requested.
:param str su_path:
Filename or complete path to the ``su`` binary. ``PATH`` will be
searched if given as a filename. Defaults to ``su``.
:param str password_prompt:
The string to wait to that signals ``su`` is requesting a password.
Defaults to ``Password:``.
:param str password_prompt:
The string that signal a request for the password. Defaults to
``Password:``.
:param str incorrect_prompts:
Strings that signal the password is incorrect. Defaults to `("su:
sorry", "su: authentication failure")`.
:raises mitogen.su.PasswordError:
A password was requested but none was provided, the supplied
password was incorrect, or (on BSD) the target account did not
exist.
.. method:: sudo (username=None, sudo_path=None, password=None, \**kwargs)
Construct a context on the local machine over a ``sudo`` invocation.
@ -812,7 +842,7 @@ Router Class
:py:class:`mitogen.core.StreamError` to be raised, and that
attributes of the stream match the actual behaviour of ``sudo``.
.. method:: ssh (hostname, username=None, ssh_path=None, port=None, check_host_keys=True, password=None, identity_file=None, compression=True, \**kwargs)
.. method:: ssh (hostname, username=None, ssh_path=None, port=None, check_host_keys='enforce', password=None, identity_file=None, compression=True, \**kwargs)
Construct a remote context over a ``ssh`` invocation. The ``ssh``
process is started in a newly allocated pseudo-terminal, and supports
@ -828,10 +858,16 @@ Router Class
:param int port:
Port number to connect to; default is unspecified, which causes SSH
to pick the port number.
:param bool check_host_keys:
If ``False``, arrange for SSH to perform no verification of host
keys. If ``True``, cause SSH to pick the default behaviour, which
is usually to verify host keys.
:param str check_host_keys:
Specifies the SSH host key checking mode:
* ``ignore``: no host key checking is performed. Connections never
fail due to an unknown or changed host key.
* ``accept``: known hosts keys are checked to ensure they match,
new host keys are automatically accepted and verified in future
connections.
* ``enforce``: known host keys are checke to ensure they match,
unknown hosts cause a connection failure.
:param str password:
Password to type if/when ``ssh`` requests it. If not specified and
a password is requested, :py:class:`mitogen.ssh.PasswordError` is

@ -498,6 +498,7 @@ class Importer(object):
'service',
'setns',
'ssh',
'su',
'sudo',
'utils',
]}
@ -1287,7 +1288,7 @@ class Router(object):
if respondent:
assert policy is None
def policy(msg, _stream):
return msg.src_id == respondent.context_id
return msg.is_dead or msg.src_id == respondent.context_id
def on_disconnect():
if handle in self._handle_map:
fn(Message.dead())
@ -1683,6 +1684,8 @@ class ExternalContext(object):
_profile_hook('main', self._dispatch_calls)
_v and LOG.debug('ExternalContext.main() normal exit')
except KeyboardInterrupt:
LOG.debug('KeyboardInterrupt received, exiting gracefully.')
except BaseException:
LOG.exception('ExternalContext.main() crashed')
raise

@ -1031,12 +1031,15 @@ class Router(mitogen.core.Router):
def setns(self, **kwargs):
return self.connect('setns', **kwargs)
def ssh(self, **kwargs):
return self.connect('ssh', **kwargs)
def su(self, **kwargs):
return self.connect('su', **kwargs)
def sudo(self, **kwargs):
return self.connect('sudo', **kwargs)
def ssh(self, **kwargs):
return self.connect('ssh', **kwargs)
class ProcessMonitor(object):
def __init__(self):

@ -45,12 +45,18 @@ LOG = logging.getLogger('mitogen')
PASSWORD_PROMPT = 'password:'
PERMDENIED_PROMPT = 'permission denied'
HOSTKEY_REQ_PROMPT = 'are you sure you want to continue connecting (yes/no)?'
HOSTKEY_FAIL = 'host key verification failed.'
class PasswordError(mitogen.core.StreamError):
pass
class HostKeyError(mitogen.core.StreamError):
pass
class Stream(mitogen.parent.Stream):
create_child = staticmethod(mitogen.parent.hybrid_tty_create_child)
python_path = 'python2.7'
@ -62,16 +68,24 @@ class Stream(mitogen.parent.Stream):
#: The path to the SSH binary.
ssh_path = 'ssh'
hostname = None
username = None
port = None
identity_file = None
password = None
port = None
ssh_args = None
check_host_keys_msg = 'host_keys= must be set to accept, enforce or ignore'
def construct(self, hostname, username=None, ssh_path=None, port=None,
check_host_keys=True, password=None, identity_file=None,
check_host_keys='enforce', password=None, identity_file=None,
compression=True, ssh_args=None, keepalive_enabled=True,
keepalive_count=3, keepalive_interval=15, **kwargs):
super(Stream, self).construct(**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
@ -93,8 +107,6 @@ class Stream(mitogen.parent.Stream):
def get_boot_command(self):
bits = [self.ssh_path]
# bits += ['-o', 'BatchMode yes']
if self.username:
bits += ['-l', self.username]
if self.port is not None:
@ -110,7 +122,11 @@ class Stream(mitogen.parent.Stream):
'-o', 'ServerAliveInterval %s' % (self.keepalive_interval,),
'-o', 'ServerAliveCountMax %s' % (self.keepalive_count,),
]
if not self.check_host_keys:
if self.check_host_keys == 'enforce':
bits += ['-o', 'StrictHostKeyChecking yes']
if self.check_host_keys == 'accept':
bits += ['-o', 'StrictHostKeyChecking ask']
elif self.check_host_keys == 'ignore':
bits += [
'-o', 'StrictHostKeyChecking no',
'-o', 'UserKnownHostsFile /dev/null',
@ -131,6 +147,27 @@ class Stream(mitogen.parent.Stream):
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 = (
'check_host_keys is set to enforce, and SSH reported an unknown '
'or changed host key.'
)
def _host_key_prompt(self):
if self.check_host_keys == 'accept':
LOG.debug('%r: accepting host key', self)
self.tty_stream.transmit_side.write('y\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.
raise HostKeyError(self.hostkey_config_msg)
def _connect_bootstrap(self, extra_fd):
self.tty_stream = mitogen.parent.TtyLogStream(extra_fd, self)
@ -146,6 +183,10 @@ class Stream(mitogen.parent.Stream):
if buf.endswith('EC0\n'):
self._ec0_received()
return
elif HOSTKEY_REQ_PROMPT in buf.lower():
self._host_key_prompt()
elif HOSTKEY_FAIL in buf.lower():
raise HostKeyError(self.hostkey_failed_msg)
elif PERMDENIED_PROMPT in buf.lower():
if self.password is not None and password_sent:
raise PasswordError(self.password_incorrect_msg)
@ -154,7 +195,7 @@ class Stream(mitogen.parent.Stream):
elif PASSWORD_PROMPT in buf.lower():
if self.password is None:
raise PasswordError(self.password_required_msg)
LOG.debug('sending password')
LOG.debug('%r: sending password', self)
self.tty_stream.transmit_side.write(self.password + '\n')
password_sent = True

@ -0,0 +1,113 @@
# Copyright 2017, 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.
import logging
import os
import mitogen.core
import mitogen.parent
LOG = logging.getLogger(__name__)
class PasswordError(mitogen.core.StreamError):
pass
class Stream(mitogen.parent.Stream):
# TODO: BSD su cannot handle stdin being a socketpair, but it does let the
# child inherit fds from the parent. So we can still pass a socketpair in
# for hybrid_tty_create_child(), there just needs to be either a shell
# snippet or bootstrap support for fixing things up afterwards.
create_child = staticmethod(mitogen.parent.tty_create_child)
#: Once connected, points to the corresponding TtyLogStream, allowing it to
#: be disconnected at the same time this stream is being torn down.
username = 'root'
password = None
su_path = 'su'
password_prompt = 'password:'
incorrect_prompts = (
'su: sorry', # BSD
'su: authentication failure', # Linux
)
def construct(self, username=None, password=None, su_path=None,
password_prompt=None, incorrect_prompts=None, **kwargs):
super(Stream, self).construct(**kwargs)
if username is not None:
self.username = username
if password is not None:
self.password = password
if su_path is not None:
self.su_path = su_path
if password_prompt is not None:
self.password_prompt = password_prompt.lower()
if incorrect_prompts is not None:
self.incorrect_prompts = map(str.lower, incorrect_prompts)
def connect(self):
super(Stream, self).connect()
self.name = 'su.' + self.username
def on_disconnect(self, broker):
super(Stream, self).on_disconnect(broker)
def get_boot_command(self):
argv = mitogen.parent.Argv(super(Stream, self).get_boot_command())
return [self.su_path, self.username, '-c', str(argv)]
password_incorrect_msg = 'su password is incorrect'
password_required_msg = 'su password is required'
def _connect_bootstrap(self, extra_fd):
password_sent = False
it = mitogen.parent.iter_read(
fds=[self.receive_side.fd],
deadline=self.connect_deadline,
)
for buf in it:
LOG.debug('%r: received %r', self, buf)
if buf.endswith('EC0\n'):
self._ec0_received()
return
if any(s in buf.lower() for s in self.incorrect_prompts):
if password_sent:
raise PasswordError(self.password_incorrect_msg)
elif self.password_prompt in buf.lower():
if self.password is None:
raise PasswordError(self.password_required_msg)
if password_sent:
raise PasswordError(self.password_incorrect_msg)
LOG.debug('sending password')
self.transmit_side.write(self.password + '\n')
password_sent = True
raise mitogen.core.StreamError('bootstrap failed')

@ -1,4 +1,5 @@
- import_playbook: su_password.yml
- import_playbook: sudo_flags_failure.yml
- import_playbook: sudo_nonexistent.yml
- import_playbook: sudo_nopassword.yml

@ -0,0 +1,58 @@
# Verify passwordful su behaviour
# Ansible can't handle this on OS X. I don't care why.
- name: integration/become/su_password.yml
hosts: test-targets
become_method: su
any_errors_fatal: true
tasks:
- name: Ensure su password absent but required.
shell: whoami
become: true
become_user: mitogen__user1
register: out
ignore_errors: true
when: is_mitogen
- assert:
that:
- out.failed
- (
('password is required' in out.msg) or
('password is required' in out.module_stderr)
)
when: is_mitogen
- name: Ensure password su incorrect.
shell: whoami
become: true
become_user: mitogen__user1
register: out
vars:
ansible_become_pass: nopes
ignore_errors: true
when: is_mitogen
- assert:
that: |
out.failed and (
('Incorrect su password' in out.msg) or
('su password is incorrect' in out.msg)
)
when: is_mitogen
- name: Ensure password su succeeds.
shell: whoami
become: true
become_user: mitogen__user1
register: out
vars:
ansible_become_pass: user1_password
when: is_mitogen
- assert:
that:
- out.stdout == 'mitogen__user1'
when: is_mitogen

@ -242,7 +242,7 @@ class DockerMixin(RouterMixin):
def docker_ssh(self, **kwargs):
kwargs.setdefault('hostname', self.dockerized_ssh.host)
kwargs.setdefault('port', self.dockerized_ssh.port)
kwargs.setdefault('check_host_keys', False)
kwargs.setdefault('check_host_keys', 'ignore')
return self.router.ssh(**kwargs)
def docker_ssh_any(self, **kwargs):

Loading…
Cancel
Save