Skip to content

Commit

Permalink
Merge pull request #20 from ctrlaltcoop/2.0-dev
Browse files Browse the repository at this point in the history
Release 2.0
  • Loading branch information
Jonatan Zint authored Dec 12, 2022
2 parents f7cd70a + a9beb49 commit 3504a7d
Show file tree
Hide file tree
Showing 16 changed files with 127 additions and 639 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ jobs:
- name: Setup Python
uses: actions/setup-python@master
with:
python-version: '3.8'
python-version: '3.11'
- name: Dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
- name: Linting
run: |
pip install pylint==2.4.4
pip install pylint==2.15.8
python -m pylint --reports=n --rcfile=.pylintrc certbot_dns_hetzner
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- name: Publish Python Package
uses: mariamrf/[email protected]
with:
python_version: '3.8.0'
python_version: '3.11'
env:
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ '2.7', '3.6', '3.7', '3.8' ]
python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ]
steps:
- uses: actions/checkout@master
- name: Setup Python
Expand All @@ -26,10 +26,10 @@ jobs:
pip install pytest-cov
pytest --cov=./ --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dist*/
letsencrypt.log
certbot.log
letsencrypt-auto-source/letsencrypt-auto.sig.lzma.base64
coverage.xml

# coverage
.coverage
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ This certbot plugin automates the process of
completing a dns-01 challenge by creating, and
subsequently removing, TXT records using the Hetzner DNS API.

## Requirements

### For certbot < 2

Notice that this plugin is only supporting certbot>=2.0 from 2.0 onwards. For older certbot versions use 1.x releases.

## Install

Install this package via pip in the same python environment where you installed your certbot.
Expand Down
99 changes: 54 additions & 45 deletions certbot_dns_hetzner/dns_hetzner.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
"""DNS Authenticator for Hetzner DNS."""
import requests


from certbot import errors
from certbot.plugins import dns_common

from certbot_dns_hetzner.hetzner_client import \
_MalformedResponseException, \
_HetznerClient, \
_ZoneNotFoundException, _NotAuthorizedException
import tldextract
from certbot.plugins import dns_common, dns_common_lexicon
from lexicon.providers import hetzner

TTL = 60

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

description = 'Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS).'
description = (
"Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS)."
)

def __init__(self, *args, **kwargs):
super(Authenticator, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.credentials = None

@classmethod
def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=60)
add('credentials', help='Hetzner credentials INI file.')
super(Authenticator, cls).add_parser_arguments(
add, default_propagation_seconds=60
)
add("credentials", help="Hetzner credentials INI file.")

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

def _setup_credentials(self):
self.credentials = self._configure_credentials(
'credentials',
'Hetzner credentials INI file',
"credentials",
"Hetzner credentials INI file",
{
'api_token': 'Hetzner API Token from \'https://dns.hetzner.com/settings/api-token\'',
}
"api_token": "Hetzner API Token from 'https://dns.hetzner.com/settings/api-token'",
},
)

@staticmethod
def _get_zone(domain):
zone_name = tldextract.extract(domain)
return '.'.join([zone_name.domain, zone_name.suffix])

def _perform(self, domain, validation_name, validation):
try:
self._get_hetzner_client().add_record(
domain,
"TXT",
self._fqdn_format(validation_name),
validation,
TTL
)
except (
_ZoneNotFoundException,
requests.ConnectionError,
_MalformedResponseException,
_NotAuthorizedException
) as exception:
raise errors.PluginError(exception)
self._get_hetzner_client().add_txt_record(
self._get_zone(domain),
self._fqdn_format(validation_name),
validation
)

def _cleanup(self, domain, validation_name, validation):
try:
self._get_hetzner_client().delete_record_by_name(domain, self._fqdn_format(validation_name))
except (requests.ConnectionError, _NotAuthorizedException) as exception:
raise errors.PluginError(exception)
self._get_hetzner_client().del_txt_record(
self._get_zone(domain),
self._fqdn_format(validation_name),
validation
)

def _get_hetzner_client(self):
return _HetznerClient(
self.credentials.conf('api_token'),
)
return _HetznerClient(self.credentials.conf("api_token"))

@staticmethod
def _fqdn_format(name):
if not name.endswith('.'):
return '{0}.'.format(name)
if not name.endswith("."):
return f"{name}."
return name


class _HetznerClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the Hetzner via Lexicon.
"""
def __init__(self, auth_token):
super().__init__()

config = dns_common_lexicon.build_lexicon_config('hetzner', {
'ttl': TTL,
}, {
'auth_token': auth_token,
})

self.provider = hetzner.Provider(config)
71 changes: 37 additions & 34 deletions certbot_dns_hetzner/dns_hetzner_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Tests for certbot_dns_ispconfig.dns_ispconfig."""
"""Tests for certbot_dns_hetzner.dns_hetzner."""

import unittest

Expand All @@ -10,85 +10,88 @@
from certbot.plugins.dns_test_common import DOMAIN
from certbot.tests import util as test_util

try:
# certbot 1.18+
patch_display_util = test_util.patch_display_util
except AttributeError:
# certbot <= 1.17
patch_display_util = test_util.patch_get_utility

from certbot_dns_hetzner.fakes import FAKE_API_TOKEN, FAKE_RECORD
from certbot_dns_hetzner.hetzner_client import _ZoneNotFoundException



patch_display_util = test_util.patch_display_util


class AuthenticatorTest(
test_util.TempDirTestCase,
dns_test_common.BaseAuthenticatorTest
test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest
):
"""
Test for Hetzner DNS Authenticator
"""

def setUp(self):
super(AuthenticatorTest, self).setUp()
super().setUp()
from certbot_dns_hetzner.dns_hetzner import Authenticator # pylint: disable=import-outside-toplevel

path = os.path.join(self.tempdir, 'fake_credentials.ini')
path = os.path.join(self.tempdir, "fake_credentials.ini")
dns_test_common.write(
{
'hetzner_api_token': FAKE_API_TOKEN,
"hetzner_api_token": FAKE_API_TOKEN,
},
path,
)

super(AuthenticatorTest, self).setUp()
super().setUp()
self.config = mock.MagicMock(
hetzner_credentials=path, hetzner_propagation_seconds=0
) # don't wait during tests

self.auth = Authenticator(self.config, 'hetzner')
self.auth = Authenticator(self.config, "hetzner")

self.mock_client = mock.MagicMock()
# _get_ispconfig_client | pylint: disable=protected-access
self.auth._get_hetzner_client = mock.MagicMock(return_value=self.mock_client)
self.auth._get_hetzner_client = mock.MagicMock(
return_value=self.mock_client)

@patch_display_util()
def test_perform(self, unused_mock_get_utility):
self.mock_client.add_record.return_value = FAKE_RECORD
self.mock_client.add_txt_record.return_value = FAKE_RECORD
self.auth.perform([self.achall])
self.mock_client.add_record.assert_called_with(
DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY
self.mock_client.add_txt_record.assert_called_with(
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
)

def test_perform_but_raises_zone_not_found(self):
self.mock_client.add_record.side_effect = mock.MagicMock(side_effect=_ZoneNotFoundException(DOMAIN))
self.assertRaises(
PluginError,
self.auth.perform, [self.achall]
def test_perform_but_raises_plugin_error(self):
self.mock_client.add_txt_record.side_effect = mock.MagicMock(
side_effect=PluginError()
)
self.mock_client.add_record.assert_called_with(
DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY
self.assertRaises(PluginError, self.auth.perform, [self.achall])
self.mock_client.add_txt_record.assert_called_with(
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
)

@patch_display_util()
def test_cleanup(self, unused_mock_get_utility):
self.mock_client.add_record.return_value = FAKE_RECORD
self.mock_client.add_txt_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 + '.')
self.mock_client.del_txt_record.assert_called_with(
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
)

@patch_display_util()
def test_cleanup_but_connection_aborts(self, unused_mock_get_utility):
self.mock_client.add_record.return_value = FAKE_RECORD
def test_cleanup_but_raises_plugin_error(self, unused_mock_get_utility):
self.mock_client.add_txt_record.return_value = FAKE_RECORD
self.mock_client.del_txt_record.side_effect = mock.MagicMock(
side_effect=PluginError()
)
# _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 + '.')
self.assertRaises(PluginError, self.auth.cleanup, [self.achall])
self.mock_client.del_txt_record.assert_called_with(
DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY
)


if __name__ == '__main__':
if __name__ == "__main__":
unittest.main() # pragma: no cover
14 changes: 7 additions & 7 deletions certbot_dns_hetzner/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
Fakes needed for tests
"""

FAKE_API_TOKEN = 'XXXXXXXXXXXXXXXXXXXxxx'
FAKE_API_TOKEN = "XXXXXXXXXXXXXXXXXXXxxx"
FAKE_RECORD = {
"record": {
'id': "123Fake",
"id": "123Fake",
}
}

FAKE_DOMAIN = 'some.domain'
FAKE_ZONE_ID = 'xyz'
FAKE_RECORD_ID = 'zzz'
FAKE_RECORD_NAME = 'thisisarecordname'
FAKE_DOMAIN = "some.domain"
FAKE_ZONE_ID = "xyz"
FAKE_RECORD_ID = "zzz"
FAKE_RECORD_NAME = "thisisarecordname"

FAKE_RECORD_RESPONSE = {
"record": {
Expand All @@ -33,7 +33,7 @@
FAKE_RECORDS_RESPONSE_WITHOUT_RECORD = {
"records": [
{
"id": 'nottheoneuwant',
"id": "nottheoneuwant",
"name": "string",
}
]
Expand Down
Loading

0 comments on commit 3504a7d

Please sign in to comment.