[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