Repository: certbot/certbot

Issue:
dns-rfc2136 challenges run infinitely for invalid DNS servers
1. Set `dns_rfc2136_server` to something invalid, e.g. `dns_rfc2136_server = 2001:db8::`.
2. Try to obtain certificates.
3. Challenges run forever.

CTRL+C during a challenge:
```
[... usual]
Performing the following challenges:
dns-01 challenge for [...]
dns-01 challenge for [...]
Encountered exception:
Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/certbot/auth_handler.py", line 75, in handle_authorizations
    resp = self._solve_challenges(aauthzrs)
  File "/usr/lib/python3.7/site-packages/certbot/auth_handler.py", line 139, in _solve_challenges
    resp = self.auth.perform(all_achalls)
  File "/usr/lib/python3.7/site-packages/certbot/plugins/dns_common.py", line 57, in perform
    self._perform(domain, validation_domain_name, validation)
  File "/usr/lib/python3.7/site-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 76, in _perform
    self._get_rfc2136_client().add_txt_record(validation_name, validation, self.ttl)
  File "/usr/lib/python3.7/site-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 112, in add_txt_record
    domain = self._find_domain(record_name)
  File "/usr/lib/python3.7/site-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 186, in _find_domain
    if self._query_soa(guess):
  File "/usr/lib/python3.7/site-packages/certbot_dns_rfc2136/dns_rfc2136.py", line 209, in _query_soa
    response = dns.query.udp(request, self.server, port=self.port)
  File "/usr/lib/python3.7/site-packages/dns/query.py", line 240, in udp
    _wait_for_readable(s, expiration)
  File "/usr/lib/python3.7/site-packages/dns/query.py", line 157, in _wait_for_readable
    _wait_for(s, True, False, True, expiration)
  File "/usr/lib/python3.7/site-packages/dns/query.py", line 131, in _wait_for
    if not _polling_backend(fd, readable, writable, error, timeout):
  File "/usr/lib/python3.7/site-packages/dns/query.py", line 87, in _poll_for
    event_list = pollable.poll()
KeyboardInterrupt
```

**Expected:**  
Certbot should set a reasonable query timeout ([this is supported by `dnspython`](https://stackoverflow.com/questions/8989457/dnspython-setting-query-timeout-lifetime)) and alert the user of an issue with the DNS server.


Discussion:
How would you recommend going about picking a reasonable query timeout? For some of our users, DNS propagation might take a while, and we don't want to cut off before that.
@ohemorange This isn't related to propagation. Per my understanding, the SOA is queried to get the information necessary for even adding/deleting the TXT record. If the SOA can't be queried in a reasonable time (probably 5s) then there's likely something wrong with the server and it ought to let the user know instead of waiting infinitely for a response that isn't coming.

Since the DNS server is queried over UDP, it would also be sensible to add a retry mechanism.
Hm maybe this is less about the delay & timeout and more about selecting the right server.
Getting the SOA record and from there determine the primary DNS (first field after SOA).
and try to query that server and maybe try a probe update (before starting to handle the actual challenge) to test if can be updated in reasonable time.

Resolving patch:
diff --git a/certbot-dns-rfc2136/certbot_dns_rfc2136/_internal/dns_rfc2136.py b/certbot-dns-rfc2136/certbot_dns_rfc2136/_internal/dns_rfc2136.py
--- a/certbot-dns-rfc2136/certbot_dns_rfc2136/_internal/dns_rfc2136.py
+++ b/certbot-dns-rfc2136/certbot_dns_rfc2136/_internal/dns_rfc2136.py
@@ -18,6 +18,7 @@
 
 logger = logging.getLogger(__name__)
 
+DEFAULT_NETWORK_TIMEOUT = 45
 
 @zope.interface.implementer(interfaces.IAuthenticator)
 @zope.interface.provider(interfaces.IPluginFactory)
@@ -91,13 +92,15 @@ class _RFC2136Client(object):
     """
     Encapsulates all communication with the target DNS server.
     """
-    def __init__(self, server, port, key_name, key_secret, key_algorithm):
+    def __init__(self, server, port, key_name, key_secret, key_algorithm,
+        timeout=DEFAULT_NETWORK_TIMEOUT):
         self.server = server
         self.port = port
         self.keyring = dns.tsigkeyring.from_text({
             key_name: key_secret
         })
         self.algorithm = key_algorithm
+        self._default_timeout = timeout
 
     def add_txt_record(self, record_name, record_content, record_ttl):
         """
@@ -122,7 +125,7 @@ def add_txt_record(self, record_name, record_content, record_ttl):
         update.add(rel, record_ttl, dns.rdatatype.TXT, record_content)
 
         try:
-            response = dns.query.tcp(update, self.server, port=self.port)
+            response = dns.query.tcp(update, self.server, self._default_timeout, self.port)
         except Exception as e:
             raise errors.PluginError('Encountered error adding TXT record: {0}'
                                      .format(e))
@@ -157,7 +160,7 @@ def del_txt_record(self, record_name, record_content):
         update.delete(rel, dns.rdatatype.TXT, record_content)
 
         try:
-            response = dns.query.tcp(update, self.server, port=self.port)
+            response = dns.query.tcp(update, self.server, self._default_timeout, self.port)
         except Exception as e:
             raise errors.PluginError('Encountered error deleting TXT record: {0}'
                                      .format(e))
@@ -207,10 +210,10 @@ def _query_soa(self, domain_name):
 
         try:
             try:
-                response = dns.query.tcp(request, self.server, port=self.port)
-            except OSError as e:
+                response = dns.query.tcp(request, self.server, self._default_timeout, self.port)
+            except (OSError, dns.exception.Timeout) as e:
                 logger.debug('TCP query failed, fallback to UDP: %s', e)
-                response = dns.query.udp(request, self.server, port=self.port)
+                response = dns.query.udp(request, self.server, self._default_timeout, self.port)
             rcode = response.rcode()
 
             # Authoritative Answer bit should be set


Test patch:
diff --git a/certbot-dns-rfc2136/tests/dns_rfc2136_test.py b/certbot-dns-rfc2136/tests/dns_rfc2136_test.py
--- a/certbot-dns-rfc2136/tests/dns_rfc2136_test.py
+++ b/certbot-dns-rfc2136/tests/dns_rfc2136_test.py
@@ -21,7 +21,7 @@
 NAME = 'a-tsig-key.'
 SECRET = 'SSB3b25kZXIgd2hvIHdpbGwgYm90aGVyIHRvIGRlY29kZSB0aGlzIHRleHQK'
 VALID_CONFIG = {"rfc2136_server": SERVER, "rfc2136_name": NAME, "rfc2136_secret": SECRET}
-
+TIMEOUT = 45
 
 class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):
 
@@ -78,7 +78,8 @@ class RFC2136ClientTest(unittest.TestCase):
     def setUp(self):
         from certbot_dns_rfc2136._internal.dns_rfc2136 import _RFC2136Client
 
-        self.rfc2136_client = _RFC2136Client(SERVER, PORT, NAME, SECRET, dns.tsig.HMAC_MD5)
+        self.rfc2136_client = _RFC2136Client(SERVER, PORT, NAME, SECRET, dns.tsig.HMAC_MD5,
+        TIMEOUT)
 
     @mock.patch("dns.query.tcp")
     def test_add_txt_record(self, query_mock):
@@ -88,7 +89,7 @@ def test_add_txt_record(self, query_mock):
 
         self.rfc2136_client.add_txt_record("bar", "baz", 42)
 
-        query_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
+        query_mock.assert_called_with(mock.ANY, SERVER, TIMEOUT, PORT)
         self.assertTrue("bar. 42 IN TXT \"baz\"" in str(query_mock.call_args[0][0]))
 
     @mock.patch("dns.query.tcp")
@@ -121,7 +122,7 @@ def test_del_txt_record(self, query_mock):
 
         self.rfc2136_client.del_txt_record("bar", "baz")
 
-        query_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
+        query_mock.assert_called_with(mock.ANY, SERVER, TIMEOUT, PORT)
         self.assertTrue("bar. 0 NONE TXT \"baz\"" in str(query_mock.call_args[0][0]))
 
     @mock.patch("dns.query.tcp")
@@ -173,7 +174,7 @@ def test_query_soa_found(self, query_mock):
         # _query_soa | pylint: disable=protected-access
         result = self.rfc2136_client._query_soa(DOMAIN)
 
-        query_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
+        query_mock.assert_called_with(mock.ANY, SERVER, TIMEOUT, PORT)
         self.assertTrue(result)
 
     @mock.patch("dns.query.tcp")
@@ -183,7 +184,7 @@ def test_query_soa_not_found(self, query_mock):
         # _query_soa | pylint: disable=protected-access
         result = self.rfc2136_client._query_soa(DOMAIN)
 
-        query_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
+        query_mock.assert_called_with(mock.ANY, SERVER, TIMEOUT, PORT)
         self.assertFalse(result)
 
     @mock.patch("dns.query.tcp")
@@ -206,8 +207,8 @@ def test_query_soa_fallback_to_udp(self, tcp_mock, udp_mock):
         # _query_soa | pylint: disable=protected-access
         result = self.rfc2136_client._query_soa(DOMAIN)
 
-        tcp_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
-        udp_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
+        tcp_mock.assert_called_with(mock.ANY, SERVER, TIMEOUT, PORT)
+        udp_mock.assert_called_with(mock.ANY, SERVER, TIMEOUT, PORT)
         self.assertTrue(result)