~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/sftp.py

- win32 get_console_size from Alexander

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