[Checkins] SVN: zope.app.fssync/branches/jim-hack/ Added ssh transport for zsync.

Amos Latteier amos at latteier.com
Thu Jan 29 17:26:26 EST 2009


Log message for revision 95529:
  Added ssh transport for zsync.
  * updated the client to work with ssh
  * added a demo server
  

Changed:
  U   zope.app.fssync/branches/jim-hack/README.txt
  U   zope.app.fssync/branches/jim-hack/buildout.cfg
  U   zope.app.fssync/branches/jim-hack/setup.py
  A   zope.app.fssync/branches/jim-hack/src/zope/app/fssync/demo_server.py
  U   zope.app.fssync/branches/jim-hack/src/zope/app/fssync/fssync.py
  U   zope.app.fssync/branches/jim-hack/src/zope/app/fssync/passwd.py
  A   zope.app.fssync/branches/jim-hack/src/zope/app/fssync/ssh.py
  A   zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/ssh.txt
  A   zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/test_ssh.py

-=-
Modified: zope.app.fssync/branches/jim-hack/README.txt
===================================================================
--- zope.app.fssync/branches/jim-hack/README.txt	2009-01-29 21:48:50 UTC (rev 95528)
+++ zope.app.fssync/branches/jim-hack/README.txt	2009-01-29 22:26:26 UTC (rev 95529)
@@ -36,6 +36,41 @@
 The modified file should now be available on the server.
 
 
+SSH
+---
+
+Zsync now supports communication over ssh in addition to http. ssh
+urls look like:
+
+  zsync+ssh://user:passwd@host:port/path
+
+The zsync protocol is the same as over HTTP, it simply is sent via
+ssh.
+
+On the server side, the ssh server can check public keys to enforce
+security (though the demo server doesn't bother), and is responsible
+for passing the zsync request to zope and returning the response over
+ssh.
+
+There is an example ssh server in src/zope/app/fssync/demo_server.py
+To use it, first make sure that zope is running. Then start the
+server:
+
+    sudo bin/demo-ssh-server
+
+This starts a demo ssh server on port 2200. The server must be run as
+root in order to read the ssh host keys.
+
+In another window use the zsync client to connect to it.
+
+    bin/zsync co zsync+ssh://zsync:zsync@localhost:2200/demo parts/co2
+
+This checks out the demo folder into the parts/co2 folder.
+
+You should be able to work in the check out normally. Zsync will use
+ssh to communicate, but will otherwise act normally.
+
+
 Extending zsync
 ---------------
 

Modified: zope.app.fssync/branches/jim-hack/buildout.cfg
===================================================================
--- zope.app.fssync/branches/jim-hack/buildout.cfg	2009-01-29 21:48:50 UTC (rev 95528)
+++ zope.app.fssync/branches/jim-hack/buildout.cfg	2009-01-29 22:26:26 UTC (rev 95529)
@@ -1,6 +1,6 @@
 [buildout]
 develop = .
-parts = demo test zsync
+parts = demo test zsync zsync-demo-server
 find-links = http://download.zope.org/distribution/
 
 [zope3]
@@ -24,7 +24,12 @@
 [zsync]
 recipe = zc.recipe.egg
 eggs = zope.app.fssync
-entry-points = zsync=zope.app.fssync.main:main
+entry-points = zsync=zope.app.fssync.main:main 
 
 [database]
 recipe = zc.recipe.filestorage
+
+[zsync-demo-server]
+recipe = zc.recipe.egg
+eggs = zope.app.fssync
+entry-points = demo-ssh-server=zope.app.fssync.demo_server:main

Modified: zope.app.fssync/branches/jim-hack/setup.py
===================================================================
--- zope.app.fssync/branches/jim-hack/setup.py	2009-01-29 21:48:50 UTC (rev 95528)
+++ zope.app.fssync/branches/jim-hack/setup.py	2009-01-29 22:26:26 UTC (rev 95529)
@@ -32,6 +32,7 @@
                ]
               ),
     install_requires=['setuptools',
+                    'paramiko',
                     'zope.dublincore',
                     'zope.fssync',
                     'zope.interface',

Added: zope.app.fssync/branches/jim-hack/src/zope/app/fssync/demo_server.py
===================================================================
--- zope.app.fssync/branches/jim-hack/src/zope/app/fssync/demo_server.py	                        (rev 0)
+++ zope.app.fssync/branches/jim-hack/src/zope/app/fssync/demo_server.py	2009-01-29 22:26:26 UTC (rev 95529)
@@ -0,0 +1,100 @@
+##############################################################################
+#
+# Copyright (c) 2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+import httplib
+import logging
+import socket
+
+
+import paramiko
+
+logging.basicConfig(level=logging.DEBUG)
+
+
+class Server(paramiko.ServerInterface):
+    """
+    Demo server configuration
+    """
+    def check_channel_request(self, kind, chanid):
+        if kind == 'session':
+            return paramiko.OPEN_SUCCEEDED
+        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
+
+    def check_auth_publickey(self, username, key):
+        # All public keys are accepted for the demo, in a real server
+        # you would check public keys against keys of authorized
+        # users.
+        return paramiko.AUTH_SUCCESSFUL
+
+    def get_allowed_auths(self, username):
+        return 'publickey'
+
+
+class ZsyncHandler(paramiko.SubsystemHandler):
+    """
+    An example handler that forwards request to a zope server over
+    HTTP. A real server would probably communicate a different way.
+    """
+    def parse_request(self, channel, transport):
+        f = channel.makefile('r')
+        line = f.readline().strip('\r\n')
+        command, path = line.split(' ', 1)
+        command = command.strip()
+        path = path.strip()
+        headers = {}
+        while transport.is_active:
+            line = f.readline().strip('\r\n')
+            if not line:
+                break
+            key, value = line.split(':', 1)
+            headers[key.strip().lower()] = value.strip()
+        body = ''
+        length = int(headers.get('content-length', 0))
+        if length:
+            body = f.read(length)
+        return command, path, headers, body
+            
+    def start_subsystem(self, name, transport, channel):
+        command, path, headers, body = self.parse_request(channel, transport)
+        connection = httplib.HTTPConnection('localhost', 8080)
+        if body:
+            connection.request(command, path, body=body, headers=headers)
+        else:
+            connection.request(command, path, headers=headers)
+        response = connection.getresponse()
+        channel.send('HTTP/1.0 %s %s\r\n' % (
+                response.status, response.reason))
+        for name, value in response.getheaders():
+            channel.send('%s: %s\r\n' % (name, value))
+        channel.send('\r\n')    
+        body = response.read()
+        channel.send(body)
+        
+            
+def main(port=2200):
+    # read host keys
+    host_key = paramiko.RSAKey(filename = '/etc/ssh/ssh_host_rsa_key')
+
+    # start ssh server, install zsync handler
+    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    sock.settimeout(None)
+    sock.bind(('', port))
+    sock.listen(100)
+    while True:
+        client, addr = sock.accept()
+        client.settimeout(None)
+        t = paramiko.Transport(client)
+        t.add_server_key(host_key)
+        t.set_subsystem_handler('zsync', ZsyncHandler)
+        t.start_server(server = Server())

Modified: zope.app.fssync/branches/jim-hack/src/zope/app/fssync/fssync.py
===================================================================
--- zope.app.fssync/branches/jim-hack/src/zope/app/fssync/fssync.py	2009-01-29 21:48:50 UTC (rev 95528)
+++ zope.app.fssync/branches/jim-hack/src/zope/app/fssync/fssync.py	2009-01-29 22:26:26 UTC (rev 95529)
@@ -42,6 +42,7 @@
 from zope.fssync import fsutil
 from zope.fssync.snarf import Snarfer, Unsnarfer
 from zope.app.fssync.passwd import PasswordManager
+from zope.app.fssync.ssh import SSHConnection
 
 if sys.platform[:3].lower() == "win":
     DEV_NULL = r".\nul"
@@ -136,8 +137,9 @@
             rooturl = roottype = rootpath = user_passwd = host_port = None
         else:
             roottype, rest = urllib.splittype(rooturl)
-            if roottype not in ("http", "https"):
-                raise Error("root url must be 'http' or 'https'", rooturl)
+            if roottype not in ("http", "https", "zsync+ssh"):
+                raise Error("root url must be 'http', 'https', or 'zsync+ssh'",
+                            rooturl)
             if roottype == "https" and not hasattr(httplib, "HTTPS"):
                 raise Error("https not supported by this Python build")
             netloc, rootpath = urllib.splithost(rest)
@@ -211,6 +213,8 @@
         path += view
         if self.roottype == "https":
             conn = httplib.HTTPSConnection(self.host_port)
+        elif self.roottype == "zsync+ssh":
+            conn = SSHConnection(self.host_port, self.user_passwd)
         else:
             conn = httplib.HTTPConnection(self.host_port)
          

Modified: zope.app.fssync/branches/jim-hack/src/zope/app/fssync/passwd.py
===================================================================
--- zope.app.fssync/branches/jim-hack/src/zope/app/fssync/passwd.py	2009-01-29 21:48:50 UTC (rev 95528)
+++ zope.app.fssync/branches/jim-hack/src/zope/app/fssync/passwd.py	2009-01-29 22:26:26 UTC (rev 95529)
@@ -22,6 +22,7 @@
 
 from cStringIO import StringIO
 
+from zope.fssync import fsutil
 
 DEFAULT_FILENAME = os.path.expanduser(os.path.join("~", ".zsyncpass"))
 
@@ -179,6 +180,8 @@
         return _normalize_port(host_port, httplib.HTTP_PORT)
     elif scheme == "https":
         return _normalize_port(host_port, httplib.HTTPS_PORT)
+    elif scheme == "zsync+ssh":
+        return _normalize_port(host_port, None)
     else:
         raise fsutil.Error("unsupported URL scheme: %r" % scheme)
 

Added: zope.app.fssync/branches/jim-hack/src/zope/app/fssync/ssh.py
===================================================================
--- zope.app.fssync/branches/jim-hack/src/zope/app/fssync/ssh.py	                        (rev 0)
+++ zope.app.fssync/branches/jim-hack/src/zope/app/fssync/ssh.py	2009-01-29 22:26:26 UTC (rev 95529)
@@ -0,0 +1,139 @@
+##############################################################################
+#
+# Copyright (c) 2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+import getpass
+import httplib
+import os.path
+import socket
+
+import paramiko
+
+
+class FileSocket(object):
+    """
+    Adapts a file to the socket interface for use with http.HTTPResponse
+    """
+    def __init__(self, file):
+        self.file = file
+
+    def makefile(self, *args):
+        return self.file
+
+
+class SSHConnection(object):
+    """
+    SSH connection that implements parts of the httplib.HTTPConnection
+    interface
+    """
+    def __init__(self, host_port, user_passwd=None):
+        self.headers = {}
+        self.host, self.port = host_port.split(':')
+        self.port = int(self.port)
+
+        # if username is specified in URL then use it, otherwise
+        # default to local userid
+        if user_passwd:
+            self.remote_user_name = user_passwd.split(':')[0]
+        else:
+            self.remote_user_name = getpass.getuser()
+
+    def putrequest(self, method, path):
+        # open connection to server
+        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        sock.connect((self.host, self.port))
+        self.transport = paramiko.Transport(sock)
+        self.transport.start_client()
+
+        # try to get public key from ssh agent
+        agent = paramiko.Agent()
+        for key in agent.get_keys():
+            try:
+                self.transport.auth_publickey(self.remote_user_name, key)
+                break
+            except paramiko.SSHException:
+                pass
+
+        # try to get public key from fs
+        if not self.transport.is_authenticated():
+            path = os.path.expanduser('~/.ssh/id_rsa')
+            try:
+                key = paramiko.RSAKey.from_private_key_file(path)
+            except paramiko.PasswordRequiredException:
+                password = getpass.getpass('RSA key password: ')
+                key = paramiko.RSAKey.from_private_key_file(path, password)
+            try:
+                self.transport.auth_publickey(self.remote_user_name, key)
+            except paramiko.SSHException:
+                pass
+
+        if not self.transport.is_authenticated():
+            path = os.path.expanduser('~/.ssh/id_dsa')
+            try:
+                key = paramiko.DSSKey.from_private_key_file(path)
+            except paramiko.PasswordRequiredException:
+                password = getpass.getpass('DSS key password: ')
+                key = paramiko.DSSKey.from_private_key_file(path, password)
+            try:
+                self.transport.auth_publickey(self.remote_user_name, key)
+            except paramiko.SSHException:
+                raise Exception('No valid public key found')
+
+        # try to get host key
+        hostkeytype = None
+        hostkey = None
+        try:
+            host_keys = paramiko.util.load_host_keys(
+                os.path.expanduser('~/.ssh/known_hosts'))
+        except IOError:
+            try:
+                # try ~/ssh/ too, because windows can't have a folder
+                # named ~/.ssh/
+                host_keys = paramiko.util.load_host_keys(
+                    os.path.expanduser('~/ssh/known_hosts'))
+            except IOError:
+                host_keys = {}
+
+        if host_keys.has_key(self.host):
+            hostkeytype = host_keys[self.host].keys()[0]
+            hostkey = host_keys[self.host][hostkeytype]
+
+        # verify host key
+        if hostkey:
+            server_key = self.transport.get_server_key()
+            if server_key != hostkey:
+                raise Exception(
+                    "Remote host key doesn't match value in known_hosts")
+
+        # start zsync subsystem on server
+        self.channel = self.transport.open_session()
+        self.channel.invoke_subsystem('zsync')
+        self.channelf = self.channel.makefile('r')
+
+        # start sending request
+        self.channel.send('%s %s\r\n' % (method, path))
+
+    def putheader(self, name, value):
+        self.channel.send('%s: %s\r\n' % (name, value))
+
+    def endheaders(self):
+        self.channel.send('\r\n')
+
+    def send(self, data):
+        self.channel.send(data)
+
+    def getresponse(self):
+        response = httplib.HTTPResponse(FileSocket(self.channelf))
+        response.begin()
+        self.channel.close()
+        self.transport.close()
+        return response

Added: zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/ssh.txt
===================================================================
--- zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/ssh.txt	                        (rev 0)
+++ zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/ssh.txt	2009-01-29 22:26:26 UTC (rev 95529)
@@ -0,0 +1,120 @@
+SSH Networking
+==============
+
+The ssh module provides ssh networking classes. The main class is
+SSHConnection, which follows the httplib.HTTPConnection interface.
+
+This class don't provide complete compatibility with httplib; it only
+implements the parts that zope.app.fssync expects.
+
+
+Response
+--------
+
+In order to handle reponses the ssh connection uses an adapter that
+makes a file look like a socket. This adapter is then used to create
+an HTTPResponse.
+
+    >>> from zope.app.fssync.ssh import FileSocket
+    >>> from httplib import HTTPResponse
+
+    >>> data = """HTTP/1.0 302 Found\r
+    ... Location: http://www.google.ca/\r
+    ... Cache-Control: private\r
+    ... Content-Type: text/html; charset=UTF-8\r
+    ... Set-Cookie: PREF=ID=6bcca0d3fbdb7918:TM=1229706725:LM=1229706725:S=Io4QKPOm7laH7W7q; expires=Sun, 19-Dec-2010 17:12:05 GMT; path=/; domain=.google.com\r
+    ... Date: Fri, 19 Dec 2008 17:12:05 GMT\r
+    ... Server: gws\r
+    ... Content-Length: 218\r
+    ... Connection: Close\r
+    ... \r
+    ... <HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
+    ... <TITLE>302 Moved</TITLE></HEAD><BODY>
+    ... <H1>302 Moved</H1>
+    ... The document has moved
+    ... <A HREF="http://www.google.ca/">here</A>.
+    ... </BODY></HTML>"""
+
+    >>> from cStringIO import StringIO
+    >>> f = StringIO()
+    >>> f.write(data)
+    >>> f.seek(0)
+    >>> s = FileSocket(f)
+    >>> r = HTTPResponse(s)
+    >>> r.begin()
+
+Now let's make sure that the response looks right.
+
+    >>> r.msg['Content-type']
+    'text/html; charset=UTF-8'
+
+    >>> r.reason
+    'Found'
+
+    >>> r.fp.read()
+    '<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">\n<TITLE>302 Moved</TITLE></HEAD><BODY>\n<H1>302 Moved</H1>\nThe document has moved\n<A HREF="http://www.google.ca/">here</A>.\n</BODY></HTML>'
+
+Excellent.
+
+Connection
+----------
+
+The connection class should be tested here. This will be hard since it
+requires lots of stuff like having ssh agent running, host keys, and
+having an ssh server running. For now let's just do what we can.
+
+    >>> from zope.app.fssync.ssh import SSHConnection
+    >>> c = SSHConnection('localhost:12345')
+    >>> c.putrequest('GET', '/')
+    Traceback (most recent call last):
+    ...
+    error: (111, 'Connection refused')
+
+We get an error here because we're not running an ssh server on port
+12345.
+
+Even though we don't have a real encrypted network connection, let's
+see what the connection object would send over the ssh channel.
+
+    >>> class StubChannel(object):
+    ...     def send(self, data):
+    ...         print repr(data)
+    ...     def close(self): pass
+
+    >>> c.channel = StubChannel()
+
+    >>> c.putheader('Header Name', 'Header value')
+    'Header Name: Header value\r\n'
+
+    >>> c.endheaders()
+    '\r\n'
+
+    >>> c.send('this is some data')
+    'this is some data'
+
+The connection understands data sent back from the ssh server as a
+HTTP response. We can demo this by using a fake server response.
+
+    >>> from cStringIO import StringIO
+    >>> f = StringIO("""HTTP/1.0 200 OK\r\nHeader: value\r\n\r\nBody""")
+    >>> f.seek(0)
+    >>> c.channelf = f
+    >>> class StubTransport(object):
+    ...     def close(self): pass
+    >>> c.transport = StubTransport()
+
+Now we can test the response.
+
+    >>> r = c.getresponse()
+    >>> r
+    <httplib.HTTPResponse instance at 0x...>
+
+    >>> r.getheaders()
+    [('header', 'value')]
+
+    >>> r.status
+    200
+
+    >>> r.read()
+    'Body'
+

Added: zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/test_ssh.py
===================================================================
--- zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/test_ssh.py	                        (rev 0)
+++ zope.app.fssync/branches/jim-hack/src/zope/app/fssync/tests/test_ssh.py	2009-01-29 22:26:26 UTC (rev 95529)
@@ -0,0 +1,21 @@
+##############################################################################
+#
+# Copyright (c) 2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+import doctest
+import unittest
+
+def test_suite():
+    return unittest.TestSuite([
+        doctest.DocFileSuite('ssh.txt',
+            optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE))
+        ])



More information about the Checkins mailing list