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:
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user