43
43
class SSHParamikoBackend(duplicity.backend.Backend):
44
44
"""This backend accesses files using the sftp or scp protocols.
45
It does not need any local client programs, but an ssh server and the sftp program must be installed on the remote
46
side (or with scp, the programs scp, ls, mkdir, rm and a POSIX-compliant shell).
45
It does not need any local client programs, but an ssh server and the sftp
46
program must be installed on the remote side (or with scp, the programs
47
scp, ls, mkdir, rm and a POSIX-compliant shell).
48
Authentication keys are requested from an ssh agent if present, then ~/.ssh/id_rsa/dsa are tried.
49
If -oIdentityFile=path is present in --ssh-options, then that file is also tried.
50
The passphrase for any of these keys is taken from the URI or FTP_PASSWORD.
51
If none of the above are available, password authentication is attempted (using the URI or FTP_PASSWORD).
49
Authentication keys are requested from an ssh agent if present, then
50
~/.ssh/id_rsa/dsa are tried. If -oIdentityFile=path is present in
51
--ssh-options, then that file is also tried. The passphrase for any of
52
these keys is taken from the URI or FTP_PASSWORD. If none of the above are
53
available, password authentication is attempted (using the URI or
53
56
Missing directories on the remote side will be created.
55
If scp is active then all operations on the remote side require passing arguments through a shell,
56
which introduces unavoidable quoting issues: directory and file names that contain single quotes will not work.
58
If scp is active then all operations on the remote side require passing
59
arguments through a shell, which introduces unavoidable quoting issues:
60
directory and file names that contain single quotes will not work.
57
61
This problem does not exist with sftp.
59
63
def __init__(self, parsed_url):
68
72
self.remote_dir = '.'
70
74
# lazily import paramiko when we need it
71
# debian squeeze's paramiko is a bit old, so we silence randompool depreciation warning
72
# note also: passphrased private keys work with squeeze's paramiko only if done with DES, not AES
75
# debian squeeze's paramiko is a bit old, so we silence randompool
76
# depreciation warning note also: passphrased private keys work with
77
# squeeze's paramiko only if done with DES, not AES
74
79
warnings.simplefilter("ignore")
80
85
Policy for showing a yes/no prompt and adding the hostname and new
81
86
host key to the known host file accordingly.
83
This class simply extends the AutoAddPolicy class with a yes/no prompt.
88
This class simply extends the AutoAddPolicy class with a yes/no
85
91
def missing_host_key(self, client, hostname, key):
86
92
fp = hexlify(key.get_fingerprint())
87
93
fingerprint = ':'.join(a + b for a, b in zip(fp[::2], fp[1::2]))
88
94
question = """The authenticity of host '%s' can't be established.
89
95
%s key fingerprint is %s.
90
Are you sure you want to continue connecting (yes/no)? """ % (hostname, key.get_name().upper(), fingerprint)
96
Are you sure you want to continue connecting (yes/no)? """ % (hostname,
97
key.get_name().upper(),
92
100
sys.stdout.write(question)
93
101
choice = raw_input().lower()
94
102
if choice in ['yes', 'y']:
95
paramiko.AutoAddPolicy.missing_host_key(self, client, hostname, key)
103
paramiko.AutoAddPolicy.missing_host_key(self, client,
97
106
elif choice in ['no', 'n']:
98
107
raise AuthenticityException(hostname)
102
111
class AuthenticityException (paramiko.SSHException):
103
112
def __init__(self, hostname):
104
paramiko.SSHException.__init__(self, 'Host key verification for server %s failed.' % hostname)
113
paramiko.SSHException.__init__(self,
114
'Host key verification for server %s failed.' %
106
117
self.client = paramiko.SSHClient()
107
118
self.client.set_missing_host_key_policy(AgreedAddPolicy())
115
126
ours.addHandler(dest)
117
128
# ..and the duplicity levels are neither linear,
118
# nor are the names compatible with python logging, eg. 'NOTICE'...WAAAAAH!
129
# nor are the names compatible with python logging,
130
# eg. 'NOTICE'...WAAAAAH!
119
131
plevel = logging.getLogger("duplicity").getEffectiveLevel()
121
133
wanted = logging.DEBUG
135
147
if os.path.isfile("/etc/ssh/ssh_known_hosts"):
136
148
self.client.load_system_host_keys("/etc/ssh/ssh_known_hosts")
137
149
except Exception as e:
138
raise BackendException("could not load /etc/ssh/ssh_known_hosts, maybe corrupt?")
150
raise BackendException("could not load /etc/ssh/ssh_known_hosts, "
140
153
# use load_host_keys() to signal it's writable to paramiko
141
154
# load if file exists or add filename to create it if needed
146
159
self.client._host_keys_filename = file
147
160
except Exception as e:
148
raise BackendException("could not load ~/.ssh/known_hosts, maybe corrupt?")
161
raise BackendException("could not load ~/.ssh/known_hosts, "
150
164
""" the next block reorganizes all host parameters into a
151
165
dictionary like SSHConfig does. this dictionary 'self.config'
156
170
self.config = {'hostname': parsed_url.hostname}
157
171
# get system host config entries
158
self.config.update(self.gethostconfig('/etc/ssh/ssh_config', parsed_url.hostname))
172
self.config.update(self.gethostconfig('/etc/ssh/ssh_config',
173
parsed_url.hostname))
159
174
# update with user's config file
160
self.config.update(self.gethostconfig('~/.ssh/config', parsed_url.hostname))
175
self.config.update(self.gethostconfig('~/.ssh/config',
176
parsed_url.hostname))
161
177
# update with url values
162
178
# username from url
163
179
if parsed_url.username:
175
191
self.config.update({'port': 22})
176
192
# parse ssh options for alternative ssh private key, identity file
177
m = re.search("^(?:.+\s+)?(?:-oIdentityFile=|-i\s+)(([\"'])([^\\2]+)\\2|[\S]+).*", globals.ssh_options)
193
m = re.search("^(?:.+\s+)?(?:-oIdentityFile=|-i\s+)(([\"'])([^\\2]+)\\2|[\S]+).*",
178
195
if (m is not None):
179
196
keyfilename = m.group(3) if m.group(3) else m.group(1)
180
197
self.config['identityfile'] = keyfilename
218
235
self.config['port'], e))
219
236
self.client.get_transport().set_keepalive((int)(globals.timeout / 2))
221
self.scheme = duplicity.backend.strip_prefix(parsed_url.scheme, 'paramiko')
238
self.scheme = duplicity.backend.strip_prefix(parsed_url.scheme,
222
240
self.use_scp = (self.scheme == 'scp')
252
270
self.sftp.mkdir(d)
253
271
except Exception as e:
254
raise BackendException("sftp mkdir %s failed: %s" % (self.sftp.normalize(".") + "/" + d, e))
272
raise BackendException("sftp mkdir %s failed: %s" %
273
(self.sftp.normalize(".") + "/" + d, e))
256
raise BackendException("sftp stat %s failed: %s" % (self.sftp.normalize(".") + "/" + d, e))
275
raise BackendException("sftp stat %s failed: %s" %
276
(self.sftp.normalize(".") + "/" + d, e))
258
278
self.sftp.chdir(d)
259
279
except Exception as e:
260
raise BackendException("sftp chdir to %s failed: %s" % (self.sftp.normalize(".") + "/" + d, e))
280
raise BackendException("sftp chdir to %s failed: %s" %
281
(self.sftp.normalize(".") + "/" + d, e))
262
283
def _put(self, source_path, remote_filename):
266
287
chan = self.client.get_transport().open_session()
267
288
chan.settimeout(globals.timeout)
268
chan.exec_command("scp -t '%s'" % self.remote_dir) # scp in sink mode uses the arg as base directory
289
# scp in sink mode uses the arg as base directory
290
chan.exec_command("scp -t '%s'" % self.remote_dir)
269
291
except Exception as e:
270
292
raise BackendException("scp execution failed: %s" % e)
271
# scp protocol: one 0x0 after startup, one after the Create meta, one after saving
272
# if there's a problem: 0x1 or 0x02 and some error text
293
# scp protocol: one 0x0 after startup, one after the Create meta,
294
# one after saving if there's a problem: 0x1 or 0x02 and some error
273
296
response = chan.recv(1)
274
297
if (response != "\0"):
275
298
raise BackendException("scp remote error: %s" % chan.recv(-1))
276
299
fstat = os.stat(source_path.name)
277
chan.send('C%s %d %s\n' % (oct(fstat.st_mode)[-4:], fstat.st_size, remote_filename))
300
chan.send('C%s %d %s\n' % (oct(fstat.st_mode)[-4:], fstat.st_size,
278
302
response = chan.recv(1)
279
303
if (response != "\0"):
280
304
raise BackendException("scp remote error: %s" % chan.recv(-1))
293
317
chan = self.client.get_transport().open_session()
294
318
chan.settimeout(globals.timeout)
295
chan.exec_command("scp -f '%s/%s'" % (self.remote_dir, remote_filename))
319
chan.exec_command("scp -f '%s/%s'" % (self.remote_dir,
296
321
except Exception as e:
297
322
raise BackendException("scp execution failed: %s" % e)
300
325
msg = chan.recv(-1)
301
326
m = re.match(r"C([0-7]{4})\s+(\d+)\s+(\S.*)$", msg)
302
327
if (m is None or m.group(3) != remote_filename):
303
raise BackendException("scp get %s failed: incorrect response '%s'" % (remote_filename, msg))
328
raise BackendException("scp get %s failed: incorrect response '%s'" %
329
(remote_filename, msg))
304
330
chan.recv(1) # dispose of the newline trailing the C message
306
332
size = int(m.group(2))
322
348
msg = chan.recv(1) # check the final status
324
raise BackendException("scp get %s failed: %s" % (remote_filename, chan.recv(-1)))
350
raise BackendException("scp get %s failed: %s" % (remote_filename,
326
353
chan.send('\0') # send final done indicator
332
359
# In scp mode unavoidable quoting issues will make this fail if the
333
360
# directory name contains single quotes.
335
output = self.runremote("ls -1 '%s'" % self.remote_dir, False, "scp dir listing ")
362
output = self.runremote("ls -1 '%s'" % self.remote_dir, False,
336
364
return output.splitlines()
338
366
return self.sftp.listdir()
341
369
# In scp mode unavoidable quoting issues will cause failures if
342
370
# filenames containing single quotes are encountered.
344
self.runremote("rm '%s/%s'" % (self.remote_dir, filename), False, "scp rm ")
372
self.runremote("rm '%s/%s'" % (self.remote_dir, filename), False,
346
375
self.sftp.remove(filename)
348
377
def runremote(self, cmd, ignoreexitcode=False, errorprefix=""):
349
"""small convenience function that opens a shell channel, runs remote command and returns
350
stdout of command. throws an exception if exit code!=0 and not ignored"""
378
"""small convenience function that opens a shell channel, runs remote
379
command and returns stdout of command. throws an exception if exit
380
code!=0 and not ignored"""
352
382
chan = self.client.get_transport().open_session()
353
383
chan.settimeout(globals.timeout)
357
387
output = chan.recv(-1)
358
388
res = chan.recv_exit_status()
359
389
if (res != 0 and not ignoreexitcode):
360
raise BackendException("%sfailed(%d): %s" % (errorprefix, res, chan.recv_stderr(4096)))
390
raise BackendException("%sfailed(%d): %s" % (errorprefix, res,
391
chan.recv_stderr(4096)))
363
394
def gethostconfig(self, file, host):