Permalink
Browse files

Initial release

  • Loading branch information...
0 parents commit 3e20f881265c8914ee25e2d5f6c32d2839cb00c3 @imankulov imankulov committed Jun 21, 2011
Showing with 353 additions and 0 deletions.
  1. +3 −0 .gitignore
  2. +67 −0 README.rst
  3. +193 −0 openssh_wrapper.py
  4. +28 −0 setup.py
  5. +2 −0 ssh_config.test
  6. +60 −0 tests.py
@@ -0,0 +1,3 @@
+*.pyc
+.*.swp
+/*egg-info
@@ -0,0 +1,67 @@
+OpenSSH Python wrapper
+=======================
+
+Under some circumstances simple wrapper around OpenSSH ``ssh`` command-line
+utility seems more preferable than paramiko machinery.
+
+This project proposes yet another hopefully thin wrapper around ``ssh`` to
+execute commands on remote servers. All you need thereis to make sure that
+OpenSSH client and Python interpreter are installed, and then install
+`openssh-wrapper` package.
+
+Usage sample
+-------------
+
+Simple command execution ::
+
+ >>> from openssh_wrapper import SSHConnection
+ >>> conn = SSHConnection('localhost', 'root')
+ >>> ret = conn.run('whoami')
+ >>> print ret
+ command: whoami
+ stdout: root
+ stderr:
+ returncode: 0
+ >>> ret.command
+ 'whoami'
+ >>> ret.stdout
+ 'root'
+ >>> ret.stderr
+ ''
+ >>> ret.returncode
+ 0
+
+If python interpreter is installed on a remote machine, you can also run pieces
+of python code remotely. The same is true for any other interpreter which can
+execute code from stdin ::
+
+ >>> ret = conn.run('whoami')
+ >>> print conn.run('print "Hello world"', interpreter='/usr/bin/python').stdout
+ Hello world
+
+Yet another userful `run` method option is `forward_ssh_agent` (the feature
+which paramiko doesn't yet have). Suppose you have access as support to foobar
+server while root@localhost does not, so you can take advantage of SSH agent
+forwarding ::
+
+ $ eval `ssh-agent`
+ Agent pid 5272
+ $ ssh-add
+ Identity added: /home/me/.ssh/id_rsa (/home/,e/.ssh/id_rsa)
+ $ python
+ >>> conn = SSHConnection('localhost', 'root')
+ >>> print conn.run('ssh support@foobar "whoami"', forward_ssh_agent=True).stdout
+ support
+
+
+And finally there is a sample which shows how to copy a file from local to
+remote machine ::
+
+ >>> fd = open('test.txt', 'w')
+ >>> fd.write('Hello world')
+ >>> fd.close()
+ >>> from openssh_wrapper import SSHConnection
+ >>> conn = SSHConnection('localhost', 'root')
+ >>> conn.scp('test.txt', target='/tmp')
+ >>> print conn.run('cat /tmp/test.txt').stdout
+ Hello world
@@ -0,0 +1,193 @@
+# -*- coding: utf-8 -*-
+import re, os, subprocess, signal
+
+__all__ = 'SSHConnection SSHResult SSHError'.split()
+
+class SSHConnection(object):
+
+ def __init__(self, server, login=None, configfile=None, identity_file=None,
+ ssh_agent_socket=None, timeout=60):
+ """
+ Create new object to establish SSH connection to remote
+ servers.
+
+ Arguments:
+
+ - `server`: server name or IP address to send commands to (required).
+ - `login`: user login (by default, current login)
+ - `confgfile`: local configuration file (by default ~/.ssh/config is used)
+ - `identity_file`: identity file (by default ~/.ssh/id_rsa)
+ - `ssh_agent_socket`: address of the socket to connect to ssh agent,
+ if you want to use one. ``SSH_AUTH_SOCK`` environment variable is
+ used if None is supplied.
+ - `timeout`: connect timeout. If you plan to execute long lasting
+ commands, adjust this variable accordingly. Default value of 60
+ seconds is usually a good choice.
+
+ By the way, `man ssh_config` is highly recommended amendment to this
+ command.
+ """
+ self.server = str(server)
+ self.timeout = timeout
+ self.check_server(server)
+ if login:
+ self.check_login(login)
+ self.login = str(login)
+ else:
+ self.login = None
+ if configfile:
+ self.configfile = os.path.expanduser(configfile)
+ if not os.path.isfile(self.configfile):
+ raise SSHError('Config file %s is not found' % self.configfile )
+ else:
+ self.configfile = None
+ if identity_file:
+ self.identity_file = os.path.expanduser(configfile)
+ if not os.path.isfile(self.identity_file):
+ raise SSHError('Key file %s is not found' % self.identity_file)
+ else:
+ self.identity_file = None
+ self.ssh_agent_socket = ssh_agent_socket
+
+ def check_server(self, server):
+ if not re.compile(r'^[a-zA-Z0-9.\-]+$').match(server):
+ raise SSHError('Server name contains illegal symbols')
+
+ def check_login(self, login):
+ if not re.compile(r'^[a-zA-Z0-9.\-]+$').match(login):
+ raise SSHError('User login contains illegal symbols')
+
+ def run(self, command, interpreter='/bin/bash', forward_ssh_agent=False):
+ """
+ Execute ``command`` using ``interpreter``
+
+ Consider this roughly as::
+
+ echo "command" | ssh root@server "/bin/interpreter"
+
+ Raise SSHError if server is unreachable
+
+ Hint: Try interpreter='/usr/bin/python'
+ """
+ ssh_command = self.ssh_command(interpreter, forward_ssh_agent)
+ pipe = subprocess.Popen(ssh_command,
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, env=self.get_env())
+ try:
+ signal.signal(signal.SIGALRM, _timeout_handler)
+ except ValueError, e: #signal only works in main thread
+ pass
+ signal.alarm(self.timeout)
+ try:
+ out, err = pipe.communicate(command)
+ except IOError, e:
+ # pipe.terminate() # only in python 2.6 allowed
+ os.kill(pipe.pid, signal.SIGTERM)
+ signal.alarm(0) # disable alarm
+ raise SSHError(str(e))
+
+ signal.alarm(0) # disable alarm
+ returncode = pipe.returncode
+ if returncode == 255: # ssh client error
+ raise SSHError(err.strip())
+ return SSHResult(command, out.strip(), err.strip(), returncode)
+
+ def scp(self, *filenames, **kwargs):
+ """ Copy files identified by their names to remote location
+
+ Optional ``target`` parameter can be used to define target directory
+ """
+ scp_command = self.scp_command(*filenames, **kwargs)
+ pipe = subprocess.Popen(scp_command,
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, env=self.get_env())
+ signal.signal(signal.SIGALRM, _timeout_handler)
+ signal.alarm(self.timeout)
+ try:
+ out, err = pipe.communicate()
+ except IOError, e:
+ # pipe.terminate() # only in python 2.6 allowed
+ os.kill(pipe.pid, signal.SIGTERM)
+ signal.alarm(0) # disable alarm
+ raise SSHError(stderr=str(e))
+ signal.alarm(0) # disable alarm
+ returncode = pipe.returncode
+ if returncode != 0: # ssh client error
+ raise SSHError(err.strip())
+
+ def ssh_command(self, interpreter, forward_ssh_agent):
+ interpreter = str(interpreter)
+ cmd = ['/usr/bin/ssh', ]
+ if self.login:
+ cmd += [ '-l', self.login ]
+ if self.configfile:
+ cmd += [ '-F', self.configfile ]
+ if self.identity_file:
+ cmd += [ '-i', self.identity_file ]
+ if forward_ssh_agent:
+ cmd.append('-A')
+ cmd.append(self.server)
+ cmd.append(interpreter)
+ return cmd
+
+ def scp_command(self, *filenames, **kwargs):
+ cmd = ['/usr/bin/scp', '-q', '-r']
+ filenames = map(str, filenames)
+ if self.login:
+ remotename = '%s@%s' % (self.login, self.server)
+ else:
+ remotename = self.server
+ if self.configfile:
+ cmd += [ '-F', self.configfile ]
+ if self.identity_file:
+ cmd += [ '-i', self.identity_file ]
+
+ if len(filenames) < 1:
+ raise ValueError('You should name at least one file to copy')
+
+ if 'target' in kwargs:
+ cmd += filenames
+ target = kwargs['target']
+ else:
+ if len(filenames) > 1:
+ cmd += filenames[:-1]
+ target = filenames[-1]
+ else:
+ cmd.append = filenames[0]
+ target = filenames[0]
+ cmd.append('%s:%s' % (remotename, target))
+ return cmd
+
+ def get_env(self):
+ env = os.environ.copy()
+ if self.ssh_agent_socket:
+ env['SSH_AUTH_SOCK'] = self.ssh_agent_socket
+ return env
+
+
+def _timeout_handler(signum, frame):
+ raise IOError, 'SSH connect timeout'
+
+
+class SSHResult(object):
+ """ Command execution status. Has ``command``, ``stdout``, ``stderr`` and
+ ``returncode`` fields """
+
+ def __init__(self, command, stdout, stderr, returncode):
+ self.command = command
+ self.stdout = stdout
+ self.stderr = stderr
+ self.returncode = returncode
+
+ def __str__(self):
+ return unicode(self).decode('utf-8', 'ignore')
+
+ def __unicode__(self):
+ ret = []
+ ret.append(u'command: %s' % unicode(self.command))
+ ret.append(u'stdout: %s' % unicode(self.stdout))
+ ret.append(u'stderr: %s' % unicode(self.stderr))
+ ret.append(u'returncode: %s' % unicode(self.returncode))
+ return u'\n'.join(ret)
+
+class SSHError(Exception): pass
@@ -0,0 +1,28 @@
+#!/usr/bin/env python
+import os
+from distutils.core import setup
+
+def read(fname):
+ try:
+ return open(os.path.join(os.path.dirname(__file__), fname)).read()
+ except:
+ return ''
+
+setup(
+ name='openssh-wrapper',
+ version='0.2',
+ description='OpenSSH python wrapper',
+ author='NetAngels team',
+ author_email='info@netangels.ru',
+ url='https://github.com/NetAngels/openssh-wrapper',
+ long_description = read('README.rst'),
+ license = 'BSD License',
+ py_modules=['openssh_wrapper'],
+ classifiers=(
+ 'Development Status :: 4 - Beta',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Programming Language :: Python',
+ 'Topic :: Software Development :: Libraries :: Python Modules',
+ ),
+)
@@ -0,0 +1,2 @@
+Host *
+ PasswordAuthentication no
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+import os
+from openssh_wrapper import *
+from nose.tools import *
+
+
+class TestSSHCommandNames(object):
+
+ def setUp(self):
+ self.c = SSHConnection('localhost', login='root',
+ configfile='ssh_config.test')
+
+ def test_ssh_command(self):
+ eq_(self.c.ssh_command('/bin/bash', False),
+ ['/usr/bin/ssh', '-l', 'root', '-F', 'ssh_config.test', 'localhost', '/bin/bash'])
+
+ def test_scp_command(self):
+ eq_(self.c.scp_command('/tmp/1.txt', target='/tmp/2.txt'),
+ ['/usr/bin/scp', '-q', '-r', '-F', 'ssh_config.test', '/tmp/1.txt', 'root@localhost:/tmp/2.txt'])
+
+ def test_scp_multiple_files(self):
+ eq_(self.c.scp_command('/tmp/1.txt', '2.txt', target='/home/username/'),
+ ['/usr/bin/scp', '-q', '-r', '-F', 'ssh_config.test', '/tmp/1.txt', '2.txt', 'root@localhost:/home/username/'])
+
+ def test_simple_command(self):
+ result = self.c.run('whoami')
+ eq_(result.stdout, 'root')
+ eq_(result.stderr, '')
+ eq_(result.returncode, 0)
+
+ def test_python_command(self):
+ result = self.c.run('print "Hello world"', interpreter='/usr/bin/python')
+ eq_(result.stdout, 'Hello world')
+ eq_(result.stderr, '')
+ eq_(result.returncode, 0)
+
+@raises(SSHError) # ssh connect timeout
+def test_timeout():
+ c = SSHConnection('example.com', login='root', timeout=1)
+ c.run('whoami')
+
+
+@raises(SSHError) # Permission denied (publickey)
+def test_permission_denied():
+ c = SSHConnection('localhost', login='www-data', configfile='ssh_config.test')
+ c.run('whoami')
+
+
+class TestSCP(object):
+
+ def setUp(self):
+ self.c = SSHConnection('localhost', login='root')
+
+ def test_scp(self):
+ self.c.scp(__file__, target='/tmp')
+ ok_(os.path.isfile('/tmp/tests.py'))
+
+ @raises(SSHError)
+ def test_scp_to_nonexistent_dir(self):
+ self.c.scp(__file__, target='/abc/def/')

0 comments on commit 3e20f88

Please sign in to comment.