diff options
-rw-r--r-- | NEWS | 15 | ||||
-rw-r--r-- | paramiko/__init__.py | 7 | ||||
-rw-r--r-- | paramiko/client.py | 37 | ||||
-rw-r--r-- | paramiko/config.py | 56 | ||||
-rw-r--r-- | paramiko/packet.py | 4 | ||||
-rw-r--r-- | paramiko/proxy.py | 91 | ||||
-rw-r--r-- | paramiko/ssh_exception.py | 17 | ||||
-rw-r--r-- | paramiko/transport.py | 5 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | tests/test_util.py | 48 |
10 files changed, 238 insertions, 44 deletions
@@ -12,6 +12,19 @@ Issues noted as "Fabric #NN" can be found at https://github.com/fabric/fabric/. Releases ======== +v1.9.0 (6th Nov 2012) +--------------------- + +* #97 (with a little #93): Improve config parsing of `ProxyCommand` directives + and provide a wrapper class to allow subprocess-driven proxy commands to be + used as `sock=` arguments for `SSHClient.connect`. +* #77: Allow `SSHClient.connect()` to take an explicit `sock` parameter + overriding creation of an internal, implicit socket object. +* Thanks in no particular order to Erwin Bolwidt, Oskari Saarenmaa, Steven + Noonan, Vladimir Lazarenko, Lincoln de Sousa, Valentino Volonghi, Olle + Lundberg, and Github user `@acrish` for the various and sundry patches + leading to the above changes. + v1.8.1 (6th Nov 2012) --------------------- @@ -28,6 +41,8 @@ v1.8.1 (6th Nov 2012) v1.8.0 (3rd Oct 2012) --------------------- +* #17 ('ssh' 28): Fix spurious `NoneType has no attribute 'error'` and similar + exceptions that crop up on interpreter exit. * 'ssh' 32: Raise a more useful error explaining which `known_hosts` key line was problematic, when encountering `binascii` issues decoding known host keys. Thanks to `@thomasvs` for catch & patch. diff --git a/paramiko/__init__.py b/paramiko/__init__.py index cd4dbe02..29e470a6 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -55,7 +55,7 @@ if sys.version_info < (2, 2): __author__ = "Jeff Forcier <jeff@bitprophet.org>" -__version__ = "1.8.1" +__version__ = "1.9.0" __license__ = "GNU Lesser General Public License (LGPL)" @@ -65,7 +65,7 @@ from auth_handler import AuthHandler from channel import Channel, ChannelFile from ssh_exception import SSHException, PasswordRequiredException, \ BadAuthenticationType, ChannelException, BadHostKeyException, \ - AuthenticationException + AuthenticationException, ProxyCommandFailure from server import ServerInterface, SubsystemHandler, InteractiveQuery from rsakey import RSAKey from dsskey import DSSKey @@ -83,6 +83,7 @@ from agent import Agent, AgentKey from pkey import PKey from hostkeys import HostKeys from config import SSHConfig +from proxy import ProxyCommand # fix module names for epydoc for c in locals().values(): @@ -119,6 +120,8 @@ __all__ = [ 'Transport', 'BadAuthenticationType', 'ChannelException', 'BadHostKeyException', + 'ProxyCommand', + 'ProxyCommandFailure', 'SFTP', 'SFTPFile', 'SFTPHandle', diff --git a/paramiko/client.py b/paramiko/client.py index 0f408977..07560a39 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -229,7 +229,7 @@ class SSHClient (object): def connect(self, hostname, port=SSH_PORT, username=None, password=None, pkey=None, key_filename=None, timeout=None, allow_agent=True, look_for_keys=True, - compress=False): + compress=False, sock=None): """ Connect to an SSH server and authenticate to it. The server's host key is checked against the system host keys (see L{load_system_host_keys}) @@ -272,6 +272,9 @@ class SSHClient (object): @type look_for_keys: bool @param compress: set to True to turn on compression @type compress: bool + @param sock: an open socket or socket-like object (such as a + L{Channel}) to use for communication to the target host + @type sock: socket @raise BadHostKeyException: if the server's host key could not be verified @@ -280,21 +283,23 @@ class SSHClient (object): establishing an SSH session @raise socket.error: if a socket error occurred while connecting """ - for (family, socktype, proto, canonname, sockaddr) in socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM): - if socktype == socket.SOCK_STREAM: - af = family - addr = sockaddr - break - else: - # some OS like AIX don't indicate SOCK_STREAM support, so just guess. :( - af, _, _, _, addr = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM) - sock = socket.socket(af, socket.SOCK_STREAM) - if timeout is not None: - try: - sock.settimeout(timeout) - except: - pass - retry_on_signal(lambda: sock.connect(addr)) + if not sock: + for (family, socktype, proto, canonname, sockaddr) in socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM): + if socktype == socket.SOCK_STREAM: + af = family + addr = sockaddr + break + else: + # some OS like AIX don't indicate SOCK_STREAM support, so just guess. :( + af, _, _, _, addr = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + sock = socket.socket(af, socket.SOCK_STREAM) + if timeout is not None: + try: + sock.settimeout(timeout) + except: + pass + retry_on_signal(lambda: sock.connect(addr)) + t = self._transport = Transport(sock) t.use_compression(compress=compress) if self._log_channel is not None: diff --git a/paramiko/config.py b/paramiko/config.py index 458d5dd0..2828d903 100644 --- a/paramiko/config.py +++ b/paramiko/config.py @@ -22,9 +22,12 @@ L{SSHConfig}. import fnmatch import os +import re import socket SSH_PORT=22 +proxy_re = re.compile(r"^(proxycommand)\s*=*\s*(.*)", re.I) + class SSHConfig (object): """ @@ -56,8 +59,13 @@ class SSHConfig (object): if (line == '') or (line[0] == '#'): continue if '=' in line: - key, value = line.split('=', 1) - key = key.strip().lower() + # Ensure ProxyCommand gets properly split + if line.lower().strip().startswith('proxycommand'): + match = proxy_re.match(line) + key, value = match.group(1).lower(), match.group(2) + else: + key, value = line.split('=', 1) + key = key.strip().lower() else: # find first whitespace, and split there i = 0 @@ -149,26 +157,30 @@ class SSHConfig (object): host = socket.gethostname().split('.')[0] fqdn = socket.getfqdn() homedir = os.path.expanduser('~') - replacements = {'controlpath' : - [ - ('%h', config['hostname']), - ('%l', fqdn), - ('%L', host), - ('%n', hostname), - ('%p', port), - ('%r', remoteuser), - ('%u', user) - ], - 'identityfile' : - [ - ('~', homedir), - ('%d', homedir), - ('%h', config['hostname']), - ('%l', fqdn), - ('%u', user), - ('%r', remoteuser) - ] - } + replacements = { + 'controlpath': [ + ('%h', config['hostname']), + ('%l', fqdn), + ('%L', host), + ('%n', hostname), + ('%p', port), + ('%r', remoteuser), + ('%u', user) + ], + 'identityfile': [ + ('~', homedir), + ('%d', homedir), + ('%h', config['hostname']), + ('%l', fqdn), + ('%u', user), + ('%r', remoteuser) + ], + 'proxycommand': [ + ('%h', config['hostname']), + ('%p', port), + ('%r', remoteuser), + ], + } for k in config: if k in replacements: for find, replace in replacements[k]: diff --git a/paramiko/packet.py b/paramiko/packet.py index 97820619..5d918e2a 100644 --- a/paramiko/packet.py +++ b/paramiko/packet.py @@ -29,7 +29,7 @@ import time from paramiko.common import * from paramiko import util -from paramiko.ssh_exception import SSHException +from paramiko.ssh_exception import SSHException, ProxyCommandFailure from paramiko.message import Message @@ -254,6 +254,8 @@ class Packetizer (object): retry_write = True else: n = -1 + except ProxyCommandFailure: + raise # so it doesn't get swallowed by the below catchall except Exception: # could be: (32, 'Broken pipe') n = -1 diff --git a/paramiko/proxy.py b/paramiko/proxy.py new file mode 100644 index 00000000..218b76e2 --- /dev/null +++ b/paramiko/proxy.py @@ -0,0 +1,91 @@ +# Copyright (C) 2012 Yipit, Inc <coders@yipit.com> +# +# This file is part of paramiko. +# +# Paramiko is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# Paramiko is distrubuted in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Paramiko; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. + +""" +L{ProxyCommand}. +""" + +import os +from shlex import split as shlsplit +import signal +from subprocess import Popen, PIPE + +from paramiko.ssh_exception import ProxyCommandFailure + + +class ProxyCommand(object): + """ + Wraps a subprocess running ProxyCommand-driven programs. + + This class implements a the socket-like interface needed by the + L{Transport} and L{Packetizer} classes. Using this class instead of a + regular socket makes it possible to talk with a Popen'd command that will + proxy traffic between the client and a server hosted in another machine. + """ + def __init__(self, command_line): + """ + Create a new CommandProxy instance. The instance created by this + class can be passed as an argument to the L{Transport} class. + + @param command_line: the command that should be executed and + used as the proxy. + @type command_line: str + """ + self.cmd = shlsplit(command_line) + self.process = Popen(self.cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) + + def send(self, content): + """ + Write the content received from the SSH client to the standard + input of the forked command. + + @param content: string to be sent to the forked command + @type content: str + """ + try: + self.process.stdin.write(content) + except IOError, e: + # There was a problem with the child process. It probably + # died and we can't proceed. The best option here is to + # raise an exception informing the user that the informed + # ProxyCommand is not working. + raise BadProxyCommand(' '.join(self.cmd), e.strerror) + return len(content) + + def recv(self, size): + """ + Read from the standard output of the forked program. + + @param size: how many chars should be read + @type size: int + + @return: the length of the read content + @rtype: int + """ + try: + return os.read(self.process.stdout.fileno(), size) + except IOError, e: + raise BadProxyCommand(' '.join(self.cmd), e.strerror) + + def close(self): + os.kill(self.process.pid, signal.SIGTERM) + + def settimeout(self, timeout): + # Timeouts are meaningless for this implementation, but are part of the + # spec, so must be present. + pass diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py index 68924d0f..f2406dcf 100644 --- a/paramiko/ssh_exception.py +++ b/paramiko/ssh_exception.py @@ -113,3 +113,20 @@ class BadHostKeyException (SSHException): self.key = got_key self.expected_key = expected_key + +class ProxyCommandFailure (SSHException): + """ + The "ProxyCommand" found in the .ssh/config file returned an error. + + @ivar command: The command line that is generating this exception. + @type command: str + @ivar error: The error captured from the proxy command output. + @type error: str + """ + def __init__(self, command, error): + SSHException.__init__(self, + '"ProxyCommand (%s)" returned non-zero exit status: %s' % ( + command, error + ) + ) + self.error = error diff --git a/paramiko/transport.py b/paramiko/transport.py index 04680a9f..c8010312 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -44,7 +44,8 @@ from paramiko.primes import ModulusPack from paramiko.rsakey import RSAKey from paramiko.server import ServerInterface from paramiko.sftp_client import SFTPClient -from paramiko.ssh_exception import SSHException, BadAuthenticationType, ChannelException +from paramiko.ssh_exception import (SSHException, BadAuthenticationType, + ChannelException, ProxyCommandFailure) from paramiko.util import retry_on_signal from Crypto import Random @@ -1674,6 +1675,8 @@ class Transport (threading.Thread): timeout = 2 try: buf = self.packetizer.readline(timeout) + except ProxyCommandFailure: + raise except Exception, x: raise SSHException('Error reading SSH protocol banner' + str(x)) if buf[:4] == 'SSH-': @@ -52,7 +52,7 @@ if sys.platform == 'darwin': setup(name = "paramiko", - version = "1.8.1", + version = "1.9.0", description = "SSH2 protocol library", author = "Jeff Forcier", author_email = "jeff@bitprophet.org", diff --git a/tests/test_util.py b/tests/test_util.py index 458709b2..093a2157 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -27,6 +27,7 @@ import os import unittest from Crypto.Hash import SHA import paramiko.util +from paramiko.util import lookup_ssh_host_config as host_config from util import ParamikoTest @@ -151,7 +152,7 @@ class UtilTest(ParamikoTest): x = rng.read(32) self.assertEquals(len(x), 32) - def test_7_host_config_expose_issue_33(self): + def test_7_host_config_expose_ssh_issue_33(self): test_config_file = """ Host www13.* Port 22 @@ -194,3 +195,48 @@ Host * raise AssertionError('foo') self.assertRaises(AssertionError, lambda: paramiko.util.retry_on_signal(raises_other_exception)) + + def test_9_proxycommand_config_equals_parsing(self): + """ + ProxyCommand should not split on equals signs within the value. + """ + conf = """ +Host space-delimited + ProxyCommand foo bar=biz baz + +Host equals-delimited + ProxyCommand=foo bar=biz baz +""" + f = cStringIO.StringIO(conf) + config = paramiko.util.parse_ssh_config(f) + for host in ('space-delimited', 'equals-delimited'): + self.assertEquals( + host_config(host, config)['proxycommand'], + 'foo bar=biz baz' + ) + + def test_10_proxycommand_interpolation(self): + """ + ProxyCommand should perform interpolation on the value + """ + config = paramiko.util.parse_ssh_config(cStringIO.StringIO(""" +Host * + Port 25 + ProxyCommand host %h port %p + +Host specific + Port 37 + ProxyCommand host %h port %p lol + +Host portonly + Port 155 +""")) + for host, val in ( + ('foo.com', "host foo.com port 25"), + ('specific', "host specific port 37 lol"), + ('portonly', "host portonly port 155"), + ): + self.assertEquals( + host_config(host, config)['proxycommand'], + val + ) |