~mwilck/duplicity/duplicity

« back to all changes in this revision

Viewing changes to duplicity/backends/ssh_paramiko_backend.py

  • Committer: Aaron A Whitehouse
  • Date: 2016-07-02 17:12:58 UTC
  • mfrom: (1111 duplicity-src8)
  • mto: This revision was merged to the branch mainline in revision 1113.
  • Revision ID: lists@whitehouse.kiwi.nz-20160702171258-xhnf5v5g542fcfhq
Merge with trunk.

Show diffs side-by-side

added added

removed removed

Lines of Context:
42
42
 
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).
47
48
 
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
 
54
    FTP_PASSWORD).
52
55
 
53
56
    Missing directories on the remote side will be created.
54
57
 
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.
58
62
    """
59
63
    def __init__(self, parsed_url):
68
72
            self.remote_dir = '.'
69
73
 
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
73
78
        import warnings
74
79
        warnings.simplefilter("ignore")
75
80
        import paramiko
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.
82
87
 
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
 
89
            prompt.
84
90
            """
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(),
 
98
                                                              fingerprint)
91
99
                while True:
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,
 
104
                                                                hostname, key)
96
105
                        return
97
106
                    elif choice in ['no', 'n']:
98
107
                        raise AuthenticityException(hostname)
101
110
 
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.' %
 
115
                                               hostname)
105
116
 
106
117
        self.client = paramiko.SSHClient()
107
118
        self.client.set_missing_host_key_policy(AgreedAddPolicy())
115
126
        ours.addHandler(dest)
116
127
 
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()
120
132
        if plevel <= 1:
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, "
 
151
                                   "maybe corrupt?")
139
152
        try:
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
145
158
            else:
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, "
 
162
                                   "maybe corrupt?")
149
163
 
150
164
        """ the next block reorganizes all host parameters into a
151
165
        dictionary like SSHConfig does. this dictionary 'self.config'
155
169
        """
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:
174
190
        else:
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]+).*",
 
194
                      globals.ssh_options)
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))
220
237
 
221
 
        self.scheme = duplicity.backend.strip_prefix(parsed_url.scheme, 'paramiko')
 
238
        self.scheme = duplicity.backend.strip_prefix(parsed_url.scheme,
 
239
                                                     'paramiko')
222
240
        self.use_scp = (self.scheme == 'scp')
223
241
 
224
242
        # scp or sftp?
251
269
                            try:
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))
255
274
                        else:
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))
257
277
                    try:
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))
261
282
 
262
283
    def _put(self, source_path, remote_filename):
263
284
        if self.use_scp:
265
286
            try:
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
 
295
            # text
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,
 
301
                                       remote_filename))
278
302
            response = chan.recv(1)
279
303
            if (response != "\0"):
280
304
                raise BackendException("scp remote error: %s" % chan.recv(-1))
292
316
            try:
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,
 
320
                                                      remote_filename))
296
321
            except Exception as e:
297
322
                raise BackendException("scp execution failed: %s" % e)
298
323
 
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
305
331
 
306
332
            size = int(m.group(2))
321
347
 
322
348
            msg = chan.recv(1)  # check the final status
323
349
            if msg != '\0':
324
 
                raise BackendException("scp get %s failed: %s" % (remote_filename, chan.recv(-1)))
 
350
                raise BackendException("scp get %s failed: %s" % (remote_filename,
 
351
                                                                  chan.recv(-1)))
325
352
            f.close()
326
353
            chan.send('\0')  # send final done indicator
327
354
            chan.close()
332
359
        # In scp mode unavoidable quoting issues will make this fail if the
333
360
        # directory name contains single quotes.
334
361
        if self.use_scp:
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,
 
363
                                    "scp dir listing ")
336
364
            return output.splitlines()
337
365
        else:
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.
343
371
        if self.use_scp:
344
 
            self.runremote("rm '%s/%s'" % (self.remote_dir, filename), False, "scp rm ")
 
372
            self.runremote("rm '%s/%s'" % (self.remote_dir, filename), False,
 
373
                           "scp rm ")
345
374
        else:
346
375
            self.sftp.remove(filename)
347
376
 
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"""
351
381
        try:
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)))
361
392
        return output
362
393
 
363
394
    def gethostconfig(self, file, host):