~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/sftp.py

[merge] fix \t in commit messages

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