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
18
Implementation of Transport over SFTP, using paramiko.
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
35
error('The SFTP plugin requires paramiko.')
44
Load system host keys (probably doesn't work on windows) and any
45
"discovered" keys from previous sessions.
47
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
49
SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
51
mutter('failed to load system host keys: ' + str(e))
52
bzr_hostkey_path = os.path.join(config_dir(), 'ssh_host_keys')
54
BZR_HOSTKEYS = paramiko.util.load_host_keys(bzr_hostkey_path)
56
mutter('failed to load bzr host keys: ' + str(e))
61
Save "discovered" host keys in $(config)/ssh_host_keys/.
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())
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()))
75
mutter('failed to save bzr host keys: ' + str(e))
79
class SFTPTransportError (TransportError):
83
class SFTPTransport (Transport):
85
Transport implementation for SFTP access.
88
_url_matcher = re.compile(r'^sftp://([^@]*@)?(.*?)(:\d+)?(/.*)?$')
90
def __init__(self, base, clone_from=None):
91
assert base.startswith('sftp://')
92
super(SFTPTransport, self).__init__(base)
94
if clone_from is None:
97
# use the same ssh connection, etc
98
self._sftp = clone_from._sftp
99
# super saves 'self.base'
101
def should_cache(self):
103
Return True if the data pulled across should be cached locally.
107
def clone(self, offset=None):
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.
114
return SFTPTransport(self.base, self)
116
return SFTPTransport(self.abspath(offset), self)
118
def abspath(self, relpath):
120
Return the full url to the given relative path.
122
@param relpath: the relative path or path components
123
@type relpath: str or list
125
return self._unparse_url(self._abspath(relpath))
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):
132
basepath = self._path.split('/')
133
if len(basepath) > 0 and basepath[-1] == '':
134
basepath = basepath[:-1]
138
if len(basepath) == 0:
139
# In most filesystems, a request for the parent
140
# of root, just returns root.
148
path = '/'.join(basepath)
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))
159
return abspath[pl:].lstrip('/')
161
def has(self, relpath):
163
Does the target location exist?
166
self._sftp.stat(self._abspath(relpath))
171
def get(self, relpath, decode=False):
173
Get the file at the given relative path.
175
:param relpath: The relative path to the file
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)
183
def get_partial(self, relpath, start, length=None):
185
Get just part of a file.
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.
195
f = self.get(relpath)
199
def put(self, relpath, f):
201
Copy the file-like or string object into the location.
203
:param relpath: Location to put the contents, relative to base.
204
:param f: File-like or string object.
206
# FIXME: should do something atomic or locking here, this is unsafe
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)
217
def iter_files_recursive(self):
218
"""Walk the relative paths of all files in this transport."""
219
queue = list(self.list_dir('.'))
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)
229
def mkdir(self, relpath):
230
"""Create a directory at the given path."""
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)
237
def append(self, relpath, f):
239
Append the text in the file-like object into the final
243
path = self._abspath(relpath)
244
fout = self._sftp.file(path, 'ab')
246
except (IOError, paramiko.SSHException), x:
247
raise SFTPTransportError('Unable to append file %r' % (path,), x)
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)
254
fin = self._sftp.file(path_from, 'rb')
256
fout = self._sftp.file(path_to, 'wb')
258
fout.set_pipelined(True)
259
self._pump(fin, fout)
264
except (IOError, paramiko.SSHException), x:
265
raise SFTPTransportError('Unable to copy %r to %r' % (path_from, path_to), x)
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)
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)
276
def delete(self, relpath):
277
"""Delete the item at relpath"""
278
path = self._abspath(relpath)
280
self._sftp.remove(path)
281
except (IOError, paramiko.SSHException), x:
282
raise SFTPTransportError('Unable to delete %r' % (path,), x)
285
"""Return True if this store supports listing."""
288
def list_dir(self, relpath):
290
Return a list of all files at the given location.
292
# does anything actually use this?
293
path = self._abspath(relpath)
295
return self._sftp.listdir(path)
296
except (IOError, paramiko.SSHException), x:
297
raise SFTPTransportError('Unable to list folder %r' % (path,), x)
299
def stat(self, relpath):
300
"""Return the stat information for a file."""
301
path = self._abspath(relpath)
303
return self._sftp.stat(path)
304
except (IOError, paramiko.SSHException), x:
305
raise SFTPTransportError('Unable to stat %r' % (path,), x)
307
def lock_read(self, relpath):
309
Lock the given file for shared (read) access.
310
:return: A lock object, which should be passed to Transport.unlock()
312
# FIXME: there should be something clever i can do here...
313
class BogusLock(object):
314
def __init__(self, path):
318
return BogusLock(relpath)
320
def lock_write(self, relpath):
322
Lock the given file for exclusive (write) access.
323
WARNING: many transports do not support this, so trying avoid using it
325
:return: A lock object, which should be passed to Transport.unlock()
327
# FIXME: there should be something clever i can do here...
328
class BogusLock(object):
329
def __init__(self, path):
333
return BogusLock(relpath)
336
def _unparse_url(self, path=None):
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)
343
def _parse_url(self, url):
344
assert url[:7] == 'sftp://'
345
m = self._url_matcher.match(url)
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()
352
self._username = self._username[:-1]
353
if self._port is None:
356
self._port = int(self._port[1:])
357
if (self._path is None) or (self._path == ''):
360
def _sftp_connect(self):
361
global SYSTEM_HOSTKEYS, BZR_HOSTKEYS
366
t = paramiko.Transport((self._host, self._port))
368
except paramiko.SSHException:
369
raise SFTPTransportError('Unable to reach SSH host %s:%d' % (self._host, self._port))
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())
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())
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)])
395
self._sftp_auth(t, self._username, self._host)
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))
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()))
408
transport.auth_publickey(self._username, key)
410
except paramiko.SSHException, e:
413
# okay, try finding id_rsa or id_dss? (posix only)
414
if self._try_pkey_auth(transport, paramiko.RSAKey, 'id_rsa'):
416
if self._try_pkey_auth(transport, paramiko.DSSKey, 'id_dsa'):
419
# give up and ask for a password
420
password = getpass.getpass('SSH %s@%s password: ' % (self._username, self._host))
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))
427
def _try_pkey_auth(self, transport, pkey_class, filename):
428
filename = os.path.expanduser('~/.ssh/' + filename)
430
key = pkey_class.from_private_key_file(filename)
431
transport.auth_publickey(self._username, key)
433
except paramiko.PasswordRequiredException:
434
password = getpass.getpass('SSH %s password: ' % (os.path.basename(filename),))
436
key = pkey_class.from_private_key_file(filename, password)
437
transport.auth_publickey(self._username, key)
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),))
448
register_transport('sftp://', SFTPTransport)