Skip to content

Commit a9beb49

Browse files
author
Jonatan Zint
committed
Chore: Prepare 2.0 release
This commit introduces potentially breaking changes as it adjust this plugin to be compatible with certbot >=2.0 and implements best practices from certbot2. * Remove custom hetzner client in favor for lexicon implementation * Version of this plugin 2.0 is only compatible with certbot>=2.0 * General simplification
1 parent e3a68df commit a9beb49

File tree

16 files changed

+127
-639
lines changed

16 files changed

+127
-639
lines changed

.github/workflows/lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ jobs:
88
- name: Setup Python
99
uses: actions/setup-python@master
1010
with:
11-
python-version: '3.8'
11+
python-version: '3.11'
1212
- name: Dependencies
1313
run: |
1414
python -m pip install --upgrade pip
1515
pip install -e .
1616
- name: Linting
1717
run: |
18-
pip install pylint==2.4.4
18+
pip install pylint==2.15.8
1919
python -m pylint --reports=n --rcfile=.pylintrc certbot_dns_hetzner

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
- name: Publish Python Package
1313
uses: mariamrf/[email protected]
1414
with:
15-
python_version: '3.8.0'
15+
python_version: '3.11'
1616
env:
1717
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
1818
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}

.github/workflows/tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ jobs:
55
runs-on: ubuntu-latest
66
strategy:
77
matrix:
8-
python-version: [ '2.7', '3.6', '3.7', '3.8' ]
8+
python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ]
99
steps:
1010
- uses: actions/checkout@master
1111
- name: Setup Python
@@ -26,10 +26,10 @@ jobs:
2626
pip install pytest-cov
2727
pytest --cov=./ --cov-report=xml
2828
- name: Upload coverage to Codecov
29-
uses: codecov/codecov-action@v1
29+
uses: codecov/codecov-action@v3
3030
with:
3131
token: ${{ secrets.CODECOV_TOKEN }}
32-
file: ./coverage.xml
32+
files: ./coverage.xml
3333
flags: unittests
3434
name: codecov-umbrella
3535
fail_ci_if_error: true

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dist*/
1212
letsencrypt.log
1313
certbot.log
1414
letsencrypt-auto-source/letsencrypt-auto.sig.lzma.base64
15+
coverage.xml
1516

1617
# coverage
1718
.coverage

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ This certbot plugin automates the process of
88
completing a dns-01 challenge by creating, and
99
subsequently removing, TXT records using the Hetzner DNS API.
1010

11+
## Requirements
12+
13+
### For certbot < 2
14+
15+
Notice that this plugin is only supporting certbot>=2.0 from 2.0 onwards. For older certbot versions use 1.x releases.
16+
1117
## Install
1218

1319
Install this package via pip in the same python environment where you installed your certbot.

certbot_dns_hetzner/dns_hetzner.py

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
"""DNS Authenticator for Hetzner DNS."""
2-
import requests
3-
4-
5-
from certbot import errors
6-
from certbot.plugins import dns_common
7-
8-
from certbot_dns_hetzner.hetzner_client import \
9-
_MalformedResponseException, \
10-
_HetznerClient, \
11-
_ZoneNotFoundException, _NotAuthorizedException
2+
import tldextract
3+
from certbot.plugins import dns_common, dns_common_lexicon
4+
from lexicon.providers import hetzner
125

136
TTL = 60
147

@@ -18,60 +11,76 @@ class Authenticator(dns_common.DNSAuthenticator):
1811
This Authenticator uses the Hetzner DNS API to fulfill a dns-01 challenge.
1912
"""
2013

21-
description = 'Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS).'
14+
description = (
15+
"Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS)."
16+
)
2217

2318
def __init__(self, *args, **kwargs):
24-
super(Authenticator, self).__init__(*args, **kwargs)
19+
super().__init__(*args, **kwargs)
2520
self.credentials = None
2621

2722
@classmethod
2823
def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
29-
super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=60)
30-
add('credentials', help='Hetzner credentials INI file.')
24+
super(Authenticator, cls).add_parser_arguments(
25+
add, default_propagation_seconds=60
26+
)
27+
add("credentials", help="Hetzner credentials INI file.")
3128

32-
def more_info(self): # pylint: disable=missing-function-docstring,no-self-use
33-
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
34-
'the Hetzner API.'
29+
def more_info(self): # pylint: disable=missing-function-docstring
30+
return (
31+
"This plugin configures a DNS TXT record to respond to a dns-01 challenge using "
32+
+ "the Hetzner API."
33+
)
3534

3635
def _setup_credentials(self):
3736
self.credentials = self._configure_credentials(
38-
'credentials',
39-
'Hetzner credentials INI file',
37+
"credentials",
38+
"Hetzner credentials INI file",
4039
{
41-
'api_token': 'Hetzner API Token from \'https://dns.hetzner.com/settings/api-token\'',
42-
}
40+
"api_token": "Hetzner API Token from 'https://dns.hetzner.com/settings/api-token'",
41+
},
4342
)
4443

44+
@staticmethod
45+
def _get_zone(domain):
46+
zone_name = tldextract.extract(domain)
47+
return '.'.join([zone_name.domain, zone_name.suffix])
48+
4549
def _perform(self, domain, validation_name, validation):
46-
try:
47-
self._get_hetzner_client().add_record(
48-
domain,
49-
"TXT",
50-
self._fqdn_format(validation_name),
51-
validation,
52-
TTL
53-
)
54-
except (
55-
_ZoneNotFoundException,
56-
requests.ConnectionError,
57-
_MalformedResponseException,
58-
_NotAuthorizedException
59-
) as exception:
60-
raise errors.PluginError(exception)
50+
self._get_hetzner_client().add_txt_record(
51+
self._get_zone(domain),
52+
self._fqdn_format(validation_name),
53+
validation
54+
)
6155

6256
def _cleanup(self, domain, validation_name, validation):
63-
try:
64-
self._get_hetzner_client().delete_record_by_name(domain, self._fqdn_format(validation_name))
65-
except (requests.ConnectionError, _NotAuthorizedException) as exception:
66-
raise errors.PluginError(exception)
57+
self._get_hetzner_client().del_txt_record(
58+
self._get_zone(domain),
59+
self._fqdn_format(validation_name),
60+
validation
61+
)
6762

6863
def _get_hetzner_client(self):
69-
return _HetznerClient(
70-
self.credentials.conf('api_token'),
71-
)
64+
return _HetznerClient(self.credentials.conf("api_token"))
7265

7366
@staticmethod
7467
def _fqdn_format(name):
75-
if not name.endswith('.'):
76-
return '{0}.'.format(name)
68+
if not name.endswith("."):
69+
return f"{name}."
7770
return name
71+
72+
73+
class _HetznerClient(dns_common_lexicon.LexiconClient):
74+
"""
75+
Encapsulates all communication with the Hetzner via Lexicon.
76+
"""
77+
def __init__(self, auth_token):
78+
super().__init__()
79+
80+
config = dns_common_lexicon.build_lexicon_config('hetzner', {
81+
'ttl': TTL,
82+
}, {
83+
'auth_token': auth_token,
84+
})
85+
86+
self.provider = hetzner.Provider(config)
Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for certbot_dns_ispconfig.dns_ispconfig."""
1+
"""Tests for certbot_dns_hetzner.dns_hetzner."""
22

33
import unittest
44

@@ -10,85 +10,88 @@
1010
from certbot.plugins.dns_test_common import DOMAIN
1111
from certbot.tests import util as test_util
1212

13-
try:
14-
# certbot 1.18+
15-
patch_display_util = test_util.patch_display_util
16-
except AttributeError:
17-
# certbot <= 1.17
18-
patch_display_util = test_util.patch_get_utility
19-
2013
from certbot_dns_hetzner.fakes import FAKE_API_TOKEN, FAKE_RECORD
21-
from certbot_dns_hetzner.hetzner_client import _ZoneNotFoundException
14+
15+
16+
17+
patch_display_util = test_util.patch_display_util
2218

2319

2420
class AuthenticatorTest(
25-
test_util.TempDirTestCase,
26-
dns_test_common.BaseAuthenticatorTest
21+
test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest
2722
):
2823
"""
2924
Test for Hetzner DNS Authenticator
3025
"""
26+
3127
def setUp(self):
32-
super(AuthenticatorTest, self).setUp()
28+
super().setUp()
3329
from certbot_dns_hetzner.dns_hetzner import Authenticator # pylint: disable=import-outside-toplevel
3430

35-
path = os.path.join(self.tempdir, 'fake_credentials.ini')
31+
path = os.path.join(self.tempdir, "fake_credentials.ini")
3632
dns_test_common.write(
3733
{
38-
'hetzner_api_token': FAKE_API_TOKEN,
34+
"hetzner_api_token": FAKE_API_TOKEN,
3935
},
4036
path,
4137
)
4238

43-
super(AuthenticatorTest, self).setUp()
39+
super().setUp()
4440
self.config = mock.MagicMock(
4541
hetzner_credentials=path, hetzner_propagation_seconds=0
4642
) # don't wait during tests
4743

48-
self.auth = Authenticator(self.config, 'hetzner')
44+
self.auth = Authenticator(self.config, "hetzner")
4945

5046
self.mock_client = mock.MagicMock()
5147
# _get_ispconfig_client | pylint: disable=protected-access
52-
self.auth._get_hetzner_client = mock.MagicMock(return_value=self.mock_client)
48+
self.auth._get_hetzner_client = mock.MagicMock(
49+
return_value=self.mock_client)
5350

5451
@patch_display_util()
5552
def test_perform(self, unused_mock_get_utility):
56-
self.mock_client.add_record.return_value = FAKE_RECORD
53+
self.mock_client.add_txt_record.return_value = FAKE_RECORD
5754
self.auth.perform([self.achall])
58-
self.mock_client.add_record.assert_called_with(
59-
DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY
55+
self.mock_client.add_txt_record.assert_called_with(
56+
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
6057
)
6158

62-
def test_perform_but_raises_zone_not_found(self):
63-
self.mock_client.add_record.side_effect = mock.MagicMock(side_effect=_ZoneNotFoundException(DOMAIN))
64-
self.assertRaises(
65-
PluginError,
66-
self.auth.perform, [self.achall]
59+
def test_perform_but_raises_plugin_error(self):
60+
self.mock_client.add_txt_record.side_effect = mock.MagicMock(
61+
side_effect=PluginError()
6762
)
68-
self.mock_client.add_record.assert_called_with(
69-
DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY
63+
self.assertRaises(PluginError, self.auth.perform, [self.achall])
64+
self.mock_client.add_txt_record.assert_called_with(
65+
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
7066
)
7167

7268
@patch_display_util()
7369
def test_cleanup(self, unused_mock_get_utility):
74-
self.mock_client.add_record.return_value = FAKE_RECORD
70+
self.mock_client.add_txt_record.return_value = FAKE_RECORD
7571
# _attempt_cleanup | pylint: disable=protected-access
7672
self.auth.perform([self.achall])
7773
self.auth._attempt_cleanup = True
7874
self.auth.cleanup([self.achall])
7975

80-
self.mock_client.delete_record_by_name.assert_called_with(DOMAIN, '_acme-challenge.' + DOMAIN + '.')
76+
self.mock_client.del_txt_record.assert_called_with(
77+
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
78+
)
8179

8280
@patch_display_util()
83-
def test_cleanup_but_connection_aborts(self, unused_mock_get_utility):
84-
self.mock_client.add_record.return_value = FAKE_RECORD
81+
def test_cleanup_but_raises_plugin_error(self, unused_mock_get_utility):
82+
self.mock_client.add_txt_record.return_value = FAKE_RECORD
83+
self.mock_client.del_txt_record.side_effect = mock.MagicMock(
84+
side_effect=PluginError()
85+
)
8586
# _attempt_cleanup | pylint: disable=protected-access
8687
self.auth.perform([self.achall])
8788
self.auth._attempt_cleanup = True
88-
self.auth.cleanup([self.achall])
8989

90-
self.mock_client.delete_record_by_name.assert_called_with(DOMAIN, '_acme-challenge.' + DOMAIN + '.')
90+
self.assertRaises(PluginError, self.auth.cleanup, [self.achall])
91+
self.mock_client.del_txt_record.assert_called_with(
92+
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
93+
)
9194

9295

93-
if __name__ == '__main__':
96+
if __name__ == "__main__":
9497
unittest.main() # pragma: no cover

certbot_dns_hetzner/fakes.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22
Fakes needed for tests
33
"""
44

5-
FAKE_API_TOKEN = 'XXXXXXXXXXXXXXXXXXXxxx'
5+
FAKE_API_TOKEN = "XXXXXXXXXXXXXXXXXXXxxx"
66
FAKE_RECORD = {
77
"record": {
8-
'id': "123Fake",
8+
"id": "123Fake",
99
}
1010
}
1111

12-
FAKE_DOMAIN = 'some.domain'
13-
FAKE_ZONE_ID = 'xyz'
14-
FAKE_RECORD_ID = 'zzz'
15-
FAKE_RECORD_NAME = 'thisisarecordname'
12+
FAKE_DOMAIN = "some.domain"
13+
FAKE_ZONE_ID = "xyz"
14+
FAKE_RECORD_ID = "zzz"
15+
FAKE_RECORD_NAME = "thisisarecordname"
1616

1717
FAKE_RECORD_RESPONSE = {
1818
"record": {
@@ -33,7 +33,7 @@
3333
FAKE_RECORDS_RESPONSE_WITHOUT_RECORD = {
3434
"records": [
3535
{
36-
"id": 'nottheoneuwant',
36+
"id": "nottheoneuwant",
3737
"name": "string",
3838
}
3939
]

0 commit comments

Comments
 (0)