Merge lp:~ed.so/duplicity/webdav.fix-retry into lp:duplicity/0.6

Proposed by edso
Status: Merged
Merged at revision: 904
Proposed branch: lp:~ed.so/duplicity/webdav.fix-retry
Merge into: lp:duplicity/0.6
Diff against target: 442 lines (+184/-48)
5 files modified
duplicity/backend.py (+25/-15)
duplicity/backends/webdavbackend.py (+144/-33)
duplicity/commandline.py (+5/-0)
duplicity/errors.py (+6/-0)
duplicity/globals.py (+4/-0)
To merge this branch: bzr merge lp:~ed.so/duplicity/webdav.fix-retry
Reviewer Review Type Date Requested Status
edso Approve
Review via email: mp+142759@code.launchpad.net

Commit message

webdav
- added ssl certificate verification (see man page)
- more robust retry routine to survive ssl errors, broken pipe errors
- added http redirect support

Description of the change

webdav
- added ssl certificate verification (see man page)
- more robust retry routine to survive ssl errors, broken pipe errors
- added http redirect support

To post a comment you must log in.
Revision history for this message
Kenneth Loafman (kenneth-loafman) wrote :

The 'Diff against target' has conflicts (see above). Needs to be fixed.

Revision history for this message
edso (ed.so) wrote :

would simply merging interim manpage changes in this branch solve this?

..ede

Revision history for this message
Kenneth Loafman (kenneth-loafman) wrote :

Possibly, bzr is fairly fussy about merges.

...Ken

On Fri, Jan 11, 2013 at 4:51 AM, edso <email address hidden> wrote:

> would simply merging interim manpage changes in this branch solve this?
>
> ..ede
> --
> https://code.launchpad.net/~ed.so/duplicity/webdav.fix-retry/+merge/142759
> You are subscribed to branch lp:duplicity.
>

lp:~ed.so/duplicity/webdav.fix-retry updated
904. By ede

move man page changes to a separate uptodate merge branch

Revision history for this message
edso (ed.so) wrote :

merge problem should be removed
please merge manpage branch as well

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'duplicity/backend.py'
2--- duplicity/backend.py 2012-12-12 17:45:38 +0000
3+++ duplicity/backend.py 2013-01-11 18:46:21 +0000
4@@ -42,7 +42,7 @@
5
6 from duplicity.util import exception_traceback
7
8-from duplicity.errors import BackendException
9+from duplicity.errors import BackendException, FatalBackendError
10 from duplicity.errors import TemporaryLoadException
11 from duplicity.errors import ConflictingScheme
12 from duplicity.errors import InvalidBackendURL
13@@ -333,26 +333,35 @@
14 # as we don't know what the underlying code comes up with and we really *do*
15 # want to retry globals.num_retries times under all circumstances
16 def retry_fatal(fn):
17- def iterate(*args):
18- for n in range(1, globals.num_retries):
19- try:
20- return fn(*args)
21- except Exception, e:
22- log.Warn("Attempt %s failed. %s: %s"
23- % (n, e.__class__.__name__, str(e)))
24- log.Debug("Backtrace of previous error: %s"
25- % exception_traceback())
26- time.sleep(10) # wait a bit before trying again
27+ def _retry_fatal(self, *args):
28+ try:
29+ n = 0
30+ for n in range(1, globals.num_retries):
31+ try:
32+ self.retry_count = n
33+ return fn(self, *args)
34+ except FatalBackendError, e:
35+ # die on fatal errors
36+ raise e
37+ except Exception, e:
38+ # retry on anything else
39+ log.Warn("Attempt %s failed. %s: %s"
40+ % (n, e.__class__.__name__, str(e)))
41+ log.Debug("Backtrace of previous error: %s"
42+ % exception_traceback())
43+ time.sleep(10) # wait a bit before trying again
44 # final trial, die on exception
45- try:
46- return fn(*args)
47+ self.retry_count = n+1
48+ return fn(self, *args)
49 except Exception, e:
50 log.FatalError("Giving up after %s attempts. %s: %s"
51- % (globals.num_retries, e.__class__.__name__, str(e)),
52+ % (self.retry_count, e.__class__.__name__, str(e)),
53 log.ErrorCode.backend_error)
54 log.Debug("Backtrace of previous error: %s"
55 % exception_traceback())
56- return iterate
57+ self.retry_count = 0
58+
59+ return _retry_fatal
60
61 class Backend:
62 """
63@@ -371,6 +380,7 @@
64
65 - move
66 """
67+
68 def __init__(self, parsed_url):
69 self.parsed_url = parsed_url
70
71
72=== modified file 'duplicity/backends/webdavbackend.py'
73--- duplicity/backends/webdavbackend.py 2012-12-09 14:36:19 +0000
74+++ duplicity/backends/webdavbackend.py 2013-01-11 18:46:21 +0000
75@@ -2,6 +2,8 @@
76 #
77 # Copyright 2002 Ben Escoto <ben@emerose.org>
78 # Copyright 2007 Kenneth Loafman <kenneth@loafman.com>
79+# Copyright 2013 Edgar Soldin
80+# - ssl cert verification, some robustness enhancements
81 #
82 # This file is part of duplicity.
83 #
84@@ -20,7 +22,7 @@
85 # Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
86
87 import base64
88-import httplib
89+import httplib, os
90 import re
91 import urllib
92 import urllib2
93@@ -46,6 +48,58 @@
94 def get_method(self):
95 return self.method
96
97+class VerifiedHTTPSConnection(httplib.HTTPSConnection):
98+ def __init__(self, *args, **kwargs):
99+ try:
100+ global socket, ssl
101+ import socket, ssl
102+ except ImportError:
103+ raise FatalBackendError("Missing socket or ssl libraries.")
104+
105+ httplib.HTTPSConnection.__init__(self, *args, **kwargs)
106+
107+ self.cacert_file = globals.ssl_cacert_file
108+ cacert_candidates = [ "~/.duplicity/cacert.pem", \
109+ "~/duplicity_cacert.pem", \
110+ "/etc/duplicity/cacert.pem" ]
111+ #
112+ if not self.cacert_file:
113+ for path in cacert_candidates :
114+ path = os.path.expanduser(path)
115+ if (os.path.isfile(path)):
116+ self.cacert_file = path
117+ break
118+ # still no cacert file, inform user
119+ if not self.cacert_file:
120+ raise FatalBackendError("""For certificate verification a cacert database file is needed in one of these locations: %s
121+Hints:
122+ Consult the man page, chapter 'SSL Certificate Verification'.
123+ Consider using the options --ssl-cacert-file, --ssl-no-check-certificate .""" % ", ".join(cacert_candidates) )
124+ # check if file is accessible (libssl errors are not very detailed)
125+ if not os.access(self.cacert_file, os.R_OK):
126+ raise FatalBackendError("Cacert database file '%s' is not readable." % cacert_file)
127+
128+ def connect(self):
129+ # create new socket
130+ sock = socket.create_connection((self.host, self.port),
131+ self.timeout)
132+ if self._tunnel_host:
133+ self.sock = sock
134+ self._tunnel()
135+
136+ # wrap the socket in ssl using verification
137+ self.sock = ssl.wrap_socket(sock,
138+ cert_reqs=ssl.CERT_REQUIRED,
139+ ca_certs=self.cacert_file,
140+ )
141+
142+ def request(self, *args, **kwargs):
143+ try:
144+ return httplib.HTTPSConnection.request(self, *args, **kwargs)
145+ except ssl.SSLError, e:
146+ # encapsulate ssl errors
147+ raise BackendException("SSL failed: %s" % str(e),log.ErrorCode.backend_error)
148+
149
150 class WebDAVBackend(duplicity.backend.Backend):
151 """Backend for accessing a WebDAV repository.
152@@ -70,23 +124,22 @@
153 self.digest_challenge = None
154 self.digest_auth_handler = None
155
156- if parsed_url.path:
157+ self.username = parsed_url.username
158+ self.password = self.get_password()
159+ self.directory = self._sanitize_path(parsed_url.path)
160+
161+ log.Info("Using WebDAV protocol %s" % (globals.webdav_proto,))
162+ log.Info("Using WebDAV host %s port %s" % (parsed_url.hostname, parsed_url.port))
163+ log.Info("Using WebDAV directory %s" % (self.directory,))
164+
165+ self.conn = None
166+
167+ def _sanitize_path(self,path):
168+ if path:
169 foldpath = re.compile('/+')
170- self.directory = foldpath.sub('/', parsed_url.path + '/' )
171- else:
172- self.directory = '/'
173-
174- log.Info("Using WebDAV host %s" % (parsed_url.hostname,))
175- log.Info("Using WebDAV port %s" % (parsed_url.port,))
176- log.Info("Using WebDAV directory %s" % (self.directory,))
177- log.Info("Using WebDAV protocol %s" % (globals.webdav_proto,))
178-
179- if parsed_url.scheme == 'webdav':
180- self.conn = httplib.HTTPConnection(parsed_url.hostname, parsed_url.port)
181- elif parsed_url.scheme == 'webdavs':
182- self.conn = httplib.HTTPSConnection(parsed_url.hostname, parsed_url.port)
183- else:
184- raise BackendException("Unknown URI scheme: %s" % (parsed_url.scheme))
185+ return foldpath.sub('/', path + '/' )
186+ else:
187+ return '/'
188
189 def _getText(self,nodelist):
190 rc = ""
191@@ -94,29 +147,76 @@
192 if node.nodeType == node.TEXT_NODE:
193 rc = rc + node.data
194 return rc
195+
196+ def _connect(self, forced=False):
197+ """
198+ Connect or re-connect to the server, updates self.conn
199+ # reconnect on errors as a precaution, there are errors e.g.
200+ # "[Errno 32] Broken pipe" or SSl errors that render the connection unusable
201+ """
202+ if self.retry_count<=1 and self.conn \
203+ and self.conn.host == self.parsed_url.hostname: return
204+
205+ log.Info("WebDAV create connection on '%s' (retry %s) " % (self.parsed_url.hostname,self.retry_count) )
206+ if self.conn: self.conn.close()
207+ # http schemes needed for redirect urls from servers
208+ if self.parsed_url.scheme in ['webdav','http']:
209+ self.conn = httplib.HTTPConnection(self.parsed_url.hostname, self.parsed_url.port)
210+ elif self.parsed_url.scheme in ['webdavs','https']:
211+ if globals.ssl_no_check_certificate:
212+ self.conn = httplib.HTTPSConnection(self.parsed_url.hostname, self.parsed_url.port)
213+ else:
214+ self.conn = VerifiedHTTPSConnection(self.parsed_url.hostname, self.parsed_url.port)
215+ else:
216+ raise FatalBackendError("WebDAV Unknown URI scheme: %s" % (self.parsed_url.scheme))
217
218 def close(self):
219 self.conn.close()
220
221- def request(self, method, path, data=None):
222+ def request(self, method, path, data=None, redirected=0):
223 """
224 Wraps the connection.request method to retry once if authentication is
225 required
226 """
227- quoted_path = urllib.quote(path)
228+ self._connect()
229+
230+ quoted_path = urllib.quote(path,"/:~")
231
232 if self.digest_challenge is not None:
233 self.headers['Authorization'] = self.get_digest_authorization(path)
234+
235+ log.Info("WebDAV %s %s request with headers: %s " % (method,quoted_path,self.headers))
236+ log.Info("WebDAV data length: %s " % len(str(data)) )
237 self.conn.request(method, quoted_path, data, self.headers)
238 response = self.conn.getresponse()
239- if response.status == 401:
240+ log.Info("WebDAV response status %s with reason '%s'." % (response.status,response.reason))
241+ # resolve redirects and reset url on listing requests (they usually come before everything else)
242+ if response.status in [301,302] and method == 'PROPFIND':
243+ redirect_url = response.getheader('location',None)
244+ response.close()
245+ if redirect_url:
246+ log.Notice("WebDAV redirect to: %s " % urllib.unquote(redirect_url) )
247+ if redirected > 10:
248+ raise FatalBackendError("WebDAV redirected 10 times. Giving up.")
249+ self.parsed_url = duplicity.backend.ParsedUrl(redirect_url)
250+ self.directory = self._sanitize_path(self.parsed_url.path)
251+ return self.request(method,self.directory,data,redirected+1)
252+ else:
253+ raise FatalBackendError("WebDAV missing location header in redirect response.")
254+ elif response.status == 401:
255 response.close()
256 self.headers['Authorization'] = self.get_authorization(response, quoted_path)
257+ log.Info("WebDAV retry request with authentification headers.")
258+ log.Info("WebDAV %s %s request2 with headers: %s " % (method,quoted_path,self.headers))
259+ log.Info("WebDAV data length: %s " % len(str(data)) )
260 self.conn.request(method, quoted_path, data, self.headers)
261 response = self.conn.getresponse()
262-
263+ log.Info("WebDAV response2 status %s with reason '%s'." % (response.status,response.reason))
264+
265 return response
266
267+
268+
269 def get_authorization(self, response, path):
270 """
271 Fetches the auth header based on the requested method (basic or digest)
272@@ -139,7 +239,7 @@
273 """
274 Returns the basic auth header
275 """
276- auth_string = '%s:%s' % (self.parsed_url.username, self.get_password())
277+ auth_string = '%s:%s' % (self.username, self.password)
278 return 'Basic %s' % base64.encodestring(auth_string).strip()
279
280 def get_digest_authorization(self, path):
281@@ -149,7 +249,7 @@
282 u = self.parsed_url
283 if self.digest_auth_handler is None:
284 pw_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
285- pw_manager.add_password(None, self.conn.host, u.username, self.get_password())
286+ pw_manager.add_password(None, self.conn.host, self.username, self.password)
287 self.digest_auth_handler = urllib2.HTTPDigestAuthHandler(pw_manager)
288
289 # building a dummy request that gets never sent,
290@@ -165,6 +265,7 @@
291 def list(self):
292 """List files in directory"""
293 log.Info("Listing directory %s on WebDAV server" % (self.directory,))
294+ response = None
295 try:
296 self.headers['Depth'] = "1"
297 response = self.request("PROPFIND", self.directory, self.listbody)
298@@ -178,7 +279,7 @@
299 response.close()
300 # just created folder is so return empty
301 return []
302- elif response.status == 207:
303+ elif response.status in [200, 207]:
304 document = response.read()
305 response.close()
306 else:
307@@ -195,9 +296,10 @@
308 if filename:
309 result.append(filename)
310 return result
311- except Exception, cause:
312- e = BackendException("Listing directory %s on WebDAV server failed. %s" % (self.directory,cause))
313+ except Exception, e:
314 raise e
315+ finally:
316+ if response: response.close()
317
318 def __taste_href(self, href):
319 """
320@@ -241,11 +343,15 @@
321 """Get remote filename, saving it to local_path"""
322 url = self.directory + remote_filename
323 log.Info("Retrieving %s from WebDAV server" % (url ,))
324+ response = None
325 try:
326 target_file = local_path.open("wb")
327 response = self.request("GET", url)
328 if response.status == 200:
329+ #data=response.read()
330 target_file.write(response.read())
331+ #import hashlib
332+ #log.Info("WebDAV GOT %s bytes with md5=%s" % (len(data),hashlib.md5(data).hexdigest()) )
333 assert not target_file.close()
334 local_path.setdata()
335 response.close()
336@@ -254,9 +360,10 @@
337 reason = response.reason
338 response.close()
339 raise BackendException("Bad status code %s reason %s." % (status,reason))
340- except Exception, cause:
341- e = BackendException("Getting %s from WebDAV server failed. %s" % (url,cause))
342+ except Exception, e:
343 raise e
344+ finally:
345+ if response: response.close()
346
347 @retry_fatal
348 def put(self, source_path, remote_filename = None):
349@@ -265,10 +372,11 @@
350 remote_filename = source_path.get_filename()
351 url = self.directory + remote_filename
352 log.Info("Saving %s on WebDAV server" % (url ,))
353+ response = None
354 try:
355 source_file = source_path.open("rb")
356 response = self.request("PUT", url, source_file.read())
357- if response.status == 201:
358+ if response.status in [201, 204]:
359 response.read()
360 response.close()
361 else:
362@@ -276,9 +384,10 @@
363 reason = response.reason
364 response.close()
365 raise BackendException("Bad status code %s reason %s." % (status,reason))
366- except Exception, cause:
367- e = BackendException("Putting %s on WebDAV server failed. %s" % (url,cause))
368+ except Exception, e:
369 raise e
370+ finally:
371+ if response: response.close()
372
373 @retry_fatal
374 def delete(self, filename_list):
375@@ -286,6 +395,7 @@
376 for filename in filename_list:
377 url = self.directory + filename
378 log.Info("Deleting %s from WebDAV server" % (url ,))
379+ response = None
380 try:
381 response = self.request("DELETE", url)
382 if response.status == 204:
383@@ -296,9 +406,10 @@
384 reason = response.reason
385 response.close()
386 raise BackendException("Bad status code %s reason %s." % (status,reason))
387- except Exception, cause:
388- e = BackendException("Deleting %s on WebDAV server failed. %s" % (url,cause))
389+ except Exception, e:
390 raise e
391+ finally:
392+ if response: response.close()
393
394 duplicity.backend.register_backend("webdav", WebDAVBackend)
395 duplicity.backend.register_backend("webdavs", WebDAVBackend)
396
397=== modified file 'duplicity/commandline.py'
398--- duplicity/commandline.py 2012-11-21 01:27:35 +0000
399+++ duplicity/commandline.py 2013-01-11 18:46:21 +0000
400@@ -502,6 +502,11 @@
401 # user added ssh options
402 parser.add_option("--ssh-options", action="extend", metavar=_("options"))
403
404+ # user added ssl options (webdav backend)
405+ parser.add_option("--ssl-cacert-file", metavar=_("pem formatted bundle of certificate authorities"))
406+
407+ parser.add_option("--ssl-no-check-certificate", action="store_true")
408+
409 # Working directory for the tempfile module. Defaults to /tmp on most systems.
410 parser.add_option("--tempdir", dest="temproot", type="file", metavar=_("path"))
411
412
413=== modified file 'duplicity/errors.py'
414--- duplicity/errors.py 2011-08-03 19:47:29 +0000
415+++ duplicity/errors.py 2013-01-11 18:46:21 +0000
416@@ -70,6 +70,12 @@
417 """
418 pass
419
420+class FatalBackendError(DuplicityError):
421+ """
422+ Raised to indicate a backend failed fatally.
423+ """
424+ pass
425+
426 class TemporaryLoadException(BackendException):
427 """
428 Raised to indicate a temporary issue on the backend.
429
430=== modified file 'duplicity/globals.py'
431--- duplicity/globals.py 2012-03-13 20:53:35 +0000
432+++ duplicity/globals.py 2013-01-11 18:46:21 +0000
433@@ -214,6 +214,10 @@
434 # whether to use scp for put/get, sftp is default
435 use_scp = False
436
437+# HTTPS ssl optons (currently only webdav)
438+ssl_cacert_file = None
439+ssl_no_check_certificate = False
440+
441 # user added rsync options
442 rsync_options = ""
443

Subscribers

People subscribed via source and target branches

to all changes: