Repository: certbot/certbot

Issue:
dns-rfc2136 is unusable if packet loss present
certbot_dns_rfc2136 authenticator is not robust on a losing network.

During authenticating, the plugin have to send several SOA queries directly to the authoritative nameserver.  The number of queries is depended on the specific configuration. In my case, it sends out 21 queries for a single certification with 6 dns-alt names.

Currently, it sends them out using UDP **without timeout** and without retry mechanism. Thus, any single packet lost on these queries will cause the certbot stuck forever.

https://github.com/certbot/certbot/blob/3c24ff88cc0106ac39e5b0f5bd6bf0f29572201e/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136.py#L209-L210

In my case, the network between my webserver and the nameserver regularly get a ~20% packet loss rate (since one of them is in China). The chance I pass the authentication is around 0.8 ^ 21 < 1%.

## Proposed solution

1. Add a timeout with proper retry mechanism; or
2. Simply use TCP and let the OS handle it for us.
dns-rfc2136 is unusable if packet loss present
certbot_dns_rfc2136 authenticator is not robust on a losing network.

During authenticating, the plugin have to send several SOA queries directly to the authoritative nameserver.  The number of queries is depended on the specific configuration. In my case, it sends out 21 queries for a single certification with 6 dns-alt names.

Currently, it sends them out using UDP **without timeout** and without retry mechanism. Thus, any single packet lost on these queries will cause the certbot stuck forever.

https://github.com/certbot/certbot/blob/3c24ff88cc0106ac39e5b0f5bd6bf0f29572201e/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136.py#L209-L210

In my case, the network between my webserver and the nameserver regularly get a ~20% packet loss rate (since one of them is in China). The chance I pass the authentication is around 0.8 ^ 21 < 1%.

## Proposed solution

1. Add a timeout with proper retry mechanism; or
2. Simply use TCP and let the OS handle it for us.


Discussion:

Resolving patch:
diff --git a/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136.py b/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136.py
--- a/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136.py
+++ b/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136.py
@@ -206,7 +206,11 @@ def _query_soa(self, domain_name):
         request.flags ^= dns.flags.RD
 
         try:
-            response = dns.query.udp(request, self.server, port=self.port)
+            try:
+                response = dns.query.tcp(request, self.server, port=self.port)
+            except OSError as e:
+                logger.debug('TCP query failed, fallback to UDP: %s', e)
+                response = dns.query.udp(request, self.server, port=self.port)
             rcode = response.rcode()
 
             # Authoritative Answer bit should be set


Test patch:
diff --git a/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136_test.py b/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136_test.py
--- a/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136_test.py
+++ b/certbot-dns-rfc2136/certbot_dns_rfc2136/dns_rfc2136_test.py
@@ -162,7 +162,7 @@ def test_find_domain_wraps_errors(self):
             self.rfc2136_client._find_domain,
             'foo.bar.'+DOMAIN)
 
-    @mock.patch("dns.query.udp")
+    @mock.patch("dns.query.tcp")
     def test_query_soa_found(self, query_mock):
         query_mock.return_value = mock.MagicMock(answer=[mock.MagicMock()], flags=dns.flags.AA)
         query_mock.return_value.rcode.return_value = dns.rcode.NOERROR
@@ -173,7 +173,7 @@ def test_query_soa_found(self, query_mock):
         query_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
         self.assertTrue(result)
 
-    @mock.patch("dns.query.udp")
+    @mock.patch("dns.query.tcp")
     def test_query_soa_not_found(self, query_mock):
         query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN
 
@@ -183,7 +183,7 @@ def test_query_soa_not_found(self, query_mock):
         query_mock.assert_called_with(mock.ANY, SERVER, port=PORT)
         self.assertFalse(result)
 
-    @mock.patch("dns.query.udp")
+    @mock.patch("dns.query.tcp")
     def test_query_soa_wraps_errors(self, query_mock):
         query_mock.side_effect = Exception
 
@@ -193,6 +193,20 @@ def test_query_soa_wraps_errors(self, query_mock):
             self.rfc2136_client._query_soa,
             DOMAIN)
 
+    @mock.patch("dns.query.udp")
+    @mock.patch("dns.query.tcp")
+    def test_query_soa_fallback_to_udp(self, tcp_mock, udp_mock):
+        tcp_mock.side_effect = OSError
+        udp_mock.return_value = mock.MagicMock(answer=[mock.MagicMock()], flags=dns.flags.AA)
+        udp_mock.return_value.rcode.return_value = dns.rcode.NOERROR
+
+        # _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)
+        self.assertTrue(result)
+
 
 if __name__ == "__main__":
     unittest.main()  # pragma: no cover