1
# Copyright (C) 2005 Robey Pointer <robey@lag.net>, Canonical Ltd
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.
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.
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
17
"""Implementation of Transport over SFTP, using paramiko."""
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
34
error('The SFTP transport requires paramiko.')
43
Load system host keys (probably doesn't work on windows) and any
44
"discovered" keys from previous sessions.
46
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
48
SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
50
mutter('failed to load system host keys: ' + str(e))
51
bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
53
BZR_HOSTKEYS = paramiko.util.load_host_keys(bzr_hostkey_path)
55
mutter('failed to load bzr host keys: ' + str(e))
60
Save "discovered" host keys in $(config)/ssh_host_keys/.
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())
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()))
74
mutter('failed to save bzr host keys: ' + str(e))
78
class SFTPTransportError (TransportError):
82
class SFTPTransport (Transport):
84
Transport implementation for SFTP access.
87
_url_matcher = re.compile(r'^sftp://([^@]*@)?(.*?)(:\d+)?(/.*)?$')
89
def __init__(self, base, clone_from=None):
90
assert base.startswith('sftp://')
91
super(SFTPTransport, self).__init__(base)
93
if clone_from is None:
96
# use the same ssh connection, etc
97
self._sftp = clone_from._sftp
98
# super saves 'self.base'
100
def should_cache(self):
102
Return True if the data pulled across should be cached locally.
106
def clone(self, offset=None):
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.
113
return SFTPTransport(self.base, self)
115
return SFTPTransport(self.abspath(offset), self)
117
def abspath(self, relpath):
119
Return the full url to the given relative path.
121
@param relpath: the relative path or path components
122
@type relpath: str or list
124
return self._unparse_url(self._abspath(relpath))
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]
137
if len(basepath) == 0:
138
# In most filesystems, a request for the parent
139
# of root, just returns root.
147
path = '/'.join(basepath)
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))
158
return abspath[pl:].lstrip('/')
160
def has(self, relpath):
162
Does the target location exist?
165
self._sftp.stat(self._abspath(relpath))
170
def get(self, relpath, decode=False):
172
Get the file at the given relative path.
174
:param relpath: The relative path to the file
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)
182
def get_partial(self, relpath, start, length=None):
184
Get just part of a file.
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.
194
f = self.get(relpath)
198
def put(self, relpath, f):
200
Copy the file-like or string object into the location.
202
:param relpath: Location to put the contents, relative to base.
203
:param f: File-like or string object.
205
# FIXME: should do something atomic or locking here, this is unsafe
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)
216
def iter_files_recursive(self):
217
"""Walk the relative paths of all files in this transport."""
218
queue = list(self.list_dir('.'))
220
relpath = urllib.quote(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)
228
def mkdir(self, relpath):
229
"""Create a directory at the given path."""
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)
236
def append(self, relpath, f):
238
Append the text in the file-like object into the final
242
path = self._abspath(relpath)
243
fout = self._sftp.file(path, 'ab')
245
except (IOError, paramiko.SSHException), x:
246
raise SFTPTransportError('Unable to append file %r' % (path,), x)
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)
253
fin = self._sftp.file(path_from, 'rb')
255
fout = self._sftp.file(path_to, 'wb')
257
fout.set_pipelined(True)
258
self._pump(fin, fout)
263
except (IOError, paramiko.SSHException), x:
264
raise SFTPTransportError('Unable to copy %r to %r' % (path_from, path_to), x)
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)
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)
275
def delete(self, relpath):
276
"""Delete the item at relpath"""
277
path = self._abspath(relpath)
279
self._sftp.remove(path)
280
except (IOError, paramiko.SSHException), x:
281
raise SFTPTransportError('Unable to delete %r' % (path,), x)
284
"""Return True if this store supports listing."""
287
def list_dir(self, relpath):
289
Return a list of all files at the given location.
291
# does anything actually use this?
292
path = self._abspath(relpath)
294
return self._sftp.listdir(path)
295
except (IOError, paramiko.SSHException), x:
296
raise SFTPTransportError('Unable to list folder %r' % (path,), x)
298
def stat(self, relpath):
299
"""Return the stat information for a file."""
300
path = self._abspath(relpath)
302
return self._sftp.stat(path)
303
except (IOError, paramiko.SSHException), x:
304
raise SFTPTransportError('Unable to stat %r' % (path,), x)
306
def lock_read(self, relpath):
308
Lock the given file for shared (read) access.
309
:return: A lock object, which should be passed to Transport.unlock()
311
# FIXME: there should be something clever i can do here...
312
class BogusLock(object):
313
def __init__(self, path):
317
return BogusLock(relpath)
319
def lock_write(self, relpath):
321
Lock the given file for exclusive (write) access.
322
WARNING: many transports do not support this, so trying avoid using it
324
:return: A lock object, which should be passed to Transport.unlock()
326
# FIXME: there should be something clever i can do here...
327
class BogusLock(object):
328
def __init__(self, path):
332
return BogusLock(relpath)
335
def _unparse_url(self, path=None):
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)
342
def _parse_url(self, url):
343
assert url[:7] == 'sftp://'
344
m = self._url_matcher.match(url)
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()
351
self._username = self._username[:-1]
352
if self._port is None:
355
self._port = int(self._port[1:])
356
if (self._path is None) or (self._path == ''):
359
def _sftp_connect(self):
360
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
365
t = paramiko.Transport((self._host, self._port))
367
except paramiko.SSHException:
368
raise SFTPTransportError('Unable to reach SSH host %s:%d' % (self._host, self._port))
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())
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())
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)])
394
self._sftp_auth(t, self._username, self._host)
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))
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()))
407
transport.auth_publickey(self._username, key)
409
except paramiko.SSHException, e:
412
# okay, try finding id_rsa or id_dss? (posix only)
413
if self._try_pkey_auth(transport, paramiko.RSAKey, 'id_rsa'):
415
if self._try_pkey_auth(transport, paramiko.DSSKey, 'id_dsa'):
418
# give up and ask for a password
419
password = getpass.getpass('SSH %s@%s password: ' % (self._username, self._host))
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))
426
def _try_pkey_auth(self, transport, pkey_class, filename):
427
filename = os.path.expanduser('~/.ssh/' + filename)
429
key = pkey_class.from_private_key_file(filename)
430
transport.auth_publickey(self._username, key)
432
except paramiko.PasswordRequiredException:
433
password = getpass.getpass('SSH %s password: ' % (os.path.basename(filename),))
435
key = pkey_class.from_private_key_file(filename, password)
436
transport.auth_publickey(self._username, key)
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),))