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."""
27
from bzrlib.errors import (FileExists,
28
TransportNotPossible, NoSuchFile, NonRelativePath,
30
from bzrlib.config import config_dir
31
from bzrlib.trace import mutter, warning, error
32
from bzrlib.transport import Transport, register_transport
37
error('The SFTP transport requires paramiko.')
46
Load system host keys (probably doesn't work on windows) and any
47
"discovered" keys from previous sessions.
49
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
51
SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
53
mutter('failed to load system host keys: ' + str(e))
54
bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
56
BZR_HOSTKEYS = paramiko.util.load_host_keys(bzr_hostkey_path)
58
mutter('failed to load bzr host keys: ' + str(e))
63
Save "discovered" host keys in $(config)/ssh_host_keys/.
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())
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()))
77
mutter('failed to save bzr host keys: ' + str(e))
81
class SFTPTransportError (TransportError):
85
class SFTPTransport (Transport):
87
Transport implementation for SFTP access.
90
_url_matcher = re.compile(r'^sftp://([^@]*@)?(.*?)(:\d+)?(/.*)?$')
92
def __init__(self, base, clone_from=None):
93
assert base.startswith('sftp://')
94
super(SFTPTransport, self).__init__(base)
96
if clone_from is None:
99
# use the same ssh connection, etc
100
self._sftp = clone_from._sftp
101
# super saves 'self.base'
103
def should_cache(self):
105
Return True if the data pulled across should be cached locally.
109
def clone(self, offset=None):
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.
116
return SFTPTransport(self.base, self)
118
return SFTPTransport(self.abspath(offset), self)
120
def abspath(self, relpath):
122
Return the full url to the given relative path.
124
@param relpath: the relative path or path components
125
@type relpath: str or list
127
return self._unparse_url(self._abspath(relpath))
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]
140
if len(basepath) == 0:
141
# In most filesystems, a request for the parent
142
# of root, just returns root.
150
path = '/'.join(basepath)
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))
161
return abspath[pl:].lstrip('/')
163
def has(self, relpath):
165
Does the target location exist?
168
self._sftp.stat(self._abspath(relpath))
173
def get(self, relpath, decode=False):
175
Get the file at the given relative path.
177
:param relpath: The relative path to the file
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)
185
def get_partial(self, relpath, start, length=None):
187
Get just part of a file.
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.
197
f = self.get(relpath)
201
def put(self, relpath, f):
203
Copy the file-like or string object into the location.
205
:param relpath: Location to put the contents, relative to base.
206
:param f: File-like or string object.
208
# FIXME: should do something atomic or locking here, this is unsafe
210
path = self._abspath(relpath)
211
fout = self._sftp.file(path, 'wb')
213
self._translate_io_exception(e, relpath)
214
except (IOError, paramiko.SSHException), x:
215
raise SFTPTransportError('Unable to write file %r' % (path,), x)
221
def iter_files_recursive(self):
222
"""Walk the relative paths of all files in this transport."""
223
queue = list(self.list_dir('.'))
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)
233
def mkdir(self, relpath):
234
"""Create a directory at the given path."""
236
path = self._abspath(relpath)
237
self._sftp.mkdir(path)
239
self._translate_io_exception(e, relpath)
240
except (IOError, paramiko.SSHException), x:
241
raise SFTPTransportError('Unable to mkdir %r' % (path,), x)
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)
256
def append(self, relpath, f):
258
Append the text in the file-like object into the final
262
path = self._abspath(relpath)
263
fout = self._sftp.file(path, 'ab')
265
except (IOError, paramiko.SSHException), x:
266
raise SFTPTransportError('Unable to append file %r' % (path,), x)
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)
273
fin = self._sftp.file(path_from, 'rb')
275
fout = self._sftp.file(path_to, 'wb')
277
fout.set_pipelined(True)
278
self._pump(fin, fout)
283
except (IOError, paramiko.SSHException), x:
284
raise SFTPTransportError('Unable to copy %r to %r' % (path_from, path_to), x)
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)
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)
295
def delete(self, relpath):
296
"""Delete the item at relpath"""
297
path = self._abspath(relpath)
299
self._sftp.remove(path)
300
except (IOError, paramiko.SSHException), x:
301
raise SFTPTransportError('Unable to delete %r' % (path,), x)
304
"""Return True if this store supports listing."""
307
def list_dir(self, relpath):
309
Return a list of all files at the given location.
311
# does anything actually use this?
312
path = self._abspath(relpath)
314
return self._sftp.listdir(path)
315
except (IOError, paramiko.SSHException), x:
316
raise SFTPTransportError('Unable to list folder %r' % (path,), x)
318
def stat(self, relpath):
319
"""Return the stat information for a file."""
320
path = self._abspath(relpath)
322
return self._sftp.stat(path)
323
except (IOError, paramiko.SSHException), x:
324
raise SFTPTransportError('Unable to stat %r' % (path,), x)
326
def lock_read(self, relpath):
328
Lock the given file for shared (read) access.
329
:return: A lock object, which should be passed to Transport.unlock()
331
# FIXME: there should be something clever i can do here...
332
class BogusLock(object):
333
def __init__(self, path):
337
return BogusLock(relpath)
339
def lock_write(self, relpath):
341
Lock the given file for exclusive (write) access.
342
WARNING: many transports do not support this, so trying avoid using it
344
:return: A lock object, which should be passed to Transport.unlock()
346
# FIXME: there should be something clever i can do here...
347
class BogusLock(object):
348
def __init__(self, path):
352
return BogusLock(relpath)
355
def _unparse_url(self, path=None):
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)
362
def _parse_url(self, url):
363
assert url[:7] == 'sftp://'
364
m = self._url_matcher.match(url)
366
raise SFTPTransportError('Unable to parse SFTP URL %r' % (url,))
367
self._username, self._host, self._port, self._path = m.groups()
368
if self._username is None:
369
self._username = getpass.getuser()
371
self._username = self._username[:-1]
372
if self._port is None:
375
self._port = int(self._port[1:])
376
if (self._path is None) or (self._path == ''):
379
def _sftp_connect(self):
380
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
385
t = paramiko.Transport((self._host, self._port))
387
except paramiko.SSHException:
388
raise SFTPTransportError('Unable to reach SSH host %s:%d' % (self._host, self._port))
390
server_key = t.get_remote_server_key()
391
server_key_hex = paramiko.util.hexify(server_key.get_fingerprint())
392
keytype = server_key.get_name()
393
if SYSTEM_HOSTKEYS.has_key(self._host) and SYSTEM_HOSTKEYS[self._host].has_key(keytype):
394
our_server_key = SYSTEM_HOSTKEYS[self._host][keytype]
395
our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
396
elif BZR_HOSTKEYS.has_key(self._host) and BZR_HOSTKEYS[self._host].has_key(keytype):
397
our_server_key = BZR_HOSTKEYS[self._host][keytype]
398
our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
400
warning('Adding %s host key for %s: %s' % (keytype, self._host, server_key_hex))
401
if not BZR_HOSTKEYS.has_key(self._host):
402
BZR_HOSTKEYS[self._host] = {}
403
BZR_HOSTKEYS[self._host][keytype] = server_key
404
our_server_key = server_key
405
our_server_key_hex = paramiko.util.hexify(our_server_key.get_fingerprint())
407
if server_key != our_server_key:
408
filename1 = os.path.expanduser('~/.ssh/known_hosts')
409
filename2 = os.path.join(config_dir(), 'ssh_host_keys')
410
raise SFTPTransportError('Host keys for %s do not match! %s != %s' % \
411
(self._host, our_server_key_hex, server_key_hex),
412
['Try editing %s or %s' % (filename1, filename2)])
414
self._sftp_auth(t, self._username, self._host)
417
self._sftp = t.open_sftp_client()
418
except paramiko.SSHException:
419
raise BzrError('Unable to find path %s on SFTP server %s' % \
420
(self._path, self._host))
422
def _sftp_auth(self, transport, username, host):
423
agent = paramiko.Agent()
424
for key in agent.get_keys():
425
mutter('Trying SSH agent key %s' % paramiko.util.hexify(key.get_fingerprint()))
427
transport.auth_publickey(self._username, key)
429
except paramiko.SSHException, e:
432
# okay, try finding id_rsa or id_dss? (posix only)
433
if self._try_pkey_auth(transport, paramiko.RSAKey, 'id_rsa'):
435
if self._try_pkey_auth(transport, paramiko.DSSKey, 'id_dsa'):
438
# give up and ask for a password
439
password = getpass.getpass('SSH %s@%s password: ' % (self._username, self._host))
441
transport.auth_password(self._username, password)
442
except paramiko.SSHException:
443
raise SFTPTransportError('Unable to authenticate to SSH host as %s@%s' % \
444
(self._username, self._host))
446
def _try_pkey_auth(self, transport, pkey_class, filename):
447
filename = os.path.expanduser('~/.ssh/' + filename)
449
key = pkey_class.from_private_key_file(filename)
450
transport.auth_publickey(self._username, key)
452
except paramiko.PasswordRequiredException:
453
password = getpass.getpass('SSH %s password: ' % (os.path.basename(filename),))
455
key = pkey_class.from_private_key_file(filename, password)
456
transport.auth_publickey(self._username, key)
458
except paramiko.SSHException:
459
mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))
460
except paramiko.SSHException:
461
mutter('SSH authentication via %s key failed.' % (os.path.basename(filename),))