Repository: ctrlaltcoop/certbot-dns-hetzner

Issue:
Cleanup only removes the 1st TXT dns entry if more than 1 domain is given
If more than one domain name is given on issue, only the first entry is removed on cleanup.

e.g. 
```
sudo certbot --installer apache \
  --authenticator certbot-dns-hetzner:dns-hetzner \
  --certbot-dns-hetzner:dns-hetzner-credentials /etc/sysconfig/mycred.ini \
  -d host1.xx.yyy \
  -d host2.xx.yyy
```

This generates 2 challenge TXT dns entries, but at the end leads to:

```
Waiting for verification...
Cleaning up challenges
Starting new HTTPS connection (1): dns.hetzner.com
Encountered exception during recovery:
Traceback (most recent call last):
  File "/usr/lib/python2.7/site-packages/certbot/_internal/error_handler.py", line 125, in _call_registered
    self.funcs[-1]()
  File "/usr/lib/python2.7/site-packages/certbot/_internal/auth_handler.py", line 243, in _cleanup_challenges
    self.auth.cleanup(achalls)
  File "/usr/lib/python2.7/site-packages/certbot/plugins/dns_common.py", line 76, in cleanup
    self._cleanup(domain, validation_domain_name, validation)
  File "/usr/lib/python2.7/site-packages/certbot_dns_hetzner/dns_hetzner.py", line 71, in _cleanup
    self._get_hetzner_client().delete_record(record_id=self.record_id)
  File "/usr/lib/python2.7/site-packages/certbot_dns_hetzner/hetzner_client.py", line 106, in delete_record
    raise _MalformedResponseException('Status code not 200')
_MalformedResponseException: Received an unexpected response from Hetzner API:
Status code not 200
Created an SSL vhost at /etc/httpd/conf.d/vhost-le-ssl.conf
```

Then I need to cleanup the challenge manually from the DNS table.

OS is CentOS7.



Comments:

Patch:
diff --git a/certbot_dns_hetzner/dns_hetzner.py b/certbot_dns_hetzner/dns_hetzner.py
index 3c72c52..509dabb 100644
--- a/certbot_dns_hetzner/dns_hetzner.py
+++ b/certbot_dns_hetzner/dns_hetzner.py
@@ -23,7 +23,6 @@ class Authenticator(dns_common.DNSAuthenticator):
     """
 
     description = 'Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS).'
-    record_id = None
 
     def __init__(self, *args, **kwargs):
         super(Authenticator, self).__init__(*args, **kwargs)
@@ -49,14 +48,13 @@ class Authenticator(dns_common.DNSAuthenticator):
 
     def _perform(self, domain, validation_name, validation):
         try:
-            record_response = self._get_hetzner_client().add_record(
+            self._get_hetzner_client().add_record(
                 domain,
                 "TXT",
-                '{0}.'.format(validation_name),
+                self._fqdn_format(validation_name),
                 validation,
                 TTL
             )
-            self.record_id = record_response['record']['id']
         except (
                 _ZoneNotFoundException,
                 requests.ConnectionError,
@@ -67,8 +65,7 @@ class Authenticator(dns_common.DNSAuthenticator):
 
     def _cleanup(self, domain, validation_name, validation):
         try:
-            if self.record_id:
-                self._get_hetzner_client().delete_record(record_id=self.record_id)
+            self._get_hetzner_client().delete_record_by_name(domain, self._fqdn_format(validation_name))
         except (requests.ConnectionError, _NotAuthorizedException) as exception:
             raise errors.PluginError(exception)
 
@@ -76,3 +73,9 @@ class Authenticator(dns_common.DNSAuthenticator):
         return _HetznerClient(
             self.credentials.conf('api_token'),
         )
+
+    @staticmethod
+    def _fqdn_format(name):
+        if not name.endswith('.'):
+            return '{0}.'.format(name)
+        return name
diff --git a/certbot_dns_hetzner/fakes.py b/certbot_dns_hetzner/fakes.py
index 9b7c0d6..e22f1fa 100644
--- a/certbot_dns_hetzner/fakes.py
+++ b/certbot_dns_hetzner/fakes.py
@@ -12,6 +12,7 @@ FAKE_RECORD = {
 FAKE_DOMAIN = 'some.domain'
 FAKE_ZONE_ID = 'xyz'
 FAKE_RECORD_ID = 'zzz'
+FAKE_RECORD_NAME = 'thisisarecordname'
 
 FAKE_RECORD_RESPONSE = {
     "record": {
@@ -20,6 +21,24 @@ FAKE_RECORD_RESPONSE = {
     }
 }
 
+FAKE_RECORDS_RESPONSE_WITH_RECORD = {
+    "records": [
+        {
+            "id": FAKE_RECORD_ID,
+            "name": FAKE_RECORD_NAME,
+        }
+    ]
+}
+
+FAKE_RECORDS_RESPONSE_WITHOUT_RECORD = {
+    "records": [
+        {
+            "id": 'nottheoneuwant',
+            "name": "string",
+        }
+    ]
+}
+
 FAKE_ZONES_RESPONSE_WITH_DOMAIN = {
     "zones": [
         {
diff --git a/certbot_dns_hetzner/hetzner_client.py b/certbot_dns_hetzner/hetzner_client.py
index 0c55125..c8048c8 100644
--- a/certbot_dns_hetzner/hetzner_client.py
+++ b/certbot_dns_hetzner/hetzner_client.py
@@ -88,6 +88,22 @@ class _HetznerClient:
         except (ValueError, UnicodeDecodeError) as exception:
             raise _MalformedResponseException(exception)
 
+    def delete_record_by_name(self, domain, record_name):
+        """
+        Searches for a zone matching ``domain``, if found find and delete a record matching ``record_name`
+        Deletes record with ``record_id`` from your Hetzner Account
+        :param domain: Domain of zone in which the record should be found
+        :param record_name: ID of record to be deleted
+        :raises requests.exceptions.ConnectionError: If the API request fails
+        :raises ._MalformedResponseException: If the API response is not 200
+        :raises ._ZoneNotFoundException: If no zone is found matching ``domain``
+        :raises ._RecordNotFoundException: If no record is found matching ``record_name``
+        :raises ._NotAuthorizedException: If Hetzner does not accept the authorization credentials
+        """
+        zone_id = self._get_zone_id_by_domain(domain)
+        record_id = self._get_record_id_by_name(zone_id, record_name)
+        self.delete_record(record_id)
+
     def delete_record(self, record_id):
         """
         Deletes record with ``record_id`` from your Hetzner Account


Test patch:
diff --git a/certbot_dns_hetzner/dns_hetzner_test.py b/certbot_dns_hetzner/dns_hetzner_test.py
index 7456102..c754d2c 100644
--- a/certbot_dns_hetzner/dns_hetzner_test.py
+++ b/certbot_dns_hetzner/dns_hetzner_test.py
@@ -50,7 +50,6 @@ class AuthenticatorTest(
         self.mock_client.add_record.assert_called_with(
             DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY
         )
-        self.assertEqual(self.auth.record_id, FAKE_RECORD['record']['id'])
 
     def test_perform_but_raises_zone_not_found(self):
         self.mock_client.add_record.side_effect = mock.MagicMock(side_effect=_ZoneNotFoundException(DOMAIN))
@@ -69,7 +68,16 @@ class AuthenticatorTest(
         self.auth._attempt_cleanup = True
         self.auth.cleanup([self.achall])
 
-        self.mock_client.delete_record.assert_called_with(record_id=FAKE_RECORD['record']['id'])
+        self.mock_client.delete_record_by_name.assert_called_with(DOMAIN, '_acme-challenge.' + DOMAIN + '.')
+
+    def test_cleanup_but_connection_aborts(self):
+        self.mock_client.add_record.return_value = FAKE_RECORD
+        # _attempt_cleanup | pylint: disable=protected-access
+        self.auth.perform([self.achall])
+        self.auth._attempt_cleanup = True
+        self.auth.cleanup([self.achall])
+
+        self.mock_client.delete_record_by_name.assert_called_with(DOMAIN, '_acme-challenge.' + DOMAIN + '.')
 
 
 if __name__ == '__main__':
diff --git a/certbot_dns_hetzner/hetzner_client_test.py b/certbot_dns_hetzner/hetzner_client_test.py
index 4c41971..b0f55fa 100644
--- a/certbot_dns_hetzner/hetzner_client_test.py
+++ b/certbot_dns_hetzner/hetzner_client_test.py
@@ -1,3 +1,4 @@
+# pylint: disable=W0212
 """
 Test suite for _HetznerClient
 """
@@ -7,9 +8,10 @@ import requests
 import requests_mock
 
 from certbot_dns_hetzner.fakes import FAKE_API_TOKEN, FAKE_RECORD_RESPONSE, FAKE_DOMAIN, \
-    FAKE_ZONES_RESPONSE_WITH_DOMAIN, FAKE_ZONES_RESPONSE_WITHOUT_DOMAIN, FAKE_RECORD_ID
+    FAKE_ZONES_RESPONSE_WITH_DOMAIN, FAKE_ZONES_RESPONSE_WITHOUT_DOMAIN, FAKE_RECORD_ID, FAKE_ZONE_ID, \
+    FAKE_RECORDS_RESPONSE_WITH_RECORD, FAKE_RECORD_NAME, FAKE_RECORDS_RESPONSE_WITHOUT_RECORD
 from certbot_dns_hetzner.hetzner_client import HETZNER_API_ENDPOINT, _ZoneNotFoundException, \
-    _MalformedResponseException, _NotAuthorizedException
+    _MalformedResponseException, _NotAuthorizedException, _RecordNotFoundException
 
 
 class HetznerClientTest(unittest.TestCase):
@@ -21,6 +23,20 @@ class HetznerClientTest(unittest.TestCase):
         from certbot_dns_hetzner.dns_hetzner import _HetznerClient  # pylint: disable=import-outside-toplevel
         self.client = _HetznerClient(FAKE_API_TOKEN)
 
+    def test_get_zone_by_name(self):
+        with requests_mock.Mocker() as mock:
+            mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN)
+            zone_id = self.client._get_zone_id_by_domain(FAKE_DOMAIN)
+            self.assertEqual(zone_id, FAKE_ZONE_ID)
+
+    def test_get_zone_by_name_but_zone_response_is_garbage(self):
+        with requests_mock.Mocker() as mock:
+            mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, text='garbage')
+            self.assertRaises(
+                _MalformedResponseException,
+                self.client._get_zone_id_by_domain, FAKE_DOMAIN
+            )
+
     def test_add_record(self):
         with requests_mock.Mocker() as mock:
             mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN)
@@ -46,6 +62,14 @@ class HetznerClientTest(unittest.TestCase):
                 self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42
             )
 
+    def test_add_record_but_record_but_unauthorized(self):
+        with requests_mock.Mocker() as mock:
+            mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=401)
+            self.assertRaises(
+                _NotAuthorizedException,
+                self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42
+            )
+
     def test_add_record_but_zone_listing_is_401(self):
         with requests_mock.Mocker() as mock:
             mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=401)
@@ -84,3 +108,33 @@ class HetznerClientTest(unittest.TestCase):
                 _MalformedResponseException,
                 self.client.delete_record, FAKE_RECORD_ID
             )
+
+    def test_delete_record_by_name_and_found(self):
+        with requests_mock.Mocker() as mock:
+            mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN)
+            mock.get('{0}/records?zone_id={1}'.format(
+                HETZNER_API_ENDPOINT,
+                FAKE_ZONE_ID
+            ), status_code=200, json=FAKE_RECORDS_RESPONSE_WITH_RECORD)
+            mock.delete('{0}/records/{1}'.format(HETZNER_API_ENDPOINT, FAKE_RECORD_ID), status_code=200)
+            self.client.delete_record_by_name(FAKE_DOMAIN, FAKE_RECORD_NAME)
+
+    def test_delete_record_by_name_but_its_not_found(self):
+        with requests_mock.Mocker() as mock:
+            mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN)
+            mock.get('{0}/records?zone_id={1}'.format(
+                HETZNER_API_ENDPOINT,
+                FAKE_ZONE_ID
+            ), status_code=200, json=FAKE_RECORDS_RESPONSE_WITHOUT_RECORD)
+            self.assertRaises(
+                _RecordNotFoundException,
+                self.client.delete_record_by_name, FAKE_DOMAIN, FAKE_RECORD_NAME
+            )
+
+    def test_delete_record_by_name_but_zone_is_not_found(self):
+        with requests_mock.Mocker() as mock:
+            mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITHOUT_DOMAIN)
+            self.assertRaises(
+                _ZoneNotFoundException,
+                self.client.delete_record_by_name, FAKE_DOMAIN, FAKE_RECORD_NAME
+            )