diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c373a81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +**/__pycache__/ +*.egg-info +venv +.venv +.idea +.coveralls.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..252fd30 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,28 @@ +language: python + +python: + - "2.7" + - "3.4" + - "3.5" + - "3.6" + # PyPy versions + - "pypy3.5" + +install: + - pip install python-coveralls + - pip install . + +# run tests +script: + - pytest + +deploy: + provider: pypi + user: $PYPI_USERNAME + password: $PYPI_PASSWORD + on: + tags: true + python: "3.6" + +after_success: + - coveralls \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d416fe3 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +[![Build Status](https://travis-ci.com/pan-net-security/certbot-powerdns.svg?branch=master)](https://travis-ci.com/pan-net-security/certbot-powerdns) +[![Coverage Status](https://coveralls.io/repos/github/pan-net-security/certbot-dns-powerdns/badge.svg?branch=master)](https://coveralls.io/github/pan-net-security/certbot-dns-powerdns?branch=master) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=6cfb0c4728624ebff38afc0b1ef91700795ea9ef&metric=alert_status)](https://sonarcloud.io/dashboard?id=6cfb0c4728624ebff38afc0b1ef91700795ea9ef) +![Libraries.io dependency status for latest release](https://img.shields.io/librariesio/release/github/pan-net-security/certbot-dns-powerdns.svg) +![PyPI - Status](https://img.shields.io/pypi/status/certbot-dns-powerdns.svg) + +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/certbot-dns-powerdns.svg) + + +certbot-dns-powerdns +============ + +PowerDNS DNS Authenticator plugin for [Certbot](https://certbot.eff.org/). + +This plugin is built from the ground up and follows the development style and life-cycle +of other `certbot-dns-*` plugins found in the +[Official Certbot Repository](https://github.com/certbot/certbot). + +Installation +------------ + +``` +pip install --upgrade certbot +pip install certbot-dns-powerdns +``` + +Verify: + +``` +$ certbot plugins --text + +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +* certbot-dns-powerdns:dns-powerdns +Description: Obtain certificates using a DNS TXT record (if you are using +PowerDNS for DNS.) +Interfaces: IAuthenticator, IPlugin +Entry point: dns-powerdns = certbot_dns_powerdns.dns_powerdns:Authenticator + +... +... +``` + +Configuration +------------- + +The credentials file e.g. `~/pdns-credentials.ini` should look like this: + +``` +certbot_dns_powerdns:dns_powerdns_api_url = https://api.mypowerdns.example.org +certbot_dns_powerdns:dns_powerdns_api_key = AbCbASsd!@34 +``` + +Usage +----- + + +``` +certbot ... \ + --authenticator certbot-dns-powerdns:dns-powerdns + --certbot-dns-powerdns:dns-powerdns-credentials ~/pdns-credentials.ini + certonly +``` + +FAQ +----- + +##### Why such long name for a plugin? + +This follows the upstream nomenclature: `certbot-dns-`. + +##### Why do I have to use `:` separator in the name? And why are the configuration file parameters so weird? + +This is a limitation of the Certbot interface towards _third-party_ plugins. + +For details read the discussions: + +- https://github.com/certbot/certbot/issues/6504#issuecomment-473462138 +- https://github.com/certbot/certbot/issues/6040 +- https://github.com/certbot/certbot/issues/4351 +- https://github.com/certbot/certbot/pull/6372 + + +License +-------- + +Copyright (c) 2019 [DT Pan-Net s.r.o](https://github.com/pan-net-security) \ No newline at end of file diff --git a/certbot_dns_powerdns/__init__.py b/certbot_dns_powerdns/__init__.py new file mode 100644 index 0000000..ab0b919 --- /dev/null +++ b/certbot_dns_powerdns/__init__.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Let's Encrypt PDNS plugin""" + +# import inspect +# +# # https://github.com/certbot/certbot/issues/6504#issuecomment-473462138 +# # https://github.com/certbot/certbot/issues/6040 +# # https://github.com/certbot/certbot/issues/4351 +# # https://github.com/certbot/certbot/pull/6372 +# def _patch(): +# for frame_obj, filename, line, func, _, _ in inspect.stack(): +# if func == '__init__' and frame_obj.f_locals['self'].__class__.__name__ == 'PluginEntryPoint': +# frame_obj.f_locals['self'].name = frame_obj.f_locals['entry_point'].name +# module_name = frame_obj.f_locals['entry_point'].dist.key +# pre_free_dist = frame_obj.f_locals['self'].PREFIX_FREE_DISTRIBUTIONS +# if module_name not in pre_free_dist: +# pre_free_dist.append(module_name) +# +# _patch() \ No newline at end of file diff --git a/certbot_dns_powerdns/dns_powerdns.py b/certbot_dns_powerdns/dns_powerdns.py new file mode 100644 index 0000000..545c3bb --- /dev/null +++ b/certbot_dns_powerdns/dns_powerdns.py @@ -0,0 +1,89 @@ +"""DNS Authenticator for PowerDNS.""" + +import logging + +import zope.interface +from certbot import interfaces +from certbot import errors + +from certbot.plugins import dns_common +from certbot.plugins import dns_common_lexicon + +from lexicon.providers import powerdns + +logger = logging.getLogger(__name__) + +@zope.interface.implementer(interfaces.IAuthenticator) +@zope.interface.provider(interfaces.IPluginFactory) +class Authenticator(dns_common.DNSAuthenticator): + """DNS Authenticator for PowerDNS DNS.""" + + description = 'Obtain certificates using a DNS TXT record ' + \ + '(if you are using PowerDNS for DNS.)' + + ttl = 60 + + def __init__(self, *args, **kwargs): + super(Authenticator, self).__init__(*args, **kwargs) + self.credentials = None + + @classmethod + def add_parser_arguments(cls, add): + super(Authenticator, cls).add_parser_arguments( + add, default_propagation_seconds=60) + add("credentials", help="PowerDNS credentials file.") + + def more_info(self): # pylint: disable=missing-docstring,no-self-use + return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ + 'PowerDNS API' + + def _setup_credentials(self): + self._configure_file('credentials', + 'Absolute path to PowerDNS credentials file') + dns_common.validate_file_permissions(self.conf('credentials')) + self.credentials = self._configure_credentials( + 'credentials', + 'PowerDNS credentials file', + { + 'api-url': 'PowerDNS-compatible API FQDN', + 'api-key': 'PowerDNS-compatible API key (X-API-Key)' + } + ) + + def _perform(self, domain, validation_name, validation): + self._get_powerdns_client().add_txt_record( + domain, validation_name, validation) + + def _cleanup(self, domain, validation_name, validation): + self._get_powerdns_client().del_txt_record( + domain, validation_name, validation) + + def _get_powerdns_client(self): + return _PowerDNSLexiconClient( + self.credentials.conf('api-url'), + self.credentials.conf('api-key'), + self.ttl + ) + + +class _PowerDNSLexiconClient(dns_common_lexicon.LexiconClient): + """ + Encapsulates all communication with the PowerDNS via Lexicon. + """ + + def __init__(self, api_url, api_key, ttl): + super(_PowerDNSLexiconClient, self).__init__() + + config = dns_common_lexicon.build_lexicon_config('powerdns', { + 'ttl': ttl, + }, { + 'auth_token': api_key, + 'pdns_server': api_url, + }) + + self.provider = powerdns.Provider(config) + + def _handle_http_error(self, e, domain_name): + if domain_name in str(e) and (str(e).startswith('422 Client Error: Unprocessable Entity for url:')): + return # Expected errors when zone name guess is wrong + return super(_PowerDNSLexiconClient, self)._handle_http_error(e, domain_name) diff --git a/certbot_dns_powerdns/dns_powerdns_test.py b/certbot_dns_powerdns/dns_powerdns_test.py new file mode 100644 index 0000000..55060f0 --- /dev/null +++ b/certbot_dns_powerdns/dns_powerdns_test.py @@ -0,0 +1,62 @@ +"""Tests for certbot_dns_powerdns.dns_powerdns""" + +import os +import unittest + +import mock +from requests.exceptions import HTTPError + +from certbot.plugins import dns_test_common +from certbot.plugins import dns_test_common_lexicon +from certbot.plugins.dns_test_common import DOMAIN + +from certbot.tests import util as test_util + +API_TOKEN = '00000000-0000-0000-0000-000000000000' +API_URL = 'https://127.0.0.1' + + +class AuthenticatorTest(test_util.TempDirTestCase, + dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + + def setUp(self): + super(AuthenticatorTest, self).setUp() + + from certbot_dns_powerdns.dns_powerdns import Authenticator + + path = os.path.join(self.tempdir, 'file.ini') + dns_test_common.write( + {"powerdns_api_url": API_URL, + "powerdns_api_key": API_TOKEN}, + path + ) + + print("File content") + print(open(path).read()) + + self.config = mock.MagicMock(powerdns_credentials=path, + powerdns_propagation_seconds=0) # don't wait during tests + + self.auth = Authenticator(self.config, "powerdns") + + self.mock_client = mock.MagicMock() + # _get_powerdns_client | pylint: disable=protected-access + self.auth._get_powerdns_client = mock.MagicMock(return_value=self.mock_client) + + +class PowerDnsLexiconClientTest(unittest.TestCase, + dns_test_common_lexicon.BaseLexiconClientTest): + DOMAIN_NOT_FOUND = HTTPError('422 Client Error: Unprocessable Entity for url: {0}.'.format(DOMAIN)) + LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized') + + def setUp(self): + from certbot_dns_powerdns.dns_powerdns import _PowerDNSLexiconClient + + self.client = _PowerDNSLexiconClient(api_key=API_TOKEN, api_url=API_URL, ttl=0) + + self.provider_mock = mock.MagicMock() + self.client.provider = self.provider_mock + + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..79bc678 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +# This flag says that the code is written to work on both Python 2 and Python +# 3. If at all possible, it is good practice to do this. If you cannot, you +# will need to generate wheels for each Python version that you support. +universal=1 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..ba0295a --- /dev/null +++ b/setup.py @@ -0,0 +1,71 @@ +#! /usr/bin/env python +from os import path +from setuptools import setup +from setuptools import find_packages + +version = "0.1.0" + +with open('README.md') as f: + long_description = f.read() + +install_requires = [ + 'acme>=0.31.0', + 'certbot>=0.31.0', + 'dns-lexicon>=2.1.23', + 'dnspython', + 'mock', + 'setuptools', + 'zope.interface', + 'requests' +] + +here = path.abspath(path.dirname(__file__)) + +setup( + name='certbot-dns-powerdns', + version=version, + + description="PowerDNS DNS Authenticator plugin for Certbot", + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/pan-net-security/certbot-dns-powerdns', + author="DT Pan-Net s.r.o", + author_email='pannet.security@pan-net.eu', + license='Apache License 2.0', + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Plugins', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ], + + packages=find_packages(), + install_requires=install_requires, + + # extras_require={ + # 'docs': docs_extras, + # }, + + entry_points={ + 'certbot.plugins': [ + 'dns-powerdns = certbot_dns_powerdns.dns_powerdns:Authenticator', + ], + }, + test_suite='certbot_dns_powerdns', +)