Repository: certbot/certbot

Issue:
Detect & report on connectivity problems in verification
All sorts of things can go wrong in terms of the server being unable to connect to the client.  For instance, the DNS for a name may not actually point at the server, or the server may be behind a NAT or otherwall, or the server may have a local firewall that blocks inbound :443 connections.

We should, at minimum, provide a sensible error report when this occurs.



Discussion:

Resolving patch:
diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py
--- a/letsencrypt/auth_handler.py
+++ b/letsencrypt/auth_handler.py
@@ -3,12 +3,15 @@
 import logging
 import time
 
+import zope.component
+
 from acme import challenges
 from acme import messages
 
 from letsencrypt import achallenges
 from letsencrypt import constants
 from letsencrypt import errors
+from letsencrypt import interfaces
 
 
 class AuthHandler(object):
@@ -193,6 +196,7 @@ def _poll_challenges(
                             updated for _, updated in failed_achalls)
 
             if all_failed_achalls:
+                _report_failed_challs(all_failed_achalls)
                 raise errors.FailedChallenges(all_failed_achalls)
 
             dom_to_check -= comp_domains
@@ -480,3 +484,80 @@ def is_preferred(offered_challb, satisfied,
                 different=True):
             return False
     return True
+
+
+_ERROR_HELP_COMMON = (
+    "To fix these errors, please make sure that your domain name was entered "
+    "correctly and the DNS A/AAAA record(s) for that domain contains the "
+    "right IP address.")
+
+
+_ERROR_HELP = {
+    "connection" :
+        _ERROR_HELP_COMMON + " Additionally, please check that your computer "
+        "has publicly routable IP address and no firewalls are preventing the "
+        "server from communicating with the client.",
+    "dnssec" :
+        _ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for "
+        "your domain, please ensure the signature is valid.",
+    "malformed" :
+        "To fix these errors, please make sure that you did not provide any "
+        "invalid information to the client and try running Let's Encrypt "
+        "again.",
+    "serverInternal" :
+        "Unfortunately, an error on the ACME server prevented you from completing "
+        "authorization. Please try again later.",
+    "tls" :
+        _ERROR_HELP_COMMON + " Additionally, please check that you have an up "
+        "to date TLS configuration that allows the server to communicate with "
+        "the Let's Encrypt client.",
+    "unauthorized" : _ERROR_HELP_COMMON,
+    "unknownHost" : _ERROR_HELP_COMMON,}
+
+
+def _report_failed_challs(failed_achalls):
+    """Notifies the user about failed challenges.
+
+    :param set failed_achalls: A set of failed
+        :class:`letsencrypt.achallenges.AnnotatedChallenge`.
+
+    """
+    problems = dict()
+    for achall in failed_achalls:
+        if achall.error:
+            problems.setdefault(achall.error.typ, []).append(achall)
+
+    reporter = zope.component.getUtility(interfaces.IReporter)
+    for achalls in problems.itervalues():
+        reporter.add_message(
+            _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY, True)
+
+
+def _generate_failed_chall_msg(failed_achalls):
+    """Creates a user friendly error message about failed challenges.
+
+    :param list failed_achalls: A list of failed
+        :class:`letsencrypt.achallenges.AnnotatedChallenge` with the same error
+        type.
+
+    :returns: A formatted error message for the client.
+    :rtype: str
+
+    """
+    typ = failed_achalls[0].error.typ
+    msg = [
+        "The following '{0}' errors were reported by the server:".format(typ)]
+
+    problems = dict()
+    for achall in failed_achalls:
+        problems.setdefault(achall.error.description, set()).add(achall.domain)
+    for problem in problems:
+        msg.append("\n\nDomains: ")
+        msg.append(", ".join(sorted(problems[problem])))
+        msg.append("\nError: {0}".format(problem))
+
+    if typ in _ERROR_HELP:
+        msg.append("\n\n")
+        msg.append(_ERROR_HELP[typ])
+
+    return "".join(msg)
diff --git a/letsencrypt/reporter.py b/letsencrypt/reporter.py
--- a/letsencrypt/reporter.py
+++ b/letsencrypt/reporter.py
@@ -66,7 +66,8 @@ def print_messages(self):
 
         If there is an unhandled exception, only messages for which
         ``on_crash`` is ``True`` are printed.
-"""
+
+        """
         bold_on = False
         if not self.messages.empty():
             no_exception = sys.exc_info()[0] is None
@@ -74,14 +75,21 @@ def print_messages(self):
             if bold_on:
                 print self._BOLD
             print 'IMPORTANT NOTES:'
-            wrapper = textwrap.TextWrapper(initial_indent=' - ',
-                                           subsequent_indent=(' ' * 3))
+            first_wrapper = textwrap.TextWrapper(
+                initial_indent=' - ', subsequent_indent=(' ' * 3))
+            next_wrapper = textwrap.TextWrapper(
+                initial_indent=first_wrapper.subsequent_indent,
+                subsequent_indent=first_wrapper.subsequent_indent)
         while not self.messages.empty():
             msg = self.messages.get()
             if no_exception or msg.on_crash:
                 if bold_on and msg.priority > self.HIGH_PRIORITY:
                     sys.stdout.write(self._RESET)
                     bold_on = False
-                print wrapper.fill(msg.text)
+                lines = msg.text.splitlines()
+                print first_wrapper.fill(lines[0])
+                if len(lines) > 1:
+                    print "\n".join(
+                        next_wrapper.fill(line) for line in lines[1:])
         if bold_on:
             sys.stdout.write(self._RESET)


Test patch:
diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py
--- a/letsencrypt/tests/auth_handler_test.py
+++ b/letsencrypt/tests/auth_handler_test.py
@@ -216,7 +216,8 @@ def test_poll_challenges_failure_best_effort(self, unused_mock_time):
             self.assertEqual(authzr.body.status, messages.STATUS_PENDING)
 
     @mock.patch("letsencrypt.auth_handler.time")
-    def test_poll_challenges_failure(self, unused_mock_time):
+    @mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
+    def test_poll_challenges_failure(self, unused_mock_time, unused_mock_zope):
         self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid
         self.assertRaises(
             errors.AuthorizationError, self.handler._poll_challenges,
@@ -420,6 +421,54 @@ def test_mutually_exclusive_same_type(self):
             self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P])))
 
 
+class ReportFailedChallsTest(unittest.TestCase):
+    """Tests for letsencrypt.auth_handler._report_failed_challs."""
+    # pylint: disable=protected-access
+
+    def setUp(self):
+        from letsencrypt import achallenges
+
+        kwargs = {
+            "chall" : acme_util.SIMPLE_HTTP,
+            "uri": "uri",
+            "status": messages.STATUS_INVALID,
+            "error": messages.Error(typ="tls", detail="detail"),
+        }
+
+        self.simple_http = achallenges.SimpleHTTP(
+            challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
+            domain="example.com",
+            key=acme_util.KEY)
+
+        kwargs["chall"] = acme_util.DVSNI
+        self.dvsni_same = achallenges.DVSNI(
+            challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
+            domain="example.com",
+            key=acme_util.KEY)
+
+        kwargs["error"] = messages.Error(typ="dnssec", detail="detail")
+        self.dvsni_diff = achallenges.DVSNI(
+            challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
+            domain="foo.bar",
+            key=acme_util.KEY)
+
+    @mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
+    def test_same_error_and_domain(self, mock_zope):
+        from letsencrypt import auth_handler
+
+        auth_handler._report_failed_challs([self.simple_http, self.dvsni_same])
+        call_list = mock_zope().add_message.call_args_list
+        self.assertTrue(len(call_list) == 1)
+        self.assertTrue("Domains: example.com\n" in call_list[0][0][0])
+
+    @mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
+    def test_different_errors_and_domains(self, mock_zope):
+        from letsencrypt import auth_handler
+
+        auth_handler._report_failed_challs([self.simple_http, self.dvsni_diff])
+        self.assertTrue(mock_zope().add_message.call_count == 2)
+
+
 def gen_auth_resp(chall_list):
     """Generate a dummy authorization response."""
     return ["%s%s" % (chall.__class__.__name__, chall.domain)