Allow non-sudo escalations and prevent cmd dupes

If a root_password is supplied to the RemoteShell
initialization ( where applicable ), 'su -' will be
used to escalate the command, regardless of the user
that logged in. This effectively means that two passwords
may be supplied when instantiating RemoteShell, 'password'
and 'root_password'. Of course, if the username is actually
"root", the logic will infer that the corresponding password
may act as 'root_password'. The standard procedure will
be to simply use the "sudo" prefix if escalate=True on
remote_execute().

In the previous implementation, to run a command as su, you
would have to explicitly include the prefix with your command.
This change allows the executor logic to determine how to
proceed, and the caller will only need to call remote_execute
with escalate=True, like so:

    client = RemoteShell('123.123.123.5', username='bob',
                         password='bobsecret',
                         root_password='secret')
    # in this case, 'su -' will be used to escalate
    client.remote_execute('mkdir /things', escalate=True)

The second feature added to remote_execute with this change is
the allow_many keyword. To prevent your RemoteShell client from
executing the same command twice, pass allow_many=False. This
will run `ps -ef |grep -v grep|grep -c <your command>` and ensure
that the response is "0".

    client.remote_execute('echo not twice!', allow_many=False)

If the command is found to be running already, a
SatoriDuplicateCommandException will be raised.

Change-Id: I993472ecadebd67cb0bde49e692c1924c6d5c88e
Implements: blueprint allow-non-sudo-escalation
This commit is contained in:
Samuel Stavinoha
2014-08-20 20:03:42 +00:00
parent 31aa43591d
commit bfb8247f54
5 changed files with 107 additions and 38 deletions

View File

@@ -56,6 +56,11 @@ class DiscoveryException(SatoriException):
"""Discovery exception with custom message."""
class SatoriDuplicateCommandException(SatoriException):
"""The command cannot be run because it was already found to be running."""
class UnsupportedPlatform(DiscoveryException):
"""Unsupported operating system or distro."""

View File

@@ -47,6 +47,7 @@ TTY_REQUIRED = [
"you must have a tty to run sudo",
"is not a tty",
"no tty present",
"must be run from a terminal",
]
@@ -99,8 +100,8 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
"""Connects to devices via SSH to execute commands."""
# pylint: disable=R0913
def __init__(self, host, password=None, username="root",
private_key=None, key_filename=None, port=22,
def __init__(self, host, password=None, username="root", private_key=None,
root_password=None, key_filename=None, port=22,
timeout=20, gateway=None, options=None, interactive=False):
"""Create an instance of the SSH class.
@@ -109,6 +110,10 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
:param str password: A password to use for authentication
or for unlocking a private key
:param username: The username to authenticate as
:param root_password: root user password to be used if 'username'
is not root. This will use 'username' and
'password to login and then 'su' to root
using root_password
:param private_key: Private SSH Key string to use
(instead of using a filename)
:param key_filename: a private key filename (path)
@@ -130,6 +135,7 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
self.password = password
self.host = host
self.username = username or 'root'
self.root_password = root_password
self.private_key = private_key
self.key_filename = key_filename
self.port = port or 22
@@ -140,6 +146,10 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
self.sock = None
self.interactive = interactive
self.escalation_command = 'sudo -i %s'
if self.root_password:
self.escalation_command = "su -c '%s'"
if self.gateway:
if not isinstance(self.gateway, SSH):
raise TypeError("'gateway' must be a satori.ssh.SSH instance. "
@@ -335,7 +345,7 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
def _handle_tty_required(self, results, get_pty):
"""Determine whether the result implies a tty request."""
if any(m in str(k) for m in TTY_REQUIRED for k in results.values()):
LOG.info('%s requires TTY for sudo. Using TTY mode.',
LOG.info('%s requires TTY for sudo/su. Using TTY mode.',
self.host)
if get_pty is True: # if this is *already* True
raise errors.GetPTYRetryFailure(
@@ -345,7 +355,7 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
return True
return False
def _handle_password_prompt(self, stdin, stdout):
def _handle_password_prompt(self, stdin, stdout, su_auth=False):
"""Determine whether the remote host is prompting for a password.
Respond to the prompt through stdin if applicable.
@@ -361,7 +371,11 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
LOG.warning("%s@%s encountered prompt! of length "
" [%s] {%s}",
self.username, self.host, buflen, prompt)
stdin.write("%s\n" % self.password)
if su_auth:
LOG.warning("Escalating using 'su -'.")
stdin.write("%s\n" % self.root_password)
else:
stdin.write("%s\n" % self.password)
stdin.flush()
return True
else:
@@ -372,8 +386,21 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
return False
def remote_execute(self, command, with_exit_code=False,
get_pty=False, cwd=None, keepalive=True, **kwargs):
def _command_is_already_running(self, command):
"""Check to see if the command is already running using ps & grep."""
# check plain 'command' w/o prefix or escalation
check_cmd = 'ps -ef |grep -v grep|grep -c "%s"' % command
result = self.remote_execute(check_cmd, keepalive=True,
allow_many=True)
if result['stdout'] != '0':
return True
else:
LOG.debug("Remote command %s IS NOT already running. "
"Continuing with remote_execute.", command)
def remote_execute(self, command, with_exit_code=False, # noqa
get_pty=False, cwd=None, keepalive=True,
escalate=False, allow_many=True, **kw):
"""Execute an ssh command on a remote host.
Tries cert auth first and falls back
@@ -387,32 +414,48 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
executable, so you can't specify the program's
path relative to this argument
:param get_pty: Request a pseudo-terminal from the server.
:param allow_many: If False, do not run command if it is already
found running on remote client.
:returns: a dict with stdin, stdout,
and (optionally) the exit code of the call.
"""
if escalate and self.username != 'root':
run_command = self.escalation_command % command
else:
run_command = command
if cwd:
prefix = "cd %s && " % cwd
command = prefix + command
run_command = prefix + run_command
LOG.debug("Executing '%s' on ssh://%s@%s:%s.",
command, self.username, self.host, self.port)
# _command_is_already_running wont be called if allow_many is True
# python is great :)
if not allow_many and self._command_is_already_running(command):
raise errors.SatoriDuplicateCommandException(
"Remote command %s is already running and allow_many was "
"set to False. Aborting remote_execute." % command)
try:
self.connect()
results = None
chan = self.get_transport().open_session()
su_auth = False
if 'su -' in run_command:
su_auth = True
get_pty = True
if get_pty:
chan.get_pty()
stdin = chan.makefile('wb')
stdout = chan.makefile('rb')
stderr = chan.makefile_stderr('rb')
chan.exec_command(command)
LOG.debug("Executing '%s' on ssh://%s@%s:%s.",
run_command, self.username, self.host, self.port)
chan.exec_command(run_command)
LOG.debug('ssh://%s@%s:%d responded.', self.username, self.host,
self.port)
time.sleep(.25)
self._handle_password_prompt(stdin, stdout)
self._handle_password_prompt(stdin, stdout, su_auth=su_auth)
results = {
'stdout': stdout.read().strip(),
@@ -434,12 +477,14 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
if self._handle_tty_required(results, get_pty):
return self.remote_execute(
command, with_exit_code=with_exit_code, get_pty=True)
command, with_exit_code=with_exit_code, get_pty=True,
cwd=cwd, keepalive=keepalive, escalate=escalate,
allow_many=allow_many)
return results
except Exception as exc:
LOG.info("ssh://%s@%s:%d failed. %s", self.username, self.host,
LOG.info("ssh://%s@%s:%d failed. | %s", self.username, self.host,
self.port, exc)
raise
finally:

View File

@@ -51,7 +51,7 @@ def get_systeminfo(ipaddress, config, interactive=False):
return system_info(client)
def system_info(client):
def system_info(client, with_install=False):
"""Run ohai-solo on a remote system and gather the output.
:param client: :class:`ssh.SSH` instance
@@ -64,12 +64,16 @@ def system_info(client):
SystemInfoNotJson if `ohai` does not return valid JSON.
SystemInfoMissingJson if `ohai` does not return any JSON.
"""
if with_install:
perform_install(client)
if client.is_windows():
raise errors.UnsupportedPlatform(
"ohai-solo is a linux-only sytem info provider. "
"Target platform was %s", client.platform_info['dist'])
else:
output = client.execute("sudo -i ohai-solo")
command = "unset GEM_CACHE GEM_HOME GEM_PATH && sudo ohai-solo"
output = client.execute(command, escalate=True, allow_many=False)
not_found_msgs = ["command not found", "Could not find ohai"]
if any(m in k for m in not_found_msgs
for k in list(output.values()) if isinstance(k,
@@ -107,23 +111,29 @@ def perform_install(client):
"Target platform was %s", client.platform_info['dist'])
else:
# Download to host
command = "sudo wget -N http://ohai.rax.io/install.sh"
client.execute(command, cwd='/tmp')
command = "wget -N http://ohai.rax.io/install.sh"
output = client.execute(command, cwd='/tmp', escalate=True,
allow_many=False)
LOG.debug("Downloaded ohai-solo | %s", output['stdout'])
# Run install
command = "sudo bash install.sh"
output = client.execute(command, cwd='/tmp', with_exit_code=True)
command = "bash install.sh"
install_output = client.execute(command, cwd='/tmp',
with_exit_code=True,
escalate=True, allow_many=False)
LOG.debug("Ran ohai-solo install script. | %s.",
install_output['stdout'])
# Be a good citizen and clean up your tmp data
command = "sudo rm install.sh"
client.execute(command, cwd='/tmp')
command = "rm install.sh"
client.execute(command, cwd='/tmp', escalate=True, allow_many=False)
# Process install command output
if output['exit_code'] != 0:
if install_output['exit_code'] != 0:
raise errors.SystemInfoCommandInstallFailed(
output['stderr'][:256])
install_output['stderr'][:256])
else:
return output
return install_output
def remove_remote(client):
@@ -142,14 +152,14 @@ def remove_remote(client):
else:
platform_info = client.platform_info
if client.is_debian():
remove = "sudo dpkg --purge ohai-solo"
remove = "dpkg --purge ohai-solo"
elif client.is_fedora():
remove = "sudo yum -y erase ohai-solo"
remove = "yum -y erase ohai-solo"
else:
raise errors.UnsupportedPlatform("Unknown distro: %s" %
platform_info['dist'])
command = "%s" % remove
output = client.execute(command, cwd='/tmp')
output = client.execute(command, cwd='/tmp', escalate=True)
return output

View File

@@ -51,7 +51,7 @@ def get_systeminfo(ipaddress, config, interactive=False):
return system_info(client)
def system_info(client):
def system_info(client, with_install=False):
"""Run Posh-Ohai on a remote system and gather the output.
:param client: :class:`smb.SMB` instance
@@ -64,6 +64,9 @@ def system_info(client):
SystemInfoNotJson if `posh-ohai` does not return valid JSON.
SystemInfoMissingJson if `posh-ohai` does not return any JSON.
"""
if with_install:
perform_install(client)
if client.is_windows():
powershell_command = 'Get-ComputerConfiguration'
output = client.execute(powershell_command)

View File

@@ -55,15 +55,18 @@ class TestOhaiInstall(utils.TestCase):
self.mock_remotesshclient.is_windows.return_value = False
def test_perform_install_fedora(self):
response = {'exit_code': 0, 'foo': 'bar'}
response = {'exit_code': 0, 'stdout': 'installed remote'}
self.mock_remotesshclient.execute.return_value = response
result = ohai_solo.perform_install(self.mock_remotesshclient)
self.assertEqual(result, response)
self.assertEqual(self.mock_remotesshclient.execute.call_count, 3)
self.mock_remotesshclient.execute.assert_has_calls([
mock.call('sudo wget -N http://ohai.rax.io/install.sh', cwd='/tmp'),
mock.call('sudo bash install.sh', cwd='/tmp', with_exit_code=True),
mock.call('sudo rm install.sh', cwd='/tmp')])
mock.call('wget -N http://ohai.rax.io/install.sh', cwd='/tmp',
escalate=True, allow_many=False),
mock.call('bash install.sh', cwd='/tmp', with_exit_code=True,
escalate=True, allow_many=False),
mock.call('rm install.sh', cwd='/tmp', escalate=True,
allow_many=False)])
def test_install_linux_remote_failed(self):
response = {'exit_code': 1, 'stdout': "", "stderr": "FAIL"}
@@ -87,7 +90,7 @@ class TestOhaiRemove(utils.TestCase):
result = ohai_solo.remove_remote(self.mock_remotesshclient)
self.assertEqual(result, response)
self.mock_remotesshclient.execute.assert_called_once_with(
'sudo yum -y erase ohai-solo', cwd='/tmp')
'yum -y erase ohai-solo', cwd='/tmp', escalate=True)
def test_remove_remote_debian(self):
self.mock_remotesshclient.is_debian.return_value = True
@@ -97,7 +100,7 @@ class TestOhaiRemove(utils.TestCase):
result = ohai_solo.remove_remote(self.mock_remotesshclient)
self.assertEqual(result, response)
self.mock_remotesshclient.execute.assert_called_once_with(
'sudo dpkg --purge ohai-solo', cwd='/tmp')
'dpkg --purge ohai-solo', cwd='/tmp', escalate=True)
def test_remove_remote_unsupported(self):
self.mock_remotesshclient.is_debian.return_value = False
@@ -121,7 +124,8 @@ class TestSystemInfo(utils.TestCase):
}
ohai_solo.system_info(self.mock_remotesshclient)
self.mock_remotesshclient.execute.assert_called_with(
"sudo -i ohai-solo")
"unset GEM_CACHE GEM_HOME GEM_PATH && sudo ohai-solo",
escalate=True, allow_many=False)
def test_system_info_with_motd(self):
self.mock_remotesshclient.execute.return_value = {
@@ -130,7 +134,9 @@ class TestSystemInfo(utils.TestCase):
'stderr': ""
}
ohai_solo.system_info(self.mock_remotesshclient)
self.mock_remotesshclient.execute.assert_called_with("sudo -i ohai-solo")
self.mock_remotesshclient.execute.assert_called_with(
"unset GEM_CACHE GEM_HOME GEM_PATH && sudo ohai-solo",
escalate=True, allow_many=False)
def test_system_info_bad_json(self):
self.mock_remotesshclient.execute.return_value = {