~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/sftp.py

- import sftp transport from robey

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2005 Robey Pointer <robey@lag.net>, Canonical Ltd
 
2
 
 
3
# This program is free software; you can redistribute it and/or modify
 
4
# it under the terms of the GNU General Public License as published by
 
5
# the Free Software Foundation; either version 2 of the License, or
 
6
# (at your option) any later version.
 
7
 
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
 
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software
 
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
16
 
 
17
"""
 
18
Implementation of Transport over SFTP, using paramiko.
 
19
"""
 
20
 
 
21
import getpass
 
22
import os
 
23
import re
 
24
import stat
 
25
import sys
 
26
 
 
27
from bzrlib.errors import TransportNotPossible, NoSuchFile, NonRelativePath, TransportError
 
28
from bzrlib.config import config_dir
 
29
from bzrlib.trace import mutter, warning, error
 
30
from bzrlib.transport import Transport, register_transport
 
31
 
 
32
try:
 
33
    import paramiko
 
34
except ImportError:
 
35
    error('The SFTP plugin requires paramiko.')
 
36
    raise
 
37
 
 
38
 
 
39
SYSTEM_HOSTKEYS = {}
 
40
BZR_HOSTKEYS = {}
 
41
 
 
42
def load_host_keys():
 
43
    """
 
44
    Load system host keys (probably doesn't work on windows) and any
 
45
    "discovered" keys from previous sessions.
 
46
    """
 
47
    global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
 
48
    try:
 
49
        SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
 
50
    except Exception, e:
 
51
        mutter('failed to load system host keys: ' + str(e))
 
52
    bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
 
53
    try:
 
54
        BZR_HOSTKEYS = paramiko.util.load_host_keys(bzr_hostkey_path)
 
55
    except Exception, e:
 
56
        mutter('failed to load bzr host keys: ' + str(e))
 
57
        save_host_keys()
 
58
 
 
59
def save_host_keys():
 
60
    """
 
61
    Save "discovered" host keys in $(config)/ssh_host_keys/.
 
62
    """
 
63
    global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
 
64
    bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
 
65
    if not os.path.isdir(config_dir()):
 
66
        os.mkdir(config_dir())
 
67
    try:
 
68
        f = open(bzr_hostkey_path, 'w')
 
69
        f.write('# SSH host keys collected by bzr\n')
 
70
        for hostname, keys in BZR_HOSTKEYS.iteritems():
 
71
            for keytype, key in keys.iteritems():
 
72
                f.write('%s %s %s\n' % (hostname, keytype, key.get_base64()))
 
73
        f.close()
 
74
    except IOError, e:
 
75
        mutter('failed to save bzr host keys: ' + str(e))
 
76
 
 
77
 
 
78
 
 
79
class SFTPTransportError (TransportError):
 
80
    pass
 
81
 
 
82
 
 
83
class SFTPTransport (Transport):
 
84
    """
 
85
    Transport implementation for SFTP access.
 
86
    """
 
87
 
 
88
    _url_matcher = re.compile(r'^sftp://([^@]*@)?(.*?)(:\d+)?(/.*)?$')
 
89
    
 
90
    def __init__(self, base, clone_from=None):
 
91
        assert base.startswith('sftp://')
 
92
        super(SFTPTransport, self).__init__(base)
 
93
        self._parse_url(base)
 
94
        if clone_from is None:
 
95
            self._sftp_connect()
 
96
        else:
 
97
            # use the same ssh connection, etc
 
98
            self._sftp = clone_from._sftp
 
99
        # super saves 'self.base'
 
100
    
 
101
    def should_cache(self):
 
102
        """
 
103
        Return True if the data pulled across should be cached locally.
 
104
        """
 
105
        return True
 
106
 
 
107
    def clone(self, offset=None):
 
108
        """
 
109
        Return a new SFTPTransport with root at self.base + offset.
 
110
        We share the same SFTP session between such transports, because it's
 
111
        fairly expensive to set them up.
 
112
        """
 
113
        if offset is None:
 
114
            return SFTPTransport(self.base, self)
 
115
        else:
 
116
            return SFTPTransport(self.abspath(offset), self)
 
117
 
 
118
    def abspath(self, relpath):
 
119
        """
 
120
        Return the full url to the given relative path.
 
121
        
 
122
        @param relpath: the relative path or path components
 
123
        @type relpath: str or list
 
124
        """
 
125
        return self._unparse_url(self._abspath(relpath))
 
126
    
 
127
    def _abspath(self, relpath):
 
128
        """Return the absolute path segment without the SFTP URL."""
 
129
        # FIXME: share the common code across transports
 
130
        if isinstance(relpath, basestring):
 
131
            relpath = [relpath]
 
132
        basepath = self._path.split('/')
 
133
        if len(basepath) > 0 and basepath[-1] == '':
 
134
            basepath = basepath[:-1]
 
135
 
 
136
        for p in relpath:
 
137
            if p == '..':
 
138
                if len(basepath) == 0:
 
139
                    # In most filesystems, a request for the parent
 
140
                    # of root, just returns root.
 
141
                    continue
 
142
                basepath.pop()
 
143
            elif p == '.':
 
144
                continue # No-op
 
145
            else:
 
146
                basepath.append(p)
 
147
 
 
148
        path = '/'.join(basepath)
 
149
        if path[0] != '/':
 
150
            path = '/' + path
 
151
        return path
 
152
 
 
153
    def relpath(self, abspath):
 
154
        # FIXME: this is identical to HttpTransport -- share it
 
155
        if not abspath.startswith(self.base):
 
156
            raise NonRelativePath('path %r is not under base URL %r'
 
157
                           % (abspath, self.base))
 
158
        pl = len(self.base)
 
159
        return abspath[pl:].lstrip('/')
 
160
 
 
161
    def has(self, relpath):
 
162
        """
 
163
        Does the target location exist?
 
164
        """
 
165
        try:
 
166
            self._sftp.stat(self._abspath(relpath))
 
167
            return True
 
168
        except IOError:
 
169
            return False
 
170
 
 
171
    def get(self, relpath, decode=False):
 
172
        """
 
173
        Get the file at the given relative path.
 
174
 
 
175
        :param relpath: The relative path to the file
 
176
        """
 
177
        try:
 
178
            path = self._abspath(relpath)
 
179
            return self._sftp.file(path)
 
180
        except (IOError, paramiko.SSHException), x:
 
181
            raise NoSuchFile('Error retrieving %s: %s' % (path, str(x)), x)
 
182
 
 
183
    def get_partial(self, relpath, start, length=None):
 
184
        """
 
185
        Get just part of a file.
 
186
 
 
187
        :param relpath: Path to the file, relative to base
 
188
        :param start: The starting position to read from
 
189
        :param length: The length to read. A length of None indicates
 
190
                       read to the end of the file.
 
191
        :return: A file-like object containing at least the specified bytes.
 
192
                 Some implementations may return objects which can be read
 
193
                 past this length, but this is not guaranteed.
 
194
        """
 
195
        f = self.get(relpath)
 
196
        f.seek(start)
 
197
        return f
 
198
 
 
199
    def put(self, relpath, f):
 
200
        """
 
201
        Copy the file-like or string object into the location.
 
202
 
 
203
        :param relpath: Location to put the contents, relative to base.
 
204
        :param f:       File-like or string object.
 
205
        """
 
206
        # FIXME: should do something atomic or locking here, this is unsafe
 
207
        try:
 
208
            path = self._abspath(relpath)
 
209
            fout = self._sftp.file(path, 'wb')
 
210
        except (IOError, paramiko.SSHException), x:
 
211
            raise SFTPTransportError('Unable to write file %r' % (path,), x)
 
212
        try:
 
213
            self._pump(f, fout)
 
214
        finally:
 
215
            fout.close()
 
216
 
 
217
    def iter_files_recursive(self):
 
218
        """Walk the relative paths of all files in this transport."""
 
219
        queue = list(self.list_dir('.'))
 
220
        while queue:
 
221
            relpath = queue.pop(0)
 
222
            st = self.stat(relpath)
 
223
            if stat.S_ISDIR(st.st_mode):
 
224
                for i, basename in enumerate(self.list_dir(relpath)):
 
225
                    queue.insert(i, relpath+'/'+basename)
 
226
            else:
 
227
                yield relpath
 
228
 
 
229
    def mkdir(self, relpath):
 
230
        """Create a directory at the given path."""
 
231
        try:
 
232
            path = self._abspath(relpath)
 
233
            self._sftp.mkdir(path)
 
234
        except (IOError, paramiko.SSHException), x:
 
235
            raise SFTPTransportError('Unable to mkdir %r' % (path,), x)
 
236
 
 
237
    def append(self, relpath, f):
 
238
        """
 
239
        Append the text in the file-like object into the final
 
240
        location.
 
241
        """
 
242
        try:
 
243
            path = self._abspath(relpath)
 
244
            fout = self._sftp.file(path, 'ab')
 
245
            self._pump(f, fout)
 
246
        except (IOError, paramiko.SSHException), x:
 
247
            raise SFTPTransportError('Unable to append file %r' % (path,), x)
 
248
 
 
249
    def copy(self, rel_from, rel_to):
 
250
        """Copy the item at rel_from to the location at rel_to"""
 
251
        path_from = self._abspath(rel_from)
 
252
        path_to = self._abspath(rel_to)
 
253
        try:
 
254
            fin = self._sftp.file(path_from, 'rb')
 
255
            try:
 
256
                fout = self._sftp.file(path_to, 'wb')
 
257
                try:
 
258
                    fout.set_pipelined(True)
 
259
                    self._pump(fin, fout)
 
260
                finally:
 
261
                    fout.close()
 
262
            finally:
 
263
                fin.close()
 
264
        except (IOError, paramiko.SSHException), x:
 
265
            raise SFTPTransportError('Unable to copy %r to %r' % (path_from, path_to), x)
 
266
 
 
267
    def move(self, rel_from, rel_to):
 
268
        """Move the item at rel_from to the location at rel_to"""
 
269
        path_from = self._abspath(rel_from)
 
270
        path_to = self._abspath(rel_to)
 
271
        try:
 
272
            self._sftp.rename(path_from, path_to)
 
273
        except (IOError, paramiko.SSHException), x:
 
274
            raise SFTPTransportError('Unable to move %r to %r' % (path_from, path_to), x)
 
275
 
 
276
    def delete(self, relpath):
 
277
        """Delete the item at relpath"""
 
278
        path = self._abspath(relpath)
 
279
        try:
 
280
            self._sftp.remove(path)
 
281
        except (IOError, paramiko.SSHException), x:
 
282
            raise SFTPTransportError('Unable to delete %r' % (path,), x)
 
283
            
 
284
    def listable(self):
 
285
        """Return True if this store supports listing."""
 
286
        return True
 
287
 
 
288
    def list_dir(self, relpath):
 
289
        """
 
290
        Return a list of all files at the given location.
 
291
        """
 
292
        # does anything actually use this?
 
293
        path = self._abspath(relpath)
 
294
        try:
 
295
            return self._sftp.listdir(path)
 
296
        except (IOError, paramiko.SSHException), x:
 
297
            raise SFTPTransportError('Unable to list folder %r' % (path,), x)
 
298
 
 
299
    def stat(self, relpath):
 
300
        """Return the stat information for a file."""
 
301
        path = self._abspath(relpath)
 
302
        try:
 
303
            return self._sftp.stat(path)
 
304
        except (IOError, paramiko.SSHException), x:
 
305
            raise SFTPTransportError('Unable to stat %r' % (path,), x)
 
306
 
 
307
    def lock_read(self, relpath):
 
308
        """
 
309
        Lock the given file for shared (read) access.
 
310
        :return: A lock object, which should be passed to Transport.unlock()
 
311
        """
 
312
        # FIXME: there should be something clever i can do here...
 
313
        class BogusLock(object):
 
314
            def __init__(self, path):
 
315
                self.path = path
 
316
            def unlock(self):
 
317
                pass
 
318
        return BogusLock(relpath)
 
319
 
 
320
    def lock_write(self, relpath):
 
321
        """
 
322
        Lock the given file for exclusive (write) access.
 
323
        WARNING: many transports do not support this, so trying avoid using it
 
324
 
 
325
        :return: A lock object, which should be passed to Transport.unlock()
 
326
        """
 
327
        # FIXME: there should be something clever i can do here...
 
328
        class BogusLock(object):
 
329
            def __init__(self, path):
 
330
                self.path = path
 
331
            def unlock(self):
 
332
                pass
 
333
        return BogusLock(relpath)
 
334
 
 
335
 
 
336
    def _unparse_url(self, path=None):
 
337
        if path is None:
 
338
            path = self._path
 
339
        if self._port == 22:
 
340
            return 'sftp://%s@%s%s' % (self._username, self._host, path)
 
341
        return 'sftp://%s@%s:%d%s' % (self._username, self._host, self._port, path)
 
342
 
 
343
    def _parse_url(self, url):
 
344
        assert url[:7] == 'sftp://'
 
345
        m = self._url_matcher.match(url)
 
346
        if m is None:
 
347
            raise SFTPTransportError('Unable to parse SFTP URL %r' % (url,))
 
348
        self._username, self._host, self._port, self._path = m.groups()
 
349
        if self._username is None:
 
350
            self._username = getpass.getuser()
 
351
        else:
 
352
            self._username = self._username[:-1]
 
353
        if self._port is None:
 
354
            self._port = 22
 
355
        else:
 
356
            self._port = int(self._port[1:])
 
357
        if (self._path is None) or (self._path == ''):
 
358
            self._path = '/'
 
359
 
 
360
    def _sftp_connect(self):
 
361
        global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
 
362
        
 
363
        load_host_keys()
 
364
        
 
365
        try:
 
366
            t = paramiko.Transport((self._host, self._port))
 
367
            t.start_client()
 
368
        except paramiko.SSHException:
 
369
            raise SFTPTransportError('Unable to reach SSH host %s:%d' % (self._host, self._port))
 
370
            
 
371
        server_key = t.get_remote_server_key()
 
372
        server_key_hex = paramiko.util.hexify(server_key.get_fingerprint())
 
373
        keytype = server_key.get_name()
 
374
        if SYSTEM_HOSTKEYS.has_key(self._host) and SYSTEM_HOSTKEYS[self._host].has_key(keytype):
 
375
            our_server_key = SYSTEM_HOSTKEYS[self._host][keytype]
 
376
            our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
 
377
        elif BZR_HOSTKEYS.has_key(self._host) and BZR_HOSTKEYS[self._host].has_key(keytype):
 
378
            our_server_key = BZR_HOSTKEYS[self._host][keytype]
 
379
            our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
 
380
        else:
 
381
            warning('Adding %s host key for %s: %s' % (keytype, self._host, server_key_hex))
 
382
            if not BZR_HOSTKEYS.has_key(self._host):
 
383
                BZR_HOSTKEYS[self._host] = {}
 
384
            BZR_HOSTKEYS[self._host][keytype] = server_key
 
385
            our_server_key = server_key
 
386
            our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
 
387
            save_host_keys()
 
388
        if server_key != our_server_key:
 
389
            filename1 = os.path.expanduser('~/.ssh/known_hosts')
 
390
            filename2 = os.path.join(config_dir(), 'ssh_host_keys')
 
391
            raise SFTPTransportError('Host keys for %s do not match!  %s != %s' % \
 
392
                (self._host, our_server_key_hex, server_key_hex),
 
393
                ['Try editing %s or %s' % (filename1, filename2)])
 
394
 
 
395
        self._sftp_auth(t, self._username, self._host)
 
396
        
 
397
        try:
 
398
            self._sftp = t.open_sftp_client()
 
399
        except paramiko.SSHException:
 
400
            raise BzrError('Unable to find path %s on SFTP server %s' % \
 
401
                (self._path, self._host))
 
402
 
 
403
    def _sftp_auth(self, transport, username, host):
 
404
        agent = paramiko.Agent()
 
405
        for key in agent.get_keys():
 
406
            mutter('Trying SSH agent key %s' % paramiko.util.hexify(key.get_fingerprint()))
 
407
            try:
 
408
                transport.auth_publickey(self._username, key)
 
409
                return
 
410
            except paramiko.SSHException, e:
 
411
                pass
 
412
        
 
413
        # okay, try finding id_rsa or id_dss?  (posix only)
 
414
        if self._try_pkey_auth(transport, paramiko.RSAKey, 'id_rsa'):
 
415
            return
 
416
        if self._try_pkey_auth(transport, paramiko.DSSKey, 'id_dsa'):
 
417
            return
 
418
 
 
419
        # give up and ask for a password
 
420
        password = getpass.getpass('SSH %s@%s password: ' % (self._username, self._host))
 
421
        try:
 
422
            transport.auth_password(self._username, password)
 
423
        except paramiko.SSHException:
 
424
            raise SFTPTransportError('Unable to authenticate to SSH host as %s@%s' % \
 
425
                (self._username, self._host))
 
426
 
 
427
    def _try_pkey_auth(self, transport, pkey_class, filename):
 
428
        filename = os.path.expanduser('~/.ssh/' + filename)
 
429
        try:
 
430
            key = pkey_class.from_private_key_file(filename)
 
431
            transport.auth_publickey(self._username, key)
 
432
            return True
 
433
        except paramiko.PasswordRequiredException:
 
434
            password = getpass.getpass('SSH %s password: ' % (os.path.basename(filename),))
 
435
            try:
 
436
                key = pkey_class.from_private_key_file(filename, password)
 
437
                transport.auth_publickey(self._username, key)
 
438
                return True
 
439
            except paramiko.SSHException:
 
440
                mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
 
441
        except paramiko.SSHException:
 
442
            mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
 
443
        except IOError:
 
444
            pass
 
445
        return False
 
446
 
 
447
 
 
448
register_transport('sftp://', SFTPTransport)
 
449