← Back to team overview

duplicity-team team mailing list archive

[Merge] lp:~ed.so/duplicity/webdav.lftp.ssl-overhaul into lp:duplicity

 

edso has proposed merging lp:~ed.so/duplicity/webdav.lftp.ssl-overhaul into lp:duplicity.

Requested reviews:
  duplicity-team (duplicity-team)

For more details, see:
https://code.launchpad.net/~ed.so/duplicity/webdav.lftp.ssl-overhaul/+merge/287654

duplicity.1, commandline.py, globals.py
- added --ssl-cacert-path parameter
backend.py
- make sure url path component is properly url decoded, 
  in case it contains special chars (eg. @ or space)
lftpbackend.py
- quote _all_ cmd line params
- added missing lftp+ftpes protocol
- fix empty list result when chdir failed silently
- added ssl_cacert_path support
webdavbackend.py
- add ssl default context support for python 2.7.9+
  (using system certs eg. in /etc/ssl/certs)
- added ssl_cacert_path support for python 2.7.9+
- gettext wrapped all log messages
- minor refinements
-- 
Your team duplicity-team is requested to review the proposed merge of lp:~ed.so/duplicity/webdav.lftp.ssl-overhaul into lp:duplicity.
=== modified file 'bin/duplicity.1'
--- bin/duplicity.1	2016-02-18 18:22:35 +0000
+++ bin/duplicity.1	2016-03-01 15:46:05 +0000
@@ -854,15 +854,23 @@
 
 .TP
 .BI "--ssl-cacert-file " file
-.B (only webdav backend)
+.B (only webdav & lftp backend)
 Provide a cacert file for ssl certificate verification.
 .br
 See also
 .BR "A NOTE ON SSL CERTIFICATE VERIFICATION" .
 
 .TP
+.BI "--ssl-cacert-path " path/to/certs/
+.B (only webdav backend and python 2.7.9+ OR lftp+webdavs and a recent lftp)
+Provide a path to a folder containing cacert files for ssl certificate verification.
+.br
+See also
+.BR "A NOTE ON SSL CERTIFICATE VERIFICATION" .
+
+.TP
 .BI --ssl-no-check-certificate
-.B (only webdav backend)
+.B (only webdav & lftp backend)
 Disable ssl certificate verification.
 .br
 See also
@@ -1873,8 +1881,16 @@
 an sftp service running on the backend server, which is sometimes not an option.
 
 .SH A NOTE ON SSL CERTIFICATE VERIFICATION
-Certificate verification as implemented right now [01.2013] only in the webdav backend needs a file
-based database of certification authority certificates (cacert file). It has to be a
+Certificate verification as implemented right now [02.2016] only in the webdav
+and lftp backends. older pythons 2.7.8- and older lftp binaries need a file
+based database of certification authority certificates (cacert file). 
+.br
+Newer python 2.7.9+ and recent lftp versions however support the system default
+certificates (usually in /etc/ssl/certs) and also giving an alternative ca cert
+folder via
+.BR --ssl-cacert-path .
+.PP
+The cacert file has to be a
 .B PEM
 formatted text file as currently provided by the
 .B CURL

=== modified file 'duplicity/backend.py'
--- duplicity/backend.py	2016-01-24 17:56:38 +0000
+++ duplicity/backend.py	2016-03-01 15:46:05 +0000
@@ -265,7 +265,7 @@
             raise InvalidBackendURL("Syntax error (netloc) in: %s" % url_string)
 
         try:
-            self.path = pu.path
+            self.path = urllib.unquote(pu.path)
         except Exception:
             raise InvalidBackendURL("Syntax error (path) in: %s" % url_string)
 

=== modified file 'duplicity/backends/lftpbackend.py'
--- duplicity/backends/lftpbackend.py	2016-01-24 17:30:02 +0000
+++ duplicity/backends/lftpbackend.py	2016-03-01 15:46:05 +0000
@@ -95,24 +95,21 @@
             cacert_candidates = ["~/.duplicity/cacert.pem",
                                  "~/duplicity_cacert.pem",
                                  "/etc/duplicity/cacert.pem"]
-            #
+            # look for a default cacert file
             if not self.cacert_file:
                 for path in cacert_candidates:
                     path = os.path.expanduser(path)
                     if (os.path.isfile(path)):
                         self.cacert_file = path
                         break
-            # still no cacert file, inform user
-            if not self.cacert_file:
-                raise duplicity.errors.FatalBackendException("""For certificate verification a cacert database file is needed in one of these locations: %s
-Hints:
-  Consult the man page, chapter 'SSL Certificate Verification'.
-  Consider using the options --ssl-cacert-file, --ssl-no-check-certificate .""" % ", ".join(cacert_candidates))
 
+        # save config into a reusable temp file
         self.tempfile, self.tempname = tempdir.default().mkstemp()
         os.write(self.tempfile, "set ssl:verify-certificate " + ("false" if globals.ssl_no_check_certificate else "true") + "\n")
-        if globals.ssl_cacert_file:
-            os.write(self.tempfile, "set ssl:ca-file '" + globals.ssl_cacert_file + "'\n")
+        if self.cacert_file:
+            os.write(self.tempfile, "set ssl:ca-file " + cmd_quote(self.cacert_file) + "\n")
+        if globals.ssl_cacert_path:
+            os.write(self.tempfile, "set ssl:ca-path " + cmd_quote(globals.ssl_cacert_path) + "\n")
         if self.parsed_url.scheme == 'ftps':
             os.write(self.tempfile, "set ftp:ssl-allow true\n")
             os.write(self.tempfile, "set ftp:ssl-protect-data true\n")
@@ -134,13 +131,14 @@
         else:
             os.write(self.tempfile, "open %s %s\n" % (self.authflag, self.url_string))
         os.close(self.tempfile)
+        # print settings in debug mode
         if log.getverbosity() >= log.DEBUG:
             f = open(self.tempname, 'r')
             log.Debug("SETTINGS: \n"
-                      "%s" % f.readlines())
+                      "%s" % f.read())
 
     def _put(self, source_path, remote_filename):
-        commandline = "lftp -c 'source %s; mkdir -p %s; put %s -o %s'" % (
+        commandline = "lftp -c \"source %s; mkdir -p %s; put %s -o %s\"" % (
             self.tempname,
             cmd_quote(self.remote_path),
             cmd_quote(source_path.name),
@@ -155,8 +153,8 @@
                   "%s" % (l))
 
     def _get(self, remote_filename, local_path):
-        commandline = "lftp -c 'source %s; get %s -o %s'" % (
-            self.tempname,
+        commandline = "lftp -c \"source %s; get %s -o %s\"" % (
+            cmd_quote(self.tempname),
             cmd_quote(self.remote_path) + remote_filename,
             cmd_quote(local_path.name)
         )
@@ -172,9 +170,11 @@
         # remote_dir = urllib.unquote(self.parsed_url.path.lstrip('/')).rstrip()
         remote_dir = urllib.unquote(self.parsed_url.path)
         # print remote_dir
-        commandline = "lftp -c 'source %s; cd %s || exit 0; ls'" % (
-            self.tempname,
-            cmd_quote(self.remote_path)
+        quoted_path = cmd_quote(self.remote_path);
+        # failing to cd into the folder might be because it was not created already 
+        commandline = "lftp -c \"source %s; ( cd %s && ls ) || ( mkdir -p %s && cd %s && ls )\"" % (
+            cmd_quote(self.tempname),
+            quoted_path, quoted_path, quoted_path
         )
         log.Debug("CMD: %s" % commandline)
         _, l, e = self.subprocess_popen(commandline)
@@ -187,10 +187,10 @@
         return [x.split()[-1] for x in l.split('\n') if x]
 
     def _delete(self, filename):
-        commandline = "lftp -c 'source %s; cd %s; rm %s'" % (
-            self.tempname,
+        commandline = "lftp -c \"source %s; cd %s; rm %s\"" % (
+            cmd_quote(self.tempname),
             cmd_quote(self.remote_path),
-            filename
+            cmd_quote(filename)
         )
         log.Debug("CMD: %s" % commandline)
         _, l, e = self.subprocess_popen(commandline)
@@ -204,10 +204,10 @@
 duplicity.backend.register_backend("fish", LFTPBackend)
 duplicity.backend.register_backend("ftpes", LFTPBackend)
 
-
 duplicity.backend.register_backend("lftp+ftp", LFTPBackend)
 duplicity.backend.register_backend("lftp+ftps", LFTPBackend)
 duplicity.backend.register_backend("lftp+fish", LFTPBackend)
+duplicity.backend.register_backend("lftp+ftpes", LFTPBackend)
 duplicity.backend.register_backend("lftp+sftp", LFTPBackend)
 duplicity.backend.register_backend("lftp+webdav", LFTPBackend)
 duplicity.backend.register_backend("lftp+webdavs", LFTPBackend)
@@ -216,7 +216,8 @@
 
 duplicity.backend.uses_netloc.extend(['ftp', 'ftps', 'fish', 'ftpes',
                                       'lftp+ftp', 'lftp+ftps',
-                                      'lftp+fish', 'lftp+sftp',
+                                      'lftp+fish', 'lftp+ftpes',
+                                      'lftp+sftp',
                                       'lftp+webdav', 'lftp+webdavs',
                                       'lftp+http', 'lftp+https']
                                      )

=== modified file 'duplicity/backends/webdavbackend.py'
--- duplicity/backends/webdavbackend.py	2016-02-05 09:58:57 +0000
+++ duplicity/backends/webdavbackend.py	2016-03-01 15:46:05 +0000
@@ -58,31 +58,25 @@
                 import socket
                 import ssl
             except ImportError:
-                raise FatalBackendException("Missing socket or ssl libraries.")
+                raise FatalBackendException(_("Missing socket or ssl python modules."))
 
             httplib.HTTPSConnection.__init__(self, *args, **kwargs)
 
             self.cacert_file = globals.ssl_cacert_file
-            cacert_candidates = ["~/.duplicity/cacert.pem",
+            self.cacert_candidates = ["~/.duplicity/cacert.pem",
                                  "~/duplicity_cacert.pem",
                                  "/etc/duplicity/cacert.pem"]
-            #
+            # if no cacert file was given search default locations
             if not self.cacert_file:
-                for path in cacert_candidates:
+                for path in self.cacert_candidates:
                     path = os.path.expanduser(path)
                     if (os.path.isfile(path)):
                         self.cacert_file = path
                         break
-            # still no cacert file, inform user
-            if not self.cacert_file:
-                raise FatalBackendException("""\
-For certificate verification a cacert database file is needed in one of these locations: %s
-Hints:
-  Consult the man page, chapter 'SSL Certificate Verification'.
-  Consider using the options --ssl-cacert-file, --ssl-no-check-certificate .""" % ", ".join(cacert_candidates))
+
             # check if file is accessible (libssl errors are not very detailed)
-            if not os.access(self.cacert_file, os.R_OK):
-                raise FatalBackendException("Cacert database file '%s' is not readable." % self.cacert_file)
+            if self.cacert_file and not os.access(self.cacert_file, os.R_OK):
+                raise FatalBackendException(_("Cacert database file '%s' is not readable.") % self.cacert_file)
 
         def connect(self):
             # create new socket
@@ -92,8 +86,24 @@
                 self.sock = sock
                 self.tunnel()
 
-            # wrap the socket in ssl using verification
-            self.sock = ssl.wrap_socket(sock,
+            # python 2.7.9+ supports default system certs now
+            if "create_default_context" in dir(ssl):
+                context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=self.cacert_file, capath=globals.ssl_cacert_path)
+                self.sock = context.wrap_socket(sock, server_hostname=self.host)
+            # the legacy way needing a cert file
+            else:
+                if globals.ssl_cacert_path:
+                    raise FatalBackendException(_("Option '--ssl-cacert-path' is not supported with python 2.7.8 and below."))
+                    
+                if not self.cacert_file:
+                    raise FatalBackendException(_("""\
+For certificate verification with python 2.7.8 or earlier a cacert database file is needed in one of these locations: %s
+Hints:
+  Consult the man page, chapter 'SSL Certificate Verification'.
+  Consider using the options --ssl-cacert-file, --ssl-no-check-certificate .""") % ", ".join(self.cacert_candidates))
+
+                # wrap the socket in ssl using verification
+                self.sock = ssl.wrap_socket(sock,
                                         cert_reqs=ssl.CERT_REQUIRED,
                                         ca_certs=self.cacert_file,
                                         )
@@ -129,9 +139,9 @@
         self.password = self.get_password()
         self.directory = self.sanitize_path(parsed_url.path)
 
-        log.Info("Using WebDAV protocol %s" % (globals.webdav_proto,))
-        log.Info("Using WebDAV host %s port %s" % (parsed_url.hostname, parsed_url.port))
-        log.Info("Using WebDAV directory %s" % (self.directory,))
+        log.Info(_("Using WebDAV protocol %s") % (globals.webdav_proto,))
+        log.Info(_("Using WebDAV host %s port %s") % (parsed_url.hostname, parsed_url.port))
+        log.Info(_("Using WebDAV directory %s") % (self.directory,))
 
         self.conn = None
 
@@ -162,7 +172,7 @@
                 and self.conn.host == self.parsed_url.hostname:
             return
 
-        log.Info("WebDAV create connection on '%s'" % (self.parsed_url.hostname))
+        log.Info(_("WebDAV create connection on '%s'") % (self.parsed_url.hostname))
         self._close()
         # http schemes needed for redirect urls from servers
         if self.parsed_url.scheme in ['webdav', 'http']:
@@ -173,7 +183,7 @@
             else:
                 self.conn = VerifiedHTTPSConnection(self.parsed_url.hostname, self.parsed_url.port)
         else:
-            raise FatalBackendException("WebDAV Unknown URI scheme: %s" % (self.parsed_url.scheme))
+            raise FatalBackendException(_("WebDAV Unknown URI scheme: %s") % (self.parsed_url.scheme))
 
     def _close(self):
         if self.conn:
@@ -192,34 +202,34 @@
         if self.digest_challenge is not None:
             self.headers['Authorization'] = self.get_digest_authorization(path)
 
-        log.Info("WebDAV %s %s request with headers: %s " % (method, quoted_path, self.headers))
-        log.Info("WebDAV data length: %s " % len(str(data)))
+        log.Info(_("WebDAV %s %s request with headers: %s ") % (method, quoted_path, self.headers))
+        log.Info(_("WebDAV data length: %s ") % len(str(data)))
         self.conn.request(method, quoted_path, data, self.headers)
         response = self.conn.getresponse()
-        log.Info("WebDAV response status %s with reason '%s'." % (response.status, response.reason))
+        log.Info(_("WebDAV response status %s with reason '%s'.") % (response.status, response.reason))
         # resolve redirects and reset url on listing requests (they usually come before everything else)
         if response.status in [301, 302] and method == 'PROPFIND':
             redirect_url = response.getheader('location', None)
             response.close()
             if redirect_url:
-                log.Notice("WebDAV redirect to: %s " % urllib.unquote(redirect_url))
+                log.Notice(_("WebDAV redirect to: %s ") % urllib.unquote(redirect_url))
                 if redirected > 10:
-                    raise FatalBackendException("WebDAV redirected 10 times. Giving up.")
+                    raise FatalBackendException(_("WebDAV redirected 10 times. Giving up."))
                 self.parsed_url = duplicity.backend.ParsedUrl(redirect_url)
                 self.directory = self.sanitize_path(self.parsed_url.path)
                 return self.request(method, self.directory, data, redirected + 1)
             else:
-                raise FatalBackendException("WebDAV missing location header in redirect response.")
+                raise FatalBackendException(_("WebDAV missing location header in redirect response."))
         elif response.status == 401:
             response.read()
             response.close()
             self.headers['Authorization'] = self.get_authorization(response, quoted_path)
-            log.Info("WebDAV retry request with authentification headers.")
-            log.Info("WebDAV %s %s request2 with headers: %s " % (method, quoted_path, self.headers))
-            log.Info("WebDAV data length: %s " % len(str(data)))
+            log.Info(_("WebDAV retry request with authentification headers."))
+            log.Info(_("WebDAV %s %s request2 with headers: %s ") % (method, quoted_path, self.headers))
+            log.Info(_("WebDAV data length: %s ") % len(str(data)))
             self.conn.request(method, quoted_path, data, self.headers)
             response = self.conn.getresponse()
-            log.Info("WebDAV response2 status %s with reason '%s'." % (response.status, response.reason))
+            log.Info(_("WebDAV response2 status %s with reason '%s'.") % (response.status, response.reason))
 
         return response
 
@@ -337,11 +347,11 @@
             log.Info("Checking existence dir %s: %d" % (d, response.status))
 
             if response.status == 404:
-                log.Info("Creating missing directory %s" % d)
+                log.Info(_("Creating missing directory %s") % d)
 
                 res = self.request("MKCOL", d)
                 if res.status != 201:
-                    raise BackendException("WebDAV MKCOL %s failed: %s %s" % (d, res.status, res.reason))
+                    raise BackendException(_("WebDAV MKCOL %s failed: %s %s") % (d, res.status, res.reason))
 
     def taste_href(self, href):
         """
@@ -353,8 +363,8 @@
         raw_filename = self.getText(href.childNodes).strip()
         parsed_url = urlparse.urlparse(urllib.unquote(raw_filename))
         filename = parsed_url.path
-        log.Debug("webdav path decoding and translation: "
-                  "%s -> %s" % (raw_filename, filename))
+        log.Debug(_("WebDAV path decoding and translation: "
+                  "%s -> %s") % (raw_filename, filename))
 
         # at least one WebDAV server returns files in the form
         # of full URL:s. this may or may not be
@@ -397,7 +407,7 @@
                 status = response.status
                 reason = response.reason
                 response.close()
-                raise BackendException("Bad status code %s reason %s." % (status, reason))
+                raise BackendException(_("WebDAV GET Bad status code %s reason %s.") % (status, reason))
         except Exception as e:
             raise e
         finally:
@@ -418,7 +428,7 @@
                 status = response.status
                 reason = response.reason
                 response.close()
-                raise BackendException("Bad status code %s reason %s." % (status, reason))
+                raise BackendException(_("WebDAV PUT Bad status code %s reason %s.") % (status, reason))
         except Exception as e:
             raise e
         finally:
@@ -437,7 +447,7 @@
                 status = response.status
                 reason = response.reason
                 response.close()
-                raise BackendException("Bad status code %s reason %s." % (status, reason))
+                raise BackendException(_("WebDAV DEL Bad status code %s reason %s.") % (status, reason))
         except Exception as e:
             raise e
         finally:

=== modified file 'duplicity/commandline.py'
--- duplicity/commandline.py	2016-02-10 17:15:49 +0000
+++ duplicity/commandline.py	2016-03-01 15:46:05 +0000
@@ -582,9 +582,9 @@
     # user added ssh options
     parser.add_option("--ssh-options", action="extend", metavar=_("options"))
 
-    # user added ssl options (webdav backend)
+    # user added ssl options (used by webdav, lftp backend)
     parser.add_option("--ssl-cacert-file", metavar=_("pem formatted bundle of certificate authorities"))
-
+    parser.add_option("--ssl-cacert-path", metavar=_("path to a folder with certificate authority files"))
     parser.add_option("--ssl-no-check-certificate", action="store_true")
 
     # Working directory for the tempfile module. Defaults to /tmp on most systems.

=== modified file 'duplicity/globals.py'
--- duplicity/globals.py	2015-10-10 00:02:35 +0000
+++ duplicity/globals.py	2016-03-01 15:46:05 +0000
@@ -242,8 +242,9 @@
 # default cf backend is pyrax
 cf_backend = "pyrax"
 
-# HTTPS ssl optons (currently only webdav)
+# HTTPS ssl options (currently only webdav, lftp)
 ssl_cacert_file = None
+ssl_cacert_path = None
 ssl_no_check_certificate = False
 
 # user added rsync options


Follow ups